- 确定对象被使用前已经被初始化(条款4)
- 为多态基类声明virtual析构函数(条款07)
- 区分接口继承和实现继承(条款34)
- 不要在构造和析构函数中调用virtual函数
- RAII(Resource Acquisition is Initialization)
- 模板特化
《Effective C++》读书笔记。
确定对象被使用前已经被初始化(条款4)
在不同语境中如果我们在声明变量但不主动初始化时,如int x;
,它有可能是0,也有可能是其他值,这会导致“不明确的行为”,甚至因为无法读取而让程序终止运行。
分清赋值和初始化,一个类中的成员变量可以在构造函数中初始化,但是如果在类的定义中只是声明了它们,然后在构造函数的实现中对其进行赋值,虽然可以达到一样的效果,但是这是赋值而非初始化(伪初始化),因为实际上它们的初始化时间发生的更早,是在这些成员的default构造函数被自动调用之时。
最好的方法就是使用构造函数的初始化列表来对每个成员初始化。注意用该列表时成员的初始化顺序只和它们的声明顺序有关,因此为了避免由次序初始化带来的bug,在列表中最好总是以声明次序为次序。
还有一个问题是C++对“定义于不同编译单元内的non-local static对象”的初始化次序没有明确定义。non-local static对象指的是声明在函数外的任何static对象,也就是说如果某个类的初始化依赖于一个外部类的静态实例(如以extern关键词定义,extern与static声明的变量的生命周期相同),我们无法保证该变量的初始化在这个类之前。为了解决这个问题,可以将non-local static对象搬进一个函数中,让其变成local static对象,这样我们就知道它什么时候会初始化了,即在调用该函数时。
class FileSystem {...};
FileSystem& tfs(){
static FileSystem fs;
return fs;
}
class Dir {...};
Dir& tempDir(){
static Dir td;
return td;
}
如此一来我们得到的就是指向static对象的引用,而不是对象自身了。
为多态基类声明virtual析构函数(条款07)
想象一个简单的情况,当一个子类对象经由一个基类指针被删除,比如:
class Clock{
public:
Clock();
~Clock();
...
};
class AtomicClock : public Clock{...};
int main(){
Clock* clock = new AtomicClock();
...
delete clock;
return 0;
}
这会导致AtomicClock
中的成分没有被销毁,而Clock
中的成分被销毁了,造成一种局部销毁的窘境,从而引发资源泄露。解决这个问题的方法很简单,即给基类声明一个virtual
析构函数,这样一来就不会出现销毁不全的情况了。
任何类中只要有virtual
函数几乎确定应该也有一个virtual
析构函数。如果一个类被设计成不需要派生任何基类,那么它则不应该带有任何虚函数,否则会徒增对象的体积。原因是一旦虚函数被声明,则必须携带一个指针vptr
(virtual table pointer)用以在运行时决定哪个虚函数应该被调用,vptr
指向一个由函数指针构成的数组,称为vtbl
(virtual table),每个带有虚函数的类都有一个相应的vtbl
,当虚函数被调用时,实际被调用的函数取决于vptr
所指向的vtbl
。
我们甚至可以将基类的析构函数声明为纯虚函数,这样我们就在不声明其他虚函数时得到了一个抽象类(不可被实例化),不过要注意的是一定要记得为这个纯虚析构函数提供一个定义。
析构函数的运作方式是,最深层的子类(most derived)的那个析构函数最先被调用,然后是其每个基类的析构函数被调用。而编译器会对子类的析构函数中创建一个基类析构函数的调用,这就是为什么我们如果有一个纯虚析构函数,必须为它提供定义的原因。
总结:
-
带多态性质的基类应该声明一个
virtual
析构函数。如果一个类带有任何虚函数,则它就应该拥有一个virtual
析构函数。 -
一个类如果不是为派生其他子类而设计,或不是为了具备多态性而设计,就不应该存在
virtual
函数。
区分接口继承和实现继承(条款34)
不要在构造和析构函数中调用virtual函数
原因是在构造中,还没有构建出子类(如果有),此时对象还被看作当前类(基类),如果调用virtual函数,不会如我们所想去调用子类中的版本,derived对象在该类的构造调用前不会成为derived类型的对象。同理,在derived类的析构函数调用时,该类中的成员变量便变为不确定值,此时执行该类的函数既不安全,也不会往下去找更子类的重载。
RAII(Resource Acquisition is Initialization)
说的是将资源的管理与对象的生命周期绑定,对象在构造时完成资源的分配(或在得到资源后立刻用其初始化对象),在析构时完成释放,只要对象能正确被析构,如超出作用域,就可以避免内存泄漏。
除了在构造和析构中做资源的获取和释放外,还可以使用一个shared_ptr
的本地变量,并为其准备一个deleter,这样在该类对象被删除时那个shared_ptr
成员会被一并删除并调用deleter,如此一来就无需专门声明析构函数去处理了。
而如果RAII对象面临复制,我们有两种选择:
- 将拷贝构造和赋值声明为private并且不提供定义,或继承一个这样做的基类,以禁止复制(条款6)
- 对对象内的指针成员使用shared_ptr和deleter来在引用计数归零时执行deleter
RAII对象在被复制时需进行深拷贝,以一并复制其管理的资源。
(条款15)就像shared_ptr提供get方法让我们访问其持有的原始指针,我们在设计RAII类时也需要提供其管理的原始资源(raw resources)的方法,或定义隐式/显示转换的方法,其中显示转换较为安全,而隐式转换使用起来比较自然但是有误用的风险。
(条款17)在对智能指针使用new赋值时,应使用独立语句,防止因异常导致的资源泄露。
模板特化
(条款25)我们不能重载std函数,也不能添加额外的功能或偏特化(partially specialize),但是可以全特化(total template specialization)其中的函数,如swap
(事实上C++只允许对类模板偏特化):
class Widget
{
public:
void swap(Widget& Other)
{
using std::swap; // 令std::swap可用
swap(pImpl, Other.pImpl); // 同类型对象可以访问私有成员
}
private:
WidgetImp* pImpl; // pointer to implementation手法
};
namespace std
{
template<>
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b);
}
}
当调用swap
时传入了Widget类型,就会使用上面我们特化的版本,调用成员函数执行真正的swap
。using std::swap
表明我们希望尝试使用全特化的std::swap
。
而如果Widget是一个模板类,那么就会面临偏特化或重载std命名空间中的函数,这都是不被允许的,为此我们可以将其放入一个新的命名空间中。这种方法对普通类和模板类都适用,但如果我们面对的是普通类,还是要选择特化std::swap
,因为这可以兼容直接调用std:swap
的代码。