第14章 实例化

模板实例化就是从泛型模板定义中生成类型、函数和变量的过程。C++模板实例化的概念非常基础,但有时又错综复杂。这一复杂性的其中一个底层原因在于:模板生成的实体定义不再局限于源代码单一的位置。模板本身的位置、模板使用的位置以及模板实参定义的位置均在实体的含义中扮演着重要角色。

本章我们会讲解如何组织源代码来正确使用模板。此外,我们调查了最流行的C++编译器处理模板实例所使用的各种各样的方法。尽管所有的方法都应该语义等价,但理解编译器实例化策略的基本原则是大有裨益的。在构建实际软件时,每种机制都带有一些小怪癖,反过来,每种机制都影响了标准C++的最终规范。

14.1 On-Demand实例化

当C++编译器遇到模板特化的使用时,它会用需要的实参来替换模板参数,然后创造出特化体。这一过程是自动完成的,不需要客户端代码来引导(或者不需要模板定义来引导)。这一on-demand实例化特性使得C++与其他早期的编译型语言的类似功能大相径庭(如Ada或Eiffel,其中的一些语言需要显式地实例化引导,另外一些使用运行时分发机制来避免编译期实例化过程)。有时这也被称作隐式(implicit)抑或自动(automatic)实例化。

On-demand实例化意味着编译器常常需要访问模板完整的定义(换句话说,不只是声明)以及某些成员。考虑下面这一短小的源码文件:

  1. template<typename T> class C; // #1 declaration only
  2. C<int>* p = 0; // #2 fine: definition of C<int> not needed
  3. template<typename T>
  4. class C{
  5. public:
  6. void f(); // #3 member declaration
  7. }; // #4 class template definition completed
  8. void g(C<int>& c) // #5 use class template declaration only
  9. {
  10. c.f(); // #6 use class template definition;
  11. } // will need definition of C::f()
  12. // in this translation unit
  13. template<typename T>
  14. void C<T>::f() // required definition due to #6
  15. {
  16. }

在源码的#1处,仅仅只有模板的声明,并没有定义(这种声明有时也被称作前置声明)。与普通类的情况一样,如果你声明的是一个指向某种类型的指针或引用(#2处的声明),那么在声明的作用域中,你并不需要看到该类模板的定义。例如,声明函数g的参数类型并不需要模板C的完整定义。然而,一旦某个组件需要知道模板特化体的大小或是访问了这种特化体的成员,那么就需要看到完整的类模板定义。这就解释了为什么#6处必须看到类模板的定义。若非如此,编译器无法确认该成员是否存在、是否可访问(非private或protected)。更进一步,成员函数定义也是需要的,因为#6处的调用需要确认C<int>::f()是否存在。

下面是另一个需要进行(前面的)类模板实例化的表达式,这是因为它需要C<void>的尺寸:

  1. C<void>* p = new C<void>;

本例中,需要实例化来保证编译器可以确定C<void>的尺寸,该new表达式需要去确认要分配多少存储空间。你可能会发现,对这一模板来说,替换模板参数T的实参X的类型无论是什么,都不会影响模板的尺寸,毕竟C<X>是一个空类(没有成员变量或虚函数)。然而,编译器并不会通过分析模板定义来避免实例化(所有编译器实际上都会进行实例化)。此外,对于上例来说,为了确定C<void>是否有可访问的默认构造器并确保C<void>没有成员operator newoperator delete操作符函数,实例化也同样是必要的。

在源码中是否需要访问类模板的成员并不总是那么直观。例如,C++重载解析规则要求:如果候选函数的参数是类类型,那么该类类型就必须是可见的:

  1. template<typename T>
  2. class C {
  3. public:
  4. C(int); // a constructor that can be called with a single parameter
  5. }; // may be used for implicit conversions
  6. void candidate(C<double>); // #1
  7. void candidate(int) { } // #2
  8. int main()
  9. {
  10. candidate(42); // both previous function declarations can be called
  11. }

调用candidate(42)会采用#2处的声明。然而,在#1处的声明也会被实例化来检查对于这个调用来说它是否是可用的候选者(这个例子中,由于模板的单实参构造器可以把42隐式转换成一个类型为C<double>的右值)。请注意,如果模板不经实例化也可以找到调用函数(合适的候选),编译器还是被允许(但没被要求)执行该实例化(上例的情景中,由于有精准匹配的候选者,隐式转换的那个不会被选择)。另外,令我们的惊讶的是:C<double>的实例化可能还会触发一个错误。

14.2 延迟实例化

到目前为止所展示的这些例子,和使用非模板类相比并没有本质上的区别。譬如,非模板类的许多用法会要求类类型的完整性(参考P154节10.3.1)。而对模板来说,编译器会用类模板定义来生成完整的定义。

现在就有了一个相关的问题:模板实例化的程度如何?下面有一个模糊的答案:会实例化到它实际需要的程度。换句话说,编译器在实例化模板时应该是“懒惰”的。让我们来细究“懒惰”在这里的真正意义。

14.2.1 部分实例化和完整实例化

如我们之前所见,编译器有时不需要替换类或函数模板的完整定义。例如:

  1. template<typename T> T f(T p) { return 2*p; }
  2. decltype(f(2)) x = 2;

本例中,decltype(f(2))所指示的类型并不需要函数模板f()的完整实例化。编译器因此只被允许替换f()的声明,而不是替换整个“身体”。这有时被称为部分实例化(partial instantiation)。

同样,如果引用类模板的实例而不需要将该实例作为完整类型,则编译器不应对该类模板实例执行完整的实例化。考虑下面的例子:

  1. template<typename T> class Q {
  2. using Type = typename T::Type;
  3. };
  4. Q<int>* p = 0; // OK: the body of Q<int> is not substituted

