C++
工程实践:采用有利于版本管理的代码格式
版本管理(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。)
这里举一些例子。
对 diff 友好的代码格式
1. 多行注释也用 //,不用 /* */
Scott Meyers 写的《Effective C++》第二版第 4 条建议使用 C++ 风格,我这里为他补充一条理由:对
diff 友好。比如,我要注释一大段代码(其实这不是个好的做法,但是在实践中有时会遇到),如果用 /*
*/,那么得到的 diff 是:
从这样的 diff output 能看出注释了哪些代码吗?
如果用 //,结果会清晰很多:
同样的道理,取消注释的时候 // 也比 /* */ 更清晰。
另外,如果用 /* */ 来做多行注释,从 diff 不一定能看出来你是在修改代码还是修改注释。比如以下
diff 似乎修改了 muduo::EventLoop::runAfter 的调用参数:
总之,不要用 /* */ 来注释多行代码。
或许是时过境迁,大家都在用 // 注释了,《Effective C++》第三版去掉了这一条建议。
2. 局部变量与成员变量的定义
基本原则是,一行代码只定义一个变量,比如
double x;
double y;
将来代码增加一个 double z 的时候,diff 输出一眼就能看出改了什么:
@@ -63,6 +63,7 @@ private:
int count_;
double x;
double y;
+ double z;
};
int main()
如果把 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. 函数声明中的参数
如果函数的参数大于 3 个,那么在逗号后面换行,这样每个参数占一行,便于 diff。以 muduo::net::TcpClient
为例:
class TcpClient : boost::noncopyable
{
public:
TcpClient(EventLoop* loop,
const InetAddress& serverAddr,
const string& name);
如果将来 TcpClient 的构造函数增加或修改一个参数,那么很容易从 diff 看出来。这恐怕比在一行长代码里数逗号要高效一些。
4. 函数调用时的参数
在函数调用的时候,如果参数大于 3 个,那么把实参分行写。以 muduo::net::EPollPoller
为例:
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 具有相同的形式(比方说都是在倒数第二行加了一行内容),很容易肉眼验证有没有错位。如果参数写在一行里边,就得睁大眼睛数逗号了。
5. class 初始化列表的写法
同样的道理,class 初始化列表(initializer list)也遵循一行一个的原则,这样将来如果加入新的成员变量,那么两处(class
定义和 ctor 定义)的 diff 具有相同的形式,让错误无所遁形。以 muduo::net::Buffer
为例:
注意,初始化列表的顺序必须和数据成员声明的顺序相同。
6. 与 namespace 有关的缩进
Google 的 C++ 编程规范明确指出,namespace 不增加缩进。这么做非常有道理,方便 diff
–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; 是个表达式还是变量定义?
7. public 与 private
我认为这是 C++ 语法的一个缺陷,如果我把一个成员函数从 public 区移到 private 区,那么从
diff 上看不出来我干了什么,例如:
从上面的 diff 能看出我把 retry() 变成 private 了吗?对此我也没有好的解决办法,总不能每个函数前面都写上
public: 或 private: 吧?
对此 Java 和 C# 都做得比较好,它们把 public/private 等修饰符放到每个成员函数的定义中。这么做增加了信息的冗余度,让
diff 的结果更直观。
对 grep 友好的代码风格
操作符重载
C++工具匮乏,在一个项目里,要找到一个函数的定义或许不算太难(最多就是分析一下重载和模板特化),但是要找到一个函数的使用就难多了。不比
Java,在 Eclipse 里 Ctrl+Shift+G 就能找到所有的引用点。
假如我要做一个重构,想先找到代码里所有用到 muduo::timeDifference 的地方,判断一下工作是否可行,基本上惟一的办法是grep。用
grep 还不能排除同名的函数和注释里的内容。这也说明为什么要用 // 来引导注释,因为在 grep 的时候,一眼就能看出这行代码是在注释里的。
在我看来,operator overloading 应仅限于和 STL algorithm/container
配合时使用,比如 transform() 和 map<T,U>,其他情况都用具名函数为宜。原因之一是,我根本用
grep 找不到在哪儿用到了 operator-()。这也是 muduo::Timestamp 只提供
operator<() 而不提供 operator+() operator-() 的原因,我提供了两个函数
timeDifference 和 addTime 来实现所需的功能。
又比如,Google Protocol Buffers 的回调是 class Closure,它的接口用的是
virtual function Run() 而不是 virtual operator()()。
static_cast 与 C-style cast
为什么 C++ 要引入 static_cast 之类的转型操作符,原因之一就是像 (int*) pBuffer
这样的表达式基本上没办法用 grep 判断出它是个强制类型转换,写不出一个刚好只匹配类型转换的正则表达式。(again,语法是上下文无关的,无法用正则搞定。)
如果类型转换都用 *_cast,那只要 grep 一下我就能知道代码里哪儿用了 reinterpret_cast
转换,便于迅速地检查有没有用错。为了强调这一点,muduo 开启了编译选项 -Wold-style-cast
来帮助查找 C-style cast,这样在编译时就能帮我们找到问题。
一切为了效率
如果用图形化的文件比较工具,似乎能避免上面列举的问题。但无论是 web 还是客户端,无论是 inline
diff 还是 diff by lines 都不能解决全部问题,效率也不一定更高。
对于(2),如果想知道是谁在什么时候增加的 double z,在分行写的情况下,用 git blame
或 svn blame 立刻就能找到始作俑者。如果写成一行,那就得把文件的 revisions 拿来一个个人工比较,因为这一行
double x = 0.0, y = 1.0, z = -1.0; 可能修改过多次,你得一个个看才知道什么时候加入了变量
z。这个 blame 的 case 也适用于 3、4、5。
比如(6)改动了一行代码,你还是要 scroll up 去找改的是哪个 function,人眼看的话还有“看走眼”的可能,又得再定睛观瞧。这一切都是浪费人的时间,使用更好的图形化工具并不能减少浪费,相反,我认为增加了浪费。
另外一个常见的工作场景,早上来到办公室,update 一下代码,然后扫一眼 diff output 看看别人昨天动了哪些文件,改了哪些代码,这就是一两条命令的事,几秒钟就能解决战斗。如果用图形化的工具,得一个个点开文件
diff 的链接或点开新 tab 来看文件的 side-by-side 比较(不这么做的话看不到足够多的上下文,跟看
diff output 无异),然后点击鼠标滚动页面去看别人到底改了什么。说实话我觉得这么做效率不比 diff
高。
C++ 工程实践:二进制兼容性
C/C++ 的二进制兼容性 (binary compatibility) 有多重含义,本文主要在“头文件和库文件分别升级,可执行文件是否受影响”这个意义下讨论,我称之为
library (主要是 shared library,即动态链接库)的 ABI (application
binary interface)。至于编译器与操作系统的 ABI 留给下一篇谈 C++ 标准与实践的文章。
什么是二进制兼容性
在解释这个定义之前,先看看 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)库文件的时候,不必重新编译使用这个库的可执行文件或使用这个库的其他库文件,程序的功能不被破坏。
在 Windows 下有恶名叫 DLL Hell,比如 MFC 有一堆 DLL,mfc40.dll,
mfc42.dll, mfc71.dll, mfc80.dll, mfc90.dll,这是动态链接库的本质问题,怪不到
MFC 头上。
有哪些情况会破坏库的 ABI
到底如何判断一个改动是不是二进制兼容呢?这跟 C++ 的实现方式直接相关,虽然 C++ 标准没有规定
C++ 的 ABI,但是几乎所有主流平台都有明文或事实上的 ABI 标准。比方说 ARM 有 EABI,Intel
Itanium 有 http://www.codesourcery.com/public/cxx-abi/abi.html,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)把可执行文件和动态库绑到一起。如何判断一个改动是不是二进制兼容,主要就是看头文件暴露的这份“使用说明”能否与新版本的动态库的实际使用方法兼容。因为新的库必然有新的头文件,但是现有的二进制可执行文件还是按旧的头文件来调用动态库。
这里举一些源代码兼容但是二进制代码不兼容例子
- 给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
- 增加虚函数,会造成 vtbl 里的排列变化。(不要考虑“只在末尾增加”这种取巧行为,因为你的
class 可能已被继承。)
- 增加默认模板类型参数,比方说 Foo<T> 改为 Foo<T, Alloc=alloc<T>
>,这会改变 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>,客户端不需要用到 sizeof(Bar),那么可能是安全的。
- 如果客户代码里有 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。
哪些做法多半是安全的
前面我说“不能轻易修改”,暗示有些改动多半是安全的,只要库改动不影响现有的可执行文件的二进制代码的正确性,那么就是安全的,我们可以先部署新的库,让现有的二进制程序受益。
- 增加新的 class
- 增加 non-virtual 成员函数
- 修改数据成员的名称,因为生产的二进制代码是按偏移量来访问的,当然,这会造成源码级的不兼容。
- 还有很多,不一一列举了。
在 C++ 中以虚函数作为接口基本上就跟二进制兼容性说拜拜了。具体地说,以只包含虚函数的 class
(称为 interface class)作为程序库的接口,这样的接口是僵硬的,一旦发布,无法修改。
比方说 M$ 的 COM,其 DirectX 和 MSXML 都以 COM 组件方式发布,我们来看看它的带版本接口
(versioned interfaces):
- IDirect3D7, IDirect3D8, IDirect3D9, ID3D10*, ID3D11*
- IXMLDOMDocument, IXMLDOMDocument2, IXMLDOMDocument3
换话句话说,每次发布新版本都引入新的 interface class,而不是在现有的 interface
上做扩充。这样一样不能兼容现有的代码,强迫客户端代码也要改写。
回过头来看看 C 语言,C/Posix 这些年逐渐加入了很多新函数,同时,现有的代码不用修改也能运行得很好。如果要用这些新函数,直接用就行了,也基本不会修改已有的代码。相反,COM
里边要想用 IXMLDOMDocument3 的功能,就得把现有的代码从 IXMLDOMDocument
全部升级到 IXMLDOMDocument3,很讽刺吧。
tip:如果遇到鼓吹在 C++ 里使用面向接口编程的人,可以拿二进制兼容性考考他。
解决办法
采用静态链接
这个是王道。在分布式系统这,采用静态链接也带来部署上的好处,只要把可执行文件放到机器上就行运行,不用考虑它依赖的
libraries。目前 muduo 就是采用静态链接。 通过动态库的版本管理来控制兼容性
这需要非常小心检查每次改动的二进制兼容性并做好发布计划,比如 1.0.x 系列做到二进制兼容,1.1.x
系列做到二进制兼容,而 1.0.x 和 1.1.x 二进制不兼容。《程序员的自我修养》里边讲过 .so
文件的命名与二进制兼容性相关的话题,值得一读。
用 pimpl 技法,编译器防火墙
在头文件中只暴露 non-virtual 接口,并且 class 的大小固定为 sizeof(Impl*),这样可以随意更新库文件而不影响可执行文件。当然,这么做有多了一道间接性,可能有一定的性能损失。见
Exceptional C++ 有关条款和 C++ Coding Standards 101.
Java 是如何应对的
Java 实际上把 C/C++ 的 linking 这一步骤推迟到 class loading 的时候来做。就不存在“不能增加虚函数”,“不能修改
data member” 等问题。在 Java 里边用面向 interface 编程远比 C++ 更通用和自然,也没有上面提到的“僵硬的接口”问题。
|