版权信息

书名：码农·经典 C++（第26期）

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

图灵社区会员 Dastan（dastan@139.com） 专享 尊重版权

编者的话

专题：经典C++

深入理解指针

动态内存分配

动态内存分配函数

用free函数释放内存

迷途指针

动态内存分配技术

小结

测试驱动开发

Soundex类

开始吧

去掉不干净的代码

增量性

fixture与设置

思索与测试驱动开发

测试驱动与测试

如果出现别的情况呢

一次只做一件事

限制长度

丢掉元音

让测试自我澄清

跳出条条框框来测试

言归正传

重构至单一责任的函数

收尾工作

漏了什么测试吗

解决方案

Soundex类

小结

那些优质API的特征

问题域建模

隐藏实现细节

最小完备性

易用性

松耦合

稳定的、文档详细且经过测试的API

吐吐槽还能赚银子

人物

古生物学家邢立达：追求生命的本质

俏皮“伊娃”

全长18.5厘米

来自缅甸北部

推荐阅读

《我心爱的雷龙》译者邢立达：追求生命的本质

践行

特征选择方法简介

特征选择的重要性

过滤法

包装法

嵌入法

案例

结语

鲜阅

每天叫醒我的不是闹钟，而是梦想

你可能不知道的16针打印机

书单

偷偷看下你的书单

电子书榜

妙评

注意力的秘密

编者的话

编者 / 刘敏

时至今日，C++仍然是计算机编程领域的经典语言之一。它不再是当初的样子，稚嫩偶显笨拙，在C++标准委员会的雕琢下，日臻完善。希望当初熬夜学习C++的你我，能跳出偏见，重新审视下这位老朋友！

C++的创始人Bjarne Stroutrup是位天才，他对C++的贡献令人无比钦佩。图灵访谈也有幸邀请到了Bjarne就C++17新增特征进行分享，届时会在《码农》刊载。对于初学者甚至是较高级的程序员而言，很可能永远都不会理解它的某些功能。坦白说，有些语言特性我们可以也应该忽视，与此同时，把精力和时间花费在那些构思精巧的地方。

像所有其他程序设计语言一样，C++只是一种方法，可以把一连串逻辑精确地下达给计算机执行。同样，学习它的捷径就是用它去解决各种编程任务。但在实际操作过程中，程序员却总是在相似的地方犯错。如果可以有幸获得经验长者的悉心指点，我们就能够巧妙地避开编程路上的“坑洼”，又不错过那些“美景”。

本期《码农》的“专题”部分刊载了编程老手多年的经验总结，深入讲解C++指针、API和一种“现代开发方式”——测试驱动。“人物”专栏邀请到前段时间媒体轮番儿报道的“琥珀中发现真恐龙”里的主人公邢立达古生物专家，一起探讨他的古生物奇缘。

“鲜阅”专栏带我们穿越回打印机的演进发展史，了解“16针打印机”不曾昙花一现的原因。叮呤呤...叮呤呤，不是闹钟惊扰了美梦，而是梦想开启了努力的一天。忙碌中，作者在“每天叫醒我的不是闹钟，而是梦想”一文中提醒我们停下脚步，思考人生！

专题：经典C++

深入理解指针

作者/Richard Reese

华盛顿州塔尔顿州立大学副教授。自2002年起在塔尔顿州立大学教授计算机科学课程，此前在洛克希德·马丁公司做过十年的软件开发。Reese著有多本技术图书，包括Oracle Certified Associate，Java SE 7 Programmer Study Guide，Java 7 New Features Cookbook，EJB 3.1 CookBook等。

指针强大的功能源于它们能追踪动态分配的内存。通过指针来管理这部分内存是很多操作的基础，包括一些用来处理复杂数据结构的操作。要完全利用这些能力，需要理解C的动态内存管理是怎么回事。

C程序在运行时环境中执行，这通常是由操作系统提供的环境，支持栈和堆以及其他的程序行为。

内存管理对所有程序来说都很重要。有时候内存由运行时系统隐式地管理，比如为自动变量分配内存。在这种情况下，变量分配在它所处的函数的栈帧上。如果是静态或全局变量，内存处于程序的数据段，会被自动清零。数据段是一个区别于可执行代码和运行时系统管理的其他数据的内存区域。

由于可以先分配内存然后释放，因而应用程序可以更灵活高效地管理内存，无需为适应数据结构可能的最大长度分配内存，只要分配实际需要的内存即可。

比如，在C99以前数组是固定长度的。如果我们要持有可变数量的元素，比如员工记录，可能就必须声明一个足够大的数组来装下可能的最大员工数。如果我们低估了这个值，那就只能重新编译应用程序或是采用别的办法。如果高估了，那就会浪费空间。动态分配内存的能力也对使用链表或队列等可变数量元素的数据结构有帮助。

注意　C99引入了变长数组（VLA）。数组长度在运行时而不是编译时确定。不过，数组一旦创建出来就不能再改变长度了。

像C这类语言也支持动态内存管理，对象就是从堆上分配出来的内存。这是用分配和释放函数手动实现的，这个过程被称为动态内存管理。

接下来我们首先概述如何分配和释放内存。然后讲解基本的分配函数，如malloc和realloc，还有free函数，包括NULL的使用和重复释放这类问题。

迷途指针是个常见问题。我们会通过示例说明什么情况下出现迷途指针以及处理这种问题的技术。最后讲解内存管理的其他技术。指针使用不当会造成不可预期的行为，这么说的意思是程序可能产生无效结果，损坏数据或者终止程序。

动态内存分配

在C中动态分配内存的基本步骤有：

1.用malloc类的函数分配内存；

2.用这些内存支持应用程序；

3.用free函数释放内存。

这个方法在具体操作上可能存在一些小变化，不过这里列出的是最常见的。在下例中，我们用malloc函数为整数分配内存。指针将分配的内存赋值为5，然后内存被free函数释放。

int *pi = (int*) malloc(sizeof(int));
*pi = 5;
printf("*pi: %d\n", *pi);
free(pi);

当这段代码执行时会打印数字5。图2-1说明了在free函数执行之前内存如何分配。为方便说明问题，除非特别指出，我们假定示例代码出现在main函数中。

图2-1：整数的内存分配

malloc函数的参数指定要分配的字节数。如果成功，它会返回从堆上分配的内存的指针。如果失败则会返回空指针。后面再讨论测试所分配内存的指针是否有效。sizeof操作符使应用程序更容易移植，还能确定在宿主系统中应该分配的正确的字节数。

在本例中，我们试图为整数分配足够多的内存。假定长度是4，我们可以这么写：

int *pi = (int*) malloc(4));

然而，依赖于系统所用的内存模型，整数的长度可能会发生变化。可移植的方法是使用sizeof操作符，这样不管程序在哪里运行都会返回正确的长度。

注意　涉及解引操作的常见错误见下面的代码：

int *pi;
*pi = (int*) malloc(sizeof(int));

问题出在赋值符号的左边。我们在解引指针，这样会把malloc函数返回的地址赋给pi中存放的地址所在的内存单元。如果这是第一次对指针进行赋值操作，那指针所包含的地址可能无效。正确的方法如下所示：

pi = (int*) malloc(sizeof(int));

这种情况下不应该用解引操作符。

稍后也会深入讨论free函数，它和malloc协同工作，不再需要内存时将其释放。

注意　每次调用malloc（或类似函数），程序结束时必须有对应的free函数调用，以防止内存泄漏。

一旦内存被释放，就不应该再访问它了。通常我们不会在释放内存后有意去访问,不过也存在意外访问的情况。这时系统的行为将依赖于实现。通常的做法总是把被释放的指针赋值为NULL。

分配内存时，堆管理器维护的数据结构中会保存额外的信息。这些信息包括块大小和其他一些东西，通常放在紧挨着分配块的位置。如果应用程序的写入操作超出了这块内存，数据结构可能会被破坏。这可能会造成程序奇怪的行为或者堆损坏。

考虑如下代码段，我们为字符串分配内存，让它可以存放最多5个字符外加结尾的NUL字符。for循环在每个位置写入0，但是没有在写入6字节后停止。for语句的结束条件是写入8字节。写入的0是二进制0而不是ASCII字符0的值。

char *pc = (char*) malloc(6);
for(int i=0; i<8; i++) {
*pc[i] = 0;
}

在图2-2中，6字节的字符串后面还分配了额外的内存，这是堆管理器用来记录内存分配的。如果我们越过字符串的结尾边界写入，额外的内存中的数据会损坏。在本例中，额外的内存跟在字符串后面。不过，实际的位置和原始信息取决于编译器。

图2-2：堆管理器用到的额外内存

内存泄漏

如果不再使用已分配的内存却没有将其释放就会发生内存泄漏，导致内存泄漏的情况可能如下：

- 丢失内存地址；

- 应该调用free函数却没有调用（有时候也称为隐式泄漏）。

内存泄漏的一个问题是无法回收内存并重复利用，堆管理器可用的内存将变少。如果内存不断地被分配并丢失，那么当需要更多内存而malloc又不能分配时程序可能会终止，因为它用光了内存。在极端情况下，操作系统可能崩溃。

下面这个简单的例子可以说明这个问题：

char *chunk;
while (1) {
chunk = (char*) malloc(1000000);
printf("Allocating\n");
}

chunk变量指向堆上的内存。然而，在它指向另一块内存之前没有释放这块内存。最终，程序会用光内存然后非正常终止，即使没有终止，至少内存的利用效率也不高。

1. 丢失地址

下面的代码段说明了当pi被赋值为一个新地址时丢失内存地址的例子。当pi又指向第二次分配的内存时，第一次分配的内存地址就会丢失。

int *pi = (int*) malloc(sizeof(int));
*pi = 5;
...
pi = (int*) malloc(sizeof(int));

图2-3说明了这一点，“前”和“后”分别表示在执行第二次malloc之前和之后的程序状态。由于没有释放地址500处的内存，程序已经没有地方持有这个地址。

图2-3：丢失地址

下面这个例子是为字符串分配内存，将其初始化，并逐个打印字符串：

char *name = (char*)malloc(strlen("Susan")+1);
strcpy(name,"Susan");
while(*name != 0) {
printf("%c",*name);
name++;
}

然而每次迭代name都会增加1，最后name会指向字符串结尾的NUL字符，如图2-4所示，分配内存的起始地址丢失了。

图2-4：丢失动态分配的内存的地址

2. 隐式内存泄漏

如果程序应该释放内存而实际却没有释放，也会发生内存泄漏。如果我们不再需要某个对象但它仍然保存在堆上，就会发生隐式内存泄漏，一般这是程序员忽视所致。这类泄漏的主要问题是对象在使用的内存其实已经不需要了，应该归还给堆。最差的情况是，堆管理器可能无法按需分配内存，导致程序不得不终止。最好的情况是我们持有了不必要的内存。

在释放用struct关键字创建的结构体时也可能发生内存泄漏。如果结构体包含指向动态分配的内存的指针，那么可能需要在释放结构体之前先释放这些指针。

动态内存分配函数

有几个内存分配函数可以用来管理动态内存，虽然具体可用的函数取决于系统，但大部分系统的stdlib.h头文件中都有如下函数：

- malloc

- realloc

- calloc

- free

表2-1总结了这些函数。

表2-1：动态内存分配函数

动态内存从堆上分配，至于一连串内存分配调用，系统不保证内存的顺序和所分配内存的连续性。不过，分配的内存会根据指针的数据类型对齐，比如说，4字节的整数会分配在能被4整除的地址边界上。堆管理器返回的地址是最低字节的地址。

在图2-3中，malloc函数在地址500处分配了4字节空间，第二次使用该函数在地址600处分配了内存。它们都处于4字节地址边界上，而且不是从相邻的内存位置上分配的。

使用malloc函数

malloc函数从堆上分配一块内存，所分配的字节数由该函数唯一的参数指定，返回值是void指针，如果内存不足，就会返回NULL。此函数不会清空或者修改内存，所以我们认为新分配的内存包含垃圾数据。函数的原型如下：

void* malloc(size_t);

这个函数只有一个参数，类型是size_t。传递参数给这个函数时要小心，因为如果参数是负数就会引发问题。在有些系统中，参数是负数会返回NULL。

如果malloc的参数是0，其行为是实现相关的：可能返回NULL指针，也可能返回一个指向分配了0字节区域的指针。如果malloc函数的参数是NULL，那么一般会生成一个警告然后返回0字节。

以下是malloc函数的典型用法：

int *pi = (int*) malloc(sizeof(int));

执行malloc函数时会进行以下操作：

(1) 从堆上分配内存；

(2) 内存不会被修改或是清空；

(3) 返回首字节的地址。

注意　当malloc无法分配内存时会返回NULL，所以，在使用它返回的指针之前先检查NULL是不错的做法，如下所示：

int *pi = (int*) malloc(sizeof(int));
if(pi != NULL) {
// 指针没有问题
} else {
// 无效的指针
}

1. 要不要强制类型转换

C引入void指针之前，在两种互不兼容的指针类型之间赋值需要对malloc使用显式转换类型以避免产生警告。因为可以将void指针赋值给其他任何指针类型，所以就不再需要显式类型转换了。有些开发者认为显式类型转换是不错的做法，因为：

- 这样可以说明malloc函数的用意；

- 代码可以和C++（或早期的C编译器）兼容，后两者需要显式的类型转换。

如果没有引用malloc的头文件，类型转换可能会有问题，编译器可能会产生警告。C默认函数返回整数，如果没有引用malloc的原型，编译器会抱怨你试图把int赋值给指针。

2. 分配内存失败

如果声明了一个指针，但没有在使用之前为它指向的地址分配内存，那么内存通常会包含垃圾，这往往会导致一个无效内存引用的错误。考虑如下代码片段：

int *pi;
...
printf("%d\n",*pi);

内存分配如图2-5所示。

图2-5：没有分配内存

执行这段代码可能会导致一个运行时异常。字符串中这类问题比较常见，如下所示：

char *name;
printf("Enter a name: ");
scanf("%s",name);

这里使用的是name所引用的内存，看起来似乎可以正确执行，实际上这块内存还没有分配。把图2-5中的pi换成name就可以说明这个问题。

3. 没有给malloc传递正确的参数

malloc函数分配的字节数是由它的参数指定的，在用这个函数分配正确的字节数时要小心。比如说要为10个双精度浮点数分配空间，那就需要80字节，通过下面的代码可以做到：

double *pd = (double*)malloc(NUMBER_OF_DOUBLES * sizeof(double));

注意　为数据类型分配指定字节数时尽量用sizeof操作符。

下例尝试为10个双精度浮点数分配内存：

const int NUMBER_OF_DOUBLES = 10;
double *pd = (double*)malloc(NUMBER_OF_DOUBLES);

这段代码实际只分配了10字节。

4. 确认所分配的内存数

没有标准的方法可以知道堆上分配的内存总数，不过有些编译器为此提供了扩展。另外，也没有标准的方法可以知道堆管理器分配的内存块大小。

比如说，如果我们为一个字符串分配64字节，堆管理器会分配额外的内存来管理这个块。所分配内存的总大小，以及堆管理器所用到的内存数，是两者的和。图2-2对此有说明。

malloc可分配的最大内存是跟系统相关的，看起来这个大小由size_t限制。不过这个限制可能受可用的物理内存和操作系统的其他限制所影响。

执行malloc时应该分配所请求数量的内存然后返回内存地址。如果操作系统采用“惰性初始化”策略直到访问内存才真正分配的话会怎样？这时候万一没有足够的内存用来分配就会有问题，答案取决于运行时和操作系统。开发者一般不需要处理这个问题，因为这种初始化策略非常罕见。

5. 静态、全局指针和malloc

初始化静态或全局变量时不能调用函数。下面的代码声明一个静态变量，并试图用malloc来初始化：

static int *pi = malloc(sizeof(int));

这样会产生一个编译时错误消息，全局变量也一样。对于静态变量，可以通过在后面用一个单独的语句给变量分配内存来避免这个问题。但是全局变量不能用单独的赋值语句，因为全局变量是在函数和可执行代码外部声明的，赋值语句这类代码必须出现在函数中：

static int *pi;
pi = malloc(sizeof(int));

注意　在编译器看来，作为初始化操作符的=和作为赋值操作符的=不一样。

使用calloc函数

calloc会在分配的同时清空内存。该函数的原型如下：

void *calloc(size_t numElements, size_t elementSize);

注意　清空内存的意思是将其内容置为二进制0。

calloc函数会根据numElements和elementSize两个参数的乘积来分配内存，并返回一个指向内存的第一个字节的指针。如果不能分配内存，则会返回NULL。此函数最初用来辅助分配数组内存。

如果numElements或elementSize为0，calloc可能返回空指针。如果calloc无法分配内存就会返回空指针，而且全局变量errno会设置为ENOMEM（内存不足），这是POSIX错误码，有的系统上可能没有。

下例为pi分配了20字节，全部包含0：

int *pi = calloc(5,sizeof(int));

不用calloc的话，用malloc函数和memset函数可以得到同样的结果，如下所示：

int *pi = malloc(5 * sizeof(int));
memset(pi, 0, 5* sizeof(int));

注意　memset函数会用某个值填充内存块。第一个参数是指向要填充的缓冲区的指针，第二个参数是填充缓冲区的值，最后一个参数是要填充的字节数。

如果内存需要清零可以使用calloc，不过执行calloc可能比执行malloc慢。

注意　cfree函数已经没用了。早期的C用cfree来释放calloc分配的内存。

使用realloc函数

我们可能需要时不时地增加或减少为指针分配的内存，如果需要一个变长数组这种做法尤其有用。realloc函数会重新分配内存，下面是它的原型：

void *realloc(void *ptr, size_t size);

realloc函数返回指向内存块的指针。该函数接受两个参数，第一个参数是指向原内存块的指针，第二个是请求的大小。重新分配的块大小和第一个参数引用的块大小不同。返回值是指向重新分配的内存的指针。

请求的大小可以比当前分配的字节数小或者大。如果比当前分配的小，那么多余的内存会还给堆，不能保证多余的内存会被清空。如果比当前分配的大，那么可能的话，就在紧挨着当前分配内存的区域分配新的内存，否则就会在堆的其他区域分配并把旧的内存复制到新区域。

如果大小是0而指针非空，那么就释放内存。如果无法分配空间，那么原来的内存块就保持不变，不过返回的指针是空指针，且errno会设置为ENOMEM。

该函数的行为概括在表2-2中。

表2-2：realloc函数的行为

在下例中，我们使用两个变量为字符串分配内存。一开始分配16字节，但只用到了前面的13字节（12个十六进制数字外加null结束字符（0））：

char *string1;
char *string2;
string1 = (char*) malloc(16);
strcpy(string1, "0123456789AB");

接着，用realloc函数指定一个范围更小的内存区域。然后打印这两个变量的地址和内容：

string2 = realloc(string1, 8);
printf("string1 Value: %p [%s]\n", string1, string1);
printf("string2 Value: %p [%s]\n", string2, string2);

输出如下：

string1  Value: 0x500 [0123456789AB]
string2  Value: 0x500 [0123456789AB]

图2-6说明了内存如何分配。

图2-6：realloc示例

堆管理器可以重用原始的内存块，且不会修改其内容。不过程序继续使用的内存超过了所请求的8字节。也就是说，我们没有修改字符串以便它能装进8字节的内存块中。在本例中，我们本应该调整字符串的长度以使它能装进重新分配的8字节。实现这一点最简单的办法是将NUL赋给地址507。实际使用的内存超出分配的内存不是个好做法，应该避免。

在接下来的例子中，我们会重新分配额外的内存：

string1 = (char*) malloc(16);
strcpy(string1, "0123456789AB");
string2 = realloc(string1, 64);
printf("string1 Value: %p [%s]\n", string1, string1);
printf("string2 Value: %p [%s]\n", string2, string2);

执行以上代码得到类似下面的结果：

string1  Value: 0x500 [0123456789AB]
string2  Value: 0x600 [0123456789AB]

在本例中，realloc必须分配一个新的内存块。图2-7说明了内存的分配。

图2-7：分配额外内存

alloca函数和边长数组

alloca函数（微软为malloca）在函数的栈帧上分配内存。函数返回后会自动释放内存。若底层的运行时系统不基于栈，alloca函数会很难实现，所以这个函数是不标准的，如果应用程序需要可移植就尽量避免使用它。

C99引入了变长数组（VLA），允许函数内部声明和创建其长度由变量决定的数组。在下例中，我们分配了一个在函数内使用的char数组：

void compute(int size) {
char* buffer[size];
...
}

这意味着内存分配在运行时完成，且将内存作为栈帧的一部分来分配。另外，如果数组用到sizeof操作符，也是在运行时而不是编译时执行。

这么做只会有一点小小的运行时开销。而且一旦函数退出，立即释放内存。因为我们没有用malloc这类函数来创建数组，所以不应该用free函数来释放它。alloca函数也不应该返回指向数组所在内存的指针。

注意　VLA的长度不能改变，一经分配其长度就固定了。如果你需要一个长度能够实际变化的数组，那么需要使用类似realloc的函数。

用free函数释放内存

有了动态内存分配，程序员可以将不再使用的内存返还给系统，这样可以释放内存留作他用。通常用free函数实现这一点，该函数的原型如下：

void free(void *ptr);

指针参数应该指向由malloc类函数分配的内存的地址，这块内存会被返还给堆。尽管指针仍然指向这块区域，但是我们应该将它看成指向垃圾数据。稍后可能重新分配这块区域，并将其装进不同的数据。

在下面这个简单的例子中，pi指向分配的内存，这块内存最终会被释放：

int *pi = (int*) malloc(sizeof(int));
...
free(pi);

图2-8说明了free函数执行前后瞬间内存的分配情况。地址500处的虚线框表示内存已经释放，但仍然有可能包含原值，pi变量仍然指向地址500。这种情况称为迷途指针。

图2-8：用free释放内存

如果传递给free函数的参数是空指针，通常它什么都不做。如果传入的指针所指向的内存不是由malloc类的函数分配，那么该函数的行为将是未定义的。在下例中，分配给pi的是num的地址，不过这不是一个合法的堆地址：

int num;
int *pi = &num;
free(pi); // 未定义行为

注意　应该在同一层管理内存的分配和释放。比如说，如果是在函数内分配内存，那么就应该在同一个函数内释放它。

将已释放的指针赋值为NULL

已释放的指针仍然可能造成问题。如果我们试图解引一个已释放的指针，其行为将是未定义的。所以有些程序员会显式地给指针赋NULL来表示该指针无效，后续再使用这种指针会造成运行时异常。

下面是该方法的示例：

int *pi = (int*) malloc(sizeof(int));
...
free(pi);
pi = NULL;

内存分配如图2-9所示。

图2-9：调用free后给指针赋值NULL

这种技术的目的是解决迷途指针类问题。不过，花时间处理造成这类问题的条件要比粗暴地用空指针一刀切好，更何况除了初始化的情况，都不能将NULL赋给指针。

重复释放

重复释放是指两次释放同一块内存。下面是一个简单的例子：

int *pi = (int*) malloc(sizeof(int));
*pi = 5;
free(pi);
...
free(pi);

调用第二个free函数会导致运行时异常。另一个例子不那么明显，涉及指向同一块内存的两个指针。如下所示，如果我们试图第二次释放同一块内存会发生同样的运行时异常。

p1 = (int*) malloc(sizeof(int));
int *p2 = p1;
free(p1);
...
free(p2);

内存分配如图2-10所示。

图2-10：重复释放

注意　两个指针引用同一个地址称为别名。

不幸的是，堆管理器很难判断一个块是否已经被释放，因此它们不会试图去检测是否两次释放了同一块内存。这通常会导致堆损坏和程序终止，即使程序没有终止，它意味着程序逻辑可能存在问题，同一块内存没有理由释放两次。

有人建议free函数应该在返回时将NULL或其他某个特殊值赋给自身的参数。但指针是传值的，因此free函数无法显式地给它赋值NULL。

堆和系统内存

堆一般利用操作系统的功能来管理内存。堆的大小可能在程序创建后就固定不变了，也可能可以增长。不过堆管理器不一定会在调用free函数时将内存返还给操作系统。释放的内存只是可供应用程序后续使用。所以，如果程序先分配内存然后释放，从操作系统的角度看，释放的内存通常不会反映在应用程序的内存使用上。

程序结束前释放内存

操作系统负责维护应用程序的资源，包括内存。当应用程序终止时，操作系统要负责重新分配这块内存以便别的应用程序使用。已终止的应用程序的内存状态不管是否损坏都无关紧要，事实上，内存损坏可能正是应用程序终止的原因。异常终止的程序可能无法做清理工作，因此没有理由在程序终止之前释放分配的内存。

话虽如此，可能又有一些原因要求我们在程序终止前释放内存。尽责的程序员可能会把释放内存当成质量指标。即使应用程序正在终止，不再使用内存后将其释放总归是好习惯。如果用工具来检测内存泄漏或是类似问题，那么释放内存会让这类工具的输出是干净的。在有些相对简单的操作系统上，操作系统本身可能不会自动回收内存，而是需要程序在终止前回收内存。还有，新版的应用程序可能会在程序末尾增加代码，如果之前的内存没有释放就可能出问题。

因此，确保程序终止前释放所有内存：

- 可能得不偿失；

- 可能很耗时，释放复杂结构也比较麻烦；

- 可能增加应用程序大小；

- 导致更长的运行时间；

- 增加引入更多编程错误的概率。

是否要在程序终止前释放内存取决于具体的应用程序。

迷途指针

如果内存已经释放，而指针还在引用原始内存，这样的指针就称为迷途指针。迷途指针没有指向有效对象，有时候也称为过早释放。

使用迷途指针会造成一系列问题，包括：

- 如果访问内存，则行为不可预期；

- 如果内存不可访问，则是段错误；

- 潜在的安全隐患。

导致这几类问题的情况可能如下：

- 访问已释放的内存；

- 返回的指针指向的是上次函数调用中的自动变量

迷途指针示例

在下面这个简单的例子中,我们用malloc函数为一个整数分配内存，接下来，用free函数释放内存：

int *pi = (int*) malloc(sizeof(int));
*pi = 5;
printf("*pi: %d\n", *pi);
free(pi);

pi变量持有整数的地址，但堆管理器可以重复利用这块内存，且其中存放的可能是非整数数据。图2-11说明了free函数执行前后的程序状态。假设pi变量属于main函数，位于地址100，malloc函数分配的内存位于地址500。

图2-11：迷途指针

执行free函数将释放地址500处的内存，此后就不应该再使用这块内存了。但大部分运行时系统不会阻止后续的访问或修改。我们还是可以向这个位置写入数据，如下所示。这么做的结果是不可预期的。

free(pi);
*pi = 10;

还有一种迷途指针的情况更难觉察：一个以上的指针引用同一内存区域而其中一个指针被释放。如下所示，p1和p2都引用同一块内存区域（称为指针别名），不过p1被释放了：

int *p1 = (int*) malloc(sizeof(int));
*p1 = 5;
...
int *p2;
p2 = p1;
...
free(p1);
...
*p2 = 10; // 迷途指针

图2-12说明了内存分配情况，虚线框表示释放的内存。

图2-12：迷途指针和指针别名

使用块语句时也可能出现一些小问题，如下所示。这里pi被赋值为tmp的地址，变量pi可能是全局变量，也可能是局部变量。不过当包含tmp的块出栈之后，地址就不再有效：

int *pi;
...
{
int tmp = 5;
pi = &tmp;
}
// 这里pi变成了迷途指针
foo();

大部分编译器都把块语句当做一个栈帧。tmp变量分配在栈帧上，之后在块语句退出时会出栈。pi指针现在指向一块最终可能被其他活跃记录（比如foo函数）覆盖的内存区域。图2-13说明的就是这种情形。

图2-13：块语句的问题

处理迷途指针

有时候调试指针诱发的问题会很难解决，以下方法可用来对付迷途指针。

- 释放指针后置为NULL，后续使用这个指针会终止应用程序。不过，如果存在多个指针的话还是会有问题。因为赋值只会影响一个指针。

- 写一个特殊的函数代替free函数。

- 有些系统（运行时或调试系统）会在释放后覆写数据（比如0xDEADBEEF，取决于被释放的对象，Visual Studio会用0xCC、0xCD或者0xDD）。在不抛出异常的情况下，如果程序员在预期之外的地方看到这些值，可以认为程序可能在访问已释放的内存。

- 用第三方工具检测迷途指针和其他问题。

在调试迷途指针时打印指针的值可能会有所帮助，但需要注意打印的方式。确保用一致的方式打印，从而避免比较指针的值时产生歧义。

调试器对检测内存泄漏的支持

微软提供了解决动态分配内存的覆写和内存泄漏的技术。这种方法在调试版程序里用了特殊的内存管理技术：

