Effective C++ 阅读笔记(二)

Constructors, Destructors, and Assignment Operators

Item 5: Know what functions C++ silently writes and calls.

C++ 的类会自动实现以下函数:

  • 默认构造函数
  • 拷贝构造函数
  • 拷贝赋值函数
  • 析构函数

也就是定义一个空类时,实际上有:

class Empty {
public:
    Empty() { ... } // default constructor
    Empty(const Empty& rhs) { ... } // copy constructor
    ~Empty() { ... } // destructor — see below for whether it’s virtual
    Empty& operator=(const Empty& rhs) { ... } // copy assignment operator
};

其中的拷贝构造和拷贝赋值函数都采用深拷贝的方式,也就是会对类的每一个成员函数都进行赋值。正因如此会有一些糟糕的情况,比如类里有引用或者const成员,此时的拷贝赋值函数的定义变得非常不明确,所以编译器就不会自动生成拷贝构造函数:

template<typename T>
class NamedObject {
 public:
  NamedObject(std::string& name, const T& value): nameValue(name), objectValue(value) {}
 public:
    std::string& nameValue; // this is now a reference
    const T objectValue;    // this is now const
};

std::string newDog("Persephone");
std::string oldDog("Satch");
NamedObject<int> p(newDog, 2); 
NamedObject<int> s(oldDog, 36); 
p = s; // 不通过,因为找不到拷贝构造函数

Item 6: Explicit disallow the use of compiler generated functions you do not want.

这里给出了一种通过私有继承来禁止使用编译器自动生成的函数,比如拷贝构造和拷贝赋值函数的方法:

class Uncopyable {
protected: // allow construction
    Uncopyable() {} // and destruction of
    ~Uncopyable() {} // derived objects...
private:
    Uncopyable(const Uncopyable&); // ...but prevent copying
    Uncopyable& operator=(const Uncopyable&);
};

class HomeForSale: private Uncopyable { // class no longer
    ... // declares copy ctor or
};

如果不使用私有继承,而是直接在类中将拷贝构造函数定义为 private 也是一个方法,但是缺点是,可能有一个内部成员函数,或者一个友元函数,同样有使用这个函数的权限,这不会在编译阶段报错,只会在链接阶段报错(因为这个拷贝构造函数只有声明没有定义):

class HomeForSale { // class no longer
 public:
  HomeForSale(){}
  void foo() {
    HomeForSale h_temp(*this);
  }
 private:
  HomeForSale(const HomeForSale&);
};

HomeForSale h1;
HomeForSale h2(h1); // 无法通过编译,因为调用了 private 方法
h1.foo(); // 可以通过编译,但链接阶段报错

为了避免这种情况,更好的方法是写一个类并私有继承这个类:

class Uncopyable {
 protected: // allow construction
  Uncopyable() {} // and destruction of
  ~Uncopyable() {} // derived objects...
 private:
  Uncopyable(const Uncopyable&); // ...but prevent copying
  Uncopyable& operator=(const Uncopyable&);
};

class HomeForSale : private Uncopyable{ // class no longer
 public:
  HomeForSale(){}
  void foo() {
    HomeForSale h_temp(*this); // 无法通过编译,因为拷贝构造函数被隐式删除了
  }
};

结果是类似的,HomeForSale的拷贝构造和拷贝赋值函数被禁用了,而默认构造函数和析构函数则被保留,好处则在于,内部函数调用拷贝构造函数这一步在编译阶段就能够报错,不用等到链接阶段。这是由于HomeForSale私有继承了Uncopyable,根据构造/析构函数经过继承后的调用原则,对于构造函数是先调用父类的构造函数,再调用子类的构造函数;对于析构函数则是先调用子类的析构函数,再调用父类的析构函数。根据私有继承的原则,父类的protected属性被子类继承为private属性,而父类的private属性对子类不可见。从而产生禁用了拷贝赋值和拷贝构造函数的现象。

补充:现在这个方法可能也有点过时了,C++11中引入了delete关键字来显式禁止编译器生成这些默认函数,比如 IDE 的语法检测工具可能会提示你修改为使用delete的写法。但我们还是可以通过这个例子了解一些类的构造析构函数以及继承相关的原理。

Item 7: Declare destructors virtual in polymorphic base classes.

被继承父类(基类)的析构函数经常需要被virtual修饰,否则会产生一些未定义的行为。