在这里,Q<int>完整的实例化会触发一个错误,因为在Tint类型时,T::Type并没有意义。但是因为本例并不需要完整的Q<int>,所以不会执行完整实例化,代码也是OK的(尽管可疑)。

变量模板也有“完整”和“部分”实例化的区别。下面的例子用以阐释:

  1. template<typename T> T v = T::default_value();
  2. decltype(v<int>) s; // OK: initializer of v<int> not instantiated

v<int>的完整实例化会引起错误,但是如果只是需要变量模板实例的类型的话,是不需要进行完整实例化的。

有意思的是,别名模板没有这一区别:不存在两种方法来替换它们。

在C++中,当谈到“模板实例化”而没有说特定的完整或部分实例化时,往往意味着前者。也就是说,默认情况我们指的都是完整实例化。

14.2.2 实例化组件

当类模板隐式(完整)实例化时,其所有成员的声明也都会进行实例化,但是对应的定义却并不会实例化(即,成员是部分实例化的)。对此有一些特殊情况:首先,如果类模板包含一个匿名的联合体(union),该联合体的成员的定义也会实例化;另一个特殊的情况出现在虚成员函数场景中,它们的定义作为模板实例化的结果,可能会也可能不会进行实例化。实际上,许多实现都会实例化该定义,因为“实现虚函数调用机制的内部结构”需要虚函数有一个链接实体存在。

实例化模板时,默认函数调用实参被单独考虑。具体来说,除非调用该函数(或成员函数)时确实使用了默认实参,否则它们不会被实例化。反之,如果调用该函数时显式地指定了实参去覆盖这一默认实参,那么默认实参就不会被实例化。

类似的,除非有必要,异常规范和默认成员初始化器也不会被实例化。

让我们用一些例子来阐释这些原则:

details/lazy1.hpp

  1. template<typename T>
  2. class Safe {
  3. };
  4. template<int N>
  5. class Danger {
  6. int arr[N]; // OK here, although would fail for N<=0
  7. };
  8. template<typename T, int N>
  9. class Tricky {
  10. public:
  11. void noBodyHere(Safe<T> = 3); // OK until usage of default value results in an error
  12. void inclass() {
  13. Danger<N> noBoomYet; // OK until inclass() is used with N<=0
  14. }
  15. struct Nested {
  16. Danger<N> pfew; // OK until Nested is used with N<=0
  17. };
  18. union { // due anonymous union:
  19. Danger<N> anonymous; // OK until Tricky is instantiated with N<=0
  20. int aligh;
  21. };
  22. void unsafe(T (*p)[N]); // OK until Tricky is instantiated with N<=0
  23. void error(){
  24. Danger<-1> boom; // always ERROR (which not all compilers detect)
  25. }
  26. };

标准C ++编译器将审查这些模板定义以检查语法和常规语义约束。这样做时,当检查涉及模板参数的约束时,它将“假设最佳”。举个例子,Danger::arr的成员参数N可能是零或负数(非法的),但是编译器会假定不会出现这种情况。inclass()struct Nested,匿名联合体的定义因而都没有问题。

出于同样的原因,只要N还是一个未被替换的模板参数时,成员unsafe(T (*p)[N])的声明也不是问题。

noBodyHere()的默认实参规格声明(=3)看起来很诡异,因为模板Safe<>并不能以一个整型数来初始化,但是编译器会假定:对于Safe<T>泛型定义来说,它实际上并不需要默认实参;或者是Safe<T>的特化体会引入使用一个整型数来初始化的能力(见第16章)。然而,成员函数error()的定义必定会引起一个错误,即使模板尚未实例化,这是因为Danger<-1>的使用需要一个完整的类Danger<-1>的定义,也就会生成该类并尝试去定义一个负数尺寸的数组。有趣的是,虽然标准明确指出此段代码无效,但它还是允许编译器在未实际使用模板实例时不去诊断这个错误。也就是说,只要Tricky<T,N>::error()对任何具体的TN类型都未被使用,那么编译器就不用抛出这个错误。例如,GCC和Visual C++在撰写此书时都不会抛出这一个错误。

让我们来分析一下,在增加下面的一行定义语句时,会发生什么:

  1. Tricky<int, -1> inst;

这将引起编译器(完整)实例化Tricky<int, -1>,在模板Tricky<>定义中替换TintN-1。并非所有的成员定义都是必要的,但是默认构造器和析构器(本例中都是隐式声明的)一定会被调用到,因此它们的定义必须是可用的(在我们的例子中,它们都会隐式生成)。如上所述,Tricky<int, -1>的成员会部分实例化(即,它们的声明会被替换):这一过程可能会引起错误。例如,unsafe(T (*p)[N])的声明创建了一个负数尺寸的数组类型,这就是一个错误。类似的,anonymous成员现在也会抛出一个错误,因为并不能生成Danger<-1>类型。另一方面,成员inclass()struct Nested的定义现在还不会被实例化,因此对完整类型Danger<-1>的需求并不会产生错误(它们都包含了一个无效的数组定义)。

如上所述,当实例化一个模板时,对于虚函数实际上是需要提供定义的。否则,就会遇到链接错误。例如: details/lazy2.cpp

  1. template<typename T>
  2. class VirtualClass {
  3. public:
  4. virtual ~VirtualClass() {}
  5. virtual T vmem(); // Likely ERROR if instantiated without definition
  6. };
  7. int main()
  8. {
  9. VirtualClass<int> inst;
  10. }

最后,operator->值得留意。考虑:

  1. template<typename T>
  2. class C{
  3. public:
  4. T operator-> ();
  5. };

