研二上这学期真是从头忙到尾呀,后悔自己暑假没有平衡好生活和学习。最近终于搞完了科研任务和一些其他杂事,有时间来沉淀一下自己了。准备把C++路线整个走一遍,以备战明年的实习和校招,加油吧,少年!

之前粗略地记录了C++ primer第五版,就不重复花费时间了。虽然之前也看过effective c++,但当时理解还不够,这次准备重点理解好《effective c++》和《effective stl》这两本书。

🆗,那就一起来揭开,the 55 specific ways to improve your programs and designs. —— Scott Meyers

导读

声明(declaration)告诉编译器某个东西的名称和类型。

定义(definition)提供编译器一些声明所遗漏的细节。于对象而言,定义是编译器为此对象拨发内存的地点;于函数而言,定义提供了代码本体。对类class而言,定义列出它们的成员。

初始化(initialization)是“给予对象初值”的过程。对用户自定义类型的对象来说,初始化由构造函数执行。

区别“copy构造”和“copy赋值”

“=” 既可以表示赋值,也可以表示拷贝构造。而区分也是很简单的,如果有新对象被定义,那么一定会有构造函数调用,就一定是copy构造。

copy构造函数是一个尤其重要的函数,它定义一个对象如何 passed by value(以值传递),例如:

1
2
3
4
bool hasAcceptableQuality(Widget w);
...
Widget aWidget;
if(hasAcceptableQuality(aWidget)) ...

这里参数w以 by value 的方式传递,借用的就是Widget的拷贝构造函数。

建议就是:以by value传递用户自定义类型通常是个坏主意,pass-by-reference-to-const往往是较好的选择。

指针和引用类型的命名规范:指针(pt,pointer to T),引用(rt,reference to T)。

让自己习惯C++

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

C++现在已是一个多重泛型编程语言,支持:过程形式、面向对象形式、函数形式、泛型形式以及元编程形式等。

C语言的局限:没有模板、没有异常、没有重载。。。

C++面向对象方面:类(构造函数和析构函数)、封装、继承、多态、virtual函数(动态绑定)等。

STL,是一个模板程序库,对容器、迭代器、算法以及函数对象的规约有极佳的密切配合。

条款02:尽量以const, enum, inline 替代 #define

#define定义的记号名称会被预处理器处理,却不会被编译器看到,这样调试时会无法追踪到这个记号,而导致浪费时间。

使用const的注意事项:

  1. 确定指针是否有必要定义为const
  2. const变量作为类成员(作用域在类内),若只需要保留一份,则加static

使用enum hack补偿做法: enum的行为更像#define。取const地址是合法的,而取#define和enum的地址是不合法的。如果不想让别人获得一个pointer或者reference指向整数常量,enum就可以实现这个约束。

1
enum { numTurn = 5 };

对于常量,最好以const对象或者enums,替换#define。

对于形似函数的宏,最好改用inline函数,替换#define。

条款03:尽可能使用const

const允许定义一个语义约束,即指定一个“不该被改动”的对象。

修饰指针及内容时,原则是“左物右指”,即星号左边的const表示所指物是常量,而星号右边的const表示指针本身是常量。

STL的迭代器以指针为根据塑造,类似于一个 T* 指针。注意的是,声明迭代器为const相当于声明指针为const(即,T* const),表示迭代器不可更换指向,但所指物的指是可以改变的。如果希望迭代器所指的东西不可被改动(即,希望const T*),那么应该使用 const_iterator. 例如:

1
2
3
4
5
6
7
8
std::vector<int> vec;
...
const std::vector<int>::iterator iter = vec.begin(); // int * const
*iter = 10; // 可以,改变所指物的值
++iter; // 错误,const指针不可改变指向
std::vector<int>::const_iterator cIter = vec.begin(); // const int *
*cIter = 10; // 错误,所指物的值不可改变
++cIter; // 可以,改变指针指向

const成员函数 是很重要的一类成员函数,因为:

  • 使class接口容易被理解,哪些函数可以改动对象内容,而哪些不能。
  • 使“操作const对象”成为可能。

例如,用const重载operator []来对不同版本的对象返回不同的返回类型。试想,如果const对象调用operator [] 没有得到const相应类型,那么就可以被修改了,这样是不对的。而重载两种 operator [] 又要避免代码冗余,就可以使用转型。

1
2
3
4
5
6
7
8
9
10
11
12
13
class TextBlock{
public:
...
const char& operator[] (std::size_t position) const
{
... // 处理
return text[position];
}
char& operator[] (std::size_t position)
{
return const_cast<char&>(static_cast<const TextBlock>(*this)[position]);
}
};

让non-const 调用 const成员函数,non-const版本中有两个转型,第一个static_cast<const TextBlock>则是为了调用const版本的成员函数,防止non-const版本循环调用。第二个const_cast<char&>用于消除const成员函数返回值类型的const部分。

小结,当 const 和 non-const 成员函数有着实质等价的实现时,令 non-const 版本调用 const 版本能够避免代码重复。

条款04:确定对象被使用前已先被初始化

C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。这意味着,构造函数内的操作其实是赋值,而非初始化。初始化发生的时机更早,发生于成员的default构造函数被自动调用的时候。而在对象构造时初始化的一个较佳写法是,使用成员初值列表(member initialization list)替换赋值动作:

1
2
3
4
5
6
7
ABEntry::ABEntry(const std::string &name, const std::string &address, 
const std::list<PhoneNumber>& phones)
:theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{}

这种方式通常更高效,省去了赋值的过程,成员的初值由它们的default构造函数初始化。

规定一下,在成员初值列表中总是按声明的顺序列出所有成员变量,以免还得记住哪些成员变量可以无需初值。

C++有十分固定的“成员初始化次序”,即(1)基类总是早于派生类被初始化;(2)类的成员变量总是以声明的次序被初始化。

一个问题是,不同编译单元内的non-local static对象的初始化相对次序是不明确的,不过可以使用单例模式解决,即不直接调用non-local static对象,而是调用其专属函数(首次调用时初始化,否则返回引用)。这种方式也是将 non-local static 对象转化成了 local static 对象。

构造、析构、赋值运算

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

如果写了一个空类,那么编译器会自动为其编写构造、析构、copy构造和copy赋值函数(都是public且inline的),当这些函数被需要时,它们就会被编译器创建。类似于:

1
2
3
4
5
6
7
class Empty{
public:
Empty(){...}
Empty(const Empty& rhs){...}
~Empty(){...}
Empty& operator=(const Empty& rhs){...}
};

注意,编译器产生的析构函数是 non-virtual ,除非这个类的基类自身声明有virtual析构函数(这种情况下该函数的虚属性来自于基类)。关于虚析构函数的讨论见:虚析构函数 | 百度百科

至于 copy构造 和 copy赋值 操作符,编译器创建的版本仅是单纯地“将来源对象的每一个 non-static 成员变量拷贝到目标对象”。

如果用户自己声明了构造函数,那么编译器将不再为类创建default构造函数。

如果用户定义的类中包含“reference成员”或者“const成员”,或者其基类的copy赋值操作符为private,那么编译器将拒绝自动创建copy赋值函数。reference和const成员是不可改变的,编译器不会自动生成copy赋值函数而导致这样错误的行为。此时就必须由用户自己定义copy赋值函数。

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

假如有一个类是唯一的,那么就不允许任何对该对象的拷贝和赋值。通常如果不希望class支持某一特定机制,只要不声明对应函数就行了,但这个策略却对 copy构造copy赋值 行不通。

为驳回编译器自动提供的机能,通常有两种方式:

  1. 将相应的成员函数声明为 private 并且不予实现。

    1
    2
    3
    4
    5
    6
    7
    8
    class HomeForSale{
    public:
    ...
    private:
    ...
    HomeForSale(const HomeForSale&); // 仅声明
    HomeForSale& operator=(const HomeForSale&);
    };

    这样编译器可以防止客户拷贝HomeForSale对象,而且若其成员函数或者friend函数调用该对象的拷贝,连接器也会由于缺少定义而拒绝这一行为。

  2. 使用像 Uncopyable 这样的 base class.

    1
    2
    3
    4
    5
    6
    7
    8
    class Uncopyable{
    protected:
    Uncopyable(){}
    ~Uncopyable(){}
    private: // 阻止copying行为
    Uncopyable(const Uncopyable&);
    Uncopyable& operator=(const Uncopyable&);
    };

    要组织HomeForSale对象被拷贝,唯一需要的就是继承Uncopyable.

    1
    2
    3
    class HomeForSale: private Uncopyable{
    ...
    };

条款07:为多态基类声明virtual析构函数

当使用工厂(factory)函数,返回基类指针时,其指向的对象必须位于heap中。因此,为避免内存和其他资源被泄露,每一个对象都应该被适当地delete掉。

C++指出,当派生类对象经由一个基类指针被删除,而该基类带着一个 non-virtual 析构函数时,其结果未定义——实际执行时通常发生的是对象的derived部分没有被销毁。造成一个诡异的“局部销毁”对象。

解决这个问题也很简单:给base class一个virtual析构函数。这样删除派生类对象就会完整地销毁整个对象,包括所有派生的部分。

