条款01: 视 C++ 为一个预言联邦
将 C++视为一个由相关语言组成的联邦而非单—语言。
在其某个次语言中,各种守则与通例都倾向简单、直观易懂、并且容易记住。然而当你从一个次语言移往另一个次语言,守则可能改变。
次语言总共有四个:
- C。说到底 C++ 仍是以 C 为基础。区块 (blocks) 、语句 (statements) 、预处理器 (preprocessor) 、内置数据类型 (built-in data types) 、数组 (arrays) 、 指针 (pointers) 等统统来自 C。
- Object-Oriented C++。这部分也就是 C with Classes 所诉求的: classes (包括构 造函数和析构函数),封装 (encapsulation) 、继承 (inheritance) 、多态 (polymorphism) 、 virtual 函数(动态绑定)……等等。这一部分是面向对象设计的古典守则在 C++上的最直接实施。
- Template C++。这是 C++的泛型编程 (generic programming) 部分。 Template 相关考虑与设计已经弥漫整个 C++,良好编程守则中 “惟 template 适用” 的特殊条款并不罕见。templates 带来崭新的编程范型 (programming pradigm) ,也就是所谓的 template metaprogramming (TMP, 模板元编程)。
- STL。 STL 是个 template 程序库。它对容器 (containers) 、迭代器 (iterators) 、算法 (algorithms) 以及函数对象 (function objects) 的规约有极佳的紧密配合与协调。
C++高效编程守则视状况而变化,取决于你使用 C++的哪一部分。
条款 02: 尽量以 const, enum, inline 替换 #define
“尽可能以编译器替换预处理器”
以常量替换 #defines
基本形式
问题: 记号名称有可能没进入记号表 (symbol table) 内。当你运用此常量但获得一个编译错误信息时,可能会带来困惑。
1 |
|
优点:
- 作为一个语言常量,
AspectRatio
肯定会被编译器看到,当然就会进入记号表内。 - 对于浮点常量而言,使用常量可能比使用 #define 得到更小的目标码 (object code) ,因为预处理器盲目地将宏名称替换为 1.653 可能导致目标码出现多份 1.653, 若改用常量则不会出现相同情况。
有两种特殊情况值得讨论。
定义常量指针
由于常量定义式通常被放在头文件内 (以便被不同的源码含入), 因此有必要将指针(而不只是指针所指之物)声明为 const。(const 在*号右侧)(下一条款会详细介绍)
class 专属常量
无法利用 #define 创建一个 class 专属常量,因为 #defines 一旦被定义,它就在其后的编译过程中有效,除非在某处被 #undef。若要创建一个 class 专属常量,需要使用 const。
为了将常量的作用域限制于 class 内,你必须让它成为 class 的一个成员; 而为确保此常数至多只有一份实体,你必须让它成为一个 static 成员:
1 | class GarnePlayer { |
这是 NumTurns 的声明式而非定义式。通常 C++要求你对你所使用的任何东西提供一个定义式,但如果它是个 class 专属常量又是 static 且为整数类型 (例如 ints, chars, bools) ,只要不取它们的地址, 你可以声明并使用它们而无须提供定义式。
但如果需要取某个 class 专属常量的地址 (或编译器(不正确地)坚持要看到一个定义式),就必须在实现文件中提供定义式:
1 | const int GarnePlayer::NumTurns; //NumTurns 的定义式 |
由于 class 常量已在声明时获得初值,因此定义时不可以再设初值。
旧式编译器也许不支持上述语法,即不允许 static 成员在其声明式上获得初值。在声明式上获得初值也只允许对整数常量进行。
如果编译器不支持上述语法,你可以将初值放在定义式 :
1 | class CostEstimate { |
然而,当在 class 编译期间需要一个 class 常量值时这种方法不适用,例如在上述的GarnePlayer::scores
的数组声明式中 (编译器坚持必须在编译期间知道数组的大小)。这时候若编译器不允许 static 整数常量成员在其声明式上获得初值,可使用所谓的 “the enum hack” 补偿做法。其理论基础是: “一个属于枚举类型 (enumerated type) 的数值可当作 int 被使用”,GarnePlayer 可定义如下:
1 | class GamePlayer { |
“The enum hack” 的优点:
enum hack 的行为在一些方面比较像 #define 而不像 const,有时候这正是你想要的。
- 例如取一个 const 的地址是合法的,但取一个 enum 的地址就不合法,而取一个#define的地址通常也不合法。如果你不想让别人获得一个 pointer 或 reference 指向你的某个整数常量, enum 可以帮助你实现这个约束。
- 此外虽然优秀的编译器不会为 “整数型 const对象” 设定另外的存储空间,不够优秀的编译器却可能如此。 Enums 和 #defines 一样绝不会导致非必要的内存分配。
“enum hack” 是 template metaprogramming (模板元编程) 的基础技术。
用 inline 函数替换#defines
问题: 必须为 #defines 实现的宏中的所有实参加上小括号,否则某些人在表达式中调用这个宏时可能会遭遇麻烦。但纵使你为所有实参加上小括号,仍然会有一些问题。
1 |
|
优点:
- 这里不需要在函数本体中为参数加上括号,也不需要操心参数被求值多次。
- 由于
callWithMax
是个真正的函数,它遵守作用域 (scope) 和访问规则。例如可以写出一个 “class 内的 private inline 函数”。一般而言宏无法完成此事。
- 对于单纯常量,最好以 const对象或 enums 替换 #defines。
- 对于形似函数的宏 (macros) ,最好改用 inline 函数替换#defines。
条款03: 尽可能使用 const
只要某值保持不变是事实,就应该说出来。因为说出来可以获得编译器的襄助,确保这条约束不被违反。
const 允许指定一个语义约束(也就是指定一个“不该被改动”的对象),而编译器会强制实施这项约束。
面对指针
面对指针,可以指出指针自身、指针所指物,或两者都 (或都不) 是 const:
1 | char greeting[] = "Hello"; |
如果关键字 const 出现在星号左边,表示被指物是常量; 如果出现在星号右边,表示指针自身是常量; 如果出现在星号两边,表示被指物和指针两者都是常量。
如果被指物是常量,有些程序员会将关键字 const 写在类型之前,有些人会把它写在类型之后、星号之前。两种写法的意义相同:
1 | void fl (const Widget* pw); //fl 获得一个指针,指向一个常量的 Widget 对象 |
STL 迭代器
STL 迭代器系以指针为根据塑模出来,所以迭代器的作用就像个 T*
指针。声明迭代器为 const 就像声明指针为 const 一样(即声明一个 T* const
指针),表示这个迭代器不得指向不同的东西,但它所指的东西的值是可以改动的。如果希望迭代器所指的东西不可被改动 (即希望 STL 模拟一个 const T*
指针),你需要的是 const_iterator
:
1 | std::vector<int> vec; |
函数声明
在一个函数声明式内, const 可以和函数返回值、各参数、函数自身(如果是成员函数)产生关联。
返回值
令函数返回一个常橇值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。
举个例子,考虑有理数的 operator* 声明式:
1 | class Rational {... }; |
如果 a 和 b 都是内置类型,这样的代码直截了当就是不合法。一个“良好的用户自定义类型”的特征是它们避免无端地与内置类型不兼容 ,因此允许对两值乘积做赋值动作也就没什么意思了。
至于 const 参数,应该在必要使用它们的时候使用它们。除非你有需要改动参数或 local 对 象,否则请将它们声明为 const。
const 成员函数
将 const 实施于成员函数的目的,是为了确认该成员函数可作用于 const 对象身上。const 成员函数之所以重要,基于两个理由:
- 它们使 class 接口比较容易被理解:得知哪个函数可以改动对象内容而哪个函数不行是很重要的。
- 它们使 “操作 const对象” 成为可能:改善 C++程序效率的一个根本办法是以 pass by reference-to-const 方式传递对象,此技术可行的前提是,const成员函数可用来处理取得的 const 对象。
两个成员函数即使只是常量性 (constness) 不同,也可以被重载。
1 | class TextBlock { |
也请注意,non-const operator[]
的返回类型是个 reference to char, 不是 char。如果 operator[] 只是返回一个 char, tb[O] ='x';
就无法通过编译。 那是因为,如果函数的返回类型是个内置类型,那么改动函数返回值从来就不合法。纵使合法, 由于 C++ 是 by value返回对象,意味被改动的其实是 tb.text[0] 的一个副本,不是 tb.text[0] 自身。
成员函数如果是 const 意味什么? 有两个流行概念: bitwise constness 和 logical constness。
bitwise const: 成员函数只有在不更改对象的任何成员变量 (static 除外) 时才可以说是 const。也就是说它不更改对象内的任何一个 bit。
不幸的是许多成员函数不十足具备 const 性质。举个例子,一个更改了”指针所指物”的成员函数虽然不能算是 bitwise const, 但如果只有指针(而非其所指物)隶属于对象,那么称此函数为 const 不会引发编译器异议。
logical constness: 一个 const 成员函数可以修改它所处理的对象内的某些 bits, 但只有在客户端侦测不出的情况下才得如此。可以利用C++的一个与const相关的摆动场: mutable
(可变的)。 mutable 释放掉 non-static 成员变量的 bitwise constness 约束:
1 | class CTextBlock { |
在 const 和 non-const 成员函数中避免重复
令 non-const operator[ ] 调用其 const 兄弟是一个避免代码重复的安全做法——即使过程中需要一个转型动作。
1 | class TextBlock { |
值得了解的是,反向做法——令 const 版本调用 non-con玩版本以避免重复——是错误的。记住, const 成员函数承诺绝不改变其对象的逻辑状态 (logical state), non-const 成员函数却没有这般承诺。如果在 const 函数内调用 non-const 函数,就是冒了这样的风险: 你曾经承诺不改动的那个对象被改动了。
- 将某些东西声明为 const 可帮助编译器侦测出错误用法。 const 可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
- 编译器强制实施 bitwise constness,但你编写程序时应该使用”概念上的常量性” (conceptual constness) 。
- 当 const 和 non-const 成员函数有着实质等价的实现时,令 non-const 版本调用 const 版本可避免代码重复。
条款04: 确定对象被使用前已先被初始化
通常如果你使用 C part of C++ 而且初始化可能招致运行期成本, 那么就不保证发生初始化。一旦进入 non-C parts of C++,规则有些变化。这就很好地解释了为什么 array (来自 C part of C++) 不保证其内容被初始化,而 vector (来自 STL part of C++) 却有此保证。
确保每一个构造函数都将对象的每一个成员初始化。
别混淆了赋值 (assignment) 和初始化 (initialization) 。
1 | class PhoneNurnber { ... }; |
C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。在 ABEntry构造函数内, 成员变量都不是被初始化,而是被赋值。初始化的发生时间更早,发生于这些成员的 default 构造函数被自动调用之时 (比进入 ABEntry构造函数本体的时间更早)。但对于numTimesConsulted,因为它属于内置类型,不保证一定在赋值动作的时间点之前获得初值。
ABEntry构造函数的一个较佳写法是,使用所谓的member initialization list (成员初值列)替换赋值动作:
1 | ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNurnber>& phones) |
基千赋值的那个版本首先调用 default 构造函数为 theName,theAddress和 thePhones 设初值,然后立刻再对它们赋予新值。 default构造函数的一切作为因此浪费了。成员初值列的做法避免了这一问题,因为初值列中针对各个成员变量而设的实参,被拿去作为各成员变量的copy构造函数的实参。
甚至当你想要 default构造一个成员变量, 你都可以使用成员初值列,只要指定无物 ()
作为初始化实参即可。
总是在初值列中列出所有成员变量,以免还得记住哪些成员变量可以无需初值。
此外,如果成员变量是 const 或 references, 它们就一定需要初值,不能被赋值(见条款 5)。
C++有着十分固定的“成员初始化次序”: base classes 更早于其 derived classes 被初始化 ,而 class 的成员变量总是以其声明次序被初始化,即使它们在成员初值列中以不同的次序出现。当你在成员初值列中条列各个成员时,最好总是以其声明次序为次序。
“不同编译单元内定义的 non-local static 对象”的初始化次序
- 函数内的 static 对象称为 local static 对象,其他 static 对象称为 non-local static 对象。
- 编译单元 (translation unit) 是指产出单一目标文件 (single object file) 的那些源码。基本上它是单一源码文件加上其所含入的头文件。
- C++对定义于不同编译单元内的 non-local static 对象的初始化次序并无明确定义。这是有原因的: 决定它们的初始化次序相当困难,根本无解。
我们关心的问题涉及至少两个源码文件,每一个内含至少一个 non-local static 对象,如果某编译单元内的某个 non-local static 对象的初始化动作使用了另一编译单元内的某个 non-local static 对象,它所用到的这个对象可能尚未被初始化。
幸运的是一个小小的设计便可完全消除这个问题:将每个 non-local static对象搬到自己的专属函数内 (该对象在此函数内被声明为 static) 。 这些函数返回一个 reference 指向它所含的对象。用户调用这些函数,而不直接指涉这些对象。换句话说, non-local static 对象被 local static 对象替换了。
这个手法的基础在于: C++ 保证函数内的 local static 对象会在该函数被调用期间首次遇上该对象的定义式时被初始化。
1 | class FileSystem { . . . }; |
这些内含 static 对象的函数在多线程系统中带有不确定性。
- 为内置型对象进行手工初始化,因为 C++ 不保证初始化它们。
- 构造函数最好使用成员初值列,而不要在构造函数本体内使用赋值操作 (assignment) 。初值列列出的成员变量,其排列次序应该和它们在 class 中的声明次序相同。
- 为免除 “跨编译单元之初始化次序” 问题,请以 local static 对象替换 non-local static 对象。
读书笔记,来自 《Effective C++》第 3 版