部分销毁问题

这个问题发生的场景是,创建了一个指向子类的指针,却保存为父类并删除该指针,如果父类TimeKeeper的析构函数没有被virtual修饰,会导致子类AtomicClock的析构函数不被调用的问题,从而发生内存溢出,例如下面两种定义:

class TimeKeeper {
 public:
  TimeKeeper() {
      std::cout << "Time Keeper Constructor." << std::endl;
  };
  ~TimeKeeper() {
      std::cout << "TIme Keeper Destructor." << std::endl;
  };
};
class AtomicClock: public TimeKeeper {
 public:
  AtomicClock() {
    std::cout << "Atomic Clock Constructor." << std::endl;
  }
  ~AtomicClock() {
    std::cout << "Atomic Clock Destructor." << std::endl;
  }
};

TimeKeeper* getTimeKeeper() {
  return new AtomicClock;
};

int main() {
  TimeKeeper* ptk = getTimeKeeper();
  delete ptk;
  return 0;
}

TimeKeeper的析构函数没有被virtual修饰时,输出为:

Time Keeper Constructor.
Atomic Clock Constructor.
Time Keeper Destructor.

此时,AtomicClock的析构函数没有被调用。

TimeKeeper的析构函数被virtual修饰时,输出为:

Time Keeper Constructor.
Atomic Clock Constructor.
Atomic Clock Destructor.
Time Keeper Destructor.

virtual相当于告诉编译器,这个函数允许在衍生类中被重新实现,总之被修饰virtual后的函数行为是符合标准的。

同时,因为有上面这个问题存在,很多 STL 在设计上是不应该作为基类被继承的,比如class SpecialString: public std::string,由于string并没有被virtual修饰,当一个SpecialString*被存为string*时,可能会发生类似的未被定义的情况。 从这里也可以看到C++语法上的一个缺陷,就是没有禁止某个类或者方法被继承的机制,而在 java 中可以用final关键字,在 C# 中可以用sealed类。

virtual关键字

关于virtual还有几点关键知识:

  • virtual 的一个缺点是会增加类的空间开销,并且使得类在 C 或 Fortran 等其他语言的移植中受到影响。主要原因是虚函数会引入 virtual table pointer,它指向了一个称之为 virtual table 的函数指针数组。当虚函数被调用时,相当于需要用对应的 virtual table pointer 进行查表,在 virtual table 中找到对应的函数。这个 virtual table pointer 的大小和操作系统位数相关,比如对于 32 位操作系统,指针大小就为 32 位,每个虚函数就额外需要 32 位的存储空间。
  • 当某个函数被定义时既被virtual修饰,又以=0结尾,说明该函数被定义为一个纯虚函数,比如virtual ~AWOV() = 0;。纯虚函数会使得这个类变为一个抽象类,无法被实例化。语法上要注意,这样写只是声明了一个纯虚函数,还需要再额外定义AWOV::~AWOV() {}
  • 如果在设计上,保证不存在通过基类方法操作派生对象的情况,那就不需要使用virtual关键字。比如上一节的Uncopyable就不需要用virtual修饰析构函数。这种通过基类方法操作派生对象的情况只会出现在多态场景,也就是一个基类派生出多个子类,这也就是virtual的使用场景。
  • 一个简单的原则:一个类的析构函数是否应该被virtual修饰,这通常和析构函数本身无关,而是在于该类是否有其他方法被virtual修饰了。只要有一个方法被virtual修饰,析构函数也应该被virtual修饰。反之则不需要,因为基本不会出现只有一个析构函数被virtual修饰而其他函数都没有被virtual修饰的情况。

Item 8: Prevent exceptions from leaving destructors.

不要在析构函数里抛异常,因为这可能导致同时抛出两个或更多异常,这对C++来说是无法处理的。

  • 在抛出异常时,如果当前代码块没有处理这个异常,则会把这个异常抛给上一层调用进行处理,问题在于在返回上一层之前,局部变量需要调用自己的析构函数来释放掉,如果在析构函数中再次抛异常,此时相当于同时抛两个异常,就会引发未定义的行为。此外,例如vector这样的 STL,在作为局部函数被销毁时会调用其中每一个元素的析构函数来进行销毁,如果在销毁元素的过程中抛异常也会导致同时抛出多个异常。

  • 有两种解决方法,但是其实都不是很优雅,一是把析构函数中的抛异常改为直接终止程序,比如通过std::abort();二是不抛异常,直接使用 try…catch 把异常处理掉。

  • 相对来说,如果一定要在释放资源的过程中抛异常,比较好的方法是在一个新定义的、非析构的函数,比如close(),在这个函数里释放资源并抛异常,这样用户就可以自己掌握异常处理的时机和方式了。

  • 总之,永远不要在析构函数里抛出异常。

