以下以obj 表示目标文件object file, lib表示库library,exe表示可执行程序executable files。
回顾流行的C++编译器产品实现置入式模型inclusion model方法。所有这些产品都依赖两个典型组件:编译器和链接器。编译器用来将源码转翻译为obj文件,其中包含带符号标注(symbolic annotations; 用以交叉引用其它obj文件和lib文件)的机器码。链接器用来合并obj文件、解析obj文件所含的符号式交叉引用symbolic cross-reference,最终生成exe文件或lib文件。接下来的内容中,我们假设你的编译系统使用这种模型,当然以其它方式实现C++编译系统也是完全可能的。例如可以想象一个C++直译器interpreter。
当一份class template 特化体被用于多个编译单元中,编译器会在处理每个编译单元时重复具现化。这不会引发什么问题。因为编译器并不直接根据class定义式产生机器码。C++编译器只是在其内部用到这些具现化,以便检验或解释其他各种算式和声明。从这个角度说,class定义式的多次实例化,与class定义式在多个编译单元被多次含入(典型情况是透过含入文件)并无本质区别。
然而,如果你实例化一个noninline function template。情况可能有所不同。如果你提供了一个常规的noninline function 的多份定义,将违反ODR(单一定义原则)。假设你要编译并链接一个程序,而它由下面两个文件组成:
// a.cpp
int main()
{
}
// b.cpp
int main()
{
}
C++ 编译器会顺利编译两个文件,不会遭遇任何问题,因为它们确实都是合法的C++编译单元。然而如果你试图链接两个obj文件,链接器会报错,因为在同一个程序中重复定义了main()函数,这是不允许的。
对比地,考虑template的情况:
//t.hpp
//普通的头文件(置入式模型)
template
class S {
public:
void f();
};
template
void S::f()
{
}
void helper(S
//a.cpp
#include "t.hpp"
void help(S
{
s->f(); // (1) S::f的第一个POI
}
//b.cpp
#include "t.hpp"
int main()
{
S
help(&s);
s.f(); //(2) S::f 的第二个POI
}
如果链接器以对待常规函数或常规成员函数方式来处理template的实例化成员,那么编译器便需确保在两个POI处只生成一份程序代码:不是(1)处就是(2)处,但不能两处都生成程序码。为了达到这个目标,编译器不得不把某个编译单元的信息带到其它单元,而这在template概念被引入之前,编译器是绝对无需这么做的。接下来的内容将讨论三种主要解决方案,它们在各种C++编译器实作中运用极广。
同样的问题也会出现在template实例化过程所产生的所有可链接物linkable entities 上:
实例化的function template和member function templates以及实例化的static成员变量。
1. 贪婪式实例化(Greedy Instantiation)
第一个运用贪婪式机制的编译器出自于Borland公司。后来这种机制逐渐成为各种C++编译器系统中最常用的技术,特别是在微软开发环境中,它几乎成为所有编译系统使用的机制。
贪婪式实例化机制假设,链接器知道某些物体(特别是可链接之template具现化)可能在各个obj文件和lib文件中存在多份。编译器通常会以特殊方法在这些物体上做出标记。当链接器发现存在多个重复具现体时,它只保留一个,并将其他具现体全部丢弃。就是这么简单。
贪婪式实例化在理论上有一些严重缺点:
编译器可能生成并优化N个template具现体,最后却只保留一个。这会严重浪费时间。
通常链接器并不检查两个具现体是否完全相同,因为要给template特化体的多个实体之间往往存在一些不重要的差异,这是合法的。这些小差异不会导致链接失败。然而这也经常导致链接器注意不到更大的差异,例如某个具现体可能在编译期间针对最大效能进行优化,另一个具现体在编译期间含有更多除错信息。
和其他机制对比,贪婪式实例化机制可能会产生总长度大得多的obj文件,因为相同的程序可能重复多次。
这些缺点并不是什么大问题。可能是因为贪婪式机制在一个重要方面明显优于其他机制:传统的源码-目标文件(source-object)依存关系得以保留,特别是:一个编译单元只生成一个obj文件,每个obj文件都包含对应源码文件中的所有可链接定义(其中包括实例化后的定义)。
最后一个值得注意的问题是,如果链接机制允许多个可链接物(linkable entities)的定义存在,这个机制通常可被用来处理重复出现到处散落的inline函数和虚拟函数分派表virtual function dispatch tables。当然,如果不支持这中机制,也可能使用其他机制以内部链接方式internal linkage 产生这些东西,代价是产生出来的obj文档比较大。
2. 查询式实例化(Queried Instatiation)
此类机制最普及的实现来自Sun Microsystem公司,第一个支持此机制的是其编译器4.0版本。查询式实例化概念上及其简单优雅,如果按发生时间排序,它也是我们回顾的这些实例化机制中出现最晚的。该机制中,编译器维护一个由程序各编译单元共享的数据库database。这个数据库可用来追踪“哪些特化体在哪个相关源码文件中被实例化”。生成的所有具现体也会连同这些信息被存储于数据库中。当编译器遇到一个可链接物)(linkable entity)的POI(具现点)时,可能会发生以下三种情况之一:
1)找不到特化体
这种情况下,编译器会进行实例化过程,产生的特化体被存入数据库。
2)找到了特化体,但已经过期out of date
也就是特化体生成后,源码又被改动过。和1.相同,编译器于是进行实例化过程。然后把新生成的特化体存入数据库中。
3)在数据库中找到了最新的特化体,编译器什么都不用做。
虽然概念上简单,但实操时这种设计带来一些难题:
编译器需要正确的根据源码状态来维护数据库中的各种依存关系,而这并非唾手可得。虽然,把第三种情况误当做第二种情况并不算错误,但这会增加编译器的工作量,也就是增加整个编译时间。
并行(concurrently)编译多份源码如今已经是颇为平常的事情了。那么编译系统的实作者必须提供一个工业强度的数据库,恰当地控制各种并行情况。
尽管存在这些困难,这个机制还是可以实现出极高效率的编译器。而且也不存在什么情况会使这种解决办法无法处理大规模程序。相反地,如果采用贪婪式实例化机制,处理大规模程序时会产生许多任务工时的浪费。
不幸的是,数据库的使用会带给程序员一些问题。多数问题的根源在于,继承自大多数C编译器的传统编译模型,此处不再适用,因为单一编译单元不再产生单一的独立obj文件。举个例子,
假设你想要链接你的最终版本的程序,链接操作中不仅需要编译单元相应的各个obj文件,也需要存储于数据库中的obj文件。同样道理,如果要建立一个二进制lib文件,必须确保生成该文件的工具程序(通常是linker或archiver)能够感知数据库中的内容。更常见的情况是,所有obj文件操作工具都需要感知数据库的内容。许多这些问题都可以透过不要将具现体保存于数据库中而得以缓和,换之以另一种方式:吐露出obj文件(首先引发实例化)中的obj吗。
lib文件是另一个大难题。很多编译器生成的特化体可能会被打包到一个lib文件中。当另一个专案使用这个lib时,该项目的数据库必须感知到这些既有的特化体。如果无法感知,编译器就会为此项目产生出它自己的POI。然而这些特化体已经存在于Lib文件中,于是造成重复实例化。
3. 迭代式实例化(Iterated Instantiation)
第一个支持C++ template的编译器是Cfront 3.0,Cfront的一个硬性条件是:必须具备高度移植性。因此要求它:
1)在所有平台上都以C语言为共通表述;
2)使用平台提供的链接器。
更确切地说这意味着链接器对template一无所知。事实上Cfront将template具现体生成为常规C函数,因此它必须避免产出重复的具现体。
尽管Cfront的源码模型不同于标准的置入式模型和分离式模型,但是它的实例化策略可加以改编适应置入式模型。也因此它被人们誉为迭代式实例化机制的第一个化身。
CFront的迭代式实例化机制描述如下:
1)编译源码,但不实例化任何所需的可链接特化体(linkable specialization)
2)使用一个预链接器(prelinker)链接obj文件
3)预链接器调用链接器,并解析其错误信息以便得知是否遗漏任何具现体。
4)如果产出任何具现定义,则重复3)
原始的Cfront方案有一个致命缺点:链接时间大大膨胀了,因为不仅预编译器的额外开销,每次必要的再编译和再链接也需要高额的时间代价。
下面介绍改良后的一种迭代式实例化机制EDG版本。
EDG的迭代方式是在预链接器和各编译步骤之间建立双向通信:预链接器可将特定编译单元所需的实例化透过一个实例化申请档instantiation request file转给编译器处理:在obj文件中嵌入某些信息或产生个别template信息文件,编译器有能力告诉预编译器可能的POI位置。实例化申请文件与template信息文件与对应的待编译文件同名,分别带有.ii, 和.ti后缀。
迭代机制按以下步骤工作:
1)编译某个编译单元的源码时,EDG编译器读取对应的.ii文件,并产生需要的具现体。同时它将原本可兑现的POI写入当前编译产生的obj文件中,或写入一个独立的.ti文件中。同时也将这个文件如何编译的信息写入。
2)链接步骤被预编译器拦截。预编译器检查参与链接的obj文件和对应的.ti文件,如果发现有尚未产生的具现体,就把请求写入可兑现此一请求的编译单元所对应的.ii文件中。
3)如果预链接器察觉到任何.ii文件被更改过。就重新调用编译器来处理对应的源码文件,然后迭代预链接步骤。
4)当所有具现体都被产出完毕(迭代完成),链接器便会进行实际的链接工作。这个机制解决了并行构建(conrrent build)问题,它把所有用到的信息分置于各编译单元中。