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

  1. 当两次声明完全一致,没有额外信息时,会视作重复定义,从而编译失败。比如把以上任意一个模板声明重复一遍。
  2. 尽管声明可以多次,翻译单元中还是只能出现一个定义。如果要使用这个类模板,对于上面的代码还需要补充且仅补充一个定义。
  3. 如果声明了靠前的默认参数(比如上图的第三次声明),且与后面的默认参数无法衔接,这时并不会编译失败,但该模板声明会失效(因为在使用时匹配不上)。

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 功能相等,尽管只是交换了加号两边操作符的顺序,但编译器不一定能认得出来。

函数模板特化与函数从来不相等

函数模板特化后也和具有对应参数和返回值的函数不相等。这意味着:

  1. 类中的与复制赋值、移动赋值、移动构造、复制构造形式相同的函数模板均不会遮掩对应的非模板函数。
  2. 但如果定义了移动构造、复制构造函数模板,也算是声明了构造函数,默认构造函数会被隐式删除。

💡 定义赋值操作符不会遮盖默认构造函数。至少在 C++17,声明移动构造、复制构造函数 = default 也不会遮盖默认构造函数,但定义会。

  1. 按照 P200 的说法,从模板实例化得到的函数从来不会是移动或复制构造函数,但可以是构造函数。

4 可变参数 aka Parameter Pack

Pack expansion

Pack expansion 指把可变参数用省略号展开成一长串参数的行为。我们能对包展开参数做出额外包装,也可以把多个包组成表达式一起展开,但当多个包一起展开时需要保证包的长度一致。

Fold expression

Fold expression 指的是以相同模式利用参数的行为,这将利用完所有参数。折叠表达式也是包展开。折叠表达式在遇到 || && , 操作符时会有默认初始值,分别是 falsetruevoid 类型。因而如果我们对运算符做出了重载时,||&& 的默认初始值不一定能符合我们的表达含义。因此应该优先考虑带有初始值的折叠表达式。

可变参数模板和 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,否则不能将定义包含在头文件中。或者把它的定义放在非头文件中。

事实上,如果一个函数根本不依赖这个类模板的某个实例,声明其为友元没有意义!