通常来说,operator->必须返回一个指针类型或是另一个应用了operator->的类类型。C<int>的完全体会触发一个错误,因为它声明了一个int返回类型的operator->。然而,因为某些常见的类模板定义实现了这种(返回类型为T或者T*)定义,所以语言规则更加灵活。于是,只有在重载解析规则确实选择了用户自定义的operator->时,才要求该自定义operator->只能返回一个应用了其他(例如,内建的)operator->的类型。这甚至对模板之外的代码也同样生效(尽管这种松弛法则(relaxed behavior)在那些上下文中用处不大)。因此,这里的声明不会触发错误,尽管int会替代该返回类型。

14.3 C++实例化模型

模板实例化就是从对应的模板实体通过合适地模板参数替换来得到一个常规的类型、函数或是变量的过程。这可能听起来直截了当,但实际上需要遵循非常多的细节。

14.3.1 两阶段查找

在第13章中,我们曾看到依赖型名称无法在解析模板时被找到。取而代之的是,它们会在实例化的时刻再次进行查找。非依赖型名称则会在更早的阶段被查找,因此当模板第一次看到它的时候,就可以诊断出许多错误。这就引出了“两阶段查找”的概念。第一阶段查找发生在解析模板的时候,而第二阶段查找发生在模板实例化的时候:

  1. 在第一阶段,当解析模板时,非依赖型名称会并用普通查找规则和ADL规则(如果可行的话)。非受限的依赖型名称(诸如函数调用中的函数名称,它们之所以是依赖型名称,是因为它们具有依赖型实参)会使用普通查找规则,但是这一查找结果并不会作为最终结果,而是要等到第二阶段的另一个查找过程完成(也就是模板实例化的时候)。
  2. 在第二阶段,此时的模板实例化被称作POI(point of instantiation),依赖型受限名称会在此时被查找(对选定的实例用模板实参替换模板参数),而且还会对非受限依赖型名称进行额外的ADL查找(它们曾在第一阶段进行过普通查找)。

对非受限依赖型名称,首次的普通查找(并不是终态)被用来判断该名称是否是一个模板。考虑下面的例子:

  1. namespace N {
  2. template<typename> void g() {}
  3. enum E { e };
  4. }
  5. template<typename> void f() {}
  6. template<typename T> void h(T p) {
  7. f<int>(p); // #1
  8. g<int>(p); // #2 ERROR
  9. }
  10. int main() {
  11. h(N::e); // calls template h with T = N::E
  12. }

#1行,当看到跟着一个<的名称f时,编译器就需要判断<到底是一个尖括号还是一个小于号。这取决于f是否是一个已知的模板名称。在本例中,普通查找会找到f的声明,它确实是一个模板,因此这里会以尖括号来成功解析。

而在#2行,这里会产生一个错误,这是因为普通查找并不能找到模板g,因此,<就被认为是一个小于号操作符,对于我们的例子来说这就是个语法错误。如果想让该解析通过,那么在用T = N::E实例化h的时候最终得用ADL找到一个模板N::g(尽管N是与E关联的命名空间),但是只有先成功解析h的泛型定义,这才能行得通。

译者注:这里给的例子挺奇怪的,我觉着可以给g和f都增加一个模板参数作为函数参数。否则没有参数,f<int>(p)一样通不过。

14.3.2 POI

如上所述,C++编译器会在模板客户单代码的某些位置访问模板实体的声明或者定义。当某些代码结构引用了模板特化,而且为了生成该特化需要实例化相应的模板定义时,就会在源代码中产生一个POI。POI是源代码中的一个点,在这里会插入已被替换的模板。例如:

  1. class MyInt {
  2. public:
  3. MyInt(int i);
  4. };
  5. MyInt operator - (MyInt const&);
  6. bool operator > (MyInt const&, MyInt const&);
  7. using Int = MyInt;
  8. template<typename T>
  9. void f(T i)
  10. {
  11. if(i > 0) {
  12. g(-i);
  13. }
  14. }
  15. // #1
  16. void g(Int)
  17. {
  18. // #2
  19. f<Int>(42); // point of call
  20. // #3
  21. }
  22. // #4

C++编译器看到f<Int>(42)时,它知道模板f需要用MyInt替换T来实例化:这就创造了一个POI。#2#3与该调用点紧邻,但是它们都不是POI,因为C++不允许我们在这里插入::f<Int>(Int)的定义。另外,#1#4两处的本质区别在于,在#4处,函数g(Int)是可见的,因此模板依赖的调用g(-i)可以在#4处被解析。然而,如果我们假定#1是POI的话,那么调用g(-i)将不能被解析,因为g(Int)#1处是不可见的。幸运的是,对于函数模板特化的引用,C++把它的POI定义,置于紧跟在“包含这个引用的定义或声明所在的最近的命名空间作用域”之后。在我们的例子中,这个位置就是#4

你可能会想要知道为什么这个例子引入了类型MyInt而不是用int基础类型。这是因为,在POI执行的第二次查找(指g(-i))仅仅使用了ADL,而基础类型int并没有关联的命名空间,因此,如果使用int类型,就不会发生ADL查找,也就不能找到函数g。所以,如果你用下面的类型别名声明语句:

  1. using Int = int;

代码将无法通过编译。下面的例子有着类似的问题:

  1. template<typename T>
  2. void f1(T x)
  3. {
  4. g1(x); // #1
  5. }
  6. void g1(int)
  7. {
  8. }
  9. int main()
  10. {
  11. f1(7); // ERROR: g1 not found!
  12. }
  13. // #2 POI for f1<int>(int)

