《Effective C++》 笔记

《Effective C++》的副标题是改善程序与设计的55个具体做法。这本书用比较多的示例展示了很多改善C++程序的方法,值得一读。

让自己习惯C++

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

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

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

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

使用#define时在编译之前所有的常量都被替换成了数字,如果在编译的时候产生错误,那么追溯错误产生的源头将是一件非常困难的事情。使用const就不一样,其变量一定会被编译器看到,当然就会引入symbol table

当我们以常量替换#defines,有两种特殊情况值得说说。第一是定义常量指针( constant pointers)。由于常量定义式通常被放在头文件内(以便被不同的源码含入),因此有必要将指针(而不只是指针所指之物)声明为const

第二个值得注意的是class专属常量。为了将常量的作用域(scope)限制于class内,你必须让它成为 class一个成员(member);而为确保此常量至多只有一份实体,你必须让它成为一个static成员:

重点

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

条款03:尽可能使用const

const的一件奇妙的事情是,它允许你指定一个语义约束,而编译器会强制执行这项约束。

const语法虽然变化多端,但并不莫测高深。如果关键字const出现在星号左边,表示被指物是常量(底层const);如果出现在星号右边,表示指针自身是常量(顶层const);如果出现在星号两边,表示被指物和指针两者都是常量。

const最具威力的用法是面对函数声明时的应用。在一个函数声明式内, const可以和函数返回值、各参数、函数自身(如果是成员函数)产生关联。

const 成员函数

将 const实施于成员函数的目的,是为了确认该成员函数可作用于 const对象身上。这一类成员函数之所以重要,基于两个理由。第一,它们使 class接口比较容易被理解。这是因为,得知哪个函数可以改动对象内容而哪个函数不行,很是重要。第二,它们使“操作 const对象”成为可能。

重点

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

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

在某些语境下x保证被初始化(为0),但在其他语境中却不保证。读取未初始化的值会导致不明确行为

对于内置类型之外的东西,初始化的任务落在了构造函数的身上。我们要确保每一个构造函数都将对象的每一个成员初始化。但是我们很容易混淆复制和初始化的而例子,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

class A {
public:
A(const std::string& a, const std::string& b);
private:
std::string thea;
std::string theb;
};

A::A(const std::string& a, const std::string& b)
{
thea = a; // 这些都是赋值,而非初始化
theb = b;
}

C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。所以构造函数最好使用成员初值列进行初始化。

重点

  • 为内置型对象进行手工初始化,因为C++不保证初始化它们。
  • 构造函数最好使用成员初值列( member initialization list),而不要在构造函数本体内使用赋值操作( assignment)。初值列列出的成员变量,其排列次序应该和它们在 class中的声明次序相同。

构造/析构/赋值运算

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

如果你自己没声明,编译器就会为它声明(编译器版本的)一个copy构造函数、一个 copy assignment操作符和一个析构函数。此外如果你没有声明任何构造函数,编译器也会为你声明一个 default构造函数。所有这些函数都是 public且in1ine。

default构造函数和析构函数主要是给编译器一个地方用来放置“藏身幕后”的代码,像是调用 base classes和non-static成员变量的构造函数和析构函数。

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

重点

  • 编译器可以暗自为 class创建default构造函数、copy构造函数、 copy assignment操作符,以及析构函数。

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

如果你不希望 class支持某一特定机能,只要不声明对应函数就是了。但这个策略对copy构造函数和copy assignment操作符却不起作用

所有编译器产出的函数都是 public.为阻止这些函数被创建出来,你得自行声明它们,但这里并没有什么需求使你必须将它们声明为 public。因此你可以将cory构造函数或 copy assignment操作符声明为 private。藉由明确声明一个成员函数,你阻止了编译器暗自创建其专属版本;而令这些函数为 private,使你得以成功组织别人调用他

重点

  • 为驳回编译器自动(暗自)提供的机能,可将相应的成员函数声明为 private并且不予实现。

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

