Effective C++读书笔记~02 构造/析构/赋值运算

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

Know what functions C++ silently writes and calls.

如果你没自己声明,编译器就会为你的class声明:一个copy构造函数、一个copy assignment操作符,一个析构函数。如果没有声明任何构造函数,编译器会为你声明一个default构造函数。

例如,你写这样的class:

class Empty {};

编译器会为你添加一些必要的函数,就好像是这样:

class Empty 
{
public:
    Empty() { ... } // default 构造函数
    Empty(const Empty& rhs) { ... } // copy构造函数
    ~Empty() { ... } // 析构函数, 是否是virtual见后

    Empty& operator=(const Empty& rhs) { ... } // copy assignment操作符
};

不过,只有这些函数真正被调用时,编译器才会创建出来(如果 从来没被调用,就不会创建)。比如,下面就是调用相应函数的时候:

Empty e1; // 调用default 构造函数, 还会调用析构函数

Empty e2(e1); // 调用copy 构造函数
e2 = e1; // 调用copy assignment操作符

对于default构造函数和析构函数

只有当你没有声明任何构造函数的时候,编译器才会为你合成default构造函数。
编译器合成的函数,像是调用base classes和non-static(对象)成员变量的构造函数和析构函数。

对于析构函数

编译器合成的析构函数是non-virtual的,除非该class的base class自身声明有virtual析构函数。

对于copy构造函数和copy assignment操作符(=)

编译器合成的版本,只是单纯的将来源对象的每个non-static成员变量拷贝到目标对象。
对于合成的代码不合法,或无意义时,编译器会拒绝为class合成operator =。

例如,下面的代码,

template<class T>
class NamedObject {
public:
       NamedObject(string& name, const T& value) : nameValue(name),  objectValue(value) { }
       void show() { cout << nameValue << ", " << objectValue << endl; }
private:
       string& nameValue;        // reference
       const T objectValue;      // const
};

string newDog("Persephone");
string oldDog("Satch");
NamedObject<int> p(newDog, 2);
NamedObject<int> s(oldDog, 36);
p.show();
s.show();
p = s;    // 错误:因为编译器拒绝为class合成operator =, 无法调用赋值运算符

编译器不确定合成copy assignment操作符(operator =)是修改reference指向的内容,还是修改reference本身,会拒绝合成。
另外,修改const 成员,也会导致状况,编译器亦会拒绝合成。

当编译器拒绝合成copy assignment操作符时,程序员需要自行手动编写。
一种可行方案:自定义operator =,对需要赋值的成员手动添加赋值,如果是const成员,可以去掉const属性,或者不对const属性赋值。

template<class T>
class NamedObject {
public:
       NamedObject(string& name, const T& value) : nameValue(name),  objectValue(value) { }
       NamedObject& operator = (const NamedObject& obj) {
              nameValue = obj.nameValue;
              objectValue = obj.objectValue;
              return *this;
       }
       void show() { cout << nameValue << ", " << objectValue << endl; }
private:
       string& nameValue; // reference
       T objectValue; // 去掉const属性
};

小结

编译器可以暗自为class创建default构造函数、copy构造函数、copy assignment操作符、析构函数,前提是能合成合法且有明确意义的函数。

[======]

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

Explicitly disallow the use of complier-generated functions you do not want.

由于编译器会自动合成copy构造函数和copy assignment操作符,因此,如果不需要编译器自动生成的函数时,应明确拒绝。
自然而然想到的方式是,在class中声明copy构造函数、copy assignment操作符为private:

// 不安全做法: 将copy构造函数,copy assign. 操作符设为private, 阻止编译器生成函数
class HomeForSale
{
public:
       HomeForSale() {}
private:
       HomeForSale(const HomeForSale&); // 只是声明, 无需实现 -- 阻止编译器合成copy构造函数
       HomeForSale& operator = (HomeForSale&); // 只是声明, 无需实现-- 阻止编译器合成copy assignment操作符
};

