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 () = ; // 声明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 = ; //做出一份因类型不同而不同的日志
......
}; 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 = ; // 赋值连锁形式
// 赋值采用右结合律,所以上述连锁赋值被解析为:
x = (y = (z = ));

这里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函数共同调用。

上一篇:JavaScript学习笔记-正则表达式(语法篇)


下一篇:通过Javascript得到URL中的参数(query string)