C++元编程教程00:模板基础回顾

Zhige Chen Lv1

C++模版基础回顾

前言:为什么我们需要模版?

在使用编程语言进行程序设计的时候,有这样一个概念会反复的出现,这就是抽象(abstraction)。以一个简单的返回两数最大值的函数max为例:

1
2
3
auto max(int lhs, int rhs) -> int {
return lhs > rhs ? lhs : rhs;
}

这个函数可以看作对一类实际的表达式2 > 1 ? 2 : 1进行了抽象,将1、2这样实际的数值抽象为了函数形参lhsrhs,从而将代码逻辑从具体的表达式值中解耦。抽象的强大之处在于它能极大地减少重复代码,如果一段代码逻辑在我们所写的代码中反复的出现,将其抽象为一个函数可以有效的减少我们的工作量。
在使用max函数时,我们可能会发现我们也需要对浮点数求最大值,这时候我们可能会想到通过重载函数的方式添加支持:

1
2
3
4
5
6
auto max(float lhs, float rhs) -> float {
return lhs > rhs ? lhs : rhs;
}
auto max(double lhs, double rhs) -> double {
return lhs > rhs ? lhs : rhs;
}

添加一两个重载的工作量尚可接受,但是如果我们还想支持更多的数据类型,甚至是不同的数据类型之间的混合比较的时候,那么需要编写的代码很快就会增长到无法接受的程度。为了避免代码重复,我们就需要继续使用抽象,类似于前文将逻辑与具体的表达式值解耦类似,我们进一步将逻辑与具体的类型解耦。为此,我们需要使用模板(template):

1
2
3
4
template <typename T>
auto max(T lhs, T rhs) -> T {
return lhs > rhs ? lhs : rhs;
}

与函数参数类似,我们通过让类型也成为可变的形参来完成抽象。每当需要在某个类型的值上使用max函数(严谨来说,现在应该叫做函数模板)时,我们除了需要传入lhsrhs之外,还需要传入它们的类型:

1
2
3
4
5
auto main() -> int {
int result1 = max<int>(1, 2);
double result2 = max<double>(1.3d, 4.2d);
char result3 = max<char>('a', 'c');
}

这个简单的max函数,精准地反映了泛型编程(Generic Programming)的核心思想:不为每种类型重复编写逻辑相同的代码,而是将类型本身参数化,让编译器在编译期自动生成不同类型的版本。这种思想,不仅直接催生了C++标准容器库的产生,更成为现代C++的基石之一。

函数模板

基本语法

函数模板(Function template)的一般定义方式为:

1
2
template <模板形参列表>
函数声明

以前文中的max函数模板为例

1
2
3
4
template <typename T>
auto max(T lhs, T rhs) -> T {
return lhs > rhs ? lhs : rhs;
}

可以看到,max具有一个模板形参typename Ttypename表示T是一个类型形参,所以当在调用max的时候,T只能是一个类型(如int)而不能是一个值(如42)。我们也可以使用class T来声明类型形参,这种写法完全等价于typename T,但笔者认为使用typename T更加清晰(使用class容易让人认为T只能说一个类,但实际上T能是任意类型)。

函数模板实例化

函数模板并不是实际的函数,单纯的定义一个函数模板并不会在编译后生成任何代码,为了使用函数模板,我们需要实例化(instantiate)模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename T>
auto max(T lhs, T rhs) -> T {
return lhs > rhs ? lhs : rhs;
}

auto main() -> int {
//显式地提供模板实参
int result1 = max<int>(114, 514);

//如果模板实参能从上下文推导出来,那么我们不必提供它
double result2 = max<>(4.2, 1.3);
//我们也可以直接省略尖括号对
double result3 = max(4.2, 1.3);

std::println("{} {} {}", result1, result2, result3);
}

Compiler Explorer链接
我们可以将实例化看作编译器自动地将函数定义中对模板形参的使用全部替换为模板实参的过程,上面的代码在编译时,编译器会实例化产生两个max函数,我们将这些生成的函数成为函数模板的实例

1
2
3
4
5
6
7
auto max(int lhs, int rhs) -> int {
return lhs > rhs ? lhs : rhs;
}

auto max(double lhs, double rhs) -> double {
return lhs > rhs ? lhs : rhs;
}

注意:

  • 同一个函数模板实例化出的不同实例间没有任何关系,如同两个相互独立的函数。
  • 如果一个函数模板已经对某些模板实参完成实例化,那么再次用这些模板实参来调用函数模板将不会再次实例化,而是使用之前已经产生的实例。(注意在上面的例子中,我们在T=double的情况下使用了两次max,而实际只实例化生成了一个函数)

函数模板的实例化可以分为显式实例化(Explicit Template Instantiation)和隐式实例化(Implicit Template Instantiation)两种,继续以max模板为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <typename T>
auto max(T lhs, T rhs) -> T {
return lhs > rhs ? lhs : rhs;
}

//显式实例化
template
auto max<int>(int, int) -> int;

//如果模板实参能够从上下文推导,则我们可以将其省略
template
auto max<>(short, short) -> short;
//当然也可以连尖括号一起省略
template
auto max(long, long) -> long;

auto main() -> int {
//如果我们使用了一个函数模板而该函数模板还没有被实例化过,则会发生隐式实例化:
//max<double>没有实例化,故此处发生隐式实例化
double result1 = max(4.2, 1.3);

//max<int>已经被实例化过了,则此处不会发生任何实例化
int result2 = max<int>(114, 514);
}

与函数形参类似,我们也可以为模板形参指定默认值:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T = int>
auto foo(T t) -> T {
return t;
}

auto main() -> int {
//此时默认T = int
int result1 = foo(1);
//注意,当推导出的实参与形参默认值不一致时,以推导出的实参为准
auto result2 = foo(4.2f);
static_assert(not std::same_as<decltype(result2), int>);
}