f1(7)调用为f1<int>(int)创造了一个POI紧随其后(在位置#2)。在这一实例中,关键点在于函数g1的查找。当首次遇到模板定义f1时,它会注意到非受限名称g1是一个依赖型名称,因为它作为一个函数名称,有着依赖型实参(实参x的类型取决于模板参数T)。因此,g1会在#1处使用普通查找规则,然而,在#1处找不到任何的g1。在#2处,即POI处,函数名称被再一次查找(在关联的命名空间和类中),但是唯一的实参类型是一个int型,它根本没有关联的命名空间和类。因此,g1永远都无法被找到,尽管在这里(POI处)哪怕用普通查找都可以找到g1

变量模板的POI的处理与函数模板相似。而对于类模板特化来说,情况则不太相同,如下例所示:

  1. template<typename T>
  2. class S {
  3. public:
  4. T m;
  5. };
  6. // #1
  7. unsigned long h()
  8. {
  9. // #2
  10. return (unsigned long)sizeof(S<int>);
  11. // #3
  12. }
  13. // #4

一样地,#2#3都不能作为POI,因为这里不能进行命名空间作用域类S<int>的定义(模板是不能出现在函数作用域内部的)。如果我们遵循函数模板实例的规则,POI会出现在位置#4处,但是这样一来表达式sizeof(S<int>)就是无效的,因为S<int>的尺寸直到#4之后才能被确定。因此,生成的类模板实例的引用被紧邻地定义在包含该引用的声明或定义的命名空间作用域之前。在我们的例子中,这个位置就是#1

当模板实例化时,可能还会产生某些额外的实例化。考虑这一简短的例子:

  1. template<typename T>
  2. class S {
  3. public:
  4. using I = int;
  5. };
  6. // #1
  7. template<typename T>
  8. void f()
  9. {
  10. S<char>::I var1 = 41;
  11. typename S<T>::I var2 = 42;
  12. }
  13. int main()
  14. {
  15. f<double>();
  16. }
  17. // #2: #2a, #2b

根据我们之前的讨论,f<double>()的POI位于#2处。函数模板f()还引用了类模板特化S<char>,它的POI位于#1处。与此同时它还引用了S<T>,但是因为这仍然是一个依赖型名称,我们此时此刻无法真正完成实例化。然而,如果我们在#2处实例化f<double>(),我们会注意到同时也需要实例化S<double>的定义。这种副(secondary)POI(或这叫过渡的POI)的定义位置会有些差异。对于函数模板,副POI与主(primary)POI严格一致;而对于类模板,副POI会(在最近的命名空间作用域中)先于主POI。在我们的例子中,这意味着f<double>()会被放在#2b处,而前面紧邻的#2a处会是S<double>的副POI。注意,这与S<char>的POI位置摆放完全不同。

编译单元通常会包含相同实例的多个POI。对类模板实例,在每个编译单元中,只有首个POI会被保留,后续的那些都会被忽略(它们不会被真正视为POI)。对函数模板实例和变量模板实例,所有的POI都会被保留。无论是哪一种情形,ODR原则都会要求:对保留的任何一个POI处所出现的同种实例化体,都必须是等价的;但是C++编译器既不需要保证这一原则,也不需要诊断是否违反这一原则。这就允许一个C++编译器选择一个非类类型的POI来执行实际的实例化,而不用担心另一个POI会产生一个不同的实例。

在实际应用中,大多数编译器会延迟大部分函数模板的实例化,直到编译单元末尾处(才进行真正的实例化)。某些实例化不能被拖延,这包括:判定某个推导的返回类型所需要的实例化场合(参考P296节15.10.1和P303节15.10.4)、函数是constexpr且必须产生一个常量结果的场合。一些编译器在首次用于潜在地内联调用时会立刻实例化内联函数。这种做法有效的将对应的模板特化的POI转移到了编译单元末尾处,而这作为一种可选择的POI(方式),被C++标准所允许。

14.3.3 包含式模型

当遇到POI时,对应模板的定义必须是可访问的。对类特化来说,这意味着类模板定义必须在编译单元中被更早地看见。而对函数模板和变量模板(以及类模板的成员函数和静态数据成员)的POI来说,也同样需要。典型的模板定义被简单的通过#include语句引入到编译单元,尽管是非类型模板也一样。这种模板定义的源码模型被称为包容式模型,它目前是当下C++标准所支持的模板的唯一自动的源码模型。

尽管包含式模型鼓励程序员将所有模板定义都放在头文件中,以便它们可以满足可能出现的任何POI,但显式地使用“显式实例化声明(explicit instantiation declarations)”和“显式实例化定义(explicit instantiation definitions)”(P260节14.5)来管理实例化也是可行的。从逻辑上讲,这样做并不是一件容易的事,大多数时候程序员会更喜欢依靠自动的实例化机制。用自动方案实现的一个挑战是要解决跨不同编译单元为函数模板或变量模板(或类模板实例的相同成员函数或静态数据成员)的特化体实现完全相同的POI。我们随后会讨论这个问题的解法。

14.4 几种实现方案

本节我们来回顾一下支持包含式模型的几种C++实现。所有的这些实现都依赖于两个基础组件:编译器和链接器。编译器将源代码编译成目标文件,它们包含机器码和符号注释(跨引用其他目标文件和库)。链接器通过组合这些目标文件解决它们包含的跨引用符号来创建可执行程序或库文件。在下面的内容中,即使完全有可能(但不流行)以其他方式实现C ++(例如,你可以假想出一个C++解释器。),我们也将采用这种模型。

当类模板特化在多个编译单元中被使用时,编译器会为每个编译单元重复实例化过程。这几乎不会造成什么问题,因为类定义不会直接创建低级代码。它们仅仅由C++实现体在内部使用,用来审查并解释各种其他表达式和声明。在这方面,类定义的多个实例化体与类定义的多个包含(在不同编译单元中通常通过头文件包含)没有实质性区别。

然而,如果你实例化一个(非内联)函数模板,情况就有些不同了。如果你想提供某个普通的非内联函数的多个定义,那么就会违反ODR原则。例如,假设你编译和链接下面这两个文件:

  1. // ==== a.cpp:
  2. int main()
  3. {
  4. }
  5. // ==== b.cpp:
  6. int main()
  7. {
  8. }

C++编译器会对每个模块进行单独编译,此时没有什么问题,因为在每个编译单元内它们都合法。然而,如果你想把它们链接在一起,你的链接器很大可能会抗议:不允许出现重复的定义。

反之,我们考虑模板的场合:

  1. // ==== t.hpp:
  2. // common header (inclusion model)
  3. template<typename T>
  4. class S {
  5. public:
  6. void f();
  7. };
  8. template<typename T>
  9. void S::f() // member definition
  10. {
  11. }
  12. void helper(S<int>*);
  13. // ==== a.cpp:
  14. #include "t.hpp"
  15. void helper(S<int>* s)
  16. {
  17. s->f(); // #1 first point of instantiation of S::f
  18. }
  19. // ==== b.cpp:
  20. #include "t.hpp"
  21. int main()
  22. {
  23. S<int> s;
  24. helper(&s);
  25. s.f(); // #2 second point of instantiation of S::f
  26. }

如果链接器处理类模板实例化的成员函数与处理普通函数或成员函数的方式一致,那么编译器就需要保证它只会生成一份代码,要么在#1处生成,要么在#2处生成(两处POI的位置)。为了达成这一目标,编译器需要在每个编译单元中都携带其他的编译单元的信息,而这对于C++编译器来说在引入模板之前是从未有过的要求。接下来,我们讨论C ++实现中已使用的三大类解决方案。

请注意,模板实例化产生的所有的链接实体都有同样的问题:实例化的函数模板和成员函数模板,以及实例化的静态数据成员和实例化的变量模板。

14.4.1 贪婪实例化

首个实现贪婪实例化的C++编译器是由Borland公司开发的。现如今,这一技术已经在各种C++系统上被广泛使用了。

贪婪实例化假定链接器会意识到特定的实体(尤其是可链接的模板实例化体),它们大多在多个目标文件和库中重复出现。编译器会以一种特殊的方式标记这些实体。当链接器发现了多个实例时,它会保留单个并丢弃掉所有其他的。这就是贪婪实例化的处理方法。

理论上,贪婪实例化有一些严重的缺陷:

  • 编译器会在生成和优化N个实例化体时浪费时间,它只需要保持一个即可。
  • 链接器一般不会检查两个实例化体是否相同,因为一个模板特化的多个实例生成的代码可能有些合法的无关紧要的差别。这些微小的差异不应该导致链接器失败(编译器在实例化的时刻可能因状态不同而产生细微的差异)。然而,这常常会导致链接器无法注意到更多的充足的差异,比如某一个实例化是使用严格的浮点数运算法则,而另一个确是松弛的、高性能的浮点数运算法则。
  • 所有的目标文件加起来可能大小远远超过理应生成的替换体总和,这是因为相同的代码会被复制多次。

实践当中,这些缺陷看起来并没有引起重大问题。也许这是因为贪婪实例化在一个重要方面与替代品相比非常有利:源对象之间的原始依赖被保留了下来。尤其是,每个编译单元只产生一个目标文件,并且在相应的源文件(它包含了实例化后的定义)中,每个目标文件都包含针对所有可链接定义的代码,而且这些代码是已经经过编译的代码。另一个重要的收益在于所有的函数模板实例都是内联的候选对象而无需求助于昂贵的“链接时”优化机制(实际上,函数模板实例常常是短小的函数而从内联中得益)。其他的实例化机制则需要专门对函数模板进行内联(判定)处理,以确保它们是否可以内联展开。然而,贪婪实例化甚至允许非内联函数模板也进行内联展开。

最后,可能值得注意:允许可链接实体重复定义的链接器机制,通常还被用于处理重复的“内联函数溢出”(spilled inlined functions)和“虚函数分发表“(virtual function dispatch tables)。如果这一机制不可用,则替代方法通常是使用内部链接来发出这些项,然后以生成更大的代码为代价。内联函数需要有单一地址,而这一要求使得以合乎标准的方式去实现某种替代方法变得困难。

14.4.2 查询实例化

上世纪90年代中期,一家名为Sun Microsystems的公司发行了它们的C++编译器的新版实现(版本4.0),这一版本以一种新的有趣的方式解决了实例化问题,我们称之为查询实例化(queried instantiation)。查询实例化在概念上明显更简单、优雅,而且按照时间顺序,它也是我们在此回顾的实例化方案中最新的一种。在这一方案中,程序中参与的所有编译单元会汇集一个共享的数据库。该数据库可以追溯哪些特化体被实例化了,并且可以找到其所依赖的源代码。生成的特化体本身会把信息存储在数据库中。当可链接实体遇到一个POI时,会进入下面的处理流程:

  1. 无可用的特化体:这种情况会进行实例化,特化的结果会保存到数据库中。
  2. 特化体虽可用但超期了,因为自它生成以来源代码发生了变化。这种情况同样会进行实例化,新的特化结果会覆盖数据库中旧的那一个。
  3. 数据库中有最新可用的特化体。这种情况什么都不用做。尽管从概念上来讲非常简单,这一设计还是有着一些实现的挑战的:
    • 正确的维护数据库内容相对于源代码的依赖性并不是一件简单的事情。尽管将第三种情况误认为是第二种也不会导致错误,但是这样做会增加编译器完成的工作量(并因此增加了总体构建时间)。
    • 并行编译多个源文件是非常常见的。因此,工业级实现需要支持适当数量的并发控制。

尽管存在这些挑战,这一方案还是可以非常有效地实施。此外,没有明显的病态场景会导致该方案的伸缩性变差。例如,与贪婪实例化相比,贪婪实例化可能会导致许多浪费的工作。

不幸的是,数据库的使用可能对程序员来说也存在一些问题。这些问题中的大部分的源头都在于传统的继承自C编译器的编译模型将不再可用:单一的编译单元不再会产生单独的目标文件。例如,假设你希望链接最终的程序,链接操作不仅需要各个编译单元所关联的目标文件的内容,还需要数据库中存储的目标文件。类似地,如果你创建了一个二进制库文件,你需要确保创建该库的工具(一般是一个链接器或是一个打包器)也能意识到数据库中的内容。这些问题大都可以通过不将实例化体存储在数据库,而是在目标文件中第一个引起实例化的地方放置目标代码的方式来缓解。

库文件还面临另一个挑战。许多生成的特化体可以打包在同一个库中。当库被另一个项目所添加时,该项目的数据库也需要意识到该库的数据库中已经可用的那些实例化体。否则,一旦项目创建了存在于库中的某个实例化的POI,就会遇到重复的实例化。一种可以解决该问题的策略是效仿贪婪实例化的链接器技术;让链接器意识到生成的特化体,并把它们淘汰掉(尽管如此,它的发生频率要比贪婪实例化要少得多)。源文件、目标文件以及库文件的各种复杂组织形式通常也会带来一些很难解决的问题,诸如找不到实例化体,因为包含该实例化体的目标代码可能并没有被链接入最终的可执行程序中。

总而言之,查询实例化最终没能在市场中存活,甚至Sun的编译器目前也在使用贪婪实例化。

14.4.3 迭代实例化

第一个支持C++模板的编译器是Cfront 3.0,它是语言之父Bjarne Stroustrup开发C++语言时所写的编译器的后浪。Cfront的一个不灵活约束是:它必须有良好的跨平台移植性。这就意味着:(1)在多个目标平台中,它都是使用C语言作为共同的目标表示;(2)它使用了局部的目标链接器,即链接器无法察觉到模板的存在。实际上,Cfront以普通C函数的形式来分发模板实例化体,因此它也必须避免重复的实例化体。虽然Cfront的源模型与标准的包含式模型有所差异,但它的实例化策略可以通过一些修改而适应包含式模型。于是,它也值得被公认为是迭代实例化的第一个实现。

关于Cfront的迭代,可以按以下内容描述:

  1. 编译源代码,此时不要实例化任何需要链接的特化体
  2. 使用预链接器(prelinker)链接目标文件
  3. 预链接器调用链接器,解析错误信息,判断是否缺少某个实例化体。如果缺少的话,预链接器会调用编译器,来编译包含所需模板定义的源代码,然后(可选地)生成这个缺少的实例化体。
  4. 重复第3步,直到不再生成新的定义。

第3步中,这种迭代的要求基于这样的事实:在实例化一个可链接实体过程中,可能会要求”另一个仍未实例化“的实体进行实例化;最后,所有的迭代都已经完成,链接器才会成功创建一个完整的程序。

原始Cfront方案的缺陷相当严重:

  • 要完成一次完整的链接,所需要的时间不仅包含预链接器的时间开销,还包括每次询问重新编译和重新链接的时间。某些使用Cfront系统的用户会抱怨说:”链接时间往往需要几天,而同样的工作,如果采用前面介绍的其他候选解决方案,则一个小时就足够了。”
  • 诊断信息(错误和警告)延迟到了链接期,当链接大型程序时,这个缺点才是最严重的。譬如,对于模板定义中的某个书写错误,开发者可能需要等待漫长的几个小时才能检查出来。
  • 需要进行特别地处理,来记住包含特殊定义的源代码的位置,Cfront(在一些情况下)会使用一个中心库,他不得不克服查询实例化方案中所面临的中心数据库的一些挑战。另外,原始Cfront实现并不支持并行编译。

迭代原则后来被Edison Design Group(EDG)和惠普的C++编译器实现精炼了一番,消除了原始Cfront实现的一些缺陷。实际上,这些实现体表现相当好,尽管从头开始构建比其他的替代方案更耗时,但后续的构建时间却相当有可比性。不过,相对而言,很少有C ++编译器使用迭代实例化。

14.5 显式实例化

为模板特化显式地生成POI是可行的,我们把获得这种特化的结构称为显式实例化引导(explicit instantiation directive)。从语法上来说,它由关键字template和紧随其后的待实例化的特化声明组成。例如:

  1. template<typename T>
  2. void f(T)
  3. {
  4. }
  5. // four valid explicit instantiations:
  6. template void f<int>(int);
  7. template void f<>(float);
  8. template void f(long);
  9. template void f(char);

注意上面的每一个实例化引导都是有效的。模板实参可以被推导(见第15章)。

类模板的成员也可以通过这种方式显式实例化:

  1. template<typename T>
  2. class S {
  3. public:
  4. void f() {
  5. }
  6. };
  7. template void S<int>::f();
  8. template class S<void>;

此外,通过显式实例化该类模板特化本身,其所有的成员也都可以被显式实例化。因为这些显式实例化引导确保了具有名称的模板特化的定义被创造了出来,上面的显式实例化引导更准确地来说,指的是显式实例化定义(explicit instantiation definitions)。显式实例化的模板特化不应该被显示地特化,反之亦然,这是因为这样会产生两个不同的定义(也就违反了ODR原则)。

14.5.1 手动实例化

许多C++程序员都观察到了自动模板实例化在编译期有一个值得一提的负面影响。这对于实现了贪婪实例化的编译器来说确实如此(P256节14.4.1),因为相同的模板特化可以在许多不同的编译单元中实例化。

有一种缩短构建时间的技术:在单一位置手动实例化程序所需的那些模板特化,并禁止其在所有其他编译单元中实例化。一种确保这种禁止行为的可行方法是:除非在编译单元中,有显示地实例化,否则不提供其模板定义。例如:

  1. // ===== translation unit 1:
  2. template<typename T> void f(); // no definition: prevents instantiation
  3. // in this translation unit
  4. void g()
  5. {
  6. f<int>();
  7. }
  8. // ===== translation unit 2:
  9. template<typename T> void f()
  10. {
  11. // implementation
  12. }
  13. template void f<int>(); // manual instantiation
  14. void g();
  15. int main()
  16. {
  17. g();
  18. }

在第一个编译单元中,编译器看不到函数模板f的定义,因此它不会实例化f<int>。第二个编译单元借由显式实例化定义提供了f<int>的定义,如果没有该定义的话,程序链接会失败。

手动实例化有一个明显的缺陷:我们必须小心地追溯哪些实体会被实例化。对于大型项目来说,这很快就变成一个负担,因此我们并不推荐使用。我们已经在好几个项目中使用了这种做法,这些项目最初低估了这种负担,然而随着代码的成熟,我们对一开始的决定感到遗憾。

然而,手动实例化也有一些优势,因为实例化转变成了程序的需求。显然,它避免了大型头文件的开销,也避免了在多个编译单元中重复实例化具有相同参数的相同模板的开销。此外,模板定义的源代码可以隐藏起来,只不过客户端程序此后就再也无法创建额外的实例化体了。

手动实例化的一些负担可以通过将模板定义摆放至第三方源文件中来减轻,按照惯例,以.tpp作为扩展。对我们的函数f来说,就会变成:

  1. // ===== f.hpp
  2. template<typename T> void f(); // no definition: prevents instantiation
  3. // ===== t.hpp
  4. #include "f.hpp"
  5. template<typename T> void f() // definition
  6. {
  7. // implementation
  8. }
  9. // ===== f.cpp
  10. #include "f.tpp"
  11. template void f<int>(); // manual instantiation

这种结构提供了某种灵活性。你可以仅仅引用f.hpp来获取f的声明,此时不会有自动实例化。显式实例化体可以被手动地添加到f.cpp中(如果需要的话)。或者,如果手动实例化太费劲,你也可以包含f.tpp来启用自动实例化。

14.5.2 显式实例化声明

消除冗余自动实例化的一种更有针对性的方法是使用显式实例化声明,该声明是一个以关键字extern为前缀的显式实例化引导。显式实例化声明通常会抑制命名模板特化的自动实例化,因为它声明命名模板特化将在程序中的某个位置定义(通过显式实例化定义)。之所以说是通常来说,是因为有一些特例存在:

  • 内联函数仍可以实例化,以展开成内联样式(但不会生成单独的目标代码)。
  • 具有autodecltype(auto)推导的类型和具有返回类型推导的函数仍然可以被实例化,以判断它们的类型。
  • 其值可用作常量表达式的变量仍可以被实例化,以便对其值进行求值。
  • 引用类型的变量仍然可以被实例化,因此可以解析它们引用的实体。
  • 类模板和别名模板仍然可以被实例化,以检查其返回类型。

通过使用显式实例化声明,我们可以在头文件(t.hpp)中为f提供模板定义,然后通过使用特化来抑制自动实例化,如下:

  1. // ===== t.hpp
  2. template<typename T> void f()
  3. {
  4. }
  5. extern template void f<int>(); // declared but not defined
  6. extern template void f<float>(); // declared but not defined
  7. // ===== t.cpp
  8. template void f<int>(); // definition
  9. template void f<float)(); // definition

