Table of Contents generated with DocToc
模板实例化是从泛型模板定义生成类型、函数和变量的过程。实例化一词来自对象创建过程,当然在模板语境下都是指模板实例化。C++的实例化机制是C++模板中最底层但同时也比较复杂的机制。复杂度的来源之一是因为生成的模板不会被限制到源码的一个地方。本章还会介绍大多数编译器的实例化策略,他们是语义上等价的,理解编译器实例化策略的基本原则是有用的。
C++采用一种隐式(或者叫自动)实例化机制,即遇到了使用模板特化(template specialization,这里指模板实参替换形参后生成的实体)的代码时才进行实例化。
- 这是一种按需实例化(On-demand instantiation)。
- 这种实例化隐含要求了编译器需要在实例化时看到模板的完整定义而不仅仅是声明。
- 实例化会发生在需要见到定义的地方,比如函数模板调用时会发生实例化,变量模板使用时同样会发生实例化,类模板构造和使用对象、取数据成员或者调用成员函数时会发生实例化(仅仅用到指针或者引用则不需要能看到定义,和普通不完全类型一个道理)。
模板实例化时应该仅实例化其需要用到的部分,所有的实例化都会延迟到真正需要用到它时。
部分与全部实例化:
- 某些时候不需要实例化整个模板,比如模板函数并且用在
decltype
中作为不求值表达式时,只需要实例化声明即可得到返回值类型。称为部分实例化(partial instantiation)。 - 另外如果只用到指针引用则类模板也不需要完全实例化(full instantiation)。
- 变量模板用于不求值表达式也不会完全实例化。
- 但是对于别名模板来说则没有两种不一样的实例化方式。
- 当然在模板中我们谈到实例化,通常都是指完全实例化,部分实例化也是可能的,只是比较少见。
实例化组件:
- 当一个类模板隐式(全部)实例化时,它的每个成员的声明都会被实例化,但是定义则不会。其中有一些例外:
- 如果类模板包含一个匿名联合,那么联合定义也会实例化。
- 虚成员函数的定义可能会被实例化,也可能不会。一些实现会,因为虚函数机制要求虚函数在链接时存在,即使没有调用。
- 实例化模板时,函数的默认实参会被分开考虑,只有在函数模板调用用到了默认实参时才会将其实例化。如果函数调用时传入显式实参覆盖了默认参数,那么默认参数就不会被实例化。
- 同样道理,异常说明和默认成员初始化器(default member initializer,我还是没搞懂这是什么东西?)也会直到用到时才实例化。
- 标准允许编译器对某些语法错误但是没有用到的代码不报错。
- 当实例化一个类模板时,从实践上来说,需要提供虚函数定义。
实例化的过程看起来就是用模板实参去替换模板形参,然后得到对应的类型、函数或者变量。看起来很直接,但实际上其中还是有很多细节的。
两阶段查找:
- 独立名称在解析模板时查找,而非独立名称在模板实例化时查找,这称之为两阶段查找(Two-phase Lookup)。
- 第一阶段,解析模板时,使用普通查找或者ADL查找独立名称。未修饰的非独立名称(通常是调用了非独立参数的函数)会进行普通查找。但是查找结果不会被完全考虑直到第二次查找进行后才会正式做出判断。
- 第二阶段,当在实例化点(point of instantiation,POI)进行实例化时,用模板实参替换模板形参后,会查找相应的非独立修饰名称。第一次查找中使用普通查找的非独立名称会使用ADL再查找一次。
实例化点:
- 实例化点(POI)就是模板实例化后要插入到的位置。
- C++将实例化点定义为命名空间作用域中使用到函数模板特化的后面的最近点。
- 例子1:
class MyInt
{
public:
MyInt(int i);
};
MyInt operator-(const MyInt&);
bool operator>(const MyInt&, const MyInt&);
using Int = MyInt;
template<typename T>
void f(T i)
{
if (i > 0)
{
g(-i);
}
}
void g(Int)
{
f<Int>(42);
}
// POI of f<Int>
- 这个例子中,解析到函数模板
f
时,g
还没有定义,是一个未限定非独立名称,会进行普通查找,没有找到。在实例化时会进行第二次查找,注意这次查找仅仅会进行ADL,通过ADL能够找到,这里编译能够通过(很多函数没有定义,链接会有问题)。 - 例子2:
template<typename T>
void f1(T x)
{
g1(x);
}
void g1(int)
{
}
int main(int argc, char const *argv[])
{
f1(7);
return 0;
}
// POI of f1<int>
- 这个例子中,解析
f1
时第一查找g1
没有找到,但是第二次查找因为参数是int
,ADL同样找不到g1
,所以编译是通不过的。gcc12.1.0报错信息是:P252.POI2.cpp:4:7: error: 'g1' was not declared in this scope, and no declarations were found by argument-dependent lookup at the point of instantiation [-fpermissive]
。 - 变量模板实例化点的处理和函数模板类似。
- 对于类模板来说,情况有所不同,类模板实例化的POI是命名空间作用域中使用到该类模板特化的前面的最近点。
template<typename T>
struct S
{
T m;
};
// POI of S<int>
auto h()
{
return sizeof(S<int>);
}
- 在一个编译单元中可能会用到多次某个模板特化,只有第一次用到时对应的实例化点才会被认为真正的实例化点,并在此处生成模板实例。
- 在现实世界中,也有某些实现在整个编译单元编译完成后才生成模板实例。
包含模型:
- C++通常来说使用包含模型来组织源码,在每个实例化点,模板定义都应该是可见的,通常来说模板定义都是直接写在头文件中以确保可见。
- C++还提供了另一种实例化机制,其使用显式实例化声明(explicit instantiation declaration)与显式实例化定义(explicit instantiation definition),相比隐式实例化,需要自行选择POI,并且同一个模板实例会跨编译单元。后续会详细讨论。
首先回顾一下,链接器会对多个编译单元中的多份模板实例化代码(变量模板、函数模板、成员函数模板、静态成员变量模板,类定义并不生成代码)进行合并,最终的程序只会留下一份。为了做到这一点,C++编译器需要在编译单元中携带这个信息。下面是编译器实现者常用的解决这个问题的几种方法:
贪婪实例化:
- 多个编译单元中生成多个相同的实例,然后将其以一种特殊方式标记,链接器就会选择其中一个丢弃其他的定义。
- 缺点:
- 编译器需要浪费时间多次生成和优化同一份模板实例化代码。
- 多份代码中的同一个模板实例可能存在些微甚至显著的合法差异,链接器通常来说不检查这种差异,这种差异也不会让链接器链接失败,这些差异是由实例化时编译器的状态有差异导致的(比如一个编译为调试版附带调试信息,一个不附带,或者两份代码优化层级不同)。这些差异可能造成最终程序出现预期外的问题(通常不会是正确性问题)。
- 对象文件膨胀因为其中包含了重复的代码。
- 从实践中来看,这些缺点通常不会造成大的问题。
- 同一个链接机制通常用于处理具有多个定义的不能内联但却声明为
inline
的函数,这样的函数还是会生成定义,链接时会和模板实例做类似处理。
询问实例化:
- 1990年代中期,Sun公司公布了一个C++编译器,其中包含了一个处理实例化问题的全新并且很有趣的解决方案。称之为查询实例化。
- 这是一个简单且优雅的解决方案,使用一个数据库维护已经实例化的模板的信息,这个数据库跟踪哪个特化已经被实例化然后他依赖哪些代码。编译到一个链接实体的实例化点时,有几种情况:
- 数据库总没有特化可用,那么发生实例化,并将其加入数据库。
- 特化可用但是已经过期,也就是源码发生了变化,那么重新实例化并加入数据库,更新最新时间。
- 找到一个最新的特化,什么事情都不做。
- 虽然看起来很简单,但是实际上还是有不少困难需要解决:
- 维护数据库内容与源码的状态的对应关系并不简单。编译器要做很多额外工作。
- 并行编译在工业界是很常见的,数据库可能需要面对大量的并发查询与修改。
- 尽管有许多挑战,但这种方案提供了一种非常高效的实现方式。
- 这个实现也不再是传统的继承自C的编译模型,一个源文件生成一个独立的对象文件。而是有一个额外的数据库,任何操作对象文件的工具都需要知道这个数据库的存在。
- 如何生成库也同样是一个问题,多个库链接中如何共享来自这个数据库的是信息也是一个问题。一个自然的想法是库之间依然采用贪婪实例化。
- 总之,这个方案并不像看上去那么trivial,目前现实中没有C++编译器这么做,Sun都改变了他们的实现方案。
迭代实例化:
- 最先支持C++模板的编译器是CFront3.0,是C++之父Bjarne Stroustrup开发的用来发展C++的编译器的直接延续。
- 这个编译器实现时有两个约束:
- 使用C作为目标语言以保证跨平台移植性。
- 使用本地C链接器,这个链接器将不会知道模板的存在。
- C++的模板实例化最终被编译为普通C函数,为了解决模板实例化重定义的问题,CFront将编译过程分做了几步,这种解决实例化问题的方案被称为迭代实例化:
- 编译源码时不实例化任何需要被链接的特化。
- 对对象文件使用预链接器(prelinker)进行链接。
- 预链接器调用链接器并解析器错误信息,然后根据其中的模板实例化缺失信息对包含模板定义的源文件进行重编译,只重新生成缺失的实例化代码。
- 重复第三步直到所有特化都被生成。
- 缺点:
- 大量重编译和重链接的时间。
- 模板错误信息被延迟到了链接时。
- 需要记住哪个源文件哪段源码编译生成哪个模板实例,这需要一个中央数据库来做,可能会面临询问实例化同样的问题。
- 目前来说,基本没有C++编译器再这么做,Cfront项目也很早就停止了(1993年Cfront4.0尝试支持异常失败后)。
前面都是在说隐式实例化机制,但是为模板特化显式创建一个实例化点是可行的,这种C++结构叫做显式实例化指示(explicit instantiation directive)。
- 语法:
template
关键字后跟要实例化的特化。 - 例子:
template<typename T>
void f(T)
{
}
// explicit instantiations
template void f<int>(int);
template void f<>(float);
template void f(long);
template void f(char);
- 显式实例化的模板实参可以经过推导而得,
template
关键字后不跟形参列表。 - 可以显式实例化类模板整体,也可以只显式实例化类模板的成员。
- 上面的显式实例化语句更准确地说是显式实例化定义(explicit instantiation definition)。
- 一个被显式实例化的特化不应该被显式特化,反之亦然。因为很容易想象这两个定义可能是不同的,一个来自主模板一个来自特化,这违反了一个定义原则(ODR)。
手动实例化:
- 因为现在的大多数C++编译实现模板实例化的方式都是贪婪实例化,这造成了大量的编译时间消耗。
- 显式实例化算是一种语法层面解决这个问题的可选手段。
- 具体方法是:手动在一个唯一的地方对模板进行显式实例化,同时抑制其他所有地方的隐式实例化。一种抑制隐式实例化的手段是将模板定义和显式实例化放在同一个单独的编译单元,而头文件中只进行模板声明。
- 优点:减少编译时间,可以向外部隐藏模板定义。
- 缺点:必须仔细跟踪需要显式实例化哪些模板,对于大型程序,这需要很大的代价(所以通常不推荐)。隐藏定义的同时外部程序也不能进行实例化了。
显式实例化声明:
- 另一种消除隐式实例化重复生成代价的手段是显式实例化声明(explicit instantiation declaration)。
- 语法:在显式实例化定义前加上
extern
。 - 显式实例化声明一般来说(generally)会抑制隐式实例化。显式实例化声明同时需要在程序中某处进行显式实例化定义。
- 存在一些例外:
inline
总是被实例化以内联展开。- 用
auto decltype(auto)
推导类型的的变量模板和函数模板依然被实例化以决定他们的类型。 - 变量模板的值被用在常量表达式中时需要实例化以求值。
- 引用类型变量需要实例化才能决定他们要引用的对象。
- 类模板和别名模板可以被实例化用来检查结果类型。
- 通常做法是在头文件中进行模板定义和显式模板实例化声明,在某个源文件中进行显式实例化定义。
- 因为我们可以选择只对用得比较多的模板进行显式实例化(以一定程度改善编译时间),其他的依然使用隐式实例化。所以可以规避模板声明定义分离,需要手动实例化所有模板带来的问题。
- 如果写了显式模板实例化声明但是没有定义,可能会链接错误。
- 语法:
if constexpr
。 - 条件必须是编译期常量表达式。未选择的分支称为丢弃的分支(discarded branch)。
- 丢弃的分支不会被实例化。丢弃的分支不需要对当前模板实参合法。
- 通过编译器if可以将以前必须通过重载然后使用某种手段分发(标签分发,特化分发)的逻辑统一到一个函数中。
- 使用编译期if还可以简化可变参数模板中参数包的递归处理。
template<typename... Args>
void f(Args... args)
{
if constexpr (sizeof...(args) == 0) // end of recursion
{
}
else // recursion logic
{
}
}
标准库中包含了很多模板,其中一部分模板会经常使用,所以标准库也显式实例化了一些类型,并进行了显式实例化声明,以避免重复实例化。比如std:string std::basic_istream
等。当然标准库中肯定会有对应的显式实例化定义。
本章主要介绍模板编译模型和实例化机制:
- 编译模型决定了在编译的各个阶段模板的意义。实际上来说,它决定了实例化时不同的结构是什么含义。名称查找是编译模型中的重要组成成分。
- 实例化机制是C++编译器为了正确进行实例化的额外机制,这个机制可能对链接器和构建工具有要求和约束。不同工具链会有不同的机制,这可能会一定程度影响在该工具链上的C++编程风格。