非类型模板形参

既然我们有只接受类型的类型模板形参,自然也有非类型模板形参(NTTP,Nontype Template Parameter),NTTP接受一个对象的值:

1
2
3
4
5
6
7
8
template <int N>
auto foo() -> int {
return N;
}

auto main() -> {
std::println("{}", foo<42>());
}

NTTP的类型可以不是具体类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <auto N>
auto print() -> void {
std::println("{}", N);
}

auto main() {
print<114514>();
print<4.2f>();
print<true>();

//这行代码没法通过编译,详细解释见下
print<std::string{"awa"}>();
}

为什么在上面的例子中,我们不能使用std::string来作为非类型模板实参呢?这是由于C++为了保证相同的模板实参列表所对应的模板实例是唯一的,换而言之就是需要判断模板实参之间的相等性。对于类型模板实参而言,其相等性判断非常容易实现(即判断两个类型是否为同一类型)。但是对于非类型模板实参,判断其相等性可能会较为复杂,同样以std::string为例,假设我们有两个std::string对象:

1
2
std::string str1{"awa"};
std::string str2{"awa"};

在忽略短字符串优化(SSO,Short String Optimization)和字符串常量池优化的情况下,这两个std::string对象的内部指针会指向两个不同的内存地址,但是两个字符串的存储内容是相同的,这就会造成相等性判断时的歧义:单从类型的定义上看,编译器没法知道你需要比较两个std::string的存储内容,只能从最简单的比较相应成员变量的方式来判断相等性,然而这会导致编译器认为这两个对象是不相等的,从而导致以下两行代码(假设它们能通过编译):

1
2
print<str1>();
print<str2>();

所指的实际上是不同的模板实例,这显然与我们的预期不一致。为了解决这个问题,C++规定用于NTTP的对象的类型必须是结构化类型(Structural Type),结构化类型的定义较为复杂,此处不展开讲解,详细定义参见CppReference上相关页面。
至于NTTP的进一步的引用场景,我们会在后续章节详细讲解。

简写函数模板

对于一些简单的函数模板,如上文例子中所用的max,C++20标准引入了一种更为简洁的写法,称为简写函数模板(Abbreviated Function Template):

1
2
3
auto max(auto lhs, auto rhs) {
return (lhs > rhs) ? lhs : rhs;
}

这种写法相当于:

1
2
3
4
template <typename T1, typename T2>
auto max(T1 lhs, T2 rhs) {
return (lhs > rhs) ? lhs : rhs;
}

简写函数模板这个特性非常直观且容易理解,故此处不再展开。

转发引用与引用折叠

在使用模板时,我们经常会想要保留原实参的表达式的值类别,通俗得讲就是左值和右值(关于什么是左值和右值不在本教程的范围之内,如果你不了解,推荐阅读HackingCpp上关于移动语义的这一章),这个时候就要用到转发引用(Forwarding Reference):

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
auto foo(T &&t) -> void {}

auto main() -> int {
//传递右值,T推导为int,函数形参为int &&t
foo(4);

//传递左值,T推导为int&,函数形参为int & &&t,引用折叠后变为int &t
int a = 42;
foo(a);
}

引用折叠规则(Reference Collapsing Rule)可以用一句话来总结:右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用

1
2
3
4
5
6
7
8
9
10
using LRef = int&;
using RRef = int&&;

auto main() -> int {
int n = 42;
LRef &ref1 = n; //LRef &折叠为int &
LRef &&ref2 = n;//LRef &&折叠为int &
RRef &ref3 = n; //RRef &折叠为int &
RRef &&ref4 = 1;//RRef &&折叠为int &&
}

如果我们想将转发引用传递给另一个函数,并让转发引用继续保持其值类别,就需要使用标准库中的转发函数std::forward

1
2
3
4
5
template <typename T>
auto foo(T &&t) -> void {
//将t完美转发给bar函数
bar(std::forward<T>(t));
}

模板形参包

在实际场景中,我们常常会遇到需要编写变长参数的函数的情景,我们可以通过C++模板来实现这种函数。以一个能够接收任意多参数并计算所有参数的和并返回的函数sum为例:

1
2
int sum_result = sum(1, 4, 6, 8);
std::println("{}" sum_result); //输出19

为了实现这样的函数,我们需要用到模板形参包(Template Parameter Pack):

1
2
3
4
template <typename ... ArgTypes>
auto sum(ArgTypes ... args) -> std::common_type_t<ArgTypes...> {
return (args + ...);
}

其中,typename ... ArgTypes声明了一个类型模板形参包(Type Template Parameter Pack,我不说你应该也能猜到还有非类型模板形参包),并通过这个模板形参包声明了一个函数形参包(Function Parameter Pack)ArgTypes ... argsArgTypes包含了所有函数形参的类型,而args则包含了函数所有的参数。这些包能够接受任意数量的实参,比如:

1
2
3
4
5
6
7
8
9
10
auto main() -> int {
int result1 = sum<int, int, int, int>(1, 3, 6, 9);
//模板形参包当然能够被推导,所以上面这行代码也可以写为
int result2 = sum(1, 3, 6, 9);

//不同的类型也可以
auto result3 = sum(1, 4.2f, 6ull);

std::println("{} {} {}", result1, result2, result3);
}

Compiler Explorer链接
以上几行代码会导致以下实例的产生:

1
2
3
4
5
template
auto sum(int, int, int, int) -> int;

template
auto sum(int, float, unsigned long long) -> float;

除了声明形参包之外,sum函数还用到了两个特性:包展开(Pack Expansion)和折叠表达式(Fold Expression),我们逐一讲解这些特性。

包展开

想要使用一个形参包里的内容,就需要用到包展开。所谓包展开,就是将包在代码中按照特定的模式展开成为一个列表,以sum函数的返回类型为例:

1
std::common_type_t<ArgTypes...>

其中,std::common_type_t是C++标准元编程库中的一个类型特征(Type Trait),它的作用就是找出它的模板实参的公共类型,也就是所有模板实参都能显式转换到的类型。这个类型特征的原理我们会在后面的章节讲到。而ArgTypes...就是我们所说的包展开。假设ArgTypes接受了三个类型:int, float, unsigned long long,那么包展开的结果为

1
std::common_type_t<int, float, unsigned long long>

我们将ArgTypes...中的ArgTypes称为包展开的模式(Pattern),包展开会将包中的每个元素按照我们所指定的模式展开成为一个逗号分隔的列表,比如:

1
2
3
4
5
6
7
8
9
10
template <typename ... ArgTypes>//模板形参包展开为bool, float, char const*
auto foo(ArgTypes ... args) -> void {//函数形参包展开为bool arg0, float arg1, char const *arg2
args...;//展开为arg0, arg1, arg2
ArgTypes...;//展开为bool, float, char const *
&args...;//展开为&arg0, &arg1, &arg2
}

auto main() -> int {
foo(true, 4.2f, "awa");
}

一般而言,常见的包展开方式有:

  • 递归展开:通过递归模板实例化逐个处理参数
  • 逗号运算符展开:结合逗号运算符生成初始化列表
  • 初始化列表展开:利用{}初始化语法展开
    通过这些包展开形式,我们就可以写出一些有用的函数。
    示例1:递归展开求和:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//递归模板实例化终止条件:只有一个实参
template <typename T>
auto accumulate(T value) -> T {
return value;
}

//递归展开参数包
template <typename T, typename ... Args>
auto accumulate(T first, Args ... args) -> T {
return first + accumulate(args...);
}

auto main() -> int {
auto result = accumulate(1, 4.3f, 5.6, 7l);
//递归实例化步骤:
//1. T=int, Args=<float, double, long>,accumulate(4.3f, 5.6d, 7l)
//2. T=float, Args=<double, long>,递归调用accumulate(5.6d, 7l)
//3. T=double, Args=<long>,递归调用accumulate(7l)
//4. T=long,到达递归终止条件

std::println("{}", result);
}

Compiler Explorer链接
示例2:使用逗号运算符展开输出所有实参

1
2
3
4
5
6
7
8
9
template <typename ... ArgTypes>
auto printAll(ArgTypes ... args) -> void {//展开为char const *arg0, char arg1, int arg2, double arg3
int dummy_array[] = {(std::println("{}", args), 0)...};
//展开为{(std::print("{}", arg0), 0), (std::print("{}", arg1), 0), (std::print("{}", arg2), 0), (std::print("{}", arg3), 0)}
}

auto main() -> int {
printAll("awa", 'o', 42, 114.514);
}

Compiler Explorer链接
对于这个例子,没有接触过形参包的人总是会写出这样的包展开:

1
2
3
4
template <typename ... ArgTypes>
auto printAll(ArgTypes ... args) -> void {
(std::println("{}", args)...;
}

但实际上这是不行的,这是因为包展开需要在一定的包展开场所中进行,包展开场所的完整列表相当长,故此处不做展开,如有需要可以查看CppReference相关页面。一般而言,常用的包展开场所有函数或模板的实参列表,及大括号/圆括号包围的初始化器。
示例3:多个包的同时展开
我们可以在一个包展开中同时展开多个包,当然,这些包的长度必须一致,我们可以使用sizeof...(pack)来获取包的长度(这个例子里使用了模板类,但是不是很深入,如果你看不懂可以先行阅读一下下一节[[#类模板]]的基础部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <typename ... Args1>
struct Zip {
template <typename ... Args2>
struct With {
//检查两个形参包的长度是否一致,若不一致则给出报错信息
static_assert(sizeof...(Args1) == sizeof...(Args2), "The two packs must have the same length");

using type = std::tuple<std::pair<Args1, Args2>...>;
//包展开结果为:
//std::tuple<std::pair<int, bool>, std::pair<float, double>, std::pair<char, std::string>>
};
};

auto main() -> int {
Zip<int, float, char>::With<bool, double, std::string>::type zipped{
{42, true},
{11.4f, 5.14},
{'a', "wa"}
};
}

示例4:嵌套包展开
若包展开内嵌于另一个包展开中,那么包展开按从里到外的顺序依次进行:

1
2
3
4
5
6
7
8
9
10
11
template <typename ... ArgTypes>
auto foo(ArgTypes ... args) -> void {
bar(meow(args...) + args...);
//首先将内层包展开meow(args...)展开为meow(1, 2, 3)
//随后将内层包展开的结果进行外层包展开
//最终结果为bar(meow(1, 2, 3) + 1, meow(1, 2, 3) + 2, meow(1, 2, 3) + 3)
}

auto main() -> int {
foo(1, 2, 3);
}

折叠表达式

通过刚才的例子,不难发现包展开的语法较为复杂,且在处理计算的时候(如上文中的sum函数)时较为复杂。C++17标准中所引入的折叠表达式,可以让我们通过简洁的方式来完成这些包展开。在函数模板

1
2
3
4
template <typename ... ArgTypes>
auto sum(ArgTypes ... args) -> std::common_type_t<ArgTypes...> {
return (args + ...);
}

中,(args + ...)就是一个折叠表达式。折叠表达式的语法为:

1
2
3
4
(形参包 运算符 ...)            //一元右折叠
(... 运算符 形参包) //一元左折叠
(形参包 运算符 ... 运算符 初始值)//二元右折叠
(初始值 运算符 ... 运算符 形参包)//二元左折叠

注意折叠表达式的括号是不可省略的。
一个折叠表达式是左折叠还是右折叠,取决于...出现在形参包的左侧还是右侧。左折叠和右折叠的区别是展开后的括号结合性:

  • 一元右折叠:(E op ...)展开为(E1 op (E2 op (... op (EN-1 op EN))))
  • 一元左折叠:(... op E)展开为((((E1 op E2) op ...) op EN-1) op EN)
  • 二元右折叠:(E op ... op init)展开为 (E1 op (E2 op (... op (EN-1 op (EN op init)))))
  • 二元左折叠:(init op ... op E)展开为(((((init op E1) op E2) op ...) op EN-1) op EN)
    sum函数模板为例:
1
2
3
4
5
auto main() -> int {
int result = sum(1, 1, 4, 5, 1, 4);
//折叠表达式展开为:
//(1 + (1 + (4 + (5 + (1 + 4)))))
}

我们也可以使用其他运算符,比如逻辑与&&

1
2
3
4
template <typename ... ArgTypes>
auto allTrue(ArgTypes ... args) -> bool {
return (args && ...);
}

注意,左折叠和右折叠会影响计算顺序,因此如果运算符不满足结合性,那么左折叠和右折叠的计算结果也是不同的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <typename ... ArgTypes>
auto rightFoldSubtract(ArgTypes ... args) -> std::common_type_t<ArgTypes...> {
return (args - ...);
}
template <typename ... ArgTypes>
auto leftFoldSubtract(ArgTypes ... args) -> std::common_type_t<ArgTypes...> {
return (... - args);
}

auto main() -> int {
int result1 = leftFoldSubtract(1, 2, 3);
int result2 = rightFoldSubtract(1, 2, 3);
//result1 = ((1 - 2) - 3) = -4
//result2 = (1 - (2 - 3)) = 2
std::println("{} {}", result1, result2);
}

Compiler Explorer链接
将折叠表达式与逗号运算符相结合,我们能够更加优雅的写出很多东西,比如printAll函数模板可以简化成:

1
2
3
4
template <typename ... ArgTypes>
auto printAll(ArgTypes ... args) -> void {
((std::println("{}", args), 0), ...);
}

包索引

在C++26之前,我们并没有一个直接访问包中第n个元素的方式,唯一使用形参包的方式就是将其展开。为了访问第n个元素,我们需要使用一个递归模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<size_t N, typename... Ts>
struct GetNthType;

//终止条件:当 N=0 时,捕获第一个类型
template<typename T, typename... Ts>
struct GetNthType<0, T, Ts...> {
using type = T;
};

//递归条件:递减索引 N,继续处理剩余参数包
template<size_t N, typename T, typename... Ts>
struct GetNthType<N, T, Ts...> {
using type = typename GetType<N - 1, Ts...>::type;
};

//使用示例
using ThirdType = GetNthType<2, int, double, char>::type; //char

为了实现GetNthType,此处使用了模板特化,模板特化会在[[#模板特化与部分特化]]一节中详细讲解。
这种方法不仅效率低下(尝试思考一下获取第n个元素需要实例化多少个模板),而且不够通用(显然GetNthType只能处理类型形参包)。为了解决这个问题,C++26标准引入了包索引(Pack Indexing),包索引的语法如下:

1
形参包...[索引]

这一特性大大简化了跟包相关的模板代码。将包索引和折叠表达式结合在一起,我们能够写出非常简洁优雅的代码:

1
2
3
4
5
6
7
8
9
template <std::size_t ... Indexes, typename ... ArgTypes>
auto sampleSum(ArgTypes ... args) -> std::common_type_t<ArgTypes...> {
return (args...[Indexes] + ...);//将Indexes对应的所有args中的元素相加
}

auto main() -> int {
auto result = sampleSum<1, 3, 5>(1, 9, 1, 9, 8, 1, 0);//result = 9 + 9 + 1
std::println("{}", result);//输出19
}

Compiler Explorer链接

类模板

与函数模板相类似,我们也可以声明类模板(Class Template),类模板的语法与函数模板非常类似:

1
2
template <模板形参列表>
类声明

类模板的一大使用案例是实现容器,标准库当中常用的std::vectorstd::map等容器都是类模板。在此我们通过实现一个简单的固定容量容器FixedArray来演示类模板的使用:

1
2
3
4
template <typename ElementType>
struct FixedArray {
ElementType storage[16];
};

与函数模板类似,类模板并不是实际存在的类,在不进行实例化的情况下编译器不会为类模板生成任何代码。要使用类模板,首先要做的就是实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <typename ElementType>
struct FixedArray {
ElementType storage[16];
};

auto main() -> int {
//实例化FixedArray<char>
FixedArray<char> array1{'t', 'e', 'm', 'p', 'l', 'a', 't', 'e'};

//C++17添加了类模板实参推导特性(Class Template Argument Deduction, CTAD)
//在可以推导出模板实参的上下文中我们可以省略模板实参
FixedArray array2{1.3f, 2.6f, 3.9f};

for (auto elem : array1.storage) {
std::print("{} ", elem);
}
std::println("");
for (auto elem : array2.storage) {
std::print("{} ", elem);
}
}

Compiler Explorer链接
类模板的显式实例化较函数模板更加复杂,我们可以一次性显式实例化整个类模板,也可以单独实例化类中的成员(包括成员函数、成员类、静态成员变量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <typename T>
class Foo {
static inline int demo_static_member = 42;

auto demo_function() -> void {}

class Bar {};
};

//实例化整个类
template
class Foo<int>;

//实例化单个成员函数,注意模板参数列表的摆放位置
template
auto Foo<char>::demo_function() -> void;

//实例化单个静态成员变量
template
int Foo<double>::demo_static_member;

//实例化单个成员类
template
class Foo<bool>::Bar;

类模板的隐式实例化与函数模板类似,当我们需要使用一个被完整定义的类模板且该类模板没有对应显式实例化的时候,隐式实例化就会发生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename ElementType>
struct FixedArray {
ElementType storage[16];
};

template
struct FixedArray<int>;

auto main() -> {
//已经有对应的显式实例化,故此处不发生隐式实例化
FixedArray<int> array1{};

//没有对应的显式实例化,故此处发生隐式实例化
FixedArray<double> array2{};

//使用一个类型的指针类型不要求该类型有完整定义,故此处不发生隐式实例化
FixedArray<char>* array_ptr = nullptr;
}

在上面这个例子中,最后一处使用FixedArray<char>*指针的例子尤其值得注意。由于使用一个类型的指针类型并不需要该类型有定义,故此行代码不会触发隐式实例化。

这是因为C++作为静态类型语言,需要在定义一个类型的变量之前知道该类型的大小,而指针类型的大小仅由平台决定,与指针所指向的类型大小无关,故不需要指针指向的类型有完整定义。

类模板实参推导

类模板实参推导(CTAD,Class Template Argument Deduction)可以通过初始化表达式的类型来自动推导模板实参,CTAD的推导规则相当复杂,此处不进行展开说明,如有需要可以参考CppReference上的CTAD一节。
对于简单的类模板,CTAD可以很方便的自动推导出对应的模板实参,但是如果类较为复杂或者有特殊处理需求,导致CTAD不起作用时,可以通过定义推导指引(Deduction Guides)来改变CTAD的行为。推导指引的语法如下:

1
2
template <模板形参列表>
模板名(形参列表) -> 模板名<推导结果>

为了演示推导指引的作用,我们通过向FixedArray加入一个int类型的非类型模板形参来使其支持存储指定长度的数组:

1
2
3
4
5
6
7
8
template <typename ElementType, int Length>
struct FixedArray {
ElementType storage[Length];
};

auto main() -> int {
FixedArray<int, 4> array{1, 2, 3, 4};
}

很自然的,我们期望FixedArray能够通过初始化列表来自动推导Length

1
2
//这样写既简洁又不容易出错,但很可惜CTAD不支持这种情况下的模板实参推导
FixedArray array{1, 2, 3, 4};

为了让CTAD能自动完成类似推导,我们需要定义以下推导指引:

1
2
template <typename ElementType, typename... TailArgTypes>
FixedArray(ElementType, TailArgTypes...) -> FixedArray<ElementType, sizeof...(TailArgTypes) + 1>;

推导规则的工作原理是定义一个假想的函数模板,编译器会将无法默认规则无法推导的初始化表达式作为该函数模板的实参,再通过函数模板的推导规则来获取我们定义的类模板实参推导结果。就拿FixedArray作为例子,当编译器遇到一个无法被默认推导规则推导的规则时,如:

1
FixedArray array{1, 2, 3, 4};

编译器会尝试查找FixedArray的推导规则,在这个例子中,只有上文定义的一条推导规则。在编译器查找到之后,它会尝试“想象”一个函数模板调用:

1
2
3
4
template <typename ElementType, typename... TailArgTypes>
FixedArray(ElementType, TailArgTypes...) /*忽略返回类型和函数体*/;

FixedArray(1, 2, 3, 4);

然后编译器就可以从这一调用推导出ElementType=int, TailArgTypes=<int, int, int>,从而成功推导出类模板的实参。
当然,这一推导规则可以用我们前文所讲的[[#包索引]]简化:

1
2
template <typename ... ArgTypes>
FixedArray(ArgTypes...) -> FixedArray<ArgTypes...[0], sizeof...(ArgTypes)>;

模板模板形参

我们现在有了可以接受类型的类型模板形参,有了可以接受对象的非类型模板实参(即NTTP),还有什么是模板形参接受不了的呢?就是模板。为了解决这一问题,C++引入了模板模板形参(Template Template Parameter,很绕口就对了),模板模板形参的声明语法如下:

1
2
3
template <template <模板形参列表> 模板类型 标识符>
template <template <模板形参列表> 模板类型 标识符 = 默认值>
template <template <模板形参列表> 模板类型 ... 标识符>

其中,模板类型可以为classtypenameconcept(C++26加入)、auto(C++26加入)。我们此处只讲解typename一种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename ElementT>
struct MyArray {
//something...
};

template <typename KeyT, typename ValueT, template <typename> typename ContainerT>
struct Map {
ContainerT<KeyT> _keys;
ContainerT<ValueT> _keys;
};

auto main() -> int {
Map<int, std::string, MyArray> my_map;
}

其中,template <typename> typename ContainerT即为一个模板模板形参。ContainerT接受一个模板类作为实参(typename ContainerT说明ContainerT是一个类型),且该模板类拥有一个类型模板形参(template <typename>说明Container是一个模板,且模板形参列表为<typename>)。我们可以将符合这一要求的类模板(比如MyArray)赋给ContainerT

成员函数模板

成员函数模板与类模板的相关性不大,类模板和普通的类都可以有成员函数模板,类模板的模板形参在其成员函数模板中都可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class NonTemplateClass {
template <typename T>
auto templateFunction() -> void {}

auto regularFunction() -> void {}
};

template <typename ClassT>
class TemplateClass {
//类模板的模板形参在其成员函数模板中都可用
template <typename T = ClassT>
auto templateFunction() -> void {}

auto regularFunction() -> void {}

//构造函数也可以是函数模板
template <typename F>
TemplateClass() {}
};

注意,一个类的析构函数不能是函数模板。
成员模板函数与一般的模板函数没有太大差别,故此处不深入讲解。

类模板与形参包

与函数模板一样,形参包在类模板中亦有广泛的使用,其中最为知名的莫过于std::tuple。通过使用形参包,std::tuple有了在一个对象中存储不同类型对象的能力。
我们用一段简单的例子来演示形参包在类模板中的使用:

1
2
3
4
5
6
7
8
template <typename ... ElementT>
struct MyTuple {
std::tuple<ElementT...> _storage;

MyTuple(ElementT&& ... args)
: _storage(std::forward<ElementT>(args)...)
{}
};

别名模板与变量模板

除了函数和类之外,类型别名与变量都可以成为模板,分别称作别名模板(Alias Template)和变量模板(Variable Template)。这两类模板的作用会在后面几篇教程详细讲解,在此处我们仅做简单演示:

1
2
3
4
5
6
7
8
9
10
11
//别名模板
template <typename T>
using Alias = T;

//变量模板
template <typename T>
std::size_t size = sizeof(T);

auto main() -> int {
std::println("{}", size<Alias<int>>);//输出4,即int类型的大小
}

Compiler Explorer链接

待决名

typename消岐义符

我们使用一段代码来引入待决名(Dependent Name)这一概念:

1
2
3
4
5
6
7
8
9
template <typename T>
auto getIterator(std::vector<T> const &v) -> void {
std::vector<T>::const_iterator it = v.begin();
}

auto main() -> int {
std::vector<int> v{};
getIterator(v);
}

Compiler Explorer链接
上面这段代码在经过clang编译后会产生以下错误:

1
2
3
4
5
<source>:5:2: error: missing 'typename' prior to dependent type name 'std::vector<T>::const_iterator'
5 | std::vector<T>::const_iterator it = v.begin();
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| typename
1 error generated.

报错的原因也很简单:由于std::vector<T>是一个依赖于模板形参T的类型,所以编译器在实例化之前并不知道std::vector<T>里面有什么东西。在编译器的视角中,std::vector<T>::const_iterator可以是:

  • 一个静态成员变量
  • 一个成员类型别名
  • 一个静态成员函数

  • 显然,编译器并不知道应该选择哪一种来解释const_iterator,所以产生了这样的报错,我们称std::vector<T>::const_iterator是一个待决名。为了避免报错,我们应该显式地告诉编译器(即“消岐义”,Disambiguate),const_iterator是一个成员类型别名:
1
2
3
4
5
6
7
8
9
10
template <typename T>
auto getIterator(std::vector<T> const &v) -> void {
//在前面加上typename关键字来说明这是个类型
typename std::vector<T>::const_iterator it = v.begin();
}

auto main() -> int {
std::vector<int> v{};
getIterator(v);
}

那么,我们应该在何时使用typename进行消岐义呢?

在模板(包括别名模版)的声明或定义中,不是当前实例化的成员且取决于某个模板形参的名字不会被认为是类型,除非使用关键词typename或它已经被设立为类型名(例如用typedef声明或通过用作基类名)。

将这句话拆分成几个部分:

  • 在模板的声明或定义中
  • 不是当前实例化的成员且取决于某个模板形参的名字
  • 除非使用关键词typename或它已经被设立为类型名
    对应到上面的例子中:
  • 我们在一个函数模板getIterator的定义中,符合
  • std::vector<T>::const_iterator不是当前实例化的成员,同时取决于我们的模板形参T,符合
    所以编译器不会认为std::vector<T>::const_iterator是一个类型名,所以我们需要使用typename来进行消岐义。
    当然,如果名字并非待决,我们也可以在前面使用typename。通俗来讲就是如果编译器已经知道某个标识符是个类型,你也可以继续跟编译器重复说这是个类型(当然不少编译器都会给你一个warning说这不是必要的)。

template消岐义符

我们同样以一个例子做引入:

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
struct Foo {
template <typename U>
auto foo() -> void {}
};

template <typename T>
auto bar() -> void {
Foo<T> foo_inst{};
foo_inst.foo<T>();
}

Compiler Explorer链接
这段代码同样会发生编译错误,clang编译器的报错如下:

1
2
3
4
5
<source>:10:11: error: use 'template' keyword to treat 'foo' as a dependent template name
10 | foo_inst.foo<T>();
| ^
| template
1 error generated.

typename类似,编译器不认为foo_inst.foo<T>是一个模板,所以相应的,我们需要加上template消岐义符:

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
struct Foo {
template <typename U>
auto foo() -> void {}
};

template <typename T>
auto bar() -> void {
Foo<T> foo_inst{};
foo_inst.template foo<T>();
}

使用template消岐义的规则与typename类似:

模板定义中不是当前实例化的成员的待决名同样不被认为是模板名,除非使用消歧义关键词template,或它已被设立为模板名。

对应上面的例子:

  • 我们在bar模板函数的定义中
  • Foo<T>不是当前实例化的成员
    foo_inst.foo<T>亦是待决名,故编译器不认为它是一个模板。
    同样的,template消岐义符也可以用在不需要消岐义的标识符前面。

绑定规则

模板中标识符的绑定规则可以根据该标识符是否待决分为两类:

  • 非待决名在模板定义点查找并绑定。即使在模板实例化点有更好的匹配,也保持此绑定。
  • 待决名的绑定则延迟到查找发生时,即实例化时。
    我们用一段代码来理解以上规则:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
auto foo(double dummy) -> void {
std::println("foo(double) called");
}

template <typename T>
struct Bar {
auto meow() -> void {
foo(42);
}
};

auto foo(int dummy) -> void {
std::println("foo(int) called");
}

auto main() -> int {
foo(42);

Bar<void> bar_inst;
bar_inst.meow();
}

Compiler Explorer链接
运行这段代码,输出结果为:

1
2
foo(int) called
foo(double) called

为什么会出现这个结果呢?
main函数中对foo(42)函数的调用很直观的绑定到了foo(int),但是在Bar<T>::meow 中的foo(42)是一个非待决名,非待决名在模板的定义点查找并绑定,而非在实例化点绑定。所以当编译器编译到了meow函数时,它会立刻开始查找目前已经定义的foo函数,而由于此时编译器还没有看到后面的foo(int),所以此处只能绑定到已知的foo(double)。但如果在meowfoo是一个待决名,那么绑定会在实例化的时候发生。

查找规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
auto bar() -> void { std::println("::bar() called"); }

template <typename T>
struct Base {
auto bar() -> void { std::println("Base::bar() called"); }
};

template <typename T>
struct Foo : public Base<T> {
auto dependent() -> void {
this->bar();
}

auto independent() -> void {
bar();
}
};

auto main() -> int {
Foo<void> foo;
foo.dependent();
foo.independent();
}

Compiler Explorer链接
运行上面的代码,输出结果为:

1
2
Base::bar() called
::bar() called

首先我们明确在dependentindependent函数中,由于我们没有使用任何的作用域解析操作符::,对bar进行的查找都是无限定名字查找(UDL,Unqualified Name Lookup)。同时,在dependent中,bar是一个依赖于Base<T>的待决名,而在independent中,bar是一个非待决名。非待决名和待决名的查找规则有所不同,高度概括地说:

  • 非待决名在模版定义的时候就进行无限定名字查找。
  • 待决名的名字查找会推迟到模板实例化时。
    这称作二阶段名字查找(Two-phase Name Lookup)
    所以对于independent中对bar的调用,编译器在该类模板定义时就会进行查找。按照UDL的查找顺序,编译器首先会在本类中查找(注意不会查找父类Base<T>),在本类中查找不到bar后编译器会进一步查找全局命名空间,随后就会查找到在全局命名空间中的bar
    而对于dependent中对bar的调用,编译器会在实例化,即main函数中Foo<void>一行时进行查找,由于所有模板形参都已确定,编译器可以对父类进行查找,所以会查找到父类中对bar

模板特化与部分特化

模板全特化

模板特化(Template Specialization),说的直白点就是让模板能够对某些模板实参进行特殊处理,以前文中的FixedArray为例(为了简洁我们将其长度固定为8):

1
2
3
4
template <typename ElementT>
struct FixedArray {
ElementT storage[8];
};

我们都知道在C++中,一个bool需要占用完整的一个字节,所以FixedArray<bool>就会占用8个字节。但是bool的信息存储只需要用到一个字节中的一位,所以我们想通过某种方式来优化FixedArray<bool>,来使其只占用一个字节,这时候我们就要用到模板全特化(又称模板显式特化,Explicit (Full) Template Specialization):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//模板主定义
template <typename ElementT>
struct FixedArray {
ElementT storage[8];
};

//针对bool特化模板
template <>
struct FixedArray<bool> {
std::int8_t storage;
};

auto main() -> int {
FixedArray<int> array1;
FixedArray<bool> array2;
std::println("{} {}", sizeof(array1), sizeof(array2));
}

Compiler Explorer链接
可以看到,array2的大小仅为一个字节,说明我们的模板特化起了作用。

全特化函数模板

对于函数模板的全特化来说,其语法如下:

1
2
template <>
函数定义(或声明)

如果模板实参能从函数参数列表中推导,那么我们可以省略模板实参:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//模板主定义
template <typename T>
auto foo(T t) -> T {
return t;
}

//T=int的全特化
template <>
auto foo<int>(int t) -> int {
return 42;
}

//T=double的全特化,由于可以推导模板实参所以不必指定
template <>
auto foo<double>(double t) -> double {
return 114.514;
}

auto main() -> int {
std::println("{} {} {}", foo(true), foo(1), foo(4.2));
}

Compiler Explorer链接
注意一点,所有的模板特化都应该位于在第一次会引起隐式实例化的使用前,比如下面的代码不能通过编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//模板主定义
template <typename T>
auto foo(T t) -> T {
return t;
}

auto main() -> int {
//引发foo<int>隐式实例化
std::println("{}", foo(1));
}

//T=int的全特化,由于特化位于隐式实例化之后,所以编译不通过
template <>
auto foo<int>(int t) -> int {
return 42;
}

注意要分清楚全特化与显式实例化之间的差异,两者的语法很接近,但特化的函数模板与模板主定义之间没有关系,而显式实例化是基于模板主定义进行的实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//模板主定义
template <typename T>
auto foo(T t) -> T {
return t;
}

//这是一个特化,注意template后面带有一对空尖括号
template <>
auto foo(int t) -> int {
return 42;
}

//这是一个显式实例化,注意template后面直接跟着函数声明
template auto foo(double) -> double;

注意,特化的函数模板可以跟主模板具有不同的说明符(inline/constexpr/constinit/conseval):

1
2
3
4
5
6
7
8
9
template <typename T>
auto foo(T) -> void {}
template <>
inline auto foo(int) -> void {}

template <typename T>
inline auto bar(T) -> T {}
template <>
auto bar(int) -> int {} // OK,没有内联

全特化类模板

类模板全特化的语法与函数模板类似:

1
2
template <>
类定义(或声明)

一个简单的小例子(虽说这里value应该是constexpr变量,但是由于我们还没有讲到相关知识,故省略):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//类模板主定义
template <typename T>
struct isVoid {
inline static bool value = false;
};

//T=void全特化
template <>
struct isVoid<void> {
inline static bool value = true;
};

auto main() -> int {
std::println("{} {}", isVoid<int>::value, isVoid<void>::value);
}

Compiler Explorer链接
注意,类模板的特化相当于一个全新的,与原类模板主声明无关的类。我们可以随意向类模板的特化中添加成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//类模板主定义
template <typename T>
struct MathUtils {
auto abs(T t) -> T {
std::println("MathUtils<WhatSoEver>::abs() called");
return std::abs(t);
}
};

//T=int全特化
template <>
struct MathUtils<int> {
auto abs(int t) -> int {
std::println("MathUtils<int>::abs() called");
return t < 0 ? -t : t;
}
auto sqrt(int t) -> int { return std::sqrt(t); }
};

auto main() -> int {
MathUtils<double> utils_double;
MathUtils<int> utils_int;
utils_double.abs(-4.2);
utils_int.abs(-114514);
//sqrt只存在于MathUtils<int>特化中,故下面这行代码无法通过编译
//utils_double.sqrt(4.2);
utils_int.sqrt(4);
}

Compiler Explorer链接

全特化类模板成员

在类体外定义显式特化的类模板的成员时,不需要使用template<>前缀,除非它是某个被特化为类模板的显式特化的成员类模板的成员:

  • 成员函数及成员函数模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//类模板主定义
template <typename T>
struct Foo {
auto bar() -> void {}
auto meow() -> void {}
template <typename U>
auto templated() -> void {}
};

//T=void全特化
template <>
struct Foo<void> {
//可以在类内定义
auto bar() -> void {}
auto meow() -> void;
template <typename U>
auto templated() -> void;
};

//也可以在类内声明,类外定义
auto Foo<void>::meow() -> void {}
//注意无论是类内还是类外定义,三个函数都不需要使用template <>做前缀
//这是由于三个函数(模板)都在全特化的类模板中声明,故都可以看作一个独立类(即类模板的特化)的成员,故不需要额外的特化前缀
template <typename U>
auto Foo<void>::templated() -> void {}
  • 成员类及成员类模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T>
struct Foo {
//成员类
struct Bar {};
//成员类模板
template <typename U>
struct Meow {};
};

template <>
struct Foo<int> {
struct Bar;
template <typename U>
struct Meow {};
};
//同样的,由于都已在类模板的全特化中声明,Bar和Meow均不需要template <>前缀
struct Foo<int>::Bar {};

我们还可以通过对类模板的隐式实例化进行特化来单独特化类模板中的部分成员,此时我们才需要用到template <>前缀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <typename T>
struct Foo {
auto foo() -> void {
std::println("Unspecialized foo()");
}
auto bar() -> void {
std::println("Unspecialized bar()");
}
};

//注意,为了对类模板的隐式实例化进行特化,这里我们需要加上template <>前缀
template <>
auto Foo<int>::foo() -> void {
std::println("Specialized foo()");
}

auto main() -> int {
Foo<void> foo_void;
Foo<int> foo_int;
foo_void.foo();
foo_void.bar();
foo_int.foo();
foo_int.bar();
}

Compiler Explorer链接
模板特化也可以进行嵌套,比如特化一个类模板中的特定成员函数模板:

1
2
3
4
5
6
7
8
9
10
template <typename T>
struct Foo {
template <typename U>
auto bar() -> void {}
};

template <>
template <>
auto Foo<int>::bar<double>() -> void {}
//针对Foo<int>::bar<double>进行特化

全特化变量模板

全特化变量模板会在后续章节中深入讲解,此处仅提供一个简单的例子说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//变量模板主定义
template <typename T>
std::string_view getTypeName = "<unknown type>";

//T=int全特化
template <>
std::string_view getTypeName<int> = "int";

//T=double全特化
template <>
std::string_view getTypeName<double> = "double";

auto main() -> int {
std::println("{} {} {}", getTypeName<void>, getTypeName<int>, getTypeName<double>);
}

Compiler Explorer链接

模板部分特化

模板全特化,是提供一个定义给一组具体的模板实参。而所谓模板部分特化(又译模板偏特化,Partial Template Specialization)是将一个定义提供给一组具有某些特征的模板实参。我们以一个例子做引入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template <typename T>
std::string_view typeCategory = "<unknown>";

//对所有指针进行部分特化
template <typename T>
std::string_view typeCategory<T *> = "<pointer>";

//对所有左值引用进行部分特化
template <typename T>
std::string_view typeCategory<T &> = "<Lvalue Reference>";

//对所有右值引用进行部分特化
template <typename T>
std::string_view typeCategory<T &&> = "<Rvalue Reference>";

auto main() -> int {
std::println(
"{} {} {} {} {}",
typeCategory<int>,//<unknown>
typeCategory<int *>,//<pointer>
typeCategory<int const *>,//<pointer>
typeCategory<int &>,//<Lvalue Reference>
typeCategory<int &&>//<Rvalue Reference>
);
}

Compiler Explorer链接
在有多个模板形参时,完全指定一部分模板实参也是偏特化的一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//类模板主定义
template <typename T1, typename T2>
struct Foo {
static auto foo() -> void {
std::println("Foo<T1, T2>::foo() called");
}
};

//对T1=int进行偏特化,T2不做指定
template <typename T2>
struct Foo<int, T2> {
static auto foo() -> void {
std::println("Foo<int, T2>::foo() called");
}
};

auto main() -> int {
Foo<void, double>::foo();
Foo<float, int>::foo();
//偏特化匹配所有第一个模板实参为int的实例化
Foo<int, void>::foo();
Foo<int, char>::foo();
}

Compiler Explorer链接
注意,与模板全特化不同,模板偏特化只能特化类模板与变量模板
模板偏特化这一特性十分强大,这相当于允许我们对模板实参进行模式匹配,并根据不同的匹配结果进行不同的处理,这一特性直接构成了我们后面要讲的模板元编程的核心工具之一。
模板偏特化的语法与全特化大致相同,故此处不再赘述。

  • Title: C++元编程教程00:模板基础回顾
  • Author: Zhige Chen
  • Created at : 2025-04-09 23:13:53
  • Updated at : 2025-04-09 21:07:41
  • Link: https://nofe1248.github.io/2025/04/09/cpp-metaprogramming-00/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments