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

编译器会为一个类声明(编译器版本的)一个 copy构造函数、一个 copy assignment操作符、一个 default构造函数和一个析构函数。

  • 所有这些函数都是 public 且 inline (见条款 30) 。

  • 惟有当这些函数被调用,它们才会被编译器创建出来。

构造函数/析构函数

  • 若你已经声明了一个构造函数,编译器不再为它创建 default 构造函数。

  • 编译器产出的析构函数是个 non-virtual (见条款 7) ,除非这个类的基类自身声明有 virtual 析构函数。

拷贝构造函数/拷贝赋值操作符

  • 对于 copy构造函数和 copy assignment操作符,编译器创建的版本只是单纯地将来源对象的每一个 non-static 成员变量拷贝到目标对象。
  • 只有当生出的代码合法且有意义,编译器才会构造 copy assignment 操作符。
    • 如果打算在一个内含 reference 成员的 class 内支持赋值操作 (assignment) ,必须自己定义 copy assignment操作符。C++不允许让 reference 改指向不同对象。
    • 面对内含 const成员的 classes, 编译器的反应也一样。
    • 如果某个基类将 copy assignment 操作符声明为 private, 编译器将拒绝为其派生类生成一个 copy assignment 操作符。
1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T> class NamedObject{ 
public:
NamedObject(std::string& name, const T& value); //构造函数,
//编译器不会创建 default 构造函数
private:
std::string& nameValue; //reference
const T objectValue; //const
};

std::string newDog("Persephone");
NamedObject<int> p(newDog, 2);
NamedObject<int> s(p); //正确,拷贝构造函数
p = s ; //错误,编译器不会创建 operator=
  • 编译器可以暗自为 class 创建 default 构造函数、 copy构造函数、 copy assignment 操作符,以及析构函数。

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

通常如果你不希望 class 支待某一特定机能,只要不声明对应函数就是了。但这个策略对 copy 构造函数和 copy assignment 操作符却不起作用,如果你不声明它们,而某些 人尝试调用它们,编译器会为你声明它们。

将相应的成员函数声明为 private 并且不予实现

所有编译器产出的函数都是 public。为阻止这些函数被创建出来,可以自行声明它们,令这些函数为 private, 以阻止人们调用它。

这个做法并不绝对安全,因为 member 函数和 friend 函数可以调用 private 函数。如果在这些函数中不慎调用,会获得一个连接错误 (linkage error) 。

使用 Uncopyable 的基类

1
2
3
4
5
6
7
8
9
10
11
12
13
class Uncopyable {
protected:
Uncopyable(){} //允许derived对象构造和析构
~Uncopyable(){}
private:
Uncopyable(const Uncopyable&); //但阻止copying
Uncopyable& operator=(const Uncopyable&);
};

//为阻止 HomeForSale 对象被拷贝,需要做的就是继承 Uncopyable
class HomeForSale: private Uncopyable {
...
};
  • 为驳回编译器自动提供的机能,可将相应的成员函数声明为 private 并且不予实现。使用像 Uncopyable 这样的基类也是一种做法。

回忆: virtual 关键字

虚函数的作用,是实现多态性;用形象的语言来解释就是实现以共同的方法,但因个体差异,而采用不同的策略。

一般会为多态基类中的函数定义为虚函数,在派生类中定义与基类函数具有相同的签名和返回类型的函数。对一个指向派生类的基类引用或指针,在调用虚函数时,它将被解析为派生程度最高的版本。 这种能力被称为多态性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base {
public:
virtual std::string_view getName() const { return "Base"; } // note addition of virtual keyword
};

class Derived: public Base {
public:
virtual std::string_view getName() const { return "Derived"; }
};

int main()
{
Derived derived;
Base& rBase{ derived };
std::cout << "rBase is a " << rBase.getName() << '\n';
return 0;
}
//输出: rBase is a Derived

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

任何 class 只要带有 virtual 函数都几乎确定应该也有一个 virtual 析构函数。

若基类的析构函数是 non-virtual,当删除一个经由基类指针返回的派生类对象时,编译器通常只调用基类的析构函数,对象的派生成分未被销毁。但其基类成分通常会被销 毁,造成一个诡异的局部销毁对象。这是形成资源泄漏、败坏之数据结构、在调试器上浪费许多时间的绝佳途径。

消除这个问题的做法很简单: 给基类一个 virtual 析构函数。

如果一个类不含 virtual 函数,通常表示它并不被意图用做一个基类。这是不应该令其析构函数为virtual。

为了实现出 virtual 函数,对象必须携带某些信息,主要用来在运行期决定哪一个 virtual 函数该被调用。这些virtual函数会使对象体积增加。同时也可能使这个类不具有移植性。