1
2
3
4
5
6
7
8
9
class TimeKeeper{
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper* ptk = getTimeKeeper();
...
delete ptk; // 行为正确

像TimeKeeper这样的基类除了析构函数之外通常还有其他virtual函数,使得派生类得到客制化。同样地,任何class只要带有virtual函数都几乎确定应该有一个virtual析构函数。

当某个基类具备虚析构函数时,它的大小就有了变化。为了体现出虚函数,对象必须携带某些信息,用来在运行期间决定哪一个virtual函数该被调用。这份信息通常由一个所谓vptr(virtual table pointer)指针指出。vptr指向一个由函数指针构成的数组,成为vtbl(virtual table);每一个带有virtual函数的类都有一个相应的vtbl,当对象调用某一virtual函数,实际被调用的函数取决于该对象的vptr所指向的那个函数指针。

继承一个带有 non-virtual 析构函数的基类,是很容易出现的一种错误行为。例如,某个具体的字符串类直接继承了STL中的string类,注意string类的析构函数是 non-virtual 的。所以,当继承一个基类时,需要注意其析构函数是否为 virtual.

总结一下,如果要作为基类使用且具备多态性质,那么就应该拥有一个 virtual 析构函数。

条款08:别让异常逃离析构函数

C++不禁止析构函数吐出异常,但它不鼓励这么做。

Tips:为保证用户对资源类对象不忘记调用close方法(例如数据库连接),可以创建一个管理资源的类,并在其析构函数中调用close.

1
2
3
4
5
6
7
class DBConn{
public:
...
~DBConn(){ close(); }
private:
DBConnection db;
};

即,不让用户使用new,delete来调用堆中的资源。这样,用户就能写出这样的代码:

1
2
3
4
{
DBConn dbc(DBConnection::create());
...
}

当DBConn离开作用域之后,就会析构,进而自动调用DBConnection的close()方法了。

然而,如果close调用导致异常,那么DBConn析构函数会传播异常。这样通常可以abort终止该异常,或者吞下异常。但是这样导致用户无法对该异常做出反应。更好的做法是提供一个普通函数,让用户有机会对可能出现的问题做出反应。这里在DBConn中设计一个close函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class DBConn{
public:
...
void close()
{
db.close();
closed = true;
}
~DBConn(){
if(!closed){
try{
db.close();
}catch(...){
... // 制作转运记录,记下对close的调用失败
}
}
}
private:
DBConnection db;
bool closed;
};

这样将调用close的责任从DBConn析构函数手上转移到DBConn客户手上,但是DBConn析构函数中仍含有一个“双保险”的调用,以防粗心的客户忘记使用close方法。

条款09:绝不在构造和析构过程中调用virtual函数

不应该在构造和析构函数期间调用virtual函数,这类调用从不下降至派生类。例如,基类的构造和析构中使用了virtual函数,那么派生类对象的基类构造期间,其对象类型是基类而不是派生类,虚函数只会被编译器解析至基类。

如果基类构造和析构中的虚函数是pure虚函数还好,大多数系统会中止程序;而如果是正常的虚函数,那么在构造和析构时总是会调用基类版本的虚函数,这会让你百思不得其解。

解决方法是将虚函数作为正常的函数,然后“令派生类将必要的信息向上传递至基类的构造函数”。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Transaction{
public:
explicit Transaction(const std::string& logInfo);
void logTransaction(const std::string& logInfo) const; // 作为non-virtual函数
...
};
Transaction::Transaction(const std::string& logInfo){
logTransaction(logInfo);
}

class BuyTransaction: public Transaction{
public:
BuyTransaction(parameters): Transaction(createLogString(parameters)){}
private:
static std::string createLogString(parameters);
};

条款10:令operator=返回一个reference to *this

赋值运算是右结合的,并且能够连锁运算。为了实现“连锁运算”,赋值操作符就必须返回一个reference指向操作符的左侧实参。例如:

1
2
3
4
5
6
7
class Widget{
public:
Widget& operator=(const Widget& rhs){
...
return *this; // 返回左侧对象
}
};

这个协议不仅适用于标准赋值形式,也适用于所有赋值相关运算(+=, -=, *= 等等)。

条款11:在operator=中处理“自我赋值”

“自我赋值”发生在对象赋值给自己时。这有点愚蠢,但是合法。例如a[i] = a[j],当i==j时,其实就是自我赋值。

自我赋值并不一定是安全的,例如:

1
2
3
4
5
6
7
Widget::operator=(const Widget& rhs){
if(this == &rhs) return *this; // 接下文,证同测试的语句

delete pb; // pb是Widge对象的私有成员,是一个bitmap的指针
pb = new Bitmap(*rhs.pb);
return *this;
}

这样如果rhs本身就是this指向的对象,那么rhs.pb首先将被删除,然后new Bitmap将用一个“已删除的”对象拷贝构造,这是不应该发生的。

如果想要阻止这种“自我赋值的不安全性”,那么可以在函数最前面加一个“证同测试(identity test)”来检验是否遇上相同对象。或者用一个临时对象先保存要删除pb,然后在函数结束前删除。但其实这两个方式都会降低效率。

还有一个替换方案就是 “copy and swap”,这是一个常见而够好的operator=撰写方法:

1
2
3
4
5
Widget::operator=(const Widget& rhs){
Widget temp(rhs);
swap(temp); // 详见条款29
return *this;
}

其实,由此引出的是,任何函数如果操作一个以上的对象,都要考虑“自我赋值的安全”,即其中多个对象是同一个时,其行为是否仍热正确。

条款12:复制对象时勿忘其每个成分

当编写了一个copying函数,就应该确保:

  1. 复制所有local成员变量
  2. 调用所有base class内适当的copying函数(考虑清楚是默认构造基类 or 自定义构造基类)

如果copy构造copy赋值中有相近的代码,消除重复是一个很好的想法,但是其实两者谁调用谁都说不通,所以消除copy构造和赋值代码重复的一个方法是,建立一个新的成员函数给两者调用。这样的函数往往是private而且常常命名为init。类似于:

1
2
3
4
5
6
7
8
9
class Widget{
public:
Widget(){}
init(int a){ this->ia = a; }
Widget(const Widget& rhs){ init(rhs.ia); ...}
Widget& operator=(const Widget& rhs){ init(rhs.ia); ...}
private:
int ia;
};

资源管理

条款13:以对象管理资源

在获取了资源后,就有责任在不需要的时候释放掉,考虑一个标准的函数f:

1
2
3
4
5
void f(){
Investment* pInv = createInvestment(); // 调用factory函数,返回基类指针
...
delete pInv;
}

f函数正常获取了动态分配内存资源,并且在函数最后释放掉了。不过这个过程中还是存在着许多“隐患”,比如“…”过程中提前return了,或者循环中的goto或者continue语句导致跳过了资源释放的环节,那么就很可能导致内存泄漏的问题。

一个确保获取的资源总是能够被释放的方法是:将资源放到对象内。这样当控制流离开f函数后,对象的析构函数会自动释放那些资源。

许多资源被动态分配在heap内,然后被用在单一区块或者函数中。其实这些资源应该在离开作用域之后被释放。

标准程序库提供了 auto_ptr 就是针对以上情况而特制的解决方案。auto_ptr 是一个“类指针对象”,也就是所谓的智能指针。它的析构函数将自动对其所指对象调用delete。能够避免f函数潜在的资源泄露(使用auto_ptr需要包含头文件memory):

1
2
3
4
5
6
7
8
9
10
void f1(){     // 手动资源管理
Widget *pw = new Widget(7);
cout << "pw.x = " << pw->getX() << endl;
delete pw;
}

void f2(){ // auto_ptr自动资源管理
auto_ptr<Widget> auto_pw(new Widget(77));
cout << "pw.x = " << auto_pw.get()->getX() << endl;
}

“以对象管理资源”有两个关键想法:

  • 获得资源后立即放进管理对象。对于资源管理对象来说,“资源获取的时机就是初始化时机”(Resource Acquisition Is Initialization; RAII),有时候获取的资源被拿来初始化或者赋值,不管那种做法,都应该在获得资源后立马放到管理对象中。
  • 管理对象运用析构函数确保资源被释放。对象离开作用域将被销毁,其析构函数会自动调用。

需要注意的是,auto_ptr 虽然好用,但是不应该让多个 auto_ptr 同时指向同一对象。否则该对象被多次销毁,会发生未定义行为。为此,它有一个不寻常的特性:通过copy构造或者copy赋值来赋值它们时,它们会变成null,而复制得到的指针将取得资源的唯一拥有权。

1
2
3
std::auto_ptr<Investment> pInv1(createInvestment());    // 创建pInv1
std::auto_ptr<Investment> pInv2(pInv1); // 利用pInv1创建pInv2, pInv1将被置为null
pInv1 = pInv2; // pInv2赋值给pInv1, pInv2之后为null

auto_ptr这种行为限制了 受其管理的资源必须绝没有一个以上auto_ptr同时指向它。

不过这种方式的限制并不能使问题得到解决,如果客户并没有在资源获得时就放入管理对象,那么就无法限制对象的唯一权,还是会发生内存泄露,例如:

1
2
3
4
// 测试auto_ptr同时指向一个对象
Widget *pw = new Widget(7);
auto_ptr<Widget> auto_pw1(pw);
auto_ptr<Widget> auto_pw2(pw);

另外,在C++17中,auto_ptr已经被删除了。其代替方案是“引用型智慧指针”(Reference-Counting Smart Pointer;RCSP)。这也是个智能指针,会持续追踪共有多少对象指向某个资源,并在无人指向它时自动删除该资源。TR1中的tr1::shared_ptr就是个RCSP,于是可以这么写f函数:

1
2
3
4
5
void f(){
std::tr1::shared_ptr<Investment> pInv1(createInvestment());
std::tr1::shared_ptr<Investment> pIvn2(pInv1); // pInv1 和 pInv2指向同一个对象
pInv1 = pInv2; // 同上,无改变
} // pInv1,2销毁,它们所指对象也销毁

shared_ptr 和 auto_ptr仅仅是个例子。本条款最重要的就是使用RAII对象来防止资源泄露,它们在构造函数中获得资源,并在析构函数中释放资源。

条款14:在资源管理类中小心copying行为

对于非heap-based资源,智能指针往往不适合作为资源管理者。那么有时,就需要建立自己的资源管理类。例如,为确保不会忘记将一个锁住的Mutex解锁,我们希望建立一个class来管理锁的机制:

1
2
3
4
5
6
7
8
9
class Lock{
public:
explicit Lock(Mutex* pm): mutexPtr(pm){
lock(mutexPtr);
}
~Lock(){ unlock(mutexPtr); }
private:
Mutex *mutexPtr;
};

然后,这里存在一个一般性的问题是:“当一个RAII对象被复制,会发生生么事?”大多情况下,会有两种选择:

  • 禁止复制。可以通过条款6的方式,将copying操作声明为private来禁止RAII对象被复制。
  • 引用计数法。当希望保有资源,直到它的最后一个使用者被销毁时,复制RAII对象,应该将资源的“被引用数”递增,可以基于shared_ptr实现,而且可以通过其“删除器”指定当引用计数为0时的动作(对于mutex来说,引用为0时是unlock而不是删除)。
  • 复制底部资源。不过这样的复制应该是深度拷贝,也就是创建资源的复件。
  • 转移底部资源的拥有权。可以确保永远只有一个RAII对象指向一个资源。

条款15:在资源管理类中提供对原始资源的访问

资源管理类将资源管理起来,也要提供相应的接口来提供对原始资源的访问。对于智能指针(例如 tr1::shared_ptr 和 auto_ptr)而言,有两种方式来获取原始资源:

  • 显式转换:智能指针中提供了一个get成员函数,可以返回智能指针内部的指向原始资源的指针。

    1
    2
    3
    int daysHeld(const Investment* pi);           // 函数原型,返回投资天数

    int days = daysHeld(pInv.get()); // pInv.get()返回原始资源指针
  • 隐式转换:几乎所有智能指针都重载了指针取值操作符(operator-> 和 operator*),允许隐式转换至底部原始指针。如果要直接获取原始资源,还可以重载operator(),但也会造成不小心错误转换的问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Investment{
    public:
    bool isTaxFree() const; // 一个成员函数
    };
    Investment* createInvestment(); // factory函数
    std::tr1::shared_ptr<Investment> pi1(createInvestment());
    bool taxable1 = !(pi1->isTaxFree()); // 使用operator->隐式转换
    std::auto_ptr<Investment> pi2(createInvestment());
    bool taxable2 = !((*pi2).isTaxFree()); // 使用operator*隐式转换

一般来说,显式转换比较安全,而隐式转换对客户比较方便。

条款16:成对使用new和delete时要采取相同形式

考虑这样的行为:

1
2
3
string *stringArray = new string[100];
...
delete stringArray;

很明显,new的是一个100个string元素的数组,而delete仅正确删除了数组中的第一个对象,另外99个不太可能被正确删除,因为它们的析构函数没有被调用。

关于new和delete:

使用new动态生成对象时,有两件事发生:(1)通过名为 operator new 的函数将内存分配出来;(2)针对此内存会有一个(或更多)构造函数被调用。

当使用delete时,也有两件事发生:(1)针对此内存有一个(或更多)析构函数被调用;(2)通过名为 operator delete 的函数释放内存。

然而,delete的最大问题在于:即将被删除的内存之中究竟有多少对象?这关系到有多少个析构函数必须被调用。这个问题也可以简化为:即将被删除的指针,所指向的是单一对象还是对象数组?这是因为,数组所在的内存中通常还包括“数组大小”的记录,delete如果知道是数组,就能知道需要调用多少次析构函数。

所以,当你调用new时使用[ ],就必须在对应调用delete时也使用[ ]。

1
2
3
4
5
string *stringPtr1 = new string;
string *stringPtr2 = new string[100];
...
delete stringPtr1;
delete [] stringPtr2;

条款17:以独立语句将newed对象放入智能指针

考虑这样的语句:

1
2
3
4
int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw, int priority);

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

prcoessWidget需要传入智能指针,就直接在传参时初始化了。但是令人惊讶的是,这样的调用可能会造成隐晦的资源泄露。这是因为调用processWidget前,编译器必须创建代码,做以下三件事:

(1)调用priority;(2)执行“new Widget”;(3)调用tr1::shared_ptr构造函数

但是,C++编译器以什么样的顺序执行这三个语句是不确定的(只能确定的是,(2)优先于(3)执行,但(1)可能排在1,2或3的次序来执行)。假设调用priority在第二步执行,万一priority的调用导致异常,那么“new Widget”返回的指针将会遗失,因为它还没有置入tr1: :shared_ptr中。

为避免这样的问题,应该使用分离语句,用独立的语句将newed对象置入智能指针中。否则,一旦异常被抛出,有可能导致难以察觉的资源泄露。

1
2
std::tr1::shared_ptr<Widget> pw(new Widget);    // 独立语句
processWidget(pw, priority());

设计与声明

条款18:让接口容易被正确使用,不易被误用

想设计出一个“易用而不易出错的”接口,必须考虑客户可能出现什么样的错误。具体地,可以从两个方面着手改善:

  • “促进正确使用”:包括接口一致性,以及与内置类型的行为兼容。

  • “阻止误用”:包括建立新类型、限制类型上的操作、束缚对象值,以及消除客户的资源管理责任。

    例如,用一个class表示日期,考虑这样的构造函数:

    1
    2
    3
    4
    class Date{
    public:
    Date(int month, int day, int year);
    };

    这样有可能客户会不记得那个参数是月,而哪个是日,客户可能会迷糊。一个好的办法是引入外覆类型来区别天数、月份和年份,然后在Date构造函数中使用这些类型:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    struct Day/Month/Year{
    explicit Day/Month/Year(int x):val(x){}
    int val;
    };

    class Date{
    public:
    Data(const Month& m, const Day& d, const Year& y);
    };

    Date d(Month(1), Day(7), Year(2023)); // 这样调用更加明确,错误的类型将导致异常,防止接口被误用

条款19:设计class犹如设计type

如何设计高效的class,需要面对以下的问题:

  • 新type的对象应该如何被创建和销毁?创建销毁是单一对象还是数组。
  • 对象的初始化和赋值有什么样的差异?
  • 什么是新type的”合法值“?这意味着它的成员函数必须错误检查工作,以及考虑可能抛出的异常。
  • 新的type需要配合某个继承体系吗?特别是受到”它们的函数是virtual还是non-virtual“的影响。
  • 新的type需要什么样的转换?涉及到类型转换。
  • 什么样的操作符和函数对此新type而言是合理的?这会决定你将为该class声明哪些函数。
  • 什么样的标准函数应该被驳回?哪些函数(包括编译器自动创建的)应该被声明为private。
  • 谁该取用新type的成员?这个问题将决定哪个成员为public,哪个为protected,哪个为private。
  • 你的新type有多么一般化?如果足够一般化,就应该定义一个新的class template。
  • 你真的需要一个新的type吗?

条款20:宁以 pass-by-reference-to-const 替换 pass-by-value

缺省情况下,C++以 by value 方式传递对象至函数,函数参数以实际参数的复件为初值,而调用端所获得的也是函数返回值的一个复件。这些复件是由对象的copy构造函数产出的,这使得 pass-by-value 成为昂贵的(费时的)操作。

1
2
3
4
5
6
7
8
// pass-by-value
bool validStudent(Student s){
return s.getSchoolName() == "HUST";
}
// pass-by-reference-to-const
bool validStudentRef(const Student& cs){
return cs.getSchoolName() == "HUST";
}

我们知道,在C++中有以下三种情况需要调用拷贝构造函数:

  1. 一个对象作为函数参数,以值传递的方式传入函数体;
  2. 一个对象作为返回值,以值传递的方式从函数返回;
  3. 一个对象用于给另一个对象进行初始化。

另一方面,pass-by-reference-to-const 这种方式就高效得多:没有任何构造函数或析构函数被调用,因为没有任何新对象被创建。其中的const声明也是很重要的,这样就不用担心调用者会对传入的对象进行修改,除非确实需要修改才不加const。

而且,pass-by-reference-to-const 还有一个好处是:可以避免对象切割(slicing)的问题。下面作为一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
class Window{
public:
...
string name() const; // 返回窗口名称
virtual void display() const; // 返回窗口和其内容
};

class WindowWithScrollBars: public Window{
public:
...
virtual void display() const; // 带滑动条的窗口展示
};

注意到 display 是一个虚函数,这意味着两种窗口的展示方式会不同。现在假设你希望写一个函数打印窗口名称并显示该窗口,如果这么写将是一个错误的示范:

1
2
3
4
void printWindow(Window w){
cout<< w.name() << endl;
w.display();
}

当调用以上函数,并传递一个 WindowWithScrollBars 对象时,参数w会被构造成一个Window对象,而所有 WindowWithScrollBars 对象所特有的信息将会被切除。

解决对象切割(slicing)问题的办法就是,以 pass-by-reference-to-const 的方式传递w:

1
2
3
4
void printWindow(const Window& w){    // 引用传递,对象参数不会被切割
cout<< w.name() <<endl;
w.display();
}

由于 reference 往往以指针实现,这意味着 pass-by-reference 真正传递的是指针(引用是别名形式,不过其内涵仍是指针)。所以,传进来的窗口是什么类型,w就表现的是哪种类型。

不过,虽然 pass-by-reference-to-const 更加高效,且能够避免对象切割的问题。但是,这种规则并不适用于内置类型,以及STL的迭代器和函数对象,对它们而言,pass-by-value 往往比较合适。

条款21:必须返回对象时,别妄想返回其reference

领悟到了 pass-by-reference-to-const 的高效后,如果一味执着地追求,那么一定会犯下一个致命错误:传递一些references指向其实并不存在的对象

在函数返回时,一定要注意以下三点:

  1. 绝不要返回指向一个 local stack 对象的 pointer 或者 reference。这很容易理解,本地栈对象离开函数体就销毁了。

  2. 不要返回指向一个 heap-allocated 对象的reference。

    返回堆对象的引用,那么谁去负责它的资源释放呢?对于外层看来就是一个对象,客户大概率不会想到去释放这部分资源(或者说,释放一个看似普通对象而非指针是比较迷惑的),若不处理,则必然都是内存泄露。比如释放堆对象的引用就需要这么做:

    1
    2
    3
    4
    5
    friend const Student& clonedStudent(const Student& cs){    // 返回堆对象的引用
    Student *clone_s = new Student(cs);
    return *clone_s;
    }
    delete &clonedStudent(wlz); // 释放资源

    当然,返回堆对象的指针也需要很好的资源管理,但至少对于客户来说,能够清晰地知道 “这个指针指向了一部分资源,需要进行管理”。

  3. 不要返回 pointer 或者 reference 指向一个 local static 对象而可能同时需要多个这样的对象。这会在多线程中出现安全性问题。

同时,结合到条款10,应该得到这样的结论:

  • class中不需要产生新对象的操作,例如重载赋值型操作(=,-=,+=,*=等等),应返回 reference to *this。因为赋值函数不用产生新对象,而引用的返回可以保证“连锁赋值”操作。
  • 对于需要产生新对象的函数,遵守上述三条规则。例如class中的运算操作(*,+等)。