- 检查堆的完整性；

- 检查内存泄漏；

- 模拟堆内存不够的情况。

微软是通过使用一种特殊的数据结构管理内存分配来做到这一点的。这种结构维护调试信息，比如malloc调用点的文件名和行号，还会在实际的内存分配之前和之后分配缓冲区来检测对实际内存的覆写。关于这种技术的更多信息可以参考Microsoft Developer Network（http://msdn.microsoft.com/en-us/library/x98tx3cf.aspx）。

Mudflap库（http://gcc.fyxm.net/summit/2003/mudflap.pdf）为GCC编译器提供了类似的功能，它的运行时库支持对内存泄漏的检测和其他功能，这种检测是通过监控指针解引操作来实现的。

动态内存分配技术

目前为止，我们已经讨论了如何使用堆管理器分配和释放内存。不过，不同的编译器在技术实现上有所不同。大部分堆管理器把堆或数据段作为内存资源。这种方法的缺点是会造成碎片，而且可能和程序栈碰撞。尽管如此，它还是实现堆最常用的方法。

堆管理器需要处理很多问题，比如堆是否基于进程和（或）线程分配，如何保护堆不受安全攻击。

堆管理器有不少，包括OpenBSD的malloc、Hoard的malloc和Google开发的TCMalloc。GNU C库的分配器基于通用分配器dlmalloc（http://dmalloc.com），它提供调试机制，能追踪内存泄漏。dlmalloc的日志特性可以追踪内存的使用和内存事务，还有一些其他功能。

C的垃圾回收

malloc和free函数提供了手动分配和释放内存的方法。不过对于很多问题，需要考虑使用C的手动内存管理，比如性能、达到好的引用局部性、线程问题，以及优雅地清理内存。

有些非标准的技术可以用来解决部分问题，这里将探讨其中一部分技术。这些技术的关键特性在于自动释放内存。内存不再使用之后会被收集起来以备后续使用，释放的内存称为垃圾，因此，垃圾回收就是指这个过程。

鉴于以下原因，垃圾回收是有价值的：

- 不需要程序员费尽心思决定何时释放内存；

- 让程序员专注应用程序本身的问题。

Boehm-Weiser Collector（http://www.hpl.hp.com/personal/Hans_Boehm/gc/）可以作为手动内存管理的替换方法，不过它不属于语言的一部分。

资源获取即初始化

资源获取即初始化（Resource Acquisition Is Initialization，RAII）是Bjarne Stroustrup发明的技术，可以用来解决C++中资源的分配和释放。即使有异常发生，这种技术也能保证资源的初始化和后续的释放。分配的资源最终总是会得到释放。

有好几种方法可以在C中使用RAII。GNU编译器提供了非标准的扩展来支持这个特性，通过演示如何在一个函数中分配内存然后释放可以说明这种扩展。一旦变量超出作用域会自动触发释放过程。

GNU的扩展要用到RAII_VARIABLE宏，它声明一个变量，然后给变量关联如下属性：

- 一个类型；

- 创建变量时执行的函数；

- 变量超出作用域时执行的函数。

这个宏如下所示：