每个显式实例化声明必须与一个相应的显式实例化定义配对,该定义必须遵循该显式实例化声明。忽略定义将导致链接器错误。

当在许多不同的编译单元中使用某些特定的特化时,可以使用显式实例化声明来改善编译或链接时间。与手动实例化(每次需要新的特化时,都需要手动更新显式实例化定义的列表)不同的是,在任何时候都可以引入显式实例化声明作为优化项。然而,与手动实例化相比,编译器的受益可能没有那么显著,这是因为可能会发生一些冗余的自动实例化,以及模板定义作为头文件的一部分,仍然会被解析。

14.6 编译期if语句

正如在P134节8.5中介绍的,C++17增加了一种新的语句——编译器if,它使得在书写模板时非常有用。该语句同时也对实例化过程造成了一种新的影响。

下面的例子展示了这一基本操作:

  1. template<typename T> bool f(T p) {
  2. if constexpr (sizeof(T) <= sizeof(long long)) {
  3. return p > 0;
  4. } else {
  5. return p.compare(0) > 0;
  6. }
  7. }
  8. bool g(int n) {
  9. return f(n); // OK
  10. }

编译器if是一个if语句,其中关键字if后面紧跟着一个constexpr关键字(如本例所示)。跟随在后面的是一个小括号条件语句,该语句必须是一个常量布尔值(也可以是隐式转换为bool值的情形)。编译器因而就会知道该选择哪一个分支,而另一个未被选中的分支则被称作“丢弃的分支”。特别有趣的是,在模板(包括通用lambda)的实例化过程中,被丢弃的分支不会进行实例化。对于这一示例的代码合法性来说,这一机制是很有必要的:我们用T=int来实例化f(T),会使得else分支被丢弃。如果该分支未被丢弃的话,它也会进行实例化并且表达式p.compare(0)会引起一个错误(当p是简单的int型时,这段代码是不合法的)。

