On-Demand实例化(隐式实例化/自动实例化)
- 编译器遇到模板特化时会用所给的实参替换对应的模板参数,从而产生特化
- 如果声明一个指向某类型的指针或引用,不需要看到类模板定义,但如果要访问特化的成员或想知道模板特化的大小,就要先看到模板定义
template<typename T> class C; // (1) 前置声明
C<int>* p = 0; // (2) 正确:不需要C<int>的定义
template<typename T>
class C {
public:
void f(); // (3) 成员声明
}; // (4) 类模板定义结束
void g (C<int>& c) // (5) 只使用类模板声明
{
c.f(); // (6) 使用了类模板定义,需要C::f()的定义
}
template<typename T>
void C<T>::f() // (6)需要的定义
{
}
- 下面是另一个需要实例化的例子,因为编译器需要知道C<void>大小,虽然任何类型的实参X替换T后C<X>都是空类,但编译器不会检查它是否为空
C<void>* p = new C<void>;
- 函数重载时,如果候选函数的参数是类类型,则该类必须可见
template<typename T>
class C {
public:
C(int);
};
void candidate(C<double>);
void candidate(int) {}
int main()
{
candidate(42); // 前两个函数声明都可以被调用
}
- 虽然candidate(42)会采用第二个重载声明,但编译器仍然会实例化第一个声明来检查实例化后是否为有效的候选函数(因为类C的构造函数可以将42隐式转换为C<double>类型的右值)
延迟实例化
- 实例化只会对需要的部分进行。隐式实例化模板时也实例化了每个成员声明,但没有实例化定义。例外的两种情况是匿名union和虚函数,如果类模板包含一个匿名union则union定义的成员也被实例化了,而虚函数是否实例化依赖于具体实现
- 实例化模板时,只有当函数用上了默认实参时才会实例化该实参
template <typename T>
class Safe {
};
template <int N>
class Danger {
int arr[N]; // N<=0则失败
// 但编译器会假设最好的结果,即N是正整数
};
template <typename T, int N>
class Tricky {
public:
void noBodyHere(Safe<T> = 3); // 不一定能用整数对模板Safe初始化
// 但编译器会假设对Safe<T>的泛型定义不会用到这个默认实参
void inclass() {
Danger<N> noBoomYet; // OK until inclass() is used with N<=0
}
void error() { // 会引发错误
Danger<-1> boom;
}
// Danger<-1>会被要求给出类Danger<-1>的完整定义
// 于是会定义一个-1大小的数组
// 即使error没被使用不被实例化,也仍会引发错误
void unsafe(T (*p)[N]); // 在N没被模板参数替换前该声明不会出错
T operator->();
// virtual Safe<T> suspect();
struct Nested {
Danger<N> pfew; // OK until Nested is used with N<=0
};
union { // 匿名union
int align;
Safe<T> anonymous;
};
};
int main()
{
Tricky<int, 0> ok; // 默认构造函数和析构函数肯定会被调用
// 因此它们的定义必须存在,虚函数的定义也必须存在
// 因此suspect()只有声明没有定义则会出现链接错误
// 对于inclass()和结构Nested的定义,会要求一个Danger<0>类型
// 但因为没有用到这两个成员的定义,因此不会产生定义而引发错误
// 所有成员声明都会被生成,因此N为0时unsafe(T (*p)[N])会产生错误
// 同理,如果匿名union中的不是Safe<T>而是Danger<T>也会产生错误
// 对于operator->通常应该返回指针类型,或用于这个操作符的class类型
// 但在模板中规则会更灵活,虽然这里T为int,返回int类型,但不会出错
}
C++的实例化模型
两阶段查找
- 解析模板时编译器不能解析依赖型名称,所以编译器会在POI(point of instantiation,实例化点)再次查找依赖型名称,而非依赖型名称在首次看到模板时就会进行查找。因此就有了两阶段查找:第一阶段发生在模板解析阶段,第二阶段在模板实例化阶段
- 第一阶段使用普通查找规则(适当情况也会用ADL)会查找非依赖型名称和非受限的依赖型名称(如函数调用中的函数名称,该名称具有依赖型实参所以是依赖型名称),但后者的查找不完整,在实例化时还会再次 查找
- 第二阶段发生的地点称为POI,这个阶段会查找依赖型受限名称,并对非受限的依赖型名称再次进行ADL查找
POI
- 编译器会在模板中的某个位置访问模板实体的声明或定义,实例化相应的模板定义时就会产生POI,POI是代码中的一个点,在该点会插入替换后的模板实例
class MyInt {
public:
MyInt(int i);
};
MyInt operator - (MyInt const&);
bool operator > (MyInt const&, MyInt const&);
using Int = MyInt;
template<typename T>
void f(T i)
{
if (i>0) {
g(-i);
}
}
// (1)
void g(Int)
{
// (2)
f<Int>(42); // 调用点
// (3)
}
// (4)
- 编译器看到调用f<Int>(42)时,要用MyInt替换T实例化模板f,即生成一个POI。(2)(3)临近调用点但不能作为POI,因为C++不允许在这里插入::f<Int>(Int)的定义。(1)(4)的区别是g(Int)在(4)可见而在(1)不可见,如果(1)作为POI则g(-i)不能被解析,因此POI的位置是(4)
- 这里使用类型MyInt而不是int的原因是,POI执行第二次查找(g(-i))使用了ADL,int没有关联命名空间,不会发生ADL查找,也就找不到函数g
- 对于类特化,POI位置是不一样的
template<typename T>
class S {
public:
T m;
};
// (5)
unsigned long h()
{
// (6)
return (unsigned long)sizeof(S<int>);
// (7)
}
// (8)
- (6)(7)不能作为POI,因为模板(S<int>的定义)不能出现在函数作用域内部,采用前面非类型实例的规则的话POI在(8),但这样sizeof(S<int>)就是无效的,因为要等编译到(8)后才知道S<int>大小,因此对于指向产生自模板的类实例的引用,POI只能定义在包含这个实例的定义或声明前的最近作用域,即(5)
- 实例化模板时可能需要一些附带的实例化
template<typename T>
class S {
public:
using I = int;
};
// (1)
template<typename T>
void f()
{
S<char>::I var1 = 41;
typename S<T>::I var2 = 42;
}
int main()
{
f<double>();
}
// (2): (2a), (2b)
- f<double>的POI在(2),但函数模板f()还引用了S<T>,而S<T>是依赖型的所以不能像S<char>一样确定它的POI。如果在(2)实例化f<double>,同时需要实例化S<double>的定义,对于非类型实体,二次POI(S<double>的POI)的位置和主POI(f<double>)位置相同,对于类型实体,二次POI位于主POI的紧前处。因此f<double>的POI位于(2b),S<double>的POI位于(2a)
- 一个编译单元通常会包含一个实例的多个POI,对类模板实例,每个编译单元只有首个POI会被保留,其他POI会被忽略(其实它们不会被认为是POI),对于非类型实例所有POI都会保留,ODR原则要求保留的任何一个POI所出现的同种实例化体都必须等价,但编译器没有这个约束,因此编译器允许选择一个非类型的POI执行实例化而不用在意其他POI是否会产生不同的实例化体
包含模型与分离模型
- 分离模型在新标准中已经废弃,这是一种理论上的优秀做法,但编译器几乎都不支持,因为实现难度大
- 遇到POI时相应模板的定义必须可见,这表示在同个编译单元中类模板的定义必须在其POI前就可见。对非类型POI通常会把定义放在头文件,使用时再把头文件包含到这个编译单元,这种方式就是包含模型
- 分离模型是用export声明非类型模板,在另一个编译单元定义
// Translation unit 1:
#include <iostream>
export template<typename T>
T const& max (T const&, T const&);
int main()
{
std::cout << max(7, 42) << std::endl; // (1)
}
// Translation unit 2:
export template<typename T>
T const& max (T const& a, T const& b)
{
return a < b ? b : a; // (2)
}
- 编译第一个文件时,根据(1)的声明,需要用int替换T来生成一个POI,接着编译器必须确定可以实例化第二个文件中max模板的定义来满足这个POI
跨翻译单元查找
// Translation unit 1:
#include <iostream>
export template<typename T>
T const& max(T const&, T const&);
namespace N {
class I {
public:
I(int i): v(i) {}
int v;
};
bool operator < (I const& a, I const& b) {
return a.v < b.v;
}
}
int main()
{
std::cout << max(N::I(7), N::I(42)).v << std::endl; // (3)
}
- (3)生成的POI会再次要求编译单元2中的max模板定义,而定义中使用的<
在编译单元1中重载了,在编译单元2中不可见,为了解决这个问题,实例化过程需要引用两处不同的声明上下文,第一处是模板定义的上下文,第二处是类型I声明的上下文,为了在两种上下文中进行查找,模板中的名称应该分两阶段查找
- 第一阶段对非依赖型名称使用普通查找和ADL,只是把查找结果保存起来但不会进行重载解析
- 第二阶段发生在POI,使用普通查找和ADL来查找依赖型受限名称,使用ADL查找依赖型非受限名称,然后把ADL的查找和第一阶段普通查找的结果结合,组成一个候选函数集合,再用重载解析选出最佳匹配
例子
template<typename T>
void f1(T x)
{
g1(x); // (1)
}
void g1(int)
{
}
int main()
{
f1(7); // ERROR: g1 not found!
} // (2) POI for f1<int>(int)
- 调用f1(7)在(2)产生f1<int>(int)的一个POI。第一次看到f1定义时编译器注意到非受限名称g1是依赖型名称,使用普通查找规则查找g1,而在(1)看不到g1,于是第一阶段找不到g1。在f1的POI处,再次在关联命名空间和关联类中查找g1,但g1的实参类型为int,无关联命名空间和关联类,第二阶段也找不到g1。因此看似在f1的POI处可以用普通查找规则找到g1,但实际并不能
- 下面的例子说明了分离模型如何导致跨编译单元的重载二义性问题
// File common.hpp:
export template<typename T>
void f(T);
class A {
};
class B {
};
class X {
public:
operator A() { return A(); }
operator B() { return B(); }
};
// File a.cpp:
#include "common.hpp"
void g(A)
{
}
int main()
{
f<X>(X());
}
// File b.cpp:
#include "common.hpp"
void g(B)
{
}
export template<typename T>
void f(T x)
{
g(x);
}
-
a.cpp
中的main函数调用了f<X>(X()),解析为b.cpp
中定义的导出模板,因此该模板中的调用g(x)会基于X类型实参进行实例化。g()执行两次查找,第一次使用普通查找规则在b.cpp
中查找,第二次在a.cpp
中使用ADL查找,第一次找到g(B),第二次找到g(A),这两个结果都可行,因此调用是二义性的
显式实例化
- 为模板特化显式生成POI的构造称为显式实例化指示符,它由template关键字和后面的特化声明组成
template<typename T>
void f(T)
{
}
// 4个有效的显式实例化体
template void f<int>(int);
template void f<>(float);
template void f(long);
template void f(char);
template<typename T>
class S {
public:
void f() {
}
};
template void S<int>::f();
template class S<void>;
- 显式实例化类模板特化本身,同时也显式实例化了类模板特化的所有成员
- 同一个程序中,每个特定的模板特化最多只能存在一处显式实例化,如果一个模板特化显式实例化了,就不能再显式特化
// File toast.hpp:
template<typename T>
void toast(T const& x)
{
...
}
// Client code:
#include "toast.hpp"
template void toast(float);
- 如果库编写者决定显式特化toast<float>,上面的client code就是错的
手动实例化(Manual Instantiation)
- 显式实例化能提高编译效率,隐式实例化对build时间有严重的负面影响,一种提高build效率的一种方法是,在一个位置手动实例化特定的模板特化,同时禁止在其他的编译单元中的实例化,唯一保证这种禁止的可移植方法是,只在这个显式实例化的翻译单元提供模板定义
// Translation unit 1:
template<typename T>
void f(); // 没有定义,禁止在这个编译单元实例化
void g()
{
f<int>();
}
// Translation unit 2:
template<typename T>
void f()
{
}
template void f<int>(); // 手动实例化
void g();
int main()
{
g();
}
- 手动实例化有一个明显的缺点:必须仔细跟踪哪些实体要实例化。对于大型项目来说,这很快成为一个巨大负担,因此不推荐它。我们已经研究了一些最初低估了这一负担的项目,我们在代码成熟的时候后悔了
- 将模板定义放到一个第三方的源文件(通常扩展名为.tpp)中可以减轻手动实例化负担,比如对函数f可以分解如下
// f.hpp:
template<typename T>
void f(); // no definition: prevents instantiation
// t.hpp:
#include "f.hpp"
template<typename T>
void f()
{
}
// f.cpp:
#include "f.tpp"
template void f<int>(); // 手动实例化
- 这种结果提供了一些灵活性,通过只包含f.hpp来获取不带自动实例化的f的声明,如果需要可以手动添加显式实例化到f.cpp。如果觉得手动实例化太繁重,也可以包含f.tpp来允许自动实例化
显式实例化声明(Explicit Instantiation Declarations)
- 一个更有针对性的消除自动实例化冗余的方法是使用一个显式实例化声明,它的直接前缀是关键字extern
- 显式实例化声明通常会抑制命名模板特化的自动实例化,因为它声明命名模板特化将会定义在某处,但这有很多例外
- 内联函数为了展开内联,仍能被实例化
- auto或decltype(auto)类型变量和函数返回类型,仍可以被实例化来确定类型
- 值为常量表达式的变量仍能被实例化,以估计它们的值
- 引用类型变量仍能被实例化,这样引用的实体才能被解析
- 类模板和别名模板,为了检查生成类型仍能被实例化
- 使用显式实例化声明,可以在头文件(t.hpp)中提供模板定义,以此抑制自动实例化的特化
// t.hpp:
template<typename T> void f()
{
}
extern template void f<int>(); // declared but not defined
extern template void f<float>(); // declared but not defined
// t.cpp:
template void f<int>(); // definition
template void f<float>(); // definition
- 每个显式实例化声明必须与定义配对,省略定义将引发链接器错误
- 当特化被用在许多不同的编译单元中,显式实例化声明能用来提高编译效率。不同于手动实例化每需要一个新的特化都要手动更新显式实例化定义列表,显式实例化声明能在任何情况下作为一个优化引入。然而编译时间优化上就不如手动实例化了,因为可能产生一些冗余的自动实例化,且模板定义也会被作为头文件的一部分解析
编译期if语句
- C++17添加了一种新的语句类型,编译期if语句,在编写模板时十分有用,它引入了一个实例化过程的新技巧
template<typename T> bool f(T p) {
if constexpr (sizeof(T) <= sizeof(long long)) {
return p > 0;
} else {
return p.compare(0) > 0;
}
}
bool g(int n) {
return f(n); // OK
}
- 编译期if是一个if后紧接constexpr关键词的if语句,括号中的条件必须有一个常量布尔值,编译器由此得知将选用的分支,另一个分支被称为discarded branch,对于上例,来说这是十分重要的,f(T)根据T=int实例化,另一个分支则被丢弃,如果没有丢弃,对表达式p.compare(0)来说实例化将产生错误
- C++17之前没有constexpr if语句,为了避免这样的错误,用显式模板特化或重载实现类似的功能,在C++14中可能实现如下
template<bool b>
struct Dispatch { // only to be instantiated when b is false
static bool f(T p) { // (due to next specialization for true)
return p.compare(0) > 0;
}
};
template<>
struct Dispatch<true> {
static bool f(T p) {
return p > 0;
}
};
template<typename T> bool f(T p) {
return Dispatch<sizeof(T) <= sizeof(long long)>::f(p);
}
bool g(int n) {
return f(n); // OK
}
- constexpr if另一个非常方便的使用是表示处理函数参数包所需要的递归
template<typename Head, typename... Remainder>
void f(Head&& h, Remainder&&... r) {
doSomething(std::forward<Head>(h));
if constexpr (sizeof...(r) != 0) {
// handle the remainder recursively (perfectly forwarding the arguments):
f(std::forward<Remainder>(r)...);
}
}
- 如果没有constexpr if语句,就需要一个额外的模板f()的重载来保证递归终止
- 在非模板语境中,constexpr if语句也有一些独特效果。下例中g()总会调用h()失败,因此h()就不必定义,如果省略了constexpr则会出现链接错误
void h();
void g() {
if constexpr (sizeof(int) == 1) {
h();
}
}
In the Standard Library
- 标准库包含了许多通常只使用一些基本类型的模板,如std::basic_string常用于char(因为std::basic_string是std::basic_string<char>的别名,std::string就是一个std::basic_string使用char的实例化)或wchar_t,因此标准库的实现通常会为这些常见情况引入显式实例化声明
namespace std {
template<typename charT, typename traits = char_traits<charT>,
typename Allocator = allocator<charT>>
class basic_string {
...
};
extern template class basic_string<char>;
extern template class basic_string<wchar_t>;
}
- 标准库的源文件实现将包含对应的显式实例化定义,这样常用的实现就能被所有标准库的用户共享,类似的显式实例化通常存在于各种各样的stream类,如basic_iostream,basic_istream等
网友评论