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函数共同调用;
[======]