在C++17的constexpr if语句出现之前,规避这类错误需要进行显式模板特化或重载(见第16章)才能起到相似的效果。

上面的例子,在C++14中,可能会按如下方法来实现:

  1. template<bool b> struct Dispatch { // only to be instantiated when b is false
  2. static bool f(T p) { // (due to next specialization for true)
  3. return p.compare(0) > 0;
  4. }
  5. };
  6. template<> struct Dispatch<true> {
  7. static bool f(T p) {
  8. return p > 0;
  9. }
  10. };
  11. template<typename T> bool f(T p) {
  12. return Dispatch<sizeof(T) <= sizeof(long long)>::f(p);
  13. }
  14. bool g(int n) {
  15. return f(n); // OK
  16. }

显然,constexpr if这一替代方案的引入使得我们的意图简明扼要、一目了然。然而,它需要(编译器)的实现去提炼实例化单元:此前的函数定义始终都是作为整体来实例化,现在它必须禁用其中的一部分。

另一个非常好用的constexpr if场景是处理函数模板包的递归表达式。为了泛化这一例子,我们引用P134节8.5中出现的例子:

  1. template<typename Head, typename... Remainder>
  2. void f(Head&& h, Remainder&&... r) {
  3. doSomething(std::forward<Head>(h));
  4. if constexpr (sizeof...(r) != 0) {
  5. // handle the remainder recursively (perfectly forwarding the arguments):
  6. f(std::forward<Remainder>(r)...);
  7. }
  8. }

