Effective C++


参考来源:《Effective C++ Third Edition》[美] Scott Meyers 著

Part One: Accustoming Yourself to C++

条款(一):视 C++ 为一个语言联邦

C++ 是多重范型编程语言(multiparadigm),一个同时支持过程形式(procedural)、面向对象形式(object-oriented)、函数形式(functional)、泛型形式(generic)、元编程形式(metaprogramming)的语言。

须记

  • C++ 高效编程守则视状况而变化,取决于你使用 C++ 的哪一部分。

条款(二):尽量以 const、enum、inline 替换 #define

“宁可以编译器替换预处理器。”

宏名称在编译器开始处理源码之前就被预处理器替换掉了,因此不会进入记号表(symbol table)中。编译错误信息中提到的是替换值而不是宏名称,不便于调试时追踪纠错。

以常量替换 #define 时,有两种情况需要考虑,一是定义常量指针时,因为常量定义式通常放置与头文件内,因此需要声明为常指针,例如:

const char * const str = "Hello world!";

二是作用域限制于类内的常量,由于需要确保此常量只有一份实体,需要声明为 static 型:

class Obj
{
    static const int n;
    int a[n];
};

const int Obj::n = 5;

若编译器不允许 static 整型作为编译器常量设定数组大小,可使用 “the enum hack” 做法:

class Obj
{
    enum { n = 5 };
    int a[n];
};

enum 和 #define 一样都不会导致非必要的内存分配,因此取一个 enum 的地址是非法的,对其 sizeof 也是非法的。

宏函数有潜在的危险:

#define MAX(a, b)    ((a) > (b) ? (a) : (b))
int a = 5, b = 0, c = 10, max;
max = MAX(++a, b); // a 递增两次,max = 7
max = MAX(++a, c); // a 递增一次,max = 10

将宏展开后显然能发现问题所在,但是只要使用 inline 函数,便可以获得宏函数带来的效率以及一般函数的所有可预料行为和类型安全性:

template <typename T>
inline T max(const T& a, const T& b)
{
    return a > b ? a : b;
}

这样既不用加上括号,也不用担心宏函数导致的参数被运算多次,同时 max 是函数,遵守作用域和访问规则。

须记

  • 对于单纯常量,最好以 const 对象或 enums 替换 #defines。
  • 对于形式函数的宏,最好改用 inline 函数替换 #defines。

条款(三):尽可能使用 const

const 如果出现在星号左边,表示被指物为常量,如果出现在星号右边,表示指针自身为常量。以下两种写法等效:

const char *str; // 指针指向常对象
char const *str; // 指针指向常对象

迭代器是广义指针,const 修饰也有两种:

std::vector<int>::const_iterator citv = vec.begin(); // 指向常量的迭代器, *citv = 5 不合法
const std::vector<int>::iterator itv = vec.begin();  // 迭代器是常量,++itv 不合法

令函数返回一个常量值,往往可以降低失误造成的意外,而又不至于放弃安全性和高效性。譬如:

Rational operator*(const Rational& lhs, const Rational& rhs); // 运算符声明,返回值不是常量

Rational a, b, c;
......
if (a * b = c) { ... } // 其实是 compare 操作,漏打一个 =

此时,若 Rational 类型是一个可隐式转换为 bool 的类型,会造成程序运行结果非预期而编译器不会报错,若 a 和 b 都是内置类型,编译器会对上述代码报错,一个良好的用户自定义类型应该避免无端地与内置类型不兼容,将运算符的返回值声明为 const 就可以在编译时预防上述错误。

将成员函数声明为 const 的目的,是为了确认该成员函数可作用于 const  对象身上。重载的成员函数的 const 版本和 non-const 版本只是操作的对象不同,执行的代码都是一样的,譬如:

class TextBlock
{
public:
    const char& operator[](std::size_t pos) const
    {
        ...... // 冗长的操作
        return text[pos];
    }
    char& operator[](std::size_t pos)
    {
        ...... // 冗长的操作
        return text[pos];
    }
private:
    char *text;
};

为避免高重复度的代码,可以使用一个版本来调用另一个版本,这将需要常量性转除。让 non-const 版本调用 const 版本:

char& operator[](std::size_t pos)
{
    return const_cast<char&>(static_cast<const TextBlock&>(*this)[pos]);
}

上述代码先将 *this 从原始的 TextBlock& 类型转型为 const TextBlock&,从而能够调用 const 版本的 operator[],接下来从 const operator[] 返回值中移除 const 属性。添加 const 时强迫进行了一次安全转型,所以使用 static_cast,移除 const 的操作只能藉由 const_cast 来完成。值得注意的是,反向做法,即使用 const 版本来调用 non-const 版本是不安全的,原因在于必须将 *this 上的 const 性质移除,就冒了这样的风险:原先承诺不改动的对象被改动了,这是乌云罩顶的清晰前兆。

须记

  • 将某些东西声明为 const 可帮助编译器侦测出错误用法。const 可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
  • 编译器强制实施 bitwise constness,但你编写程序时应该使用 conceptual constness。
  • 当 const 和 non-const 成员函数有着实质等价的实现时,令 non-const 版本调用 const 版本可避免代码重复。

条款(四):确定对象被使用前已被初始化

对于内置类型,进行手工初始化,对于自定义类型,确保每一个构造函数都将对象的每一个成员初始化。尽量使用成员初始化列表而不是赋值的形式进行初始化,这样做有时候绝对必要(const 和 reference 成员变量不能被赋值),且又往往比赋值高效。成员初始化列表列出的成员变量次序应与它们在类中的声明次序一致。

C++ 对 “定义于不同编译单元内的 non-local static 对象” 的初始化顺序无明确定义。所谓编译单元是指产出单一目标文件的那些源码,基本上它是单一源码文件加上其所包含入的头文件。在程序运行结束的时候才会被销毁的对象称为 non-local static 对象。如果某个编译单元内的 non-local static 对象的初始化动作使用了另一个编译单元内的 non-local static 对象,它所用到的这个对象可能尚未初始化。为免除“跨编译单元的初始化次序”问题,使用 local static 对象替换 non-local static 对象。

须记

  • 为内置对象进行手工初始化,因为 C++ 不保证初始化它们。
  • 构造函数最好使用成员初始化列表,而不要在构造函数本体内使用赋值操作。初始化列表列出的成员变量,其排列次序应该和它们在类中的声明次序相同。
  • 为免除 “跨编译单元之初始化次序” 问题,请以 local static 对象替换 non-local static 对象。

Part Two: Constructor, Destructor and Assignment Operators

条款(五):了解 C++ 默默编写并调用哪些函数

什么时候 empty class 不再是个 empty class 呢?若用户未声明,编译器会为它声明一个 default 构造函数,一个 copy 构造函数,一个 copy assignment 运算符和一个 default 析构函数,所有这些自动生成的函数都是 public 且 inline 的。

如果一个类内含 const 或 reference 成员或者该类的基类将 copy assignment 运算符声明为 private,必须自定义 copy assignment 运算符,后者是因为编译器为派生类默认生成的 copy assignment 运算符想象中可以处理 base class 成分,但它无权调用派生类无权调用的成员函数。

须记

  • 编译器可以暗自为类创建默认构造函数、拷贝构造函数、赋值操作符以及析构函数。

条款(六):若不想使用编译器自动生成的函数,就该明确拒绝

如果需要阻止赋值操作,可以将拷贝构造函数和赋值运算符声明为 private 而不去定义,藉由明确声明它们,阻止了编译器暗自生成默认版本,而令它们为 private 属性,得以阻止人们调用它。不去定义它是因为其他 member 函数和 friend 函数仍可以调用它们,没有定义的时候当被调用会导致链接错误(Link error),“将成员函数声明为 private 且故意不去实现它们”是如此为大家接受,因而被应用在 iostream 库中阻止 copy 行为。

将链接期错误提前到编译期是可能的,可以将上述函数移至基类当中,通过继承达到效果:

class Uncopyable
{
public:
    Uncopyable() {}   // 允许派生类构造
    ~Uncopyable() {}  // 允许派生类析构
private:
    Uncopyable(const Uncopyable&); // 不允许派生类拷贝构造
    Uncopyable& operator=(const Uncopyable&); // 不允许派生类赋值
};

class Derived : public Uncopyable { ... };

只要任何地方尝试拷贝 Derived 对象,编译器便尝试生成一个拷贝构造函数和赋值运算符,自动生成的版本会调用基类的版本, 显然因为无权调用会被编译器拒绝。

须记

  • 为驳回编译器自动提供的功能,可将相应的成员函数声明为 private 并且不予实现。使用像 Uncopyable 这样的基类也是一种做法。

条款(七):为多态基类声明 virtual 析构函数

class TimeKeeper // base class
{
    ~TimeKeeper();
};

class AtomicClock : public TimeKeeper { ... }; // 原子钟
class WaterClock  : public TimeKeeper { ... }; // 水钟
class WristWatch  : public TimeKeeper { ... }; // 腕表

TimeKeeper* p =  getTimeKeeper(); // 返回一个指针,指向一个 TimeKeeper 派生类动态分配的对象
...
delete p; // 释放指针,避免内存泄漏

上述代码的问题出在指针 p 指向的是一个派生类对象,而那个对象却经由一个基类指针被删除,而基类的析构函数是 non-virtual 的,这通常会造成派生类对象的成分没有被销毁,于是造成一个诡异的局部销毁的对象。消除该问题的做法就是将基类的析构函数声明为 virtual。

使用 virtual 函数是允许派生类的实现得以定制化,任何只要带有 virtual 函数的类都几乎确定应该也有一个 virtual 析构函数。当类不作为基类时,析构函数不声明为 virtual。

即使类不含 virtual 函数,有时候程序员也会错误地把它当作基类:

class MyString : public std::string { ... }; // std::string 有个 non-virtual 析构函数

MyString *p1 = new MyString("Hello World!");
std::string *p2 = p1;
delete p2; // 局部销毁,MyString 对象的资源泄漏

为此,切忌继承标准容器及其他带有 non-virtual 析构函数的类。

有时候希望创建抽象类,但手上没有 pure virtual 函数,可以为希望它成为抽象类的那个类声明一个 pure virtual 析构函数,而且必须为该 pure virtual 析构函数提供一份定义:

class AWOV // AWOV = "Abstract w/o Virtuals"
{
public:
    virtual ~AWOV() = 0; // 声明 pure virtual 析构函数
};
AWOV::~AWOV() {} // 定义 pure virtual 析构函数

因为编译器会在派生类的析构函数中创建一个 ~AWOV() 的调用,所以如果不提供定义会导致链接错误。

须记

  • polymorphic(带多态性质的)基类应该声明一个 virtual 析构函数,如果类含有任何 virtual 成员函数,它就应该拥有一个 virtual 析构函数;
  • 类设计的目的如果不是为了作为基类使用,或不是为了具备 polymorphically(多态性),就不该声明 virtual 析构函数。

条款(八):别让异常逃离析构函数

假如有一个用来管理数据库资源的类,在析构函数中关闭连接以防客户忘记关闭连接:

class DBConn
{
public:
    ~DBConn() { db.close(); } // 确保数据库的连接总是会被关闭
private:
    DBConnection db;
};

只要调用 close 导致异常,DBConn 的析构函数会传播该异常,也就是允许它离开这个析构函数,那会造成问题,因为抛出了难以驾驭的麻烦,两个办法可以避免这一问题:

一是如果 close 抛出异常就结束程序:

DBConn::~DBConn()
{
    try { db.close; }
    catch () {
        std::abort();
    }
}

如果程序遭遇一个“于析构期间发生的错误”后无法继续执行,强迫结束成员是个合理的选项。

二是吞下调用 close 引发的异常,即去除 std::abort() 那一行操作,有时候吞下异常比草率结束程序或不明确行为带来的风险好,因为程序必须能够继续可靠地执行,即使在遭遇并忽略一个错误之后。

这两者都无法对“导致 close 抛出异常”的情况作出反应,一个较佳策略是对 DBConn 增加一个 close 接口,因而赋予客户一个机会得以处理“因该操作而引发的异常”:

class DBConn
{
public:
    ~DBConn()
    {
        if (closed == false)
        {
             try { db.close; }
             catch (...) { ... }
        }
    }
    void close()
    {
        db.close();
        closed = true;
    }
private:
    DBConnection db;
    bool closed;
};

须记

  • 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
  • 如果客户需要对某个操作函数运行期间抛出的异常作出反应,那么应该提供一个普通函数接口执行该操作。

条款(九):绝不在构造和析构过程中调用虚函数

对象在派生类的构造函数开始执行之前不会成为一个派生类对象,因此在基类的构造函数中调用的虚函数是基类的版本,不会下降到派生类层,一旦基类的析构函数开始执行,对象内的派生类成员变量便呈现未定义值,仿佛不再存在,此时对象就称为一个基类对象,因此在基类的析构函数中调用的虚函数是基类的版本。

将基类中原本被构造函数调用的虚函数改为非虚版本后,可由“派生类构造函数传递必要的构造参数上传给基类的构造函数”替换之而加以弥补。

须记

  • 在构造和析构期间不要调用虚函数,因为这类调用不下降至派生类(比起当前执行构造函数和析构函数的那层)。

条款(十):令 operator= 返回一个 reference to *this

为实现连锁赋值,赋值运算符必须返回一个 reference 指向运算符的左侧实参,该条款也适用于 +=、-=、*=、/=、%=、^= 等运算符。

须记

  • 令赋值操作符返回一个 reference to *this。

条款(十一):在 operator= 中处理“自我赋值”

一个“在停止使用资源之前意外释放了它”的陷阱:

class BitMap { ... };
class Widget
{
public:
    Widget& operator=(const Widget& rhs)
    {
        delete pb; // 删除当前的 BitMap
        pb = new BitMap(*rhs.pb); // 使用 rhs 的 BitMap 的副本
        return *this;
    }
private:
    BitMap *pb; // 指针,指向一个动态分配的对象
};

上述代码问题在于,如果 *this 和 rhs 是同一个对象,那么 delete 就不只是销毁了当前对象的 BitMap,同时也销毁了 rhs 的 BitMap,最终的 Widget 发现自己持有了一个指向已被删除对象的指针。

解决之道就是在进入运算符函数时作“自我赋值”检测:

Widget& Widget::operator=(const Widget& rhs)
{
    if (this == &rhs) return *this;
    ... // 刚刚那三行操作
}

然而,上述代码不具备异常安全性,如果在 new BitMap 时抛出异常,Widget 最终会持有一个指针指向一块被删除的 BitMap,这样的指针你无法安全地删除它们,甚至无法安全地读取它们。令人高兴的是,让 operator= 具备异常安全性的同时往往自动获得自我赋值安全的回报:

Widget& Widget::operator=(const Widget& rhs)
{
    BitMap *p0 = pb; // 保存原先的指针
    pb = new BitMap(*rhs.pb);
    delete p0;       // 删除原先的指针
    return *this;
}

现在,如果 new 抛出异常,pb (及其栖身的那个 Widget)仍将保持现状。在 operator= 中精心排列语句的一个替代方案是使用 copy and swap 技术:

void Widget::swap(Widget& rhs);
/* 实现版本1 */
Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs); // 创建副本
    swap(temp);       // 将 *this 的数据同副本的交换
    return *this;
}
/* 实现版本2 */
Widget& Widget::operator=(Widget rhs) // 值传递
{
    swap(rhs); // 将 *this 的数据同形参的交换
    return *this;
}

版本 2 将 copy 工作从函数本体内移至函数参数构造阶段可令编译器有时产生更高效的代码。

须记

  • 确保当对象自我赋值时 operator= 有良好行为,其中技术包括比较 “来源对象” 和 “目标对象” 的地址、精心周到的语句顺序、以及 copy-and-swap。
  • 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。

条款(十二):复制对象时勿忘其每一个成分

在派生类的复制函数中要调用基类中所有适当的复制函数,以免遗漏基类的数据成员。

不要尝试以某个复制函数实现另一个复制函数,应该将共同机能放进第三个函数中,然后由两个复制函数共同调用。

须记

  • Copying 函数应该确保复制 “对象内的所有成员变量” 及 “所有基类成分”。
  • 不要尝试以某个 copying 函数实现另一个 copying 函数,应该将共同机能放进第三个函数中,并由两个 copying 函数共同调用。

Part Three: Resource Management

条款(十三):以对象管理资源

假定某个程序库中存在基类 Investment,其派生出了大量派生类,程序库中通过一个工厂函数创建特定的 Investment 对象:

class Investment { ... };
Investment* createInvestment(); // 调用者有责任删除返回的动态分配对象

假定有个 f 函数履行释放返回的动态分配对象的责任

void f()
{
    Investment *pInv = createInvestment();
    ...
    delete pInv;
}

看起来似乎妥当,但若干情况下 f 可能无法删除它得自 createInvestment 的对象:或许是 “…” 区域内一个过早的 return 语句;或是对 createInvestment 和 delete 动作位于某循环内,而该循环由于某个 continue 或 break 语句过早退出;或是 “…” 区域内的语句抛出异常。

为确保 createInvestment 返回的资源总是被释放,需要将资源放进对象内,但控制流离开 f,该对象的析构函数会自动释放那些资源,即倚赖 C++ 的 “析构函数自动调用机制” 确保资源被释放。

标准库提供的 auto_ptr 正是为此设计的特制产品,其析构函数自动对其所指对象调用 delete,使用它以避免 f 函数潜在的资源泄漏可能性:

void f()
{
    std::auto_ptr<Investment> pInv(createInvestment());
    ...
}

该例示范了 “以对象管理资源” 的两个关键想法:

  • 获得资源后立刻放进管理对象(managing object)内。此观念常被称为 “资源获取时即初始化” (Resource Acquisition Is Initialization; RAII),有时获得的资源拿来赋值某个管理对象,但不论哪一种做法,每一笔资源都在获得的同时立刻被放进管理对象中。
  • 管理对象运用析构函数确保资源被释放。

上述的 std::auto_ptr 在 C++11 中应当采用 “引用计数型智能指针” std::shared_ptr 来替代。这两者在析构函数内执行的是 delete 而不是 delete[],针对数组而设计的智能指针可在 Boost 库中找到 boost::scoped_array 和 boost::shared_array。

须记

  • 为防止资源泄漏,请使用 RAII 对象,它们在构造函数中获得资源并在析构函数中释放资源。
  • 两个常被使用的 RAII 类分别是 std::shared_ptr(C++11)和 std::auto_ptr,后者应当避免被使用,因为复制动作会使它指向 NULL。

条款(十四):在资源管理类中小心 copying 行为

并非所有资源都是 heap-based,或许需要自己建立资源管理类。假设使用 C API 函数处理类型为 Mutex 的互斥锁对象,共有 lock 和 unlock 两函数可用,为确保绝不会忘记将一个锁住的 Mutex 解锁,你可能会希望建立一个 RAII 守则支配的类来管理锁:

void lock(Mutex *pm);
void unlock(Mutex *pm);
class Lock
{
public:
    explicit Lock(Mutex *pm) : mutexPtr(pm) { lock(mutexPtr); }
    ~Lock() { unlock(mutexPtr); }

private:
    Mutex *mutexPtr;
};

客户对 Lock 的用法符合 RAII 方式:

Mutex m;
{ // 建立一个作用域用来定义 critical section
    Lock lock(&m); // 锁定互斥锁
    ...
} // 离开作用域,自动解锁互斥锁

这很好,但如果 Lock 对象被复制,会发生什么事?

Lock lock1(&m);
Lock lock2(lock1);

当一个 RAII 对象被复制,会发生什么事?有多种选择:

  • 禁止复制。许多时候允许 RAII 对象被复制并不合理。
  • 对底层资源祭出 “引用计数法”。有时候我们希望保有资源,直到它的最后一个使用者被销毁,std::shared_ptr 便是如此。
  • 复制底层资源。复制资源管理对象时,应当使用 “深拷贝”。
  • 转移底层资源的拥有权。在某些罕见3场合下希望确保永远只有一个 RAII 对象指向一个 raw resource,即是 RAII 对象被复制依然如此,此时资源的拥有权会从被复制对象转移到目标对象,std::auto_ptr 便是如此。

Copying  函数可能被编译器自动生成,除非编译器自动生成的版本是想要的,否则应当自行编写。

须记

  • 复制 RAII 对象必须一并复制它所管理的资源,所以资源的 copying 行为决定 RAII 对象的 copying 行为。
  • 普遍而常见的 RAII 类 copying 行为是:禁止 copying、施行引用计数法。

条款(十五):在资源管理类中提供对原始资源的访问

许多 APIs 直接指涉资源,因此有时只得绕过资源管理对象直接访问原始资源,这需要一个函数将 RAII 对象转换为其所内含的原始资源,有两个做法可以达成目标:显式转换和隐式转换。std::shared_ptr 提供了一个 get 成员函数,用来执行显式转换:

std::shared_ptr<Investment> pInv(createInvestment());
int daysHeld(const Investment *pi);
int days = daysHeld(pInv.get()); // 将 pInv 内的原始指针传给 daysHeld

同时,std::shared_ptr 也重载了指针取值操作符(operator-> 和 operator*),它们允许隐式转换至底层原始指针:

class Investment
{
public:
    bool isTaxFree() const;
};
bool taxable1 = !(pInv->isTaxFree());   // 通过 operator-> 访问资源
bool taxable2 = !((*pInv).isTaxFree()); // 通过 operator* 访问资源

当必须取得 RAII 对象内的原始资源时,既可以提供一个显式的转换函数,像 get 那样,也可以提供隐式类型转换函数,考虑下面这个用于字体的 RAII 类:

void releaseFont(FontHandle fh);
class Font
{
public:
    explicit Font(FontHandle fh) : f(fh) {}
    ~Font() { releaseFont(); }
    FontHandle get() const { return f; }      // explicit cast
    operator FontHandle() const { return f; } // implicit cast

private:
    FontHandle f; // raw resource
};

隐式转换会增加错误发生几率。采用何种方式主要取决于 RAII 类被设计执行特定工作以及它被使用的情况。

须记

  • APIs 往往要求访问原始资源,所以每一个 RAII 类应该提供一个取得其所管理的资源的方式。
  • 对原始资源的访问可能经由显式转换或隐式转换。一般而言显式转换比较安全,但隐式转换对客户比较方便。

条款(十六):成对使用 new 和 delete 时要采取相同形式。

当使用 new 的时候,有两件事发生:第一,内存被分配出来(通过名为 operator new 的函数),第二,针对此内存会有一个或多个构造函数被调用。当使用 delete 的时候,也有两件事发生:针对此内存会有一个或多个析构函数被调用,然后内存才被释放(通过名为 operator delete 的函数)。delete  的最大问题在于,即将被删除的内存之内究竟存在多少个对象?通常编译器会在对象数组的内存布局中第一个对象前开辟一小块空间存储数组中对象的数目。

最好不要对数组形式做 typedef 动作,否则客户代码极易出错,譬如

typedef std::string AddressLines[4];
std::string *pal = new AddressLines;
...
delete pal;   // undefined behaviour
delete []pal; // fine

须记

  • 如果在 new 表达式中使用 [],必须在相应的 delete 表达式中也使用 [];如果在 new 表达式中不使用 [],一定不要在相应的 delete 表达式中使用 []。

条款(十七):以独立语句将 newed 对象置入智能指针

假设有个函数用来揭示处理程序的优先级,另一个函数用来在某动态分配所得的 Widget 对象上进行某些带有优先级的处理

int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);

现在考虑调用 processWidget

processWidget(std::shared_ptr<Widget>(new Widget), priority());

令人惊讶的是,虽然在此使用对象管理资源,上述调用却可能出现资源泄漏。

编译器在产出一个 processWidget 调用码之前,必须首先核算即将被传递的各个实参,上述第二个实参只是一个单纯的对 priority 函数的调用,但上述第一个实参有两部分组成

  • 执行 new Widget 表达式
  • 调用 std::shared_ptr 构造函数

于是在调用 processWidget 之前,编译器必须创建代码,完成上述三件事,但以什么样的次序呢?弹性很大,可以确定的是 new Widget 一定执行于 std::shared_ptr 构造函数之前,但 priority 的调用可以排在第一或第二或第三执行。假定编译器选择以第二顺位执行它,现在考虑万一对 priority 的调用发生异常,在此情况下,new Widget 返回的指针将会丢失,因为它尚未被置入 std::shared_ptr 内,而后者是我们期盼用来防卫资源泄漏的武器。是的,在对 processWidget 的调用过程中可能引发资源泄漏,因为在 “资源被创建” 和 “资源被转换为资源管理对象” 两个时间点之间有可能发生异常。

避免这类问题的办法很简单:使用分离语句

std::shared_ptr<Widget> pw(new Widget); // 在单独语句中以智能指针存储 newed 对象
processWidget(pw, priority());

以上之所以行得通,因为编译器对于 “跨越语句的各项操作” 没有重新排列的自由。

须记

  • 以独立语句将 newed 对象存储于智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄漏。

Part Four: Designs and Declarations

条款(十八):让接口容易被正确使用,不易被误用

欲开发一个 “容易被正确使用,不容易被误用” 的接口,首先必须考虑客户可能做出什么样的错误。假设为一个用来表现日期的类设计构造函数

class Date
{
public:
    Date(int month, int day, int year);
};

乍见之下这个接口通情达理,但它的客户很容易犯以下两个错误。

Date d(30, 3, 1995); // 错误的次序传递参数
Date d(2, 30, 1995); // 传递一个无效的月份或天数

许多客户端错误可以因为导入新类型而获得预防。真的,在防范 “不值得拥有的代码” 上,类型系统非常有用,于是,导入 wrapper 类来区别天数、月份和年份

struct Day {
    explicit Day(int d) : val(d) {}
    int val;
};
struct Month {
    explicit Month(int m) : val(m) {}
    int val;
};
struct Year {
    explicit Year(int y) : val(y) {}
    int val;
};
class Date
{
public:
    Date(const Month& m, const Day& d, const Year& y);
};

Date d(Month(3), Day(30), Year(1995)); // 类型正确

令 Day、Month 和 Year 成为成熟且经充分检验的类并封装其内部数据,比简单使用上述的结构体要好,但即使结构体也已经足够示范:明智而审慎地导入新类型对预防 “接口被误用” 有神奇疗效。

一旦正确的类型就位,限制其值有时候是通情达理的,例如一年只有 12 个月,因此 Month 应该反映这一事实,办法之一是使用枚举类型表现月份,但是枚举类型不具备我们期望的类型安全性,比较安全的做法是预先定义所有有些的 Months:

class Month
{
public:
    static Month Jan() { return Month(1); }
    ...
    static Month Dec() { return Month(12); }

private:
    explicit Month(int m); // 阻止生成新的月份
};

Date d(Month::Mar(), Day(30), Year(1995));

代码中 “以函数替换对象,表现某个特定月份” 是因为 non-local static 对象的初始化次序有可能出现问题。

下面是另一个一般性准则 “让类型容易被正确使用,不容易被误用” 的表现形式:“除非有好理由,否则应该尽量令自定义类型的行为与内置类型一致”。避免无端与内置类型不兼容,真正的理由是为了提供行为一致的接口,很少有其他性质比得上 “一致性” 更能导致 “接口容易被正确使用”,也很少有其他性质比得上 “不一致性” 更加加剧接口的恶化。STL 容器的接口十分一致,这使得它们非常容易被正确使用。