即使 class 完全不带 virtual 函数,由于 non-virtual 析构函数出现还是有可能的。举个例子,标准 string 不含任何 virtual 函数,但有时候程序员会错误地把它当做基类,此时用 string 指针删除一个派生类将导致问题

1
2
3
4
5
6
7
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 析构函数的类, 包括所有 STL 容器如 vector, list, set等等。

有时候令类带一个 pure virtual 析构函数,可能颇为便利。一个类中若存在pure virtual 函数,则称它为抽象类,也就是不能被实体化的类。 然而有时候希望一个类是抽象类, 但没有合适的pure virtual函数,可以为它声明一个 pure virtual 析构函数。必须为这个 pure virtual 析构函数提供一份定义:

1
2
3
4
class AWOV {           //AWOV= "Abstractw/o Virtuals" public:
virtual ~AWOV() = 0; //声明purevirtual析构函数
};
AWOV::~AWOV(){ } //pure virtual 析构函数的定义

析构函数的运作方式是,最深层派生的类的析构函数最先被调用,然后是其每一个基类的析构函数被调用。编译器会在 AWOV 的派生类的析构函数中创建一个对~AWOV 的调用动作,所以你必须为这个函数提供一份定义。

  • polymorphic (带多态性质的) 基类应该声明一个 virtual 析构函数。如果一个类带有任何 virtual 函数,它就应该拥有一个 virtual 析构函数。
  • 如果类的设计目的如果不是作为基类使用,或不是为了具备多态性,就不该声明 virtual 析构函数。

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

若析构函数会抛出异常,抛出异常后后续程序将跳过,然而,定义的类仍应该被析构,此时可能会继续抛出异常。C++没有定义多个异常同时存在的情况。在两个异常同时存在的情况下,程序若不是结束执行就是导致不明确行为。

但如果你的析构函数必须执行一个动作,而该动作可能会在失败时抛出异常,该怎么办?

最直接的方法是在析构函数中捕捉异常,直接结束程序或吞下它们。

  • 如果抛出异常就结束程序。通常通过调用 abort完成:

    1
    2
    3
    4
    5
    6
    7
    8
    DBConn::~DBConn() 
    {
    try { db.close(); }
    catch (...) {
    //制作运转记录,记下对 close 的调用失败;
    std::abort() ;
    }
    }
  • 吞下发生的异常:

    1
    2
    3
    4
    5
    6
    7
    DBConn::~DBConn() 
    {
    try { db.close(); }
    catch (...) {
    //制作运转记录,记下对 close 的调用失败;
    }
    }

这些办法都没什么吸引力。问题在千两者都无法对抛出异常的情况做出反应。

一个较佳策略是重新设计接口,使其客户有机会对可能出现的问题作出反应。

例如 DBConn 自己可以提供一个 close 函数,因而赋予客户一个机会得以处理 “因该操作而发生的异常”。 DBConn 可以追踪所管理的 DBConnection 是否已被关闭,并在答案为否的情况下由其析构函数关闭。如果 DBConnection 析构函数调用 close 失败,我们又将退回“强迫结束程序” 或“吞下异常”的老路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class DBConn { 
public:
void close() //供客户使用的新函数
{
db.close();
closed= true;
}
~DBConn()
{
if (!closed){
try {
db.close();
}
catch (...) { //如果关闭动作失败,
//记录下来并结束程序或吞下异常。
}
}
}
private:
DBConnection db;
bool closed;
};

由客户自己调用 close 并不会对他们带来负担,而是给他们一个处理错误的机会,否则他们没机会响应。如果他们不认为这个机会有用,可以忽略它,倚赖 DBConn析构函数去调用 close。如果真有错误发生而且 DBConn 吞下该异常或结束程序,客户没有立场抱怨。

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

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

当创建一个派生类,它的构造函数被调用,但首先会调用基类的构造函数。派生类对象内的基类成分会在 派生类自身成分被构造之前先构造妥当。若基类的构造函数调用 virtual 函数,此时这个函数是基类中的版本,即使创建的是派生类对象。也就是说,基类构造期间 virtual 函数不会下降到派生类,对象的行为就像隶属于基类一样。非正式的说: 在基类构造期间, virtual 函数不是 virtual 函数。

这一行为的理由是,当基类构造函数执行时 派生类 的成员变量尚未初始化。如果此期间调用的 virtual 函数下降至派生类阶层,派生类的成员变量尚未初始化。这将要求使用对象内部尚未初始化的成分。

相同道理也适用于析构函数。一旦派生类析构函数开始执行,对象内的派生类员变量便呈现未定义值,进入基类析构函数后对象就成为一个基类对象。

确定构造函数和析构函数都没有调用 virtual 函数, 它们调用的所有函数也服从这一约束。

