Tizeng's blog Ordinary Gamer

《Effective C++》笔记——面向对象套路(待完成)

2019-03-04
Tizeng
C++

《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对象面临复制,我们有两种选择:

  1. 将拷贝构造和赋值声明为private并且不提供定义,或继承一个这样做的基类,以禁止复制(条款6)
  2. 对对象内的指针成员使用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类型,就会使用上面我们特化的版本,调用成员函数执行真正的swapusing std::swap表明我们希望尝试使用全特化的std::swap

而如果Widget是一个模板类,那么就会面临偏特化或重载std命名空间中的函数,这都是不被允许的,为此我们可以将其放入一个新的命名空间中。这种方法对普通类和模板类都适用,但如果我们面对的是普通类,还是要选择特化std::swap,因为这可以兼容直接调用std:swap的代码。


上一篇 C++基础

Comments

Content