条款22:将成员变量声明为private

仅记住两点即可:

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

条款23:宁以 non-member、non-friend 替换 member 函数

考虑用一个class来表示网页浏览器。在class可能提供的众多函数中,有一些用来清除下载元素高速缓冲区、清除访问过的URLs、以及移除系统中所有的cookies:

1
2
3
4
5
6
7
class WebBrowser{
public:
...
void clearCache();
void clearHistory();
void removeCookies();
};

许多用户会想,再建立一个功能来一整个执行所有的清除动作,因此这个类还可以提供一个这样的函数:

1
2
3
4
5
class WebBrowser{
public:
...
void clearEverthing(); // 调用以上3个清除成员函数
};

或者,这一功能也可以由一个 non-member 函数调用适当的 member 函数来完成,即:

1
2
3
4
5
void clearEverthing(WebBrowser& wb){
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}

那么,这两种实现,哪一个更好呢?

事实是,使用 non-member,non-friend 函数代替 member 函数会更好,这样能够提高封装性(它不增加“能够访问class内private部分”的函数数量)、包裹弹性和机能扩充性。

条款24:若所有参数都需类型转换,请为此采用 non-member 函数

让class支持隐式类型转换通常是一个糟糕的主意,但是也有意外,比如在建立数值类型时。假设设计了一个class用来表示有理数,那么允许整数“隐式转换”为有理数是合理的。假如这样开始了“有理数”类的构建:

1
2
3
4
5
6
7
8
class Rational{
public:
Rational(int numerator=0, int denominator=1); // 构造函数刻意不加explicit,以允许隐式类型转换
int numerator() const; // 访问分子
int denominator() const; // 访问分母
private:
...
};

此时,想要支持算术运算符(如+,*等),但不确定该由 member 函数、non-member 函数,或者 non-member friend 函数来实现时,应该保持面向对象的精神。实现乘法与有理数有关,所以应该在类中成员函数为有理数实现 operator*。比如,这么实现了:

1
2
3
4
class Rational{
public:
const Rational operator* (const Rational& rhs) const;
};

这种方式可以应对两个有理数的相乘:

1
2
3
4
Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEight; // 正确
result = result * oneHalf; // 正确

但是,当进行混合运算时,比如用有理数与int进行相乘,却只有一半能行得通:

1
2
result = oneHalf * 2;        // (1)正确, oneHalf.operator*(2), 2会隐式转换为Rational
result = 2 * oneHalf; // (2)错误, 2.operator*(oneHalf),整数2并不是Rational,也不支持这样的混合运算

我们尝试找出(1)之所以成功的原因,oneHalf调用operator*,而参数是2,编译器知道函数需要的是Rational,而由于Rational的构造函数并没有explicit,所以可以将int隐式转换到Rational。

这样得出的一个结论是:只有当参数被列于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。

那么让Rational进行混合运算的方法也拨云见日了,即:让operator*成为一个 non-member 函数,接受所有运算符的参与者,以允许编译器在任何一个实参上执行隐式类型转换

1
2
3
4
5
6
7
8
9
10
11
12
class Rational{
... // operator* 不再作为成员函数
};

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

result = oneHalf * 2;
result = 2 * oneHalf; // 这两个都没有问题了

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

条款25:考虑写出一个不抛异常的swap函数

swap是一个有趣的函数。它原来只是STL的一部分,后来成为异常安全性编程(条款29)的脊柱,以及用来处理自我赋值(条款11)可能性的一个常见机制。swap的典型实现如下:

1
2
3
4
5
6
7
8
namespace std{
template<typename T>
void swap(T& a, T& b){
T temp(a);
a = b;
b = temp;
}
}

只要类型T支持copying(copy构造和copy赋值操作符),swap就会帮你完成置换的操作。不过这样的实现对于“pimpl手法”来说是没有必要的。所谓“pimpl手法”指的是:“以指针指向一个对象,内含真正数据”的类型。比如用这种方式来设计Widget,那么就会是这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class WidgetImpl{    // Implment类,包含真正数据
public:
...
private:
int a, b, c;
vector<double> v; // 假如有很多数据,那么要复制的话就要很久
};

class Widget{
public:
Widget(const Widget& rhs);
Widget& operator= (const Widget& rhs){
...
*pImpl = *(rhs.pImpl);
...
}
private:
WidgetImpl *pImpl; // Impl指针
};

当需要交换Widget对象时,实际上只要交换它们的 Impl 指针就可以了,但是上述实现的典型swap并不知道。这样的话,swap不仅会复制3个Widget,还有3个WidgetImpl对象,这样的效率比较低。我们希望告诉swap,置换Widget时,其实只需要置换内部的Impl指针即可。

(书中这段讲的感觉有点乱,需要消化一下)

这里直接上结论:

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

实现

条款26:尽可能延后变量定义式的出现时间

只要你定义了一个变量而其类型具有一个构造函数或析构函数,那么就得承受它带来的构造和析构成本。即使这个变量最终并未使用,仍需耗费这些成本,所以应该尽可能避免这种情形。

考虑这样的情景:一个函数计算通行密码的加密版本然后返回,前提是密码足够长;如果密码太短,函数就会丢出一个异常,类型为logic_error。

1
2
3
4
5
6
7
8
string encryptPassword(const string& password){
string encrypted;
if(password.length() < MinimumPasswordLength){
throw logic_error("Password is too short.");
}
... // 必要动作,将加密后的密码置入 encrypted
return encrypted;
}

那么情况就是,如果有个异常被丢出,对象encrypted就没有用,但仍得付出encrypted的构造和析构成本,所以最好延后encrypted的定义式,直到确实需要它。进一步地,应该尝试延后这份定义直到能够给它初值为止,这样可以避免无意义的default构造行为(“默认构造后再赋值”比“直接在构造的时候指定初值”效率低)。这样可以增加程序的清晰度,并改善程序效率。

条款27:尽量少做转型动作

转型通常可能导致种种麻烦,有些容易识别,有些则非常隐晦。然而,在C++中转型是一个你会想带着极大尊重去亲近的一个特性。

来回顾一下转型语法,可以分为“旧式转型”和“新式转型”两种:

“旧式转型”

1
2
(T)expression    // C风格的转型
T(expression) // 函数风格的转型

“新式转型”:指C++提供的四种新式转型

1
2
3
4
const_cast<T>( expression )
dynamic_cast<T>( expression )
reinterpret_cast<T>( expression )
static_cast<T>( expression )
  • const_cast 通常被用来将对象的常量性转除(cast away the constness)。它也是唯一有此能力的C++style转型操作符。
  • dynamic_cast 主要用来执行“安全向下转型(safe downcasting)”,也就是用来决定对象是否归属继承体系中的某个类型。
  • reinterpret_cast 意图执行低级转型,实际动作(及结果)可能取决于编译器,这也代表它不可移植。例如将一个 pointer to int 转型为一个 int,这类转型在低级代码以外非常少见。
  • static_cast 用来强迫隐式转换(implicit conversion),例如将 non-const 对象转换为 const 对象,将 int 转换为 double 等。它也可以用来执行上述多种转换的反向转换,例如将 void* 指针转换为 typed 指针,将 pointer-to-base 转换为 pointer-to-derived。但是它无法将 const 转换为 non-const ,这个只有const_cast能够做到。

许多人认为转型只是告诉编译器把某种类型视为另一种类型。这是错误的观念,其实任何一个类型转换往往会令编译器编译出运行期间执行的码,因为不同类型的底层实现是不同的。

而且因为转型,我们很容易写出似是而非的代码。例如许多应用框架都要求derived class内的virtual函数代码第一个动作就先调用base class的对应函数。假设这么进行了实现,它看起来对,但实际上错:

1
2
3
4
5
6
7
8
9
10
11
12
class Window{
public:
virtual void onResize(){ ... } // base class 中的实现代码
};

class SpecialWindow: public Window{
public:
virtual void onResize(){
static_cast<Window>(*this).onResize(); // 想要通过转型调用基类的onResize函数
... // 执行SpecialWindow内的专属动作
}
};

这段程序将*this转型为Window,对函数onResize的调用也因此调用了Window::onResize。但恐怕你没有想到,这样调用的并不是当前对象上的函数,而是稍早转型动作所建立的一个“*this对象的base class成分”的暂时副本身上的onResize. 换句话来说就是,它在“当前对象的base class成分”的副本上调用Window::onResize,然后在当前对象身上执行SpecialWindow的专属动作。

如果Window::onResize修改了对象内容(也有可能,因为onResize此时是一个non-const成员函数),就会使得当前对象进入到一个“伤残”状态:其 base class 成分的更改没有落实,而 derived class 成分的更改落实了。

解决方法就是拿掉转型动作,取而代之的是你真正想要表达的:令base class的onResize函数作用到当前对象的身上。

1
2
3
4
5
6
7
class SpecialWindow: public Window{
public:
virtual void onResize(){
Window::onResize(); // 调用Window::onResize作用在*this身上
...
}
};

这个例子也说明,如果你发现自己打算转型,这可能就是一个警告⚠:可能会发生错误。

如果使用的是 dynamic_cast 就更是如此。而且 dynamic_cast 的许多实现版本执行速度相当慢,深度继承或者多重继承时,它的成本更高。

之所以需要dynamic_cast,通常是因为想在一个认定为 derived class 对象身上执行 derived class 操作函数,但是手上却只有一个“指向 base”的pointer或reference,只能靠它们来处理对象。有两个一般性方法可以避免这个问题:

  1. 使用容器,并在其中存储直接指向 derived class 对象的指针(通常是智能指针,以管理资源)。

    1
    2
    3
    4
    5
    6
    typedef vector< tr1::shared_ptr<SpecialWidon> > VPSW;
    VPSW winPtrs;
    ...
    for(VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end() ++iter){
    (*iter)->blink();
    }

    当然,这种做法无法在同一容器中存储“所有Window派生类的指针”。如果要处理多种窗口类型,可能需要多个容器,都必须具备类型安全性。

  2. 在 base class 内提供virtual函数做你想对各个派生类做的事。例如,虽然只有SpeicalWindow可以闪烁,但将闪烁函数声明于base class内,并提供一份”什么也没做“的缺省实现码也是有意义的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Window{
    public:
    virtual void blink(){} // 缺省实现代码“什么也没做”
    };

    class SpecialWindow: public Window{
    public:
    virtual void blink(){ ... } // 在该类内 blink 进行一些动作
    };

    typedef vector< tr1::shared_ptr<Window> > VPW;
    VPW winPtrs;
    ...
    for(VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter){
    (*iter)->blink();
    }

