|
2. Template Basics
TMP 的学习必须建立在对 C++ 模板坚实理解的基础之上,本章我们介绍一些模板的基础概念。
2.1 Template Declaration and Definition
在 C++ 中,我们一共可以声明(declare) 5种不同的模板,分别是:类模板(class template)、函数模板(function template)、变量模板(variable template)、别名模板(alias template)、和概念(concept)。
- // declarations
- template <typename T> struct class_tmpl;
- template <typename T> void function_tmpl(T);
- template <typename T> T variable_tmpl; // since c++14
- template <typename T> using alias_tmpl = T; // since c++11
- template <typename T> concept no_constraint = true; // since c++20
复制代码
其中,前三种模板都可以拥有定义(definition),而后两种模板不需要提供定义,因为它们不产生运行时的实体 (对于 concept,目前的术语还不清晰,在 cpp_reference 里能看到 difinition of a concept 的字样,但根据一般的理解,concept 语句应该属于声明语句)。
- // definitions
- template <typename T> struct class_tmpl {};
- template <typename T> void function_tmpl(T) {}
- template <typename T> T variable_tmpl = T(3.14);
复制代码
可以看到,对于类模板、函数模板、和变量模板,它们的声明和定义与普通的类、函数和变量一致,区别仅是在开头多了一个 template 关键字以及一对尖括号<...>。template 关键字表明这是一个模板,尖括号中声明了模板的参数。参数通常是类型,因为模板的发明就是为了实现泛型编程(Generic Programming),因此在模板中,类型(type)被参数化(Parameterized)了。这也是为什么我们可以在 TMP 中对类型做计算。另外,由于模板的发明是为了泛型编程而非元编程,因此 TMP 代码的语法是很反人类的,既难读又难写。
要注意,在提到模板的时候,我们应该使用类模板(class template)、函数模板(function template)这样的词汇,而非模板类(template class)、模板函数(template function)。后者通常指代由模板生成的具体的类和函数,就像牛奶巧克力和巧克力牛奶是不同的东西一样。类模板不是类,函数模板也不是函数,它们是模板,是对类和函数的描述。这也是 TMP 的思想基石:模板是对代码的描述。
此外,还需要注意的一点是,在 C++14 中我们有了泛型 lambda (generic lambda),它的定义看起来很像模板,但它不是模板,只是它的函数调用运算符(operator())是一个函数模板:
- // NOTE: Generic lambda (since c++14) is NOT a template,
- // its operator() is a member function template.
- auto glambda = []<typename T>(T a, auto&& b) { return a < b; };
复制代码
2.2 Template Parameters and Arguments
在模板中,我们可以声明三种类型的 形参(Parameters),分别是:非类型模板形参(Non-type Template Parameters)、类型模板形参(Type Template Parameters)和模板模板形参(Template Template Parameters):
- // There are 3 kinds of template parameters:
- template <int n> struct NontypeTemplateParameter {};
- template <typename T> struct TypeTemplateParameter {};
- template <template <typename T> typename Tmpl> struct TemplateTemplateParameter {};
复制代码
其中,非类型的形参接受一个确定类型的常量作为实参(Arguments),例如在上面的例子中,模板 NontypeTemplateParameter 接受一个 int 类型的常量。更一般地,非类型模板形参必须是 结构化类型(structural type) 的,主要包括:
- 整型,如 int, char, long
- enum 类型
- 指针和引用类型
- 浮点数类型和字面量类型(C++20后)
要注意的是,非类型模板实参必须是常量,因为模板是在编译期被展开的,在这个阶段只有常量,没有变量。
- template <float &f>
- void foo() { std::cout << f << std::endl; }
- template <int i>
- void bar() { std::cout << i << std::endl; }
- int main() {
- static float f1 = 0.1f;
- float f2 = 0.2f;
- foo<f1>(); // output: 0.1
- foo<f2>(); // error: no matching function for call to 'foo', invalid explicitly-specified argument.
- int i1 = 1;
- int const i2 = 2;
- bar<i1>(); // error: no matching function for call to 'bar',
- // the value of 'i' is not usable in a constant expression.
- bar<i2>(); // output: 2
- }
复制代码
对于类型模板形参(Type Template Parameters),我们使用 typename 关键字声明它是一个类型。对于模板模板形参(Template Template Parameters),和类模板的声明类似,也是在类型的前面加上 template <...>。模板模板形参只接受类模板或类的别名模板作为实参,并且实参模板的形参列表必须要与形参模板的形参列表匹配。
- template <template <typename T> typename Tmpl>
- struct S {};
- template <typename T> void foo() {}
- template <typename T> struct Bar1 {};
- template <typename T, typename U> struct Bar2 {};
- S<foo>(); // error: template argument for template template parameter
- // must be a class template or type alias template
- S<Bar1>(); // ok
- S<Bar2>(); // error: template template argument has different template
- // parameters than its corresponding template template parameter
复制代码
关键字 typename 可以替换为 class,它们是等效的,唯一的不同就是字面语义。我更倾向于使用 typename,这更接近类型模板形参和模板模板形参的语义。而 class 在某些时候会产生让人困惑的字面语义,想象一下一个名叫 template <class T> 的模板可以接受 int 作为实参,而 int 不是 class。
一个模板可以声明多个形参,更一般地,可以声明一个变长的形参列表,称为 "template parameter pack",这个变长形参列表可以接受 0 个或多个非类型常量、类型、或模板作为模板实参。变长形参列表必须出现在所有模板形参的最后。
- // template with two parameters
- template <typename T, typename U> struct TemplateWithTwoParameters {};
- // variadic template, "Args" is called template parameter pack
- template <int... Args> struct VariadicTemplate1 {};
- template <int, typename... Args> struct VariadicTemplate2 {};
- template <template <typename T> typename... Args> struct VariadicTemplate3 {};
- VariadicTemplate1<1, 2, 3>();
- VariadicTemplate2<1, int>();
- VariadicTemplate3<>();
复制代码
模板可以声明默认实参,与函数的默认实参类似。只有 主模板(Primary Template) 才可以声明默认实参,模板特化(Template Specialization)不可以。后面会介绍什么是主模板和模板特化。
- // default template argument
- template <typename T = int> struct TemplateWithDefaultArguments {};
复制代码
2.3 Template Instantiation
模板的实例化(Instantiation)是指由泛型的模板定义生成具体的类型、函数、和变量的过程。模板在实例化时,模板形参被替换(Substitute)为实参,从而生成具体的实例。模板的实例化分为两种:按需(或隐式)实例化(on-demand (or implicit) instantiation) 和 显示实例化(explicit instantiation),其中隐式的实例化是我们平时最常用的实例化方式。隐式实例化,或者说按需实例化,是当我们要用这个模板生成实体的时候,要创建具体对象的时候,才做的实例化。而显式实例化是告诉编译器,你帮我去实例化一个模板,但我现在还不用它创建对象,将来再用。要注意,隐式实例化和显式实例化并不是根据是否隐式传参而区分的。
自 C++11 后,新标准还支持了显式的实例化声明(explicit instantiation declaration),我们会在后面的 Advanced Topics - Explicit Instantiation Declarations 中介绍这一特性。
- // t.hpp
- template <typename T> void foo(T t) { std::cout << t << std::endl; }
- // t.cpp
- // on-demand (implicit) instantiation
- #include "t.hpp"
- foo<int>(1);
- foo(2);
- std::function<void(int)> func = &foo<int>;
- // explicit instantiation
- #include "t.hpp"
- template void foo<int>(int);
- template void foo<>(int);
- template void foo(int);
复制代码
当我们在代码中使用了一个模板,触发了一个实例化过程时,编译器就会用模板的实参(Arguments)去替换(Substitute)模板的形参(Parameters),生成对应的代码。同时,编译器会根据一定规则选择一个位置,将生成的代码插入到这个位置中,这个位置被称为 POI(point of instantiation)。由于要做替换才能生成具体的代码,因此 C++ 要求模板的定义对它的 POI 一定要是可见的。换句话说,在同一个翻译单元(Translation Unit)中,编译器一定要能看到模板的定义,才能对其进行替换,完成实例化。因此最常见的做法是,我们会将模板定义在头文件中,然后再源文件中 #include 头文件来获取该模板的定义。这就是模板编程中的包含模型(Inclusion Model)。
现在的一些 C++ 库,整个项目中就只有头文件,没有源文件,库的逻辑全部由模板实现在头文件中。而且这种做法似乎越来越流行,在 GitHub 和 boost 中能看到很多很多。我想原因一个是 C++ 缺乏一个官方的 package manager,这样发布的软件包更易使用(include就行了);另一个就是模板实例化的这种要求,使得包含模型成为泛型编程中组织代码最容易的方式。
但包含模型也有自身的问题。在一个翻译单元(Translation Unit)中,同一个模板实例只会被实例化一次。也就是对同一个模板传入相同的实参,编译器会先检查是否已实例化过,如果是则使用之前实例化的结果。但在不同的翻译单元中,相同实参的模板会被实例化多次,从而产生多个相同的类型、函数和变量。这带来两个问题:
- 链接时的重定义问题,如果不加以处理,这些相同的实体会被链接器认为是重定义的符号,这违反了ODR(One Definition Rule)。对这个问题的主流解决方案是为模板实例化生成的实体添加特殊标记,链接器在链接时对有标记的符号做特殊处理。例如在 GNU 体系下,模板实例化生成的符号都被标记为弱符号(Weak Symbol)。
- 编译时长的问题,同一个模板传入相同实参在不同的编译单元下被实例化了多次,这是不必要的,浪费了编译的时间。
我们在 Advanced Topics - Explicit Instantiation Declarations 中讨论除了包含模型外的另一个方案,它规避了上面这些问题,但也带来了其他的成本。
2.4 Template Arguments Deduction
为了实例化一个模板,编译器需要知道所有的模板实参,但不是每个实参都要显式地指定。有时,编译器可以根据函数调用的实参来推断模板的实参,这一过程被称为模板实参推导(Template Arguments Deduction)。对每一个函数实参,编译器都尝试去推导对应的模板实参,如果所有的模板实参都能被推导出来,且推导结果不产生冲突,那么模板实参推导成功。举个例子:
- template <typename T>
- void foo(T, T) {}
- foo(1, 1); // #1, deduced T = int, equivalent to foo<int>
- foo(1, 1.0); // #2, deduction failed.
- // with 1st arg, deduced T = int
- // with 2nd arg, deduced T = double
复制代码
在 #1 中,两次推导的结果一直,均为 int,推导成功;在 #2 中,两次推导的结果不一致,推导失败。C++17 引入了类模板实参推导(Class Template Arguments Deduction),可以通过类模板的构造函数来推导模板实参:
- template <typename T>
- struct S { S(T, int) {} };
- S s(1, 2); // deduced T = int, equivalent to S<int>
复制代码
2.5 Template Specialization
模板的特化(Template Specialization)允许我们替换一部分或全部的形参,并定义一个对应改替换的模板实现。其中,替换全部形参的特化称为全特化(Full Specialization),替换部分形参的特化称为偏特化(Partial Specialization),非特化的原始模板称为主模板(Primary Template)。只有类模板和变量模板可以进行偏特化,函数模板只能全特化。在实例化模板的时候,编译器会从所有的特化版本中选择最匹配的那个实现来做替换(Substitution),如果没有特化匹配,那么就会选择主模板进行替换操作。
- // function template
- template <typename T, typename U> void foo(T, U) {} // primary template
- template <> void foo(int, float) {} // full specialization
- // class template
- template <typename T, typename U> struct S {}; // #1, primary template
- template <typename T> struct S<int, T> {}; // #2, partial specialization
- template <> struct S<int, float> {}; // #3, full specialization
- S<int, int>(); // choose #2
- S<int, float>(); // choose #3
- S<float, int>(); // choose #1
复制代码
我们可以只声明一个特化,然后在其他的地方定义它:
- template <> void foo<float, int>;
- template <typename T> struct S<float, T>;
复制代码
这里你可能已经注意到了,特化声明与显式实例化(explicit instantiation)的语法非常相似,注意不要混淆了。
- // Don't mix the syntax of "full specialization declaration" up with "explict instantiation"
- template void foo<int, int>; // this is an explict instantiation
- template <> void foo<int, int>; // this is a full specialization declaration
复制代码
除了语法外,二者的含义也很容易混淆。理论上来说,模板实例化的结果就是一个该模板的全特化,因为它就是一个用确定实参替换了全部形参的模板实现。所以有的书和文档中也会用特化(Specialization)这个词来指代模板实例化之后生成的那个实体(类型、函数、或变量)。为了区分,我们称这种为隐式特化(Implicit Specialization),称我们在本节中讨论的特化机制为显式特化(Explicit Specialization)。很多的书和文档中是不做这种区分的,所以可能会产生误解,需要读者结合上下文去理解 Specialization 指的是什么。本文中我们避免使用特化一词来指代实例化的结果,而改用“实例”或“实体”,特化一词专指模板的特化机制。
2.6 Function Template Overloading
函数模板虽然不能偏特化,但是可以重载(Overloading),并且可以与普通的函数一起重载。在 C++ 中,所有的函数和函数模板,只要它们拥有不同的签名(Signature),就可以在程序中共存。一个函数(模板)的签名包含下面的部分:
- 函数(模板)的非限定名(Unqualified Name)
- 这个名字的域(Scope)
- 成员函数(模板)的 CV 限定符
- 成员函数(模板)的 引用限定符
- 函数(模板)的形参列表类型,如果是模板,则取决于实例化前的形参列表类型
- 函数模板的返回值类型
- 函数模板的模板形参列表
所以,根据这个规则,下列的所有函数和函数模板foo,都被认为是重载,而非重定义:
- template <typename T> void foo(T) {} // #1
- template <typename T> void foo(T*) {} // #2
- template <typename T> int foo(T) {} // #3
- template <typename T, typename U> void foo(T) {} // #4
- template <typename T, typename U> void foo(T, U) {} // #5
- template <typename T, typename U> void foo(U, T) {} // #6
- void foo(int) {} // #7
- void foo(float, int) {} // #8
复制代码
由于模板的参数可以推导,不用显式指定,所以函数模板和普通函数可以一起重载。但是要注意,虽然上述 #5 和 #6 两个模板不是重定义,但在调用的时候仍有可能触发一个歧义错误,编译器有时无法决定两个函数模板哪个的重载优先级更高,我们将在 4.4 Partial Ordering Rule 里进一步解释模板重载的偏序规则:
- foo(1); // call #7
- foo(new int(1)); // call #2
- foo(1.0f, 1); // call #8
- foo<int, float>(1, 1.0f); // call #5
- foo(1, 1.0f); // error: ambiguous
复制代码
2.7 Review of binary
我们在了解了模板的基础知识后,就可以解释 binary 的工作原理了:
- // primary template
- template <int N> // non-type parameter N
- struct binary {
- // an template instantiation inside the template itself, which contructs a recursion
- static constexpr int value = binary<N / 10>::value << 1 | N % 10;
- };
- // full specialization when N == 0
- template <> struct binary<0> {
- static constexpr int value = 0;
- };
- std::cout << binary<101>::value << std::endl; // instantiation
复制代码
在上面的代码中,我们定义了一个主模板 binary,它接受一个非类型形参,更具体地说是一个 int 类型的形参。同时,我们定义了一个 binary 的特化,它是模板 binary 在 N == 0 时的一个全特化,在实例化 binary<0> 时,编译器会为我们匹配这一个特化。在主模板中,我们定义了一个静态常量 value,并将它初始化为 binary<N / 10>::value << 1 | N % 10,由于静态常量会在编译期求值,所以编译器在实例化 binary<101> 时会尝试求值(Evaluate)这个表达式。这个表达式中包含了一个对 binary 的另一个实例化,所以编译器会递归地实例化 binary 这个模板。递归的过程如下:
N | instantiation | matches | recursion | value | 101 | binary<101> | primary | binary<10>::value << 1 丨 1 | 5 | 10 | binary<10> | primary | binary<1>::value << 1 丨 0 | 2 | 1 | binary<1> | primary | binary<0>::value << 1 丨 1 | 1 | 0 | binary<1> | specialization | 0 | 0 |
直到 N == 0 时,模板的实例化匹配到特化版本,在这个特化中,也定义了一个静态常量 value = 0,递归到这里终止,被求值的表达式层层返回,最终计算出 binary<101>::value = 5。
通过对这个例子的分析,我们已经得到了 TMP 的一些线索:
- 模板像函数一样被使用,模板的形参就像是函数的形参,模板的静态成员作为函数的返回值。
- 通过实例化(Instantiation)来“调用”模板。
- 通过特化(Specialization)和重载(Overloading)来实现分支选择。
- 通过递归来实现循环逻辑。
- 所有过程发生在编译期间,由编译器驱动。
所以,我们已经有了函数,有了if/else,有了循环,这不就可以编程了嘛!这种编程的逻辑工作在编译期,处理的数据是常量和类型,没有运行时,也没有变量。这,就是 TMP。
|
|