条款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
2
3
#define ASPECT_RATIO 1.653
=>
const double AspectRatio = 1.653;

优点:

  • 作为一个语言常量, AspectRatio 肯定会被编译器看到,当然就会进入记号表内。
  • 对于浮点常量而言,使用常量可能比使用 #define 得到更小的目标码 (object code) ,因为预处理器盲目地将宏名称替换为 1.653 可能导致目标码出现多份 1.653, 若改用常量则不会出现相同情况。

有两种特殊情况值得讨论。

定义常量指针

由于常量定义式通常被放在头文件内 (以便被不同的源码含入), 因此有必要将指针(而不只是指针所指之物)声明为 const。(const 在*号右侧)(下一条款会详细介绍)

class 专属常量

无法利用 #define 创建一个 class 专属常量,因为 #defines 一旦被定义,它就在其后的编译过程中有效,除非在某处被 #undef。若要创建一个 class 专属常量,需要使用 const。

为了将常量的作用域限制于 class 内,你必须让它成为 class 的一个成员; 而为确保此常数至多只有一份实体,你必须让它成为一个 static 成员:

1
2
3
4
5
6
class GarnePlayer { 
private:
static const int NumTurns = 5;
int scores[NumTurns];
...
};

这是 NumTurns 的声明式而非定义式。通常 C++要求你对你所使用的任何东西提供一个定义式,但如果它是个 class 专属常量又是 static 且为整数类型 (例如 ints, chars, bools) ,只要不取它们的地址, 你可以声明并使用它们而无须提供定义式。

但如果需要取某个 class 专属常量的地址 (或编译器(不正确地)坚持要看到一个定义式),就必须在实现文件中提供定义式:

1
const int GarnePlayer::NumTurns; //NumTurns 的定义式

由于 class 常量已在声明时获得初值,因此定义时不可以再设初值。

旧式编译器也许不支持上述语法,即不允许 static 成员在其声明式上获得初值。在声明式上获得初值也只允许对整数常量进行。

如果编译器不支持上述语法,你可以将初值放在定义式 :

1
2
3
4
5
6
class CostEstimate { 
private:
static const double FudgeFactor;
...
};
const double CostEstimate::FudgeFactor = 1.35;

然而,当在 class 编译期间需要一个 class 常量值时这种方法不适用,例如在上述的GarnePlayer::scores 的数组声明式中 (编译器坚持必须在编译期间知道数组的大小)。这时候若编译器不允许 static 整数常量成员在其声明式上获得初值,可使用所谓的 “the enum hack” 补偿做法。其理论基础是: “一个属于枚举类型 (enumerated type) 的数值可当作 int 被使用”,GarnePlayer 可定义如下:

1
2
3
4
5
6
class GamePlayer { 
private:
enum { NumTurns=5 }; //"the enum hack” — 令NumTurns 成为 5 的一个记号名称
int scores[NumTurns]; //这就没问题了.
...
};

“The enum hack” 的优点:

enum hack 的行为在一些方面比较像 #define 而不像 const,有时候这正是你想要的。

  • 例如取一个 const 的地址是合法的,但取一个 enum 的地址就不合法,而取一个#define的地址通常也不合法。如果你不想让别人获得一个 pointer 或 reference 指向你的某个整数常量, enum 可以帮助你实现这个约束。
  • 此外虽然优秀的编译器不会为 “整数型 const对象” 设定另外的存储空间,不够优秀的编译器却可能如此。 Enums 和 #defines 一样绝不会导致非必要的内存分配。

“enum hack” 是 template metaprogramming (模板元编程) 的基础技术。

用 inline 函数替换#defines

问题: 必须为 #defines 实现的宏中的所有实参加上小括号,否则某些人在表达式中调用这个宏时可能会遭遇麻烦。但纵使你为所有实参加上小括号,仍然会有一些问题。

1
2
3
4
5
6
7
8
9
10
11
12
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

%int a = 5,b = 0;
%CALL_WITH_MAX(++a, b); //a被累加二次
%CALL_WITH_MAX(++a, b+lO); //a被累加一次

=>
template<typename T> inline void callWithMax(const T& a, const T& b)
{
f(a > b ? a : b);
//由于我们不知道 T 是什么,所以采用 pass by reference-to-const.
}

优点:

  • 这里不需要在函数本体中为参数加上括号,也不需要操心参数被求值多次。
  • 由于 callWithMax 是个真正的函数,它遵守作用域 (scope) 和访问规则。例如可以写出一个 “class 内的 private inline 函数”。一般而言宏无法完成此事。
  • 对于单纯常量,最好以 const对象或 enums 替换 #defines。
  • 对于形似函数的宏 (macros) ,最好改用 inline 函数替换#defines。

条款03: 尽可能使用 const

只要某值保持不变是事实,就应该说出来。因为说出来可以获得编译器的襄助,确保这条约束不被违反。

const 允许指定一个语义约束(也就是指定一个“不该被改动”的对象),而编译器会强制实施这项约束。

面对指针

面对指针,可以指出指针自身、指针所指物,或两者都 (或都不) 是 const:

1
2
3
4
5
char greeting[] = "Hello"; 
char* p = greeting; //non-const pointer, non-const data
const char* p = greeting; //non-const pointer, const data
char* const p = greeting; //const pointer,non-const data
const char* const p = greeting; //const pointer,const data

如果关键字 const 出现在星号左边,表示被指物是常量; 如果出现在星号右边,表示指针自身是常量; 如果出现在星号两边,表示被指物和指针两者都是常量。