如果没有constexpr if语句,我们需要对f()模板实现一个额外的重载来保证递归的终结。

甚至在非模板上下文中,constexpr if语句有时也能起到独特的效果:

  1. void h();
  2. void g() {
  3. if constexpr (sizeof(int) == 1) {
  4. h();
  5. }
  6. }

大部分平台,g()中的条件都是false,对h()的调用也就会被丢弃掉。因此,h()甚至完全不需要被定义(当然,除非它在别的地方被使用到了)。如果在此示例中省略了关键字constexpr,则在链接期会触发“缺少h()的定义”的错误。

14.7 标准库中的显式实例化

C++标准库包含了若干数量的模板,这些模板通常仅仅与一些基础类型一起使用。例如,和std::basic_string类模板一起最常用的类型就是charwchar_t,尽管使用其他的类字符类型也可以完成实例化。因此,对标准库的实现来说,通常会为这些常见的情景引入显式实例化声明。例如:

  1. namespace std {
  2. template<typename charT, typename traits = char_traits<charT>,
  3. typename Allocator = allocator<charT>>
  4. class basic_string {
  5. ...
  6. };
  7. extern template class basic_string<char>;
  8. extern template class basic_string<wchar_t>;
  9. }

实现了标准库的源文件会包含相应的显式实例化定义,因此这些常见的实现体可以在所有使用标准库的编译单元中共享。类似的显示实例化还出现在各种“流(stream)”类类型中,诸如basic_iostream, basic_istream等等。

