PHP类构造函数中的作用域展开

10

我正在学习 PHP 类和异常,而且由于我的 C++ 背景,以下内容让我感到奇怪:

当派生类的构造函数抛出异常时,似乎基类的析构函数不会自动运行:

class Base
{
  public function __construct() { print("Base const.\n"); }
  public function __destruct()  { print("Base destr.\n"); }
}

class Der extends Base
{
  public function __construct()
  {
    parent::__construct();
    $this->foo = new Foo;
    print("Der const.\n");
    throw new Exception("foo"); // #1
  }
  public function __destruct()  { print("Der destr.\n"); parent::__destruct(); }
  public $foo;                  // #2
}

class Foo
{
  public function __construct() { print("Foo const.\n"); }
  public function __destruct()  { print("Foo destr.\n"); }
}


try {
  $x = new Der;
} catch (Exception $e) {
}

这将打印:

Base const.
Foo const.
Der const.
Foo destr.

另一方面,如果构造函数中出现异常(在#1处),则成员对象的析构函数将被正确执行。现在我想知道:如何在PHP类层次结构中实现正确的作用域展开,以便在发生异常时可以正确销毁子对象?

此外,似乎没有办法在所有成员对象都被销毁后运行基类的析构函数(在#2处)。也就是说,如果我们删除#1行,我们会得到:

Base const.
Foo const.
Der const.
Der destr.
Base destr.
Foo destr.    // ouch!!

如何解决这个问题?

更新:我仍然欢迎更多的贡献。如果有人能够充分证明为什么PHP对象系统从来没有强制要求正确的销毁顺序,我将再次提供奖励(或者对其他具有说服力的回答进行奖励)。


1
我必须说,在PHP中我很少需要实现析构函数,所以这可能不是什么大问题。不过你提出了一个很好的问题。 - Jani Hartikainen
@Jani:坦白说,考虑到它们的设计方式,我理解为什么你确实不想使用析构函数。我只是想知道它们为什么看起来设计得如此糟糕,是否有任何常见习语来避开这个设计缺陷,而不是“不要使用语言的这一部分”... :-S - Kerrek SB
1
同意Jani的观点:在PHP中,写析构函数实际上没有任何意义,因为你不可能泄漏任何东西。如果你是从C++转过来的,你可能会认为析构函数是一个非常糟糕的工具,用于解决它们本来不应该解决的问题。 - Jon
@Jon:您能否澄清一下?您是指析构函数是一个经过深思熟虑的概念,当以惯用方式使用时非常有用且有效,还是说一个良好设计的PHP程序不应该使用析构函数?如果您对前者有充分的论据,请发表您的答案! - Kerrek SB
1
后者,我不会说“不应该使用析构函数”,因为这可能太过强硬,而且我只是从经验上判断:在我写PHP的10年中,我从未需要编写一个析构函数。我希望能像你一样看到第一种类型的答案。 - Jon
如果您需要Base和Der解构函数,请确保它们可用。 - hakre
4个回答

6
我想解释一下PHP为什么会有这种行为,以及它为什么是有意义的(某些情况下)。在PHP中,只要没有引用指向对象,对象就立即被销毁。引用可以通过多种方式移除,例如通过unset()删除变量、离开作用域或作为关闭的一部分。
如果您理解了这一点,您就可以轻松地理解这里发生了什么(我将首先解释没有异常的情况):
1. PHP进入关闭状态,因此删除所有变量引用。 2. 当$x创建的引用(指向Der实例)被删除时,对象被销毁。 3. 调用派生析构函数,该函数调用基础析构函数。 4. 现在从$this->fooFoo实例的引用也被删除(作为销毁成员字段的一部分)。 5. 没有更多关于Foo的引用,所以它也被销毁并调用析构函数。
想象一下,如果不是这样工作的话,成员字段将在调用析构函数之前被销毁:您将无法再在析构函数中访问它们。我严重怀疑C++中是否存在这样的行为。
在异常情况下,您需要理解的是,在PHP中,该类实例根本不存在,因为构造函数从未返回。如何摧毁从未构造过的东西呢?
如何修复它?你不需要修复它。仅仅需要 destructor 可能是糟糕设计的迹象。而且,销毁顺序对你来说如此重要也更加让人担忧。

我不确定我相信这个解释:当我在示例中说“ouch”时,是因为我期望销毁顺序为“派生 - foo - 基类”; 但当然这并没有发生,因为我实际上显式调用了基类析构函数。但是想象一下$this->foo对象以某种方式依赖于Base子对象的有效状态。现在,$this->foo的销毁可能需要执行一些需要Base子对象的关闭操作,但这已经无效了。是否有任何理由说明这不能或不应该发生? - Kerrek SB
@KerrekSB 我已经解释过,在析构函数被调用后,成员变量需要被销毁,否则你无法在析构函数中访问它们。这与C++相同(据我所知)。此外:$this->foo 不应该 依赖于 Base 对象(它实际上应该如何访问它?)。 $this->foo 是一个依赖项(应该注入,参见 DI、IoC 和 SOLID)。它甚至不应该知道它在另一个类中被使用,并且绝对不能依赖于它。 - NikiC
基类和派生类对象之间的区别很重要。事实上,派生类的析构函数必须首先执行。但是$this->foo是在基类子对象构造之后构造的,因此应该在基类之前被销毁,不是吗?我会在帖子中添加一个示例! - Kerrek SB
我会在这里发布一个单独的“回答”帖子,以保持主帖整洁,更详细地解释。 - Kerrek SB
@KerrekSB,我仔细阅读了你的回答,我真的认为你应该重新考虑你的设计。但除此之外:显然你仍然有可能明确地删除引用(因此触发析构函数,如果没有其他引用):__destruct() { 'derived destructor code'; unset($this->foo); parent::__destruct(); } - NikiC
显示剩余2条评论

2
这不是一个答案,而是对问题动机的更详细说明。我不想用这些有些离题的材料来混淆问题本身。
下面是一个关于如何销毁派生类成员的通常顺序的解释。假设类是这样的:
class Base
{
  public $x;
  // ... (constructor, destructor)
}

class Derived extends Base
{
  public $foo;
  // ... (constructor, destructor)
}

当我创建一个实例,$z = new Derived;,这首先构造了Base子对象,然后是Derived的成员对象(即$z->foo),最后执行Derived的构造函数。

因此,我期望销毁顺序恰好相反:

  1. 执行Derived析构函数

  2. 销毁Derived的成员对象

  3. 执行Base析构函数。

然而,由于PHP不会隐式调用基类的析构函数或构造函数,所以这并不起作用,我们必须在派生类的析构函数中显式调用基类的析构函数。但这会打乱销毁顺序,现在变成了“derived”,“base”,“members”。
我的担忧是:如果任何成员对象需要基类子对象的状态对其自身的操作有效,则在其自己的销毁过程中,这些成员对象都不能依赖于该基类子对象,因为该基类对象已经无效。
这是一个真正的担忧,还是语言中有什么防止这种依赖关系发生的东西?
以下是C++示例,说明了正确的销毁顺序的必要性:
class ResourceController
{
  Foo & resource;
public:
  ResourceController(Foo & rc) : resource(rc) { }
  ~ResourceController() { resource.do_important_cleanup(); }
};

class Base
{
protected:
  Foo important_resource;
public:
  Base() { important_resource.initialize(); }  // constructor
  ~Base() { important_resource.free(); }       // destructor
}

class Derived
{
  ResourceController rc;
public:
  Derived() : Base(), rc(important_resource) { }
  ~Derived() { }
};

当我实例化 Derived x; 时,基类子对象首先被构造,它设置了 important_resource。然后成员对象 rc 被初始化为对 important_resource 的引用,这在 rc 的销毁期间是必需的。因此当 x 的生命周期结束时,派生析构函数首先被调用(什么也不做),然后销毁 rc,完成其清理工作,最后才销毁 Base 子对象,释放 important_resource
如果销毁顺序发生错误,那么 rc 的析构函数将访问无效的引用。

1

如果在构造函数中抛出异常,该对象将永远不会启动(对象的zval至少具有一个引用计数,这对析构函数非常重要),因此没有任何拥有析构函数可供调用的东西。

现在我想知道:如何在PHP类层次结构中实现正确的作用域解除,以便在发生异常时正确销毁子对象?

在您给出的示例中,没有什么需要解除。但是为了演示,让我们假设您知道基本构造函数可能会引发异常,但是您需要在调用它之前初始化$this->foo

然后,您只需要将"$this"的引用计数增加一(临时),这比__construct中的局部变量多需要(一点)更多,我们将其储存在$foo中:

class Der extends Base
{
  public function __construct()
  {
    parent::__construct();
    $this->foo = new Foo;
    $this->foo->__ref = $this; # <-- make base and Der __destructors active
    print("Der const.\n");
    throw new Exception("foo"); // #1
    unset($this->foo->__ref); # cleanup for prosperity
  }

结果:

Base const.
Foo const.
Der const.
Der destr.
Base destr.
Foo destr.

演示

自行思考是否需要此功能。

为了控制Foo析构函数的调用顺序,在析构函数中取消属性设置,例如此示例演示

编辑:由于您可以控制对象构造的时间,因此也可以控制对象销毁的时间。以下是顺序:

Der const.
Base const.
Foo const.
Foo destr.
Base destr.
Der destr.

是通过以下方式完成的:

class Base
{
  public function __construct() { print("Base const.\n"); }
  public function __destruct()  { print("Base destr.\n"); }
}

class Der extends Base
{
  public function __construct()
  {
    print("Der const.\n");
    parent::__construct();
    $this->foo = new Foo;
    $this->foo->__ref = $this; #  <-- make Base and Def __destructors active
    throw new Exception("foo");
    unset($this->foo->__ref);
  }
  public function __destruct()
  {
    unset($this->foo);
    parent::__destruct();
    print("Der destr.\n");
  }
  public $foo;
}

class Foo
{
  public function __construct() { print("Foo const.\n"); }
  public function __destruct()  { print("Foo destr.\n"); }
}


try {
  $x = new Der;
} catch (Exception $e) {
}

我不确定我相信你的第一句话:如果构造函数抛出异常,成员对象和基类成员对象可能已经被初始化并且需要适当的销毁。在我的 PHP 示例中,想象一下 Foo 有非平凡的成员;并参考我的 C++ 示例,了解派生类对基类成员的依赖关系。 - Kerrek SB
另外,我认为你的示例实际上更加错误:如果构造函数抛出异常,则没有对象,因此析构函数绝对不应该运行。但是,成员对象的析构函数应该运行,然后是基类的析构函数。换句话说,如果 Der::__construct() 抛出异常,则销毁顺序应该是 Base::const, Foo::const, Foo::dest, Base::dest - Kerrek SB
@kerrek SB:针对第一个评论,您的情况下Foo的析构函数已经被调用了,Foo应该自己处理,不是吗?在我提供的代码示例中的第一个示例中,DerBase的析构函数都被调用了。如果您想改变顺序,在Der的析构函数中您可以控制它,我没有做出改变。 - hakre
@kerrek SB:对于第二条评论:在PHP中,你可以自己决定是否有对象存在。正如示例所示,如果你想要调用析构函数,你需要自己注意。至于你提出的顺序问题,我会添加一个代码示例,基本上是你自己决定析构函数的调用顺序,如果你不喜欢默认顺序(我认为默认顺序很好)。 - hakre

1

C++和PHP之间的一个主要区别是,在PHP中,基类构造函数和析构函数不会自动调用。这在构造函数和析构函数的PHP手册页面上明确提到:

注意:如果子类定义了构造函数,则父类构造函数不会隐式调用。为了运行父类构造函数,需要在子类构造函数中调用parent::__construct()

...

与构造函数一样,父类析构函数也不会被引擎隐式调用。为了运行父类析构函数,必须在析构函数体中显式调用parent::__destruct()

因此,PHP完全将正确调用基类构造函数和析构函数的任务交给程序员,程序员始终有责任在必要时调用基类构造函数和析构函数。

以上段落中的关键点是必要时。很少情况下会出现不调用析构函数就会“泄漏资源”的情况。请记住,在调用基类的构造函数时创建的基本实例的数据成员本身将变得无引用,因此,每个这些成员的析构函数(如果存在)都将被调用。使用以下代码进行尝试:
<?php

class MyResource {
    function __destruct() {
        echo "MyResource::__destruct\n";
    }
}

class Base {
    private $res;

    function __construct() {
        $this->res = new MyResource();
    }
}

class Derived extends Base {
    function __construct() {
        parent::__construct();
        throw new Exception();
    }
}

new Derived();

示例输出:

MyResource :: __destruct
致命错误:在 /t.php 中抛出异常 'Exception' :20 堆栈跟踪: #0 /t.php(24):Derived-> __construct() #1 {main}

http://codepad.org/nnLGoFk1

在这个例子中,Derived 构造函数调用了 Base 构造函数,后者创建了一个新的 MyResource 实例。当 Derived 在构造函数中抛出异常时,由 Base 构造函数创建的 MyResource 实例变得无法引用。最终,将调用 MyResource 析构函数。
可能需要调用析构函数的一种情况是,析构函数与另一个系统(如关系型数据库管理系统、缓存、消息系统等)交互。如果必须调用析构函数,则可以将析构函数封装为一个不受类层次结构影响的单独对象(如上面的 MyResource 示例),或使用 catch 块:
class Derived extends Base {
    function __construct() {
        parent::__construct();
        try {
            // The rest of the constructor
        } catch (Exception $ex) {
            parent::__destruct();
            throw $ex;
        }
    }

    function __destruct() {
        parent::__destruct();
    }
}

编辑:为了模拟清理最派生类的本地变量和数据成员,您需要有一个catch块来清理每个成功初始化的本地变量或数据成员:

class Derived extends Base {
    private $x;
    private $y;

    function __construct() {
        parent::__construct();
        try {
            $this->x = new Foo();
            try {
                $this->y = new Bar();
                try {
                    // The rest of the constructor
                } catch (Exception $ex) {
                    $this->y = NULL;
                    throw $ex;
                }
            } catch (Exception $ex) {
                $thix->x = NULL;
                throw $ex;
            }
        } catch (Exception $ex) {
            parent::__destruct();
            throw $ex;
        }
    }

    function __destruct() {
        $this->y = NULL;
        $this->x = NULL;
        parent::__destruct();
    }
}

在Java 7的try-with-resources语句出现之前,这也是Java中的做法。


解决方法还应该进行任何本地清理,以便Derived的成员有机会在调用基础析构函数之前进行清理。但是,假设派生构造函数try块包含$this->x = new Foo; $this->y = new Bar;,并且FooBar的构造函数都可能抛出异常,则在发生异常时不知道要清理谁。 - Kerrek SB
@KerrekSB:没错。这就是C++的做法。如果你需要在PHP中实现这种行为,那么诀窍就是为每个成功构造的成员设置一个catch块。请参见我的编辑。 - Daniel Trebbien

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接