C++ Templates Complete Guide 2nd Part1 翻译
第一章
函数模板
本章介绍函数模板。函数模板是被参数化的函数,被参数化的函数代表一系列函数。
1.1 函数模板初探
函数模板提供了可以针对不同类型调用的功能行为。换句话说,一个函数模板表示了一系列函数。该表示看上去很像一个普通函数,只是函数的一些元素没有确定:这些元素被参数化了。为了说明,我们来看一个简单的例子。
1.1.1 定义模板
下面是一个函数模板,它返回两个数的最大值:
- template<typename T>
- T max(T a, T b)
- {
- // if b < a then yield a else yield b
- return b < a ? a : b;
- }
这个模板的定义指定了一系列返回两个数最大值的函数,这两个数作为函数参数 a 和 b 传递 1。这些参数的类型作为模板参数 T 被留空。如本例所示,模板参数必须用以下形式的语法进行声明:
- template< 逗号分割的参数列表 >
在我们的例子中,参数列表为 typename T。请注意 < 和 > 标记是如何被用作括号的;我们把它们称为尖括号。关键字 typename 引入了一个类型参数。这是到目前为止 C++ 程序中最常见的一种模板参数,但是其他参数也是可能的,并且我们稍后会讨论它们(参见第 3 章)。
这里,类型参数是 T。你可以使用任何标识符作为参数名,但是使用 T 是惯例。类型参数表示任意的类型,它是在调用方调用函数的时候由调用方确定的。你可以使用任何类型(基本类型、类等),只要它提供模板使用的操作。在这种情况下,T 类型必须支持运算符 <,因为 a 和 b 使用这个运算符被比较。从 max() 的定义来看,或许不那么明显的是,为了能被返回,T 类型的值也必须是可复制的 2。
出于历史原因,你可以使用关键字 class 而不是 typename 来定义类型参数。在 C++98 标准的发展历程中,关键字 typename 出现的相对较晚。在此之前,关键字 class 是引入类型参数的唯一方法,这仍然是一种有效的方法。因此,模板 max() 可以等效地定义如下:
- template<class T>
- T max(T a, T b)
- {
- return b < a ? a : b;
- }
在语义上来说,在这个上下文中没有区别。因此,即使你在这里使用了 class,任何类型都能用于模板参数被使用。然而,因为 class 的这种使用可能会引起误解(不仅仅类类型可以被 T 替代),所以在这种情况下,你应该更倾向使用 typename。但是,请注意,与类类型声明不同,在声明类型参数时,关键字 struct 不能被用来代替 typename。
1.1.2 使用模板
以下程序显示了如何使用 max() 函数模板:
- #include "max1.hpp"
- #include <iostream>
- #include <string>
- int main()
- {
- int i = 42;
- std::cout << "max(7,i): " << ::max(7,i) << '\n';
- double f1 = 3.4;
- double f2 = -6.7;
- std::cout << "max(f1,f2): " << ::max(f1,f2) << '\n';
- std::string s1 = "mathematics";
- std::string s2 = "math";
- std::cout << "max(s1,s2): " << ::max(s1,s2) << '\n';
- }
在程序内部,max() 被调用三次:一次用于两个 int,一次用于两个 double,一次用于两个 std::string。每次最大值都被计算。作为结果,该程序具有以下输出:
- max(7,i): 42
- max(f1,f2): 3.4
- max(s1,s2): mathematics
请注意,max() 模板的每次调用都被 :: 限定。这是确保我们的 max() 模板在全局命名空间中被找到。标准库中还有一个 std::max() 模板,它在某些情况下可能会被调用或者可能引发歧义 3。
模板不是被编译成可以处理任何类型的单一实体。相反,模板用于不同的实体,这些实体由不同类型的模板生成 4。因此,max() 是为了这三种类型中的每一种而被编译的。
例如,max() 的第一次调用
- int i = 42;
- ... max(7,i) ...
使用带有 int 作为模板参数 T 的函数模板。因此,它具有调用以下代码的语义:
- int max(int a, int b)
- {
- return b < a ? a : b;
- }
用具体类型替换模板参数的过程叫做实例化。它会生成一个模板实例 5。
请注意,仅仅使用一个函数模板就可以触发这样的实例化过程。程序员不需要单独请求实例化。
同样,max() 的其他调用为 double 和 std::string 实例化 max 模板,就好像它们是被单独地声明和实现的一样:
- double max(double a, double b);
- std::string max(std::string a, std::string b);
还要注意的是,如果结果码是有效的,那么 void 是一个有效的模板参数。
- template<typename T>
- T foo(T*)
- {
- }
- void* vp = nullptr;
- foo(vp); //正确:推断出 foo(void*)
1.1.3 两个阶段的翻译
尝试实例化一个模板,它的类型不支持里面使用的所有操作,将会导致一个编译时错误。例如:
- std::complex<float> c1, c2; // doesn't provide operator <
- ::max(c1, c2); // ERROR at compile time
因此,模板分为两个阶段被“编译”:
1. 如果在定义时没有实例化,则检查模板代码本身的正确性,此时忽略模板参数。这包括:
- 发现语法错误,例如缺少封号。
- 发现那些不依赖于模板参数的、使用的未知名称(类型名称、函数名称、……)。
- 检查不依赖于模板参数的静态断言。
2. 在实例化时,模板代码被(再次)检查,以确保所有的代码都是有效的。也就是说,尤其是现在,所有依赖于模板参数的部分都要经过双重检查。
例如:
- template<typename T>
- void foo(T t)
- {
- undeclared(); // first-phase compile-time error if undeclared() unknown
- undeclared(t); // second-phase compile-time error if undeclared(T) unknown
- static_assert(sizeof(int) > 10, // always fails if sizeof(int)<=10
- "int too small");
- static_assert(sizeof(T) > 10, // fail if instantiated for T with size <=10
- "T too small");
- }
名称被检查两次的事实被称为两阶段查找,并且将在 249 页的 14.3.1 节中详细讨论。
请注意,一些编译器不执行第一阶段的全部检查 6。因此,在模板代码至少被实例化一次之前,你可能看不到一般问题。
编译和链接
两阶段翻译导致了一个在实践中处理模板的重要问题:当函数模板在一个触发它实例的方式下被使用,编译器将(在某一时刻)需要看到该模板的定义。当一个函数的声明足以编译它的用法时,这打破了普通函数在通常的编译和链接上的区别。第九章讨论了处理这个问题的方法。目前,让我们采用最简单的方法:在头文件中实现每个模板。
1.2 模板参数推断
当我们调用一个像 max() 这样有一些参数的函数模板时,模板参数由我们传递的参数决定。如果我们给参数类型 T 传递两个 int,那么 C++ 编译器必须推断出 T 是 int。
然而,T 可能只是类型的“一部分”。例如,如果我们声明使用常量引用的 max() :
- template<typename T>
- T max(T const& a, T const& b)
- {
- return b < a ? a : b;
- }
并传递 int,T 被再次推断为 int,因为函数参数与 int const& 相匹配。
类型推断时的类型转换
请注意,自动类型转换在类型推断过程中受到限制:
• 当通过引用方式声明调用参数时,即使再小的转换也不适用于类型推断。用同一个模板参数 T 声明的两个参数必须完全匹配。
• 当按值声明调用参数时,只有微小的衰减转换是被支持的:const 或 volatile 的限定被忽略,引用转换成引用的类型,原始数组或函数转换成相应的指针类型。对于用同一个模板参数 T 声明的两个参数,衰减 类型必须匹配。
例如:
- template<typename T>
- T max(T a, T b)
- …
- int const c = 42;
- max(i, c); // OK: T is deduced as int
- max(c, c); // OK: T is deduced as int
- int& ir = i;
- max(i, ir); // OK: T is deduced as int
- int arr[4];
- foo(&i, arr); // OK: T is deduced as int*
但是,以下是错误的:
- max(4, 7.2); // ERROR: T can be deduced as int or double
- std::string s;
- max("hello", s); // ERROR: T can be deduced as char const [6] or std::string
有三种方法可以处理此类错误:
1. 转换参数,以使两者匹配:
- max(static_cast<double>(4), 7.2); // OK
2. 明确指定(或限定)T 的类型,以防止编译器尝试类型推断。
- max<double>(4, 7.2); // OK
3. 指定参数可以有不同的类型
第 9 页的 1.3 节将详细说明这些选项。第 108 页的 7.2 节和第 15 章将详细讨论类型推断过程中的类型转换规则。
默认参数的类型推断
还要注意,类型推断不适用于默认调用参数。例如:
- template<typename T>
- void f(T = "");
- …
- f(1); // OK: deduced T to be int, so that it calls f<int>(1)
- f(); // ERROR: cannot deduce T
为了支持这种情况,你还需要为模板参数声明一个默认参数,这将在第 13 页的 1.4 节中讨论:
- template<typename T = std::string>
- void f(T = "");
- …
- f(); // OK
1.3 多参数模板
正如我们到目前为止所看到的,函数模板有两组不同的参数:
1. 模板参数,它们在函数模板名称前面的尖括号中声明:
- template<typename T> // T is template parameter
2. 调用参数,它们在函数模板名称后面的括号中声明:
- T max(T a, T b) // a and b are call parameters
你可以有任意多个模板参数。例如,你可以使用两个可能不同类型的调用参数来定义 max() 模板:
- template<typename T1, typename T2>
- T1 max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
- …
- auto m = ::max(4, 7.2); // OK, but type of first argument defines return type
将不同类型的参数传递给 max() 模板,似乎是可取的,但是,如本例所示,这带来了一个问题。如果你使用其中一个参数类型作为返回值,则另一个参数的值可能会被转换为该类型,而不管调用方的意图。因此,返回类型取决于调用参数的顺序。66.66 和 42 的最大值将是 double 66.66,而 42 和 66.66 的最大值将是 int 66。
C++ 提供了不同的方法来处理这个问题:
• 为返回类型引入第三个模板参数。
• 让编译器找出返回类型。
• 将返回类型声明为两个参数类型的 “公共类型”。
接下来将讨论所以这些选项。
1.3.1 模板参数的返回类型
我们早些的讨论表明,模板参数推断允许我们使用与调用普通函数相同的语法来调用函数模板:我们不必显式指定模板参数所对应的类型。
但是,我们还提到,我们可以明确指定模板参数所使用的类型:
- template<typename T>
- T max(T a, T b)
- …
- max<double>(4, 7.2); // instantiate T as double
如果此时模板参数和调用参数之间没有联系,并且模板参数无法被确定,你必须通过调用来显式指定模板参数。例如,你可以引入第三个模板参数类型来定义函数模板的返回类型:
- template<typename T1, typename T2, typename RT>
- RT max(T1 a, T2 b);
但是,模板参数推断没有考虑返回类型 7,并且 RT 没有出现在函数调用参数的类型中。因此,RT 无法被推断 8。
因此,你必须显式指定模板参数列表。例如:
- template<typename T1, typename T2, typename RT>
- RT max(T1 a, T2 b);
- …
- ::max<int,double,double>(4, 7.2); // OK, but tedious
到目前为止,我们已经看了这些例子,其中所有或没有函数模板参数的情况被明确提及。另一种方法是只明确指定第一个参数,并允许推断过程推断处其余参数。通常,你必须指定所有参数,直到最后一个不能隐式确定类型的参数。因此,如果你在我们的示例中更改模板参数的顺序,则调用方只需要指定返回类型。
- template<typename RT, typename T1, typename T2>
- RT max(T1 a, T2 b);
- …
- ::max<double>(4, 7.2); // OK: return type is double, T1 and T2 are reduced
在这个例子中,max() 调用显式地将 RT 设置为 double,但是参数 T1 和 T2 从调用参数中被推断为 int 和 double。
请注意,max() 的这些修改版本不会带来显著的优势。对于单参数版本,如果两个不同类型的参数被传递,则你已经可以指定参数(和返回)的类型了(正如我们在接下来的章节中讨论其他模板问题时所做的那样)。
推断过程的细节见第 15 章。
1.3.2 推断返回类型
如果一个返回类型依赖于模板参数,那么推断返回类型最简单、最好的方法就是让编译器去发现。自 C++14 以来,通过简单地不声明任何返回类型来实现是可能的(你仍然必须声明返回类型为 auto):
- template<typename T1, typename T2>
- auto max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
事实上,没有对应拖尾返回类型(与结尾的 -> 被一起引入)的返回类型对 auto 的使用,表明实际的返回类型必须从函数体的返回语句中推断出来。当然,从函数体推断返回类型必须是可能的。因此,代码必须可用,并且多个返回语句必须匹配。
在 C++14 之前,这只可能让编译器或多或少地将函数的实现作为其声明的一部分来确定返回类型。在 C++11 中,我们可以受益于这样一个事实,即拖尾返回类型语法允许我们使用调用参数。也就是说,我们可以声明返回类型是从 operator?: 的输出结果派生出来的。
- template<typename T1, typename T2>
- auto max(T1 a, T2 b) -> decltype(b < a ? a : b)
- {
- return b < a ? a : b;
- }
这里,结果类型是由运算符 ?: 的规则决定的,这相当复杂但通常会产生直观的预期结果(例如,如果 a 和 b 具有不同的算术类型,则会为结果找到一个共同的算术类型)。
请注意,
- template<typename T1, typename T2>
- auto max(T1 a, T2 b) -> decltype(b < a ? a : b);
是一个声明,因此编译器在编译时使用调用参数 a 和 b 的 operator?: 规则来发现 max() 的返回类型。实现不一定要匹配。事实上,在声明中使用 true 作为 operator?: 的条件就足够了:
- template<typename T1, typename T2>
- auto max(T1 a, T2 b) -> decltype(true? a : b);
然而,在任何情况下,这个定义都有一个明显的缺点:返回类型可能是引用类型,因为在某些情况下,T 可能是引用。出于这个原因,你应该返回从 T 衰减的类型,如下所示:
- #include <type_traits>
- template<typename T1, typename T2>
- auto max(T1 a, T2 b) -> typename std::decay<decltype(true ? a : b)>::type
- {
- return b < a ? a : b;
- }
这里,类型特性 std::decay<> 被使用,它返回 type 成员中的结果类型。它是在 <type_trait> 标准库(见第 732 页的节 D.5)中被定义的。因为 type 成员是类型,所以为了访问它,你必须用 typename 来限定这个表达式(见第 67 页的 5.1 节)。
请注意,auto 类型的初始化总是会衰减。这也适用于返回的类型仅仅是 auto 的时候。auto 作为返回类型的行为与下面的代码一样,其中 a 被定义为 i 的衰减类型,int。
- int i = 42;
- int const& ir = i; // ir refers to i
- auto a = ir; // a is declared as new object of type int
1.3.3 作为通用类型的返回类型
自从 C++11 以来,C++ 标准库提供了一种方法来指定选择“更通用的类型”。std::common_type<>::type 产出作为模板参数传入的两个(或多个)不同类型的“公共类型”。例如:
- #include <type_traits>
- template<typename T1, typename T2>
- std::common_type_t<T1, T2> max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
同样,std::common_type 是一个类型特性,在 <type_trait> 中定义,它产出一个有 type 成员作为结果类型的结构体。因此,它的核心用法如下:
- typename std::common_type<T1, T2>::type // since C++11
但是,从 C++14 开始,你可以简化特性的使用,通过将 _t 附加在特性名称后面,并且省略 typename 和 ::type(详情见第 40 页的 2.8 节),这样返回类型定义简化成:
- std::common_type_t<T1, T2> // equivalent since C++14
std::common_type<> 方法使用一些多变的模板程序来实现,这将在第 622 页的 26.5.2 节中讨论。在内部,它根据操作符 ?: 的语言规则或者特定类型的特定实现来选择结果类型。因此,::max(4,7.2) 和 ::max(7.2,4) 都产出相同 double 类型的相同值 7.2。请注意,std::common_type<> 也会衰减。详情见第 732 页的节 D.5。
1.4 默认模板参数
你还可以定义模板参数的默认值。这些值称为默认模板参数,可用于任何类型的模板 9。它们甚至可以参考前面的模板参数。
例如,如果你想把定义返回类型的方法和拥有多个参数类型的能力结合起来(如前一节所述),你可以为返回类型引入一个模板参数 RT,以两个参数的公共类型作为默认情况。同样,我们有多种选择:
1. 我们可以直接使用 operator?:。然而,因为我们必须在调用参数 a 和 b 被定义之前应用 operator?:,所以我们只能使用它们的类型:
- #include <type_traits>
- template<typename T1, typename T2,
- typename RT = std::decay_t<decltype(true ? T1() : T2())> >
- RT max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
请再次注意,std::decay_t<> 的使用是为了确保任何引用不被返回 10。
还要注意,这个实现要求我们能够调用传递类型的默认构造函数。还有一个解决方案,即使用 std::declval,然而这使得声明更加复杂。参见第 166 页 11.2.3 节的例子。
2. 我们还可以使用 std::common_type<> 类型特性来指定返回类型的默认值:
- template<typename T1, typename T2,
- typename RT = std::common_type<T1,T2>>
- RT max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
请再次注意,std::common_type<> 会衰减,以至于返回值不能成为引用。
在所有情况下,作为调用者,你现在可以使用返回类型的默认值:
- auto a = ::max(4, 7.2);
或者在其他所有参数后面,显示地指定返回类型:
- auto b = ::max<double, int, long double>(7.2, 4);
然而,我们又有一个问题,即我们必须指定三种类型才能指定返回类型。相反,我们需要能够将返回类型作为第一个参数,同时仍然能够从参数类型中推断出它。原则上,即使跟着没有默认值的参数,最前的函数模板参数也可以有默认值:
- template<typename RT = long, typename T1, typename T2>
- RT max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
例如,使用此定义,你可以调用:
- int i;
- long l;
- …
- max(i, l); // returns long(default argument of template parameter for return type)
- max<int>(4, 42); // returns int as explicitly requested
然而,这种方法只有在模板参数有 “自然” 默认值的情况下才有意义。这里,我们需要模板参数的默认值依赖于前面的模板参数。原则上这是可能的,正如我们在第 621 页的 26.5.1 节中讨论的,但是这种技术依赖于类型特性,并使定义变得复杂。
出于所有的这些原因,如第 11 页的 1.3.2 节中提出的那样,最好并且最简单的解决方案是让编译器推断出返回类型。
1.5 重载函数模板
和普通函数一样,函数模板可以被重载。也就是说,你可以有具有相同函数名的不同函数定义,这样,当在函数调用中使用该名称时,C++ 编译器就必须决定要调用各种候选函数中的哪一个。这个决定的规则可能会变得相当复杂,即使在没有模板的情况下。在本节中,我们将讨论模板被调用时的重载。如果你还不熟悉不使用模板时的重载基本规则,请查看附录 C,其中我们提供了重载解析规则相当详细的调查。
下面的小程序演示了如何重载函数模板:
- // maximum of two int values:
- int max(int a, int b)
- {
- return b < a ? a : b;
- }
- // maximum of two values of any type:
- template<typename T>
- T max(T a, T b)
- {
- return b < a ? a : b;
- }
- int main()
- {
- ::max(7, 42); // calls the nontemplate for two ints
- ::max(7.0, 42.0); // calls max<double> (by argument deduction)
- ::max('a', 'b'); // calls max<char> (by argument deduction)
- ::max<>(7, 42); // calls max<int> (by argument deduction)
- ::max<double>(7, 42); // calls max<double> (no argument deduction)
- ::max('a', 42.7); // calls the nontemplate for two ints
- }
如本例所示,非模板函数可以与同名的函数模板并存,并且可以用相同的类型进行实例化。在所有其他因素相同的情况下,重载解析过程更喜欢非模板而不是从模板生成的。第一个调用属于这个规则:
- ::max(7, 24); // both int values match the nontemplate function perfectly
然而,如果模板可以生成一个具有更好匹配的函数,则选择模板。max() 的第二次和第三次调用就证明了这一点:
- ::max(7.0, 42.0); // calls max<double> (by argument deduction)
- ::max('a', 'b'); // calls max<char> (by argument deduction)
这里,模板是一个更好的匹配,因为不需要从 double 或 char 到 int 的转换(关于重载解析的规则,请参见第 682 页的节 C.2)。
也可以显式地指定一个空的模板参数列表。该语法表明,只有模板可以解析调用,但所有模板参数都应该根据调用参数被推断出来。
- ::max<>(7, 42); // calls max<int> (by argument deduction)
因为推断出的模板参数不考虑自动类型转换,而是考虑一般的普通函数参数,所以最后一次调用使用非模板函数(同时 'a' 和 42.7 都被转换为 int):
- ::max('a', 42.7); // only the nontemplate function allows nontrivial conversions
一个有趣的例子是重载最大值模板,以便只能显式地指定返回类型:
- template<typename T1, typename T2>
- auto max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
- template<typename RT, typename T1, typename T2>
- RT max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
现在我们可以调用 max(),例如,如下所示:
- auto a = ::max(4, 7.2); // use first template
- auto b = ::max<long double>(7.2, 4); // use second template
然而,当调用:
- auto c = ::max<int>(4, 7.2); // ERROR: both function templates match
两个模板都会匹配,这通常导致重载解析程序倾向于不匹配,并且导致一个歧义性错误。因此,当重载函数模板时,你应该确保对于任何调用,只有一个模板匹配。
一个有用的例子是为指针和普通的 C 字符串重载最大值模板:
- #include <cstring>
- #include <string>
- // maximum of two values of any type:
- template<typename T>
- T max(T a, T b)
- {
- return b < a ? a : b;
- }
- // maximum of two pointers:
- template<typename T>
- T* max(T* a, T* b)
- {
- return *b < *a ? a : b;
- }
- // maximum of two C-strings:
- char const* max(char const* a, char const* b)
- {
- return std::strcmp(b, a) < 0 ? a : b;
- }
- int main()
- {
- int a = 7;
- int b = 42;
- auto m1 = ::max(a, b); // max() for two values of type int
- std::string s1 = "hey";
- std::string s2 = "you";
- auto m2 = ::max(s1, s2); // max() for two values of type std::string
- int* p1 = &b;
- int* p2 = &a;
- auto m3 = ::max(p1, p2); // max() for two pointers
- char const* x = "hello";
- char const* y = "world";
- auto m4 = ::max(x, y); // max() for two C-strings
- }
请注意,在 max() 的所有重载中,我们按值传递参数。一般来说,在重载函数模板时,最好不要进行不必要的更改。你应该限制更改的参数数量或者明确指定模板参数。否则,可能会出现意想不到的结果。例如,如果你实现你的 max() 模板来通过引用传递参数,并且以两个按值传递的 C 字符串重载它,那么你不能使用三个参数的版本来计算三个 C 字符串的最大值:
- #include <cstring>
- // maximum of two values of any type (call-by-reference)
- template<typename T>
- T const& max(T const& a, T const& b)
- {
- return b < a ? a : b;
- }
- // maximum of two C-strings (call-by-value)
- char const* max(char const* a, char const* b)
- {
- return std::strcmp(b, a) < 0 ? a : b;
- }
- // maximum of three values of any type (call-by-reference)
- template<typename T>
- T const& max(T const& a, T const& b, T const& c)
- {
- return max(max(a, b), c); // error if max(a,b) uses call-by-value
- }
- int main()
- {
- auto m1 = ::max(7, 42, 68); // OK
- char const* s1 = "frederic";
- char const* s2 = "anica";
- char const* s3 = "lucas";
- auto m2 = ::max(s1, s2, s3); // run-time ERROR
- }
问题是,如果对三个 C 字符串调用 max(),那么语句
- return max(max(a, b), c);
成为运行时错误,因为对于 C 字符串,max(a,b) 创建一个新的临时本地值,该值由引用返回,但该临时值在返回语句完成后立即过期,从而使 main() 具有悬空引用。不幸的是,这个错误是非常微妙的,可能不会在所有情况下都展现出来 11。
请注意,相比之下,在 main() 中对 max() 的第一次调用不会遇到相同的问题。那里为参数(7、42 和 68)创建了临时变量,但是这些临时变量是在 main() 中创建的,在这里它们会一直存在,直到语句完成。
由于精细的重载解析规则,代码的行为可能与预期的不同,这只是其中的一个示例。此外,请确保在调用函数之前声明了函数的所有重载版本。这是因为当进行相应的函数调用时,并不是所有的重载函数都是可见的,这一事实可能很重要。例如,定义了 max() 的三参数版本,而没有看到针对 int 的特殊双参数 max() 版本的声明,这导致双参数模板会被三参数版本使用:
- #include <iostream>
- // maximum of two values of any type:
- template<typename T>
- T max(T a, T b)
- {
- std::cout << "max<T>() \n";
- return b < a ? a : b;
- }
- // maximum of three values of any type:
- template<typename T>
- T max(T a, T b, T c)
- {
- return max(max(a, b), c); // uses the template version even for ints
- // because the following declaration comes
- // too late:
- }
- // maximum of two int values:
- int max(int a, int b)
- {
- std::cout << "max(int,int) \n";
- return b < a ? a : b;
- }
- int main()
- {
- ::max(47, 11, 33); // OOPS: uses max<T>() instead of max(int,int)
- }
我们将在第 217 页的 13.2 节讨论细节。
1.6 但是,我们不应该……?
很可能,即使是这些简单的函数模板例子也会引发更多的问题。有三个问题可能很常见,我们应该在这里简单讨论一下。
1.6.1 按值传递还是按引用传递?
你可能会想,为什么我们通常声明函数来按值传递参数,而不是使用引用。一般来说,除了便宜的简单类型(如基本类型或 std::string_view)以外,建议对其他类型使用按引用传递,因为不会创建不必要的副本。
然而,出于几个原因,按值传递通常更好:
• 语法简单。
• 编译器优化得更好。
• 移动语义经常使副本变得便宜。
• 而且有时候根本没有复制或者移动。
此外,对于模板来说,具体方面开始发挥作用:
• 模板可以用于简单类型和复杂类型,因此为复杂类型选择方法可能会对简单类型产生反作用。
• 作为调用者,通常你仍然可以决定按引用传递参数,通过使用 std::ref() 和 std::cref()(参见第 112 页的 7.3 节)。
• 虽然传递字符串常量或原始数组总是会成为问题,但通过引用传递它们通常被认为会成为更大的问题。所有这些将在第 7 章详细讨论。目前在这本书里,我们通常按值传递参数,除非某些功能只有在使用引用时才可能实现。
1.6.2 为什么不内联?
一般来说,函数模板不必使用 inline 声明。与一般的非内联函数不同,我们可以在头文件中定义非内联函数模板,并将该头文件包含在多个翻译单元中。
该规则的唯一例外是针对特定类型模板的完全专门化,因此生成的代码不再是通用的(所有模板参数都已定义)。详见第 149 页的 9.2 节。
从严格的语言定义角度来看,内联仅仅意味着一个函数的定义可以在一个程序中出现多次。然而,这也意味着向编译器提示,对该函数的调用应该 “内联地展开”:这样做可以在某些情况下产生更高效的代码,但在许多其他情况下也会降低代码的效率。如今,编译器通常更擅长在没有 inline 关键字提示的情况下决定这一点。但是,编译器仍然会在该决定中考虑 内联 的存在。
1.6.3 为什么不用 constexpr ?
自 C++11 以来,你可以使用 constexpr,以提供在编译时使用代码计算一些值的能力。对于许多模板来说,这是有意义的。
例如,为了能够在编译时使用最大值函数,你必须按如下方式声明它:
- template<typename T1, typename T2>
- constexpr auto max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
这样,你可以在有编译时上下文的地方,使用最大值函数模板,例如在声明原始数组的大小时:
- int a[::max(sizeof(char), 1000u)];
或者 std::array<> 的大小:
- std::array<std::string, ::max(sizeof(char), 1000u)> arr;
请注意,我们将 1000 作为无符号 int 传递,以避免在模板中比较有符号值和无符号值时出现警告。
第 125 页的 8.2 节将讨论使用 constexpr 的其他例子。然而,为了保持我们对基础的关注,当讨论其他模板特性时,我们通常会跳过 constexpr。
1.7 总结
• 函数模板为不同的模板参数定义了一系列函数。
• 当你根据模板参数,将参数传递给函数参数时,函数模板会为相应的参数类型推断出要实例化的模板参数。
• 你可以明确地限定最前面的模板参数。
• 你可以为模板参数定义默认参数。这些参数可以适用于前面的模板参数,并且后面跟着没有默认参数的参数。
• 你可以重载函数模板。
• 当同其他函数模板重载函数模板时,你应该确保对于任何调用,只有一个函数模板匹配。
• 当你重载函数模板时,请显式地指定模板参数,以限制你的更改。
• 在调用函数之前,确保编译器看到所有重载版本的函数模板。
1 请注意,根据 [StepanovNotes] 的 max() 模板,有意返回 "b < a ? a : b",而不是 "a < b ? b : a",以确保函数正常运行,即使两个值相等但不等价。
2 在 C++17 之前,类型 T 也必须是可复制的,以便能够传入参数,但是从 C++17 开始,即使复制或移动构造函数都无效,你也可以传递临时值(右值,见附录 B)。
3 例如,如果一个参数类型在命名空间 std 中被定义(比如 std::string),那么根据 C++ 的查找规则,就会同时找到全局的和 std 中的 max() 模板。
4 “一个实体放之四海而皆准” 的替代方案是可以现象的,但在实践中没有使用(在运行时效率会比较低)。所有语言规则都基于这样的原则,即不同的模板参数生成不同的实体。
5 术语实例和实例化是在面向对象编程的不同上下文中使用的——即用于一个类的具体对象。然而,因为这本书是关于模板的,所以除非另有说明,我们把这个术语用作对模板的“使用”。
6 例如,某些版本(如 Visual Studio 20133 和 2015)的 Visual C++ 编译器允许出现不依赖于模板参数的未声明名称,甚至一些语法缺陷(比如缺少分号)。
7 推断可以被视为重载解析的一部分——这个过程也不基于返回类型的选择。唯一的例外是转化操作符成员的返回类型。
8 在 C++ 中,返回类型也不能从调用者所使用的调用上下文中被推断出来。
9 在 C++11 之前,默认模板参数只允许在类模板中使用,这是由于函数模板开发中的一个历史故障。
10 同样,在 C++11 中,你必须使用 typename std::decay<…>::type,而不是 std::decay_t<…>(见第 40 页的 2.8 节)。
11 一般来说,一致性编译器甚至不允许拒绝这些代码。
第 2 章
类模板
和函数相似,类也可以被参数化,具有一个或多个类型。容器类(用于管理特定类型的元素)就是这一特性的典型例子。通过使用类模板,当元素类型仍然处于开放的情况下,你可以实现这样的容器类。在本章中,我们使用栈作为类模板的例子。
类模板栈的实现
正如我们对函数模板所做的那样,我们在头文件中声明并定义 Stack<> 类,如下所示:
- #include <vector>
- #include <cassert>
- template<typename T>
- class Stack
- {
- private:
- std::vector<T> elems; // elements
- public:
- void push(T const& elem); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- };
- template<typename T>
- void Stack<T>::push(T const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
- template<typename T>
- void Stack<T>::pop()
- {
- assert(!elems.empty());
- elems.pop_back(); // remove last element
- }
- template<typename T>
- T const& Stack<T>::top() const
- {
- assert(!elems.empty());
- return elems.back(); // return copy of last element
- }
如你所见,类模板是通过 C++ 标准库的一个类模板: vector<> 来实现的。因此我们不必实现内存管理、复制构造函数和赋值运算符,所以我们可以专注于这个模板的接口。
2.1.1 类模板的声明
声明类模板类似于声明函数模板:在声明之前,你必须声明一个或多个标识符作为类型参数。同样,T 通常被用作标识符:
- template<typename T>
- class Stack
- {
- ……
- };
在类模板中,T 可以像其他任何类型一样,用来声明成员和成员函数。在本例中,T 用于将元素的类型声明为 T 的向量,将 push() 声明为使用 T 作为参数的成员函数,并将 top() 声明为返回 T 的函数。
- template<typename T>
- class Stack
- {
- private:
- std::vector<T> elems; // elements
- public:
- void push(T const& elem); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- };
这个类的类型是 Stack<T>,T 是一个模板参数。因此,无论何时在声明中使用这个类的类型,你都必须使用 Stack<T>,除非模板参数可以被推断出来。但是在类模板中,使用不附带模板参数的类名来表示附带模板参数作为参数的类(详见第 221 页的 13.2.3 节)。
例如,如果你必须声明自己的复制构造函数和赋值运算符,它通常如下所示:
- template<typename T>
- class Stack
- {
- ……
- Stack(Stack const&); // copy constructor
- Stack& operator=(Stack const&); // assignment operator
- ……
- };
这在形式上相当于:
- template<typename T>
- class Stack
- {
- ……
- Stack(Stack<T> const&); // copy constructor
- Stack<T>& operator=(Stack<T> const&); // assignment operator
- ……
- };
但是,通常 <T> 表示对特殊模板参数的特殊处理,所以通常最好使用第一种形式。
然而,在类结构之外,你需要:
- template<typename T>
- bool operator==(Stack<T> const& lhs, Stack<T> const& rhs);
请注意,在需要类的名称而不是类型的地方,只能使用 Stack。尤其是当你指定构造函数和析构函数的名称(而不是他们的参数)时。
还要注意,与非模板类不同,你不能在函数或块作用域内声明或定义类模板。一般来说,模板只能定义在全局/命名空间作用域或类声明中。
2.1.2 成员函数的实现
要定义类模板的成员函数,你必须指定它是一个模板,并且你必须使用类模板的完整类型限定。因此 Stack<> 类型的成员函数 push() 的实现如下所示:
- template<typename T>
- void Stack<T>::push(T const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
在此情况下,元素向量的 push_back() 被调用,它将元素追加到向量的末尾。
请注意,向量的 pop_back() 移除最后一个元素,但不返回它。这种行为的原因是为了异常安全。不可能实现一个完全异常安全的 pop() 版本来返回被删除的元素(Tom Cargill 最先在 [CargilleExceptionSafety] 中讨论这个话题,[CargilleExceptionSafety] 中作为第 10 项进行讨论)。然后,忽略这个危险,我们可以实现一个 pop(),返回刚刚移除的元素。为此,我们只需要使用 T 来声明一个元素类型的局部变量。
- template<typename T>
- T Stack<T>::pop()
- {
- assert(!elems.empty());
- T elem = elems.back(); // save copy of last element
- elems.pop_back(); // remove last element
- return elem; // return copy of saved element
- }
当然,对于任何成员函数,也可以将类模板的成员函数实现为内联函数,它在类声明的内部。例如:
- template<typename T>
- class Stack
- {
- ……
- void push(T const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
- ……
- };
2.2 类模板栈的使用
要使用类模板的对象,在 C++17 之前,必须始终显式指定模板参数 1。以下示例显示了如何使用类模板 Stack<>:
- #include "stack1.hpp"
- #include <iostream>
- #include <string>
- int main()
- {
- Stack<int> intStack; // stack of ints
- Stack<std::string> stringStack; // stack of strings
- // manipulate int stack
- intStack.push(7);
- std::cout << intStack.top() << '\n';
- // manipulate string stack
- stringStack.push("hello");
- std::cout << stringStack.top() << '\n';
- stringStack.pop();
- }
通过声明类型 Stack<int>,int 在类模板内部被用作类型 T。因此,intStack 被创建为一个对象,该对象使用 int 的向量作为元素,并且对于所有被调用的成员函数,该类型的代码被实例化。同样,通过声明和使用 Stack<std::string>,创建了一个使用字符串向量作为元素的对象,并且对于所有被调用的成员函数,实例化了该类型的代码。
请注意,实例化代码仅针对那些被调用的模板(成员)函数。对于类模板,成员函数只有在被使用时才会被实例化。这当然节省了时间和空间,并且允许只使用部分类模板,我们将在第 29 页的 2.3 节中讨论。
在本例中,默认构造函数 push() 和 pop() 都针对 int 和字符串进行了实例化。但是,pop() 仅针对字符串进行实例化。如果一个类模板有静态成员,那么对于使用该类模板的每种类型,这些静态成员也会被实例化一次。
一个实例化了的类模板类型可以像任何其他类型一样使用。你可以用 const 或 volatile 限定它,或者从它派生出数组和引用类型。你也可以将它用作类型定义的一部分,连同 typedef 或 using(有关类型定义的详细信息,请参见第 38 页的 2.8 节),或者在构建另一个模板类型时将其用作类型参数。例如:
- void foo(Stack<int> const& s) // parameter s is int stack
- {
- using IntStack = Stack<int>; // IntStack is another name for Stack<int>
- Stack<int> istack[10]; // istack is array of 10 int stacks
- IntStack istack2[10]; // istack2 is also an array of 10 int stacks (same type)
- ……
- }
模板参数可以是任何类型,例如指向 float 的指针或者甚至是 int 栈:
- Stack<float*> floatPtrStack; // stack of float pointers
- Stack<Stack<int>> intStackStack; // stack of stack of ints
唯一的要求是,根据这种类型,调用的任何操作都要是可能的。
请注意,在 C++11 之前,你必须在两个结束的模板括号之间放置空白符:
- Stack<Stack<int> > intStackStack; // OK with all C++ versions
如果你没有这样做,你使用的是运算符 >>,这将导致语法错误:
- Stack<Stack<int>> intStackStack; // ERROR before C++11
旧行为的原因是,它帮助 C++ 编译器的第一轮工作来标记独立于代码语义的源代码。然而,因为缺少空格是一个典型的故障,需要相应的错误消息,所以无论何如,代码的语义越来越需要被考虑。因此,在 C++11 中,在两个结束的模板括号之间放置一个空格的规则随 “尖括号 hack” 被移除了。
2.3 类模板的部分使用
一个类模板通常对它所实例化的模板参数应用多个操作(包括构造和析构)。这可能导致这样的印象:这些模板参数必须为类模板的所有成员函数提供所有必须的操作。但事实并非如此:模板参数只需要提供被需要的操作(而不是可能需要的操作)。
例如,如果类 Stack<> 提供成员函数 printOn() 来打印整个栈的内容,这会为每个元素调用 operator<<:
- template<typename T>
- class Stack
- {
- ……
- void printOn(std::ostream& strm) const
- {
- for (T const elem : elems)
- {
- strm << elem << ' '; // call << for each element
- }
- }
- };
对于没有定义运算符 << 的元素,你仍然可以使用此类:
- Stack<std::pair<int, int>> ps; // note: std::pair<> has no operator<< defined
- ps.push({4, 5}); // OK
- ps.push({6, 7}); // OK
- std::cout << ps.top().first << '\n'; // OK
- std::cout << ps.top().second << '\n'; // OK
只有当你为这样的栈调用 printOn() 时,代码才会产生错误,因为对于这个特定元素类型,它无法实例化 operator<< 的调用:
- ps.printOn(std::cout); // ERROR: operator<< not supported for element type
2.3.1 概念
这就提出了一个问题:我们如何知道模板需要哪些操作才能被实例化?术语概念通常用于表示模板库中重复需要的一组约束。例如,C++ 标准库依赖于这些概念:诸如随机访问迭代器和默认可构造性。
目前(即截至 C++17),概念或多或少只能在文档中表达(如代码注释)。这可能会成为一个严重的问题,因为不遵守约束可能会导致严重的错误信息(参见第 143 页的 9.4 节)。
多年来,也有一些方法和尝试来支持概念的定义和验证,以作为一种语言特性。然而,直到 C++17,这种方法还没有标准化。
从 C++11 开始,你至少可以通过使用 static_assert 关键字和一些预定义的类型特性来检查一些基本的约束。例如:
- template<typename T>
- class C
- {
- static_assert(std::is_default_constructible<T>::value,
- "Class C requires default-constructible elements");
- ……
- };
如果需要默认构造函数,当没有这个断言时编译仍然会失败。然而,错误消息可能包含整个模板历史,从实例化的初始原因到检测出错误的实际模板定义(参见第 143 页的 9.4 节)。
但是,需要检查更复杂的代码,例如,T 类型的对象提供特定的成员函数,或者可以使用运算符 < 进行比较。有关此类代码的详细定义,请参见第 436 页的 19.6.3 节。
关于 C++ 概念的详细定义,请参见附录 E。
2.4 友元
与其用 printOn() 打印栈内容,不如为栈实现 operator<< 来的好。但是,通常 operator<< 必须作为非成员函数实现,然后它可以内联调用 printOn():
- template<typename T>
- class Stack
- {
- ……
- void printOn(std::ostream& strm) const
- {
- ……
- }
- friend std::ostream& operator<<(std::ostream& strm,
- Stack<T> const& s)
- {
- s.printOn(strm);
- return strm;
- }
- };
请注意,这意味着对于 Stack<>,operator<< 不是函数模板,而是一个“普通”函数,会根据需要随类模板一起被实例化 2。
然而,当试图声明友元函数并在之后定义它时,事情变得更加复杂。事实上,我们有两个选择:
1. 我们可以隐式声明一个新的函数模板,它必须使用不同的模板参数,比如 U:
- template<typename T>
- class Stack
- {
- ……
- template<typename U>
- friend std::ostream& operator<<(std::ostream& strm,
- Stack<U> const& s);
- };
再次使用 T,或者跳过模板参数声明,两者都不会起作用(要不内部 T 隐藏外部 T,要么我们在命名空间范围内声明一个非模板函数)。
2. 我们可以将 Stack<> 的这个输出操作符向前声明为模板,但是,这意味着我们首先必须向前声明 Stack<> :
- template<typename T>
- class Stack;
- template<typename T>
- std::ostream& operator<<(std::ostream&, Stack<T> const&);
然后,我们可以将这个函数声明为友元:
- template<typename T>
- class Stack
- {
- ……
- friend std::ostream& operator<< <T>(std::ostream& strm,
- Stack<T> const& s);
- };
请注意,<T> 跟在 operator<< “函数名”的后面。因此,我们将非成员函数模板的特化声明为友元。如果没有 <T>,我们将声明一个新的非模板函数。详见第 211 页的 12.5.2 节。
在任何情况下,对于那些没有定义运算符 << 的元素,你仍然可以使用此类。仅仅对这个栈调用运算符 << 才会导致错误:
- Stack<std::pair<int, int>> ps; // std::pair<> has no operator<< defined
- ps.push({4, 5}); // OK
- ps.push({6, 7}); // OK
- std::cout << ps.top().first << '\n'; // OK
- std::cout << ps.top().second << '\n'; // OK
- std::cout << ps << '\n'; // ERROR: operator<< not supported for element type
2.5 类模板的特化
你可以为某些模板参数特化一个类模板。类似于函数模板的重载(参见第 15 页的 1.5 节),特化类模板允许你优化特定类型的实现,或者为类模板的实例修复特定类型的错误行为。但是,如果你特化了一个类模板,你还必须特化所有成员函数。虽然可以特化类模板中的单个成员函数,但是一旦这样做了,就不能再特化这个已经特化了的成员所属的整个类模板实例。
要特化一个类模板,你必须使用前导 template<>,以及该类模板所特化的类型规范来声明该类。这些类型用作模板参数,并且必须直接在类名后面指定:
- template<>
- class Stack<std::string>
- {
- ……
- };
对于这些特化,成员函数的任何定义都必须被定义为“普通”成员函数,每次出现的 T 都被特化类型替换:
- void Stack<std::string>::push(std::string const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
以下是类型为 std::string 的 Stack<> 特化的完整示例:
- #include "stack1.hpp"
- #include <deque>
- #include <string>
- #include <cassert>
- template<>
- class Stack<std::string>
- {
- private:
- std::deque<std::string> elems; // elements
- public:
- void push(std::string const&); // push element
- void pop(); // pop element
- std::string const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- };
- void Stack<std::string>::push(std::string const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
- void Stack<std::string>::pop()
- {
- assert(!elems.empty());
- elems.pop_back(); // remove last element
- }
- std::string const& Stack<std::string>::top() const
- {
- assert(!elems.empty());
- return elems.back(); // return copy of last element
- }
在本例中,特化使用引用语义将字符串参数传递给 push(),这对于这种特定类型更有意义(不过,我们传递转发引用更好,这将在第 91 页的 6.1 节中讨论)。
另一个区别是使用双端队列而不是向量来管理栈内的元素。虽然此做法在这里没有特别的好处,但是它确实证明了:特化的实现可能看起来与主模板的实现非常不同。
2.6 部分特化
类模板可以部分特化。你可以为特定环境提供特殊的实现,但是有些模板参数仍然必须由用户定义。例如,我们可以为指针定义一个 Stack<> 类的特殊实现:
- #include "stack1.hpp"
- // partial specialization of class Stack<> for pointers:
- template<typename T>
- class Stack<T*>
- {
- private:
- std::vector<T*> elems; // elements
- public:
- void push(T*); // push element
- T* pop(); // pop element
- T* top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- };
- template<typename T>
- void Stack<T*>::push(T* elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
- template<typename T>
- T* Stack<T*>::pop()
- {
- assert(!elems.empty());
- T* p = elems.back();
- elems.pop_back(); // remove last element
- return p; // and return it (unlike in the general case)
- }
- template<typename T>
- T* Stack<T*>::top() const
- {
- assert(!elems.empty());
- return elems.back(); // return copy of last element
- }
通过
- template<typename T>
- class Stack<T*>
- {
- };
我们定义了一个类模板,它仍然是针对 T 进行参数化的,但是专门用于指针。
请再次注意,特化可能会提供一个(稍微)不同的接口。例如,在这里,pop() 返回存储的指针,以便用户可以对移除的值调用 delete,当它是通过 new 创建的时候:
- Stack<int*> ptrStack; // Stack of pointer (special implementation)
- ptrStack.push(new int{42});
- std::cout << *ptrStack.top() << '\n';
- delete ptrStack.pop();
多参数部分特化
类模板也可以特化多个模板参数之间的关系。例如,对于以下类模板:
- template<typename T1, typename T2>
- class MyClass
- {
- ……
- };
以下的部分特化是可能的:
- // partial specialization: both template parameters have same type
- template<typename T>
- class MyClass<T, T>
- {
- ……
- };
- // partial specialization: second type is int
- template<typename T>
- class MyClass<T, int>
- {
- ……
- };
- // partial specialization: both template parameters are pointer types
- template<typename T1, typename T2>
- class MyClass<T1*, T2*>
- {
- ……
- };
以下示例显示了哪个声明使用了哪个模板:
- MyClass<int, float> mif; // use MyClass<T1,T2>
- MyClass<float, float> mff; // use MyClass<T,T>
- MyClass<float, int> mfi; // use MyClass<T,int>
- MyClass<int*, float*> mp; // use MyClass<T1*,T2*>
如果多个部分特化匹配得同等好,则声明是有歧义的:
- MyClass<int, int> m; // ERROR: matches MyClass<T,T>
- // and MyClass<T, int>
- MyClass<int*, int*> m; // ERROR: matches MyClass<T,T>
- // and MyClass<T1*,T2*>
为了解决第二个歧义性,你可以为相同类型的指针提供额外的部分特化:
- template<typename T>
- class MyClass<T*, T*>
- {
- ……
- };
有关部分特化的详细信息,请参见第 347 页的 16.4 节。
2.7 默认类模板参数
对于函数模板,你可以定义类模板参数的默认值。例如,在 Stack<> 类中,你可以定义用于管理元素的容器,将其作为第二个模板参数,并使用 std::vector<> 作为默认值:
- #include <vector>
- #include <cassert>
- template<typename T, typename Cont = std::vector<T>>
- class Stack
- {
- private:
- Cont elems; // elements
- public:
- void push(T const& elem); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- };
- template<typename T, typename Cont>
- void Stack<T, Cont>::push(T const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
- template<typename T, typename Cont>
- void Stack<T, Cont>::pop()
- {
- assert(!elems.empty());
- elems.pop_back(); // remove last element
- }
- template<typename T, typename Cont>
- T const& Stack<T, Cont>::top() const
- {
- assert(!elems.empty());
- return elems.back(); // return copy of last element
- }
请注意,我们现在有两个模板参数,因此成员函数的每个定义都必须用这两个参数来定义:
- template<typename T, typename Cont>
- void Stack<T, Cont>::push(T const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
你可以像之前一样使用这个栈。因此,如果你将第一个也是唯一一个参数作为元素类型传递时,则会使用一个向量来管理这种类型的元素:
- template<typename T, typename Cont = std::vector<T>>
- class Stack
- {
- private:
- Cont elems; // elements
- ……
- };
此外,在程序中声明栈对象时,你可以指定元素的容器:
- #include "stack3.hpp"
- #include <iostream>
- #include <deque>
- int main()
- {
- // stack of ints:
- Stack<int> intStack;
- // stack of doubles using a std::deque<> to manage the elements
- Stack<double, std::deque<double>> dblStack;
- // manipulate int stack
- intStack.push(7);
- std::cout << intStack.top() << '\n';
- intStack.pop();
- // manipulate double stack
- dblStack.push(42.42);
- std::cout << dblStack.top() << '\n';
- dblStack.pop();
- }
通过
- Stack<double, std::deque<double>>
你为 double 声明了一个栈,该栈使用 std::deque<> 在内部管理元素。
2.8 类型别名
为整个类型定义一个新名称,你可以更加方便地使用类模板。
类型定义和别名声明
要简单地为一个完整的类型定义一个新名称,有两种方法:
1. 通过使用关键字 typedef:
- typedef Stack<int> IntStack; // typedef
- void foo(IntStack const& s); // s is stack of ints
- IntStack istack[10]; // istack is array of 10 stack of ints
我们称这个声明为一个 typedef 3,得到的名字叫做一个 typedef-name。
2. 通过使用 using 关键字(自 C++11 开始):
- using IntStack = Stack<int>; // alias declaration
- void foo(IntStack const& s); // s is stack of ints
- IntStack istack[10]; // istack is array of 10 stacks of ints
这叫做别名声明,由 [DosReisMarcusAliasTemplates] 引入。请注意,在这两种情况下,我们都为现有类型定义了新名称,而不是新类型。因此在定义
- typedef Stack<int> IntStack; // typedef
或者
- using IntStack = Stack<int>; // alias declaration
之后,IntStack 和 Stack<int> 是同一类型的两种可互换的符号。
为现有类型定义新名称的这两种选择的通用术语,我们使用术语类型别名声明。新名称叫做类型别名。
因为更易读(定义的类型名称总在 = 的左边),所以对于本书的其余部分,我们更喜欢声明类型别名时使用别名声明语法。
别名模板
与 typedef 不同,别名声明可以模板化,以便为一系列类型提供一个方便的名称。从 C++11 开始,这也是可用的,并称它为别名模板 4。
下面的别名模板 DequeStack 在元素类型 T 上进行实例化。它扩展为一个栈,该栈将其元素存储在一个 std::deque。
- template<typename T>
- using DequeStack = Stack<T, std::deque<T>>;
因此,类模板和别名模板都可以被用作一种参数化类型。但是同样的,别名模板只是为现有类型赋予一个新名称,其仍然可以使用。DequeStack<> 和 Stack<int,std::deque<int>> 都表示相同的类型。
请再次注意,一般来说,模板只能在全局/命名空间范围中,或类声明内,进行声明和定义。
成员类型的别名模板
别名模板对定义类模板成员类型的快捷方式特别有帮助。在定义
- struct C
- {
- typedef …… iterator;
- ……
- };
或者
- struct MyType
- {
- using iterator = ……;
- ……
- };
之后,允许这种用法
- MyTypeIterator<int> pos;
代替以下的用法 5:
- typename MyType<T>::iterator pos;
类型萃取后缀
自从 C++14 开始,标准库使用这种技术为标准库中的所有产生类型的类型萃取,定义了快捷方式。例如,可以写
- std::add_const_t<T> // since C++14
来代替
- typename std::add_const<T>::type // since C++11
标准库定义了
- namespace std
- {
- template <class _Ty>
- using add_const_t = typename add_const<_Ty>::type;
- }
2.9 类模板参数推断
在 C++17 之前,你总是必须将所有模板参数类型传递给类模板(除非它们有默认值)。C++17 以后,必须显式指定模板参数的约束就放宽了。相反,如果构造函数能够推断出所有模板参数(没有默认值),你可以跳过显式定义模板参数。
例如,在前面的所有代码示例中,你可以使用复制构造函数,而无需指定模板参数:
- Stack<int> intStack1; // stack of ints
- Stack<int> intStack2 = intStack1; // OK in all version
- Stack intStack3 = intStack1; // OK since C++17
通过提供传递初始参数的构造函数,你可以支持栈元素的推断。例如,我们可以提供一个可以由单个元素进行初始化的栈:
- template<typename T>
- class Stack
- {
- private:
- std::vector<T> elems; // elements
- public:
- Stack() = default;
- Stack(T const& elem) // initialize stack with one element
- : elems({ elem })
- {
- }
- ……
- };
这允许你按如下方式声明栈:
- Stack intStack = 0; // Stack<int> deduced since C++17
通过用整数 0 初始化栈,模板参数 T 被推断为 int,从而实例化一个 Stack<int>。
请注意以下几点:
由于 int 构造函数的定义,你必须请求默认构造函数可用,并使用其默认行为。因为默认构造函数只有在没有定义其他构造函数时才可用:
- Stack() = default;
参数 elem 被传递给 elems,并带有大括号,这是使用带有 elem 的初始化列表(其中 elem 是唯一参数)来初始化 elems 向量。
- : elems({ elem })
没有一个向量的构造函数能够直接将单个参数作为初始化元素 6。
请注意,与函数模板不同,类模板参数不能只部分推断(通过只显式指定一些模板参数)。详见第 314 页的 15.12 节。
用字符串常量进行类模板参数推断
原则上,你甚至可以用字符串常量来初始化栈:
- Stack stringStack = "bottom"; // Stack<char const[7]> deduced since C++17
但这就造成了很多麻烦:一般情况下,通过引用传递类型为 T 的模板参数时,参数不会朽化,这个术语是指将原始数组类型转换为对应原始指针类型的机制。这意味着我们真正初始化了一个
- Stack<char const[7]>
并且任何使用 T 的地方,都将使用 char const[7] 类型。例如,我们不能压入不同大小的字符串,因为它具有不同的类型。有关详细讨论,请参见第 115 页的 7.4 节。
但是,当按值传递类型为 T 的模板参数时,参数会朽化,这个术语是指将原始数组类型转换为对应原始指针类型的机制。也就是说,构造函数的调用参数 T 被推断为 char const*,从而整个类被推断为 Stack<char cosnt*>。
因此,按值传递参数来声明构造函数是值得的:
- template<typename T>
- class Stack
- {
- private:
- std::vector<T> elems; // elements
- public:
- Stack() = default;
- Stack(T const elem) // initialize stack with one element
- : elems({ elem }) // to decay on class tmpl arg deduction
- {
- }
- ……
- };
这样,以下初始化内容工作正常:
- Stack stringStack = "bottom"; // Stack<char const*> deduced since C++17
但是,在这种情况下,我们应该更好地将临时的 elem 移动到栈中,以避免不必要的复制:
- template<typename T>
- class Stack
- {
- private:
- std::vector<T> elems; // elements
- public:
- Stack() = default;
- Stack(T const elem) // initialize stack with one element
- : elems({ std::move(elem) })
- {
- }
- };
推断指南
除了声明构造函数为按值调用,还有一个不同的解决方案:因为处理容器中的原始指针是麻烦的根源,所以我们应该禁止为容器类自动推断原始字符指针。
你可以定义特定的推断指南,以提供附加的或固定的现有类模板参数推断。例如,你可以定义每当传递字符串常量或者 C 字符串时,栈都会以 std::string 进行实例化。
- Stack(char const*) -> Stack<std::string>;
此指南必须出现在与类定义相同的范围(或命名空间)中。通常紧跟着类定义。我们把跟在 -> 后面的类型成为推断指南的指导类型。
现在声明
- Stack stringStack{ "bottom" }; // OK: Stack<std::string> deduced since C++17
推断栈为 Stack<std::string>。然而,以下声明仍然不能工作:
- Stack stringStack = "bottom"; // Stack<std::string> deduced, but still not valid
我们推断为 std::string,因此我们实例化了 Stack<std::string>:
- template<typename T>
- class Stack
- {
- private:
- std::vector<T> elems; // elements
- public:
- Stack() = default;
- Stack(T const elem) // initialize stack with one element
- : elems({ elem })
- {
- }
- ……
- };
但是,根据语言规则,除了 std::string,不能传递字符串常量给构造函数以复制初始化(使用 = 初始化)一个对象。因此,你必须按如下初始化栈:
- Stack stringStack{ "bottom" }; // OK: Stack<std::string> deduced since C++17
请注意,如果有疑问,类模板参数推断会进行复制。在将 stringStack 声明为 Stack<std::string> 之后,以下初始化声明了相同的类型(因此,调用了复制构造函数),而不是将字符串栈作为元素来初始化栈。
- Stack stack2{ stringStack }; // Stack<std::string> deduced
- Stack stack3{ stringStack }; // Stack<std::string> deduced
- Stack stack4 = { stringStack }; // Stack<std::string> deduced
有关类模板参数推断的更多细节,请参见第 313 页的 15.12 节。
聚合类(指的是这些类或结构体,它们没有用户提供的、显式的或继承的构造函数、没有私有的或受保护的非静态数据或成员、没有虚函数、也没有虚拟的或私有的或受保护的基类)也可以是模板。例如:
- template<typename T>
- struct ValueWithComment
- {
- T value;
- std::string comment;
- };
定义了一个聚合类,它为保存的值 val 的类型进行了参数化。你可以声明对象就像其他类模板一样,但仍然将其用作聚合类:
- ValueWithComment<int> vc;
- vc.value = 42;
- vc.comment = "initial value";
自 C++17 开始,你甚至可以为聚合类模板定义推断指南:
- ValueWithComment(
- char const*, char const*)
- ->ValueWithComment<std::string>;
- ValueWithComment<char const*> vc2 = {"hello", "initial value"};
没有推断指南,初始化是不可能的,因为 ValueWithComment 没有对应的执行推断的构造函数。
标准库类 std::array<> 也是一个聚合类,其针对元素类型和大小进行了参数化。C++17标准库也为它定义了一个推断指南,我们将在第 64 页的 4.4.4 节中讨论。
2.11 总结
• 类模板是在一个或多个类型参数保持开放的情况下实现的类。
• 要使用类模板,可以将开放的类型作为模板参数传递。然后类模板会为这些类型进行实例化(和编译)。
• 对于类模板,只实例化那些被调用的成员函数。
• 你可以针对某些类型特化类模板。
• 你可以针对某些类型部分特化类模板。
• 从 C++17 开始,类模板参数可以自动从构造函数中推导出来。
• 你可以定义聚合类模板。
• 如果声明为按值调用,那么调用模板参数类型会被朽化。
• 模板只能在全局/命名空间范围或类声明中声明和定义。
1 C++17 引入了类参数模板推断,即如果模板参数可以从构造函数中派生出来,那么就可以跳过模板参数。这将在第 40 页的 2.9 节中讨论。
2 它是一个模板化实体,参见第 181 页的 12.1 节。
3 有意使用 typedef 这个词来代替“类型定义”。关键字 typedef 最初是为了建议“类型定义”的。然而,在 C++ 中,“类型定义”实际上意味着别的东西(例如,类或枚举类型的定义)。相反,typedef 应该被认为只是现有类型的替代名称(“别名”),它可以通过 typedef 来实现。
4 别名模板有时(不正确地)被称为 typedef 模板,因为如果一个 typedef 可以被制成模板,那么它们就扮演着同样的角色。
5 这里需要 typename,因为成员是一个类型。详见第 67 页的 5.1 节。
6 更糟糕的是,向量有一个构造函数是以一个整数作为初始大小的。因此对于初始值为 5 的栈,当使用 : elems(elem) 时,向量将获得五个元素的初始化大小。
第 3 章
非类型模板参数
对于函数和类的模板,模板参数不必是类型,它们可以是普通的值。与使用类型参数的模板一样,你同样可以定义代码,并且在使用之前,该代码的某些细节保持开放。然而,开放的细节是值而不是类型。当使用这样的模板时,你必须显示指定该值,然后生成的代码才被实例化。本章使用新版本的类模板说明这一特性。此外,我们展示了一个非类型函数模板参数的例子,并讨论了该技术的一些限制。
3.1 非类型类模板参数
与前面章节中栈示例的实现相反,你可以通过为元素使用固定大小的数组来实现栈。这种方法的优点是避免了内存管理开销,无论是由你还是由标准容器执行。然后,确定这种栈的最佳大小可能具有挑战性。你指定的大小越小,栈越有可能变满。你指定的大小越大,就越有可能不必要地保留内存。一个好的解决方案是让栈的用户指定数组的大小,使其作为栈元素所需的最大大小。
为此,将大小定义为模板参数:
- #include <array>
- #include <cassert>
- template<typename T, std::size_t Maxsize>
- class Stack
- {
- private:
- std::array<T, Maxsize> elems; // elements
- std::size_t numElems; // current number of elements
- public:
- Stack(); // constructor
- void push(T const& elem); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return numElems == 0;
- }
- std::size_t size() const // return current number of elements
- {
- return numElems;
- }
- };
- template<typename T, std::size_t Maxsize>
- Stack<T, Maxsize>::Stack()
- : numElems(0) // start with no elements
- {
- // nothing else to do
- }
- template<typename T, std::size_t Maxsize>
- void Stack<T, Maxsize>::push(T const& elem)
- {
- assert(numElems < Maxsize);
- elems[numElems] = elem; // append element
- ++numElems; // increment number of elements
- }
- template<typename T, std::size_t Maxsize>
- void Stack<T, Maxsize>::pop()
- {
- assert(!elems.empty());
- --numElems; // decrement number of elements
- }
- template<typename T, std::size_t Maxsize>
- T const& Stack<T, Maxsize>::top() const
- {
- assert(!elems.empty());
- return elems[numElems - 1]; // return last element
- }
新的第二个模板参数 Maxsize 是 int 类型。它指定栈元素内部数组的大小:
- template<typename T, std::size_t Maxsize>
- class Stack
- {
- private:
- std::array<T, Maxsize> elems; // elements
- ……
- };
此外,它在 push() 中用于检查栈是否已满:
- template<typename T, std::size_t Maxsize>
- void Stack<T, Maxsize>::push(T const& elem)
- {
- assert(numElems < Maxsize);
- elems[numElems] = elem; // append element
- ++numElems; // increment number of elements
- }
要使用此类模板,你必须指定元素类型和最大大小:
- #include "stacknontype.hpp"
- #include <iostream>
- #include <string>
- int main()
- {
- Stack<int, 20> int20Stack; // stack of up to 20 ints
- Stack<int, 40> int40Stack; // stack of up to 40 ints
- Stack<std::string, 40> stringStack; // stack of up to 40 strings
- // manipulate stack of up to 20 ints
- int20Stack.push(7);
- std::cout << int20Stack.top() << '\n';
- int20Stack.pop();
- // manipulate stack of up to 40 strings
- stringStack.push("hello");
- std::cout << stringStack.top() << '\n';
- stringStack.pop();
- }
请注意,每个模板实体都是它自己的类型。因此 int20Stack 和 int40Stack 是两种不同的类型,它们之间没有定义隐式或显式的类型转换。因此,不能用一个代替另一个,也不能把一个分配给另一个。
同样,可以指定模板参数的默认参数:
- template<typename T = int, std::size_t Maxsize = 100>
- class Stack
- {
- ……
- };
但是,从好的设计角度来看,这个例子可能不太合适。默认参数应该是直观正确的。但是对于一般的栈类型来说,int 类型和最大大小 100 都不直观。因此这才是最好的:程序员必须显式地指定这两个值,以便总是在声明期间记录这两个属性。
3.2 非类型函数模板参数
你还可以会函数模板定义非类型参数。例如,以下函数模板定义了一组可以增加特定值的函数:
- template<int Val, typename T>
- T addValue(T x)
- {
- return x + Val;
- }
如果将函数或操作用作参数,这些类型的函数将会很有用。例如,如果你使用 C++ 标准库,你可以传递此函数模板的一个实例,为集合的每个元素添加一个值:
- std::transform(source.begin(), source.end(), // start and end of source
- dest.begin(), // start of destination
- addValue<5, int>); // operation
最后一个参数实例化了函数模板 addValue<>(),它将传递其中的 int 值增加 5。源集合 source 中的每个元素都会调用这个产生函数,同时将元素转换到目标集合 dest 中。
请注意,你必须为 addValue<>() 的模板参数 T 指定参数 int。推断只适用于立即调用,std::transform() 需要一个完整的类型来推断其第四个参数的类型。这是不支持的:仅仅替换/推断一些模板参数,然后看看什么适合,接着推断剩余的参数。
同样,你也可以指定从之前的参数推断出模板参数。例如,从传递的非类型参数派生出返回类型:
- template<auto Val, typename T = decltype(Val)>
- T foo();
或者确保传递的值与传递的类型相同:
- template<typename T, T Val = T{} >
- T bar();
3.3 非类型模板参数的限制
请注意,非类型模板参数带有一些限制。一般来说,它们只能是常量整数(包括枚举)、指向对象/函数/成员的指针、对象或函数的左值引用或 std::nullptr_t(nullptr 的类型)。
浮点数和类类型对象不允许作为非类型模板参数:
- template<double VAT> // ERROR: floating-point values are not
- double process(double v) // allowed as template parameters
- {
- return v & VAT;
- }
- template<std::string name> // ERROR: class-type objects are not
- class MyClass // allowed as template parameters
- {
- ……
- };
将模板参数传递的是指针或引用时,对象不能是字符串、临时对象、数据成员和其他子对象。因为在 C++17 之前的每一个版本都放宽了这些限制,所以还应用了其他约束:
• 在 C++11 中,对象也必须有外部链接。
• 在 C++14 中,对象也必须有外部或内部链接。
因此,以下情况是不允许的:
- template<char const* name>
- class MyClass
- {
- ……
- };
- MyClass<"hello"> x; // ERROR: string literal "hello" not allowed
但是有一些变通的方法(同样取决于 C++ 版本):
- extern char const s03[] = "hi"; // external linkage
- char const s11[] = "hi"; // internal linkage
- int main()
- {
- Message<s03> m03; // OK (all versions)
- Message<s11> m11; // OK since C++11
- static char const s17[] = "hi"; // no linkage
- Message<s17> m17; // OK since C++17
- }
在这三种情况下,常量字符数组都是由 "hello" 初始化的,并且这个对象被用作声明为 char const* 的模板参数。如果对象有外部链接(s03),那么在所有 C++ 版本中都有效;如果对象有内部链接(s11),那么在 C++11 和 C++14 中也有效;如果对象根本没有链接,那么只在 C++17 以及之后有效。
详见第 194 页的 12.3.3 节,以及第 354 页的 17.2 节,它们讨论该领域未来可能的变化。
避免无效表达式
非类型模板参数的参数可以是任何编译时表达式。例如:
- template<int I, bool B>
- class C;
- ……
- C<sizeof(int) + 4, sizeof(int) == 4> c;
但是,请注意,如果表达式中使用了运算符 >,则必须将整个表达式放入括号中,以便嵌套的 > 结束参数列表:
- C<42, sizeof(int) > 4> c; // ERROR: first > ends the template argument list
- C<42, (sizeof(int) > 4)> c; // OK
3.4 模板参数类型 auto
自 C++17 开始,你可以定义一个非类型模板参数来通用地接受任何非类型参数所允许的类型。使用这个特性,我们可以提供一个更通用的固定大小的栈类:
- #include <array>
- #include <cassert>
- template<typename T, auto Maxsize>
- class Stack
- {
- public:
- using size_type = decltype(Maxsize);
- private:
- std::array<T, Maxsize> elems; // elements
- size_type numElems; // current number of elements
- public:
- Stack(); // constructor
- void push(T const& elem); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return numElems == 0;
- }
- size_type size() const // return current number of elements
- {
- return numElems;
- }
- };
- // constructor
- template<typename T, auto Maxsize>
- Stack<T, Maxsize>::Stack()
- : numElems(0) // start with no elements
- {
- // nothing else to do
- }
- template<typename T, auto Maxsize>
- void Stack<T, Maxsize>::push(T const& elem)
- {
- assert(numElems < Maxsize);
- elems[numElems] = elem; // append element
- ++numElems; // increment number of elements
- }
- template<typename T, auto Maxsize>
- void Stack<T, Maxsize>::pop()
- {
- assert(!elems.empty());
- --numElems; // decrement number of elements
- }
- template<typename T, auto Maxsize>
- T const& Stack<T, Maxsize>::top() const
- {
- assert(!elems.empty());
- return elems[numElems - 1]; // return last element
- }
通过占位符 auto 定义
- template<typename T, auto Maxsize>
- class Stack
- {
- ……
- };
可以将 Maxsize 定义为尚未指定类型的值。它可以是允许作为非类型模板参数类型的任何类型。
在内部,你可以同时使用以下两个值:
- std::array<T, Maxsize> elems; // elements
以及它的类型:
- using size_type = decltype(Maxsize);
然后,它被用作 size() 成员函数的返回类型:
- size_type size() const // return current number of elements
- {
- return numElems;
- }
自 C++14 开始,你也可以在这里使用 auto 作为返回类型,让编译器找出返回类型:
- auto size() const // return current number of elements
- {
- return numElems;
- }
在使用栈时,通过声明这个类,元素数量的类型由所使用的类型定义:
- #include <iostream>
- #include <string>
- #include "stackauto.hpp"
- int main()
- {
- Stack<int, 20u> int20Stack; // stack of up to 20 ints
- Stack<std::string, 40> stringStack; // stack of up to 40 strings
- // manipulate stack of up to 20 ints
- int20Stack.push(7);
- std::cout << int20Stack.top() << '\n';
- auto size1 = int20Stack.size();
- // manipulate stack of up to 40 strings
- stringStack.push("hello");
- std::cout << stringStack.top() << '\n';
- auto size2 = stringStack.size();
- if (!std::is_same_v<decltype(size1), decltype(size2)>)
- {
- std::cout << "size types differ" << '\n';
- }
- }
当定义
- Stack<int, 20u> int20Stack; // stack of up to 20 ints
时,内部大小类型是 unsigned int,因为传递的是 20u。
当定义
- Stack<std::string, 40> stringStack; // stack of up to 40 strings
时,内部大小类型是 int,因为传递的是 40。
两个栈的 size() 将有不同的返回类型,所以在执行
- auto size1 = int20Stack.size();
- ……
- auto size2 = stringStack.size();
之后,size1 和 size2 的类型不同。通过使用标准类型萃取 std::is_same(参见第 726 页的 D.3.3 节)和 decltype,我们可以按如下检查:
- if (!std::is_same<decltype(size1), decltype(size2)>::value)
- {
- std::cout << "size type differ" << '\n';
- }
因此,输出将是:
size types differ
自 C++17 开始,对于返回值的萃取,你还可以使用后缀 _v 并且跳过 ::value(详见第 83 页的 5.6 节)。
- if (!std::is_same_v<decltype(size1), decltype(size2)>)
- {
- std::cout << "size type differ" << '\n';
- }
请注意,非类型模板参数类型的其他约束仍然有效。尤其,第 49 页 3.3 节中讨论的关于非类型模板参数可能类型的限制仍然适用。
- Stack<int, 3.14> sd; // ERROR: Floating-point nontype argument
而且,因为你可以把字符串作为常量数组传递(从 C++17 开始甚至可以是静态局部声明;参见第 49 页的 3.3 节),所以可能会出现以下情况:
- template<auto T> // take value of any possible nontype parameter (since C++17)
- class Message
- {
- public:
- void print()
- {
- std::cout << T << '\n';
- }
- };
- int main()
- {
- Message<42> msg1;
- msg1.print(); // initialize with int 42 and print that value
- static char const s[] = "hello";
- Message<s> msg2; // initialize with char~const[6] "hello"
- msg2.print(); // and print that value
- }
还要注意,甚至 template<decltype(auto) N> 也是可能的,这允许 N 的实例作为引用:
- template<decltype(auto) N>
- class C
- {
- ……
- };
- int i;
- C<(i)> x; // N is int&
详见第 296 页的 15.10.1 节。
3.5 总结
• 模板的模板参数可以是值而不是类型。
• 不能将浮点数或类类型对象用作非类型模板参数的参数。对于指向字符串、临时对象和子对象的指针/引用,也有限制。
• auto 的使用可以使模板拥有值为通用类型的非类型模板参数。
第 4 章
可变模板
自 C++11 开始,模板的参数可以接受可变数量的模板参数。该特性允许你在必须传递任意数量的任意类型的参数时,也可以使用模板。典型的应用是通过类或框架传递任意数量的任意类型的参数。另一个应用是提供通用代码来处理任意数量的任意类型的参数。
4.1 可变模板
模板参数可以定义为接受无限数量的模板参数。具有这种能力的模板称为可变模板。
4.1.1 可变模板示例
例如,你可以使用以下代码为不同类型的可变数量的参数调用 print():
- #include <iostream>
- void print()
- {
- }
- template<typename T, typename... Types>
- void print(T firstArg, Types... args)
- {
- std::cout << firstArg << '\n'; // print first argument
- print(args...); // call print() for remaining arguments
- }
如果传递了一个或多个参数,则使用函数模板。该模板通过单独指定一个参数,使其在为剩余参数递归调用 print() 之前打印第一个参数。这些命名为 args 的剩余参数是一个函数参数包:
- void print(T firstArg, Types... args)
使用模板参数包来指定不同的“类型”:
- template<typename T, typename... Types>
为了结束递归,提供了 print() 的非模板重载,在参数包为空时调用该重载。
例如,如下调用
- std::string s("world");
- print(7.5, "hello", s);
将输出以下内容:
- 7.5
- hello
- world
原因是,该调用首先扩展为
- print<double, char const*, std::string>(7.5, "hello", s);
其中
• firstArg 值为 7.5,因此类型 T 为 double。
• args 是一个可变模板参数,其值为 char const* 类型的 "hello" 和 std::string 类型的 "world"。
将 7.5 作为 firstArg 打印之后,它再次为剩余的参数调用 print(),然后扩展为:
- print<char const*, std::string>("hello", s);
其中
• firstArg 值为 "hello",因此这里类型 T 为 char const*。
• args 是一个可变模板参数,其值为 std::string。
在将作为 firstArg 的 "hello" 打印之后,它再次为剩余的参数调用 print(),然后扩展为:
- print<std::string>(s);
其中
• firstArg 值为 "world",因此此时类型 T 为 std::string。
• args 是一个没有值的空可变模板参数。
因此,在将作为 firstArg 的 "world" 打印之后,我们调用没有参数的 print(),这将导致调用什么都没有做的 print() 非模板重载。
4.1.2 重载可变和非可变模板
请注意,你也可以按如下实现上面的示例:
- #include <iostream>
- template<typename T>
- void print(T arg)
- {
- std::cout << arg << '\n'; // print passed argument
- }
- template<typename T, typename... Types>
- void print(T firstArg, Types... args)
- {
- print(firstArg); // call print() for the first argument
- print(args...); // call print() for remaining arguments
- }
也就是说,如果两个函数模板只相差一个尾部参数包,则首选不带尾部参数包的函数模板 1。第 688 页的 C.3.1 节解释了适用此处的更一般的重载解析规则。
4.1.3 sizeof... 操作符
C++11 还为可变模板引入了一种新形式的 sizeof 运算符:sizeof...,它扩展为参数包包含的元素数量。因此,
- template<typename T, typename... Types>
- void print(T firstArg, Types... args)
- {
- std::cout << sizeof...(Types) << '\n'; //print number of remaining types
- std::cout << sizeof...(args) << '\n'; //print number of remaining args
- }
打印了两次跟在 print() 第一个参数后面的剩余参数的个数。如你所见,你可以为模板参数包和函数参数包调用 sizeof...。
这可能会让我们认为,在没有更多参数的情况下,我们可以通过不调用函数来跳过递归的结尾:
- template<typename T, typename... Types>
- void print(T firstArg, Types... args)
- {
- std::cout << firstArg << '\n';
- if (sizeof...(args) > 0) // error if sizeof...(args)==0
- {
- print(args...); // and no print() for no arguments declared
- }
- }
然而,这种方法不起作用,因为一般来说,函数模板中所有 if 语句的两个分支都是实例化的。实例化的代码是否有用是运行时决定的,而调用的实例化是编译时决定的。因此,如果你为一个(最后一个)参数调用 print() 函数模板,则调用 print(args...) 的语句仍然会因为没有参数而被实例化。如果没有提供一个没有参数的 print() 函数,则这是一个错误。
但是请注意,从 C++17 开始,编译时 if 是可用的,它以稍微不同的语法实现了这里所期望的。这将在第 134 页的 8.5 节中讨论。
4.2 折叠表达式
从 C++17 开始,有一个特性可以对参数包的所有参数使用二元运算符计算结果(带有可选的初始值)。
例如,以下函数返回所有传递参数的总和:
- template<typename... T>
- auto foldSum(T... s)
- {
- return (... + s); // ((s1 + s2) + s3) ...
- }
如果参数包为空,则表达式的格式通常是不正确的(例外情况是,对于运算符 && 值为 true,对于运算符 || 值为 false,对于逗号运算符,空参数包的值为 void())。
表 4.1 列出了可能的折叠表达式。
折叠表达式 | 求值 |
---|---|
( … op pack ) | ((( pack1 op pack2 ) op pack3 ) … op packN ) |
( pack op … ) | ( pack1 op ( … ( packN-1 op packN ))) |
( init op … op pack ) | ((( init op pack1 ) op pack2 ) … op packN ) |
( pack op … op init ) | ( pack1 op ( … ( packN op init ))) |
对于折叠表达式,你可以使用几乎所有的二元运算符(详见第 208 页的 12.4.6 节)。例如,你可以使用一个折叠表达式来遍历二叉树中的路径,方法是使用操作符 ->*:
- // define binary tree structure and traverse helpers:
- struct Node
- {
- int value;
- Node* left;
- Node* right;
- Node(int i = 0) : value(i), left(nullptr), right(nullptr)
- {}
- //...
- };
- auto left = &Node::left;
- auto right = &Node::right;
- // traverse tree, using fold experssion:
- template<typename T, typename... TP>
- Node* traverse(T np, TP... paths)
- {
- return (np ->* ... ->* paths); // np ->* paths1 ->* path2 ...
- }
- int main()
- {
- // init binary tree structure
- Node* root = new Node{ 0 };
- root->left = new Node{ 1 };
- root->left->right = new Node{ 2 };
- //...
- //traverse binary tree:
- Node* node = traverse(root, left, right);
- //...
- }
这里,
(np ->* ... ->* paths)
使用折叠表达式,从 np 遍历可变元素 paths。
有了这样一个使用初始值的折叠表达式,我们可以考虑简化可变模板来打印上面介绍的所有参数:
- template<typename... Types>
- void print(Types const&... args)
- {
- (std::cout << ... << args) << '\n';
- }
但是请注意,在这种情况下,参数包中的所有元素之间没有空格。为此,你需要一个额外的类模板,以确保任何参数的任何输出都有一个空格扩展。
- template<typename T>
- class AddSpace
- {
- private:
- T const& ref; // refer to argument passed in constructor
- public:
- AddSpace(T const& r) : ref(r)
- {}
- friend std::ostream& operator<<(std::ostream& os, AddSpace<T> s)
- {
- return os << s.ref << ' '; // output passed argument and a space
- }
- };
- template<typename... Types>
- void print(Types const&... args)
- {
- (std::cout << ... << AddSpace(args)) << '\n';
- }
请注意,表达式 AddSpace(args) 使用类模板参数推断(参见第 40 页的 2.9 节)来实现 AddSpace<Args>(args) 的效果。它为每个参数创建一个 AddSpace 对象,该对象引用传递的参数,并在使用输出表达式时添加一个空格。
有关折叠表达式的详细信息,请参见第 207 页的 12.4.6 节。
4.3 可变模板的应用
可变模板在实现泛型库(如 C++ 标准库)时起着重要作用。
一个典型的应用是转发任意类型的可变数量的参数。例如,我们在以下情况使用此功能:
• 将参数传递给共享指针所拥有的新堆栈对象的构造函数:
- // create shared pointer to complex<float> initialized by 4.2 and 7.7
- auto sp = std::make_shared<std::complex<float>>(4.2, 7.7);
• 将参数传递给线程,它由库启动:
- std::thread t(foo, 42, "hello"); // call foo(42, "hello") in a separate thread
• 将参数传递给构造函数,该参数为推入向量的新元素:
- std::vector<Customer> v;
- ……
- v.emplace_back("Tim", "Jovi", 1962); //insert a Customer initialized by three argument
通常,参数是使用移动语义的“完美转发”(参见第 91 页的 6.1 节),因此相应的声明为,如下:
- namespace std
- {
- template<class _Ty, class... _Types> shared_ptr<_Ty>
- make_shared(_Types&&... _Args);
- class thread
- {
- public:
- template<class _Fn, class... _Args>
- explicit thread(_Fn&& _Fx, _Args&&... _Ax);
- };
- template <class _Ty, class _Alloc = allocator<_Ty>>
- class vector
- {
- public:
- template <class... _Valty>
- decltype(auto) emplace_back(_Valty&&... _Val);
- };
- }
还要注意,可变函数模板参数和普通参数适用相同的规则。例如,如果按值传递,参数会被复制并朽化(例如数组变为指针),而如果按引用传递,参数会引用原始参数,不会朽化:
- // args are copies with decayed types:
- template<typename... Args> void foo(Args... args);
- // args are nondecayed references to passed objects:
- template<typename... Args> void bar(Args const&... args);
4.4 可变类模板和可变表达式
除了上面的例子之外,参数包还可以出现在其他地方,例如表达式、类模板、使用声明,甚至推断指南。第 202 页的 12.4.2 节有一个完整的列表。
4.4.1 可变表达式
你可以做的不仅仅是转发所有参数。你可以使用它们进行计算,这意味着使用参数包中的所有参数进行计算。
例如,以下函数将参数包参数的每个参数加倍,并将每个加倍的参数传递给 print():
- template<typename... T>
- void printDouble(T const&... args)
- {
- print(args + args...);
- }
例如,如果你调用
- printDouble(7.5, std::string("hello"), std::complex<float>(4, 2));
该函数具有以下效果(除了任何构造函数副作用):
- print(7.5 + 7.5,
- std::string("hello") + std::string("hello"),
- std::complex<float>(4, 2) + std::complex<float>(4, 2));
如果你只要为每个参数增加 1,请注意省略号中的点不能直接跟在数字文字后面:
- template<typename... T>
- void addOne(T const&... args)
- {
- print(args + 1...); // ERROR: 1... is a literal with too many decimal points
- print(args + 1 ...); // OK
- print((args + 1)...); // OK
- }
编译时表达式可以用相同的方式包含模板参数包。例如,以下函数模板返回所有参数的类型是否相同:
- template<typename T1, typename... TN>
- constexpr bool isHomogeneous(T1, TN...)
- {
- return (std::is_same<T1,TN>::value && ...);
- }
这是折叠表达式的一个应用(见第 58 页的 4.2 节):
- isHomogeneous(43, -1, "hello");
返回值的表达式扩展为
- std::is_same<int, int>::value&&
- std::is_same<int, char const*>::value;
因此输出 false,而
- isHomogeneous("hello", "", "world", "!");
输出 false。因为所有传递的参数都被推断为 char const*(请注意,参数类型会朽化,因为调用参数是通过值传递的)。
4.4.2 可变索引
作为另一个例子,下面的函数使用可变的索引列表来访问传递的第一个参数的相应元素:
- template<typename C, typename... Idx>
- void printElems(C const& coll, Idx... idx)
- {
- print(coll[idx] ...);
- }
也就是当调用
- std::vector<std::string> coll = {"good", "time", "say", "bye"};
- printElems(coll, 2, 0, 3);
效果是调用
- print(coll[2], coll[0], coll[3]);
你也可以将非类型模板参数声明为参数包。例如:
- template<std::size_t... Idx, typename C>
- void printIdx(C const& coll)
- {
- print(coll[Idx]...);
- }
允许你调用
- std::vector<std::string> coll = {"good", "time", "say", "bye"};
- printIdx<2, 0, 3>(coll);
这与前面的例子具有相同的效果。
4.4.3 可变类模板
可变模板也可以是类模板。一个重要的例子是一个类,其中任意数量的模板参数指定了相应成员的类型:
- template<typename... Elements>
- class Tuple;
- Tuple<int, std::string, char> t; // t can hold integer, string, and character
这将在第 25 章讨论。
另一个例子是能够指定对象可能具有的类型:
- template<typename... Types>
- class Variant;
- Variant<int, std::string, char> v; // v can hold integer, string, or character
这将在第 26 章讨论。
你还可以定义一个类,该类作为一个类型表示一系列索引:
- // type for arbitrary number of indices
- template<std::size_t...>
- struct Indices
- {
- };
这可用于定义一个函数,为 std::array 或 std::tuple 的元素调用 print()。该函数使用给定索引的 get<> 编译时访问。
- template<typename T, std::size_t... Idx>
- void printByIdx(T t, Indices<Idx...>)
- {
- print(std::get<Idx>(t)...);
- }
该模板可以按如下使用:
- std::array<std::string, 5> arr{ "Hello", "my", "new", "!", "World" };
- printByIdx(arr, Indices<0, 4, 3>());
或者按如下使用:
- auto t = std::make_tuple(12, "monkeys", 2.0);
- printByIdx(t, Indices<0, 1, 2>());
这是迈向元编程的第一步,将在第 123 页和第 23 章的 8.1 节中讨论。
4.4.4 可变推断指南
甚至推断指南(参见第 42 页的 2.9 节)也可以是可变的。例如,C++ 标准库为 std::array 定义了如下推断指南:
- namespace std
- {
- template<typename T, typename... U> array(T, U...)
- ->array<enable_if_t<(is_same_v<T, U> && ...), T>, 1 + sizeof...(U)>;
- }
例如,初始化
- std::array a{ 42, 45, 77 };
将指南中的 T 推断为元素的类型,将各种 U... 类型推断为后续元素的类型。因此,元素总数为 1+sizeof...(U):
- std::array<int, 3> a{ 42, 45, 77 };
第一个数组参数的 std::enable_if<> 表达式是一个折叠表达式(如第 62 页的 4.4.1 节中的 isHomogeneous 所介绍的那样)扩展为:
- is_same_v<T, U1> && is_same_v<T, U2> && is_same_v<T, U3> ...
如果结果不为 true(即并非所有的元素都相同),则推断指南将被丢弃,整体推断将失败。这样,标准库确保所有元素必须具有相同的类型,推断指南才能成功。
4.4.5 可变基类和使用
最后,考虑如下示例:
- #include <string>
- #include <unordered_set>
- class Customer
- {
- private:
- std::string name;
- public:
- Customer(std::string const& n) : name(n) {}
- std::string getName() const { return name; }
- };
- struct CustomerEq
- {
- bool operator()(Customer const& c1, Customer const& c2) const
- {
- return c1.getName() == c2.getName();
- }
- };
- struct CustomerHash
- {
- std::size_t operator()(Customer const& c) const
- {
- return std::hash<std::string>()(c.getName());
- }
- };
- // define class that combines operator() for variadic base classes:
- template<typename... Bases>
- struct Overloader : Bases...
- {
- using Bases::operator()...; // OK since C++17
- };
- int main()
- {
- // combine hasher and equality for customer in one type:
- using CustomerOP = Overloader<CustomerHash, CustomerEq>;
- std::unordered_set<Customer, CustomerHash, CustomerEq> coll1;
- std::unordered_set<Customer, CustomerOP, CustomerOP> coll2;
- }
这里,我们首先定义一个 Customer 类,以及独立的函数对象来散列和比较 Customer 对象。使用
- template<typename... Bases>
- struct Overloader : Bases...
- {
- using Bases::operator()...; // OK since C++17
- };
我们可以定义一个从不同数量的基类派生的类,它引入了来自每个基类的 operator() 声明。使用
- using CustomerOP = Overloader<CustomerHash, CustomerEq>;
使用此特性从 CustomerHash 和 CustomerEq 派生出 CustomerOP,并在派生类中启用运算符 operator() 的两种实现。
参见第 611 页的 26.4 节,了解该技术的另一种应用。
4.5 总结
• 通过使用参数包,可以为任意类型的任意数量的模板参数定义模板。
• 要处理参数,需要递归和(或)匹配非可变函数。
• 运算符 sizeof... 输出参数包提供的参数数量。
• 可变模板的典型应用是转发任意数量的任意类型的参数。
• 通过折叠表达式,可以将运算符应用于参数包的所有参数。
1 最初在 C++11 和 C++14 中,这具有模糊性,后来被修复了(参见 [coreissues 1395])。但所有编译器在所有版本中都是这样处理的。
第 5 章
高阶基础知识
本章涵盖了一些模板的进阶基本方面,它们与模板的实际使用相关:typename 关键字的额外使用、将成员函数和嵌套类定义为模板、模板的模板参数、零初始化以及关于使用字符串作为函数模板参数的一些细节。这些方面有时候可能很棘手,但每个日常程序员都应该听说过。
5.1 typename 关键字
关键字 typename 是在 C++ 标准化过程中引入的,目的是澄清模板中的标识符是一种类型。考虑以下示例:
- template<typename T>
- class MyClass
- {
- public:
- ……
- void foo()
- {
- typename T::SubType* ptr;
- }
- };
这里,第二个 typename 是为了澄清 SubType 是在类 T 中定义的类型。因此 ptr 是指向类型 T::SubType 的指针。
如果没有 typename,SubType 将被假定为非类型成员(例如,静态数据成员或枚举常量)。因此表达式
T::SubType* ptr
将是类 T 的静态成员 SubType 与成员 ptr 的乘积,这不是一个错误。因为对于 MyClass<> 的一些实例化,这可能是有效的代码。
通常,只要依赖于模板参数的名称是类型,就必须使用 typename。这将在第 228 页的 13.3.2 节中详细讨论。
typename 的一个应用是在泛型代码中声明标准容器的迭代器:
- #include <iostream>
- // print elements of an STL container
- template<typename T>
- void printcoll(T const& coll)
- {
- typename T::const_iterator pos; // iterator to iterate over coll
- typename T::const_iterator end(coll.end()); // end position
- for (pos = coll.begin(); pos != end; ++pos)
- {
- std::cout << *pos << ' ';
- }
- std::cout << '\n';
- }
在这个函数模板中,调用参数是一个 T 类型的标准容器。要迭代容器的所有元素,需要使用容器的迭代器类型,它在每个标准容器类中声明为类型 const_iterator:
- class vector {
- public:
- using iterator = ……; // iterator for read/write access
- using const_iterator = ……; // iterator for read access
- ……
- }
因此,要访问模板类型 T 的类型 const_iterator,你必须用一个前导 typename 来限定它:
- typename T::const_iterator pos;
参见第 228 页的 13.3.2 节,了解更多关于在 C++17 之前需要 typename 的细节。请注意,C++20 可能会在许多常见情况下消除对 typename 的需求(详见第 354 页的 17.1 节)。
5.2 零初始化
对于基本类型,如 int、double 或指针类型,没有默认构造函数来初始化有用的默认值。相反,任何未初始化的局部变量都有一个未定义的值。
- void foo()
- {
- int x; // x has undefined value
- int* ptr; // ptr pointer to anywhere (instead of nowhere)
- }
现在,如果你编写模板,并希望模板类型的变量由默认值初始化,那么你会遇到这样的问题:对于内置类型,简单的定义无法做到这一点:
- template<typename T>
- void foo()
- {
- T x; // x has undefined value if T is built-in type
- }
因此,可以为内置类型显示调用默认构造函数,用零初始化它们(或者 bool 设置为 false,指针设置为 nullptr)。因此,即使对于内置类型,也可以通过编写以下内容来确保正确的初始化:
- template<typename T>
- void foo()
- {
- T x{}; // x is zero (or false) if T is a built-in type
- }
这种初始化方式称为值初始化,这意味着要么调用提供的构造函数,要么零初始化一个对象。即使构造函数为 explicit,这也是有效的。
在 C++11 之前,确保正确初始化的语法是
- T x = T()
在 C++17 之前,只有选择的构造函数,其复制构造不是 explicit 的时候,这种机制(仍支持)才有效。在 C++17 中,强制复制省略避免了这种限制,并且两种语法都可以工作。但是如果没有默认构造函数可用,大括号初始化符号可以使用列表初始化构造函数 1。
为了确保被参数化的类模板成员可以初始化,你可以定义一个默认构造函数,该函数使用大括号初始项来初始化成员:
- template<typename T>
- class MyClass
- {
- private:
- T x;
- public:
- MyClass() : x{} // ensures that x is initialized even for built-in types
- {
- }
- ……
- };
C++11 之前的语法
- MyClass() : x{} // ensures that x is initialized even for built-in types
- {
- }
仍然适用。
自 C++11 开始,你还可以为非静态成员提供默认初始化,因此以下内容也是可以的:
- template<typename T>
- class MyClass
- {
- private:
- T x{}; // zero-initialize x unless otherwise specified
- ……
- };
但是请注意,默认参数不能使用该语法,例如,
- template<typename T>
- void foo(T p{}) // ERROR
- {
- ……
- }
相反,我们必须写:
- template<typename T>
- void foo(T p = T{}) // OK (must use T() before C++11)
- {
- }
5.3 使用 this->
对于一个具有基类的模板类,且这个基类依赖于模板参数,此时单独使用名称 x 不总是等于 this->x,即使成员 x 是继承的。例如:
- template<typename T>
- class Base
- {
- public:
- void bar();
- };
- template<typename T>
- class Derived : Base<T>
- {
- public:
- void foo()
- {
- bar(); // calls external bar() or error
- }
- };
在本例中,为了解析 foo() 中的 bar(),没有考虑定义在 Base 中的 bar()。因此,要么有错误,要么调用另一个 bar()(如全局 bar())。
我们将在第 237 页的 13.4.2 节中详细讨论这个问题。目前,作为一个经验法则,对于在某种程度上依赖于模板参数的基类中声明的任何符号,我们建议你总是用 -> 或 Base:: 来限定。
5.4 原始数组和字符串常量的模板
当原始数组或字符常量传递给模板时,必须小心处理。首先,如果模板参数被声明为引用,那么参数不会朽化。也就是说,传递的 "hello" 参数类型为 char const[6]。如果传递不同长度的原始数组或字符串常量参数,这可能会称为一个问题,因为类型不同。只有在按值传递时,类型才会朽化,因此字符串常量会转化为 char const* 类型。这将在第 7 章中详细讨论。
请注意,你还可以提供专门处理原始数组或字符串常量的模板。例如:
- template<typename T, int N, int M>
- bool less(T(&a)[N], T(&b)[M])
- {
- for (int i = 0; i < N && i < M; ++i)
- {
- if (a[i] < b[i]) return true;
- if (b[i] < a[i]) return true;
- }
- return N < M;
- }
这里,当调用
- int x[] = { 1, 2, 3 };
- int y[] = { 1, 2, 3, 4, 5 };
- std::cout << less(x, y) << '\n';
时,less<> 被实例化,其中 T 为 int,N 为 3,M 为 5。
你也可以将此模板用于字符串常量:
- std::cout << less("ab", "abc") << '\n';
此时,less<> 被实例化,其中 T 为 char const,N 为 3,M 为 4。
如果你只想为字符串常量(和 char 数组)提供一个函数模板,可以按如下方式进行:
- template<int N, int M>
- bool less(char const(&a)[N], char const(&b)[M])
- {
- for (int i = 0; i < N && i < M; ++i)
- {
- if (a[i] < b[i]) return true;
- if (b[i] < a[i]) return true;
- }
- return N < M;
- }
请注意,对于未知范围的数组,你可以(有时候是必须)重载或部分特化。以下程序说明了数组所有可能的重载:
- #include <iostream>
- template<typename T>
- struct MyClass; // primary template
- template<typename T, std::size_t SZ>
- struct MyClass<T[SZ]> // partial specialization for arrays of known bounds
- {
- static void print()
- {
- std::cout << "print() for T[" << SZ << "]\n";
- }
- };
- template<typename T, std::size_t SZ>
- struct MyClass<T(&)[SZ]> // partial spec. for references to arrays of known bounds
- {
- static void print()
- {
- std::cout << "print() for T(&)[" << SZ << "]\n";
- }
- };
- template<typename T>
- struct MyClass<T[]> // partial specialization for arrays of unknown bounds
- {
- static void print()
- {
- std::cout << "print() for T[]\n";
- }
- };
- template<typename T>
- struct MyClass<T(&)[]> // partial spec. for references to arrays of unknown bounds
- {
- static void print()
- {
- std::cout << "print() for T(&)[]\n";
- }
- };
- template<typename T>
- struct MyClass<T*> // partial specialization for pointers
- {
- static void print()
- {
- std::cout << "print() for T*\n";
- }
- };
这里,类模板 MyClass<> 专门用于各种类型:已知和未知范围的数组、已知和未知范围数组的引用以及指针。每种情况都不同,在使用数组时可能会出现:
- #include "arrays.hpp"
- template<typename T1, typename T2, typename T3>
- void foo(
- int a1[7], int a2[], // pointer by language rules
- int(&a3)[42], // reference to array of known bound
- int(&x0)[], // reference to array of unknown bound
- T1 x1, // passing by value decays,
- T2& x2, T3&& x3) // passing by reference
- {
- MyClass<decltype(a1)>::print(); // uses MyClass<T*>
- MyClass<decltype(a2)>::print(); // uses MyClass<T*>
- MyClass<decltype(a3)>::print(); // uses MyClass<T(&)[SZ]>
- MyClass<decltype(x0)>::print(); // uses MyClass<T(&)[]>
- MyClass<decltype(x1)>::print(); // uses MyClass<T*>
- MyClass<decltype(x2)>::print(); // uses MyClass<T(&)[]>
- MyClass<decltype(x3)>::print(); // uses MyClass<T(&)[]>
- }
- int main()
- {
- int a[42];
- MyClass<decltype(a)>::print(); // uses MyClass<T[SZ]>
- extern int x[]; // forward declare array
- MyClass<decltype(x)>::print(); // uses MyClass<T[]>
- foo(a, a, a, x, x, x, x);
- }
- int x[] = { 0, 8, 15 }; // define forward-declared array
请注意,由语言规则声明为数组(有或没有长度)的调用参数实际上具有指针类型。还要注意,未知范围数组的模板可以用于不完整的类型,如
- extern int i[];
当通过引用传递时,它变成 int(&)[],其也可以用作模板参数 2。
参见第 401 页的 19.3.1 节,了解在泛型代码中使用不同数组类型的另一示例。
5.5 成员模板
类成员也可以是模板。这对于嵌套类和成员函数都是可行的。这种能力的应用和优势可以再次用 Stack<> 类模板来演示。通常,只有当栈具有相同的类型时,即元素具有相同的类型,才可以将它们相互分配。但是,你不能将任何其他类型的元素分配给栈,即使定义的元素类型存在隐式类型转换:
- Stack<int> intStack1, intStack2; // stacks for ints
- Stack<float> floatStack; // stacks for floats
- ……
- intStack1 = intStack2; // OK: stacks have same type
- floatStack = intStack1; // ERROR: stack have different types
默认赋值运算符要求赋值运算符的两边具有相同的类型,如果栈具有不同的元素类型,情况就不是这样了。
但是通过将赋值运算符定义为模板。你可以使用定义了适当类型转换的元素来使能栈赋值。为此,你必须按如下声明 Stack<>:
- template<typename T>
- class Stack
- {
- private:
- std::deque<T> elems; // elements
- public:
- void push(T const&); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- // assign stack of elements of type T2
- template<typename T2>
- Stack& operator=(Stack<T2> const&);
- };
以下两处进行了更改:
1. 我们为另一种元素类型为 T2 的栈添加了赋值操作符的声明。
2. 栈现在使用 std::deque<> 作为元素的内部容器。同样,这是实现新赋值运算符的结果。
新赋值运算符的实现如下所示:3
- template<typename T>
- template<typename T2>
- Stack<T>& Stack<T>::operator=(Stack<T2> const& op2)
- {
- Stack<T2> tmp(op2); // create a copy of the assigned stack
- elems.clear(); // remove existing elements
- while (!tmp.empty()) // copy all elements
- {
- elems.push_front(tmp.top());
- tmp.pop();
- }
- return *this;
- }
首先让我们看看定义成员模板的语法。在具有模板参数 T 的模板内部,定义了具有模板参数 T2 的内部模板:
- template<typename T>
- template<typename T2>
- ……
在成员函数内部,你可能期望简单地访问分配的栈 op2 的所有必要元素。但是,这个栈有一个不同的类型(如果你为两个不同的参数类型实例化一个类模板,你会得到两个不同的类类型),因此你只能使用公共接口。因此,访问元素的唯一方法是调用 top()。然而,每个元素都必须称为顶层元素。因此,必须首先制作 op2 的副本,以便通过调用 pop() 从该副本中获取元素。因为 top() 返回最后一个被推送到栈上的元素,所以我们可能更喜欢使用一个支持在集合的另一端插入元素的容器。因此,我们使用 std::deque<>,它提供 push_front() 将元素放在集合的另一端。
要访问 op2 的所有成员,你可以声明所有其他栈示例都是友元:
- template<typename T>
- class Stack
- {
- private:
- std::deque<T> elems; // elements
- public:
- void push(T const&); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- // assign stack of elements of type T2
- template<typename T2>
- Stack& operator=(Stack<T2> const&);
- // to get access to private members of Stack<T2> for any type T2
- template<typename> friend class Stack;
- };
可以看到,因为没有使用模板参数的名称,所以可以省略它。
- template<typename> friend class Stack;
现在,模板赋值运算符的以下实现是可以的:
- template<typename T>
- template<typename T2>
- Stack<T>& Stack<T>::operator=(Stack<T2> const& op2)
- {
- elems.clear(); // remove existing elements
- elems.insert(
- elems.begin(), // insert at the beginning
- op2.elems.begin(), // all elements from op2
- op2.elems.end());
- return *this;
- }
无论你的实现是什么,有了这个成员模板,你现在可以将一堆 int 分配给一堆 floats:
- Stack<int> intStack; // stacks for ints
- Stack<float> floatStack; // stacks for floats
- ……
- floatStack = intStack1; // OK: stacks have different types,
- // but int convert to float
当然,这个赋值不会改变栈及其元素的类型。赋值之后,floatStack 的元素仍然是 float,因此 top() 仍然返回一个 float。
这个函数似乎会禁止类型检查,这样你就可以用任何类型的元素分配一个栈,但是事实并非如此。当源栈的元素(副本)移动到目标栈时,会进行必要的类型检查:
- elems.push_front(tmp.top());
例如,如果一个字符串栈被分配给一个 float 栈,这一行的编译将导致一条错误信息,指出由 tmp.top() 返回的字符串不能作为参数传递给 elems.push_front() (该消息因编译器而异,但这是其含义的要点):
- Stack<std::string> stringStack; // stack of strings
- Stack<float> floatStack; // stacks of floats
- ……
- floatStack = stringStack; // ERROR: std::string doesn't convert to float
同样,你可以更改实现来参数化内部容器类型:
- template<typename T, typename Cont = std::deque<T>>
- class Stack
- {
- private:
- Cont elems; // elements
- public:
- void push(T const&); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- // assign stack of element of type T2
- template<typename T2, typename Cont2>
- Stack& operator=(Stack<T2, Cont2> const&);
- // to get access to private members of Stack<T2> for any type T2:
- template<typename, typename> friend class Stack;
- };
然后模板赋值操作是这样实现的:
- template<typename T, typename Cont>
- template<typename T2, typename Cont2>
- Stack<T,Cont>&
- Stack<T,Cont>::operator=(Stack<T2, Cont2> const& op2)
- {
- elems.clear(); // remove existing elements
- elems.insert(
- elems.begin(), // insert at the beginning
- op2.elems.begin(), // all elements from op2
- op2.elems.end());
- return *this;
- }
记住,对于类模板,只有那些被调用的成员函数才被实例化。因此,如果你不给栈分配不同类型的元素,甚至可以使用向量作为内部容器:
- // stack for ints using a vector as an internal container
- Stack<int, std::vector<int>> vStack;
- ……
- vStack.push(42); vStack.push(7);
- std::cout << vStack.top() << '\n';
因为赋值运算符模板不是必需的,所以不会出现缺少成员函数 push_front() 的错误消息,程序也没有问题。
关于最后一个示例的完整实现,请参见子目录 basics 中以 stack7 开头的所有文件。
成员函数模板的特化
成员函数模板也可以部分或完全特化。例如,对于以下类:
- class BoolString
- {
- private:
- std::string value;
- public:
- BoolString(std::string const& s)
- : value(s)
- {
- }
- template<typename T = std::string>
- T get() const // get value (convert to T)
- {
- return value;
- }
- };
你可以为成员函数模板提供特化,如下所示:
- // full specialization for BoolString::getValue<>() for bool
- template<>
- inline bool BoolString::get<bool>() const
- {
- return value == "true" || value == "1" || value == "on";
- }
请注意,你不需要也不能声明特化;你只能定义它们。因为它是完全特化,并且在头文件中,所以你必须用 inline 声明它,以避免定义被不同的翻译单元包含时出错。
你可以按如下使用类和完全特化:
- std::cout << std::boolalpha;
- BoolString s1("hello");
- std::cout << s1.get() << '\n'; // prints hello
- std::cout << s1.get<bool>() << '\n'; // prints false
- BoolString s2("on");
- std::cout << s2.get<bool>() << '\n'; // prints true
特殊成员模板
只要特殊成员函数允许复制或移动,就可以使用模板成员函数。类似于上面定义的赋值运算符,它们也可以是构造函数。但是请注意,模板构造函数或模板赋值运算符不会替换预定义的构造函数或赋值运算符。成员函数不将复制或移动对象的特殊成员函数计算在内。在本例中,对于相同类型的栈赋值,仍然调用默认赋值函数。
这种效果有好有坏:
• 模板构造函数或赋值操作符可能比预定义的复制/移动构造函数或复制操作符更匹配,尽管模板版本仅用于其他类型的初始化。详见第 95 页的 6.2 节。
• 例如,要“试探”一个复制/移动构造函数来约束它的存在并不容易。详见第 102 页的 6.4 节。
5.5.1 .template 构造
有时,在调用成员模板时,有必要显示限定模板参数。在这种情况下,你必须使用 template 关键字来确保 < 是模板参数列表的开头。考虑以下使用 bitset 类型的示例:
- template<unsigned long N>
- void printBitset(std::bitset<N> const& bs)
- {
- std::cout << bs.template to_string<char, std::char_traits<char>, std::allocator<char>>();
- }
对于位集 bs,我们调用成员模板 to_string(),同时显示指定字符串类型的详细信息。如果没有额外使用 .template,编译器将不知道后面的小于标记(<)实际上不是小于,而是模板参数列表的开始。请注意,只有当周期之前的构造函数依赖于模板参数时,这才是一个问题。在我们的例子中,参数 bs 取决于模板参数 N。
.template 符号(以及类似的符号,如 ->template 和 ::template)应该仅在模板内部使用,并且仅当它们遵循依赖于模板参数的内容时使用。详见第 230 页的 13.3.3 节。
5.5.2 泛型 Lambdas 和成员模板
请注意,C++14 引入的泛型 lambdas 是成员模板的快捷方式。一个计算两个任意类型参数的“和”的简单 lambda :
- [](auto x, auto y)
- {
- return x + y;
- }
是以下类的默认构造对象的快捷方式:
- class SomeCompilerSpecificName
- {
- public:
- SomeCompilerSpecificName(); // constructor only callable by compiler
- template<typename T1, typename T2>
- auto operator()(T1 x, T2 y) const
- {
- return x + y;
- }
- };
详见第 309 页的 15.10.6 节。
5.6 变量模板
自 C++14 以来,变量也可以通过特定类型进行参数化。这样的东西叫做 变量模板 4。
例如,你可以使用以下代码来定义 π 的值,但仍不定义该值的类型:
- template<typename T>
- constexpr T pi(3.1415926535897932385);
请注意,对于所有模板,此声明不能出现在函数或块范围内。
要使用变量模板,你必须指定其类型。例如,下面的代码使用 pi<> 作用域下定义的两个不同变量:
- std::cout << pi<double> << '\n';
- std::cout << pi<float> << '\n';
你还可以声明在不同翻译单元中使用的变量模板:
- //== header.hpp
- template<typename T> T val{}; // zero initialized value
- //== translation unit 1:
- #include <header.hpp>
- int main()
- {
- val<long> = 42;
- print();
- }
- //== translation unit 2:
- #include <header.hpp>
- void print()
- {
- std::cout << val<long> << '\n'; // OK: print 42
- }
变量模板也可以有默认模板参数:
- template<typename T = long double>
- constexpr T pi = T{ 3.1415926535897932385 };
你可以使用默认值或任何其他类型:
- std::cout << pi<> << '\n'; // output a long double
- std::cout << pi<float> << '\n'; // output a float
但是请注意,你必须始终指定尖括号。仅仅使用 pi 是错误的:
- std::cout << pi << '\n'; // ERROR
变量模板也可以通过非类型参数来参数化,非类型参数也可以用来参数化初始化器。例如:
- #include <iostream>
- #include <array>
- template<int N>
- std::array<int, N> arr{}; // array with N elements, zero-initialized
- template<auto N>
- constexpr decltype(N) dval = N; // type of dval depends on passed value
- int main()
- {
- std::cout << dval<'c'> << '\n'; // N has value 'c' of type char
- arr<10>[0] = 42; // sets first element of global arr
- for (std::size_t i = 0; i < arr<10>.size(); ++i) // uses values set in arr
- {
- std::cout << arr<10>[i] << '\n';
- }
- }
再次注意,即使 arr 的初始化和迭代发生在不同的翻译单元,但仍然使用全局范围的相同变量 std::array<int,10> arr。
数据成员的变量模板
变量模板的一个有用的应用是定义代表类模板成员的变量。例如,如果类模板定义如下:
- template<typename T>
- class MyClass
- {
- public:
- static constexpr int max = 1000;
- };
它允许你为 MyClass<> 的不同特化定义不同的值,然后你可以定义
- template<typename T>
- int myMax = MyClass<T>::max;
这样应用程序员就可以编写
- auto i = myMax<std::string>;
来代替
- auto i = MyClass<std::string>::max;
这意味着,例如对于标准类
- namespace std
- {
- template<typename T> class numeric_limits
- {
- public:
- ……
- static constexpr bool is_signed = false;
- ……
- };
- }
你可以定义
- template<typename T>
- constexpr bool isSigned = std::numeric_limits<T>::is_signed;
从而能用
- isSigned<char>
来代替
- std::numeric_limits<char>::is_signed
类型萃取 Suffix_v
从 C++17 开始,标准库使用变量模板为标准库中所有产生(布尔)值的类型萃取,来定义快捷方式。例如,可以这么写
- std::is_const_v<T> // since C++17
来代替
- std::is_const<T>::value // since C++11
标准库定义为
- namespace std
- {
- template<typename T> constexpr bool is_const_v =
- is_const<T>::value;
- }
5.7 模板模板参数
允许模板参数本身称为类模板可能很有用。同样,我们的栈类模板可以作为一个例子。
要为栈使用不同的内部容器,程序员必须指定元素类型两次。因此,要指定内部容器的类型,必须再次传递容器的类型以及元素的类型。
- Stack<int, std::vector<int>> vStack; // integer stack that uses a vector
使用模板参数模板允许你通过指定容器的类型来声明栈类模板,而不需重新指定其元素的类型:
- Stack<int, std::vector> vStack; // integer stack that uses a vectors
为此,必须将第二个模板参数指定为模板参数模板。原则上,这看起来如下 5:
- template<typename T,
- template<typename Elem> class Cont = std::deque>
- class Stack
- {
- private:
- Cont<T> elems; // elements
- public:
- void push(T const&); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- //...
- };
不同之处在于,第二个模板参数被声明为类模板:
- template<typename Elem> class Cont
默认值已从 std::deque<T> 更改为 std::deque。此参数必须是一个类模板,它是用第一个模板参数传递的类型进行实例化的:
- Cont<T> elems;
第一个模板参数用于第二个模板参数的实例化,对于这个例子还说是特殊的。通常,你可以在类模板中实例化任何类型的模板参数模板。
通常,你可以使用关键字 class 来代替 typename 作为模板参数。在 C++11 之前,Cont 只能用类模板的名称来代替。
- template<typename T,
- template<class Elem> class Cont = std::deque>
- class Stack // OK
- {
- ……
- };
从 C++11 开始,我们可以使用别名模板的名称来代替 Cont,但是直到 C++17 才进行了相应的更改,允许使用关键字 typename 而不是 class 来声明模板参数模板:
- template<typename T,
- template<typename Elem> class Cont = std::deque>
- class Stack // ERROR before C++17
- {
- ……
- };
这两个变体的意义完全相同:使用类而不是类型名并不妨碍我们指定别名模板作为与 Cont 参数相对应的参数。
因为没有使用模板参数模板的模板参数,所以通常会省略名称(除非它提供了有用的文档):
- template<typename T,
- template<typename> class Cont = std::deque>
- class Stack // ERROR before C++17
- {
- ……
- };
必须相应的修改成员函数。因此,你必须指定第二个模板参数作为模板参数模板。这同样适用于成员函数的实现。例如,push() 成员函数的实现如下:
- template<typename T, template<typename> class Cont>
- void Stack<T, Cont>::push(T const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
请注意,虽然模板参数模板是类或别名模板的占位符,但函数或变量模板没有相应的占位符。
匹配模板参数的模板
如果你尝试使用新版本的 Stack,可能会收到一条错误信息,指出默认值 std::deque 与模板参数的模板 Cont 不兼容。问题是,在 C++17 之前,模板参数模板必须是一个模板,其参数与它所替代的模板参数模板的参数完全匹配,但与变量模板参数相关的一些例外情况除外(参见第 197 页的 12.3.4 节)。没有考虑模板参数模板的默认模板参数,因此不能通过省略具有默认值的参数来实现匹配(在 C++17 中,会考虑默认参数)。
在本例中,C++17 之前的问题是标准库的 std::deque 模板有多个参数:第二个参数(描述分配器)有一个默认值,但是在 C++17 之前,在将 std::deque 与 Cont 参数匹配时没有考虑到这一点。
不过,有一个解决方法。我们可以重写类声明,以便 Cont 参数是希望的具有两个模板参数的容器:
- template<typename T,
- template<typename Elem,
- typename Alloc = std::allocator<Elem>>
- class Cont = std::deque>
- class Stack
- {
- private:
- Cont<T> elems; // elements
- ……
- };
同样,我们可以省略 Alloc,因为它没有被使用。
我们栈模板的最终版本(包括不同元素类型栈赋值的成员模板),现在如下所示:
- #include <deque>
- #include <cassert>
- #include <memory>
- template<typename T,
- template<typename Elem,
- typename = std::allocator<Elem>>
- class Cont = std::deque>
- class Stack
- {
- private:
- Cont<T> elems; // elements
- public:
- void push(T const&); // push element
- void pop(); // pop element
- T const& top() const; // return top element
- bool empty() const // return whether the stack is empty
- {
- return elems.empty();
- }
- // assign stack of element of type T2
- template<typename T2,
- template<typename Elem2,
- typename = std::allocator<Elem2>>
- class Cont2 = std::deque>
- Stack<T, Cont>& operator=(Stack<T2, Cont2> const&);
- // to get access to private member of any Stack with elements of type T2:
- template<typename, template<typename, typename> class>
- friend class Stack;
- };
- template<typename T, template<typename, typename> class Cont>
- void Stack<T, Cont>::push(T const& elem)
- {
- elems.push_back(elem); // append copy of passed elem
- }
- template<typename T, template<typename, typename> class Cont>
- void Stack<T, Cont>::pop()
- {
- assert(!elems.empty());
- elems.pop_back(); // remove last element
- }
- template<typename T, template<typename, typename> class Cont>
- T const& Stack<T, Cont>::top() const
- {
- assert(!elems.empty());
- return elems.back(); // return copy of last elemet
- }
- template<typename T, template<typename, typename> class Cont>
- template<typename T2, template<typename, typename> class Cont2>
- Stack<T, Cont>&
- Stack<T, Cont>::operator=(Stack<T2, Cont2> const& op2)
- {
- elems.clear(); // remove existing elements
- elems.insert(
- elems.begin(), // insert at the beginning
- op2.elems.begin(), // all elements from op2
- op2.elems.end());
- return *this;
- }
再次注意,要访问 op2 的所有成员,我们声明所有其他栈示例都是友元(忽略模板参数的名称):
- template<typename, template<typename, typename> class>
- friend class Stack;
然而,并不是所有的标准容器模板都可以用于 Cont 参数。例如,std::array 将不起作用,因为它包含一个非类型模板参数,其数组长度与我们的模板参数模板的参数声明中的长度不匹配。
以下程序使用此最终版本的所有功能:
- #include "stack9.hpp"
- #include <iostream>
- #include <vector>
- int main()
- {
- Stack<int> iStack; // stack of ints
- Stack<float> fStack; // stack of floats
- // manipulate int stack
- iStack.push(1);
- iStack.push(2);
- std::cout << "iStack.top(): " << iStack.top() << '\n';
- // manipulate float stack
- fStack.push(3.3);
- std::cout << "fStack.top(): " << fStack.top() << '\n';
- // assign stack of different type and manipulate again
- fStack = iStack;
- fStack.push(4.4);
- std::cout << "fStack.top(): " << fStack.top() << '\n';
- // stack for doubless using a vector as an internal container
- Stack<double, std::vector> vSatck;
- vSatck.push(5.5);
- vSatck.push(6.6);
- std::cout << "vStack.top(): " << vSatck.top() << '\n';
- vSatck = fStack;
- std::cout << "vStack: ";
- while (!vSatck.empty())
- {
- std::cout << vSatck.top() << ' ';
- vSatck.pop();
- }
- std::cout << '\n';
- }
该程序具有以下输出:
- iStack.top(): 2
- fStack.top(): 3.3
- fStack.top(): 4.4
- vStack.top(): 6.6
- vStack: 4.4 2 1
有关模板参数模板的进一步讨论和示例,请参见第 187 页的 12.2.3 节、第 197 页的 12.3.4 节和第 398 页的 19.2.2 节。
5.8 总结
• 要访问依赖于模板参数的类型名,必须用前导 typename 来限定该名称。
• 要访问依赖于模板参数的基类的成员,必须通过 this-> 或它们的类名来限定访问。
• 嵌套类和成员函数也可以是模板。一个应用是实现带有内部类型转换的泛型操作能力。
• 构造函数或赋值运算符的模板版本,不会替换预定义的构造函数或赋值运算符。
• 通过使用大括号初始化或显式调用默认构造函数,可以确保模板的变量和成员用默认值初始化,即使它们是用内置类型实例化的。
• 你可以为原始数组提供特定的模板,这也适用于字符串常量。
• 当传递原始数组或字符串常量时,当且仅当参数不是引用时,参数会在参数推断过程中朽化(执行数组到指针的转换)。
• 你可以定义 变量模板(从 C++14 开始)。
• 你也可以使用类模板作为模板参数,称为模板模板参数。
• 模板模板参数通常必须与其参数完全匹配。
1 这是,对于某些类型 X,构造函数的参数类型为 std::initializer_list。
2 为了解决核心问题 393,参数类型 X(&)[] —— 对于某些任意的类型 X —— 只有在 C++17 中才有效。然而,许多编译器在语言的早期版本中接受了这样的参数。
3 这是演示模板特性的基本实验。像正确的异常处理这样的问题肯定是缺失的。
4 是的,我们对非常不同的事物有非常相似的术语:变量模板是一个变量,它是一个模板(变量在这里是一个名词)。可变模板是可变数量模板参数的模板(这里可变是一个形容词)。
5 在 C++17 之前,这个版本有一个问题,我们稍后会解释。但是,这只会影响默认值 std::deque。因此,在讨论如何在 C++17 之前处理模板模板参数之前,我们可以用这个默认值来说明模板模板参数的一般特性。
第 6 章
移动语义和 enable_if<>
C++11 引入的最突出的特性之一是移动语义。你可以使用它来优化复制和分配,方法是将内部资源从源对象移动(“窃取”)到目标对象,而不是复制这些内容。只要源不再需要其内部值或状态(因为它即将被丢弃),就可以做到这一点。
移动语义对模板的设计有着重要的影响,在泛型代码中引入特殊的规则来支持移动语义。本章介绍这些功能。
6.1 完美转发
假设你想编写泛型代码来转发传递参数的通用代码:
• 应该转发可修改的对象,以便它们仍然可以被修改。
• 常量对象应该作为只读对象转发。
• 可移动的对象(我们可以“偷走”的对象,因为它们即将过期)应该作为可移动对象转发。
要在没有模板的情况下实现这一功能,我们必须对所有三种情况进行编程。例如,要将 f() 的调用转发到相应的函数 g():
- #include <utility>
- #include <iostream>
- class X
- {
- //...
- };
- void g(X&)
- {
- std::cout << "g() for variable\n";
- }
- void g(X const&)
- {
- std::cout << "g() for constant\n";
- }
- void g(X&&)
- {
- std::cout << "g() for movable object\n";
- }
- void f(X& val)
- {
- g(val); // val is non-const lvalue => calls g(X&)
- }
- void f(X const& val)
- {
- g(val); // val is const lvalue => calls g(X const&)
- }
- void f(X&& val)
- {
- g(std::move(val)); // val is non-const lvalue => needs std::move() to call g(X&&)
- }
- int main()
- {
- X v; // create variable
- X const c; // create constant
- f(v); // f() for nonconstant object calls f(X&) => calls g(X&)
- f(c); // f() for constant object calls f(X const&) => calls g(X const&)
- f(X()); // f() for temporary calls f(X&&) => calls g(X&&)
- f(std::move(v)); // f() for movable variable calls f(X&&) => calls g(X&&)
- }
在这里,我们看到 f() 的三种不同实现,其将参数转发给 g():
- void g(X&)
- {
- std::cout << "g() for variable\n";
- }
- void g(X const&)
- {
- std::cout << "g() for constant\n";
- }
- void g(X&&)
- {
- std::cout << "g() for movable object\n";
- }
请注意,可移动对象的代码(通过右值引用)不同于其他代码:它需要一个 std::move,因为根据语言规则,移动语义不会被传递 1。虽然第三个 f() 中的 val 被声明为右值引用,但当用作表达式时,它的值类别是非恒定左值(见附录 B),其行为与第一个 f() 中的 val 相同。如果没有 move(),将调用非常数左值的 g(X&) 而不是 g(&&)。
如果我们想在泛型代码中组合所有三种情况,我们有一个问题:
- template<typename T>
- void f(T val)
- {
- g(T);
- }
适用于前两种情况,但不适用于可移动对象传递的(第三种)情况。
为此,C++11 引入了完美转发参数的特殊规则。实现这一点的常用代码模式如下:
- template<typename T>
- void f(T&& val)
- {
- g(std::forward<T>(val)); // perfect forward val to g()
- }
请注意,对于传递的参数,std::move() 没有模板参数和“触发”移动语义,而 std::forward<>() “转发”潜在的移动语义取决于传递的模板参数。
针对模板参数 T 的 T&&,不要假设它的行为与特定类型 X 的 X&& 相同。它们适用于不同的规则!然而,它们在语法上看起来是一样的:
• 特定类型 X 的 X&& 将参数声明为右值引用。它只能绑定到一个可移动对象(一个 prvalue。如临时对象,以及一个 xvalue,如用 std::move 传递的对象;详见附录 B)。它总是可变的,你总能“偷到”它的值 2。
• 模板参数 T 的 T&& 声明了一个转发引用(也称通用引用)3。它可以绑定到可变的、不变的(即常量)或可移动的对象。在函数定义中,参数可能是可变的、不变的,或者引用一个可以“窃取”其内部的值。
请注意,T 必须确定是模板参数的名称。依赖模板参数是不够的。对于模板参数 T,诸如 typename T::iterator&& 这样的声明只是一个右值引用,而不是转发引用。
所以,整个完美转发的程序将如下所示:
- #include <utility>
- #include <iostream>
- class X
- {
- //...
- };
- void g(X&)
- {
- std::cout << "g() for variable\n";
- }
- void g(X const&)
- {
- std::cout << "g() for constant\n";
- }
- void g(X&&)
- {
- std::cout << "g() for movable object\n";
- }
- // let f() perfect forward argument val to g():
- template<typename T>
- void f(T&& val)
- {
- g(std::forward<T>(val)); // call the right g() for any passed argument val
- }
- int main()
- {
- X v; // create variable
- X const c; // create constant
- f(v); // f() for variable calls f(X&) => calls g(X&)
- f(c); // f() for constant calls f(X const&) => calls g(X const&)
- f(X()); // f() for temporary calls f(X&&) => calls g(X&&)
- f(std::move(v)); // f() for move-enabled variable calls f(X&&) => calls g(X&&)
- }
当然,完美转发也可以与可变模板一起使用(参见第 60 页的 4.3 节中的一些示例)。有关完美转发的详细信息,请参见第 280 页的 15.6.3 节。
6.2 特殊成员函数模板
成员函数模板也可以用作特殊的成员函数,包括构造函数,然而这可能会导致令人惊讶的行为。
考虑以下例子:
- #include <utility>
- #include <string>
- #include <iostream>
- class Person
- {
- private:
- std::string name;
- public:
- // constructor for passed initial name:
- explicit Person(std::string const& n) : name(n)
- {
- std::cout << "copying string-CONSTR for '" << name << "'\n";
- }
- explicit Person(std::string&& n) : name(std::move(n))
- {
- std::cout << "moving string-CONSTR for '" << name << "'\n";
- }
- // copy and move constructor
- Person(Person const& p) : name(p.name)
- {
- std::cout << "COPY-CONSTR Person '" << name << "'\n";
- }
- Person(Person&& p) : name(std::move(p.name))
- {
- std::cout << "MOVE-CONSTR Person '" << name << "'\n";
- }
- };
- int main()
- {
- std::string s = "name";
- Person p1(s); // init with string object => calls copying string-CONSTR
- Person p2("tmp"); // init with string literal => calls moving string-CONSTR
- Person p3(p1); // copy Person => calls COPY-CONSTR
- Person p4(std::move(p1)); // move Person => calls MOVE-CONST
- }
这里,我们有一个 Person 类,它有一个字符串成员 name,我们为它提供了初始化构造函数。为了支持移动语义,我们重载了采用 std::string 的构造函数:
• 我们为字符串对象提供了一个版本,这个版本调用者仍然需要,其中 name 由传递参数的副本进行初始化:
- Person(std::string const& n) : name(n)
- {
- std::cout << "copying string-CONSTR for '" << name << "'\n";
- }
• 我们为可移动字符串对象提供了一个版本,为此我们调用 std::move() “偷取”值:
- Person(std::string&& n) : name(std::move(n))
- {
- std::cout << "moving string-CONSTR for '" << name << "'\n";
- }
正如预期的那样,第一个调用针对传递的正在使用的字符串对象(左值),而后者调用针对可移动对象(右值):
- std::string s = "name";
- Person p1(s); // init with string object => calls copying string-CONSTR
- Person p2("tmp"); // init with string literal => calls moving string-CONSTR
除了这些构造函数之外,该实例还有复制和移动构造函数的特定实现,用来查看 Person 作为一个整体何时被复制/移动:
- Person p3(p1); // copy Person => calls COPY-CONSTR
- Person p4(std::move(p1)); // move Person => calls MOVE-CONST
现在,让我们用一个通用构造函数替代这两个字符串构造函数,将传递的参数完美转发给成员 name :
- #include <utility>
- #include <string>
- #include <iostream>
- class Person
- {
- private:
- std::string name;
- public:
- template<typename STR>
- explicit Person(STR&& n) : name(std::forward<STR>(n))
- {
- std::cout << "TMPL-CONSTR for '" << name << "'\n";
- }
- // copy and move constructor
- Person(Person const& p) : name(p.name)
- {
- std::cout << "COPY-CONSTR Person '" << name << "'\n";
- }
- Person(Person&& p) : name(std::move(p.name))
- {
- std::cout << "MOVE-CONSTR Person '" << name << "'\n";
- }
- };
传递字符串的构造函数允许良好,如预期:
- std::string s = "name";
- Person p1(s); // init with string object => calls TMPL-CONSTR
- Person p2("tmp"); // init with string literal => calls TMPL-CONSTR
注意在当前情况下,为什么 p2 的构造不创建临时字符串:参数 STR 被推断为类型 char const[4]。将 std::forward 应用于构造函数的指针参数没有太大效果,因此成员 name 是以空值结尾的字符串构造的。
但是当我们试图调用复制构造函数时,我们会得到一个错误:
- Person p3(p1); // ERROR
然而通过可移动对象初始化一个新的 Person 仍然可以正常工作:
- Person p4(std::move(p1)); // OK: move Person => calls MOVE-CONST
请注意,复制一个常量 Person 也正常工作:
- Person const p2c("ctmp"); // init constant object with string literal
- Person p3c(p2c); // OK: copy constant Person => calls COPY-CONSTR
问题的原因为,根据 C++ 的重载解析规则(参见第 333 页的 16.2.4 节),对于非常量左值 Person p,成员模板
- template<typename STR>
- Person(STR&& n)
比(通常是预定义的)复制构造函数更匹配:
- Person(Person const& p)
STR 只是用 Person& 代替,而对于复制构造函数,转换为 const 是必须的。
你可以考虑通过提供一个非常规的复制构造函数来解决这个问题:
- Person(Person& p)
但是,这只是部分解决方案,因为对于派生类的对象,成员模板仍然是更好的匹配。你真正想要的是在传递的参数是 Person 或一个可转化为 Person 的表达式的时候,禁用成员模板。这可以通过使用下一节中介绍的 std::enable_if<> 来实现。
6.3 使用 enable_if<> 来禁用模板
自 C++11 以来,C++ 标准库提供一个助手模板 std::enable<> 以便在某些编译条件下忽略函数模板。
例如,如果函数模板 foo<>() 定义如下:
- template<typename T>
- typename std::enable_if<(sizeof(T) > 4)>::type
- foo()
- {
- }
如果 sizeof(T) > 4 结果为 false,则忽略 foo<> 这个定义 4。如果 sizeof(T) > 4 结果为 true,则函数模板扩展为
- void foo()
- {
- }
也就是说,std::enable_if<> 是一种类型萃取,它计算给定的编译时表达式,这里表达式通过(第一个)模板参数传递。其行为如下:
• 如果表达式结果为 true,则它的成员 type 生成一个类型:
–如果没有传递第二个模板参数,则类型为 void。
–否则,该类型是第二个模板参数类型。
• 如果表达式结果为 false,则成员 type 不会被定义。由于后来引入了一个名叫 SFINAE(替换失败不是错误)的模板特性(参见第 129 页的 8.4 节),其结果是带有 enable_if 表达式的函数模板被忽略。
从 C++14 开始,所有产出类型的类型萃取,有一个相应的别名模板 std::enable_if_t<>,它允许你跳过 typename 和 ::type(详见第 40 页的 2.8 节)。因此,从 C++14 开始你可以写
- template<typename T>
- std::enable_if_t<(sizeof(T) > 4)>
- foo()
- {
- }
如果将第二个参数传递给 enable_if<> 或 enable_if_t<>:
- template<typename T>
- std::enable_if_t<(sizeof(T) > 4), T>
- foo()
- {
- return T();
- }
如果表达式结果为 true,enable_if 构造扩展到第二个参数。所以,如果 MyType 是作为 T 传递或推断的具体类型,其大小大于 4,那么效果是
- MyType foo();
请注意,在声明中间使用 enable_if 表达式是非常笨拙的。因此,使用 std::enable_if<> 的常见方法是使用具有默认值的附加函数模板参数:
- template<typename T,
- typename = std::enable_if_t<(sizeof(T) > 4)>>
- void foo()
- {
- }
其将会扩展为
- template<typename T,
- typename = void>
- void foo()
- {
- }
如果 sizeof(T) > 4。
如果这仍然太笨拙,并且你想使用需求/约束更加明确,你可以使用别名模板为其定义自己的名称:5
- template<typename T>
- using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;
- template<typename T,
- typename = EnableIfSizeGreater4<T>>
- void foo()
- {
- }
有关如何实现 std::enable_if 的讨论,请参见第 469 页的 20.3 节。
6.4 使用 enable_if<>
我们可以使用 enable_if<> 来解决我们在第 95 页 6.2 节中介绍的构造模板的问题。
我们要解决的问题是禁用模板构造函数的声明
- template<typename STR>
- Person(STR&& n);
如果传递的参数 STR 具有正确的类型(即是 std::string 或可转换为 std::string 的类型)。
为此,我们使用另一个标准类型萃取 std::is_convertible<FROM,TO>。对于 C++17,相应的声明如下所示:
- template<typename STR,
- typename = std::enable_if_t<
- std::is_convertible_v<STR, std::string>>>
- Person(STR&& n);
如果 STR 可以转换为 std::string 类型,则整个声明扩展为
- template<typename STR,
- typename = void>
- Person(STR&& n);
如果 STR 不能转换为 std::string 类型,则忽略整个函数模板 6。
同样,我们可以使用别名模板为约束定义自己的名称:
- template<typename T>
- using EnableIfString = std::enable_if_t<
- std::is_convertible_v<T, std::string>>;
- ……
- template<typename STR, typename = EnableIfString<STR>>
- Person(STR&& n);
因此,整个 Person 如下:
- #include <utility>
- #include <string>
- #include <iostream>
- #include <type_traits>
- template<typename T>
- using EnableIfString = std::enable_if_t<
- std::is_convertible_v<T, std::string>>;
- class Person
- {
- private:
- std::string name;
- public:
- // generic constructor for passed initial name:
- template<typename STR, typename = EnableIfString<STR>>
- explicit Person(STR&& n)
- : name(std::forward<STR>(n))
- {
- std::cout << "TMPL-CONSTR for '" << name << "'\n";
- }
- // copy and move constructor
- Person(Person const& p) : name(p.name)
- {
- std::cout << "COPY-CONSTR Person '" << name << "'\n";
- }
- Person(Person&& p) : name(std::move(p.name))
- {
- std::cout << "MOVE-CONSTR Person '" << name << "'\n";
- }
- };
现在,所有的调用行为都符合预期:
- #include "specialmemtmpl3.hpp"
- int main()
- {
- std::string s = "sname";
- Person p1(s); // init with string object => calls TMPL-CONSTR
- Person p2("tmp"); // init with string literal => calls TMPL-CONSTR
- Person p3(p1); // OK => calls COPY-CONSTR
- Person p4(std::move(p1)); // OK => calls MOVE-CONST
- }
再次注意,在 C++14 中,我们必须按如下声明别名模板,因为没有为产出值的类型萃取定义 _v 版本:
- template<typename T>
- using EnableIfString = std::enable_if_t<
- std::is_convertible<T, std::string>::value>;
在 C++11 中,我们必须按如下声明特殊成员模板,因为正如所写的那样。没有为产出类型的类型萃取定义 _t 版本:
- template <typename T>
- using EnableIfString =
- typename std::enable_if<
- std::is_convertible<T, std::string>::value
- >::type;
但是现在这些都隐藏在 EnableIfString<> 的定义中了。
还要注意,除了使用 std::is_convertible<>,还有一种替代方法,因为它要求类型是可隐式转换的。通过使用 std::is_constructible<>,我们还允许显式转化用于初始化。然而在这种情况下,参数的顺序是相反的:
- template<typename T>
- using EnableIfString = std::enable_if_t<
- std::is_constructible_v<std::string, T>>;
有关 std::is_constructible<> 的详细信息,请参见第 719 页的 D.3.2 节。有关 std::is_convertible<> 的详细信息,请参见第 727 页的 D.3.3 节。参见第 734 页的 D.6 节,了解在可变模板上应用 enable_if<> 的详细信息和示例。
禁用特殊成员函数
请注意,通常我们不能使用 enable_if<> 来禁止预定义的复制(移动)构造函数和(或)赋值运算符。原因是成员函数模板从不被视为特殊成员函数,并且在需要复制构造函数时被忽略。因此,通过这一声明:
- class C
- {
- public:
- template<typename T>
- C(T const&)
- {
- std::cout << "tmpl copy constructor\n";
- }
- // ……
- };
当请求 C 的副本时,仍然使用预定义的副本构造函数:
- C x;
- C y{ x }; // still use the predefined copy constructor (not the member template)
(真的没有办法使用成员模板,因为没有办法指定或者推断它的模板参数 T。)
删除预定义的复制构造函数不是解决方案,因为尝试复制 C 会导致错误。
不过,有一个高阶的解决方案 7:我们可以使用 const volatile 参数声明一个复制构造函数,并将其标记为“已删除”(例如使用 = delete 来定义它)。这样做可以防止隐式声明另一个复制构造函数。有了这些,我们可以定义一个构造函数模板,针对于非易失性类型,它将优先于(已删除的)复制构造函数。
- class C
- {
- public:
- // ……
- // user-define the predefined copy constructor as deleted
- // (with conversion to volatile to enable better matches)
- C(C const volatile&) = delete;
- // implement copy constructor template with better match:
- template<typename T>
- C(T const&)
- {
- std::cout << "tmpl copy constructor\n";
- }
- // ……
- };
现在模板构造函数甚至用于“正常”复制:
- C x;
- C y{ x }; // uses the member template
在这样的模板构造函数中,我们可以使用 enable_if<> 应用附加约束。例如,如果模板参数是整数,为了防止复制类模板 C<> 的对象,我们可以实现以下内容:
- template<typename T>
- class C
- {
- public:
- // ……
- // user-define the predefined copy constructor as deleted
- // (with conversion to volatile to enable better matches)
- C(C const volatile&) = delete;
- template<typename U,
- typename =
- std::enable_if_t<!std::is_integral<U>::value>>
- C(C<U> const&)
- {
- // ……
- }
- // ……
- };
6.5 使用概念简化 enable_if<> 表达式
即使在使用别名模板时,enable_if 语法也相当笨拙,因为它使用了一种变通的方法:为了获得所需的效果,我们添加了一个额外的模板参数,并“滥用”了该参数,以提供函数模板可用的特定要求。像这样的代码很难阅读,并使函数模板的其余部分很难理解。
原则上,我们只需要一个语法特性,它允许我们为一个函数指定需求或约束,如果需求(约束)没有得到满足,就会导致该函数被忽略。
这是期待已久的语言特性概念的应用,它允许我们用自己简单的语法为模板指定需求(条件)。不幸的是,尽管讨论了很长时间,概念仍然没有称为 C++17 标准的一部分。然而,一些编译器为这样的特性提供了实验性的支持,并且概念很可能称为 C++17 之后的下一个标准的一部分。有了概念,当提及它们的用途时,我们只需写下以下内容:
- template<typename STR>
- requires std::is_convertible_v<STR, std::string>
- Person(STR&& n) : name(std::forward<STR>(n))
- {
- // ……
- }
我们甚至可以将需求指定为一个一般概念
- template<typename T>
- concept ConvertibleToString =
- std::is_convertible_v<T, std::string>;
并将这一概念表述为一项要求:
- template<typename STR>
- requires ConvertibleToString<STR>
- Person(STR&& n) : name(std::forward<STR>(n))
- {
- // ……
- }
也可以表述如下:
- template<ConvertibleToString STR>
- Person(STR&& n) : name(std::forward<STR>(n))
- {
- // ……
- }
有关 C++ 概念的详细讨论,请参见附录 E。
6.6 总结
• 在模板中,你可以“完美”转发参数,方法是将它们声明为转发引用(用模板参数名后跟 && 的类型声明),并在转发调用中使用 std::forward<>()。
• 当使用完美转发成员函数模板时,它们可能比预定义的特殊成员函数更好地匹配复制或移动对象。
• 当使用 std::enable_if<> 时,你可以在编译时条件为 false 的时候禁用函数模板(一旦确定该条件,模板将被忽略)。
• 当单个参数调用的构造函数模板或赋值运算符模板比隐式生成的特殊成员函数更匹配时,可以通过使用 std::enable_if<> 避免此问题。
• 通过删除 const volatile 的预定义特殊成员函数,你可以将模板化(并应用 std::enable_if<>)特殊成员函数。
• 概念将允许我们对函数模板的需求,使用更直观的语法。
1 移动语义不会自动传递的事实是有意义的,也是重要的。如果不是,我们第一次在函数中使用可移动对象时,它的值就会丢失。
2 像 X const&& 这样的类型是有效的,但在实践中不提供通用语义,因为“窃取”可移动对象的内部,就表示需要修改该对象。但是,它可以用于强制只传递临时对象或标有 std::move() 的对象,而不能修改它们。
3 通用引用 这个术语是由 Scott Meyers 创造的。作为一个普通术语,它可以产生“左值引用”或“右值引用”。因为“通用”太通用了,C++17 标准引入了术语转发引用,因为使用这种引用的主要原因是转发对象。但是请注意,它不会自动转发。该术语并不描述它是什么,而是描述它通常用于什么。
4 不要忘记将条件放入括号,否则条件中的 > 将结束模板参数列表。
5 感谢 Stephen C. Dewhurst 指出这一点。
6 如果你想知道为什么我们不检查 STR 是否“不可转换为 Person”。请注意:我们正在定义一个函数,它将允许我们把字符串转换成 Person。所以构造函数要知道它是否被启用,这取决于它是否可以转换、取决于是否被启动,以及等等。切勿在影响 enable_if 使用条件的地方使用 enable_if。这是编译器不一定能检测到的逻辑错误。
7 感谢 Peter Dimov 指出这项技术。
第 7 章
按值还是按引用?
从一开始,C++ 就提供了按值调用和按引用调用,选择哪一种并不总是容易的:通常按引用调用调用对于非平凡对象来说开销更小,但更复杂。C++11 中增加了移动语义,这意味着我们现在有不同的方法按引用传递 1:
1.X const& (常量左值引用)
引用传递对象的参数,但不能修改它。
2.X&(非常量左值引用)
引用传递对象的参数,可以修改它。
3.X&&(右值引用)
引用传递对象的参数,具有移动语义,这意味着你可以修改或“窃取”该值。
决定如何用已知的具体类型来声明参数已经足够复杂了。在模板中,类型是未知的,因此决定哪种传递机制合适变得更加困难。
然而,在第 20 页的 1.6.1 节中,我们确实建议按值传递函数模板中的参数,除非有充足的理由,例如:
• 不能复制 2。
• 参数用于返回数据。
• 模板只是保留原始参数的所有属性而将其转发到其他地方。
• 性能有显著提升。
本章讨论了在模板中声明参数的不同方法,激发按值传递的一般建议,并提供了不这样做的理由。它还讨论了在处理字符串和其他原始数组时遇到的高阶问题。
阅读本章时,熟悉值类别的术语(lvalue、rvalue、prvalue 和 xvalue 等)是有帮助的,详见附录 B。
7.1 按值传递
当按值传递参数时,原则上每个参数都必须被复制。因此,每个参数都成为传递参数的副本。对于类,创建为副本的对象通常由副本构造函数进行初始化。
调用复制构造函数会使开销变得昂贵。然而,即使在通过按值传递参数时,也有各种方法来避免昂贵开销的复制:事实上,编译器可能优化复制对象的复制操作;并且通过移动语义,即使是复杂的对象,也可能使开销变得廉价。
例如,让我们来看一个简单的函数模板,它通过按值传递参数:
- template<typename T>
- void printV(T arg)
- {
- // ……
- }
为整数调用此函数模板时,结果代码为
- void printV(int arg)
- {
- // ……
- }
参数 arg 成为任何传递参数的副本,无论它是对象、常量还是函数返回的值。
如果我们定义一个 std::string 并为此调用我们的函数模板:
- std::string s = "hi";
- printV(s);
模板参数 T 被实例化为 std::string,因此我们得到
- void printV(std::string arg)
- {
- // ……
- }
同样,在传递字符串时,arg 变成了 s 的副本。这时该副本由字符串类的复制构造函数创建,这是一个潜在的昂贵操作。因为原则上,该复制操作会创建一个完整或“深度”的副本,以便该副本在内部分配自己的内存来保存该值 3。
然而,潜在的复制构造函数并不总是被调用。请考虑以下几点:
- std::string returnString();
- std::string s = "hi";
- printV(s); // copy constructor
- printV(std::string("hi")); // copying usually optimized away (if not, move constructor)
- printV(returnString()); // copying usually optimized away (if not, move constructor)
- printV(std::move(s)); // move constructor
在第一次调用时,我们传递了一个左值,这意味着使用了复制构造函数。但是,在第二次和第三次调用中,当直接为 prvalues(在运行时创建的临时对象,或由另一个函数返回的对象;参见附录 B)调用函数模板时,编译器通常会优化参数的传递,以便根本不调用复制构造函数。请注意,自 C++17 开始,这种优化是强制的。在 C++17 之前,一个不优化复制的编译器至少必须尝试使用移动语义,这通常会使复制开销变得廉价。在最后一次调用中,当传递一个 xvalue(现有非常量对象上作用 std::move)时,我们通过发出不再需要 s 值的信号,以此来强制调用移动构造函数。
因此,如果我们传递一个 lvalue(我们之前创建的一个对象,通常在之后仍然使用,因为我们没有使用 std::move() 来传递它),那么调用 printV() 的一个实现,声明的参数按值进行传递,其开销通常是很昂贵的。不幸的是,这是一个非常常见的情况。一个原因是,这是很常见的情形:早期创建对象,然后(经过一些修改后)将它们传递给其他函数。
通过值朽化传递
我么必须提到的另一个按值传递的属性:当按值传递参数时,类型会朽化。这意味着原始数组被转换为指针,并且像 const 和 volatile 这样的限定符被移除(就像使用 auto 声明的作为初始值的对象一样)4:
- template<typename T>
- void printV(T arg)
- {
- // ……
- }
- std::string const c = "hi";
- printV(c); // c decays so that arg has type std::string
- printV("hi"); // decays to pointer so that arg has type char const*
- int arr[4];
- printV(arr); // decays to pointer so that arg has type char const*
因此,当传递字符串常量 "hi" 的时候,它的类型从 char const[3] 朽化为 char const*,这是 T 的推断类型。因此,这个模板初始化如下:
- void printV(char const* arg)
- {
- // ……
- }
这个行为源于 C 语言,有其优点和缺点。通常,它简化了传递字符串常量的处理;但缺点是在 printV 内部,我们无法区分传递的是指向单个元素的指针,还是传递的原始数组。为此,我们将在第 115 页的 7.4 节中讨论如何处理字符串常量和其他原始数组。
7.2 按引用传递
现在我们来讨论一下不同口味的按引用传递。在所有情况下,都不会创建副本(因为参数只引用传递的参数)。此外,传递的参数永远不会朽化。但是,有时候传递不被允许;且如果传递允许,在某些情况下,参数的结果类型可能会导致问题。
7.2.1 按常量引用传递
为了避免任何(不必要的)复制,当传递非临时对象时,我们可以使用常量引用。例如:
- template<typename T>
- void printR(T const& arg)
- {
- // ……
- }
有了这个声明,传递一个对象永远不会创建副本(不管它是否开销廉价):
- std::string returnString();
- std::string s = "hi";
- printR(s); // no copy
- printR(std::string("hi")); // no copy
- printR(returnString()); // no copy
- printR(std::move(s)); // no copy
甚至一个 int 都是按引用传递的,这有点适得其反,但应该没有那么重要。因此:
- int i = 42;
- printR(i); // passes reference instead of just copying i
导致 printR() 被实例化为:
- void printR(int const& arg)
- {
- // ……
- }
在幕后,按引用传递参数是通过传递参数的地址来实现的。地址被紧凑地编码,因此从调用者向被调用者传递地址本身是有效地。然而,当编译器编译调用者地代码时,传递地址会给编译器带来不确定性:被调用者用这个地址做什么?理论上,被调用者可以通过该地址更改“可到达的”所有值。这意味着编译器必须假设所有缓存的值(通常在机器寄存器中)在调用后都是无效的。重新加载这些值可能会开销昂贵。你可能会认为我们是在按常量引用:编译器不能从常量引用中推断出不会发生变化么?不幸的是,情况并非如此,因为调用者可以通过它自己的非常量引用来修改被引用的对象 5。
内联缓和了这个坏消息:如果编译器可以内联扩展调用,它可以一起推断调用者和被调用者,并且在许多情况下“看到”地址除了传递基础值之外没有任何用途。函数模板通常非常短,因此可能是内联扩展的候选对象。但是,如果模板封装了更复杂的算法,内联就不太可能发生。
按引用传递不会朽化
按引用将参数传递给参数时,它们不会朽化。这意味着原始数组不会转换为指针,并且 const 和 volatile 等限定符不会被移除。但是,因为调用参数被声明为 T const&,所以模板参数 T 自身并没有被推断为 const。例如:
- std::string const c = "hi";
- printR(c); // T deduced as std::string, arg is std::string const&
- printR("hi"); // T deduced as char[3], arg is char const(&)[3]
- int arr[4];
- printR(arr); // T deduced as int[4], arg is int const(&)[4]
因此,在 printR() 中用类型 T 声明的局部对象不是常量。
7.2.2 按非常量引用传递
当你想要通过传递的参数返回值时(即,当你想要使用 out 或 inout 参数时),你必须使用非常量引用(除非你更喜欢通过指针传递它们)。同样,这意味着在传递参数时,不会创建副本。被调用的函数模板的参数只是直接访问传递的参数。
考虑以下情形:
- template<typename T>
- void outR(T& arg)
- {
- // ……
- }
请注意,通常调用 outR() 时,临时值(prvalue)或用 std::move() 传递的现有值是不允许的:
- std::string returnString();
- std::string s = "hi";
- outR(s); // OK: T deduced as std::string, arg is std::string&
- outR(std::string("hi")); // ERROR: not allowed to pass a temporary (prvalue)
- outR(returnString()); // ERROR: not allowed to pass a temporary (prvalue)
- outR(std::move(s)); // ERROR: not allowed to pass an xvalue
你可以传递非常数类型的原始数组,这些数组也不会朽化:
- int arr[4];
- outR(arr); // OK: T deduced as int[4], arg is int(&)[4]
因此,你可以修改元素,例如,处理数组的大小。例如:
- template<typename T>
- void outR(T& arg)
- {
- if (std::is_array<T>::value)
- {
- std::cout << "got array of " << std::extent<T>::value << " elems\n";
- }
- // ……
- }
然而,模板在这里有些棘手。如果你传递一个 const 参数,推断可能导致 arg 成为常量引用的声明,这意味着传递一个右值突然被允许,其中左值才是预期的:
- std::string const c = "hi";
- outR(c); // OK: T deduced as std::string const
- outR(returnConstString()); // OK: same if returnConstString() returns const strings
- outR(std::move(c)); // OK: T deduced as std::string const 6
- outR("hi"); // OK: T deduced as char const[3]
当然,在这种情况下,任何试图修改函数模板中传递参数的行为都是错误的。在调用表达式本身中传递一个 const 对象是可能的,但是当函数完全实例化时(这可能在编译过程的较晚发生),任何修改该值的尝试都会触发一个错误(然而,这可能发生在被调用模板的内部;见第 143 页的 9.4 节)。
如果要禁止将常量对象传递给非常量引用,可以执行以下操作:
• 使用静态断言触发编译时错误:
- template<typename T>
- void outR(T& arg)
- {
- static_assert(!std::is_const<T>::value,
- "out parameter of foo<T>(T&) is const");
- // ……
- }
• 使用 std::enable_if<> 禁用这种情况下的模板(参见第 98 页的 6.3 节):
- template<typename T,
- typename = std::enable_if_t<!std::is_const<T>::value>>
- void outR(T& arg)
- {
- // ……
- }
或者当概念支持的时候:
- template<typename T>
- requires !std::is_const_v<T>
- void outR(T& arg)
- {
- // ……
- }
7.2.3 按转发引用传递
使用引用调用的一个原因时为了能够完美转发一个参数(参见第 91 页的 6.1 节)。但是请注意,当使用转发引用时,如果其被定义为模板参数的右值引用,将会应用特殊的规则。
考虑如下情况:
- template<typename T>
- void passR(T&& arg) // arg declared as forwarding
- {
- // ……
- }
你可以将所有内容传递给一个转发引用,并且与传递引用时一样,不会创建任何副本:
- std::string s = "hi";
- passR(s); // OK: T deduced as std::string& (also the type of arg)
- passR(std::string("hi")); // OK: T deduced as std::string, arg is std::string&&
- passR(returnString()); // OK: T deduced as std::string, arg is std::string&&
- passR(std::move(s)); // OK: T deduced as std::string, arg is std::string&&
- passR(arr); // OK: T deduced as int(&)[4] (also the type of arg)
但是,类型推断的特殊规则可能会带来一些惊喜:
- std::string const c = "hi";
- passR(c); // OK: T deduced as std::string const&
- passR("hi"); // OK: T deduced as char const(&)[3] (also the type of arg)
- int arr[4];
- passR(arr); // OK: T deduced as int(&)[4] (also the type of arg)
在每种情况下,在 passR() 内部,参数 arg 都有一个类型,它“知道”我们是传递了一个右值(使用移动语义)还是一个常量/非常量左值。这是传递参数的唯一方法,这样它就可以用来区分所有三种情况的行为。
这给人的印象是,将参数声明为转发引用几乎是完美的。但是要注意,天下没有免费的午餐。
例如,这是参数模板 T 隐式成为引用类型的唯一情况。因此,在没有初始化的情况下使用 T 声明本地对象可能会成为一个错误:
- template<typename T>
- void passR(T&& arg) // arg declared as forwarding
- {
- T x; // for passed lvalues, x is a reference, which requires an initializer
- // ……
- }
- passR(42); // OK: T deduced as int
- int i;
- passR(i); // ERROR: T deduced as int&, which makes the declaration of x in passR() invalid
请参阅第 279 页的 15.6.2 节,了解如何处理这种情况的更多详细信息。
7.3 使用 std::ref() 和 std::cref()
从 C++11 开始,对于函数模板参数,你可以让调用者决定是按值还是按引用传递。当模板声明为按值接受参数时,调用方可以使用 std::cref() 和 std::ref() 来按引用传递,这两个函数在 <functional> 头文件中定义。例如:
- template<typename T>
- void printT(T arg)
- {
- // ……
- }
- std::string s = "hello";
- printT(s); // pass s by reference
- printT(std::cref(s)); // pass s "as if by reference"
但是请注意,std::cref() 不会更改模板中参数的处理。相反,它使用了一个技巧:它通过一个充当引用的对象包装传递的参数。事实上,它创建了一个引用原始参数的 std::reference_wrapper<> 类型对象,并按值传递该对象。包装器或多或少只支持一个操作:隐式类型转换回原始类型,产生原始对象 7。因此,只要对传递的对象作用有效的操作符,就可以使用引用包装。例如:
- #include <functional> // for std::cref()
- #include <string>
- #include <iostream>
- void printString(std::string const& s)
- {
- std::cout << s << '\n';
- }
- template<typename T>
- void printT(T arg)
- {
- printString(arg); // might convert arg back to std::string
- }
- int main()
- {
- std::string s = "hello";
- printT(s); // print s passed by value
- printT(std::cref(s)); // print s passed "as if by reference"
- }
最后一次调用按值将 std::reference_wrapper<string const> 类型的对象传递给参数 arg,然后参数 arg 继续传递,并将其转换回它的基础类型 std::string。
请注意,编译器必须知道隐式转换回原始类型是必要的。因此,只有在通过泛型代码传递对象时,std::cref() 和 std::ref() 才能正常工作。例如,直接尝试输出泛型类型 T 的传递对象将失败,因为没有为 std::reference_wrapper<> 定义输出运算符:
- template<typename T>
- void printT(T arg)
- {
- std::cout << arg << '\n';
- }
- // ……
- std::string s = "hello";
- printT(s); // OK
- printT(std::cref(s)); // ERROR: no operator << for reference wrapper defined
同样下面的例子也会失败,因为你无法比较使用引用包装的 char const* 或者 std::string:
- template<typename T1, typename T2>
- bool isless(T1 arg1, T2 arg2)
- {
- return arg1 < arg2;
- }
- // ……
- std::string s = "hello";
- if (isless(std::cref(s), "world")) {/*……*/ } // ERROR
- if (isless(std::cref(s), std::string("world"))) {/*……*/ } // ERROR
给 arg1 和 arg2 同样的类型 T 也无济于事:
- template<typename T>
- bool isLess(T arg1, T arg2)
- {
- return arg1 < arg2;
- }
因为这样编译器在试图推断 arg1 和 arg2 的 T 时会得到冲突的类型。
因此,std::reference_wrapper<> 类的效果是能够将引用用作“第一类对象”,你可以复制该对象,从而将值传递给函数模板。例如,你也可以在类中使用它来保存对容器中对象的引用。但是你最终总是需要转换回基础类型。
7.4 处理字符串和原始数组
到目前为止,我们已经看到了使用字符串和原始数组时模板参数的不同效果:
• 按值调用会朽化,从而成为指向元素类型的指针。
• 任何形式的按引用调用都不会朽化,因此参数会变成仍然引用数组的引用。
两者都有好坏。当数组朽化为指针时,你将无法区分指向元素指针的处理和传递数组的处理。另一方面,当处理可能传递字符串常量的参数时,不朽化可能会成为问题,因为不同大小的字符串常量有不同的类型。例如:
- template<typename T>
- void foo(T const& arg1, T const& arg2)
- {
- // ……
- }
- foo("hi", "guy"); // ERROR
这里,foo("hi","guy") 无法编译,因为 "hi" 具有 char const[3] 类型,而 "guy" 具有 char const[4] 类型,但是模板要求它们具有相同的 T 类型。只有当字符串常量具有相同长度时,这样的代码才能编译。因此,强烈建议在测试用例中使用不同长度的字符串。
通过声明函数模板 foo() 为按值传递参数,调用是可以的:
- template<typename T>
- void foo(T arg1, T arg2)
- {
- // ……
- }
- foo("hi", "guy"); // compiles, but …
但是,这并不意味着所有的问题都消失了。更糟糕的是,编译时问题可能已经变成了运行时问题。考虑下面的代码,其中我们使用运算符 == 来比较传递的参数:
- template<typename T>
- void foo(T arg1, T arg2)
- {
- if (arg1 == arg2) // OOPS: compares addresses of passed arrays
- {
- // ……
- }
- }
- foo("hi", "guy"); // ERROR
如前所述,你必须知道应该将传递的字符指针解释为字符串。但无论如何,情况可能就是这样,因为模板还必须处理来自已经朽化的字符串常量参数(例如,来自另一个按值调用的函数或分配给用 auto 声明的对象)。
然而,在许多情况下,朽化是有帮助的,特别是对于检查两个参数(两个都作为参数传递,或者一个作为传递的参数,一个作为期待的参数)是否具有或转换为相同的类型。一个典型的用法是完美转发。但是如果你想使用完美转发,你必须声明参数为转发引用。在这些情况下,你可以使用类型萃取 std::decade<> 显式朽化参数。具体例子参见第 120 页的 7.6 节中关于 std::make_pair() 的说明。
请注意,其他类型萃取有时候会隐式朽化,例如 std::common_type<> 会生成两个传递参数类型的公共类型(参见第 12 页的 1.3.3 节和第 732 页的 D.5 节)。
7.4.1 字符串和原始数组的特殊实现
你可能需要根据传递的是指针还是数组来区分你的实现。当然,这需要传递的数组还没有朽化。
为了区分这些情况,你必须检测数组是否被传递。基本上,有两种选择:
• 你可以声明模板参数,以便它们仅对数组有效:
- template<typename T, std::size_t L1, std::size_t L2>
- void foo(T(&arg1)[L1], T(&arg2)[L2])
- {
- T* pa = arg1; // decay arg1
- T* pb = arg2; // decay arg2
- if (compareArrays(pa, L1, pb, L2))
- {
- // ……
- }
- }
这里,arg1 和 arg2 必须是相同元素类型 T 的原始数组,但具有不同的大小 L1 和 L2。但是请注意,你可能需要多个实现来支持各种形式的原始数组(参见第 71 页的 5.4 节)。
• 你可以使用类型萃取来检测是否传递了数组(或指针):
- template<typename T,
- typename = std::enable_if_t<std::is_array_v<T>>>
- void foo(T&& arg1, T&& arg2)
- {
- // ……
- }
由于这些特殊的处理,通常以不同的方式处理数组的最佳方式就是简单地使用不同地函数名。当然,更好的方法是确保模板的调用者使用 std::vector 或 std::array。但是只要字符串是原始数组,我们就必须考虑它们。
7.5 处理返回值
对于返回值,你还可以决定是按值还是按引用返回。然而,返回引用可能是麻烦的来源,因为你引用的东西超出了你的控制范围。在一些情况下,返回引用是常见的编程实践。
• 返回容器的元素或返回字符串(例如,通过 operator[] 或 front())。
• 授予类成员写权限。
• 返回链式调用的对象(一般来说为,流的 operator<< 和 operator>>,类成员的 operator=)。
此外,通常通过返回常量引用来授予成员读取权限。
请注意,如果使用不当,所有这些情况都可能造成麻烦。例如:
- std::string* s = new std::string("whatever");
- auto& c = (*s)[0];
- delete s;
- std::cout << c; // run-time ERROR
在这里,我们获得了一个对字符串元素的引用,但是当我们使用该引用时,基础字符串已经不存在了(即,我们创建了一个悬空引用),并且我们有未定义行为。这个例子有些做作(有经验的程序员可能会马上注意到这个问题),但是事情很容易变得不那么明显。例如:
- auto s = std::make_shared<std::string>("whatever");
- auto& c = (*s)[0];
- s.reset();
- std::cout << c; // run-time ERROR
因此,我们应该确保函数模板按值返回它们的结果。但是,正如本章中所讨论的,使用模板 T 并不能保证它不是引用,因为 T 有时可能会被隐式推断为引用:
- template<typename T>
- T retR(T&& p) // p is a forwarding reference
- {
- return T{/*...*/}; // OOPS: returns by reference when called for lvalues
- }
即使 T 是从按值调用推断出的模板参数,当显式指定模板参数为引用时,它也可能成为引用类型:
- template<typename T>
- T retV(T p) // Note: T might become a reference
- {
- return T{/*...*/}; // OOPS: returns a reference if T is a reference
- }
- int x;
- retV<int&>(x); // retV() instantiated for T as int&
为了安全起见,你有两个选择:
• 使用类型萃取 std::remove_reference<>(参见第 729 页的 D.4 节)将类型 T 转换为非引用。
- template<typename T>
- typename std::remove_reference<T>::type retV(T p)
- {
- return T{/*...*/}; // always returns by value
- }
其他萃取,如 std::decay<>(见第 731 页的 D.4 节),在这里也可能有用,因为它们也隐式地移除了引用。
通过仅仅声明返回类型为 auto 来让编译器推断返回类型(从 C++14 开始;见第 11 页的 1.3.2 节),因为 auto 总是朽化:
- template<typename T>
- auto retV(T p) // by-value return type deduced by compiler
- {
- return T{/*...*/}; // always returns by value
- }
7.6 推荐的模板参数声明
正如我们在前面几节中了解到的,我们有非常不同的方法来声明依赖于模板参数的参数:
• 声明按值传递参数
这种方法很简单,它朽化了字符串常量和原始数组,但是它不能为大型对象提供最佳性能。调用者仍然可以决定使用 std::cref() 和 std::ref() 来按引用传递,但是调用者必须小心这样做是有效的。
• 声明按引用传递参数
这种方法通常为稍微大一点的对象提供更好的性能,尤其是在传递
– 现有对象(左值)到左值引用,
– 临时对象(prvalues)或标记为可移动的对象(xvalue)来引用右值,
– 或者两者都可以的转发引用。
因为在所有这些情况下,参数都不会朽化,所以在传递字符串常量和其他原始数组时,你可能需要特别小心。对于转发引用,你还必须注意,使用这种方法时模板参数可以隐式推断为引用类型。
一般性建议
考虑到这些选项,对于函数模板,我们建议如下:
1. 默认情况下,声明参数为按值传递。这很简单,即使是字符串也能正常工作。对于小参数和临时或可移动的对象,这种性能很好。调用方有时可以在传递现有大型对象(左值)时使用 std::ref() 和 std::cref(),以避免开销昂贵的复制。
2. 如果有充足的利用,否则不要这样做:
– 如果你需要一个 out 或 inout 的参数,该参数返回一个新对象或允许调用方修改参数,请将该参数作为非常量引用传递(除非你更喜欢通过指针传递)。但是,你需要考虑禁止意外接受 const 对象,如第 110 页的 7.2.2 节所述。
– 如果提供了模板来转发参数,请使用完美转发。也就是说,将参数声明为完美转发,并在适当的地方使用 std::forward<>()。考虑使用 std::decay<> 或 std::common_type<> 来“协调”不同类型的字符常量和原始数组。
– 如果性能是关键,并且复制参数开销很昂贵,请使用常量引用。当然,如果你需要本地副本,这并不适用。
3. 如果你知道得很清楚,不要遵循这些建议。但是,不要对性能做出直观得假设。即使专家尝试也会失败。相反的:你需要测量!
不用泛型过头
请注意,在实践中,函数模板通常不适合任何类型的参数。相关,会应用一些限制。例如,你可能知道只会传递某种类型的向量。在这种情况下,最好不要过于笼统地声明这样的函数,因为正如所讨论的,可能会出现令人惊讶的副作用:
- template<typename T>
- void printVector(std::vector<T> const& v)
- {
- // ……
- }
通过 printVector() 中的参数 v 声明,我们可以确定传递的 T 不能成为引用,因为向量不能使用引用作为元素的类型。此外,很明显的是,按值传递向量几乎总是开销昂贵,因为 std::vector<> 的复制构造函数会创建元素的副本。由于这个原因,声明这样一个向量参数来按值传递可能永远都没有用处。如果我们将参数 v 声明为由 T 决定,那么按值调用和按引用调用之间的区别就不那么明显了。
std::make_pair<> 示例
std::make_pair<>() 是一个很好的例子,演示了决定参数传递机制的陷阱。它是 C++ 标准库中的一个便利函数模板,用来使用推断的类型创建 std::pair<>。它的声明随着不同版本的 C++ 标准而改变:
• 在第一个 C++ 标准 C++98 中,make_pair<> 在命名空间 std 中声明为按引用调用,以此避免不必要的复制:
- template<typename T1, typename T2>
- pair<T1, T2> make_pair(T1 const& a, T2 const& b)
- {
- return pair<T1, T2>(a, b);
- }
然而,当使用成对的字符串常量或不同大小的原始数组时,这几乎立即引起严重的问题 8。
• 因此,在 C++03 中,函数定义被更改为按值调用:
- template<typename T1, typename T2>
- pair<T1, T2> make_pair(T1 a, T2 b)
- {
- return pair<T1, T2>(a, b);
- }
正如你在问题解决方案的基本原理中所看到的,"这似乎是一个对标准小得多的更改,相较于其他两个建议。任何效率问题都被此解决方案的优势所抵消。"
• 然而,在 C++11 中,make_pair<> 必须支持移动语义,因此参数必须成为转发引用。由于这个原因,定义又大致改变如下:
- template<typename T1, typename T2>
- constexpr pair<typename decay<T1>::type,
- typename decay<T2>::type>
- make_pair(T1&& a, T2&& b)
- {
- return pair<typename decay<T1>::type,
- typename decay<T2>::type>(
- forward<T1>(a),
- forward<T2>(b));
- }
完整的实现甚至更加复杂:为了支持 std::ref() 和 std::cref(),该函数还将 std::reference_wrapper 的实例解包为真实引用。
C++ 标准库现在在许多地方以类似的方式完美转发传递的参数,通常结合使用 std::decay<>。
7.7 总结
• 当测试模板时,使用不同长度的字符串常量。
• 按值传递的模板参数会朽化,为按引用传递的模板参数不会朽化。
• 类型萃取 std::decay<> 允许你朽化按引用传递的模板参数。
• 在某些情况下,当函数模板声明为按值传递参数时,std::cref() 和 std::ref() 允许你按引用传递参数。
• 模板参数按值传递很简单,但可能不会产生最佳性能。
• 将参数按值传递给函数模板,除非有充足的理由不这么做。
• 确保返回值通常是按值传递的(这意味着不能将模板参数直接指定为返回类型)。
• 重要的时候一定要测量性能。不要依赖于直觉;它可能是错的。
1 常量右值引用 X const&& 也是可能的,但是它没有确定的语义。
2 请注意,自 C++17 开始,即使没有复制或移动构造函数可用(参见第 676 页的 B.2.1 节),你也可以按值传递临时实体(右值)。因此,自 C++17 开始,额外的约束是左值的复制是不可能的。
3 字符串类的实现本身可能有一些优化,以使复制的开销更便宜。一种是小字符串优化(small string optimization,SSO),直接在对象内部使用一些内存来保存值,只要值不太长,就不分配内存。另一个是写时复制优化,只要源和副本都没有被修改,就使用与源相同的内存创建副本。然而,写时复制优化在多线程代码中有明显的缺陷。因此,从 C++11 开始,标准字符串就被禁止了。
4 术语朽化来自 C,也适用于从函数到函数指针的类型转换(见第 159 页的 11.1.1 节)。
5 使用 const_cast 是另一种更显式地修改被引用对象的方法。
6 当传递 std::move(c) 时,std::move() 首先将 c 转换为 std::string const&&,这样做的效果是将 T 推断为 std::string const。
7 还可以在引用包装器上调用 get(),并将其用作函数对象。
8 详见 C++ 库话题 181 [LibIssue181]。
第 8 章
编译时编程
C++ 总是包含一些在编译时计算值的简单方法。模板大大增加了这方面的可能性,语言的进一步发展只是增加了这个工具箱。
在简单的情况下,你可以决定是否使用某些模板代码,或者在不同的模板代码之间进行选择。但是只要所有必要的输入都可用,编译器甚至可以在编译时计算控制流的结果。
事实上,C++ 有多个支持编译时编程的特性:
• 在 C++98 之前,模板已经提供了在编译时计算的能力,包括使用循环和执行路径的选择。(然而,有人认为这是对模板功能的“滥用”,例如,因为它需要非直觉语法。)
• 通过部分特化,我们可以在编译时根据特定的约束或需求在不同的类模板实现之间进行选择。
• 利用 SFINAE 原理,我们可以针对不同的类型或不同的约束在不同的函数模板实现之间进行选择。
• 在 C++11 和 C++ 14,constexpr 特性使用“直觉性的”执行路径选择,以及 C++14 开始的大多数语句种类(例如 for 循环、switch 语句等),使得编译时计算得到了越来越好的支持。
• C++17 引入了一个“编译时 if”,根据编译时条件或约束来丢弃语句,它甚至可以在模板之外工作。
本章介绍这些特性,特别关注模板的作用和上下文。
8.1 模板元编程
模板在编译时被实例化(与动态语言相反,动态语言在运行时处理泛型)。事实证明,C++ 模板的一些特性可以与实例化过程相结合,以此在 C++ 语言内部产生一种原始的递归“编程语言” 1。因此,模板可以用来“计算程序”。第 23 章将涵盖整个故事和所有特性,但这里使用一个简短的例子来说明什么是可能的。
下面的代码在编译时得到给定的数字是否是质数:
- template<unsigned p, unsigned d> // p: number to check, d: current divisor
- struct DoIsPrime
- {
- static constexpr bool value = (p % d != 0) && DoIsPrime<p, d - 1>::value;
- };
- template<unsigned p> // end recursion if divisor is 2
- struct DoIsPrime<p, 2>
- {
- static constexpr bool value = (p % 2 != 0);
- };
- template<unsigned p> // primary template
- struct IsPrime
- {
- // start recursion with divisor from p/2:
- static constexpr bool value = DoIsPrime<p, p / 2>::value;
- };
- // special cases (to avoid endless recursion with template instantiation):
- template<>
- struct IsPrime<0> { static constexpr bool value = false; };
- template<>
- struct IsPrime<1> { static constexpr bool value = false; };
- template<>
- struct IsPrime<2> { static constexpr bool value = true; };
- template<>
- struct IsPrime<3> { static constexpr bool value = true; };
IsPrime<> 模板在成员 value 中返回传递的模板参数 p 是否为质数。为了实现这一点,它实例化了 DoIsPrime<>,它递归地扩展为一个表达式,用于检查 p/2 和 2 之间地每个除数 d,是否除以 p 而没有余数。
例如,表达式
- IsPrime<9>::value
扩展为
- DoIsPrime<9, 4>::value
继续扩展为
- 9 % 4 != 0 && DoIsPrime<9, 3>::value
继续扩展为
- 9 % 4 != 0 && 9 % 3 != 0 && DoIsPrime<9, 2>::value
继续扩展为
- 9 % 4 != 0 && 9 % 3 != 0 && 9 % 2 != 0
其计算结果为 false,因为 9 % 3 等于 0。
正如这个实例链所展示的:
• 我们使用 DoIsPrime<> 的递归展开式来迭代从 p/2 到 2 的所有整数,以找出这些除数中是否有任何除数正好除以给定的整数(即没有余数)。
• d 等于 2 时,DoIsPrime<> 为部分特化,其作为结束递归的标准。
请注意,所有这些都是在编译时完成的。也就是说,
- IsPrime<9>::value
在编译时被展开为 false。
模板语法可以说是笨拙的,但是类似的代码从 C++98(和更早的)版本开始就已经有效了,并且已经证明对相当多的库有用 2。
详见第 23 章。
8.2 用 constexpr 计算
C++11 引入了一个新特性 constexpr,它大大简化了各种形式的编译时计算。特别是,给定适当的输入,可以在编译时计算 constexpr 函数。虽然在 C++11 中引入的 constexpr 函数有严格的限制(例如,每个 constexpr 函数的定义基本上只限于由一个返回语句组成),但 C++14 取消了大多数限制。当然,能成功评估 constexpr 函数仍然需要所有计算步骤在编译时都是可能的和有效的:目前,这不包括堆分配或抛出异常。
我们测试一个数是否为质数的例子可以在 C++11 中实现如下:
- constexpr bool
- doIsPrime(unsigned p, unsigned d) // p: number to check, d: current divisor
- {
- return d != 2 ? (p % d != 0) && doIsPrime(p, d - 1) // check this and smaller divisors
- : (p % 2 != 0); // end recursion if divisor is 2
- }
- constexpr bool isPrime(unsigned p)
- {
- return p < 4 ? !(p < 2) // handle special cases
- : doIsPrime(p, p / 2); // start recursion with divisor from p / 2
- }
由于有只有一条语句的限制,我们只能使用条件运算符作为选择机制,并且我们仍然需要递归来迭代元素。但是语法是普通的 C++ 函数代码,这使得它比我们依赖模板实例化的第一个版本更容易访问。
使用 C++14,constexpr 函数可以利用一般 C++ 代码中可用的大多数控制结构。因此,我们现在可以使用一个简单的 for 循环,而不是编写笨拙的模板代码或有些晦涩难懂的单行代码:
- constexpr bool isPrime(unsigned int p)
- {
- for (unsigned int d = 2; d <= p / 2; ++d)
- {
- if (p % d == 0)
- return false; // found divisor without remainder
- }
- return p > 1; // no divisor without remainder found
- }
对于 constexpr isPrime() 的 C++11 和 C++14 版本,我们都可以简单地调用
- isPrime(9)
来得出 9 是否是质数。请注意,它可以在编译时这样做,但不一定需要这样做。在需要编译时值(例如,数组长度或非类型模板参数)的上下文中,编译器将评估对 constexpr 函数的调用,如果不可能,则发出错误(因为最终必须生成一个常量)。在其他上下文中,编译器可能会也可能不会在编译时尝试求值 3,但如果这种求值失败,则不会发出错误,调用将保留为运行时调用。
例如:
- constexpr bool b1 = isPrime(9); // evaluated at compile time
将在编译时计算该值。这同样适用于
- const bool b2 = isPrime(9); // evaluated at compile time if in namespace scope
前提是 b2 是全局定义的或是在命名空间中定义的。在块范围内,编译器可以决定编译器是在编译时计算还是在运行时计算 4。例如,以下例子就是如此:
- bool fiftySevenIsPrime()
- {
- return isPrime(57); // evaluated at compile or running time
- }
编译器可能会也可能不会在编译时评估对 isPrime 的调用。
另一方面:
- int x;
- // ……
- std::cout << isPrime(x); // evaluated at run time
将生成在运行时计算 x 是否是质数的代码。
8.3 部分特化的执行路径选择
像 isPrime() 这样编译时测试的一个有趣的应用是,使用部分特化在编译时选择不同的实现。
例如,我们可以根据模板参数是否为质数在不同的实现之间进行选择:
- // primary helper template
- template<int SZ, bool = isPrime(SZ)>
- struct Helper;
- // implementation if SZ is not a prime number:
- template<int SZ>
- struct Helper<SZ, false>
- {
- // ……
- };
- // implementation if SZ is a prime number:
- template<int SZ>
- struct Helper<SZ, true>
- {
- // ……
- };
- template<typename T, std::size_t SZ>
- long foo(std::array<T, SZ> const& coll)
- {
- Helper<SZ> h; // implementation depends on whether array has prime number as size
- // ……
- }
这里,根据 std::array<> 参数的大小是否为质数,我们使用两种不同的类 Helper<> 实现。这种部分特化的应用广泛用于根据调用函数模板的参数属性,在函数模板的不同实现中进行选择。
上面,我们使用了两个部分特化来实现两个可能的选择。相反,我们还可以将主模板用于其中一个备选(默认)案例,并将部分特化用于其他任何特殊案例:
- // primary helper template (used if no specialization fits):
- template<int SZ, bool = isPrime(SZ)>
- struct Helper
- {
- // ……
- };
- // implementation if SZ is a prime number:
- template<int SZ>
- struct Helper<SZ, true>
- {
- // ……
- };
因为函数模板不支持部分特化,所以你必须使用其他机制来基于某些约束来更改函数实现。我们的选项包括以下内容:
• 使用带有静态函数的类,
• 使用第 98 页 6.3 节中介绍的 std::enable_if,
• 使用接下来介绍的 SFINAE 特性,或者
• 使用编译时 if 特性,其从 C++17 开始可用,该特性将在 135 页的 8.5 节中介绍。
第 20 章讨论了基于约束选择函数实现的技术。
8.4 SFINE(替换失败不是错误)
在 C++ 中,重载函数来说明各种参数类型是很常见的。当编译器看到对重载函数的调用时,它必须单独考虑每个候选函数,评估调用的参数并选择最匹配的候选函数(关于这个过程的一些细节,参见附录 C)。
当调用的候选集包括函数模板时,编译器必须首先确定该候选使用什么样的模板参数,然后在函数参数列表及其返回类型中替换这些参数,然后评估它的匹配程度(就像普通函数一样)。然而,替换过程可能会遇到问题:它可能会产生毫无意义的结构。语言规则没有判定这种无意义的替换会导致错误,而是说有这种替换问题的候选会被忽略。
我们称这一原则为 SFINAE(发音类似 sfee-nay),代表“替换失败不是错误”。
请注意,这里描述的替换过程不同于按需实例化过程(参见第 27 页的 2.2 节):甚至可以对不需要的潜在实例化进行替换(因此编译器可以评估它们是否确实不需要)。它是直接出现在函数声明中的结构替换(而不是它的主体)。
考虑以下示例:
- // number of elements in a raw array:
- template<typename T, unsigned N>
- std::size_t len(T(&)[N])
- {
- return N;
- }
- // number of elements for a type having size_type:
- template<typename T>
- typename T::size_type len(T const& t)
- {
- return t.size();
- }
这里,我们定义了两个函数模板 len(),取一个泛型参数 5:
1. 第一个函数模板将参数声明为 T(&)[N],这意味着参数必须是 T 类型的 N 个元素的数组。
2. 第二个函数模板将参数简单地声明为 T,这不会对参数施加任何约束,但会返回类型 T::size_type,这要求传递的参数类型具有相应的成员 size_type。
当传递原始数组或字符串常量时,只有原始数组的函数模板匹配:
- int a[10];
- std::cout << len(a); // OK: only len() for array matches
- std::cout << len("tmp"); // OK: only len() for array matches
根据其签名,当用 int[10] 和 char const[4] 分别替换 T 时,第二个函数模板也匹配,但是这些替换会导致返回类型 T::size_type 的潜在错误。因此,对于这些调用,第二个模板被忽略。
传递 std::vector<> 时,只有第二个函数模板匹配:
- std::vector<int> v;
- std::cout << len(v); // OK: only len() for a type with size_type matches
传递原始指针时,两个模板都不匹配(没有失败)。因此,编译器会抱怨找不到匹配的 len() 函数:
- int* p;
- std::cout << len(p); // ERROR: no matching len() function found
请注意,这不同于传递具有 size_type 成员但没有 size() 成员函数的类型对象,例如 std::allocator<>:
- std::allocator<int> x;
- std::cout << len(x); // ERROR: len() function found, but can't size()
当传递这种类型的对象时,编译器会找到第二个函数模板作为匹配的函数模板。因此,这将导致编译时错误,即调用 std::allocator<int> 的 size() 无效,而不是出现找不到匹配 len() 函数的错误。这一次,第二个函数模板没有被忽略。
当替换候选对象的返回类型没有意义时,会忽略该候选对象,这会导致编译器选择另一个参数匹配性更差的候选对象。例如:
- // number of elements in a raw array:
- template<typename T, unsigned N>
- std::size_t len(T(&)(N))
- {
- return N;
- }
- // number of elements for a type having size_type
- template<typename T>
- typename T::size_type len(T const& t)
- {
- return t.size();
- }
- // fall for all other types
- std::size_t len(...)
- {
- return 0;
- }
在这里,我们还提供了一个通用的 len() 函数,该函数总是匹配,但具有最差的匹配(在重载解析中与省略号(...)匹配,参见第 682 页的 C.2 节)。
因此,对于原始数组和向量,我们有两个匹配,其中特定匹配是更好的匹配。对于指针,只有回退匹配,这样编译器就不会抱怨这个调用缺少 len() 6。但是对于分配器,第二个和第三个函数模板匹配,第二个函数模板是更好的匹配。因此,这仍然会导致无法调用 size() 成员函数的错误:
- int a[10];
- std::cout << len(a); // OK: len() for array is best match
- std::cout << len("tmp"); // OK: len() for array is best match
- std::vector<int> v;
- std::cout << len(v); // OK: len() for a type with size_type is best match
- int* p;
- std::cout << len(p); // OK: only fallback len() matches
- std::allocator<int> x;
- std::cout << len(x); // ERROR: 2nd len() function matches best,
- // but can't call size() for x
有关 SFINAE 的更多详细信息,请参见第 284 页的 15.7 节,有关 SFINAE 的一些应用,请参见第 416 页的 19.4 节。
SFINAE 和重载方案
随着时间的推移,SFINAE 原则变得如此重要,在模板设计者中如此流行,以至于缩写成了动词。如果我们打算应用 SFINAE 机制,来确保忽略针对某些约束的模板函数,其通过检测模板代码导致这些约束的代码无效,那么我们就说“我们 SFINAE 出了一个函数”。每当你在 C++ 标准中读到一个函数模板“不得参与重载解析,除非……”时,它意味着在某些情况下,SFINAE 被用来“SFINAE 出”该函数模板。
例如,类 std::thread 声明了一个构造函数:
- namespace std
- {
- class thread
- {
- public:
- //……
- template<typename F, typename... Args>
- explicit thread(F&& f, Args&&... args);
- //……
- };
- }
带有以下备注:
备注:如果 decay_t<F> 与 std::thread 类型相同,则此构造函数不参与重载解析。
这意味着,如果使用 std::thread 作为第一个且唯一的参数来调用模板构造函数,则该模板构造函数将被忽略。原因是,除此之外,像这样的成员模板函数有时可能比任何预定义的复制或移动构造函数更加匹配(详见第 95 页的 6.2 节和第 333 页的 16.2.4 节)。当调用一个线程时,通过 SFINAE 出构造函数模板,我们确保当一个线程从另一个线程得到构造时,总是使用预定义的复制或移动构造函数 7。
在个案的基础上应用这种技术可能是笨拙的。幸运的是,标准库提供了更容易禁用模板的工具。最著名的功能是 std::enable_if<>,它在第 98 页的 6.3 节中介绍。它允许我们通过用包含禁用条件的构造来替换类型,以此来禁用模板。
因此,std::thread 的真实声明通常如下:
- namespace std
- {
- class thread
- {
- public:
- //……
- template<typename F, typename... Args,
- typename = std::enable_if_t<!std::is_same_v<std::decay_t<F>, thread>>>
- explicit thread(F&& f, Args&&... args);
- //……
- };
- }
有关如何使用部分特化和 SFINAE 实现 std::enable_if<> 的详细信息,请参见第 469 页的 20.3 节。
8.4.1 SFINAE 表达式使用 decltype
在特定情况下,找出并表达出 SFINAE 出函数模板的正确表达式并不总是容易的。
例如,假设我们希望确保函数模板 len() 忽略对于具有 size_type 成员但没有 size() 成员函数的类型参数。如果函数声明中没有对 size() 成员函数的任何形式的要求,其最终实例化会导致错误:
- template<typename T>
- typename T::size_type len(T const& t)
- {
- return t.size();
- }
- std::allocator<int> x;
- std::cout << len(x) << '\n'; // ERROR: len() selected, but x has no size()
有一种常见的模式或习语来处理这种情况:
• 使用尾部返回类型语法指定返回类型(在前面使用 auto,在后面的返回类型前使用 ->)。
• 使用 decltype 和逗号运算符定义返回类型。
• 表述逗号运算符开头必须有效的所有表达式(在逗号运算符重载的情况下转换为 void)。
• 在逗号运算符的末尾定义一个实际返回类型的对象。
例如:
- template<typename T>
- auto len(T const& t) -> decltype( (void)(t.size()), T::size_type())
- {
- return t.size();
- }
这里返回类型由下式给出
- decltype( (void)(t.size()), T::size_type())
decltype 构造的操作数是一个逗号分割的表达式列表,因此最后一个表达式 T::size_type() 会生成所需返回类型的值(使用 decltype 转换为返回类型)。在(最后一个)逗号之前,我们有必须有效的表达式,在这种情况下就是 t.size()。将表达式强制转换为 void 是为了避免可能用户定义的逗号运算符重载表达式类型。
请注意,decltype 的参数是一个未赋值的操作数,这意味着,你可以在不调用构造函数的情况下创建“伪对象”,这将在第 166 页的 11.2.3 节中讨论。
8.5 运行时 if
部分特化、SFINAE 和 std::enable_if 允许我们整体启用或禁用模板。C++17 还引入了编译时 if 语句,该语句允许根据编译时条件启用或禁用特定语句。使用 if constexpr(...) 语法,编译器使用编译时表达式来决定是应用 then 部分还是 else 部分(如果有)。
作为第一个例子,考虑第 55 页 4.1.1 节中介绍的可变函数模板 print()。它使用递归打印它的参数(任意类型)。constexpr if 特性允许我们在本地决定是否继续递归,而不是提供单独的函数来结束递归 8:
- template<typename T, typename... Types>
- void print(T const& firstArg, Types const&... args)
- {
- std::cout << firstArg << '\n';
- if constexpr (sizeof...(args) > 0)
- {
- print(args...); // code only available if sizeof...(args)>0 (since C++17)
- }
- }
这里,这里 print() 只为一个参数调用,那么 args 就变成了一个空的参数包,这样 sizeof...(args) 就变成了 0。结果,print() 的递归调用变成了一个被丢弃的语句,代码没有被实例化。因此,不需要存在相应的函数,递归结束。
代码没有实例化的事实,意味着只执行第一翻译阶段(定义时间),该阶段检查不依赖于模板参数的正确语法和名称(参见第 6 页 1.1.3 节)。例如,
- template<typename T>
- void foo(T t)
- {
- if constexpr (std::is_integral_v<T>)
- {
- if (t > 0)
- {
- foo(t - 1); // OK
- }
- }
- else
- {
- undeclared(t); // error if not declared and not discarded (i.e. T is not integral)
- undeclared(); // error if not declared (even if discard)
- static_assert(false, "no integral"); // always asserts (even if discarded)
- static_assert(!std::is_integral_v<T>, "no integral"); // OK
- }
- }
请注意,如果 constexpr 可以用于任何函数,而不仅仅是模板。我们只需要一个产生布尔值的编译时表达式。例如:
- int main()
- {
- if constexpr (std::numeric_limits<char>::is_signed)
- {
- foo(42); // OK
- }
- else
- {
- undeclared(42); // error if undeclared() not declared
- static_assert(false, "unsigned"); // always asserts (even if discarded)
- static_assert(!std::numeric_limits<char>::is_signed, "char is unsigned"); // OK
- }
- }
例如,有了这个特性,我们可以使用第 125 页第 8.2 节中介绍的 isPrime() 编译时函数,在给定大小不是质数的情况下执行额外的代码:
- template<typename T, std::size_t SZ>
- void foo(std::array<T, SZ> const& coll)
- {
- if constexpr (!isPrime(SZ))
- {
- // …… // special additional handling if the passed array has no prime number as size
- }
- // ……
- }
详见第 263 页的 14.6 节。
8.6 总结
• 模板提供了编译时进行计算的能力(使用递归进行迭代,使用部分特化或运算符 ?: 用于选择)。
• 使用 constexpr 函数,我们可以用编译时上下文中调用的“普通函数”来代替大多数编译时计算。
• 通过部分特化,我们可以基于特定的编译时约束,在类模板的不同实例之间进行选择。
• 模板仅在需要时使用,函数模板声明中的替换不会导致无效代码。这个原理叫做 SFINAE(替换失败不是错误)。
• SFINAE 只能为某些类型和(或)约束提供函数模板。
• 从 C++17 开始,编译时 if 允许我们根据编译时条件启用或丢弃语句(甚至在模板之外)。
1 实际上是 Erwin Unruh 首先发现了这一点,通过展示一个编译时计算质数的程序。详见第 545 页的 23.7 节。
2 在 C++11 之前,通常将 value 成员声明为枚举器常量,而不是静态数据成员,以避免需要静态数据成员的类外定义(详见第 543 页的 23.6 节)。例如:
- enum { value = (p % d != 0) && DoIsPrime<p, d-1>::value };
4 理论上,即使使用 constexpr,编译器也可以决定在运行时计算 b 的初始值。
5 我们不命名这个函数为 size() 是因为我们想避免与 C++ 标准库的命名冲突,从 C++17 开始定义了标准函数模板 std::size()。
6 在实践中,这样的回退函数通常会提供更有用的默认值、抛出异常或包含静态断言以产生有用的错误信息。
7 由于删除了 thread 类的复制构造函数,这也确保了禁止复制。
8 尽管代码写作 if constexpr,但该特性称为 constexpr if,因为它是 if 的 “constexpr”形式(并且出于历史原因)。
第 9 章
在实践中使用模板
模板代码与普通代码略有不同。在某些方面,模板位于宏与普通(非模板)声明之间。尽管这可能过于简单化,但它不仅会影响我们使用模板编写算法和数据结构的方式,还会影响到表达和分析涉及模板程序的日常逻辑。
在本章中,我们将讨论其中一些实用性,而不必探讨它们背后的技术细节。第 14 章将讨论其中的许多细节。为了简化讨论,我们假设我们的 C++ 编译系统由相当传统的编译器和链接器组成(不属于这一类的 C++ 系统很少)。
9.1 包含模型
有几种方法可以组织模板源代码。本节介绍最流行的方法:包含模型。
9.1.1 链接器错误
大多数 C 和 C++ 程序员主要按照以下方式组织其非模板代码:
• 类和其他类型完全放在头文件中。通常这个文件具有 .hpp(或 .H, .h, .hh, .hxx)的文件扩展名。
• 对于全局(非内联)变量和(非内联)函数,只有一个声明放在头文件中,而定义则放在被编译为自己翻译单元的文件中。这样的 CPP 文件 通常具有 .cpp(或 .C, .c, .cc 或 .cxx)文件扩展名。
这很好:它使定义在整个程序中的所需类型很容易获得,并避免了链接器中变量和函数的重复定义错误。
考虑到这些约定,下面的(错误的)小程序说明了模板程序员抱怨的一个常见错误。与“普通代码”一样,我们在头文件中声明模板:
- #ifndef MYFIRST_HPP
- #define MYFIRST_HPP
- // declaration of template
- template<typename T>
- void printTypeof(T const&);
- #endif // MYFIRST_HPP
printTypeof() 是一个简单辅助函数的声明,用于打印某些类型的信息。该功能的实现放在 CPP 文件中:
- #include <iostream>
- #include <typeinfo>
- #include "myfirst.hpp"
- // implementation/definition of template
- template<typename T>
- void printTypeof(T const& x)
- {
- std::cout << typeid(x).name << '\n';
- }
该示例使用 typeid 运算符打印了一个字符串,该字符串描述传递给它的表达式的类型。它返回静态类型 std::type_info 的左值,该类型提供一个显示某些表达式类型的成员函数 name()。C++ 标准实际上并没有说 name() 必须返回有意义的内容,但好的 C++ 实现中,你应该得到一个字符串,该字符串可以很好地描述传递给 typeid 表达式的类型 1。
最后,我们在另一个 CPP 文件中使用该模板,其中包含我们的模板声明:
C++ 编译器很可能会毫无问题地接受此程序,但链接器可能会报错误,这意味着函数 printTypeof() 没有定义。
此错误的原因是函数模板 printTypeof() 的定义尚未实例化。为了实例化模板,编译器必须知道应该实例化哪个定义以及应该使用哪些模板参数实例化。不幸的是,在前面的示例中,这两条信息位于单独编译的文件中。因此,当我们的编译器看到看到对 printTypeof() 的调用,但没有为 double 实例化此函数的定义时,它只是假设在其他地方提供了这样的定义,并创建对该定义的引用(供链接器解析)、另一方面,当编译器首先处理 myfirst.cpp,此时它没有指示必须实例化包含指定参数的模板定义。
9.1.2 头文件中的模板
前一个问题的常见解决方法是使用宏或内联函数相同的解决方法:在声明模板的头文件中包含模板的定义。
也就是说,不提供 myfirst.cpp,我们重写 myfirst.hpp,使其包含所有模板声明和模板定义:
- #ifndef MYFIRST_HPP
- #define MYFIRST_HPP
- // declaration of template
- template<typename T>
- void printTypeof(T const&);
- // implementation/definition of template
- template<typename T>
- void printTypeof(T const& x)
- {
- std::cout << typeid(x).name << '\n';
- }
- #endif // MYFIRST_HPP
这种组织模板的方法称为包含模型。有了它,你应该会发现我们的程序现在可以正常地编译、链接和执行。
在这一点上,我们可以做一些观察。最值得注意的是,这种方法大大增加了包含头文件 myfirst.hpp 的成本。在本例中,成本不是模板定义本身的大小,而是我们还必须包含模板定义使用的头文件——本例为 <iostream> 和 <typeinfo>。你可能会发现这相当于数万行代码,因为像这样的头文件包含许多自己的模板定义。
这在实践中是一个真正的问题,因为它大大增加了编译器编译重要程序所需的时间。因此,我们将研究一些可能的方法来解决这个问题,包括预编译头(见第 141 页的 9.3 节)和显式模板实例化(见第 260 页的 14.5 节)。
尽管存在构建时问题,但我们建议尽可能遵循此包含模型来组织模板,直到有更好的机制可用为止。在 2017 年撰写本书时,这样一种机制正在酝酿之中:模块,这将在第 366 页的 17.11 节中介绍。它们是一种语言机制,允许程序员更合理地组织代码,使编译器可以单独编译所有声明,然后在需要时高效、有选择性地导入处理过的声明。
关于包含方法的另一个(更微妙的)观察结果是,非内联函数模板与内联函数和宏有一个重要的区别:它们不在调用侧展开。相反,当它们被实例化时,它们会创建一个函数的新副本。由于这是一个自动化过程,编译器最终可能会在两个不同的文件中创建两个副本,并且一些链接器在找到同一个函数的两个不同定义时可能会发生错误。从理论上讲,这不应该是我们所关心的:这是一个 C++ 编译系统要适应的问题。实际上,大多数情况下,事情都很顺利,我们根本不需要处理这个问题。但是,对于创建自己代码库的大型项目,偶尔会出现问题。第 14 章中对实例化方案的讨论和对 C++ 翻译系统(编译器)附带文档的仔细研究应该有助于解决这些问题。
最后我们需要指出,在我们的示例中,适用于普通函数模板的内容也适用于类模板的成员函数和静态数据成员,以及成员函数模板。
9.2 模板和内联
将函数声明为内联是提高程序运行时间的常用工具。inline 说明符旨在提示实现,即在调用点内联替换函数体优于通常的函数调用机制。
但是,实现可能会忽略提示。因此,内联的唯一保证效果是允许函数定义在程序中多次出现(通常是因为它出现在包含于多个位置的头文件中)。
与内联函数一样,函数模板可以在多个翻译单元中定义。这通常通过将定义放置在头文件中实现,头文件由多个 CPP 文件包含。
然而,这并不意味着函数模板默认使用内联替换。是否以及何时在调用点对函数模板主体进行内联替换优于通常的函数调用机制完全取决于编译器。也许令人惊讶的是,在估计内联调用是否会导致净性能改进方面,编译器通常比程序员更好。因此,编译器关于 inline 的精确策略因编译器而异,甚至取决于为特定编译选择的选项。
然而,使用适当的性能监控工具,程序员可能拥有比编译器更好的信息,因此可能希望覆盖编译器的决定(例如,在为特定平台,如手机或特定输入,调整软件时)。有时,这仅适用于特定于编译器的属性,例如 noinline 或 always_inline。
在这一点上值得指出的是,函数模板的完全特化在这方面就像普通函数一样:它们的定义只能出现一次,除非它们被内联定义(参见第 338 页的 16.3 节)。有关该主题的更广泛、详细的概述,另请参见附录 A。
9.3 预编译头
即使没有模板,C++ 头文件也可能变得很大,因此编译需要很长时间。模板增加了这种趋势,在许多情况下程序员对等待的抗议促使供应商实现一种通常称为预编译头(PCH)的方案。该方案不在标准范围之内,并依赖于供应商的特定选项。尽管我们将有关如何创建和使用预编译头文件的详细信息留给具有此功能的各种 C++ 编译系统的文档,但了解它的工作原理是很有用的。
当编译器翻译一个文件时,它会从文件的开头一直翻译到最后。当它处理文件中的每一个标记(可能来自 #include 的文件)时,它会调整其内部状态,包括将条目添加到符号表中,以便稍后查找它们的内容。执行此操作时,编译器还可以在对象文件中生成代码。
预编译头依赖于这样一个事实,即代码的组织方式可以使许多文件以相同的代码行开始。为了便于讨论,我们假设要编译的每个文件都以相同的 N 行代码开头。我们可以编译这 N 行,并在预编译头文件中保存当时编译器的完整状态。然后,对于我们程序中的每个文件,我们可以重新加载保存的状态并在第 N+1 行开始编译。此时值得注意的是,重新加载保存的状态是一个比实际编译前 N 行快几个数量级的操作。但是,首先保存状态通常比只编译 N 更昂贵。成本增加幅度大约在 20% 到 200%。
- #include <iostream>
有效使用预编译头的关键是确保(尽可能多的)文件以最大数量的公共代码行开始。在实践中,这意味着文件必须以相同的 #include 指令开头,这(如前所述)占用了大量的构建时间。因此,注意头文件的包含顺序是非常有利的。例如以下两个文件:
- #include <vector>
- #include <list>
- // ……
和
- #include <list>
- #include <vector>
- // ……
禁止使用预编译头,因为源中没有公共的初始状态。
一些程序员决定,#include 一些额外的不必要的头文件,比传递机会给预编译头文件来加速文件翻译要更好。这一决定大大简化了包含政策的管理。例如,创建一个命名为 std.hpp 包含所有标准头文件的头文件通常相对简单 2:
- #include <iostream>
- #include <string>
- #include <vector>
- #include <deque>
- #include <list>
- // ……
然后可以对该文件进行预编译,然后使用标准库的每个程序文件都可以按如下方式开始:
- #include <std.hpp>
- // ……
通常这需要一段时间来编译,但如果系统有足够的内存,预编译头方案允许它比几乎任何单个标准头在没有预编译的情况下需要的处理速度快得多。标准头文件以这种方式特别方便,因为它们很少改变,因此我们的 std.hpp 文件的预编译头文件可以只编译一次。否则,预编译头文通常是项目依赖项配置的一部分(例如,它们会根据流行的 make 工具或者集成开发环境(IDE)项目构建工具的需要进行更新)。
管理预编译头文件的一种有吸引力的方法是创建预编译头层,从最广泛使用和稳定的头文件(例如,我们的 std.hpp 头文件)到预计不会一直更改的头文件(因为它们仍然值得预编译)。但是,如果头文件用于大量开发,为它们创建预编译头可能比通过重用它们节省的时间花费更多的时间。这种方法的一个关键概念是,可以重用更稳定层的预编译文件,以缩短不太稳定的头文件的预编译时间。例如,我们还定义了一个 core.hpp 头文件,其中包含特定于我们的项目但仍然达到一定稳定性的附加额外文件:
- #include <std.hpp>
- #include <core_data.hpp>
- #include <core_algos.hpp>
- // ……
由于此文件以 #include "std.hpp" 开头,因此编译器可以加载关联的预编译头文件并继续下一行,而无需重新编译所有标准头文件。当文件被完全处理后,可以产生一个新的预编译头。然后应用程序可以使用 #include "core.hpp" 来提供对大量功能的快速访问,因为编译器可以加载后者的预编译头文件。
9.4 解码小说中的错误
普通的编译错误通常非常简介明了。例如,当编译器说"类 X 没有成员 'fun'",通常不难找出代码中的错误(例如,我们可能错误地将 run 输入成了 fun)。模板则不然。让我们看一些例子。
简单的类型不匹配
考虑下面使用 C++ 标准库的相对简单的例子:
- #include <string>
- #include <map>
- #include <algorithm>
- int main()
- {
- std::map<std::string, double> coll;
- // ……
- // find the first nonempty string in coll:
- auto pos = std::find_if(coll.begin(), coll.end(),
- [](std::string const& s)
- {
- return s != "";
- });
- }
它包含一个相当小的错误:在用于查找集合中第一个匹配字符串的 lambda 中,我们检查给定字符串。但是,映射表中的元素是键/值对,因此我们应该期望 std::pair<std::string const, double>。
流行的 GNU C++ 编译器包含以下错误:
- 1 In file included from /cygdrive/p/gcc/gcc61-
- include/bits/stl_algobase.h:71:0,
- 2 from /cygdrive/p/gcc/gcc61-include/bits/char_traits.h:39,
- 3 from /cygdrive/p/gcc/gcc61-include/string:40,
- 4 from errornovel1.cpp:1:
- 5 /cygdrive/p/gcc/gcc61-
- include/bits/predefined_ops.h: In instantiation of 'bool __gnu_cxx
- ::__ops::_Iter_pred<_Predicate>::operator()
- (_Iterator) [with _Iterator = std::_Rb_tree_i
- terator<std::pair<const std::__cxx11::basic_string<char>, double> >; _Predicate = main()
- ::<lambda(const string&)>]':
- 6 /cygdrive/p/gcc/gcc61-
- include/bits/stl_algo.h:104:42: required from '_InputIterator
- std::__find_if(_InputIterator, _InputIterator, _Predicate, std::input_iterator_tag)
- [with _InputIterator = std::_Rb_tree_iterator<std::pair<const std::__cxx11::basic_string
- <char>, double> >; _Predicate = __gnu_cxx::__ops::_Iter_pred<main()::
- <lambda(const
- string&)> >]'
- 7 /cygdrive/p/gcc/gcc61-
- include/bits/stl_algo.h:161:23: required from '_Iterator std::__
- find_if(_Iterator, _Iterator, _Predicate) [with _Iterator = std::_Rb_tree_iterator<std::
- pair<const std::__cxx11::basic_string<char>, double> >; _Predicate = __gnu_cxx::__ops::_
- Iter_pred<main()::<lambda(const string&)> >]'
- 8 /cygdrive/p/gcc/gcc61-
- include/bits/stl_algo.h:3824:28: required from '_IIter std::find
- _if(_IIter, _IIter, _Predicate) [with _IIter = std::_Rb_tree_iterator<std::pair<const
- std::__cxx11::basic_string<char>, double> >; _Predicate = main()::
- <lambda(const string&)
- >]'
- 9 errornovel1.cpp:13:29: required from here
- 10 /cygdrive/p/gcc/gcc61-
- include/bits/predefined_ops.h:234:11: error: no match for call to
- '(main()::
- <lambda(const string&)>) (std::pair<const std::__cxx11::basic_string<char>,
- double>&)'
- 11 { return bool(_M_pred(*__it)); }
- 12 ^~~~~~~~~~~~~~~~~~~~
- 13 /cygdrive/p/gcc/gcc61-
- include/bits/predefined_ops.h:234:11: note: candidate: bool (*)
- (
- const string&) {aka bool (*)
- (const std::__cxx11::basic_string<char>&)} <conversion>
- 14 /cygdrive/p/gcc/gcc61-
- include/bits/predefined_ops.h:234:11: note: candidate expects 2
- arguments, 2 provided
- 15 errornovel1.cpp:11:52: note: candidate: main()::
- <lambda(const string&)>
- 16 [] (std::string const& s) {
- 17 ^
- 18 errornovel1.cpp:11:52: note: no known conversion for argument 1 from 'std::pair<const
- std::__cxx11::basic_string<char>, double>' to 'const string& {aka const std::__cxx11::
- basic_string<char>&}'
这样的信息开始看起来更像小说而不是诊断信息。这也可能让新手模板用户望而却步。然而,通过一些实践,这样的消息变得易于管理,并且错误至少相对容易定位。
此错误消息的第一部分表示在内部 predefined_ops.h 头文件深处的函数模板实例中发生错误,该头文件通过各种头文件由 errornovel1.cpp 包含。在此处和以下几行中,编译器报告使用哪些参数实例化的内容。在本例中,这一切都始于 errornovel1.cpp 第 13 行结尾的语句,即:
- auto pos = std::find_if(coll.begin(), coll.end(),
- [](std::string const& s)
- {
- return s != "";
- });
这导致在 stl_algo.h 头文件的 115 行实例化 find_if 模板,其代码为
- _IIter std::find_if(_IIter _First, _IIter _Last, _Predicate _Pred)
被实例化为
- _IIter = std::_Rb_tree_iterator<std::pair<const
- std::__cxx11::basic_string<char>,
- double> >
- _Predicate = main()::<lambda(const string&)>
编译器会报告所有的这些,以防我们不希望所有这些模板都被实例化。它允许我们确定导致实例化的事件链。
但是,在我们的示例中,我们愿意相信所有类型的模板都需要实例化,我们只是想知道为什么它不起作用。此信息出现在消息的最后一部分:"no match for call" 部分表示无法解析函数调用,因为声明参数类型和调用参数类型不匹配。它列出了所谓的
- (main()::<lambda(const string&)>) (std::pair<const
- std::__cxx11::basic_string<char>,
- double>&)
以及导致此调用的代码:
- { return bool(_M_pred(*__it)); }
此外,紧接着的是,包含 "note: candidate:" 的行解释了有一个候选类型需要一个 const string& 并且这个候选类型在 errornovel1.cpp 的第 11 行定义为 lambda [](std::string constÁ s),并且说明了可能候选不适合的原因:
- no known conversion for argument 1
- from ’std::pair<const std::__cxx11::basic_string<char>, double>’
- to ’const string& {aka const std::__cxx11::basic_string<char>&}’
其描述了我们的问题。
毫无疑问,错误信息可以更好。实际问题可以在实例化历史之前发生,而不是使用像 std::__cxx11::basic_string<char> 这样完全扩展的模板实例化名称,只使用 std::string 可能就足够了。但是,此诊断信息中的所有信息在某些情况下也可能有用,这也是事实。因此,其他编译器提供类似信息也就不足为奇了(尽管有些编译器使用了提到的结构化技术)。
例如,Visual C++ 编译器输出如下内容:
- 1 c:\tools_root\cl\inc\algorithm(166): error C2664: 'bool main::
- <lambda_b863c1c7cd07048816
- f454330789acb4>::operator ()
- (const std::string &) const': cannot convert argument 1 from
- 'std::pair<const _Kty,_Ty>' to 'const std::string &'
- 2 with
- 3 [
- 4 _Kty=std::string,
- 5 _Ty=double
- 6 ]
- 7 c:\tools_root\cl\inc\algorithm(166): note: Reason: cannot convert from 'std::pair<const
- _Kty,_Ty>' to 'const std::string'
- 8 with
- 9 [
- 10 _Kty=std::string,
- 11 _Ty=double
- 12 ]
- 13 c:\tools_root\cl\inc\algorithm(166): note: No user-
- defined-conversion operator available
- that can perform this conversion, or the operator cannot be called
- 14 c:\tools_root\cl\inc\algorithm(177): note: see reference to function template instantiat
- ion '_InIt std::_Find_if_unchecked<std::_Tree_unchecked_iterator<_Mytree>,_Pr>
- (_InIt,_In
- It,_Pr &)' being compiled
- 15 with
- 16 [
- 17 _InIt=std::_Tree_unchecked_iterator<std::_Tree_val<std::_Tree_simple_types
- <std::pair<const std::string,double>>>>,
- 18 _Mytree=std::_Tree_val<std::_Tree_simple_types<std::pair<const std::string,
- double>>>,
- 19 _Pr=main::
- <lambda_b863c1c7cd07048816f454330789acb4>
- 20 ]
- 21 main.cpp(13): note: see reference to function template instantiation '_InIt std::find_if
- <std::_Tree_iterator<std::_Tree_val<std::_Tree_simple_types<std::pair<const _Kty,_Ty>>>>
- ,main::<lambda_b863c1c7cd07048816f454330789acb4>>
- (_InIt,_InIt,_Pr)' being compiled
- 22 with
- 23 [
- 24 _InIt=std::_Tree_iterator<std::_Tree_val<std::_Tree_simple_types<std::pair<
- const std::string,double>>>>,
- 25 _Kty=std::string,
- 26 _Ty=double,
- 27 _Pr=main::
- <lambda_b863c1c7cd07048816f454330789acb4>
- 28 ]
在这里,我们再次为实例化链提供信息,告诉我们哪些参数实例化了什么以及在代码中的位置,我们看到了两次
- cannot convert from ’std::pair<const _Kty,_Ty>’ to ’const
- std::string’
- with
- [
- _Kty=std::string,
- _Ty=double
- ]
在某些编译器上缺失 const
不幸的是,有时会发生泛型代码仅对某些编译器有问题的情况。考虑以下示例:
- #include <string>
- #include <unordered_set>
- class Customer
- {
- private:
- std::string name;
- public:
- Customer(std::string const& n)
- : name(n)
- {}
- std::string getName() const
- {
- return name;
- }
- };
- int main()
- {
- // provide our own hash function:
- struct MyCustomerHash
- {
- // NOTE: missing const is only an error with g++ and clang:
- std::size_t operator()(Customer const& c)
- {
- return std::hash<std::string>()(c.getName());
- }
- };
- // and use it for a hash table of Customers:
- std::unordered_set<Customer, MyCustomerHash> coll;
- // ……
- }
使用 Visual Studio 2013 或 2015,此代码按预期编译。但是,使用 g++ 或 clang 时,代码会导致严重的错误消息。例如,在 g++6.1 上,第一条错误消息如下:
- 1 In file included from /cygdrive/p/gcc/gcc61-
- include/bits/hashtable.h:35:0,
- 2 from /cygdrive/p/gcc/gcc61-include/unordered_set:47,
- 3 from errornovel2.cpp:2:
- 4 /cygdrive/p/gcc/gcc61-include/bits/hashtable_policy.h: In
- instantiation of 'struct std::
- __detail::__is_noexcept_hash<Customer,
- main()::MyCustomerHash>':
- 5 /cygdrive/p/gcc/gcc61-include/type_traits:143:12:
- required from 'struct std::__and_<
- std::__is_fast_hash<main()::MyCustomerHash>,
- std::__detail::__is_noexcept_hash<Customer,
- main()::MyCustomerHash> >'
- 6 /cygdrive/p/gcc/gcc61-include/type_traits:154:38:
- required from 'struct std::__not_<
- std::__and_<std::__is_fast_hash<main()::MyCustomerHash>,
- std::__detail::__is_noexcept_
- hash<Customer, main()::MyCustomerHash> > >'
- 7 /cygdrive/p/gcc/gcc61-
- include/bits/unordered_set.h:95:63: required from 'class
- std::
- unordered_set<Customer, main()::MyCustomerHash>'
- 8 errornovel2.cpp:28:47: required from here
- 9 /cygdrive/p/gcc/gcc61-
- include/bits/hashtable_policy.h:85:34: error: no match for
- call to
- '(const main()::MyCustomerHash) (const Customer&)'
- 10 noexcept(declval<const _Hash&>()(declval<const _Key&>
- ()))>
- 11 ~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~
- 12 errornovel2.cpp:22:17: note: candidate: std::size_t
- main()::MyCustomerHash::operator()(
- const Customer&) <near match>
- 13 std::size_t operator() (const Customer& c) {
- 14 ^~~~~~~~
- 15 errornovel2.cpp:22:17: note: passing 'const
- main()::MyCustomerHash*' as 'this' argument
- discards qualifiers
紧接着是 20 多条其他错误信息:
- 16 In file included from /cygdrive/p/gcc/gcc61-
- include/bits/move.h:57:0,
- 18 from /cygdrive/p/gcc/gcc61-
- include/bits/stl_pair.h:59,
- 19 from /cygdrive/p/gcc/gcc61-
- include/bits/stl_algobase.h:64,
- 20 from /cygdrive/p/gcc/gcc61-
- include/bits/char_traits.h:39,
- 21 from /cygdrive/p/gcc/gcc61-include/string:40,
- 22 from errornovel2.cpp:1:
- 23 /cygdrive/p/gcc/gcc61-include/type_traits: In
- instantiation of 'struct std::__not_<std::
- __and_<std::__is_fast_hash<main()::MyCustomerHash>,
- std::__detail::__is_noexcept_hash<
- Customer, main()::MyCustomerHash> > >':
- 24 /cygdrive/p/gcc/gcc61-
- include/bits/unordered_set.h:95:63: required from 'class
- std::
- unordered_set<Customer, main()::MyCustomerHash>'
- 25 errornovel2.cpp:28:47: required from here
- 26 /cygdrive/p/gcc/gcc61-include/type_traits:154:38: error:
- 'value' is not a member of 'std
- ::__and_<std::__is_fast_hash<main()::MyCustomerHash>,
- std::__detail::__is_noexcept_hash<
- Customer, main()::MyCustomerHash> >'
- 27 : public integral_constant<bool, !_Pp::value>
- 28 ^~~~
- 29 In file included from /cygdrive/p/gcc/gcc61-
- include/unordered_set:48:0,
- 30 from errornovel2.cpp:2:
- 31 /cygdrive/p/gcc/gcc61-include/bits/unordered_set.h: In
- instantiation of 'class std::
- unordered_set<Customer, main()::MyCustomerHash>':
- 32 errornovel2.cpp:28:47: required from here
- 33 /cygdrive/p/gcc/gcc61-include/bits/unordered_set.h:95:63:
- error: 'value' is not a member
- of
- 'std::__not_<std::__and_<std::__is_fast_hash<main()::MyCustomerHash>,
- std::__detail::
- __is_noexcept_hash<Customer, main()::MyCustomerHash> >
- >'
- 34 typedef __uset_hashtable<_Value, _Hash, _Pred, _Alloc>
- _Hashtable;
- 35 ^~~~~~~~~~
- 36 /cygdrive/p/gcc/gcc61-include/bits/unordered_set.h:102:45:
- error: 'value' is not a member
- of
- 'std::__not_<std::__and_<std::__is_fast_hash<main()::MyCustomerHash>,
- std::__detail::
- __is_noexcept_hash<Customer, main()::MyCustomerHash> >
- >'
- 37 typedef typename _Hashtable::key_type key_type;
- 38 ^~~~~~~~
- …
同样很难阅读错误信息(即使找到每条信息的开头和结尾也是一件麻烦事)。本质在于 std::unordered_set<> 实例化的 hashtable_policy.h 深处,它被以下语句需要:
- std::unordered_set<Customer, MyCustomerHash> coll;
没有调用匹配
- const main()::MyCustomerHash (const Customer&)
在下述实例化中
- noexcept(declval<const _Hash&>()(declval<const _Key&>()))>
- ~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~
(declval<const _Hash&>() 是 main()::MyCustomerHash 类型的表达式)。一个可能的“接近匹配”的候选是
- std::size_t main()::MyCustomerHash::operator()(const Customer&)
其被声明为
- std::size_t operator() (const Customer& c) {
- ^~~~~~~~
最后一条注释说明了问题:
- passing ’const main()::MyCustomerHash*’ as ’this’ argument
- discards qualifiers
你能看出问题是什么吗?std::unordered_set 类模板的这种实现要求哈希对象的函数调用运算符是一个 const 成员函数(另见第 159 页的 11.1.1 节)。如果不是这种情况,算法内部就会出现错误。
当一个 const 限定符简单地添加到哈希函数运算符时,所有的其他错误从第一个错误级联而来,然后消失:
- std::size_t operator()(Customer const& c)
- {
- // ……
- }
CLang 3.9 在第一条错误消息的末尾给出了稍微好一点的提示,即哈希函数的 operator() 未标记为 const:
- …
- errornovel2.cpp:28:47: note: in instantiation of template class
- ’std::unordered_set<Customer
- , MyCustomerHash, std::equal_to<Customer>,
- std::allocator<Customer> >’ requested here
- std::unordered_set<Customer,MyCustomerHash> coll;
- ^
- errornovel2.cpp:22:17: note: candidate function not viable:
- ’this’ argument has type ’const
- MyCustomerHash’, but method is not marked const
- std::size_t operator() (const Customer& c) {
注意,clang 这里提到了默认模板参数,例如 std::allocator<Customer>,而 gcc 跳过了它们。
如你所见,使用多个编译器来测试你的代码通常很有帮助。它不仅可以帮助你编写更具有可移植性的代码,而且当一个编译器产生特别难以理解的错误消息时,另一个编译器可能会提供更多的洞察力。
9.5 后记
头文件和 CPP 文件中源代码的组织是单一定义规则或ODR的各种化身的实际结果。附录 A 中提供了对该规则的广泛讨论。
包含模型是一个务实的答案,主要由 C++ 编译器实现中的现有实践决定。然而,第一个 C++ 实现是不同的:模板定义的包含是隐式的,这造成了某种分离的错觉(有关该原始模型的详细信息,请参见第 14 章)。
第一个 C++ 标准([C++98])通过导出的模板为模板编译的分离模型提供了明确的支持。分离模型允许在头文件中声明标记为 export 的模板声明,而它们的相应定义则放置在 CPP 文件中,很像非模板代码的声明和定义。与包含模型不同的是,该模型是一个不基于任何现有实现的理论模型,而且实现本身被证明比 C++ 标准委员会预期的要复杂得多。它的第一个实现发布(2002年5月)花了五年多的时间,此后几年没有其他实现出现。为了更好地使 C++ 标准与现有实践保持一致,C++ 标准委员会从 C++11 中删除了导出的模板。有兴趣了解更多分离模型细节(和缺陷)的读者,请阅读第 6.3 和 10.3 节。
有时很容易想象扩展预编译头概念的方法,以便可以为单个编译加载多个头文件。原则上,这将允许使用更细粒度的预编译方法。这里的障碍主要是预处理器:一个头文件中的宏可以完全改变后续头文件的含义。然而,一旦头文件被预编译,宏处理就完成了,并且尝试修补预编译头以应对由其他头引起的预处理器效应几乎不切实际。一种称为模块的新语言功能(请参阅第 366 页的 17.11 节)预计在不久的将来添加到 C++ 中以解决此问题(宏定义不能泄露到模块接口中)。
9.6 总结
• 模板包含模型是组织模板代码最广泛使用的模型。第 14 章讨论了备选方案。
• 当在类或结构之外的头文件中定义时,只有函数模板的完全特化才需要内联。
• 要利用预编译头,请确保对 #include 指令保持相同的顺序。
• 使用模板调试代码可能会有挑战性。
1 在某些实现中,这个字符串会被破坏(使用参数类型和周围作用域的名称进行编码,以将其与其他名称区分开来),但是可以使用 demangler 将其转换为人类可读的文本。
2 理论上,标准头文件实际上不需要与物理文件相对应。然而在实践中,它们是对应的,而且文件非常大。
第 10 章
基本模板术语
到目前为止,我们已经介绍了 C++ 中模板的基本概念。在详细介绍之前,让我们先看看我们使用的术语。这是必要的,因为在 C++ 社区内部(甚至在标准的早期版本中),有时术语缺乏精确性。
10.1 “类模板”还是“模板类”?
在 C++ 中,结构体、类和联合体统称为类类型。在没有附加限定的情况下,纯文本类型中的“类”一词意味着包含关键字 class 或关键字 struct 引入的类类型 1。请注意,“类类型”包括联合体,而“类”不包括。
对于如何调用作为模板的类,存在一些混淆:
• 术语类模板表示该类是一个模板。也就是说,它是一系列类的参数化描述。
• 另一方面,术语模板类被用作
– 类模板的同义词。
– 从模板生成的类的引用。
– 名称为模板标识号(模板名称后跟在 < 和 > 之间指定的模板参数的组合)的引用。
第二个和第三个含义之间的区别对于正文的其余部分来说有些微妙和不重要。
由于这种不精确性。我们在本文中避免使用术语模板类。
类似的,我们使用函数模板、成员模板、成员函数模板和变量模板,但避免使用模板函数、模板成员、模板成员函数和模板变量。
10.2 替换、实例化和特化
在处理使用模板的源代码时,C++ 编译器必须在不同的时间用具体的模板参数替换模板中的模板参数。有时,这种替换只是暂时的:编译器可能需要检查替换是否有效(参见第 129 页的 8.4 节和第 284 页的 15.7 节)。
通过替换模板参数的具体参数,从模板中实际为常规类、类型别名、函数、成员函数或变量,创建定义的过程称为模板实例化。
令人惊讶的是,目前还没有标准或普遍同意的术语来表示通过模板参数替换常见不是定义的声明的过程。我们已经看到一些团队使用短语部分实例化或声明实例化,但这些短语并不是通用的。也许一个更直观的术语是不完整的实例化(在类模板的情况下,它会生成不完整的类)。
由实例化或不完整实例化(即类、函数、成员函数或变量)产生的实体一般称为特化。
然而,在 C++ 中,实例化过程并不是产生特化的唯一途径。其他机制允许程序员显式指定一个声明,该声明与模板参数的特殊替换相关联。正如我们在第 31 页的 2.5 节中所展示的,这样的特化是通过前缀 template<> 引入的:
- template<typename T1, typename T2> // primary class template
- class MyClass
- {
- // ……
- };
- template<> // explicit specialization
- class MyClass<std::string, float>
- {
- // ……
- };
严格地说,这称为显式特化(与实例化或生成特化相反)。
如第 33 页的 2.6 节所述,仍然具有模板参数的特化称为部分特化:
- template<typename T> // partial specialization
- class MyClass<T, T>
- {
- // ……
- };
- template<typename T> // partial specialization
- class MyClass<bool, T>
- {
- // ……
- };
当谈到(显式或部分)特化时,通用模板也称为主模板。
10.3 声明与定义
到目前为止,声明和定义这两个词在本书中只出现过几次。然而,这些词在 C++ 标准中有一个想当精确的含义,这就是我们使用的含义。
声明是一种 C++ 构造,它将名称引入或重新引入 C++ 作用域。此介绍始终包括对该名称的部分分类,但不需要详细信息进行有效声明。例如:
- class C; // a declaration of C as a class
- void f(int p); // a declaration of f() as a function and P as a named parameter
- extern int v; // a declaration of v as a variable
请注意,宏定义和 goto 标签在 C++ 中不被视为声明,即使它们有名字。
当声明的结构细节已知时,或者在变量的情况下,当必须分配存储空间时,声明就成为定义。对于类类型定义,这意味着必须提供大括号封闭的主体。对于函数定义,这意味着必须提供大括号封闭的主体(在常见情况下),或者必须将函数指定为 = default 2 或 = delete。对于变量,初始化或缺少 extern 说明符会导致声明成为定义。以下是对前面的非定义声明进行补充的示例:
- class C {}; // definition (and declaration) of class C
- void f(int p) // definition (and declaration) of function f()
- {
- std::cout << p << '\n';
- }
- extern int v = 1; // an initializer makes this a definition for v
- int w; // global variable declarations not preceded by
- // extern are also definitions
通过扩展,如果类模板或函数模板具有主体,则其声明成为定义。因此,
- template<typename T>
- void func(T);
是非定义声明,而
- template<typename T>
- class S {};
实际上是一个定义。
10.3.1 完全类型和非完全类型
类型可以是完整的,也可以是不完整的,这是一个与声明和定义之间区别密切相关的概念。一些语言构造需要完整类型,而另一些语言构造也适用于不完整类型。
不完整类型是以下类型之一:
• 已声明但尚未定义的类类型。
• 未指定范围的数据类型。
• 元素类型不完整的数组类型。
• void
• 枚举值,只要未定义基础类型或枚举值。
• 应用 const 和(或) volatile 的任何类型。所有其他类型都是完整的。例如:
- class C; // C is an incomplete type
- C const* cp; // cp is a pointer to an incomplete type
- extern C elems[10]; // elems has an incomplete type
- extern int arr[]; // arr has an incomplete type
- //……
- class C {}; // C now is a complete type (and therefore cpand elems
- // no longer refer to an incomplete type)
- int arr[10]; // arr now has a complete type
有关如何处理模板中不完整类型的提示,请参阅第 171 页的 11.5 节。
10.4 单一定义规则
C++ 语言定义对各种实体的重新声明施加了一些限制。这些约束的总和成为单一定义规则或 ODR。这个规则的细节有点复杂,涵盖了各种各样的情况,后面的章节说明了每个适用上下文中产生的各种方面,你可以在附录 A 中找到 ODR 的完整描述。现在,记住以下 ODR 基础知识就足够了:
• 在整个程序中,普通(如非模板)的非内联函数和成员函数,以及(非内联)全局变量和静态数据成员只能定义一次 3。
• 类类型(包括结构体和联合体)、模板(包括部分特化,但不包括完全特化)以及内联函数和变量应在每个翻译单元中最多定义一次,并且所有这些定义应该相同。
翻译单元是对源文件进行预处理的结果:也就是说,它包含由 #include 指令命名并由宏扩展产生的内容。
在本书的其余部分,可链接实体指的是以下任意一项:函数或成员函数、全局变量或静态数据成员,包括链接器可见的从模板生成的任何此类内容。
10.5 模板调用参数和模板声明参数
将以下类模板:
- template<typename T, int N>
- class ArrayInClass
- {
- public:
- T array[N];
- };
和类似的普通类相比较:
- class DoubleArrayInClass
- {
- public:
- double array[10];
- };
如果我们分别用 double 和 10 替换参数 T 和 N,后者本质上与前者等效。在 C++ 中,此替换的名称表示为:
- ArrayInClass<double, 10>
请注意,模板名称后面是尖括号中的模板调用参数。
无论这些调用参数是否依赖于模板声明参数,模板名称后跟尖括号中的调用参数的组合成为模板标识号。
可以像使用相应的非模板实体一样使用此名称。例如:
- int main()
- {
- ArrayInClass<double, 10> ad;
- ad.array[0] = 1.0;
- }
必须区分模板声明参数和模板调用参数。简言之,你可以说,声明参数是由调用参数初始化的 4。更准确地说:
• 模板声明参数是在模板声明或定义中地关键字 template 之后列出的那些名称(在我们地示例中是 T 和 N)。
• 模板调用参数是替换模板声明参数的项(在我们的示例中为 double 和 10)。与模板声明参数不同,模板调用参数可以不仅仅是“名称”。
当使用模板标识号指示时,模板调用参数对模板声明参数的替换是显式的。但是当替换是隐式的(例如,如果模板声明参数被它们的默认参数替换),则存在各种问题。
一个基本原则是,任何模板声明参数都必须是可以在编译时确定的数量或值。稍后会清楚,这一需求为模板实体运行时成本带来的巨大好处。因为模板声明参数最终会被编译时值替换,所以它们本身可以用来形成编译时表达式。这在 ArrayInClass 模板中被利用来调整成员数组的大小。数组的大小必须是一个常量表达式,模板声明参数 N 符合此条件。
我们可以进一步推进这种推理:因为模板声明参数是编译时实体,所以它们也可以用于创建有效的模板调用参数。以下是一个例子:
- template<typename T>
- class Dozen
- {
- public:
- ArrayInClass<T, 12> contents;
- };
请注意,在本例中,名称 T 既是模板声明参数,又是模板调用参数。因此,可以使用一种机制来从更简单的模板构建更复杂的模板。当然,这与我们组装类型和函数的机制并没有本质区别。
10.6 总结
• 对于是模板的类、函数和变量,分别使用术语类模板、函数模板和变量模板。
• 模板实例化是通过用具体调用参数替换声明参数来创建常规类或函数的过程。由此产生的实体是一个特化。
• 类型可以是完整的,也可以是不完整的。
• 根据单一定义规则(ODR),非内联函数、成员函数、全局变量和静态数据成员在整个程序中只能定义一次。
1 在 C++ 中,class 和 struct 之间的唯一区别是 class 的默认访问权限是 private,而 struct 的默认访问权限是 public。但是,我们更喜欢将 class 用于使用新 C++ 功能的类型,而我们将 struct 用于可用作“普通旧数据”(POD)的普通 C 数据结构。
2 默认函数是特殊的成员函数,编译器将为其提供默认实现,例如默认的复制构造函数。
3 从 C++17 开始,全局变量和静态变量以及数据成员可以定义为 inline。这消除了只在一个翻译单元中定义它们的要求。
4 在学术界,有时称调用参数为实际参数,而称声明参数为形式参数。
第 11 章
通用库
到目前为止,我们对模板的讨论主要集中在它们的具体特征、能力和约束上,考虑的是眼前的任务和应用(我们作为应用程序员遇到的那种事情)。然而,模板在用于编写通用库和框架时是最有效的,在这种情况下,我们的设计必须考虑先验的、广泛的、不受约束的潜在用途。虽然本书中几乎所有的内容都可以适用于这样的设计,但这里有一些你在编写可移植组件时应该考虑的一般问题,你打算将其用于尚未想象到的类型。
这里提出的问题清单在任何意义上都不是完整的,但它总结了到目前为止介绍的一些功能,介绍了一些额外的功能,并提到了本书后面涉及的一些功能。我们希望它也能成为阅读后面许多章节的巨大动力。
11.1 可调用对象
许多库包括一些接口,客户代码将一个必须被"调用"的实体传递给这些接口。例如,一个必须在另一个线程上调度的操作、一个描述如何将数值散列存储在哈希表中的函数、一个描述对集合中的元素进行排序的对象,以及一个提供一些默认参数值的通用包装器。标准库在这里也不例外。它定义了许多采取这种可调用实体的组件。
在这种情况下使用的一个术语是回调。传统上,这个术语被保留给作为函数调用参数传递的实体(而不是模板参数),我们保持这个传统。例如,一个排序函数可能包括一个作为"排序标准"的回调参数,它被调用以确定一个元素在所需的排序顺序中是否先于另一个。
在 C++ 中,有几种类型可以很好地用于回调,因为它们既可以作为函数调用参数传递,又可以用语法 f(...) 直接调用:
• 指向函数类型的指针
• 具有重载 operator() 的类类型(有时称为 仿函数),包括 lambda。
• 具有转换函数的类类型,产生一个指针到函数或引用到函数的转换。
这些类型统称为函数对象类型,这种类型的值就是一个函数对象。
C++ 标准库引入了略微宽泛的可调用类型的概念,它是一个函数对象类型或一个成员的指针。可调用类型的对象是一个可调用对象,为了方便起见,我们把它称为可调用对象(callable)。
泛型代码经常受益于能够接受任何类型的可调用代码,而模板使之成为可能。
11.1.1 支持函数对象
让我们看看标准库的 for_each() 算法是如何实现的(使用我们自己的名字 "foreach" 以避免名称冲突,并且为了简单起见跳过返回任何东西):
- template<typename Iter, typename Callable>
- void foreach(Iter current, Iter end, Callable op)
- {
- while (current != end) // as long as not reached the end
- {
- op(*current); // call passed operator for current element
- ++current; // and move iterator to next element
- }
- }
以下程序演示如何将此模板用于各种函数对象:
- #include <iostream>
- #include <vector>
- #include "foreach.hpp"
- // a function to call:
- void func(int i)
- {
- std::cout << "func() called for: " << i << '\n';
- }
- // a function object type (for objects that can be used as functions):
- class FuncObj
- {
- public:
- void operator()(int i) const // Note: const member function
- {
- std::cout << "FuncObj::op() called for: " << i << '\n';
- }
- };
- int main()
- {
- std::vector<int> primes = {2, 3, 5, 7, 11, 13, 17, 19};
- foreach(primes.begin(), primes.end(), // range
- func); // function as callable (decays to pointer)
- foreach(primes.begin(), primes.end(), // range
- &func); // function pointer as callable
- foreach(primes.begin(), primes.end(), // range
- FuncObj()); // function object as callable
- foreach(primes.begin(), primes.end(), // range
- [](int i) {
- std::cout << "lambda called for: " << i << '\n';
- });
- }
让我们详细看看每个案例:
• 当我们把一个函数的名字作为函数参数传递时,我们并没有真正传递函数本身,而是传递一个指针或对它的引用。与数组一样(见第 115 页的 7.4 节),当以值传递时,函数参数会 朽化 为一个指针,如果一个参数的类型是模板参数,将推断出一个指针到函数的类型。
就像数组一样,函数可以通过引用来传递,而不需要朽化。然而,函数类型不能真正用 const 来限定。如果我们用 Callable const& 类型来声明 foreach() 的最后一个参数,那么 const 就会被忽略掉。(一般来说,对函数的引用在主流 C++ 代码中很少使用。)
• 我们的第二个调用通过传递一个函数名的地址,明确地接受了一个函数指针。这等同于第一次调用(函数名隐含地朽化为一个指针值),但也许更清楚一些。
• 当传递一个函数时,我们把一个类的类型对象作为可调用的对象来传递。通过一个类的类型进行调用,通常相当于调用它的 operator()。所以调用
- op(*current);
通常转化为
- op.operator()(*current); // call operator() with parameter *current for op
注意,在定义 operator() 时,你通常应该把它定义为一个常量成员函数。否则,当框架或库期望这个调用不改变所传递对象的状态时,就会出现微妙的错误信息(详见第 146 页的 9.4 节)。
类类型对象也有可能隐含地转换为指向代理调用函数的指针或引用(在第 694 页的 C.3.5 节讨论)。在这种情况下,调用
- op(*current);
将转化为
- (op.operator F())(*current);
其中 F 是类类型对象可以转换为的指针到函数或引用到函数的类型。这是相对不寻常的。
• Lambda 表达式会产生伪函数(称为 闭包),因此这种情况与函数的情况没有什么不同。然而,Lambdas 是一种非常方便的简写符号,可以引入伪函数,因此自 C++11 以来,它们常常出现在 C++ 代码中。
有趣的是,以 [] 开头(无捕获)的 lambda 会产生一个到函数指针的转换运算符。 然而,它永远不会被选为代理调用函数,因为它总是比闭包的正常 operator() 更坏的匹配。
11.1.2 处理成员函数和附加参数
在前面的示例中没有使用一个可能要调用的实体:成员函数。这是因为调用一个非静态的成员函数通常涉及到指定一个对象,使用类似 object.memfunc(...) 或 ptr->memfunc(...) 的语法进行调用,这与通常的模式 function-object(...) 不相符。
幸运的是,从 C++17 开始,C++ 标准库提供了一个实用工具 std::invoke(),它方便地将这种情况与普通的函数调用语法情况统一起来,从而使对任何可调用对象的调用只需一个形式。下面我们的 foreach() 模板的实现使用了 std::invoke()。
- #include <utility>
- #include <functional>
- template<typename Iter, typename Callable, typename... Args>
- void foreach(Iter current, Iter end, Callable op, Args const&... args)
- {
- while (current != end) // as long as not reached the end of the elements
- {
- std::invoke(op, // call passed callable with
- args..., // any additional args
- *current); // and the current element
- ++current;
- }
- }
在这里,除了可调用参数,我们还接受任意数量的附加参数。然后,foreach() 模板调用 std::invoke(),在给定的可调用参数后面,还有额外给定的参数和被引用的元素。
• 如果可调用程序是一个指向成员的指针,它使用第一个附加参数作为 this 对象。所有其余的附加参数只是作为参数传递给可调用对象。
• 否则,所有的附加参数都只是作为参数传递给可调用程序。注意,我们不能在这里对可调用参数或附加参数使用完美转发。第一次调用可能会"窃取"它们的值,从而导致在随后的迭代中调用 op 的意外行为。
有了这个实现,我们仍然可以编译我们上面对 foreach() 的原始调用。现在,除此之外,我们还可以向可调用函数传递额外的参数,并且可调用函数可以是一个成员函数 1。下面的客户端代码说明了这一点:
- #include <iostream>
- #include <vector>
- #include <string>
- #include "foreachinvoke.hpp"
- // a class with a member function that shall be called
- class MyClass
- {
- public:
- void memfunc(int i) const
- {
- std::cout << "MyClass::memfunc() called for: " << i << '\n';
- }
- };
- int main()
- {
- std::vector<int> primes = {2, 3, 5, 7, 11, 13, 17, 19};
- // pass lambda as callable and an additional argument
- foreach(primes.begin(), primes.end(), // elemnets for 2nd arg of lambda
- [](std::string const& prefix, int i) { // lambda to call
- std::cout << prefix << i << '\n';
- },
- "- value: "); // 1st arg of lambda
- // call obj.memfunc() for/with each elements in primes passed as argument
- MyClass obj;
- foreach(primes.begin(), primes.end(), // elements used as args
- &MyClass::memfunc, // member function to call
- obj); // object to call memfunc() for
- }
foreach() 的第一次调用将其第四个参数(字符串常量 "value: ")传递给 lambda 的第一个参数,而向量中的当前元素与 lambda 的第二个参数绑定。第二个调用将成员函数 memfunc() 作为第三个参数传递给作为第四个参数传递的 obj。
参见第 716 页的 D.3.1 节,了解产生可调用程序是否能被 std::invoke() 使用的类型萃取。
11.1.3 包装函数调用
std::invoke() 的一个常见应用是包裹单个函数调用(例如,记录调用,测量其持续时间,或准备一些上下文,如为其启动一个新线程)。现在,我们可以通过完美转发可调用对象和所有传递的参数来支持移动语义。
- #include <utility> // for std::invoke()
- #include <functional> // for std::forward()
- template<typename Callable, typename... Args>
- decltype(auto) call(Callable&& op, Args&&... args)
- {
- return std::invoke(std::forward<Callable>(op), // passed callable with
- std::forward<Args>(args)...); // any additional args
- }
另一个有趣的方面是如何处理被调用函数的返回值,将其"完美转发"给调用者。为了支持返回引用(比如 std::ostream&),你必须使用 decltype(auto),而不仅仅是 auto。
- template<typename Callable, typename... Args>
- decltype(auto) call(Callable&& op, Args&&... args)
decltype(auto)(从 C++14 开始可用)是一个占位符类型,它根据相关表达式(初始化器、返回值或模板参数)的类型来确定变量、返回类型或模板参数的类型。详见第 301 页的 15.10.3 节。
如果你想把 std::invoke() 返回的值暂时存放在一个变量中,以便在做完其他事情后再返回(例如,处理返回值或记录调用的结束),你也必须用 decltype(auto) 声明这个临时变量。
- decltype(auto) ret{ std::invoke(std::forward<Callable>(op),
- std::forward<Args>(args)...) };
- // ……
- return ret;
注意,用 auto&& 声明 ret 是不正确的。作为引用,auto&& 将返回值的生存期延长到其作用域的末端(见第 167 页第 11.3 节),但不会超过 return 语句对函数调用者的影响。
然而,使用 decltype(auto) 也有一个问题。如果可调用程序的返回类型为 void,则不允许将 ret 初始化为 decltype(auto),因为 void 是一个不完全类型。你有以下选择:
• 在该语句前一行声明一个对象,其析构器执行你想实现的可观察行为。比如说 2:
- struct cleanup
- {
- ~cleanup()
- {
- // …… // code to perform on return
- }
- } dummy;
- return std::invoke(std::forward<Callable>(op), // passed callable with
- std::forward<Args>(args)...); // any additional args
以不同的方式实现 void和不是 void 的情况:
- #include <utility> // for std::invoke()
- #include <functional> // for std::forward()
- #include <type_traits> // for std::is_same<> and invoke_result<>
- template<typename Callable, typename... Args>
- decltype(auto) call(Callable&& op, Args&&... args)
- {
- if constexpr (std::is_same_v<std::invoke_result<Callable, Args...>,
- void>)
- {
- // return type is void
- std::invoke(std::forward<Callable>(op),
- std::forward<Args>(args)...);
- //...
- return;
- }
- else
- {
- // return type is not void
- decltype(auto) ret{ std::invoke(std::forward<Callable>(op),
- std::forward<Args>(args)...) };
- //...
- return ret;
- }
- }
使用
- if constexpr (std::is_same_v<std::invoke_result<Callable, Args...>,
- void>)
我们在编译时测试用 Args... 调用可调用对象的返回类型是否为 void。关于 std::invoke_result<> 的详细信息,请参见第 717 页的 D.3.1 节 3。
未来的 C++ 版本可能有望避免对 void 进行这样的特殊处理(见第 361 页第 17.7 节)。
11.2 实现通用库的其他实用程序
std::invoke() 只是 C++ 标准库为实现通用库提供的有用工具的一个例子。在下面的内容中,我们将调查其他一些重要的工具。
11.2.1 类型萃取
标准库提供了各种被称为类型萃取的实用工具,允许我们评估和修改类型。这支持了各种情况,在这些情况下,泛型代码必须适应它们被实例化的类型的能力或对其作出反应。比如说:
- #include <type_traits>
- template<typename T>
- class C
- {
- // ensure that T is not void (ignoring const or volatile):
- static_assert(!std::is_same_v<std::remove_cv_t<T>,void>,
- "invalid instantiation of class C for void type");
- public:
- template<typename V>
- void f(V&& v)
- {
- if constexpr(std::is_reference_v<T>)
- {
- //……
- // special code if T is a reference type
- }
- if constexpr(std::is_convertible_v<std::decay_t<V>, T>)
- {
- //……
- // special code if V is convertile to T
- }
- if constexpr(std::has_virtual_destructor_v<V>)
- {
- //……
- // special code is V has virtual destructor
- }
- }
- };
正如这个例子所演示的,通过检查某些条件,我们可以在模板的不同实现之间进行选择。在这里,我们使用了编译时 if 特性,它从 C++17 开始可用(见第 134 页的 8.5 节),但我们可以使用 std::enable_if、部分特化或 SFINAE 来代替启用或禁用辅助模板(详见第 8 章)。
然而,请注意,在使用类型萃取时必须特别小心。它们的行为可能与(天真的)程序员的预期不同。比如说:
- std::remove_const_t<int const&> // yields int const&
在这里,因为引用不是 const(尽管你不能修改它),所以调用没有效果,产生的是传递的类型。
因此,移除引用和 const 对象的顺序有关:
- std::remove_const_t<std::remove_reference_t<int const&>> // int
- std::remove_reference_t<std::remove_const_t<int const&>> // int const
相反,你可以直接调用
- std::decay_t<int const&> // yields int
然而,这也会将原始数组和函数转换为相应的指针类型。
也有一些情况,类型萃取有要求。不满足这些要求就会导致未定义的行为 4。例如:
- std::make_unsigned_t<int> // unsigned int
- std::make_unsigned_t<int const&> // undefined behavior (hopefully error)
有时,结果可能是令人惊讶的。比如说:
- std::add_rvalue_reference_t<int> // int&&
- std::add_rvalue_reference_t<int const> // int const&&
- std::add_rvalue_reference_t<int const&> // int const& (lvalue-ref remains lvalue-ref)
在这里我们可能期望 add_rvalue_reference 总是产生一个 rvalue 引用,但是 C++ 的引用重叠规则(见第 277 页第 15.6.1 节)导致 lvalue 引用和 rvalue 引用的组合产生一个 lvalue 引用。
另一个例子:
- std::is_copy_assignable_v<int> // yields true (generally, you can assign an int to an int)
- std::is_assignable_v<int, int> // yields false (can't call 42 = 42)
is_copy_assignable 只是在一般情况下检查你是否可以将 int 分配给另一个(检查 lvalues 的操作),而 is_assignable 考虑到了值的类别(见附录 B)(这里检查你是否可以将一个 prvalue 分配给一个 prvalue)。也就是说,第一个表达式等同于
- std::is_assignable_v<int&, int&> // yields true
出于同样的原因:
- std::is_swappable_v<int> // yields true (assuming lvalues)
- std::is_swappable_with_v<int&, int&> // yields true (equivalent to the previous check)
- std::is_swappable_with_v<int, int> // yields false (taking value category into account)
由于所有这些原因,请仔细注意类型萃取的确切定义。我们在附录 D 中对标准的进行了详细描述。
11.2.2 std::addressof()
std::addressof<>() 函数模板产生一个对象或函数的实际地址。即使对象类型有一个重载的操作符 &,它也能工作。尽管后者有些罕见,但它可能会发生(例如,在智能指针中)。因此,如果你需要一个任意类型的对象的地址,建议使用 addressof()。
- template<typename T>
- void f(T&& x)
- {
- auto p = &x; // might fail with overloaded operator &
- auto q = std::addressof(x); // works even with overloaded operator &
- // ……
- }
11.2.3 std::declval()
std::declval<>() 函数模板可以作为一个特定类型的对象引用的占位符。该函数没有定义,因此不能被调用(也不会创建一个对象)。因此,它只能用于未评价的操作数(如 decltype 和 sizeof 结构体的操作数)。因此,与其尝试创建一个对象,不如假设你有一个相应类型的对象。
例如,下面的声明从传递的模板参数 T1 和 T2 推断出默认的返回类型 RT。
- #include <utility>
- template<typename T1, typename T2,
- typename RT = std::decay_t<decltype(true ?
- std::declval<T1>() :
- std::declval<T2>())>>
- RT max(T1 a, T2 b)
- {
- return b < a ? a : b;
- }
为了避免我们必须为 T1 和 T2 调用一个(缺省的)构造函数,以便能够在表达式中调用运算符 ?: 来初始化 RT,我们使用 std::declval 来"使用"相应类型的对象而不创建它们。不过这只能在 decltype 的未评价上下文中实现。
不要忘记使用 std::decay<> 类型萃取来确保默认的返回类型不能是引用,因为 std::declval() 本身产生 rvalue 引用。否则,像 max(1, 2) 这样的调用将得到一个 int&& 的返回类型 5。详情请参见第 415 页的第 19.3.4 节。
11.3 完美转发临时变量
如第 91 页第 6.1 节所示,我们可以使用转发引用和 std::forward<> 来“完美转发”泛型参数。
- template<typename T>
- void f(T&& t) // t is forwarding reference
- {
- g(std::forward<T>(t)); // perfectly forward passed argument t to g()
- }
然而,有时我们必须在泛型代码中完美转发那些不通过参数的数据。在这种情况下,我们可以使用 auto&& 来创建一个可以被转发的变量。例如,假设我们对函数 get() 和 set() 进行了链式调用,其中 get() 的返回值应该被完美地转发到 set():
- template<typename T>
- void foo(T x)
- {
- set(get(x));
- }
再假设我们需要更新我们的代码,对 get() 产生的中间值进行一些操作。我们通过在一个用 auto&& 声明的变量中保留该值来做到这一点。
- template<typename T>
- void foo(T x)
- {
- auto&& val = get(x);
- // ……
- // perfectly forward the return value of get() to set():
- set(std::forward<decltype(val)>(val));
- }
这就避免了中间值的额外拷贝。
11.4 引用作为模板参数
虽然这并不常见,但模板类型参数可以成为引用类型。例如:
- #include <iostream>
- template<typename T>
- void tmplParamIsReference(T)
- {
- std::cout << "T is reference: " << std::is_reference_v<T> << '\n';
- }
- int main()
- {
- std::cout << std::boolalpha;
- int i;
- int& r = i;
- tmplParamIsReference(i); // false
- tmplParamIsReference(r); // false
- tmplParamIsReference<int&>(i); // true
- tmplParamIsReference<int&>(r); // true
- }
即使引用变量被传递给 tmplParamIsReference(),模板参数 T 也会被推断为被引用的类型(因为对于引用变量 v 来说,表达式 v 具有被引用的类型;表达式的类型永远不是引用)。然而,我们可以通过明确地指定 T 的类型来强制使用引用:
- tmplParamIsReference<int&>(i); // true
- tmplParamIsReference<int&>(r); // true
这样做可以从根本上改变模板的行为,而且,很可能在设计模板时没有考虑到这种可能性,从而引发错误或意外行为。考虑一下下面的例子:
- template<typename T, T Z = T{}>
- class RefMem
- {
- private:
- T zero;
- public:
- RefMem() : zero{Z}
- {}
- };
- int null = 0;
- int main()
- {
- RefMem<int> rm1, rm2;
- rm1 = rm2; // OK
- RefMem<int&> rm3; // ERROR: invalid default value for N
- RefMem<int&, 0> rm4; // ERROR: invalid default value for N
- extern int null;
- RefMem<int&, null> rm5, rm6;
- rm5 = rm6; // ERROR: operator= is deleted due to reference member
- }
在这里,我们有一个具有模板参数类型T的成员的类,用一个非类型的模板参数 Z 初始化,该参数的默认值为零。用 int 类型来实例化这个类,可以达到预期效果。然而,当试图用引用来实例化它时,事情就变得棘手了:
• 默认的初始化不再工作。
• 你不能只传递 0 作为 int 的初始化器。
• 而且,也许最令人惊讶的是,赋值运算符不再可用,因为具有非静态引用成员的类已经删除了默认赋值运算符。。
另外,为非类型的模板参数使用引用类型是很棘手的,可能会有危险。考虑一下这个例子:
- #include <vector>
- #include <iostream>
- template<typename T, int& SZ> // Note: size is reference
- class Arr
- {
- private:
- std::vector<T> elems;
- public:
- Arr() : elems(SZ) // use current SZ as initial vector size
- {}
- void print() const
- {
- for (int i = 0; i < SZ; ++i) // loop over SZ elements
- {
- std::cout << elems[i] << ' ';
- }
- }
- };
- int size = 10;
- int main()
- {
- Arr<int&, size> y; // compile-time ERROR deep in the code of class std::vector<>
- Arr<int, size> x; // initializes internal vector with 10 elements
- x.print(); // OK
- size += 100; // OOPS: modifies SZ in Arr<>
- x.print(); // run-time ERROR: invalid memory access: loops over 120 elements
- }
在这里,试图为引用类型的元素实例化 Arr,结果在 std::vector<> 类的代码深处出现了错误,因为它不能以引用为元素进行实例化。
- Arr<int&, size> y; // compile-time ERROR deep in the code of class std::vector<>
这个错误通常会导致第 143 页 9.4 节中描述的“错误日志”,编译器提供了整个模板实例化历史,从实例化的初始原因一直到检测到错误的实际模板定义。
也许更糟糕的是使 size 参数成为引用而导致的运行时错误。它允许记录的 size 值在容器不知道的情况下发生变化(也就是说,size 值可能变得无效)。因此,使用 size 的操作(如 print() 成员)必然会遇到未定义的行为(导致程序崩溃,或者更糟)。
- int size = 10;
- // ……
- Arr<int, size> x; // initializes internal vector with 10 elements
- size += 100; // OOPS: modifies SZ in Arr<>
- x.print(); // run-time ERROR: invalid memory access: loops over 120 elements
注意,将模板参数 SZ 改为 int const& 类型并不能解决这个问题,因为大小本身仍然是可以修改的。
可以说,这个例子有些牵强。然而,在更复杂的情况下,像这样的问题确实会发生。另外,在 C++17 中,非类型参数可以被推导出来;例如:
- template<typename T, decltype(auto) SZ>
- class Arr;
使用 decltype(auto) 很容易产生引用类型,因此在这种情况下一般要避免使用(默认使用 auto)。详见第 302 页的 15.10.3 节。
由于这个原因,C++标准库有时会有令人惊讶的规范和约束。比如说:
• 为了在模板参数被实例化成引用时仍有一个赋值运算符,std::pair<> 和 std::tuple<> 类实现了赋值运算符,而不是使用默认行为。比如说:
- namespace std
- {
- template<typename T1, typename T2>
- struct pair
- {
- T1 first;
- T2 second;
- // ……
- // default copy/move constructors are OK even with references:
- pair(pair const&) = default;
- pair(pair&&) = default;
- //……
- // but assignment operator have to be defined to be available with reference:
- pair& operator=(pair const& p);
- pair& operator-(pair&& p) noexcept(...);
- // ……
- };
- }
• 由于可能的副作用的复杂性,C++17 标准库类模板 std::optional<>和 std::variant<> 对引用类型的实例化是错误的(至少在 C++17 中)。
要禁用引用,一个简单的静态断言就足够了:
- template<typename T>
- class optional
- {
- static_assert(!std::is_reference<T>::value,
- "Invalid instantiation of optional<T> for references");
- // ……
- };
一般来说,引用类型与其他类型很不一样,要遵守一些独特的语言规则。例如,这影响了调用参数的声明(见第 105 页第 7 节),也影响了我们定义类型萃取的方式(见第 432 页的 19.6.1 节)。
11.5 推迟评估
在实现模板时,有时会出现这样的问题:代码是否可以处理不完整的类型(见第 154 页的 10.3.1节)。考虑下面这个类模板:
- template<typename T>
- class Cont
- {
- private:
- T* elems;
- public:
- // ……
- };
到目前为止,这个类可以与不完整的类型一起使用。这是很有用的,例如,对于那些引用自己类型的元素的类:
- struct Node
- {
- std::string value;
- Cont<Node> next; // only possible if Cont accepts incomplete types
- };
然而,例如,仅仅通过使用一些萃取,你可能会失去处理不完整类型的能力。比如说:
- template<typename T>
- class Cont
- {
- private:
- T* elems;
- public:
- // ……
- typename std::conditional<std::is_move_constructible<T>::value,
- T&&, T&>::type
- foo();
- };
这里,我们使用特质 std::conditional(见第 732 页的 D.5 节)来决定成员函数 foo() 的返回类型是 T&&还是 T&。这个决定取决于模板参数类型 T 是否支持移动语义。
问题是萃取 std::is_move_constructible 要求它的参数是一个完整的类型(并且不是 void 或未知边界的数组;见第 721 页的 D.3.2 节)。因此,通过这个 foo() 的声明,struct node 的声明失败了 6。
我们可以通过用一个成员模板代替 foo() 来处理这个问题,这样 std::is_move_constructible 的评估就会推迟到 foo() 的实例化阶段:
- template<typename T>
- class Cont
- {
- private:
- T* elems;
- public:
- // ……
- template<typename D = T>
- typename std::conditional<std::is_move_constructible<D>::value,
- D&&, D&>::type
- foo();
- };
现在,萃取取决于模板参数 (默认为 T,反正是我们想要的值),而且编译器必须等到 foo() 被调用到像 Node 这样的具体类型时才会评估萃取(那时 Node 是一个完整的类型;它只在被定义时是不完整)。
11.6 编写泛型库时需要考虑的事项
让我们列出一些在实现泛型库时需要记住的事情(注意,其中一些可能会在本书后面介绍):
• 使用转发引用来转发模板中的值(见第 91 页的 6.1 节)。如果值不依赖于模板参数,使用 auto&&(见 167 页的 11.3 节)。
• 当参数被声明为转发引用时,在传递 lvalues 时要准备好模板参数有一个引用类型(参见第 279 页的 15.6.2 节)。
• 当你需要一个依赖于模板参数的对象的地址时,使用 std::addressof(),以避免当它绑定到一个具有重载 operator& 的类型时出现意外(第 166 页的 11.2.2节)。
• 对于成员函数模板,确保它们不比预定义的复制/移动构造函数或赋值运算符更匹配(第 99 页的 6.4 节)。
• 当模板参数可能是字符串常量且不通过值传递时,考虑使用 std::decay(第 116 页的 7.4 节和第 731页的 D.4 节)。
• 如果你有依赖于模板参数的 out 或 inout 参数,要准备好处理 const 模板参数可能被指定的情况(例如,见第 110 页的 7.2.2 节)。
• 准备好处理模板参数成为引用的副作用(详见第 167 页的 11.4 节和第 432 页的 19.6.1 节中的例子)。特别是,你可能想确保返回类型不能成为引用(见第 117 页的 7.5 节)。
• 准备好处理不完整的类型,以支持例如递归数据结构(见第 171 页的 11.5 节)。
• 重载所有数组类型,而不仅仅是 T[SZ](参见第 71 页的 5.4 节)。
11.7 总结
• 模板允许你把函数、函数指针、函数对象、向量和 lambdas 作为可调用对象传递。
• 当用重载的 operator() 定义类时,将其声明为 const(除非调用时改变其状态)。
• 通过 std::invoke(),你可以实现处理所有可调用的代码,包括成员函数。
• 使用 decltype(auto) 可以完美转发返回值。
• 类型萃取是检查类型的属性和能力的类型函数。
• 当你需要一个模板中的对象的地址时,使用 std::addressof()。
• 使用 std::declval() 在未评价的表达式中创建特定类型的值。
• 如果对象的类型不依赖于模板参数,则在通用代码中使用 auto&& 来完美转发对象。
• 准备好处理模板参数被引用的副作用。
• 你可以使用模板来推迟表达式的评估(例如,支持在类模板中使用不完整类型)。
1 std::invoke() 也允许一个指向数据成员的指针作为回调类型。它不是调用一个函数,而是返回附加参数所指向的对象中相应的数据成员的值。
2 感谢 Daniel Krügler 指出这一点。
3 std::invoke_result<> 从 C++17 开始可用。从 C++11 开始,为了获得返回类型,你可以调用:typename std::result_of<Callable(Args...)>::type。
4 曾有一个关于 C++17 的提议,要求违反类型萃取的前提条件必须总是导致编译时错误。然而,由于一些类型萃取有过度的限制性要求,例如总是要求完整的类型,这一更改被推迟了。
5 感谢 Dietmar Kühl 指出这一点。
6 如果 std::is_move_constructible 不是一个不完整的类型,并非所有编译器都会产生错误。这是允许的,因为对于这种错误,不需要进行诊断。因此,这至少是一个可移植性问题。