文章目录
2. 构造、析构、赋值运算
条款05:了解C++默默编写并调用哪些函数
如果你自己没有声明,编译器会为他声明一个copy构造函数、一个copy assignment操作符和一个析构函数。
如果你没有声明任何构造函数,编译器也会声明一个default构造函数。
class Empyt{};
等价于
class Empty{
public:
Empty(){...} //defalut构造函数
Empty(const Empty& rhs){...} //copy构造函数
~Empty(){...} //析构函数
Empty& operator=(const Empty& rhs){...} //copy assignment操作符
};
唯有当这些函数被调用,它们才会被编译器创建出来。
Empty e1; // 默认构造函数 & 析构函数
Empty e2(e1); // 拷贝构造函数
e2 = e1; // 拷贝赋值运算符
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
原书中使用的做法是将不想使用的函数声明为private,但在 C++11 后我们有了更好的做法
class Uncopyable {
public:
Uncopyable(const Uncopyable&) = delete;
Uncopyable& operator=(const Uncopyable&) = delete;
};
条款07:为多态基类声明virtual析构函数
当派生类对象经由一个基类指针被删除,而该基类指针带着一个非虚析构函数,其结果是未定义的,可能会无法完全销毁派生类的成员,造成内存泄漏。消除这个问题的方法就是对基类使用虚析构函数:
class Base {
public:
Base();
virtual ~Base();
};
如果你不想让一个类成为基类,那么在类中声明虚函数是是一个坏主意,因为额外存储的虚表指针会使类的体积变大。
只要基类的析构函数是虚函数,那么派生类的析构函数不论是否用virtual关键字声明,都自动成为虚析构函数。
虚析构函数的运作方式是,最深层派生的那个类的析构函数最先被调用,然后是其上的基类的析构函数被依次调用。
欲实现出virtual函数,对象必须携带某些信息,主要用来在运行期决定哪一个virtual函数该被调用。这份信息通常是由一个所谓vptr(virtual table pointer)指针支出。vptr指向一个由函数指针构成的数组,称为vtbl(virtual table);每一个带有virtual函数的class都有一个相应的vtbl。当对象调用某一virtual函数,实际被调用的函数取决于该对象的vptr所指的那个vtbl——编译器在其中寻找适当的函数指针。
如果你想将基类作为抽象类使用——也就是不能被实体化的类,但手头上又没有别的虚函数,那么将它的析构函数设为纯虚函数是一个不错的想法。
class Base {
public:
virtual ~Base() = 0 {}
};
- 带有多态性质的base classes应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数
- 类的设计目的如果不是作为base classes使用,或不是为了具备多态性,就不该声明virtual析构函数
条款08:别让异常逃离析构函数
在析构函数中吐出异常并不被禁止,但为了程序的可靠性,应当极力避免这种行为。
为了实现 RAII,我们通常会将对象的销毁方法封装在析构函数中,如下例子:
class DBConn {
public:
...
~DBConn() {
db.close(); // 该函数可能会抛出异常
}
private:
DBConnection db;
};
但这样我们就需要在析构函数中完成对异常的处理,以下是几种常见的做法:
- 杀死程序
DBConn::~DBConn() {
try { db.close(); }
catch (...) {
// 记录运行日志,以便调试
std::abort();
}
}
- 吞下因调用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;
};
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下他们或结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数执行该从左。
条款09:绝不在构造和析构过程中调用virtual函数
在创建派生类对象时,基类的构造函数永远会早于派生类的构造函数被调用,而基类的析构函数永远会晚于派生类的析构函数被调用。
在派生类对象的基类构造和析构期间,对象的类型是基类而非派生类,因此此时调用虚函数会被编译器解析至基类的虚函数版本,通常不会得到我们想要的结果。
间接调用虚函数是一个比较难以发现的危险行为,需要尽量避免:
class Transaction {
public:
Transaction() { Init(); }
virtual void LogTransaction() const = 0;
private:
void Init(){
...
LogTransaction(); // 此处间接调用了虚函数!
}
};
如果想要基类在构造时就得知派生类的构造信息,推荐的做法是在派生类的构造函数中将必要的信息向上传递给基类的构造函数:
class Transaction {
public:
explicit Transaction(const std::string& logInfo);
void LogTransaction(const std::string& logInfo) const;
...
};
Transaction::Transaction(const std::string& logInfo) {
LogTransaction(logInfo); // 更改为了非虚函数调用
}
class BuyTransaction : public Transaction {
public:
BuyTransaction(...)
: Transaction(CreateLogString(...)) { ... } // 将信息传递给基类构造函数
...
private:
static std::string CreateLogString(...);
}
- 在构造和析构期间不要调用virtual函数,因为这类调用从不下降至derived class 。
条款10:令 operator= 返回一个指向 *this 的引用
class Widget {
public:
Widget& operator+=(const Widget& rhs) { // 这个条款适用于
... // +=, -=, *= 等等运算符
return *this;
}
Widget& operator=(int rhs) { // 即使参数类型不是 Widget& 也适用
...
return *this;
}
};
条款11:在operator=中处理“自我赋值”
“自我赋值”发成在对象赋值给自己时
class Widget{};
Widget w;
w = w; //赋值给自己
a[i] = a[j]; //可能发生自我赋值
*px = *py; //可能发生自我赋值
一些情况下可能会导致意外的错误,例如在复制堆上的资源时:
//若rhs和*this指向的是相同的对象,就会导致访问到已删除的数据。
Widget& operator=(const Widget& rhs){
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
最简单的解决方法是在执行后续语句前先进行证同测试(Identity test):
Widget& operator=(const Widget& rhs){
if(this == rhs) return *this; //证同测试
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
适当安排语句的顺序,就可以做到使整个过程具有异常安全性。例如以下代码,只需要注意在复制pb所指东西之前别删除pb:
Widget& operator=(const Widget& rhs){
Bitmap* pOrig = pb;
pb = new Bitmap(*rhs.pb);
delete pOrig;
return *this;
}
还有一种取巧的做法是使用 copy and swap 技术,这种技术聪明地利用了栈空间会自动释放的特性,这样就可以通过析构函数来实现资源的释放:
Widget& operator=(const Widget& rhs) {
Widget temp(rhs);
std::swap(*this, temp);
return *this;
}
- 确保当前对象自我赋值时operator=有良好的行为。其中技术包括比较“来源对象”和“目标对象”的地址、进行周到的语句顺序、以及copy-and-swap
- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
条款12:复制对象时勿忘其每一个成分
这个条款正如其字面意思,当你决定手动实现拷贝构造函数或拷贝赋值运算符时,忘记复制任何一个成员都可能会导致意外的错误。
当使用继承时,继承自基类的成员往往容易忘记在派生类中完成复制,如果你的基类拥有拷贝构造函数和拷贝赋值运算符,应该记得调用它们:
class PriorityCustomer : public Customer {
public:
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
...
private:
int priority;
}
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), // 调用基类的拷贝构造函数
priority(rhs.priority) {
...
}
PriorityCustomer::PriorityCustomer& operator=(const PriorityCustomer& rhs) {
Customer::operator=(rhs); // 调用基类的拷贝赋值运算符
priority = rhs.priority;
return *this;
}
不要尝试在拷贝构造函数中调用拷贝赋值运算符,或在拷贝赋值运算符的实现中调用拷贝构造函数。
拷贝构造函数调用拷贝赋值运算符:对正在构造中的对象执行赋值意味着对尚未初始化的对象 执行某些只对初始化的对象有意义的操作。
拷贝赋值运算符调用拷贝构造函数:将试图构造一个已经存在的对象。
- 拷贝构造函数应该确保复制对象内的所有成员变量及所有base class成分
- 不要尝试以某个拷贝函数实现另一个。应该将共同机能放进第三个函数中,并由两个拷贝函数共同调用。