C++明白指出,当 derived class对象经由一个base class指针被删除,而该 base class带着一个non- virtual析构函数,其结果未有定义实际执行时通常发生的是对象的 derived成分没被销毁。

消除这个问题的做法很简单:给 base class一个 virtua析构函数

重点

  • polymorphic(带多态性质的) base classes应该声明一个 virtual析构函数。如果class带有任何 virtual函数,它就应该拥有一个 virtual析构函数。

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

C++并不禁止析构函数吐出异常,但它不鼓励你这样做。理由是当有多个(比如在vector中)对象需要销毁的时候,第一个对象和第二个对象析构是都抛出异常,这种情况下程序不是结束执行就是导致未定义行为。

重点

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

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

base class构造期间 virtual函数绝不会下降到derived classes阶层。取而代之的是,对象的作为就像隶属base类型一样。非正式的说法或许比较传神:在 base class构造期间, virtual函数不是 virtual函数。

由于base class构造函数的执行更早于derived class构造函数,当base class构造函数执行时 derived class的成员变量尚未初始化。如果此期间调用的 virtual函数下降至 derived classes阶层,要知道 derived class的函数几乎必然取用 local成员变量,而那些成员变量尚未初始化。这将是一张通往不明确行为和彻夜调试大会串的直达车票。“要求使用对象内部尚未初始化的成分”是危险的代名词,所以C++不让你走这条路。

相同道理也适用于析构函数。一旦derived class析构函数开始执行,对象内的derived class成员变量便呈现未定义值,所以C++视它们仿佛不再存在。进入base class析构函数后对象就成为一个base class对象,而C++的任何部分包括virtual函数、 dynamic casts等等也就那么看待它。

重点

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

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

复制可以写成连锁形式:

x = y = z = 15;

同时赋值采用右结合律,所以上述连锁赋值被解析为:

x = (y = (z = 15));

为了实现“连锁赋值”,赋值操作符必须返回一个 reference指向操作符的左侧实参。

重点

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

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

“自我赋值“发生在对象赋值给自己时:

1
2
3
4
class Widget { ... } ;
Widget w;
...
w = w;

这看起来有点愚蠢,但它合法,所以不要认定客户绝不会那么做。

自我赋值可能出现的一个问题是, operator=函数内的*this(赋值的目的端)和rhs有可能是同一个对象。果真如此 delete就不只是销毁当前对象的 bitmap,它也销毁rhs的 bitmap。

欲阻止这种错误,传统做法是藉由 operator=最前面的一个“证同测试( identitytest)”达到“自我赋值”的检验目的:

1
2
3
4
5
Widget& Widget::operator=(const Widget& rhs)
{
if(this == rhs) return *this;
...
}

重点

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

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

重点

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

资源管理

条款13:以对象管理资源

有的时候我们我们使用工厂函数获得某个特定的对象的指针的时候,在使用完毕时候需要将其占用的空间释放,尽管我们有这个意识,但是在实际开发中,如果中间出现异常或者return可能会造成内存泄漏的现象。

为了确保使用工厂函数返回的资源总是被释放,我们需要将资源放进对象内,当控制流离开f,该对象的析构函数会自动释放那些资源。实际上这正是隐身于本条款背后的半边想法:把资源放进对象内,我们便可倚赖C++的“析构函数自动调用机制”确保资源被释放。(稍后讨论另半边想法。)

重点

  • 为防止资源泄漏,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源
  • 两个常被使用的 RAII classes分别是trl::shared_ptr和 auto_ptr。前者通常是较佳选择,因为其copy行为比较直观。若选择 auto_ptr,复制动作会使它(被复制物)指向null

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

我们使用C API管理一个互斥对象的时候,有lock和unlock两个函数可以用;

1
2
void lock(Mutex* pm);     // 锁定
void unlock(Mutex* pm); // 解锁

为了确保不会忘记解锁一个互斥量,我们可以使用上一个条款的建议,创建一个对象来管理这个互斥量,使得“资源在构造期间获得,在析构期间释放”