由于无法使用 virtual 函数从基类向下调用,在构造期间可以令派生类将必要的构造信息向上传递至基类构造函数加以弥补。

  • 在构造和析构期间不要调用 virtual 函数,因为这类调用从不下降至派生类 (比起当前执行构造函数和析构函数的那层)。

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

赋值可以写成连锁形式,赋值采用右结合律。

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

为了实现连锁赋值,赋值操作符必须返回一个 reference 指向操作符的左侧实参。这是一个类 实现赋值操作符时应该遵循的协议:

1
2
3
4
5
6
7
8
9
10
class Widget { 
public:
...
Widget& operator=(const Widget& rhs) //返回类型是个 referenc, 指向当前对象
{
...
return* this; //返回 this 的引用
}
...
};

这个协议不仅适用于以上的标准赋值形式,也适用于所有赋值相关运算,如 += , -= , *=

这是个协议,并无强制性。如果不遵循它,代码一样可通过编译。这份协议被所有内置类型和标准程序库提供的类型如 string, vector, complex 等共同遵守。

  • 令赋值 (assignment) 操作符返回一个 reference to *this

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

“自我赋值” 发生在对象被赋值给自己时:

1
2
3
4
5
6
7
class Widget {... }; 
Widget w;
...
w = w; //赋值给自己
//潜在的自我赋值
a[i] =a[j];
*px = *py;

但如果尝试自行管理资源可能会在停止使用资源之前意外释放了它。但出现自我赋值时,operator= 函数内的 *this 和 rhs 是同一个对象。 delete 销毁 rhs 的 bitmap。在函数未尾, Widget 持有一个指针指向一个已被删除的对象。

1
2
3
4
5
6
Widget& Widget::operator= (const Widget& rhs) //不安全的 operator= 实现版本
{
delete pb; //停止使用当前的 bitmap,
pb = new Bitmap(*rhs.pb); //使用 rhs's bitmap 的副本。
return *this;
}

传统做法是藉由 operator=最前面的一个证同测试 (identity test) 达到自我赋值的检验目的:

1
2
3
4
5
6
7
Widget& Widget::operator= (const Widget& rhs)
{
if (this == &rhs) return *this; //证同测试: 如果是自我赋值,就不做任何事。
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

但这么写仍不具备“异常安全性” (条款 29)。具体地说,如果 new Bitmap() 导致异常, Widget 最终会持有一个指针指向一块被删除的 Bitmap。 让 operator= 具备 “异常安全性”往往自动获得自我赋值安全。**许多时候精心安排语句的顺序就可以保证异常安全(以及自我赋值安全)**。例如以下代码,我们只需注意在复制 pb 所指东西之前别删除 pb:

1
2
3
4
5
6
7
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 技术 (条款 29)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget {
...
void swap(Widget& rhs); //交换*this 和 rhs 的数据; 详见条款 29
};
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs); //为 rhs 数据制作一份副本
swap(temp) ; //将 *this 数据和上述副本的数据交换。
return *this;
}

//一个类的copy赋值操作符可能被声明为以 by value方式接受实参
//以 by value方式传递东西会创建一份副本(见条款20)
Widget& Widget::operator=(Widget rhs) //rhs 是被传对象的副本
{ //注意这里是 pass by value.
swap(rhs); //将 *this 的数据和副本的数据互换
return *this;
}
  • 确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较 ”来源对象” 和 “目标对象” 的地址、精心周到的语句顺序、以及 copy-and-swap。
  • 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。

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

如果复制对象时只进行了局部拷贝(有一些对象没有复制),大多数编译器对此不会发出警告——即使在最高警告级别中(见条款 53)。

一旦发生继承,可能会造成更严重的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PriorityCustomer: public Customer { //一个派生类
public:
PriorityCustomer(const PriorityCus七omer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs) ;
private:
int priority;
};
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), priority(rhs.priority) //调用基类的 copy 构造函数
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustorner copy assignment operator");
Customer::operator=(rhs); //对基类成分进行赋值动作
priority= rhs.priority;
return *this;
}

当编写一个 copying 函数,请确保 (1) 复制所有 local 成员变量,(2) 调用所有基类内的适当的 copying 函数。

拷贝构造函数和拷贝赋值函数往往有近似相同的实现本体,这可能会诱使你让某个函数调用另一个函数以避免代码重复。但是,令某个 copying 函数调用另一个 copying 函数无法让你达到你想要的目标。

如果你发现你的拷贝构造函数和拷贝赋值操作符有相近的代码,消除重复代码的做法是建立一个新的成员函数给两者调用。这样的函数往往是 private 而且常被命名为 init。

  • Copying函数应该确保复制对象内的所有成员变量及所有 baseclass成分。
  • 不要尝试以某个 copying 函数实现另一个 copying 函数。应该将共同机能放进第三个函数中,并由两个 coping 函数共同调用。

读书笔记,来自 《Effective C++》第 3 版