《Effective C++》——在读
约 4817 字大约 16 分钟
2025-08-07
《Effective C++》 - [美] Scott Meyers
有人说 C++程序员可以分成两类,读过 EfcciveC++的和没读过的。世界顶级C++大师Scont Meyers 成名之作的第三版的确当得起这样的评价。当您读过这本书之后,就获得了迅速提升自己C++功力的一个契机。
在国际上,本书所引起的反响,波及整个计算技术出版领域,余音至今未绝。几乎在所有C++书籍的推荐名单上,本节都会位于前三名。作者高超的技术把握力、独特的视角、诙谐轻松的写作风格、独具匠心的内容组织,都受到极大的推崇和仿效。这种奇特的现象,只能解释为人们对这本书衷心的赞美和推崇。
这本书不是读完一遍就可以束之高阁的快餐读物,也不是用以解决手边问题的参考手册,而是需要您去反复阅读体会的,C++是真正程序员的语言,背后有着精深的思想与无与伦比的表达能力,这使得它具有类似宗教般的魅力。希望这本书能够帮助您跨越 C++的重重险阻,领略高处才有的壮美风光,做一个成功而快乐的C++程序员。
序
按孙中山先生的说法,这个世界依聪明才智的先天高下得三种人:先知先觉得发明家,后知后觉得宣传家,不知不觉得实践家。三者之中发明家最少最稀珍,最具创造力。正是匠心独具的发明家创造了这个花花绿绿的计算机世界。
世上没有白吃的午餐!又要有效率,又要有弹性,又要前瞻望远,又要回溯相又要治大国,又要烹小鲜,学习起来当然就不可能太简单。在庞大复杂的机制下,万千使用者前仆后继的动力是:一旦学成,妙用无穷。
1 让自己习惯C++
条款01:视 C++ 为一个语言联邦
注
一开始,C++只是C加上一些面向对象特性。C++最初的名称CwithClasses也反映了这个血缘关系。
但是当这个语言逐渐成熟,它变得更活跃更无拘束,更大胆更冒险,开始接受不同于Cwith Classes 的各种观念、特性和编程战略。Exceptions(异常)对函数的结构化带来不同的做法(见条款29),templates(模板)将我们带到新的设计思考方式(见条款 41),STL 则定义了一个前所未见的伸展性做法。
今天的 C++已经是个多重范型编程语言(multiparadigm programming language),一个同时支持过程形式(procedural)、面向对象形式(object-oriented)函数形式(functional)、泛型形式(generic)、元编程形式(metaprogramming)的语言。这些能力和弹性使C++ 成为一个无可匹敌的工具,但也可能引发某些迷惑:所有“适当用法”似乎都有例外。我们该如何理解这样一个语言呢?
注
- C。说到底C++仍是以C为基础。区块(blocks)、语句(statements)、预处理器(preprocessor)、内置数据类型(built-indatatypes)、数组(arrays)指针(pointers)等统统来自C。许多时候C++对问题的解法其实不过就是较高级的C解法(例如条款2谈到预处理器之外的另一选择,条款13谈到以对象管理资源),但当你以C++内的C成分工作时,高效编程守则映照出C语言的局限: 没有模板(templates),没有异常(exceptions),没有重载(overloading)……
- Object-Oriented C++。这部分也就是Cwith Classes所诉求的:classes(包括构造函数和析构函数),封装(encapsulation)、继承(inheritance)、多态(polymorphism)、virtual函数(动态绑定)……等等。这一部分是面向对象设计之古典守则在 C++ 上的最直接实施。
- Template C++。这是C++ 的泛型编程(generic programming)部分,也是大多数程序员经验最少的部分。Template相关考虑与设计已经弥漫整个C++,良好编程守则中“惟template 适用”的特殊条款并不罕见(例如条款46谈到调用template functions 时如何协助类型转换)。实际上由于templates 威力强大,它们带来崭新的编程范型(programmingparadigm),也就是所谓的templatemetaprogramming(TMP,模板元编程)。条款48对此提供了一份概述,但除非你是 template 激进团队的中坚骨干,大可不必太担心这些。TMP 相关规则很少与 C++ 主流编程互相影响。
- STL。STL是个template 程序库,看名称也知道,但它是非常特殊的一个。它对容器(containers)、迭代器(iterators)、算法(algorithms)以及函数对象(functionobjects)的规约有极佳的紧密配合与协调,然而templates及程序库也可以其他想法建置出来。STL有自己特殊的办事方式,当你伙同STL一起工作,你必须遵守它的规约。
注
记住这四个次语言,当你从某个次语言切换到另一个,导致高效编程守则要求你改变策略时,不要感到惊讶。
例如对内置(也就是C-like)类型而言pass-by-value通常比 pass-by-reference高效,但当你从Cpart ofC++ 移往 Object-Oriented C++,由于用户自定义(user-defined)构造函数和析构函数的存在,pass-by-reference-to-const往往更好。运用Template C++ 时尤其如此,因为彼时你甚至不知道所处理的对象的类型。然而一旦跨入STL你就会了解,迭代器和函数对象都是在C指针之上塑造出来的,所以对STL的选代器和函数对象而言,旧式的Cpass-by-value守则再次适用(参数传递方式的选择细节请见条款20)。
因此我说,C++并不是一个带有一组守则的一体语言;它是从四个次语言组成的联邦政府,每个次语言都有自己的规约。记住这四个次语言你就会发现C++容易了解得多。
提示
C++高效编程守则视状况而变化,取决于你使用C++的哪一部分。
条款02:尽量以 const, enum, inline 替换 #define
注
这个条款或许改为“宁可以编译器替换预处理器”比较好,因为或许 #define
不被视为语言的一部分。那正是它的问题所在。当你做出这样的事情:
#define ASPECT RATIO 1.653
记号名称 ASPECT_RATIO
也许从未被编译器看见;也许在编译器开始处理源码之前它就被预处理器移走了。于是记号名称 ASPECT_RATIO
有可能没进入记号表(symboltable)内。于是当你运用此常量但获得一个编译错误信息时,可能会带来困惑,因为这个错误信息也许会提到1.653而不是 ASPECT_RATIO
。如果 ASPECT_RATIO
被定义在一个非你所写的头文件内,你肯定对1.653 以及它来自何处毫无概念,于是你将因为追踪它而浪费时间。这个问题也可能出现在记号式调试器(symbolic debugger)中,原因相同:你所使用的名称可能并未进入记号表(symboltable)。
提示
对于单纯常量,最好以const
对象或enums
替换#defines
。 对于形似函数的宏(macros),最好改用inline
函数替换#defines
。
条款03:尽可能使用 const
注
令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。举个例子,考虑有理数(rationalnumbers,详见条款24)的operator*
声明式:
Class Rational.{ ...};
const Rational operator*(const Rational& lhs, const Rational& rhs);
许多程序员第一次看到这个声明时不免斜着眼睛说,唔,为什么返回一个const
对象?原因是如果不这样客户就能实现这样的暴行:
Rational a,b,c;
...
(a * b) = c; //在a*b的成果上调用operator=
我不知道为什么会有人想对两个数值的乘积再做一次赋值(assignment),但我知道许多程序员会在无意识中那么做,只因为单纯的打字错误(以及一个可被隐式转换为bool
的类型):
if(a *b= c).. //喔欧,其实是想做一个比较(comparison)动作!
如果a和b都是内置类型,这样的代码直截了当就是不合法。而一个“良好的用户自定义类型”的特征是它们避免无端地与内置类型不兼容(见条款18),因此允许对两值乘积做赋值动作也就没什么意思了。将operator*
的回传值声明为const
可以预防那个“没意思的赋值动作”,这就是该那么做的原因。
更值得了解的是,反向做法——令const版本调用 non-const 版本以避免重复——并不是你该做的事。记住,const成员函数承诺绝不改变其对象的逻辑状态(logical state),non-const成员函数却没有这般承诺。如果在const函数内调用non-const 函数,就是冒了这样的风险:你曾经承诺不改动的那个对象被改动了。这就是为什么“const成员函数调用non-const成员函数”是一种错误行为:因为对象有可能因此被改动。实际上若要令这样的代码通过编译,你必须使用一个const cast将*this
身上的 const性质解放掉,这是乌云罩顶的清晰前兆。反向调用(也就是我们先前使用的那个)才是安全的:non-const成员函数本来就可以对其对象做任何动作,所以在其中调用一个const成员函数并不会带来风险。这就是为什么本例以 static cast作用于*this
的原因:这里并不存在 const相关危险。
提示
将某些东西声明为const可帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
编译器强制实施 bitwise constness,但你编写程序时应该使用“概念上的常量性(conceptual constness)。
当const和 non-const成员函数有着实质等价的实现时,令non-const版本调用 const版本可避免代码重复。
条款04:确定对象被使用前已先被初始化
表面上这似乎是个无法决定的状态,而最佳处理办法就是:永远在使用对象之前先将它初始化。对于无任何成员的内置类型,你必须手工完成此事。至于内置类型以外的任何其他东西,初始化责任落在构造函数(constructors)身上。规则很简单:确保每一个构造函数都将对象的每一个成员初始化。
有些情况下即使面对的成员变量属于内置类型(那么其初始化与赋值的成本相同),也一定得使用初值列。是的,如果成员变量是const或references,它们就一定需要初值,不能被赋值(见条款5)。为避免需要记住成员变量何时必须在成员初值列中初始化,何时不需要,最简单的做法就是:总是使用成员初值列。这样做有时候绝对必要,且又往往比赋值更高效。
提示
- 为内置型对象进行手工初始化,因为C++不保证初始化它们。
- 构造函数最好使用成员初值列(memberinitializationlist),而不要在构造函数本体内使用赋值操作(assignment)。初值列列出的成员变量,其排列次序应该和它们在 class 中的声明次序相同。
- 为免除“跨编译单元之初始化次序”问题,请以localstatic 对象替换 non-localstatic 对象。
2 构造/析构/赋值运算
条款05:了解C++默默编写并调用哪些函数
什么时候 empty class(空类)不再是个empty class 呢?当C++ 处理过它之后是的,如果你自己没声明,编译器就会为它声明(编译器版本的)一个copy构造函数、一个 copy assignment操作符和一个析构函数。此外如果你没有声明任何构造函数,编译器也会为你声明一个 defaut构造函数。所有这些函数都是 public目 inline(见条款 30)。
面对这个难题,C++的响应是拒绝编译那一行赋值动作。如果你打算在一个“内含reference成员”的class内支持赋值操作(assignment),你必须自己定义coPyassignment操作符。面对“内含const成员”(如本例之obiectValue)的 classes,编译器的反应也一样。更改const成员是不合法的,所以编译器不知道如何在它自已生成的赋值函数内面对它们。最后还有一种情况:如果某个basecasses将coPassignment操作符声明为private,编译器将拒绝为其derived classes生成一个 copyassignment 操作符。毕竟编译器为derived classes所生的 copy assignment操作符想象中可以处理 base class 成分(见条款12),但它们当然无法调用 derived class 无权调用的成员函数。编译器两手一摊,无能为力。
提示
编译器可以暗自为 class 创建 default构造函数、copy构造函数、copyassignment 操作符,以及析构函数。
条款 06:若不想使用编译器自动生成的函数,就该明确拒绝
答案的关键是,所有编译器产出的函数都是 public。为阻止这些函数被创建出来你得自行声明它们,但这里并没有什么需求使你必须将它们声明为public。因此你可以将copy构造函数或copyassignment操作符声明为 private。藉由明确声明一个成员函数,你阻止了编译器暗自创建其专属版本;而令这些函数为private,使你得以成功阻止人们调用它。
一般而言这个做法并不绝对安全,因为member 函数和 friend 函数还是可以调用你的 private 函数。除非你够聪明,不去定义它们,那么如果某些人不慎调用任何一个,会获得一个连接错误(linkage error)。“将成员函数声明为private而且故意不实现它们”这一伎俩是如此为大家接受,因而被用在C++ iostream 程序库中阻止copying行为。是的,看看你手上的标准程序库实现码中的ios_base,basic_ios 和sentry。你会发现无论哪一个,其copy构造函数和copy assignment操作符都被声明为 private 而且没有定义。
提示
为驳回编译器自动(暗自)提供的机能,可将相应的成员函数声明为private并且不予实现。使用像 Uncopyable这样的 base class 也是一种做法。
条款 07:为多态基类声明 virtual 析构函数
因此,无端地将所有classes的析构函数声明为virtual,就像从未声明它们为virtual一样,都是错误的。许多人的心得是:只有当 class内含至少一个 virtual 函数,才为它声明 virtual 析构函数。
提示
- polymorphic(带多态性质的)base classes应该声明一个virtual析构函数。如果class 带有任何 virtual函数,它就应该拥有一个 virtual析构函数。
- Classes的设计目的如果不是作为base classes使用,或不是为了具备多态性(polymorphically),就不该声明 virtual 析构函数。
条款 08:别让异常逃离析构函数
因为析构函数吐出异常就是危险,总会带来“过早结束程序”或“发生不明确行为”的风险。本例要说的是,由客户自己调用close并不会对他们带来负担,而是给他们一个处理错误的机会,否则他们没机会响应。如果他们不认为这个机会有用(或许他们坚信不会有错误发生),可以忽略它,倚赖 DBConn 析构函数去调用close。如果真有错误发生-如果 close的确抛出异常--而且 DBConn吞下该异常或结束程序,客户没有立场抱怨,毕竟他们曾有机会第一手处理问题,而他们选择了放弃。
提示
析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。
条款 09:绝不在构造和析构过程中调用vitual函数
提示
在构造和析构期间不要调用 virtual函数,因为这类调用从不下降至 derived class(比起当前执行构造函数和析构函数的那层)。
条款 10:令operator= 返回一个 reference to *this
提示
令赋值(assignment)操作符返回一个reference to *this。
条款 11:在operator= 中处理“自我赋值”
提示
- 确保当对象自我赋值时 operator=有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及 copy-and-swap。
- 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
条款 12:复制对象时勿忘其每一个成分
这时候既有的 copying函数执行的是局部拷贝(partialcopy):它们的确复制了顾客的 name,但没有复制新添加的lastTransaction。大多数编译器对此不出任何怨言——即使在最高警告级别中(见条款53)。这是编译器对“你自己写出copying函数”的复仇行为:既然你拒绝它们为你写出copying函数,如果你的代码不完全,它们也不告诉你。结论很明显:如果你为class添加一个成员变量,你必须同时修改copying函数。(你也需要修改 class 的所有构造函数(见条款4和条款 45)以及任何非标准形式的operator=(条款10有个例子)。如果你忘记,编译器不太可能提醒你。)
提示
- Copying函数应该确保复制“对象内的所有成员变量”及“所有base class 成分”。
- 不要尝试以某个copying函数实现另一个copying函数。应该将共同机能放进第三个函数中,并由两个 coping函数共同调用。