• Effective C++ —— 构造/析构/赋值运算(二)


    条款05 : 了解C++默默编写并调用哪些函数

      编译器可以暗自为class创建default构造函数、copy构造函数、copy assignment操作符,以及析构函数。

      1. default构造函数和析构函数:主要是给编译器一个地方用来放置“藏身幕后”的代码,像是调用base classes和non-static成员变量的构造和析构函数当我们显式声明了一个构造函数,编译器于是不再为我们的类创建default构造函数

      2. 如果你打算在一个“内含reference成员”的class内支持赋值操作,则必须自己定义copy assignment操作符。面对“内含const成员”的classes,编译器的反应也是一样的,面对"内含const成员"的classes,更改const成员是不合法的,所以编译器不知道如何在它自己生成的赋值函数面对它们。

      3. 如果某个base classes将copy assignment操作符声明为private,编译器将拒绝为其derived classes生成一个copy assignment操作符。因为:derived classes 所生成的copy assignment操作符需要处理base class成分,但它们却无法调用base class 的copy assignment操作符。(其他的default构造函数,copy构造函数以及析构函数也一样。这也是下一个条款所描述的“若不想使用编译器自动生成的函数,就该明确拒绝”的其中一种实现方法:通过在base class中显式(编译器不再自动生成)的将构造函数等放置在private(阻止人们调用它)下,阻止编译器自动生成。)

    故而:

      编译器可以暗自为class创建default构造函数、copy构造函数、copy assignment操作符,以及析构函数。

    条款06 : 若不想使用编译器自动生成的函数,就该明确拒绝

      1. 所有编译器产出的函数都是public。为了阻止这些函数被创建出来,你得自己声明它们,但这里并没有什么需求使你必须将它们声明为public。因此你可以将copy构造函数或copy assignment操作符声明为private。藉由明确声明一个成员函数,你阻止了编译器自创建其专属版本;而令这些函数为private,使你得以成功阻止人们调用它。但这有时还不够,因为member函数和friend函数还是可以调用你的private函数。除非你够聪明,不去定义它们,那么如果某些人不慎调用任何一个,会获得一个连接错误。“将成员函数声明为private而且故意不实现它们”可以被很好的用来阻止copying行为(copy构造函数和copy assignment操作符)。如下:

    class HomeForSale{
    public:
        ......
    private:
        ......
        HomeForSale(const HomeForSale&);      //只有声明
        HomeForSale& operator=(const HomeForSale&);
    };

      2. 将连接期错误移至编译期是可能的(而且那是好事,毕竟越早侦测出错误越好)。如下:

    class Uncopyable{
    protected:
        Uncopyable() {}        //允许derived对象构造和析构
        ~Uncopyable()  {}
    private:
        Uncopyable(const Uncopyable&);       //但阻止copying
        Uncopyable& operator=(const Uncopyable&);
    };

    为了阻止HomeForSale对象被拷贝,我们唯一需要做的就是继承Uncopyable

    class HomeForSale:private Uncopyable{
       .......        // class 不再声明copying函数
    };

    只要任何人——甚至是member函数或friend函数——尝试拷贝HomeForSale对象,编译器便试着生成copying函数,而正如条款12所说,这些函数的“编译器生成版”会尝试调用其base class的对应兄弟,那些调用会被编译器拒绝,因为其base class的拷贝函数式private。

    故而:
      为驳回编译器自动(暗自)提供的机能,可将相应的成员函数声明为private并且不予实现。使用像Uncopyable这样的base class 也是一种做法。 

    条款07 : 为多态基类声明virtual析构函数

       1. C++指出:当derived class 对象经由一个base class 指针被删除,而该base class带着一个non-virtual析构函数,其结果未有定义——实际执行时通常发生的是对象的derived成分没被销毁,而其base class成分通常会被销毁,于是造成一个诡异的“局部销毁”对象。

      解决方案:给base class一个virtual析构函数。任何class只要带有virtual函数(多态)都几乎确定应该也有一个virtual析构函数

      如果class不含virtual函数,通常表示它并不意图被用做一个base class,当class不企图被当作base class,令其析构函数为virtual往往是个馊主意。原因在于:欲实现virtual函数,对象必须携带某些信息,主要用来在运行期决定哪一个virtual函数该被调用。这份信息通常是由一个所谓vptr(virtual table pointer)指针指出。vptr指向一个由函数指针构成的数组,称为vtbl(virtual table);每一个带有virtual函数的class都有一个相应的vtbl。当对象调用某一virtual函数,实际被调用的函数取决于该对象的vptr所指的那个vtbl——编译器在其中寻找适当的函数指针。这导致对象的体积增加,并且移植性变差。

      2. 很多人的心得是:只有当class内含至少一个virtual函数,才为它声明virtual析构函数。然而,即使class完全不带virtual函数,被“non-virtual析构函数问题”给咬伤还是有可能。如下:

    class SpecialString:public std::string { // 馊主意。std::string有个non-virtual析构函数
        ......
    };

    请考虑下面代码:

    SpecialString* pss = new SpecialString("Impending Doom");
    std::string * ps;
    ....
    ps = pss;     //SpecialString* =>std::string*
    ...
    delete ps;     // 未有定义,现实中*ps的SpecialString资源会泄露,因为SpecialString析构函数没被调用
            

    注意:相同的分析使用与任何不带virtual析构函数的class,包括所有的STL容器如vector,list,set,tr1::unordered_map(条款54)等等。记住:不要企图继承一个标准容器或任何其他”带有non-virtual析构函数“的class
      3. 由于抽象class(不能被实体化的class,也即不能构造出对象)总是企图被当作一个base class来用,而又由于base class应该有个virtual析构函数,并且由于pure virtual函数(纯虚函数)会导致抽象class,因此:为你希望它成为抽象的那个class 声明一个pure virtual析构函数。如下:

    class AWOV {
    public:
        virtual ~AWOV () = 0;      // 声明pure virtual析构函数
    };
    
    AWOV :: ~AWOV () { }    // pure virtual析构函数定义

      注意:必须为这个pure virtual析构函数提供一份定义。析构函数的运作方式是,最深层派生的那个class其析构函数最先被调用(与构造函数的调用顺序相反),然后是其每一个base class的析构函数被调用。编译器会在AWOV的derived classes的析构函数中创建一个对~AWOV的调用动作(详见条款05-1),所以你必须为这个函数提供一份定义。否则编译器会报错。
    故而:

      1. polymorphic(带多态性质的)base classes应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数。

      2. Classes的设计目的如果不是作为base class使用,或不是为了具备多态性(如条款06-2中的基类Uncopyable),就不该声明virtual析构函数。

    条款08: 别让异常逃离析构函数

       首先考虑以下代码:

    class DBConnection {
    public:
        ...
        static DBConnection create();    // 这个函数返回DBConnection 对象
       
        void close();    //关闭联机;失败则抛出异常。
    };
    
    class DBConn {   // 这个class用来管理DBConnection 对象
    public:
         ....
         ~DBConn()     // 确保数据库连接总是会被关闭
          {
               db.close();
           }
    private:
         DBConnection db;
    };    

    只要调用close成功,一切都美好。但如果该调用导致异常,DBConn析构函数会传播该异常,也就是允许它离开这个析构函数。那会造成问题,因为那就是抛出了难以驾驭的麻烦。有两个方法可以避免这一问题:

      1. 如果close抛出异常就结束程序。通常通过调用abort完成。

    DBConn::~DBConn()
    {
        try { db.close(); }
        catch( ... ){
             // 日志
             std::abort();
        }
    }

      2. 吞下因调用close而发生的异常。

    DBConn::~DBConn()
    {
        try { db.close(); }
        catch( ... ){
             // 日志
        }
    }

    然而,上面两种方法都不尽如人意,因为它们都无法对“导致close抛出异常”的情况做出反应。一个较佳策略如下:

    class DBConn {
    public:
       .....
       void close()        //供客户使用的新函数
       {
           db.close();
           closed = true;
        }
        ~DBConn()
        {
           if (!closed) {
                try { db.close(); }
                catch( ... ){
                       // 日志
                       ....
                    }
             }
        }
    private:
        DBConnection db;
        bool closed;
    };

    注意:这个在DBConn类中提供一个供客户使用的新函数,如果客户不调用,那么才会在析构函数中调用DBConnection的close函数关闭联机。这就给了客户一个处理相应异常的机会,并且在析构函数中做了双重保险。

      如果某个操作可能在失败时抛出异常,而又存在某种需要必须处理该异常,那么这个异常必须来自析构函数以外的某个函数。因为析构函数吐出异常就是危险,总会带来“过早结束程序”或“发生不明确行为”的风险


    故而:

       1. 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。

       2. 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。

    条款09: 绝不在构造和析构过程中调用virtual函数

       首先考虑以下代码:

    class Transaction {
    public:
         Transaction ();
         virtual void logTransaction () const = 0;     //做出一份因类型不同而不同的日志
         ......
    };
    
    Transaction::Transaction ()
    {
         ......
         logTransaction ();
    }
    
    class BuyTransaction :public Transaction {
    public:
         virtual void logTransaction () const;
         ....
    };
    
    class SellTransaction :public Transaction {
    public:
         virtual void logTransaction () const;
         ....
    };
    
    ------------------------------------------------
    //考虑下面语句
    BuyTransaction b;

      无疑地会有一个BuyTransaction构造函数被调用,但首先Transaction构造函数一定会更早被调用;是的,derived class对象内的base class成分会在derived class 自身成分被构造之前先构造妥当。
      问题在于:Transaction构造函数最后调用了logTransaction是Transaction内的版本,不是BuyTransaction内的版本——即使目前即将建立的对象类型是BuyTransaction。是的,base class构造期间virtual函数绝不会下降到derived classes阶层 --> (理由)由于base class 构造函数的执行更早于derived class构造函数,当base class构造函数执行时derived class的成员变量尚未初始化。如果此期间调用的virtual函数下降至derived class阶层,要知道derived class 的函数几乎必然取用local成员变量,而那些成员变量尚未初始化。这将是一张通往不明确行为和彻夜调试大会的车票。 --> (更根本的原因)在derived class 对象的base class构造期间,对象的类型是base class而不是derived class。不只virtual函数会被编译器解析至base class ,若使用运行期类型信息,也会把对象视为base class 类型。这样的处理是合理的:derived class的专属成分尚未被初始化,所以面对它们,最安全的做法就是视它们不存在。对象在derived class构造函数开始执行前不会成为一个derived class对象。

      相同的道理也适用于析构函数。一旦derived class析构函数开始执行,对象内的derived class成员变量便呈现未定义值,所以C++视它们仿佛不再存在。进入base class析构函数后对象就成为一个base class对象,而此时,C++的任何部分包括virtual函数、dynamic_casts等等也就那么看待它。

      解决方案:在base class 内将virtual函数改为non-virtual,然后要求derived class构造函数传递必要信息给base class构造函数,而后base class 构造函数就可以安全的调用non-virtual函数了。如下:

    class Transaction {
    public:
         explicit Transaction (const std::string& logInfo);   //单参数构造函数,最好使用explicit禁止其进行隐式类型转换
         void logTransaction (const std::string& logInfo) const;     //non-virtual函数
         ......
    };
    
    Transaction::Transaction (const std::string& logInfo)
    {
         ......
         logTransaction (logInfo);  //non-virtual调用
    }
    
    class BuyTransaction :public Transaction {
    public:
        BuyTransaction(parameters)
        :Transaction(createLogString(parameters))  // 将log信息传给base class 构造函数
        {   .....   }
         ....
    private:
         static std::string createLogString(parameters);  //   函数为static
    };

    注意:比起成员初值列内给予base class所需数据,利用辅助函数创建一个值传给base class构造函数往往比较方便(也比较可读)。
    令此函数为static,也就不可能意外指向"初期未成熟之derived class对象内尚未初始化的成员变量"。可以参考Effective C++ —— 让自己习惯C++(一)条款04 和 C++类中的static数据成员,static成员函数

    故而:

      在构造和析构期间不要调用virtual函数,因为这类调用从不下降至derived class(比起当前执行构造函数和析构函数的那层)。这就是所谓的:virtual函数在构造/析构期间的“失常表现”,也即,在此期间,virtual函数不是virtual函数。

    条款10: 令operator=返回一个reference to *this.

       关于赋值,你可以写下如下语句:

    int x, y, z;
    x = y = z = 15;    // 赋值连锁形式
    // 赋值采用右结合律,所以上述连锁赋值被解析为:
    x = (y = (z = 15));

    这里15先被赋值给z,然后其结果(更新后的z)再被赋值给y,然后其结果(更新后的y)再被赋值给x。
    为了实现“连锁赋值”,赋值操作符必须返回一个reference指向操作符的左侧

    故而:

      令赋值操作符返回一个reference to *this.

    条款11: 在operator=中处理“自我赋值”

       “自我赋值”发生在对象被赋值给自己时。考虑以下代码:

    class Bitmap { ... }
    class Widget{
        ....
    private:
        Bitmap *pb;   //指针,指向一个从heap分配而得的对象
    };
    
    --------------------Method1 不具备“自我赋值安全性”、不具备“异常安全性”----------------------------------------------------------
    Widget& Widget::operator=(const Widget& rhs)     //一份不安全的operator=实现版本
    {
         delete pb;    // 停止使用当前的bitmap
         pb = new Bitmap(*rhs.pb);   // 使用rhs's bitmap的副本
         return *this;
    }
    
    --------------------Method2 具备“自我赋值安全性”、不具备“异常安全性”----------------------------------------------------------
    Widget& Widget::operator=(const Widget& rhs)     //
    {
        // 赋值之前会先释放自身的内容,如果是自己,数据就丢失了
    if (this == &rhs) return *this; //证同测试,自我赋值安全性 delete pb; // 停止使用当前的bitmap pb = new Bitmap(*rhs.pb); // 申请内存失败,此时pb已被删除,导致异常 return *this; } --------------------Method3 具备“自我赋值安全性”、具备“异常安全性”---------------------------------------------------------- // 让operator=具备“异常安全性”往往自动获得“自我赋值安全性”的回报,只需注意在复制pb所指东西之前别删除pb: Widget& Widget::operator=(const Widget& rhs) // { Bitmap* pOrig = pb; // 记住原先的pb pb = new Bitmap(*rhs.pb); // 若申请内存失败,原先的pb此时仍未被删除(保持原状),不导致异常;new分配失败时,会抛出异常跳过后面的代码
                          若成功,皆大欢喜,pb指向新的内容,后面pOrig正常删除原来数据;
                          若成功,但是是自我赋值,那么由于此时pb所指的内容还未被删除,pb指向自己的一个副本(新的内容),之后才删除原来的内容,不会导致自我赋值时数据丢失的可能
    delete pOrig; // 删除原先的pb,在赋值之后删除
    return *this; } --------------------Method4 具备“自我赋值安全性”、具备“异常安全性”---------------------------------------------------------- // copy and swap技术(条款29) class Widget{ .... void swap(Widget& rhs); //交换*this和rhs的数据(见条款29) .... }; Widget& Widget::operator=(const Widget& rhs) // { Widget temp(rhs); //为rhs数据制作一份复本 swap(temp); //将*this数据和上述复件的数据交换 return *this; } --------------------Method5 具备“自我赋值安全性”、具备“异常安全性”---------------------------------------------------------- // (1) 某class的copy assignment操作符可能被声明为“以by value方式接受实参”; // (2) 以by value方式传递东西会造成一份复件; Widget& Widget::operator=(Widget rhs) //rhs是被传对象的一份复件,注意这里是pass by value { swap(ths); //将*this数据和上述复件的数据交换 return *this; }

     故而:

      1. 确保当对象自我赋值时operator= 有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap。

      2. 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。

    条款12: 复制对象时勿忘其每一个成分

       设计良好之面向对象系统会将对象的内部封装起来,只留两个函数负责对象拷贝,那便是带着适切名称的copy构造函数和copy assignment操作符,也即所谓的copying函数。

      考虑以下代码:

    class Date { ... };
    class Customer {
    public:
         .....
    private:
         std::string name;
         Date lastTransaction;
    };
    
    class PriorityCustomer:public Customer {
    public:
        .....
        PriorityCustomer(const PriorityCustomer& rhs);
        PriorityCustomer& operator=(const PriorityCustomer& rhs);
        ....
    private:
        int priority;
    };
    
    PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs):priority(rhs.priority)
    {
          logCall("PriorityCustomer copy constructor");
    }
    
    PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
    {
          logCall("PriorityCustomer copy assignment operator");
          priority = rhs.priority;
          return *this;    // 条款10
    }

      PriorityCustomer的copying函数看起来好像复制了PriorityCustomer内的每一样东西,但注意,它们复制了PriorityCustomer声明的成员变量,但每个PriorityCustomer还内含它所继承的Customer成员变量复件,而那些成员变量却未被复制。PriorityCustomer的copy构造函数并没有指定实参传给其base class构造函数(也就是说它在它的成员初值列中没有提到Customer),因此PriorityCustomer对象的Customer成分会被不带实参之Customer构造函数(即default构造函数——必定有一个否则无法通过编译)初始化。default构造函数将针对name和lastTransaction执行缺省的初始化动作。

      以上事态在PriorityCustomer的copy assignment操作符身上只有轻微不同。

      所以,任何时候,只要你决定自己承担起“为derived class撰写copying函数”的重责大任,就必须很小心地复制其base class成分。那些成分往往是private(条款22),所以你无法直接访问它们,你应该让derived class的copying函数调用相应的base class 函数。如下:

    PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
    :Customer(rhs),  // 调用base class 的copy构造函数
      priority(rhs.priority)
    {
          logCall("PriorityCustomer copy constructor");
    }
    
    PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
    {
          logCall("PriorityCustomer copy assignment operator");
          Customer::operator=(rhs);   // 对base class成分进行赋值动作
          priority = rhs.priority;
          return *this;    // 条款10
    }

      注意:虽然两个copying函数往往有相似的实现本体,但却不能互为调用。令copy assignment操作符调用copy构造函数是不合理的,因为这就像试图构造一个已经存在的对象。相反,令copy构造函数调用copy assignment操作符同样无意义,构造函数用来初始化新对象,而assignment操作符只施行于已初始化对象身上,对一个尚未构造好的对象赋值,就像在一个尚未初始化的对象身上做“只对已初始化对象才有意义”的事一样。无聊嘛,别尝试。


    故而:

      1. Copying函数应该确保复制“对象内的所有成员变量”及“所有base class成分”。

      2. 不要尝试以某个copying函数实现另一个copying函数。应该将共同机能放进第三个函数中,并由两个copying函数共同调用。

  • 相关阅读:
    numpy-tutorial
    Pandas 数据分析资料
    python3 创建虚拟环境
    机器学习中的评价指标--02
    机器学习中的评价指标--01
    pytest 测试框架
    Ubuntu 添加删除用户
    VSCODE 设置护眼颜色
    信息熵、交叉熵、KL散度等等
    深度学习优化方法演变和公式理解
  • 原文地址:https://www.cnblogs.com/yyxt/p/4802390.html
Copyright © 2020-2023  润新知