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。
C++ 工程实践经验谈
1
影响面要小得多,它只影响本 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 Python5 说 explicit is better than implicit,我深信不疑。
出可以与 libc 的 malloc 媲美的通用内存分配器,在多核多线程时代更是如此。因 为 libc 有专人维护,会不断把适合新硬件的分配算法与策略整合进去。在打算写自 己的内存池之前,建议先看一看 Andrei Alexandrescu 在 ACCU 2008 会议的演讲 Memory Allocation: Either Love it or Hate It (Or Think It’s Just OK). 6 和这篇论文 Reconsidering Custom Memory Allocation. 总结: 分配器。
7 8
保持二进制兼容性带来很大麻烦,不得不增加很多不必要的 interfaces,最终重蹈 COM 的覆辙。 本文主要讨论 Linux x86 平台,会继续举 Windows/COM 作为反面教材。 本文是上一篇《二进制兼容性》的延续,在写这篇文章的时候,我原本以外大家 都对“以 C++ 虚函数作为接口”的害处达成共识,我就写得比较简略,看来情况不 是这样,我还得展开谈一谈。 “接口”有广义和狭义之分,本文用中文“接口”表示广义的接口,即一个库的 代码界面;用英文 interface 表示狭义的接口,即只包含 virtual function 的 class,这 种 class 通常没有 data member,在 Java 里有一个专门的关键字 interface 来表示它。
• 调用,也就是库提供一个什么功能(比如绘图 Graphics) ,以虚函数为接口方 式暴露给客户端代码。客户端代码一般不需要继承这个 interface,而是直接调 用其 member function。这么做据说是有利于接口和实现分离,我认为纯属脱 了裤子放屁。 • 回调,也就是事件通知,比如网络库的“连接建立” “数据到达” “连接断 、 、 开”等等。客户端代码一般会继承这个 interface,然后把对象实例注册到库里 边,等库来回调自己。一般来说客户端不会自己去调用这些 member function, 除非是为了写单元测试模拟库的行为。 • 混合,一个 class 既可以被客户端代码继承用作回调,又可以被客户端直接调 用。说实话我没看出这么做的好处,但实际中某些面向对象的 C++ 库就是这么 设计的。 对于“回调”方式,现代 C++ 有更好的做法,即 boost::function + boost::bind, 见第 7 节,muduo 的回调全部采用这种新方法,见《Muduo 网络编程示例之零:前 言》11 。本文以下不考虑以虚函数为回调的过时做法。 对于“调用”方式,这里举一个虚构的图形库,这个库的功能是画线、画矩形、 画圆弧:
struct Point { int x; int y; };
11 http://blog.csdn.net/Solstice/archive/2011/02/02/6171831.aspx
C++ 工程实践经验谈
5
避免使用虚函数作为库的接口 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); };
32
这里略去了很多与本文主题无关细节,比如 Graphics 的构造与析构、draw*() 函数 应该是 public、Graphics 应该不允许复制,还比如 Graphics 可能会用 pure virtual functions 等等,这些都不影响本文的讨论。 这个 Graphics 库的使用很简单,客户端看起来是这个样子。
Graphics* g = getGraphics(); g->drawLine(0, 0, 100, 200); releaseGraphics(g); g = NULL;
函数的坐标以浮点数表示,我理想中的新接口是:
--- 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);
C++ 工程实践经验谈
5
避免使用虚函数作为库的接口 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); };
33
受 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); };
到 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 integer 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
quotient with any fractional part discarded. (脚注:This is often called ”truncation toward zero”.) C99 明确规定了商是向 0 取整,也就意味着余数的符号与被除数相同,前面的 转换算法能正常工作。C99 Rationale 20 提到了这个规定的原因,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++0x (x 已经确定无疑是个十六进制数了) 最近的 n2800 草案第 5.6.4 节采用了与 C99 类似的表述:For integeral operands the / operator yields the algebraic quotient with any fractional part discarded; (This is often called truncation towards zero.) 可见 C++ 还是尽力保持与 C 的兼容性。 小结:C89 和 C++98 都留给实现去决定,而 C99 和 C++0x 都规定商向 0 取整, 这算是语言的进步吧。
20 http://www.open-std.org/jtc1/sc22/wg14/www/C99RationaleV5.10.pdf
C# 3.0 语言规定 The division rounds the result towards zero. 对于溢出的情
况,规定在 checked 上下文中抛 ArithmeticException 异常;在 unchecked 上下文里 没有明确规定,可抛可不抛。(据了解,C# 1.0/2.0 可能有所不同。 ) Python Python 在语言参考手册 25 的显著位置标明,商是向负无穷取整。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.
21 http://gcc.gnu.org/onlinedocs/gcc/Integers-implementation.html 22 http://msdn.microsoft.com/en-us/library/eayc4fzk.aspx 23 http://java.sun.com/docs/books/jls/third_edition/html/expressions.html#15.17.2 24 http://msdn.microsoft.com/en-us/vcsharp/aa336809.aspx 25 http://docs.python.org/reference/expressions.html#binary-arithmetic-operations
C++ 工程实践经验谈
8
带符号整数的除法与余数
52
Ruby Ruby 的语言手册没有明说,不过库的手册说到也是向负无穷取整。The quotient is rounded toward -infinity.
27 26
/* Return type of i_divmod */ enum divmod_result { DIVMOD_OK, DIVMOD_OVERFLOW, DIVMOD_ERROR };
/* Correct result */ /* Overflow, try again using longs */ /* Exception raised */
static enum divmod_result i_divmod(register long x, register long y, long *p_xdivy, long *p_xmody) { long xdivy, xmody; if (y == 0) {
26 http://www.ruby-doc.org/docs/ProgrammingRuby/html/ref_c_numeric.html#Numeric.divmod 27 http://perldoc.perl.org/perlop.html#Multiplicative-Operators 28 http://svn.python.org/view/python/tags/r266/Objects/intobject.c?view=markup
带符号整数的除法与余数 PyErr_SetString(PyExc_ZeroDivisionError, ”integer division or modulo 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; /* xdiv*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 quotient. We want the floor, * and we have it iff the remainder’s sign matches y’s. */ 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; }
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-”. */ #define UNARY_NEG_WOULD_OVERFLOW(x) \ ((x) < 0 && (unsigned long)(x) == 0-(unsigned long)(x)) python/tags/r266/Objects/intobject.c
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). 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. But, 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. */ static PyObject * int_mul(PyObject *v, PyObject *w) { long a, b; long longprod; double doubled_longprod; double doubleprod;
/* a*b in native long arithmetic */ /* (double)longprod */ /* (double)a * (double)b */
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; doubled_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_longprod == doubleprod == 0). The difference either is or isn’t significant compared to the true value (of which doubleprod is a good approximation). */ {
C++ 工程实践经验谈
static void fixdivmod(x, y, divp, modp) long x, y; long *divp, *modp; { long div, mod; if (y == 0) rb_num_zerodiv(); 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; if ((mod < 0 && y > 0) || (mod > 0 && y < 0)) { mod += y; div -= 1; } if (divp) *divp = div; if (modp) *modp = mod; }
这告诉我们,不要想当然地优化,也不要低估编译器的能力。关于现在的编译器 有多聪明,这里有一个不错的介绍 31 Bjarne Stroustrup 说过,I like my code to be elegant and efficient. The logic should be straightforward to make it hard for bugs to hide, the dependencies minimal to ease maintenance, error handling complete according to an articulated strategy, and performance close to optimal so as not to tempt people to make the code messy with unprincipled optimizations. Clean code does one thing well. 中文据韩磊的翻译《代码整洁之道》32 (陈硕 对文字有修改,出错责任在我)“我喜欢优雅和高效的代码。代码逻辑应当直截了 : 当,叫缺陷难以隐藏;尽量减少依赖关系,使之便于维护;以某种全局策略一以贯
31 http://www.linux-kongress.org/2009/slides/compiler_survey_felix_von_leitner.pdf 32 http://www.china-pub.com/196266
些技巧来提高生成代码的质量。现在已经不是那个懂点汇编就能打败编译器的时代 了。 Mark C. Chu-Carroll 有一篇博客文章《The “C is Efficient” Language Fallacy》
34
的观点我非常赞同: 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 independent: the human needs to express the algorithm in a way that 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++ 模板当然是错的,适当使用不会有问题。
34 http://scienceblogs.com/goodmath/2006/11/the_c_is_efficient_language_fa.php
C++ 工程实践经验谈
10 在单元测试中 mock 系统调用
63
10 在单元测试中 mock 系统调用
陈硕在《分布式程序的自动化回归测试》35 一文中曾经谈到单元测试在分布式程 序开发中的优缺点(好吧,主要是缺点) 。但是,在某些情况下,单元测试是很有必 要的,在测试 failure 场景的时候尤显重要,比如: • 在开发存储系统时,模拟 read(2)/write(2) 返回 EIO 错误(有可能是磁盘写满 了,有可能是磁盘出坏道读不出数据) 。 • 在开发网络库的时候,模拟 write(2) 返回 EPIPE 错误(对方意外断开连接) 。 • 在开发网络库的时候,模拟自连接 (self-connection),网络库应该用 getsockname(2) 和 getpeername(2) 判断是否是自连接,然后断开之。 • 在开发网络库的时候,模拟本地 ephemeral port 耗尽,connect(2) 返回 EAGAIN 临时错误。 • 让 gethostbyname(2) 返回我们预设的值,防止单元测试给公司的 DNS server 带来太大压力。 这些 test case 恐怕很难用前文提到的 test harness 来测试,该单元测试上场了。现在 的问题是,如何 mock 这些系统函数?或者换句话说,如何把对系统函数的依赖注入 到被测程序中?
ZooKeeper 的 C client library 正是采用了 link seams 来编写单元测试,代码见:
http://svn.apache.org/repos/asf/zookeeper/tags/release-3.3.3/src/c/tests/LibCMocks.h http://svn.apache.org/repos/asf/zookeeper/tags/release-3.3.3/src/c/tests/LibCMocks.cc
10.4
其他做法
Stack Overflow 的帖子里还提到一个做法,可以方便地替换动态库里的函数,即 使用 ld 的 –wrap 参数,文档里说得很清楚,这里不再赘述。
man ld(1)
--wrap=symbol Use a wrapper function for symbol. Any undefined reference to symbol will be resolved to ”__wrap_symbol”. Any undefined reference to ”__real_symbol” will be resolved to symbol. This can be used to provide a wrapper for a system function. The wrapper function should be called ”__wrap_symbol”. If it wishes to call the system function, it should call ”__real_symbol”. C++ 工程实践经验谈
10 在单元测试中 mock 系统调用 Here is a trivial example: void * __wrap_malloc (size_t c) { printf (”malloc called with %zu\n”, c); return __real_malloc (c); } If you link other code with this file using --wrap malloc, then all calls to ”malloc” will call the function ”__wrap_malloc” instead. The call to ”__real_malloc” in ”__wrap_malloc” will call the real ”malloc” function.
67
You may wish to provide a ”__real_malloc” function as well, so that links without the --wrap option will succeed. If you do this, you should not put the definition of ”__real_malloc” in the same file as ”__wrap_malloc”; if you do, the assembler may resolve the call before the linker has a chance to wrap it to ”malloc”. man ld(1)
10.5
第三方 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.h
如果需要为用到 File class 的程序编写单元测试,那么我们可以自己定义其成员 函数的实现,这样可以注入任何我们想要的结果。
MockFile.cc
int File::readn(void* data, int len) { C++ 工程实践经验谈
11 iostream 的用途与局限 return -1; }
68
MockFile.cc
(这个做法对动态库是可行的,静态库会报错。我们要么让对方提供专供单元测试的 动态库,要么拿过源码来自己编译一个。 ) Java 也有类似的做法,在 class path 里替换我们自己的 stub jar 文件,以实现 link seam。不过 Java 有动态代理,很少用得着 link seam 来实现依赖注入。
muduo 的 Timestamp 使用了 PRId64。37 Google C++ 编码规范也提到了 64-bit 兼容性。38 这些问题在 C++ 里都不存在,在这方面 iostream 是个进步。 C stdio 在类型安全方面原本还有一个缺点,即格式化字符串与参数类型不匹配 会造成难以发现的 bug,不过现在的编译器已经能够检测很多这种错误(使用 -Wall 编译选项) :
int main() { double d = 100.0; // warning: format ’%d’ expects type ’int’, but argument 2 has type ’double’
37 http://code.google.com/p/muduo/source/browse/trunk/muduo/base/Timestamp.cc#25 38 http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#64-bit_Portability
C++ 工程实践经验谈
11 iostream 的用途与局限 printf(”%d\n”, d);
72
short s; // warning: format ’%d’ expects type ’int*’, but argument 2 has type ’short int*’ scanf(”%d”, &s); size_t sz = 1; // no warning printf(”%zd\n”, sz); }
11.1.4 不可扩展? C stdio 的另外一个缺点是无法支持自定义的类型,比如我写了一个 Date class, 我无法像打印 int 那样用 printf() 来直接打印 Date 对象。
struct Date { int year, month, day; }; Date date; printf(”%D”, &date); // WRONG
类型可扩展和类型安全 “类型可扩展”和“类型安全”都是通过函数重载来实现的。 iostream 对初学者很友好,用 iostream 重写与前面同样功能的代码:
#include <iostream> #include <string> using namespace std; int main() { int i; short s; float f; double d; string name; cin >> i >> s >> f >> d >> name; cout << i << ” ” << s << ” ” << f << ” ” << d << ” ” << name << endl; }
11.4.2 外部可配置性 能不能用外部的配置文件来定义程序中日期的格式?C stdio 很好办,把格式字 符串 ”%d-%02d-%02d” 保存到配置里就行。但是 iostream 呢?它的格式是写死在代码 里的,灵活性大打折扣。 再举一个例子,程序的 message 的多语言化。
C++ 工程实践经验谈
11 iostream 的用途与局限 const char* name = ”Shuo Chen”; int age = 29; printf(”My name is %1$s, I am %2$d years old.\n”, name, age); cout << ”My name is ” << name << ”, I am ” << age << ” years old.” << endl;
78
对于 stdio,要让这段程序支持中文的话,把代码中的”My name is ...”,替换 为 ” 我叫%1$s,今年%2$d 岁。\n” 即可。也可以把这段提示语做成资源文件,在运 行时读入。而对于 iostream,恐怕没有这么方便,因为代码是支离破碎的。 C stdio 的格式化字符串体现了重要的“数据就是代码”的思想,这种“数据” 与“代码”之间的相互转换是程序灵活性的根源,远比 OO 更为灵活。 11.4.3 stream 的状态 如果我想用 16 进制方式输出一个整数 x,那么可以用 hex 操控符,但是这会改 变 ostream 的状态。比如说
int x = 8888; cout << hex << showbase << x << endl; cout << 123 << endl; // print 0x22b8 // print 0x7b
这这段代码会把 123 也按照 16 进制方式输出,这恐怕不是我们想要的。 再举一个例子,setprecision() 也会造成持续影响:
double d = 123.45; printf(”%8.3f\n”, d); cout << d << endl; cout << setw(8) << fixed << setprecision(3) << d << endl; cout << d << endl;
11.5 iostream 在设计方面的缺点
iostream 的设计有相当多的 WTFs,stackoverflow 有人吐槽说“If you had to judge by today’s software engineering standards, would C++’s IOStreams still be considered well-designed?”47 。
45 另外可以先试试调用 cin.sync_with_stdio(false); ,见 http://stackoverflow.com/questions/9371238 46 http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#Streams 47 http://stackoverflow.com/questions/2753060/who-architected-designed-cs-iostreams-and-would-
11 iostream 的用途与局限 // must provide buffering since callers may append small fragments // at a time to the file. class WritableFile { public: WritableFile() { } virtual ~WritableFile(); virtual virtual virtual virtual }; Status Status Status Status Append(const Slice& data) = 0; Close() = 0; Flush() = 0; Sync() = 0;
12 值语义与数据抽象
本文是第11节《iostream 的用途与局限》的后续,在11.3“iostream 与标准库其 他组件的交互”这一小节,我简单地提到 iostream 的对象和 C++ 标准库中的其他对 象(主要是容器和 string)具有不同的语义,主要体现在 iostream 不能拷贝或赋值。 今天全面谈一谈我对这个问题的理解。 本文的“对象”定义较为宽泛,a region of memory that has a type,在这个定 义下,int、double、bool 变量都是对象。
12.1
什么是值语义
值语义 (value sematics) 指的是对象的拷贝与原对象无关,就像拷贝 int 一样。 C++ 的内置类型 (bool/int/double/char) 都是值语义,标准库里的 complex<> 、 pair<>、vector<>、map<>、string 等等类型也都是值语意,拷贝之后就与原对象脱 离关系。Java 语言的 primitive types 也是值语义。 与值语义对应的是“对象语义/object sematics” ,或者叫做引用语义 (reference sematics),由于“引用”一词在 C++ 里有特殊含义,所以我在本文中使用“对象语 义”这个术语。对象语义指的是面向对象意义下的对象,对象拷贝是禁止的。例如 muduo 里的 Thread 是对象语义,拷贝 Thread 是无意义的,也是被禁止的:因为 Thread 代表线程,拷贝一个 Thread 对象并不能让系统增加一个一模一样的线程。 同样的道理,拷贝一个 Employee 对象是没有意义的,一个雇员不会变成两个雇 员,他也不会领两份薪水。拷贝 TcpConnection 对象也没有意义,系统里边只有一 个 TCP 连接,拷贝 TcpConnection 对象不会让我们拥有两个连接。Printer 也是不能 拷贝的,系统只连接了一个打印机,拷贝 Printer 并不能凭空增加打印机。此总总, 面向对象意义下的“对象”是 non-copyable。 Java 里边的 class 对象都是对象语义/引用语义。
C++ 工程实践经验谈
12 值语义与数据抽象 ArrayList<Integer> a = new ArrayList<Integer>(); ArrayList<Integer> b = a;
值语义的一个巨大好处是生命期管理很简单,就跟 int 一样——你不需要操心 int 的生命期。值语义的对象要么是 stack object,或者直接作为其他 object 的成员,因 此我们不用担心它的生命期(一个函数使用自己 stack 上的对象,一个成员函数使用 自己的数据成员对象) 。相反,对象语义的 object 由于不能拷贝,我们只能通过指针 或引用来使用它。 一旦使用指针和引用来操作对象,那么就要担心所指的对象是否已被释放,这一 度是 C++ 程序 bug 的一大来源。此外,由于 C++ 只能通过指针或引用来获得多态 性,那么在 C++ 里从事基于继承和多态的面向对象编程有其本质的困难——对象生 命期管理(资源管理) 。 考虑一个简单的对象建模——家长与子女:a Parent has a Child, a Child knows his/her Parent。在 Java 里边很好写,不用担心内存泄漏,也不用担心空悬指针:
public class Parent { private Child myChild; C++ 工程实践经验谈
12 值语义与数据抽象 } public class Child { private Parent myParent; }
99
只要正确初始化 myChild 和 myParent,那么 Java 程序员就不用担心出现访问错误。 一个 handle 是否有效,只需要判断其是否 non null。 在 C++ 里边就要为资源管理费一番脑筋:Parent 和 Child 都代表的是真人,肯 定是不能拷贝的,因此具有对象语义。Parent 是直接持有 Child 吗?抑或 Parent 和 Child 通过指针互指?Child 的生命期由 Parent 控制吗?如果还有 ParentClub 和 School 两个 class,分别代表家长俱乐部和学校:ParentClub has many Parent(s), School has many Child(ren),那么如何保证它们始终持有有效的 Parent 对象和 Child 对象?何时才能安全地释放 Parent 和 Child ? 直接但是易错的写法:
class Child; class Parent : boost::noncopyable { private: Child* myChild; }; class Child : boost::noncopyable { private: Parent* myParent; };
class Parent : public boost::enable_shared_from_this<Parent>, private boost::noncopyable { public: Parent() { } void addChild() { myChild.reset(new Child(shared_from_this())); } private: ChildPtr myChild; }; int main() { ParentPtr p(new Parent); p->addChild(); }
上面这个 shared_ptr+weak_ptr 的做法似乎有点小题大做。 考虑一个稍微复杂一点的对象模型:a Child has parents: mom and dad; a Parent has one or more Child(ren); a Parent knows his/her spouser. 这个对象模型用 Java 表述一点都不复杂,垃圾收集会帮我们搞定对象生命期。
public class Parent { private Parent mySpouser; private ArrayList<Child> myChildren; } public class Child { private Parent myMom; private Parent myDad; }
C++ 要求凡是能放入标准容器的类型必须具有值语义。准确地说:type 必须是 SGIAssignable concept 的 model。但是,由于 C++ 编译器会为 class 默认提供 copy constructor 和 assignment operator,因此除非明确禁止,否则 class 总是可以作为标 准库的元素类型——尽管程序可以编译通过,但是隐藏了资源管理方面的 bug。 因此,在写一个 class 的时候,先让它继承 boost::noncopyable,几乎总是正确 的。 在现代 C++ 中,一般不需要自己编写 copy constructor 或 assignment operator, 因为只要每个数据成员都具有值语义的话,编译器自动生成的 member-wise copying&assigning 就能正常工作;如果以 smart ptr 为成员来持有其他对象,那么就能 自动启用或禁用 copying&assigning。例外:编写 HashMap 这类底层库时还是需要 自己实现 copy control。
12.4
值语义与 C++ 语言
C++ 的 class 本质上是值语义的,这才会出现 object slicing 这种语言独有的问 题,也才会需要程序员注意 pass-by-value 和 pass-by-const-reference 的取舍。在其 他面向对象编程语言中,这都不需要费脑筋。 值语义是 C++ 语言的三大约束之一,C++ 的设计初衷是让用户定义的类型 (class) 能像内置类型 (int) 一样工作,具有同等的地位。为此 C++ 做了以下设计(妥 协) : • class 的 layout 与 C struct 一样,没有额外的开销。定义一个“只包含一个 int 成员的 class ”的对象开销和定义一个 int 一样。 • 甚至 class data member 都默认是 uninitialized,因为函数局部的 int 是 uninitialized。 • class 可 以 在 stack 上 创 建,也 可 以 在 heap 上 创 建。因 为 int 可 以 是 stack variable。
C++ 工程实践经验谈
12 值语义与数据抽象
104
• class 的数组就是一个个 class 对象挨着,没有额外的 indirection。因为 int 数组 就是这样。 • 编译器会为 class 默认生成 copy constructor 和 assignment operator。其他语 言没有 copy constructor 一说,也不允许重载 assignment operator。C++ 的对 象默认是可以拷贝的,这是一个尴尬的特性。 • 当 class type 传入函数时,默认是 make a copy (除非参数声明为 reference) 。 因为把 int 传入函数时是 make a copy。 C++ 的“函数调用”比其他语言复杂之处在于参数传递和返回值传递。C、Java 等语言都是传值,简单地复制几个字节的内存就行了。但是 C++ 对象是值语 义,如果以 pass-by-value 方式把对象传入函数,会涉及拷贝构造。代码里看 到一句简单的函数调用,实际背后发生的可能是一长串对象构造操作,因此减 少无谓的临时对象是 C++ 代码优化的关键之一。 • 当函数返回一个 class type 时,只能通过 make a copy(C++ 不得不定义 RVO 来解决性能问题) 。因为函数返回 int 时是 make a copy。 • 以 class type 为 成 员 时,数 据 成 员 是 嵌 入 的。例 如 pair<complex<double>, size_t> 的 layout 就是 complex<double> 挨着 size_t。 这些设计带来了性能上的好处,原因是 memory locality。比方说我们在 C++ 里定义 complex<double> class,array of complex<double>,vector<complex<double> >, 它们的 layout 分别是: (re 和 im 分别是复数的实部和虚部。 )
complex: re im
array handle ArrayList<Complex>: handle head [ ] size
head
re
im
head
hdl hdl null null
head
re
im
Java 里边每个 object 都有 header,在常见的 JVM 中有两个 word 的开销。对比 Java 和 C++,可见 C++ 的对象模型要紧凑得多。
12.5
什么是数据抽象
本节谈一谈与值语义紧密相关的数据抽象 (data abstraction),解释为什么它是与 面向对象并列的一种编程范式,为什么支持面向对象的编程语言不一定支持数据抽 象。C++ 在最初的时候是以 data abstraction 为卖点,不过随着时间的流逝,现在似 乎很多人只知 Object-Oriented,不知 data abstraction 了。C++ 的强大之处在于“抽 象”不以性能损失为代价,下一篇文章我们将看到具体例子。 数据抽象 (data abstraction) 是与面向对象 (object-oriented) 并列的一种编程范式 (programming paradigm)。说“数据抽象”或许显得陌生,它的另外一个名字“抽 象数据类型/abstract data type/ADT”想必如雷贯耳。
C++ 工程实践经验谈
12 值语义与数据抽象
106
“支持数据抽象”一直是 C++ 语言的设计目标,Bjarne Stroustrup 在他的《The C++ Programming Language》第二版(1991 年出版)中写道 [2nd]: The C++ programming language is designed to • be a better C • support data abstraction • support object-oriented programming 这本书第三版(1997 年出版)[3rd] 增加了一条: C++ is a general-purpose programming language with a bias towards systems programming that • is a better C, • supports data abstraction, • supports object-oriented programming, and • supports generic programming. 在 C++ 的早期文献 61 中中有一篇 Bjarne Stroustrup 在 1984 年写的《Data Abstraction in C++》62 。在这个页面还能找到 Bjarne 写的关于 C++ 操作符重载和复数运算 的文章,作为数据抽象的详解与范例。可见 C++ 早期是以数据抽象为卖点的,支持 数据抽象是 C++ 相对于 C 的一大优势。 作为语言的设计者,Bjarne 把数据抽象作为 C++ 的四个子语言之一。这个观 点不是普遍接受的,比如作为语言的使用者,Scott Meyers 在《Effective C++ 第三 版》中把 C++ 分为四个子语言:C、Object-Oriented C++、Template C++、STL。在 Scott Meyers 的分类法中,就没有出现数据抽象,而是归入了 object-oriented C++。 那么到底什么是数据抽象? 简单的说,数据抽象是用来描述 (抽象) 数据结构的。
类似的,muduo::Date、muduo::Timestamp 都是数据抽象。尽管这两个 classes 简单到只有一个 int/long 数据成员,但是它们各自定义了一套操作 (operation),并 隐藏了内部数据,从而让它从 data aggregation 变成了 data abstraction。 数据抽象是针对“数据”的,这意味着 ADT class 应该可以拷贝,只要把数据复 制一份就行了。如果一个 class 代表了其他资源(文件、员工、打印机、账号) ,那么 它就是 object-based 或 object-oriented,而不是数据抽象。 ADT class 可以作为 Object-based/object-oriented class 的成员,但反过来不成 立,因为这样一来 ADS class 的拷贝就失去意义了。
12.6
数据抽象所需的语言设施
不是每个语言都支持数据抽象,下面简要列出“数据抽象”所需的语言设施。 支持数据聚合 数 据 聚 合 data aggregation, 或 者 value aggregates。 即 定 义 C-
类似 std::vector 的“三指针”结构。代码骨架 (省略模板):
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 unininitialized 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 { C++ 工程实践经验谈
13 再探 std::string public: const_pointer data() const iterator begin() iterator end() size_type size() const size_type capacity() const private: char* start; char* finish; char* end_of_storage; };
类似的代码还能生成多重排列,比如 2 个 a、3 个 b 的全部排列,代码见 permutation2.cc。输出是
1: a, a, b, b, b, 2: a, b, a, b, b, 3: a, b, b, a, b, 4: a, b, b, b, a, 5: b, a, a, b, b, 6: b, a, b, a, b, 7: b, a, b, b, a, 8: b, b, a, a, b, 9: b, b, a, b, a, 10: b, b, b, a, a,
注:
5! = 10 2! × 3! 思考:能不能把 do {} while() 循环换成 while () {} 循环?
14.1.2 生成从 N 个元素中取出 M 个的所有组合 题目 输出从 7 个不同元素中取出 3 个元素的所有组合。
15 C++ 编译链接模型精要
C++ 从 C 语言 90 继承了一种古老的编译模型,引发了其他语言中根本不存在的 一些编译方面的问题(比方说“一次定义原则 (ODR)91 ” 。理解这些问题有助于在实 ) 际开发中规避各种古怪的错误。 C++ 语言的三大约束是:与 C 兼容、零开销 (zero overhead) 原则、值语义。值 语义的话题前文第 12 节已经讲过,这里谈谈第一点“与 C 兼容” 。
89 快速排序是本科生数据结构课上就有的内容,但是工业强度的实现是足以在顶级期刊上发论文的。 90 本文谈的
C 语言和 C++ 语言指的是现代的常见的实现(没有特别指明时,可认为是 Linux x86-64 的
GCC) ,并不限于 C 标准或 C++ 标准,因为标准里根本就没有提到“程序库/library”这个概念。另外本 文所提的 C 语言库函数不仅包括 C 标准中的函数,也包括 POSIX 里的常用函数,因为在 Linux 下二者是 不分家的,都位于 libc.so。 91 http://en.wikipedia.org/wiki/One_Definition_Rule
C++ 工程实践经验谈
15 C++ 编译链接模型精要
133
“与 C 兼容”的含义很丰富,不仅仅是兼容 C 的语法 92 ,更重要的是兼容 C 语 言的编译模型与运行模型,也就是说能直接使用 C 语言的头文件和库。比方说对于 connect(2) 这个系统函数,它的头文件和原型如下。
#include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
C++ 的基本类型的长度和表示 (representation) 必须和 C 语言 93 一样(int、指针等 等) ,C++ 编译器必须能理解头文件 sys/socket.h 中 struct sockaddr 的定义,生成与 C 编译器完全相同的 layout(包括采用相同的对齐 (alignment) 算法) ,并且遵循 C 语言的函数调用约定(参数传递,返回值传递,栈帧管理等等) ,才能直接调用这个 C 语言库函数。 由于现代操作系统暴露出的原生接口往往是 C 语言描述的 94 。C++ 兼容 C,从 而能在编译的时候直接使用这些头文件,并链接到相应的库上。并在运行的时候直接 调用 C 语言的函数库,这省了一道中间层的手续,可算作是是 C++ 高效的原因之一。 下图表明了 Linux 上编译一个 C++ 程序的典型过程。其中最耗时间的是 cc1plus 这一步,在一台正在编译 C++ 项目的机器上运行 top(1),排在首位的往往就是这个 进程。
Header files Library files
for (int i = 0; i < 100; ++i) { /* do something */ } 93 准确地说是和编译系统库的 C 语言编译器保持一致。 94 Windows 的原生 API 接口是 Windows.h 头文件,POSIX 是一堆 C 语言头文件,本文不区分系统调 用与用户态库函数,统称为“系统函数” 。
读者有兴趣的话可以把其中的 stdio.h 替换为 C++ 标准库的头文件 complex,看看预 处理之后的源代码有多少行,额外包含了哪些头文件。(在我的机器上测试,预处理 之后有 21879 行,包含了近 150 个头文件,包括 string、sstream 等大块头。 ) 值得一提的是,为了兼容 C 语言,C++ 付出了很大的代价。例如要兼容 C 语 言的隐式类型转换规则(例如整数类型提升) ,在让 C++ 的函数重载决议 (overload resolution) 规则无比复杂 97 。另外 class 定义式后面那个分号也不晓得谋杀了多少初 学者的时间 98 。Bjarne Stroustrup 自己也说“我又不是不懂如何设计出比 C++ 更漂 ”(由于 C 语言没函数重载,也就不存在重载决议,所以隐式类型转换 亮的语言 99 。 的危害没有体现在这一方面。 )
15.1 C 语言的编译模型及其成因
要想了解 C 语言的编译模型的成因,我们需要略微回顾一下 Unix 的早期历史
100
1969 年 Ken Thompson 用汇编语言在一台闲置的 PDP-7 上写出了 Unix 的史前版
101
本
。值得一提的是,PDP-7 的字长是 18-bit102 ,只能按字 (word) 寻址,不支持今
日常见的按 8-bit 字节寻址。假如 C 语言诞生在 PDP-7 上,计算机软硬件的发展史恐 怕要改写。
97 C++
复杂的作用域机制也大大增加了函数重载决议的难度,基本上只有 C++ 编译器才弄得清楚。
http://en.wikipedia.org/wiki/C%2B%2B#Parsing_and_processing_C.2B.2B_source_code 98 这是为了与 C struct 语法兼容,因为 C 允许在函数返回类型处定义新 struct 类型,因此分号是必需的。 99 Even I knew how to design a prettier language than C++. — Bjarne Stroustrup 100 The Evolution of the Unix Time-sharing System. Dennis M. Ritchie. http://cm.bell-labs.com/cm/cs/who/dmr/hist.pdf 101 Unix 历史一般从 1970 年算起(Unix Epoch 是 1970-01-01 00:00:00 UTC) ,因此这个只能算“史前” 。 102 http://en.wikipedia.org/wiki/PDP-7
C++ 工程实践经验谈
15 C++ 编译链接模型精要
136
1970 年 5 月,Ken Thompson 和 Dennis Ritchie 所在的贝尔实验室下订单购买 了一台 PDP-11 小型机 103 ,这是 1970 年 1 月刚刚上市的新机型。PDP-11 的字长是 16-bit,可以按 8-bit 字节寻址,这可谓一举奠定了今后 C 语言及硬件的发展道路 104 。 这台机器的主机(处理器和内存)当年夏天就到货了,但是硬盘直到 1970 年 12 月才 到货。 1971 年,Ken Thompson 把原来运行在 PDP-7 上的 Unix 用 PDP-11 汇编人肉重 写了一遍,运行在这台 PDP-11/20 机器上。这台机器一共只有 24kB 内存105 ,其中 16kB 运行操作系统,8kB 运行用户代码 106 ;硬盘一共只有 512kB,文件大小限制为 64kB。然后实现了一个文本处理器,用于排版贝尔实验室的专利申请,这是购买这 台计算机的正经用途。 下面的 Unix 历史多半发生在另外一台内存和硬盘都更大的 PDP-11 机器上,型 号可能是 PDP-11/40 或 PDP-11/45。 (不同的权威文献说法不一,可能不止一台。 ) 1972 年是 C 语言历史上最为关键的一年 107 ,这一年 C 语言加入了预处理,具备 了编写大型程序的能力(理由见下文) 。到了 1973 年初,C 语言基本定型,主要新特 性是支持结构体。此时 C 语言的编译模型已经基本定型,即分为预处理、编译、汇 编、链接这四个步骤,沿用至今。 1973 年是 Unix 历史上关键的一年,这一年夏天,二神把 Unix 的内核用 C 语言 重写了一遍,完成了用高级语言编写操作系统的伟大创举。(Thompson 在 1972 年 就尝试过用 C 重写 Unix 内核,但是当时的 C 语言不支持结构体,因此他放弃了。 ) 随后,1974 年,Dennis Ritchie 和 Ken Thompson 发表了经典论文《The UNIX Time-Sharing System》108 。除了没有函数原型声明外,1974 年的 C 代码 109 读起来 跟现在的 C 程序基本无区别。 这里的 Unix 早期版本历史还参考了 http://minnie.tuhs.org/cgi-bin/utree.pl
103 http://en.wikipedia.org/wiki/PDP-11 104 在 C 语言 70 年代开始流行之后,高效支持 C 语言就成了 CPU 指令集的设计目标之一,否则这种 CPU
很难推广。另外,C/Unix/Arpanet 还规范了字节的长度,在此之前,字节可以是 6、7、8、9、12 比特, 之后都是 8-bit,否则就不能与其他系统联网通信。http://herbsutter.com/2011/10/12/dennis-ritchie/ 105 用的是磁芯存储器 http://en.wikipedia.org/wiki/Magnetic-core_memory ,因此早期文献常以 core 指代内存。 106 PDP-11/20 是 PDP-11 系列的第一个型号,甚至没有内存保护机制,也就没法区分核心态和用户态。 107 The Development of the C Language. Dennis M. Ritchie. http://cm.bell-labs.com/cm/cs/who/dmr/chist.pdf 108 http://cm.bell-labs.com/cm/cs/who/dmr/cacm.pdf 这篇文章至少有三个版本,第一版以单页摘要 的形式发表于 1973 年 10 月的第四届 ACM SOSP 会议上,第二版发表于 1974 年 7 月的 CACM 期刊上, 第三版发表于 1978 年七 -八月的 BSTJ 上。此处连接是第三版,内容与 CACM 的原始版本略有出入。 109 Unix V5 的 C 编译器源码:http://minnie.tuhs.org/cgi-bin/utree.pl?file=V5/usr/c
C++ 工程实践经验谈
15 C++ 编译链接模型精要
137
15.1.1 为什么 C 语言需要预处理? 了解了 C 语言的诞生背景,我们可以归纳 PDP-11 上的第一代 C 编译器的硬性 约束:内存地址空间只有 16-bit,程序和数据必须挤在这狭小的 64kB 空间里,可谓 捉襟见肘 110 。注意,本节提到的 C 语言甚至早于 1978 年的 K&R C,是 1970 年代最 初几年的原始 C 语言。 编译器没办法在内存里完整地表示单个源文件的抽象语法树 111 ,更不可能把整 个程序(由多个源文件组成)放到内存里,以完成交叉引用(不同源文件的函数之间 相互调用,使用外部变量等等) 。由于内存限制,编译器必须要能分别编译多个源文 件,生成多个目标文件,再设法把这些目标文件组合(链接 112 )为一个可执行文件。 在今天看来,C 语言这种支持把一个大程序分成多个源文件的“功能”几乎是 顺理成章的。但是在当时而言,并不是每个语言都有意做到这一点。我们以同一时 期(1968 ∼ 1974)Niklaus Wirth 设计的 Pascal 语言为对照。Pascal 语言可以定义 函数和结构体,也支持指针,语法也比当时的 C 语言更优美。但是它长期没有官方 规定113 的多源文件模块化机制,它要求每个程序 (program) 必须位于同一个源文件
114
码空间和数据空间分离(即哈佛结构,而非冯诺依曼结构) ,各自有 64kB。但是直到 1979 年的 Unix V7 才用上这个功能,而此时 C 语言早已定型。http://en.wikipedia.org/wiki/PDP-11_architecture 111 我怀疑当时的 C 编译器恐怕连整个函数都无法放到内存里,只能放下当前的表达式。 112 其实链接器的历史比编译器还长,在没有高级语言编译器只有汇编器的时代,链接器就已经存在。我 们可以把多个汇编源文件 assemble 成目标文件,再让链接器来处理外部符号的地址与函数重定位。 113 PASCAL - User Manual and Report. Springer-Verlag, 1974. 114 Donald Knuth 写的 T X 就是一个 20000 多行的单源文件 Pascal 大程序。 E 115 Niklaus Wirth 最初的设计目的是让 Pascal 成为结构化编程的教学语言。 116 Why Pascal is Not My Favorite Programming Language. Brian W. Kernighan. http://www.lysator.liu.se/c/bwk-on-pascal.html 117 孟岩《C++ 开源程序库评话》http://blog.csdn.net/myan/article/details/679007 118 Regenerating System Software http://minnie.tuhs.org/PUPS/Setup/v7_regen.html 119 在 Unix V5 中 c[012] 的源代码一共有 6100 行,在 Unix V6 中一共有 8000 行。 120 A Tour through the UNIX C Compiler. Dennis M. Ritchie. http://plan9.bell-labs.com/7thEdMan/v7vol2b.pdf
为了能在尽量减少内存使用的情况下实现分离编译,C 语言采用了“隐式函数声 明 (implicit declaration of function)”的做法。代码在使用前文未定义的函数时,编 译器不需要也不检查函数原型 121 。既不检查参数个数,也不检查参数类型与返回值 类型。编译器认为未声明的函数都返回 int,并且能接受任意个数的 int 型参数。而且 早期的 C 语言甚至不严格区分指针和 int,而是认为二者可以相互赋值转换。在 C++ 程序员看来,这是毫无安全保障的做法,但是 C 语言就是如此地相信程序员。 举例解释一下什么是“隐式函数声明” 。
$ cat hello.c int main() { printf(”hello C.\n”); return 0; } # 这个程序没有引用任何头文件 # 隐式声明 int printf(...);
$ gcc hello.c -Wall # 用 gcc 可以编译运行通过 hello.c: In function ’main’: hello.c:3: warning: implicit declaration of function ’printf’ hello.c:3: warning: incompatible implicit declaration of built-in function ’printf’ $ g++ hello.c -Wall # 用 g++ 则会报错 hello.c: In function ’int main()’: hello.c:3: error: ’printf’ was not declared in this scope
如果 C 程序用到了某个没有定义的函数(可能错误拼写了函数名) ,那么实际造 成的是链接错误 (undefined reference),而非编译错误。例如
121 C
语言的函数原型是 1980 年代才从 C++ 借用过来的,算是 C++ 对 C 的反哺。
C++ 工程实践经验谈
15 C++ 编译链接模型精要 $ cat undefined.c int main() { helloworld(); return 0; }
139
# 隐式声明 helloworld
$ gcc undefined.c -Wall undefined.c: In function ’main’: undefined.c:3: warning: implicit declaration of function ’helloworld’ /tmp/ccHUCGat.o: In function ‘main’: undefined.c:(.text+0xa): undefined reference to ‘helloworld’ collect2: ld returned 1 exit status # 真正报错的是 ld,不是 cc1
其实,有了隐式函数声明,我们已经能分别编译多个源文件,然后把它们链接为 一个大的可执行文件(此处指的是编译出来有几十 kB 的程序) 。那么为什么还需要 头文件和预处理呢? 根据 Eric S. Raymond 在《The Art of Unix Programming》第 17.1.1 节 122 引用 Steve Johnson 的话,最早的 Unix 是把内核数据结构(例如 struct dirent)打印在手 册上,然后每个程序自己在代码中定义 struct。例如 Unix V5 的 ls(1) 源码 123 中就自 行定义了表示目录的结构体。有了预处理和头文件,这些公共信息就可以做成头文件 放到/usr/include,然后程序包含用到的头文件即可。减少无谓错误,提高代码的可 移植性。 最早的预处理只有两项功能:#include 和 #define。#include 完成文件内容替 换,#define 只支持定义宏常量,不支持定义宏函数。早期的头文件里只放三样东西: struct 定义,外部变量124 的声明,宏常量。这样可以减少各个源文件里的重复代码。 到目前为止,头文件的预处理的作用都还是正面的。在谈头文件与预处理的害处 之前,让我把 PDP-11 的 16-bit 地址空间对 C 语言及其编译模型的影响讲完。 15.1.2 C 语言的编译模型 由于不能将整个源文件的语法树保存在内存中,C 语言其实是按“单遍编译 (one pass) 125 ”来设计的。所谓单遍编译,指的是从头到尾扫描一遍源码,一边解析 (parse) 代码,一边即刻生成目标代码。在单遍编译时,编译器只能看到目前(当前 语句/符号之前)已经 parse 过的代码,看不到之后的代码,而且过眼即忘。意味着
122 http://www.faqs.org/docs/artu/c_evolution.html 123 http://minnie.tuhs.org/cgi-bin/utree.pl?file=V5/usr/source/s1/ls.c 124 或者叫全局变量,如果不那么学究的话。 125 http://en.wikipedia.org/wiki/One-pass_compiler
注意 readdir() 函数
C++ 工程实践经验谈
15 C++ 编译链接模型精要
140
• C 语言要求结构体必须先定义,才能访问其成员,否则编译器不知道结构体成 员的类型和偏移量,就无法立刻生成目标代码。 • 局部变量也必须先定义再使用,因为如果把定义放到后面,编译器在第一次看 到一个局部变量时并不知道它的类型和在 stack 中的位置,也就无法立刻生成 代码,只能报错退出。 • 为了方便编译器分配 stack 空间,C 语言要求局部变量只能在语句块的开始处 定义。 • 对于外部变量,编译器只需要知道它的类型和名字,不需要知道它的地址,因 此需要先声明后使用。在生成的目标代码中,外部变量的地址是个空白,留给 链接器去填上。 • 当编译器看到一个函数调用,按隐式函数声明规则,编译器可以立刻生成调用 函数的汇编代码(函数参数入栈、调用、获取返回值) ,这里惟一尚不能确定的 是函数的实际地址,编译器可以留下一个空白给链接器去填。 对 C 编译器来说,只需要记住 struct 的成员和偏移,知道外部变量的类型,就 足以一边解析源代码,一边生成目标代码。因此早期的头文件和预处理恰好满足了编 译器的需求。外部符号(函数或变量)的决议 (resolution) 可以留给链接器去做 126 。 从上面的编译过程可以发现,C 编译器可以做得很小,只使用很少的内存。据我 观察,Unix V5 的 C 编译器甚至没有使用动态分配内存,而是用一些全局的栈和数组 来帮助处理复杂表达式和语句嵌套,整个编译器的内存消耗是固定的 127 。(我推测 C 语言不支持在函数内部嵌套定义函数也是受此影响,因为这样一来意味着要必须用 递归才能解析函数体,编译器的内存消耗就不是一个定值。 ) 受“不能嵌套”的影响,整个 C 语言的命名空间是平坦的 (flat),函数和 struct 都处于全局命名空间。这其实给 C 程序员带来不少麻烦,因为每个库都要设法避免 自己的函数和 struct 与其他库冲突。早期 C 语言甚至不允许在不同 struct 中使用相同 的成员名称 128 ,因此我们看到一些 struct 的名字有前缀,例如 struct timeval 的成员 是 tv_sec 和 tv_usec,struct sockaddr_in 的成员是 sin_family、sin_port、sin_addr。 讲清楚了 C 语言的编译模型,我们再来看看它对 C++ 的影响(和伤害) 。
126 链接器的主要作用之一其实就是填空,见 127 这意味着
[6] 和 [9] 等书有关章节。 Unix V5 的 C 编译器不能处理太复杂的表达式,编译器也确实有对”Expression overflow”
pimpl 手法实现的公开 class。这个 class 的头文件完全没有暴露 Impl class 的任何细 节,只用到了前向声明。并且有意地把构造函数和析构函数也显式声明了。
printer.h
#include <boost/scoped_ptr.hpp> class Printer // : boost::noncopyable { public: Printer(); ~Printer(); // make it out-line // other member functions private: class Impl; // forward declaration only boost::scoped_ptr<Impl> impl_; };
按照 C++ 模板的具现化规则,编译器会为每一个用到的类模板函数具现化一份实体。
$ g++ buffer.cc $ nm a.out 00400748 W _ZN6BufferILi1024EE5clearEv 004006f2 W _ZN6BufferILi1024EE6appendEPKvi 004006da W _ZN6BufferILi1024EEC1Ev 004006c2 W _ZN6BufferILi256EE5clearEv 0040066c W _ZN6BufferILi256EE6appendEPKvi 00400654 W _ZN6BufferILi256EEC1Ev
$ nm a.out |grep -o ’ U .*’ # 而是引用了标准库中的实现 U _ZNSsC1Ev@@GLIBCXX_3.4 # 这两个是 string 的构造函数与析构函数 U _ZNSsD1Ev@@GLIBCXX_3.4 U _ZNSt8ios_base4InitC1Ev@@GLIBCXX_3.4 U _ZNSt8ios_base4InitD1Ev@@GLIBCXX_3.4 U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc # 这三个是输入输出操作符 U _ZStlsIcSt11char_traitsIcESaIcEERSt13basic_ostreamIT_T0_ES7_RKSbIS4_S5_T1_E U _ZStrsIcSt11char_traitsIcESaIcEERSt13basic_istreamIT_T0_ES7_RSbIS4_S5_T1_E
这或许能帮助消除一定模板恐惧吧。
15.3.4 虚函数 在现在的 C++ 实现中,虚函数的动态调用(动态绑定、运行期决议)是透过虚 函数表 (vtable) 进行的,每个多态 class 都应该有一份 vtable。定义或继承了虚函数 的对象中会有一个隐含成员:指向 vtable 的指针,即 vptr。在构造和析构对象的时 候,编译器生成的代码会修改这个 vptr 成员,这就要用到 vtable 的定义(使用其地 址) 。因此我们我们有时看到的链接错误不是抱怨找不到某个虚函数的定义,而是找 不到虚函数表的定义。例如
$ cat virt.cc class Base { public: virtual ~Base(); C++ 工程实践经验谈
15 C++ 编译链接模型精要 virtual void doIt(); }; int main() { Base* b = new Base; b->doIt(); } $ g++ virt.cc /tmp/cc8Q7qKi.o: In function ‘Base::Base()’: virt.cc:(.text._ZN4BaseC1Ev[Base::Base()]+0xf): undefined reference to ‘vtable for Base’ collect2: ld returned 1 exit status
157
出现这种错误的根本原因是程序中某个虚函数没有定义,知道了这个方向,查找问题 就不难了。 另外,按道理说,一个多态 class 的 vtable 应该恰好被某一个目标文件定义,这 样链接就不会有错。但是 C++ 编译器有时无法判断是否应该在当前编译单元生成 vtable 定义 157 ,为了保险起见,只能每个编译单元都生成 vtable,交给链接器去消 除重复数据 158 。有时我们不希望 vtable 导致目标文件膨胀,可以在头文件的 class 定 义中声明 out-line 虚函数 159 。
15.4
工程项目中头文件的使用规则
既然短时间内 C++ 还无法摆脱头文件和预处理,我们要深入理解可能存在的陷 阱。在实际项目中,有必要规范头文件和预处理的用法,避免它们的危害。
16 Zero overhead 原则
http://www.open-std.org/jtc1/sc22/wg21/docs/18015.html http://www.aristeia.com/c++-in-embedded.html
C++ 工程实践经验谈
Bibliography
171
Bibliography
[1] 侯捷. 《池內春秋——Memory Pool 的設計哲學與無痛運用》. 《程序员》2002 年第 9 期。 [2] Scott Meyers 著, 侯捷译. 《Effective C++ 中文版》(第 3 版). 电子工业出版社, 2006. [3] Scott Meyers 著. 《Effective STL》. Addison-Wesley, 2001. [4] Herb Sutter and Andrei Alexandrescu 著, 侯捷 陈硕译. C++ Coding Standards: 101 Rules, Guidelines, and Best Practices. 《C++ 编程规范》. 碁峰出版社, 2008. [5] Herb Sutter 著, 刘未鹏译. 《Exceptional C++ Style 中文版》. 人民邮电出版 社, 2006. [6] 俞甲子 石凡 潘爱民著. 《程序员的自我修养——链接、装载与库》. 电子工业 出版社, 2009. [7] Michael Feathers 著, 刘未鹏译. Working Effectively with Legacy Code. 《修改代 码的艺术》. 人民邮电出版社, 2007. [8] Andrei Alexandrescu. Scalable Use of the STL. C++ and Beyond 2010.
http://www.artima.com/shop/cpp_and_beyond_2010 [9] Peter van der Linden 著. 《Expert C Programming: Deep C Secrets》. Prentice Hall Professional, 1994. [10] Randal E. Bryant and David R. O’Hallaron 著. 龚奕利 雷迎春 译. 《深入理解 计算机系统(第 2 版) 机械工业出版社, 2011. 》. [11] Bjarne Stroustrup 著, 裘宗燕 译. 《C++ 语言的设计和演化》. 机械工业出版 社, 2002. [12] 陈硕. 从《C++ Primer 第四版》入手学习 C++. 《C++ Primer 第 4 版 评注版》 前言. 电子工业出版社,2012. https://github.com/downloads/chenshuo/documents/LearnCpp.pdf