八月十七号,A友在看C++时豪气地抛给了我一个有关静态数据成员重定义的问题。年轻人总是好匹夫 之勇,我当然不例外。 简单了解之后我们便一起稀里糊涂的讨论,直到把这个问题给糊里糊涂地解决掉。这里用了略显夸张的副词,只是为了说明我们之间当时的讨论都是建立在各自残缺的理论基础之上进行的。我们都有自知之明,因此在讨论的时候多以“就我认为”、“据我掌握的知识”、“应该是”作为特定的修饰加以强调,其实也正因为这样,才能把讨论给继续下去,也得以最终将问题解决。
当我发现自己对一些自己引用的知识点没有足够的自信,就像如上那样频繁地使用“就我认为”之类的词语之时,我便发现,新的问题又来到了。所以便打算将此留为本周的额外作业,即对碰到的问题作个小结并去了解自己不熟悉的相关知识。不多废话,言归正传。
1. 头文件中的重定义问题
A友给我的代码(多个源文件)中,最为主要的问题在将一个类的静态成员在头文件中进行了定义,并在多个源文件中进行了包含。因为C++中的静态数据成员只能定义一次,因此在链接时出现相关的错误提示。
我们对此讨论的焦点在于,既然头文件中包含有相关的预处理指令来避免“多次包含”,但是为什么还会出现重定义的问题。实际上,预处理的指令的确有着避免多次包含的作用,但更为确切地说是为了避免“头文件的多次包含”。因此,而如果考虑避免头文件中的相关数据成分多次包含的话基本是不可能的。举例如下(简化了过程,平台为Windows):
假设一个工程有三个文件组成,main.cpp, test.cpp,和test.h,main.cpp和test.cpp中分别包含了test.h。在工程的编译阶段将分别对预处理过后的test.cpp和main.cpp进行编译,在链接阶段将经过编译之后的main.o 和test.o相链接,最终生成可执行文件。正因为main.cpp和test.cpp是分别进行编译的,所以main.o和test.o中肯定都包含有test.h中相关的一些数据,只是这些重复的数据在链接阶段经过了一定的处理而已。
因此,A友给我的代码在链接阶段出错便可以进行解释了。修改的方法即为将静态成员的定义在头文件中注释,并在其他地方定义(注意,不要在main()中定义,为什么,因为作用域的关系)。 有关编译、链接中更为详细的知识可参考《程序员的自我修养——链接、装载与库》。
2. 头文件中的头文件包含
在自定义的头文件中包含其他的标准头文件。当我看到代码中这样做时感觉比较怪异。如果不是很有必要,这种做法通常会导致编译时间的延长。因为有可能对于某些源文件来说你包含了过多的库文件。
这里不妨多了解下头文件(以C中的头文件为例)。头文件中最为常见的内容包括如下几部分:
1)明显常量
2)宏函数
3)函数声明
4)结构模板定义
5)类型定义
头文件通常用于向外提供接口,它屏蔽了相关的实现细节,使得程序的用户不用太过关注于一些复杂的实现, 使得具体实现与使用相分离。
这就意味着通常情况下,每一个头文件会对应相应的源文件。对于标准头文件有着对应的库文件,对于自定义的头文件有着对应的实现源文件。两者不同在于头文件所对应的库文件是已经经过编译的目标文件,而自定头文件中的实现源文件在编译阶段才开始编译。
在程序经过预编译之后,相关的头文件内容会被包括在相关的源文件中,之前我一直以为这里面包括的东西应该很少,因为我打开过头文件,里面的东西并不是很多。然而,当我某天把经过预处理的文件打开来时才发现之前的认为很肤浅,预处理之后的文件非常庞大。1kB的源文件经过预处理之后可以增长到1000倍的大小,而其包含的标准头文件只有3KB + 6KB = 9KB。其中忽略掉的一点便是各个头文件之间通常有着相互依赖的关系,因为某种关系,这个头文件还必须包含着另外其他的一些头文件。这些文件经过“编译”之后能够为连接阶段提供一些必要的信息,如怎么能够找到一些程序中用到的库文件(问题)。
由上可以看出,如果不经过仔细分析,只为图个便捷在自己定义的头文件中包含库文件,将会有很大的概率增加编译的时间开销。
3. 头文件中的内联问题
由于有时一味的固守成见,便会导致许多错误。由于本身就有点“野路子”,也就是在学习C++的时候东打一枪、西打一炮,所以犯的一些错误就更显得低级了。比如我之前一直以为将所有类、函数的声明写在自定义的头文件中,而将其他的具体实现写在另一个对应的实现文件当中是一种良好的编程风格。没有想到的是在使用内联函数的时候却是一个例外。那么内联函数与一般函数有什么不同之处呢?在谈到它们的时候 其实可以将话题扩展到宏、函数和内联函数三者之上。
宏(对象宏、函数宏)的使用无疑使程序的易更改性、可移植性得到了加强,但在使用宏时如果不仔细加以注意便会出现一些令人匪夷所思的现象。在一个程序中,许多的任务是即可以用函数宏来完成也可以通过函数来完成的。两者的区别其实和算法效率中所谓的“空间与时间”差不多,宏通过产生内联代码虽然占据了较多的空间,但同时它也少了许多函数调用上时间的耗费。考虑一个对时间效率要求较高的场合,我们能否有一种既可以在时间上有较高效率又易于使用的办法呢?一种可替代的方案便是内联函数。
内联函数与一般函数有两点不同,(1)具体定义不同;(2)实现机理不一样。
(1)我们知道,在C中,一般的函数是只能够进行惟一一次定义的,因为这些函数默认具有外部链接,也就是说它在具有多个文件程序的任何地方是可用的。而内联函数通常具有内部链接,因而在多文件程序中每个调用内联函数的文件都要对该函数进行定义。这已经可以说明我起初提出的那个问题:即为什么内联函数的定义要放到头文件中。
在C++中,因为其多了域(scope)的概念,新增了命名空间域和类域。所以对于C中的一般函数来说,在这方面更为灵活,因而你可以在不同的命名空间域或者类域中定义相同的函数(不同于重载,这里函数名及相关参数均相同),编译器能够根据上下文区分出多个相同函数名字的不同含义。
(2)一个内联函数在编译时,编译器将内联函数的定义在调用处展开,之后再进行编译。这和一般函数的编译过程是不一样的。值得一提的是,将一个函数定义为内联函数时仅仅是建议该函数被内联,而是否编译器将其内联会随着编译器的实现而不同。如下两种情况下编译器可能不会将一个声明为内联函数的函数作为内联函数进行处理:
#函数的定义过长
#函数被多个文件调用,在各文件中生成定义会产生过大的、不必要的可执行文件
4. 编译、链接(静态模块拼接)小窥
因为在前面碰到的一些小问题都涉及到了编译、链接的过程。比如在考虑到头文件中的重定义问题的时候,很自然的对重定义为什么会在编译阶段顺利编译,而在链接的过程却报告错误感到不解; 在解决头文件中的头文件包含的时候便想到了头文件中的这些东西在编译过后在什么时候起作用,进行库链接的时候该怎么去找这些库;在排解内联函数的问题的时候很好奇编译器是怎么找到一般函数的,又是怎样“忽略”掉内联函数的。所以自然而然地对这些相关问题的关注便逐渐转向了程序的编译、链接过程之上。既然有了这么一个解决问题的机会,那么何不妨了解下整个构建的过程。
一般情况下,当整个工程文件进行构建时会经历预处理准备工作、预处理、编译、链接几个步骤,如下稍微展开。
1)预处理准备工作, 这个阶段编译器会对所有的文件进行几次翻译处理,如字符集映射、逻辑行转换、文本划分等工作。
2)预处理, 这个阶段编译器按照相关的“指令”进行文字替换操作,#pragma指令暂不作处理。
3)编译,这个阶段通常是进行扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。
4)链接,此处也是指的最基本的静态链接,这个阶段编译器(实际上是链接器)将目标代码、系统的标准启动代码和库代码结合在一起,生成可执行文件。
整个过程很复杂,涉及到了太多的内容,这里因为只对一些特定的问题感兴趣,所以也就只截取相关的部分。
我们知道,编译阶段主要的任务是生成目标文件,链接阶段主要的任务是将多个目标文件进行组装入相关启动代码生成可执行文件。其实目标文件在结构上与可执行文件上相差不大,还缺少一部分工作。首先,它们需要被组装在一起以形成一个逻辑的整体。其实,组装好一个整体之后,还需要进行一部分与系统的衔接工作,以便其加载到系统中执行。
这里一个很重要的概念就是目标文件或者可执行文件都可以看作是以段为单位的数据的组合。也即是源代码经过编译为了机器码,这种机器码按照一定的规律(被划分为多个段)存放,其中最为主要的三个段即是头文件段、文本段和数据段。头文件段包含有文件的基本属性,文本段包含程序源代码被编译后的机器指令,数据段通常包含有全局变量和局部静态变量的数据。
在链接阶段,链接器必须要将“几个”输入目标文件加工后合并成一个输出文件,因为每个目标文件有着相似的结构(即包含有多个段),这时链接器通常会采用相似段合并的方法将它们整合到一起。而其实质性的工作便是对各目标文件中函数和变量的“修改”,因为在每一个目标文件里都会有一个相应的符号表,表里面记录了目标文件中所用到的所有符号。每个符号都有一个对应的值,对于变量和函数来说,符号值就是它们的地址。
当我终究了解到函数和变量是如何在编译过程中起作用时我之前想到的一些问题便能够得到一些启发了。如下:
第一个:头文件中的重定义问题
答:首先,各个文件是相互独立编译的,因而在编译阶段是没有错误提示得。其次,多个目标文件在链接过程中,因为之前编译过后的静态数据成员信息是存放在数据段当中的, 这时候很自然的根据符号表中的数据,很自然的便可以得到错误(这里可能会问为什
第二个问题,头文件中的头文件包含的时候想到了头文件中的这些东西在编译过后在什么时候起作用,进行库链接的时候该怎么去找这些库?
答:其实头文件的作用就类似于一些函数或者变量的声明,编译过程中记录下这些“符号” ,相当于声明这些符号会在程序中使用到,但是目前还不能够告诉你它的确切位置,请作记号。之后在链接阶段将相关的函数或变量具体定义对应的机器码整合在一起后(空间和地址分配),再通过符号重定义等操作给他们加上具体的地址(符号解析与重定位)。而对于库连接的时候是怎样去寻找这些库的现在也没有搞清楚。
第三个问题,在排解内联函数的问题的时候很好奇编译器是怎么找到一般函数的,又是怎样“忽略”掉内联函数的。
答:怎么找到一般函数这个问题上面已经涉及到了,而内联函数只是在编译阶段进行了基本的内联展开,这时候相当于函数名其实已经没有记录在符号表中或者已经经过处理,只是相关参数还在而已。
后记
噼里啪啦了这么多,快昏倒了, 有点体力不支。里面想法有些还需要进一步的考查,有关编译与链接的知识在《程序员的自我修养——链接、装载与库》这本书里面有很详细的介绍。为了回答自己的几个小问题,我只是很粗糙的浏览了其中的静态链接部分。我其实还在想,为了这么点儿小问题就这么折腾,值得么? 不过想想自己现在有点时间,也正在学习这些,只要觉得有所收获,付出一些还是可取的。
2011.3.5 更新
这两天学习C++时又犯了将类的内联成员函数分开编译的毛病,突然想起之前也遇到过这个问题。又重新读了一遍,发现写得好差啊 ^_^
:错别字,改了一个,还有的。
:各市乱,整理了下。