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


Scope unwinding in PHP class constructors

我正在学习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对象系统从不要求需要一个正确的销毁序列,我将再给他一个赏金(或者只是给任何其他令人信服的答案)。

我想解释一下为什么PHP是这样做的,为什么它实际上是(一些)有意义的。

在PHP 中,一旦不再有对该对象的引用,该对象就被销毁。可以通过多种方式删除引用,例如,通过unset()变量,通过离开作用域或作为关闭的一部分。

如果你理解了这一点,你就可以很容易地理解这里发生的事情(我将首先解释没有Exception的情况):

  1. PHP进入关闭状态,因此所有变量引用都被删除。
  2. $x (Der的实例)创建的引用被删除时,对象被销毁。
  3. 调用派生析构函数,它调用基析构函数。
  4. 现在从$this->fooFoo实例的引用被删除了(作为销毁成员字段的一部分)
  5. 也没有任何对Foo的引用,所以它也被销毁并调用析构函数。

想象一下,如果不是这样,成员字段将在调用析构函数之前被销毁:您不能再在析构函数中访问它们。我非常怀疑c++中是否有这样的行为。

在Exception情况下,您需要理解,对于PHP来说,从来没有真正存在过类的实例,因为构造函数从未返回。你怎么能摧毁一个从未建造过的东西呢?


我如何修复它?

你不。你需要析构函数这一事实本身可能就是糟糕设计的标志。销毁顺序对你来说如此重要,这更重要。

这不是答案,而是对问题动机的更详细的解释。我不想用这些离题的材料把问题搞得一团糟。

下面解释了我如何期望具有成员的派生类的常规销毁顺序。假设这个类是这样的:

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

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

因此,我期望销毁顺序以完全相反的顺序发生:

  1. execute Derived destructor

  2. 销毁Derived的成员对象

  3. execute Base destructor.

然而,由于PHP不隐式调用基类析构函数或基类构造函数,这不起作用,我们必须在派生析构函数中显式地调用基类析构函数。但这打乱了析构顺序,现在是"派生的"、"基的"、"成员的"。

这是我关心的问题:如果任何成员对象要求基子对象的状态对它们自己的操作有效,那么在它们自己的销毁过程中,这些成员对象都不能依赖于该基子对象,因为该基对象已经无效了。

这是一个真正的问题,还是语言中有什么东西阻止了这种依赖的发生?

下面是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。然后使用对important_resource的引用初始化成员对象rc,这在rc销毁期间是必需的。因此,当x的生命周期结束时,首先调用派生析构函数(什么都不做),然后销毁rc,完成它的清理工作,只有然后被销毁Base子对象,释放important_resource

如果析构函数顺序错误,则rc的析构函数访问的是无效引用

如果在构造函数内部抛出异常,则对象永远不会激活(对象的zval至少有一个引用计数为1,这是析构函数所需要的),因此没有任何具有可调用的析构函数。

现在我想知道:如何在PHP的类层次结构中实现正确的范围展开,以便在发生异常时正确地销毁子对象?

在你给出的例子中,没有什么需要unwind的。但是对于游戏,让我们假设,你知道基本构造函数可以抛出异常,但是你需要在调用它之前初始化$this->foo

然后您只需要将"$this"的refcount(暂时)提高1,这需要(稍微)超过__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析构函数时的顺序,在析构函数中取消该属性,如下例所示。

Edit:正如您可以控制对象构造的时间一样,您也可以控制对象何时被销毁。以下顺序:

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) {
}

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致命错误:未捕获的异常' exception '在/t.p php:20堆栈跟踪:#0/t.p php(24): Derived->__construct()# 1{主要}在/t.p php第20行抛出之前http://codepad.org/nnLGoFk1

在这个例子中,Derived构造函数调用Base构造函数,后者创建了一个新的MyResource实例。当Derived随后在构造函数中抛出异常时,由Base构造函数创建的MyResource实例变为未引用。最后,将调用MyResource析构函数。

可能需要调用析构函数的一个场景是析构函数与另一个系统交互,例如关系DBMS、缓存、消息传递系统等。如果必须调用析构函数,那么您可以将析构函数封装为不受类层次结构影响的单独对象(如上面的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();
    }
}

EDIT:为了模拟清理最派生类的局部变量和数据成员,您需要有一个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也是这样做的。