但是如果这个对象发生复制的时候怎么办?大多时候有以下两种可行的方案:

  • 禁止复制
  • 对底层资源祭出“引用计数法”(reference-count)

重点

  • 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定RAII对象的copying行为。
  • 普遍而常见的 Rail class copying行为是:抑制 copying、施行引用计数法( reference counting)。不过其他行为也都可能被实现

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

资源管理类( resource-managing classes)很棒。它们是你对抗资源泄漏的堡垒。排除此等泄漏是良好设计系统的根本性质。在一个完美世界中你将倚赖这样的classes来处理和资源之间的所有互动,而不是玷污双手直接处理原始资源(rawresources)。但这个世界并不完美。许多APIs直接指涉资源,所以除非你发誓(这其实是一种少有实际价值的举动)永不录用这样的APls,否则只得绕过资源管理对象( resource-managing objects)直接访问原始资源( raw resources)。

由于有时候还是必须取得RAI对象内的原始资源,某些 RAII class设计者于是联想到“将油脂涂在滑轨上”,做法是提供一个显示转换函数或者隐式转换函数。

重点

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

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

当你使用new(也就是通过new动态生成一个对象),有两件事发生。第内存被分配出来(通过名为 operator new的函数)。第二,针对此内存会有一个(或更多)构造函数被调用。

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

delete的最大问题在于:即将被删除的内存之内究竟存有多少对象?这个问题的答案决定了有多少个析构函数必须被调用起来。实际上这个问题可以更简单些:即将被删除的那个指针,所指的是单一对象或对象数组?这是个必不可缺的问题,因为单一对象的内存布局一般而言不同于数组的内存布局。

所以在成对使用new和delete的时候要采取相同的形式

重点

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

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

重点

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

设计与声明

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

欲开发一个“容易被正确使用,不容易被误用”的接口,首先必须考虑客户可能做出什么样的错误。

预防客户错误的另一个办法是,限制类型内什么事可做,什么事不能做。常见的限制是加上 const。例如,以const修饰operator* 的返回类型可以阻止客户因“用户自定义类型”而犯错:

if( a * b = c ) ...原意应该为做一次比较动作

下面是另一个一般性准则“让 types容易被正确使用,不容易被误用”的表现形式“除非有好理由,否则应该尽量令你的 types的行为与内置 types一致”。客户已经知道像int这样的type有些什么行为,所以你应该努力让你的 types在合样合理的前提下也有相同表现。

重点

  • 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
  • “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
  • “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任

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

设计优秀的 classes是一项艰巨的工作,因为设计好的 types是一项艰巨的工作。好的 types有自然的语法,直观的语义,以及一或多个高效实现品。在C++中,一个不良规划下的 class定义恐怕无法达到上述任何一个目标。甚至 class的成员函数的效率都有可能受到它们“如何被声明”的影响。

设计一个class需要思考如下的一些问题:

  • 新type的对象应该如何被创建和销毁?这会影响到你的 class的构造函数和析构函数以及内存分配函数和释放函数( operator new, operator new[], operator delete和 operator delete[])的设计,当然前提是如果你打算撰写它们。
  • 对象的初始化和对象的赋值该有什么样的差别?这个答案决定你的构造函数和赋值操作符的行为,以及其间的差异。
  • 新type的对象如果被 passed by value(以值传递),意味着什么?记住,copy构造函数用来定义一个type的 pass-by-value该如何实现。
  • 什么是新“type”的合法值
  • 你的新type需要配合某个继承图系( inheritance graph)吗?

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

一般情况下,使用pass-by-reference-to-const能够调用更少的构造函数和析构函数,从而使得程序更加高效。

以by reference方式传递参数也可以避免 slicing(对象切割)问题。当一个 derived class对象以 by value方式传递并被视为一个 base class对象, base class的copy构造函数会被调用,而“造成此对象的行为像个 derived class对象”的那些特化性质全被切割掉了,仅仅留下一个 base class对象。

