CTTCG 12 Fundamentals in Depth
1 参数化声明
模板分类
模板有四类:类模板、函数模板、变量模板、别名模板。其中别名模板是:
变量模板其实可以不需要 inline(但是最好加上)
C++17 中静态和全局变量都能够用 inline 来修饰。这也意味着类中的静态变量不必在类中声明、类外初始化(非头文件),而是可以在类中直接写 inline。之前笔记里面提到的问题里,inline 加不加都没有关系,因为变量模板本身可以在多个翻译单元中出现。P179
嵌套模板的非原地定义
想要给类模板中的模板在类外提供定义是比较复杂的,需要嵌套模板参数描述:
这里为了给 Handle 函数模板提供定义写了两次 template 参数列表。
构造函数模板会覆盖默认构造函数
虽然类模板的特殊函数模板不会覆盖掉类的同类函数的默认版本,但是如果提供了其他的构造函数模板,默认的构造函数也将不会被定义。这是由默认构造函数的特殊性决定的。
Union templates
函数模板默认参数
如果调用的时候提供了参数,那么 T const &=T{} 这一句不会被实例化,这意味着即便是 T 没有默认构造函数,在用户了解 fill 函数用法并传入了合法的 T 类型值时,编译也不会失败。
虚函数不支持模板
因为虚函数要写入虚表,在编译后必须大小确定,但是模板的实例化是按需进行的,只有链接完成后才能确定函数模板的特化数目。
模板的 linkage
函数模板的 linkage 只能是 C++ 类型。如果外围没有 extern "C"
,这里的 extern 可以不写。
int const zero_int = int{};
按照 const 的全局变量规定,是 internal linkage。但是 template<typename T> int const max_volume = 11;
按照变量模板的规定,是 external linkage。
原始模板(Primary template)
没有任何参数被特化的模板是原始模板;参数有偏特化就是非原始模板(但因为有参数没有被确定下来因而还是模板)。
2 模板形参(Parameters)
模板声明中参数名有时可以省略
模板声明就像函数声明,没用到参数名的时候可以省略参数名。比如 template <typename, int> class X;
就是一个完整的声明。模板中有些参数可能依赖其他参数,这个时候被依赖的参数就需要参数名。
类型参数
类型参数本身被假定为一个完整的类型,所以不能加上 class 前缀。以前我们在一个普通类中写 friend class B;
其实既声明了类型 B,又完成了友元声明。在 B 类已经声明的情况下,friend B;
也可以。
非类型参数
可能用到 typename 关键字:
因为访问了 T::Allocator
类型。
也可能用到 class 关键字:
这里附带了对类 X 的声明效果。注意 X 类看起来像个泛型参数,实际上是个具体的类。
非引用类型的非类型参数还会 decay:
这包含了数组、函数、cv 限定符等情况。
非类型参数用 auto 是值语义;用 auto && 是转发引用语义,绑定不了纯右值。
模板模板参数
在 C++17 之前要用 template<…> class
来写,C++17 之后也可以用 template<…> typename
写。
模板模板参数中的参数不能在外面使用:
Template Parameter Packs - 可变长模板参数
类模板的可变长模板参数只能放在参数列表的末尾,不能有默认值,而且一个原始模板只能有一组可变长模板参数。与此相对,类模板偏特化可以有多组可变长模板参数:
正因为可变长模板参数只能放在参数列表的末尾,同时获取一组参数的类型和值是不可能的:
template<typename... Ts, Ts... vals> struct StaticValues {};
→ error
解决方案 1:可以使用 decltype(auto) 或者 auto 可变长参数表达式,然后用 decltype 再把类型推导出来。
解决方案 2:使用嵌套模板。
函数模板的可变长模板参数可以不用放在最后,定义可以通过,但是使用时会报错!下面的模板函数在使用时无法推导出参数 N。(其实相当于只能放在最后了……)
默认模板参数
类模板的默认模板参数可以分次声明,但有着从后向前的要求。作为对比,函数模板的默认参数可以从前面开始声明。
图为 Clang:
其中第二次多声明了一个 T3 的默认值。
GCC
- 当两次声明完全一致,没有额外信息时,会视作重复定义,从而编译失败。比如把以上任意一个模板声明重复一遍。
- 尽管声明可以多次,翻译单元中还是只能出现一个定义。如果要使用这个类模板,对于上面的代码还需要补充且仅补充一个定义。
- 如果声明了靠前的默认参数(比如上图的第三次声明),且与后面的默认参数无法衔接,这时并不会编译失败,但该模板声明会失效(因为在使用时匹配不上)。
Clang
clang 在这里的编译检查更加严格!clang 中必须按顺序从后向前声明默认值(不允许失序),因而在上面代码中第三次提供模板声明时会编译失败。如果去掉第三次声明,交换前两次模板声明顺序,编译也会失败。可见作者使用的编译器也是 clang。
不允许默认参数的场景
类模板偏特化、可变长模板参数、类模板外函数定义、类中的友元函数模板声明(定义是允许的)。P191
3 模板实参(Arguments)
P194 非类型参数必须是几类之一。
其中整数参数可以从 class type 编译期隐式转换而来,也可以通过整数提升得到,但不允许窄化。
指针或引用参数可以从函数或者数组 decay 得到。~~在 C++17 之后,指针或引用参数还可以从子类指针/引用隐式转换到父类指针/引用,也可以从用户定义类型转换得来。~~这同样被要求必须发生在编译期。
💡 关于带有删除线的句子,书上是有说可以的。但是实测到 C++20 都不行。
模板参数不能使用字符串字面量,因为尽管两个字面量的值可能相同,其地址不一定相同。
注意看上图对指针或引用的非类型模板参数的 linkage 要求。
模板模板实参
参数列表数目匹配
就像前面章节”模板模板参数“提到的,模板模板实参在 C++17 之前要求提供的参数与使用时完全匹配:如果传入的模板有默认参数,可能会导致参数数量不一致从而编译失败。这时需要声明对应数量的模板参数,并提供相应的默认值。C++17 之后模板的默认参数也会被考虑,就没有这个问题。
如果在模板模板形参中写 template <typename… > class Cont
,这样可变长参数列表就能接受多个类型,这在一定场景下可以无视类型参数数量。但是这无法匹配非类型参数。
相等性判断
如果两个模板声明除了使用的符号外完全一致,这被认为是相等(equivalent)的两次声明,编译器不会报错。但如果符号一致,形式不一致,而含义一致,这被成为功能相等(functional equivalent)。编译器不被要求找到”功能相等“的声明并报错,但我们不应该这么写。下图中 #1 和 #2 相等。而 #1 和 #3 功能相等,尽管只是交换了加号两边操作符的顺序,但编译器不一定能认得出来。
函数模板特化与函数从来不相等
函数模板特化后也和具有对应参数和返回值的函数不相等。这意味着:
- 类中的与复制赋值、移动赋值、移动构造、复制构造形式相同的函数模板均不会遮掩对应的非模板函数。
- 但如果定义了移动构造、复制构造函数模板,也算是声明了构造函数,默认构造函数会被隐式删除。
💡 定义赋值操作符不会遮盖默认构造函数。至少在 C++17,声明移动构造、复制构造函数 = default
也不会遮盖默认构造函数,但定义会。
- 按照 P200 的说法,从模板实例化得到的函数从来不会是移动或复制构造函数,但可以是构造函数。
4 可变参数 aka Parameter Pack
Pack expansion
Pack expansion 指把可变参数用省略号展开成一长串参数的行为。我们能对包展开参数做出额外包装,也可以把多个包组成表达式一起展开,但当多个包一起展开时需要保证包的长度一致。
Fold expression
Fold expression 指的是以相同模式利用参数的行为,这将利用完所有参数。折叠表达式也是包展开。折叠表达式在遇到 ||
&&
,
操作符时会有默认初始值,分别是 false
、true
和 void
类型。因而如果我们对运算符做出了重载时,||
和 &&
的默认初始值不一定能符合我们的表达含义。因此应该优先考虑带有初始值的折叠表达式。
可变参数模板和 C 语言可变参数函数混淆
在上图中,第一行声明的 T
不是参数包,因此 T…
会被理解成 T, ….
,后面的省略号表示兼容 C 语言的 varargs 语法。因而必须小心使用省略号语法,确认前面的类型是参数包类型。
5 友元
非模板类
使用 friend class Foo;
不仅有声明友元,还有声明这个类的作用。所以,仅仅承担声明友元功能的写法是 friend Foo;
。
声明友元时可以给出函数和原始模板函数的定义,这些友元将成为 hidden friends,可以在类外使用(相当于定义了类外的内联函数,可以在不同翻译单元中出现)。但是声明友元时不能给出非原始模板函数(特化的模板函数)、带有作用域限定符::
类型(这个时候相当于在查找类外的函数或类型,但是找不到或者提供了重复定义)、友元类的定义。
Hidden friends 在类中定义后将不能在类外定义,否则会出现重定义错误,所以不存在 hidden friends 优先级更高的说法。但是这点表现很奇怪:如果不加声明,hidden friends 不能在外面被限定名找到,但是却能和外面的定义冲突。 如果在类中声明,类外定义,那就不是 hidden friends 了,就是普通的友元函数,因而也能被找到。
struct Foo {
int x;
public:
friend bool operator<(Foo a, Foo b) {
// std::cout << "hidden friend\n";
return a.x < b.x;
}
};
// 1. 不能给出定义,否则有重定义错误
// 2. 如果不声明则无法编译,因为 main 函数中用了限定名,所以禁用了 ADL
// 而这个 < 操作符重载原本又是 hidden friend
bool operator<(Foo a, Foo b);
int main() {
std::cout << ::operator<(Foo{1}, Foo{2}) << '\n';
}
友元模板是指 friend 声明可以被模板修饰,使得一组函数、类,或模板类的特定函数成为友元,但是只有函数和原始模板函数能够给出定义(上一段)。
类模板(2023 年 5 月 15 日)
最好在类模板外定义非依赖友元,否则模板类在同一个翻译单元的多次实例化会导致友元被定义多次,从而编译错误。
不过类模板外定义非依赖友元函数又有其他问题,比如必须加上 inline,否则不能将定义包含在头文件中。或者把它的定义放在非头文件中。
事实上,如果一个函数根本不依赖这个类模板的某个实例,声明其为友元没有意义!