Guru of the Week 条款23:对象的生存期(第二部分)

    技术2022-05-11  173

    GotW #23 Object Lifetimes – Part II

    著者:Herb Sutter

    翻译:CAT*G

    [声明]:本文内容取自www.gotw.ca网站上的Guru of the Week栏目,其著作权归原著者本人所有。译者CAT*G在未经原著者本人同意的情况下翻译本文。本翻译内容仅供自学和参考用,请所有阅读过本文的人不要擅自转载、传播本翻译内容;下载本翻译内容的人请在阅读浏览后,立即删除其备份。译者CAT*G对违反上述两条原则的人不负任何责任。特此声明。

    Revision 1.0

     

    Guru of the Week 条款23:对象的生存期(第二部分)

     

    难度:6 / 10

     

    (接着条款22,本期条款考虑一个经常被推荐使用的C++惯用法——它经常也是危险且错误的。)

     

    [Problem]

    [问题]

     

    评述下面的惯用法(用常见的代码形式表达如下): 

        T& T::operator=( const T& other ) {         if( this != &other ) {             this->~T();             new (this) T(other);         }         return *this;     }

    1.代码试图达到什么样的合法目的?修正上述代码中所有的编码缺陷。

     

    2 .假如修正了所有的缺陷,这种惯用法是安全的吗?对你的回答做出解释。如果其是不安全的,程序员又该如何达到预想的目标呢?

     

    (参见GotW条款22,以及October 1997 C++ Report

     

    [Solution]

    [解答]

     

    评述下面的惯用法(用常见的代码形式表达如下):

        T& T::operator=( const T& other ) {         if( this != &other ) {             this->~T();             new (this) T(other);         }         return *this; }  

    [Summary][1]

    [小结][1]

     

    这个惯用法经常被推荐使用,且在C++标准草案中作为一个例子出现。[2]但其却具有不良的形式,而且——若要这么形容的话——恰恰是有害无益。请不要这样做。

     

    1.代码试图达到什么样的合法目的?

     

    这个惯用法以拷贝构造(copy construction)操作来实现拷贝赋值(copy assignment)操作。这即是说,该方法试图保证「T的拷贝构造与拷贝赋值实现的是相同的操作」,以避免程序员被迫在两个地方不必要的重复相同的代码。

     

    这是一个高尚的目标。无论如何,它使编程更为简单,因为你不必把同一段代码编写两次,而且当T被改变(例如,给T增加了新的成员变量)的时候,你也不会像以前那样在更新了其中一个之后忘记更新另一个。

     

    假如虚拟基类拥有数据成员,那么这个惯用法还是蛮有用的,因为若不使用此方法的话,数据成员在最好的情况下会被赋值数次,而在最坏的情况下则会被施以不正确的赋值操作。这听起来颇佳,但实际上却并无多大用处,因为虚拟基类其实是不应该拥有数据成员的。[3] 另外,既然有虚拟基类,那便意味着该类是为了用于继承而设计的——这又意味着:(正如我们即将看到的那样)我们不能使用这个惯用法,原因是它太具危险性。

     

    修正上述代码中所有的编码缺陷。

     

    上面的代码中包含一个可以修正的缺陷,以及若干个无法修正的缺陷。

     

    [Problem #1: It Can Slice Objects]

    [问题#1:它会切割对象]

     

    如果T是一个带有虚拟析构函数(virtual destructor)的基类,那么”this->~T();”这一句就执行了错误的操作。如果是对一个派生类的对象执行这个调用的话,这一句的执行将会销毁派生类的对象并用一个T对象替代。而这种结果几乎将肯定破坏性的影响后续任何试图使用这个对象的代码。(更多关于“切割(slicing)” 问题的讨论,参见GotW 条款22

     

    特别要指出的是,这种状况将会使编写派生类的编码者们陷入人间地狱般的生活(另外还有其它一些关于派生类的潜在陷阱,见下面的叙述)。回想一下,派生的赋值运算符通常是基于基类的赋值操作编写的: 

        Derived&     Derived::operator=( const Derived& other ) {         Base::operator=( other );         // ...现在对派生成员进行赋值...         return *this;     }

    这样我们得到: 

        class U : /* ... */ T { /* ... */ };      U& U::operator=( const U& other ) {         T::operator=( other );         // ...现在对U 成员进行赋值... 呜呼呀         return *this;           //呜呼呀     }

    正如代码所示,对T::operator=()的调用一声不响的对其后所有的代码(包括U成员的赋值操作以及返回语句)产生了破坏性的影响。如果U的析构函数没有把它的数据成员重置为无效数值的话(译注:即可以编译运行通过),这里将表现为一个神秘的、极难调试的运行期错误。

     

    为了改正这个问题,可以调用"this->T::~T();"作为替代,这可以保证「对于一个派生类对象,只有其中的T subobject 被替换(而不是整个派生类对象被切割从而被错误的转为一个T对象)」。这样做只是用一个更为微妙的危险替换掉了一个明显的危险,而这个替换方案仍然会影响派生类的编写者(见下面的叙述)。

     

    2.假如修正了所有的缺陷,这种惯用法是安全的吗?

     

    不,不安全。要注意:如果不放弃整个惯用法的话,下列任何一个问题都无法得到解决:

     

    [Problem #2: It's Not Exception-Safe]

    [问题#2:它不是异常安全的]

     

    ‘new’语句将会唤起T的拷贝构造函数。如果这个构造函数可以抛出异常的话(其实许多甚至是绝大部分的类都会通过抛出异常来报告构造函数的错误),那么这个函数就不是异常安全的,因为其在构造函数抛出异常时会导致「销毁了原有对象而没有用新的对象替换上去」的情形。

     

    与切割(slicing)问题一样,这个缺陷将会对后续的任何试图使用这个对象的代码产生破坏性影响。更糟糕的是,这还可能导致「程序试图将同一个对象销毁两次」的情况发生,因为外部的代码无法知晓这个对象的析构函数是否已经被运行过了。(参见GotW条款22中更多关于重复析构的讨论。)

     

    [Problem #3: It’s Inefficient for Assignment]

    [问题#3:它使赋值操作变得低效]

     

    这个惯用法是低效的,因为赋值过程中的构造操作几乎总是涉及到比重置数值更多的工作。析构和重构在一起进行则更是增加了工作量。

     

    [Problem #4: It Changes Normal Object Lifetimes]

    [问题#4:它改变了正常的对象生存期]

     

    这个惯用法破坏性的影响了那些依赖于正常的对象生存期之代码。特别是它破坏或干预了所有使用常见的“初始化就是资源获取(initialization is resource acquisition)”惯用法的类。

     

    例如,若T在构造函数里获取了一个互斥锁(mutext lock)或者开启了数据库事务(database transaction),又在析构函数里释放这个锁或者事务处理,那会发生什么呢?这个锁或者事务处理将会以不正确的方式被释放并在赋值操作中被重新获得——这一般来说会破坏性的影响客户代码(client code)和这个类本身。除了TT的基类以外,如果T的派生类也依赖于T正常的生存期语义,它也会同样破坏性的影响这些派生类。

     

    有人会说,“我当然决不会对一个在构造函数和析构函数中获取和释放互斥量的类使用这个惯用法了!”回答很简单:“真的吗?你怎么知道你使用的那些(直接或间接)的基类不这样做呢?”坦白的说,你经常是无法知晓这个情况的,你也绝不应该依赖那些工作起来似乎正常但却与对象生存期玩花招儿的基类。

     

    这个惯用法的根本问题在于它搅浊了构造操作和析构操作的含义。构造操作和析构操作分别准确的对应于对象生存期的开始和结束,对象通常分别在这两个时刻获取和释放资源。构造操作和析构操作不是用来改变对象值的操作(实际上它们压根儿也不会改变对象的值,它们只是销毁原来的对象并替换上一个看起来一样、恰好拥有新数值的东西,其实这个新的东西与原来的对象根本就不是一回事儿)。

     

    [Problem #5: It Can Still Break Derived Classes]

    [问题#5:它可以对派生类产生破坏性影响]

     

    "this->T::~T();"作为替代语句解决了问题#1之后,这个惯用法仅仅替换掉派生类对象中的T subobject。许多派生类都可以如此正常工作,把它们的基类subobject换出换入,但有些派生类却可能不行。

     

    特别要指出的是,有些派生类可对其基类的状态予以控制,如果在不知道此信息的情况下对这些派生类的基类subobject进行盲目修改(以不可见的方式销毁和重构一个对象当然也算作是一种修改),那么这些派生类就可能导致产生失败。一旦赋值操作做了任何超出「一个“正常写入”型赋值运算符所应该做的操作」之额外操作,这个危险就会体现出来。

     

    [Problem #6: It Relies on Unreliable Pointer Comparisons]

    [问题#6:它依赖于不可靠的指针比较操作]

     

    该惯用法完全依赖于"this != &other"测试。(如果你对此有疑问的话,请考虑自赋值的情形。)

     

    其问题在于:这个测试并不保证你希望它保证的事情:C++标准保证「对指向同一个对象的多个指针的比较之结果必须是“相等(equal)” 」,但却并不保证「对指向不同对象的多个指针的比较之结果必须是“不相等(unequal)”」。如果这种情况发生,那么赋值操作就无法如愿完成。(关于"this != &other"测试的内容,参见GotW条款11。)

     

    如果有人认为这太钻牛角尖了,请参看GotW条款11中的简要论述:所有“必须”检查自赋值(self-assignment)的拷贝赋值操作都不是异常安全的。[4][注意:请看Exceptional C++及其勘误表以得到更新的信息。]

     

    另外还有一些能够影响客户代码和/或派生类的潜在危险(诸如虚拟赋值运算符的情形——这即使是在最好的情况下也还是多少有些诡异的),但到目前为止已经有足够多的内容用来演示该惯用法存在的严重问题了。

     

    [So What Should We Do Instead?]

    [那现在我们应该怎么做呢]

     

    如果其是不安全的,程序员又该如何达到预想的目标呢?

     

    用同一个成员函数完成两种拷贝操作(拷贝构造和拷贝赋值)的注意是很好的:这意味着我们只需在一个地方编写和维护操作代码。本条款问题中的惯用法只不过是选择了错误的函数来做这件事。如此而已。

     

    其实,惯用法应该是反过来实现的:拷贝构造操作应该以拷贝赋值操作来实现,不是反过来实现。例如: 

        T::T( const T& other ) {       /* T:: */ operator=( other );     }      T& T::operator=( const T& other ) {       // 真正的工作在这里进行       // (大概可以在异常安全的状态下完成,但现在       // 其可以抛出异常,却不会像原来那样产生什么不良影响       return *this;     }

    这段代码拥有原惯用法的所有益处,却不存在任何原惯用法中存在的问题。[5] 为了代码的美观,你或许还要编写一个常见的私有辅助函数,利用其做真正的工作;但这也是一样的,无甚区别: 

        T::T( const T& other ) {       do_copy( other );     }      T& T::operator=( const T& other ) {       do_copy( other );       return *this;     }      T& T::do_copy( const T& other ) {       // 真正的工作在这里进行       // (大概可以在异常安全的状态下完成,但现在       // 其可以抛出异常,却不会像原来那样产生什么不良影响 }  

    [Conclusion]

    [结论]

     

    原始的惯用法中充满了缺陷,且经常是错误的,它使派生类的编写者过上人间地狱般的生活。我时常禁不住想把这个原始的惯用法贴在办公室的厨房里,并注明:“有暴龙出没。”

     

    摘自GotW编码标准:

     

    -如果需要的话,请编写一个私有函数来使拷贝操作和拷贝赋值共享代码;千万不要利用「使用显式的析构函数并且后跟一个placement new」的方法来达到「以拷贝构造操作实现拷贝赋值操作」这样的目的,即使这个所谓的技巧会每隔三个月就在新闻组中出现几次。(也就是说,决不要编写如下的代码:) 

            T& T::operator=( const T& other )         {             if( this != &other)             {                 this->~T();             // 有害!                 new (this) T( other );  // 有害!             }             return *this;         }

     

    [Notes]

     

    [1]:这里我忽略一些变态的情形(例如,重载T::operator&(),使其做出返回this以外的事情)。GotW条款11提到一些有关情况。

     

    [2]:在C++标准草案中的那个例子意在演示对象生存期的规则,而不是要推荐一个好的现实用法(它不现实!)。下面给出草案3.8/7中的那个例子(处于空间的考虑做了微小的修改)以飨感兴趣的读者: 

      [例子:     struct C {       int i;       void f();       const C& operator=( const C& );     };     const C& C::operator=( const C& other)     {       if ( this != &other )       {         this->~C();     // '*this'的生存期结束         new (this) C(other);                         // 新的C型别的对象被创建         f();            // 此处定义良好       }       return *this;     }     C c1;    C c2;     c1 = c2; //此处定义良好     c1.f();  //此处定义良好; c1指的是              //  新的C型别的对象   --例子 ]

    并不推荐实际使用该代码的进一步的证据在于:C::operator=()返回了一个const C&而不单纯是C&,这不必要的避免了这些对象在标准程序库之容器(container)中的可移植用法。

     

    摘自GotW编码标准:

     

    - 将拷贝赋值操作声明为 "T& T::operator=(const T&)"

    - 不要返回const T&,尽管这样做避免了诸如"(a=b)=c"的用法;这样做意味着:你无法出于移植性的考虑而将T对象放入标准程序库之容器——因为其需要赋值操作返回一个单纯的T&Cline95: 212; Murray93: 32-33

     

    [3]:参见Scott Meyers的《Effective C++

     

    [4]:尽管你不能依赖于"this != &other"测试,但如果你为了通过优化处理排除已知的自赋值情形而这样做,则并没有错。如果它起作用的话,你便可以省掉一个赋值操作。当然,如果它不起作用的话,你的赋值运算符应该仍然以「对于自赋值而言是安全的」之方式来编写。关于使用这个测试作为优化手段,有人赞同也有人反对——但这超出了本期GotW的讨论范围。

     

    [注5]:的确,它仍然需要一个缺省的构造函数,并可能仍然不是最高效的;但要知道,你唯有利用初始化列表(initializer lists)才能得到最优的高效性(利用初始化列表即在构造过程中同时初始化成员变量,一气呵成,而不是分为先构造,再赋值两步来完成)。当然,这样做又要牺牲代码的公用性(commonality),而对此的权衡也超出了本期GotW的讨论范围。

    (完)


    最新回复(0)