这一章最有价值的条款:14.
14.宁要编译和连接错误,而不要运行时错误
编译和连接错误属于静态检查,静态检查有以下好处:
静态检查独立于数据和控制流:动态检查要足够可靠,需要使用对所有输入都具有代表性的例子进行测试,这对最简单的系统来说都是令人生畏的。
静态表示的模型更可靠:通常,一个程序较少地依赖动态检查,更多地依赖于静态检查,说明了其设计比较好,因为程序建立的模型能使用C++的类型系统适当地表达。
静态检查不会导致运行时开销。
C++最强有力的一个静态检查工具是静态类型检查。静态和动态检查两大阵营一直在争论,静态检查阵营认为静态检查可以消除a large category运行时错误处理,生成的程序更健壮;动态检查阵营认为编译器只能检查出一小部分潜在的错误,既然无论如何都要写单元测试,就无需去进行静态检查,这样还能获得一个相对宽松的编程环境。
可以用静态检查取代动态检查的情况:
例1,编译时布尔条件,如果测试编译时布尔条件,可以用静态断言代替运行时测试。
例2,编译时多态,在定义泛型函数或类型时,考虑用编译时多态(模板)代替运行时多态(虚函数)。
例3,枚举,在表示符号常量或受限整数值时考虑定义枚举
例4,向下转换,如果经常使用dynamic_cast(甚至更糟,static_cast)进行向下强制转换,说明基类的功能太少,考虑重新定义接口,使得程序能够用基类来表示计算。
有些条件不能在编译时检查,需要运行时检查,这些情况应使用断言来检查内部程序错误。
15. 主动(积极)使用const(Use const proactively)
使用mutable变量实现逻辑不变性。当类的一个const成员函数需要合法修改成员变量时,声明该成员变量为mutable。如果所有私有成员都通过使用Pimpl惯用法隐藏起来,就没有必要为缓存的信息或指向它的不变的指针声明mutable了。
例,在函数声明中避免cosnt的传值函数参数,以下两种声明完全等效
void func(int x);
void func(const int x); //这里的const完全多余。
但是在函数定义中,此类const是有意义的,他说明了参数是不可修改的。
16.避免使用宏
Sutter提到过:Macros are obnoxious, smelly, sheet-hogging bedfellows for several reasons, most of wihci are related to the fact that they are a glorified text substitution facility whose effects are applied during preprocessing, before any C++ syntax and semantic rules can even begin to apply. 翻译过来就是由于几方面的原因,宏已经成为讨厌、恶心、杂乱的混合体,其中最主要的原因在于它们被吹捧为一种文本替换设施,其效果在预处理阶段就产生了,而此时C++的语法和语义规则都还没有起作用。
Bjarne也说过:我不喜欢大多数形式的预处理器和宏。C++的一个目的就是要使C的预处理器成为多余的,因为我认为其操作天生就容易出错。 C++中几乎从来就不需要宏。使用const和enum定义变量,使用inline避免函数调用开销,使用模板定义函数和类型族,使用namespace避免名字冲突。宏的第一条规则就是:不要使用它,除非必须用。几乎每个宏都说明程序语言、程序或程序员的一个缺陷。
C++的宏看起来很好,但实际却不是。它会忽略作用域,类型系统,以及所有其他语言特性和规则,而且会劫持为文件剩余部分通过#define定义的符号。宏会根据所处的上下文令人惊奇的展开为各种东西。
例,给宏传一个模板实例,宏只能理解C的()和[],如果传入一个模板实例,宏会认为传入两个参数,
MACRO(Foo<int,double>),宏会认为传入Foo<int和double>。
在少数一些重要的任务当中还是需要宏的,例如include防护,#ifdef和#if defined条件编译以及实现assert。
注:从来没有考虑过这个问题,没想到大牛们对宏这么的排斥。
17.避免使用魔数(magic number)
众所周知,使用符号常量或者枚举而不是直接使用字面量更有利于代码的维护和可读性。
18.尽可能局部地声明变量
这是条款10的一个特例。
变量声明周期过长有以下缺点:
使得程序难于理解和维护
他们的名字会污染上下文
他们不能总是合理地被初始化:在能够合理地初始化一个变量前不要声明它。
C99之前的C版本要求变量只能在一个作用域开始之初声明,这种风格在C++中已经作废。这种限制的一个严重问题就是在作用域开始之处并没有足够的信息对变量进行初始化。有两种选择:或者使用某些默认空值进行初始化,通常这很浪费,而且在变量具有有效状态前使用会导致错误;或者不初始化,这很危险。
因此,尽可能局部定义每个变量,通常就在你有足够的数据进行初始化,而且就在首次使用之前。
由于常量不添加状态,因此本条款不适用于常量。
19.总是初始化变量
未初始化的变量是C和C++程序的常见来源。应该显式初始化变量。
对于未初始化变量的一个误解是:它们会导致程序崩溃,因此通过简单的测试就能发现那些为数不多的分布在各处的未初始化变量。相反,如果内存布局满足程序运行需要,带有未初始化变量的程序可以长年没有问题地运行。之后,一个不同环境的调用,重编译或程序其他部分的修改会导致故障发生。
例1:使用默认初始值或?:,减少数据流和控制流的混合。
// 不可取的:没有初始化变量
int speedupFactor;
if( condition )
speedupFactor = 2;
else
speedupFactor = -1;
// 较好的:初始化了变量
int speedupFactor = -1;
if( condition )
speedupFactor = 2;
// 较好的:初始化了变量
int speedupFactor = condition ? 2 : -1;
例2,用函数取代复杂的计算流
例3,初始化数组。
对于输入缓冲区和volatile数据,他们是由硬件或其他进程直接写入,不需要程序进行初始化。
20.避免函数过长,避免嵌套过深。
过长的函数和嵌套过深使得函数难于理解和维护。
1.尽量紧凑:一个函数只赋予一种职责
2.尽量不要重复:为重复出现的相似的代码片段定义一个命名函数
3.尽量使用&&:在可以使用&&的地方不要使用连续嵌套的if
4.不要过多使用try:尽量通过析构函数进行自动化清除而不是try块
5.尽量使用标准算法
6.不要根据类型标记进行switch:优先考虑多态函数
如果一个函数不能合理地重构为多个独立的子任务,那么它比较长和嵌套较深就是合理的。
21.避免出现跨编译单元的初始化依赖
不同编译单元的命名空间级对象初始化时不要形成相互依赖,因为他们的初始化顺序是未定义的。
在初始化命名空间级对象时,不要假设在其他编译单元中的对象已经初始化了。
在使用构造函数进行构造之前,名字空间级对象已经静态初始化为0了(而自动对象的初始化包含的是垃圾数据)。但是这种零初始化会使得错误难于检查,因为静态的零初始化不会使程序迅速崩溃,而是使得未初始化对象看起来是合法的。
为了避免这个问题,尽可能不要使用名字空间级对象。如果确实需要这么一个依赖于其他变量的变量,考虑使用Singleton设计模式:它可以通过对象的第一次访问时初始化来避免隐式的依赖。Singleton本质上也是全局变量,它也会因为相互依赖或循环依赖而被破坏。
22.最小化定义依赖。避免循环依赖。
如果可以用前向声明实现就不要用#include包含定义。
在两种情况下需要一个类C的完整定义:
1.当需要知道一个C对象的大小
2.当需要命名或调用一个C的成员
最简单的循环依赖是两个相互直接依赖的类:
class Child; // 打破循环依赖
class Parent {// ……
Child* myChild_;
};
class Child {// …… // 可能位于不同的头文件中
Parent* myParent_;
};
Parent和Child相互依赖。这种代码可以编译,但是有个基本问题:两者相互依赖。这未必是坏事,但是应该只在两个类出现在一个模块的时候出现。
为了打破循环,应用依赖倒置原则(Dependency Inversion Principle):高层次模块不要依赖于低层次模块,相反,他们应该各自依赖于抽象。如果能为Parent和Child定义独立的抽象类,就能打破依赖循环,否则,必须保证他们处于同一模块中。
依赖有一种特殊形式,某些设计受其所害:派生类的传递依赖(transitive dependency),基类直接或间接地依赖于他的所有后代。某些Visitor设计模式的实现会导致这种依赖的出现。这种依赖只对非常稳定的层次是可接受的。否则,要修改设计,使用非循环Visitor模式(Acyclic Visitor Pattern)。
过度相互依赖的一个症状,就是局部发生变化时需要进行增量构建,不得不重新编译项目中的很大一部分代码。
类之间的循环不一定是坏事-只要这些类属于同一模块,一起测试和发布。像Command和Visitor设计模式的Naive实现会导致相互依赖的接口。这些相互依赖可以被打破,但是需要明确的设计才行。
23.头文件要自足(Self-sufficient)
确保每个头文件可单独编译,因此需要包含它所依赖的所有头文件。如果一个文件包含一个头文件时,需要包含其他头文件才能工作时,则会给头文件的使用者增加负担。
多年前,有些专家建议头文件不应该包含其他头文件,因为多次打开和解析带防护的头文件会增加开销。幸运的是,这已经过时了:现代的C++编译器自动识别头文件防护符,不会重复打开头文件。有些编译器甚至提供预编译头文件,以确保不会经常解析那些常用而很少修改的头文件。
考虑有助于使头文件自足的技术:独立编译每个头文件,确认没有警告或者错误。
在使用模板时会出现一些微妙的问题。
例1,非独立的名称。模板在定义处编译,除非一些非独立的名称或类型等到模板实例化时编译。这意味着一个emplate<class T> class Widget如果带有std::deque<T>成员,即使没有包含<deque>也不会产生编译时错误,只要没有人实例化Widget。假设Widget是必然需要实例化,则应该包含:#include <deque>
例2,成员函数模板,和模板的成员函数,只在使用时实例化。假设Widget没有任何std::deque<T>类型的成员,但是它的成员函数Transmogrify使用了一个deque。Widget的调用者即使没有包含<deque>,也可以很好地实例化和使用Widget,只要他们没有使用Transmogrify。在一些很罕见的情况下,为了很少使用的模板函数,需要包含一个开销很大的头文件,这时应该考虑将这些函数重构为非成员函数,放在一个单独的头文件中,并在该头文件中包含大开销的头文件。
24.总是编写内部#include防护符,不要写外部#include防护符
在所有头文件使用唯一名称作为包含防护符来防止无意的多次包含。
在头文件被多次包含的情况下,每个头文件应该使用内部#include防护符来避免重定义。
定义包含防护符时应遵循以下规则:
1.使用唯一的防护符名称。至少要确保在你的应用程序中是唯一的。采用流行的命名规范:防护符名称可以包含应用程序名称,有一些工具可以生成包含随机数的名称。
2.不要自作聪明:不要在受保护部分前后放任何代码或注释。虽然如今的预处理器可以识别包含防护符,但是只认识正好位于头文件的开始和结束之处的保护代码。
避免使用一些比较老的书中所提倡的已经过时了的外部包含保护符:
#ifndef FOO_H_INCLUDED_ // 不推荐
#include "foo.h"
#define FOO_H_INCLUDED_
#endif
对于今天的编译器来说,外部包含防护符已经过时了,而且因为耦合很紧密容易出错。
在少数情况下,可能需要多次包含一个头文件。