Item 9: Never call virtual functions during construction or destruction.

不要在构造函数和析构函数中调用虚函数,如下面这个例子:

class Transaction { // base class for all
public: // transactions
    Transaction();
    virtual void logTransaction() const = 0; // make type-dependent log entry
};
Transaction::Transaction() // implementation of base class ctor
{
    logTransaction(); // as final action, log this transaction
}
class BuyTransaction: public Transaction { // derived class
public:
    virtual void logTransaction() const; // how to log transactions of this type ...
};
class SellTransaction: public Transaction { // derived class
public:
    virtual void logTransaction() const; // how to log transactions of this type
};

功能上是为了对于买订单和卖订单,用不同的方式记录日志,所以把logTransaction定义为虚函数,分别在BuyTransactionSellTransaction中有不同的实现,然后在Transaction的构造函数中调用这个虚函数。

但是结果是不符合期望的:在基类Transaction调用虚函数时,无论这个对象属于哪个派生类,都只会调用Transaction本身的实现。在这个例子中,由于Transaction里的logTransaction只有一个纯虚函数的声明而没有定义,因此会在链接阶段就报错。

实际上这种设计是很合理的,因为构造函数的调用顺序是先调用基类,再调用派生类,假设在Transaction的构造函数中真的调用了其派生类中实现的函数,其中很可能会涉及派生类的属性,但此时派生类的属性还没有经过初始化,这显然会出大错。进一步说,这和虚函数其实也没什么关系,本质上是,在基类的构造函数中,派生类的属性和方法是不可见的,在构造函数调用时,这个对象的运行时状态就是Transcation,而不是BuyTransaction或者SellTransaction,这个状态会持续到基类的构造函数结束,直到开始执行派生类的构造函数。这个现象在析构函数中同样存在,唯一的区别是先调用派生类的析构函数,接着才是基类的。

上面给的这个例子其实很容易就找到错误,因为很少会在基类中给出纯虚函数的定义,缺少定义会直接导致链接阶段的报错,但在下面这个场景,虽然发生了类似的错误却不会报错,这样的错误会更加难以发现:

class Transaction {
public:
    Transaction()
    { init(); } // call to non-virtual...
    virtual void logTransaction() const = 0;
private:
    void init()
    {
        logTransaction(); // ...that calls a virtual!
    }
};

这个没有定义的纯虚函数没有直接在构造函数中被调用,而是由init()间接调用了,导致编译器无法在链接阶段找到这个错误。但实际上执行阶段的结果是一样的,由于派生类在构造函数中不可见,会去尝试调用基类的这个纯虚函数,并抛异常:

pure virtual method called
terminate called without an active exception

Process finished with exit code 6

如果真的需要对于不同的派生类在构造函数中就进行不同的行为,比如对于不同的订单打印不同的日志,比较好的方式是把差异体现在派生类的某个私有static方法里,并且直接通过构造函数的参数传递,static保证了不会有派生类对象未初始化的问题。由于差异在static方法里体现了,也就不需要virtual关键字来进行方法重载了:

class Transaction {
public:
    explicit Transaction(const std::string& logInfo);
    void logTransaction(const std::string& logInfo) const; // now a non-virtual func
    ...
};
Transaction::Transaction(const std::string& logInfo)
{
    ...
    logTransaction(logInfo); // now a non-virtual call
}
class BuyTransaction: public Transaction {
public:
    BuyTransaction( parameters )
    : Transaction(createLogString( parameters )) // pass log info to base class constructor
    { ... } 
    ... 
private:
    static std::string createLogString( parameters );
};

Item 10: Have assignment operators return a reference to *this.

重载赋值运算时,记得返回*this。除了=,还包括+=-=*=之类的运算符。

这样的好处主要是支持了链式赋值,也就是类似于下面的形式:

int x, y, z;
x = y = z = 15;