HomeForSale h1;
HomeForSale h2;
HomeForSale h3(h1); // 企图拷贝h1用于构造h3 -- 不应通过编译
h1 = h2; // 企图将 h2赋值给h1 -- 不应通过编译

虽然声明private函数,可以阻止编译器自动生成对应的函数,但是这样并不安全,因为可以在member function和friend function中调用。有没有更安全的做法?
答案是有的。可以设置一个基类,在基类中将copy构造函数、copy assignment操作符设为private,编译器为派生类合成函数时,会自动调用基类对应的函数,而基类函数为private,导致编译器无法合成。

// 更安全的做法: 将基类的copy构造函数, copy assign.操作符设为private,阻止编译器为派生类生成函数
class Uncopyable
{
protected:
       Uncopyable() { }
       ~Uncopyable() { }
private:
       Uncopyable(const Uncopyable&);
       Uncopyable& operator = (const Uncopyable&);
};
class HomeForSale : private Uncopyable
{ // class不再声明copy构造函数、copy assign.操作符
public:
       HomeForSale() {}
};
...

delete与default

C++ 11以后,可以使用delete表示禁止编译器自动生成函数;default表示使用编译器自动生成的函数。

class HomeForSale
{
public:
       HomeForSale() {}
       HomeForSale(const HomeForSale&) = delete; // delete关键字阻止编译器合成copy构造函数
       HomeForSale& operator = (HomeForSale&) = delete; // delete关键字阻止编译器合成copy assignment操作符
};

小结

  • 为阻止编译器自动合成函数,可以将相应成员函数声明为private并且不实现。
  • 使用uncopyable这样的base class是一种更安全的做法。
  • C++11以后可以用delete关键字,更简洁、安全。

[======]

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

Declare destructors virtual in polymorphic base classses.

当一个基类指针指向派生类对象,在delete释放时,如果基类析构函数是非virtual的,那么会直接调用基类析构函数,造成灾难后果;只有当基类析构函数是virtual的,才会正确调用派生类的析构函数析构对象。

i.e. 当一个类的析构函数不是virtual时,说明该类不希望被继承。

运行期如何决定调用哪一个virtual函数?

通常由vptr(virtual table pointer)指针指出:每个class包含一个vptr,而vptr指向一个由函数构成的数组,称为vtbl(virtual table,虚函数表);每个带有virtual函数的class,都会有一个相应的vtbl。当对象调用某一个virtual函数时,实际被调用的函数取决于对象的vptr所指的那个vtbl -- 编译器在其中寻找适当的函数指针。

virtual函数的缺点

为何不把所有函数声明为virtual函数,以避免调用错误?这是因为virtual也是有缺点的:
1)会占用更多内存,在32bit系统中,至少会额外占用4byte vptr + 4byte typeinfo + 4byte 函数指针(1个虚函数);
2)不再具有移植性,虚函数不再和C语言的函数具有相同的结构;

C++禁止派生 -- final

一个类不希望被继承时,最好的方式是使用保留字final(适用于C++11以上版本)。

class A final // 禁止A被派生
{
public:
    int val;
};

class B : public A // 错误: A无法被派生
{...}

C++希望派生 -- 纯虚函数

推荐将析构函数设为纯虚函数。纯虚函数无法实例化,只能通过被继承后,才能实例化派生类。

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

小结

  • 带多态性质的base class应该声明一个virtual析构函数。如果一个class带有任何virtual函数,那么它就应该拥有一个virtual析构函数;
  • class的设计目的如果不是作为base class使用,或者不是为了多态(被继承),就不该声明virtual析构函数。

[======]

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

Prevent exceptions from leaving destructors.

C++不禁止析构函数吐出异常,但不建议这么做。因为抛出异常会导致不明确行为:剩余资源是继续释放,还是不释放?如果继续释放,那么异常谁捕获,如何处理?如果不继续释放,那么内存就会发生泄漏。

例如,DBConn类负责管理DBConnection对象,DBConnect对象负责建立数据库连接的建立和释放。

// 负责数据库连接
class DBConnection
{
public:
    ...
    static DBConnection create(); // 返回DBConnection对象
    void close(); // 关闭数据库连接, 失败则抛出异常
};