但是如果你有个对象属于内置类型(例如int), pass by value往往比 pass by reference的效率高些。对内置类型而言,当你有机会选择采用 pass-by-value或 pass-by-reference-to-const时,选择 pass-by-value并非没有道理。这个忠告也适用于STL的迭代器和函数对象,因为习惯上它们都被设计为passed by value。迭代器和函数对象的实践者有责任看看它们是否高效且不受切割问题

重点

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

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

所谓 reference只是个名称,代表某个既有对象。任何时候看到一个 reference声明式,你都应该立刻问自己,它的另一个名称是什么?因为它一定是某物的另一个名称。

有的时候我们在返回引用的时候,其引用的对象如果一个局部变量,而这个局部变量在退出前就销毁了,所以不能够使用引用;如果所引用的对象是通过动态创建内存的方式创建的,这种情况下由谁来实施delete是一个问题。

重点

  • 绝不要返回 pointer或 reference指向一个 local stack对象,或返回 reference指向个 heap-allocated对象,或返回 pointer或 reference指向一个 local static对象而有可能同时需要多个这样的对象。

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

如果成员变量不是 public,客户唯一能够访问对象的办法就是通过成员函数。如果 public接口内的每样东西都是函数,客户就不需要在打算访问 class成员时迷惑地试着记住是否该使用小括号(圆括号)。他们只要做就是了,因为每样东西都是函数。就生命而言,这至少可以省下许多搔首弄耳的时间

使用函数可以让你对成员变量的处理有更精确的控制。如果你令成员变量为 public,每个人都可以读写它,但如果你以函数取得或设定其值,你就可以实现出“不准访问”、“只读访问”以及“读写访问”。

还是不够说服你?是端出大口径武器的时候了:封装啦。如果你通过函数访问成员变量,日后可改以某个计算替换这个成员变量,而 class客户一点也不会知道 class的内部实现已经起了变化。

重点

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

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

面向对象守则要求,数据以及操作数据的那些函数应该被捆绑在一块,这意味它建议 member函数是较好的选择。不幸的是这个建议不正确。这是基于对面向对象真实意义的一个误解。面向对象守则要求数据应该尽可能被封装,然而与直观相反地,member函数带来的封装性比non-member函数低。

让我们从封装开始讨论。如果某些东西被封装,它就不再可见。愈多东西被封装愈少人可以看到它。而愈少人看到它,我们就有愈大的弹性去变化它,因为我们的改变仅仅直接影响看到改变的那些人事物。因此,愈多东西被封装,我们改变那些东西的能力也就愈大。这就是我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户

现在考虑对象内的数据。愈少代码可以看到数据(也就是访问它),愈多的数据可被封装,而我们也就愈能自由地改变对象数据,例如改变成员变量的数量、类型等等。

重点

  • 宁可拿non-member non-friend函数替换 member函数。这样做可以增加封装性、包裹弹性(packaging flexibility)和机能扩充性。

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

只有当参数被列于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。地位相当于“被调用之成员函数所隶属的那个对象”一一即this对象—的那个隐喻参数,绝不是隐式转换的合格参与者。

重点

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

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

如果swap的缺省实现码对你的 class或 class template提供可接受的效率,你不需要额外做任何事。任何尝试置换(swap)那种对象的人都会取得缺省版本,而那将有良好的运作。

其次,如果swap缺省实现版的效率不足(那几乎总是意味你的 class或 template使用了某种pmpl手法),试着做以下事情

  • 提供一个 public swap成员函数,让它高效地置换你的类型的两个对象值。这个函数绝不该抛出异常。
  • 在你的cass或 template所在的命名空间内提供一个non-member swap,并令它调用上述swap成员函数。
  • 如果你正编写一个cass而非 class template),为你的clas特化stc并令它调用你的swap成员函数。

唯一还未明确的是我的劝告:成员版swap绝不可抛出异常。那是因为swap的个最好的应用是帮助 classes(和 class templates)提供强烈的异常安全性(exception-safety)保障。

高效率的swap几乎总是基于对内置类型的操作(例如pmpl手法的底层指针),而内置类型上的操作绝不会抛出异常。

重点

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

实现

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