总结一下,本条款需要注意的就是:如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast ,如果有个设计需要转型,试着发展无需转型的替代设计;如果转型是必要的,试着将其隐匿在某个函数后,而不需要客户将转型放到自己的代码中。宁可使用新型转型,不要使用旧式转型,前者更容易分辨且有分门别类的职掌。

条款28:避免返回handles指向对象内部成分

考虑这样的程序设计,每个矩形由左上角和右上角表示。为了让Rectangle对象尽可能小,会将其放到一个辅助的struct内,再让Rectangle指向它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Point{
public:
Point(int x, int y);
...
void setX(int newVal);
void setY(int newVal);
};

struct RectData{
Point ulhc; // 左上角
Point lrhc; // 右下角
};

class Rectangle{
public:
...
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
private:
tr1::shared_ptr<RectData> pData;
};

Rectangle要返回左上角和右下角的数据给客户,而为了提高效率返回了Point引用。这样虽然能够通过编译,确实自我矛盾的,因为Point本应该是内部私有数据,但是经由public函数返回了引用,这样Point对于客户相当于是public的了,可以通过引用来更改内部数据。

这给我们带来两个教训:

  • 成员变量的封装性最多只等于“返回其reference”的函数的访问级别;
  • 如果const成员函数传出一个reference,后者所指数据与对象自身有关联,而它又被存储于对象之外,那么这个函数调用者就能够修改那笔数据。

一般地,如果成员函数返回指针或者迭代器,情况也是一样的。Reference, 指针和迭代器都是所谓的“handles”(号码牌,用来获取某个对象),返回一个“代表对象内部数据”的handle,随之而来的就是“降低对象封装性”的风险。

其实对于上述矩形例子的情况,对返回值加上const就能够保证内部数据不被外部修改:

1
2
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }

尽管如此,返回“代表内部对象的handles”也可能在其他场合带来问题,即可能导致dangling handles(空悬的号码牌):handles所指东西不复存在。

种种,并不代表你绝不可以让成员函数返回handles。但是,尽量避免返回handles指向对象内部,可以增加封装性,帮助const成员函数的行为像个const,并将发生“空悬号码牌”的可能性降到最低。

条款29:为“”异常安全“而努力是值得的

假设有一个class用来表现带背景图案的GUI菜单,同时这个class希望用于多线程环境,所以它配有一个互斥器(mutex)作为并发控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class PrettyMenu{
public:
...
void changeBackground(std::istream& imgSrc); // 改变背景图像
private:
Mutex mutex; // 互斥器
Image* bgImage; // 目前的背景图像
int imageChanges; // 背景图像被改变的次数
};

// 改变背景图像的一种可能的实现
void PrettyMenu::changeBackground(std::istream& imgSrc){
lock(&mutex); // 取得互斥器
delete bgImage; // 撤销旧的背景图像
++imageChanges; // 修改图像变更次数
bgImage = new Image(imgSrc); // 配置新的背景图像
unlock(&mutex); // 释放互斥器
}

这个函数仅仅完成了“基础功能”,但是从“异常安全性”的观点来看,却实现的非常糟糕。所谓“异常安全”有两个条件,而这个函数并没有满足其中任何一个。考虑到当异常被抛出时,带有异常安全性的函数往往会:

  1. 不泄露任何资源。上述代码没有做到,因为一旦“new Image()”导致了异常,那么unlock就不会调用,于是互斥器就永远被锁住了。
  2. 不允许数据破坏。如果“new Image()”抛出异常,那么bgImage就会指向一个已被删除的对象,而且imageChanges也被累加,但是实际上并没有新的图像被安装应用。

对于“资源泄露”,第三章已经介绍了很多方法,这里对于非heap-based的mutex,可以借鉴条款14引入的Lock类进行互斥器资源管理:

1
2
3
4
5
6
void PrettyMenu::changeBackground(std::istream& imgSrc){
Lock myLock(&mutex); // 自定义资源管理类,获取互斥器并保证稍后得到释放
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}

解决了第一个问题,就可以专注于如何处理可能的数据破坏了。异常安全函数(Exception-safe functions)提供以下三个保证之一:

  • 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下,不会有任何数据被破坏。但是唯一的问题就是程序的现实状态可能无法预测,例如更换背景的函数,异常抛出后回到之前的状态或者默认的状态都是有效的。
  • 强烈保证:如果异常被抛出,程序状态不改变。这意味着如果函数失败,程序会回复到“调用函数之前”的状态。
  • 不抛掷(nothrow)保证:承诺绝不抛出异常,因为它们总能够完成承诺的功能。例如,作用在内置类型(ints, 指针等)身上的所有操作都提供nothrow保证。

对于大部分函数而言,考虑异常安全会在基本保证和强烈保证中进行抉择。至于changeBackground来说,提供强烈保证并不困难:(1)改变PrettyMenu的bgImage成员变量的类型,从Image*指针改为一个“用于资源管理”的指针,使用std::tr1::shared_ptr。(2)对语句重新排序,当更换背景图像之后,在累加imageChanges变量。改变结果如下:

1
2
3
4
5
6
7
8
9
10
class PrettyMenu{
...
std::tr1::shared_ptr<Image> bgImage;
};

void PrettyMenu::changeBackground(std::istream& imgSrc){
Lock myLock(&mutex);
bgImage.reset(new Image(imgSrc)); // 以"new Image"的执行结果设定bgImage内部指针
++imageChanges;
}

这两个修改足以为changeBackground函数提供异常安全保证,唯一美中不足的就是参数imgSrc,如果输入流参数被移走,那么就会导致Image构造函数抛出异常。

注意,这里不再需要手动delete旧图像,因为这个动作在智能指针内部处理掉了,当新的背景图像创建成功后,智能指针更换指向前,就会自动释放旧的背景图像。

一般地,有个经典的策略也能够承诺强烈保证,即:copy and swap.

原则很简单:给你打算修改的对象(原件)做出一份副本,然后在那个副本身上做一切必要的修改。若有任何修改抛出异常,原对象仍保持不变。等所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换(swap)。

copy and swap在实现上通常是将所有“隶属对象的数据”从原对象放进另一个对象内,然后赋予原对象一个指针,指向那个实现对象,这种手法也就是所谓的”pimpl”,对于PrettyMenu而言,典型的写法是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct PMImpl{
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};

class PrettyMenu{
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc){
using std::swap;
Lock myLock(&mutex);
std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
swap(pImpl, pNew); // 置换实现对象,自动释放mutex
}

在这个例子中,之所以让PMImpl成为一个struct而不是一个class,是因为PrettyMenu的数据封装性已经由“pImpl是一个private成员”而得到了保证。

当然,有的时候某些原因必然会阻止你为函数提供强烈保证,其中一个就是效率。copy-and-swap的关键在于“修改对象数据的副本,然后在一个不抛出异常的函数中将修改后的副本与原件置换”,而为原有对象做出副本,就得消耗一定的时间和空间成本。当你无法提供这样的成本时,强烈保证就不那么实际了,这时就必须提供基本保证。

条款30:透彻了解inlining的里里外外

inline函数背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之。

但是,过于热衷inlining也会导致:增加目标码(object code)的大小,代码膨胀导致额外的换页行为,降低告诉缓存装置的命中率,以及伴随而来的效率损失。不过,如果inline函数的本体很小,编译器针对“函数本体”所产出的码可能比针对“函数调用”所产出的码更小,从而提高效率。

inline只是对编译器的一个申请,不是强制命令。而且这项申请既可以明确提出,也可以隐喻提出:

  • 明确提出,用inline关键字加以修饰。例如,标准的max template:

    1
    2
    3
    4
    template<typename T>
    inline const T& max(const T& a, const T& b){
    return a < b ? b : a;
    }
  • 隐喻提出,该方式是将函数定义在class的定义式内。即,类内定义的成员函数已被隐含地指定为Inline函数了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Person{
    public:
    int age() const { return theAge; } // age其实被隐含指定为inline
    inline string name() const; // 在类外定义,如需内联需明确提出为inline
    private:
    int theAge;
    string theName;
    };

    inline string Person::name() const{
    return theName;
    }

    但要注意的是,如果类的成员函数在类内声明,而在类外定义,那么编译器并不认为它是一个内联函数(例如Person::name)。所以建议就是不要在类中编写大量的代码,而是应该将类的声明和定义分开,只有成员规模小且调用频率高时,才指定为inline函数。

条款31:将文件间的编译依存关系降至最低

C++项目代码应该做到“接口与实现分离”,而其关键在于“以声明的依存性”替换“定义的依存性”。这也是编译依存性最小化的本质——程序头文件应该以“完全且仅有声明式”的形式存在:头文件自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。