所有内置的数据类型和STL都遵守了这个原则,所以最好在自己写代码时也遵守它。

Item 11: Handle assignment to self in operator=.

在实现拷贝赋值函数的时候需要注意一些特殊情况,特别是当拷贝赋值自身的时候,比如:

class Widget { ... };
Widget w;
w = w;

除了这种浅显的情况,还有一些不容易发现的情况会导致拷贝赋值自身:

a[i] = a[j]; // 当 i == j
*px = *py; // 当 px, py 指向同一个地址

甚至是两个类型不同的对象,比如一个基类和一个派生类,因为基类指针是可以指向派生类的:

class Base { ... };
class Derived: public Base { ... };
void doSomething(const Base& rb, Derived* pd); // rb and *pd might actually be the same object

拷贝赋值自身的安全问题

假设有一个类Widget,存储了一个指向Bitmap的指针:

class Bitmap { ... };
class Widget {
    ...
    private:
    Bitmap *pb; // ptr to a heap-allocated object
};

在拷贝赋值函数中需要同时拷贝Bitmap,注意下面这种写法看似合理,但当拷贝赋值自身时会抛异常:

Widget& Widget::operator=(const Widget& rhs) // unsafe impl. of operator=
{
    delete pb; // stop using current bitmap
    pb = new Bitmap(*rhs.pb); // start using a copy of rhs’s bitmap
    return *this; // see Item 10
}

因为第一步就是delete pb,由于这里的pbrhs.pb相同,此时rhs.pb指向了一个已经被删除的内存地址,访问这个被删除的内存地址是非常危险的行为。

一个简单的解决方法是,在最开始进行一次相同检测if (this == &rhs) return *this;,如果发现对自身在进行拷贝赋值,就直接返回。这个方法是有效的,但是另一个问题是,new Bitmap(*rhs.pb)可能会因为各种原因抛异常,比如内存不足,或者Bitmap的构造函数抛异常,这些异常是难以控制的。一旦这一步抛异常,这个Widget示例的pb同样指向了一个被删除的内存地址,这个情况同样极其糟糕。

异常安全问题

可以这样修改来解决异常安全问题:

Widget& Widget::operator=(const Widget& rhs)
{
    Bitmap *pOrig = pb; // remember original pb
    pb = new Bitmap(*rhs.pb); // point pb to a copy of rhs’s bitmap
    delete pOrig; // delete the original pb
    return *this;
}

相当于调换了newdelete的顺序,只有确保了一个新的Bitmap已经被正确生成了,才会把原来的pb指向的空间删除。即使在new Bitmap时报错,此时抛异常弹出,pb至少还是指向了原来的地址空间。

可以发现,在解决异常安全问题的同时,这种实现也同时解决了拷贝复制自身的安全问题。当然此时效率不是最佳的,因为额外进行了一次内存申请和释放,并调用了一次Bitmap的构造函数。如果需要追求效率,可以试着像之前那样进行一次相同检测,但深入一步讲,这里其实存在一个 trade-off,因为相同检测也有代价,会导致源代码和目标代码略微增大,也会导致控制流的分叉,这些都可能导致运行时的效率下降,比如导致指令预取成功率降低,缓存命中率降低等等,需要结合拷贝赋值自身这种情况发生的频率来衡量,涉及到的性能优化问题不在此展开。

用 “copy and swap” 的方式实现拷贝赋值函数

copy and swap 的实现方式是一个同时保证了“拷贝复制自身”和“异常安全”两个问题的方案,这在实际场景中很常见:

class Widget {
    ...
    void swap(Widget& rhs); // exchange *this’s and rhs’s data; ... // see Item 29 for details
};
Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs); // make a copy of rhs’s data
    swap(temp); // swap *this’s data with the copy’s
    return *this;
}

下面是一个变种写法,利用到了函数传递的拷贝构造函数,但实质上没有区别:

Widget& Widget::operator=(Widget rhs) // rhs is a copy of the object passed in — note pass by val
{
    swap(rhs); // swap *this’s data with the copy’s
    return *this;
}

相对来说第一种写法看上去更清晰,但是第二种写法更容易利用编译器进行优化,总体而言差别不大。

Item 12: Copy all parts of an object.

拷贝时需要注意拷贝到了所有部分,这里包括了两种拷贝函数:拷贝构造函数和拷贝赋值函数。

拷贝函数要包含所有部分