须记

  • 好的接口很容易被正确使用,不容易被误用。
  • “促进正确使用” 的办法包括接口的一致性,以及与内置类型的行为兼容。
  • “阻止误用” 的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
  • std::shared_ptr 支持 custom deleter,这可防范 DLL 问题,可被用来自动解除互斥锁等等。

条款(十九):设计 class 犹如设计 type

几乎每一个类都要求你面对以下提问,而你的回答往往导致你的设计规范

  • 新类型的对象应该如何被创建和销毁?这会影响到类的构造函数和析构函数以及内存分配函数和释放函数的设计。
  • 对象的初始化和对象的赋值该有什么样的差别?这个答案决定构造函数和赋值操作符的行为以及其间的差异。
  • 新类型的对象如果被以值传递,意味着什么?记住,拷贝构造函数用来定义一个类型的值传递该如何实现。
  • 什么是新类型的合法值?对类成员变量而言,通常只有某些数值集是有效的,那些数值集决定了类必须维护的约束条件(invariants),也就决定了成员函数必须进行的错误检查工作,它也影响函数抛出的异常、以及函数异常规格列表。
  • 新类型需要配合某个继承图系吗?如果继承自某些既有类,就受到哪些类的设计的束缚,特别是受到 “它们的函数是 virtual 或 non-virtual” 的影响。如果允许有其他类继承该类,那会影响所声明的函数,特别是析构函数,是否为 virtual。
  • 新类型需要什么样的转换?如果希望类型为 T1 的对象被隐式转换为类型为 T2 的对象,就必须在类 T1 内写一个类型转换函数或在类 T2 内写一个 non-explicit-one-argument 的构造函数。
  • 什么样的操作符和函数对此新类型而言是合理的?该问题的答案决定将为类声明哪些函数,其中某些应该是成员函数,某些则否。
  • 什么样的标准函数应该驳回?那些正是必须声明为 private 者。
  • 谁该取用新类型的成员?该提问可以帮助决定哪个成员为 public、哪个为 protected、哪个为 private,它也帮助决定哪一个类和/函数应该是 friends,以及将它们嵌套于另一个之内是否合理。
  • 什么是新类型的 “未声明接口”?它对效率、异常安全性以及资源运用提供何种保证?在这些方面提供的保证将为类实现代码加上相应的约束条件。
  • 新类型有多么一般化?或许其实并非定义一个新类型,而是定义一整个类型家族,果真如此就应定义一个新的类模板。
  • 真的需要一个新类型么?如果只是定义新的派生类以便为既有类添加功能,那么说不定单纯定义一个或多个 non-member 函数或 templates,更能够达到目标。

须记

  • Class 的设计就是 type 的设计。在定义一个新 type 之前,先确定已经考虑过本条款覆盖的所有讨论主题。

条款(二十):宁以 pass-by-reference-to-const 替换 pass-by-value

以引用方式传递参数除了避免无用的构造和析构,也可以避免 slicing 问题。当一个派生类对象以值传递的方式并被视为一个基类对象时,基类的拷贝构造函数会被调用,而 “造成此对象的行为像个派生类对象” 的那些特化性质却被切割掉了,仅仅留下一个基类对象。例如

class Window
{
public:
    std::string name() const;
    virtual void display() const;
};
class WindowWithScrollBars: public Window
{
public:
    virtual void display() const;
};

void printNameAndDisplay(Window w) // not correct!
{
    std::cout << w.name();
    w.display();
}

WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb); // will be slice

上述定义的 printNameAndDisplay 函数内不论传递进来的对象原本是什么类型,参数 w 就像一个 Window 对象,因此在 printNameAndDisplay 内调用 display,调用的总是 Window::display,绝不会是 WindowWithScrollBars::display。

须记

  • 尽量以 pass-by-reference-to-const 替换 pass-by-value。前者通常比较高效,并可避免对象切割问题(slicing problem)。
  • 以上规则并不适用于内置类型,以及 STL 的迭代器和函数对象,对它们而言,pass-by-value 往往比较适当。

条款(二十一):必须返回对象时,别妄想返回其 reference

考虑一个用来表现有理数的类,内含一个函数用来计算两个有理数的乘积

class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1);

private:
    int n, d;
    friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};

Rational 类的 operator* 的正确写法为

inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

当然,需要承受 operator* 返回值的构造成本和析构成本,然而长远来看,那只是为了获得正确行为而付出的一个小小代价。某些情况下,编译器优化可将返回值的构造和析构安全地消除。

须记

  • 绝不要返回 pointer 或 reference 指向一个 local stack 对象,或返回 reference 指向一个 heap-allocated 对象,或返回 pointer 或 reference 指向一个 local static 对象而有可能同时需要多个这样的对象。条款 4 已经为 “在单线程环境中合理返回 reference 指向一个 local static 对象” 提供了一份设计实例。

条款(二十二):将成员变量声明为 private

如果成员变量不是 public,客户唯一能够访问变量的办法就是通过成员函数。如果 public 接口内的每样东西都是函数。客户就不需要在打算访问类成员时迷惑地试着记住是否该采用圆括号。将成员变量隐藏在函数接口的背后,可以为 “所有可能的实现” 提供弹性,例如这可使得成员变量被读或写的时候轻松通知其他对象、可以验证类的约束条件已经函数的前提和事后状态、可以在多线程环境中执行同步控制等。

须记

  • 切记将成员变量声明为 private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供类作者以充分的实现弹性。
  • protected 并不比 public 更具封装性。

条款(二十三):宁以 non-member non-friend 替换 member 函数

假定有个类用来表示网页浏览器,其中有三个成员函数用来清除下载元素高速缓存区、清除访问过的 URLs 的历史记录、以及移除系统中的所有 cookies:

class WebBrowser
{
public:
    void clearCache();
    void clearHistory();
    void removeCookies();
};

许多用户会想一次性执行所有这些动作,因此 WebBrowser 也提供这样一个函数

class WebBrowser
{
public:
    ...
    void clearEverything(); // 调用那三个函数
};

当然,这一功能也可由一个 non-member 函数调用适当的 member 函数而提供

void clearBrowser(WebBrowser& wb)
{
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

那么。哪一个比较好呢?面向对象守则要求,数据以及操作数据的那些函数应该被捆绑在一块,这意味它建议 member  函数是较好选择。不幸的是这个建议并不正确,面向对象守则要求数据应该尽可能被封装,然而与直观相反地,member 函数 clearEverything 带来的封装性比 non-member 函数 clearBrowser 低。此外,提供 non-member 函数可允许对 WebBrowser 相关功能有较大的包裹弹性,而那最终导致较低的编译依赖度,增加 WebBrowser 的可扩展性。考虑对象内的数据,愈少的代码能够访问数据,数据的封装性愈强。能够访问 private 成员变量的函数只有类的 member 函数加上 friend 函数而已。如果要在一个 member 函数(它不止可以访问类的 private 数据,也可以访问 private 函数、enums、typedefs 等)和一个 non-member、non-friend 函数(它无法访问上述任何东西)之间作抉择,而且两者提供相同功能,那么导致较大封装性的是 non-member、non-friend 函数,因为它并不增加 “能够访问类内的 private 成分” 的函数数量。

以上有两点值得注意,第一,此论述只适用于 non-friend 函数;第二,只因在意封装性而让函数称为类的 non-member,并不意味着它 “不可以是另一个类的 member”,它可以是某个工具类的一个 static member 函数。

在 C++ 中,比较自然的做法是让 clearBrowser 成为一个 non-member 函数并且位于 WebBrowser 所在的同一个命名空间内。

须记

  • 宁可拿 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性和功能的可扩展性。

条款(二十四):若所有参数皆需类型转换,请为此采用 non-member 函数

对于表示有理数的类,允许整数隐式转换为有理数似乎颇为有理,的确,它并不比 C++ 内置从 int 至 double 的转换来得不合理:

class Rational
{
public:
    Rational(int numerator = 0, int denominator = 1);
    int numerator() const;
    int denominator() const;

private:
    int n, d;
    const Rational operator*(const Rational& rhs);
};

上述设计在混合式算术时只有一半行得通:

Rational oneHalf(1, 2);
Rational result = oneHalf * 2; // fine
result = 2 * oneHalf;          // error!

乘法应该满足交换律,不是么?当以对应的函数形式重写上述两式,问题一目了然

result = oneHalf.operator*(2); // fine
result = 2.operator*(oneHalf); // error!

整数 2 并没有 operator* 成员函数,那么为什么 2 没有被隐式转换为 Rational 对象?只有当参数被列于参数列表内,这个参数才是隐式类型转换的合格参与者。地位相当于 “被调用的成员函数所隶属的那个对象” —— 即 this 对象 —— 的那个隐喻参数,绝不是隐式转换的合格参与者。

若要支持混合式算术运算,让 operator* 成为一个 non-member 函数,便允许编译器在每一个实参身上执行隐式类型转换:

const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.numerator() * rhs.numerator(),
                    lhs.denominator() * rhs.denominator());
}

就本例而言,operator* 不需要成为 Rational 类的一个 friend 函数,因为其完全借由 Rational 的 public 接口完成任务。这导出一个重要的观察:member 函数的反面是 non-member 函数,不是 friend 函数。

须记

  • 如果需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member 函数。

条款(二十五):考虑写出一个不抛异常的 swap 函数

缺省情况下 swap 动作可由标准库函数 swap 算法完成,其典型实现完全如你所预期

namespace std {
    template <typename T>
    void swap(T& a, T& b)
    {
        T temp(a);
        a = b;
        b = temp;
    }
}

只要类型 T 支持 copying,缺省的 swap 实现代码就会置换类型为 T 的对象。对某些类型而言,复制动作并不必要,其中最主要的就是 “指针指向一个对象,内含真正数据”,这种设计的常见表现形式是所谓的 “pimpl 手法”,如果以这种手法设计 Widget 类,看起来会像这样:

class WidgetImpl
{
public:
    ...

private:
    int a, b, c;
    std::vector<double> v;
    ... // 可能有许多数据,意味复制时间很长
};

class Widget
{
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs)
    {
        ...
        *pImpl = *(rhs.pImpl);
        ...
    }

private:
    WidgetImpl *pImpl;
};

缺省的 swap 算法不只复制三个 Widgets,还复制三个 WidgetImpl 对象,我们可以将 std::swap 针对 Widget 特化,当 Widgets 被置换时唯一需要做的就是置换其 pImpl 指针:

class Widget
{
public:
    ...
    void swap(Widget& other)
    {
        using std::swap;
        swap(pImpl, other.pImpl);
    }
};
namespace std {
    template <>
    void swap<Widget>(Widget& a, Widget& b)
    {
        a.swap(b);
    }
}

这种做法与 STL 容器具有一致性,因为所有 STL 容器也都提供有 public swap 成员函数和 std::swap 特化版本。

成员版 swap 绝不可抛出异常,那是因为 swap 的一个最好的应用是帮助类提供强烈的异常安全性保障。

须记

  • 当 std::swap 对你的类型效率不高时,提供一个 swap 成员函数,并确定这个函数不抛出异常。
  • 如果提供一个 member swap,也该提供一个 non-member swap 用来调用前者,对于 classes(而非 templates),特化 std::swap。
  • 调用 swap 时应针对 std::swap 使用 using 声明式,然后调用 swap 并且不带任何命名空间修饰。
  • 为 “用户定义类型” 进行 std templates 全特化是好的,但千万不要尝试在 std 内加入某些对 std 而言全新的东西。

Part Five: Implementations

条款(二十六):尽可能延后变量定义式的出现时间

不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。如果这样,不仅能够避免构造和析构非必要对象,还可以避免无意义的默认构造行为。

对于循环,如果变量只在循环内使用,那么把它定义于循环外好呢还是循环内好?

// 方法 A: 定义于循环外         // 方法 B: 定义于循环内
Widget w;
for (int i = 0; i < n; ++i)    for (int i = 0; i < n; ++i)
{                              {
    w = 取决于 i 的某个值;           Widget w(取决于 i 的某个值);
    ...                             ...
}                              }

上述两种写法的成本如下

  • 方法 A: 1 个构造函数 + 1 个析构函数 + n 个赋值操作;
  • 方法 B: n 个构造函数 + n 个析构函数。

如果类的一个赋值成本低于一组构造析构成本,方法 A 比较高效,尤其是当 n 的值很大时,否则方法 B 或许较好,因为方法 A 造成名称 w 的作用域比方法 B 更大,有时会对程序的可理解性和易维护性造成冲突。因此除非确定赋值成本低于 “构造+析构” 成本且正在处理代码中效率高度敏感的部分,否则应该使用方法 B。

须记

  • 尽可能延后变量定义式的出现,这样做可增加程序的清晰度并改善程序效率。

条款(二十七):尽量少做转型动作

C++ 提供的四种形式转型解释如下:

  • const_cast 通常被用来将对象的常量性转除(cast away the constness),它是唯一有此能力的转型操作符;
  • dynamic_cast 主要用来执行 “安全向下转型” (safe downcasting),也就是用来决定某对象是否归属于继承体系中的某个类型,它是唯一可能耗费重大运行成本的转型动作;
  • reinterpret_cast 意图执行低级转型,实际动作及结果可能取决于编译器,这也就表示它不可移植,这一类转型在低级代码以外很少见;
  • static_cast 用来强迫隐式转换,例如将 non-const 转为 const,将 void * 指针转为 typed 指针,将 pointer-to-base 转为 pointer-to-derived,但它无法将 const 转为 non-const。

