Table of Contents generated with DocToc
模板从1988年引入以来,经过了1988,2011,2014,2017几个里程碑,可以说自C++98标准后模板一直都是新特性中比较主要的那一部分。在C++98之后,下列是比较重要的模板相关特性:
- 角括号hack:C++11已经移除模板中两个
>
中间必须有空格的限制。 - 默认函数模板实参:C++11引入。
typedef
模板:C++11引入等价的别名模板。typeof
运算符:C++11引入decltype
运算符,做同样的事情,为了避免与使用编译器扩展的现有代码(gcc有typeof
这个扩展)冲突,使用了另一个名称。- 静态属性:未添加(这是什么东西?)。
- 自定义模板实例化诊断信息:由
static_assert
提供。 - 列表模板形参:C++11中变成了参数包这个特性。
- 布局控制:C++11的
alignof alignas
覆盖了这个需求。 - 初始化器推导:C++17引入类模板实参推导,解决同样问题。
- 函数表达式:C++11的lambda表达式提供完全相同的功能。
上面基本都是已经加入语言特性中的,在最初的想法中某些还没有成为现实,下面介绍这些东西,以及一些新冒出来的可能的新特性。这些特性或多或少都面临一些挑战,不是那么容易就能够引入,当然也有基本和现有的东西无冲突,或者已经引入的。总览:
- 宽松的
typename
规则C++20已经引入,小特性影响不大。 - concepts在C++20已经引入,可用性还不错。
- 模块C++20已经引入,可用性待考。
- 反射已经有提案了,不过C++23并没有进入标准,C++26有望进入标准。
- 其他的东西目前看起没有太大希望,让我们期待到C++32会不会有。
未来可能会放松在类模板中的typename
使用限制,某些场景不再强制要求添加typename
关键字。必须是不会有歧义的场景:
- 返回值和参数类型、命名作用域以及类作用中的成员函数声明,函数与成员函数模板同理,以及lambda表达式中。
- 变量类型、变量模板、静态数据成员声明。
- 别名或者别名模板声明中
=
后面的内容。 - 模板类型参数的默认实参。
- 出现在转换运算符中的类型:
static_cast const_cast reinterpret_cast dynamic_cast
。 new
表达式中用到的类型。
目前进展:
- C++20已经放宽了
typename
限制,至于具体放宽了哪些限制,见待决名的 typename 消歧义符。 - 具体细节我没有太大兴趣去了解与记忆,惯例能加则加。
- 不用去记,基本都符合直觉,都是只能是类型的场景。
现在某些类型还是不能作为模板的非类型模板参数:
- 字符串字面量,有说法说可以支持字符串用
char...
捕获。但是无论如何都面临着一个如何存储这个字符串的问题。两个同样的不同编译单元中实例化出的类型返回这个字符串(以const char*
形式)等不等,很明显会出现问题。 - 一个相关议题是提供浮点类型非类型模板参数。这个其实没有多大挑战就能被编译器实现,还不会有副作用。
- 然后推广到一般情况是,是否允许能够作为编译期常量的类型(C++引入的
constexpr
实现了字面量类类型)作为非类型模板参数,可以发现会面临和字符串字面量一样的问题,即两个字面量的相等性判断将完全依赖于operator==
,但这不是平凡的问题。这个是否相等决定了两个模板实例是否是同一个的问题,并且这个operator==
还需要用在链接期的重整后名称的检查。 - 总之C++20还没有支持以上任何一点。
上一章提到类模板可以偏特化以提供特化的实现,而函数模板可以重载,这两种机制是有些不同的:
- 偏特化并没有引入一个全新的模板,偏特化是已有的主模板的扩展。类模板查找时,主模板是最先查找的。主模板找过之后去找偏特化,找到则会实例化其类定义。全特化也是一样工作方式。
- 函数模板重载则是相互之间完全独立的模板,当要决定选择哪个模板时,所有模板都在重载集合中,同时被考虑,从其中选择最佳匹配。一开始看起来可能有足够的选择,但面临着一些限制:
- 可以在不更改类实现的情况下偏特化成员模板,但是添加重载函数却需要修改类的定义。某些情况下无法修改类定义(比如使用别人提供的库)。另外现在C++标准不允许添加新模板到
std
命名空间,但是允许特化命名空间中的模板。 - 为了重载函数模板,他们的函数形参需要在某种程度上不同。仅仅返回值不同就不是有效重载,要这样调用必须显式指定模板实参。
- 在没有重载的时候能够工作的代码,重载了新的函数之后可能就不能工作了(可能在现有调用参数下产生歧义)。
- 将一个函数模板或者函数模板的特定实例声明为类的友元,它的重载不是友元。而特化可以做到这一点,主模板是友元,特化也会成为友元。
- 可以在不更改类实现的情况下偏特化成员模板,但是添加重载函数却需要修改类的定义。某些情况下无法修改类定义(比如使用别人提供的库)。另外现在C++标准不允许添加新模板到
- 目前担心的点主要在于特化与重载的交互会让事情变得复杂。比如两个重载的函数模板都能够偏特化为某一形式,那么它的主模板是谁?
template<typename T>
void add(T& x, int i);
template<typename T1, typename T2>
void add(T1 a, T2, b);
template<typename T>
void add<T*> (T*& a, int i);
- 因为一系列问题没有解决,目前C++20依然不存在这个特性。
- 作为替代可以使用类模板偏特化加静态成员函数曲线地部分解决这个问题。
在不用指定其他有默认实参的模板参数的情况下支持仅指定其中一个模板实参是一个非常自然的提议,比如对于模板参数非常多的类模板,很多模板形参都有默认实参,在只想为其中一个带有默认实参的形参指定实参,而其他的使用默认实参时,还必须指定所有它前面的实参,这显得非常冗长:
- 一个自然的想法是支持命名模板实参(或者叫关键字模板实参,Python中函数参数就支持这样传递),比如以类似这样的语法:
template<typename T,
typename Move = defaultMove<T>,
typename Copy = defaultCopy<T>,
typename Swap = defaultSwap<T>,
typename Init = defaultInit<T>>
class A;
A<Matrix, .Swap = matrixSwap> a;
- C99标准中为结构的初始化引入了命名初始化器,通过指定名称初始化指定成员,和这个有点类似。
- 但要引入这个特性,模板参数名称就必须固定下来,也就是必须成为类的公共接口的一部分,而一直以来模板形参的名称都是不重要的,不是模板公共接口的一部分。这为引入这个特性带来了困难。
- 目前来说C++20依旧没有引入这个特性。
- 第21章的21.4节描述了一种利用现有语言特性解决这个问题的技巧。
除了可以想象函数模板可以偏特化,也可以想象类模板可以重载的场景,当然就像函数重载一样,多套模板形参需要有差别。C++20中依然是没有的,不过可以期待以后的标准。
当前为参数包展开进行模板实参推导只能工作于参数包展开位于模板实参列表末尾的情况:
template<typename... Args>
struct Front;
template<typename First, typename... Rest>
struct Front<First, Rest...> {
using Type = FrontT;
};
- 这个例子传入实参进行推导时,很容易推导出
First
类型参数和Rest
类型参数包。 - 但是如果,包在前面展开,则是非法的:
template<typename... Args>
struct Back;
template<typename... Rest, typename Last>
struct Back<Rest..., Last> {
using Type = FrontT;
};
- 目前C++只允许模板参数包出现在类模板参数列表末尾,而不允许出现在中间。尽管这种推导看起来是很好做的。
- C++类模板当前也不允许出现多个参数包,尽管已经有了类模板参数推导(函数模板因为有推导的存在是允许多个参数包且可以不出现在末尾的)。
- 最新的C++20/23是不支持的。
在模板编程中,规范化是一种美德(regularity is a virtue),如果一种简单的结构能够覆盖所有情况,那么模板就会变得简单。模板中有些不太规范的点是类型:
- 用万能引用
auto&&
或者decltype(auto)
接收返回值时是不能接收void
的。
auto&& r = f(); // ERROR when f returns void
decltype(auto) r = f(); // ERROR when f returns void
- 因为
void
是一个不常规的类型。关于在实践中要如何解决这个问题,可以参考第十一章:泛型库关于包装std::invoke
的例子。 - 因为这个问题的存在,很多模板不得不进行特化,或者使用
if constexpr
。如果能够将void
约定为一个完整类型,并且有一个特殊的值,令其引用和值合法,既能够满足向前兼容,又能使模板得到统一。 - C++20目前依旧没有规范化的
void
。
- 模板的复杂度一定程度上来源于:解析模板定义时能做的事情非常有限,大部分的事情都要等到实例化才能做,这是模板定义和模板实例化的(还有模板参数的)上下文交织在一起导致的。
- 当发生错误时,编译器无法分清是模板定义的错误,还是使用了不正确的模板实参,或者模板实参不满足要求的错误。编译器只能把它所知道的所有信息(实例化历史,有哪些候选等)一股脑抛出来(某些编译器在某些情况会有一定改善,但不改变模板报错总体来说就是难于分析这个事实),一个小错误造成成千上万行的错误信息是很常见的。
- 模板的类型检查的核心是怎样在模板内部描述模板的要求。编译器就能够决定在什么地方停下来报错,了解是什么原因。
- 这个问题的一个解决方案是使用概念(concept,C++11开始提案,C++20已实装)。
- 概念是用来表达类型约束的实体。
我们将以程序方式(编码)检查程序的特性的功能称之为反射(refelection),可以解决比如以下问题:
- 一个类型是整型吗?
- 一个类有哪些非静态数据成员?
而元编程(metaprogramming):是指编写能够编写(生成)程序的程序,通常用于编写自动生成代码的程序。
反射元编程(Reflective Metaprogramming):则是指自动分析程序并且适配其中的属性(通常是类型),然后自动生成代码。
在本书第三部分,会探索一些通过模板实现简单形式的反射和模板元编程的手段。在某种意义上,模板实例化也属于元编程的范畴,毕竟编译器通过分析代码生成了针对特定类型的模板实例。
反射已经有提案了,如果顺利的话预期会在C++26进入标准。
参数包很多时候都需要模板递归实例化来解决问题,这可能造成编译效率、二进制膨胀、运行效率问题,当然折叠表达式可以一定程度解决,但不是所有。比如可能还需要从包中取出第N个对象或者类型之类的操作(包选择,pack selection,比如使用Args.[N]
这种语法),经过调研之后发现这种功能需要反射元编程的支持,所以在C++依然没有反射的现在还没有引入。
传统的头文件/源文件代码组织方式有一些缺点:
- 头文件中内容可能会意外改变,比如通过宏。
- 编译每个编译单元时其中包含的每个头文件都被重复解析,意外改变的头文件大幅增加构建时间。
模块(Modules):
- 模块的出现允许库接口被编译为编译器特定的格式。然后这种格式可以被导入到另一个编译单元,其中就不需要再重复解析这个模块内的代码了。大幅降低编译时间。
- 模块中可不可以包含宏,怎么处理是一个实际问题。
- C++20已引入,细节待学习。