#define RAII_VARIABLE(vartype,varname,initval,dtor) \
void _dtor_ ## varname (vartype * v) { dtor(*v); } \
vartype varname __attribute__((cleanup(_dtor_ ## varname))) = (initval)

在下例中，我们将name变量声明为字符指针。创建它时会执行malloc函数，为其分配32字节。当函数结束时，name超出作用域就会执行free函数：

void raiiExample() {
RAII_VARIABLE(char*, name, (char*)malloc(32), free);
strcpy(name,"RAII Example");
printf("%s\n",name);
}

函数执行后会打印"RAII_Example"字符串。

不用GNU扩展也可以达到类似的效果（http://en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization#Ad-hoc_mechanisms）。

使用异常处理函数

另一种处理内存释放的方法是利用异常处理（http://www.adomas.org/excc/）。尽管异常处理不属于标准C，但如果可以使用它且不考虑移植问题，它会很有用。下面说明利用Microsoft Visua Studio版的C语言的方法。

这里的try块包含任何可能在运行时抛出异常的语句。不管有没有异常抛出，都会执行finally块，因此也一定会执行free函数。

void exceptionExample() {
int *pi = NULL;
__try {
pi = (int*)malloc(sizeof(int));
*pi = 5;
printf("%d\n",*pi);
}
__finally {
free(pi);
}
}

也可以用别的方法在C中实现异常处理。

小结

动态内存分配是C语言的重要特性。这里主要关注用malloc和free函数实现手动分配内存。我们解决了涉及这两个函数的一系列常见问题，包括内存分配失败和迷途指针。

此外，还有一些非标准技术可用来实现C的动态内存管理。我们也接触了几种垃圾回收技术，包括RAII和异常处理。

《深入理解C指针》是一本实战型图书。读者可以掌握指针动态操控内存的机制、对数据结构的增强支持，以及访问硬件等技术。本书详细阐述了如何在数组、字符串、结构体和函数中使用指针，同时演示了相应的内存模型及其对指针使用的影响。

测试驱动开发

作者/Jeff Langr

资深程序员，C++语言专家，曾在Bob大叔的Object Mentor公司工作，后创建Langr Software Solutions公司。出版过多本与测试驱动开发相关的图书，如《Agile Java：测试驱动开发的编程技术》等。

写个测试，保证它通过，接着重构设计。这就是TDD(test-driven development)的全部内容。但是这三个简单步骤的背后却另有乾坤。理解怎样利用TDD将使你受益匪浅。俗话说“前车之鉴，后事之师”，如果缺乏前人的经验，你很有可能放弃TDD。

与其让在学习中趔趄前行，我更愿意带领你以测试驱动的方法开发一些代码，帮助你理解每一步背后的故事。

虽然本文的示例规模不大，但也绝非一点用没有或毫无价值。这些示例提供了许多教学点，展示了TDD怎样增量地设计一个算法。

好了，希望你已经准备好编写代码了！

Soundex类

在许多应用程序中，搜索是很常见的功能。一次有效的搜索应该找出匹配的结果，即便用户的输入有拼写错误。许多人就曾以各种奇葩的方式拼错了我的名字：Langer、Lang、Langur、Lange、Lutefish就是其中一些。不管怎样，还是希望他们能够找到我。

我们将以TDD方法开发Soundex类，该类能提升应用程序的搜索能力。这个算法是将单词编码为一个字母和三个数字，它将发音相似的单词映射到相同的编码。下面是维基百科上关于Soundex规则的描述。1

1http://en.wikipedia.org/wiki/Soundex

(1) 保留第一个字母。丢掉所有出现的a、e、i、o、 u、y、h、w。

(2) 以数字来代替辅音（第一个字母除外）：

- b、f、p、v : 1

- c、g、j、k、q、s、x、z : 2

- d、t : 3

- l : 4

- m、n : 5

- r : 6

(3) 如果相邻字母编码相同，用一个数字表示它们即可。同样，如果出现两个编码相同的字母，且它们被h或w隔开，也这样处理；但如果被元音隔开，就要编码两次。这条规则同样适用于第一个字母。

(4) 当得到一个字母和三个数字时，停止处理。如果需要，补零以对齐。

开始吧

关于TDD的一个常见误解是，在实现产品代码前先定义好所有的测试。事实正好相反，每次只需要关注一个测试，此后逐渐考虑下一个加入到系统中的行为。

TDD的一般方法是逐步实现下一个最简单的规则。哪个有用行为的实现最直接且需要最少的代码改动？

基于这一思路，要从哪里开始实现Soundex呢？让我们快速推想一下实现每个规则都需要做些什么。

规则3似乎是最常用到的。规则4指示何时停止编码，这条规则应该只在其他规则生成编码时才会用到。规则2暗示第一个字母已经处理完毕，所以从规则1着手。它似乎比较直接。

规则1告诉我们，保留名字的第一个字母，然后……停止！让我们尽可能地保持事情简单。如果一个单词中只有一个字母呢？为此写个测试。

c2/1/SoundexTest.cpp

Line 1 #include "gmock/gmock.h"
2 TEST(SoundexEncoding, RetainSoleLetterOfOneLetterWord) {
3    Soundex soundex;
4 }

测试列表

在TDD过程中，你编写并通过的每一个测试，都代表新加入到系统中的一种行为。除了完成整个功能外，通过的测试数目是衡量进度的最佳指标。每个测试都代表系统中一个小的行为。

虽然不能事先知晓所有需要编写的测试，但需要对将要处理的事务有一些初步的认识。许多使用TDD方法的开发者将他们想到的测试记录到测试列表中（测试列表在Test Driven Development: By Example [Bec02]中首次提出）。这个列表包含测试的名称，或在需要做代码清理时提示。

可以把测试列表写在工作台边上的写字板上（也可以把它作为注释写入测试文件，只要在提交代码前删掉就行）。这个列表仅属于你自己，所以如果喜欢的话，大可以使用简要或隐晦的表达方式。

在做测试驱动开发并思考新的测试用例时，记得把它们加到测试列表里。当添加一些你认为将来需要清理的代码时，也在这个列表中加一个提醒项。在完成一个测试或任务时，把它从列表中删除即可，就这么简单。如果在编码结束后，发现仍有没完成的任务项，可以把它们加入到下一个编码阶段的列表。

可以把测试列表作为初始设计的一部分。它能帮助说明你认为自己需要构建什么。它也可以启发你去思考其他需要做的事情。

不要被这个列表束缚，它决定不了你要做什么，也决定不了你做事的顺序。但是，TDD是一个自然的流程，通常要顺着测试指引的方向去做下一件事。

在学习TDD时，管理测试列表非常管用。试一试吧！

- 第1行代码中包含了gmock头文件，它具备写一个测试所需的全部功能。

- 一个简单的测试声明需要使用TEST宏（第2行代码所示）。TEST宏有两个参数：测试用例的名称和测试的描述性名称。根据Google的文档，测试用例（test case）是一些能共享数据和子程序的测试集合。2（这个术语在这里被复用了，对于有些人而言，一个测试用例代表一种情境。3）

2http://code.google.com/p/googletest/wiki/V1_6_Primer#Introduction:_Why_Google_C++_Testing_Framework?

3

从左到右阅读测试用例名称和测试名称，可以连成一句话，描述了我们想要验证的行为：Soundex encoding retains [the] sole letter of [a] one-letter word。因为还要为Soundex编码行为写其他测试，所以用SoundexEncoding作为测试用例名字，以帮助组织相关测试。

不要低估好的测试名称的重要性。如下所示。

测试名称的重要性

多留心一下命名。长久看来，花一点精力起一个描述性强的测试名称是值得的，这是因为维护代码的人需要经常阅读测试。好的测试名称同样会帮助你自己（写测试的人）更好地理解将要构建的东西背后的意图。

你将为系统中的每个新行为开发一些测试。将测试名称视为一种索引，它可以为开发人员快速提供一个有关代码行为的准确描述。测试名称越容易理解，你和其他开发人员就能越快找到需要的东西。

- 第三行代码中，我们创建一个Soundex对象，然后……停止！在写更多的测试之前，我们知道已经加入了一些不能通过编译的代码：我们还没有定义Soundex类。在继续编写测试前，先停下来去解决这个问题。这个方法和Bob大叔(Robert C. Martin)关于TDD的三条规则保持一致。

- 只在为了使失败测试通过时才编写产品代码。

- 当测试刚好失败时，停止继续编写。编译失败也是失败。

- 只编写刚好能让一个失败测试通过的产品代码。

在使用C++时，增量地获得反馈是很好的方法，因为有时候只需几行测试代码，就能产生一大堆编译错误。若能及时看到所写代码产生的错误，那么将会更容易地解决它们。

撇开TDD的三条原则不谈，有时候你会觉得，在运行测试之前编写完整测试更靠谱，或许这有助于更好地理解应该怎样设计待测试的接口。你或许也会觉得不值得花费额外的编译时间，来获得更及时的反馈信息。

但是现在，尤其在学习TDD时，及时获得反馈很有用。归根结底，还是由你决定怎样增量地设计每个测试。

编译器显示我们需要Soundex类。可以为Soundex添加一个编译单元（一对.h/.cpp文件），但是先别自找麻烦。相反，不要急于使用独立文件，先简单地在包含测试的文件中声明4所有的东西。

4作者在书中许多地方未区分声明（declare）和定义（define）。不过这只是一个小细节，并不影响主题的论述。读者在阅读时可以依据上下文作出判断。——译者注

在准备提交代码，或苦于所有东西都放在一个文件中的时候，再以适当的方式把测试从产品代码中剥离。

c2/2/SoundexTest.cpp

➤ class Soundex{
➤ };

#include "gmock/gmock.h"

TEST(SoundexEncoding, RetainSoleLetterOfOneLetterWord) {
Soundex soundex;
}

提问：把所有东西放在一个文件中不是危险的捷径吗？

回答：这是一种短期内不引入复杂开销而节省时间的方式。前提是稍晚些分开文件产生的开销，小于一直在文件间来回切换的开销。当以TDD的方式为一个新行为打磨设计时，很可能经常改变接口。过早地拆分出头文件往往只会成为累赘。

说到“危险”，你忘记过在提交代码前拆分文件吗？

提问：但是遵循TDD周期的话，你不是应该要清理代码的吗？你不是一直要保证代码尽量维持在最高质量吗？

回答：一般而言，这两个问题的答案是肯定的。但是我们的代码没问题，我们只是在确实需要的时候，才选择一个更有效的组织方式。在真的需要之前，我们推延了做这种复杂的事情，否则过早地引入这些复杂性往往会使开发速度变慢。（一些敏捷支持者使用缩写YANGI——You ain't gonna need it。）

如果这种理念深深地困扰你，就马上把东西分散到不同的文件吧！你依然能够进行其余的练习。但是，我建议先试一试这种方法。TDD以一种安全的方式磨砺你，所以要敢于尝试认为可能更有效的工作方式。

我们遵循了第三条规则：只编写刚好让一个失败测试通过的产品代码。很显然，我们还没有完成测试。RetainsSoleLetterOfOneLetterWord并没有测试任何行为，所以它验证不了什么。然而，我们却可以对出现的任何负反馈5及时采取应对措施（这里的情况是编译失败），加入足够的代码消掉它。为使编译通过，我们加入了Soundex类的一个空定义。

5负反馈即为任何错误。与之对应的是正反馈。——译者注

这个时候构建并执行测试就能得到正反馈了。如下：

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from SoundexEncoding
[ RUN      ] SoundexEncoding.RetainsSoleLetterOfOneLetterWord
[       OK ] SoundexEncoding.RetainsSoleLetterOfOneLetterWord (0 ms)
[----------] 1 test from SoundexEncoding (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (0 ms total)
[ PASSED ] 1 test.

欢乐时刻！

等等，还不算。毕竟，这个测试除了构造一个Soundex类的实例（Soundex还是个空类），没做任何事情。但是，我们已经有了一些基本的要素。更重要的是，我们已经证明了目前所做的是正确的。

你进行到这一步了吗？你有没有拼错include的文件名，或者忘记在class定义后加上分号？如果有，恭喜你！你已经以最少的代码制造出了错误。在TDD中，如果践行这种安全编码方式的话，即尽早并频繁地测试，通常测试失败的原因只有一个。

测试通过了，是时候将代码提交到本地了。你有合适的工具吗？一个好的版本控制系统，让你可以在代码状态好（或者说，所有测试都通过）的时候方便地提交代码。如果稍后遇到麻烦的话，很容易将代码回滚到一个已知的完好状态，重新尝试改动。

TDD的一部分理念是，每个通过的测试都代表了加入到系统中的、已经验证了的行为。当然，这并不一定意味着可以发布代码了。但是，更多地以此增量的方式思考和频繁地集成本地已经验证通过的功能，成功的几率就会大大增加。

继续吧！往测试中加入一行代码，来表明我们期待的客户端代码与Soundex对象的交互方式。

c2/3/SoundexTest.cpp

TEST(SoundexEncoding, RetainSoleLetterOfOneLetterWord) {
Soundex soundex;

➤    auto encoded = soundex.encode("A");
}

我们在添加测试的同时也在作决定。如上述代码所示，我们决定Soundex类暴露一个名为encode()的公共成员函数，它有一个字符串类型的参数。尝试编译会导致失败，因为encode()尚不存在。这一负反馈迫使我们去写足够的代码，让测试通过编译，然后运行。

c2/4/SoundexTest.cpp

class Soundex
{
➤ public:
➤    std::string encode(const std::string& word) const {
➤       return "" ;
➤    }
};

现在代码可以编译了，所有的测试也可以通过，这还算不上非常有趣的时刻。是时候验证一些有用的东西了：给定一个字母A，encode()能返回正确的Soundex代码吗？我们用一个断言（assertion）来表达这一关注。

c2/5/SoundexTest.cpp

TEST(SoundexEncoding, RetainSoleLetterOfOneLetterWord) {
Soundex soundex;

auto encoded = soundex.encode("A");

➤     ASSERT_THAT(encoded, testing::Eq("A"));
}

断言可用来验证结果是否符合预期。上面代码中的断言声明了encode()返回的字符串等于指定的字符串。现在，编译是通过了，但是断言却失败了。

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from SoundexEncoding
[ RUN      ] SoundexEncoding.RetainsSoleLetterOfOneLetterWord
SoundexTest.cpp:21: Failure
Value of: encoded
Expected: is equal to 0x806defb pointing to "A"
Actual: "" (of type std::string)
[  FAILED  ] SoundexEncoding.RetainsSoleLetterOfOneLetterWord (0 ms)
[----------] 1 test from SoundexEncoding (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (0 ms total)
[  PASSED  ] 0 tests.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] SoundexEncoding.RetainsSoleLetterOfOneLetterWord 1 FAILED TEST

1 FALED TEST

乍一看，Google Mock给出的相关输出信息可能不太容易读懂。从最后一行开始看吧！如果是PASSED，可以不用看测试的输出，因为所有测试都过了！如果是FAILED（正如示例一样），我们可以看到有多少测试失败了。如果是其他类别的信息，则是测试程序在一个测试运行过程中崩溃了。

如果有一个或多个测试失败了，可以从下至上观察Google Mock输出信息，找到失败的测试。Google Mock会在每个测试名称前输出一个[RUN]，在测试失败的情况下输出[FAILED]或[OK]。失败的时候，[RUN]和[FAILED]之间的信息行可以帮助我们了解测试为什么失败。在上面这个示例中，可以看到下面信息：

[ RUN      ] SoundexEncoding.RetainsSoleLetterOfOneLetterWord
SoundexTest.cpp:21: Failure
Value of: encoded
Expected: is equal to 0x806defb pointing to "A"
Actual: "" (of type std::string)
[  FAILED  ] SoundexEncoding.RetainsSoleLetterOfOneLetterWord (0 ms)

这个断言失败的意思是，Google Mock期待一个名为encoded的局部变量的值为"A"，但实际的值是一个空字符串。

这个断言失败是意料之中的，因为为了通过编译，我们特意硬编码了一个空字符串。得到这个负反馈其实是好事，而且这也属于TDD周期内可以发生的事情。首先，我们正是想确保新加的断言不能通过，它代表了还没实现的功能。（有时候是通过的，通常这不是个好事情）。我们也想确保测试是有效的。起初测试失败，在加入适当的代码后通过了，这也说明测试是可靠的。

失败的测试提醒我们编写仅够通过断言的代码即可。如下：

c2/4/SoundexTest.cpp

std::string encode(const std::string& word) const {
➤    return "A";
}

现在编译并重新运行测试。最后两行的输出显示测试通过。

[==========] 1 test from 1 test case ran. (0 ms total)
[ PASSED ] 1 test.

发布吧！

我是在开玩笑吗？好吧，不是的。我们想以渐进的方式工作。这么说吧：如果某人告诉我们去构建一个Soundex类，而且只支持字母A即可，那我们已经完成了。自此，我们还想把代码弄得更干净些，但仅此而已，不需要额外的处理逻辑。

另外一个说法是，测试表明了目前系统拥有的功能。现在只有一个测试。为什么还要添加超出此测试所测行为之外的代码呢？

当然，还没完成。我们有大量的其他需求，这些需求将用TDD方法增量地加入系统。就目前已有的测试来说，我们也没完成。我们必须清理掉自己制造的一些小凌乱。

去掉不干净的代码

什么？我们只写了一行产品代码和三行测试代码就有问题了啊？当然，即便区区几行代码也非常容易引入缺陷。TDD方法提供了这样的契机，当一些小问题出现时，我们可以及时修复，这样就避免小问题越积越多（甚至产生一些大问题）。

我们先审阅一下刚写的测试和产品代码，找一找缺陷吧！有一点是可以确定的，测试中的断言不是非常便于阅读。

ASSERT_THAT(encoded, testing::Eq("A"));

和测试声明（测试用例和测试名称的组合）类似，我们也希望断言读起来像个句子。为此，我们引入using指示符来帮忙。

c2/7/SoundexTest.cpp

#include "gmock/gmock.h"
➤ using ::testing::Eq;

TEST(SoundexEncoding, RetainSoleLetterOfOneLetterWord){
Soundex soundex;
auto encoded = soundex.encode("A");
➤    ASSERT_THAT(encoded, Eq("A"));
}

现在可以通顺地朗读我们的断言了：断言encoded的值等于"A"。

我们称这一小改动为重构，重构是一种代码改写，其特点是在保持现有行为不变的前提下改进设计。这里，我们通过增强测试的表达力来提升测试的设计。Eq()所在的命名空间是一个与测试本意无关的实现细节。隐藏这一细节可以提升测试中的抽象程度。

另一个难点是，我们常常要面对重复代码。一旦有大量的重复代码，维护成本和风险就会大大增加。

我们的Soundex类还没有明显的重复代码。但是，一并看一下测试代码和产品代码，就会发现一个公共的常量，字符串"A"。我们想去除这一重复代码。还有一个问题，测试名称（RetainsSoleLetterOfOneLetterWord）声明了一个一般的行为，但是代码实现仅仅支持一个特定的单个字母。我们想找一个一箭双雕的方法去掉这个硬编码的"A"。

直接返回传入的字符串可以吗？

c2/8/SoundexTest.cpp

class Soundex
{
public:
std::string encode(const std::string& word) const {
➤       return word;
}
};

任何时候，一个完整的测试集合声明了系统中期望的行为。这里蕴含着一个潜台词：如果一个行为没有对应的测试来描述，那这个行为要么不存在，要么不是期望的（或者测试本身没有尽到描述行为的职责）。

那该怎么办呢？现在有一个测试。但它只支持单个字母的单词。所以，可以假定Soundex类仅需要支持单个字母的单词，至少目前是这样。如果所有的单词都只有一个字母，那么最简单的通用方案就是直接返回传给encode()的单词。

[其他TDD流派会想，如果不这样做，编出来的代码会是什么样的？一个可行的技巧是Triangulation6，写一个类似的断言，但是有不同的数据期望。]

6Test Driven Development: By Example [Bec02]

刚才的改动都很小且琐碎，但是当下完成这些改动是合时宜的。TDD中的重构步骤给予我们关注所有问题的契机，不论问题是大还是小，都是由小的、独立的代码改变引起的。当历经TDD的各个周期时，我们会使用重构来审阅设计，同时修复出现的所有问题。

重构的主要关注点是提升表达能力，去除重复代码。就代码的可维护性来说，这两个点最有裨益。此外，还有一些其他的设计知识，譬如SOLID设计原则和代码坏味。

增量性

问答时间到了！

提问：你真的对明知道将要替换的东西进行硬编码吗？

回答：我总是被问到这个问题。答案是肯定的。

提问：这似乎不明智！

回答：这不是个问题，但是在第一次接触这种方式时，认为它不明智是正常的。我也曾经有过这样的感受。我是过来人。

提问：你会一直以此方式工作吗？如果对所有东西硬编码，那怎么能够搞定所有事情？

回答：这是两个问题，但我很乐意一并回答！是的，我们会一直以增量的方式工作。这个技巧能快速让测试通过。不要担心，硬编码的东西最多存在一小会。我们心里清楚离目标还有一段距离，所以需要写更多的测试来描述这些行为。在这个例子中，还要实现其他的规则。在接下来编写其他测试时，我们将会用有趣的逻辑替换掉硬编码，使测试通过。

增量性是TDD取得成功的关键。乍一看，增量方法显得非常不自然且速度慢。但是，随着时间的推移，小步的增量开发反而能够加快你的速度，部分原因是这可以避开由一次编写而成的大量且复杂的代码产生的错误。坚持它！7

7小步开发出来的代码更容易适应未来的需求和变化。——译者注

精明的读者或许注意到了，我们编写的代码并不完全符合Soundex的规范。规则4的最后说，如果没有三个数字，需要补零。喔，这就是规范的乐趣所在！我们必须认真且全面地阅读它，彻底理解各个部分是怎样交互的。（最好与用户沟通，他会阐明哪些是期望的。）目前来说，规则4和我们所实现的代码尚不吻合。

想象一下，这些规则是逐个向我们提出的。“先把规则1的第一部分完成，然后再给你下一条规则。”TDD与后一种方法一致，规范的各个部分是增量地编写进系统。这一方法可以使我们以任何顺序一点点地开发系统，并持续地验证，向前推进。这里有一个权衡：相比于多做一点计划而言，我们可能要花费更多的时间合并新的代码。稍后再来讨论这个问题。目前来说，先看看没有这个权衡，会发生什么。

现在有两个工作要做：为新行为写一个新测试；修改已有的测试以满足规范。下面是新测试：

c2/9/SoundexTest.cpp

TEST(SoundexEncoding, PadsWithZerosToEnsureThreeDigits)  {
Soundex soundex;

auto encoded = soundex.encode("I");

ASSERT_THAT(encoded, Eq("I000"));
}

（一个审阅人提出问题：“为什么在编写测试前不认真地阅读Soundex规则呢？”好问题！的确，我们没有认真阅读规范。TDD的一个强项就是在信息不完全的情况下，我们依然可以向前推进，并且可以在得到新信息后及早纠正之前的代码。）

加入的每个测试都是独立的。我们不会将一个测试的结果作为另一个测试的前提。每个测试必须设置好自己的上下文。所以，新加的测试需要创建自己的Soundex类实例。

运行测试后，测试结果提示，encode()返回的是"I"而非"I000"。让它通过很简单。

c2/9/SoundexTest.cpp

std::string encode(const std::string& word) const {
➤    return word + "000";
}

硬编码可能再次导致事情乱作一团，但它能帮助我们不跑偏。就我们目前的测试而言，Soundex类不需要额外的行为。此外，通过采取尽可能小的步伐，我们在往系统中加入新行为时，必须编写额外的测试。

新的测试通过了，但是第一个测试失败了。这是因为测试描述的行为和维基百科上列出的规范不吻合。

如果一个测试通过了，就说明这个测试正确地描述了系统是如何工作的。如果测试设计得好，那么会起到例子的作用，比规范更易读。在接下来的练习中，我们会继续专注让测试具有可读性（我偶尔甚至把它们称之为规范）。

c2/9/SoundexTest.cpp

TEST(SoundexEncoding,RetainsSoleLetterOfOneLetterWord){
Soundex soundex;

auto encoded = soundex.encode("A");

➤    ASSERT_THAT(encoded, Eq("A000"));
}

如上述代码所示，让第一个测试通过并不难！

现在有两个大体相同的测试，只是数据稍微有点差别。这没关系。每个测试分别描述一种行为。我们不仅要确保系统按预期工作，还要让每个人知道所有既定的系统行为。

是时候重构了。encode()中的代码在描述其中可能发生的操作时有点模糊其词。我们决定提取独立的方法（Method），配以意图明确的名字。

c2/10/SoundexTest.cpp

public:
std::string encode(const std::string& word) const {
➤       return zeroPad(word);
}

private:
➤       std::string zeroPad(const std::string& word) const {
➤          return word + "000";
➤       }

fixture与设置

在重构的时候，不仅要审阅产品代码，还要审阅测试。如上所述，我们的测试都需要创建Soundex类实例，并且使用相同的代码。我们不乐意看到此类貌似无关紧要的重复代码。这些重复会积累得很快，并且通常会演变为更复杂的重复代码。这也会让测试变得有点主次不分，对于阅读代码的人来说，这会分散注意力，从而忽视真正需要关注的重要内容。

相关测试拥有一些共同的代码是常见的。Google Mock允许我们定义一个fixture类，我们可以在这个类中为相关的测试声明函数和数据。（从技术角度说，所有的Google Mock测试都使用一个由Google Mock自己生成的fixture。）

c2/10/SoundexTest.cpp

➤ class SoundexEncoding : public testing::Test {
➤ public:
➤    Soundex soundex;
➤ };

➤ TEST_F(SoundexEncoding, RetainsSoleLetterOfOneLetterWord) {
auto encoded = soundex.encode("A");

ASSERT_THAT(encoded, Eq("A000"));
}

➤ TEST_F(SoundexEncoding, PadsWithZerosToEnsureThreeDigits) {
auto encoded = soundex.encode("I");

ASSERT_THAT(encoded, Eq("I000"));
}

我们在上述代码中创建了一个名为SoundexEncoding的fixture（必须从::testing::Test继承）。这样，创建Soundex类实例就在一个地方完成了。在fixture内部，我们声明了公共成员变量soundex，以便测试可以访问。（如果你对暴露变量soundex有点担心的话，记住fixture类只在这个.cpp文件中。我们想避免在测试中加入多余的杂乱代码。）

Google Mock会在运行每个测试时创建fixture类实例。在Google Mock运行RetainSole- LetterOfOneLetterWord前，它先创建一个SoundexEncoding类实例。在运行PadsWithZeroTo- EnsureThreeDigits前，创建另外一个SoundexEncoding类实例。为了使用自定义的fixture，我们把TEST宏替换为TEST_F，F表示fixture。如果忘记使用TEST_F，任何使用了fixture类成员的测试代码都会编译失败。

现在，可以删掉测试内的局部变量soundex，因为每个测试都可以访问fixture中的成员。我们增量地作出这些代码改动。在定义完fixture类并修改宏处理后，我们从第一个测试中删掉局部变量soundex。运行所有的测试来验证这个改动，继而删掉第二个测试中的局部变量soundex，然后再次运行所有的测试。

去掉测试中重复的Soundex类实例定义至少会产生下面两个影响。

- 提升了测试的抽象度。现在，每个测试中只包含两行代码，这有助于我们集中精力关注与测试相关的东西。我们也看不到Soundex类实例是怎样构造出这一不相干的细节的。

- 可以降低未来维护测试的开销。试想一下我们必须改变Soundex类实例的构造方式（譬如我们需要能够将语言种类作为一个构造函数的参数）。将Soundex类实例的构造放到fixture中，这意味着改动一个地方即可。否则，要改动每个测试。

每个测试只有两行代码，这让测试更具可读性。还可以做些什么呢？我们可以让每个测试只有一行代码，同时保持可读性。另外，我也不是显式使用using指示符的拥趸，所以也可以去掉它。

c2/11/SoundexTest.cpp

#include "gmock/gmock.h"
➤ #include "Soundex.h"

➤ using namespace testing;

➤ class SoundexEncoding: public Test {
public:
Soundex soundex;
};

TEST_F(SoundexEncoding, RetainsSoleLetterOfOneLetterWord) {
➤    ASSERT_THAT(soundex.encode("A"), Eq("A000"));
}

TEST_F(SoundexEncoding, PadsWithZerosToEnsureThreeDigits) {
➤    ASSERT_THAT(soundex.encode("I"), Eq("I000"));
}

从上述代码中可以看到，测试仅仅引用了Soundex.h头文件。短期内，测试和产品代码同在一个文件是有益的。但现在，我们需要在一个文件中不断地拉上拉下，所以有点费力。先把测试和头文件分开（稍后再决定是否应该创建.impl文件），下面是头文件。

c2/11/Soundex.h

➤ #ifndef Soundex_h
➤ #define Soundex_h
#include <string>

class Soundex
{
public:
std::string encode(const std::string& word) const {
return zeroPad(word);
}

private:
std::string zeroPad(const std::string& word) const {
return word + "000";
}
};

➤ #endif

思索与测试驱动开发

简单地说，TDD的周期就是写一个测试，先确保测试失败，然后编码让测试通过，接着审阅代码和打磨设计（包括测试的设计），最后确保所有测试依然通过。在一天的工作中，你不断地重复此周期，保持周期短小，以便得到最多的反馈。虽然是重复，但绝非盲目，每一个周期你都需要思考很多事情。

为了保持事情持续推进，假定你遵从了上面提到的步骤，我只会偶尔提醒一下。你可以在显示器边上贴一个便签用作提醒。

在下一个测试中，我们将处理规则2（即在第一个字母后，用数字替换辅音）。替换规则表中说字母b对应数字1。那就先写个这样的测试，如下：

c2/12/SoundexTest.cpp

TEST_F(SoundexEncoding, RetainsSoleLetterOfOneLetterWord) {
➤    ASSERT_THAT(soundex.encode("Ab"), Eq("A100"));
}

不出意料，测试失败了。

Value of: soundex.encode("Ab")
Expected: is equal to 0x80b8a5f pointing to "A100"
Actual: "Ab000" (of type std::string)

如大部分要写的测试一样，对应的解决方案可能枚不胜举，但可能只有少量是合理的。从技术角度讲，唯一要做的是让这个测试通过，然后打磨我们的解决方案。

但是，我们寻求的是通用的解决方案（也不要过于通用，以至于考虑过多），也不要对已处理过的行为重复编码。

可以提供一个让测试通过的解决方案，如下：

std::string encode(const std::string& word) const {
if (word == "Ab") return "A100";
return zeroPad(word);
}

然而，对于用数字替换辅音而言，这一段代码不是通用的解决方案。它也引入了一些重复的东西：特例"Ab"的结果为"A1"的补零对齐输出，即"A100"，但是我们已经有了对任何词做补零操作的通用代码。

你可能认为这个理由不太充分，或许是吧。如果不限定时间，你能给出的替代方案数目可能也是无穷无尽的。但是TDD不是纯粹的科学，相反，可以把它想成软件工匠用来增量开发代码的工具。这个工具适合持续的实验、发现和改进。

行动胜于空谈！下面是迈向通用解决方案的一步：

c2/13/Soundex.h

std::string encode(const std::string& word) const {
➤    auto encoded = word.substr(0, 1);
➤
➤    if (word.length() > 1)
➤       encoded += "1";
➤    return zeroPad(encoded);
}

运行一下，新的测试没有通过。

Expected: is equal to 0x80b8ac4 pointing to "A100"
Actual: "A1000" (of type std::string)

补齐逻辑不对。必须修改它，以考虑到待编码词的长度。

c2/14/Soundex.h

std::string zeroPad(const std::string& word) const {
➤    auto zerosNeeded = 4 - word.length();
➤    return word + std::string(zerosNeeded, '0');
}

测试通过了。太棒了！但是代码看起来变得有点乏味。当然，我们知道自己编写的encode()是怎样工作的。但是其他人可能需要花更多时间，仔细阅读代码，以理解其背后的意图。其实我们可以做得更好。先把它重构为更具可读性的方案吧，如下：

c2/15/Soundex.h

class Soundex
{
public:
std::string encode(const std::string& word) const {
➤       return zeroPad(head(word) + encodedDigits(word));
}

private:
➤    std::string head(const std::string& word) const {
➤       return word.substr(0, 1);
➤    }

➤    std::string encodedDigits(const std::string& word) const {
➤       if (word.length() > 1) return "1";
➤       return "";
➤    }

std::string zeroPad(const std::string& word) const {
// ...
}
};

瞧，我们正一点点地完善Soundex编码算法。同时，重构有助于确保核心算法清晰，实现细节也很整洁。

以声明性的方式组织代码，使其非常易于理解。设计中非常重要的一方面是从实现（怎么做）中分离接口（做什么），这提供了迈向更高层次设计方案的跳板。每到TDD的重构环节时，都要考虑类似的重组。

有些读者或许担心实现细节。第一，是不是应该用stringstream，而不是直接将字符串连接起来？第二，为什么不尽可能地用单独的char？例如，为什么用return words.substr(0, 1);而非return word.front();？第三，用return std::string();不是比return "";更好吗？

这些替代的代码方案可能更好。但这些都是过早优化（premature optimization）。这个时候，一个好的设计（接口一致且代码可读性高）更重要。一旦以牢靠的设计实现了正确的行为后，再考虑是否优化性能。

撇开过早的性能优化不谈，我们的代码也确实需要雕琢一番。先去掉使用神奇的常量表示Soundex编码最大长度而产生的代码坏味，取而代之，使用一个具有合理名字的常量。

c2/16/Soundex.h

static const size_t MaxCodeLength{4};
// ...
std::string zeroPad(const std::string& word) const {
➤    auto zerosNeeded = MaxCodeLength - word.length();
return word + std::string(zerosNeeded, '0');

你怎么看待encodeDigits()中硬编码的字符串"1"呢？我们的代码需要将字母b替换为1，所以不能用变量去掉这个硬编码。我们可以引入另一个常量，或从一个名副其实的函数中返回一个常量。

甚至可以先不管这个硬编码，待到下一个测试时再消除它。但是我们能保证在集成代码前写下一个测试吗？万一我们转去做其他事情了呢？那个时候再回头看这段代码，或许要花费更多的时间来理解它了。请保持增量交付的态度，现在就来解决问题！

c2/17/Soundex.h

std::string encodedDigits(const std::string& word) const {
➤    if (word.length() > 1) return encodedDigit();
return "";
}

➤ std::string encodedDigit() const {
➤    return "1";
➤ }

测试驱动与测试

我们要以测试驱动方法开发更多的辅音变换逻辑（如c对应2、d对应3，等等），使解决方案更具通用性。为此，应该在ReplacesConsonantsWithAppropriateDigits中加一个断言，还是再创建一个测试呢？

TDD的经验法则是一个测试一个断言。我们提倡专注测试行为，而非测试功能函数。大部分时候要遵从这一个规则。

对第二个辅音进行编码的断言不像是另一个行为。如果创建一个这样的测试，该怎么命名呢？难道是ReplacesBWith1、ReplacesCWith2……？

向测试中加入第二个断言来表示一个测试用例的情况比较少。我们希望一个断言失败时，其他的代码能继续执行。为此，我们使用Google Mock提供的EXPECT_THAT宏而非ASSERT_THAT。

c2/18/SoundexTest.cpp

TEST_F(SoundexEncoding, ReplacesConsonantsWithAppropriateDigits) {
➤    EXPECT_THAT(soundex.encode("Ab"), Eq("A100"));
➤    EXPECT_THAT(soundex.encode("Ac"), Eq("A200"));
}

上述代码中，第二个辅音处理情形需要一个if分支来处理特殊的情况。

c2/18/Soundex.h

std::string encodedDigits(const std::string& word) const {
➤    if (word.length() > 1) return encodedDigit(word[1]);
return "";
}
➤ std::string encodedDigit(char letter) const {
➤    if (letter == 'c') return "2";
return "1";
}

再加上第三种数据输入情形。

c2/19/SoundexTest.cpp

TEST_F(SoundexEncoding, ReplacesConsonantsWithAppropriateDigits) {
EXPECT_THAT(soundex.encode("Ab"), Eq("A100"));
EXPECT_THAT(soundex.encode("Ac"), Eq("A200"));
➤    EXPECT_THAT(soundex.encode("Ad"), Eq("A300"));
}

第三个辅音处理情形让事情变得明朗，需要用一个基于哈希（hash）的集合代替简单的if分支。

c2/19/Soundex.h

std::string encodedDigit(char letter) const {
➤    const std::unordered_map<char,std::string> encodings {
➤       {'b', "1"},
➤       {'c', "2"},
➤       {'d', "3"}
➤    };
➤    return encodings.find(letter)->second;
}

现在，需要写代码支持其余的辅音转换。问题是，每个辅音转换功能都需要以测试驱动方式开发吗？

在TDD诞生初期有个口头禅：“可能出问题的地方都需要测试。”人们常常会问：“我需要测试什么？”这个口头禅算是一个漫不经心的回复。事实上，encoding中的映射（map）操作出现问题的风险比较小。不挨个测试一遍，应该也不会出什么问题。

一个相反的观点是，什么都可能出问题，无论它多么简单（我以前就是这样的，嘿嘿）。对重复数据越是感到乏味，犯错误的几率越大，甚至不会注意到已经犯错了。测试可以降低犯错的几率。

另外，测试好比一个辅音转换为数字的清晰文档（虽然你有可能辩称表格本身就是一个再清楚不过的文档了）。反过来，如果创建一个有几百个元素的表格，为每个都提供测试似乎很荒唐。

正确答案是什么？考量的重点在于我们在做测试驱动开发，而非测试。你或许会问：这有什么不同吗？答案是肯定的。用测试的技巧，你会全面地分析规范（也有可能是代码），并创建大量的测试来罗列各种行为。TDD则着力于代码设计。测试主要用于表述你要构建的行为。TDD过程中编写的测试大都是这个流程的附属产物。有了这些测试，在接下来改动代码时，你会更有信心。

其实，TDD与测试之间的区别很微妙。TDD的一个重要方面就是秉承够用心态。你写的测试只是为开发代码做准备。当要开发下一个行为时，再写测试。如果代码逻辑不再变，就可以不用写测试了。

当然，实际经验起决定作用。直到发布一个缺陷时，TDD都能很好地工作。但是，一旦选择TDD，还是要提醒自己采取更小、更安全的步伐。

既然选择了TDD，那就来完成转换表吧！

c2/20/Soundex.h

std::string encodedDigit(char letter) const {
const std::unordered_map<char, std::string> encodings {
➤       {'b', "1"}, {'f', "1"}, {'p', "1"}, {'v', "1"},
➤       {'c', "2"}, {'g', "2"}, {'j', "2"}, {'k', "2"}, {'q', "2"},
➤                   {'s', "2"}, {'x', "2"}, {'z', "2"},
➤       {'d', "3"}, {'t', "3"},
➤       {'l', "4"},
➤       {'m', "5"}, {'n', "5"},
➤       {'r', "6"}
};
return encodings.find(letter)->second;
}

那测试呢？ReplacesConsonantsWithAppropriateDigits中需要3个断言吗？为了回答这个问题，先自问额外的测试是否能增加对此功能特性的理解。答案应该是不能。所以，去掉两个断言，将剩下的断言改用ASSERT_THAT，并选择一个不同的辅音来编码，以此增加我们的信心。

c2/20/SoundexTest.cpp

TEST_F(SoundexEncoding, ReplacesConsonantsWithAppropriateDigits) {
➤    ASSERT_THAT(soundex.encode("Ax"), Eq("A200"));
}

如果出现别的情况呢

目前的实现假定能够在encodings映射中找到传入encodedDigit()的字母。之前是为了能慢慢向前推进开发，故作此假设，因为这样就可以写最少的代码来通过每个测试。但是，依然要思考可能需要写的其他代码。

有没有可能传入encodedDigit()的字母没有出现在查找映射中？如果可能，该怎么应对？维基百科并不能回答这个问题。我们可以猜，但是最好的答案来自客户。如果没有客户，可以在网上搜索一下，搜索会马上给出一堆Soundex应用。输入A#会得到A000。答案有了：需要忽略不能识别的字母。

在TDD中，我们可以记下这个既定测试的名称，或者现在就动手写这个测试。有几次我发现提前写一些例外情形的测试，会节省后来调试的时间。所以，现在就来写这个测试吧！

c2/21/SoundexTest.cpp

TEST_F(SoundexEncoding, IgnoresNonAlphabetics) {
ASSERT_THAT(soundex.encode("A#"), Eq("A000"));
}

运行这个测试，你会发现测试不是失败了，而是崩溃了。find()返回了指向end()的迭代器，而我们却尝试解引用。当遇到这种情形，先修改一下encodedDigit()，返回一个空字符串。

c2/21/Soundex.h

std::string encodedDigit(char letter) const {
const std::unordered_map<char, std::string> encodings {
{'b', "1"}, {'f', "1"}, {'p', "1"}, {'v', "1"},
// ...
};
➤    auto it = encodings.find(letter);
➤    return it == encodings.end() ? "" : it->second;
}

一次只做一件事

我们需要测试驱动开发出代码用以转换一个词末尾剩下的字母。

c2/22/SoundexTest.cpp

TEST_F(SoundexEncoding, ReplacesMultipleConsonantsWithDigits) {
ASSERT_THAT(soundex.encode("Acdl"), Eq("A234"));
}

一个简单的方法是：除了第一个字母，遍历剩下的字母并转换。但是，目前的代码结构还不太容易支持这么做。先重构下代码吧！

但是记住，一次只做一件事。在测试驱动开发时，要保持每一步都不同。在写测试时，不要跑去重构。同样，在尝试让测试通过时也不要重构。将两件事并在一起做时，一旦出岔子会浪费时间。而且这肯定会发生的。

先把刚才写的测试注释掉，暂时停止这个测试。（在Google Mock中，在测试名称前加DISABLE_前缀会跳过执行测试。）

c2/23/SoundexTest.cpp

➤ TEST_F(SoundexEncoding, DISABLED_ReplacesMultipleConsonantsWithDigits) {
ASSERT_THAT(soundex.encode("Acdl"), Eq("A234"));
}

我们先专注于重构，修改一下目前的解决方案。不要将整个词传入encodedDigits()，而是将词尾（除了第一个字母外的其余字母）传入encodedDigits()。这样做会让循环处理待转换字母的代码更简洁。同时，使用empty()和front()函数有助于澄清代码的意图。

c2/23/Soundex.h

std::string encode(const std::string& word) const {
➤       return zeroPad(head(word) + encodedDigits(tail(word)));
}
private:
// ...
➤    std::string tail(const std::string& word) const {
➤       return word.substr(1);
➤    }

std::string encodedDigits(const std::string& word) const {
➤       if (word.empty()) return "";
➤       return encodedDigit(word.front());
}

完成后，运行一下所有的测试，以确保改动不会破坏其他测试。至此，重构工作可以告一段落了。

回到TDD周期的开始，重新启用ReplacesMultipleConsonantsWithDigits，使其失败。可以使用一个基于范围（range）的for循环来遍历词尾，让测试通过。

c2/24/Soundex.h

std::string encodedDigits(const std::string& word) const {
if (word.empty()) return "";

➤    std::string encoding;
➤    for (auto letter: word) encoding += encodedDigit(letter);
➤    return encoding;
}

既然在encodedDigits()中加入了循环，就不用应对传入空字符串之类的防御语句了。在重构环节中，移除它。

c2/25/Soundex.h

std::string encodedDigits(const std::string& word) const {
std::string encoding;
for (auto letter: word) encoding += encodedDigit(letter);
return encoding;
}

重新运行测试，测试通过！删掉不必要的代码是非常令人满意的，但前提是要有足够的信心。有了测试的保障，做这些代码清理时你会感觉真的很棒！

限制长度

规则4声明Soundex编码结果必须是4个字符。下面为此写个新的测试。

c2/26/SoundexTest.cpp

TEST_F(SoundexEncoding, LimitsLengthToFourCharacters) {
ASSERT_THAT(soundex.encode("Dcdlb").length(), Eq(4u));
}

当用Google Mock运行这个新测试时，会抛出异常。不要担心，因为我们的测试工具捕获了这个异常，生成了测试失败的报告，然后继续运行其他的测试。

[ RUN      ] SoundexEncoding.LimitsLengthToFourCharacters
unknown file: Failure
C++ exception with description "basic_string::_S_create" thrown in the test body.
[  FAILED  ] SoundexEncoding.LimitsLengthToFourCharacters (1 ms)

默认情况下，Google Mock会记下问题，继续运行剩余的测试。如果你喜欢一遇到未处理的异常就让测试崩溃，可以用下面的命令行选项运行Google Mock：

--gtest_catch_exceptions=0

gdb（或类似的调试工具）中的栈回溯显示问题出在zeroPad()中。搜索引擎给出的结果显示，创建一个超出最大长度的字符串，导致了_S_create错误。基于这两点事实，我们集中看一下zeroPad()中的字符串构造过程。当编码的长度超出MaxCodeLength时，zeroNeeded溢出了一个值，这使字符串构造函数失败。

TDD方法学提倡的增量方法，使解决问题变得更加容易，因为一旦问题出现，就会暴露出来。不需要调试来精确定位问题，看一眼栈回溯就足够了。

（但是，只要程序出现崩溃，就得思考一下我们的方法。怎么更好地进行测试驱动开发，让问题的根源更加明显？在写zeroPad()时，我们或许想到将其声明为公有的实用方法。那样的话，我们或许会更全面地测试这个方法，以便让其他开发者知道怎么使用它。我们也很有可能会考虑防止zeroPad()创建不合法长度的字符串。）

此问题的解决方案是，修复zeroPad()中的问题。也可以改变encodedDigits()，使之在得到足够的字母时，停止编码。我们选择后者：一旦encoding得到编码，就跳出循环。

c2/26/Soundex.h

std::string encodedDigits(const std::string& word) const {
std::string encoding;
for (auto letter: word)
➤    {
➤       if (encoding.length() == MaxCodeLength - 1) break;
encoding += encodedDigit(letter);
➤    }
return encoding;
}

新加的代码不能清晰直接地表达其意图。先把它提取为意图明确的函数isComplete()。

c2/27/Soundex.h

std::string encodedDigits(const std::string& word) const {
std::string encoding;
for (auto letter: word) {
➤      if (isComplete(encoding)) break;
encoding += encodedDigit(letter);
}
return encoding;

}

➤ bool isComplete (const std::string& encoding) const {
➤    return encoding.length() == MaxCodeLength - 1;
➤ }

丢掉元音

规则1说要丢掉所有的元音以及w、h和y。 由于想不到更好的名字，先称这些字母为元音类字母。

c2/28/SoundexTest.cpp

TEST_F(SoundexEncoding, IgnoresVowelLikeLetters) {
ASSERT_THAT(soundex.encode("Baeiouhycdl"), Eq("B234"));
}

运行一下测试，发现还没添加产品代码，测试就通过了。这是由于encodedDigit()对于转换表中找不到的字母会返回空字符串。这样，所有的元音就被编码为空字符串了，并且追加到结果字符串。

如果没有改动类定义，测试就通过了，那背后肯定另有故事。可以问自己一个问题：“我在测试中做了与期望不一致的事情吗？”

如果接下来的测试也都继续通过，那么应该考虑回滚掉代码改动。测试提前通过的原因，可能是你的步伐有点大，但这样你可能不会感受到TDD带来的好处。在我们的示例中，本来可以先写一个测试，演示该怎么应对不能识别的字母，这样就可以选择返回相同的字母，而非空字符串。

让测试自我澄清

下一个测试是要处理两个相邻字母有相同数字编码的情形。按照规则3，将用一个数字表示这些字母。这条规则也适用于第一个字母。我们先处理前一种情形，然后再考虑后者。

c2/29/SoundexTest.cpp

TEST_F(SoundexEncoding, CombinesDuplicateEncodings) {
ASSERT_THAT(soundex.encode("Abfcgdt"), Eq("A123"));
}

这个测试有点让人迷糊！为了理解为什么Abfcgdt编码为A123，我们需要知道b和f都编码为1，c和g编码为2，d和t编码为3。也可以通过阅读其他测试（如ReplacesConsonantsWith AppropriateDigits）来了解这一事实，但或许应该让测试更加直接。

我们添加一些前置条件（precondition）断言，以帮助阅读代码的人建立这种关联。

c2/30/SoundexTest.cpp

TEST_F(SoundexEncoding, CombinesDuplicateEncodings) {

➤    ASSERT_THAT(soundex.encodedDigit('b'), Eq(soundex.encodedDigit('f')));
➤    ASSERT_THAT(soundex.encodedDigit('c'), Eq(soundex.encodedDigit('g')));
➤    ASSERT_THAT(soundex.encodedDigit('d'), Eq(soundex.encodedDigit('t')));

ASSERT_THAT(soundex.encode("Abfcgdt"), Eq("A123"));
}

前件断言不能通过编译，因为encodeDigit()是私有成员函数。先简单把它声明为公有的成员函数。

c2/30/Soundex.h

➤ public:
std::string encodedDigit(char letter) const {
// ...
}

➤ private:
// ...

我感觉到读者的惊愕了。

提问：不，等一等！你不能把私有函数改成公有的。

回答：我们也有其他的解决方案。可以把测试代码作为Soundex类的友员函数，但是友员往往是一个糟糕的选择，这一点在TDD中也是一样的。可以把这个函数写到另一个类中，譬如SoundexDigitEncoder，但是这似乎过度设计了。也可以不要前置条件断言，而是寻找其他的方法让测试更具可读性。

提问：我们一直被教导不要暴露内部实现细节。你不是也应该遵守此金科玉律吗？

回答：首先，我们不会随意暴露所有的实现，而只是我们需要的。其次，我们没有暴露过多的实现细节以至于影响到Soundex类的公共接口。是的，我们添加了一个产品客户不需要的函数，但是测试需要。所以，滥用的风险是比较低的，换而得之的是，对于以后必须阅读测试的开发者来说，节省了很多时间。

可以在其他写好的测试中使用前置条件断言。但是，尽量不要这样做。通常，使用名称有意义的常量或局部变量是一个简单有效的方案。此外，试图加一个前置条件断言，可能表示你错过了另一个测试。对吧？可以试着添加一个测试，然后看看它是否消去了对前置条件断言的需要。

为了让测试CombinesDuplicateEncodings通过，可以引入一个局部变量，记录最后一个追加的数字，并在每次循环迭代时更新它。但是光一个局部变量似乎模棱两可。我们还是以一个意图明确的声明开始。

c2/31/Soundex.h

std::string encodedDigits(const std::string& word) const {
std::string encoding;
for (auto letter: word) {
if (isComplete(encoding)) break;
➤       if (encodedDigit(letter) != lastDigit(encoding))
encoding += encodedDigit(letter);
}
return encoding;
}

我们知道lastDigit()需要做什么。稍微想一想就能找到一个实现的方法。如下：

c2/31/Soundex.h

std::string lastDigit(const std::string& encoding) const {
if (encoding.empty()) return "";
return std::string(1, encoding.back());
}

跳出条条框框来测试

现在考虑写下一个测试，其中第二个字母和第一个字母重复。嗯……目前所有测试都是以一个大写字母开始，其余字母是小写，但是这个算法应该是大小写无关的。让我们稍微停一下，先实现一些考虑大小写的测试。（也可以把这个问题加入到测试列表，以后再处理。）

没有指导怎么去处理大小写的规范，但是TDD的好处之一就是，我们可以思考目前手头之外的事情。创建一个健壮的应用程序需要我们能够处理没有明确指明的关键要素。（提示：可以问问你的客户。）

为了能够快速、简单地比较，Soundex算法将类似的词编码至相同的代码。字母的大小写并不影响发音。但为了简化比较Soundex编码，我们将自始至终使用一样的写法。

c2/32/SoundexTest.cpp

TEST_F(SoundexEncoding, UppercasesFirstLetter) {
ASSERT_THAT(soundex.encode("abcd"), StartsWith("A"));
}

修改一下encode()中的核心算法，将首字母大写，这里我们期待只有一个字母。（upperFront()中的映射操作避免了处理字符串结束符带来的潜在问题。）

c2/32/Soundex.h

std::string encode(const std::string& word) const {
➤    return zeroPad(upperFront(head(word)) + encodedDigits(tail(word)));
}

➤ std::string upperFront(const std::string& string) const {
➤    return std::string(1,
➤          std::toupper(static_cast<unsigned char>(string.front())));
}

有了对大小写的处理，也促使我们去修改测试IgnoresVowelLikeLetters。如上所述，我们期望算法代码可以忽略大小写的元音字母。但是，我们还是想确认一下。为此，更新测试，验证我们的想法。这样看来，我们从TDD跳转到了事后测试的方式。

c2/33/SoundexTest.cpp

TEST_F(SoundexEncoding, IgnoresVowelLikeLetters) {
➤    ASSERT_THAT(soundex.encode("BaAeEiIoOuUhHyYcdl"), Eq("B234"));
}

测试通过了，我们可以丢掉刚才更新了的测试。但是为了其他开发者考虑，我们决定保留此测试，用来显式地文档化此行为。

由于不是很确定代码的行为，我们不得不再写一个测试。但是当它立马通过时，我们觉得有必要去审视一下代码。encodedDigits()中的代码有点隐晦和难以理解。我们必须深入考虑来发现以下几点：

- 许多字母没有对应的编码；

- encodedDigit()对于上述字母会返回空字符串；

- 将一个空字符串和encodedDigits()中的变量encodings连接起来没有任何意义。

我们重构一下，以便代码更加明了。首先，对于一个字母，如果encodings表中没有对应的编码，encodedDigit()将返回一个名为NotADigit的常量。然后，在encodedDigits()中增加一个条件表达式，显式地指明NotADigit将被忽略。同时，我们也在lastDigit()中使用这一常量。

c2/34/Soundex.h

➤ const std::string NotADigit{"*"};

std::string encodedDigits(const std::string& word) const {
std::string encoding;
for (auto letter: word) {
if (isComplete(encoding)) break;

➤       auto digit = encodedDigit(letter);
➤       if (digit != NotADigit && digit != lastDigit(encoding))
➤          encoding += digit;
}
return encoding;
}

std::string lastDigit(const std::string& encoding) const {
➤    if (encoding.empty()) return NotADigit;
return std::string(1, encoding.back());
}
// ...
std::string encodedDigit(char letter) const {
const std::unordered_map<char, std::string> encodings {
{'b', "1"}, {'f', "1"}, {'p', "1"}, {'v', "1"},
// ...
};
auto it = encodings.find(letter);
➤    return it == encodings.end() ? NotADigit : it->second;
}

（上述代码列出了一些增量重构所做的改动，每次改动都经测试验证通过。换句话说，我们并不是一次完成这些改动的。）

让我们继续看一个处理辅音大小写的测试吧！

c2/35/SoundexTest.cpp

TEST_F(SoundexEncoding, IgnoresCaseWhenEncodingConsonants) {
ASSERT_THAT(soundex.encode("BCDL"), Eq(soundex.encode("Bcdl")));
}

这里的断言和之前的稍微不同。它声明了对"BCDL"和"Bcdl"的编码结果是一样的。也就是说，我们并不关心实际的编码是什么，只要大写的输入和小写的输入得到的结果一样就行。

我们的解决方案是在查询encodings表时，将字母全部转为小写（在encodedDigit()中）。

c2/35/Soundex.h

std::string encodedDigit(char letter) const {
const std::unordered_map<char, std::string> encodings {
{'b', "1"}, {'f', "1"}, {'p', "1"}, {'v', "1"},
// ...
};
➤       auto it = encodings.find(lower(letter));
return it == encodings.end() ? NotADigit : it->second;
}

private:
➤    char lower(char c) const {
➤       return std::tolower(static_cast<unsigned char>(c));
➤    }

言归正传

我们曾尝试写一个测试来处理第二个字母和第一个字母一样的情形。这又促使我们先将算法改为与大小写无关的。现在，可以回到最初的目标，继续写这个测试了。

c2/36/SoundexTest.cpp

TEST_F(SoundexEncoding, CombinesDuplicateCodesWhen2ndLetterDuplicates1st) {
ASSERT_THAT(soundex.encode("Bbcd"), Eq("B230"));
}

我们的解决方案会对encode()的总体策略稍作修改。将整个词传进encodedDigits()进行编码，这样就可以比较第一个字母和第二个字母的编码了。我们只将编码的尾部追加到最终的编码。

在encodedDigits()内部，先将单词的第一个字母编码，这样后续的编码可以与之作比较。由于encodedDigits()现在是编码整个单词，所以我们修改isComplete()以多容纳一个字母。同时，还修改encodedDigits()中的循环，使之遍历整个单词的词尾。

c2/36/Soundex.h

std::string encode(const std::string& word) const {
➤    return zeroPad(upperFront(head(word)) + tail(encodedDigits(word)));
}

std::string encodedDigits(const std::string& word) const {
std::string encoding;

➤    encoding += encodedDigit(word.front());

➤    for (auto letter: tail(word)) {
if (isComplete(encoding)) break;

auto digit = encodedDigit(letter);
if (digit != NotADigit && digit != lastDigit(encoding))
encoding += digit;
}
return encoding;
}

bool isComplete (const std::string& encoding) const {
➤    return encoding.length() == MaxCodeLength;
}

重构至单一责任的函数

函数encodedDigits()变得越来越复杂。为了将相关的语句分组，我们在其间插入了一些空行。这也揭示了此函数做的事情太多了。

单一责任原则（Single Responsibility Principle，SRP）告诉我们，每个函数的改动都是基于一个原因8。encodedDigits()是违反这一原则的典型例子：它把高层的策略与底层的实现细节糅合到一起了。

8参见《敏捷软件开发：原则、模式与实践》。

encodedDigits()通过两个步骤的算法完成其目的。它首先将首字母的编码追加至变量encoding中，然后遍历剩下的字母，追加结果至encoding。问题是，encodedDigits()中还包含了达成这两步的一些底层实现细节。此函数违背了单一责任原则，因为有两个原因会导致要修改它：想要改变实现细节，或需要改变整个编码策略。

可以将encodedDigits()中的两个步骤提取成两个单独的函数，每个函数各自包含一个抽象概念的实现细节。如此，encodedDigits()中的代码只是声明了解决方案的策略。

c2/37/Soundex.h

std::string encodedDigits(const std::string& word) const {
std::string encoding;
encodeHead(encoding, word);
encodeTail(encoding, word);
return encoding;
}

void encodeHead(std::string& encoding, const std::string& word) const {
encoding += encodedDigit(word.front());
}

void encodeTail(std::string& encoding, const std::string& word) const {
for (auto letter: tail(word)) {
if (isComplete(encoding)) break;

auto digit = encodedDigit(letter);
if (digit != NotADigit && digit != lastDigit(encoding))
encoding += digit;
}
}

这样看起来好多了。再往前迈进一步，将encodeTail()中的for循环体提取出来。

c2/38/Soundex.h

void encodeTail(std::string& encoding, const std::string& word) const {
for (auto letter: tail(word))
if (!isComplete(encoding))
encodeLetter(encoding, letter);
}

void encodeLetter(std::string& encoding, char letter) const {
auto digit = encodedDigit(letter);
if (digit != NotADigit && digit != lastDigit(encoding))
encoding += digit;
}

从视觉上看，重构后的代码还有进一步提升的空间。对单个字母编码的encodeHead()函数是不是encodeTail()中编码的一个特例呢？尽管去试验吧！因为有了测试的保证，所以你所做的事情将是安全的。目前而言，我们觉得算法的实现已经足够清楚，所以继续吧！

收尾工作

那元音怎么办呢？规则3说被一个元音（不是h或w）分开的相同编码，应该编码两次。

c2/39/SoundexTest.cpp

TEST_F(SoundexEncoding, DoesNotCombineDuplicateEncodingsSeparatedByVowels) {
ASSERT_THAT(soundex.encode("Jbob"), Eq("J110"));
}

再一次通过声明要完成的目标来解决问题。我们修改了encodeLetter()中的条件表达式，在不是重复编码或最后一个字母是元音的情况下，追加一个数字。这个声明也敦促了一些其他的相应改动。

c2/39/Soundex.h

void encodeTail(std::string& encoding, const std::string& word) const {
➤    for (auto i = 1u; i < word.length(); i++)
➤       if (!isComplete(encoding))
➤          encodeLetter(encoding, word[i], word[i - 1]);
}

➤ void encodeLetter(std::string& encoding, char letter, char lastLetter) const {
auto digit = encodedDigit(letter);
if (digit != NotADigit &&
➤          (digit != lastDigit(encoding) || isVowel(lastLetter)))
encoding += digit;
}

➤ bool isVowel(char letter) const {
➤    return
➤       std::string("aeiouy").find(lower(letter)) != std::string::npos;
➤ }

把最后一个字母传入isVowel()中是最好的方法吗？不过，这个方法直接且具有表达力。我们暂时先这样做。

漏了什么测试吗

我们很少拥有所有的规范。很少有人会如此幸运。即便是Soundex的规则，看似完整，实际却不能蕴含所有情形。在编程过程中，一些测试或代码实现经常会激发我们进行其他方面的思考。一般而言，要么把这些思考结果记在脑子里，要么写到一个列表或记事本中。下面就是对Soundex进行思考而得到的列表。

- 若给定的词中含有分隔符，如句点（例如，Mr.Smith），该怎么办？应该忽略它们（就像现在做的这样），抛出一个异常（假定客户应该把词合理地分好），还是做些其他操作？

说到异常，怎样以测试驱动的方法在代码中加入异常处理？

- 空字符串该怎样编码？（或者说，可以假定不会接收到一个空字符串输入吗？）

- 该怎样处理非英语字母中的辅音（如ñ）？Soundex算法依然适用吗？isVowel()函数需要支持带变音符的元音吗？

许多这样的考虑对于设计一个健壮的Soundex类至关重要。若未能处理好它们，在实际使用中，应用程序可能会失效。

对于诸如此类的问题，我们其实没有确切的答案。作为程序员，我们应该已经学会适时作出自己的决定。但往往更好的方法就是去问客户，甚至可以和他们一起制定验收测试。

“系统应该按照A来做，还是按照B来做？”以往而言，我们会直接把选择代码化，然后继续。这样做的后果是，未能及时将我们作出的决定写入文档。有时，解决方案B可能会被夹杂在一大堆其他代码中。当然，可以分析代码来确定我们作出了什么选择，但这通常很耗时。相信我们都曾经花过无数小时试图判定代码的行为吧！

相反，TDD留下了一份清晰的文档。我们可以毫不费力地回忆起数月前作出的决定。

和迄今为止所做的一样，我们可以采用TDD方式为前面提出的几个问题开发解决方案。这些漏掉的测试就留给读者作为练习吧！

解决方案

我们用测试驱动方法开发出了Soundex的解决方案。这个解决方案绝不是唯一的或最好的，但是我们有足够的信心去发布了，这才是最重要的。

我自己对Soundex做了好几次测试驱动开发，每次都得到不同的解决方案。其中大部分解决方案的差异很小，但有一个差异很大（而且工作起来很糟糕），这是由我积极地以极具描述性的方式来解决问题而导致的。每一次Soundex TDD经历都让我更好地了解此算法，与此同时，我也学会了更多可以在TDD中很好地工作的东西。

多次测试驱动开发Soundex算法后，你会发现类似的好处。重复使用TDD方法去开发同一个示例可称为套路（kata）。

任何解决方案的实现并非仅有一种正确的方式。下面是一个解决方案应具备的一些重要特征。

- 它实现了客户的需求。如果没有，那么不管怎样，它都不是好的解决方案。在TDD中，你编写的测试能够帮助你了解你的解决方案是不是客户要的。性能可能是众多客户需求中的一项。你的一部分职责就是理解他们的性能需求，如果没必要的话，就不要花费时间去做性能优化。

- 它可以工作。如果一个解决方案有大量的缺陷，那么构建得再优雅，也不是好的解决方案。TDD可以帮助确定我们交付的软件能以期望的方式工作。TDD不是银弹9。你交付的软件依然会有缺陷，所以照样需要许多其他方式的测试。但是，TDD会让你发布的代码包含非常少的缺陷。

- 它易于理解。对于编写得不好的代码，每个人都需要花费大量的时间去理解。TDD让你可以安全地重新组织代码以提高可读性。

- 它易于修改。通常，容易修改的代码意味着高质量的设计。TDD使你可以持续地修改，以保持设计的高质量。

9银弹（Silver Bullet）是欧洲民间传说中用来对付怪物的杀手锏武器，后来银弹经常被比喻为一种非常有效的解决方案。Frederick在1987年的IFIPS会议上发表了题为“No Silver Bullet，Essense and Accidents of Software Engineering”的论文。由于这篇论文引发了剧烈的的争论，后来Frederic在《人月神话》一书中对一些公开的批评做了说明，并更新了论文中的一些观点，有兴趣的读者可以找来看看。——译者注

我们的解决方案不是过程式的。整个算法不是在一个函数中，不能一次从头读到尾。相反，我们以许多小的成员函数的方式完成整体实现，许多函数只有一两行代码。每个函数的代码实现一个抽象。第一次遇到这样的代码可能会导致中风发作：晕！到底工作是在哪完成的？

Soundex类

因为已准备好提交代码，所以让我们来纵览一下整个解决方案。我们觉得还没有足够的理由去单独拆分出实现文件（.cpp），但是这是成为系统产品代码所必需的一步。

c2/40/SoundexTest.cpp

#include "gmock/gmock.h"
#include "Soundex.h"
using namespace testing;

class SoundexEncoding: public Test {
public:
Soundex soundex;
};

TEST_F(SoundexEncoding, RetainsSoleLetterOfOneLetterWord) {
ASSERT_THAT(soundex.encode("A"), Eq("A000"));
}

TEST_F(SoundexEncoding, PadsWithZerosToEnsureThreeDigits) {
ASSERT_THAT(soundex.encode("I"), Eq("I000"));
}

TEST_F(SoundexEncoding, ReplacesConsonantsWithAppropriateDigits) {
ASSERT_THAT(soundex.encode("Ax"), Eq("A200"));
}

TEST_F(SoundexEncoding, IgnoresNonAlphabetics) {
ASSERT_THAT(soundex.encode("A#"), Eq("A000"));
}

TEST_F(SoundexEncoding, ReplacesMultipleConsonantsWithDigits) {
ASSERT_THAT(soundex.encode("Acdl"), Eq("A234"));
}

TEST_F(SoundexEncoding, LimitsLengthToFourCharacters) {
ASSERT_THAT(soundex.encode("Dcdlb").length(), Eq(4u));
}

TEST_F(SoundexEncoding, IgnoresVowelLikeLetters) {
ASSERT_THAT(soundex.encode("BaAeEiIoOuUhHyYcdl"), Eq("B234"));
}

TEST_F(SoundexEncoding, CombinesDuplicateEncodings) {
ASSERT_THAT(soundex.encodedDigit('b'), Eq(soundex.encodedDigit('f')));
ASSERT_THAT(soundex.encodedDigit('c'), Eq(soundex.encodedDigit('g')));
ASSERT_THAT(soundex.encodedDigit('d'), Eq(soundex.encodedDigit('t')));

ASSERT_THAT(soundex.encode("Abfcgdt"), Eq("A123"));
}

TEST_F(SoundexEncoding, UppercasesFirstLetter) {
ASSERT_THAT(soundex.encode("abcd"), StartsWith("A"));
}

TEST_F(SoundexEncoding, IgnoresCaseWhenEncodingConsonants) {
ASSERT_THAT(soundex.encode("BCDL"), Eq(soundex.encode("Bcdl")));
}

TEST_F(SoundexEncoding, CombinesDuplicateCodesWhen2ndLetterDuplicates1st) {
ASSERT_THAT(soundex.encode("Bbcd"), Eq("B230"));
}

TEST_F(SoundexEncoding, DoesNotCombineDuplicateEncodingsSeparatedByVowels) {
ASSERT_THAT(soundex.encode("Jbob"), Eq("J110"));
}

c2/40/Soundex.h

#ifndef Soundex_h
#define Soundex_h

#include <string>
#include <unordered_map>

#include "CharUtil.h"
#include "StringUtil.h"

class Soundex
{
public:
static const size_t MaxCodeLength{4};

std::string encode(const std::string& word) const {
return stringutil::zeroPad(
stringutil::upperFront(stringutil::head(word)) +
stringutil::tail(encodedDigits(word)),
MaxCodeLength);
}

std::string encodedDigit(char letter) const {
const std::unordered_map<char, std::string> encodings {
{'b', "1"}, {'f', "1"}, {'p', "1"}, {'v', "1"},
{'c', "2"}, {'g', "2"}, {'j', "2"}, {'k', "2"}, {'q', "2"},
{'s', "2"}, {'x', "2"}, {'z', "2"},
{'d', "3"}, {'t', "3"},
{'l', "4"},
{'m', "5"}, {'n', "5"},
{'r', "6"}
};
auto it = encodings.find(charutil::lower(letter));
return it == encodings.end() ? NotADigit : it->second;
}

private:
const std::string NotADigit{"*"};

std::string encodedDigits(const std::string& word) const {
std::string encoding;
encodeHead(encoding, word);
encodeTail(encoding, word);
return encoding;
}

void encodeHead(std::string& encoding, const std::string& word) const {
encoding += encodedDigit(word.front());
}

void encodeTail(std::string& encoding, const std::string& word) const {
for (auto i = 1u; i < word.length(); i++)
if (!isComplete(encoding))
encodeLetter(encoding, word[i], word[i - 1]);
}
void encodeLetter(std::string& encoding, char letter, char lastLetter) const {
auto digit = encodedDigit(letter);
if (digit != NotADigit &&
(digit != lastDigit(encoding) || charutil::isVowel(lastLetter)))
encoding += digit;
}

std::string lastDigit(const std::string& encoding) const {
if (encoding.empty()) return NotADigit;
return std::string(1, encoding.back());
}

bool isComplete(const std::string& encoding) const {
return encoding.length() == MaxCodeLength;
}
};

#endif

等等，有些东西已经变了！head()、tail()和zeroPad()在哪？isVowel()和upper()呢？lastDigit()看起来也不同了！

哈哈！在你忙于阅读的时候，我做了额外的一些重构工作。这些消失的函数（原先定义在Soundex中）现在被作为自由函数声明在StringUtil.h和CharUtil.h中。通过这个小的重构，它们成了高度可重用的函数。

简单地将这些函数从Soundex中移除还不够。作为公用的工具函数，它们需要恰当的描述，以便其他程序员能够理解它们的意图和用法。这也就意味着，需要一些测试告诉程序员如何在代码中使用这些函数。

我们用测试驱动方法开发出了Soundex的一种解决方案。这个方案的演化完全取决于你。你越多地践行TDD，你的解决方案风格也就演变得越多。两年前测试驱动开发出的结果和我今天测试开发出的结果有着天壤之别。

小结

这样，你就亲身经历了一个实际的TDD过程。你是自己编写代码的吗？如果是，你已经实现了一个几乎可以在产品代码中使用的Soundex类。如果不是，打开编辑器去实现Soundex类吧！通过写代码学习比简单阅读代码更有效。

准备好真正去学TDD了吗？再次出发！再一次测试驱动开发Soundex类，但这次抛开本文，以不同的顺序实现Soundex规则会发生什么呢？采用不同的编程风格又会怎样呢？如果每个规则都用一个函数变换输入，然后把它们串起来作为Soundex的实现，结果又会如何呢？

《C++程序设计实践与技巧：测试驱动开发》是一本关于设计原则、编程实践、测试驱动开发的指南，旨在帮助C++程序员用测试驱动开发方法构建高性能解决方案。全书涵盖了测试驱动开发的基本工作方式、潜在好处、怎样利用测试驱动开发解决设计缺陷、测试驱动开发的难点和成本、怎样利用测试驱动开发减少甚至免除调试工作，以及如何长时间维持测试驱动开发。

本书适合所有技术层次的C++ 程序员阅读。

那些优质API的特征

作者/Martin Reddy

是软件行业的一名老兵，有着15年以上的从业经验，共撰写过40多篇论文，拥有3项软件专利，并与他人合著了Level of Detail for 3D Graphics。另外，他还是ACM以及IEEE的会员。

早年，他曾在SRI International供职5年，主要从事分布式三维地形可视化技术方面的工作，他成功创建了在Web上描述3D地球空间信息模型的ISO标准，并且还连续两年被选为Web3D协会的会长。

他曾在Pixar动画工作室工作过6年，担任内部动画系统的首席工程师，设计并实现了很多高性能API，这些API在一些奥斯卡获奖及提名影片的制作中都发挥了关键作用，这些影片有《海底总动员》《超人总动员》《赛车总动员》《料理鼠王》，以及《机器人总动员》等。

他还开办了一家咨询公司Code Reddy，为各家软件公司提供技术咨询，主要客户有Linden Lab和Planet 9 Studios，为大型在线3D虚拟世界《第二人生》设计了API并改善了其基本架构。

现在他担任ToyTalk公司的首席技术官。

这里主要回答下面的问题：优质的API应该具有哪些基本特征？绝大多数开发者都会同意这样的观点：优质的API应该设计精巧且实用性强。它不仅能带来愉悦的使用体验，且能与各种应用程序完美融合，甚至让使用者感觉不到其存在（Henning, 2009）。这些定性的表述都没错，但是应该怎样设计API才能够使之拥有这些特征呢？很显然，每个API都是不同的，但是高质量API的设计具有某些共同的特征。API开发者应该尽量遵循这些特征，同时尽量避免那些导致糟糕设计的特征。

API设计没有放之四海而皆准的方法，不可能用一组固定规则应对所有情况。当遇到某些特别的项目时，你可能不会采用建议的方法设计API，但是这样做的前提是你已经合理而周全地考虑了该设计方案。这里提到的原则是设计API通常应该遵循的基本准则。

这里重点讨论API所具有的通用的、与语言无关的特征，比如信息隐藏、一致性以及松耦合。虽然我们是在C++语境下解释这些概念，但是，不管你使用的是C++、Java、C#还是Python，这里的建议大体上都能适用。

问题域建模

编写API的目的是解决特定的问题或完成具体的任务。因此， API应该首先为问题提供一个清晰的解决方案，同时能对实际的问题域进行准确的建模。例如， API应该对问题域进行很好的抽象并且模塑出问题域的核心对象，这样会使API更便于用户使用和理解，因为它与使用者已有的知识和经验关联密切。

提供良好的抽象

API应该对它所解决的问题提供逻辑抽象。也就是说，在设计API时，应该阐述在选定问题域内有意义的深层概念，而不是公开低层实现细节。当你把API文档提供给一位非程序员时，他应当能够理解接口中的概念并且知道它的工作机制。

对于非技术人员， API提供的一组操作应该合乎逻辑且共属同一单元。每个类都应该有一个主旨，且这个主旨应该能通过类名和类包含的方法名体现出来。有可能的话，还可以让非技术人员看看接口，要是连他们都能看懂接口的运行机制，那说明接口的抽象就非常到位了。

得出好的抽象并非易事。需要注意的是，任何问题的正确抽象不止一种。绝大多数API都能以多种方式建模，每种建模方式都能提供良好的抽象和易用的接口。关键是API应该具有一致且合理的支撑体系。

例如，为一个简单的地址簿程序设计API。从概念上讲，地址簿包含许多人的详细信息。因此，我们的API应当提供一个AddressBook对象，它包含一个Person对象集合，每个Person对象描述了单个联系人的姓名和地址。此外还要能进行如下操作——向地址簿中添加联系人和从地址簿中删除联系人。这些操作都会更新地址簿状态，因此逻辑上它们应是AddressBook对象的一部分。我们的初始设计可以使用UML（Unified Modeling Language，统一建模语言）描述这个API结构，如图3-1所示。

图3-1　地址簿API的高层次UML抽象

下面向那些不熟悉UML的人解释下这个图的含义。图3-1显示了一个AddressBook对象，该对象包含一组Person对象和两个操作（AddPerson() 、DeletePerson()）。其中AddressBook和Person是一对多关系。 Person对象包含一组公有的属性，用于描述联系人的姓名和地址。稍后我会改进这个设计，但是目前暂且把它作为此问题域的逻辑抽象。

UML 类图

UML规范定义了一组面向对象软件系统的可视化建模符号（Booch et al., 2005）。如图3-1所示，本文会经常使用UML类图描述类的设计。在这些图表中，类用方框表示，该方框被分割成以下三个部分：

(1) 上层区域是类名；

(2) 中层区域列出类的属性；

(3) 下层区域列出类的方法。

方框中、下层区域中的每条记录都可以添加前缀符，以此标识对应属性或方法的访问级别（或可见性）。这些标识符有：

+表示公有的（public）类成员

-表示私有的（private）类成员

表示受保护的（protected）类成员

类之间的关系可以用多种样式的连接线和箭头表示。下面列出了UML类图中可能出现的常见关系。

关联：两个类之间简单的依赖关系，二者互不从属于对方。这种关系用实线表示。关联可以带有方向，UML用开放箭头表示方向，比如“>”。

聚合：“has-a”关系，或者整体与部分的关系，两个类互不从属于对方。用带有空心菱形的直线表示。

组合：“contains-a”关系，部分和整体具有统一的生存期。用带有实心菱形的直线表示。

泛化：类之间的父子关系，用带空心三角箭头的直线表示。

对象间的各种关系可以在某个对象边用注释定义，这样就能分辨出它们的关系是一对一、一对多还是多对多。一些常见的关系有：

0..1 表示零个或一个实例

1 表示只有一个实例

0..* 表示零个或多个实例

1..* 表示一个或多个实例

关键对象的建模

API同样需要对问题域的关键对象建模。该过程旨在描述特定问题域中对象的层次结构，因此经常被称作“面向对象设计”或者“对象建模”。对象建模的目的是确定主要对象的集合，这些对象提供的操作以及对象之间的关系。

再次声明，对于给定的问题域，正确的对象模型不止一个。对象建模的任务应该根据API的特定需求决定。只有根据需求制定相应的对象模型，才能以最佳的方式满足这些需求。例如，前面那个地址簿的示例，假定对API有以下需求。

(1) 每个联系人可能有多个地址。

(2) 每个联系人可能有多个电话号码。

(3) 电话号码可以验证并格式化。

(4) 地址簿可能包含多个同名的联系人。

(5) 已经存在的地址簿条目可以修改。

这些需求将会对该API的对象模型产生巨大影响。在图3-1的原始设计中，一个联系人只能有一个地址 。为了满足多个地址的需求，可以为Person对象添加新的属性（如HomeAddress1、WorkAddress1），但这个方法既不健壮也不优雅。另一种方法是引入一个新对象（如Address）表示地址，同时允许一个Person对象包含多个Address对象。

电话号码亦是如此。单独创建电话号码对象（如TelephoneNumber），同时允许Person对象拥有多个这样的对象。创建独立的TelephoneNumber对象的另一个理由是，我们需要支持像IsValid()这样的操作验证号码是否有效，支持像GetFormattedNumber()这样的操作返回格式化的电话号码。很自然，这些操作都针对电话号码而非针对联系人，所以电话号码应该设计成一个独立的对象。

“多个People对象可能拥有相同名字”的需求意味着，姓名并不能唯一确定Person对象的实例。因此，需要某种方法唯一确定一个Person实例，以保证能够定位并更新地址簿中已有的条目。满足此需求的一个简便做法是为每个联系人生成一个全局唯一标识（UUID）。综上所述，下面列出地址簿API的关键对象。

(1) 地址簿（Address Book）：包含零个或多个Person对象，包括AddPerson()、DeletePerson()以及UpdatePerson()操作。

(2) 联系人（Person）：完整地描述联系人的详细信息，包括零个或多个地址和电话号码。每个联系人用UUID唯一标识。

(3) 地址（Address）：描述地址，包括一个类型字段，例如Home或者Work。

(4) 电话号码（Telephone Number）：描述电话号码，包括一个类型字段，例如Home或者Cell。还要支持IsValid()和GetFormattedNumber()操作。

更新后的对象模型如图3-2所示。

图3-2　地址簿API关键对象的UML图

要特别注意的是，API的对象模型可能需要随时变化。由于产生的新需求或要添加新功能，之前切合需求的类和方法可能不再符合。因此，明智之举是根据新需求重新评估对象模型，以判断能否在重新设计中获益。例如，你预计可能会需要用到国际地址，或决定创建更加通用的Address对象以满足需求。然而，不要太过极端，不要尝试创建过于通用的对象模型。同时，确保理解最小完备性！

隐藏实现细节

创建API的主要原因是隐藏所有的实现细节，以免将来修改API对已有客户造成影响。因此，API最重要的特征就是要切实达到这一目标。也就是说，任何内部实现细节（那些很可能变更的部分）必须对该API的客户隐藏。David L. Parnas称此概念为信息隐藏（Parnas, 1972）。

主要有两种技巧可以达到此目标：物理隐藏和逻辑隐藏。物理隐藏表示只是不让用户获得私有源代码。逻辑隐藏则需要使用语言特性限制用户访问API的某些元素。

物理隐藏：声明与定义

在C和C++中，声明和定义是有特定含义的精确术语。声明只是告诉编译器一个名字以及它的类型，并不为其分配任何内存。与之相对，定义提供了类型结构体的细节，如果是变量则为其分配内存。

（C程序员所使用的术语“函数原型”与术语“函数声明”是等价的。）例如，以下都是声明：

extern int i;
class MyClass;
void MyFunc(int value);

而以下都是定义：

int i=0;
class MyClass
{
public:
float x,y,z;
};

void MyFunc(int value)
{
printf("In MyFunc(%d).", value);
}

提示　声明告诉编译器某个标识符的名称及类型。定义提供该标识符的完整细节，即它是一个函数体还是一块内存区域。

从类和方法的角度来说，以下代码定义了一个类，该类只声明了一个方法。

class MyClass
{
public:
void MyMethod();
};

在方法的定义中包含其实现（体）。

void MyClass::MyMethod()
{
printf("In MyMethod() of MyClass.\n");
}

一般来说，声明包含在.h文件中，相关的定义包含在.cpp文件中。当然，也可以在.h文件中声明方法的位置给出方法的定义。例如：

class MyClass
{
public:
void MyMethod()
{
printf("In MyMethod() of MyClass.\n");
}
};

该技巧隐式地要求编译器在任何调用MyMethod()的地方内联此成员函数。从API设计的角度来看，因为它不仅暴露了此方法的实现代码，而且将代码直接内联到客户程序中，所以这是很拙劣的做法。因此，应该尽量做到，在API的头部只提供声明。不过，为了支持模板和有意的内联，这个规则也会有例外。

提示　物理隐藏表示将内部细节（.cpp）与公有接口（.h）分离，存储在不同的文件中。

注意，如样例代码所示，有时我会内联函数的实现，但这么做纯粹是为了清晰和简洁，在实际的API开发中应该避免采用这种方式。

逻辑隐藏：封装

封装（面向对象中的一个概念）提供了限制访问对象成员的机制。在C++中，此机制通过对类和结构体（类和结构体在功能上是等价的，仅在默认访问级别上有所不同）使用以下访问控制关键字来实现。图3-3举例说明了这些访问级别。

图3-3　C++类的三种访问级别

- public（公有的）：能从类或结构体的外部访问这些成员。这是结构体的默认访问级别。

- protected（受保护的）：只能在该类或该类的派生类中访问这些成员。

- private（私有的）：只能在定义这些成员的类中访问它们。这是类的默认访问级别。

其他语言下的封装

C++为类成员提供了public、protected以及private访问控制，然而其他面向对象语言提供了不同粒度的级别。例如，在Smalltalk中，所有的实例变量都是私有的，而所有方法都是公有的。而Java语言则提供了public、private、protected以及包私有（package-private）访问级别。

Java中的包私有表示该成员只能被同一个包中的类访问，这是Java中的默认访问级别。若要让同一个JAR文件中的其他类访问该类的内部成员，而又不必将该类的内部成员暴露给客户，那么使用包私有是很好的做法。包私有在需要验证私有方法的单元测试中十分有用。

C++没有包私有的概念，而是使用更加宽泛的友元概念，以允许指定的类和方法访问某个类的受保护的或私有的成员。虽然友元可以用来加强封装，但是如果使用不当，它也会向用户过度暴露内部细节。

用户很可能不遵循公有API的约束。如果向用户提供内部成员的钩子（hook），并且用户可以通过这些钩子得到所需资源，那么他们就会利用这些钩子完成工作。这样做虽然看起来对用户有利，帮助他们快速找到问题的解决方案，但是这也使得将来修改这些实现细节变得更加困难，同时扼杀了

改进和优化产品的能力。

提示　封装是将API的公有接口与其底层实现分离的过程。

让我们看看一些用户滥用钩子的示例。多人第一人称射击游戏《反恐精英》（Counter-Strike）自2000年左右问世以来，经常成为外挂目标。最著名的外挂之一就是“wallhack”。从本质上来讲，它是一个修改过的OpenGL驱动，它将墙壁部分或完全地渲染为透明。这就使得用外挂的玩家占据了明显的优势，因为他们可以看到墙后的东西。虽然你现在可能不会编写游戏或将玩家作为你的目标用户，但是该准则说明用户会尽一切可能获得他们想要的东西。如果一些用户可以通过修改OpenGL图形驱动在游戏中获得优势，那么由此推断，也一定会有用户使用API暴露出来的内部细节实现老板交代的功能。

下面用一个更加直接的应用程序说明这一点。Roland Faber报告了在西门子公司内部出现的一些困难，原因是它们的一个团队决定使用另一个团队开发的API的内部细节（Feber, 2010）。

一个不在欧洲的团队需要为在德国开发用户接口的团队提供远程控制。因为自动控制接口还没有完成，于是他们擅自使用内部接口作为替代，而没有通知架构师。不一致的接口变更导致系统整合时遭遇了意想不到的问题，高昂的重构开销在所难免。

因此，接下来的部分会讨论如何使用编程语言的访问控制特性为API提供最大限度的信息隐藏。之后，我们也会提到一些C++语言特性影响封装的情况，如友元和外部链接。

提示　逻辑隐藏指的是使用C++语言中受保护的和私有的访问控制特性从而限制访问内部细节。

隐藏成员变量

术语“封装”也经常用于描述打包数据和操作这些数据的方法。在C++中，通过让类同时包含变量和方法来实现封装。然而，按照良好API的设计原则，不应该将成员变量设置为公有的。如果数据成员构成了API逻辑接口的一部分，那么应该使用getter或setter方法间接地访问成员变量。例如，应该避免将代码编写成：

class Vector3
{
public:
double x,y,z;
};

而应该编写成：

class Vector3
{
public:
double GetX() const;
double GetY() const;
double GetZ() const;
void SetX(double val);
void SetY(double val);
void SetZ(double val);
private:
double mX,mY,mZ;
};

后者的语法显然更加繁琐，对程序员而言需要敲入更多的字符，但是现在为此多费的这几分钟，将来会在修改接口时省下数小时甚至数天。使用getter/setter惯用法与直接暴露成员变量相比，还有以

下额外的好处。

- 有效性验证。可以检验值的有效性，以确保该类的内部状态始终有效且一致。例如，如果有个可以让客户更新某个RGB颜色的方法，那么就可以检测每次输入的红、绿、蓝值是否在有效范围内，如0~255或者0.0~1.0。

- 惰性求值。计算变量的值会带来巨大开销，应该仅在需要的时候才执行计算。通过使用getter方法访问内部数据的值，可以仅在需要这种高消耗的计算时再执行。

- 缓存。典型的优化技巧是：存储频繁请求计算的值，在将来请求时直接返回存储的值。例如，解析Linux上的/proc/meminfo文件可以得到机器的内存总量。与每次请求内存总量时都去执行读文件操作相比，效率更高的方法是将第一次读取的值缓存起来，并在以后需要时直接返回缓存的值。

- 额外的计算。如果需要，可以在客户试图访问某个变量时执行一些附加操作。例如，只要用户更改某个首选项设置的值，就将UserPreferences类的当前状态写到磁盘上的配置文件中。

- 通知。其他模块可能需要知道类中的值何时发生了变化。例如，如果你正在实现一个进度条的数据模型，那么用户界面代码就需要知道何时更新了进度值，以便它能及时更新GUI。所以，你可能要在setter方法中发布变更通知。

- 调试。可以增加调试或日志语句，从而追踪变量何时被客户访问或改变；也可以增加断言（assert）语句来强制假设成立。

- 同步。在发布API的第一个版本后，可能发现它不是线程安全的。标准的解决方法是在任何访问值的地方增加互斥锁，而这样做的前提是要访问的数值都已包装在getter/setter方法中。

- 更精细的访问控制。如果将成员变量设置为公有的，那么客户就可以随意读写该值。但在使用了getter/setter方法后，就可以提供更精细的读写控制级别。例如，不提供setter方法就可以使该值变为只读。

- 维护不变式关系。一些内部数值可能彼此依赖。例如在汽车动画系统中，需要根据汽车在关键帧之间运动所花费的时间，来计算它的速度和加速度。速度可以通过一段时间内的位置变化来计算，加速度则可以通过一段时间内速度的变化来计算。然而，如果用户可以访问该计算的内部数值，他们可能会改变加速度的值，导致汽车的加速度和速度不再相关，进而导致意想不到的结果。

如果成员变量实际上不是逻辑接口的一部分，而表示与公有接口无关的内部细节，则应该将它们从接口中移除。例如，考虑下面这个整数栈定义：

Class IntegerStack
{
public:
static const int MAX_SIZE=100;
void Push(int val);
int Pop();
bool IsEmpty()const;
int mStack[MAX_SIZE];
int mCurSize;
};

很明显，这是一个糟糕的API，因为它暴露出该栈是通过长整型数组（拙劣地）实现的，变量mCurSize暴露了栈内部的状态。如果以后需要改进该类的实现，例如使用std::vector或者std::list替代静态分配的定长数组，就会发现很难下手。因为接口暴露了mStack和mCurSize变量，客户代码可能已经习惯直接访问这些变量，改变原有的实现就会导致客户的代码无法正常工作。

规范的做法是从一开始就隐藏这些成员变量，使客户代码无法访问它们。

Class IntegerStack
{
public:
void Push(int val);
int Pop();
bool IsEmpty()const;
private:
static const int MAX_SIZE=100;
int mStack[MAX_SIZE];
int mCurSize;
};

我已经解释清楚永远不要将成员变量声明为公有的原因了，那么是否可以将它们声明为受保护的呢？如果变量是受保护的，那么所有客户都可以通过继承类的方式直接访问变量，这样就和使用公有变量的情况相同了。所以不要将成员变量声明为受保护的。正如Alan Snyder所说，在面向对象编程语

言中，继承严重损害了封装所带来的好处（Snyder,1986）。

提示　类的数据成员应该始终声明为私有，而不是公有的或受保护的。

暴露成员变量的唯一貌似合理原因就是出于对性能的考虑。执行C++函数调用是有开销的，即需要将方法的参数和返回地址压到调用栈中，同时还要为方法中的局部变量预留存储空间。然后，函数执行完毕时，调用栈再次被回退到起始状态。在注重性能的代码区域中执行这些操作的开销是非常显著的，例如在紧凑循环中操作大量对象。直接访问公有成员变量的代码比通过getter/setter方法访问成员变量的代码快2~3倍。

但是即便在上述情况下，也不应该暴露成员变量。首先，方法调用的开销相对于全部API调用来说，很可能是微不足道的。即使你正在编写注重性能的API，谨慎地使用内联，结合现代编译器优化实现，通常会完全消除方法调用的开销，使你获得与暴露成员变量等同的性能。如果你仍然有所顾虑，试着测试一下内联的getter/setter方法和使用公有成员变量的时间开销。本。访问http://APIBook.com/下载代码，然后亲自试验一下。

隐藏实现方法

除了隐藏所有成员变量外，同样也应该隐藏不需要公开的方法。信息隐藏的原则是：将类的固定接口与其内部设计实现相分离。对几个大型程序的早期调研发现，那些使用信息隐藏技巧的程序的修改难度是没有使用该技巧程序的1/4（Korson and Vaishnavi, 1986）。虽然具体的修改难度各不相同，但可以明显地看出，隐藏API的内部细节会使软件更易于维护和升级。

记住关键的一点：类只应该定义做什么而不是如何做。考虑下面这个从远程HTTP服务器下载文件的类。

#include<string>
#include<cstddef>
#include<sys/socket.h>
#include<unistd.h>

class URLDownloader
{
public:
UPLDownloader();
bool DownloadToFile(const std::string &url,
const std::string &localFile);
bool SocketConnect(const std::string &host,int port);
void SocketDisconnect();
bool IsSocketConnected() const;
int GetSocket() const;
bool SocketWrite(const char *buffer,size_t bytes);
size_t SocketRead(char *buffer,size_t bytes);
bool WriteBufferToFile(char *buffer,
const std::string &filename);
private:
int mSocketID;
struct sockaddr_in mServAddr;
bool mIsConnected;
};

将所有的成员变量都声明为私有并没有错，这是一个好的开始。但一些涉及具体实现的方法却被暴露了，比如打开套接字并从中读取数据的方法，以及将内存中缓存的结果写回磁盘的方法。客户其实并不需要了解这些具体信息。他们想要的仅仅是指定一个URL，然后在磁盘上创建一个文件，此文件包含URL指定的远程位置上的内容。

此处的GetSocket()方法相当糟糕。该公有方法返回对私有成员变量的访问结果。只要调用该方法，客户就可以获得隐含的套接字句柄，并且可以在不了解URLDownloader类的情况下直接操作此套接字。更让人不安的是，GetSocket()方法竟然被声明为const，表示该方法不能修改类的状态。虽

然严格上说的确是这样，但是客户仍然可以通过该方法返回的整型套接字句柄修改类的状态。如果返回指向某个私有成员变量的non-const指针或引用，那也会发生同样的内部状态泄露（Meyers, 2005）。这么做使得客户能够得到内部数据成员的句柄，进而就能绕过API直接使用此句柄改变对象的状态。

提示　永远不要返回私有数据成员的非const指针或引用。这会破坏封装性。

对于URLDownloader类而言，另一种设计方法是将除了构造函数以及DownloadToFile()方法之外的所有方法都声明为私有，除此之外都是实现细节。这样做之后，就可以自由地改变类的实现，而不影响任何使用该类的客户。

但这种情况仍然有瑕疵。它只是从编译器的角度隐藏了实现细节，但是客户仍然可以查看头文件，获得类的所有内部细节。事实上，这几乎不能避免，因为必须向客户分发头文件，以便他们能够编译并使用你的API的代码。另外，即便这些类的私有成员独立于公有接口，也必须使用#include包含这

些成员所需要的所有头文件。例如，URLDownloader的头部需要使用#include包含所有特定平台的下的套接头文件。

很遗憾，由于C++语言的限制，所有公有的、受保护的和私有的类成员都必须出现在类的声明中。理想情况下，类的头部应该如下所示：

#include<string>

class URLDownloader
{
public:
URLDownloader();
bool DownloadToFile(const std::string &url,
const std::string &localFile);
};

随后可以在其他地方声明所有的私有成员，例如在.cpp文件中。但对于C++而言，这是不可能做到的。（因为如果这样做就需要在编译时知道所有对象的大小。）尽管如此，仍然有一些方法可以让私有成员在头文件中不可见（Headington, 1995）。一种常用的技巧称为Pimpl惯用法，它将所有的私有数据成员隔离到一个.cpp文件中独立实现的类或结构体内。之后，.h文件仅需要包含指向该类实例的不透明指针（opaque pointer）即可。

强烈建议在API中采用Pimpl惯用法，这样就可以将所有实现细节完全和公有头文件分开。如果你不想这么做，至少也要将头文件内不需要的私有方法移到.cpp文件中，并将它们转换为静态函数（Lakos, 1996）。但只有当私有方法仅访问类的公有成员或者根本不访问任何类成员时才能这么做，（例如接收文件名字符串，然后返回该文件名的扩展名的程序）。很多工程师认为如果类使用了私有方法，那么就必须将其包含在类的声明当中，但这么就暴露了多余的实现细节。

提示　将私有功能声明为.cpp文件中的静态函数，而不要将其作为私有方法暴露在公开的头文件中。（更好的做法是使用Pimpl惯用法。）

隐藏实现类

除了隐藏类的内部方法和变量之外，还应该尽力隐藏那些纯粹是实现细节的类。大多数程序员都习惯隐藏方法和变量，但是其中很多人忽视了一个事实——并非所有的类都是公开。实际上，一些类仅用于实现，因此应该将其从API的公有接口中移除。

例如，考虑一个简单的Fireworks类：该接口可以指定烟火动画在屏幕上的位置，同时可以控制火焰粒子的颜色、速度和数量。显然，该API需要记录制造烟火效果的每个粒子，以确保它能更新每个粒子在每一帧中的位置。这意味着需要引入一个包含单个火焰粒子状态的FireParticle类。API客户永远不需要访问FireParticle类，因为它纯粹是API的实现。因此，应该将其包含在Fireworks类的私有部分中，并声明为私有类。

#include<vector>

class Fireworks
{
public:
Fireworks();
void SetOrigin(double X. double y):
void SetColor(float r, float g, float b);
void SetGravity(float g);
void SetSpeed(float s);
void SetNumberOfParticles(int num);

void Start();
void Stop();
void NextFrame(float dt);
private:
class FireParticle
{
public:
double mX,mY;
double mVelocityX,mvelocityY;
double mAccelerationX, mAccelerationY;
double mLifeTime;
};

double mOriginX, mOriginY;
float mRed, mGreen, mBlue;
float mGravity;
float mSpeed;
bool mIsActive;
std::vector<FireParticle*>mParticles;
};

注意，这里并没有为FireParticle类使用getter/setter方法。如果需要，可以这么做；但从严格上意义上讲，这并不是必要的，因为不能从公共接口中访问该类。在类似情况下，一些工程师会想使用结构体代替类，以表示该结构是POD（Plain Old Data）类型。

此外，也可尝试将FireParticle类的内容隐藏起来而不是出现在头文件中，这样即便检查头文件也不会发现它。

最小完备性

优秀的的API设计应该是最小完备的。即它应该尽量简洁，但不要过分简洁。

显然，API应该是完备的，即它应该提供客户需要的所有功能，但这些功能可能不那么显而易见。要解决此问题，应当提前收集需求并使用用例建模，以获知客户期望的API行为。之后你就可以宣称，API已经实现了这些期望的功能。

最小化API存在一个不太明显的矛盾。但是，最小完备是可以计划的最重要的特征之一，它对API的长期维护以及升级会产生重大影响。确切地说，今天作出的决定会限制将来所能做的事情。它还会影响API的易用性，因为紧凑的接口更符合用户的思维习惯（Blanchette, 2008）。所以，接下来的部分将探讨保证API最小化的各种技巧，以及为什么需要最小化。

提示　谨记奥卡姆（Occam）剃刀原理：若无必要，勿增实体。

不要过度承诺

API中每个公有元素都是一项承诺，它承诺了该功能在API的生命周期中都将得到支持。虽然你可以背弃某项承诺，但是此举会令客户受挫并迫使其重写代码。更糟糕的是，由于你无法保证API的稳定迫使用户不停地修正代码，或者由于你移除了支持其独特用例的功能导致他们无法使用API，用户很可能会弃用你的API。

关键的一点是，在发布了某个API并且已经有客户开始使用之后，增加功新能很容易，而移除功能就非常困难。一个很好的建议是：当不确定是否需要某个接口时，就不要提供此接口（Bloch, 2008; Tulach, 2008）。

该建议违背了API设计人员的良好意愿。因为工程师容易被解决方案的通用性和灵活性诱惑，所以可能情不自禁地为API增加抽象层次或通用性，盼望着它们将来可能被用到。工程师应该抵制这种诱惑，原因如下所述。

(1) 你想要添加的通用性可能永远不会用到。

(2) 如果某天用到了想要添加的通用性，那时你可能已经掌握了更多API设计知识，并可能有了与最初设想方案不同的解决方案。

(3) 如果你确实需要添加新功能，那么简单的API比复杂的API更容易添加新功能。

因此，应该保证API尽量简单：类及类中的公有成员暴露得越少越好。这样做的好处是API会更易于理解，更易于用户记住API模型，而且更易于调试。

提示　疑惑之时，果断弃之！精简API中公有的类和函数。

谨慎添加虚函数

继承（将某个成员函数设置为虚函数）暴露出的功能可能会超出预期，而且这种方式并不易察觉。客户可以通过继承API中的类重新实现任意虚方法。虽然继承十分强大，但仍要意识到其潜在的隐患。

- 对基类看似无害的修改可能会给客户带来不利的影响。如果不了解客户基于虚API构建代码的方式就孤立地升级基类，则会导致这种情况。这就是常说的“脆弱基类问题”（fragile base class problem， Blanchette, 2008）。

- 客户可能会以你根本无法预料的方式使用API。这将导致API执行你无法控制的代码进而可能导致非预期行为。一个极端的例子是，你无法阻止客户在某个重写的方法中调用delete this，而且这么做甚至可能是一个合理需求，而如果你设计的API实现不允许此操作，那么代码很可能会崩溃。

- 客户可能采用不正确的或易于出错的方式扩展API。例如，你有一个线程安全的API，但是依照你的设计，客户可以重写某个虚函数，如果客户的实现没有执行恰当的互斥加锁操作，就会存在难于调试的竞态条件隐患。

- 重写函数可能破坏类的内部完整性。例如，某个虚方法的默认实现可能调用该类的其他方法更新其内部状态。如果重写方法没有进行这些相同的调用，那么该对象就会处于不一致的状态，从而产生无法预料的行为或导致程序崩溃。

除了这些API层面的行为问题，当你在C++中使用虚函数时，还要注意以下常见问题。

- 虚函数的调用必须在运行时查询虚函数表决定，而非虚函数的调用在编译时就能确定。这就使得虚函数的调用比非虚函数的调用慢。实际上，这部分开销可以忽略，特别是当函数处理的工作微不足道或当其调用不频繁时。

- 使用虚函数一般需要维护指向虚函数表的指针，进而增加了对象的大小。创建需要很多实例的小对象时，这可能会成为一个问题。尽管如此，与各种成员变量实际所占用的内存空间相比，这可能是微不足道的。

- 添加、重排或移除虚函数会破坏二进制兼容性。因为虚函数调用通常用类的虚函数表的整型偏移量表示，所以改变虚函数的顺序，或引起其他虚函数的顺序发生变化的操作，都需要重新编译现有代码，以确保客户仍然调用正确的方法。

- 不是所有的虚函数都能内联，因而将虚函数声明为内联是没有任何意义的。因为虚函数是运行时确定的，而内联是在编译时进行优化。但是，在有一定约束的情况下，编译器可以内联虚函数。即便如此，同内联非虚函数的情况相比，内联虚函数的例子少之又少。（谨记C++中的inline关键字仅仅是给编译器的一个提示。）

- 重载虚函数是需要技巧的。在派生类中声明的符号会隐藏基类中所有的同名符号。因此，基类中一组重载的虚函数会被子类中一个覆盖函数所隐藏。虽然存在一些解决此问题的方法（Dewhurst, 2002），但更好的方式是避免重载虚函数。

最后，仅在明确需要使用覆盖时才应该允许覆盖。没有虚函数的类比有虚函数的类更健壮且更易于维护。一般来说，如果API没有在其内部调用某个特定的方法，那么此方法很可能不应声明为虚方法。当潜在的子类与基类之间形成一种“is-a”的关系时，继承才是有意义的，这种情况下应该允许继承。

实际上，Herb Sutter指出，应该将虚函数声明为私有的，并且仅在派生类需要调用虚函数的基类实现时才将其声明为受保护的派生类（Sutter, 2001）。因此，Sutter建议接口应当是非虚的，同时在适当的情况下使用模板方法（Template Method）设计模式。这通常称为非虚拟接口惯用法（Non-Virtual Interface idiom， NVI）。

如果你仍然决定支持继承，那么确保你设计的API能够安全地使用继承。谨记以下几点原则。

(1) 如果类包含任一虚函数，那么必须将析构函数声明为虚函数。这样子类就可以释放其可能申请的额外资源。

(2) 一定要编写文档，说明类的方法是如何相互调用的。如果客户想要为某个虚函数提供替代实现，那么他们需要知道，在虚函数实现中需要另外调用哪些方法以维持对象的内部完整性。

(3) 绝不在构造函数或析构函数中调用虚函数，这些调用不会指向子类（Meyers, 2005） 。这是一条必须知道的优秀准则，尽管调用虚函数并不影响API的外部接口。

提示　避免将函数声明为可以重写的函数（虚的），除非你有合理且迫切的需求。

便捷API

简化API是一项困难的任务。在减少API函数数目与使API易于各种客户使用之间存在天然的矛盾。大多数API设计者面临这样的问题，是保持API纯粹和集中，还是允许便捷包装（convenience wrappers）。（术语便捷包装指封装了多个API调用的实用程序，它能提供更简单的高层操作。）

一方面，有人认为API应该仅提供一个方法，仅执行一项任务。这确保了API是最小化的、集中的、一致的且易于理解的，还减少了实现的复杂性，并具备更稳定、更易于调试和维护等优点。Grady Booch称其为原语性（primitiveness），即原语方法需要访问类的内部细节以便高效地实现，而非原语方法可以完全基于原语方法进行构建且不需要访问任何内部状态（Booch et al., 2007）。

另一方面，也有人认为API应该让简单的事情更简单。不应该要求客户编写大量代码完成基础任务。因为这么做会导致样板代码被大量复制粘贴到源码的其他部分。一旦代码块被复制，就会产生潜在的代码不一致等问题。不过，你也许想将API为多种客户服务，这些客户中有人需要能够灵活地控

制，有人则需要尽量简单地完成一项任务。

这两个目标都是合理的。所幸它们并不互相排斥。有几种方法可以为核心API的功能提供更高层次的便捷包装，而不淡化其主要目标。重要的一点是，不要将便捷API与核心API混在同一个类中。也就是说，应该创建补充类包装核心API的某些公有功能。便捷类应该与核心API完全隔离，比如将它们

放在不同的源文件中甚至是在完全独立的库中。这样做还有一个额外的好处，就是确保了便捷API只依赖于核心API的公有接口，而不依赖于任何内部的方法或类。让我们看一个真实的例子。

OpenGL API提供了一个方法，可以编写平台无关的2D和3D应用程序，此方法操作的对象都是简单的图元（如点、线和多边形），图元可以被变换、点亮以及光栅化到帧缓冲区中。OpenGL API十分强大，但它的定位是一个底层API。例如，在OpenGL中创建一个球体需要将其显式地建模为由多个小

的平面多边形构成的表面，如下面的代码片段所示：

for(int i=0; i<=stacks; ++i)
{
GLfloat stack0=((i-1.0)/stacks-0.5)*M_PI;
GLfloat stack1=((GLfloat)i/stacks-0.5)*M_PI;
GLfloat z0=sin(stack0);
GLfloat z1=sin(stack1);
GLfloat r0=cos(stack0);
GLfloat r1=cos(stack1);

glBegin(GL_QUAD_STRIP);
for(int j=0; j<=slices; ++j)
{
GLfloat slice=(j-1.0)*2*M_PI/slices;
GLfloat x=cos(slice);
GLfloat y=sin(slice);
glNormal3f(x*r0, y*r0, z0);
glVertex3f(x*r0, y*r0, z0);
glNormal3f(x*r1, y*r1, z1);
glVertex3f(x*r1, y*r1, z1);
}
glEnd();
}

然而，多数OpenGL的实现也包含了OpenGL实用库（OpenGL Utility Library，GLU）。GLU是一个基于OpenGL API构建的API，它提供了更高层的函数，如mip-map生成、坐标变换、二次曲面、多边形镶嵌（polygon tessellation）以及简单摄像定位（simple camera positioning）。这些函数都被定义在一个与OpenGL完全独立的库中，同时为了与核OpenGL API区分，所有的函数名均以glu作为前缀。例如，如下代码片段展示了使用GLU创建一个球体有多么简单。

GLUquadric *qobj=gluNewQuadric();
gluSphere(qobj, radius, slices, stacks);
gluDeleteQuadric(qobj);

这个例子演示了如何在保持最小化设计和集中核心API的同时，仍然提供额外的便捷方法，使API更易于使用。事实上，基于OpenGL构建的其他API提供了更加实用的类，如Mark Kilgard的OpenGL Utility Toolkit（GLUT）。该API提供创建各种实心的和空心的几何图元（包括Utah立体茶壶）以及简单的窗口管理函数和事件处理。图3-4显示了GL、GLU和GLUT之间的关系。

图3-4　核心API（OpenGL）与基于它的便捷API（GLU 和 GLUT）分离的例子

Ken Arnold将这一概念称为渐进公开（progressive disclosure），它表示API应该通过易用接口来呈现基本功能，同时将高级功能隐藏在另一个独立的层次中（Arnold, 2005）。他指出这一概念经常出现在GUI设计中，即用一个“高级”（Advanced）或“专家”（Expert）按钮来隐藏复杂功能。这样，可以既提供强大API，又能确保专家用例不会搅乱基本的工作流程。

提示　基于最小化的核心API，以独立的模块或库的形式构建便捷API。

易用性

优秀的API设计应该使简单的任务更简单，使人一目了然。例如，好的API可以让客户仅通过方法签名就能知晓使用方法，而不需要另写文档。这种API特征与最小化特征非常相似：如果API是简单的，那么它也应该易于理解，而且它也应该遵循最小惊奇（least surprise）原则。可以采用现有的模型和模式实现最小惊奇，这样用户就能集中精力处理手头的任务，而不会因为接口的新奇或繁杂分散精力（Raymond, 2003）。

当然，不能因此就忽视优秀支持文档的必要性。事实上，这应该让编写文档的工作变得更容易。众所周知，优秀的示例对文档大有帮助。提供样例代码能够增加API的易用性。优秀的开发人员应该能够在阅读API的样例代码后，就能明白怎样将其运用到自己的任务当中。

接下来，我们讨论使API更易于理解的方法技巧。不过，在此之前需要注意的是，API也可能为专家用户提供一些不那么易用的复杂功能。但是，必须首先保证这个API能使简单的任务更简单，然后再提供复杂功能。

可发现性

可发现的（discoverable）API要求用户能够通过API自身明白如何使用它们，而不需参阅任何解释或文档。下面举一个UI设计领域中的反例阐明这一点。Windows XP中的“开始”按钮没有提供一个可发现的接口，以帮助用户找到“关闭计算机”选项；只有点击“关闭计算机”按钮才能看到“重启”选项，这种方式也很不直观。

可发现性并不一定能带来易用性。例如，就某个API而言，有可能初次使用的用户很容易上手，而经常使用它的专家用户可能就会觉得十分繁琐，但一般而言，可发现性有助于产生更加好用的接口。

在设计API时有很多方法可以提升可发现性。一种直观的方法是构思一个直观的、合乎逻辑的对象模型，例如为类和方法选择恰当的名字。事实上，给出清晰的、描述性强的且恰当的名字是API设计中最困难的任务之一。避免缩略语也是影响可发现性的一个因素（Blanchette, 2008），它的好处是用户无需记住API方法名到底是GetCurrentValue()、GetCurrValue()、GetCurValue()，还是GetCurVal()。

不易误用

优秀的API不仅要易于使用，而且还要不易误用，Scott Meyers认为这是最重要的通用接口设计准则（Meyers, 2004） 。最常见的误用API的方式是向方法传递错误的参数或非法值。当方法拥有多个相同类型的参数，但由于用户忘记了正确的参数顺序，或者使用int而非更具约束性的enum类型表示小范围的值时，误用情况很可能发生（Bloch, 2008）。例如，考虑如下方法的签名:

std::string FindString(const std::string &text,
bool search_forward,
bool case_sensitive);

用户很容易忘记第一个布尔型参数是搜索方向还是区分大小写的标记。使用错误的顺序传递标记将导致不法预料的行为，在用户意识到自己颠倒了布尔型参数之前，他们很可能会把时间浪费在调试程序上。你可以重新设计这个方法，为每个选项引入新的enum类型，以便让编译器捕获此类错误。例如：

enum SearchDirection{
FORWARD,
BACKWARD
};
enum CaseSensitive{
CASE_SENSITIVE,
CASE_INSENSITIVE
};
std::string FindString(const std::string &text,
SearchDirection direction,
CaseSensitivity case_sensitivity);

这么做之后，如果用户混淆两个标记的顺序，则会报出编译错误。这不仅意味着用户不能混淆两个标记的顺序，还意味着用户编写的代码的自描述性会更好。比较result=FindString(text, true, false);和result=FindString(text, FORWARD, CASE_INSENSITIVE);

提示　使用枚举类型代替布尔类型，提高代码的可读性。

对于enum不能解决的复杂情况，为了确保每个参数都有唯一的类型，甚至可以引入新类。例如，Scott Meyers用一个Date类阐述了此方法（Meyers, 2004, 2005）。这个Date类指定了三个整数：

class Date
{
public:
Date(int year, int month, int day);
...
};

Meyers注意到，按照上面的设计客户可能会按错误的顺序传递年、月、日的值，还可能设置非法值，比如把month的值设为13。为了避免这些问题，他建议引入特定的类来表示年、月、日的值，如:

class Year
{
public:
explicit Year(int y):mYear(y){}
int GetYear() const {return mYear;}

private:
int mYear;
};
class Month
{
public：
explicit Month(int m):mMonth(m){}
int GetMonth() const {return mMonth;}
static Month Jan() {return Month(1);}
static Month Feb() {return Month(2);}
static Month Mar() {return Month(3);}
static Month Apr() {return Month(4);}
static Month May() {return Month(5);}
static Month Jun() {return Month(6);}
static Month Jul() {return Month(7);}
static Month Aug() {return Month(8);}
static Month Sep() {return Month(9);}
static Month Oct() {return Month(10);}
static Month Nov() {return Month(11);}
static Month Dec() {return Month(12);}

private:
int mMonth;
};
class Day
{
public:
explicit Day(int d):mDay(d){}
int GetDay() const {return mDay;}
private:
int mDay;
};

现在，Date类的构造函数可以使用新增的类Year、Month以及Day作为参数。

calss Date
{
public:
Date(const Year &y, const Month &m, const Day &d);
...
};

采用此设计，客户就能使用如下没有歧义且易于理解的语法创建新的Date对象，而且使用不同的顺序指定参数值会导致编译错误。

Dare birthday(Year(1976), Month::Jul(), Day(7));

提示　避免编写拥有多个相同类型参数的函数。

一致性

优秀的API应该采用一致的设计方法，以便于用户记住其风格，进而更容易被用户采用（Blanchette, 2008）。这一点适用于API设计的各个方面，比如命名约定、参数顺序、标准设计模式的使用、内存模型语义、异常的使用和错误处理等。

考虑其中的第一个方面，一致的命名约定表示整个API都使用相同的词语表述相同的概念。例如，如果你已经决定使用动词词组Begin和End，那么就不应该再夹杂着Start和Finish这样的词语。例如，Qt3 API中的几个方法名时而使用缩略语，时而又不使用（比如像prevValue()和preciousSib- ling()）。这个例子再一次说明了无论如何都要避免使用缩略语。

使用一致的方法签名也是重要的设计准备。如果有几个方法接受相似的参数列表，那么应该尽力保证这些参数的数目和顺序相一致。下面给出一个反例，考虑标准C库中的如下方法：

void bcopy(const void *s1, void *s2, size_t n);
char *strncpy(char *restrict s1, const char *restrict s2, size_t n);

这两个方法都涉及将n个字节的数据从一块内存区域复制到另一块中。函数bcopy()将数据从s1复制到s2，而strncpy()是从s2复制到s1。如果开发人员没有仔细阅读这两个方法的手册就调换其用法，将会引发内存错误。的确，函数签名中有一条线索指明了接口规范的冲突：注意在两种情况中const指针的用法。但它很容被忽视，而且如果不将源指针声明为const，编译器将无法捕获错误。

你可能还注意到，词语“copy”和“cpy”的使用也是不一致的。

再举一个标准C库中的例子。常用的malloc()函数用来分配一片连续的内存块，而calloc()函数也执行相同的操作，并附带将申请的内存初始化为零值。尽管它们的目标相似，方法签名却不同。

void *calloc(size_t count, size_t size);
void *malloc(size_t size);

malloc()函数分配size字节的空间，而calloc()分配count × size字节的空间。除了不一致，它还违背了最小惊奇原则。另一个例子是，标准C函数read()和write()将文件描述符作为其第一个参数，而fgets()和fputs()函数把文件描述符作为最后一个参数（Henning, 2009）。

提示　使用一致的函数命名和参数顺序。

虽然这些例子都集中在函数或方法层面，但是显然在类的层面上一致性也十分重要。拥有相似角色的类应该提供相似的接口。在这方面STL是一个很好的示例。std::vector、std::set，std::map甚至std::string类都提供了size()方法，用以返回容器中元素的数目。它们还都支持迭代器，一旦你知道了如何遍历std::set，也就等于知道了如何迭代std::map。这样做可以很容易地记住API的编程模式。

通过多态，可以毫不费力地达到一致性目标：将共享的功能放到一个公共的基类中。但是，让所有的类都继承该公共基类并没有意义，而且不应该纯粹为了达到一致性而引入基类，因为它增加了接口的复杂性以及类的数目。事实上，STL容器显然不是继承自一个公共的基类，而是人为地指定类中的公共概念并在每个类中使用相同的习惯来表述这些概念，设计以达到一致性。这通常被称为静态多态。也可以使用C++模板帮助定义和应用这种一致性。例如，可以为一个2D坐标类创建模板，然后分别用整型、单精度浮点型和双精度浮点型特化它。通过此方法，可以确保每种类型的坐标都提供完全相同的接口。如下的代码是一个简单的示例：

template<typename T>
class Coord2D
{
public:
Coord2D(Tx,Ty):mX(x), mY(y){};

T GetX() const{return mX;}
T GetY() const{return mY;}

void SetX(Tx){mX=x;}
void SetY(Ty){mY=y;}

void Add(Tdx,Tdy){mX+=dx; mY+=dy;}
void Multiply(Tdx, Tdy){mX *=dx; mY *=dy;}

private:
TmX;
TmY;
};

使用此模板定义，可以构建Coord2D、Coord2D和Coord2D类型的变量，并且所有的这些变量都拥有完全相同的接口。

一致性的另一面是使用习惯的模式和标准的平台习语。比如你买了一辆新汽车，但你并不需要重新学习如何驾驶。刹车、油门踏板、方向盘以及离合器（手动的或自动的）的使用方式是全世界通用的。如果你能驾驶一辆汽车，那么驾驶另一辆相似的汽车也应该不是问题，即便两辆汽车的制作和模型不同，或者方向盘不在同一侧。

最容易使用的API是简化用户学习过程的API。例如，大部分C++开发人员都熟悉STL以及容器类和迭代器的用法。因此，如果你决定编写具有相似目标的API，那么模仿STL的模式是十分有益的，因为开发人员将会发现他们很熟悉你的API的用法，进而更容易采用你的API。

正交

在数学中，如果两个向量互相垂直（成90°），那么就称它们是正交的，也就是说，它们的内积为零。该特性使得两个向量线性独立，即不存在一组标量能够作用于第一个向量而产生第二个向量。地理上的类比就是东和北这两个相互垂直的方向，即向东边走多远都不能走到北边。使用略带计算机特色的术语表述即是，东向坐标的变化并不影响北向坐标。

在API设计中，正交性意味着方法没有副作用。调用设置特定属性的方法应该仅改变那个属性，而不能额外改变其他可以公共访问的属性。这样一来，变更API某部分的实现将不会影响到API的其他部分（Raymond, 2003）。尽力保证API的正交性有助于预测和理解API的行为。此外，不产生副作用的代码，或者不依赖其他代码的副作用的代码，更加易于开发、测试、调试及修改，因为代码的影响范围是局部的且有边界的（Hunt and Thomas, 1999）。

提示　正交的API意味着函数没有副作用。

让我们看一个具体的例子。也许你曾经在一家汽车旅馆住宿，那里的淋浴控制器并不直观。想分别设置水压和温度，但是仅有一个控制器，它似乎通过一种复杂且隐晦的方式同时影响这两种属性。可以使用如下API建模：

class CheapMotelShower
{
public:
float GetTemperature() const;//单位华氏温度
float GetPower() const;//单位百分比0~100
void SetPower(float p);

private:
float mTemperature;
float mPower;
};

为了进一步阐述这一点，考虑该类公有方法的实现：

float CheapMotelShower::GetTemperature() const
{
return mTemperature;
}

float CheapMotelShower::GetPower() const
{
return mPower;
}

void CheapMotelShower::SetPower(float p)
{
if (p<0) p=0;
if (p>100) p=100;
mPower=p;
mTemperature=42.0f+sin(p/38.0f) *45.0f;
}

从本例可以看出，对水压的设置同时也会通过一种非线性关系影响水温。因此，不可能实现温度和水压的任意组合，自然也无法实现最高温度和最大水压的组合。而且，如果你打算改变SetPower()方法的实现，它会对GetTemperature()方法的结果产生副作用。在更加复杂的系统中，这种相互依赖性可能会被程序员遗忘，或者被忽视，进而对代码区域的简单地改动就可能对系统其他部分的行为产生的影响。

另一种方式是设计一个理想的、正交的淋浴器接口，其温度和水压的控制是相互独立的。

class IdealShower
{
public:
float GetTemperature() const;//单位华氏温度
float GetPower() const;//单位百分比0~100
void SetTemperature(float t);
void SetPower(float p);

private:
float mTemperature;
float mPower;
};

float IdealShower::GetTemperature() const
{
return mTemperature;
}

void IdealShower::SetTemperature(float t)
{
if (t<42) t=42;
if (t>85) t=85;
mTemperature=t;
}

void IdealShower::SetPower(float p)
{
if (p<0) p=0;
if (p>100) p=100;
mPower=p;
}

设计正交API时需要铭记如下两个重要因素。

(1) 减少冗余。确保只用一种方式表示相同的信息。每条信息应该只有唯一的权威来源。

(2) 增加独立性。确保暴露的概念没有重叠。任何重叠的概念都应该分解到它们的基础组件中。

另一个对正交设计的通俗解释是，不同的操作全部适用于每个可用的数据类型。该定义通常用于编程语言和CPU设计领域。在CPU设计领域中，正交指令集中的指令可以通过任何寻址模式使用任意CPU寄存器；而在非正交设计中，特定的指令只能使用特定的寄存器。就API设计而言，STL提供了满足此特性的良好示例。STL提供的一组泛型算法和迭代器适用于所有的容器。例如，STL中的std::count算法适用于任意std::vector、std::set或std::map容器。因此，算法的选择并不依赖所使用的容器类。

健壮的资源分配

内存管理是C++编程中最富技巧性的方面之一。特别是对于那些习惯使用Java或C#语言的开发人员，内存管理更加重要。在这两种语言中，对象会被垃圾回收器自动释放。相对而言，大部分的C++错误都是由于误用指针或引用所导致的。

- 对NULL解引用：尝试对NULL指针使用->或*操作。

- 二次释放：为一块内存调用两次delete或free()。

- 访问非法内存区域：对尚未分配或已经被释放的指针使用->或*操作。

- 混用内存分配器：用delete释放由malloc()分配的内存，或用free()释放new分配的内存。

- 数组释放不正确：使用delete操作符而非delete[]释放数组。

- 内存泄露：内存使用后没有释放。

产生这些问题的原因是无法辨别C++指针是否引用合法的内存，或指针否指向尚未分配的或已经释放的内存。因此要依赖程序员追踪指针状态并确保永远不会错误地解引用指针。然而，众所周知，程序员难免会犯错误。但是，这些特定问题中多数可以通过使用托管指针（或者说智能指针）避免。

(1) 共享指针。共享指针是引用计数指针，即当一段代码要保留该指针时，引用计数加一；当这段代码使用完该指针时，引用计数减一。如果引用计数达到零，则该指针指向的对象自动释放。这种指针通过令指针在使用期间有效来避免访问已经释放的内存。

(2) 弱指针。弱指针包含一个指向对象的指针，通常是共享指针，但是并不增加其指向的对象的引用计数。如果一个共享指针和一个弱指针引用了相同对象，那么在共享指针被销毁时，弱指针的值会立即变为NULL。通过此方式，弱指针可以检测其所指向的对象是否已经过期：即其指向对象的引用计数是否为零。这样就避免了悬挂指针（指向已经释放的内存）问题。

(3) 作用域指针。作用域指针仅属于单一对象，并且当作用域指针超出其作用域时自动释放所指向对象。有时也称其为自动指针。作用域指针定义为单一对象的所有者，因此无法复制。

这些智能指针不是最初的C++98标准的一部分，它们包含在TR1（Technical Report 1）中。TR1是一项C++新功能的提案（ISO/IEC 2007）。在计划中的新版C++标准（C++0x）中也包含了这些智能指针。Boost库提供了这些智能指针的可移植的且开源的实现，包括boost::shared_ptr、boost::weak_ptr 以及boost::scoped_ptr。

使用这些智能指针可以使API更加易于使用，同时降低犯前面所列举的内存错误的几率。例如，使用boost::shared_ptr可以不再要求用户显式释放动态创建的对象。当不再引用该对象时，它会被自动删除。例如，考虑以下API，它通过名为CreateInstance()的工厂方法创建对象实例。

#include<boost/shared_ptr.hpp>

typedef boost::shared_ptr<class MyObject>MyObjectPtr;

calss MyObject
{
public:
static MyObjectPtr CreateInstance();
~MyObject();

private:
MyObject();//必须使用工厂方法来创建实例
};

工厂方法的实现方式如下：

MyObjectPtr MyObject::CreateInstance()
{
return MyObjectPtr(new MyObject());
}

根据该API，客户可以使用以下方式创建MyObject的实例：

int main(int argc, char *argv[])
{
MyObjectPtr ptr=MyObject::CreateInstance();
ptr=MyObject::CreateInstance();
return 0;
}

此例创建了两个MyObject实例，并且当ptr变量超出作用域时（此处即在程序的末尾），实例就会销毁。而如果CreateInstance()方法仅返回MyObject *类型，那么之前给出的示例将永远不能调用析构函数。因此，使用智能指针可以简化内存管理，进而提高API的易用性。

通常情况下，如果函数返回了需要客户销毁的指针，或者你预计，客户需要的指针的生命周期比对象的生命周期更长时，就应该返回智能指针，如boost::shared_ptr。而如果由对象持有指针的所有权，那么可以返回一个标准指针。例如：

//MyObject*的所有权转移给调用者。
boost::shared_ptr<MyObject>GetObject() const;

//MyObject*的所有权由API持有。
MyObject* GetObject() const;

提示　当需要客户销毁指针时，使用智能指针返回动态申请的对象。

值得注意的是，此类内存管理问题实际上仅仅是更广泛的资源管理分类中的一个特例。类似的问题也可能在操作互斥锁或文件句柄时遇到。智能指针的概念可以被泛化为具有如下特点的资源管理任务，即资源分配是对象构造，资源释放是对象析构。人们经常将其缩写为RAII（Resource Acquisition Is Initialization），表示资源获取即初始化。

考虑如下代码示例，它展现了一个典型的同步错误。

void SetName(const std::string &name)
{
mMutex.lock();

if (name.empty()){
return;
}
mName=name;

mMutex.unlock();
}

显然，如果给方法传递一个空字符串，那么该代码将不能释放互斥锁，进而导致程序在下一次尝试调用此方法时陷入死锁，因为此时互斥锁仍处于锁定状态。还可以创建一个类ScopedMutex，还是由构造函数负责加锁，而析构函数负责释放锁。使用这个类，可以将前面的方法重新编写如下：

void SetName(const std::string &name)
{
ScopeMutex locker(mMutex);

if (name.empty())
return;

mName=name;
}

现在可以保证方法在返回时锁会被立即释放，因为只要ScopedMutex变量超出作用域，互斥锁就会被解锁。因此不需要检查每个返回语句，以确保它们显式地释放了锁。而且该代码的可读性也更高。

需要指出的是，在API设计中，如果API提供对某些资源的分配和回收，那么应当考虑提供一个类来管理这一操作。在该类中，资源的分配发生在构造函数中，回收发生在析构函数中。（也许需要另外通过公有的Release()方法，以便客户能够控制何时释放资源。）

提示　将资源的申请与释放当作对象的构造和析构。

平台独立

设计精良的C++ API不应在公共头文件中出现平台相关的#if/#ifdef语句。如果API给出了问题域的高层次的逻辑模型（API本身应该如此），那么API几乎不会因平台而异。只有在编写使用专用平台资源的接口的API时，比如某个程序绘制一个窗口，并为本地操作系统传递正确的窗口句柄，这样做才是合适的。除此之外，永远不要在公共头文件编写平台相关的#ifdef语句。

例如，考虑封装移动电话功能的API。一些移动电话提供内置的GPS设备，该设备能发送电话的地理位置，但并不是所有的设备都有该功能。然而，不应该在API中直接暴露这一事实，如下例所示：

class MobilePhone
{
public:
bool StartCall(const std::string &number);
bool EndCall();
#if defined TARGET_OS_IPHONE
bool GetGPSLocation(double*lat, double*lon);
#endif
};

这个拙劣的设计在不同的平台上创建不同的API，进而强迫API的客户为其应用程序引入同样的平台相关特征。例如，在前面提到的例子中，客户可能被迫为所有GetGPSLocation()的调用增加完全一致的#if条件语句保护，否则他们的代码在其他平台上编译时就可能出现未定义的符号错误。

而如果在API的后续版本中增加了对另一个设备类的支持（如Windows Mobile），就不得不更新公有头文件中的#if语句，使其包含_WIN32_WCE。然后，你的API客户就必须在他们的代码中查找所有已经嵌入的TARGET_OS_IPHONE定义，并且扩展它使其也包含WIN32_WCE，这都是因为你在无意中暴露了API的实现细节。

正确的方法是隐藏某些功能只适用于特定平台这一事实，并提供一个方法判定当前平台是否支持所需的功能。例如：

class MobilePhone
{
public:
bool StartCall(const std::string &number);
bool EndCall();
bool HasGPS() const;
bool GetGPSLocation(double*lat, double*lon);
};

现在你的API就能在所有平台下保持一致，并且不会暴露在哪些平台上支持GPS坐标的细节。此刻客户可以编写代码，调用HasGPS()检测当前设备是否支持GPS；如果支持，就调用GetGPSLocation()方法返回实际的坐标。客户可能会按以下方式实现HasGPS()方法：

bool MobilePhone::HasGPS() const
{
#if defined TARGET_OS_IPHONE
return true;
#else
return false;
#endif
}

现在的设计远比最初的设计优秀，因为平台相关的#if语句现在被隐藏在.cpp文件中，而不是暴露在头文件中。

提示　不要将平台相关的#if或#ifdef语句放在公共的API中，因为这些语句暴露了实现细节，并使API因平台而异。

松耦合

1974年，Wayne Stevens、Glenford Myers和Larry Constantine联合发表了关于结构化软件设计的开创性论文。该论文引入了耦合和内聚两个互相关联的概念（Stevens et al., 1974），它们的定义如下所述。

- 耦合：软件组件之间相互连接的强度的度量，即系统中每个组件对其他组件的依赖程度。

- 内聚：单个软件组件内的各种方法相互关联或聚合强度的度量。

优秀的软件设计应该是低耦合（或松耦合）且高内聚的，即最小化不同组件之间功能的关联性和连通性。达到这一目标之后，每个组件的使用、理解以及维护就能实现相互独立了。

提示　优秀的API表现为松耦合和高内聚。

Steve McConnell对松耦合作了一个特别形象的类比。火车模型的各车厢间使用链子、车钩彼此连接，仅通过单点连接就将两个车厢挂接在一起。这就是一种松耦合系统。试想一下，如果需要使用几种不同类型的连接（可能包括螺丝和缆线）才能将车厢连到一起，或者特定类型的车厢只能和其他几种特定类型的车厢连接，该有多困难（McConnel, 2004）。

一种理解耦合的方式是给定A和B两个组件，当A改变时需要改变B中多少代码。评估组件之间的耦合度可以采用下面几种不同的度量方式。

- 尺度。与组件之间的连接数相关，包括类的数目、方法的数目、每个方法的参数数目等。例如，拥有较少参数的方法的组件与调用此方法的组件之间的耦合度较低。

- 可见度。指组件之间的连接的显著程度。例如，改变一个全局变量以便间接影响另一个组件的状态，这种可见度较低。

- 密切度。指组件之间连接的直接性。如果A同B耦合，并且B同C耦合，那么A间接地同C耦合。而继承某个类比引入该类为成员变量（组成）的耦合度要高，因为继承还支持访问类中所有受保护的成员。

- 灵活度。与改变组件之间连接的难易度相关。例如，如果要改变对象A中某方法的签名以使对象B能够调用它，那么灵活度就代表修改此方法和所有依赖的代码的难易度。

一种令人生厌且应该杜绝的紧耦合形式是，有两个直接或间接相互依赖的组件，即依赖循环或者说循环依赖。这样要重用一个组件而不包含它的循环依赖组件就非常困难，甚至 无法实现。

接下来，将给出不同的降低API内的类和方法的耦合度（API内耦合）的技巧。

这里还有一个同样有趣的问题，即API设计的决策如何影响客户应用的内聚和耦合（API内的耦合）。由于设计API是为了解决特定的问题，所以API应该很好地体现这一共同的目标，使其成为客户应用程序中的高内聚组件。但从耦合角度考虑，API越庞大，暴露的类、方法和参数就越多，这样在客户的应用程序中，访问和联接API的方式就越多。实现最小完备性能降低耦合度。在设计API时也要考虑联接了多少其他组件的问题。例如，libpng库依赖于libz库。在编译时会在png.h中引入zlib.h头文件，这样就暴露它们之间的关系，且这种情况在链接时也存在。这就要求libpng的客户注意到libz的依赖性，并确保在构建和链接时也使用这个链接库。

仅通过名字耦合

如果类A仅需要知道类B的名字，即它不需要知道类B的大小或调用类B的任何方法，那么类A就不需要依赖类B的完整声明。在这种情况下，可以为类B使用前置声明，而非包含整个接口，这样就降低了这两个类之间的耦合（Lakos, 1996）。例如：

class MyObject;//只需知道MyObject的名字

class MyObjectHolder
{
public：
MyObjectHolder();

void SetObject(MyObject*obj);
MyObject*GetObject() const;

private:
MyObject*mObj;
};

在该例中，如果相关联的.cpp文件仅仅存储并返回MyObject的指针，同时限制任何除指针比较外的与该指针的交互，就不需要#include"MyObject.h"了。这样类MyObjectHolder就可以同MyObject的物理实现进行解耦。

提示　除非确实需要#include类的完整定义，否则应该为类使用前置声明。

降低类耦合

Scott Meyers建议，如果情况允许，那么优先声明非成员、非友元的函数，而非成员函数（Meyers, 2000）。这么做在促进封装的同时还降低了这些函数和类的耦合度。例如，考虑以下类片段，它提供PrintName()成员函数将成员变量输出到stdout。该方法使用公有的getter方法GetName()获取成员变量当前的值。

//myobject.h
class MyObject
{
public:
void PrintName() const;
std::string GetName() const;
...

protected:
...

private:
std::string mName;
...
};

依照Meyers的建议，应该优先使用以下表述：

//myobject.h
class MyObject
{
public:
std::string GetName() const;
...

protected:
...

private:
std::string mName;
...
};

void PrintName(const MyObject &obj);

后一种形式降低了耦合度，因为自由函数PrintName()只能访问MyObject的公有方法。（在这个特殊的例子中，只能访问const公有方法。）如果PrintName()是类的成员函数，它还能访问MyObject所有的私有和受保护的成员函数以及数据成员，如果MyObject有基类，则还包括其基类中受保护的成员。因此，优先使用非成员、非友元的形式意味着此方法与类内部的细节不会耦合。因此，当MyObject的内部细节改变时，对方法造成破坏的可能性会小很多。

该技巧也会促成最小完备的接口，因为类仅包含需要实现的最小功能，而基于公有接口实现的功能声明都在类的外部。（参考之前讨论的关于便捷API的示例。）值得一提的是，该技巧在STL中很常见，诸如std::for_each()和std::unique()算法都声明在容器类之外。

为了更好地传达MyObject和PrintName()在概念上的关联性，可以将它们声明在同一个命名空间中。要么将PrintName()声明在自己的命名空间中（如MyObjectHelper），要么作为新的帮助类MyObjectHelper的静态方法。和便捷API那部分讨论的一样，这个帮助类的命名空间应该包含在一个独立的模块中。例如：

//myobjecthelper.h
namespace MyObjectHelper
{
void PrintName(const MyObject &obj);
};

提示　与成员函数相比，使用非成员、非友元的方法能降低耦合度。

刻意的冗余

通常，优秀的软件工程实践的目标是去除冗余，即确保每个重要的知识点或行为有且仅有一次实现（Pierce, 2002）。而代码复用意味着耦合，因此略微增加重复以断绝过分的耦合关系有时是值得的（Parnas, 1979）。此类有意的重复可能表现为代码或数据的冗余。

举个代码冗余的示例，考虑两个互相依赖的大型组件。当你深入调查其依赖关系时，会发现实际上是一个组件依赖于另一个组件的某个微不足道的功能，例如计算最小值或最大值的函数。标准的做法是将该功能实现为一个更低层次的功能，使得两个组件都依赖于低层次的功能，而非两个组件互相依赖。有时这种重构是没有意义的，例如，该功能可能由于不够通用而不能降解为系统内的低级层次。因此，在特定的情况下，为了避免耦合，复制代码的行为是有意义的（Lakos, 1996）。

在增加数据冗余方面，考虑以下为文字聊天系统设计的API，该系统记录用户发送的每条信息。

#include"ChatUser.h"
#include<string>
#include<vector>

class TextChatLog
{
public：
bool AddMessage(const ChatUser &user, const std::string &msg);
int GetCount() const;
std::string GetMessage(int index);

private:
struct ChatEvent
{
ChatUser mUser;
std::string mMessage;
size_t mTimestamp;
};

std::vector<ChatEvent>mChatEvents;
};

该设计接受独立的文本聊天事件，使用对象描述用户和其输入的信息。为用户输入信息附加上当时的时间戳，并添加到内部列表中。GetCount()方法用来获得已经发生的文本聊天事件的数目，GetMessage()方法返回指定聊天事件的格式化的版本，如：

Joe Blow[09:46]:What's up?

TextChatLog类显然和ChatUser类是耦合的，而ChatUser是个负担很重的类，它引入了许多其他依赖。因此，你决定调查此情形，结果发现TextChatLog仅使用了用户名，即它保留着ChatUser对象仅仅是为了调用ChatUser::GetName()方法。因此，去除这两个类之间的耦合的一个简单方案是，将用户名传递给TextChatLog类，如以下重构后的版本所示：

#include<string>
#include<vector>

class TextChatLog
{
public：
bool AddMessage(const std::string &user, const std::string &msg);
int GetCount() const;
std::string GetMessage(int index);

private:
struct ChatEvent
{
std::string mUserName;
std::string mMessage;
size_t mTimestamp;
};

std::vector<ChatEvent>mChatEvents;
};

以上示例创造了一个包含用户名（现在它同时保存在TextChatLog类和ChatUser类中）的冗余版本，但是同时它也破坏了两个类之间的依赖性。它还能带来额外的好处，即当sizeof(std::string)小于std::sizeof(ChatUser)时，就降低了TextChatLog的内存开销。

尽管如此，即使是有意的冗余，也是冗余，应该小心谨慎地使用，同时添加良好的注释。例如，如果聊天系统升级以允许用户改变他们的名字，并且在名字发生变化时，更新原来的旧信息以显示新名字，那么你就必须回归到最初紧耦合的版本。（或者在名字发生变化时让ChatUser通知TextChatLog，但是这样会带来更多的耦合。）

提示　有时，使用数据冗余降低类之间的耦合是合理的。

管理器类

管理器类拥有并协调几个低层次的类。可以用它打破基于一组低层次的类的一个或多个类的依赖关系。例如，考虑一个结构化的画图程序，可以用它创建二维对象，选择对象以及在画布上移动对象。此程序支持几种输入设备供用户选择和移动对象，例如鼠标、输入板以及操纵杆。一种简单的设计是要求选择和移动操作了解每种输入设备，如UML图中所示（见图3-5）。

图3-5　多个高层次类，每个都和低层次类耦合

另一种方式是引入管理器类协调对每个特定输入设备类的访问。这样，SelectObject和MoveObject类仅需要依赖这一个管理器类，且只有此管理器类需要依赖每个输入设备类。这也可能需要为基本类创建一些抽象的形式。例如，注意MouseInput、TabletInput和JoystickInput各自的接口略有不同。因此，管理器类需要使用通用的输入设备接口，此接口将特定设备的细节抽象出来。改进后的松耦合的设计如图3-6所示。

图3-6　使用管理器类降低与低层次类的耦合

注意，该设计同时也具有良好的可扩展性。因为该系统可以加入更多的输入设备，但是不会引入更多对SelectObject或MoveObject的依赖。如果要增加操作对象（如RotateObject和ScaleObject），那么它们仅需要依赖InputManager即可，从而不会引入与底层设备类的耦合。

提示　管理器类可以通过封装几个低层次类降低耦合。

回调、观察者和通知

最后一个在API内降低耦合的技术是关于当一些事件发生时通知其他类的问题。设想一个3D多人在线游戏，该游戏允许多个玩家彼此之间进行游戏竞技。

在系统内部，每个玩家可能需要使用唯一标识符（即UUID）表示，如e5b43bbafbf2-4f91-ac71-4f2a12d04847。用户想要看到其他玩家的名字，而不是这种难以辨识的UUID字串。因此，该系统实现了玩家名字缓冲NameCache，用于存储UUID和可读的名字之间的映射关系。

现在考虑管理游戏大厅的类PreGameLobby，它需要显示每一位玩家的名字，可能需要处理的操作集如下所示：

(1) 类PreGameLobby调用NameCache::RequestName()；

(2) NameCache通过UUID向游戏服务器请求玩家的名字；

(3) NameCache接收服务器传回的玩家名字信息；

(4) NameCache调用PreGameLobby::SetPlayerName()。

在此例中，PreGameLobby依赖NameCache调用RequestName()方法，同时NameCache依赖PreGameLobby调用SetPlayerName()方法。这是一种十分脆弱且紧耦合的设计。试想一下，如果游戏内部系统也要知道玩家的姓名以显示在玩家角色上方，会发生什么？难道要扩展NameCache调用InGame::SetPlayerName()方法而进一步增加耦合度吗？

一个更好的解决方法是，为PreGameLobby和InGame类注册NameCache更新的监听器。一旦玩家姓名发生变化，NameCache就能通知所有注册监听的一方，进而避免直接依赖那些模块。实现这一操作有几种方案，如回调、观察者和通知。我将在之后详细讲解每种方案，这里首先介绍使用这些方案需要注意的一些共通的问题。

- 重入（reentrancy）。当编写API需要未知的用户代码调用时，需要考虑该代码可能回调此API。实际上，客户可能根本就不知道发生了回调。例如，如果你正在处理一个对象队列，并且每处理一个单独的对象就执行一次回调，那么回调可能尝试通过添加或删除对象修改队列的状态。你的API至少应该抛出错误来防止此类行为。而更好的解决方案是允许此类重入行为，并且确保API的实现代码能够保持一致的状态。

- 生命周期管理。API应该给客户提供一种能够干净利落地与此API断开连接的方式，即声明它们不再接收更新消息。当客户对象被删除时，该方式尤为重要，因为此后发送信息给客户对象将导致崩溃。同理，API要能够防止重复注册，以避免为同一个事件多次调用相同的客户代码。

- 事件顺序。回调或通知的序列应该明确告知API用户。例如，Cocoa API通过使用名字willChange和didChange表明通知是在某个事件之前还是之后发送，这种做法就十分明确。而Qt工具包在这方面就不够具体：一个改变信号有时在相关的对象更新之前就已经被发送出去了。

以上几点强调了一些共通的问题，应该明确告知用户在回调函数中他们能做什么和不能做什么，他们能对你的API能做哪些假设且不能做哪些假设。可以通过API文档或显式地提供受限回调接口（接口仅暴露所有潜在操作的安全子集）达到这一目的。

1. 回调

在C和C++中，回调是模块A中的一个函数指针，该指针被传递给模块B，这样B就能在合适的时间调用A中的函数。模块B对模块A一无所知，并且对模块A不存在“包含”（include）或者“链接”（link）依赖。回调的这种特性使得低层代码能够执行与其不能有依赖关系的高层代码。因此，在大型项目中，回调是一种用于打破循环依赖的常用技术。

有时为回调函数提供一个“闭包”也是有用的。闭包是模块A传递给模块B的一条数据，该数据包含在A提供给B的回调函数中。这是模块A传递一些重要的回调状态信息给模块B的一种途径。

以下的头文件展示了如何在C++中定义简单的回调API：

#include<string>

class ModuleB
{
public:
typedef void(*CallbackType)(const std::string &name, void*data);
void SetCallback(CallbackType cb, void*data);
...

private:
CallbackType mCallback;
void*mClosure;
};

然后该类就可以调用回调函数（如果已经设置了回调函数），如以下代码所示：

if(mCallback)
{
(*mCallback)("Hello World", mClosure);
}

复杂的示例将支持为模块B增加多个回调（将它们存储在std::vector中），让后轮流调用每个注册的回调。

在面向对象的C++程序中使用回调有一个难题，即使用非静态（实例）方法作为回调有些复杂。因为，此情况下对象的“this”指针也需要传递。本书附带的源代码中给出如何实现非静态回调函数的示例，该示例为每个成员回调方法创建了一个静态的封装方法，并且使用额外的回调参数传递this指针。

Boost库中为此问题提供了更加优雅的解决方案，即boost::bind功能。它的实现采用了functors（带有状态的函数）。在C++中，functors可以实现为一个类，该类使用私有成员变量存储状态，并包括一个重载的operator()方法执行函数。

2. 观察者

回调给出的解决方案在纯C程序中能够正常工作，而正如前面提到的，如果不使用类似boost::bind的辅助功能，在面向对象的C++程序中使用回调是非常复杂的。相比之下，更加面向对象的解决方案是使用观察者的概念。

这是一种软件设计模式，对象维护一个它所依赖的对象（观察者）列表，并通过调用观察者的方法通知观察者。对API设计中最小化耦合度而言，这是一个十分重要的模式。

3. 通知

回调和观察者适用于特定的任务，它们的使用机制通常定义在执行实际回调的对象中。一个替代的解决方案是，在系统中不连通的部分之间构建集中发送通知机制或事件。发送者事先不需要知道接收者，这样可以降低发送者和接收者之间的耦合度。虽然通知机制有好几种，但是最流行的是信号和槽（signals and slots）。

信号和槽的概念由Qt库引入的，它提供一种通用的、允许发送任意事件（如鼠标点击或计时器事件）给任意接受方法的方式。但在C++代码中有几种信号和槽的替代实现，包括Boost的boost::signals和boost::signals2库。

信号可以被认为是含有多个目标（槽）的回调。当信号被调用（或者发出）时，所有该信号的槽都会被调用。举一个具体的例子，以下代码片段使用boost::signal创建了一个没有参数的信号。然后将一个对象连接到信号上。最终，发出该信号会调用MySlot::operator()，进而会打印一条信息到标准输出上。

class MySlot
{
public:
void operator()() const
{
std::cout<<"MySlot called!"<<std::endl;
}
};

//创建MySlot类的一个实例
MySlot slot;

//创建一个没有参数且返回值为空的信号
boost::signal<void() >signal;

//连接槽和信号
signal.connect(slot);

//发出信号从而调动所有的槽
signal();

在实际应用中，低层次的类可以创建并拥有信号，然后允许任何不连通的类成为该信号的槽。然后低层次的类可以在任意恰当的时间发出信号，进而所有已连接的槽都会被调用。

稳定的、文档详细且经过测试的API

优秀的API设计应该是稳定的且具有前瞻性。在当前语境中，稳定并不一定意味着API不会改变，而是应该将接口版本化，并且在版本升级时保证向后兼容。习语“前瞻性”表示API应该设计为可扩展的，方便以后优雅地升级而不被修改得一塌糊涂。

优秀的API设计也应该有很好的文档支持，以便用户获取API的功能、行为、最佳实践以及错误条件的明确信息。最终，应该为API的实现编写可扩展的自动化测试程序，确保新的变更不会破坏现有的用例。

这些主题在文末浓缩为一个小节，并不是因为它们微不足道或无关紧要，实际上恰恰相反，这些问题对高质量、健壮、易于使用的API的开发而言举足轻重。

《C++ API设计》就“如何构建高效、健壮、稳定且可扩展的优质API”展开讨论。Martin Reddy凭借长期的从业经验，对优质API所应具备的各要素进行了全面分析，针对API的不同风格及模式，以及大型长期项目的内在要求，给出了种种最佳设计策略，从而对API设计过程的规范性及可持续性作出了重大的贡献。

本书适合软件工程师、高校计算机相关专业学生，以及编程爱好者阅读。

吐吐槽还能赚银子

活动规则：在吐槽贴中留言，选出任何一期中你最喜欢的文章和最不喜欢的文章，即可获得图灵社区银子2两！凡吐槽吐得掷地有声者，加赠银子3两（共5两）！（怎样使用银子兑换图书）

活动时间：本活动长期有效。

人物

古生物学家邢立达：追求生命的本质

最近，想必好多小伙伴儿都被媒体轮番儿报道的“琥珀中发现真恐龙”给震撼到了！

不管是书本里学来的，还是电影中看到的，恐龙都是那么的“大只”。想想博物馆里陈列的恐龙骨架，就知道一大票人会想不通小小的琥珀里怎么会有恐龙？

琥珀里恐龙的尾巴。

就这样，大自然给人类开了个这样婶儿的玩笑！

俏皮“伊娃”

这项研究由中国地质大学（北京）的邢立达博士与加拿大萨斯喀彻温省皇家博物馆的瑞安·麦凯勒教授领衔，论文发表于《细胞》出版集团旗下的生物学著名刊物《当代生物学》（Current Biology，五年影响因子平均达9.7）。这项研究表明人类发现了第一件琥珀中的恐龙标本！对了，它还有个俏皮的名字“伊娃”。

全长18.5厘米

琥珀里经常保存一些古昆虫或古植物等小体积的标本，这只恐龙得多小？

赫氏近鸟龙

邢立达博士介绍说，“标本非常小，尾巴展开后长度约为6厘米，推测全身长度为18.5厘米。” 伊娃标本属于基干手盗龙（maniraptorans）。手盗龙类中不乏一些非常小的个体，比如生活在1.6亿前的中国华北地区的近鸟龙，近鸟龙的体长仅34厘米，重约110克，是一种拥有飞羽的小型恐龙。

来自缅甸北部

这件标本来自著名的琥珀产区之一，缅甸北部克钦邦胡康河谷。此地的琥珀距今约9900万年前，属于白垩纪中期的诺曼森阶。

推荐阅读

该项目论文作者之一徐星，中国科学院古脊椎动物与古人类研究所的研究员，和作者之一邢立达博士曾共同为恐龙爱好者推荐了《我心爱的雷龙：一本写给大人的恐龙书》一书。

《我心爱的雷龙：一本写给大人的恐龙书》将带领大家一览恐龙研究的最新进展，通过最新的发现和技术进步一窥恐龙生活的诸多奥秘，比如恐龙的性生活、恐龙的社会性、恐龙的羽毛、恐龙的病痛，以及最重要的，恐龙的崛起和灭亡。

我们将会发现，虽然恐龙的形象和自然史发生了诸多改变，但这些改变只是让曾经的朋友更加鲜活生动，也更具魅力。

幸运的是，图灵访谈还有幸访谈了邢立达博士和Brian Switek（布赖恩·斯威特克） 原作者！

《我心爱的雷龙》译者邢立达：追求生命的本质

邢立达，青年古生物学、科普作家。1982年生于广东潮州，高中时期便创建中国大陆第一个恐龙网站。在加拿大艾伯塔大学取得古生物学硕士学位，师从著名古生物学家菲利普·柯里（电影《侏罗纪公园》的主角原型）；在中国地质大学（北京）取得古生物学与地层学博士学位。受美国《国家地理》资助。中国科普作家协会会员，出版与翻译了近百本古生物科普书籍，并多次在CCTV各频道为公众介绍古生物知识。

前往采访的路上，我脑子里还萦绕着“古生物学家为什么大费周章地研究那些早已灭绝的恐龙”这个想法，想象他们会如何搪塞，我又该怎么有力回击。

从地质大学（北京）北门进入，转过几道弯，一座从建筑风格看来上了些年纪的小楼出现在眼前，不惊不喜。它的古朴，似乎向我们诉说着古生物学研究素材的古老，以及研究者对精神纯粹的崇尚。

登上三楼，就看见邢立达老师忙碌地穿梭在走廊的各办公室之间，“你好，稍微等我一下。”

“好的，我先随便转转。”

透过走廊里虚掩的门，三四个古生物学专业的研究生正敲打着键盘，桌子和空闲的椅子上摊放着横七竖八的资料。大概五分钟后，邢老师忙完走来，示意开始我们的访谈。按邢老师的意思，直接在他们的研究生工作室进行就好，并解释说，“不用担心，他们都忙自己的事情。”但我还是不忍心打扰大家，建议换个采访地点。

我们从屋里出来，下楼。没走多远，就碰到了刚下课的学生。起初是几个学生向邢老师问好，没什么特别，后来三三两两地围了过来，交谈着课上学到的东西，个个神采飞扬，兴致勃勃。看来邢老师确实有一套办法，能在如此喧嚣浮躁的大学里让“无用之学”有用武之地。追着金融学、管理学这样的热门专业老师，寒暄招呼，求根问学，我倒很能理解。可追求古生物这般的冷专业，我实在抽象不出原因。

追求生命的本质

进入访谈后，我当然迫不及待地抛出了我的疑惑。“恐龙称霸的时代对于我们来讲，完全是另一个世界，研究早已灭绝的恐龙对现在生活的世界有什么意义吗？倒不如研究房价问题、环境问题来的实际。”

“恐龙研究，也只有恐龙1.6亿年漫长的存在，才能够解释生命的本质。生命是什么、生命从何而来、生命将到哪儿去。”任何其他的问题，无论多紧迫多重要，在生命面前都不值一提。面对死亡，建立在文明社会之上的所有问题都将坍塌。

恐龙是在全盛时期遭到重创，毫无防备地为天外偶然事件所害。关于恐龙大灭绝，最主要的假说是地外小行星的“撞击说”，这引起了地球上一系列的反应，非鸟恐龙最终没能挺过，只有鸟类作为恐龙的后裔留存了下来。这场难以计量的毁灭，不免让人类对“核毁灭”产生恐惧。

对生命本质的追求，使邢立达从高中时期开始，就创建了“恐龙网”，作为中国大陆第一个恐龙网站，向大众传授中国古生物知识。创造机会，随同国内著名恐龙学者——董枝明教授前往云南野外挖掘恐龙化石。之后，不断编著、翻译了多篇科普作品，包括《与恐龙共舞》《恐龙大灭绝》《巨兽时代》《普林斯顿恐龙大图鉴》《我心爱的雷龙：一本写给大人的恐龙书》等。目前主要研究骨骼化石、恐龙足迹，近来也研究琥珀里的白垩纪脊椎动物。“琥珀研究目前处于新兴阶段，是古生物研究学者大展身手的好舞台。”

我心爱的雷龙

谈及最近翻译的《我心爱的雷龙》，邢立达认为它首次将恐龙研究的最新进展进行了全面的总结展示，同时伴随了作者Brian Switek自己的情感体验，代入感很强。对于原作者来说，虽然“雷龙”从未真正地存在过，只是科学探索旅途上的一次凄美错误，但它见证了人类对恐龙看法的不断改变，是Brian童年的玩伴，也是带他进入恐龙世界的领路人。因此是一本非常走心的书，英国《自然》杂志还专门邀请中科院古生物专家徐星写了推荐书评。

虽然亲自命名过一些恐龙，邢立达并没有像Brian那样钟爱过某种恐龙，“对我而言，每一种恐龙都是奇妙的生命，值得尊重。没有特别钟爱的恐龙，甚至也不包括我自己命名的恐龙。”

“如果可以穿越回恐龙时期，亲眼见见一直以来研究的材料原型，我想恐龙开始长满羽毛，飞向蓝天的那一刻应该是演化史上最精彩的一幕！”

中国的恐龙研究

邢立达很感谢这个时代。他几乎到过世界上所有的恐龙足迹群，体验过各式的风俗民情。相比较战乱频发、贫困动荡的地区，他感激中国这样的环境，能给学者们研究古生物的机会。“古生物学属于材料学科，谁能收集到越多的材料，谁就有机会挖掘出材料背后的科学价值。当然，还跟地区的经济条件和政局状况有密切的联系。从这些方面考虑，中国目前国泰民安，经济发展水平较高，也使得古生物研究人员能在世界恐龙研究中取得一席之地。整体而言，中国一些机构与学者已经处于世界一线地位。”

在中国贵州，许多地方农民组织起来开采鱼龙等古生物化石，卖给研究人员或走私到其他国家从中获取暴利，但因为他们的不专业和滥采滥开，珍贵资源遭到了破坏。其实，“盗挖化石主要是贫困和监管缺失两方面原因，后者随着古生物法规的出台已经好转，前者则需要各方共同努力，比如相关机构与当地政府合作建立“化石村”，一方面保障当地人民的利益，一方面保护古生物化石的科研价值。”

推荐阅读：

《我心爱的雷龙》作者Brian Switek：骨化石，记录一段生命的时间宝盒

Brian Switek：Bones, a time capsule of a life

践行

特征选择方法简介

作者/SAURAV KAUSHIK

译者/钱亦欣，统计界烤肉最好，烤肉界统计最棒。

我时常以参加竞赛的方式来磨练自己的机器学习技能，它能让我更清楚自己的水平。一开始，我以为算法就是机器学习的一切，知道采用哪种模型就能走上人生巅峰。但后来我发觉自己错了，获胜者使用的算法和其他人选手的并无二致。我还认为，这些人一定有很牛逼的工具，但当我发现有的top选手建模用的仅仅是macbook air后，我知道自己又错了。最终，我发现真正使得这些人脱颖而出是两件事：特征构建和特征选择。

换句话说，他们创造并选取了恰能反应数据背后逻辑的特征进入预测模型。不知算好算坏，这个技能需要持之以恒地实战，还有很强的艺术性。一些人在这方面有着特别的技巧，而大部分人只能苦苦挣扎。

这篇文章将着重介绍特征选择这一重要技巧。我会详细介绍为什么特征选择在创建有效预测模型中扮演重要的角色。

搞起！

特征选择的重要性

机器学习遵循一个简单的法则：如果你输入的是垃圾，得到的输出也只能是垃圾，此处的垃圾指的就是数据中的噪声。

当特征的数量很大时，这个问题就更严重了。因此你没有必要使用所有的特征来建模，只需要放入那些真正重要的。本人亲测，相同算法下，很多时候用特征的子集反而能取得更好的效果。Rohan Rao也说“Sometimes, less is better!"。

这一法则在工业级应用中也同样奏效，它不仅可以减少训练时间，也可以减少你所要担心的事。

使用特征选择的主要理由如下：

- 更快的模型训练速度

- 更低的模型复杂度和更好的解释性

- 更高的精度（选对特征）

- 减弱了过拟合

下一部分将讨论特征选择的几种方法，让我们开始吧。

过滤法

过滤法时常应用于预处理阶段，不依赖于任何机器学习算法。它使用基于统计检验的得分作为筛选条件（检验特征和响应变量的相关性），这里所定义的相关性带有一些主观色彩。最基本地，你可以参考下面的表格来定义相关性。

- Pearson's Correlation: 它能反应两个连续变量间的线性相关程度，取值在[-1, 1]上，计算方法如下（译者注：实际上，Pearson相关系数更多反应的是两个服从正态分布的随机变量的线性相关性。变量虽然连续但分布和正态分布相距比较远，建议采用非参数的spearman相关系数或kendall相关系数。）

- LDA：全名线性判别分析，可以考察特征的线性组合能否区分一个分类变量。

- ANOVA：中文叫方差分析，原理和LDA类似只是它常用于特征是分类变量，响应变量是连续变量的情况下，它提供了不同组的均值是否相同的统计量。

- Chi-Square：卡方检验是基于频率分布来检验分类变量间的相关性的工具。（译者注：生物统计常用的列联表检验就是卡方检验，实际上和方差分析一模一样，且都能通过回归形式来表示。）

同时，请牢记过滤法不能减弱特征间的共线性，在训练模型前还需要针对特征的多重共线性做相应处理。

包装法

包装法仅用特征的一个子集来训练模型，并利用之前模型的结果来判断是否需要增删新的特征。这其实就是一个关于特征空间的搜索问题，但它的计算可能需要耗费大量时间空间。

常用的包装法包括前向选择法，后向剔除法，迭代剔除法等。

- 前向选择法：这是一种基于循环的方法，开始时我们训练一个不包含任何特征的模型，之后的每一次循环我们都持续放入能最大限度提升模型的变量，直到任何变量都不能提升模型表现为止。

- 后向剔除法：该方法先用所有特征建模，再逐步剔除最不显著的特征来提升模型表现。同样重复该方法直至模型表现收敛。

- 迭代剔除法：这是一种搜索最优特征子集的贪心优化算法。它会反复地训练模型并剔除每次循环的最优或最劣特征。下一次循环，则使用剩余的特征建模直到所有特征都被剔除。之后，按照剔除的顺序给所有特征排序作为特征重要性的度量。

利用包装法选特征可以借助R语言中的Boruta包，工作原理如下：

1. 首先，它会把原来的特征打乱顺序作为新特征（称为影特征）添加到数据集中；

2. 然后，基于所有特征训练随机森林模型，并评价每个特征的重要性（默认基于平均精度降低测度）；

3. 每一次迭代中，该方法都会检测真实特征相对其影特征是否更重要，并移除那些重要性差别最低的特征；

4. 最后，该算法会在所有特征都被判定为重要或无用之后停止（或者在达到给定的迭代次数后停止）。

想进一步了解Boruta包的使用，请参考文章。

想了解Boruta在Python中的应用，请参考文章。

嵌入法

嵌入法综合了过滤法和包装法的特点，它要借助那些自带特征选择方法的算法。

最常用的嵌入法实例是LASSO和岭回归，他们的优化目标都带有惩罚项来减弱过拟合。LASSO使用L1正则，也就是对系数的绝对值大小加以惩罚。岭回归使用L2正则也就是对系数的平方值加以惩罚。关于LASSO和岭回归的更多细节，可以参考这篇文章。

其他的方法则还有正则树，文化基因算法和随机多项logit等。

过滤法和包装法的不同

二者的主要不同包括。

- 过滤法测量特征和被解释变量的相关性，包装法则是基于模型测量特征的有效性。

- 过滤法由于不依赖于模型，速度更快。

- 过滤法基于统计检验选择特征，包装法基于交叉验证。

- 过滤法时常失效，但包装法常常被发现很有用。

- 使用包装法筛选的特征更容易导致模型过拟合。

案例

让我们使用包装法来筛选变量，看看正确地使用特征子集后模型的精度会怎么变。

我们将用R来建模预测股价的涨跌。使用的数据集包含100个特征，代表股票的相关性质，响应变量y是一个二值变量，1代表股价上涨，-1代表下跌。

请点击此处下载数据。

让我们先基于所有特征，训练一个随机森林模型。

library('Metrics')
library('randomForest')
library('ggplot2')
library('ggthemes')
library('dplyr')

# 设置随机数种子
set.seed(101)

# 导入数据
data<-read.csv("train.csv",stringsAsFactors= T)

# 检查数据维数
dim(data)

##[1] 3000  101

# 将相应变量转化为因子
data$Y<-as.factor(data$Y)
data$Time<-NULL

# 把数据集划分为训练集和测试集
train<-data[1:2000,]
test<-data[2001:3000,]

# 训练随机森林
model_rf<-randomForest(Y ~ ., data = train)
preds<-predict(model_rf,test[,-101])
table(preds)

##preds
##-1   1
##453   547

# 检测精度
auc(preds,test$Y)

##[1] 0.4522703

为了降低模型的复杂度，我们把入选特征的上限定为20，仅把随机森林模型判定最重要的20个特征纳入模型并看看预测精度。

importance(model_rf)

# 平均基尼下降值
##x1           8.815363
##x2          10.920485
##x3           9.607715
##x4          10.308006
##x5           9.645401
##x6          11.409772
##x7          10.896794
##x8           9.694667
##x9           9.636996
##x10          8.609218
...
...
##x87          8.730480
##x88          9.734735
##x89         10.884997
##x90         10.684744
##x91          9.496665
##x92          9.978600
##x93         10.479482
##x94          9.922332
##x95          8.640581
##x96          9.368352
##x97          7.014134
##x98         10.640761
##x99          8.837624
##x100         9.914497

仅用20个最重要特征建模

model_rf<-randomForest(Y ~ X55+X11+X15+X64+X30
+X37+X58+X2+X7+X89
+X31+X66+X40+X12+X90
+X29+X98+X24+X75+X56,
data = train)
preds<-predict(model_rf,test[,-101])
table(preds)

##preds
##-1   1
##218 782

# 检测精度
auc(preds,test$Y)
##[1] 0.4767592

因此，仅仅使用20个特征我们反倒把预测精度从0.452提升到了0.476，这个例子说明，应用特征选取方法，我们不仅提高了预测精度，同时也

- 提升了模型的可解释性

- 降低了模型复杂度

- 减少了模型训练时间

结语

我相信本文对你而言应该是个不错的入门文章，特征选择还有各种各样的方法和形式。我相信你将来会从特征选择中受益无穷。

注：原文刊载于Analytics Vidhya网站

原文链接：https://www.analyticsvidhya.com/blog/2016/12/introduction-to-feature-selection-methods-with-an-example-or-how-to-select-the-right-variables/

选自图灵社区

鲜阅

每天叫醒我的不是闹钟，而是梦想

作者/berlinix

我有个好习惯，早起。我在过去15年左右的时间里，几乎都在6:30左右起床，无论寒暑。假日里有时候会很晚起床，冬日里偶尔也会赖床，但绝大多数时间里，我都顽强地在6:30起床，只因每天叫醒我的不是闹钟，而是梦想。

我记得2005年那个寒假，我在读The C++ Programming Language (Special Edition)这本书。当时我决意在这个假期把这本书看完。那个假日里一是寒冷，二是混沌，除此之外什么也记不得了。寒冷是因为南方的冬天没有暖气，要安静地坐在书桌前看书是极为寒冷的；混沌是因为当时才刚学编程，身边没有电脑，英语也很烂，读TC++PL这种艰深晦涩的技术原版，是种折磨。每天我几乎是晕厥着读20页，整个寒假读完700页左右的篇幅。剩余的300页，我没读完，目标完成70%。

寒假完后的那个学期，我又老老实实地从头看TC++PL，一边看，一边照书抄程序。大约半年后，又把这700页重读了一遍。被这本书以及本书作者折磨以后，再看Stevens的《UNIX环境高级编程》和《UNIX网络编程（卷1）》的时候，才真正体验到了所谓的享受。而我的梦想，则是拥有像Stevens一般的文笔和理解力，把一个复杂的系统，清晰简单地说清楚。我想等以后上班了，6点起床，9点才上班，时间非常充裕，有大量自由的时间可以支配。

2007年，我毕业参加工作。第一份工作颇为轻松，我住的位置离公司不远，约40分钟的路程。几乎每天我都是最早到公司的人，就如在学校里一般，我几乎也是最早去教室开灯的那个人。虽然我很努力，但并不是很拼命的那种，我缺乏一个长期的职业规划，缺乏真正的梦想和坚定的目标。我只有一个模糊的信念，那就是要活得充实，也许是个平庸之辈，但也要认真地过活。我几乎不参加饭局，不好吃，不打牌，不看电视，几乎不用任何IM工具；大多数时间都在看书、看电影、思考、写字，或者找点有趣的东西玩，与一些有趣的人交流思想。我长期思考的问题是：如何才能有意义地度过自己的一生？

7年来，我在技术和生活的各个领域先后产生过浓厚的兴趣，时间碎片化严重，精力也极度分散，没能在某个领域做出过任何成绩。虽然很努力，但没有什么结果。反过来说也成立，虽然没有什么结果，但一直很努力，我相信功不唐捐。有阵子，我在每日下班前会打印一份技术文档，在等公交的昏暗灯光下阅读，吃完晚饭就开机编程学习；我在工作中遇到的大小问题，都巨细无遗地记录下来，发邮件和同事分享；时常因为一个想法，在早晨闹钟还未响前，激动得睡不着，有时也会跳下床来拿笔记录。

据说能严格控制时间的人才能训练出自信。对我，仅仅能做到早起罢了，自律还差得远，甚至有点儿自由散漫。我喜欢那种轻松自由的氛围。有人说中国的年轻人活得太辛苦，一毕业就成了中年人，为油盐柴米、买车买房而劳命奔波。看清这个世界的本质，活在现实中是好的，但有梦想则更妙。真正的梦想，让你不盲目从众，不因为其他人在说什么、做什么，而影响自己的判断力。你要做什么，成为怎样的人，终究是自己决定的。

为失败、放弃找个借口是很容易的，但也请为梦想找个借口，找个一定要实现它的强大借口，让自己相信它，依赖它，成就它，终有一天，它会反过来成就你。我看过李宗盛的《致匠心》那个短短数分钟的宣传片后，冷静了一阵子，又反复看了几次，我想，二十年后，再回头来谈我的匠心和梦想吧，在之后的岁月里我总有些进步，也一定会比现在更好吧。

然而无论如何，请让梦想，而非闹钟叫醒你，因为今天总是你剩余生命里的第一天，从今日起拥有梦想，还不算晚！

选自图灵社区

你可能不知道的16针打印机

作者/彭民德

中南大学教授（退休），中国计算机学会高级会员，我国最早一批电子计算职业人。曾任中南大学信息化建设专家组成员，湖南省软件行业协会专家。他始终坚持在计算机的学习与开发应用第一线，参加过三峡大坝下不稳定流计算，空空导弹弹道计算，多机实时控制的流速仪检定系统、863城市供水调度系统设计计算。

主要论著：

1.【专著】计算机操作系统，清华大学出版社

2.【专著】面向系统的程序开发技术与实践 ，电子工业出版社

3.【专著】Windows多媒体软件开发实例与技巧，科技文献出版社

打印机是重要的输出设备，常常需要用它打印文档和表格。随着PC进入家庭，它也是一项重要家用电器。

现在想要打印文档，只要用“记事本” “写字板” “WPS”或者“word”等编辑程序写好，从程序运行窗口中找到打印按钮，点按一下，文档就可以从打开的打印机送出。想打屏幕图形吗？只要按一个叫Print Screen的键或者组合键Ctrl+P就行了。各种工具软件，比如微软的Office套件，无论是表格软件还是幻灯片制作软件，都有打印按钮或菜单项，让人们很方便就可以印出想要的东西。可是，打印机也有一个60年进化的漫长过程。

上个世纪50年代末学习程序设计时，不讲怎样输出。由北京大学、复旦大学等编写的两本最早的程序设计教材，完全不涉及这个内容，程序的数值计算结果存放在一个内存单元里就停机。到国防科研单位后用的早期计算机，输出设备只有纸带数值输出机，仅仅能够输出数值。当时的计算机内部和输出纸带上都还没有字符的概念，纸带上只能是一个跟着一个的数值。输出纸带卷的宽度跟如今超市里收银机返给顾客的纸带条差不多，但是功能远不如今天的POS机。今天顾客得到的是张单子，有汉字，有排版，有日期，有对齐了的金额，有分项有汇总，一目了然。而早期的打印纸带上每行只能打印一个数，整卷纸带上除了一个接一个的数字，什么都没有。

纸带输出要顾及数的定点与浮点两种格式打印数据，无论多么简单的数，都用同样的打印长度表示。比如定点方式表示整数：

1 打印成： 000 0 000000010 0

-1 打印成： 000 - 000000010 0

13579 打印成： 000 0 000135790 0

-246801 打印成： 000 - 002468010 0

浮点方式表示实数：

-246.801 打印成： 003 - 246801000 0

这种纸带很难阅读，技术人员通常还要用手工由此转换成图表，才能使用。

70年代后期，随着ALGOL 60的引进，使用了80行的纸带输出机。这种纸带上有了字符，包括数字和英文，以及运算符号。每一行可以有80个字符，可以打印源程序的一个语句。用这种纸带打印源程序和程序运行结果，上机环境比以前有了明显改善。

80年代，开始引进国外的针式打印机。此时的打印机在计算机系统中，已经是一台自带芯片、与主机可以经中断通信独立工作的外部设备了。开始较为流行的是EPSON的FX-100，这种打印机带有9针字库。每个字符以精细的9х9钢针击打在色带上，把字符印在色带后面的打印纸上。色带有十几米长，每打一个字符，色带就转动到下一个击打位置，以保证各个字符着色均匀，并避免色带被钢针重复击打易击穿。打印纸两侧有导流孔，打印当前字符后被齿轮带动转到下一个待打印的字符行。

应该说这是打印史上一个重要的里程碑，因为直到今天，纸带针式打印机还在一些场所使用，它的历史有30多年了。FX-100可以实现对字符的加大字号、加粗、上下标、加下划线等不同方式打印。

跟如今相比，使用FX-100也还是不方便。所有的打印，包括印什么字符和对字符的格式控制都得编程，由CPU的程序来控制。比如想打印y=ax3+bx2+cx+d，得要编制运行下列程序。

20 lprint chr$(27)；“E”；
30 lprint “y=ax”；chr$(27)；“F”；
40 lprint chr$(27)；“S”；chr$(0)；chr$(15)；
50 lprint “3”；
60 lprint chr$(27)；“T”；chr$(18)；
70 lprint chr$(27)；“E”；
80 lprint “+bx”；chr$(27)；“F”；
90 lprint chr$(27)；“S”；chr$(0)；chr$(15)；
100 lprint “2”；
110 lprint chr$(27)；“T”；chr$(18)；
120 lprint chr$(27)；“E”；
130 lprint “+cx+d”；

程序中，有些语句纯粹是送“esc控制码”，控制后续字符的打印字体风格。打印机上没有字库，打印的内容要从主机一方送去，打印效率就不高。这时的打印机不支持汉字，但后来有国人把这类打印机作了汉化，在主机上设置汉字的点阵字库，需要时，程序从计算机一方找出要用的汉字点阵码序列，逐个字节往打印机送，最终以点阵形式把汉字打印在打印纸上。一个16х16点阵的汉字，将要陆续送32个字节，速度很慢，效率自然很低。

还有一个问题是这时期的某些打印机要在计算机主板上插打印卡并要对打印卡及打印机上的开关和跳线进行设置，这要掌握相关知识，操作也需要时间和耐心。比如一种叫P1351的寬行打印机（性能上超过了FX-100），就要设置主机与打印机的通讯协议，要对各8位的三组开关进行设置。S1设置工作方式on/off，s3用于设置停止位个数，s4对应波特率设置。

1984年，上级给过笔者所在单位多台Brother公司的打印机，其性能也要优于FX-100。可惜那种打印机用110伏交流电源，在不小心用220伏接连烧了两台后，便不敢放手使用而束之高阁了，许多人都没有见到过它们。

有了点阵打印，除了打印数据和文字，打印图形和实线以及表格就有了可能。比如微机出现后，80年代我们就有过如图所示的BASIC语言图形打印。

继9针打印之后，1989年，日本率先推出了一款配装16х16点阵，寬行132行的打印机，特别是它自带GB-2312 16х16 “汉字硬字库”。这款打印机，一下子便从提高打印质量和打印速度并能够打印汉字三个方面都有显著发展，是一款当时最先进的打印机。湖南省电子×××从日本引进了这款打印机，而且购买了销售权。凭借他们当时较强的技术实力，拟自己开发驱动程序，将该款打印机命名为HN1600P（即湖南1600P），想借此闯出品牌。可是技术发展实在快，商业竞争无情。日本人在转让了这款打印机后不久，就在市面上推出了24针同类型寬行打印机，其中一款是EPSON LQ-1600K。这种打印机也自带GB-2312 24×24汉字硬字库。字体字形、打印速度和使用的方便性又升级了，它对HN1600P构成了致命的打击。而我们的×××，却没能及时开发出打印软件，让它变成商品。香饽饽没有吃下去，反而如鲠在喉，很被动。笔者听说上述情况后，实地去看到一大批那么好的打印机搁置在库房里，由于缺乏驱动程序，没有软件，无法使用，无法销售，也无法宣传。别人的24针打印机已面市，后续开发也失去了意义，HN1600P就不得不被无形地“枪毙”了。可惜了这么好的打印机，连“昙花一现”的机会都没有。

很多同行都没有用过16针这个档次的打印机，也不知道有HN1600P。不知您是否想过，在打印机历史上何以没有16针打印机的地位？

在回顾电子计算历史的时候，这款产品的命运能够提供启示，从技术层面，直接看到软件的重要性。还可以看到，影响技术发展的因素是多方面的，“市场”也是一只无形的手。由此联想，各个时期处在不同层面的决策者，管理者们，是不是有许多案例和经验教训可以梳理呢。

笔者前面介绍过的水文流速仪检定课题用的就是这样一台打印机HN1600P，那就自己试着写打印驱动程序吧。当然这并不轻松，对自己的技术和耐心都是一种挑战。后来的情况是，这样做苦头多于快乐，教训多于经验。不顺心的时候笔者还曾经后悔过，何必为了省那点钱而自讨苦吃呢？

我们用8086/8088汇编语言，写了1000多行的驱动程序。程序一开始就要修改中断向量表，保留MS-DOS的INT 17H原来的打印驱动程序入口地址，把我们的程序入口地址写入中断向量表中，让机器改用我们自编的打印驱动程序。经过努力，最终很好地满足了课题的打印要求。还写了一个分页、折叠打印程序。把132行的整个页面分作两栏作为左右两个页面，左右两个页面逐行同时打印，可以折叠起来装订，起到了节省纸张，便于携带的作用。打印指定文本文件时既可以不带行号，也可以加行号。这个功能是我们独立开发的，在其它打印机上没有，起码当时还没有。

在PC机没有连网前，要是每台PC配备一台打印机，太浪费了。为了发挥打印机的效益，人们要停机拔插打印机，搬来搬去，让打印机在机房里为多人陆续使用。后来人们开发打印共享器，把4台主机的打印机接口连接到打印共享器上，经打印共享器再与打印机相连。共享器上的芯片将各主机启动的打印作业排队，逐个作业打印出去。这样打印机可以不动，同时归四个人使用。我校实验中心唐民山、张学丽、华晓丽等同事就开发过打印共享器。

90年代后随着网络技术的发展，共享打印机就变得容易了，只要把打印机设置为网络打印机，就可以在局域网上共享。

90年代有一款松下1121针式打印机。打印宽度窄行80行，有24х24点阵汉字字库，价格只1500元左右，很适合家用。笔者和同事家的都用了好几年。唯一的毛病就是噪音太大，让隔壁房间也听着不舒服，老伴常提意见，要我等会儿再打。在有了一台彩喷打印机后对它才冷落下来。有意思的是，这款打印机十多年后市场上还在卖，价格都保持在1500元左右，这在IT市场上是不是也创下一个奇迹呢。

笔者用的彩色喷墨打印机是EPSON PHOTO 895，EPSON公司把它定位为中档的喷打。用喷墨打印机便进入了可以打印照片的时期。这款机器最显著特点是打印彩色照片，色彩不太鲜艳，显得比较自然。

彩色喷打除了有漂亮的打印效果，还有一个显著优点就是没有噪音，它默默无闻地埋头工作。使用界面大为改善，打印机上有几个按钮，可以控制走纸、更换墨盒、打印测试页。与主机用USB口连接，十分方便。还配备了一组实用程序，可以随时显示墨水余量，提供喷头、喷嘴检测、清洗等功能。更换墨盒的操作也完全在软件帮助下交互地进行。这种机器使用确实方便。

彩喷的最大问题是墨盒贵，消耗大。EPSON原装墨盒黑色加彩色要200多块钱一套，而且常年保持这个价。打印一张A4纸的文档成本要几毛钱，照片要几块钱。EPSON公司规定只能用它的原装墨盒才能得到保修和相关服务，我一直按照其要求，用原装墨盒。后来看到其它打印机用兼容墨盒也可以，而且我们2000年在办公室使用佳能喷墨机的时候曾经用针头注墨水，也能打印。于是也曾经买来兼容墨盒用在我的打印机上，果然可以打。不过当打印机出了问题往EPSON维修站送修的时候，发生过这种情况，当修好取机时，他试机确实好了。可是笔者到附近电脑城的小店子买了一对兼容墨盒，不能正常打印，只好回头找他，说还没修好。维修人员改用他们的原装墨盒试机却又是好的。他说是我的墨盒问题，我只好当场再次购买了EPSON原装盒，它又好了，这让笔者信服用原装墨盒的必要性。

彩喷打印机还有一个问题，长时间不用时，打印头可能会乾，导致喷嘴堵塞。08年暑假没有用它，秋天黑色打印不连续了。先电子邮件问EPSON公司，工程师回答清洗不干净可以先等等，因为打印机有一定自己溶解干枯墨水功能。后来不行，只好送修。没想到搞了几天，最后结论是无法修好了，要400多元换主板。而这是一台新喷打的价格，失去了修理价值。坏事变好事，该用激光打印机了。

1983年，Adobe公司提出PostScript页面描述语言，1985年用于激光打印机。90年代中期后激光打印机流行起来。这种打印机将激光扫描技术和电子照相技术相结合。其基本工作原理是由计算机传来的对页面的二进制数据信息，通过视频控制器转换成视频信号，再由视频接口/控制系统把视频信号转换为激光驱动信号，然后由激光扫描系统产生载有字符信息的激光束，最后由电子照相系统使激光束成像并转印到纸张上。

激光打印机字体质量最高，也没有噪音。笔者现在使用的家用激光打印机是HP LaserJet P1007。它也用USB接口，打印机上有2MB内存，266MHz的处理器，故打印速度也高，每分钟14页。纸盒上可以放150页A4纸。这款打印机当年购买价只840元，故性价比很高。软件也不错。有了它放在家里，打印完全不成问题了。想打什么就打什么，文档、照片、网页等，在相应界面上想打就启动打印好了。也不用担心假期一两个月不用，碳粉会乾掉。

这台打印机的故事是，它是越南产的，在包装盒上明明白白印了“越南产品”（图）。这是我在中国首次接触和购买越南产品，而不是“Made in China”。也是惠普公司把在中国的生产转向劳动力更低廉的越南的一个例证。

彭民德《电子计算60年》

书单

偷偷看下你的书单

通关！游戏设计之道（第2版）

作者：Scott Rogers

书号：978-7-115-43177-6

图灵社区推荐：6

本书以诙谐的语言讲述了游戏设计的整个流程，包括剧情、设计文档、游戏策略、人物角色、玩法等内容，100多张趣味盎然的卡图手绘图，不仅给读者带来视觉享受、阅读快感，还有助于理解游戏设计的制作奥秘；同时书中也细述了游戏中的常见错误。相对于第1版，书中诸多案例都进行了更新。

Scott Rogers是迪士尼创意师，曾任THQ创意经理。代表作有《吃豆人世界》《魔界英雄记》系列、《战神》《描绘生命》系列以及《暗黑血统》等。

图灵访谈曾对Scott进行了专访

深入React技术栈

作者：陈屹

书号：

978-7-115-43730-3

图灵社区推荐：8

本书从几个维度介绍React。一是作为View库，它怎么实现组件化，以及它背后的实现原理。二是扩展到Flux应用架构及重要的衍生品Redux，它们怎么与React结合做应用开发。三是对React与server的碰撞产生的一些思考。四是讲述它在可视化方面的优势与劣势。

本书适合有一定经验的前端开发人员阅读。

你不可不知的50个化学知识

作者：海利·伯奇

书号：978-7-115-43685-6

图灵社区推荐：4

这里没有让你头疼的大堆化学方程式。只呈现化学中种种有趣或有用的事情。

通过化学中50个重要的概念，你将看到化学是如何理解世界的（原子、能量、化学平衡、氧化还原等）、化学家有哪些武器可用（分离、电解、晶体学、计算化学等），以及化学如何深入我们生活的每个角落（汽油、塑料、药物、复合材料等）并致力于创造一个更美好的世界（绿色化学、3D打印、人造肌肉、未来燃料等）。

此外，你还将了解到种种有趣的小知识，比如植物怎样用糖计时、什么时候吃巧克力最好、未来解决手机电池短板的希望在哪里等。

Python科学计算基础教程

作者：Hemant Kumar Mehta

书号：978-7-115-43698-6

图灵社区推荐：9

本书是将Python用于科学计算的实用指南，既介绍了相关的基础知识，又提供了丰富的精彩案例，并为读者总结了最佳实践经验。包括：科学计算的基本概念与选择Python的理由，科学工作流和科学计算的结构，科学项目相关数据的各个方面，用于科学计算的API和工具包，如何利用Python的NumPy和SciPy包完成数值计算，用Python做符号计算，数据分析与可视化，并行与大规模计算，等等。

走进大数据：组织如何推进大数据战略

作者：张东麟

书号:978-7-115-43805-8

图灵社区推荐：1

C++程序设计实践与技巧：测试驱动开发

作者：Jeff Langr

书号：978-7-115-43895-9

图灵社区推荐：6

数据架构：大数据、数据仓库以及Data Vault

作者：W.H. Inmon，Daniel Linstedt

书号：978-7-115-43843-0

图灵社区推荐：2

设计的细节：日本经典设计透析

作者：日经设计

书号：978-7-115-43450-0

图灵社区推荐：3

精简：无印良品与品牌理念打造

作者：江上隆夫

书号：978-7-115-43303-9

图灵社区推荐：3

爱上电子学：创客的趣味电子实验（第2版）

作者：Charles Platt

书号：978-7-115-43686-3

图灵社区推荐：7

电子书榜

1. 普林斯顿微积分读本（修订版）

——这本经典著作源于风靡美国普林斯顿大学阿德里安·班纳教授的微积分复习课程，将易用性与可读性以及内容的深度与数学的严谨完美地结合在了一起。

2. 数据科学入门

——基于易于理解且具有数据科学相关的丰富的库的Python语言环境，从零开始讲解数据科学工作。

3. 进化：从孤胆极客到高效团队

——本书独树一帜，列出了软件开发者学习团队协作以及成为优秀团队成员所需的所有关键内容。业界早就需要这样一本书，如今它终于面世了！

4. 垃圾回收的算法与实现

—— Ruby之父松本行弘推荐：上古传承之魔法，彻底揭开垃圾回收的秘密！ 日本天才程序员兼LISP黑客竹内郁雄审校。

5. GitHub入门与实践

——从Git的基本知识和操作方法入手，详细介绍了GitHub的各种功能，GitHub与其他工具或服务的协作，使用GitHub的开发流程以及如何将GitHub引入到企业中。

6. HTTPS权威指南：在服务器和Web应用上部署SSL/TLS和PKI

——集理论、协议细节、漏洞分析、部署建议于一体的详尽Web应用安全指南。

7. Java性能权威指南

——通过使用JVM和Java平台，以及Java语言和应用程序接口，详尽讲解了Java性能调优的相关知识，帮助读者深入理解Java平台性能的各个方面。

8. 社会工程：安全体系中的人性漏洞

——从攻击者的视角详细介绍了社会工程的所有方面，包括诱导、伪装、心理影响和人际操纵等，并通过凯文•米特尼克等社会工程大师的真实故事和案例加以阐释。

9. Linux命令行与shell脚本编程大全（第3版）

——关于Linux命令行和shell命令的全面参考资料，涵盖详尽的动手教程和实际应用指南，并提供相关参考信息和背景资料，带你从Linux命令行基础入手，直到写出自己的shell。

10. Spring Boot实战

——本书堪称Spring Boot的完美指南，面面俱到地介绍了Spring Boot特性。Spring最新的发展也都被Craig精进了此书中。

妙评

注意力的秘密

最近在看一本书《注意力--专注的科学与训练》，我看了一下豆瓣的点评，非常少，而且评分比较低。读完这本书，我却有不同的观点。这本书对我的帮助很大，我也很有收获。

我是一个不太注重细节的人，领导总是能从我做的文档里发现不完善的内容，很多细节方面的忽视。我也一直很苦恼，一直想要改进，但是一直找不到很好的办法。看完这本书，我释然了。人的注意力不是平均分配的，我们对有些东西比较敏感，而会忽视另一些东西。这对于每一个人来说都是很正常的现象。

文章中举了一个例子，我觉得非常形象。假如一个乐队在打鼓，鼓手零零散散的鼓点，根本不会引起我们的注意，但是如果所有的鼓手同时打鼓，就能吸引注意。其实有时候有些东西对我来说就如同背景，他们不是吸引我的东西，所以我就会忽视，但是他们却能引起我领导的注意，他们对领导来说并不是背景，而是一个个具体的事物。前些日子，我听到了一种说法，说读书要只字不差的阅读，我以为自己抓住了关键的法宝，认为改善注意力的问题就是要只字不差地阅读吧。现在，我对这种说法也是有疑问的，其实即使是只字不差地阅读，也会忽视很多关键的内容，这些关键的内容就如同背景，我们看过就忽视了。只有再次阅读，或者带着疑问阅读，这些曾经忽视的内容才会凸显，引起我们的注意。我觉得，要改掉忽视细节的毛病，关键在于带着问题去观察，多看几遍，才能把忽视的背景变成引起注意的东西。

还有一点让我释然的是，分心其实也是对人有帮助的。只有被那些更重要的东西所吸引，才能帮助我们躲避灾害。如果只是关注眼前的东西，我们就无法关注到周围潜在的危险，耳听六路、眼观八方，对周围的事物保持着警惕，才能躲避灾害。有时候，我很羡慕一些人能全神贯注地看书，羡慕一些人达到了心流的状态，全身心地投入到某件事情，专注当然可以给我们带来幸福感，可以让我们忘我，全心投入到外部世界中，但是过于专注有时候也不完全是一件好事。当然，随着周围环境的变动，注意力不断地改变，忘记了真正重要的事情，也不好，这样的人会变成周围环境的奴隶。因此达到两者之前的平衡是最好的，专注于手头的事情，同时也对周围的环境保持一定的关注和警惕。

令我们分心的不仅仅是外界环境，还有我们自己的内心。这让我对“正念”有了新的理解。我看过一些关于冥想的书，也听过一些佛教的打坐内容，知道冥想就是把自己的注意力全部集中在呼吸上面，感觉自己的念头升起再消失。这本书讲到我们会时刻同自己的内心对话，头脑中时时刻刻都会有自我对话的出现。打坐或冥想的目的是让注意力放到这些无时不刻出现的念头上，渐渐放空，减少杂念，专注在呼吸上面。本质上，我们是一种和环境互动的物种，不一定非要到达全神贯注的入定状态，活在当下，能把杂念放在一边，专心做好手头的事情就好。就像得道禅师说的，我以前做饭的时候想着砍柴，砍柴的时候想着挑水，挑水的时候想着做饭，但等真正参悟以后，砍柴的时候就砍柴，挑水的时候就挑水，做饭的时候就做饭。注意不只是关注某一点，而是活在当下，与当下的环境互动，内心没有一种妄念，不会肆意奔腾，做好眼前的事情。

预测能力，我们喜欢拥有一种掌控感。以前，我对自己的人生患得患失，很想抓住未来，规划好未来。当生命中出现了无法预测的变故时，加之对《蒋勋说红楼梦》里第五回判词分析的认同，一时间我觉得我们完全没有办法掌控自己的未来，人生如浮萍。一切都是上天安排好的，我们根本没有办法改变既定的人生和未来。本书从大脑的构造角度，分析人比动物高级的地方在于人类可以预测未来，可以规划自己的生活；人能吸取教训，也能对行为可能产生的后果做出判断；预测到可能产生的结果后，人可以对自己的行为加以控制。这是拥有健全大脑的人类都会有的思考和分析能力。这本书从生物性的特点分析了这一特性，是一种全新的视角。我们喜欢预测未来，喜欢一种掌控感，可以说这是人的天性。没有必要完全否定它，但是却不能依赖它。经历告诉我们，未来不能完全被掌控，人生有基因的决定，有自然规律的决定，有生物钟的决定，四季轮回是自然法则。举个例子，没有必要为了抓住未来，在双十一囤积东西，如果等东西都快用完的时候，也不要什么都不买。要掌握一个度和一种平衡。

此外，这本书还谈到了学习机制。我一直苦恼于如何找一种有效的学习方法，也在尝试不同的方式，就拿读书来说吧，我尝试过王者速读法和只字不差阅读法，两种完全对立的阅读方式。其实，这两种方式都不是最好的读书方式。本书告诉我们，最佳的学习方式是从局部到整体，就是把书由厚变薄，反复练习。初学的时候，我们要对细节进行反复地练习，不断演练，只有这样神经元才会被建立起来，大脑也才会渐渐适应这种刺激。等到熟练掌握了某项技能之后，我们的大脑会慢慢形成网络，一些原来不习惯的行为和动作就会渐渐变成无意识的或者是下意识的行为，动作也会变得连贯，这时我们不再注重细节，而是尽力把握整体。看书也是这样，一开始，书中的知识是陌生的，我们会关注细节，觉得某些部分对我们特别有帮助，而其他部分可能会无聊，但等到多次阅读之后，我们会渐渐熟悉书中的文字，在某些曾经不懂的地方慢慢明白，对内容有一个整体的概念。这也是Scott Young的整体学习法。但是，整体学习法并不是一蹴而就的，网络也不是一下子就能形成的，我们要由点到面地慢慢形成，先是细节知识，强化掌握之后，慢慢形成网络。

最重要的，这本书谈到了如何提高注意力。冥想和正念是可以提高注意力的，对于不学禅不打坐的人来说，我们可能不太愿意学习冥想。幸运的是，本书提到了一种气泡原理用于提高专注度和效率。所谓的气泡原理就是说在一个时间段里，只考虑一个目标，只做一件事情。专注地处理这一件事情，其他的长期目标虽然也有，但不在目前的视线范围之内，不在考虑范围之内，我们暂时忘记它。大脑就像被隔离了，短暂的时间里只考虑眼前的事情。虽然日常生活中有很多的烦心事，有很多的杂念，但是我们只关注眼前的气泡。这有点类似番茄工作法，就是说每25分钟内我们只专注眼下的事情。也类似于Getting thing done里面的清空大脑。我大概是从2012年开始接触番茄工作法和清空大脑释放压力法的，从那以后，我就变得非常轻松了。我把每天要做的事情列入doit.im里面，每做完一件就划掉一项记录。我变得不再瞻前顾后，什么时间做什么事，做事的时候基本上也是完全投入，虽然偶尔也会分心，但比起过去的混乱状态好了很多。我觉得书中介绍的方法，可以提高注意力。做事情时，内心不再焦虑，安稳、平和。

书中最后谈论的内容比较专业，介绍了多巴胺和去甲肾上腺素这两种神经递质对人的作用。多巴胺和去甲肾上腺素对人的影响呈现倒U型特征。多巴胺和去甲肾上腺素过少，人容易无精打采，昏昏欲睡，没有动力和激情，这时候两种递质和X受体结合；当压力来临的时候，大脑会释放多巴胺和去甲肾上腺素神经递质，在一定的范围之内，这两种神经递质和Y受体结合，这时候人的注意力高度集中，工作记忆也能发挥出较好的水平，呈现不错的状态。但是如果达到临界点之后，分泌过多的多巴胺和去甲肾上腺素就和Z受体结合，大脑思维混乱，睡眠也会受到影响。

《注意力》的科学解释，解开了我多年的疑惑。阅读过程中，虽然在大脑构造部分会有困难，总体来说，这本书还是很有帮助的，至少我对注意力有了科学的认识，真正明白了如何把握好自己的注意力，如何缓解压力，清空大脑。它是一本值得推荐的书，我还会再次阅读这本书！

本篇文章来自豆瓣读书：https://book.douban.com/review/8189975/

看完了

如果您对本书内容有疑问，可发邮件至contact@turingbook.com，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@turingbook.com。

在这里可以找到我们：

- 微博 @图灵教育 : 好书、活动每日播报

- 微博 @图灵社区 : 电子书和好文章的消息

- 微博 @图灵新知 : 图灵教育的科普小组

- 微信 图灵访谈 : ituring_interview，讲述码农精彩人生

- 微信 图灵教育 : turingbooks

图灵社区会员 Dastan（dastan@139.com） 专享 尊重版权