新式转型比旧式转型更受欢迎,一是在代码中容易辨识,因而得以简化 “找出类型系统在哪个地点被破坏” 的过程,二是各转型动作的目标愈窄化,编译器愈可能诊断出错误的运用。唯一使用旧式转型的时机是调用一个 explicit 构造函数将一个对象传递给一个函数的时候:

class Widget { public: explicit Widget(int size); ... };
void doSomeWork(const Widget& w);
doSomeWork(Widget(15));              // old style
doSomeWork(static_cast<Widget>(15)); // new style

dynamic_cast 的一个很普遍的实现版本基于类名称的字符串比较,例如对在四层深的单继承体系内的某个对象身上执行 dynamic_cast,可能会耗用多达四次的 strcmp 调用。之所以需要 dynamic_cast,通常是想要在一个 derived 类对象身上执行 derived 类操作函数,但是手上却只有一个指向基类的指针或引用,只能靠它们来处理对象,有两个一般性做法可以避免这个问题:第一,使用容器并在其中存储直接指向 derived 类对象的指针,当然这种做法使得无法在同一个容器内存储指向所有可能的派生类的指针;第二,通过基类接口处理所有可能的派生类,在基类中提供一份缺省的 virtual 函数。不论哪种方法 —— “使用类型安全容器” 或 “将 virtual 函数往继承体系上方移动” —— 都并非放之四海皆准,但在许多情况下它们都提供一个可行的 dynamic_cast 替代方案。

绝对必须避免的一件事是所谓的 cascading dynamic_casts

class Window { ... };
std::vector<std::shared_ptr<Window> > winPtrs;
for (std::vector<std::shared_ptr<Window> >::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
{
    if (SpecialWindow1 *psw1 = dynamic_cast<SpecialWindow1*>(iter->get()))
    { ... }
    else if (SpecialWindow2 *psw2 = dynamic_cast<SpecialWindow2*>(iter->get()))
    { ... }
    else if (SpecialWindow3 *psw3 = dynamic_cast<SpecialWindow3*>(iter->get()))
    { ... }
    ...
}

这样的代码又大又慢,而且每次修改 Window 类继承体系的时候,所有这样的代码都必须检阅看看是否需要修改,这样的代码总是应该以某些 “基于 virtual 函数调用” 的东西来替代。

须记

  • 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代设计。
  • 如果转型是必要的,试着将它隐藏于某个函数背后,客户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
  • 宁可使用 C++-style 转型,不要使用旧式转型,前者很容易辨识出来,而且也比较有着分门别类的职责。

条款(二十八):避免返回 handles 指向对象内部成分

假设现有程序涉及矩形,每个矩形由其左上角和右下角表示,为了让一个矩形对象尽可能小,可能会将定义矩形的这些点存放在一个辅助的 Point 对象内再让 Rectangle 指向它:

class Point
{
public:
    Point(int x, int y);
    void setX(int newX);
    void setY(int newY);
};
struct RectData {
    Point ulhc; // upper left-hand corner
    Point lrhc; // lower right-hand corner
};
class Rectangle
{
public:
    const Point& upperLeft() const { return pData->ulhc; }
    const Point& lowerRight() const { return pData->lrhc; }

private:
    std::shared_ptr<RectData> pData;
};

上述 Rectangle 类的两个 public 函数返回值的 const 是必须的,否则函数的调用者可以修改对象内的数据,引用、指针和迭代器都是 handles,而返回一个对象内部数据的 handle,随之而来的便是降低对象封装性的风险。

返回 handles 可能导致 dangling handles 问题,这种 handles 所指对象不复存在,最常见的情况来源于函数返回值。例如某个函数返回 GUI 对象的外框,该外框采用矩形形式:

class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj);

那么,客户有可能这么使用这个函数:

GUIObject *pgo;
const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft());

对 boundingBox 的调用获得一个新的暂时的 Rectangle 对象 temp,随后 upperLeft 作用于 temp 身上,返回一个指向 temp 内部成分的引用,于是 pUpperLeft 指向那个 Point 对象。在该条语句结束之后,boundingBox 的返回值 temp 将被销毁,那间接导致 temp 内的 Points 析构,最终导致 pUpperLeft 成为悬挂指针。只要有 handle 被传出去了,就暴露在 “handle 比其所指对象更长寿” 的风险下。

须记

  • 避免返回 handles 指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像个 const,并将发生 dangling handles 的可能性降至最低。

条款(二十九):为异常安全而努力是值得的

假设有个类用来表示夹带背景图案的 GUI 菜单,这个类希望用于多线程环境,所以有个互斥锁作为并发控制用

class PrettyMenu
{
public:
    void changeBackground(std::istream& imgSrc);

private:
    Mutex mutex;
    Image *bgImage;
    int imageChanges; // 背景图像改变的次数
};

下面是 PrettyMenu 的 changeBackground 函数的一个可能实现:

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    lock(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);
    unlock(&mutex);
}

从异常安全性的角度来看,这个实现很差。异常安全有两个条件,当异常被抛出时,带有异常安全性的函数会

  • 不泄露任何资源。上述代码中一旦 “new Image(imgSrc)” 导致异常,对 unlock 的调用就绝不会执行。
  • 不允许数据败坏。如果 “new Image(imgSrc)” 导致异常,bgImage 就指向一个已经被删除的对象,而 imageChanges 也已经被累加,而其实并没有新的图像被成功安装起来。

解决资源泄漏的问题很容易,因为条款 14 导入了 Lock 类作为一种 “确保互斥锁被及时释放” 的方法。在专注解决数据败坏问题前,必须先面对一些用来定义选项的术语。

异常安全函数提供以下三个保证之一:

  • 基本承诺。如果异常抛出,程序内的任何事物仍然保持在有效状态下,没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态,然而程序的确切状态恐怕不可预料。比方可以撰写 changeBackground 使得一旦有异常被抛出,PrettyMenu 对象可以继续拥有原背景图像或是令它拥有某个缺省背景图像,但客户无法预期哪一种情况。
  • 强烈保证。如果异常被抛出,程序状态不改变,调用这样的函数需要有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回滚到调用函数之前的状态。
  • 不抛掷保证。承诺不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型身上的所有操作都提供 nothrow 保证,这是异常安全代码中一个必不可少的关键基础材料。给函数声明添加上 throw() 并不意味着函数绝不会抛出异常,而是说如果其抛出异常,将是严重错误,会有特定函数被调用。函数的声明式并不能够说明它是否正确的、可移植的或高效的,也不能够说明它是否提供任何异常安全性保证,所有那些性质都由函数的实现决定,无关乎声明。

重新编排 changeBackground 内的语句次序后,使得更换图像之后才累加 imageChanges。一般而言这是个好策略:不要为了表示某件事情发生而改变对象状态,除非那件事情真的发生了

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    Lock mylock(&mutex);
    bgImage.reset(new Image(imgSrc));
    ++imageChanges;
}

注意不需要手动 delete 旧图像,这个动作已经由智能指针内部处理掉了,此外删除动作只发生在新图像被成功创建之后。美中不足的是参数 imgSrc,如果 Image 构造函数抛出异常,有可能输入流的读取记号已被移走,而这样的状态改变对程序其余部分是一种可见的改变,在 changeBackground 解决该问题之前只提供基本的异常安全保证。

有一种一般化的设计策略很典型地会导致强烈保证,该策略被称为 copy and swap。原则很简单:为打算修改的对象原件做出一份副本,在那副本身上做一切必要修改,若有任何修改动作抛出异常,原对象仍保持未改变的状态,待所有改变都成功之后,再将修改过的副本与原件在一个不抛出异常的操作中 swap。

实现上通常是将所有 “隶属对象的数据” 从原对象放进另一个对象内,然后赋予原对象一个指针指向实现对象,也即 pimpl idiom

struct PMImpl {
    std::shared_ptr<Image> bgImage;
    int imageChanges;
};
class PrettyMenu
{
    ...
private:
    Mutex mutex;
    std::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
    using std::swap;
    Lock mylock(&mutex);
    std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
    pNew->bgImage.reset(new Image(imgSrc)); // 修改副本
    ++pNew->imageChanges;
    swap(pImpl, pNew);
}

copy-and-swap 策略是对对象状态做出 “全有或全无” 改变的一个很好办法,但一般而言它并不保证整个函数有强烈的异常安全性。假如某函数内调用了两个函数

void someFunc()
{
    ...   // 对 local 状态做一份副本
    f1();
    f2();
    ...   // 将修改后的状态置换过来
}

很显然,只要 f1 或 f2 的异常安全性比 “强烈保证” 低,someFunc 就不具备强烈异常安全性。即便是 f1 和 f2 都提供强烈的异常安全性,情况并不就此好转,毕竟如果 f1 圆满结束,程序状态在任何方面都有可能有所改变,因此如果 f2 随后抛出异常,程序状态和 someFunc 被调用前并不相同,甚至当 f2 没有改变任何东西时也是如此。问题出在 side effects 上,如果函数只操作局部性状态,便相对容易地提供强烈保证,但是当函数对非局部性数据有连带影响时,提供强烈保证就困难得多。另一个议题是效率,copy-and-swap 得耗用可能无意愿供应的时间和空间。当 “强烈保证” 不切实际时,就必须提供 “基本保证”。

须记

  • 异常安全函数即使发生异常也不会泄漏资源或允许任何数据结构败坏,它分为三种可能的保证:基本型、强烈型、不抛异常型。
  • “强烈保证” 往往能够以 copy-and-swap 实现出来,但它并非对所有函数都可实现或具备现实意义。
  • 函数提供的 “异常安全保证” 通常只等于其所调用的各个函数的 “异常安全保证” 中的最弱者。

条款(三十):透彻了解 inlining 的里里外外

inline 可能增加 object code 大小,在一台内存受限的机器上,过度热衷 inlining 会造成程序体积太大,即使拥有虚拟内存,inline 造成的代码膨胀亦会导致额外的换页行为,降低指令高速缓存装置的命中率,以及伴随这些而来的效率损失。定义于类内的函数隐喻为 inline,friend 也可以定义于类内,如果真是那样,它们也是被隐喻声明为 inline。大部分编译器拒绝将复杂函数 inlining,譬如带有循环或递归的函数,而且 virtual 函数也不能是 inline。构造函数和析构函数不应该被声明为 inline。

程序库设计者必须评估将函数声明为 inline 的冲击:inline 函数无法随着程序库的升级而升级,改变其意味着重新编译相关程序,而 non-inline 只需重新链接就好,对于动态链接,升级版函数甚至可以不知不觉地被应用程序采纳。

对程序开发而言,调试器面对 inline 函数都束手无策,因为不能在一个并不存在的函数内设立断点。

不要忘记 80-20 经验法则,目标应当是找出这可以有效增进程序整体效率的 20% 代码,然后将它 inline 或竭尽所能地将它瘦身,但除非选对目标,否则一切都是徒劳。

须记

  • 将大多数 inlining 限制在小型、被频繁调用的函数身上,这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
  • 不要只因为函数模板出现在头文件,就将它们声明为 inline。

条款(三十一):将文件间的编译依存关系降至最低

采用 pimpl idiom 可以将对象实现细节隐藏在一个指针背后,例如

#include <string>
#include <memory>
class PersonImpl;
class Date;
class Address;

class Person
{
public:
    Person(const std::string& name, const Date& birthday, const Address& addr);
    std::string name() const;
    std::string birthDate() const;
    std::string address() const;

private:
    std::shared_ptr<PersonImpl> pImpl;
};

这种设计之下,Person 的客户就完全与 Dates,Addresses 和 Persons 的实现细节分离了,这些类的任何修改都不需要 Person 客户代码重新编译,此外由于客户无法看到 Person 的实现细节,也就不可能写出什么 “取决于那些细节” 的代码。这真正是 “接口与实现分离”!

该分离的关键在于以 “声明的依存性” 替换 “定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式相依。其他每一个策略都源于该策略:

  • 如果使用对象引用或对象指针可以完成任务,就不要使用对象。可以只靠一个类型声明式就定义出指向该类型的引用和指针,但是如果定义该类型的对象,则需要用到该类型的定义式。
  • 如果能够,尽量以类声明式替换类定义式。
  • 为声明式和定义式提供不同的头文件。只含声明式的那个头文件名为 “xxxfwd.h”,命名方式取自 C++ 标准库的头文件 <iosfwd>,其内含 iostream 各组件的声明式,其对应定义则分布在若干不同的头文件内,包括 <iostream>、<fstream>、<sstream> 和 <streambuf>。

像 Person 这样的 handle classes 将真正工作交由实现类:

#include "Person.h"
#include "PersonImpl.h"

Person::Person(const std::string& name, const Date& birthday, const Address& addr) : pImpl(new PersonImpl(name, birthday, addr))
{}
std::string Person::name() const
{
    return pImpl->name();
}

另一个制作 handle class 的办法是另 Person 成为一种特殊的 abstract base class,称为 interface class,这种类的目的是详细地一一描述派生类的接口,因此它不带成员变量,也没有构造函数,只有一个 virtual 析构函数和一组纯虚函数,用来叙述整个接口。一个针对 Person 而写的 interface class 或许看起来像

class Person
{
public:
    virtual ~Person();
    virtual std::string name() const = 0;
    virtual std::string birthday() const = 0;
    virtual std::string address() const = 0;
};

该类的客户必须以 Person 的指针和引用来撰写应用程序,因为它不可能针对内含纯虚函数的 Person 类创建对象,就像 handle classes 的客户一样,除非 interface class 的接口被修改,否则客户不需要重新编译。Interface class 的客户通常采用工厂函数取得指向动态分配所得对象的指针。