其中 “all parts” 主要有两个部分需要注意:

  • 拷贝时,初始化所有成员

    当你自己实现拷贝构造/赋值函数时,必须手动初始化/赋值每一个成员。比较容易出这个错的场景是,在你写完一个类的拷贝函数之后再往这个类里加了一个成员,这时必须对拷贝函数作相应的修改,否则这个成员就不会被初始化。

    这个错误的另一个隐蔽性在于,编译器甚至不会对此报 warning,因为它认为你既然自己实现了拷贝函数,就应当清楚地知道在拷贝函数内发生了什么。

  • 派生类需要调用所有基类的拷贝函数

当你自己实现派生类的拷贝函数时,应当手动调用基类的拷贝函数来为基类成员初始化,形式如下:

class PriorityCustomer: public Customer { // a derived class
public:
    ...
    PriorityCustomer(const PriorityCustomer& rhs);
    PriorityCustomer& operator=(const PriorityCustomer& rhs);
    ...
private:
    int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), // invoke base class copy ctor
priority(rhs.priority)
{
    logCall("PriorityCustomer copy constructor");
}
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
    logCall("PriorityCustomer copy assignment operator");
    Customer::operator=(rhs); // assign base class parts
    priority = rhs.priority;
    return *this;
}

由于基类的一些成员可能是私有的,所以只能通过基类的构造函数去访问并初始化。如果没有手动调用基类的构造函数,则会自动调用无参数的默认拷贝函数。(如果没有定义默认拷贝函数则会在编译阶段报错)

如果没有自己实现拷贝函数,编译器自动实现的拷贝函数则会调用以拷贝对象引用为参数的拷贝构造函数,这时的原则基于之前提到的,先调用基类构造函数再调用派生类构造函数,对比三种实现方式如下:

首先定义基类Widget

class Widget {
public:
    Widget() {
        std::cout << "Widget default constructor." << std::endl;
    }
    Widget(const Widget &rhs) {
        std::cout << "Widget copy constructor." << std::endl;
    }
};

使用编译器实现的拷贝构造函数:

class DerivedWidget : public Widget {
public:
    DerivedWidget() = default;
//  DerivedWidget(const DerivedWidget &rhs) {};
    int c;
};

DerivedWidget dw;
DerivedWidget dw1(dw);
----------------output--------------------
Widget default constructor. // DerivedWidget dw;
Widget copy constructor. // DerivedWidget dw1(dw);

使用自己定义,但不调用基类构造函数的拷贝构造函数:

class DerivedWidget : public Widget {
public:
    DerivedWidget() = default;
    DerivedWidget(const DerivedWidget &rhs) {};
    int c;
};

DerivedWidget dw;
DerivedWidget dw1(dw);
----------------output--------------------
Widget default constructor. // DerivedWidget dw;
Widget default constructor. // DerivedWidget dw1(dw);

使用自己定义,且调用基类构造函数的拷贝构造函数:

class DerivedWidget : public Widget {
 public:
  DerivedWidget() = default;
  DerivedWidget(const DerivedWidget &rhs): Widget(rhs) {};
  int c;
};

DerivedWidget dw;
DerivedWidget dw1(dw);
----------------output--------------------
Widget default constructor. // DerivedWidget dw;
Widget copy constructor. // DerivedWidget dw1(dw);

减少两种拷贝函数的代码冗余

拷贝构造函数和拷贝赋值函数,两个函数有时候功能是类似的,那么就会有代码冗余的问题。

很容易想到,能不能让其中一个函数调用另一个,来起到减少代码冗余的效果呢?作者给出的结论是不行,因为拷贝构造函数是从无到有,需要产生一个新的实例,而拷贝赋值函数则不需要产生新的实例,所有操作都发生在两个已经存在的实例上。

但是类似于 Item 11 中提到的 “copy and swap” 方法来实现拷贝赋值函数,相当于先调用拷贝构造函数初始化一个局部实例,利用这个局部实例作修改,然后再利用作用域,在返回时将这个局部实例释放掉。这是否可以也算作通过调用拷贝构造函数来实现拷贝赋值函数呢?可能是看待代码冗余的角度不同。

总之作者始终认为,两种拷贝函数不应该互相调用,而是应该用一个private方法,把相同的操作放进这个private方法里,并在两种拷贝函数中都调用这个private方法。