只要你定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流( control flow)到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受析构成本。即使这个变量最终并未被使用,仍需耗费这些成本,所以你应该尽可能避免这种情形。

重点

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

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

C++提供四种新式转型:
const_cast<T>( expression )
dynamic_cast<T>( expression )
reinterpret_cast<T>( expression )
static_cast<T>(expression )

  • const_cast通常被用来将对象的常量性转除
  • dynamic_cast主要用来执行“安全向下转型”(safe downcasting),也就是用来决定某对象是否归属继承体系中的某个类型。
  • reinterpret_cast意图执行低级转型,实际动作(及结果)可能取决于编译器这也就表示它不可移植。
  • static_cast用来强迫隐式转换

许多程序员相信,转型其实什么都没做,只是告诉编译器把某种类型视为另一种类型。这是错误的观念。任何一个类型转换(不论是通过转型操作而进行的显式完成的隐式转换〕往往真的令编译器编译出运行期间执行的码。

重点

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

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

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

重点

  • 避免返回 handles(包括 references、指针、迭代弋器)指向对象内部。遵守这个条可增加封装性,帮助 const成员函数的行为像个 const,并将发生“虚吊号码牌”( dangling handles)的可能性降至最低。

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

当异常抛出时,带有异常安全性的函数会:

  1. 不泄露任何资源
  2. 不允许数据破坏

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

  • 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态
  • 强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。
  • 不抛掷( nothrow)保证,承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(例如ints,指针等等)身上的所有操作都提供nothrow保证。这是异常安全码中一个必不可少的关键基础材料。

重点

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

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

编译器最优化机制通常被设计用来浓缩那些“不含函数调用”的代码,所以当你 inline某个函数,或许编译器就因此有能力对它(函数本体)执行语境相关最优化。然而编写程序就像现实生活一样,没有白吃的午餐。 inline函数也不例外。 inline函数背后的整体观念是,将“对此函数的每一个调用”都以函数本体替换之。首先这样会增加你的目标代码的大小

Inline函数通常一定被置于头文件内,因为大多数建置环境( build environments)在编译过程中进行 inlining,而为了将一个“函数调用”替换为“被调用函数的本体”,编译器必须知道那个函数长什么样子。

程序库设计者必须评估“将函数声明为 inline”的冲击: inline函数无法随着程序库的升级而升级。换句话说如果f是程序库内的一个 inline函数,客户将“f函数本体”编进其程序中,一旦程序库设计者决定改变f,所有用到f的客户端程序都必须重新编译。这往往是大家不愿意见到的。

重点

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

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

问题出在C++并没有把“将接口从实现中分离”这事做得很好。 Class的定义式不只详细叙述了clas接口,还包括十足的实现细目。如果一个类定义中有编译依赖的其他文件中的类,那么修改所依赖的类的定义会导致现在的文件需要重新编译。这样的连串编译依存关系( cascading compilation dependencies)会对许多项目造成难以形容的灾难

上述的东西的一种解决方案是“前置声明每一件东西”,但是这个存在两个问题:第一,对于模板的前置声明比较复杂;第二、编译器必须在编译期间知道对象的大小,知道其大小就必须知道class中是如何实现的

重点

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

继承与面向对象设计

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

以C++进行面向对象编程,最重要的一个规则是: public inheritance(公开继承)意味”is-a”(是一种)的关系。把这个规则牢牢地烙印在你的心中吧!

ls-a并非是唯一存在于 classes之间的关系。另两个常见的关系是has-a(有个)和is-implemented-in- terms-of(根据某物实现出)。

重点

  • “public继承”意味ls-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个 base class对象。

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

当编译器处于someFunc的作用域内并遭遇名称x时,它在 local作用域内查找是否有什么东西带着这个名称。如果找到就不再找其他作用域。C++的名称遮掩规则( name-hiding rules)所做的唯一事情就是:遮掩名称。至于名称是否应和相同或不同的类型,并不重要。

现在导入继承。我们知道,当位于一个derived class成员函数内指涉( refer to)base class内的某物(也许是个成员函数、 typedef、或成员变量)时,编译器可以找出我们所指涉的东西,因为 derived classes继承了声明于 base classes内的所有东西。实际运作方式是, derived class作用域被嵌套在 base class作用域内

有时候你并不想继承 base classes的所有函数,这是可以理解的。在 public继承下,这绝对不可能发生,因为它违反了 public继承所暗示的“base和 derived classes之间的is-a关系”。

重点

  • derived classes内的名称会遮掩 base classes内的名称。在public继承下从来没有人希望如此。
  • 为了让被遮掩的名称再见天日,可使用using声明式或转交函数( forwardingfunctions)。

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

表面上直截了当的 public继承概念,经过更严密的检查之后,发现它由两部分组成:函数接日( function interfaces)继承和函数实现( function implementations.)继承。这两种继承的差异,很像本书导读所讨论的函数声明与函数定义之间的差异。

pure virtual函数有两个最突出的特性:它们必须被任何“继承了它们”的具体对象class重新声明,而且它们在抽象 class中通常没有定义。把这两个性质摆在一起,你就会明白:声明一个 pure virtual函数的目的是为了让 derived classes只继承函数接口

简朴的 impure virtual函数背后的故事和 pure virtual函数有点不同。一如往常,derived classes继承其函数接口,但 impure virtual函数会提供一份实现代码, derived classes可能覆写( override)它。稍加思索,你就会明白:声明简朴的(非纯) impure virtual函数的目的,是让 derived classes继承该函数的接口和缺省实现

如果成员函数是个non-virtual函数,意味是它并不打算在 derived classes中有不同的行为。实际上一个 non-virtual成员函数所表现的不变性( invariant)凌驾其特异性( specialization),因为它表示不论 derived class变得多么特异化,它的行为都不可以改变。就其自身而言:声明non-virtual函数的目的是为了令 derived classes继承函数的接口及一份强制性实现

pure virtual函数、 simple (impure)vmal函数、 non-virtual函数之间的差异,使你得以精确指定你想要 derived classes继承的东西:只继承接口,或是继承接口和一份缺省实现,或是继承接口和一份强制实现。由于这些不同类型的声明意味根本意义并不相同的事情,当你声明你的成员函数时,必须谨慎选择。

如果你确实履行,应该能够避免经验不足的 class设计者最常犯的两个错误:

  • 第一个错误是将所有函数声明为non-virtual这使得 derived classes没有余裕空间进行特化工作
  • 另一个常见错误是将所有成员函数声明为 virtual

重点

  • 接口继承和实现继承不同在 public继承之下, derived classes总是继承 base class的接口。
  • pure virtual函数只具体指定接口继承。
  • 简朴的(非纯) impure virtual函数具体指定接口继承及缺省实现继承。
  • non-virtual函数具体指定接口继承以及强制性实现继承。

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

当你为解决问题而寻找某个设计方法时,不妨考虑virtual函数的替代方案。

  1. 使用non-virtual interface(NVI)手法,那是Template Method设计模式的一种特殊形式。它以 public non-virtual成员函数包裹较低访问性( private或 protected)的 virtua函数。
  2. 将 virtual函数替换为“函数指针成员变量”,这是 Strategy设计模式的一种分解表现形式。
  3. 以tr1:: function成员变量替换 virtual函数,因而允许使用任何可调用物( callable entit!y)搭配一个兼容于需求的签名式。这也是 Strategy设计模式的某种形式。
  4. 将继承体系内的 virtual函数替换为另一个继承体系内的 virtual函数。这是Strategy设计模式的传统实现手法。

以上并未彻底而详尽地列出 virtual函数的所有替换方案,但应该足够让你知道的确有不少替换方案。此外,它们各有其相对的优点和缺点,你应该把它们全部列入考虑

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

对于D继承B以及non-virtual成员函数B::mf身上:

  • 适用于B对象的每一件事,也适用于D对象,因为每个D对象都是一个B对象;
  • B的 derived classes一定会继承mf的接口和实现,因为mf是B的一个non-virtual函数。

现在,如果b重新定义mf,你的设计便出现矛盾。如果D真有必要实现出与B不同的mf,并且如果每一个B对象——不管多么特化—一真的必须使用B所提供的mf实现码,那么“每个D都是一个B”就不为真。既然如此D就不该以 public形式继承B。另一方面,如果D真的必须以 public方式继承B,并且如果D真有需要实现出与B不同的mf,那么mf就无法为B反映出“不变性凌驾特异性”的性质。既然这样mf应该声明为 virtual函数。最后,如果每个D真的是一个B,并且如果mf真的为B反映出“不变性凌驾特异性”的性质,那么D便不需要重新定义mf,而且它也不应该尝试这样做。

重点

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

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

让我们一开始就将讨论简化。你只能继承两种函数: virtual和 non-virtual函数。然而重新定义一个继承而来的non-virtual函数永远是错误的(见条款36),所以我们可以安全地将本条款的讨论局限于“继承一个带有缺省参数值的 virtual函数”。

这种情况下,本条款成立的理由就非常直接而明确了: virtual函数系动态绑定( dynamically bound),而缺省参数值却是静态绑定( statically bound)

为什么C++坚持以这种乖张的方式来运作呢?答案在于运行期效率。如果缺省参数值是动态绑定,编译器就必须有某种办法在运行期为 virtua函数决定适当的参数缺省值。这比目前实行的“在编译期决定”的机制更慢而且更复杂。为了程序的执行速度和编译器实现上的简易度,C++做了这样的取舍,其结果就是你如今所享受的执行效率。但如果你没有注意本条款所揭示的忠告,很容易发生混淆。

重点

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

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

复合( composition)是类型之间的一种关系,当某种类型的对象内含它种类型的对象,便是这种关系。

条款32曾说,“public继承”带有is-a(是一种)的意义。复合也有它自己的意义实际上它有两个意义。复合意味has-a(有一个)或 is-implemented-in-terms-of(根据某物实现出)。

重点

  • 复合( composition)的意义和 public继承完全不同
  • 在应用域( application domain),复合意味has-a(有一个)。在实现域( implementation domain),复合意味 is-implemented- in-terms-of(根据某物实现出)

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

统御 private继承的首要规则你刚才已经见过了:如果classes之间的继承关系是 private,编译器不会自动将一个 derived class对象(例如Student)转换为一个 base class对象(例如 Person)。这和 public继承的情况不同。第二条规则是,由 private base class继承而来的所有成员,在 derived class中都会变成 private属性,纵使它们在base class中原本是 protected或 public属性。

Private继承意味 implemented-in-terms-of(根据某物实现出)。如果你让 class D以 private形式继承 class B,你的用意是为了采用 class B内已经备妥的某些特性,不是因为B对象和D对象存在有任何观念上的关系。 private继承纯粹只是一种实现技术(这就是为什么继承自一个 private baseclass的每样东西在你的clas内都是 private:因为它们都只是实现枝节而已)。借用条款34提出的术语, private继承意味只有实现部分被继承,接口部分应略去

重点

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

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

当派生类继承的多个基类中都有某个同名成员函数的时候,可能会产生歧义。这个时候C++处理的方法与C++用来解析( resolving)重载函数调用的规则相符:在看到是否有个函数可取用之前,C++首先确认这个函数对此调用之言是最佳匹配。找出最佳匹配函数后才检验其可取用性。

从正确行为的观点看, public继承应该总是 virtuale。如果这是唯一一个观点,规则很简单:任何时候当你使用 public继承,请改用 virtual public继承。但是,啊呀,正确性并不是唯一观点。为避免继承得来的成员变量重复,编译器必须提供若干幕后戏法,而其后果是:使用 virtual继承的那些 classes所产生的对象往往比使用non- virtual继承的兄弟们体积大,访问 virtual base classes的成员变量时,也比访问non- virtual base classes的成员变量速度慢。种种细节因编译器不同而异,但基本重点很清楚:你得为 virtual继承付出代价。

重点

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