须记

  • 支持 “编译依存最小化” 的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 handle class  和 interface class。
  • 程序库头文件应该以 “完全且仅有声明式” (full and declaration-only forms)的形式存在,这种做法不论是否涉及模板都适用。

Part Six: Inheritance and Object-Oriented Design

条款(三十二):确定 public 继承塑模出 is-a 关系

如果令类 D 以 public 形式继承类 B,便是告知编译器,每一个类型为 D 的对象同时也是一个类型为 B 的对象,反之不成立。考虑问题:类 Square 应该以 public 形式继承类 Rectangle 吗?

class Rectangle
{
public:
    virtual void setHeight(int newHeight);
    virtual void setWidth(int newWidth);
    virtual int height() const;
    virtual int width() const;
};

void makeBigger(Rectangle& r) // used to increase area
{
    int oldHeight = r.height();
    r.setWidth(r.width() + 10);
    assert(r.height() == oldHeight);
}

显然,上述的 assert 结果永远为真,因为 makeBigger 只改变 r 的宽度,不改变 r 的高度,考虑下面这段允许正方形被视为一种矩形的代码:

class Square : public Rectangle { ... };

Square s;
assert(s.width() == s.height());
makeBigger(s);
assert(s.width() == s.height());

根据定义,第二个 assert 的结果应该永远为真,但现在遇上了一个问题:如何调解下面各个 assert 判断式

  • 调用 makeBigger 之前,s 的高度和宽度相同;
  • 在 makeBigger 函数内,s 的宽度改变,但高度不变;
  • makeBigger 返回之后,s 的高度再次和其宽度相同。

本例的根本困难是某些可施行于矩形身上的事情(宽度可独立于高度被更改)却不可能施行于正方形身上(宽度总是和高度一样),但是 public 继承主张,能够施行于基类对象身上的每件事情,也可以施行于派生类对象身上。

须记

  • public 继承意味 is-a,适用于基类身上的每一件事情一定也适用于派生类身上,因为每一个派生类对象也都是一个基类对象。

条款(三十三):避免遮掩继承而来的名称

当位于一个派生类成员函数内指涉基类内的某物时,编译器可以找到所指涉的东西,因为派生类作用域被嵌套在基类作用域内。

class Base
{
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
};

class Derived : public Base
{
public:
    virtual void mf1();
    void mf3();
};

上述代码中 Derived 重载了 mf3,那是一个继承而来的 non-virtual 函数,根据以作用域为基础的 “名称遮掩规则” ,Base 类内所有名为 mf1 和 mf3 的函数都被 Derived 类内 mf1 和 mf3 函数遮掩掉了:

Derived d;
int x;
...
d.mf1();  // ok, Derived::mf1
d.mf1(x); // error
d.mf2();  // ok, Base::mf2
d.mf3();  // ok, Derived::mf3
d.mf3(int); // error

可以在派生类中使用 using 声明式达成目标

class Derived : public Base
{
public:
    using Base::mf1;
    using Base::mf3;
    ...
}

有时候并不希望继承基类的所有函数,在 public 继承下,这绝不可能发生,但是在 private 继承之下却是有可能的,假设 Derived 以 private 继承 Base,而 Derived 唯一想继承的 mf1 是那个无参数版本,using 声明式会令继承而来的给定名称的所有同名函数在 Derived 中都可见,此处需要不同的技术,即一个简单的 forwarding 函数

class Derived : private Base
{
public:
    virtual void mf1() { Base::mf1(); }
};
...
Derived d;
int x;
d.mf1();  // ok, Derived::mf1
d.mf1(x); // error

须记

  • 派生类内的名称会遮掩基类内的名称,在 public 继承下无人希望如此。
  • 为了让被遮掩的名称重见天日,可使用 using 声明式或 forwarding 函数。

条款(三十四):区分接口继承和实现继承

成员函数的接口总是会被继承。声明一个 pure virtual 函数的目的是为了让派生类只继承函数接口。声明简朴的 impure virtual 函数的目的是让派生类继承该函数的接口和缺省实现。声明 non-virtual 函数的目的是为了令派生类继承函数的接口及一份强制性实现。

须记

  • 接口继承和实现继承不同,在 public 继承之下,派生类总是继承基类的接口。
  • pure virtual 函数只具体指定接口继承。
  • impure virtual 函数具体指定接口继承及缺省实现继承。
  • non-virtual 函数具体指定接口继承及强制性实现继承。

条款(三十五):考虑 virtual 函数以外的其他选择

假设正在设计一个游戏,打算为游戏内的人物设计一个继承体系,游戏中人物被伤害或其他因素而降低健康状态的情况并不罕见,需要提供一个 healthValue 成员函数返回一个整数表示人物的健康程度,由于不同的人物可能以不同的方式计算他们的健康指数,将 healthValue 声明为 virtual 似乎是再明白不过的做法,让我们考虑其他一些解法。

藉由 Non-Virtual Interface 手法实现 Template Method 模式

该流派主张 virtual 函数应该总是 private,较好的设计是保留 healthValue 为 public 成员函数,但让它成为 non-virtual,并调用一个 private virtual 函数进行实际工作

class GameCharacter
{
public:
    int healthValue() const
    {
        ...
        int retVal = doHealthValue();
        ...
        return retVal;
    }

private:
    virtual int doHealthValue() const { ... }
};

该设计令客户通过 public non-virtual 成员函数间接调用 private virtual 函数,称为 non-virtual interface (NVI) 手法,是 Template Method 设计模式的一个独特表现形式,称这个 non-virtual 函数为 virtual 函数的 wrapper。NVI 手法的一个优点隐藏在上述的 “做一些事前工作” 和 “做一些事后工作” 之中,“事前工作” 可以包括锁定互斥锁、制造日志记录项、验证类的约束条件、验证函数先决条件等,“事后工作” 可以包含解除互斥锁、验证函数事后条件等。在 NVI 手法下其实也没有必要让 virtual 函数一定得是 private。

藉由 Function Pointers 实现 Strategy 模式

一个更戏剧性的设计主张 “人物健康指数的计算与人物类型无关”。

class GameCharacter; // forward declaration
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter
{
public:
    typedef int (*HealthCalcFunc)(const GameCharacter&);
    explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc) : healthFunc(hcf) {}
    int healthValue() const { return healthFunc(*this); }

private:
    HealthCalcFunc healthFunc;
};

同一人物类型的不同对象可以有不同的健康计算函数,某人物的健康指数计算函数可以在运行期变更,例如 GameCharacter 可提供一个成员函数 setHealthCalculator,用来替换当前的健康指数计算函数。

藉由 std::function 完成 Strategy 模式

将前种做法中的 typedef 更改为

typedef std::function<int (const GameCharacter&)> HealthCalcFunc;

古典的 Strategy 模式

将继承体系内的 virtual 函数替换为另一个继承体系中的 virtual 函数。

class HealthCalcFunc
{
public:
    virtual int calc(const GameCharacter& gc) const;
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter
{
public:
    explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc) : pHealthCalc(phcf) {}
    int healthValue() const { return pHealthCalc->calc(*this); }

private:
    HealthCalcFunc *pHealthCalc;
};

该解法的吸引力在于,熟悉标准 Strategy 模式的人很容易辨认它,而且它还提供 “将一个既有的健康算法纳入使用” 的可能性 —— 只要为 HealthCalcFunc 继承体系添加一个派生类即可。

须记

  • virtual 函数的替代方案包括 NVI 手法及 Strategy 设计模式的多种形式。NVI 手法自身是一种特殊形式的 Template Method 设计模式。
  • 将机能从成员函数移到 class 外部函数,带来的一个缺点是,非成员函数无法访问 class 的 non-public 成员。
  • std::function 对象的行为就像一般函数指针,这样的对象可接纳 “与给定目标签名式兼容” 的所有可调用实体。

条款(三十六):绝不重新定义继承而来的 non-virtual 函数

non-virtual 函数是静态绑定的,而 virtual 函数是动态绑定的。

须记

  • 绝对不要重新定义继承而来的 non-virtual 函数。

条款(三十七):绝不重新定义继承而来的缺省参数值

virtual 函数系动态绑定,而缺省参数值却是静态绑定。于是可能会在 “调用一个定义于派生类内的 virtual 函数” 的同时,却使用基类为它所制定的缺省参数值。NVI 手法可以避免此类问题,因为 non-virtual 函数绝对不被重写。

须记

  • 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而 virtual 函数 —— 唯一应该重写的东西 —— 却是动态绑定。

条款(三十八):通过组合塑模出 has-a 或根据某物实现出

当组合发生于应用域内的对象之间,表现出 has-a 关系;当它发生于实现域内则是表现出 is-implemented-in-terms-of 的关系。

假设需要写一个自己的 Set,一种方式便是在底层采用 std::list,但是它们之间并非是 is-a 关系,所以 public 继承不适合,正确的做法是 Set 对象根据 std::list 对象实现出来

template<typename T>
class Set
{
public:
    bool member(const T& item) const
    {
        return std::find(rep.begin(), rep.end(), item) != rep.end();
    }
    void insert(const T& item)
    {
        if (!member(item)) rep.push_back(item);
    }
    void remove(const T& item)
    {
        typename std::list<T>::iterator it = std::find(rep.begin(), rep.end(), item);
        if (it != rep.end()) rep.erase(it);
    }
    std::size_t size() const
    {
        return rep.size();
    }

private:
    std::list<T> rep;
};

须记

  • 组合的意义和 public 继承完全不同。
  • 在应用域,组合意味 has-a;在实现域,组合意味 is-implemented-in-terms-of。

条款(三十九):明智而审慎地使用 private 继承

private 继承意味 is-implemented-in-terms-of,其纯粹只是一种实现技术,private 继承意味只有部分被继承,接口部分应略去,其在软件设计层面上没有意义,意义只及于软件实现层面。

假设程序涉及 Widget,我们需要记录其每个成员函数被调用的次数,为完成这项工作,需要某种定时器,使我们知道收集统计数据的时候到了,在工具百宝箱中发现了 Timer 类

class Timer
{
public:
    explicit Timer(int tickFrequency);
    virtual void onTick() const;
};

每次滴答该 virtual 函数就会被调用,我们可以重新定义它以取出 Widget 当时的状态,为了让 Widget 重新定义 Timer 内的 virtual 函数,Widget 必须继承自 Timer,但 public 继承在此不对,必须以 private 继承 Timer

class Widget : private Timer
{
private:
    virtual void onTick() const;
};

重新定义仍然留在 private 作用域内,因为把 onTick 放进 public 接口内会误导客户端以为他们可以调用它。此处 private 继承并非绝对必要,可以用组合取而代之,只需在 Widget 内声明一个嵌套的 private 类,后者以 public 形式继承 Timer 并重新定义 onTick,然后放一个这种类型的对象于 Widget 内,就像

class Widget
{
private:
    class WidgetTimer : public Timer
    {
    public:
        virtual void onTick() const;
    };
    WidgetTimer timer;
};

这样设计有两个好处,第一是可以阻止 Widget 的派生类重新定义 onTick,如果采用 private 继承的方式就不可能实现;第二是可以将 Widget 的编译依存性降至最低,如果 WidgetTimer 被移出 Widget 外而 Widget 内含一个指针指向一个 WidgetTimer,Widget 可以只带着一个简单的 WidgetTimer 声明式,不再需要 #include 任何与 Timer 有关的东西。

须记

  • private 继承意味 is-implemented-in-terms-of(根据某物实现出),它通常比组合的级别低,但是当派生类需要访问 protected 基类的成员,或需要重新定义继承而来的 virtual 函数时,这么设计是合理的。
  • 和组合不同,private 继承可以造成 empty base 最优化,这对致力于 “对象尺寸最小化” 的程序库开发者而言,可能很重要。

条款(四十):明智而审慎地使用多重继承

多重继承可能会导致 “钻石型多重继承”

class File { ... };
class InputFile : public File { ... };
class OutputFile : public File { ... };
class IOFile : public InputFile, public OutputFile { ... };

任何时候一个继承体系中某个基类和某个派生类之间有一条以上的相通路线,就必须面对这样一个问题:是否打算让基类内的成员变量经由每一条路径被复制?假设 File 类有个成员变量 filename,那么 IOFile 内该有多少个该名称的数据呢?C++ 缺省做法是执行复制,如果这不是想要的,必须令那个带有此数据的类成为一个 virtual base class,为了这么做,必须令所有直接继承自它的类采用 virtual 继承

class File { ... };
class InputFile : virtual public File { ... };
class OutputFile : virtual public File { ... };
class IOFile : public InputFile, public OutputFile { ... };

C++ 标准库内含一个多重继承体系,结构如此例一致,只不过都是类模板,名称分别是 basic_ios, basic_istream, basic_ostream 和 basic_iostream。

virtual 继承中,virtual base 的初始化责任是由继承体系中的最低层(most derived)类负责,这暗示 (1) classes 若派生自 virtual bases 而需要初始化,必须认知其 virtual bases —— 不论那些 bases 距离多远,(2) 当一个新的派生类加入继承体系中,它必须承担其 virtual bases 的初始化责任。采用 virtual bases classes 的忠告很简单,第一非必要时不使用 virtual bases,第二当不得不采用时尽可能避免在其中放置数据,这么一来就不需要担心这些 classes 身上的初始化和赋值所带来的诡异事情了。

多重继承一个通情达理的应用是:将 “public 继承自某接口” 和 “private 继承自某实现” 结合在一起。

须记

  • 多重继承比单一继承复杂,它可能导致新的歧义性,以及对 virtual 继承的需要。
  • virtual 继承会增加大小、速度、初始化(及赋值)复杂度等成本,如果 virtual base classes 不带任何数据,将是最具实用价值的情况。
  • 多重继承的确有正当用途,其中一个情节涉及 “public 继承某个 interface class” 和 “private 继承某个协助实现的 class” 的两者组合。

Part Seven: Templates and Generic Programming

条款(四十一):了解隐式接口和编译器多态

面向对象编程世界总是以显式接口和运行期多态解决问题,例如:

class Widget
{
public:
    virtual size_t size() const;
    virtual void normalize();
    void swap(Widget& other); // see Item 25
};

void doProcessing(Widget& w)
{
    if (w.size() > 10 && w != someNastyWidget)
    {
        Widget temp(w);
        temp.normalize();
        temp.swap(w);
    }
}

可以这样说 doProcessing 内的 w

  • 由于 w 的类型被声明为 Widget,所以 w 必须支持Widget 接口,可以在源码中找到这个接口,因此称为显式接口。
  • 由于 Widget  的某些成员函数是 virtual,w 对那些函数的调用将表现出运行期多态。

Templates 及泛型编程的世界,与面向对象有根本上的不同,当把 doProcessing 从函数转变成函数模板后

tempalte<typename T>
void doProcessing(T& w)
{
    if (w.size() > 10 && w != someNastyWidget)
    {
        T temp(w);
        temp.normalize();
        temp.swap(w);
    }
}

现在怎么说 doProcesing 内的 w 呢?

  • w 必须支持哪一种接口,系由 template 中执行于 w 身上的操作来决定,该例中似乎 w 的类型 T 必须支持 size, normalize 和 swap 成员函数、copy 构造函数、inequality 比较,这并非完全正确,但这一组表达式(对此 template 而言必须有效编译)便是 T 必须支持的一组隐式接口。
  • 凡涉及 w 的任何函数调用,有可能造成模板实例化(instantiated),使这些调用得以成功,这样的实例行为发生在编译期。以不同的模板参数实例化函数模板会导致调用不同的函数,这便是编译期多态。

隐式接口并不基于函数签名式,而是由有效表达式组成,再次看看 doProcessing 模板一开始的条件

T 的隐式接口看来好像有这些约束:

  • 它必须提供一个名为 size 的成员函数,该函数返回一个整数值。
  • 它必须支持一个 operator!= 函数,用来比较两个 T 对象

感谢运算符重载带来的可能性,这两个约束都不需要满足。虽然 T 必须支持 size 成员函数,然而这个函数也可能从基类继承而得,该成员函数不需返回一个整数值,甚至不需返回一个数值类型,它唯一需要做的是返回一个类型为 X 的对象,而 X 对象加上一个 int(10 的类型)必须能够调用一个 operator>。这个 operator> 不需要非得取得一个类型为 X 的参数不可,因为它也可以取得类型 Y 的参数,只要存在一个隐式转换能够将类型 X 的对象转换为类型 Y 的对象!同样道理,T 并不需要支持 operator!=,以下这样也是可以的:operator!= 接受一个类型为 X 的对象和一个类型为 Y 的对象,T 可被转换为 X 而 someNastyWidget 的类型可被转换为 Y,这样就可以有效调用 operator!=。

整体确认表达式约束条件却很容易,无论 “w.size() > 10 && w != someNastyWidget” 导致什么,它都必须与 bool 兼容,这是 template doProcessing 加诸于其类型参数 T 的隐式接口的一部分,doProcessing 要求的其他隐式接口:copy 构造函数、normalize 和 swap 也都必须对 T 类型对象有效。

须记

  • classes 和 templates 都支持接口和多态。
  • 对 classes 而言接口是显式的,以函数签名为中心,多态则是通过 virtual 函数发生于运行期。
  • 对 template 参数而言,接口是隐式的,奠基于有效表达式,多态则是通过模板实例化和函数重载解析发生于编译期。

条款(四十二):了解 typename 的双重意义

在 template 声明式中,class 和 typename 没有不同,然而有时候一定得使用 typename。为了解其时机,必须先谈谈可以在模板内指涉的两种名称。假设有个函数模板接受一个 STL 兼容容器为参数,容器内持有的对象可被赋值为 ints,进一步假设这个函数仅仅是打印其第二元素值,下面是实践这个想法的一种方式:

template<typename C>
void print2nd(const C& container)
{
    if (container.size() >= 2)
    {
        C::const_iterator iter(container.begin());
        ++iter;
        int value = *iter;
        std::cout << value;
    }
}

代码中 iter 的类型是 C::const_iterator,实际是什么必须取决于模板参数 C,模板内出现的名称如果依赖于某个模板参数,称之为从属名称,如果从属名称在类内呈嵌套状,称为嵌套从属名称,C::const_iterator 就是这样一个名称;value 其类型是 int,并不依赖于任何模板参数的名称,这样的名称是非从属名称(non-dependent names)。

嵌套从属名称有可能导致解析困难:

template<typename C>
void print2nd(const C& container)
{
    C::const_iterator *x;
}

看起来好像我们声明一个 x 局部变量,它是个指针,但它之所以被那么认为,只因为我们已经知道 C::const_iterator 是个类型,如果 C::const_iterator 不是个类型呢?如果 C 有个静态成员变量恰巧被命名为 const_iterator,那样的话上述代码就不再是声明一个局部变量,而是一个相乘动作。在我们知道 C 是什么之前,没有任何办法可以知道 C::const_iterator 是否为一个类型,而当编译器开始解析模板 print2nd 时,尚未确知 C 是什么东西,C++ 有个规则可以解析这一歧义状态:如果解析器在模板中遭遇一个嵌套从属名称,它便假设这名称不是个类型,除非告诉它是。所以缺省情况下嵌套从属名称不是类型,此规则有个例外,稍后会提到。

若要矫正这个形势,就必须告诉 C++ 说 C::const_iterator 是个类型,只要紧邻它之前放置关键字 typename 即可:

template<typename C>
void print2nd(const C& container)
{
    typename C::const_iterator *x;
}

“typename 必须作为嵌套从属类型名称的前缀” 这一规则的例外是,typename 不可以出现在 base classes list 内的嵌套从属类型名称之前,也不可在 member initialization list 中作为 base class 修饰符:

template<typename T>
class Derived : public Base<T>::Nested // no typename
{
public:
    explicit Derived(int x) : Base<T>::Nested(x) // no typename
    {
        typename Base<T>::Nested temp;
    }
};

看看最后一个真实程序中看到的代表性 typename 例子,假设正在撰写一个函数模板,它接受一个迭代器,而我们打算为该迭代器指涉的对象做一份本地拷贝

template<typename IterT>
void workWithIterator(IterT iter)
{
    typename std::iterator_traits<IterT>::value_type temp(*iter);
}

std::iterator_traits<IterT>::value_type 是标准 traits class 的一种运用,相当于说 “类型为 IterT 之对象所指之物的类型”。

须记

  • 声明 template 参数时,前缀关键字 class 和 typename 可互换。
  • 使用关键字 typename 标识嵌套从属类型名称,但不得在 base class lists 或 member initialization list 内以它作为 base class 修饰符。

条款(四十三):知道如何访问模板化基类内的名称

假设需要撰写一个程序,它能够传送信息到若干不同的公司去,信息要不译成密码,要不就是未经加工的文字,如果编译期有足够的信息来决定哪一个信息传至哪一家公司,就可以采用基于模板的解法:

class CompanyA
{
public:
    void sendCleartext(const std::string& msg);
    void sendEncrypted(const std::string& msg);
};
class CompanyB
{
public:
    void sendCleartext(const std::string& msg);
    void sendEncrypted(const std::string& msg);
};

class MsgInfo { ... };

template<typename Company>
class MsgSender
{
public:
    void sendClear(const MsgInfo& info)
    {
        std::string msg;
        // generate msg based on info
        Company c;
        c.sendCleartext(msg);
    }
    void sendSecret(const MsgInfo& info) { ... }
};

template<typename Company>
class LoggingMsgSender : public MsgSender<Company>
{
public:
    void sendClearMsg(const MsgInfo& info)
    {
        // write to log before send
        sendClear(info); // 调用基类函数,这段代码无法通过编译
        // write to log after send
    }
};

上述代码问题在于,当编译器遭遇类模板 LoggingMsgSender 定义式时,并不知道它继承什么样的类,如果不知道 Company 是什么,就无法知道 class MsgSender<Company> 是否有个 sendClear 函数。假设有个 class CompanyZ 坚持使用加密通讯

class CompanyZ
{
public:
    void sendEncrypted(const std::string& msg);
};

一般性的 MsgSender 模板对 CompanyZ 并不合适,欲矫正这个问题,可以针对 CompanyZ 产生一个 MsgSender 特化版:

template<>
class MsgSender<CompanyZ>
{
public:
    void sendSecret(const MsgInfo&) { ... }
};

这就是所谓的模板全特化(total template specialization)。

现在 MsgSender 针对 CompanyZ 进行了全特化,再次考虑派生类 LoggingMsgSender,当基类被指定为 MsgSender<CompanyZ> 时代码仍不合法,这就是为什么 C++ 拒绝这个调用:它知道基类模板有可能被特化,而那个特化版本可能不提供和一般性模板相同的接口。因此它往往拒绝在模板化基类(templatized base classes)内寻找继承而来的名称。

为了重头来过,必须有某种办法令 C++ “不进入 templatized base classes 观察” 的行为失效。有三个办法,第一是在基类函数调用动作之前加上 this->;第二是使用 using 声明式;第三是明确指出被调用的函数位于基类内,但这往往是最不让人满意的一种解法,因为如果被调用的是 virtual 函数,上述的显式资格修饰会关闭 “virtual 绑定行为”。

从名称可视点的角度出发,上述每一个解法做的事情都相同:对编译器承诺 “base class template 是任何特化版本都将支持其一般(泛化)版本所提供的接口”。但如果这个承诺最终未被实践出来,往后在调用处编译器会报错:

LoggingMsgSender<CompanyZ> zMsgSender;
MsgInfo msgData;
zMsgSender.sendClearMsg(msgData); // error

根本而言,本条款探讨的是,面对 “指涉 base class members” 之无效引用,编译器的诊断时间可能发生在早期(当解析派生类模板的定义式时),也可能发生在晚期(当那些模板被特定的模板实参实例化时)。

须记

  • 可在 derived class templates 内通过 “this->” 指涉 base class templates 内的成员名称,或藉由一个明确写出的 “base class 资格修饰符” 完成。

条款(四十四):将参数无关的代码抽离 templates

使用模板可能会导致代码膨胀:其二进制码带着重复的代码、数据,其结果有可能源码看起来合身而整齐,但目标码却不是那么回事。模板代码中重复是隐晦的,毕竟只存在一份模板源码。例如,想为固定尺寸的正方矩阵编写一个模板,该矩阵的性质之一是支持逆矩阵运算:

template<typename T, size_t n>
class SquareMatrix
{
public:
    void invert();
};

SquareMatrix<double, 5> sm1;
sm1.invert();
SquareMatrix<double, 10> sm2;
sm2.invert();

这是模板引出代码膨胀的一个典型例子,下面是第一次修改:

template<typename T>
class SquareMatrixBase
{
protected:
    void invert(size_t matrixSize);
};

template<typename T, size_t n>
class SquareMatrix : private SquareMatrixBase<T>
{
private:
    using SquareMatrixBase<T>::invert;

public:
    void invert() { this->invert(); }
};

SquareMatrixBase::invert 只是企图成为避免派生类代码重复的一种方法,所以它以 protected 替换 public,调用它而造成的额外成本应该是 0,因为派生类的 inverts 调用基类版本时用的是 inline 调用。还有一些棘手的问题没有解决:SquareMatrixBase::invert 如何知道该操作什么数据?它如何知道哪个特定矩阵的数据在哪儿?想必只有派生类知道,派生类如何联络其基类做逆运算操作?

一个办法是令 SquareMatrixBase 贮存一个指针,指向矩阵数值所在的内存,而只要它存储了那些东西,也就可能存储矩阵尺寸,看起来像是

template<typename T>
class SquareMatrixBase
{
protected:
    SquareMatrixBase(size_t n, T *pMem) : size(n), pData(pMem) {}
    void setDataPtr(T *ptr) { pData = ptr; }

private:
    size_t size;
    T *pData;
};

这允许派生类决定内存分配方式,某些实现版本也许会决定将矩阵数据存储在 SquareMatrix 对象内部:

template<typename T, size_t n>
class SquareMatrix : private SquareMatrixBase<T>
{
public:
    SquareMatrix() : SquareMatrixBase<T>(n, data) {}

private:
    T data[n*n];
};

这种类型的对象不需要动态分配内存,但对象自身可能非常大,另一种做法是把每一个矩阵的数据放进 heap:

template<typename T, size_t n>
class SquareMatrix : private SquareMatrixBase<T&>
{
public:
    SquareMatrix() : SquareMatrixBase<T>(n, 0), pData(new T[n*n])
    {
        this->setDataPtr(pData.get());
    }

private:
    boost::scoped_array<T> pData;
};

须记

  • templates 生成多个 classes 和多个函数,所以任何 template 代码都不应该与某个造成膨胀的 template 参数产生依赖关系。
  • 因非类型模板参数而造成的代码膨胀,往往可消除,做法是以函数参数或类成员变量替换模板参数。
  • 因类型模板参数而造成的代码膨胀,往往可降低,做法是让带有完全相同二进制表述的实例类型共享实现码。

条款(四十五):运用成员函数模板接受所有兼容类型

真实指针支持隐式转换,智能指针模拟隐式转换稍稍有点麻烦,需要将 SmartPtr classes 之间的转换关系明确地编写出来。

Templates 和泛型编程

为 SmartPtr 写一个构造函数模板(所谓的成员函数模板):

template<typename T>
class SmartPtr
{
public:
    template<typename U> // member template
    SmartPtr(const SmartPtr<U>& other);
};

上述代码的含义:对任何类型 T 和任何类型 U,可以根据 SmartPtr<U> 生成一个 SmartPtr<T>,这称为泛化拷贝构造函数。上面的泛化拷贝构造函数并未被声明为 explicit,那是蓄意的,因为原始指针类型之间的转换是隐式转换,无需明确写出转型动作。

成员函数模板的另一个角色是支持赋值操作,例如 std::shared_ptr 支持所有 “来自兼容的内置指针、std::shared_ptr、std::auto_ptr、std::weak_ptr” 的构造行为,下面是一份 std::shared_ptr 的摘录

template<class T>
class shared_ptr
{
public:
    template<class Y> explicit shared_ptr(Y *p);
    template<class Y> shared_ptr(shared_ptr<Y> const& r);
    template<class Y> explicit shared_ptr(weak_ptr<Y> const& r);
    template<class Y> explicit shared_ptr(auto_ptr<Y>& r);
    template<class Y> shared_ptr& operator=(shared_ptr<Y> const& r);
    template<class Y> shared_ptr& operator=(auto_ptr<Y>& r);

上述所有构造函数都是 explicit,唯有泛化拷贝构造函数除外,那意味着从某个 shared_ptr 类型隐式转换至另一个 shared_ptr 类型是被允许的,但从某个内置指针或从其他智能指针类型进行隐式转换则不被允许。另一个趣味点是传递给 shared_ptr 的构造函数和赋值操作符和 auto_ptrs 并未声明为 const,因为当复制一个 auto_ptr 时,它其实已经被改动了。

须记

  • 使用 member function templates 生成可接受所有兼容类型的函数。
  • 如果声明 member templates 用于泛化拷贝构造或泛化赋值操作,还是需要声明正常的拷贝构造函数和赋值操作符。

条款(四十六):需要类型转换时为模板定义非成员函数

为了让类型转换可能发生与所有实参身上,需要一个 non-member 函数,为了让这个函数被自动实例化,需要将它声明在类内部,而在类内部声明一个 non-member 函数的唯一办法就是令它成为一个 friend,将条款 24 中 Rational 类模板化了之后:

template<typename T>
class Rational
{
public:
    friend const Rational operator*(const Rational& lhs, const Rational& rhs)
    {
        return Rational(lhs.numerator() * rhs.numerator(),
                        lhs.denominator() * rhs.denominator());
    }
};

须记

  • 当编写一个类模板而它所提供的 “ 与此模板相关的” 函数支持 “所有参数之隐式类型转换” 时,将那些函数定义为类模板内部的 friend 函数。

条款(四十七):使用 traits classes 表现类型信息

STL 主要由 “用以表现容器、迭代器、算法” 的模板构成,但也覆盖若干工具性的模板,其中一个名为 std::advance,用来将迭代器移动某个给定距离:

template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d);

对于 5 种迭代器分类,C++ 标准库分别提供专属的 tag 结构加以区分:

struct input_iterator_tag {};
struct output_iterator_tag {};
struct forward_iterator_tag : public input_iterator_tag {};
struct bidirectional_iterator_tag : public forward_iterator_tag {};
struct random_access_iterator_tag : public bidirectional_iterator_tag {};

这些 structs 之间的继承关系是有效的 is-a 关系。现在回到 advance 函数,我们希望其针对随机访问迭代器做算术运算而不是循环反复递增或递减迭代器。traits 允许在编译期取得某些类型信息。这个技术的要求之一是对内置类型和用户自定义类型的表现必须一样好。traits 必须能够施行于内置类型意味着类型的 traits 信息必须位于类型自身之外,标准技术是把它放进一个模板及其一个或多个特化版本中,这样的模板在标准库中有多个,其中针对迭代器者被命名为 iterator_traits:

template<typename IterT>
struct iterator_traits;

iterator_traits 的运作方式是,针对每一个类型 IterT,在 struct iterator_traits<IterT> 内声明某个 typedef 名为 iterator_category,由它用来确认 IterT 的迭代器分类。iterator_traits 以两部分实现上述所言。首先它要求每一个用户自定义的迭代器类型必须嵌套一个 typedef 名为 iterator_category,用来确认适当的 tag 结构,例如 std::deque 的迭代器可支持随机访问,std::list 的迭代器可支持双向行进:

template< ... > // omit template parameters
class deque
{
public:
    class iterator
    {
    public:
        typedef random_access_iterator_tag iterator_category;
    };
};

template< ... > // omit template parameters
class list
{
public:
    class iterator
    {
    public:
        typedef bidirectional_iterator_tag iterator_category;
    };
};

至于 iterator_traits,只是鹦鹉学舌般地响应 iterator 类的嵌套式 typedef:

template<typename IterT>
struct iterator_traits {
    typedef typename IterT::iterator_category iterator_category;
};

这对用户自定义类型行得通,但对指针行不通,因为指针不可能嵌套 typedef,iterator_traits 的第二部分如下,其针对指针类型提供了一个偏特化版本:

template<typename IterT>
struct iterator_traits<IterT*> {
    typedef random_access_iterator_tag iterator_category;
};

现在应该知道如何设计并实现一个 traits class 了:

  • 确认若干希望将来可取得的类型相关信息。例如对迭代器而言,取得其分类。
  • 为该信息选择一个名称。例如 iterator_category。
  • 提供一个模板和一组特化版本(例如 iterator_traits),内含希望支持的类型相关信息。

好,现在有了 iterator_traits,可以采用函数重载来实践 advance,将重载函数命名为 doAdvance:

template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT& d, std::random_access_iterator_tag)
{
    iter += d;
}
template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT& d, std::bidirectional_iterator_tag)
{
    if (d >= 0) { while (d--) ++iter; }
    else { while (d++) --iter; }
}
template<typename IterT, typename DistT>
void doAdvance(IterT& iter, DistT& d, std::input_iterator_tag)
{
    if (d < 0) throw std::out_of_range("Negative distance");
    while (d--) ++iter;
}

 

由于 forward_iterator_tag 继承自 input_iterator_tag,所以上述 doAdvance 的 input_iterator_tag 版本也能够处理 forward 迭代器。有了这些 doAdvance 重载版本,advance 需要做的只是调用它们并额外传递一个对象,后者必须带有适当的迭代器分类,于是编译器运用重载解析机制调用适当的实现代码:

template<typename IterT, typename DistT>
void advance(Iter& iter, DistT d)
{
    doAdvance(iter, d, typename std::iterator_traits<IterT>::iterator_category());
}

现在可以总结如何使用一个 traits class 了:

  • 建立一个重载函数或函数模板(例如 doAdvance),彼此间的差异只在于各自的 traits 参数。令每个函数实现码与其接受之 traits 信息相应和。
  • 建立一个控制函数或函数模板(例如 advance),它调用上述那些重载函数并传递 trait class 所提供的信息。

traits 广泛应用于标准库,除了上述讨论的 iterator_traits,还有 char_traits 用来保存字符类型的相关信息,以及 numeric_limits 用来保存数值类型的相关信息。

须记

  • traits classes 使得类型相关信息在编译期可用,它们以 templates 和 templates 特化完成实现。
  • 整合重载技术后,traits classes 有可能在编译期对类型执行 if…else 测试。

条款(四十八):认识 template 元编程

Template metaprogramming(TMP)是编写 template-based C++ 程序并执行于编译期的过程。TMP 有两个伟大的效力。第一,它让某些事情更容易。第二,由于 template metaprograms 执行于编译期,因此可将工作从运行期转移到编译期,这导致的一个结果是,某些错误原本通常在运行期才能侦测到,现在可在编译期找出来,另一个结果是,使用 TMP 的 C++ 程序可能在每一方面都更高效:较小的可执行文件、较短的运行期、较少的内存需求,代价是编译时间变长了。

TMP 已经被证明是个 “图灵完全”(Turing-complete) 机器,意思是它的威力大到足以计算任何事物,使用 TMP 可以声明变量、执行循环、编写及调用函数,但这般构件相对于正常的 C++ 对应物看起来很是不同。为认识 “事物在 TMP 中如何运作”,让我们看看循环,TMP 没有真正的循环,所以循环效果藉由递归完成,TMP 循环并不涉及递归函数调用,而是涉及 “递归模板实例化”。

TMP 的起手程序是在编译期计算阶乘,该示例示范如何通过 “递归模板实例化” 实现循环,以及如何在 TMP 中创建和使用变量:

template<unsigned n>
struct Factorial {
    enum { value = n * Factorial<n-1>::value };
};

template<>
struct Factorial<0> {
    enum { value = 1 };
};

有了这个 template metaprogram,只要指涉 Factorial<n>::value 就可以得到 n 的阶乘值,循环发生在模板实例化 Factorial<n> 内部指涉另一个模板实例体 Factorial<n-1> 时,和所有良好递归一样,需要一个特殊情况结束递归,这儿就是特化体 Factorial<0>。可以这样使用 Factorial:

std::cout << Factorial<5>::value;
std::cout << Factorial<10>::value;

为求领悟 TMP 之所以值得学习,很重要的一点是先对它能够达成什么目标有一个比较好的理解。下面举出三个例子:

  • 确保量度单位正确。
  • 优化矩阵运算。
  • 可以生成客户定制的设计模式实现品。设计模式如 Strategy、Observer、Visitor 等都可以都可以以多种方式实现出来。运用所谓 policy-based design 之 TMP-based 技术,有可能产生一些模板用来表述独立的设计选项(policies),然后可以任意结合它们,导致模式实现品带着客户定制的行为。这项技术已被用来让若干模板实现出智能指针的行为政策(behavioral policies),用以在编译期间生成数以百计不同的智能指针类型。这项技术已经超越编程工艺领域如设计模式和智能指针,更广义地成为 generative programming 的一个基础。

TMP 或许永远不会成为主流,但对某些程序员 —— 特别是程序库开发人员 —— 几乎确定会成为他们的主要粮食。

须记

  • Template metaprogramming(TMP)可将工作由运行期移往编译期,因而得以实现早期错误检测和更高的执行效率。
  • TMP 可被用来生成 “基于政策选择组合”(based on combinations of policy choices)的客户定制代码,也可用来避免生成对某些特殊类型并不适合的代码。

Part Eignt: Customizing new and delete

条款(四十九):了解 new-handler 的行为

当 operator new 抛出异常以反映一个未获满足的内存需求之前,它会先调用一个客户指定的错误处理函数,一个 new-handler。为了指定这个函数,客户必须调用 set_new_handler 这个声明于 <new> 的标准库函数:

namespace std {
    typedef void (*new_handler)();
    new_handler set_new_handler(new_handler p) throw();
}

set_new_handler 是 “获得一个 new_handler 并返回一个 new_handler” 的函数,可以这样使用 set_new_handler:

void outOfMem()
{
    std::cerr << "Unable to satisfy request for memory.\n";
    std::abort();
}

std::set_new_handler(outOfMem);
int *pBigDataArray = new int[100000000];

该例中如果 operator new 无法为 100000000 个整数分配足够空间,outOfMem 会被调用,于是程序发出一个信息之后夭折。

当 operator new 无法满足内存申请时,它会不断调用 new-handler 函数,直到找到足够内存,引起反复调用的代码显示于条款 51。一个设计良好的 new-handler 函数必须做以下事情:

  • 让更多内存可被使用。实现此策略的一个做法是,程序一开始执行就分配一大块内存,而后当 new-handler 第一次被调用,将它们释还给程序使用。
  • 安装另一个 new-handler。只要调用 set_new_handler 安装另外一个 new-handler 替换自己,下次调用的 new-handler 将是最新安装的那个。(这个旋律的变奏之一是让 new-handler 修改自己的行为,于是当它下次被调用,就会做些不同的事情,譬如可让 new-handler 修改 “会影响 new-handler 行为” 的 static 数据、namespace 数据或 global 数据)。
  • 卸除 new-handler,将 NULL 指针传给 set_new_handler。一旦没有安装任何 new-handler,operator new 会在内存分配不成功时抛出异常。
  • 抛出 bad_alloc 异常。这样的异常不会被 operator new 捕捉,因此会被传播到内存索求处。
  • 不返回。通常调用 abort 或 exit。

有时候或许希望以不同的方式处理内存分配失败情况,希望被分配物属于哪个类而定:

class X
{
public:
    static void outOfMemory();
};
class Y
{
public:
    static void outOfMemory();
};
X *p1 = new X; // if fail to allocate, call X::outOfMemory
Y *p2 = new Y; // if fail to allocate, call Y::outOfMemory

C++ 不支持 class 专属的 new-handlers,可以自己实现出这种行为,只需令每一个类提供自己的 set_new_handler 和 operator new 即可。假设打算处理 Widget 类的内存分配失败情况

class Widget
{
public:
    static std::new_handler set_new_handler(std::new_handler p) throw();
    static void* operator new(size_t size) throw(std::bad_alloc);

private:
    static std::new_handler currentHandler;
};

std::new_handler Widget::currentHandler = NULL;

Widget 内的 set_new_handler 函数会将它获得的指针存储起来,然后返回先前存储的指针,这也是标准版 set_new_handler 的行为

std::new_handler Widget::set_new_handler(std::new_handler p) throw()
{
    std::new_handler oldHandler = currentHandler;
    currentHandler = p;
    return oldHandler;
}