// 管理DBConnection对象
class DBConn
{
public:
    ...
    ~DBConn()
    { // 析构函数中调用数据块连接
        db.close(); // 会抛出异常
    }
private:
    DBConnection db;
};

DBConn dbc(DBConnection::create()); // 构建DBConn对象时, 就建立数据库连接
...

如果~DBConn中db.close调用异常,就会允许离开这个析构函数,就会造成问题,因为抛出了难以处理的麻烦。
两种办法避免这个问题:
1)如果close抛出异常,就直接结束程序(通过调用abort):

DBConn::~DBConn()
{ // 析构函数中调用数据块连接
    try { db.close(); }
    catch(...) {
        // 记录日志, 记下对close的调用失败
        abort();
    }
}

2)吞下调用close而发生的异常:

DBConn::~DBConn()
{
    try { db.close(); }
    catch(...) {
        // 记录日志, 记下对close的调用失败
    }
}

上面2个办法都不是很好,因为都无法对“导致close抛出异常”的情况作出任何反应。
一个更好的办法:重新设计DBConn接口,让客户有机会对可能出现的问题作出反应。即把可能抛出异常的代码,转移到客户手上。

class DBConn
{
public:
    ...
    void close()
    { // 供客户使用的新函数, 客户有机会在这里处理异常, 也可以不处理
        db.close();
        closed = true;
    }
    ~DBConn()
    { // 析构函数中调用数据块连接
        if (!closed) {
            try { db.close(); } // 如果客户没有关闭连接, 这里就关闭连接
            catch(...) {
                // 记录日志, 记下对close的调用失败
                ...
            }
        }
    }
private:
    DBConnection db;
    bool closed;
};

小结

1)析构函数绝不要吐出异常。如果一个析构函数调用的函数可能抛出异常,那么析构函数应该捕捉任何异常,然后吞下(不传播)或结束程序。
2)如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。

[======]

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

Never call virtual functions during construction or destruction.

问题案例

假设你有个class继承体系,用来model股市交易如买进、卖出的订单等。这样的交易一定要经过审计,所有每当创建一个交易对象,在审计日志(audit log)中需要创建一笔适当记录。
如果按下面的做法:

class Transaction // 所有交易的base class
{
public:
       Transaction();
       virtual void logTransaction() const = 0; // 日志记录(log entry),因类型不同而不同
       ...
};
Transaction::Transaction()
{
       ...
       logTransaction(); // 日志记录这笔交易
}
class BuyTransaction : public Transaction // 派生类 买进
{
public:
       virtual void logTransaction() const; // log此类型交易
       ...
};
class SellTransaction : public Transaction
{
public:
       virtual void logTransaction() const; // log此类型交易
       ...
};
// case1: 构造BuyTransaction对象
BuyTransaction b; // 错误:BuyTransaction构造函数首先调用基类Transaction构造函数,而基类构造函数调用了基类的纯虚函数

当执行case1 构造BuyTransaction对象时,会发生什么?
BuyTransaction构造函数被调用前,会先去调用基类Transaction的构造函数,而基类构造函数末尾会调用纯虚函数logTransaction。由于此时派生类BuyTransaction对象尚未构造完成,logTransaction只会调用基类的版本,而不会下降到派生类阶层
问题在于,基类的logTransaction是纯虚函数,没有实现,无法被调用。因此会产生危险的结果,编译器不会让你这么做。
当然,如果基类logTransaction函数只是普通虚函数(非纯虚函数),它就会被正常调用。但如果是这样,就完全没必要用virtual函数,用普通函数即可。

还有一种常见情况,构造函数中虽然没有直接调用虚函数,但是通过其他函数间接调用了虚函数。

class Transaction // 所有交易的base class
{
public:
       Transaction();
       virtual void logTransaction() const = 0; // 日志记录(log entry),因类型不同而不同
       ...
private:
       init();
};
Transaction::Transaction()
{
       init();
}

Transaction::init()
{
       logTransaction(); // 日志记录这笔交易
}

