标签:
Please indicate the source: http://blog.csdn.net/gaoxiangnumber1
Welcome to my github: https://github.com/gaoxiangnumber1
10.4 工程项目中头文件的使用规则
10.4.1 头文件的害处
?我认为头文件的害处主要体现在以下几方面:
1.传递性。
头文件可以再包含其他头文件。一个简单的#include 展开之后有两万多行代码,一方面造成编译缓慢;另一方面,任何一个头文件改动一点点代码都会需要重新编译所有直接或间接包含它的源文件。因为build tool无法有效判断这个改动是否会影响程序语义,保守起见只能把受影响的源文件全部重新编译一遍。
2.顺序性。
一个源文件可以包含多个头文件。如果头文件内容组织不当,会造成程序的语义跟头文件包含的顺序有关,也跟是否包含某一个头文件有关66。通常的做法是把头文件分为几类67,然后分别按顺序包含这几类头文件68,相同类的头文件按文件名的字母排序。这样一方面源代码比较整洁;另一方面如果两个人同时修改源码,各自想多包含一个头文件,那么造成冲突的可能性较小。避免每次在#include列表的末尾添加新的头文件,这样很快代码的依赖关系就无法管理了。
3.差异性。
内容差异造成不同源文件看到的头文件不一致,时间差异造成头文件与库文件内容不一致。如§12.7提到不同的编译选项会造成Visual C++ std::string的大小不一样。也就是说头文件的内容经过预处理后会有变化,如果两个源文件编译时的宏定义选项不一致,可能造成二进制代码不兼容。这说明整个程序应该用统一的编译选项69。如果程序用到了第三方静态库或者动态库,除了拿到头文件和库文件,我们还要拿到当时编译这个库的编译选项,才能安全无误地使用这个程序库。如果程序用到了两个库,但是它们的编译选项有冲突,那麻烦就大了,后面谈库文件组织的时候再来说这个问题和时间差异的问题。
?现代的编程语言模块化做得比较好。模块化的做法主要有两种:
1.对于解释型语言,import的时候直接把对应模块的源文件解析(parse)一遍(不再是简单地把源文件包含进来)。
2.对于编译型语言,编译出来的目标文件(例如Java的.class文件)里直接包含了足够的元数据,import的时候只需要读目标文件的内容,不需要读源文件。
?这两种做法都避免了声明与定义不一致的问题,因为在这些语言里声明与定义是一体的。同时这种import手法也不会引入不想要的名字,大大简化了名字查找的负担(无论是人脑还是编译器),也不用担心import的顺序不同造成代码功能变化。
10.4.2 头文件的使用规则
?C++编程规范中涉及头文件的观点总结如下
1.“将文件间的编译依赖降至最小。”[EC3,条款31]
2.“将定义式之间的依赖关系降至最小。避免循环依赖。”[CCS,条款22]
3.“让class名字、头文件名字、源文件名字直接相关。”70这样方便源代码的定位。muduo遵循这一原则,例如TcpClient class的头文件是TcpClient.h,其成员函数定义在TcpClient.cc。
4.“令头文件自给自足。”[CCS,条款23] 例如要使用muduo的TcpServer,可以直接包含TcpServer.h。为了验证 TcpServer.h的自足性(self-contained),TcpServer.cc第一个包含的头文件就是它。
5.“总是在头文件内写内部 #include guard(护套),不要在源文件写外部护套。” [CCS,条款24] 这是因为现在的预处理对这种通用做法有特别的优化,GNU cpp在第二次#include同一个头文件时甚至不会去读这个文件,而是直接跳过。
6.#include guard用的宏的名字应该包含文件的路径全名(从版本管理器的角度),必要的话还要加上项目名称(如果每个项目有自己的代码仓库)。
7.如果编写程序库,那么公开的头文件应该表达模块的接口,必要的时候可以把实现细节放到内部头文件中。muduo的头文件满足这条规则(§6.3)。
?介绍一个查找头文件包含途径的技巧。比方说有一个程序只包含了,但是却能使用std::string,我想知道是如何被引入的。办法是在当前目录创建一个string文件,然后制造编译错误,步骤如下:
10.5 工程项目中库文件的组织原则
?考虑一个公司,有一个团队开发公司的公共C++网络库net;还有一个团队开发一套消息中间件,并提供一个客户端库hub,hub是基于net构建的;公司另外一个团队开发了一套存储系统,并提供一个客户端库cab,cab也是基于net构建的。公司内部开发的服务端程序可能会用到这些库的一个或几个,本节讨论如何组织这些由不同团队开发的库与应用程序。
?先谈基本的话题:依赖管理。
?假设你负责一个程序app,经测试后,app 1.0上线运行,一切顺利。app 1.0用到了网络库(net 1.0)和消息中间件的客户端库(hub 1.0),并且hub1.0本身也用到了net 1.0,依赖关系如图10-3所示。
?尽管在发布之前QA人员sign-off的是app 1.0,但是我们应该认为他们sign-off的是app 1.0和它依赖的所有库构成的bundle。因为app的行为跟它用到的库有关,如果改变其中任何一个库,app的行为都可能发生变化(尽管app的源码和可执行文件一个字节都没动),也就可能跟当时测试通过的“app 1.0”行为不一致。
?周伟明老师在《软件测试实践》的第1.7.2节“COM的可测试性分析”中表示,COM“违反了软件设计的基本原理”,理由是:我们假设一个软件包含n个不同的COM组件,按照COM的设计思想,每个组件都是可以替换的。假设每个组件都有若干个不同的版本,记为分别有M1, M2, … Mn个不同的版本,那么组成整个软件的所有组件的组合关系有M1 × M2 × … × Mn种,等于这个软件共有M1 × M2 × … × Mn种二进制版本。如果要将测试做得充分,这些组合全部都需要进行测试,否则很难保证没测试到的组合不会有问题。
?这从理论上说明,改动程序本身或它依赖的库之后应该重新测试,否则测试通过的版本和实际运行的版本根本就是两个东西。一旦出问题,责任难理清。
?这个问题对于C++之外的语言也同样存在,凡是可以在编译之后替换库的语言都需要考虑类似的问题。对于脚本语言来说,除了库之外,解释器的版本(Python2.5/2.6/2.7)也会影响程序的行为,因此有Python virtual env和Ruby rbenv这样的工具,允许一台机器同时安装多个解释器版本。Java程序的行为除了跟class path里的那些jar文件有关,也跟JVM的版本有关,通常我们不能在没有充分测试的情况下升级JVM的大版本(从1.5到1.6)。
?除了库和运行环境,还有一种依赖是对外部进程的依赖,例如app程序依赖某些数据源(运行在别的机器上的进程),会在运行的时候通过某种网络协议从这些数据源定期或不定期读取数据。数据源可能会升级,其行为也可能变化,如何管理这种依赖超出了本节的范围。
?回到C++,首先谈编译器版本之间的兼容性。截至 g++4.4,Linux目前已有四个互不兼容的ABI版本75,编译出来的库互不通用:
1.gcc 3.0之前的版本,例如2.95.3
2.gcc 3.0/3.1
3.gcc 3.2/3.3
4.gcc 3.4~4.4
?这影响不大,因为估计没有谁还在用g++3.x来编译新的代码。
?另外需要考虑C++标准库(libstdc++)的版本与C标准库(glibc)的版本。C++标准库的版本跟C++编译器直接关联。C标准库的版本跟Linux操作系统的版本直接相关。
?一般不会有人单独升级glibc,因为这意味着需要重新编译用户态的所有代码。为了稳妥起见,通常建议用Linux发行版自带的那个gcc版本来编译代码。因为这个版本的gcc是Linux发行版主要支持的编译器版本。
?根据以上分析,一旦选定操作系统的版本,另外三样东西的版本就确定了。暂且认为运行app1.0的机器的Linux操作系统版本、libstdc++版本、glibc版本是统一的,而且C++应用程序和库的代码都是用操作系统原生的g++来编译的。
?这样一来,我们就可以在C++编译器版本、C++标准库版本、C标准库版本均固定的情况下来讨论应用程序与库的组织。这里讨论的是公司内部实现的库,而不是操作系统自带编译好的库(libz、 libssl、libcurl等等)。后面这些库可以通过操作系统的package管理机制来统一部署,确保每台机器的环境相同。
?Linux的共享库(shared library)比Windows的动态链接库在C++编程方面要好用得多,对应用程序来说基本可算是透明的,跟使用静态库无区别。主要体现在:
1.一致的内存管理。Linux动态库与应用程序共享同一个heap,因此动态库分配的内存可以交给应用程序去释放82,反之亦可。
2.一致的初始化。动态库里的静态对象(全局对象、namespace级的对象等等)的初始化和程序其他地方的静态对象一样,不用特别区分对象的位置。
3.在动态库的接口中可以放心地使用class、STL、boost(如果版本相同)。
4.没有dllimport/dllexport的累赘。直接include头文件就能使用。
5.DLL Hell83 84的问题小,因为Linux允许多个版本的动态库并存,而且每个符号可以有多个版本85。
?DLL hell指的是安装新软件的时候更新了某个公用的DLL,破坏了其他已有软件的功能。例如安装xyz 1.0会把net库升级为1.1版,覆盖了原来app 1.0和hub 1.0依赖的net 1.0,这有潜在的风险(图10-4)。
?一个C++库的发布方式有三种:动态库(.so)、静态库(.a)、源码库(.cc)。表10-2总结了一些基本特性。
?本节谈动态库只包括编译时就链接动态库的常规用法,不包括运行期动态加载(dlopen())的用法。
?作为应用程序的作者,如果要在多台Linux机器上运行这个程序,我们先要把它部署(deploy)到那些机器上。
1.如果程序只依赖操作系统本身提供的库(包括可以通过package管理软件安装的第三方库),那么只要把可执行文件拷贝到目标机器上就能运行。这是静态库和源码库在分布式环境下的突出优点之一。
2.如果依赖公司内部实现的动态库,这些库必须事先(或同时)部署到这些机器上,应用程序才能正常运行。这面临的挑战:部署动态库的工作由谁(库的作者还是应用程序的作者)来做呢?另外一个问题是,如果动态库的作者修正了bug,他可以自主更新所有机器上的库吗?
?我们暂且认为库的作者可以独立地部署并更新动态库,并且影响到使用这个库的应用程序88。否则,如果每个程序都把自己用到的动态库和应用程序一起打包发布,库的作者不负责库的更新,那么这和使用静态库就没有区别了,还不如直接静态链接。
?无论哪种方式,我们都必须保证应用程序之间的独立性,也就是让动态库的多个大版本能够并存。例如部署app 1.0和xyz 1.0之后的依赖关系如图10-5所示。
?按照传统的观点,动态库比静态库节省磁盘空间和内存空间89,并且具备动态更新的能力(可以hot fix bug90),似乎动态库应该是目前的首选。但是正是这种动态更新91的能力让动态库成了烫手的山芋。
10.5.1 动态库是有害的
?Jeffrey Richter93对动态库的本质论述:一旦替换了某个应用程序用到的动态库,先前运行正常的这个程序使用的将不再是当初build和测试时的代码。结果是程序的行为变得不可预期。怎样在fix bug和增加feature的同时,还能保证不会损坏现有的应用程序?结论:这是不可能的。
?库的作者肯定不希望更新部署一个bug fix后,星期一早上被应用程序的维护者的电话吵醒,说程序不能启动(新的库破坏了二进制兼容性)或者出现了不符合预期的行为。
?有没有可能在发布动态库的bug fix之前充分测试所有受影响的应用程序呢?这遇到一个两难命题:
1.一个动态库的使用面窄,只有两三个程序用到它,测试的成本较低,它作为动态库的优势不明显。
2.一个动态库的使用面宽,有几十个程序用到它,动态库的“优势”明显,测试和更新的成本也相应很高(或许高到抵消它的“优势”)。
?有一种做法是把动态库的更新先发布到QA环境,正常运行一段时间之后再发布到生产环境,这么做也有问题:在测试下一版app 1.1的时候,该用QA环境的动态库版本还是用生产环境的动态库版本?如果程序在编译测试之后行为还会改变,这是不是在让QA白费力气?
?总之,一旦动态库可能频繁更新,没有一个完美的使用动态库的办法。在决定使用动态库之前,至少要熟悉它的各种陷阱。参考资料如下:
?http://harmful.cat-v.org/software/dynamic-linking/
?《A Quick Tour of Compiling, Linking, Loading, and Handling Libraries on Unix》(http://ref.web.cern.ch/ref/CERN/CNL/2001/003/shared-lib/Pr/)。
?《How to write shared libraries》(http://www.akkadia.org/drepper/dsohowto.pdf)。
?《Good Practices in Library Design, Implementation, and Maintenance》(http://www.akkadia.org/drepper/goodprac-tice.pdf)。
?《Solaris Linker and Libraries Guide》(http://docs.ora-cle.com/cd/E19963-01/html/819-0690/)。
?《Shared Libraries in SunOS》(http://www.cs.cornell.edu/courses/cs414/2004fa/sharedlib.pdf)。
10.5.2 静态库也好不到哪儿去
?静态库相比动态库主要有几点好处(http://en.wikipedia.org/wiki/Static_library):
1.依赖管理在编译期决定,不用担心日后它用的库会变。同理,调试core dump不会遇到库更新导致debug符号失效的情况。
2.运行速度可能更快,因为没有PLT(过程查找表),函数调用的开销更小。
3.发布方便,只要把单个可执行文件拷贝到模板机器上。
?静态库的一个缺点是链接比动态库慢。
?静态库作者把源文件编译成.a库文件,连同头文件一起打包发布。应用程序作者用库的头文件编译自己的代码,并链接到.a库文件,得到可执行文件。这里有一个编译的时间差:编译库文件比编译可执行文件要早,这就可能造成编译应用程序时看到的头文件与编译静态库时不一样。
?比方说编译net 1.1时用的是boost 1.34,但是编译xyz这个应用程序的时候用的是boost 1.40,见图10-6。这种不一致有可能导致编译错误,或者导致运行错误。比方说net库以boost::function提供回调,但是boost 1.36去掉了一个模板类型参数,造成xyz 1.0用boost 1.40的话就与net1.1不兼容。
?这说明应用程序在使用静态库的时候必须要采用完全相同的开发环境(更底层的库、编译器版本、编译器选项)。但是万一两个静态库的依赖有冲突怎么办?
?静态库把库之间的版本依赖完全放到编译期,这比动态库省心,但是仍不容易。几种可能遇到的情况:
1.迫使升级高版本。假设一开始应用程序app 1.0依赖net 1.0和hub 1.0,一切正常,如图10-7左图所示。在开发app 1.1的时候,我们要用到net 1.1的功能。但是hub 1.0仍然依赖net 1.0,hub库的作者暂时没有升级到net 1.1的打算。如果不小心的话,就会造成hub 1.0链接到net 1.1,如图10-7右图所示。这就跟编译hub 1.0的环境不同了,hub 1.0的行为不再是经过充分测试的。
2.重复链接。如果Makefile编写不当,有可能出现hub 1.0继续链接到net 1.0,而应用程序则链接到net 1.1的情况,如图10-8左图所示。这时如果net库里有internal linkage的静态变量,可能造成奇怪的行为,因为同一个变量现在有了两个实体,违背了ODR。一个具体的例子见云风的博客97。
3.版本冲突。比方说app升级到1.2版,想加入一个库cab1.0,但是cab 1.0依赖net 1.2,如图10-8右图所示。这时我们的问题是,如果用net 1.1,则不满足cab 1.0的需求;如果用net 1.2,则不满足hub 1.1的需求。那该怎么办?
?如果一个应用程序用到了三四个公司内部的静态库(图10-9),那么协调库之间的版本要花一番脑筋,单独升级任何一个库都可能破坏它原本的依赖。
?静态库的演化也费事。到目前为止我们认为公司没有历史包袱,所有的机器都是2009年前后买的,运行的是Ubuntu8.04 LTS,软件版本是g++4.2、glibc 2.7、boost 1.34等,C++程序和库也都是在这个统一的环境下开发的。现在到了2012年,线上服务器已服役满3年,进入换代周期。新购买的机器打算升级到Ubuntu 10.04 LTS。这样同时升级了内核、gcc 4.4、glibc 2.11、boost 1.40。
?这要求静态库的作者得为新系统重新编译并发布新的库文件。为了避免混淆,我们不得不为库加上后缀名,以标明环境和依赖。假设目前有net 1.0、net 1.1、net 1.2、hub 1.0、hub 1.1、cab 1.0等现役的库,那么需要发布多个版本的静态库:
net1.0_boost1.34_gcc42
net1.0_boost1.40_gcc44
net1.1_boost1.34_gcc42
net1.1_boost1.40_gcc44
net1.2_boost1.34_gcc42
net1.2_boost1.40_gcc44
hub1.0_net1.0_boost1.34_gcc42
hub1.0_net1.0_boost1.40_gcc44
hub1.1_net1.1_boost1.34_gcc42
hub1.1_net1.1_boost1.40_gcc44
cab1.0_net1.2_boost1.34_gcc42
cab1.0_net1.2_boost1.40_gcc44
?这种组合爆炸式的增长让人措手不及,因为任何一个底层库新增一个变体(variant),所有依赖它的高层库都要为之编译一个版本。
?要想摆脱这个困境,办法是使用源码库,即每个应用程序都从头编译所需的库,把时间差减到最小。
10.5.3 源码编译是王道
?每个应用程序自己选择要用到的库,并自行编译为单个可执行文件。彻底避免头文件与库文件之间的时间差,确保整个项目的源文件采用相同的编译选项,也不用为库的版本搭配操心。缺点是编译时间很长,因为把各个库的编译任务从库文件的作者转嫁到了每个应用程序的作者。
?最好能和源码版本工具配合,让应用程序只需指定用哪个库,build工具能自动帮我们check out库的源码。这样库的作者只需要维护少数几个branch,发布库的时候不需要把头文件和库文件打包供人下载,只要push到特定的branch就 行。而且这个build工具最好还能解析库的Makefile(或等价的build script),自动帮我们解决库的传递性依赖,就像 Apache Ivy能做的那样。
总结
?由于C++的头文件与源文件分离,并且目标文件里没有足够的元数据供编译器使用,因此必须同时提供库文件和头文件。也就是说要想使用一个已经编译好的C/C++库(无论是静态库还是动态库),我们需要两样东西,一是头文件(.h),二是库文件(.a或.so),这就存在了这两样东西不匹配的可能。这是造就C++简陋脆弱的模块机制的根本原因。
待解决
?DLL Hell问题
?编译器的ABI
?extern “C”
?一次定义原则(ODR)
?在头文件内写内部 #include guard(护套),不要在源文件写外部护套???
Please indicate the source: http://blog.csdn.net/gaoxiangnumber1
Welcome to my github: https://github.com/gaoxiangnumber1
标签:
原文地址:http://blog.csdn.net/gaoxiangnumber1/article/details/51285046