令编译依存性最小化,可以源于以下的设计策略:

  • 如果使用 object reference 或 object pointers 可以完成任务,就不要使用 objects。你可以只靠一个类型声明式就定义出指向该类型的references和pointers;但如果定义某类型的objects,就需要用到该类型的定义式。例如以下写法:

    1
    2
    3
    4
    5
    6
    int main()
    {
    int x; // 定义一个int
    Person p( params ); // (1)定义一个Person
    Person *p; // 定义一个Person指针
    }

    当编译器看到p的定义式(1)时,它要知道应该在stack中分配多少空间才能放置一个Person,于是就会询问class定义式。而如果时定义式(2),那么对class Person的任何实现上的修改都不需要Person客户端重新编译了。

  • 如果能够,尽量以class声明式替换class定义式。值得注意的是,当你声明一个函数而它用到某个class时,你并不需要该class的定义,纵使函数以by value方式传递该类型的参数(或返回值)也是一样的。

    1
    2
    3
    class Date;                       // class声明式
    Date today(); // 声明函数,返回Date对象
    void clearAppointments(Date d); // 声明函数,参数为Date对象

    声明todayclearAppointments函数时是不需要Date类型的定义式的。只有当有人需要调用这些函数时,调用之前才需要曝光Date的定义式。

  • 为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式。当然,这些文件必须保持一致性。举个例子,如果客户想要声明today和clearAppointments函数,那么不应该以手工前置的方式声明Date类型,而是应该#include适当含有声明的头文件:

    1
    2
    3
    #include "datefwd.h"     // 该头文件内声明(而未定义)class Date
    Date today();
    void clearAppointments(Date d);

    这个致函声明式的头文件名为“datefwd.h”,命名方式同于标准程序库头文件的<iosfwd>, <iosfwd>内含iostream各组件的声明式,其对应的定义式则分布在若干不同的头文件中。

支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。而基于此构想的两个手段是 Handle class 和 Interface class.

  • Handle class: 将所有函数转交给相应的实现类并由后者完成实际工作,也就是 “pointer to implementation”的手法,例如下面是Person两个成员函数的实现:

    1
    2
    3
    4
    5
    6
    7
    8
    #include "Person.h"        // 包含Person定义式
    #include "PersonImpl.h" // 包含PersonImpl定义式

    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();
    }

    可以看到,实际工作是由其实现类完成的。

  • Interface class: 仅作为描述derived class的接口,不携带成员变量,也没有构造函数,只有一个virtual析构函数以及一组 pure virtual 函数,用来叙述整个接口。例如,一个针对Person而写的 Interface class 看起来会是这样的:

    1
    2
    3
    4
    5
    6
    7
    8
    class Person{
    public:
    virtual ~Person();
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
    virtual std::string address() const = 0;
    ...
    };

    Interface class 的客户同时必须有办法为这种class创建对象,这通常需要工厂(factory)函数或者virtual构造函数,它们返回指针(或更为可取的智能指针),指向动态分配的对象,而该对象支持 Interface class 的接口。这样的函数往往在 Interface class 内被声明为static

    static成员函数,不会获得对象的引用,也不能访问对象的非static成员。函数调用的结果不访问或者修改任何对象(非static)数据成员,那么将这样的成员函数声明为static成员函数比较好。

    例如,Person可能提供如下工厂函数,以让客户获取真正的Person对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    class Person{
    public:
    ...
    // create工厂函数返回一个shared_ptr,指向一个真实的Person派生类
    static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
    };

    // 定义静态成员函数——工厂函数
    std::tr1::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr){
    return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
    // 这里也需要RealPerson的声明,可以构建一个personpwd.h头文件,包含各种定义式的声明
    }

    // 一个具象的Person类
    class RealPerson: public Person{
    public:
    RealPerson(const std::string& name, const Date& birthday, const Address& addr):theName(name), theBirthDate(birthday),theAddress(addr){}
    virtual ~RealPerson{}
    // 下列函数的实现码不在这里显示
    std::string name() const;
    std::string birthDate() const;
    std::string address() const;
    private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
    };

    // 客户可能会这么使用
    std::string name;
    Date dateOfBirth;
    Address address;
    // 创建一个对象,支持Person接口。调用static函数create,拷贝构造shared_ptr。
    std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
    // 通过Person接口来使用这个对象
    std::cout<< pp->name() << "was born on " << pp->birthDate() << " and now lives at " << pp->address() <<endl;
    // 离开作用域后,该对象会由智能指针自动销毁

注意,如果只因为若干额外成本便不考虑Handle class 和 Interface class,那么将是严重的错误。应该考虑以渐进的方式使用这些技术。在程序发展过程中使用 Handle class 和 Interface class 以求实现码有所变化时对其客户带来最小冲击。而当它们导致速度和/或大小差异过于重大以至于classes之间的耦合相形之下不成为关键时,就以具象类(concrete class)替换Handle class 和 Interface class.

继承与面向对象设计

条款32:确定你的public继承塑模出 is-a 关系

纵使你会忘记其他的每一件事,也要记住为数不多的重要的事情。以C++进行面向对象编程,最重要的一个规则是:public inheritance(公开继承)意味 “is-a” 的关系

如果令class D(”Derived”)以public形式继承class B(”Base”),这就意味着,每一个D类型的对象同时也是一个B类型的对象,而反之不成立。B是比D更一般化的概念,D属于B。

public继承和 is-a 之间的等价关系听起来简单,但在一些现实情况中,你的直觉可能会误导你。例如,企鹅🐧是一种鸟,这是事实;而鸟可以飞,这也是事实。但如果用public继承来表述这层关系,那么企鹅应该会有能够飞的功能,这与现实又是违背的。其原因在于,我们的描述并不严谨,应该要承认一个前提事实:有些鸟并不会飞。那么应该这样写出继承关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Bird{
... // 没有声明fly函数
};

class FlyingBird: public Bird{
public:
virtual void fly();
...
};

class Penguin: public Bird{
... // 继承Bird,并没有声明fly函数
};

条款33:避免遮掩继承而来的名称

我们知道,对于同一名称而言,局部作用域中的名称会覆盖全局作用域的相同名称

对于继承而说,derived class 作用域被嵌套在 base class 作用域内,同时“名称可见性”与上述相同:derived class 内的名称会掩盖 base class 内的名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base{
private:
int x;
public:
virtual void mf1() = 0; // 纯虚函数mf1
virtual void mf1(int); // 虚函数mf1的重载
virtual void mf2(); // 虚函数mf2
void mf3(); // 重载mf3,两个都是非虚函数
void mf3(double);
};

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

这段代码带来的行为是诡异的。以作用域为基础的“名称遮掩规则”并没有改变,所以 base class 内所有名为mf1和mf3的函数都被 derived class 内的mf1和mf3掩盖掉了。于是,客户的某些调用将会出现错误:

1
2
3
4
5
6
7
Derived d;
int x;
...
d.mf1(); // 调用Derived::mf1
d.mf1(x); // 错误!因为Derived::mf1掩盖了Base::mf1
d.mf3(); // 正确
d.mf3(x); // 错误,同上

可见,不论 base class 和 derived class 内的同名函数是否有不同的参数类型、是否为virtual函数,其名称掩盖规则都是适用的。那么这样就会引出两种情况:

  • 想要继承 base class 并加上重载函数:那么必须为那些原本会被遮掩的每个名称引入一个using声明式

    1
    2
    3
    4
    5
    6
    7
    8
    class Derived: public Base{
    public:
    using Base::mf1; // 让Base class内名为mf1和mf3的所有东西在Derived作用域中都可见(且public)
    using Base::mf3;
    virtual void mf1();
    void mf3();
    void mf4();
    };

    这样声明之后,客户再调用不同形式的mf1和mf3都是可行的了。

  • 不想继承Base class中的所有函数:例如只想继承无参版本的mf1。当然,这在public继承中是绝对不可能发生的,这违反了“is-a”的关系。但是这在private继承中是有意义的,这需要一个转交函数(forwarding function)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class Base{
    public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    };

    class Derived: private Base{
    public:
    virtual void mf1(){ // 转交函数
    Base::mf1(); // 注意,这个类内定义的成员函数也是默认为inline的
    }
    };

    ...
    Derived d;
    int x;
    d.mf1(); // 正确,调用Derived::mf1,但是转交给了Base::mf1()处理
    d.mf1(x); // 错误!Base::mf1有参版本被遮掩了

条款34:区分接口继承和实现继承

表面上直接了当的public继承概念,实际上由两部分组成:函数接口继承函数实现继承。作为class的设计者,可能会面临以下情况:

  • 希望 derived class 只继承成员函数的接口(即声明);
  • 希望 derived class 同时继承函数的接口和实现,但又希望能够覆写(override)它们所继承的实现;
  • 希望 derived class 同时继承函数的接口和实现,并且不允许覆写任何东西。

为清楚地感觉上述选择的差异,考虑以下的例子:

1
2
3
4
5
6
7
8
9
class Shape{
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle: public Shape{ ... };
class Ellipse: public Shape{ ... };

Shape是一个抽象类,而且强烈影响了所有以public形式继承它的 derived class,因为:

  • 成员函数的接口总会被继承。例如Shape中的纯虚函数具备两个特征:在抽象类中通常没有定义,且必须被任何“继承了它们”的具象类重新声明。

  • 声明一个纯虚函数,是为了让 derived class 只继承函数接口

    纯虚函数draw的声明要求Shape的具象类必须提供一个draw函数,而不管如何被实现的。不过,令人意外的是,我们可以为 pure virtual 函数提供定义,但调用它的唯一途径是“调用时明确指出其class名称”:

    1
    2
    3
    Shape *ps = new Ellipse;
    ps->draw(); // 调用 Ellipse::draw
    ps->Shape::draw(); // 调用 Shape::draw

    一般而言,这种性质的用途有限,但是它可以实现一种机制:为(非纯)impure virtual 函数提供更一般更安全的缺省实现

  • 声明(非纯)impure 的虚函数,是为了让 derived class 继承该函数的接口和缺省实现

    例如Shape中的error函数,它表示每个class都必须支持一个“当遇到错误可以调用的”函数,每个class可以自由处理错误;但如果class不想对错误做出任何特殊处理,也可以回到Shape提供的缺省错误处理行为。

    不过,这样的缺省实现也有可能造成隐患:当派生类中某些可以支持缺省实现,而派生类的另一些必须特化实现时。例如,model A飞机的fly函数可以缺省实现,但是model B飞机的飞行方式与A不同,不能利用缺省fly来实现。这个时候通常有两种方式来切断“virtual函数接口”与“缺省实现”之间的关系:

    1. 将 impure 虚函数更改为纯虚函数,并提供一份 protected non-virtual 的缺省实现。支持缺省实现的派生类继承该接口并以缺省实现实施,必须特化的派生类可以实现纯虚函数进行特殊处理:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      class Airplane{
      public:
      virtual void fly(const Airport& destination) = 0;
      ...
      protected:
      void defaultFly(const Airport& destination);
      };

