Linux多线程服务端编程:使用muduo C++网络库 学习笔记 第十二章 C++经验谈(一)

作者对C++的基本态度是“练从难处练,用从易处用”,因此本章有几节“负面”的内容。作者坚信软件开发一定要时刻注意减少不必要的复杂度,一些花团锦簇的招式玩不好反倒会伤到自己。作为应用程序的开发者,对技术的运用要明智,不要为了解决难度系数为10的问题而去强攻难度系数为100的问题,这就本末倒置了。

12.1 用异或来交换变量是错误的

反转一个字符串,例如把“12345”变成“54321”,这是一个最简单不过的编码任务,即便是C语言初学者也能毫不费力地写出类似如下的代码:

// 版本一,用中间变量交换两个数,好代码
void reverse_by_swap(char *str, int n)
{
    char *begin = str;
    char *end = str + n - 1;
    
    while (begin < end)
    {
        char tmp = *begin;
        *begin = *end;
        *end = tmp;
        ++begin;
        --end;
    }
}

上面这段代码清晰,直白,没有任何高深的技巧。不知从什么时候开始,有人“发明”了不使用临时变量交换两个数的办法,用关键词“不用临时变量 交换 两个数”在Google上能搜到很多文章。下面是一个典型的实现:

// 版本二,用异或运算交换两个数,烂代码
void reverse_by_xor(char *str, int n)
{
    // WARNING: BAD code
    char *begin = str;
    char *end = str + n - 1;
    
    while (begin < end)
    {
        *begin ^= *end;
        *end ^= *begin;
        *begin ^= *end;
        ++begin;
        --end;
    }
}

受一些过时的教科书的误导,有人认为程序里少用一个变量,节省一个字节的空间,会让程序运行得更快。这是不对的,至少在这里不成立:
1.这个所谓的“技巧”在现代机器上只会更慢(作者甚至怀疑它从来就不可能比原始办法快)。原始办法是两次内存读和写(每次通过指针访问是一次读或写内存),这个“技巧”是六读三写加三次异或(或许编译器可以优化成两读三写(两读指第一次通过begin和end指针读值,三写指三次通过指针的赋值操作)加三次异或)。

2.同样也不能节省内存,因为中间变量tmp通常会是寄存器(稍后有汇编代码供分析)。就算它在函数的局部堆栈(stack)上,反正栈已经开在那儿了,也没有进一步的函数调用,根本节约不了一丁点内存。

3.相反,由于计算步骤较多,会使用更多的指令,编译后的机器码长度会增加(这不是什么大问题,短的代码不一定快,后面有另外一个例子)。

这个技巧的意义完全在于应付无聊的面试,所以知道就行,但绝对不能放在产品代码中。作者也想不出问这样的面试题意义何在。

更有甚者,把其中三句:

*begin ^= *end;
*end ^= *begin;
*begin ^= *end;

写成一句:

*begin ^= *end ^= *begin ^= *end;    // WRONG