这种情况本质上和上面情况一样,不过通常比较难以发现。那么要如何避免呢?
一种可行的做法是在class Transaction内的virtual 函数logTransaction改为non-virtual,然后要求derived class构造函数传递必要信息给Transaction构造函数,这样Transaction构造函数就能安全地调用non-virtual函数了。

小结

在构造和析构期间不要调用virtual函数,因为这类调用从不下降至derived class;

[======]

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

Have assignment operators return a reference to *this.

为了遵循标准赋值协议:赋值操作符必须返回一个reference指向操作符的左侧实参。虽然不这么做,也可以通过编译。

int x, y, z;
x = y = z = 15; // 赋值连锁形式
x = (y = (z = 15)); // (z = 15)的值是操作符左侧实参z, (y = (z = 15))的值是操作符y左侧实参y

class如果重载了赋值操作符,也应该遵循这个协议。

class Widget 
{
public:
    Widget& operator=(const Widget& rhs) // 返回类型是个reference,指向当前对象
    {
        ...
        return *this;        // 返回左侧对象
    }
};

[======]

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

Handle assignment to self in operator=.

什么是“自我赋值”?

class Widget { ... };
Widget w;
...
w = w; // 自己赋值给自己

如何避免“自我赋值”?

传统的做法是使用证同测试(identity test):

Widget& Widget::operator=(const Widget& rhs)
{
    if (this == &rhs) return *this; // 证同测试

    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

上面代码虽然“自我赋值安全”,但存在不具备“异常安全”的问题。当new Bitmap异常时,新空间无法申请,而旧的pb却已经被删除,导致无法读取。
解决办法:记住原来的pb,确认申请新空间有效后,再删除之。

Widget& Widget::operator=(const Widget& rhs)
{
    Bitmap* pOrig = pb; // 记住旧的pb
    pb = new Bitmap(*rhs.pb); // 另pb指向*pb的一个副本
    delete pOrig; // 删除旧的pb
    return *this;
}

上面代码同时具备“自我赋值安全”和“异常安全”。不过,当自我赋值时,效率可能会比较低。一种新的效率更高的方案是使用copy and swap技术。推荐做法。

class Widget
{
public:
    ...
    void swap(Widget& rhs); // 交换*this和rhs的数据
    ...
};
Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs); // 为rhs数据制作一份副本
    swap(temp); // 将*this数据和副本temp的数据交换数据
    return *this;
}

变种:如果operator=传入参数是以值传递方式,可以无需再为rhs数据制作一份副本

Widget& Widget::operator=(Widget rhs)
{
swap(rhs); // 将*this数据和副本rhs的数据交换数据
return *this;
}

小结

1)确保当对象自我赋值时,operator= 自我赋值安全、异常安全。用到的技术,包括“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap。
2)确定任何函数如果操作同一个对象(包括自身)时,其行为仍然正确。

[======]

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

Copy all parts of an object.

copying函数

我们把copy构造函数,copy assignment操作符统称为copying函数。
当手工编写copying函数出错时,如漏掉某些class的local成员,编译器不会报错,但派生类如果依赖该copying函数可能会造成严重后果。需要是否小心处理。

当编写一个copying函数时,需要确保:
1)复制所有local成员变量;
2)调用所有base classes内适当的copying函数;

copying函数如何避免代码重复?

不要在copying函数之间相互调用,比如用copy构造函数来实现copy assignment操作符,因为这就像试图构造一个已经存在的对象。反过来,也没有意义,因为copy assignment操作符只能施加于已经初始化的对象上。

要解决2者代码重复问题,可以设置第三个成员函数,通常是设置名为init的private函数,给两者调用。

小结

1)Copying 函数应该确保复制“对象内的所有成员变量”(即local变量)以及“所有base class成分”(调用base class的copying函数);
2)不要尝试以一个copying函数实现另一个copying函数。应该将共同部分放到第三个函数中,并由2个copying函数共同调用;

[======]

posted @ 2021-11-16 12:59  明明1109  阅读(94)  评论(0编辑  收藏  举报