      void Airplane::defaultFly(const Airport& destination){
      ... // 缺省行为,飞到目的地
      }

      class ModelA: public Airplane{
      public:
      virtual void fly(const Airport& destination){
      defaultFly(destination); // 缺省实现,以inline函数实现
      }
      };
      class ModelB: public Airplane{
      public:
      virtual void fly(const Airport& destination);
      };
      void ModelB::fly(const Airport& destination){ ... } // 将ModelB飞向目的地

      其中,defaultFly为protected且non-virtual,因为客户不用知道飞机如何飞的,而且缺省实现也不应该被任何一个derived class 重新定义,故不需要virtual。但有些人反对这种方式,是考虑到这样对class的命名空间进行了污染(提供了函数接口和缺省实现)。那么下面的方式就会解除这样的名称污染问题。

    2. 将 impure 虚函数更改为纯虚函数,为该纯虚函数提供定义以作为缺省实现,但调用时注意要指明class的名称。例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      class Airplane{
      public:
      virtual void fly(const Airport& destination) = 0; // 作为纯虚函数
      };
      void Airplane::fly(const Airport& destination){
      ... // 缺省行为,飞向目的地
      }

      class ModelA: public Airplane{
      public:
      virtual void fly(const Airplane& destination){
      Airplane::fly(destination); // 缺省实现,inline
      }
      };

      class ModelB: public Airplane{
      public:
      virtual void fly(const Airport& destination);
      };
      void ModelB::fly(const Airport& destination){ ... } // 将ModelB飞向目的地

      这样相当于合并了fly和defaultFly两个函数,不过丧失了“让两个函数享有不同保护级别”的机会。

  • 声明 non-virtual 函数,是为了让 derived class 继承接口以及一份强制性实现。Base class 的成员函数为non-virutal表明它不打算在 derived class 中有不同的行为。实际上,一个non-virtual成员函数所表现的不变性(invariant)凌驾其特异性(specialization)。

pure virtual函数,impure virtual函数,non-virutal函数之间的差异,使你得以精确指定你想要 derived class 继承的东西:只继承接口,或是继承接口和一份缺省实现,或是继承接口和一份强制实现。

条款35:考虑virtual函数以外的其他选择

假设你在游戏设计中提供一个函数 healthValue,它返回一个整数,表示角色的健康程度。由于不同人物可能以不同方式计算其健康指数,因此将 healthValue 声明为一个 virtual 是一个再明白不过的做法:

1
2
3
4
class GameCharacter{
public:
virtual int healthValue() const;
};

这里 healthValue 并未声明为 pure virtual,这暗示着将会有一个计算健康程度的缺省实现。

不过,也可以考虑其他替代方案,能够帮助自己跳脱面向对象设计的常轨道:

  • 通过 Non-Virtual Interface 手法实现 Template Method 模式

    该流派主张任何 virtual 函数应该几乎总是 private。于是较好的做法就是:保留 healthValue 为public成员函数,但让它成为 non-virtual,并调用一个 private virtual 函数(例如,doHealthValue)进行实际工作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class GameCharacter{
    public:
    int healthValue() const{
    ... // 做一些事前工作
    int retVal = doHealthValue(); // 做真正的事情
    ... // 做一些事后工作
    return retVal;
    }
    private:
    virtual int doHealthValue() const{
    ... // 缺省算法
    }
    };

    这段代码中,两个在类内定义的函数都被暗自实现为 inline。让客户“通过 public non-virtual 成员函数间接调用 private virtual 函数”,这也就是 non-virtual interface(NVI) 手法。这个 non-virtual 函数(healthValue)称为 virtual 函数的外覆器(wrapper)。

    NVI手法涉及在 derived class 内重新定义 private virtual 函数,赋予它们“如何实现机能”的控制手段。其优点在于:外覆器能够确保一个 virtual 函数被调用之前能够设定好场景,并在调用结束之后清理场景。“事前工作”可以包括锁定互斥器(locking a mutex)、制造运转日志记录项、验证class约束条件、验证函数先决条件等。而“事后工作”可以包括互斥器解除锁定、验证函数的事后条件、再次验证class的约束条件等。

    注意的是,NVI手法下 virtual 函数也没必要一定为 private,有时候需要是 protected 甚至 public。

  • 通过 Function Pointers 实现 Strategy模式

    另一种设计主张“人物健康指数的计算与人物类型无关”,这样的计算不需要“人物”这个成分。例如,我们可以要求每个人物的构造函数接受一个指针,指向一个健康计算函数,而我们可以调用该函数进行实际计算:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class GameCharacter;         // 前置声明
    // 计算健康指数的缺省算法
    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;
    };

    这是 Strategy 设计模式的一个简单应用,与“基于GameCharacter继承体系内的virtual函数”相比,它提供了一些有趣的弹性:

    1. 同一人物类型的不同实体可以有不同的健康计算函数,例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class EvilBadGuy: public GameCharacter{
      public:
      explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc):healthFunc(hcf){}
      ...
      };
      int loseHealthQuickly(const GameCharacter&); // 健康指数计算1
      int loseHealthSlowly(const GameCharacter&); // 健康指数计算2

      EvilBadGuy ebg1(loseHealthQuickly);
      EvilBadGuy ebg2(loseHealthSlowly); // 相同类型的人物搭配不同的健康计算方式
    2. 某已知人物的健康指数计算函数可以在运行期间变更。例如GameCharacter可提供一个成员函数 setHealthCalculator,用来替换当前的健康指数计算函数。

  • 以 tr1::function 成员变量替换 virtual 函数,因而允许使用任何可调用物(callable entity)搭配一个兼容于需求的签名式。这也是Strategy的某种形式。

  • 将继承体系内的 virtual 函数替换为另一个继承体系内的 virtual 函数。这是Strategy设计模式的传统实现手法。

以上并没有彻底列出 virtual 函数的所有替换方案,它们各有优缺点,为避免陷入面向对象设计路上因常规而形成的凹洞中,这值得我们花时间加以研究。

条款36:绝不重新定义继承而来的 non-virtual 函数

考虑这样的情况,class D 由 class B 以public形式派生而来,class B定义有一个public成员函数mf,其参数和返回值都不重要(为简单,标记为void),而class D也有自己版本的mf函数,实现起来大概是这样:

1
2
3
4
5
6
7
8
9
class B{
public:
void mf();
};

class D: public B{
public:
void mf();
};

如果有以下行为:

1
2
3
4
5
D x;
B* pB = &x; // 获得一个指针,指向x
pB->mf(); // 经pB指针调用mf,将调用B::mf
D* pD = &x;
pD->mf(); // 经pD指针调用mf,将调用D::mf

你会比较惊奇地发现 class D 对象通过继承体系的不同指针指向,使用mf竟然会导致不同版本的调用。造成这种两面行为的原因是:non-virtual 函数(如B::mf 和 D::mf)都是静态绑定(statically bound)的。意思就是,pB被声明为一个pointer-to-B,通过pB调用的non-virtual函数永远是B所定义的版本,即使pB指向一个类型为“B派生类”的对象。

但是另一方面,virtual函数却是动态绑定(dynamically bound),所以如果mf是个virtual函数,不论通过pB或pD调用mf,都会导致调用D::mf,因为pB和pD真正指向的都是一个类型为D的对象。

public继承是is-a的关系,而且non-virtual函数的“不变性”是凌驾于“特异性”之上的。如果重新定义了base class的non-virtual函数,就与设计相矛盾。这给我们的警示就是:任何情况下都不该重新定义一个继承而来的non-virtual函数

条款37:绝不重新定义继承而来的缺省参数值

让我们整理一下思路,将讨论简化。

  1. 只能继承两种函数:virtual 和 non-virtual函数;
  2. 重新定义一个继承而来的 non-virtual 函数永远是错误的。

那么我们对于函数继承的讨论可以安全地限制在“继承一个带有缺省参数值的virtual函数”。