如果被指物是常量,有些程序员会将关键字 const 写在类型之前,有些人会把它写在类型之后、星号之前。两种写法的意义相同:

1
2
void fl (const Widget* pw);  //fl 获得一个指针,指向一个常量的 Widget 对象
void f2 (Widget const * pw); //f2 也是

STL 迭代器

STL 迭代器系以指针为根据塑模出来,所以迭代器的作用就像个 T* 指针。声明迭代器为 const 就像声明指针为 const 一样(即声明一个 T* const 指针),表示这个迭代器不得指向不同的东西,但它所指的东西的值是可以改动的。如果希望迭代器所指的东西不可被改动 (即希望 STL 模拟一个 const T* 指针),你需要的是 const_iterator:

1
2
3
4
5
6
7
8
std::vector<int> vec;
...
const std::vector<int>::iterator iter = vec.begin();//iter 的作用像个 T*const
*iter = 10; //没问题,改变 iter 所指物
++iter; //错误! iter 是 const
std::vector<int>::const_iterator citer = vec.begin();// clter 的作用像个const T*
*citer = 10; //错误! *clter 是 const
++citer; //没问题,改变 clter。

函数声明

在一个函数声明式内, const 可以和函数返回值、各参数、函数自身(如果是成员函数)产生关联。

返回值

令函数返回一个常橇值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。

举个例子,考虑有理数的 operator* 声明式:

1
2
3
4
class Rational {... };
const Rational operator* (const Rational& lhs, const Rational& rhs);

if (a*b = c) //报错

如果 a 和 b 都是内置类型,这样的代码直截了当就是不合法。一个“良好的用户自定义类型”的特征是它们避免无端地与内置类型不兼容 ,因此允许对两值乘积做赋值动作也就没什么意思了。

至于 const 参数,应该在必要使用它们的时候使用它们。除非你有需要改动参数或 local 对 象,否则请将它们声明为 const。

const 成员函数

将 const 实施于成员函数的目的,是为了确认该成员函数可作用于 const 对象身上。const 成员函数之所以重要,基于两个理由:

  • 它们使 class 接口比较容易被理解:得知哪个函数可以改动对象内容而哪个函数不行是很重要的。
  • 它们使 “操作 const对象” 成为可能:改善 C++程序效率的一个根本办法是以 pass by reference-to-const 方式传递对象,此技术可行的前提是,const成员函数可用来处理取得的 const 对象。

两个成员函数即使只是常量性 (constness) 不同,也可以被重载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TextBlock { 
public:
...
const char& operator [] (std:: size t position) const //operator[] for const对象
{ return text[position]; }
char& operator[] (std: :size_t position) //operator[] for non-const对象
{ return text[position]; }
private:
std::string text;
};
------------------------------------------------------
TextBlock tb("Hello");
const TextBlock ctb("World");
------------------------------------------------------
std::cout << tb[O]; //没问题 - 读一个 non-const TextBlock
tb[O] = 'x'; //没问题 - 写一个 non-const TextBlock
std::cout << ctb[O]; //没问题 - 读一个 const TextBlock
ctb[O] = 'x'; //错误! - 写一个 constTextBlock

也请注意,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CTextBlock { 
public:
...
std::size_t length() const;
private:
char* pText;
mutable std::size_t textLength; //这些成员变量可以在const成员函数内被更改
mutable bool lengthisValid;
);
std::size_t CTextBlock::length() const
{
if (!lengthisValid) {
textLength = std::strlen(pText);
lengthisValid = true;
}
return textLength;
}

在 const 和 non-const 成员函数中避免重复

令 non-const operator[ ] 调用其 const 兄弟是一个避免代码重复的安全做法——即使过程中需要一个转型动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TextBlock { 
public:
...
const char& operator[] (std::size_t position) const
{
...
...
return text[position];
}
char& operator[] (std::size_t position) //现在只调用 const op[]
{
return const_cast<char&> //将 op[]返回值的 const 除
(static_cast<const TextBlock&>(*this)//为*this 加上 const
[position] //调用 const op[]
);
}
...
};

值得了解的是,反向做法——令 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class PhoneNurnber { ... }; 
class ABEntry { //ABEntry = "Address Book Entry"
public:
ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNurnber>& phones);
private:
std::string theNarne;
std::string theAddress;
std::list<PhoneNurnber> thePhones;
int numTimesConsulted;
);
ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNurnber>& phones)
{
theName = name; //这些都是赋值 (assignments)
theAddress = address; //而非初始化 (initializations)
thePhones = phones;
numTimesConsulted = 0;
}

C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。在 ABEntry构造函数内, 成员变量都不是被初始化,而是被赋值。初始化的发生时间更早,发生于这些成员的 default 构造函数被自动调用之时 (比进入 ABEntry构造函数本体的时间更早)。但对于numTimesConsulted,因为它属于内置类型,不保证一定在赋值动作的时间点之前获得初值。

ABEntry构造函数的一个较佳写法是,使用所谓的member initialization list (成员初值列)替换赋值动作:

1
2
3
4
ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNurnber>& phones)
:theName(name), theAddress(address), thePhones(phones), numTimesConsulted(O)
//这些是初始化 (initializations)
{} //现在,构造函数本体不必有任何动作

基千赋值的那个版本首先调用 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
2
3
4
5
6
class FileSystem { . . . }; 
FileSystem& tfs ()
{
static FileSystem fs;
return fs;
}

这些内含 static 对象的函数在多线程系统中带有不确定性。

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

读书笔记,来自 《Effective C++》第 3 版