【问题标题】:Scope unwinding in PHP class constructorsPHP 类构造函数中的范围展开
【发布时间】:2011-11-20 05:19: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 对象系统从不需要正确的销毁序列,我将为此提供另一个赏金(或只是为任何其他令人信服的争论答案)。

【问题讨论】:

  • 我必须说我很少需要在 PHP 中实现析构函数,所以也许这不是什么大问题。不过,你确实提出了一个很好的问题。
  • @Jani:坦率地说,考虑到它们的设计方式,我理解你为什么真的不想想要使用析构函数。我只是想知道为什么他们似乎考虑得这么糟糕,以及除了“不要使用这部分语言”之外是否有任何常见的习惯用法来规避这个设计缺陷...... :-S
  • 同意 Jani:在 PHP 中编写析构函数确实没有意义,因为没有什么可以泄漏的。来自 C++ 的您可能将析构函数视为一个经过深思熟虑的工具,用于解决它们不打算解决的问题。
  • @Jon:你能澄清一下吗?你的意思是析构函数是一个经过深思熟虑的概念,在惯用的时候很有用并且可以工作,还是你的意思是一个设计良好的 PHP 程序不应该使用析构函数?如果您对前者有很好的论据,请务必将其作为答案发布!
  • 后者,我不会说“不应该使用析构函数”,因为它可能太强大了,我只是从经验来看:在我写 PHP 的 10 年里,我有从来不需要写一个。我和你一样希望看到第一种类型的答案。

标签: php exception stack-unwinding


【解决方案1】:

我想解释一下为什么 PHP 会以这种方式运行,以及为什么它实际上(某些)有意义。

在 PHP 中一个对象在没有更多引用时被销毁。可以通过多种方式删除引用,例如通过unset()ing 变量、离开作用域或作为关闭的一部分。

如果你理解了这一点,你就很容易理解这里发生了什么(我会先解释没有异常的情况):

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

想象一下这不会以这种方式工作,并且成员字段将在调用析构函数之前被销毁:您无法再在析构函数中访问它们。我严重怀疑 C++ 中是否存在这样的行为。

在异常情况下,您需要了解对于 PHP,该类的实例从未真正存在过,因为构造函数从未返回。你怎么能破坏从未建造过的东西?


我该如何解决?

你没有。您需要析构函数这一事实可能是糟糕设计的标志。而且销毁命令对你来说很重要,这一点甚至更重要。

【讨论】:

  • 我不确定我是否相信这个解释:当我在示例中说ouch时,这是因为我预计销毁序列是“派生- foo - base”;但当然这不会发生,因为我实际上明确地调用了基本析构函数。但是想象一下$this->foo 对象以某种方式依赖于Base 子对象的有效状态。现在$this->foo 的销毁可能需要执行一些需要Base 子对象的关闭,但这不再有效。这有什么不能或不应该发生的原因吗?
  • @KerrekSB 我已经解释过,成员需要在调用析构函数后销毁,否则无法在析构函数中访问它们。这与 C++ (afaik) 中的相同。此外: $this->foo mustn't 依赖于 Base 对象(它应该如何实际访问它?)。 $this->foo 是一个依赖项(应该被注入,参见 DI、IoC 和 SOLID)。它甚至不应该知道它在另一个类中使用并且绝对不依赖它。
  • 基本对象和派生对象之间的区别很重要。事实上,派生的析构函数必须先出现。但是$this->foo 是在构造基础子对象之后 构造的,所以它应该在基础子对象之前 被销毁,不是吗?我将在帖子中添加一个示例!
  • 我已将建议的基本依赖项标记为#3 行。但是经过一些测试我发现你不能在Bar的构造函数中通过引用捕获parent,所以也许语言根本不允许成员对象获得对Base子对象的任何引用?
  • @KerrekSB 抱歉,我不能再关注了。该代码中的“父级”是什么意思?它要做什么? (或者你能给我一份 C++ 语法的参考资料,以便我阅读吗?)
【解决方案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 不会隐式调用基析构函数或基构造函数,这不起作用,我们必须在派生析构函数中显式调用基析构函数。但这打乱了破坏顺序,现在是“派生”、“基础”、“成员”。

这是我的担忧:如果任何成员对象要求基础子对象的状态对其自身的操作有效,那么这些成员对象在它们自己的销毁期间都不能依赖该基础子对象,因为该基础对象具有已经失效了。

这是一个真正的问题,还是语言中的某些东西阻止了这种依赖关系的发生?

这是一个 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的析构函数会访问一个无效的引用。

【讨论】:

    【解决方案3】:

    如果你在构造函数中抛出一个异常,对象永远不会存活(对象的 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.
    

    Demo

    是否需要此功能,请自行考虑。

    要控制调用 Foo 析构函数的顺序,请取消设置析构函数中的属性,例如 this example demonstrates

    编辑: 因为您可以控制构建对象的时间,所以您可以控制何时销毁对象。以下顺序:

    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++ 示例,了解派生类如何依赖基成员。
    • 另外,我认为您的示例实际上更糟糕:如果构造函数抛出,则没有对象,因此析构函数绝对应该运行。但是,member 对象的析构函数应该运行,然后是基析构函数。也就是说,如果Der::__construct()抛出,销毁顺序应该是Base::const, Foo::const, Foo::dest, Base::dest
    • @kerrek SB:对于第一条评论:Foo 的析构函数在您的情况下已经被调用,Foo 应该自己处理,不是吗?在我的第一个代码示例中,调用了 Der 析构函数以及 Base 析构函数。如果你想改变顺序,你可以在 Der 析构函数中控制它,我没有改变它。
    • @kerrek SB:对于第二条评论:您自己决定 PHP 中是否存在对象。如示例所示,如果要调用或不调用析构函数,则需要自己小心。对于您给出的顺序,我将添加一个代码示例,如果您不喜欢默认顺序(这对我来说很好),基本上您可以自行决定调用析构函数的顺序。
    【解决方案4】:

    C++ 和 PHP 之间的一个主要区别是,在 PHP 中,基类构造函数和析构函数不会被自动调用。 the PHP Manual page for Constructors and Destructors 上明确提到了这一点:

    注意:如果子类定义了构造函数,则不会隐式调用父构造函数。为了运行父构造函数,需要在子构造函数中调用 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:20 中未捕获的异常“异常” 堆栈跟踪: #0 /t.php(24): 派生->__construct() #1 {主要} 在第 20 行的 /t.php 中抛出

    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();
        }
    }
    

    编辑:要模拟清理最派生类的局部变量和数据成员,您需要有一个 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's try-with-resources statement 之前,Java 也是如此。

    【讨论】:

    • 该解决方法还应该进行任何本地清理,以便Derived 的成员有机会在调用基本析构函数之前进行清理。但是,假设派生的构造函数 try 块包含$this-&gt;x = new Foo; $this-&gt;y = new Bar;,而FooBar 的构造函数都可能抛出异常,那么一旦出现异常,你就不知道该清理谁了。
    • @KerrekSB:对。这就是 C++ 的做法。如果您在 PHP 中需要这种行为,那么诀窍是为每个成功构造的成员设置一个 catch 块。查看我的编辑。
    猜你喜欢
    • 2018-12-14
    • 1970-01-01
    • 1970-01-01
    • 2012-09-14
    • 2011-07-28
    • 2022-11-24
    • 1970-01-01
    • 1970-01-01
    • 2013-05-06
    相关资源
    最近更新 更多