Please indicate the source: http://blog.csdn.net/gaoxiangnumber1
Welcome to my github: https://github.com/gaoxiangnumber1
?C++语言的三大约束是:与C兼容、零开销(zero overhead)原则、值语义。§11.7介绍值语义。
?“与C兼容”不仅仅是兼容C的语法,更重要的是兼容C语言的编译模型与运行模型,也就是说能直接使用C语言的头文件和库。
?比方说对于connect(2)这个系统函数,它的头文件和原型如下:
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
?C++基本类型的长度和表示(representation)必须和C语言一样(int、指针等),准确地说是和编译系统库的C语言编译器保持一致。C++编译器必须能理解头文件sys/socket.h中struct sockaddr的定义,采用相同的对齐(alignment)算法,生成与C编译器完全相同的layout,并且遵循C语言的函数调用约定(参数传递,返回值传递,栈帧管理等等),才能直接调用这个C语言库函数。
?现代操作系统暴露出的原生接口往往是C语言描述的,Windows的原生API接口是Windows.h头文件,POSIX是一堆C语言头文件。C++兼容C,从而能在编译的时候直接使用这些头文件,并链接到相应的库上。并在运行的时候直接调用C语言的函数库,省了一道中间层的手续,可算作是C++高效的原因之一。
?10-1是Linux上编译一个C++程序的典型过程。其中最耗时间的是cc1plus这一步。图中各个阶段的界线并不是铁定的。通常cpp和cc1plus会合并成一个进程;而cc1plus和as之间既可以以临时文件(*.s)为中介,也可以以管道(pipe)为中介;对于单一源文件的小程序,往往不必生成.o文件。linker还叫做link editor。
?在不同的语境下,“编译”一词有不同的含义。
—如果笼统地说把.cc文件“编译”为可执行文件,那么指的是preprocessor/ compiler/ assembler/ linker这四个步骤。
—如果区分“编译”和“链接”,那么“编译”指的是从源文件生成目标文件这几步(即g++ -c)。
—如果区分预处理、编译(代码转换)、汇编,那么编译器实际看到的是预处理器完成头文件替换和宏展开之后的源代码。
?C++至今(包括C++11)没有模块机制,不能像其他编程语言那样用import或using来引入当前源文件用到的库(含其他package/module里的函数或类),而必须用include头文件的方式来机械地将库的接口声明以文本替换的方式载入,再重新parse一遍。
?这么做一方面让编译效率低,编译器动辄要parse几万行预处理之后的源码,哪怕源文件只有几百行;另一方面留下了巨大的隐患。一个原因是头文件包含具有传递性,引入不必要的依赖;另一个原因是头文件是在编译时使用,动态库文件是在运行时使用,二者的时间差可能带来不匹配,导致二进制兼容性方面的问题(§11.2)。
10.1 C语言的编译模型及其成因
10.1.1 为什么C语言需要预处理
?PDP-11上第一代C编译器的硬性约束:内存地址空间只有16-bit,程序和数据必须挤在这狭小的64KiB空间里。编译器没办法在内存里完整地表示单个源文件的抽象语法树,更不可能把整个程序(由多个源文件组成)放到内存里,以完成交叉引用(不同源文件的函数之间相互调用,使用外部变量等等)。
?由于内存限制,编译器必须要能分别编译多个源文件,生成多个目标文件,再设法把这些目标文件链接20为一个可执行文件。
?受内存限制,一个可执行程序不能太大,Dennis Ritchie编写的PDP-11 C编译器不是一个可执行文件,而是7个可执行文件:cc、cpp、as、ld、c0、c1、c2。
?cc是driver,用于调用另外几个程序。
?cpp是预处理器,当时叫做compiler control line expander。
?c0、c1、c2是C编译器的三个阶段(phase):
c0的作用是把源程序编译为两个中间文件;
c1把中间文件编译为汇编源代码;
c2是可选的,用于对生成汇编代码做窥孔优化。
?as是汇编器,把汇编代码转换为目标文件。
?ld是链接器,把目标文件和库文件链接成可执行文件。
?编译流程见图10-2。不用cc,手工编译一个简单程序prog.c的过程如下:
?为了在减少内存使用的情况下实现分离编译,C语言采用了“隐式函数声明(implicit declaration of function)”。代码在使用前文未定义的函数时,编译器不需要也不检查函数原型:既不检查参数个数,也不检查参数类型与返回值类型。编译器认为未声明的函数都返回int,并且能接受任意个数的int型参数。
?有了隐式函数声明,我们能分别编译多个源文件,然后把它们链接为一个大的可执行文件。为什么还需要头文件和预处理呢?
?根据Eric S. Raymond在《The Art of Unix Programming》第17.1.1节引用Steve Johnson的话,最早的Unix是把内核数据结构(例如struct dirent)打印在手册上,然后每个程序自己在代码中定义struct。例如 Unix V5的ls(1)源码中就自行定义了表示目录的结构体。有了预处理和头文件,这些公共信息就可以做成头文件放到/usr/include,然后程序包含用到的头文件即可。减少无谓错误,提高代码的可移植性。
?最早的预处理只有两项功能:#include和#define。#include完成文件内容替换,#define只支持定义宏常量,不支持定义宏函数。 早期的头文件里只放三样东西:struct定义,外部变量的声明,宏常量。这样可以减少各个源文件里的重复代码。
10.1.2 C语言的编译模型
?由于不能将整个源文件的语法树保存在内存中,C语言是按“单遍编译(one pass)”设计的。单遍编译指的是从头到尾扫描一遍源码,一边解析(parse)代码,一边立刻生成目标代码。在单遍编译时,编译器只能看到当前语句/符号之前已经解析过的代码,看不到之后的代码,而且过眼即忘。这意味着
?C语言要求结构体必须先定义,才能访问其成员,否则编译器不知道结构体成员的类型和偏移量,无法立刻生成目标代码。
?局部变量也必须先定义再使用,因为如果把定义放到后面,编译器在第一次看到一个局部变量时并不知道它的类型和在stack中的位置,也就无法立刻生成代 码。
?为了方便编译器分配stack空间,C语言要求局部变量只能在语句块的开始处定义。
?对于外部变量,编译器只需要知道它的类型和名字,不需要知道它的地址,因此需要先声明后使用。在生成的目标代码中,外部变量的地址是个空白,留给链接器去填上。
?当编译器看到一个函数调用时,按隐式函数声明规则,编译器可以立刻生成调用函数的汇编代码(函数参数入栈、调用、获取返回值),这里唯一不能确定的是函数的实际地址,编译器留下一个空白给链接器去填。
?对C编译器来说,只需要记住struct的成员和偏移,知道外部变量的类型,就足以一边解析源代码,一边生成目标代码。因此早期的头文件和预处理恰好满足了编译器的需求。外部符号(函数或变量)的决议(resolution)可以留给链接器去做。
?从上面的编译过程可以发现,C编译器可以做得很小,只使用很少的内存。Unix V5的C编译器甚至没有使用动态分配内存,而是用一些全局的栈和数组来帮助处理复杂表达式和语句嵌套,整个编译器的内存消耗是固定的。我推测C语言不支持在函数内部嵌套定义函数也是受此影响,因为这样一来意味着必须用递归才能解析函数体,编译器的内存消耗就不是一个定值。
?受“不能嵌套”的影响,整个C语言的命名空间是平坦的(flat),函数和struct都处于全局命名空间。这带来了麻烦,因为每个库都要设法避免自己的函数和struct与其他库冲突。早期C语言甚至不允许在不同struct中使用相同的成员名称,因此我们看到一些struct的名字有前缀,例如 struct timeval的成员是tv_sec和tv_usec,struct sockaddr_in的成员是sin_family、 sin_port、sin_addr。
10.2 C++的编译模型
10.2.1 单遍编译
?C++继承了单遍编译。在单遍编译时,编译器只能根据目前看到的代码做出决策,读到后面的代码也不会影响前面做出的决定。这影响了名字查找(name lookup)和函数重载决议。
名字查找
?C++中的名字包括类型名、函数名、变量名、typedef名、template名等等。对下面这行代码
Foo a; // Foo、T、a这三个名字都不是macro
?如果不知道Foo、T、a这三个名字分别代表什么,编译器就无法进行语法分析。根据之前出现的代码不同,上面这行语句至少有三种可能性:
1.Foo是template class Foo;,T是type,这句话以T为模板类型参数类型具现化了Foo类型,并定义了变量a。
2.Foo是template class Foo;,T是const int变量,这句话以T为非类型模板参数具现化了Foo类型,并定义了变量a。
3.Foo、T、a都是int,这句话是个没用的语句。别忘了operator<()是可以重载的,这句代码还可以表达别的意思。另外一个经典的例子是AA BB(CC);,这句话既可以声明函数,也可以定义变量。
?C++只能通过解析源码来了解名字的含义,不能通过直接读取目标代码中的元数据来获得所需信息(函数原型、class类型定义等等)。这意味着要想准确理解一行C++代码的含义,我们需要通读这行代码之前的所有代码,并理解每个符号(包括操作符)的定义。而头文件的存在使得肉眼观察几乎是不可能的。有可能出现一种情况:某人不经意改变了头文件,或者仅仅是改变了源文件中头文件的包含顺序,就改变了代码的含义,破坏了代码的功能。
?C++编译器的符号表至少要保存目前已看到的每个名字的含义,包括class的成员定义、已声明的变量、已知的函数原型等,才能正确解析源代码。这还没有考虑template,编译template的难度超乎想象。编译器还要正确处理作用域嵌套引发的名字的含义变化:内层作用域中的名字有可能遮住(shadow)外层作用域中的名字。有些其他语言会对此发出警告,对此我建议用g++的-Wshadow选项来编译代码。(muduo的代码都是-Wall -Wextra -Werror -Wconversion -Wshadow编译的。)
函数重载决议
?当C++编译器读到一个函数调用语句时,它只能从目前已看到的同名函数中选出最佳函数。哪怕后面的代码中出现了更合适的,也不能影响当前的决定40。
?这意味着如果我们交换两个namespace级的函数定义在源代码中的位置,那么有可能改变程序的行为。比方说对于如下一段代码:
#include <stdio.h>
using namespace std;
void foo(int x)
{
printf("foo(int);\n");
}
void bar()
{
foo(‘a‘);
}
void foo(char ch)
{
printf("foo(char)\n");
}
int main()
{
bar();
return 0;
}
?如果在重构的时候把void bar()的定义挪到void foo(char)之后,程序的输出就不一样了。
?这个例子充分说明实现C++重构工具的难度:重构器对代码的理解必须达到编 译器的水准,才能在修改代码时不改变原意。函数的参数可以是个复杂表达式,重构器必须能正确解析表达式的类型才能完成重载决议。比方说foo(str[0])应该调用哪个foo()跟str[0]的类型有关,而str可能是个std::string,这就要求重构器能正确理解template并具现化之。C++至今没有像样的重构工具,恐怕正是这个原因。
?C++编译器必须在内存中保存函数级的语法树,才能正确实施返回值优化 (RVO),否则遇到return语句的时候编译器无法判断被返回的这个对象是不是那个可以被优化的named object。
?由于C++新增了不少语言特性,C++编译器并不能真正做到像C那样过眼即忘的单遍编译。但C++必须兼容C的语意,因此编译器不得不装得好像是单遍编译(准确地说是单遍parse)一样,哪怕它内部是multiple pass的。
10.2.2 前向声明
?C++编码规范建议尽量使用前向声明来减少编译期依赖,这里我用“单向编译”解释这为什么是可行的,很多时候甚至是必需的。
?如果代码里调用了函数foo(),C++编译器parse此处函数调用时,需要生成函数调用的目标代码。为了完成语法检查并生成调用函数的目标代码,编译器需要知道函数的参数个数和类型以及函数的返回值类型,它并不需要知道函数体的实现(除非要做inline展开)。因此我们通常把函数原型放到头文件里,这样每个包含了此头文件的源文件都可以使用这个函数。
?光有函数原型是不够的,程序的某一个源文件应该定义这个函数,否则会造成链接错误(未定义的符号)。这个定义foo()函数的源文件通常也会包含foo()的头文件。但是,假设在定义foo()函数时把参数类型写错了,会出现什么情况?
// in foo.h
void foo(int); // 原型声明
// in foo.cc
#include "foo.h"
void foo(int, bool) // 在定义的时候必须把参数列表和返回类型抄一遍。
{ // 有抄错的可能,也可能将来改了一处,忘了改另一处
// do something
}
?编译foo.cc不会有错,因为编译器会认为foo有两个重载。但是链接整个程序会报错:找不到void foo(int)的定义。
?这是C++的一种缺陷,即一样东西区分声明和定义,代码放到不同的文件中,这就有出现不一致的可能性。C/C++里很多错误就源自于此,比如:在一个源文件里声明extern char* name,在另一个源文件里却定义成char name[]=”Shuo Chen”;。
?对于函数的原型声明和函数体定义而言,这种不一致表现在参数列表和返回类型上,编译器能查出参数列表不同,但不一定能查出返回类型不同(§10.3)。也可能参数类型相同,但是顺序调换了。例如原型声明为draw(int height, int width),定义的时候写成draw(int width, int height),编译器无法查出此类错误,因为原型声明中的变量名是无用的。
?如果要写一个库给别人用,那么通常要把接口函数的原型声明放到头文件里。但是在写库的内部实现的时候,如果没有出现函数相互调用的情况,那么我们可以适当组织函数定义的顺序,让基础函数出现在代码的前面,这样就不必前向声明函数原型了。参见云风的一篇博客()。
?函数原型声明可以看作是对函数的前向声明(forward declaration),除此之外我们还用到class的前向声明。
?有些时候class的前向声明是必需的,例如§11.7.2出现的Child 和Parent class相互指涉的情况。
有些时候class的完整定义是必需的[CCS,条款22],例如要访问class的成员,或者要知道class的大小以便分配空间。
其他时候,有class的前向声明就足够了,编译器只需要知道有这么个名字的class。
?对于class Foo,以下使用不需要看见其完整定义:
?定义或声明Foo*和Foo&,包括用于函数参数、返回类型、局部变量、类成员变量等等。这是因为C++的内存模型是flat的,Foo的定义无法改变Foo的指针或引用的含义。
?声明一个以Foo为参数或返回类型的函数,如Foo bar()或void bar(Foo f)。但是,如果代码里调用这个函数就需要知道Foo的定义,因为编译器要使用Foo的拷贝构造函数和析构函数,因此至少要看到它们的声明(虽然构造函数没有参数,但是有可能位于private区)。
?[CCS]第30条规定不能重载&&、||、,(逗号)这三个操作符,Google的C++编程规范补充规定不能重载一元operator&(取址操作符),因为一旦重载operator&,这个class的就不能用前向声明了。例如:
class Foo; // 前向声明
void bar(Foo& foo)
{
Foo* p = &foo; // 这句话是取foo的地址,但是如果重载了&,意思就变了。
}
10.3 C++链接(linking)
?以手工编制一本书的目录和交叉索引为例,介绍链接器的基本工作原理。
?假设一个作者写完了十多个章节,你的任务是把这些章节编辑为一本书。每个章节的篇幅不等,从30页到80页都有,都已经分别排好版打印出来。(已经从源文件编译成了目标文件。)章节之间有交叉引用,即正文里会出现“请参考XXX页的第YYY节”等字样。作者在写的时候并不知道当前文字的章节号,当然也不知道当前文字将来会出现在哪一页上。因为他可以随时调整章节顺序、增减文字内容,这些举动会影响最终的章节编号和页码。
?为了引用其他章节的内容,作者会在文字中放anchor,给需要被引用的文字命名。比方说本章的名字是ch:cpp Compilation。(这就好比给全局函数或全局变量起了一个唯一的名字。)在引用其他章节的编号或页码时,作者在正文中留下一个适当的空白,并注明这里应该填上的某个anchor的页码或章节编号。
?现在你拿到了这十几摞打印的文稿,怎么把它们编辑成一本书呢?你可能会想到两个步骤:先编排页码和章节编号,再解决交叉引用。
1.第一步:
1a:把这些文稿按章的先后顺序叠好,这样就可以统一编制正文页码。
1b:在编制页码的同时,章节号也可以一并确定下来。
?在进行1a和1b这个步骤时,你可以同时顺序记录两张纸:
—章节的编号、标题和它出现的页码,用于编制目录。
—遇到anchor时,记下它的名字和出现的页码、章节号,用于解决交叉引用。
2.第二步:再从头翻一遍书稿,遇到空白的交叉应用,就到anchor索引表里查出它的页码和章节编号,填上空白。
?至此,如果一切顺利的话,书籍编辑任务完成。
?在这项工作中最容易出现以下两种意外情况,也是最常见的两种链接错误。
1.正文中交叉应用找不到对应的anchor,空白填不上。
2.某个anchor多次定义,该选哪一个填到交叉引用的空白处?
?上面的办法要至少翻两遍全文,有没有办法从头到尾只翻一遍就完成交叉引用?如果作者在写书的时候只从前面的章节引用后面的章节,那么是可以做到这一点的。我们在编排页码和章节号的时候顺便阅读全文,遇到新的交叉引用空白就记到一张纸之上。这张纸记录交叉引用的名字和空白出现的页码。我们知道后面肯定能遇到对应的anchor。遇到一个anchor时,去那张纸上看看有没有交叉引用用到它,如果有,就往回翻到空白的页码,把空白填上,回头再继续编制页码和章节号。这样一遍扫下来,章节编号、页码、交叉引用就全部搞定了。
?这正是传统one-pass链接器的工作方式,在使用这种链接器的时候要注意参数顺序,越基础的库越放到后面。如果程序用到了多个library,这些library之间有依赖(假设无循环依赖),那么链接器的参数顺序应该是依赖图的拓扑排序。这样保证每个未决符号都可以在后面出现的库中找到。比如A、B两个彼此独立的库同时依赖C库,那么链接的顺序是ABC或BAC。
?为什么不是反过来,先列出基础库,再列出应用库呢?原因是前一种做法的内存消耗小。如果先处理基础库,链接器不知道库里哪些符号会被后面的代码用到,因此只能每一个都记住,链接器的内存消耗跟所有库的大小之和成正比。如果先处理应用库,那么只需要记住目前尚未查到定义的符号就行了,链接器的内存消耗跟程序中外部符号的多少成正比(而且一旦填上空白,就可以忘掉它)。
?以上介绍了C语言的链接模型,C++与之相比主要增加了两项内容:
1.函数重载,需要类型安全的链接[D&E,第11.3节],即name mangling。
2.vague linkage,即同一个符号有多份互不冲突的定义。
?name mangling一般不需要程序员操心,只要掌握extern “C”的用法,能和C程序库interoperate就行。现在一般的C语言库的头文件都会适当使用extern “C”,使之也能用于C++程序。
?C语言通常一个符号在程序中只能有一处定义,否则就会造成重复定义。C++则不同,编译器在处理单个源文件的时候并不知道某些符号是否应该在本编译单元定义。为了保险起见,只能每个目标文件生成一份“弱定义”,而依赖链接器去选择一份作为最终的定义,这就是vague linkage。不这么做的话就会出现未定义的符号错误,因为链接器不会反过来调用编译器去生成未定义的符号。为了让这种机制能正确运作,C++要求代码满足一次定义原则(ODR ),否则代码的行为是随机的。
10.3.1 函数重载
?为了实现函数重载,C++编译器采用名字改编(name mangling)的办法,为每个重载函数生成独一无二的名字,这样在链接的时候就能找到正确的重载版本。比如foo.cc里定义了两个foo()重载函数。
// foo.cc
int foo(bool x)
{
return 42;
}
int foo(int x)
{
return 100;
}
$ g++ -c foo.cc
$ nm foo.o # foo.o定义了两个external linkage函数
0000000000000000 T _Z3foob
0000000000000010 T _Z3fooi
$ c++filt _Z3foob _Z3fooi # unmangle这两个函数名
foo(bool) # 注意,mangled name里没有返回类型
foo(int)
?普通non-template函数的mangled name不包含返回类型。返回类型不参与函数重载。这有一个隐患。如果一个源文件用到了重载函数,但它看到的函数原型声明的返回类型是错的(违反了ODR),链接器无法捕捉这样的错误。
// main.cc
void foo(bool); # 返回类型错误地写成了void
int main()
{
foo(true);
}
$ g++ -c main.cc
$ nm main.o # 目标文件依赖_Z3foob这个符号
U _Z3foob
0000000000000000 T main
$ g++ main.o foo.o # 能正常生成 ./a.out
?对于内置类型,这不会造成实际的影响。但是如果返回类型是class,后果未知。
10.3.2 inline函数
?inline函数的方方面面见[EC3]第30条。
?由于inline函数,C++源代码里调用一个函数并不意味着生成的目标代码里会做一次真正的函数调用(即有call指令)。现在的编译器可以自动判断一个函数是否适合inline,因此inline关键字在源文件中往往不是必需的。在头文件里inline还是要的,为了防止链接器抱怨重复定义(multiple definition)。
?现在的C++编译器采用重复代码消除的办法来避免重复定义。
—如果编译器无法inline展开的话,每个编译单元都会生成inline函数的目标代码,然后链接器会从多份实现中任选一份保留,其余的则丢弃(vague linkage)。
—如果编译器能够展开inline函数,那就不必单独为之生成目标代码,除非使用函数指针指向它。
?如何判断一个C++可执行文件是debug build还是release build?即如何判断一个可执行文件是-O0编译还是-O2编译?通常的做法是看class template的短成员函数有没有被inline展开。例如:
// vec.cc
#include <vector>
#include <stdio.h>
int main()
{
std::vector<int> vi;
printf("%zd\n", vi.size()); # 这里调用了 inline 函数 size()
}
$ g++ -Wall vec.cc # non-optimized build
$ nm ./a.out |grep size|c++filt
00000000004007ac W std::vector<int, std::allocator<int> >::size() const
// vector<int>::size()没有inline展开,目标文件中出现了函数(弱)定义。
$ g++ -Wall -O2 vec.cc # optimized build
$ nm ./a.out |grep size|c++filt
// 没有输出,因为vector<int>::size()被inline展开了。
?编译器为我们自动生成的class析构函数也是inline函数,有时候我们要故意outline,防止代码膨胀或出现编译错误。以下Printer是依据§11.4介绍的pimpl手法实现的公开class。这个class的头文件完全没有暴露Implclass的任何细节,只用到了前向声明。并有意地把构造函数和析构函数也显式声明了。
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_;
};
?在源文件中,我们可以从容地先定义Printer::Impl,然后再定义Printer的构造函数和析构函数。
printer.cc
#include "printer.h"
class Printer::Impl
{
// members
};
Printer::Printer() :
impl_(new Impl) // 现在编译器看到了 Impl 的定义,这句话能编译通过。
{ }
Printer::~Printer() // 尽管析构函数是空的,也必须放到这里来定义。否则编译器
{ // 在将隐式声明的~Printer() inline展开的时候无法看到
} // Impl::~Impl()的声明,会报错。见 boost::checked_delete
?在现代的C++系统中,编译和链接的界限更加模糊了。传统C++教材告诉我们,要想编译器能够inline一个函数,那么这个函数体必须在当前编译单元可见。因此我们通常把公共inline函数放到头文件中。现在有了link time code generation,编译器不需要看到inline函数的定义,inline展开可以留给链接器去做。
?除了inline函数,g++还有大量的内置函数(built-in function),因此源代码中出现memcpy、memset、strlen、sin、exp之类的“函数调用”不一定真的会调用libc里的库函数。另外,由于编译器知道这些函数的功能,因此优化起来更充分。
Please indicate the source: http://blog.csdn.net/gaoxiangnumber1
Welcome to my github: https://github.com/gaoxiangnumber1