文章的排版不行,大家可以去http://cloud.github.com/downloads/chenshuo/documents/CppPractice.pdf
查看原文
C++ 工程实践经验谈 by 陈硕 1
C++ 工程实践经验谈
陈硕 ([email protected])
最后更新 2012-4-1
版权声明
本作品采用“Creative Commons 署名 -非商业性使用 -禁止演绎 3.0 Unported 许可
协议 (cc by-nc-nd)”进行许可。http://creativecommons.org/licenses/by-nc-nd/3.0/
内容一览
1 慎用匿名 namespace . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
2 不要重载全局 ::operator new() . . . . . . . . . . . . . . . . . . . . . . . . 6
3 采用有利于版本管理的代码格式 . . . . . . . . . . . . . . . . . . . . . . . . . 13
4 二进制兼容性 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
5 避免使用虚函数作为库的接口 . . . . . . . . . . . . . . . . . . . . . . . . . . 28
6 动态库的接口的推荐做法 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
7 以 boost::function 和 boost:bind 取代虚函数 . . . . . . . . . . . . . . . . . 40
8 带符号整数的除法与余数 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
9 用异或来交换变量是错误的 . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
10 在单元测试中 mock 系统调用 . . . . . . . . . . . . . . . . . . . . . . . . . . 62
11 iostream 的用途与局限 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
12 值语义与数据抽象 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
13 再探 std::string . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
14 用 STL algorithm 秒杀几道算法面试题 . . . . . . . . . . . . . . . . . . . . . 122
说明
这是我的博客上关于 C++ 的文章的合集。最新版可从陈硕博客的置顶文章中下
载,地址见本页右下角。本系列文章适用于 Linux 操作系统,x86/amd64 硬件平台,
g++ 4.x 编译器,服务端开发。
http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 2
1 慎用匿名 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 并不是多多
益善。
1.1 C 语言的 static 关键字的两种用法
C 语言的 static 关键字有两种用途:
1. 用于函数内部修饰变量,即函数内的静态变量。这种变量的生存期长于该函数,
使得函数具有一定的“状态”。使用静态变量的函数一般是不可重入的,也不是
线程安全的,比如 strtok(3)。
2. 用在文件级别(函数体之外),修饰变量或函数,表示该变量或函数只在本文件
可见,其他文件看不到也访问不到该变量或函数。专业的说法叫“具有 internal
linkage”(简言之:不暴露给别的 translation unit)。
C 语言的这两种用法很明确,一般也不容易混淆。
1.2 C++ 语言的 static 关键字的四种用法
由于 C++ 引入了 class,在保持与 C 语言兼容的同时,static 关键字又有了两种
新用法:
3. 用于修饰 class 的数据成员,即所谓“静态成员”。这种数据成员的生存期大
于 class 的对象(实例/instance)。静态数据成员是每个 class 有一份,普通
数据成员是每个 instance 有一份,因此也分别叫做 class variable 和 instance
variable。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 3
4. 用于修饰 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 或许稍有不同,这里不展开讨论了。)
1.3 匿名 namespace 的不利之处
在工程实践中,匿名 namespace 有两大不利之处:
1. 匿名 namespace 中的函数是“匿名”的,那么在确实需要引用它的时候就比
较麻烦。
比如在调试的时候不便给其中的函数设断点,如果你像我一样使用的是 gdb 这
样的文本模式 debugger;又比如 profiler 的输出结果也不容易判别到底是哪个
文件中的 calculate() 函数需要优化。
2. 使用某些版本的 g++ 时,同一个文件每次编译出来的二进制文件会变化。
比如说拿到一个会发生 core dump 的二进制可执行文件,无法确定它是由哪
个 revision 的代码编译出来的。毕竟编译结果不可复现,具有一定的随机性。
(当然,在正式场合,这应该由软件配置管理 (SCM) 流程来解决。)
另外这也可能让某些 build tool 失灵,如果该工具用到了编译出来的二进制文
件的 MD5 的话。
考虑下面这段简短的代码 (anon.cc):
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 4
anon.cc
namespace
{
void foo()
{
}
}
int main()
{
foo();
}
anon.cc
对于问题 1: gdb 的
前提是你知道那个“(anonymous namespace)::foo()”正是你想要的函数。
$ gdb ./a.out
GNU gdb (GDB) 7.0.1-debian
(gdb) b ’
(anonymous namespace) __data_start _end
(anonymous namespace)::foo() __do_global_ctors_aux _fini
_DYNAMIC __do_global_dtors_aux _init
_GLOBAL_OFFSET_TABLE_ __dso_handle _start
_IO_stdin_used __gxx_personality_v0 anon.cc
__CTOR_END__ __gxx_personality_v0@plt call_gmon_start
__CTOR_LIST__ __init_array_end completed.6341
__DTOR_END__ __init_array_start data_start
__DTOR_LIST__ __libc_csu_fini dtor_idx.6343
__FRAME_END__ __libc_csu_init foo
__JCR_END__ __libc_start_main frame_dummy
__JCR_LIST__ __libc_start_main@plt int
__bss_start _edata main
(gdb) b ’(
anonymous namespace) anonymous namespace)::foo()
(gdb) b ’(anonymous namespace)::foo()’
Breakpoint 1 at 0x400588: file anon.cc, line 4.
麻烦的是,如果两个文件 anon.cc 和 anonlib.cc 都定义了匿名空间中的 foo()
函数(这不会冲突),那么 gdb 无法区分这两个函数,你只能给其中一个设断点。或
者你使用 文件名: 行号 的方式来分别设断点。(从技术上,匿名 namespace 中的函
数是 weak text,链接的时候如果发生符号重名,linker 不会报错。)
从根本上解决的办法是使用普通具名 namespace,如果怕重名,可以把源文件
名(必要时加上路径)作为 namespace 名字的一部分。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 5
对于问题 2: 把 anon.cc 编译两次,分别生成 a.out 和 b.out:
$ g++ -g -o a.out anon.cc
$ g++ -g -o b.out anon.cc
$ md5sum a.out b.out
0f7a9cc15af7ab1e57af17ba16afcd70 a.out
8f22fc2bbfc27beb922aefa97d174e3b b.out
$ g++ --version
g++ (GCC) 4.2.4 (Ubuntu 4.2.4-1ubuntu4)
$ diff -u <(nm a.out) <(nm b.out)
--- /dev/fd/63 2011-02-15 22:27:58.960754999 +0800
+++ /dev/fd/62 2011-02-15 22:27:58.960754999 +0800
@@ -2,7 +2,7 @@
0000000000600940 d _GLOBAL_OFFSET_TABLE_
0000000000400634 R _IO_stdin_used
w _Jv_RegisterClasses
-0000000000400538 t _ZN36_GLOBAL__N_anon.cc_00000000_E2CEEB513fooEv
+0000000000400538 t _ZN36_GLOBAL__N_anon.cc_00000000_CB51498D3fooEv
0000000000600748 d __CTOR_END__
0000000000600740 d __CTOR_LIST__
0000000000600758 d __DTOR_END__
由上可见,g++ 4.2.4 会随机地给匿名 namespace 生成一个惟一的名字(foo()
函数的 mangled name 中的 E2CEEB51 和 CB51498D 是随机的),以保证名字不冲突。
也就是说,同样的源文件,两次编译得到的二进制文件内容不相同,这有时候会造成
问题或困惑。
这可以用 gcc 的 -frandom-seed 参数解决,具体见文档。
这个现象在 gcc 4.2.4 中存在(之前的版本估计类似),在 gcc 4.4.5 中不存在。
1.4 替代办法
如果前面的“不利之处”给你带来困扰,解决办法也很简单,就是使用普通具名
namespace。当然,要起一个好的名字,比如 boost 里就常常用 boost::detail 来放
那些“不应该暴露给客户,但又不得不放到头文件里”的函数或 class。
总而言之,匿名 namespace 没什么大问题,使用它也不是什么过错。万一它碍
事了,可以用普通具名 namespace 替代之。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 6
2 不要重载全局 ::operator new()
本文只考虑 Linux x86 平台,服务端开发(不考虑 Windows 的跨 DLL 内存分配
释放问题)。本文假定读者知道 ::operator new() 和 ::operator delete() 是干什么
的,与通常用的 new/delete 表达式有和区别和联系,这方面的知识可参考侯捷先生
的文章《池内春秋》[1],或者这篇文章1。
C++ 的内存管理是个老生常谈的话题,我在《当析构函数遇到多线程》2 第 7
节“插曲:系统地避免各种指针错误”中简单回顾了一些常见的问题以及在现代
C++ 中的解决办法。基本上,按现代 C++ 的手法(RAII)来管理内存,你很难遇
到什么内存方面的错误。“没有错误”是基本要求,不代表“足够好”。我们常常
会设法优化性能,如果 profiling 表明 hot spot 在内存分配和释放上,重载全局的
::operator new() 和 ::operator delete() 似乎是一个一劳永逸好办法(以下简写
为“重载 ::operator new()”),本文试图说明这个办法往往行不通。
2.1 内存管理的基本要求
如果只考虑分配和释放,内存管理基本要求是“不重不漏”:既不重复 delete,
也不漏掉 delete。也就说我们常说的 new/delete 要配对,“配对”不仅是个数相等,
还隐含了 new 和 delete 的调用本身要匹配,不要“东家借的东西西家还”。例如:
• 用系统默认的 malloc() 分配的内存要交给系统默认的 free() 去释放;
• 用系统默认的 new 表达式创建的对象要交给系统默认的 delete 表达式去析构并
释放;
• 用系统默认的 new[] 表达式创建的对象要交给系统默认的 delete[] 表达式去析
构并释放;
• 用系统默认的 ::operator new() 分配的的内存要交给系统默认的 ::operator
delete() 去释放;
• 用 placement new 创建的对象要用 placement delete (为了表述方便,姑且这
么说吧)去析构(其实就是直接调用析构函数);
• 从某个内存池 A 分配的内存要还给这个内存池。
1
http://www.relisoft.com/book/tech/9new.html
2
http://blog.csdn.net/Solstice/archive/2010/01/22/5238671.aspx
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 7
• 如果定制 new/delete,那么要按规矩来。见 Effective C++ [2] 第 8 章“定制 new
和 delete”。
做到以上这些不难,是每个 C++ 开发人员的基本功。不过,如果你想重载全局的
::operator new(),事情就麻烦了。
2.2 重载::operator new() 的理由
《Effective C++ 第三版》[2] 第 50 条列举了定制 new/delete 的几点理由:
• 检测代码中的内存错误
• 优化性能
• 获得内存使用的统计数据
这些都是正当的需求,文末我们将会看到,不重载 ::operator new() 也能达到同样
的目的。
2.3 ::operator new() 的两种重载方式
1. 不改变其签名,无缝直接替换系统原有的版本,例如:
#include
void* operator new(size_t size);
void operator delete(void* p);
用这种方式的重载,使用方不需要包含任何特殊的头文件,也就是说不需要看见这两
个函数声明。“性能优化”通常用这种方式。
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);
然后用的时候是
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 8
Foo* p = new (__FILE, __LINE__) Foo; // 这样能跟踪是哪个文件哪一行代码分配的内存
我们也可以用宏替换 new 来节省打字。用这第二种方式重载,使用方需要看到这两
个函数声明,也就是说要主动包含你提供的头文件。“检测内存错误”和“统计内存
使用情况”通常会用这种方式重载。当然,这不是绝对的。
在学习 C++ 的阶段,每个人都可以写个一两百行的程序来验证教科书上的说法,
重载 ::operator new() 在这样的玩具程序里边不会造成什么麻烦。
不过,我认为在现实的产品开发中,重载 ::operator new() 乃是下策,我们有
更简单安全的办法来到达以上目标。
2.4 现实的开发环境
作为 C++ 应用程序的开发人员,在编写稍具规模的程序时,我们通常会用到一
些 library。我们可以根据 library 的提供方把它们大致分为这么几大类:
1. C 语言的标准库,也包括 Linux 编程环境提供的 glibc 系列函数。
2. 第三方的 C 语言库,例如 OpenSSL。
3. C++ 语言的标准库,主要是 STL。(我想没有人在产品中使用 iostream 吧?)
4. 第三方的通用 C++ 库,例如 Boost.Regex,或者某款 XML 库。
5. 公司其他团队的人开发的内部基础 C++ 库,比如网络通信和日志等基础设施。
6. 本项目组的同事自己开发的针对本应用的基础库,比如某三维模型的仿射变换
模块。
在使用这些 library 的时候,不可避免地要在各个 library 之间交换数据。比方说
library A 的输出作为 library B 的输入,而 library A 的输出本身常常会用到动态分配
的内存(比如 std::vector
如果所有的 C++ library 都用同一套内存分配器(就是系统默认的 new/delete),
那么内存的释放就很方便,直接交给 delete 去释放就行。如果不是这样,那就得时
时刻刻记住“这一块内存是属于哪个分配器,是系统默认的还是我们定制的,释放的
时候不要还错了地方”。
(由于 C 语言不像 C++ 一样提过了那么多的定制性,C library 通常都会默认直
接用 malloc/free 来分配和释放内存,不存在上面提到的“内存还错地方”问题。或
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 9
者有的考虑更全面的 C library 会让你注册两个函数,用于它内部分配和释放内存,
这就就能完全掌控该 library 的内存使用。这种依赖注入的方式在 C++ 里变得花哨而
无用,见陈硕写的《C++ 标准库中的 allocator 是多余的》3。)
但是,如果重载了 ::operator new(),事情恐怕就没有这么简单了。
2.5 重载 ::operator new() 的困境
首先,重载 ::operator new() 不会给 C 语言的库带来任何麻烦。当然,重载它
得到的三点好处也无法让 C 语言的库享受到。
以下仅考虑 C++ library 和 C++ 主程序。
规则 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),这就回到了规则 1:你无权重载全局 ::operator new(size_t)。
3
http://blog.csdn.net/Solstice/archive/2009/08/02/4401382.aspx
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 10
如 果 选 择 兼 容 系 统 默 认 的 ::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,这么做或许不会有什么问题,不过我建议你使用下一节介绍的更简单
的“替代办法”。
如果用第二种方式重载 ::operator new(size_t size, const char* file, int line),
那么你的行为是否惠及本程序用到的其他 C++ library 呢?比方说你要不要统计 C++
library 中的内存使用情况?如果某个 library 会返回它自己用 new 分配的内存和对
象,让你用完之后自己释放,那么是否打算对错误释放内存做检查?
C++ library 从代码组织上有两种形式:
1. 以头文件方式提供(如以 STL 和 Boost 为代表的模板库);
2. 以头文件 + 二进制库文件方式提供(大多数非模板库以此方式发布)。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 11
对于纯以头文件方式实现的 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() 并强塞给它。
如果确实需要定制内存分配,该如何办?
2.6 替代办法
很简单,替换 malloc()。如果需要,直接从 malloc 层面入手,通过 LD_PRELOAD
来加载一个 .so,其中有 malloc/free 的替代实现 (drop-in replacement),这样能同
时为 C 和 C++ 代码服务,而且避免 C++ 重载 ::operator new() 的阴暗角落。
对于“检测内存错误”这一用法,我们可以用 valgrind 或者 dmalloc 或者 efence
来达到相同的目的,专业的除错工具比自己山寨一个内存检查器要靠谱。
对于“统计内存使用数据”,替换 malloc 同样能得到足够的信息,因为我们可
以用 backtrace() 函数来获得调用栈,这比 new (__FILE__, __LINE__) 的信息更丰富。
比方说你通过分析 (__FILE__, __LINE__) 发现 std::string 大量分配释放内存,有超
出预期的开销,但是你却不知道代码里哪一部分在反复创建和销毁 std::string 对
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 12
象,因为 (__FILE__, __LINE__) 只能告诉你最内层的调用函数。用 backtrace() 能找
到真正的发起调用者。
对于“性能优化”这一用法,我认为这目前的多线程开发中,自己实现一个能
打败系统默认的 malloc 的内存分配器是不现实的。一个通用的内存分配器本来就有
相当的难度,为多线程程序实现一个安全和高效的通用(全局)内存分配器超出了
一般开发人员的能力。不如使用现有的针对多核多线程优化的 malloc,例如 Google
tcmalloc 和 Intel TBB 2.2 里的内存分配器。好在这些 allocator 都不是侵入式的,也
无须重载 ::operator new()。
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::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
4 说 explicit is better than implicit,我深信不疑。
总结: 重载 ::operator new() 或许在某些临时的场合能应个急,但是不应该作为
一种策略来使用。如果需要,我们可以从 malloc 层面入手,彻底而全面地替换内存
分配器。
4
http://www.python.org/dev/peps/pep-0020/
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 13
3 采用有利于版本管理的代码格式
版本管理 (version controlling) 是每个程序员的基本技能,C++ 程序员也不例
外。版本管理的基本功能之一是追踪代码变化,让你能清楚地知道代码是如何一步步
变成现在的这个样子,以及每次 check-in 都具体改动了哪些内部。无论是传统的集
中式版本管理工具,如 Subversion,还是新型的分布式管理工具,如 Git/Hg,比较
两个版本 (revision) 的差异都是其基本功能,即俗称“做一下 diff”。
diff 的输出是个窥孔 (peephole),它的上下文有限(diff -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。)
这里举一些例子。
3.1 对 diff 友好的代码格式
3.1.1 多行注释也用 //,不用 /* */
Scott Meyers 写的《Effective C++》第二版第 4 条建议使用 C++ 风格,我这里
为他补充一条理由:对 diff 友好。比如,我要注释一大段代码(其实这不是个好的做
法,但是在实践中有时会遇到),如果用 /* */,那么得到的 diff 是:
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 14
--- a/examples/asio/tutorial/timer5/timer.cc
+++ b/examples/asio/tutorial/timer5/timer.cc
@@ -18,6 +18,7 @@ class Printer : boost::noncopyable
loop2_->runAfter(1, boost::bind(&Printer::print2, this));
}
+ /*
~Printer()
{
std::cout << ”Final count is ” << count_ << ”\n”;
@@ -38,6 +39,7 @@ class Printer : boost::noncopyable
loop1_->quit();
}
}
+ */
void print2()
{
从这样的 diff output 能看出注释了哪些代码吗?
如果用 //,结果会清晰很多:
--- a/examples/asio/tutorial/timer5/timer.cc
+++ b/examples/asio/tutorial/timer5/timer.cc
@@ -18,26 +18,26 @@ class Printer : boost::noncopyable
loop2_->runAfter(1, boost::bind(&Printer::print2, this));
}
- ~Printer()
- {
- std::cout << ”Final count is ” << count_ << ”\n”;
- }
+ // ~Printer()
+ // {
+ // std::cout << ”Final count is ” << count_ << ”\n”;
+ // }
- void print1()
- {
- muduo::MutexLockGuard lock(mutex_);
- if (count_ < 10)
- {
- std::cout << ”Timer 1: ” << count_ << ”\n”;
- ++count_;
-
- loop1_->runAfter(1, boost::bind(&Printer::print1, this));
- }
- else
- {
- loop1_->quit();
- }
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 15
- }
+ // void print1()
+ // {
+ // muduo::MutexLockGuard lock(mutex_);
+ // if (count_ < 10)
+ // {
+ // std::cout << ”Timer 1: ” << count_ << ”\n”;
+ // ++count_;
+ //
+ // loop1_->runAfter(1, boost::bind(&Printer::print1, this));
+ // }
+ // else
+ // {
+ // loop1_->quit();
+ // }
+ // }
void print2()
{
同样的道理,取消注释的时候 // 也比 /* */ 更清晰。
另外,如果用 /* */ 来做多行注释,从 diff 不一定能看出来你是在修改代码还是
修改注释。比如以下 diff 似乎修改了 muduo::EventLoop::runAfter() 的调用参数:
--- a/examples/asio/tutorial/timer5/timer.cc
+++ b/examples/asio/tutorial/timer5/timer.cc
@@ -32,7 +32,7 @@ class Printer : boost::noncopyable
std::cout << ”Timer 1: ” << count_ << std::endl;
++count_;
- loop1_->runAfter(1, boost::bind(&Printer::print1, this));
+ loop1_->runAfter(2, boost::bind(&Printer::print1, this));
}
else
{
其实这个修改发生在注释里边(要增加上下文才能看到,diff -U 20,多一道手续,
降低了工作效率),对代码行为没有影响:
--- a/examples/asio/tutorial/timer5/timer.cc
+++ b/examples/asio/tutorial/timer5/timer.cc
@@ -20,31 +20,31 @@ class Printer : boost::noncopyable
/*
~Printer()
{
std::cout << ”Final count is ” << count_ << std::endl;
}
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 16
void print1()
{
muduo::MutexLockGuard lock(mutex_);
if (count_ < 10)
{
std::cout << ”Timer 1: ” << count_ << std::endl;
++count_;
- loop1_->runAfter(1, boost::bind(&Printer::print1, this));
+ loop1_->runAfter(2, boost::bind(&Printer::print1, this));
}
else
{
loop1_->quit();
}
}
*/
void print2()
{
muduo::MutexLockGuard lock(mutex_);
if (count_ < 10)
{
std::cout << ”Timer 2: ” << count_ << std::endl;
++count_;
总之,不要用 /* */ 来注释多行代码。
或许是时过境迁,大家都在用 // 注释了,《Effective C++》第三版去掉了这一条
建议。
3.1.2 局部变量与成员变量的定义
基本原则是,一行代码只定义一个变量,比如
double x;
double y;
将来代码增加一个 double z 的时候,diff 输出一眼就能看出改了什么:
@@ -63,6 +63,7 @@ private:
int count_;
double x;
double y;
+ double z;
};
int main()
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 17
如果把 x 和 y 写在一行,diff 的输出就得多看几眼才知道。
@@ -61,7 +61,7 @@ private:
muduo::net::EventLoop* loop1_;
muduo::net::EventLoop* loop2_;
int count_;
- double x, y;
+ double x, y, z;
};
int main()
所以,一行只定义一个变量更利于版本管理。同样的道理适用于 enum 成员的定义,
数组的初始化列表等等。
3.1.3 函数声明中的参数
如果函数的参数大于 3 个,那么在逗号后面换行,这样每个参数占一行,便于
diff。以 muduo::net::TcpClient 为例:
muduo/net/TcpClient.h
class TcpClient : boost::noncopyable
{
public:
TcpClient(EventLoop* loop,
const InetAddress& serverAddr,
const string& name);
muduo/net/TcpClient.h
如果将来 TcpClient 的构造函数增加或修改一个参数,那么很容易从 diff 看出来。这
恐怕比在一行长代码里数逗号要高效一些。
3.1.4 函数调用时的参数
在函数调用的时候,如果参数大于 3 个,那么把实参分行写。
以 muduo::net::EPollPoller 为例:
muduo/net/poller/EPollPoller.cc
Timestamp EPollPoller::poll(int timeoutMs, ChannelList* activeChannels)
{
int numEvents = ::epoll_wait(epollfd_,
&*events_.begin(),
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 18
static_cast
timeoutMs);
Timestamp now(Timestamp::now());
muduo/net/poller/EPollPoller.cc
这样一来,如果将来重构引入了一个新参数(好吧,epoll_wait 不会有这个问题),
那么函数定义和函数调用的地方的 diff 具有相同的形式(比方说都是在倒数第二行
加了一行内容),很容易肉眼验证有没有错位。如果参数写在一行里边,就得睁大眼
睛数逗号了。
3.1.5 class 初始化列表的写法
同样的道理,class 初始化列表 (initializer list) 也遵循一行一个的原则,这样将
来如果加入新的成员变量,那么两处(class 定义和 ctor 定义)的 diff 具有相同的形
式,让错误无所遁形。以 muduo::net::Buffer 为例:
muduo/net/Buffer.h
class Buffer : public muduo::copyable
{
public:
static const size_t kCheapPrepend = 8;
static const size_t kInitialSize = 1024;
Buffer()
: buffer_(kCheapPrepend + kInitialSize),
readerIndex_(kCheapPrepend),
writerIndex_(kCheapPrepend)
{
}
// 省略
private:
std::vector
size_t readerIndex_;
size_t writerIndex_;
};
muduo/net/Buffer.h
注意,初始化列表的顺序必须和数据成员声明的顺序相同。
3.1.6 与 namespace 有关的缩进
Google 的 C++ 编程规范明确指出,namespace 不增加缩进 5。这么做非常有道
理,方便 diff -p 把函数名显示在每个 diff chunk 的头上。
5
http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#Namespace_Formatting
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 19
如果对函数实现做 diff,chunk name 是函数名,让人一眼就能看出改的是哪个
函数。如下图,阴影部分。
diff --git a/muduo/net/SocketsOps.cc b/muduo/net/SocketsOps.cc
--- a/muduo/net/SocketsOps.cc
+++ b/muduo/net/SocketsOps.cc
@@ -125,7 +125,7 @@ int sockets::accept(int sockfd, struct sockaddr_in* addr)
case ENOTSOCK:
case EOPNOTSUPP:
// unexpected errors
- LOG_FATAL << ”unexpected error of ::accept”;
+ LOG_FATAL << ”unexpected error of ::accept ” << savedErrno;
break;
default:
LOG_FATAL << ”unknown error of ::accept ” << savedErrno;
如果对 class 做 diff,那么 chunk name 就是 class name。
diff --git a/muduo/net/Buffer.h b/muduo/net/Buffer.h
--- a/muduo/net/Buffer.h
+++ b/muduo/net/Buffer.h
@@ -60,13 +60,13 @@ class Buffer : public muduo::copyable
std::swap(writerIndex_, rhs.writerIndex_);
}
- size_t readableBytes();
+ size_t readableBytes() const;
- size_t writableBytes();
+ size_t writableBytes() const;
- size_t prependableBytes();
+ size_t prependableBytes() const;
const char* peek() const;
diff 原本是为 C 语言设计的,C 语言没有 namespace 缩进一说,所以它默认会
找到“顶格写”的函数作为一个 diff chunk 的名字,如果函数名前面有空格,它就不
认得了。muduo 的代码都遵循这一规则,例如:
muduo/base/Timestamp.h
namespace muduo
{
// class 从第一列开始写,不缩进
class Timestamp : public muduo::copyable
{
// ...
};
muduo/base/Timestamp.h
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 20
muduo/base/Timestamp.cc
// 函数的实现也从第一列开始写,不缩进。
Timestamp Timestamp::now()
{
struct timeval tv;
gettimeofday(&tv, NULL);
int64_t seconds = tv.tv_sec;
return Timestamp(seconds * kMicroSecondsPerSecond + tv.tv_usec);
}
muduo/base/Timestamp.cc
相反,boost 中的某些库的代码是按 namespace 来缩进的,这样的话看 diff 往往不
知道改动的是哪个 class 的哪个成员函数。
这个或许可以通过设置 diff 取函数名的正则表达式来解决,但是如果我们写代码
的时候就注意把函数“顶格写”,那么就不用去动 diff 的默认设置了。另外,正则表
达式不能完全匹配函数名,因为函数名是上下文无关语法 (context-free syntax),你
没办法写一个正则语法去匹配上下文无关语法。我总能写出某种函数声明,让你的
正则表达式失效(想想函数的返回类型,它可能是一个非常复杂的东西,更别说参数
了)。更何况 C++ 的语法是上下文相关的,比如你猜 Foo
变量定义?
3.1.7 public 与 private
我认为这是 C++ 语法的一个缺陷,如果我把一个成员函数从 public 区移到
private 区,那么从 diff 上看不出来我干了什么,例如:
diff --git a/muduo/net/TcpClient.h b/muduo/net/TcpClient.h
--- a/muduo/net/TcpClient.h
+++ b/muduo/net/TcpClient.h
@@ -37,7 +37,6 @@ class TcpClient : boost::noncopyable
void connect();
void disconnect();
- bool retry() const;
void enableRetry() { retry_ = true; }
/// Set connection callback.
@@ -60,6 +59,7 @@ class TcpClient : boost::noncopyable
void newConnection(int sockfd);
/// Not thread safe, but in loop
void removeConnection(const TcpConnectionPtr& conn);
+ bool retry() const;
EventLoop* loop_;
boost::scoped_ptr
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 21
从上面的 diff 能看出我把 retry() 变成 private 了吗?对此我也没有好的解决办法,
总不能每个函数前面都写上 public: 或 private: 吧?
对此 Java 和 C# 都做得比较好,它们把 public/private 等修饰符放到每个成员函
数的定义中。这么做增加了信息的冗余度,让 diff 的结果更直观。
3.1.8 避免使用版本控制软件的 keyword substitution 功能
这么做是为了避免 diff 噪音。
比方说,如果我想比较 0.1.1 和 0.1.2 两个代码分支有哪些改动,我通常会在
branches目录执行diff 0.1.1 0.1.2 -ru。两个branch中的muduo/net/EventLoop.h
其实是一样的(先后从同一个 revision 分支出来)。但是如果这个文件使用了 SVN 的
keyword substitution 功能(比如 $Id$),diff 会报告这两个 branches 中的文件不一
样,如下。
diff -rup 0.1.1/muduo/net/EventLoop.h 0.1.2/muduo/net/EventLoop.h
--- 0.1.1/muduo/net/EventLoop.h 2011-05-02 23:11:02.000000000 +0800
+++ 0.1.2/muduo/net/EventLoop.h 2011-05-02 23:12:22.000000000 +0800
@@ -8,7 +8,7 @@
//
// This is a public header file, it must only include public header files.
-// $Id: EventLoop.h 4 2011-05-01 10:11:02Z schen $
+// $Id: EventLoop.h 5 2011-05-02 15:12:22Z schen $
#ifndef MUDUO_NET_EVENTLOOP_H
#define MUDUO_NET_EVENTLOOP_H
这样纯粹增加了噪音,这是 RCS/CVS 时代过时的做法。文件的 Id 不应该在文件内
容中出现,这些 metadata 跟源文件的内容无关,应该由版本管理软件额外提供。
3.2 对 grep 友好的代码风格
3.2.1 操作符重载
C++ 工具匮乏,在一个项目里,要找到一个函数的定义或许不算太难(最多就
是分析一下重载和模板特化),但是要找到一个函数的使用就难多了。不比 Java,在
Eclipse 里 Ctrl+Shift+G 就能找到所有的引用点。
假如我要做一个重构,想先找到代码里所有用到 muduo::timeDifference() 的地
方,判断一下工作是否可行,基本上惟一的办法是 grep。用 grep 还不能排除同名的
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 22
函数和注释里的内容。这也说明为什么要用 // 来引导注释,因为在 grep 的时候,一
眼就能看出这行代码是在注释里的。
在我看来,operator overloading 应仅限于和 STL algorithm/container 配合
时使用,比如 std::transform() 和 map
宜。原因之一是,我根本用 grep 找不到在哪儿用到了减号 operator-()。这也是
muduo::Timestamp class 只提供 operator<() 而不提供 operator+() operator-() 的
原因,我提供了两个函数 timeDifference() 和 addTime() 来实现所需的功能。
又比如,Google Protocol Buffers 的回调是 class Closure,它的接口用的是
virtual function Run() 而不是 virtual operator()()。
3.2.2 static_cast 与 C-style cast
为什么 C++ 要引入 static_cast 之类的转型操作符,原因之一就是像 (int*)
pBuffer 这样的表达式基本上没办法用 grep 判断出它是个强制类型转换,写不出一
个刚好只匹配类型转换的正则表达式。(again,语法是上下文无关的,无法用正则搞
定。)
如果类型转换都用 *_cast,那只要 grep 一下我就能知道代码里哪儿用了 reinterpret_cast 转换,便于迅速地检查有没有用错。为了强调这一点,muduo 开启了编
译选项 -Wold-style-cast 来帮助查找 C-style casting,这样在编译时就能帮我们找
到问题。
3.3 一切为了效率
如果用图形化的文件比较工具,似乎能避免上面列举的问题。但无论是 web 还
是客户端,无论是 diff by words 还是 diff by lines 都不能解决全部问题,效率也不
一定更高。
对于 (3.1.2),如果想知道是谁在什么时候增加的 double z,在分行写的情况下,
用 git blame 或 svn blame 立刻就能找到始作俑者。如果写成一行,那就得把文件的
revisions 拿来一个个人工比较,因为这一行 double x = 0.0, y = 1.0, z = -1.0;
可能修改过多次,你得一个个看才知道什么时候加入了变量 z。这个 blame 的 case
也适用于 3、4、5。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 23
比如 (3.1.6) 改动了一行代码,你还是要 scroll up 去找改的是哪个 function,人
眼看的话还有“看走眼”的可能,又得再定睛观瞧。这一切都是浪费人的时间,使用
更好的图形化工具并不能减少浪费,相反,我认为增加了浪费。
另外一个常见的工作场景,早上来到办公室,update 一下代码,然后扫一眼
diff output 看看别人昨天动了哪些文件,改了哪些代码。这就是一两条命令的事,几
秒钟就能解决战斗。如果用图形化的工具,得一个个点开文件 diff 的链接或点开新
tab 来看文件的 side-by-side 比较(不这么做的话看不到足够多的上下文,跟看 diff
output 无异),然后点击鼠标滚动页面去看别人到底改了什么。说实话我觉得这么做
效率不比 diff 高。
4 二进制兼容性
本文主要讨论 Linux x86/x86-64 平台,偶尔会举 Windows 作为反面教材。
C++ 程序员有不同的角色,比如有主要编写应用程序的 (application),也有主要
编写程序库的 (library),有的程序员或许还身兼多职。如果公司的规模比较大,会出
现更细致和明确的分工。比如有的团队专门负责一两个公用的 library,有的团队负
责某个 application,并使用了前一个团队的 library。
举一个具体的例子。假设你负责一个图形库,这个图形库功能强大,且经过了充
分测试,于是在公司内慢慢推广开来。目前已经有二三十个内部项目用到了你的图
形库,大家日子过得挺好。前几天,公司新买了一批大屏幕显示器 (2560 1600 分辨
率),不巧你的图形库不能支持这么高的分辨率。(这其实不怪你,因为在你编写这
个库的时候,市面上显示器的最高分辨率是 1920 1200。)
结果用到了你的图形库的应用程序在 2560 1600 分辨率下不能正常工作,你该
怎么办?你可以发布一个新版的图形库,并要求那二三十个项目组用你的新库重新编
译他们的程序,然后让他们重新发布应用程序。或者,你提供一个新的库文件,直接
替换现有的库文件,应用程序可执行文件保持不变。
这两种做法各有优劣。第一种声势浩大,凡是用到你的库的团队都要经历一个
release cycle。后一种办法似乎节省人力,但是有风险:如果新的库文件和原有的应
用程序可执行文件不兼容怎么办?
所以,作为 C++ 程序员(无论是写应用的还是写基础库的),需要了解二进制兼
容性方面的知识。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 24
C/C++ 的二进制兼容性 (binary compatibility) 有多重含义,本文主要在“库文
件单独升级,现有可执行文件是否受影响”这个意义下讨论,我称之为 library (主
要是 shared library,即动态链接库)的 ABI (application binary interface)。至于编
译器与操作系统的 ABI 留给下一篇谈 C++ 标准与实践的文章。
4.1 什么是二进制兼容性
在解释这个定义之前,先看看 Unix/C 语言的一个历史问题:open() 的 flags
参数的取值。open(2) 函数的原型是
int open(const char *pathname, int flags);
其中 flags 的取值有三个:O_RDONLY, O_WRONLY, O_RDWR。
与一般人的直觉相反,这几个值不是按位或 (bitwise-OR) 的关系,即
O_RDONLY | O_WRONLY != O_RDWR。如果你想以读写方式打开文件,必须用 O_RDWR,
而不能用 (O_RDONLY | O_WRONLY)。为什么?因为 O_RDONLY, O_WRONLY, O_RDWR 的值分
别是 0, 1, 2。它们不满足按位或。
那么为什么 C 语言从诞生到现在一直没有纠正这个小小的缺陷?比方说把 O_-
RDONLY, O_WRONLY, O_RDWR 分别定义为 1, 2, 3,这样 O_RDONLY | O_WRONLY == O_RDWR,
符合直觉。而且这三个值都是宏定义,也不需要修改现有的源代码,只需要改改系统
的头文件就行了。
因为这么做会破坏二进制兼容性。对于已经编译好的可执行文件,它调用
open(2) 的参数是写死的,更改头文件并不能影响已经编译好的可执行文件。比方说
这个可执行文件会调用 open(path, 1) 来写文件,而在新规定中,这表示读文件,程
序就错乱了。
以上这个例子说明,如果以 shared library 方式提供函数库,那么头文件和库
文件不能轻易修改,否则容易破坏已有的二进制可执行文件,或者其他用到这个
shared library 的 library。
操作系统的 system call 可以看成 Kernel 与 User space 的 interface,kernel 在这
个意义下也可以当成 shared library,你可以把内核从 2.6.30 升级到 2.6.35,而不需
要重新编译所有用户态的程序。
所谓“二进制兼容性”指的就是在升级(也可能是 bug fix)库文件的时候,不
必重新编译使用了这个库的可执行文件或其他库文件,程序的功能不被破坏。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 25
见 QT FAQ 的有关条款:http://developer.qt.nokia.com/faq/answer/you_frequently_say_that_you_cannot_add_this_or_that_feature_because_it_woul
在 Windows 下 有 恶 名 叫 DLL Hell, 比 如 MFC 有 一 堆 DLL,mfc40.dll,
mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,这是动态链接库的本质问题,怪不到
MFC 头上。
4.2 有哪些情况会破坏库的 ABI
到底如何判断一个改动是不是二进制兼容呢?这跟 C++ 的实现方式直接相关,
虽然 C++ 标准没有规定 C++ 的 ABI,但是几乎所有主流平台都有明文或事实上
的 ABI 标准。比方说 ARM 有 EABI,Intel Itanium 有 Itanium ABI
6,x86-64 有仿
Itanium 的 ABI,SPARC 和 MIPS 也都有明文规定的 ABI,等等。x86 是个例外,它
只有事实上的 ABI,比如 Windows 就是 Visual C++,Linux 是 G++(G++ 的 ABI
还有多个版本,目前最新的是 G++ 3.4 的版本),Intel 的 C++ 编译器也得按照 Visual
C++ 或 G++ 的 ABI 来生成代码,否则就不能与系统其它部件兼容。
C++ ABI 的主要内容:
• 函数参数传递的方式,比如 x86-64 用寄存器来传函数的前 4 个整数参数
• 虚函数的调用方式,通常是 vptr/vtbl 然后用 vtbl[offset] 来调用
• struct 和 class 的内存布局,通过偏移量来访问数据成员
• name mangling
• RTTI 和异常处理的实现(以下本文不考虑异常处理)
C/C++ 通过头文件暴露出动态库的使用方法,这个“使用方法”主要是给编译
器看的,编译器会据此生成二进制代码,然后在运行的时候通过装载器 (loader) 把可
执行文件和动态库绑到一起。如何判断一个改动是不是二进制兼容,主要就是看头文
件暴露的这份“使用说明”能否与新版本的动态库的实际使用方法兼容。因为新的库
必然有新的头文件,但是现有的二进制可执行文件还是按旧的头文件来调用动态库。
这里举一些源代码兼容但是二进制代码不兼容例子
6
http://www.codesourcery.com/public/cxx-abi/abi.html
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 26
• 给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
• 增加虚函数,会造成 vtbl 里的排列变化。(不要考虑“只在末尾增加”这种取
巧行为,因为你的 class 可能已被继承。)
• 增加默认模板类型参数,比方说 Foo
改变 name mangling
• 改变 enum 的值,把 enum Color { Red = 3 }; 改为 Red = 4。这会造成错位。当
然,由于 enum 自动排列取值,添加 enum 项也是不安全的,在末尾添加除外。
给 class Bar 增加数据成员,造成 sizeof(Bar) 变大,以及内部数据成员的
offset 变化,这是不是安全的?通常不是安全的,但也有例外。
• 如果客户代码里有 new Bar,那么肯定不安全,因为 new 的字节数不够装下新
Bar 对象。相反,如果 library 通过 factory 返回 Bar* (并通过 factory 来销毁
对象)或者直接返回 shared_ptr
可能是安全的。
• 如果客户代码里有 Bar* pBar; pBar->memberA = xx;,那么肯定不安全,因为
memberA 的新 Bar 的偏移可能会变。相反,如果只通过成员函数来访问对象的
数据成员,客户端不需要用到 data member 的 offsets,那么可能是安全的。
• 如果客户调用 pBar->setMemberA(xx); 而 Bar::setMemberA() 是个 inline function,那么肯定不安全,因为偏移量已经被 inline 到客户的二进制代码里了。
如果 setMemberA() 是 outline function,其实现位于 shared library 中,会随着
Bar 的更新而更新,那么可能是安全的。
那么只使用 header-only 的库文件是不是安全呢?不一定。如果你的程序用了
boost 1.36.0,而你依赖的某个 library 在编译的时候用的是 1.33.1,那么你的程序和
这个 library 就不能正常工作。因为 1.36.0 和 1.33.1 的 boost::function 的模板参数类
型的个数不一样,后者一个多了 allocator。
这里有一份黑名单,列在这里的肯定是二级制不兼容,没有列出的也可能二进制
不兼容,见KDE的文档:http://techbase.kde.org/Policies/Binary_Compatibility_-
Issues_With_C%2B%2B
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 27
4.3 哪些做法多半是安全的
前面我说“不能轻易修改”,暗示有些改动多半是安全的,这里有一份白名单,
欢迎添加更多内容。
只要库改动不影响现有的可执行文件的二进制代码的正确性,那么就是安全的,
我们可以先部署新的库,让现有的二进制程序受益。
• 增加新的 class
• 增加 non-virtual 成员函数或 static 成员函数
• 修改数据成员的名称,因为生产的二进制代码是按偏移量来访问的,当然,这
会造成源码级的不兼容。
• 还有很多,不一一列举了。
欢迎补充
4.4 反面教材:COM
在 C++ 中以虚函数作为接口基本上就跟二进制兼容性说拜拜了。具体地说,以
只包含虚函数的 class (称为 interface class)作为程序库的接口,这样的接口是僵硬
的,一旦发布,无法修改。
此处的内容挪到“避免使用虚函数作为库的接口”一节中详细论述。
另外,Windows 下,Visual C++ 编译的时候要选择 Release 或 Debug 模式,而
且 Debug 模式编译出来的 library 通常不能在 Release binary 中使用(反之亦然),
这也是因为两种模式下的 CRT 二进制不兼容(主要是内存分配方面,Debug 有自己
的簿记)。Linux 就没有这个麻烦,可以混用。
4.5 解决办法
采用静态链接
这个是王道。在分布式系统这,采用静态链接也带来部署上的好处,只要把可执
行文件放到机器上就行运行,不用考虑它依赖的 libraries。目前 muduo 就是采用静
态链接。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 28
通过动态库的版本管理来控制兼容性
这需要非常小心检查每次改动的二进制兼容性并做好发布计划,比如 1.0.x 系列
做到二进制兼容,1.1.x 系列做到二进制兼容,而 1.0.x 和 1.1.x 二进制不兼容。《程序
员的自我修养》[3] 里边讲过 .so 文件的命名与二进制兼容性相关的话题,值得一读。
用 pimpl 技法,编译器防火墙
在头文件中只暴露 non-virtual 接口,并且 class 的大小固定为 sizeof(Impl*),
这样可以随意更新库文件而不影响可执行文件。具体做法见第 6 节。当然,这么做
有多了一道间接性,可能有一定的性能损失。另见 Exceptional C++ 有关条款和 C++
Coding Standards 101 [4].
5 避免使用虚函数作为库的接口
摘要 作为 C++ 动态库的作者,应当避免使用虚函数作为库的接口。这么做会给
保持二进制兼容性带来很大麻烦,不得不增加很多不必要的 interfaces,最终重蹈
COM 的覆辙。
本文主要讨论 Linux x86 平台,会继续举 Windows/COM 作为反面教材。
本文是上一篇《二进制兼容性》的延续,在写这篇文章的时候,我原本以外大家
都对“以 C++ 虚函数作为接口”的害处达成共识,我就写得比较简略,看来情况不
是这样,我还得展开谈一谈。
“接口”有广义和狭义之分,本文用中文“接口”表示广义的接口,即一个库的
代码界面;用英文 interface 表示狭义的接口,即只包含 virtual function 的 class,这
种 class 通常没有 data member,在 Java 里有一个专门的关键字 interface 来表示它。
5.1 C++ 程序库的作者的生存环境
假设你是一个 shared library 的维护者,你的 library 被公司另外两三个团队使
用了。你发现了一个安全漏洞,或者某个会导致 crash 的 bug 需要紧急修复,那么你
修复之后,能不能直接部署 library 的二进制文件?有没有破坏二进制兼容性?会不
会破坏别人团队已经编译好的投入生成环境的可执行文件?是不是要强迫别的团队重
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 29
新编译链接,把可执行文件也发布新版本?会不会打乱别人的 release cycle?这些都
是工程开发中经常要遇到的问题。
如果你打算新写一个 C++ library,那么通常要做以下几个决策:
• 以什么方式发布?动态库还是静态库?(本文不考虑源代码发布这种情况,这其
实和静态库类似。)
• 以什么方式暴露库的接口?可选的做法有:以全局(含 namespace 级别)函
数为接口、以 class 的 non-virtual 成员函数为接口、以 virtual 函数为接口
(interface)。
(Java 程序员没有这么多需要考虑的,直接写 class 成员函数就行,最多考虑一下要
不要给 method 或 class 标上 final。也不必考虑动态库静态库,都是 .jar 文件。)
在作出上面两个决策之前,我们考虑两个基本假设:
• 代码会有 bug,库也不例外。将来可能会发布 bug fixes。
• 会有新的功能需求。写代码不是一锤子买卖,总是会有新的需求冒出来,需要
程序员往库里增加东西。这是好事情,让程序员不丢饭碗。
(如果你的代码第一次发布的时候就已经做到完美,将来不需要任何修改,那么怎么
做都行,也就不必继续阅读本文。)
也就是说,在设计库的时候必须要考虑将来如何升级。
基于以上两个基本假设来做决定。第一个决定很好做,如果需要 hot fix,那么只
能用动态库;否则,在分布式系统中使用静态库更容易部署,这在前文中已经谈过。
(“动态库比静态库节约内存”这种优势在今天看来已不太重要。)
以下本文假定你或者你的老板选择以动态库方式发布,即发布 .so 或 .dll 文件,
来看看第二个决定怎么做。(再说一句,如果你能够以静态库方式发布,后面的麻烦
都不会遇到。)
第二个决定不是那么容易做,关键问题是,要选择一种可扩展的 (extensible) 接
口风格,让库的升级变得更轻松。“升级”有两层意思:
• 对于 bug fix only 的升级,二进制库文件的替换应该兼容现有的二进制可执行
文件,二进制兼容性方面的问题已经在前文谈过,这里从略。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 30
• 对于新增功能的升级,应该对客户代码的友好。升级库之后,客户端使用新功
能的代价应该比较小。只需要包含新的头文件(这一步都可以省略,如果新功
能已经加入原有的头文件中),然后编写新代码即可。而且,不要在客户代码中
留下垃圾,后文我们会谈到什么是垃圾。
在讨论虚函数接口的弊端之前,我们先看看虚函数做接口的常见用法。
5.2 虚函数作为库的接口的两大用途
虚函数为接口大致有这么两种用法:
• 调用,也就是库提供一个什么功能(比如绘图 Graphics),以虚函数为接口方
式暴露给客户端代码。客户端代码一般不需要继承这个 interface,而是直接调
用其 member function。这么做据说是有利于接口和实现分离,我认为纯属脱
了裤子放屁。
• 回调,也就是事件通知,比如网络库的“连接建立”、“数据到达”、“连接断
开”等等。客户端代码一般会继承这个 interface,然后把对象实例注册到库里
边,等库来回调自己。一般来说客户端不会自己去调用这些 member function,
除非是为了写单元测试模拟库的行为。
• 混合,一个 class 既可以被客户端代码继承用作回调,又可以被客户端直接调
用。说实话我没看出这么做的好处,但实际中某些面向对象的 C++ 库就是这么
设计的。
对于“回调”方式,现代 C++ 有更好的做法,即 boost::function + boost::bind,
见第 7 节,muduo 的回调全部采用这种新方法,见《Muduo 网络编程示例之零:前
言》7。本文以下不考虑以虚函数为回调的过时做法。
对于“调用”方式,这里举一个虚构的图形库,这个库的功能是画线、画矩形、
画圆弧:
struct Point
{
int x;
int y;
};
7
http://blog.csdn.net/Solstice/archive/2011/02/02/6171831.aspx
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 31
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
这里略去了很多与本文主题无关细节,比如 Graphics 的构造与析构、draw*() 函数
应该是 public、Graphics 应该不允许复制,还比如 Graphics 可能会用 pure virtual
functions 等等,这些都不影响本文的讨论。
这个 Graphics 库的使用很简单,客户端看起来是这个样子。
Graphics* g = getGraphics();
g->drawLine(0, 0, 100, 200);
releaseGraphics(g);
g = NULL;
似乎一切都很好,阳光明媚,符合“面向对象的原则”,但是一旦考虑升级,前景立
刻变得昏暗。
5.3 虚函数作为接口的弊端
以虚函数作为接口在二进制兼容性方面有本质困难:“一旦发布,不能修改”。
假如我需要给 Graphics 增加几个绘图函数,同时保持二进制兼容性。这几个新
函数的坐标以浮点数表示,我理想中的新接口是:
--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:13:30.000000000 +0800
@@ -7,11 +7,14 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
+ virtual void drawLine(double x0, double y0, double x1, double y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
virtual void drawRectangle(Point p0, Point p1);
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 32
virtual void drawArc(int x, int y, int r);
+ virtual void drawArc(double x, double y, double r);
virtual void drawArc(Point p, int r);
};
受 C++ 二进制兼容性方面的限制,我们不能这么做。其本质问题在于 C++ 以
vtable[offset] 方式实现虚函数调用,而 offset 又是根据虚函数声明的位置隐式确
定的,这造成了脆弱性。我增加了 drawLine(double x0, double y0, double x1,
double y1),造成 vtable 的排列发生了变化,现有的二进制可执行文件无法再用旧
的 offset 调用到正确的函数。
怎么办呢?有一种危险且丑陋的做法:把新的虚函数放到 interface 的末尾,例
如:
--- old/graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ new/graphics.h 2011-03-12 13:58:22.000000000 +0800
@@ -7,11 +7,15 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
+
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawArc(double x, double y, double r);
};
这么做很丑陋,因为新的 drawLine(double x0, double y0, double x1, double y1)
函数没有和原来的 drawLine() 函数呆在一起,造成阅读上的不便。这么做同时很危
险,因为 Graphics 如果被继承,那么新增虚函数会改变派生类中的 vtable offset 变
化,同样不是二进制兼容的。
另外有两种似乎安全的做法,这也是 COM 采用的办法:
1. 通过链式继承来扩展现有 interface,例如从 Graphics 派生出 Graphics2。
--- graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:58:35.000000000 +0800
@@ -7,11 +7,19 @@
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 33
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
+
+class Graphics2 : public Graphics
+{
+ using Graphics::drawLine;
+ using Graphics::drawRectangle;
+ using Graphics::drawArc;
+
+ // added in version 2
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawArc(double x, double y, double r);
+};
将来如果继续增加功能,那么还会有 class Graphics3 : public Graphics2; 以及
class Graphics4 : public Graphics3; 等等。这么做和前面的做法一样丑陋,因为新
的 drawLine(double x0, double y0, double x1, double y1) 函数位于派生 Graphics2
interace 中,没有和原来的 drawLine() 函数呆在一起,造成割裂。
2. 通过多重继承来扩展现有 interface,例如定义一个与 Graphics class 有同样成
员的 Graphics2,再让实现同时继承这两个 interfaces。
--- graphics.h 2011-03-12 13:12:44.000000000 +0800
+++ graphics2.h 2011-03-12 13:16:45.000000000 +0800
@@ -7,11 +7,32 @@
class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
+
+class Graphics2
+{
+ virtual void drawLine(int x0, int y0, int x1, int y1);
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 34
+ virtual void drawLine(double x0, double y0, double x1, double y1);
+ virtual void drawLine(Point p0, Point p1);
+
+ virtual void drawRectangle(int x0, int y0, int x1, int y1);
+ virtual void drawRectangle(double x0, double y0, double x1, double y1);
+ virtual void drawRectangle(Point p0, Point p1);
+
+ virtual void drawArc(int x, int y, int r);
+ virtual void drawArc(double x, double y, double r);
+ virtual void drawArc(Point p, int r);
+};
+
+// 在实现中采用多重接口继承
+class GraphicsImpl : public Graphics, // version 1
+ public Graphics2, // version 2
+{
+ // ...
+};
这种带版本的 interface 的做法在 COM 使用者的眼中看起来是很正常的(比
如IXMLDOMDocument、IXMLDOMDocument2、IXMLDOMDocument3,又比如
ITaskbarList、ITaskbarList2、ITaskbarList3、ITaskbarList4 等等),解决了二进制
兼容性的问题,客户端源代码也不受影响。
在我看来带版本的 interface 实在是很丑陋,因为每次改动都引入了新的 interface class,会造成日后客户端代码难以管理。比如,如果新版应用程序的代码使用了
Graphics3 的功能,要不要把现有代码中出现的 Graphics2 都替换掉?
• 如果不替换,一个程序同时依赖多个版本的 Graphics,一直背着历史包袱。依
赖的 Graphics 版本越积越多,将来如何管理得过来?
• 如果要替换,为什么不相干的代码(现有的运行得好好的使用 Graphics2 的代
码)也会因为别处用到了 Graphics3 而被修改?
这种二难境地纯粹是“以虚函数为库的接口”造成的。如果我们能直接原地扩充
class Graphics,就不会有这些屁事,见下一节“动态库接口的推荐做法”。
5.4 假如 Linux 系统调用以 COM 接口方式实现
或许上面这个 Graphics 的例子太简单,没有让“以虚函数为接口”的缺点充分
暴露出来,让我们看一个真实的案例:Linux Kernel。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 35
Linux kernel 从 0.10 的 67 个8 系统调用发展到 2.6.37 的 340 个9,kernel interface
一直在扩充,而且保持良好的兼容性,它保持兼容性的办法很土,就是给每个
system call 赋予一个终身不变的数字代号,等于把虚函数表的排列固定下来。点开
本段开头的两个链接,你就能看到 fork() 在 Linux 0.10 和 Linux 2.6.37 里的代号都是
2。(系统调用的编号跟硬件平台有关,这里我们看的是 x86 32-bit 平台。)
试想假如 Linus 当初选择用 COM 接口的链式继承风格来描述,将会是怎样一
种壮观的景象?为了避免扰乱视线,请移步观看近百层继承的代码10。(先后关系
与版本号不一定 100% 准确,我是用 git blame 去查的,现在列出的代码只从 0.01 到
2.5.31,相信已经足以展现 COM 接口方式的弊端。)
不要误认为“接口一旦发布就不能更改”是天经地义的,那不过是“以 C++ 虚
函数为接口”的固有弊端,如果跳出这个框框去思考,其实 C++ 库的接口很容易做
得更好。
为什么不能改?还不是因为用了 C++ 虚函数作为接口。Java 的 interface 可以
添加新函数,C 语言的库也可以添加新的全局函数,C++ class 也可以添加新 nonvirtual 成员函数和 namespace 级别的 non-member 函数,这些都不需要继承出新
interface 就能扩充原有接口。偏偏 COM 的 interface 不能原地扩充,只能通过继承
来 workaround,产生一堆带版本的 interfaces。有人说 COM 是二进制兼容性的正
面例子,某深不以为然。COM 确实以一种最丑陋的方式做到了“二进制兼容”。脆
弱与僵硬就是以 C++ 虚函数为接口的宿命。
相反,Linux 系统调用以编译期常数方式固定下来,万年不变,轻而易举地解
决了这个问题。在其他面向对象语言(Java/C#)中,我也没有见过每改动一次就给
interface 递增版本号的诡异做法。
还是应了《The Zen of Python》中的那句话,Explicit is better than implicit, Flat
is better than nested.
5.5 Java 是如何应对的
Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做。就
不存在“不能增加虚函数”,“不能修改 data member”等问题。在 Java 里边用面向
interface 编程远比 C++ 更通用和自然,也没有上面提到的“僵硬的接口”问题。
8
http://lxr.linux.no/linux-old+v0.01/include/unistd.h#L60
9
http://lxr.linux.no/linux+v2.6.37.3/arch/x86/include/asm/unistd_32.h
10
https://gist.github.com/867174
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 36
6 动态库的接口的推荐做法
取决于动态库的使用范围,有两类做法。
其一,如果动态库的使用范围比较窄,比如本团队内部的两三个程序在用,用户
都是受控的,要发布新版本也比较容易协调,那么不用太费事,只要做好发布的版本
管理就行了。再在可执行文件中使用 rpath 把库的完整路径确定下来。
比如现在 Graphics 库发布了 1.1.0 和 1.2.0 两个版本,这两个版本可以不必是二
进制兼容。用户的代码从 1.1.0 升级到 1.2.0 的时候要重新编译一下,反正他们要用
新功能都是要重新编译代码的。如果要原地打补丁,那么 1.1.1 应该和 1.1.0 二进制
兼容,而 1.2.1 应该和 1.2.0 兼容。如果要加入新的功能,而新的功能与 1.2.0 不兼容,
那么应该发布到 1.3.0 版本。
为了便于检查二进制兼容性,可考虑把库的代码的暴露情况分辨清楚。
muduo 的头文件和 class 就有意识地分为用户可见和用户不可见两部分,见
http://blog.csdn.net/Solstice/archive/2010/08/29/5848547.aspx。对于用户可见
的部分,升级时要注意二进制兼容性,选用合理的版本号;对于用户不可见的部
分,在升级库的时候就不必在意。另外 muduo 本身设计来是以原文件方式发布,在
二进制兼容性方面没有做太多的考虑。
其二,如果库的使用范围很广,用户很多,各家的 release cycle 不尽相同,那么
推荐 pimpl
11 技法 [4, item 43],并考虑多采用 non-member non-friend function in
namespace [2, item 23] [4, item 44 and 57] 作为接口。这里以前面的 Graphics 为例,
说明 pimpl 的基本手法。
1. 暴露的接口里边不要有虚函数,而且
sizeof(Graphics) == sizeof(Graphics::Impl*)。
graphics.h
class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
void drawRectangle(Point p0, Point p1);
11
pimpl 是 pointer to implementation 的缩写。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 37
void drawArc(int x, int y, int r);
void drawArc(Point p, int r);
private:
class Impl;
boost::scoped_ptr
};
graphics.h
2. 在库的实现中把调用转发 (forward) 给实现 Graphics::Impl ,这部分代码位于
.so/.dll 中,随库的升级一起变化。
graphics.cc
#include
class Graphics::Impl
{
public:
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
void drawArc(Point p, int r);
};
Graphics::Graphics()
: impl(new Impl)
{
}
Graphics::~Graphics()
{
}
void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}
void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
}
// ...
graphics.cc
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 38
3. 如果要加入新的功能,不必通过继承来扩展,可以原地修改,且很容易保持
二进制兼容性。先动头文件:
--- old/graphics.h 2011-03-12 15:34:06.000000000 +0800
+++ new/graphics.h 2011-03-12 15:14:12.000000000 +0800
@@ -7,19 +7,22 @@
class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor
void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(double x0, double y0, double x1, double y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
+ void drawRectangle(double x0, double y0, double x1, double y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
+ void drawArc(double x, double y, double r);
void drawArc(Point p, int r);
private:
class Impl;
boost::scoped_ptr
};
然后在实现文件里增加 forward,这么做不会破坏二进制兼容性,因为增加
non-virtual 函数不影响现有的可执行文件。
--- old/graphics.cc 2011-03-12 15:15:20.000000000 +0800
+++ new/graphics.cc 2011-03-12 15:15:26.000000000 +0800
@@ -1,35 +1,43 @@
#include
class Graphics::Impl
{
public:
void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(double x0, double y0, double x1, double y1);
void drawLine(Point p0, Point p1);
void drawRectangle(int x0, int y0, int x1, int y1);
+ void drawRectangle(double x0, double y0, double x1, double y1);
void drawRectangle(Point p0, Point p1);
void drawArc(int x, int y, int r);
+ void drawArc(double x, double y, double r);
void drawArc(Point p, int r);
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 39
};
Graphics::Graphics()
: impl(new Impl)
{
}
Graphics::~Graphics()
{
}
void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}
+void Graphics::drawLine(double x0, double y0, double x1, double y1)
+{
+ impl->drawLine(x0, y0, x1, y1);
+}
+
void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
}
采用 pimpl 多了一道 explicit forward 的手续,带来的好处是可扩展性与二进制兼容
性,通常是划算的。pimpl 扮演了编译器防火墙的作用。
pimpl 不仅 C++ 语言可以用,C 语言的库同样可以用,一样带来二进制兼容性
的好处,比如 libevent2 里边的 struct event_base 是个 opaque pointer,客户端看不
到其成员,都是通过 libevent 的函数和它打交道,这样库的版本升级比较容易做到二
进制兼容。
为什么 non-virtual 函数比 virtual 函数更健壮?因为 virtual function 是 bindby-vtable-offset,而 non-virtual function 是 bind-by-name。加载器 (loader) 会在程
序启动时做决议 (resolution),通过 mangled name 把可执行文件和动态库链接到一
起。就像使用 Internet 域名比使用 IP 地址更能适应变化一样。
万一要跨语言怎么办?很简单,暴露 C 语言的接口。Java 有 JNI 可以调用 C 语
言的代码;Python/Perl/Ruby 等等的解释器都是 C 语言编写的,使用 C 函数也不
在话下。C 函数是 Linux 下的万能接口。
本 文 只 谈 了 使 用 class 为 接 口,其 实 用 free function 有 时 候 更 好(比 如
muduo/base/Timestamp.h 除了定义 class Timestamp,还定义了 muduo::timeDifference() 等 free function),这也是 C++ 比 Java 等纯面向对象语言优越的地方。留给
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 40
将来再细谈吧。
7 以 boost::function 和 boost:bind 取代虚函数
这篇文章的中心思想是“面向对象的继承就像一条贼船,上去就下不来了”,而
借助 boost::function 和 boost::bind,大多数情况下,你都不用上贼船。
boost::function 和 boost::bind 已经纳入了 std::tr1,这或许是 C++0x 最值得
期待的功能,它将彻底改变 C++ 库的设计方式,以及应用程序的编写方式。
Scott Meyers 的 Effective C++ 3rd ed [2] 第 35 条款提到了以 boost::function
和 boost:bind 取代虚函数的做法,这里谈谈我自己使用的感受。
(这篇文章写得比较早,那会儿我还没有开始写 muduo,所以文章的例子与现
在的代码有些脱节。另见孟岩《function/bind 的救赎(上)》12,《回复几个问题》
13中的“四个半抽象”)
7.1 基本用途
boost::function 就像 C# 里的 delegate,可以指向任何函数,包括成员函数。
当用 bind 把某个成员函数绑到某个对象上时,我们得到了一个 closure(闭包)。例
如:
class Foo
{
public:
void methodA();
void methodInt(int a);
};
class Bar
{
public:
void methodB();
};
boost::function
Foo foo;
12
http://blog.csdn.net/myan/archive/2010/10/09/5928531.aspx
13
http://blog.csdn.net/myan/archive/2010/09/14/5884695.aspx
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 41
f1 = boost::bind(&Foo::methodA, &foo);
f1(); // 调用 foo.methodA();
Bar bar;
f1 = boost::bind(&Bar::methodB, &bar);
f1(); // 调用 bar.methodB();
f1 = boost::bind(&Foo::methodInt, &foo, 42);
f1(); // 调用 foo.methodInt(42);
boost::function
f2 = boost::bind(&Foo::methodInt, &foo, _1);
f2(53); // 调用 foo.methodInt(53);
如果没有 boost::bind,那么 boost::function 就什么都不是,而有了 bind,
“同一个类的不同对象可以 delegate 给不同的实现,从而实现不同的行为”(myan
语),简直就无敌了。
7.2 对程序库的影响
程序库的设计不应该给使用者带来不必要的限制(耦合),而继承是第二强的一
种耦合(最强耦合的是友元)。如果一个程序库限制其使用者必须从某个 class 派生,
那么我觉得这是一个糟糕的设计。不巧的是,目前不少 C++ 程序库就是这么做的。
例 1:线程库
常规 OO 设计 :
写一个 Thread base class,含有(纯)虚函数 Thread::run(),然后应用程序派
生一个 derived class,覆写 run()。程序里的每一种线程对应一个 Thread 的派生类。
例如 Java 的 Thread class 可以这么用。
缺点:如果一个 class 的三个 method 需要在三个不同的线程中执行,就得写
helper class(es) 并玩一些 OO 把戏。
基于 boost::function 的设计 :
令 Thread 是一个具体类,其构造函数接受 Callable 对象。应用程序只需提供
一个 Callable 对象,创建一份 Thread 实体,调用 Thread::start() 即可。Java 的
Thread也可以这么用,传入一个 Runnable对象。C# 的 Thread 只支持这一种用法,构
造函数的参数是 delegate ThreadStart。boost::thread 也只支持这种用法。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 42
// 一个基于 boost::function 的 Thread class 基本结构
class Thread
{
public:
typedef boost::function
Thread(ThreadCallback cb) : cb_(cb)
{ }
void start()
{
/* some magic to call run() in new created thread */
}
private:
void run()
{
cb_();
}
ThreadCallback cb_;
// ...
};
使用:
class Foo // 不需要继承
{
public:
void runInThread();
void runInAnotherThread(int)
};
Foo foo;
Thread thread1(boost::bind(&Foo::runInThread, &foo));
Thread thread2(boost::bind(&Foo::runInAnotherThread, &foo, 43));
thread1.start();
thread2.start();
例 2:网络库
以 boost::function 作为桥梁,NetServer class 对其使用者没有任何类型上的
限制,只对成员函数的参数和返回类型有限制。使用者 EchoService 也完全不知道
NetServer 的存在,只要在 main() 里把两者装配到一起,程序就跑起来了。
network library
class Connection;
class NetServer : boost::noncopyable
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 43
{
public:
typedef boost::function
typedef boost::function
int len)> MessageCallback;
NetServer(uint16_t port);
~NetServer();
void registerConnectionCallback(const ConnectionCallback&);
void registerMessageCallback(const MessageCallback&);
void sendMessage(Connection*, const void* buf, int len);
private:
// ...
};
network library
user code
class EchoService
{
public:
// 符合 NetServer::sendMessage 的原型
typedef boost::function
int)> SendMessageCallback;
EchoService(const SendMessageCallback& sendMsgCb)
: sendMessageCb_(sendMsgCb) // 保存 boost::function
{ }
// 符合 NetServer::MessageCallback 的原型
void onMessage(Connection* conn, const void* buf, int size)
{
printf(”Received Msg from Connection %d: %.*s\n”,
conn->id(), size, (const char*)buf);
sendMessageCb_(conn, buf, size); // echo back
}
// 符合 NetServer::ConnectionCallback 的原型
void onConnection(Connection* conn)
{
printf(”Connection from %s:%d is %s\n”,
conn->ipAddr(),
conn->port(),
conn->connected() ? ”UP” : ”DOWN”);
}
private:
SendMessageCallback sendMessageCb_;
};
// 扮演上帝的角色,把各部件拼起来
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 44
int main()
{
NetServer server(7);
EchoService echo(bind(&NetServer::sendMessage, &server, _1, _2, _3));
server.registerMessageCallback(
bind(&EchoService::onMessage, &echo, _1, _2, _3));
server.registerConnectionCallback(
bind(&EchoService::onConnection, &echo, _1));
server.run();
}
user code
7.3 对面向对象程序设计的影响
一直以来,我对面向对象有一种厌恶感,叠床架屋,绕来绕去的,一拳拳打在棉
花上,不解决实际问题。面向对象三要素是封装、继承和多态。我认为封装是根本
的,继承和多态则是可有可无。用 class 来表示 concept,这是根本的;至于继承和
多态,其耦合性太强,往往不划算。
继承和多态不仅规定了函数的名称、参数、返回类型,还规定了类的继承关系。
在现代的 OO 编程语言里,借助反射和 attribute/annotation,已经大大放宽了限制。
举例来说,JUnit 3.x 是用反射,找出派生类里的名字符合 void test*() 的函数来执
行,这里就没继承什么事,只是对函数的名称有部分限制(继承是全面限制,一字不
差)。至于 JUnit 4.x 和 NUnit 2.x 则更进一步,以 annoatation/attribute 来标明 test
case,更没继承什么事了。
我的猜测是,当初提出面向对象的时候,closure 还没有一个通用的实现,所以
它没能算作基本的抽象工具之一。现在既然 closure 已经这么方便了,或许我们应该
重新审视面向对象设计,至少不要那么滥用继承。
自从找到了 boost::function+boost::bind 这对神兵利器,不用再考虑类直接的
继承关系,只需要基于对象的设计 (object-based),拳拳到肉,程序写起来顿时顺手
了很多。
7.4 对面向对象设计模式的影响
既然虚函数能用 closure 代替,那么很多 OO 设计模式,尤其是行为模式,失去
了存在的必要。另外,既然没有继承体系,那么创建型模式似乎也没啥用了。
最明显的是 Strategy,不用累赘的 Strategy 基类和 ConcreteStrategyA、ConcreteStrategyB 等派生类,一个 boost::function<> 成员就解决问题。另外一个例
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 45
子是 Command 模式,有了 boost::function,函数调用可以直接变成对象,似乎
就没 Command 什么事了。同样的道理,Template Method 可以不必使用基类与
继承,只要传入几个 boost::function 对象,在原来调用虚函数的地方换成调用
boost::function 对象就能解决问题。
在《设计模式》这本书提到了 23 个模式,我认为 iterator 有用(或许再加个
State),其他都在摆谱,拉虚架子,没啥用。或许它们解决了面向对象中的常见问
题,不过要是我的程序里连面向对象(指继承和多态)都不用,那似乎也不用叨扰面
向对象设计模式了。
或许 closure-based programming 将作为一种新的 programming paradiam 而
流行起来。
7.5 依赖注入与单元测试
前面的 EchoService 可算是依赖注入的例子,EchoService 需要一个什么东西来
发送消息,它对这个“东西”的要求只是函数原型满足 SendMessageCallback,而并
不关系数据到底发到网络上还是发到控制台。在正常使用的时候,数据应该发给网
络,而在做单元测试的时候,数据应该发给某个 DataSink。
安照面向对象的思路,先写一个 AbstractDataSink interface,包含 sendMessage() 这个虚函数,然后派生出两个 classes:NetDataSink 和 MockDataSink,前
面那个干活用,后面那个单元测试用。EchoService 的构造函数应该以 AbstractDataSink* 为参数,这样就实现了所谓的接口与实现分离。
我认为这么做纯粹是脱了裤子放屁,直接传入一个 SendMessageCallback 对象
就能解决问题。在单元测试的时候,可以 boost::bind() 到 MockServer 上,或某个
全局函数上,完全不用继承和虚函数,也不会影响现有的设计。
7.6 什么时候使用继承?
如果是指 OO 中的 public 继承,即为了接口与实现分离,那么我只会在派生类
的数目和功能完全确定的情况下使用。换句话说,不为将来的扩展考虑,这时候面向
对象或许是一种不错的描述方法。一旦要考虑扩展,什么办法都没用,还不如把程序
写简单点,将来好大改或重写。
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 46
如果是功能继承,那么我会考虑继承 boost::noncopyable 或 boost::enable_-
shared_from_this,下一篇 blog 会讲到 enable_shared_from_this 在实现多线程安
全的对象回调时的妙用。
例如,IO-Multiplex 在不同的操作系统下有不同的推荐实现,最通用的 select(),
POSIX 的 poll(),Linux 的 epoll(),FreeBSD 的 kqueue 等等,数目固定,功能也完
全确定,不用考虑扩展。那么设计一个 NetLoop base class 加若干具体 classes 就是
不错的解决办法。换句话说,用多态来代替 switch-case 以达到简化代码的目的。
7.7 基于接口的设计
这个问题来自那个经典的讨论:不会飞的企鹅(Penguin)究竟应不应该继承自
鸟(Bird),如果 Bird 定义了 virtual function fly() 的话。讨论的结果是,把具体的
行为提出来,作为 interface,比如 Flyable(能飞的),Runnable(能跑的),然后让
企鹅实现 Runnable,麻雀实现 Flyable 和 Runnable。(其实麻雀只能双脚跳,不能
跑,这里不作深究。)
进一步的讨论表明,interface 的粒度应足够小,或许包含一个 method 就够了,
那么 interface 实际上退化成了给类型打的标签 (tag)。在这种情况下,完全可以使用
boost::function 来代替,比如:
// 企鹅能游泳,也能跑
class Penguin
{
public:
void run();
void swim();
};
// 麻雀能飞,也能跑
class Sparrow
{
public:
void fly();
void run();
};
// 以 boost::function 作为接口
typedef boost::function
typedef boost::function
typedef boost::function
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 47
// 一个既用到 run,也用到 fly 的客户 class
class Foo
{
public:
Foo(FlyCallback flyCb, RunCallback runCb)
: flyCb_(flyCb), runCb_(runCb)
{ }
private:
FlyCallback flyCb_;
RunCallback runCb_;
};
// 一个既用到 run,也用到 swim 的客户 class
class Bar
{
public:
Bar(SwimCallback swimCb, RunCallback runCb)
: swimCb_(swimCb), runCb_(runCb)
{ }
private:
SwimCallback swimCb_;
RunCallback runCb_;
};
int main()
{
Sparrow s;
Penguin p;
// 装配起来,Foo 要麻雀,Bar 要企鹅。
Foo foo(bind(&Sparrow::fly, &s), bind(&Sparrow::run, &s));
Bar bar(bind(&Penguin::swim, &p), bind(&Penguin::run, &p));
}
最后,向伟大的 C 语言致敬!
8 带符号整数的除法与余数
最近研究整数到字符串的转换,读到了 Matthew Wilson 的《Efficient Integer to
String Conversions》系列文章。14 他的巧妙之处在于,用一个对称的 digits 数组搞
定了负数转换的边界条件(二进制补码的正负整数表示范围不对称)。代码大致如下,
经过改写:
14
http://synesis.com.au/publications.html 搜 conversions
www.chenshuo.comC++ 工程实践经验谈 by 陈硕 48
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 的全部取值都是正确的(从 -2 147 483 648 到
2 147 483 647)。可以视为 itoa() 的参考实现,面试的标准答案。
读到这份代码,我心中顿时升起一个疑虑:《C Traps and Pitfalls》第 7.7 节 15
讲到,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 = 9 )。只有商向 0 取整,代
码才能正常工作。
为了弄清这个问题,我研究了一番。
15网上能下载到的一份简略版也有相同的内容,http://www.literateprogramming.com/ctraps.pdf 第
7.5 节。
www.chenshuo.com