【C++】《Effective C++》第二章
第二章 构造/析构/赋值运算
条款05:了解C++
默默编写并调用哪些函数
默认函数
一般情况下,编译器会为类默认合成以下函数:default构造函数、copy构造函数、non-virtual析构函数、拷贝赋值(copy assignment)操作符。
class Empty {};
// 等价于
class Empty {
public:
Empty() { } // default构造函数
Empty(const Empty& rhs) { } // copy构造函数
~Empty() { } // non-virtual析构函数,是否该为virtual呢?
Empty& operator=(const Empty& rhs) { } // copy assignment操作符
};
Empty e1; // default构造函数
Empty e2(e1); // copy构造函数
e2 = e1; // copy assignment操作符
- copy构造函数:默认版本会单纯地将来源对象的每一个
non-static
成员变量拷贝到目标对象。
template<class T>
class NamedObject {
public:
NamedObject(const char* name, const T& value);
NamedObject(const std::string& name, const T& value); // 自定义了构造函数,编译器不会生成default构造函数,reference-to-const
private:
std::string nameValue; // non-reference
T objectValue; // non-const
};
NamedObject<int> no1("Smallest Prime Number", 2);
NamedObject<int> no2(no1); // 调用默认copy构造函数,其中no2.nameValue的初始化是调用string的copy构造函数并以no1.nameValue为实参;no2.objectValue会以"拷贝no1.objectValue内的每一个bits"来完成初始化;
- copy assignment操作符:行为同
copy构造函数
类似,但是以下情况编译器不会默认合成copy assignment操作符
。- 含有引用成员:原因在于这种情况下,赋值的目的不明确。是修改引用还是修改引用的对象?如果是修改引用,显然是C++不允许的。
- 含有const成员:C++规定const成员不应该被修改。
- 父类的copy assignment操作符被声明为private:无法处理基类对象,因此无法合成。
template<class T>
class NamedObject {
public:
NamedObject(std::string& name, const T& value); // reference-to-non-const
private:
std::string& nameValue; // reference
const T objectValue; //const
};
std::string newA("A");
std::string newB("B");
NamedObject<int> a(newA, 2);
NamedObject<int> b(newB, 20);
a = b; // error
请记住
- 编译器默认为
class
创建default构造函数、copy构造函数、non-virtual析构函数、拷贝赋值(copy assignment)操作符。
条款06:若不想使用编译器自动生成的函数,就该明确拒绝
有的情况下,例如iostream类对象,对象是独一无二的,所以应该拒绝对象拷贝动作。
一般情况下,不声明相应函数就可以拒绝,但是我们知道,编译器会为类默认合成一些函数,因此需要显式拒绝。
通常有两个方法可以实现这个需求:
- 将默认合成函数声明为
private
,并且不定义。
class HomeForSale {
public:
// ...
private:
// ...
HomeForSale(const HomeForSale&);
HomeForSale& operator=(const HomeForSale&);
};
显然,上述的实现member
函数和friend
函数还是可以调用,这就容器发生链接错误。可以将默认合成函数放在一个基类中,它继承这个基类,这样的话,这些函数的"编译器合成版"会尝试调用其基类的对应兄弟,但是这些调用会被编译器拒绝,因为是private
。
class UnCopyable {
protected:
UnCopyable() {}
~UnCopyable() {}
private:
UnCopyable(const UnCopyable&);
UnCopyable& operator=(const UnCopyable&);
};
class HomeForSale: private UnCopyable {
// ...
};
C++11
中可使用delete
,但是析构函数不能是删除的成员。
class HomeForSale {
public:
// ...
HomeForSale() = default;
HomeForSale(const HomeForSale&) = delete;
HomeForSale& operator=(const HomeForSale&) = delete;
~HomeForSale() = default;
};
请记住
- 当不可能拷贝、赋值或销毁类的成员时,类的相应函数就应该被定义为删除的。可以通过将默认合成函数声明为
private
,并且不定义,或者使用C++11
标准实现。
条款07:为多态基类声明virtual
析构函数
多态基类应该含有virtual
函数
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
// ...
};
class AtomicClock: public TimeKeeper { } // 原子钟
class WaterClock: public TimeKeeper { } // 水钟
class WristWatch: public TimeKeeper { } // 腕表
TimeKeeper* ptk = getTimeKeeper(); // 从TimeKeeper继承体系获得动态分配对象
// ...
delete ptk; // 释放它,避免资源泄漏
以上的继承和使用,存在一个问题,当derived class
对象经由一个base class
指针被删除,而该base class
带着一个non-virtual
析构函数,其结果未有定义。即实际执行时通常发生的是base case
成分被销毁,derived class
成分没被销毁,这就是局部销毁,会造成资源泄漏。
解决方法就是给base class一个virtual析构函数:
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
// ...
};
不为基类不应该含有virtual
函数
class Point {
public:
Point(int xC, int yC);
~Point();
private:
int x, y;
};
分析上面的类,如果int占用32bits
,那么Point对象总共占64bits空间
。
如果实现virtual函数
,对象必须携带某些信息,它们主要用来在运行期决定哪一个virtual函数
该被调用。这份信息通常是由一个所谓的vptr(virtual table pointer)
指针指出,vptr
指向一个由函数指针构成的数组,称为vtbl(virtual table)
,每一个带有virtual
函数的class
都有一个相应的vtbl
,显然这会增加内存开销,可能会使得类无法被C函数使用(因为它没有vptr
),从而不再具有移植性。
所以,不能无端的将class
的析构函数声明为virtual
,只有当class
内含至少一个virtual函数
,才为它声明virtual析构函数
。
一个常识是,包括所有STL容器如vector、list等,它们都是不带virtual析构函数的class。
abstract classes
我们知道,pure virtual函数会导致abstract classes(抽象类)不能被实例化,但是它显然只使用于带有多态性质的基类。
class AWOV {
public:
virtual ~AWOA() = 0;
};
请记住
- 带多态性质的基类(
polymorphic base classes
)应该声明一个virtual析构函数
。如果class
带有任何virtual函数
,它就应该拥有一个virtual析构函数
。 - 如果
Classes
的设计目的不是作为base classes
使用,或不是为了具备多态性(polymorphically
),就不应该声明virtual析构函数
。
条款08:别让异常逃离析构函数
如果析构函数吐出异常,程序可能过早结束,比如某个函数调用发生异常,在回溯寻找catch
过程中,每离开一个函数,这个函数内的局部对象会被析构,如果此时析构函数又抛出异常,前一个异常还没处理完又来一个,编译器这时候可能就罢工了,因此一般会引起程序过早结束,如果异常从析构函数中传播出去,可能会导致不明确的行为。
解决这个问题,通常有两种方法:
- 在析构函数中
catch
异常,然后调用abort
终止程序,通过abort
抢先置"不明确行为"于死地。
DBConn::~DBConn() {
try {
db.close();
} catch (err) {
// 记录信息
std::abort();
}
}
- 在析构函数中
catch
异常,然后吞下这个异常,单着通常不是一个很好的方法。
DBConn::~DBConn() {
try {
db.close();
} catch (err) {
// 记录信息
}
}
- 最好的办法是重新设计接口,让客户能够在析构前主动调用可能引起异常的函数,然后析构函数中使用一个
bool变量
,根据用户是否主动调用来决定析构函数是否应该调用可能引起异常的函数,让客户拥有主动权。
void DBConn::close() {
db.close();
closed = true;
}
DBConn::~DBConn() {
if(!closed) {
try {
db.close();
} catch (err) {
// 记录信息
}
}
}
请记住
- 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕获应该捕获任何异常,然后吞下它们(不传播)或者直接结束程序。
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。
条款09:绝不在构造和析构过程中调用virtual
函数
如果希望在继承体系中根据类型在构建对象时表现出不同行为,可能会想到在基类的构造函数中调用一个虚函数。
假设有一个继承体系,用来模拟股市交易如买进、卖出的订单等,可能每当创建一个交易对象时,都需要添加一条日志记录。
class Transaction {
public:
Transaction () {
logTransaction();
}
virtual void logTransaction() const = 0; // 纯虚函数
};
class BuyTransaction: public Transaction {
public:
virtual void logTransaction() const; // 记录此类交易
};
class SellTransaction: public Transaction {
public:
virtual void logTransaction() const; // 记录此类交易
};
// 执行
BuyTransaction b;
以上的执行显然是有问题的。在子类构造期间,virtual函数绝不会下降到派生类。派生类对象的基类构造期间,对象的类型是基类而不是派生类,除此之外,若使用运行期类型信息(如dynamic_cast
和typeid
),也会把对象视为基类类型,这样做是合理的,因为根据构造函数执行的顺序,此时子类部分尚未初始化,如果调用对的是子类的虚函数,通常会访问子类的数据,这样会引发安全问题。同样的道理也使用与析构函数。
解决这个问题的办法是,将logTransaction函数
改为non-virtual
,然后要求derived class构造函数
传递必要信息给Transaction构造函数
。
class Transaction {
public:
explicit Transaction (const std::string&& logInfo) {
logTransaction(logInfo);
}
void logTransaction(const std::string& logInfo) const; // non-virtual函数
};
class BuyTransaction: public Transaction {
public:
BuyTransaction(parameters): Transaction(createLogString(parameters)) { } // 将log信息传递给base class构造函数
private:
static std::string createLogString(parameters);
};
请记住
- 在构造和析构期间不要调用
virtual函数
,因为这类调用从不下降至derived class
(比起当前执行构造函数和析构函数的那层)。
条款10:在operator=
返回一个reference to *this
为了实现连锁赋值,赋值操作符必须返回一个reference
指向操作符的左侧实参。
class Widget {
public:
Widget& operator=(const Widget& rhs) {
// ...
return* this;
}
Widget& operator+=(const Widget& rhs) { // 同样适用于+=,-=,*=等
// ...
return* this;
}
};
请记住
- 令赋值(
assignment
)操作符返回一个reference to *this
。
条款11:在operator=
中处理"自我赋值"
"自我赋值"发生在对象被赋值给自己时。
如果尝试自行管理资源(如果打算自己写一个用于资源管理的类就得这么做),可能会掉进“在停止使用资源之前意外释放了它”的陷阱。
// 保存一个指针指向一块动态分配的位图
class Bitmap { };
class Widget {
// ...
private:
Bitmap* pb; // 指针,指向一个从heap分配而得的对象
};
- 实现
operator=
操作:这种实现在自赋值时就会发生问题。
Widget& Widget::operator=(const Widget& rhs) {
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
};
- 解决方案1:进行“证同测试”,达到“自我赋值”的检验目的。这样做虽然能处理自赋值,但不是异常安全的,如果
new
发生异常,对象pb
将指向一块被删除的内存。
Widget& Widget::operator=(const Widget& rhs) {
if(this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
};
- 解决方案2:调整语句顺序,通过确保异常安全来获得自赋值的回报。
Widget& Widget::operator=(const Widget& rhs) {
Bitmap* pOriginal = pb;
pb = new Bitmap(*rhs.pb);
delete pOriginal;
return *this;
};
- 解决方案3:使用所谓的
copy-and-swap
技术,参数采用pass-by-reference
。
class Widget {
void swap(Widget& rhs); // 交换*this和rhs的数据
};
Widget&::Widget::operator=(const Widget& rhs) {
Widget temp(rhs);
swap(temp);
return *this;
}
- 解决方案4:使用所谓的
copy-and-swap
技术,参数采用pass-by-value
。
Widget&::Widget::operator=(Widget rhs) {
swap(rhs);
return *this;
}
请记住
- 确保类对象自我赋值时
operator=
有良好行为。其中技术包括比较"来源对象"和"目标对象"的地址、精心周到的语句顺序、以及copy-and-swap
。 - 确定任何函数如果操作一个以上的对象。而其中多个对象是同一个对象时,其行为仍然正确。
条款12:复制对象时勿忘其每一个成分
如果声明自己的copying函数
,意思是告诉编译器不喜欢它默认给的,但是当你自己写出的copying函数
代码不安全时,它也不会告诉你。
- copy构造函数:
- 非继承中:当为类添加一个新成员时,
copy构造函数
也需要为新成员添加拷贝代码。否则会调用新成员的默认构造函数初始化新成员。 - 继承中:在派生类的
copy构造函数
中,不要忘记调用基类的copy构造函数拷贝基类部分。否则会调用基类的默认构造函数初始化基类部分。
- 非继承中:当为类添加一个新成员时,
- copy assignment操作符:
- 非继承中:当为类添加一个新成员时,
copy assignment
操作符中也需要为新成员添加赋值代码,否则新成员会保持不变。 - 继承中:在派生类的
copy assignment
操作符中,不要忘记调用基类的copy assignment
操作符,否则基类部分会保持不变。
- 非继承中:当为类添加一个新成员时,
请记住
copying函数
应该确保复制"对象内的所有成员变量"及"所有base class
成分"。- 不要尝试以某个
copying函数
实现另一个copying函数
。应该将共同技能放进第三个函数中,并由两个copying函数
共同调用。