14.8 后记

本章处理了两个有一定联系但并不相同的议题:C++模板编译模型和各种C++模板实例化机制。

编译模型在程序编译的各个阶段确定模板的含义。特别是,它确定了实例化模板中各种结构的含义。名称查找是编译模型的重要组成部分。

标准C++仅仅支持单个编译模型,即包含式模型。然而,在1998和2003标准中还支持一个叫分离式模型的模板编译模型。分离式模型允许模板定义可以在其实例化体所在的不同的编译单元中书写。这种导出的模板(exprted templates)仅曾经由Edison Design Group(EDG)实现过一次。EDG在实现中付出的努力确定了以下两点:(1)实现C++模板的分离式模型相当的困难,而且完成这一任务的耗时远超预期;(2)分离式模型的假定好处(例如优化编译时间)由于模型的复杂性而无法实现。随着2011标准的制定工作逐渐结束,很明显其他实现者将不会支持这一功能,于是,C++标准委员会通过投票决定从该语言中删除了导出的模板。如果你对分离式模型的细节感兴趣,可以看看本书的第一版,里面描述了导出模板的行为。

实例化机制是一种外部机制,用以允许C++实现者去正确地创建实例化体。这些机制可能会受限于链接器和其他软件构建工具的需求。尽管每一种实例化机制都各不相同(每一种都各有利弊),但它们对日常C++编程来说并没有显著的影响。

就在C++11标准完成之后,Walter Bright, Herb Sutter和Andrei Alexandrescu提议了一种“static if”特性,它与“constexpr if”不同(文献N3329)。这是一种更为宽泛的特性,它甚至可以出现在函数定义外部(Walter Bright是D编程语言的设计者和实现者,它有一个相似的特性)。例如:

  1. template<unsigned long N>
  2. struct Fact {
  3. static if (N <= 1) {
  4. constexpr unsigned long value = 1;
  5. } else {
  6. constexpr unsigned long value = N*Fact<N-1>::value;
  7. }
  8. };

请注意看在上例中,类作用域声明是如何条件化的。然而,这种强大的能力是有争议的,有些委员会成员担心它可能会被滥用,而另一些委员会成员则不喜欢该提案的某些技术方面(诸如花括号未引入作用域,以及完全不分析丢弃的分支)。

几年之后,Ville Voutilainen提出了一个提案(P0128),该提案的大部分内容在日后摇身一变促成了constexpr if语句的诞生。它经历了一些次要的设计迭代(涉及临时关键字static_if和constexpr_if),并且在Jens Maurer的帮助下,Ville最终将该提议编入了该语言中(通过文献P0292r2)。