5. 模板(Templates)
# 5. 模板(Templates)
你使用过模板和(或)元编程(meta-programming)吗?
如果你的答案是 “是”,那么你可能会对C++17中的更新感到满意。
新标准引入了许多改进,使模板编程更加容易,表达能力也更强。
在本章中,你将了解到:
- 类模板的模板参数推导(Template argument deduction for class templates)
template<auto>
- 折叠表达式(Fold expressions)
if constexpr
——C++的编译期if
语句!- 以及一些较小的、详细的改进和修复
# 类模板的模板参数推导
C++17填补了模板推导规则中的一个空白。现在,模板参数推导不仅适用于函数模板,也适用于类模板。这也意味着,许多使用make_Type
函数的代码现在可以省略了。
例如,要创建一个std::pair
对象,通常这样写会更方便:
auto myPair = std::make_pair(42, "hello world"s);
而不是:
std::pair<int, std::string> myPair{42, "hello world"};
因为std::make_pair()
是一个模板函数,编译器可以执行函数模板参数的推导,所以无需写成:
auto myPair = std::make_pair<int, std::string>(42, "hello world");
现在,从C++17开始,符合标准的编译器也可以很好地推导类模板的模板参数类型!
这个特性被称为 “类模板参数推导(Class Template Argument Deduction)”,简称为CTAD。在我们的示例中,现在你可以这样写:
using namespace std::string_literals;
std::pair myPair{42, "hello world"s}; // 自动推导!
2
CTAD在拷贝初始化和通过new()
分配内存时也同样适用:
auto otherPair = std::pair{42, "Hello"s}; // 同样会被推导
auto ptr = new std::pair{42, "World"s}; // 使用new时
2
CTAD可以大幅简化复杂的构造,比如:
// 锁保护:
std::shared_timed_mutex mut;
std::lock_guard<std::shared_timed_mutex> lck(mut);
// 数组:
std::array<int, 3> arr {1, 2, 3};
2
3
4
5
现在可以写成:
std::shared_timed_mutex mut;
std::lock_guard lck(mut);
std::array arr { 1, 2, 3 };
2
3
注意,不能进行部分推导,你必须指定所有模板参数,或者一个都不指定:
std::tuple t(1, 2, 3); // 正确:进行推导
std::tuple<int ,int ,int> t(1, 2, 3); // 正确:提供了所有参数
std::tuple<int> t(1, 2, 3); // 错误:部分推导
2
3
有了这个特性,许多make_Type
函数可能不再需要,尤其是那些为类 “模拟” 模板推导的函数。
不过,有些工厂函数会执行额外的工作。例如,std::make_shared
,它不仅创建shared_ptr
,还确保控制块和指向的对象分配在同一内存区域:
// 控制块和int可能在内存的不同位置
std::shared_ptr<int> p(new int {10});
// 控制块和int在同一连续内存段
auto p2 = std::make_shared<int>(10);
2
3
4
类模板的模板参数推导是如何工作的呢?让我们进入 “推导指引(Deduction Guides)” 这一领域。
# 推导指引
编译器使用一种名为 “推导指引” 的特殊规则来确定参数类型。
推导指引有两种类型:编译器生成的(隐式生成)和用户定义的。为了理解编译器如何使用这些指引,让我们来看一个简化版的std::array
推导指引¹:
template <typename T, typename... U>
array(T, U...) ->
array<enable_if_t<(is_same_v<T, U> && ...), T>, 1 + sizeof ...(U)>;
2
3
这种语法看起来像一个带有后置返回类型的模板函数。编译器将这种 “虚构” 的函数视为参数的候选。如果模式匹配,那么从推导中返回找到的类型。
在我们的例子中,当你这样写:
std::array arr {1, 2, 3, 4};
那么,假设T
和U...
参数的类型相同,我们可以构建一个std::array<int, 4>
类型的数组对象。
在大多数情况下,你可以依赖编译器生成自动推导指引。它们会为主要类模板的每个构造函数(包括拷贝/移动构造函数)创建。请注意,专门化或部分专门化的类在这里不适用。
如前所述,你也可以编写自定义推导指引。一个经典的例子是推导为std::string
而不是const char*
:
template <typename T> struct MyType { T str; };
// 自定义推导指引
MyType(const char *) -> MyType<std::string>;
MyType t{"Hello World"}; // 推导为std::string
2
3
4
如果没有自定义推导指引,T
会被推导为const char*
。
另一个自定义推导指引的例子来自重载模式(在关于std::variant
的章节中,与std::visit()
相关的部分可以了解更多关于这个模式的内容):
template <class... Ts>
struct overload : Ts... { using Ts::operator()...; };
template <class... Ts>
overload(Ts...) -> overload<Ts...>; // 推导指引
2
3
4
overload
类从其他类Ts...
继承,然后公开它们的operator()
。这里使用自定义推导指引将可调用类型列表 “转换” 为我们可以从中派生的类列表。
# CTAD的局限性
在C++17中,类模板的模板参数推导有以下局限性:
- 它不适用于模板聚合类型
- 推导不包括继承构造函数
- 它不适用于模板别名
这些局限性将在C++20中通过已被接受的提案P1021 (opens new window)消除。
# 额外信息
CTAD特性是在P0091R3 (opens new window)和P0433 - Deduction Guides in the Standard Library (opens new window)中提出的。
请注意,虽然编译器可能宣称完全支持类模板的模板参数推导,但其相应的标准模板库(STL)实现可能仍然缺少某些STL类型的自定义推导指引。请参阅本章末尾的 “编译器支持” 部分。
# 折叠表达式
C++11引入了可变参数模板(variadic templates),这是一个强大的特性,尤其是当你想要处理函数中数量可变的输入模板参数时。例如,在C++11之前,你必须为一个模板函数编写多个不同的版本(一个版本处理一个参数,另一个版本处理两个参数,再一个版本处理三个参数……)。
不过,当你想要实现像求和(sum
)、全部(all
)这样的“递归”函数时,可变参数模板仍需要一些额外的代码。你必须指定递归规则。
例如:
auto SumCpp11() {
return 0;
}
template <typename T1, typename... T>
auto SumCpp11(T1 s, T... ts) {
return s + SumCpp11(ts...);
}
2
3
4
5
6
7
8
而在C++17中,我们可以编写更简单的代码:
template <typename... Args>
auto sum(Args... args) {
return (args + ... + 0);
}
// 甚至可以这样:
template <typename... Args>
auto sum2(Args... args) {
return (args + ...);
}
2
3
4
5
6
7
8
9
存在以下带有二元运算符(op
)的折叠表达式 (opens new window)变体:
表达式 | 名称 | 展开形式 |
---|---|---|
(... op e) | 一元左折叠 | ((e1 op e2) op ...) op eN |
(init op ... op e) | 二元左折叠 | (((init op e1) op e2) op ...) op eN |
(e op ...) | 一元右折叠 | e1 op (... op (eN - 1 op eN)) |
(e op ... op init) | 二元右折叠 | e1 op (... op (eN - 1 op (eN op init))) |
op
可以是以下32个二元运算符中的任意一个:+ - * / % ^ & | = < > << >> += -= *= /= %= ^= &= |= <<= >>= == != <= >= && || , .* ->*
。在二元折叠中,两个 op
必须相同。
例如,当你编写:
template <typename... Args>
auto sum2(Args... args) {
return (args + ...); // 对 '+' 进行一元右折叠
}
auto value = sum2(1, 2, 3, 4);
2
3
4
5
模板函数会展开为:auto value = 1 + (2 + (3 + 4));
默认情况下,对于空参数包,我们有以下值:
运算符 | 默认值 |
---|---|
&& | true |
|| | false |
, | void() |
其他任何运算符 | 格式错误的代码 |
这就是为什么你不能在没有任何参数的情况下调用 sum2()
,因为对 +
运算符的一元折叠对于空参数列表没有默认值。
# 更多示例
这里有一个使用折叠表达式实现 printf
的非常不错的示例(P0036R0 (opens new window)):
template <typename... Args>
void FoldPrint(Args&&... args) {
(std::cout << ... << std::forward<Args>(args)) << '\n ';
}
FoldPrint("hello", 10, 20, 30);
2
3
4
5
然而,上面的 FoldPrint
会逐个打印参数,没有任何分隔符。对于上面的函数调用,你会在输出中看到 hello102030
。
如果你想要分隔符和更多格式化选项,就必须改变打印方式并使用逗号折叠:
template <typename... Args>
void FoldSeparateLine(Args&&... args) {
auto separateLine = [](const auto& v) {
std::cout << v << '\n ';
};
(... , separateLine (std::forward<Args>(args))); // 对逗号运算符进行折叠
}
2
3
4
5
6
7
使用逗号运算符进行折叠的技巧非常实用。另一个示例是一个特殊版本的 push_back
:
template <typename T, typename... Args>
void push_back_vec(std::vector<T>& v, Args&&... args) {
(v.push_back(std::forward<Args>(args)), ...);
}
std::vector<float> vf;
push_back_vec(vf, 10.5f, 0.7f, 1.1f, 0.89f);
2
3
4
5
6
一般来说,折叠表达式能让你编写更简洁、更简短,可能也更易读的代码。
# 额外信息
这项更改是在N4295 (opens new window)和P0036R0 (opens new window)中提出的。
# if constexpr
这可是个重要特性!
C++的编译期 if
!
这个特性允许你在编译时根据常量表达式条件舍弃 if
语句的分支。
if constexpr (cond)
statement1; // 如果cond为假,则舍弃
else
statement2; // 如果cond为真,则舍弃
2
3
4
例如:
template <typename T>
auto get_value(T t) {
if constexpr (std::is_pointer_v<T>)
return *t;
else
return t;
}
2
3
4
5
6
7
if constexpr
有潜力简化大量的模板代码,尤其是在使用标签调度(tag dispatching)、替换失败不是错误(SFINAE)或预处理技术时。
# 为什么需要编译期 if
?
一开始,你可能会问,为什么我们需要 if constexpr
以及那些复杂的模板表达式……普通的 if
难道不行吗?
这里有一个代码示例:
template <typename Concrete, typename... Ts>
std::unique_ptr<Concrete> constructArgs(Ts&&... params) {
if (std::is_constructible_v<Concrete, Ts...>) // 普通 `if`
return std::make_unique<Concrete>(std::forward<Ts>(params)...);
else
return nullptr;
}
2
3
4
5
6
7
上面的例程是 std::make_unique
的“升级版”:当参数允许构造被包装的对象时,它返回 std::unique_ptr
;否则返回 nullptr
。
下面是一段测试 constructArgs
的简单代码:
class Test {
public:
Test(int, int) {}
};
int main() {
auto p = constructArgs<Test>(10, 10, 10); // 3个参数!
}
2
3
4
5
6
7
8
这段代码尝试用三个参数构建 Test
,但请注意,Test
只有一个接受两个 int
类型参数的构造函数。
编译时,你可能会得到类似这样的编译错误:
在实例化'typename std::_MakeUniq<_Tp>::single_object std::make_unique(_Args&& ...) [with _Tp = Test; _Args = {int, int, int}; typename std::_MakeUniq<_Tp>::single_object = std::unique_ptr<Test, std::default_delete<Test> >]' 中:
main.cpp:8:40: 从'std::unique_ptr<_Tp> constructArgs(Ts&& ...) [with Concrete = Test; Ts = {int, int, int}]' 所需
2
让我们试着理解这个错误信息。模板推导之后,编译器编译以下代码:
if (std::is_constructible_v<Concrete, int, int, int>)
return std::make_unique<Concrete>(10, 10, 10);
else
return nullptr;
2
3
4
在运行时,if
分支不会被执行,因为 is_constructible_v
返回 false
,但该分支中的代码仍必须能够编译通过。
这就是为什么我们需要 if constexpr
,以便“舍弃”代码,只编译匹配的语句。
要修复这段代码,你必须添加 constexpr
:
template <typename Concrete, typename... Ts>
std::unique_ptr<Concrete> constructArgs(Ts&&... params) {
if constexpr (std::is_constructible_v<Concrete, Ts...>) // 修复!
return std::make_unique<Concrete>(std::forward<Ts>(params)...);
else
return nullptr;
}
2
3
4
5
6
7
现在,编译器在编译时计算 if constexpr
条件,对于表达式 auto p = constructArgs<Test>(10, 10, 10);
,整个 if
分支将在编译过程的第二步中被“移除”。
确切地说,被舍弃分支中的代码并没有完全从编译阶段移除。只有依赖于条件中使用的模板参数的表达式才不会被实例化。语法必须始终有效。
例如:
template <typename T>
void Calculate(T t) {
if constexpr (std::is_integral_v<T>) {
//...
static_assert(sizeof(int) == 100);
} else {
execute(t);
strange syntax
}
}
2
3
4
5
6
7
8
9
10
在上面这段人为编写的代码中,如果类型 T
是 int
,那么 else
分支将被舍弃,这意味着 execute(t)
不会被实例化。但是 strange syntax
这一行仍会被编译(因为它不依赖于 T
),这就是为什么你会得到关于这一行的编译错误。
此外,static_assert
也会产生另一个编译错误,该表达式也不依赖于 T
,所以它总是会被计算。
# 模板代码简化
在C++17之前,如果根据类型要求有多个版本的算法,你可以使用模板参数替换失败不是错误(SFINAE,Substitution Failure Is Not An Error)或标签调度(tag dispatching)来生成特定的重载决议集。
例如:
// Templates/sfinae_example.cpp
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> simpleTypeInfo(T t) {
std::cout << "foo<integral T> " << t << '\n';
return t;
}
template <typename T>
std::enable_if_t<!std::is_integral_v<T>, T> simpleTypeInfo(T t) {
std::cout << "not integral \n";
return t;
}
2
3
4
5
6
7
8
9
10
11
12
在上述示例中,我们有两个函数实现,但最终只有一个会进入重载决议集。如果对于类型T
,std::is_integral_v
为true
,那么就会选用上面的函数,第二个函数则会因为SFINAE被排除。
使用标签调度时也会出现同样的情况:
// Templates/tag_dispatching_example.cpp
template <typename T>
T simpleTypeInfoTagImpl(T t, std::true_type) {
std::cout << "foo<integral T> " << t << '\n';
return t;
}
template <typename T>
T simpleTypeInfoTagImpl(T t, std::false_type) {
std::cout << "not integral \n";
return t;
}
template <typename T>
T simpleTypeInfoTag(T t) {
return simpleTypeInfoTagImpl(t, std::is_integral<T>{});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
现在,我们不再使用SFINAE,而是为条件生成一个唯一的类型标签:true_type
或false_type
。根据结果,只会选择一个实现。
现在我们可以使用if constexpr
简化这种模式:
template <typename T>
T simpleTypeInfo(T t) {
if constexpr (std::is_integral_v<T>) {
std::cout << "foo<integral T> " << t << '\n';
} else {
std::cout << "not integral \n";
}
return t;
}
2
3
4
5
6
7
8
9
编写模板代码变得更加“自然”,不再需要那么多“技巧”。
# 示例
让我们来看几个例子:
- 行打印机:在本书这部分开头的“快速入门”章节中,你可能已经见过下面这个例子。现在深入了解一下细节,看看代码是如何工作的。
template <typename T>
void linePrinter(const T& x) {
if constexpr (std::is_integral_v<T>) {
std::cout << "num: " << x << '\n';
} else if constexpr (std::is_floating_point_v<T>) {
const auto frac = x - static_cast<long>(x);
std::cout << "flt: " << x << ", frac " << frac << '\n';
} else if constexpr (std::is_pointer_v<T>) {
std::cout << "ptr, ";
linePrinter(*x);
} else {
std::cout << x << '\n';
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
linePrinter
使用if constexpr
检查输入类型,并据此输出额外的信息。处理指针类型时会有一个有趣的情况:当检测到指针时,代码会对其解引用,然后递归调用linePrinter
。
- 声明自定义的
get<N>
函数:结构化绑定表达式适用于所有成员都是公共成员的简单结构体,例如:
struct S {
int n;
std::string s;
float d;
};
S s;
auto [a, b, c] = s;
2
3
4
5
6
7
8
然而,如果你有一个自定义类型(包含私有成员),也可以重写get<N>
函数,使结构化绑定能够正常工作。以下代码展示了这个思路:
class MyClass {
public:
int GetA() const { return a; }
float GetB() const { return b; }
private:
int a;
float b;
};
template <std::size_t I>
auto get(MyClass& c) {
if constexpr (I == 0) return c.GetA();
else if constexpr (I == 1) return c.GetB();
}
// 支持类似元组接口的特化
namespace std {
template <>
struct tuple_size<MyClass> : integral_constant<size_t, 2> {};
template <>
struct tuple_element<0,MyClass> { using type = int; };
template <>
struct tuple_element<1,MyClass> { using type = float; };
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在上述代码中,你可以将所有功能整合在一个函数中,这是它的优势。也可以通过模板特化来实现:
template <>
auto& get<0>(MyClass &c) { return c.GetA(); }
template <>
auto& get<1>(MyClass &c) { return c.GetB(); }
2
3
4
想要了解更多示例,可以阅读“用if constexpr
替换std::enable_if
”这一章节,以及“结构化绑定”中关于自定义get<N>
特化的部分。你还可以查看以下文章:《在C++17中用if constexpr
简化代码 (opens new window)》。
# 额外信息
该变更在P0292R2 (opens new window)中被提出。
# 使用auto
声明非类型模板参数
这是在各处使用auto
策略的另一部分。在C++11和C++14中,你可以使用auto
自动推导变量甚至返回类型,此外还有泛型lambda表达式。现在,你还可以用它来推导非类型模板参数。
例如:
template <auto value>
void f() {}
f<10>(); // 推导为int
2
3
4
这很有用,因为你不必为非类型参数的类型单独设置一个参数。就像在C++11/14中:
template <typename Type, Type value>
constexpr Type TConstant = value;
constexpr auto const MySuperConst = TConstant<int, 100>;
2
3
4
在C++17中,代码更简单一些:
template <auto value>
constexpr auto TConstant = value;
constexpr auto const MySuperConst = TConstant<100>;
2
3
4
无需显式写出Type
。
许多论文和文章都指出,作为一种高级用法,异构编译时列表就是一个例子:
template <auto... vs>
struct HeterogenousValueList {};
using MyList = HeterogenousValueList<'a', 100, 'b'>;
2
3
4
在C++17之前,无法直接声明这样的列表,必须先提供某个包装类。
# 额外信息
该变更在P0127R2 (opens new window)中被提出。在P0127R1 (opens new window)中,你可以找到更多示例和相关论证。
# 其他变更
在C++17中,还有一些与模板相关的语言和库特性值得一提:
- 在模板模板参数中允许使用typename:在声明模板模板参数时,允许使用
typename
代替class
。普通类型参数可以互换使用这两个关键字,但模板模板参数之前仅限于使用class
。更多信息可查看N4051 (opens new window)。 - 对所有非类型模板参数进行常量求值:移除了对作为非类型模板参数出现的指针、引用以及指向成员的指针的语法限制。更多信息可查看N4268 (opens new window)。
- 类型特性的变量模板:所有返回
::value
的类型特性都有对应的_v
变量模板。例如:std::is_integral<T>::value
对应std::is_integral_v<T>
;std::is_class<T>::value
对应std::is_class_v<T>
。 这种改进延续了C++14中为返回::type
的类型特性添加_t
后缀(模板别名)的做法。这样的变更可以显著缩短模板代码。更多信息可查看P0006R0 (opens new window)。
- using声明中的包扩展:该特性是对可变参数模板和参数包的增强。现在编译器支持在包扩展中使用
using
关键字:
template <class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
2
3
4
overloaded
类暴露了基类中operator()
的所有重载。在C++17之前,要实现相同的结果,需要对参数包使用递归。overloaded
模式对std::visit
来说是一个非常有用的增强,在“变体”一章的“重载”部分可以了解更多。更多信息可查看P0195 (opens new window)。
- 逻辑操作元函数:C++17添加了实用的模板元函数:
template<class... B> struct conjunction;
—— 逻辑与;template<class... B> struct disjunction;
—— 逻辑或;template<class B> struct negation;
—— 逻辑非。 以下是一个基于提案代码的示例:
template <typename... Ts>
std::enable_if_t<std::conjunction_v<std::is_same<int, Ts>...>>
PrintIntegers(Ts... args) {
(std::cout <<... << args) << '\n';
}
2
3
4
5
上述函数PrintIntegers
可以处理数量可变的参数,但所有参数都必须是int
类型。这些辅助元函数可以提高高级模板代码的可读性。它们在<type_traits>
头文件中可用。更多信息可查看P0013 (opens new window)。
std::void_t转换特性
这是一个非常简单(对于没有实现针对核心语言工作组(CWG)1558问题(针对C++14)修复的编译器,可能需要更复杂的版本。)的元函数,它将类型列表映射为void
:
template <class... >
using void_t = void;
2
void_t
在使用SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)处理格式错误的类型时非常方便。例如,它可用于检测函数重载:
void Compute(int&) {} // 示例函数
template <typename T, typename = void>
struct is_compute_available : std::false_type {};
template <typename T>
struct is_compute_available<T,
std::void_t<decltype(Compute(std::declval<T>()))>>
: std::true_type {};
static_assert(is_compute_available<int&>::value);
static_assert(!is_compute_available<double&>::value);
2
3
4
5
6
7
8
9
10
11
12
is_compute_available
用于检查针对给定模板参数是否存在Compute()
的重载。如果表达式decltype(Compute(std::declval<T>()))
有效,编译器将选择模板特化版本。否则,会触发SFINAE,选择主模板。更多信息可查看N3911 (opens new window)。
# 编译器支持
特性 | GCC | Clang | MSVC |
---|---|---|---|
类模板的模板参数推导 | 7.0/8.0²¹ | 5.0 | VS 2017 15.7 |
标准库中的推导指南 | 8.0²² | 7.0/开发中²³ | VS 2017 15.7 |
用auto声明非类型模板参数 | 7.0 | 4.0 | VS 2017 15.7 |
折叠表达式 | 6.0 | 3.9 | VS 2017 15.5 |
if constexpr | 7.0 | 3.9 | VS 2017 |
批注
²¹ GCC 8.0中对类模板的模板参数推导进行了额外改进(P0512R0 (opens new window))。 ²² LibSTDC++的状态页面 (opens new window)中未列出推导指南,因此我们可以认为它们是作为类模板的模板参数推导的一部分实现的。 ²³ LibC++的状态页面 (opens new window)提到,到目前为止,
<string>
、序列容器、容器适配器和<regex>
部分已实现。