这种情况下,本条款成立的理由就直接而明确了,因为virtual函数是动态绑定的,而缺省参数值却是静态绑定的。这意味着如果重新定义继承而来的缺省参数值,可能会在“调用一个定义于derived class内的virtual函数”的同时,却使用了base class为它指定的缺省参数值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Shape{
public:
enum ShapeColor { Red, Green, Blue};
// 所有形状都必须提供一个函数,用来绘出自己
virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle: public Shape{
public:
// 赋予了不同的缺省参数值!
virtual void draw(ShapeColor color = Green) const;
};

class Circle: public Shape{
public:
virtual void draw(ShapeColor color) const;
// 注意,这么写的话,客户以对象调用此函数,一定要指定参数值,因为静态绑定下这个函数不从其base class继承缺省参数值;但如果以指针(或引用)调用此函数,却可能不指定参数值调用,因为动态绑定下可以从base class继承缺省参数值。
};

现在考虑这些指针:

1
2
3
4
5
Shape *ps;                      // 静态类型为Shape
Shape *pr = new Rectangle; // 静态类型为Shape,动态类型为Rectangle
Shape *pc = new Circle; // 静态类型为Shape,动态类型为Circle

pr->draw(); // 将调用Rectangle::draw(Shape::Red)

正如预期的那样,pr指向Rectangle,但是经由pr调用draw的缺省参数并不是定义Rectangle时指定的Green,而是Shape(静态绑定的基类类型)指定的Red。

这样的问题不光是指针,引用也会引起。其关键在于draw是一个virtual函数,而其中的缺省参数值在derived class中被重新定义了

那么如果遵守这条规则,并尝试解决这个问题:继承一个含有缺省参数值的virtual函数。那么又会导致“重复定义缺省参数值” 或者 代码重复:

1
2
3
4
5
6
7
8
9
class Shape{
public:
enum ShapeColor { Red, Green, Blue};
virtual void draw(ShapeColor color = Red) const = 0;
};

class Rectangle{
virtual void draw(ShapeColor color = Red) const; // 代码重复!
};

比较机智的做法是使用替代的设计(条款35列出了virtual的替代设计),例如使用NVI(non-virtual interface)手法:令 base class 内的一个 non-virtual 函数调用 private virtual 函数,后者可以被 derived class 重新定义。这里可以让 non-virtual 函数指定缺省参数,而 private virtua 函数负责真正的工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Shape{
public:
enum ShapeColor { Red, Green, Blue };
void draw(ShapeColor color = Red) const {
doDraw(color);
}
private:
virtual void doDraw(ShapeColor color) const = 0; // 派生类重新定义该函数,实现真正的工作
};

class Rectangle: public Shape{
public:
...
private:
virtual void doDraw(ShapeColor color) const{ // 注意,不用指定缺省参数值了
...
}
};

由于 non-virtual 函数绝不应该被 derived class 覆写,这个设计清楚地使得 draw 函数的 color 缺省参数值总是为 Red。

条款38:通过复合塑模出has-a或“根据某物实现出”

复合(composition)是类型之间的一种关系,当某种类型的对象内含其他类型的对象时,便是这种关系。例如:

1
2
3
4
5
6
7
8
9
10
11
class Address{ ... };
class PhoneNumber{ ... };
class Person{
public:
...
private:
std::string name; // 复合成分物(composed object)
Address address; // 同上
PhoneNumber voiceNumber; // 同上
PhoneNumber faxNumber; // 同上
};

Person对象有一个名称、一个地址以及语音和传真两笔电话号码。复合的术语也有许多同义词,包括 layering(分层)、containment(内含)、aggregation(聚和)和 embedding(内嵌)。

实际上,复合存在两种意义:

  • has-a(有一个):当复合发生在应用域内的对象之间,就会表现出 has-a 的关系。
  • is-implemented-in-terms-of(根据某物实现出):当复合发生在实现域内,则表现出 is-implemented-in-terms-of 的关系。

程序中处理的两个不同领域:

应用域:程序中的对象相当于你所塑造的世界中的某些事物,例如人、汽车、视频帧等。

实现域:程序中的对象纯碎是实现细节上的人工制品,比如缓冲区(buffers)、互斥器(mutexes)等。

区分 is-a 和 has-a 比较简单,而区分 is-a 和 is-implemented-in-terms-of 有时则比较困难。例如,要想自己实现一个set数据类型,并复用 linked list 作为底层数据结构。你可能会萌发一个想法:让Set继承List。

1
2
template<typename T>
class Set: public std::list<T> { ... };

但实际上这样的想法是完全错误的!public继承描述的是 is-a 的关系,可这完全说不通,list允许重复元素,而Set中不会存放任何一个相同的元素。所以public继承并不适合塑模他们的关系。针对此问题,更好的描述是,Set是根据List实现出的,即:

1
2
3
4
5
6
7
8
9
10
template<typename T>
class Set{
public:
bool member(const T& item) const;
void insert(const T& item);
void remove(const T& item);
std::size_t size() const;
private:
std::list<T> rep; // 用来描述Set中的数据
};

条款39:明智而审慎地使用private继承

之前已经知道了 public 继承意味这 “is-a” 的关系,而且编译器会在必要时刻(为了让函数调用成功)会将派生类暗自转换为基类。但如果是private继承,就不会这样了:

1
2
3
4
5
6
7
8
9
class Person{ ... };
class Student: private Person{ ... };

void eat(const Person& p); // 任何人都会吃
void study(const Student& s); // 学生在校学习

Person p; Studnet s;
eat(p); // 正确
eat(s); // 错误!这似乎有点违背常识,难道学生不是人?

这个例子表明,private继承不意味这 is-a 的关系,private继承所表明的规则如下:

  1. 如果classes之间的继承关系是private,编译器不会自动将一个derived class对象(例如 Student)转化为一个base class对象(例如 Person)。
  2. 由private base class 继承而来的所有成员,在derived class中都会变成private属性,纵使它们在base class中原本是protected或者public属性。

进一步地,private继承意味着 implemented-in-terms-of(根据某物实现出)。如果让 class D 以private形式继承 class B,你的用意是为了采用class B内已经具备的特性,而不是D和B对象存在有任何观念上的关系。private继承纯粹只是一种实现技术,这就是为什么继承自一个 private base class 的每样东西在你的 class 内都是private,因为它们都只是实现枝节而已。

可以发现,复合(composition)和 private继承有时可以表达相同的意思,那么应该怎么样做取舍呢?答案很简单:尽可能使用复合,必要时才使用private继承。所谓必要时候,主要是当 protected成员和/或virtual函数牵扯进来的时候。例如,我们修改 Widget class,让它记录每个成员函数的调用次数。运行期间周期性地审查这份信息,我们可以复用定时器来完成这项功能,定时器Timer的主要定义如下:

1
2
3
4
5
6
class Timer{
public:
explicit Timer(int tickFrequency);
virtual void onTick() const; // 定时器每滴答一次,该函数就被自动调用一次
...
};

我们可以调整任意频率,重新定义 virtual onTick 函数以取出Widget的当时状态。而让 Widget 重新定义 Timer 中的virtual函数就需要继承Timer。但 public 继承显然是不合适的,它们之间并不是 “is-a” 的关系,所以这里更适合的是使用 private 继承来完成功能:

1
2
3
4
class Widget: private Timer{
public:
virtual void onTick() const; // 定期查看Widget状态
};

private继承可以实现功能,但其实也并不是一个很好的设计。我们应该尽量使用复合的设计来取代,这里当时也是可以行得通的,只需要在Widget中声明一个嵌套式的 private class,该 class 以 public 形式继承Timer并重新定义onTick,然后将这样的class复合在Widget中:

1
2
3
4
5
6
7
8
9
10
class Widget{
private:
class WidgetTimer: public Timer{
public:
virtual void onTick() const;
...
};
WidgetTimer timer;
...
};

这个设计仅比 private 继承稍稍复杂一点,涉及到 public 继承和复用,并引入一个新的类(WidgetTimer)。

稍微整理一下,private继承主要用于“当 ‘derived class’ 想访问 ‘base class’ 中的protected成分,或为了重新定义一或多个virtual函数”,但这两个classes之间的概念关系是 is-implemented-in-terms-of 而非 is-a。但是,有一种激进情况涉及空间最优化,可能会促使你选择“private继承”而不是“public继承+复合”。

这种激进的情况是这样的:你所处理的class不带任何数据。这样的class没有non-static成员变量,没有virtual函数(因为虚函数会为每个对象带来一个vptr),也没有virtual base class。于是这种 empty class 对象不适用任何空间,然而C++规定凡是独立(非附属)对象都必须有非零大小,所以如果你这么做:

1
2
3
4
5
6
7
class Empty{};         // 没有数据,该类对象应该不使用任何内存

class HoldsAnInt{ // 应该只需要一个int的空间
private:
int x;
Empty e;
};

然而我们测试它们的大小,却能发现 sizeof(HoldsAnInt) > sizeof(int)。在大多数编译器中 sizeof(Empty)会获得1,因为面临大小为0的独立(非附属)对象时,C++通常会默默安插一个char到空对象内。然而由于齐位的需求,可能会造成编译器为 HoldsAnInt 加上一些衬垫(padding)。实际中得到的各对象大小如下:

1
2
3
sizeof(Empty): 1
sizeof(HoldsAnInt): 8
sizeof(int): 4

那么,如果 HoldsAnInt 附属在空白类上呢?继承Empty,而不是内含一个这样类型的对象:

1
2
3
4
5
6
7
class HoldsAnInt: private Empty{
private:
int x;
};
---------------------------
sizeof(Empty): 1
sizeof(HoldsAnInt): 4

可以发现,sizeof(HoldsAnInt) == sizeof(int)。这就是所谓的 EBO(empty base optimization; 空白基类最优化),如果客户非常在意空间,那么就值得注意EBO。另外,EBO一般只有在单一继承(而非多重继承)下才可行。

现实中,“empty class”并不是真正的empty,不然就失去了存在的意义。虽然它们没有non-static成员,但往往内含typedefs, enums, static成员变量,或non-virtual函数。EBO的广泛实践,使得这样的继承很少增加derived class 的大小。

条款40:明智而审慎地使用多重继承

多重继承(Multiple Inheritance; MI)比单一继承(Single Inheritance; SI)复杂,也会导致较多的歧义。例如:

  • 从一个以上的 base classes 继承相同的名称(如函数、typedef等)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class BorrowableItem{         // 图书馆允许你借某些东西
    public:
    void checkOut(); // 离开时进行检查
    };
    class ElectronicGadget{
    private:
    bool checkOut() const; // 执行自我检测,返回是否测试成功
    };
    class MP3Player: public BorrowableItem, public ElectronicGadget{
    ...
    };

    MP3Player mp;
    mp.checkOut(); // 导致歧义!到底调用的是哪个checkOut呢?

    当然这里ElectornicGadget中的checkOut是private,无法调用。要明确调用哪个base clas的checkOut函数也可以明确地指出类名进行调用。

  • 菱形继承问题:继承一个以上的 base classes,但这些 base classes 在继承体系中又有更高级的 base classes。

    multiple-inheritance

    这样的继承方式必须面临一个问题:是否打算让 base class 内的成员变量经由每一条路径都被复制?即,数据冗余。

    C++对于这个问题支持两种方案:

    1. 执行复制(缺省做法):IOFile将从每个基类继承一份,所以其对象内会有两份fileName成员变量。

    2. 不复制(采用“virtual 继承”):令直接继承File(含公共数据的那个类)的类采用 virtual 继承。

      1
      2
      3
      4
      class File { ... };
      class InputFile: virtual public File{ ... };
      class OutputFile: virtual public File{ ... };
      class IOFile: public InputFile, public OutputFile{ ... };

      但是也得为virtual 继承付出代价。使用virtual继承的类(如InputFile)所产生的对象比 non-virtual 的更大,而且访问 virtual base class 的成员变量时的速度也会慢下来。

    virtual继承的原理:

    。。。

virtual继承会增加大小、速度、初始化(及赋值)和复杂度等成本。如果 virtual base classes 不带任何数据,将是最具实用价值的情况。多重继承有时也有正当用途,例如涉及到“public 继承某个 Interface class”和“private 继承某个协助实现的class”两相结合的时候。

模板与泛型编程

条款41:了解隐式接口和编译期多态