《C++编程规范》三、编程风格
第14条 宁要编译时和连接时错误,也不要运行时错误
能够在编译时做的事情,就不要推迟到运行时:编写代码时,应该在编译期间使用编译器检查不变式(invariant),而不应该在运行时再进行检查。运行时检查取决于控制流和数据的具体情况,这意味着很难知道检查是否彻底。相比而言,编译时检查与控制流和数据无关,一般情况下能够获得更高的可信度。
C++最强大的静态检查工具之一,就是其自身的静态类型检查。在类型应该如何检查这一问题上,各种语言分成了静态(C++,Java,ML,Haskell)和动态(Smalltalk,Ruby,Python,Lisp)两大阵营
有些情况下,可以用编译时检查代替运行时检查。
- 例1 编译时布尔条件。如果测试的是编译时布尔条件,比如sizeof(int) >= 8,那么可以使用静态断言取代运行时测试(但另见第91条)。
- 例2 编译时多态。定义泛型函数或者类型时,考虑用编译时多态(模板)代替运行时多态(虚拟函数)。前者产生的代码能够更好地进行静态检查(另见第64条)。
- 例3 枚举。在需要表示符号常量或受限整数值时考虑定义enum(或者定义完整的类型,这样更好)。
- 例4 向下强制(downcast)。如果经常使用 dynamic_cast(或者更糟糕地,使用无检查的static_cast)执行向下强制,则可能说明基类提供的功能太少了。此时可以考虑重新设计接口,使程序能够用基类表示计算。
第15条 积极使用const
const 是我们的朋友:不变的值更易于理解、跟踪和分析,所以应该尽可能地使用常量代替变量,定义值的时候,应该把const作为默认的选项:常量很安全,在编译时会对其进行检查(见第14条),而且它与C++的类型系统已浑然一体。不要强制转换const的类型,除非要调用常量不正确的函数(见第94条)。
用mutable
成员实现逻辑上的不变。当类的const成员函数需要合法地修改成员变量时(即变量不影响对象的可观察状态时,比如缓存数据),声明该成员变量为mutable的。请注意,如果用Pimpl惯用法(见第43条)隐藏了所有私有成员,就无需对缓存信息或指向它的未改变的指针声明mutable了。
第16条 避免使用宏
宏是C和C++语言的抽象设施中最生硬的工具,它是披着函数外衣的饥饿的狼,很难驯服,它会我行我素地游走于各处。要避免使用宏。
我讨厌大多数形式的预处理器和宏。C++的目标之一就是使C的预处理器成为多余的(§4.4,§18),因为我认为其操作天生就容易出错。——[Stroustrup94]§3.3.1
在C++中几乎从不需要用宏。可以用const(§5.4)或者enum(§4.8)定义易于理解的常量(见第15条),用inline(§7.1.1)避免函数调用的开销(但是要见第8条),用template(第13章)指定函数系列和类型系列(见第64条至第67条),用namespace (§8.2)避免名字冲突(见第57条至第59条)。——[Stroustrup00]§1.6.1
关于宏的第一规则就是:不要使用它,除非不得不用。几乎每个宏都说明程序设计语言、程序或者程序员存在缺陷。——[Stroustrup00]§7.8
C++的宏的主要问题在于,它们表面上看起来很好,而实际上做的却是另一回事。宏会忽略作用域,忽略类型系统,忽略所有其他的语言特性和规则,而且会劫持它为文件其余部分所定义(#define)的符号。宏调用看上去很像符号或者函数调用,但实际上并非如此。宏不太“卫生”,也就是说,它会根据自己被使用时所处的环境引人注目而且令人惊奇地展开为各种东西。宏需要进行文本替换,因此编写远距离也正确的宏接近于一种魔法,而精通这种魔法既无意义又无趣味。
即使在极少的情况下,有正当理由编写宏(见例外情况),也决不要考虑编写一个以常见词或者缩略语为名字的宏。尽可能快地取消宏的定义(#undef),总是给它们取形如 SCREAMING_UPPERCASE_AND_UGLY这样明显的、大写的、而且难看的名字,并且不要将它们放在头文件中。
例外情况
- 宏仍然是几个重要任务的惟一解决方案,比如#include保护符(guard)(见第 24条),条件编译中的#ifdef和#if defined,以及assert的实现(见第68条)。
- 在条件编译(如与系统有关的部分)中,要避免在代码中到处杂乱地插入#ifdef。相反,应该对代码进行组织,利用宏来驱动一个公共接口的多个实现,然后始终使用该接口。
- 如果不想到处复制和粘贴代码段,那么可以使用宏(但要非常小心)。
第17条 避免使用“魔数”
程序设计并非魔术,所以不要故弄玄虚:要避免在代码中使用诸如42和3.14159这样的文字常量。它们本身没有提供任何说明,并且因为增加了难于检测的重复而使维护更加复杂。可以用符号名称和表达式替换它们,比如width * aspectRatio。
名称能够增加信息,并提供单一的维护点,而程序中到处重复的原始数据是无名的,维护起来很麻烦。常量应该是枚举符或者const值,有合适的作用域和名称。
经常会有此42可能非彼42的情形。更糟的是,如果程序员进行了一些心算(比如,“这个84是由5行代码前所用的42乘以2得到的”),那么以后需要用其他常量替换42的工作会变得既枯燥又易错。
应该用符号常量替换直接写死的字符串。将字符串与代码分开(比如将字符串放入一个专门的.cpp 文件或资源文件中),这样非程序员也能对其进行审查和更新,而且能够减少重复,还有助于国际化。
示例
例1 重要的特定于领域的常量应该放在名字空间一级。
const size_t PAGE_SIZE = 8192,
WORDS_PER_PAGE = PAGE_SIZE / sizeof(int),
INFO_BITS_PER_PAGE = 32 * CHAR_BIT;
例2 特定于类的常量。可以在类定义中定义静态整数常量,其他类型的常量需要单独的定义或者一个短小的函数。
// 文件widget.h
class Widget {
static const int defaultWidth=400; // 声明中提供的值
static const double defaultPercent; // 定义中提供的值
static const char* Name() {return "Widget"; }
};
// 文件widget.cpp
const double Widget::defaultPercent = 66.67; // 定义中提供的值
const int Widget::defaultWidth; // 所需的定义
第18条 尽可能局部地声明变量
避免作用域膨胀,对于需求如此,对于变量也是如此。变量将引入状态,而我们应该尽可能少地处理状态,变量的生存期也是越短越好。这是第10条的一个特例,但值得单独阐述。
要特别说明的是,C99之前的C语言版本曾要求只能在作用域开始处定义变量;这种方式在C++中已经过时了。这一限制的严重问题在于,在作用域的开始,经常还没有足够的相关信息进行变量初始化。因此我们只有两种选择:要么用某个默认的空值(比如零)来初始化,这通常都是一种浪费,而且如果变量在拥有有效状态之前被使用,还会导致错误;要么让变量保持未初始化,而这是很危险的。用户定义类型的未初始化变量将会自行初始化为某个空值。
解决方案很简单:尽可能局部地定义每个变量,通常就是在你有了足够的数据进行初始化的时候,而且恰恰就在首次使用变量之前。
例外情况
- 有时候将变量提出循环是有好处的(见第9条)。
- 因为常量并不添加状态,所以本条对常量不适用(见第17条)。
第19条 总是初始化变量
一切从白纸开始:未初始化的变量是 C 和 C++程序中错误的常见来源。养成在使用内存之前先清除的习惯,可以避免这种错误,在定义变量的时候就将其初始化。
关于未初始化变量,有一个常见的误解:它们会使程序崩溃,因此通过简单的测试就能很快发现分布在各处的那些为数不多的未初始化变量。但事实恰恰相反,如果内存布局碰巧满足了程序需求,带有未初始化变量的程序能够毫无问题地运行上数年。在此之后,如果从不同环境中调用,或者重新编译,或者程序的另一个部分进行了修改,都可能导致各种故障发生,轻则出现难以琢磨的行为,重则发生间歇性的崩溃。
第20条 避免函数过长,避免嵌套过深
短胜于长,平优于深:过长的函数和嵌套过深的代码块的出现,经常是因为没能赋予一个函数以一个紧凑的职责所致(见第5条),这两种情况通常都能够通过更好的重构予以解决。
每个函数都应该是顾其名而能思其义,易于理解的工作单元(见第5条和第70条中的讨论)。如果与此相反,函数试图将多个这样的小概念单元合并到一个长的函数体中,那么它最终将不堪重负。
过长的函数和嵌套过深的代码块(比如if、for、while和try代码块)是使函数更难于理解和维护的密不可分的两大元凶(而且经常毫无必要)。
请遵循这样的常识和常理:限制函数的长度和嵌套深度。以下所有的合理建议对这一点都有所裨益。
- 尽量紧凑:对一个函数只赋予一种职责(见第5条)。
- 不要自我重复:优先使用命名函数,而不要让相似的代码片断反复出现。
- 优先使用&&:在可以使用&&条件判断的地方要避免使用连续嵌套的if。
- 不要过分使用try:优先使用析构函数进行自动清除而避免使用try代码块(见第13条)。
- 优先使用标准算法:算法比循环嵌套要少,通常也更好(见第84条)。
- 不要根据类型标签(type tag)进行分支(switch)。优先使用多态函数(见第90条)。
第21条 避免跨编译单元的初始化依赖
保持(初始化)顺序:不同编译单元中的名字空间级对象决不应该在初始化上互相依赖,因为其初始化顺序是未定义的。这样做会惹出很多麻烦,轻则在项目中稍做修改就会引发奇怪的崩溃,重则出现严重的不可移植问题——即使是同一编译器的新版本也不行。
为了避免这一问题,应该尽可能地避免使用名字空间级的变量,它们很危险(见第10条)。如果确实需要可能依赖于另一个变量的此种变量,可以考虑使用Singleton(单体)设计模式。使用时要小心一些,可以通过确保对象在第一次访问时被初始化,来避免隐含的依赖性。Singleton本质上也是全局变量——披着羊皮的“狼”(另见第10条),它会因为相互依赖或者循环依赖而被破坏(同样,零初始化只会使情况更复杂)。
第22条 尽量减少定义性依赖。避免循环依赖
不要过分依赖:如果用前向声明(forward declaration)能够实现,那么就不要包含(#include)定义。
不要互相依赖:循环依赖是指两个模块直接或者间接地互相依赖。所谓模块就是一个紧凑的发布单元(见“名字空间与模块”部分的引言部分)。互相依赖的多个模块并不是真正的独立模块,而是紧紧胶着在一起的一个更大的模块,一个更大的发布单元。因此,循环依赖有碍于模块性,是大型项目的祸根。请避免循环依赖。
一般而言,应该在模块层次上考虑依赖性及其循环。模块是一同发布的类和函数的紧凑集合(见第5条和“名字空间与模块”部分的引言部分)。最简单形式的循环依赖是两个直接互相依赖的类:
class Child; // 打破循环依赖
class Parent {// ……
Child* myChild_;
};
class Child{// …… // 可能位于不同的头文件中
Parent* myParent_;
};
这里Parent和Child存在互相依赖。代码能够编译,但是有一个根本性的问题:两个类不再是独立的,而是互相依赖的了。这种情况未必很糟,但是应该只出现在两个类同属一个模块(由同一个人或者小组开发,作为一个整体进行测试和发布)的时候。
为了对比,我们考虑如下情况:如果 Child不需要保存回指向其 Parent对象的链接又会怎么样呢?那么 Child 就可以独自作为一个更小的模块(可能名字不同)发布,完全独立于Parent——这种设计显然更加灵活。
如果依赖循环跨越多个模块(这些模块将因为依赖关系而联合起来形成一个大的发布单元),情况只会变得更糟。这正是为什么称循环是模块性最凶恶的敌人的原因。
为了打破循环,可以应用[Martin96a]和[Martin00](另见第 36条)中记载的依赖倒置原理(Dependency Inversion Principle):不要让高层模块依赖于低层模块;相反,应该让两者都依赖于抽象。如果能够为Parent或Child定义独立的抽象类,那么就能够打破循环了。否则,就必须保证它们属于同一模块。
依赖有一种特殊形式,一些设计颇受其害:派生类的传递依赖(transitive dependency),即基类依赖于所有的派生类,包括直接的和间接的。Visitor(访问器)设计模式的一些实现就会导致这种依赖。它只对极为稳定的类层次而言是可以接受的。否则可能需要修改设计,例如使用Acyclic Visitor(非循环访问器)模式[Martin98]。
过度相互依赖的一个症状,就是局部发生变化时需要进行增量构建,不得不重新编译项目中的很大一部分代码(见第2条)。
例外情况
类之间的依赖循环并不一定都是坏事——只要类被认为属于同一模块,一起测试,一起发布。诸如Command和Visitor等设计模式的原始实现就会产生天生相互依赖的接口。这种相互依赖可以被打破,但是需要进行明确的设计才行。
第23条 头文件应该自给自足
各司其责:应该确保所编写的每个头文件都能够独自进行编译,为此需要包含其内容所依赖的所有头文件。
如果一个文件包含某个头文件时,还要包含另一个头文件才能工作,就会增加交流障碍,给头文件的用户增添不必要的负担。
第24条 总是编写内部#include保护符,决不要编写外部#include保护符
为头(文件)添加保护:在所有头文件中使用带有惟一名称的包含保护符(#include guard),防止无意的多次包含。
应该用内部包含保护符保护每个头文件,以避免在多次包含时重新定义。例如,头文件foo.h应该采用下面的一般形式:
#ifndef FOO_H_INCLUDED_
#define FOO_H_INCLUDED_
// ……文件内容……
#endif
定义包含保护符时,应该遵守如下规则。
- 保护符使用惟一名称。确保名称至少在你的应用程序中是惟一的。上面的代码中采用了流行的命名规范,保护符名称可以包含应用程序名称,还有一些工具,能够生成包含随机数的保护符名称。
- 不要自作聪明。不要在受保护部分的前后放置代码或者注释,要谨遵上面的标准形式。
- 虽然如今的预处理器能够检测出包含保护符,但是它们的智商有限,只认识正好位于头文件开始和结束处的保护代码。
- 避免使用一些比较老的书中所提倡的已经过时了的外部包含保护符:
外部包含保护符非常令人厌烦,对于当今的编译器来说已经过时了,而且因为耦合太紧密(调用者和头文件必须就保护符名称达成一致),很容易出错。#ifndef FOO_H_INCLUDED_ // 不推荐 #include "foo.h" #define FOO_H_INCLUDED_ #endif