最后,Widget 的 operator new 做以下事情:

  1. 调用标准 set_new_handler,告知 Widget 的错误处理函数,这会将 Widget 的 new-handler 安装为 global new-handler。
  2. 调用 global operator new,执行实际的内存分配,如果分配失败,会调用 Widget 的 new-handler,如果最终无法分配足够内存,会抛出一个 bad_alloc 异常,在此情况下 Widget 的 operator new 必须恢复原本的 global new-handler,然后再传播该异常。为确保原本的 new-handler 总是能够被重新安装回去,Widget 将 global new-handler 视为资源并运用资源管理对象防止资源泄露。
  3. 如果 global operator new 能够分配足够一个 Widget 对象所用的内存,Widget 的 operator new 会返回一个指针,指向分配所得。Widget 析构函数会管理 global new-handler,它会自动将 Widget 的 operator new 被调用前的那个 global new-handler 恢复回来。

下面以 C++ 代码再阐述一次

class NewHandlerHolder
{
public:
    explicit NewHandlerHolder(std::new_handler nh) : handler(nh) {}
    ~NewHandlerHolder() { std::set_new_handler(handler); }

private:
    std::new_handler handler;
    NewHandlerHolder(const NewHandlerHolder&); // forbid copying
    NewHandlerHolder& operator=(const NewHandlerHolder&); // forbid copying
};

这就使得 Widget 的 operator new 实现相当简单:

void* Widget::operator new(size_t size) throw(std::bad_alloc)
{
    NewHandlerHolder h(std::set_new_handler(currentHandler));
    return ::operator new(size); // 分配内存或抛出异常,恢复 global new-handler
}

Widget 的客户应该类似这样使用其 new-handling:

void outOfMem();
Widget::set_new_handler(outOfMem);
Widget *pw1 = new Widget; // if fail to allocate, call outOfMem
std::string *ps = new std::string; // if fail to allocate, call global new-handler
Widget::set_new_handler(NULL);
Widget *pw2 = new Widget; // if fail to allocate, throw bad_alloc

实现这一方案的代码并不因类的不同而不同,因此加以复用是个合理的构想,一个简单做法是建立起一个 “mixin” 风格的基类,然后将这个基类转换为模板,如此一来每个派生类都将获得实体互异的 class data 复件。

template<typename T>
class NewHandlerSupport
{
public:
    static std::new_handler set_new_handler(std::new_handler p) throw();
    static void* operator new(size_t size) throw(std::bad_alloc);

private:
    static std::new_handler currentHandler;
};

class Widget : public NewHandlerSupport<Widget> { ... };

上述 NewHandlerSupport 模板的实现如前面讨论的完全一致,模板参数 T 只是使得派生类拥有实体互异的 static 成员变量 currentHandler。有了它,为 Widget 添加 set_new_handler 支持能力只需继承自它即可。这种派生类继承自一个模板参数为自身的基类的技术称为 “怪异的循环模板模式”(curiously recurring template pattern; CRTP)。

直至 1993 年,C++ 都还要求 operator new 必须在无法分配足够内存时返回 NULL,新一代则应该抛出 bad_alloc 异常。C++ 标准委员会不想抛弃那些检测 NULL 的族群,于是提供了另一形式的 operator new,称为 “nothrow” 形式 —— 某种程度上是因为他们在 new 的场合使用了 nothrow 对象(定义于头文件 <new>)

Widget *pw1 = new Widget;
if (pw1 == NULL) ... // this test must failed
Widget *pw2 = new (std::nothrow) Widget;
if (pw2 == NULL) ... // this test may succeed

nothrow new 对异常的强制保证性并不高,使用它只能保证 operator new 不抛出异常,不保证随后调用的构造函数不抛出异常,因此其实没有运用 nothrow new 的必要。

须记

  • set_new_handler 允许客户指定一个函数,在内存分配无法获得满足时被调用。
  • nothrow new 是一个颇为局限的工具,因为它只适用于内存分配,后继的构造函数调用还是可能抛出异常。

条款(五十):了解 new 和 delete 的合理替换时机

下面是三个最常见的理由:

  • 用来检测运用上的错误。各式各样的编程错误可能导致数据 “overruns”(写入点在分配区块尾端之后)或 “underruns”(写入点在分配区块起点之前)。如果自行定义一个 operator news,便可超额分配内存,以额外空间(位于客户所得区块之前或后)放置特定 byte patterns(signatures)。operator deletes 便得以检查上述签名是否原封不动。
  • 为了强化性能。编译器自带的版本主要用于一般目的,它们不但可被长时间执行的程序接受,也可以被时间少于一秒的程序接受;它们必须处理一系列需求,包括大块内存、小块内存、大小混合型内存;它们必须接纳各种分配形态,范围从程序存活期间的少量区块动态分配,到大数量短命对象的持续分配和归还;它们必须考虑破碎问题(fragmentation),这最终导致程序无法满足大区块内存要求,即使彼时有总量足够但分散为许多小区块的自由内存。对某些应用程序而言,将自带的 new 和 delete 替换为定制版本,是获得重大性能提升的办法之一。
  • 为了收集使用上的统计数据。在一头栽进定制型 news 和 deletes 之前,理当先收集软件如何使用动态内存。分配区块的大小分布如何?寿命分布如何?它们倾向于 FIFO 次序或 LIFO 次序或随机次序来分配和归还?它们的运用形态是否随时间改变?任何时刻所使用的最大动态分配量是多少?自定义 operator new 和 operator delete 使得能够收集到这些信息。

下面是个定制型的 operator new 协助检测 “overruns” 或 “underruns”:

static const int signature = 0xdeadbeef;
typedef unsigned char Byte;
void* operator new(size_t size) throw(std::bad_alloc)
{
    size_t realSize = size + 2*sizeof(int);
    void *pMem = malloc(realSize);
    if (pMem == NULL)
        throw std::bad_alloc();
    *(static_cast<int*>(pMem)) = signature;
    *(reinterpret_cast<int*>(static_cast<Byte*>(pMem) + realSize - sizeof(int))) = signature;
    // 返回指针指向第一个 signature 之后的内存位置
    return static_cast<Byte*>(pMem) + sizeof(int);
}

这个 operator new 的缺点主要在于它疏忽了身为这个特殊函数所应该具备的特点如条款 49 所言,这儿真正重要的是齐位(alignment)以获得最佳效率。

开源领域中有许多内存管理器。Boost 程序库的 Pool 就是这样一个分配器,它对于最常见的 “分配大量小型对象” 很有帮助。

本条款的主题是,了解何时可在全局性的或类专属的基础上合理替换缺省的 new 和 delete,再次对答案做个摘要:

  • 为了检测运用错误。
  • 为了收集动态内存分配的使用统计信息。
  • 为了增加分配和归还的速度。
  • 为了降低缺省内存管理器带来的空间额外开销。
  • 为了弥补缺省分配器中的非最佳齐位。
  • 为了将相关对象成簇集中。
  • 为了获得非传统行为。

须记

  • 有许多理由需要写个自定义的 new 和 delete,包括改善性能、对 heap 运用错误进行调试、收集 heap 使用信息。

条款(五十一):编写 new 和 delete 时需固守常规

C++ 规定即使客户要求 0 bytes,operator new 也得返回一个合法的指针,下面是个 non-member operator new 的伪代码:

void* operator new(size_t size) throw(std::bad_alloc)
{
    if (size == 0)
        size = 1;
    while (true)
    {
        // try allocating size bytes
        if (/* allocate succeed */)
            return (/* a pointer pointing to the memory allocated */);
        new_handler globalHandler = set_new_handler(NULL);
        set_new_handler(globalHandler);
        if (globalHandler)
            (*globalHandler)();
        else
            throw std::bad_alloc();
    }
}

因为很不幸地没有任何办法可以直接取得 new-handling 函数指针,所以必须调用 set_new_handler 找出它来,拙劣但有效 —— 至少对单线程程序而言。若在多线程环境中,或许需要某种锁以便安全处置 new-handling 函数背后的全局数据结构。条款 49 谈到 operator new 内含一个无穷循环,上述伪代码表现出了这个循环。

许多人没有意识到 operator new 成员函数会被派生类继承,这回导致基类的 operator new 被调用以分配派生类对象:

class Base
{
public:
    static void* operator new(size_t size) throw(std::bad_alloc);
};
class Derived : public Base { ... };
Derived *p = new Derived; // call Base::operator new

如果基类专属的 operator new 并非设计用来对付上述情况,处理此情势的最佳做法是将 “内存申请量错误” 的调用行为改为采用标准 operator new:

void* Base::operator new(size_t size) throw(std::bad_alloc)
{
    if (size != sizeof(Base))
        return ::operator new(size);
    ...
}

这就是撰写 operator new 时需要奉行的规则,operator delete 情况更简单,需要记住的唯一事情就是 C++ 保证 “删除 NULL 指针永远安全”,下面是 non-member operator delete 的伪代码:

void operator delete(void *rawMemory) throw()
{
    if (rawMemory == NULL)
        return;
    ...
}

operator delete 的 member 版本只需多加一个动作检查删除数量即可:

void Base::operator delete(void *rawMemory, size_t size) throw()
{
    if (rawMemory == NULL)
        return;
    if (size != sizeof(Base))
    {
        ::operator delete(rawMemory);
        return;
    }
    ...
}

须记

  • operator new 应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用 new-handler,它也应该有能力处理 0 bytes 申请。类专属版本则还应该处理比正确大小更大的错误申请。
  • operator delete 应该在收到 NULL 指针时不做任何事。类专属版本则还应该处理比正确大小更大的错误申请。

条款(五十二):写了 placement new 也要写 placement delete

当写一个 new 表达式像这样:

Widget *pw = new Widget;

如果 operator new 调用成功,后继的构造函数抛出异常,前者分配的内存必须取消并恢复旧观,否则会造成内存泄漏,这个时候,客户没有能力归还内存,责任就落到了 C++ 运行期系统身上,后者会高高兴兴地调用 operator new 的相应 operator delete 版本,前提是它必须知道哪一个 operator delete 应当被调用。当开始声明非正常形式的 operator new 时,“究竟哪一个 delete 伴随着这个 new” 的问题便浮现了。

如果 operator new 接受的参数除了一定会有的 size_t 外还有其他,这便是个所谓的 placement new。众多的 placement new 版本中特别有用的一个是 “接受一个指针指向对象该被构造之处”,那样的operator new 声明如下:

void* operator new(size_t, void *pMemory) throw(); // placement new

这个版本的 new 已经被纳入 C++ 标准库,只要 #include <new> 就可以使用它,其用途之一是负责在 vector 的未使用空间上创建对象,它同时也是最早的 placement new,还是 placement new 术语命名来源:一个特定位置上的 new。

假设写了一个类专属的 operator new,要求接受一个 ostream 来 log 相关分配信息,同时又写了一个正常形式的类专属 operator delete:

class Widget
{
public:
    static void* operator new(size_t size, std::ostream& logStream) throw(std::bad_alloc);
    static void operator delete(void *pMemory, size_t size) throw();
};

这个类将引起微妙的内存泄漏,考虑如下客户代码:

Widget *pw = new (std::cerr) Widget;

如果 Widget 构造函数抛出异常,运行期系统有责任取消 operator new 的分配并恢复旧观,然而无法得知真正被调用的那个 operator new 如何运作,因此它无法取消分配并恢复旧观,取而代之的是,运行期系统寻找 “参数个数和类型都与 operator new 相同” 的某个 operator delete 然后调用。上述示例对应的 operator delete 应该为:

void operator delete(void*, std::ostream&) throw();

既然 Widget 没有声明 placement 版本的 operator delete,所以运行期系统不知道如何取消并恢复原先对 placement new 的调用,于是什么都不做。因此 Widget 有必要声明一个 placement delete 对应于那个有 log 功能的 placement new。然而如果客户代码中有个对应的 delete,会发生什么事?

delete pw; // call normal operator delete

正如注释所言,调用的是正常形式的 delete 而非 placement delete。这意味着如果要解决所有与 placement new 相关的内存泄漏,必须同时提供一个正常的 operator delete 和一个 placement delete,前者用于构造期间无任何异常被抛出,后者用于构造期间有异常被抛出。

对于撰写内存分配函数,需要记住缺省情况下 C++ 在 global 作用域内提供以下形式的 operator new:

void* operator new(size_t) throw(std::bad_alloc); // normal new
void* operator new(size_t, void*) throw(); // placement new
void* operator new(size_t, const nothrow_t&) throw(); // nothrow new

如果在类内声明任何 operator news,它会遮掩上述这些标准形式,如果希望自定义函数有着平常的行为,只需另类专属版本调用 global 版本即可。

须记

  • 当写一个 placement operator new,确定也写出了对应的 placement operator delete。如果没有那样做,程序可能会发生隐微而时断时续的内存泄漏。
  • 当声明 placement new 和 placement delete,确定不要无意识地遮掩了它们的正常版本。

Part Nine: Miscellany

条款(五十三):不要轻忽编译器的警告

须记

  • 严肃对待编译器发出的警告信息。努力在编译器的最高(严苛)警告级别下争取 “无任何警告” 的荣誉。
  • 不要过度倚赖编译器的报警能力,因为不同的编译器对待事情的态度并不相同。一旦移植到另一个编译器上,原本倚赖的警告信息有可能消失。

条款(五十四):让自己熟悉包括 TR1 在内的标准程序库

须记

  • C++ 标准程序库的主要机能由 STL、iostreams、locales 组成,并包含 C99 标准程序库。
  • TR1 添加了智能指针、泛化函数指针、hash-based 容器、正则表达式以及另外 10 个组件的支持。
  • TR1 自身只是一份规范。为获得 TR1 提供的好处,需要一份实物,一个好的实物来源是 Boost。

条款(五十五):让自己熟悉 Boost

须记

  • Boost 是一个社群,也是一个网站,致力于免费、源码开放、同僚复审的 C++ 程序库开发。Boost 在 C++ 标准化过程中扮演深具影响力的角色。
  • Boost 提供许多 TR1 组件实现品,以及其他许多程序库。