这更是大有问题,会导致未定义的行为(undefined behavior)(gcc的作者明说这种写法是undefined的,见http://gcc.gnu.org/bugzilla/show_bug_cgi?id=39121)。在C/C++语言的一条语句中,一个变量的值只允许改变一次(像x = x++这种代码都是未定义行为,因为x有两次写入(http://www.stroustrup.com/bs_faq2.html#evaluation-order))。在C/C++语言里没有哪条规则保证这两种写法是等价的(致语言律师:作者知道,黑话叫序列点(序列点用于描述在程序执行过程中,多个子表达式之间的相对顺序,在序列点之前的所有子表达式都会在序列点之前被求值,并且在序列点之后的所有子表达式都会在序列点之后被求值)(GCC 4.x有一个编译警告选项-Wsequence-point可以报告这种错误),一个语句可能不止一个序列点,请允许作者在这里使用不精确的表述)。

这不是一个值得炫耀的技巧,只会丑化、劣化代码。

C++对翻转字符串这个问题有更简单的解法——调用STL里的std::reverse()函数。有人担心调用函数会有开销,这种担心是多余的,现在的编译器会把std::reverse()这种简单函数自动内联展开,生成出来的优化汇编代码和“版本一”一样快。

// 版本三,用std::reverse颠倒一个区间,优质代码
void reverse_by_std(char *str, int n)
{
    std::reverse(str, str + n);
}

12.1.1 编译器分别生成什么代码

注意:查看编译器生成的汇编代码固然是了解程序行为的一个重要手段,但是千万不要认为看到的东西是永恒真理,它只是一时一地的真相。将来换了硬件平台或编译器,情况可能会变化。重要的不是为什么版本一比版本二快,而是如何发现这个事实。不要“猜(guess)”,要“测(benchmark)”。

以g++版本4.4.1,编译参数-O2 -march=core2(选项-march=core2用于指定生成的目标代码针对Intel Core 2架构进行优化),x86 Linux系统为例。

版本一

版本一编译得到的汇编代码是:
在这里插入图片描述
上图中,.L3:是一个标签,在汇编中用于标识代码的特定位置,这里用作一个循环的起始点。movzbl是x86汇编语言中的一条指令,全称是“Move with Zero extend from Byte to Long”,用于将一个字节(8位)的值零扩展(高位用0填充)为32位,并将结果存储到目标寄存器中。在汇编中,%用于表示寄存器,在x86汇编语言中,%edx是32位寄存器,它是16位寄存器%dx的32位扩展版本;%ecx也是32位寄存器,它是16位寄存器%cx的32位扩展版本。%edx表示寄存器中的值,而(%edx)表示间接寻址,即把寄存器中的值当作地址,访问该地址的值。因此movzbl (%edx), %ecx表示将寄存器%edx中存的地址处取出1个字节零扩展为32位存到寄存器%exc处。在x86汇编语言中,movb是x86汇编语言中的一条指令,用于将一个字节(8位)的数据从源操作数移动到目标操作数。%bl寄存器是%bx的低8位,也是%ebx的低8位,这是因为,%bx是%ebx的低16位,而%bl是%bx的低8位,即x86架构中,寄存器是分层设计的。因此movb %bl, (%edx)表示将8位寄存器%bl中的值复制到寄存器%edx表示的地址处。因此循环的前4行表示从内存中取两个值放到寄存器,然后将寄存器的值放回到对方的内存中,完成两值互换。incl %edx表示将寄存器%edx中的值加1,即移动到下一个地址。decl %eax表示将寄存器%eax中的值减1,即移动到前一个地址。cmpl %eax, %edx表示比较寄存器%eax和%edx中的值。jb指令在无符号数比较中表示“跳转如果低于(jump if below)”,即如果%eax小于%edx,则跳转到标签.L3。

用C语言翻译一下:

register char bl, cl;
register char *eax;
register char *edx;

L3:
cl = *edx; // 读
bl = *eax; // 读
*edx = bl; // 写
*eax = cl; // 写
++edx;
--eax;
if (edx < eax) goto L3;

一共两读两写,临时变量没有使用内存,都在寄存器里完成。考虑指令级并行(Instruction-Level Parallelism,ILP,它的目标是在不增加时钟周期的情况下提高指令的执行速度,即同一时间执行多条指令)和cache的话,中间六条语句估计能在三四个周期执行完。

版本二
在这里插入图片描述
上图中,xorb是x86汇编指令集中的一条指令,用于执行字节级别的逻辑异或运算,并将结果存储到第二个参数处。在x86架构的汇编语言中,%cl是%ecx的低8位。上图第1行是将%edx指向的地址(相当于end指针)处取1个字节零扩展到%ecx中(为方便表述,将该字节标识为字节a),然后在第2行中,将取出来的值的低8位与%eax指向的地址(相当于begin指针)处的值(将该字节标识为字节b,我们要做的是交换字节a和b的值,注意字节b没有被存到寄存器中)进行异或,然后存到%cl(即%ecx的低8位)中。这是前两行所做的工作:将要交换的两个数异或,异或的结果在寄存器%cl里。第3行将异或的结果存到%eax指向的地址处(此地址字节b的地址)。第4行将异或的结果与%edx指向的地址处的值进行异或,结果存到%cl中,此时%cl中的值就是字节b的值。第5行将%cl的值存到字节a的地址处。第6行将%edx递减,即移动到字节a的前一个地址处。第7行将%cl的值(字节b的值)与%eax指向的地址处的值(字节a和字节b的异或的结果)进行异或,结果是字节a的值,将其存到%eax指向的地址处(即一开始字节b所在的地址处)。第8行递增%eax,即移动到字节b的下一个地址处。第9行比较%edx和%eax(即两个地址)的值,如果%edx的值较小,则跳转到.L9标签处。

C语言翻译:

// 声明与前面一样
cl = *edx;    // 读
cl ^= *eax;    // 读,异或
*eax = cl;    // 写
cl ^= *edx;    // 读,异或
*edx = cl;    // 写
--edx;
*eax ^= cl;    // 读,写,异或
++eax;
if (eax < edx) goto L9;

一共六读三写三次异或,多了两条指令。指令多不一定就慢,但是这里异或版实测比临时变量版慢许多,因为它的每条指令都用到了前面一条指令的计算结果,没法并行执行。

版本三

生成的代码与“版本一”一样快。
在这里插入图片描述
上图与版本一中的指令相同。

这告诉我们,我要想当然地优化,也不要低估编译器的能力。关于现在的编译器有多聪明,Felix von Leitner有一个不错的介绍(http://www.linux-kongress.org/2009/slides/compiler_survey_felix_von_leitner.pdf)。

Bjarne Stroustrup说过:“我喜欢优雅和高效的代码。代码逻辑应当直戳了当,叫缺陷难以隐藏;尽量减少依赖关系,使之便于维护;以某种全局策略一以贯之地处理全部出错情况;性能调校至接近最优,省得引诱别人实施无原则的优化(unprincipled optimizations),搞出一团乱麻。整洁的代码只做好一件事。”(译文引自韩磊翻译的《代码整洁之道》,笔者对文字略有修改)

这恐怕就是Bjarne提及的没有原则的优化,甚至根本连优化都不是。代码的清晰性是首要的。

12.1.2 为什么短的代码不一定快

12.3将会谈到负整数的除法运算,其中引用了一段把整数转为字符串的代码。函数反复计算一个整数除以10的商和余数。作者原以为编译器会用一条DIV除法指令来算,实际生成的代码让作者大吃一惊:
在这里插入图片描述
在这里插入图片描述
一条DIV指令被替换成了十来条指令,编译器不是傻子,必然有原因。这里作者不详细解释到底是怎么算的,基本思路是把除法转换为乘法,用倒数来算。其中出现了一个魔数1717986919,转换成十六进制是0x66666667,等于(2 33 ^{33} 33+3)/5。

现代处理器的乘法运算和加减法一样快,比除法快一个数量级左右,编译器生成这样的代码是有理由的。十多年前出版的巨著《程序设计实践》[TPoP]中介绍过如何做micro benchmarking(主要关注程序的细节和局部性能,通常会对一小段代码进行多次执行,然后计算平均值和统计数据,以获得更准确的性能度量指标),方法和结果都值得一读,当然里边的数据恐怕有点过时了。

有本奇书《Hacker’s Delight》(中译本《高效程序的奥秘》),展示了大量这种速算技巧。其中第10章专门讲整数常量的除法。作者不会把其中如天书般的技巧应用到产品代码中,但是作者相信现代编译器的作者是知道这些技巧的,他们会合理地使用这些技巧来提高生成代码的质量。现在已经不是那个懂点汇编就能打败C/C++编译器的时代了。

Mark C. Chu-Carroll有一篇博客《The “C is Efficient” Language Fallacy》的观点作者非常赞同,即用清晰的代码表达程序员的意图,让编译器容易实施优化。

Making real applications run really fast is something that’s done with the help of a compiler. Modern architectures have reached the point where people can’t code effectively in assembler anymore — switching the order of two independent instructions can have a dramatic impact on performance in a modern machine, and the constraints that you need to optimize for are just more complicated than people can generally deal with.
So for modern systems, writing an efficient program is sort of a partnership. The human needs to careful choose algorithms — the machine can’t possibly do that. And the machine needs to carefully compute instruction ordering, pipeline constraints(流水线约束,指在计算机的流水线架构中,由于指令之间的依赖关系和硬件限制而导致的执行顺序约束), memory fetch delays(内存获取延迟,由于内存访问速度较慢和内存局部性,需要尽量较少内存访问次数,并优化数据的访问模式), etc. The two together can build really fast systems. But the two parts aren’t indenpendent: the human needs to express the algorithm in a way allows the compiler to understand it well enough to be able to really optimize it.

最后,说说C++模板。假如要编写一个任意进制的转换程序。C语言的函数声明是:

bool convert(char *buf, size_t bufsize, int value, int radix);

既然进制是编译期常量,C++可以用带非类型模板参数的函数模板来实现,函数的代码与C相同。

template<int radix>
bool convert(char *buf, size_t bufsize, int value);

模板确实会使代码膨胀,但是这样的膨胀有时候是好事情,编译器能针对不同的常数生成快速算法。滥用C++模板当然是错的,适当使用不会有问题。

12.2 不要重载全局::operator new()

本文只考虑Linux x86平台,服务端开发(不考虑Windows的跨DLL内存分配释放问题)。本文假定读者知道::operator new()和::operator delete()是干什么的,与通常用的new/delete表达式有何区别和联系(new表达式会调用operator new()分配空间,然后再在构造的空间中构造对象),这方面的知识可参考侯捷先生的文章《池内春秋》[jjhou02]。

C++的内存管理是个老生常谈的话题,作者在第一章中简单回顾了一些常见的问题以及在现代C++中的解决办法。基本上,按现代C++的手法(RAII)来管理内存,你很难遇到什么内存方面的错误。“没有错误”是基本要求,不代表“足够好”。我们常常会设法优化性能,如果profiling(用于分析程序性能的一种技术,它通过记录程序在运行期间各个部分的执行时间、内存使用情况等信息来帮助开发人员找出性能瓶颈和优化的方向)表明hot spot(指程序中执行时间最长或者占用最多资源的代码区域)在内存分配和释放上,重载全局的::operator new()和::operator delete()似乎是一个一劳永逸的好办法(以下简写为“重载::operator new()”)。本节试图说明这个办法往往行不通。

12.2.1 内存管理的基本要求

如果只考虑分配和释放,内存管理基本要求是“不重不漏”:既不重复delete,也不漏掉delete。也就是说我们常说的new/delete要配对,“配对”不仅是个数相等,还隐含了new和delete的调用本身要匹配,不要“东家接的东西西家还”。例如:
1.用系统默认的malloc()分配的内存要交给系统默认的free()去释放。

2.用系统默认的new表达式创建的对象要交给系统默认的delete表达式去析构并释放。

3.用系统默认的new[]表达式创建的对象要交给系统默认的delete[]表达式去析构并释放。

4.用系统默认的::operator new()分配的内存要交给系统默认的::operator delete()去释放。

5.用placement new创建的对象要用placement delete(为了表述方便,姑且这么说吧)去析构(其实就是直接调用析构函数)。

6.从某个内存池A分配的内存要还给这个内存池。

7.如果定制new/delete,那么要按规矩来。见《Effective C++中文版(第3版)》[EC3]第8章“定制new和delete”。

做到以上这些不难,是每个C++开发人员的基本功。不过,如果你想重载全局的::operator new(),事情就麻烦了。

12.2.2 重载::operator new()的理由

[EC3,条款50]列举了定制new/delete的几点理由:
1.检测代码中的内存错误;

2.优化性能;

3.获得内存使用的统计数据。

这些都是正当的需求,后面我们将会看到,不重载::operator new()也能达到同样的目的。

12.2.3 ::operator new()的两种重载方式

1.不改变其签名,无缝直接替换系统原有的版本,例如:

#include <new>

void *operator new(size_t size);
void operator delete(void *p);

用这种方式的重载(重载需要多个同名函数的至少一个参数不同,但似乎上面的operator new()的重载与原有版本的相同?),使用方不需要包含任何特殊的头文件,也就是说不需要看见这两个函数声明。“性能优化”通常用这种方式。

2.增加新的参数,调用时也提供这些额外的参数,例如:

// 此函数返回的指针必须能被普通的::operator delete(void *)释放
void *operator new(size_t size, const char *file, int line);

// 此函数只在构造函数抛异常的情况下才会被调用
void operator delete(void *p, const char *file, int line);

然后用的时候是

// __FILE__和__LINE__是一个宏,在预处理阶段会被替换为字面值,分别表示当前源文件名和所在行数
Foo *p = new (__FILE__, __LINE__) Foo;    // 这样能跟踪是哪个文件哪一行代码分配的内存

我们可以用宏替换new来节省打字。用这里的第二种方式重载,使用方需要看到这两个函数声明,也就是说要主动包含你提供的头文件。“检测内存错误”和“统计内存使用情况”通常会用这种方式重载。当然,这不是绝对的。

在学习C++的阶段,每个人都可以写个一两百行的程序来验证教科书上的说法,重载::operator new()在这样的玩具程序里边不会造成什么麻烦。

不过,作者认为在现实的产品开发中,重载::operator new()乃是下策,我们有更简单、安全的办法来达到以上目标。

12.2.4 现实的开发环境

作为C++应用程序的开发人员,在编写稍具规模的程序时,我们通常会用到一些library。我们可以根据library的提供方把它们大致分为这么几大类:
1.C语言的标准库,也包括Linux编程环境提供的glibc(GNU C Library,是Linux系统中的标准C库)系列函数。

2.第三方的C语言库,例如OpenSSL(一个开源的密码学和安全套接字层工具包,它提供了一组常见的密码学函数和协议实现,用于网络通信和数据安全)。

3.C++语言的标准库,主要是STL(作者想,没有人在产品中使用iostream吧?)。

4.第三方的通用C++库,例如Boost.Regex(Boost C++库的一部分,提供了正则表达式的支持),或者某款XML(eXtensible Markup Language,一种用于存储和传输数据的标记语言)库。

5.公司其他团队的人开发的内部基础C++库,比如网络通信和日志等基础设施。

6.本项目组的同事自己开发的针对本应用的基础库,比如某三维模型的仿射变换(仿射变换是一类线性变换,包括平移、旋转、缩放、剪切等操作)模块。

在使用这些library的时候,不可避免地要在各个library之间交换数据。比方说library A的输出作为library B的输入,而library A的输出本身常常会用到动态分配的内存(比如std::vector<double>)。

如果所有的C++ library都用同一套内存分配器(就是系统默认的new/delete),那么内存的释放就很方便,直接交给delete去释放就行。如果不是这样,那就得时时刻刻记住“这一块内存是属于哪个分配器的,是系统默认的还是我们定制的,释放的时候不要还错了地方”。

由于C语言不像C++一样提供了那么多的定制性,C library通常都会默认直接用malloc/free来分配和释放内存,不存在上面提到的“内存还错地方”问题。或者有的考虑更全面的C library会让你注册两个函数,用于其内部分配和释放内存,这就能完全掌控该library的内存使用。这种依赖注入的方式在C++里变得花哨而无用,见笔者写的《C++标准库中的allocator是多余的》(http://blog.csdn.net/Solstice/archive/2009/08/02/4401382.aspx)。

但是,如果重载了::operator new(),事情恐怕就没有这么简单了。

12.2.5 重载::operator new()的困境

首先,重载::operator new()不会给C语言的库带来任何麻烦。当然,重载它得到的三点好处也无法让C语言的库享受到。一下仅考虑C++ library和主程序。

规则1:绝对不能在library里重载::operator new()

如果你是某个library的作者,你的library要提供给别人使用,那么你无权重载全局::operator new(size_t)(注意这是前面提到的第一种重载方式),因为这非常具有侵略性:任何用到你的library的程序都被迫使用了你重载的::operator new(),而别人很可能不愿意这么做。另外,如果有两个library都试图重载::operator new(size_t),那么它们会打架,作者估计会发生duplicated symbol link error(这还算是好的,如果某个实现偷偷盖住了另一个实现,会在运行时发生诡异的现象)。干脆,作为library的编写者,大家都不要重载::operator new(size_t)好了。

那么第二种重载方式呢?

首先

::operator new(size_t size, const char *file, int line)这种方式得到的void *指针必须同时能被::operator delete(void *)::operator delete(void *p, const char *file, int line)这两个函数释放。这时候你需要决定,你的::operator new(size_t size, const char *file, int line)返回的指针是不是兼容系统默认的::operator delete(void *)

如果不兼容(也就是说不能用系统默认的::operator delete(void *)来释放内存),那么你得重载::operator delete(void *),让它的行为与你的::operator new(size_t size, const char *file, int line)匹配。一旦你决定重载::operator delete(void *),那么你必须重载::operator new(size_t)(不是语法上的必须,而是逻辑上,如果你重载了delete操作来定制内存回收过程,大概率也要重载new以创建让定制的delete操作的内容),这就回到了规则1:你无权重载全局::operator new(size_t)。

如果选择兼容系统默认的::operator delete(void *),那么你在::operator new(size_t size, const char *file, int line)里能做的事情非常有限,比方说你不能额外动态分配内存来做house keeping或保存统计数据(无论显式还是隐式),因为系统默认的::operator delete(void *)不会释放你额外分配的内存(这里的隐式分配内存指的是往std::map<>这样的容器里添加元素)。看到这里,估计很多人已经晕了,但这还没完。

其次

在library里重载::operator new(size_t size, const char *file, int line)还涉及你的重载要不要暴露给library的使用者(其他library或主程序)。这里“暴露”有两层意思:
1.包含你的头文件的代码会不会用你重载的::operator new(),

2.重载之后的::operator new()分配的内存能不能在你的library之外被安全地释放。如果不行,那么你是不是要暴露某个接口函数来让使用者安全地释放内存?或者返回shared_ptr,利用其“捕获”析构动作(deleter)的特性(第一章)?

听上去好像挺复杂?这里就不一一展开讨论了。总之,作为library的作者,建议你绝对不要动“重载::operator new()”的念头。

事实2:在主程序里重载::operator new()的作用不大

这不是一条规则,而是作者试图说明这么做没有多大意义。

如果用第一种方式重载全局::operator new(size_t),会影响本程序用到的所有C++ library,这么做或许不会有什么问题,不过作者建议你使用12.2.6介绍的更简单的“替代办法”。

如果用第二种方式重载::operator new(size_t size, const char *file, int line),那么你的行为是否惠及本程序用到的其他C++ library呢?比方说你要不要统计C++ library中的内存使用情况?如果某个library会返回它自己用new分配的内存和对象,让你用完之后自己释放,那么是否打算对错误释放内存做检查(错误释放内存指用自己重载过的delete去释放library用原生new分配的内存,或用原生delete去释放用自己重载的new分配的内存(假如我们重载的new分配的内存不兼容原生delete))?

C++ library在代码组织上有两种形式:
1.以头文件方式提供(如以STL和Boost为代表的模板库);

2.以头文件+二进制库文件方式提供(大多数非模板库以此方式发布)。

对于纯以头文件方式实现的library,可以在你的程序的每个.cpp文件的第一行包含重载::operator new()的头文件,这样程序里用到的其他C++ library也会转而使用你的::operator new()来分配内存。当然这是一种相当有侵略性的做法,如果运气好,编译和运行都没问题;如果运气差一点,可能会遇到编译错误,这其实还不算坏事;如果运气更差一点,编译没有错误,运行的时候时不时地出现非法访问,导致segment fault;或者在某些情况下你定制的分配策略与library有冲突,内存数据损坏,出现莫名其妙的行为。

对于以库文件方式实现的library,这么做并不能让其受惠,因为library的源文件已经编译成了二进制代码,它不会调用你新重载的::operator new(想想看,已经编译的二进制代码怎么可能提供额外的new(__FILE__, __LINE__)参数呢?)。更麻烦的是,如果某些头文件有inline function,还会引起诡异的“串扰”。即library有的部分用了你的分配器,有的部分用了系统默认的分配器,然后在释放内存的时候没有给对地方,造成分配器的数据结构被破坏。

总之,第二种重载方式看似功能更丰富,但其实与程序里使用的其他C++ library很难无缝配合。

综上,对于现实生活中的C++项目,重载::operator new()几乎没有用武之地,因为很难处理好与程序所用的C++ library的关系,毕竟大多数library在设计的时候没有考虑到你会重载::operator new()并强塞给它。

如果确实需要定制内存分配,该如何办?

12.2.6 解决办法:替换malloc()

很简单,替换malloc()。如果需要,直接从malloc层面入手,通过LD_PRELOAD(Linux中的一个环境变量,程序运行时会优先加载此环境变量指定的共享库,例如,你可以创建一个共享库,重写系统库中的某个函数,然后使用LD_PRELOAD让程序在运行时加载你的共享库,这样就覆盖了系统库中的那个函数)来加载一个.so,其中有malloc/free的替代实现(drop-in replacement,指一个软件组件可以在无需修改原有代码或配置的情况下,直接替代另一个组件,这种替代通常是无缝的,因为新组件具有与原有组件相同的接口和行为),这样能同时为C和C++代码服务,而且避免C++重载::operator new()的阴暗角落。

对于“检测内存错误”这一用法,我们可以用valgrind(一个用于检测内存泄漏、内存错误和执行时间分析的开源工具,它主要用于C和C++程序的内存调试,通过模拟程序的执行,Valgrind能够捕捉程序运行时的一些错误,帮助开发者发现和解决潜在的内存问题)、dmalloc(全称是debug memory allocator,它提供了一种检测内存泄漏、越界访问、重复释放等问题的方法,它允许程序员通过替换标准的内存分配函数(如malloc、free等)来使用它的内存分配和释放机制)、efence(Electric Fence,电子围栏,它通过在程序中动态注入代码,使得内存区域变得不可写,从而能够帮助检测一些内存访问错误,例如越界访问、重复释放等问题)来达到相同的目的,专业的除错工具比自己“山寨”一个内存检查器要靠谱。

对于“统计内存使用数据”,替换malloc同样能得到足够的信息,因为我们可以用backtrace()函数(一个用于获取当前线程的调用堆栈信息的函数,它可以捕获函数调用的链条,包括调用的函数和它们的地址,从而提供了关于程序执行路径的信息)来获得调用栈,这比new(__FILE__, __LINE__)的信息更丰富。比方说你通过分析(__FILE__, __LINE__)发现std::string大量分配释放内存,有超出预期的开销,但是你却不知道代码里哪一部分在反复创建和销毁std::string对象,因为(__FILE__, __LINE__)只能告诉你最内层的调用函数。用backtrace()能找到真正的发起调用者。

对于“性能优化”这一用法,作者认为在目前的多线程开发中,自己实现一个能打败系统默认的malloc的内存分配器是不现实的。一个通用的内存分配器本来就有相当的难度,为多线程程序实现一个安全和高效的通用(全局)内存分配器超出了一般开发人员的能力。不如使用现有的针对多核多线程优化的malloc,例如Google tcmalloc(Thread-Caching Malloc,一种高效的内存分配器,它专为多线程环境和大规模的、长时间运行的服务器应用程序而设计,旨在提高内存分配和释放的性能)和Intel TBB(Threading Building Blocks,一个C++库,提供了高级的抽象和模板类,使得开发者能够更容易地实现并行化的程序,充分发挥多核处理器的性能)里的内存分配器(http://locklessinc.com/benchmarks_allocator.shtml)。好在这些allocator都不是侵入式的,也无需重载::operator new()。

12.2.7 为单独的class重载::operator new()有问题吗

与全局::operator new()不同,per-class operator new()和operator delete()的影响面要小得多,它只影响本class及其派生类。似乎重载member ::operator new()是可行的。作者对此持反对态度。

如果一个class Node需要重载member ::operator new(),说明它用到了特殊的内存分配策略,常见的情况是使用了内存池或对象池。作者宁愿把这一事实明显地摆出来,而不是改变new Node语句的默认行为。具体地说,是用factory来创建对象,比如static Node *Node::createNode()或者static shared_ptr<Node> Node::createNode()

这可以归结为最小惊讶原则:如果我在代码里读到Node *p = new Node,我会认为它在heap上分配了内存。如果Node class重载了member ::operator new(),那么我要事先仔细阅读node.h才能发现其实这行代码使用了私有的内存池。为什么不写得明确一点呢?写成Node *p = NodeFactory::createNode(),那么我能猜到NodeFactory::createNode()肯定做了什么与new Node不一样的事情,免得将来大吃一惊。

The Zen of Python(http://www.python.org/dev/peps/pep-0020)说“explicit is better than implicit”,作者深信不疑。

12.2.8 有必要自行定制内存分配器吗

如果写一个简单地只能分配固定大小的allocator,确实很容易做到比系统的malloc更快,因为每次分配操作就是移动一下指针。但是作者认为普通程序员很难写出可以与libc的malloc相媲美的通用内存分配器,在多核多线程时代更是如此。因为libc有专人维护,会不断把适合新硬件体系结构的分配算法与策略整合进去。在打算写自己的内存池之前,建议先看一看Andrei Alexandrescu在ACCU(Association of C and C++ Users,一个致力于促进和支持C和C++程序员的非营利性组织) 2008会议的演讲《Memory Allocation: Either Love It or Hate It (Or Think It’s Just OK)》(http://accu.org/content/conf2008/Alexandrescu-memory-allocation.screen.pdf)和论文《Reconsidering Custom Memory Allocation》(http://www.cs.umass.edu/~emery/pubs/berger-oopsla2002.pdfhttp://www.cs.umass.edu/~emery/talks/OOPSLA-2002.ppt)。

总结

重载::operator new()或许在某些临时的场合能应个急,但是不应该作为一种策略来使用。如果需要,我们可以从malloc层面入手,彻底替换内存分配器。

12.3 带符号整数的除法与余数

最近研究整数到字符串的转换,读到了Matthew Wilson的《Efficient Integer to String Conversions》系列文章(http://synesis.com.au/publications.html搜conversions)。他的巧妙之处在于,用一个对称的digits数组搞定了负数转换的边界条件(二进制补码的正负整数表示范围不对称)。代码大致如下,经过改写:

const char *convert(char buf[], int value)
{
    static char digits[19] = 
        {'9', '8', '7', '6', '5', '4', '3', '2', '1', 
         '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'};
    static const char *zero = digits + 9;    // zero指向'0'
    
    // works for -2147483648 .. 2147483647
    int i = value;
    char *p = buf;
    do 
    {
        // lsd - least significant digit
        int lsd = i % 10;    // lsd可能小于0
        i /= 10;    // 是向下取整还是向零取整?
        *p++ = zero[lsd];    // 下标可能为负
    } while (i != 0);
    
    if (value < 0)
    {
        *p++ = '-';
    }
    *p = '\0';
    std::reverse(buf, p);
    return p;    // p - buf为整数长度
}

这段简短的代码对32-bit int的全部取值都是正确的(从-2147483648到2147483647)。可以视为itoa()的参考实现,算是面试的标准答案。

读到这份代码,作者的心中顿时升起一个疑虑:《C Traps and Pitfalls》第7.7节讲到,C语言中的整数除法(/)和取模(%)运算在操作数为负的时候,结果是implementation-defined。

也就是说,如果m、d都是整数,

int q = m / d;
int r = m % d;

那么C语言只保证m = q × d + r。如果m、d当中有负数,那么q和r的正负号是由实现决定的。比如(-13) / 4 = (-3)或(-13) / 4 = (-4)都是合法的。如果采用后一种实现,那么这段转换代码就错了(因为有(-1) / 10 = -1,从而有(-1) % 10 = 9)。只有商向0取整,代码才能正常工作。

为了弄清这个问题,作者研究了一番。

12.3.1 语言标准怎么说

C89

作者手头没有ANSI C89的文稿,只好求助于[K&R],此书第41页第2.5节讲到“The direction of truncation for / and the sign of the result for % are machine-dependent for negative operands, …”确实是实现相关的。为此,C89专门提供了div()函数,这个函数算出的商是向0取整的,便于编写可移植的程序。作者得再查C++标准。

C++98

第5.6.4节写到:“If the second operand of / or % is zero the behavior is undefined; otherwise (a/b)*b + a%b is equal to a. If both operands are nonnegative then the remainder is nonnegative; if not, the sign of the remainder is implementation-defined.”C++也没有规定余数的正负号(C++03的叙述与此一模一样)。

不过这里有一个注脚,提到“According to work underway toward the revision of ISO C, the preferred algorithm for interger division follows the rules defined in the ISO Fortran standard, ISO/IEC 1539:1991, in which the quotient is always rounded toward zero.”即C语言的修订标准会采用和Fortran一样的取整算法。作者又去查了C99标准。

C99

第6.5.5.6节说“When integers are divided, the result of the / operator is the algebraic quitient with any fractional part discarded.”(脚注:This is often called “truncation toward zero”)

C99明确规定了商是向0取整的,也就是意味着余数的符号与被除数相同,前面的转换算法能正常工作。C99 Retionale(一种C编程语言的标准,它是C89(ANSI C)的更新版本)(http://www.open-std.org/jtc1/sc22/wg14/www/C99RationaleV5.10.pdf)提到了这个规定的原因:“In Fortran, however, the result will always truncate toward zero, and the overhead seems to be acceptable to the numeric programming community. Therefore, C99 now requires similar behavior, which should facilitate porting of code from Fortran to C.”既然Fortran在数值计算领域都做了如此规定,说明开销(如果有的话)是可以接受的。

C++11

标准第5.6.4节采用了与C99类似的表述:“For integral operands the / operator yields the algebraic quitient with any fractional part discarded;(This is often called truncation towards zero.)”可见C++还是尽力保持与C的兼容性。

小结:C89和C++98都留给实现去决定,而C99和C++11都规定商向0取整,这算是语言的进步吧。

12.3.2 C/C++编译器的表现

作者主要关心G++和VC++这两个编译器。需要说明的是,用代码案例来探查编译器的行为是靠不住的,尽管前面的代码在两个编译器下都能正常工作。除非在文档里有明确表述,否则编译器可能会随时更改实现——毕竟我们关心的就是implementation-defined行为。

G++4.4(http://gcc.gnu.org/onlinedocs/gcc/Integers-implementation.html):GCC always follows the C99 requirement that the result of division is truncated towards zero.G++一直遵循C99规范,商向0取整,算法能正常工作。

Visual C++ 2008(http://msdn.microsoft.com/en-us/library/eayc4fzk.aspx):The sign of the reminder is the same as the sign of the dividend.这个说法与商向0取整是等价的,算法也能正常工作。

12.3.3 其他语言的规定

既然C89/C++98/C99/C++0x已经很有多样性了,索性弄清楚其他语言是怎么定义整数除法的。这里只列出笔者接触过的几种常用语言。

Java

Java语言规范(http://java.sun.com/docs/books/jls/third_edition/html/expressions.html#15.17.2)明确说“Integer division rounds towards 0”。另外对于int整数除法溢出,特别规定不抛异常,且-2147483648 / -1 = -2147483648(以及相应的long版本)。

C#

C# 3.0语言规定(http://msdn.microsoft.com/en-us/vcsharp/aa336809.aspx)“The division rounds the result towards zero”。对于溢出的情况,规定在checked上下文中抛ArithmeticException异常;在unchecked上下文里没有明确规定,可抛可不抛(据了解,C# 1.0/2.0可能有所不同)。

Python

Python在语言参考手册(http://docs.python.org/reference/expressions.html#binary-arithmetic-operations)的显著位置标明,商是向负无穷取整(Plain or long integer division yields an integer of the same type; the result is that of mathematical division with the ‘floor’ function applied to the result.)。

Ruby

Ruby的语言手册没有明说,不过库的手册(http://www.ruby-doc.org/docs/ProgrammingRuby/html/ref_c_numeric.html#Numeric.divmod)说明了也是向负无穷取整(The quitient is rounded toward -infinity)。

Perl

Perl语言默认按浮点数来计算除法(http://perldoc.perl.org/perlop.html#Multiplicative-Operators),所以没有这个问题。Perl的整数取模运算规则与Python/Ruby一致。

不过要注意,use integer;有可能会改变运算结果,例如:

print -10 % 3;    // => 2

use integers;
print -10 % 3;    // => -1

Lua

Lua缺省没有整数类型,除法一律按浮点数来算,因此不涉及商的取整。

综上所述,在整数除法的取整问题上,语言分为两个阵营,脚本语言彼此是相似的,C99/C++11/Java/C#则属于另一个阵营,在移植代码时要小心。既然Python和Ruby的官方解释器都是用C实现的,但是运算规则又自成一体,那么必定能从代码中找到证据。

12.3.4 脚本语言解释器代码

Python的代码很好读,作者很快就找到了2.6.6版实现整数除法和取模运算的函数i_divmod()。

// python/tags/r266/Objects/intobject.c
/* Return type of i_divmod */
enum divmod_result {
    DIVMOD_OK,    /* Correct result */
    DIVMOD_OVERFLOW,    /* Overflow, try again using longs */
    DIVMOD_ERROR    /* Exception raised */
};

// register参数是寄存器变量,编译器将尽量将其存储在CPU的寄存器而非内存中,当然只是建议
static enum divmod_result
i_divmod(register long x, register long y, long *p_xdivy, long *p_xmody)
{
    long xdivy, xmody;
    
    if (y == 0) {
        // 引发一个PyExc_ZeroDivisionError异常,并设置异常的详细信息为第二个参数
        PyErr_SetString(PyExc_ZeroDivisionError, "integer division or module by zero");
        return DIVMOD_ERROR;
    }
    /* (-sys.maxint-1)/-1 is the only overflow case. */
    if (y == -1 && UNARY_NEG_WOULD_OVERFLOW(x))
        return DIVMOD_OVERFLOW;
    xdivy = x / y;
    /* xdivy*y can overflow on platforms where x/y gives floor(x/y)
     * for x and y with differing signs. (This is unusual
     * behaviour, and C99 prohibits it, but it's allowed by C89;
     * for an example of overflow, take x = LONG_MIN, y = 5, or x =
     * LONG_MAX, y = -5.) However, x - xdivy*y is always
     * representable as a long, since it lies strictly between 
     * -abs(y) and abs(y). We add casts to avoid intermediate
     * overflow.
     */
    xmody = (long)(x - (unsigned long)xdivy * y);
    /* If the signs of x and y differ, and the remainder is non-0
     * C89 doesn't define whether xdivy is now the floor or the
     * ceiling of the infinitely precise quitient. We want the floor,
     * and we have it iff the remainder's sign matches y's.
     */
    // iff指的是if and only if
    // infinitely precise quitient指对商没有任何舍入或近似操作,保持无限精确的商值
    // 通常情况下,商是将除法的结果四舍五入到一定精度的数字
    // 这里判断的是除数和商是否异号,如果异号,说明是向0取整的
    // 被除数和商同号,也能说明是向0取整的
    if (xmody && ((y ^ xmody) < 0) /* i.e. and signs differ */) {
        xmody += y;
        --xdivy;
        assert(xmody && ((y ^ xmody) >= 0));
    }
    *p_xdivy = xdivy;
    *p_xmody = xmody;
    return DIVMOD_OK;
}

注意到这段代码甚至考虑了-2147483648 / -1在32-bit下会溢出这个特殊情况,让作者大吃一惊。宏定义UNARY_NEG_WOULD_OVERFLOW和函数int_num()前面的注释也值得一读。

// python/tags/r266/Objects/intobject.c
/* Integer overflow checking for unary negation: on a 2's-complement
 * box, -x overflows iff x is the most negative long. In this case we
 * get -x == x. However, -x is undefined (by C) if x /is/ the most
 * negative long (it's a signed overflow case), and some compilers care.
 * So we cast x to unsigned long first. However, then other compilers
 * warn about applying unary minus to an unsigned operand. Hence the
 * weird "0-".
 */
// 一元取反操作时检查long的溢出问题
// 将负数转换为无符号数时,举例来说,-1转换为unsigned short结果是255
// 即2的16次方减1,同理,将最小的long转换为unsigned long时
// 结果是2的32次方减4294967296=0,而正0和负0相等
#define UNARY_NEG_WOULD_OVERFLOW(x)    \
    ((x) < 0 && (unsigned long)(x) == 0-(unsigned long)(x))
// python/tags/r266/Objects/intobject.c
/*
Integer overflow checking for * is painful: Python tried a couple ways, but
they didn't work on all platforms, or failed in endcases (a product of
-sys.maxint-1 has been a particular pain).

Here's another way:

The native long product x*y is either exactly right or *way* off, being
just the last n bits of the true product, where n is the number of bits
in a long (the delivered product is the true product plus i*2**n for
some integer i(这里的i应该只会是负数(溢出时)或0(未溢出时))).

The native double product (double)x * (double)y is subject to three
rounding errors: on a sizeof(long)==8 box, each cast to double can lose
info, and even on a sizeof(long)==4 box, the multiplication can lose info.
Buy, unlike the native long product, it's not in *range* trouble: even
if sizeof(long)==32 (256-bit longs), the product easily fits in the
dynamic range of a double. So the leading 50 (or so) bits of the double
product are correct.

We check these two ways against each other, and declare victory if they're
approximately the same. Else, because the native long product is the only
one that can lose catastrophic amounts of information, it's the native long
product that must have overflowed.
*/
// 在较早的Python版本中,sys.maxint是常数2147483647,表示32位的最大有符号整数
// 根据以上注释,会先比较long乘积和double乘积的结果,如果差不多,说明整数乘法没有溢出
// 因为long溢出会直接丢掉溢出的高位
// 而double溢出时,前50多位是准确的,超出的位会看做低位的0,因此与真实积相差不会有long那么多
// 此函数执行整数相乘,并进行溢出检查
static PyObject *
int_mul(PyObject *v, PyObject *w)
{
    long a, b;
    long longprod;
    double doubled_longprod;    /* a*b in native long arithmetic */
    double doubleprod;    /* (double)longprod */
    
    CONVERT_TO_LONG(v, a);
    CONVERT_TO_LONG(w, b);
    /* casts in the next line avoid undefined behaviour on overflow */
    longprod = (long)((unsigned long)a * b);
    doubleprod = (double)a * (double)b;
    double_longprod = (double)longprod;
    
    /* Fast path for normal case: small multiplicands, and no info
       is lost in either method. */
    if (doubled_longprod == doubleprod)
        return PyInt_FromLong(longprod);

    /* Somebody somewhere lost info. Close enough, or way off? Note
       that a != 0 and b != 0 (else doubled_longprood == doubleprod == 0).
       The difference either is or isn't significant compared to the 
       true value (of which doubleprod is a good approximation).
    */
    {
        const double diff = doubled_longprod - doubleprod;
        const double absdiff = diff >= 0.0 ? diff : -diff;
        const double absprod = doubleprod >= 0.0 ? doubleprod : -doubleprod;
        /* absdiff/absprod <= 1/32 iff
           32 * absdiff <= absprod -- 5 good bits is "close enough" */
        // 当double的乘积和long的乘积的差小于1/32时,认为两者足够相近,即乘法没有溢出
        if (32.0 * absdiff <= absprod)
            // 直接返回long相乘的结果
            return PyInt_FromLong(longprod);
        else
            // 如果溢出了,使用nb_multiply方法进行进一步计算
            // PyLong_Type是Python中表示整数类型的对象
            // tp_as_number是一个包含了与数字操作相关的函数指针的成员
            // 其中的函数指针指向实际执行操作的函数,例如加法、减法、乘法等
            // nb_multiply是一个函数指针,表示乘法操作
            return PyLong_Type.tp_as_number->nb_multiply(v, w);
    }
}

Ruby的代码要混乱一些,花点时间还是能找到的。以下是Ruby 1.8.7-p334的实现,位于fixdivmod()函数。

// ruby/tags/v1_8_7_334/numeric.c
// 此处是老式C语言的函数语法,参数没有在圆括号中指定类型,而是在圆括号后指定
static void
fixdivmod(x, y, divp, modp)
    long x, y;
    long *divp, *modp;
{
    long div, mod;
    
    if (y == 0) rb_num_zerodiv();
    // x和y同号时,div是正的,否则是负的
    if (y < 0) {
        if (x < 0)
            div = -x / -y;
        else
            div = -(x / -y);
    }
    else {
        if (x < 0)
            div = -(-x / y);
        else
            div = x / y;
    }
    
    mod = x - div * y;
    // 如果除数和余数异号,说明是向0取整的
    if ((mod < 0 && y > 0) || (mod > 0 && y < 0)) {
        mod += y;
        div -= 1;
    }
    // 如果用户提供了指针,则将结果存到指针指向的内存
    if (divp) *divp = div;
    if (modp) *modp = mod;
}

注意到Ruby的Fixnum整数的表示范围比机器字长小1bit,直接避免了溢出的可能。

12.3.5 硬件实现

既然C/C++以效率著称,那么应该是贴近硬件实现的。作者考察了几种常见的硬件平台,它们基本都支持C99/C++11语意,也就是说新规定没有额外开销列举如下(其实我们只关心带符号除法,不过为了完整性,这里一并列出unsigned/signed整数除法指令)。

Intel x86/x64

Intel x86系列的DIV(执行无符号整数除法操作)/IDIV(执行有符号整数除法操作)指令明确提到是向0取整,与C99、C++11、Java、C#一致。

MIPS(一种常见的指令集架构(ISA),它代表Microprocessor without Interlocked Pipeline Stages,即无内锁流水线级的微处理器)

很奇怪,作者在MIPS的参考手册里没有查到DIV(执行有符号整数的除法操作)/DIVU(执行无符号整数除法操作)指令的取整方向,不过根据Patternson & Hennessy(Computer Organization and Design: The Hardware/Software Interface, 4th ed.,即《计算机组织与设计:硬件/软件接口》一书的第4版)的讲解,似乎向0取整硬件上实现起来比较容易。

ARM/Cortex-M3(ARM是一个英国公司,Cortex-M3是ARM公司设计的一种32位嵌入式处理器架构,属于ARM的Cortex-M系列之一)

ARM架构没有硬件除法指令,所以不存在这个问题。Cortex-M3有硬件除法,SDIV(执行有符号整数的除法操作)/UDIV(执行无符号整数除法操作)指令都是向0取整的。Cortex-M3的除法指令不能同时算出余数,这很特殊。

MMIX(一种虚拟计算机架构,是对MIX计算机的改进和扩展)

MMIX是Donald Knuth设计的64-bit CPU,替换原来的MIX机器。DIV(执行有符号整数的除法操作)和DIVU(执行无符号整数除法操作)指令都是向负无穷取整,这是作者知道的唯一支持Python/Ruby语义的“硬件”平台。

总结

想不到小小的整数除法都有这么多名堂。一段只涉及整数运算的代码,即便能在各种语法相似的语言里运行,结果也可能完全不同。把C语言里运行得好好的整数运算代码原样复制到Python里,也可能因为负数除法而出错。反之亦然,用Python编写的原型代码移植到C/C++里也可能出现行为异常,不可不察。

在实际项目中,可以使用特定的指令加速。

12.4 在单元测试中mock系统调用

本书9.7曾经谈到单元测试在分布式程序开发中的优缺点(主要是缺点)。但是,在某些情况下,单元测试时很有必要的,在测试failure场景的时候尤其重要,比如:
1.在开发存储系统时,模拟read(2)/write(2)返回EIO错误(有可能是磁盘写满了,也有可能是磁盘出现了坏道读不出数据)。

2.在开发网络库的时候,模拟write(2)返回EPIPE错误(对方意外断开连接)。

3.在开发网络库的时候,模拟自连接(self-connection,即源IP和源端口与目的IP和目的端口相同,发生在客户端不调用bind连接本机时,此时系统会选择一个源端口(不会选择正在使用的端口,因此此时的目的端口必须是空闲的),如果选中的源端口等于目的端口时,就会正常进行三次握手,从而发生自连接),网络库应该用getsockname(2)和getpeername(2)判断是否是自连接,然后断开之。

4.在开发网络库的时候,模拟本地ephemeral port(临时端口)耗尽,connect(2)返回EAGAIN临时错误。

5.让gethostbyname(2)返回我们预设的值,防止单元测试给公司的DNS Server带来太大压力。

这些test case恐怕很难用前文提到的test harness来测试,该单元测试上场了。现在的问题是,如何mock这些系统函数?或者换句话说,如何把对系统函数的依赖注入到被测程序中。

12.4.1 系统函数的依赖注入

在《修改代码的艺术》[WELC]一书的4.3.2节中,作者介绍了链接期接缝(link seam),正好可以解决我们的问题。另外,在Stack Overflow的一个帖子(http://stackoverflow.com/questions/2924440/advice-on-mocking-system-calls)里也总结了几种做法。

如果程序(库)在编写的时候就考虑了可测试性,那么用不到上面的hack手段,我们可以从设计上解决依赖注入的问题。这里提供两个思路。

其一

采用传统的面向对象的手法,借助运行期的迟绑定实现注入与替换。自己写一个System interface,把程序里用到的open、close、read、write、connect、bind、listen、accept、gethostname、getpeername、getsockname等等函数统统用虚函数封装一层。然后在代码里不要直接调用open(),而是调用System::interface().open()。这样代码主动把控制权交给了System interface,我们可以在这里动动手脚。在写单元测试的时候,把这个singleton interface替换为我们的mock object,这样就能模拟各种error code。

其二

采用编译期或链接期的迟绑定。注意到在第一种做法中,运行期多态是不必要的,因为程序从生到死只会用到一个implementation object。为此付出虚函数调用的代价似乎有些不值(其实,跟系统调用比起来,虚函数这点开销可忽略不计)。

我们可以写一个system namespace头文件,在其中声明read()和write()等普通函数,然后在.cc文件里转发给对应系统的系统函数::read()和::write()等。

// muduo/net/SocketsOps.h
namespace sockets
{
    int connect(int sockfd, const struct sockaddr_in &addr);
}
// muduo/net/SocketsOps.cc
int sockets::connect(int sockfd, const staruct sockaddr_in &addr)
{
    // sockaddr_cast是自己编写的工具函数,用于将不同类型套接字地址结构转换为通用套接字地址结构
    return ::connect(sockfd, sockaddr_cast(&addr), sizeof addr);
}

有了这么一层间接性,就可以在编写单元测试的时候动动手脚,链接我们的stub实现(通常用于测试的目的,以便在真实的环境中模拟某些行为或数据,以便进行测试),以达到替换实现的目的:

// MockSocketsOps.cc
int sockets::connect(int sockfd, const struct sockaddr_in &addr)
{
    errno = EAGAIN;
    return -1;
}

一个C++程序只能有一个main()入口,所以要先把程序做成library,再用单元测试代码链接这个library。假设有一个mynetcat程序,为了编写C++单元测试,我们把它拆成两部分,即library和main(),源文件分别是mynetcat.cc和main.cc。

在编译普通程序的时候:

g++ main.cc mynetcat.cc SocketsOps.cc -o mynetcat

在编译单元测试时这么写:

g++ test.cc mynetcat.cc MockSocketsOps.cc -o test

以上是最简单的例子。在实际开发中可以让stub功能更强大一些,比如根据不同的test case返回不同的错误。

第二种做法无须用到虚函数,代码写起来也比较简洁,只用前缀sockets::即可。例如在应用程序的代码里写sockets::connect(fd, addr)。

muduo目前还没有涉及系统调用的单元测试,只是预留了这些stub。

namespace的好处在于它不是封闭的,我们可以随时打开往里添加新的函数,而不用改动原来的头文件(即我们可以在任意文件中用namespace关键字打开一个命名空间,并在其中增加函数)。这也是以non-member non-friend函数为接口的优点。

以上做法还有一个好处,即只mock我们关心的部分代码。如果程序用到了SQLite或Berkeley DB这些会访问本地文件系统的第三方库,那么我们的System interface或system namespace不会拦截这些第三方库的open(2)、close(2)、read(2)、write(2)等系统调用。

12.4.2 链接期垫片(link seam)

如果程序在一开始编码的时候没有考虑单元测试,那么又该如何注入mock系统调用呢?

上面第二种做法已经给出了答案,那就是使用link seam(链接期垫片)。

比方说要仿冒connect(2)函数,那么我们在单元测试程序里实现一个自己的connect()函数,它遮盖了同名的系统函数。在链接的时候,linker会优先采用我们自己定义的函数(这对动态链接是成立的;如果是静态链接,会报multiple definition错误。好在绝大多数情况下libc是动态链接的)。

// mock connect(2)
typedef int (*connect_func_t)(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// dlsym函数用于在动态链接库中查找符号并返回其地址
// RTDL_NEXT常量表示查找下一个由动态链接器注册的符号的地址,当你使用dlsym来查找一个函数
// 但你并不知道该函数在哪个动态链接库中时,你可以使用RTDL_NEXT来查找下一个具有相同名称的符号的地址
// connect是要查找的符号
connect_func_t connect_func = dlsym(RTDL_NEXT, "connect");

bool mock_connect;
int mock_connect_errno;

// mock connection
// 用C语言的方式链接此connect函数
// 由于connect函数是在C语言的头文件中声明的,为了确保正确地链接,需要用C语言方式链接
extern "C" int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
{
    if (mock_connect)
    {
        errno = mock_connect_errno;
        return errno == 0 ? 0 : -1;
    }
    else
    {
        return connect_func(sockfd, addr, addrlen);
    }
}

如果程序真的要调用connect(2)怎么办?在我们自己的mock connect(2)里不能再调用connect()了,否则会出现无限递归。为了防止这种情况,我们用dlsym(RTDL_NEXT, “connect”)获得connect(2)系统函数的真实地址,然后通过函数指针connect_func来调用它。

例子:ZooKeeper的C client library

ZooKeeper的C client library正是采用了link seams来编写单元测试,代码见:
1.http://svn.apache.org/repos/asf/zookeeper/tags/release-3.3.3/src/c/tests/LibCMocks.h

2.http://svn.apache.org/repos/asf/zookeeper/tags/release-3.3.3/src/c/tests/LibCMocks.cc

其他做法

Stack Overflow的贴子里还提到了一个做法,可以方便地替换动态库里的函数,即使用ld(1)(该命令会将这些目标文件链接起来,并生成一个可执行文件或共享库)的–wrap参数(该参数将指定的函数替换为用户定义的包装函数,在包装函数中,你可以执行一些额外的操作,然后调用被包装的函数,或者直接返回一个固定的值),文档里说的很清楚,这里不再赘述。

第三方C++库

Link seam同样适用于第三方C++库。

比方说公司的某个基础库团队提供了File class,但是这个class没有使用虚函数,我们无法通过sub-classing的办法来实现mock object。

// File.h
class File : boost::noncopyable
{
public:
    File(const char *filename);
    ~File();
    
    int readn(void *data, int len);
    int writen(const void *data, int len);
    size_t getSize() const;

private:
};

如果需要为用到File class的程序编写单元测试,那么我们可以自己定义其成员函数的实现,这样可以注入任何我们想要的结果。

// MockFile.cc
int File::readn(void *data, int len)
{
    return -1;
}

这个做法对动态库是可行的,但对于静态库则会报错。我们要么让对方提供专供单元测试的动态库,要么拿过源码来自己编译一个。

Java也有类似的做法,在class path里替换我们自己的stub jar文件,以实现link seam。不过Java有很强的反射机制,很少用得着link seam来实现依赖注入。

12.5 慎用匿名namespace

匿名namespace(anonymous namespace或称unnamed namespace)是C++语言的一项非常有用的功能,其主要目的是让该namespace中的成员(变量或函数)具有独一无二的全局名称,避免名字碰撞(name collisions)。一般在编写.cpp文件时,如果需要写一些小的helper函数,我们常常会放到匿名namespace里。muduo 0.1.7中的muduo/base/Date.cc和muduo/base/Thread.cc等处就用到了匿名namespace。

作者最近在工作中遇到并重新思考了这一问题,发现匿名namespace并不是多多益善。

12.5.1 C语言的static关键字的两种用法

C语言的static关键字有两种用途:
第一种:用于函数内部修饰变量,即函数内的静态变量。这种变量的生存期长于该函数,使得函数具有一定的“状态”。使用静态变量的函数一般是不可重入的,也不是线程安全的,比如strtok(3)。

第二种:用在文件级别(函数体之外),修饰变量或函数,表示该变量或函数只在本文件可见,其他文件看不到、也访问不到该变量或函数。专业的说法叫“具有internal linkage”(简言之:不暴露给别的translation unit(指的是源文件(如.c或.cpp文件)以及通过包含预处理指令(如#include)而组合在一起的所有头文件所组成的单元))。

C语言的这两种用法很明确,一般也不容易混淆。

12.5.2 C++语言的static关键字的四种用法

由于C++引入了class,在保持与C语言兼容的同时,static关键字又有了两种新用法:
第三种:用于修饰class的数据成员,即所谓“静态成员”。这种数据成员的生存期大于class的对象(实体/instance)。静态数据成员是每个class有一份,普通数据成员是每个instance有一份,因此也分别叫做class variable和instance variable。

第四种:用于修饰class的成员函数,即所谓“静态成员函数”。这种成员函数只能访问class variable和其他静态成员函数,不能访问instance variable或instance method。

当然,这几种用法可以相互组合,比如C++的成员函数(无论static还是instance)都可以有其局部的静态变量(上面的用法1)。对于class template和function template,其中的static对象的真正个数跟template instantiation(模板具现化)有关,相信学过C++模板的人不会陌生。

可见在C++里static被overload了多次。匿名namespace的引入是为了减轻static的负担,它替换了static的第2种用途。也就是说,在C++里不必使用文件级的static关键字,我们可以用匿名namespace达到相同的效果(其实严格地说,linkage或许稍有不同,这里不展开讨论了)。

12.5.3 匿名namespace的不利之处

在工程实践中,匿名namespace有两大不利之处:
1.匿名namespace中的函数是“匿名”的,那么在确实需要引用它的时候就比较麻烦。

比如在调试的时候不便给其中的函数设断点,如果你像作者一样使用的是gdb这样的文本模式debugger;又比如profiler的输出结果也不容易判别到底是哪个文件中的calculate()函数需要优化。

2.使用某些版本的g++时,同一个文件每次编译出来的二进制文件会变化。

比如说拿到一个会发生core dump的二进制可执行文件,无法确定它是由哪个revision(指版本)的代码编译出来的。毕竟编译结果不可复现,具有一定的随机性(当然,在正式场合,这应该由软件配置管理(SCM)流程来解决)。

另外这也可能让某些build tool失灵,如果该工具用到了编译出来的二进制文件的MD5的话。

考虑下面这段简短的代码:

// anon.cc
namespace
{
    void foo()
    {
    }
}

int main()
{
    foo();
}

对于问题1:
gdb的<tab>键自动补全功能能帮我们设定断点,不是什么大问题。前提是你知道那个“(anonymous namespace)::foo()”正是你想要的函数。
在这里插入图片描述
gdb的b命令可用于设置断点。

麻烦的是,如果两个文件anon.cc和anonlib.cc都定义了匿名空间中的foo()函数(这不会冲突),那么gdb无法区分这两个函数,你只能给其中一个设断点。或者你使用文件名:行号的方式来分别设断点(从技术上说,匿名namespace中的函数是weak text(指的是函数的符号属性,函数可以具有不同的链接属性,其中之一是“weak”,当函数被声明为“weak”时,它意味着如果存在多个具有相同名称的函数定义,链接器不会引发错误,而是在链接时选择一个具有最强链接属性(通常是“strong”)的版本,并且忽略其他的版本),链接的时候如果发生符号重名,linker不会报错)。

从根本上解决的办法是使用普通具名namespace,如果怕重名,可以把源文件名(必要时加上路径)作为namespace名字的一部分。

对于问题2:
把anon.cc编译两次,分别生成a.out和b.out:
在这里插入图片描述
在这里插入图片描述
由上可见,g++ 4.2.4会随机地给匿名namespace生成一个唯一的名字(foo()函数的mangled name中的E2CEEB51和CB51498D是随机的),以保证名字不冲突。也就是说,同样的源文件,两次编译得到的二进制文件内容不相同,这有时候会造成问题或困惑。

这可以用gcc的-frandom-seed参数(允许你手动设置一个种子值,而不是让编译器使用默认的或自动生成的种子)解决,具体见gcc文档。

这个现象在gcc 4.2.4中存在(之前的版本估计类似),在gcc 4.4.5中不存在。

12.5.4 替代办法

如果前面的“不利之处”给你带来了困扰,解决办法也很简单,就是使用普通具名namespace。当然,要起一个好名字,比如Boost里就常常用boost::detail来放那些“不应该暴露给客户,但又不得不放到头文件里”的函数或class。

总而言之,匿名namespace没什么大问题,使用它也不是什么过错。万一它碍事了,可以用普通具名namespace替代之。

12.6 采用有利于版本管理的代码格式

版本管理(version controlling)是每个程序员的基本技能,C++程序员也不例外。版本管理的基本功能之一是追踪代码变化,让你能清楚地知道代码是如何一步步变成现在的这个样子的,以及每次check-in都具体改动了哪些内部。无论是传统的集中式版本管理工具,如Subversion,还是新型的分布式管理工具,如Git/Hg(Mercurial,通常简称为Hg,是一种分布式版本控制系统(DVCS),旨在管理项目的源代码和相关资源),比较两个版本(revision)的差异都是其基本功能,即俗称“做一下diff”。

diff的输出是个窥孔(peephole),它的上下文有限(diff -u(-u选项以Unified Format的方式输出它们之间的差异,即显示+和-表示两文件差异,并且显示差异处的3行上下文,而不加-u时只显示差异行)默认显示前后3行)。在做code review的时候,如果仅凭这“一孔之见”就能发现代码改动有问题,那就再好也不过了。

C和C++都是自由格式的语言,代码中的换行符被当做white space来对待(当然,我们说的是预处理(preprocess)之后的情况)。对编译器来说一模一样的代码可以有多种写法,比如

foo(1, 2, 3, 4);

foo(1,
    2,
    3,
    4);

词法分析的结果是一样的,语意也完全一样。

对人来说,这两种写法读起来不一样,对于版本管理工具来说,同样功能的修改造成的差异(diff)也往往不一样。所谓“有利于版本管理”,就是指在代码中合理使用换行符,对diff工具友好,让diff的结果清晰明了地表达代码的改动。diff一般以行为单位,也可以以单词为单位,文本只考虑最常见的逐行比较(diff by lines)。

12.6.1 对diff友好的代码格式

多行注释也用//,不用/* */

Scott Meyers写的《Effective C++(第2版)》第4条建议使用C++风格,作者这里为他补充一条理由:对diff友好。比如,我要注释一大段代码(其实这不是个好的做法,但是在实践中有时会遇到),如果用/* */,那么得到的diff是:
在这里插入图片描述
从这样的diff ouput能看出注释了那些代码吗?

如果用//,结果会清晰很多:
在这里插入图片描述
同样的道理,取消注释的时候//也比/* */更清晰。

另外,如果用/* /来做多行注释,从diff不一定能看出来你是在修改代码还是修改注释。比如以下diff似乎修改了muduo::EventLoop::runAfter()的调用参数:
在这里插入图片描述
其实这个修改发生在注释中(要增加上下文才能看到,diff -U 20,-U选项用于指定输出的差异的上下文行数,多一道手续,降低了工作效率),对代码行为没有影响:
在这里插入图片描述
总之,不要用/
*/来注释多行代码。

或许是时过境迁了,大家都在用//注释了,《Effective C++(第3版)》去掉了这一条建议。

局部变量与成员变量的定义

基本原则是,一行代码只定义一个变量,比如

double x;
double y;

将来代码增加一个double z的时候,diff输出一眼就能看出改了什么:
在这里插入图片描述
如果把x和y写在一行,diff的输出就得多看几眼才知道:
在这里插入图片描述
所以,一行只定义一个变量更有利于版本管理。同样的道理适用于enum成员的定义、数组的初始化列表等。

函数声明中的参数

如果函数的参数大于3个,那么在逗号后面换行,这样每个参数占一行,便于diff。以muduo::net::TcpClient为例:

// muduo/net/TcpClient.h
class TcpClient : boost::noncopyable
{
public:
    TcpClient(EventLoop *loop,
              const InetAddress &serverAddr,
              const string &name);

如果将来TcpClient的构造函数增加或修改一个参数,那么很容易从diff看出来。这恐怕比在一行长代码里数逗号要高效一些。

函数调用时的参数

在函数调用的时候,如果参数大于3个,那么把实参分行写。

以muduo::net::EPollPoller为例:

// muduo/net/poller/EPollPoller.cc
Timestamp EPollPoller::poll(int timeoutMs, ChannelList *activeChannels)
{
    int numEvents = ::epoll_wait(epollfd_,
                                 &*events_.begin(),    // 把迭代器转换为普通指针
                                 static_cast<int>(events_.size()),
                                 timeoutMs);
    Timestamp now(Timestamp::now());

这样一来,如果将来重构引入了一个新参数(当然,epoll_wait不会有这个问题),那么函数定义和函数调用的地方的diff具有相同的形式(比方说都是在倒数第二行加了一行内容),很容易肉眼验证有没有错位。如果参数写在一行里边,就得睁大眼睛数逗号了。

class初始化列表的写法

同样的道理,class初始化列表(initializer list)也遵循一行一个的原则,这样将来如果加入新的成员变量,那么两处(class定义和ctor定义)的diff具有相同的形式,让错误无所遁形。以muduo::net::Buffer为例:
在这里插入图片描述
注意,初始化列表的顺序必须和数据成员声明的顺序相同。

与namespace有关的缩进

Google的C++编程规范明确指出,namespace不增加缩进。这么做非常有道理,方便diff -p(-p选项显示差异所在的函数名称)把函数名显示在每个diff chunk的头上。

如果对函数实现做diff,chunk name是函数名,让人一眼就能看出改的是哪个函数,如下面所示的灰底部分。
在这里插入图片描述
如果对class做diff,那么chunk name就是class name。
在这里插入图片描述
diff原本是为C语言设计的,C语言没有namespace缩进一说,所以它默认会找到“顶格写”的函数作为一个diff chunk的名字。如果函数名前面有空格,它就不认得了。muduo的代码都遵循这一规则,例如:
在这里插入图片描述
相反,Boost中的某些库的代码是按namespace来缩进的,这样的话看diff往往不知道改动的是哪个class的哪个成员函数。

这个或许可以通过设置diff取函数名的正则表达式来解决,但是如果我们写代码的时候就注意把函数“顶格写”,那么就不用去动diff的默认设置了。另外,正则表达式不能完全匹配函数名,因为函数名属于上下文无关语法(context-free syntax),你没办法写一个正则语法去匹配上下文无关语法。我总能写出某种函数声明,让你的正则表达式失效(想想函数的返回类型,它可能是一个非常复杂的东西,更别说参数了)。更何况C++的语法是上下文相关的,比如,你猜Foo<Bar> qux;是个表达式还是变量定义?

public与private

作者认为这是C++语法的一个缺陷,如果我把一个成员函数从public区移到private区,那么从diff上看不出来我干了什么,例如:
在这里插入图片描述
在这里插入图片描述
从上面的diff能看出作者把retry()变成private了吗?对此作者也没有好的解决办法,总不能在每个函数前面都写上public:或private:吧?

对此Java和C#都做得比较好,它们把public/private等修饰符放到每个成员函数的定义中。这么做增加了信息的冗余度,让diff的结果更直观。

避免使用版本控制软件的keyword substitution功能

这么做是为了避免diff噪声。

比方说,如果我想比较0.1.1和0.1.2两个代码分支有哪些改动,作者通常会在branches目录执行diff 0.1.1 0.1.2 -ru(-r选项递归地对比目录和子目录中的文件)。两个branch中的muduo/net/EventLoop.h其实是一样的(先后从同一个revision分支出来)。但是如果这个文件使用了SVN的keywork substitution功能(比如$Id$,表示文件的标识符,通常包含作者、日期和版本等信息),diff会报告这两个branches中的文件不一样,如下所示。
在这里插入图片描述
这样纯粹增加了噪声,这是RCS(Revision Control System,是早期的版本控制系统之一)/CVS(Concurrent Versions System,是RCS的一个改进版本,允许多个开发者并行工作)时代的过时做法。文件的Id不应该在文件内容中出现,这些metadata跟源文件的内容无关,应该由版本管理软件额外提供。

12.6.2 对grep友好的代码风格

操作符重载

C++工具匮乏,在一个项目里,要找到一个函数的定义或许不算太难(最多就是分析一下重载和模板特化),但是要找到一个函数的使用就难多了。不比Java,在Eclipse里按Ctrl+Shift+G组合键就能找到所有的引用点。

假如我要做一个重构,想先找到代码里所有用到muduo::timeDifference()的地方,判断一下工作是否可行,基本上唯一的办法是grep。用grep还不能排除同名的函数和注释里的内容。这也说明了为什么要用//来引导注释,因为在grep的时候,一眼就能看出这样代码是在注释里的。

在作者看来,operator overloading应仅限于STL algorithm/container配合时使用,比如std::transform()(该函数会遍历输入容器中的每个元素,并对每个元素应用op函数指针参数,然后将操作后的结果存储到输出容器中,如果输出容器和输入容器是同一个容器,就会在原地修改原始容器中的元素)和map<Key, Value>,其他情况都用具名函数为宜。原因之一是,我根本用grep找不到在哪儿用到了减号operator-()。这也是muduo::Timestamp class只提供operator<()而不提供operator+()和operator-()的原因。作者提供了两个函数timeDifference()和addTime来实现所需的功能。

又比如,Google Protocol Buffers的回调是Closure class(用于定义回调函数的一种特殊类型),它的接口用的是virtual function Run()而不是virtual operator()()。

static_cast与C-style cast

为什么C++要引入static_cast之类的转型操作符,原因之一就是像(int*)pBuffer这样的表达式基本上没办法用grep判断出它是个强制类型转换,写不出一个刚好只匹配类型转换的正则表达式(其语法是上下文无关的,无法用正则搞定)。

如果类型转换都用*_cast,那只要grep一下,我就能知道代码里哪儿用了reinterpret_cast转换,便于迅速地检查有没有用错。为了强调这一点,muduo开启了编译选项-Wold-style-cast(会在编译过程中生成警告信息,提示开发者将旧式的C风格类型转换语法更新为更安全和语义明确的C++风格转换语法)来帮助查找C-style casting,这样在编译时就能帮我们找到问题。

12.6.3 一切为了效率

如果用图形化的文件比较工具,似乎能避免上面列举的问题。但无论是Web还是客户端,无论是diff by words还是diff by lines都不能解决全部问题,效率也不一定更高。

对于上面“局部变量与成员变量的定义”举的例子,如果想知道是谁在什么时候增加的double z,在分行写的情况下,用git blame或svn blame(两者都用于查看指定文件中每一行的修改历史和贡献者信息)立刻就能找到始作俑者。如果写成一行,那就得把文件的revision拿来一个个人工比较,因为这一行double x = 0.0, y = 1.0, z = -1.0;可能修改过多次,你得一个个看才知道什么时候加入了变量z。另外几种情况也使得blame的输出更易读。

比如上面“与namespace有关的缩进”改动了一行代码,你还是要向上翻页去找改的是哪个函数。人眼看的话还有“看走眼”的可能,又得再定睛观瞧。这一切都是在浪费人的时间,使用更好的图形化工具并不能减少浪费;相反,作者认为增加了浪费。

另外一个常见的工作场景,早上来到办公室,update一下代码,然后扫一眼diff output看看别人昨天动了哪些文件,改了哪些代码。这就是一两条命令的事,几秒就能结束战斗。如果用图形化的工具,得一个个点击文件diff的链接或打开新tab来看文件的side-by-side比较(不这么做的话就看不到足够多的上下文,跟看diff output无异),然后上下翻动页面去看别人到底改了什么。说实话,作者觉得这么做效率并不比diff高。

12.7 再探std::string

Scott Meyers在《Effective STL》[ESTL]第15条提到std::string有多种实现方式,归纳起来有三类,而每类又有多种变化。
1.无特殊处理(eager copy),采用类似std::vector的数据结构。现在很少有实现采用这种方式。

2.Copy-on-Write(COW)。g++的std::string一直采用这种方式实现(libstdc++的std::string是Nathan Myers的手笔)。

3.短字符串优化(SSO),利用string对象本身的空间来存储短字符串。Visual C++用的是这种实现方式。

表12-1总结了作者知道的各个库的string实现方式和string对象分别在32-bit/64-bit x86系统中的大小。
在这里插入图片描述
Visual C++的strng的大小跟编译模式有关,表12-1中小的那个数字是release编译,大的是debug编译。因此debug库和release库不能混用。除此之外,其他库的string大小是固定的。

以下分别介绍这几种实现方式的代码骨架和数据结构示意图,无论哪种实现方式都要保存三个数据:1.字符串本身(char[]),2.字符串的长度(size),3.字符串的容量(capacity)。

12.7.1 直接拷贝(eager copy)

类似std::vector的“三指针”结构(指向数组的第一个元素的指针(称为 start);指向数组的最后一个元素的下一个位置(称为 finish);指向数组分配的内存空间的末尾(称为 end_of_storage))。代码骨架(省略模板)如下,数据结构示意图如图12-1所示。

// eager copy string 1
// http://www.sgi.com/tech/stl/string

// Class invariants:
// (1) [start, finish) is a valid range.
// (2) Each iterator in [start, finish) points to a valid object
//     of type value_type.
// (3) *finish is a valid object of type value_type; in particular,
//     it is value_type().
// (4) [finish + 1, end_of_storage) is a valid range.

// (5) Each iterator in [finish + 1, end_of_storage) points to
//     unitialized memory.

// Note one important consequence: a string of length n must manage
// a block of memory whose size is at least n + 1.

class string
{
public:
    const_pointer data() const { return start; }
    iterator begin()           { return start; }
    iterator end()             { return finish; }
    size_type size() const     { return finish - start; }
    size_type capacity() const { return end_of_storage - start; }

private:
    char *start;
    char *finish;
    char *end_of_storage;
};

在这里插入图片描述
对象的大小是3个指针,在32-bit中是12字节,在64-bit中是24字节。

Eager copying string的另一种实现方式是把后两个成员变量替换成整数,表示字符串的长度和容量,代码骨架如下,数据结构示意图如图12-2所示。

// eager copy string 2
class string
{
public:
    const_pointer data() const { return start; }
    iterator begin()           { return start; }
    iterator end()             { return start + size_; }
    size_type size() const     { return size_; }
    size_type capacity() const { return capacity_; }

private:
    char *start;
    size_t size_;
    size_t capacity_;
};

在这里插入图片描述
这种做法并没有多大的改变,因为size_t和char *是一样大的。但是,我们通常用不到单个几百兆字节的字符串(如果真的用到了,就继续使用std::string或std::vector<char>好了),那么可以再改变一下长度和容量的类型(从64-bit整数改成32-bit整数),这样在64-bit下可以减小对象的大小,如图12-3所示。

// eager copy string 3
class string 
{
// ...
private:
    char *start;
    uint32_t size;
    uint32_t capacity;
};

在这里插入图片描述
新的string结构在64-bit中是16字节,比原来的24字节小了一些。

12.7.2 写时复制(copy-on-write)

string对象里只放一个指针,如图12-4所示。值得一提的是COW对多线程不友好,Andrei Alexandrescu提倡在多核时代应该改用eager copy stirng。

// copy-on-write string
class cow_string  // libstdc++-v3
{
    struct Rep
    {
        size_t size;
        size_t capacity;
        size_t refcount;
        char *data[1]; // variable length
    };
    char *start;
};

在这里插入图片描述
这种数据结构没啥好说的,在64-bit中似乎也没有优化空间。另外COW的操作复杂度不一定符合直觉,它拷贝字符串是O(1)时间,但是拷贝之后的第一次operator[]有可能是O(N)时间(http://coolshell.cn/articles/1443.html,总结一下这个链接中的内容,对于一个string,如果我们先通过operator[]获取某一个字符的引用,然后再复制这个string为string_copy,那么修改这个字符的引用会同时修改这两个string,因此,当我们使用operator[]或迭代器获取完一个字符的地址后,后续对string的拷贝时,COW将失效,作者写的是拷贝后的第一次operator[]是O(N)时间,这是有误的,正确说法应该是,operator[]或取迭代器后,后续的string copy时COW是失效的,此时如果再令迭代器失效,如push_back,那么后续的copy操作COW又会生效)。

12.7.3 短字符串优化(SSO)

string对象比前面两个都大,因为有本地缓冲区(local buffer)。

// short-string-optimized string
class sso_string // __gnu_ext::__sso_string
{
    char *start;
    size_t size;
    static const int kLocalSize = 15;
    union
    {
        char buffer[kLocalSize + 1];
        size_t capacity;
    } data;
};

内存布局如图12-5(左图)所示。如果字符串比较短(通常的阈值是15字节),那么直接存放在对象的buffer里,如图12-5(右图)所示。start指向data.buffer。
在这里插入图片描述
如果字符串超过15字节,那么就变成类似图12-2的eager copy 2结构,start指向堆上分配的空间(见图12-6)。
在这里插入图片描述
短字符串优化的实现方式不止一种,主要区别是把那三个指针/整数中的哪一个与本地缓冲重合。例如《Effective STL》[ESTL]第15条展现的“实现D”是将buffer与start指针重合,这正是Visual C++的做法。而STLPort(开源的标准模板库(Standard Template Library,STL)实现)的string是将buffer与end_of_storage指针重合。

SSO string在64-bit中有一个小小的优化空间:如果允许字符串max_size()不大于4GiB的话,我们可以用32-bit整数来表示长度和容量,这样同样是32字节的string对象,local buffer可以增大至19字节。

// short-string-optimized string 2
class sso_string // optimized for 64-bit
{
    char *start;
    uint32_t size;
    
    static const int kLocalSize = sizeof(void *) == 8 ? 19 : 15;
    
    union
    {
        char buffer[kLocalSize + 1];
        uint32_t capacity;
    } data;
};

内存布局如图12-7所示。
在这里插入图片描述
llvm/clang/lic++采用了与众不同的SSO实现,空间利用率最高。其local buffer几乎与三个指针/整数完全重合,在64-bit上对象大小是24字节,本地缓冲可达22字节。数据结构如图12-8所示。
在这里插入图片描述
它用一个bit来区分是长字符还是短字符,然后用位操作和掩码(mask)来取重叠部分的数据,因此实现是SSO里最复杂的,如图12-9所示。
在这里插入图片描述
Andrei Alexandrescu建议针对不同的应用负载选用不同的string,对于短字符串,用SSO string;对于中等长度的字符串,用eager copy;对于长字符串,用COW。具体分界点需要靠profiling来确定,选用合适的字符串可能提高10%的整体性能。

从实现的复杂度看,eager copy是最简单的,SSO稍微复杂一些,COW最难。性能也各有千秋,见Petr Ovtchenkov写的《Comparison of Strings Implementations in C++ language》(http://complement.sourceforge.net/compare.pdf)。作者准备自己写一个non-standard(C++标准库的string有很多设计缺陷,见Herb Sutter的《Exceptional C++ Style》第37~40条) non-template(见Steve Donovan写的《Overdoing C++ Templates》,http://blog.csdn.net/myan/article/details/1915)的string库(位于recipes/string)作为练手,计划采用eager copy 3和sso 2的数据结构。

注:C++03/98标准没有规定string中的字符是连续存储的,但是《Generic Programming and the STL》的作者Matthew Austern指出:现在所有的std::string实现都是连续存储的,因此建议在新标准中明确规定下来(http://www.open-std.org/JTC1/SC22/WG21/docs/lwg-defects.html#530)。

12.8 用STL algorithm轻松解决几道算法面试题

C++ STL的algorithm配合自定义的functor(仿函数、函数对象)可以轻松解决不少面试题,代码简洁,正确性也容易验证。本节仍旧采用C++03的functor写法,没有采用C++11的Lambda表达式写法,尽管后者会简洁得多。完整代码及测试用例见recipes/algorithm。

12.8.1 用nex_permutation()生成排列组合

本小节的内容源自10年前作者写的一篇博客(http://blog.csdn.net/Solstice/article/details/2059),这篇博客还找到了Visual C++ 7.0的STL的一个疑似bug(或者叫feature)。生成排列、组合、整数划分的具体算法见Donald Knuth的《The Art of Computer Programming, Volume 4A》第7.2.1节。本处只给出使用STL的实现代码。

生成N个不同元素的全排列

这是next_permutation()的基本用法,把元素从小到大放好(即字典序最小的排列),然后反复调用next_permutation()就行了。

// recipes/algorithm/permutation.cc
int main()
{
    int elements[] = { 1, 2, 3, 4 };
    const size_t N = sizeof(elements) / sizeof(elements[0]);
    std::vector<int> vec(elements, elements + N);
    
    int count = 0;
    do
    {
        std::cout << ++count << ": ";
        // 把vec中的元素复制到输出流中,构造输出流时,需要指定两个值:输出类型(int)和指定输出流(std::cout)
        // 此处还指定了一个可选的分隔符参数
        std::copy(vec.begin(), vec.end(), std::ostream_iterator<int>(std::cout, ", "));
        std::cout << std::endl;
    // next_permutation函数会获取当前序列的下一个更大的排列,更大是按字典序排的,因此需要vec一开始是有序的
    // 如果调用后vec已经是最大的排列,则返回false,否则返回true
    } while (next_permutation(vec.begin(), vec.end());    // 1
}

整个程序最关键的就是注释1所在行。输出的前几行如下:
在这里插入图片描述
类似的代码还能生成多重排列,比如2个a、3个b的全部排列,代码见permutation2.cc。输出如下:
在这里插入图片描述
注: 5 ! 2 ! × 3 ! = 10 \frac{5!}{2!×3!}=10 2!×3!5!=10(一共有5!种排列,其中2个a和3个b的位置可以互换)。

思考:能不能把do{}while()循环换成while(){}循环?

可以换,但while循环前需要先输出一下当前初始排列(最小的排列)。

题目:输出从7个不同元素中取3个元素的所有组合。

思路:对序列{1,1,1,0,0,0,0}做全排列。对于每个排列,输出数字1对应的位置上的元素。代码如下:

// recipes/algorithm/combination.cc
int main()
{
    int values[] = {1,2,3,4,5,6,7};
    int elements[] = {1,1,1,0,0,0,0};
    const size_t N = sizeof(elements) / sizeof(elements[0]);
    assert(N == sizeof(values) / sizeof(values[0]));
    std::vector<int> selectors(elements, elements + N);
    
    int count = 0;
    do
    {
        std::cout << ++count << ": ";
        for (size_t i = 0; i < selectors.size(); ++i)
        {
            if (selectors[i])
            {
                std::cout << values[i] << ", ";
            }
        }
        std::cout << std::endl;
    // prev_permutation函数重新排列selectors,使其成为上一个排列
    } while (prev_permutation(selectors.begin(), selectors.end()));    // 1
}

注意,为了照顾输出顺序,注释1所在行用的是prev_permutation()。程序输出如下:
在这里插入图片描述
可见完整地输出了C(7,4)=35种组合。

12.8.2 用unique()去除连续重复空白

孟岩在谈《C++程序设计原理与实践》(http://blog.csdn.net/hzbooks/article/details/5767169)时曾说:“比如对我来说,C++这个语言最强的地方在于它的模板技术提供了足够复杂的程序库开发机制,可以把复杂性高度集中在程序库里。做得好的话,在应用代码部分我连一个for循环都不用写,犯错误的机会就少,效率还不打折扣,关键是看着代码心里爽。”这几个小节可算是他这番话的一个注脚。C++11有了Lambda表达式,Scott Meyers提倡的“Prefer algorithm calls to hand-written loops”就更容易落实了(http://drdobbs.com/184401446)。

题目:给你一个字符串,要求原地(in-place)把相邻的多个空格替换为一个(来自http://gist.github.com/2227226)。例如,输入"a b",输出"a b";输入“aaa bbb ”,输出"aaa bbb "。

这道题目不难,手写的话也就是单重循环,复杂度是O(N)时间和O(1)空间。这里展示用std::unique()的解法,思路很简单:std::unique()的作用是去除相邻的重复元素,我们只要把“重复元素”定义为“两个元素都是空格”即可。注意所有针对区间的STL algorithm都只能调换区间内元素的顺序,不能真正删除容器内的元素,因此需要注释1。关键代码如下:

// recipes/algorithm/removeContinuousSpaces.cc
struct AreBothSpaces
{
    bool operator()(char x, char y) const
    {
        return x == ' ' && y == ' ';
    }
};

void removeContinuousSpaces(std::string &str)
{
    // AreBothSpaces()是创建了一个AreBothSpaces对象,std::unique会调用这个对象的operator()
    std::string::iterator last = std::unique(str.begin(), str.end(), AreBothSpaces());
    str.erase(last, str.end());    // 1
}

12.8.3 用{make,push,pop}_heap()实现多路归并

题目:用一台4GiB内存的机器对磁盘上的单个100GB文件排序(题目改编自http://blog.csdn.net/pennyliang/article/details/7073777)。

这种单机外部排序题目的标准思路是先分块排序,然后多路归并成输出文件。多路归并很容易用heap排序实现,比方说要归并已经按从小到大的顺序排好序的32个文件,我们可以构造一个32元素的min heap,每个元素是std::pair<Record, FILE *>。然后每次取出堆顶的元素,将其Record写入输出文件;如果FILE *还可读,就读入一条Record,再向heap中添加std::pair<Record, FILE *>。这样当heap为空的时候,多路归并就完成了。注意在这个过程中heap的大小通常会慢慢变小,因为有可能某个输入文件已经全部读完了。

这种方法比传统的二路归并要节省很多遍磁盘读写,假如用教科书上的二路归并来做外部排序(这种教科书有可能是在大型机还在使用磁带外存的时候写成的),那么我们要先读一遍这32个文件,两两归并输出16个稍大的已排序中间文件;然后再读一遍这16个中间文件,两两归并输出8个更大的中间文件;如此往复,最后归并两个已经排好序的大文件,输出最终的结果。读者可以算算这比直接多路归并要多读写多少遍磁盘。

完整的外部排序代码见recipes/esort/sort02.cc及其改进版sort{03,04}.cc。这里展示一个内存里的多路归并,以说明基本思路:

// recipes/algotirhm/mergeN.cc
File mergeN(const std::vector<File> &files)
{
    File output;
    std::vector<Input> inputs;
    
    for (size_t i = 0; i < files.size(); ++i)    // 1
    {
        Input input(&files[i]);
        if (input.next())
        {
            inputs.push_back(input);
        }
    }
    
    // 默认生成大顶堆
    std::make_heap(inputs.begin(), inputs.end());    // 2
    while (!inputs.empty())    // 3
    {
        // 把堆顶元素移动到最后位置
        std::pop_heap(inputs.begin(), inputs.end());    // 4
        // 输出最后位置的元素
        output.push_back(inputs.back().value);    // 5
        
        // 如果最后位置的元素表示的文件中还有值
        if (inputs.back().next())    // 6
        {
            // 已建堆中有新元素插入末尾时,调用std::push_heap重新建堆
            std::push_heap(inputs.begin(), inputs.end());    // 8
        }
        else
        {
            inputs.pop_back();
        }    // 7
    }
    
    return output;
}

注释1所在行到注释2所在行构造一个binary heap,注释3所在行开始的while循环反复取出堆顶元素(注释4所在行的std::pop_heap()会把堆顶元素放到序列末尾,即inputs.back()处),注释5所在行把取出的元素(当前最小值)(make_heap函数默认生成大顶堆,可能Input的大小比较方式与值相反)输出。注释6所在行到注释7所在行从堆顶元素所属的文件读入下一条记录,如果成功,就把它放回堆中(注释8所在行)。当循环结束的时候,堆为空,说明每个文件都读完了。其中用到的Input类型定义如下。

// recipes/algorithm/mergeN.cc
typedef int Record;
typedef std::vector<Record> File;

struct Input
{
    Record value;
    const File *file;
    
    explicit Input(const File *f);
    bool next();

    bool operator<(const Input &rhs) const
    {
        // make_heap to build min-heap, for merging
        return value > rhs.value;
    }
};

以上是多路归并的实现,再来考虑第一阶段分块排序的流水线设计。先做一个简化的假设:普通机械硬盘的读写速度是100MB/s,既然可用内容为4GB,那么分块(chunk)的大小就选定为1GB,这样读入和写出一个分块均耗时10秒。再假设在内存中排序1GB数据耗时10秒。为了编程方便,磁盘IO拥阻塞方式。按照这些假设,如果用单线程的方式实现外部排序,第一阶段的耗时是30N秒,其中N是分块数目。对一个6GB的文件排序,单线程程序(sort02.cc)的执行过程如图12-10所示,第一阶段将耗时180秒(只画出前120秒)。内存消耗为1GB。
在这里插入图片描述
注意到,在程序执行时,要么CPU繁忙,要么硬盘繁忙(Busy行的D表示磁盘,C表示CPU),资源并没有充分利用起来。为了加快排序速度,我们考虑用多线程,让计算和IO重叠,减少整体运行时间。注意这里我们不能简单地起多个进程,每个进程分别排序一个chunk,因为这样势必会造成多个进程争抢磁盘IO,而机械硬盘的随机读取比顺序读取慢得多。

一种解决办法是把IO放入一个单独的线程,避免争抢,然后用另外的线程(s)来排序内存中的数据块。换句话说,一个线程做IO(由于只有一块硬盘,那么不必使用多个IO线程),再用一个线程池做计算,以实现IO和计算重叠。我们预计这种方式完成分块排序会耗时120秒,比单线程快33%。预计执行流程(流水线)如图12-11所示。
在这里插入图片描述
注意同一时刻磁盘要么顺序读,要么顺序写,避免反复寻道的开销。这种方案会让CPU和磁盘同时繁忙,提高了资源利用率,内存消耗为2GB。这种思路的代码见sort03.cc。图12-12是一次实际运行的情况,方块的宽度与时间成正比。这里实际的磁盘和CPU的速度比前面的假设要快,因此第一阶段总耗时90秒。
在这里插入图片描述
注意到CPU的吞吐量(每秒排序100MB数据)大于单块磁盘吞吐量(读写100MB共耗时2秒),因此仍然会出现CPU等待IO的情况。如果有不止一块磁盘,可以重新设计流水线,进一步压缩运行时间。比方说把输入数据全部放在S盘(source),把分块排序的中间结果放到T盘(temporary),这样两块磁盘一读一写,可以相互重叠。在归并阶段,自然可以从T盘读数据写到S盘。这需要用到两个IO线程,每个磁盘配一个IO线程,确保每个磁盘都是顺序访问的,以保证吞吐量。这种方案的分块排序用时80秒,预计执行流程如图12-13所示,比第一种快50%以上,内存消耗也增长到3GB(这种方案的实现留作练习)。
在这里插入图片描述
还有一个简单的优化措施:最后的两三个排序结果不必写入磁盘,而是直接在内存中参与多路归并,这样大约可以再节约10秒。

类似的题目:有a、b两个文件,大小各式100GB左右,每行长度不超过1kB,这两个文件有少量(几百个)重复的行,要求用一台4GiB内存的机器找出这些重复行。

解这道题有两个方向,一是hash,把a、b两个文件按行的hash取模分成几百个小文件,每个小文件都在1GB以内,然后对a1、b1求交集c1,对a2、b2求交集c2,这样就能在内存里解决了。

第二个思路是外部排序,但是跟前面完整的外部排序不同,我们并不需要得到两个已排序的文件(a’和b’)再求交集,只需要把a分块排序成100个小文件,再把b分块排序成100个小文件。剩下的工作就是一边读这些小文件,一边在内存中同时归并出a’和b’,一边求出交集。内存中的两个多路归并需要两个heap,分别对应a和b的小文件(s)。内存中的运算流程如图12-14所示。
在这里插入图片描述
代码写起来估计比单个heap归并要复杂一些,特别是C++不支持类似C#的yield关键字(用于创建迭代器,迭代器是一种用于遍历集合(如数组、列表或集合)中的元素的对象,可以将一个方法或属性标记为迭代器方法,这样它就可以返回一系列值而不需要在内存中立即创建整个集合,这对于处理大型数据集合或延迟加载数据非常有用,因为它可以节省内存并提高性能)来方便地实现迭代。假如C++有yield,那么“求交集”这一步我们直接调用std::set_intersection()并配合适当的迭代器就行了,但是在没有yield的情况下要实现这样的迭代器恐怕要费事得多,因为每个迭代器要维护更多的状态。这算是coroutine(协程,一种并发编程模型,允许函数在执行期间暂时挂起并在稍后恢复执行,而不会丢失其状态)的一个使用场景。

上面两种解法的代价都是额外200GB磁盘空间,请读者思考有没有大大节省磁盘空间的做法。另外一个延伸的题目是:有几个巨大的文本文件,每行存放一个查询(query),将所有query按出现次数排序(代码https://gist.github.com/4009225)。

  • 12
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值