C++要求我们要用特定的类型来声明变量,函数以及其他一些内容。这样很多代码可能就只是处理的变量类型有所不同。比如对不同的数据类型,quicksort的算法实现在结构上可能完全一样,不管是对整形的array,还是字符串类型的vector,只要他们所包含的内容之间可以相互比较。如果你所使用的语言不支持这一泛型特性,你将可能只有如下糟糕的选择:
- 你可以对不同的类型一遍又一遍的实现相同的算法。
- 你可以在某一个公共基类(common base type,比如Object和void*)里面实现通用的算法代码。
- 你也可以使用特殊的预处理方法。
如果你是从其它语言转向C++,你可能已经使用过以上几种或全部的方法了。然而他们都各有各的缺点:
- 如果你一遍又一遍地实现相同算法,你就是在重复地制造轮子!
- 如果在公共基类里实现统一的代码,就等于放弃了类型检查的好处。而且,有时候某些类必须要从某些特殊的基类派生出来,这会进一步增加维护代码的复杂度。
- 如果采用预处理的方式,你需要实现一些“愚蠢的文本替换机制”,这将很难兼顾作用域和类型检查,因此也就更容易引发奇怪的语义错误。
而模板这一方案就不会有这些问题。模板是为了一种或者多种未明确定义的类型而定义的函数或者类。在使用模板时,需要显式地或者隐式地指定模板参数。由于模板是C++的语言特性,类型和作用域检查将依然得到支持。
函数模板(Function Templates)
本章将介绍函数模板。函数模板是被参数化的函数,因此他们代表的是一组具有相似行为的函数。
函数模板初探
函数模板提供了适用于不同数据类型的函数行为。也就是说,函数模板代表的是一组函数。除了某些信息未被明确指定之外,他们看起来很像普通函数。这些未被指定的信息就是被参数化的信息。我们将通过下面一个简单的例子来说明这一问题。
定义模板
以下就是一个函数模板,它返回两个数之中的最大值:1
2
3
4
5
6template<typename T>
T max (T a, T b)
{
//如果b < a,返回a,否则返回b
return b < a ? a : b;
}
这个模板定义了一组函数,它们都返回函数的两个参数中值较大的那一个。这两个参数的类型并没有被明确指定,而是被表示为模板参数T。如你所见,模板参数必须按照如下语法声明:1
template<由逗号分割的模板参数>
在我们的例子中,模板参数是typename T
。请留意<
和>
的使用,它们在这里被称为尖括号。关键字typename
标识了一个类型参数。这是到目前为止C++中模板参数最典型的用法,当然也有其他参数(非类型模板参数)。
在这里T
是类型参数。你可以用任意标识作为类型参数名,但是习惯上是用T
。类型参数可以代表任意类型,它在模板被调用的时候决定。但是该类型(可以是基础类型,类或者其它类型)应该支持模板中用到的运算符。
由于历史原因,除了typename
之外你还可以使用class
来定义类型参数。关键字typename
在C++98标准发展过程中引入的较晚。在那之前,关键字class
是唯一可以用来定义类型参数的方法,而且目前这一方法依然有效。因此模板max()
也可以被定义成如下等效的方式:1
2
3
4
5template<class T>
T max (T a, T b)
{
return b < a ? a : b;
}
从语义上来讲,这样写不会有任何不同。因此,在这里你依然可以使用任意类型作为类型参数。只是用class
的话可能会引起一些歧义(T
并不是只能是class
类型),你应该优先使用typename
。但是与定义class
的情况不同,在声明模板类型参数的时候,不可以用关键字struct
取代typename
。
使用模板
下面的程序展示了使用模板的方法:
1 |
|
在这段代码中,max()
被调用了三次:一次是比较两个int,一次是比较两个double,还有一次是比较两个std::string
。每一次都会算出最大值。下面是输出结果:
1 | max(7,i): 42 |
注意在调用max()
模板的时候使用了作用域限制符::
。这样程序将会在全局作用域中查找max()
模板。否则的话,在某些情况下标准库中的std::max()
模板将会被调用,或者有时候不太容易确定具体哪一个模板会被调用。
在编译阶段,模板并不是被编译成一个可以支持多种类型的实体。而是对每一个用于该模板的类型都会产生一个独立的实体。因此在本例中,max()
会被编译出三个实体,因为它被用于三种类型。比如第一次调用时:1
2
3
4int i = 42;
... ma
x(7,i)
...
函数模板的类型参数是int
。因此语义上等效于调用了如下函数:1
2
3
4int max (int a, int b)
{
return b < a ? a : b;
}
以上用具体类型取代模板类型参数的过程叫做“实例化”。它会产生模板的一个实例。值得注意的是,模板的实例化不需要程序员做额外的请求,只是简单的使用函数模板就会触发这一实例化过程。
同样的,另外两次调用也会分别为double
和std::string
各实例化出一个实例,就像是分别定义了下面两个函数一样:1
2double max (double, double);
std::string max (std::string, std::string);
另外,只要结果是有意义的,void
作为模板参数也是有效的。比如:1
2
3
4
5
6template<typename T>
T foo(T*)
{ }
void* vp = nullptr;
foo(vp); // OK:模板参数被推断为void
foo(void*)
两阶段编译检查(Two-Phase Translation )
在实例化模板的时候,如果模板参数类型不支持所有模板中用到的操作符,将会遇到编译期错误。比如:1
2
3std::complex<float> c1, c2; // std::complex<>没有提供小于运算符
... ::
max(c1,c2); //编译期ERROR
但是在定义的地方并没有遇到错误提示。这是因为模板是被分两步编译的:
- 在模板定义阶段,模板的检查并不包含类型参数的检查。只包含下面几个方面:
- 语法检查。比如少了分号。
- 使用了未定义的不依赖于模板参数的名称(类型名,函数名,……)。
- 未使用模板参数的static assertions。
- 在模板实例化阶段,为确保所有代码都是有效的,模板会再次被检查,尤其是那些依赖于类型参数的部分。
比如:1
2
3
4
5
6
7
8template<typename T>
void foo(T t)
{
undeclared(); //如果undeclared()未定义,第一阶段就会报错,因为与模板参数无关
undeclared(t); //如果undeclared(t)未定义,第二阶段会报错,因为与模板参数有关
static_assert(sizeof(int) > 10,"int too small"); //与模板参数无关,总是报错
static_assert(sizeof(T) > 10, "T too small"); //与模板参数有关,只会在第二阶段报错
}
名称被检查两次这一现象被称为“两阶段查找”。需要注意的是,有些编译器并不会执行第一阶段中的所有检查。因此如果模板没有被至少实例化一次的话,你可能一直都不会发现代码中的常规错误。
编译和链接
两阶段的编译检查给模板的处理带来了一个问题:当实例化一个模板的时候,编译器需要(一定程度上)看到模板的完整定义。这不同于函数编译和链接分离的思想,函数在编译阶段只需要声明就够了。
模板参数推断
当我们调用形如max()
的函数模板来处理某些变量时,模板参数将由被传递的调用参数决定。如果我们传递两个int类型的参数给模板函数,C++编译器会将模板参数T推断为int。不过T可能只是实际传递的函数参数类型的一部分。比如我们定义了如下接受常量引用作为函数参数的模板:1
2
3
4
5template<typename T>
T max (T const& a, T const& b)
{
return b < a ? a : b;
}
此时如果我们传递int类型的调用参数,由于调用参数和int const &
匹配,类型参数T
将被推断为int。
类型推断中的类型转换
在类型推断的时候自动的类型转换是受限制的:
- 如果调用参数是按引用传递的,任何类型转换都不被允许。通过模板类型参数T定义的两个参数,它们实参的类型必须完全一样。
- 如果调用参数是按值传递的,那么只有退化(decay)这一类简单转换是被允许的: const和volatile限制符会被忽略,引用被转换成被引用的类型,raw array和函数被转换为相应的指针类型。
- 通过模板类型参数T定义的两个参数,它们实参的类型在退化(decay)后必须一样。
例如:1
2
3
4
5
6
7
8
9
10
11template<typename T>
T max (T a, T b);
... in
T const c = 42;
int i = 1; //原书缺少i的定义
max(i, c); // OK: T被推断为int,c中的const被decay掉
max(c, c); // OK: T被推断为int
int& ir = i;
max(i, ir); // OK: T被推断为int,ir中的引用被decay掉
int arr[4];
foo(&i, arr); // OK: T被推断为int*
但是像下面这样是错误的:1
2
3max(4, 7.2); // ERROR:不确定T该被推断为int还是double
std::string s;
foo("hello", s); //ERROR:不确定T该被推断为const[6]还是std::string
有两种办法解决以上错误:
- 对参数做类型转换
1 | max(static_cast<double>(4), 7.2); // OK |
- 显式地指出类型参数T的类型,这样编译器就不再会去做类型推导。
1 | max<double>(4, 7.2); // OK |
- 指明调用参数可能有不同的类型(多个模板参数)。
对默认调用参数的类型推断
需要注意的是,类型推断并不适用于默认调用参数。例如:1
2
3
4
5template<typename T>
void f(T = "");
...
f(1); // OK: T被推断为int,调用f<int> (1)
f(); // ERROR:无法推断T的类型
为应对这一情况,你需要给模板类型参数也声明一个默认参数:1
2
3
4template<typename T = std::string>
void f(T = "");
... f(
); // OK
多个模板参数
目前我们看到了与函数模板相关的两组参数:
- 模板参数,定义在函数模板前面的尖括号里:
- 调用参数,定义在函数模板名称后面的圆括号里:
1 | template<typename T> // T是模板参数 |
模板参数可以是一个或者多个。比如,你可以定义这样一个max()
模板,它可能接受两个不同类型的调用参数:1
2
3
4
5
6
7template<typename T1, typename T2>
T1 max (T1 a, T2 b)
{
return b < a ? a : b;
}
m = ::max(4, 7.2); // OK,但是返回类型是第一个模板参数T1的类型
如果你使用其中一个类型参数的类型作为返回类型,当应该返回另一个类型的值的时候,返回值会被做类型转换。这将导致返回值的具体类型和参数的传递顺序有关。C++提供了多种应对这一问题的方法:
- 引入第三个模板参数作为返回类型。
- 让编译器找出返回类型。
- 将返回类型定义为两个参数类型的“公共类型”
下面将逐一进行讨论。
作为返回类型的模板参数
我们可以不去显式的指出模板参数的类型。但是也提到,我们也可以显式的指出模板参数的类型:1
2
3
4template<typename T>
T max (T a, T b);
... ::
max<double>(4, 7.2); // max()被针对double实例化
当模板参数和调用参数之间没有必然的联系,且模板参数不能确定的时候,就要显式的指明模板参数。比如你可以引入第三个模板来指定函数模板的返回类型:1
2template<typename T1, typename T2, typename RT>
RT max (T1 a, T2 b);
但是模板类型推断不会考虑返回类型,而RT又没有被用作调用参数的类型。因此RT不会被推断。这样就必须显式的指明模板参数的类型。比如:1
2
3
4template<typename T1, typename T2, typename RT>
RT max (T1 a, T2 b);
... ::
max<int,double,double>(4, 7.2); // OK,但是太繁琐
另一种办法是只指定第一个模板参数的类型,其余参数的类型通过推断获得。通常而言,我们必须显式指定所有模板参数的类型,直到某一个模板参数的类型可以被推断出来为止。因此,如果你改变了上面例子中的模板参数顺序,调用时只需要指定返回值的类型就可以了:1
2
3
4template<typename RT, typename T1, typename T2>
RT max (T1 a, T2 b);
... ::
max<double>(4, 7.2) //OK:返回类型是double,T1和T2根据调用参数推断
在本例中,调用max<double>
时,显式的指明了RT
的类型是double
,T1
和T2
则基于传入调用参数的类型被推断为int
和double
。然而改进版的max()
并没有带来显著的变化。使用单模板参数的版本,即使传入的两个调用参数的类型不同,你依然可以显式的指定模板参数类型(也作为返回类型)
返回类型推断
如果返回类型是由模板参数决定的,那么推断返回类型最简单也是最好的办法就是让编译器来做这件事。从C++14开始,这成为可能,而且不需要把返回类型声明为任何模板参数类型(不过你需要声明返回类型为auto):1
2
3
4
5template<typename T1, typename T2>
auto max (T1 a, T2 b)
{
return b < a ? a : b;
}
事实上,在不使用尾置返回类型(trailing return type)的情况下将auto用于返回类型,要求返回类型必须能够通过函数体中的返回语句推断出来。当然,这首先要求返回类型能够从函数体中推断出来。因此,必须要有这样可以用来推断返回类型的返回语句,而且多个返回语句之间的推断结果必须一致。
在C++14之前,要想让编译器推断出返回类型,就必须让或多或少的函数实现成为函数声明的一部分。在C++11中,尾置返回类型(trailing return type)允许我们使用函数的调用参数。也就是说,我们可以基于运算符?:
的结果声明返回类型:
1 | template<typename T1, typename T2> |
在这里,返回类型是由运算符?:
的结果决定的,这虽然复杂但是可以得到想要的结果。需要注意的是1
2template<typename T1, typename T2>
auto max (T1 a, T2 b) -> decltype(b<a?a:b);
是一个声明,编译器在编译阶段会根据运算符?:
的返回结果来决定实际的返回类型。不过具体的实现可以有所不同,事实上用true作为运算符?:
的条件就足够了:1
2template<typename T1, typename T2>
auto max (T1 a, T2 b) -> decltype(true?a:b);
但是在某些情况下会有一个严重的问题:由于T可能是引用类型,返回类型就也可能被推断为引用类型。因此你应该返回的是decay后的T,像下面这样:1
2
3
4
5
6
template<typename T1, typename T2>
auto max (T1 a, T2 b) -> typename std::decay<decltype(true? a:b)>::type
{
return b < a ? a : b;
}
在这里我们用到了类型萃取(type trait)std::decay<>
,它返回其type成员作为目标类型,定义在标准库<type_trait>
中。由于其type成员是一个类型,为了获取其结果,需要用关键字typename
修饰这个表达式。
在这里请注意,在初始化auto变量的时候其类型总是退化之后了的类型。当返回类型是auto的时候也是这样。用auto作为返回结果的效果就像下面这样,a的类型将被推断为i退化后的类型,也就是int:1
2
3int i = 42;
int const& ir = i; // ir是i的引用
auto a = ir; // a的类型是it decay之后的类型,也就是int
将返回类型声明为公共类型(Common Type)
从C++11开始,标准库提供了一种指定“更一般类型”的方式。std::common_type<>::type
产生的类型是他的两个模板参数的公共类型。比如:1
2
3
4
5
6
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_traits>
中,它返回一个结构体,结构体的type成员被用作目标类型。因此其主要应用场景如下:1
typename std::common_type<T1,T2>::type //since C++11
不过从C++14开始,你可以简化“萃取”的用法,只要在后面加个_t
,就可以省掉typename
和::type
,简化后的版本变成:1
std::common_type_t<T1,T2> // equivalent since C++14
std::common_type<>
的实现用到了一些比较取巧的模板编程手法。它根据运算符?:
的语法规则或者对某些类型的特化来决定目标类型。因此::max(4, 7.2)
和::max(7.2, 4)
都返回double类型的7.2。需要注意的是,std::common_type<>
的结果也是退化的。
默认模板参数
这些默认值被称为默认模板参数并且可以用于任意类型的模板。它们甚至可以根据其前面的模板参数来决定自己的类型。比如如果你想将前述定义返回类型的方法和多模板参数一起使用,你可以为返回类型引入一个模板参数RT,并将其默认类型声明为其它两个模板参数的公共类型。同样地,我们也有多种实现方法:
我们可以直接使用运算符?:
。不过由于我们必须在调用参数a和b被声明之前使用运算符?:
,我们只能像下面这样:1
2
3
4
5
6
7
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<>
来确保返回的值不是引用类型。同样值得注意的是,这一实现方式要求我们能够调用两个模板参数的默认构造参数。还有另一种方法,使用std::declval
,不过这将使得声明部分变得更加复杂。
我们也可以利用类型萃取std::common_type<>
作为返回类型的默认值:1
2
3
4
5
6
7
template<typename T1, typename T2, typename RT =
std::common_type_t<T1,T2>>
RT max (T1 a, T2 b)
{
return b < a ? a : b;
}
在这里std::common_type<>
也是会做类型退化的,因此返回类型不会是引用。
在以上两种情况下,作为调用者,你即可以使用RT的默认值作为返回类型:1
auto a = ::max(4, 7.2);
也可以显式的指出所有的模板参数的类型:1
auto b = ::max<double,int,long double>(7.2, 4);
但是,我们再次遇到这样一个问题:为了显式指出返回类型,我们必须显式的指出全部三个模板参数的类型。因此我们希望能够将返回类型作为第一个模板参数,并且依然能够从其它两个模板参数推断出它的类型。
原则上这是可行的,即使后面的模板参数没有默认值,我们依然可以让第一个模板参数有默认值:
1 | template<typename RT = long, typename T1, typename T2> |
基于这个定义,你可以这样调用:1
2
3
4int i; long l;
... ma
x(i, l); //返回值类型是long (RT的默认值)
max<int>(4, 42); //返回int,因为其被显式指定
函数模板的重载
定义多个有相同函数名的函数,当实际调用的时候,由C++编译器负责决定具体该调用哪一个函数。即使在不考虑模板的时候,这一决策过程也可能异常复杂。下面几行程序展示了函数模板的重载:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// 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 argumentdeduction)
::max<double>(7, 42); // calls max<double> (no argumentdeduction)
::max("a", 42.7); //calls the nontemplate for two ints
}
如你所见,一个非模板函数可以和一个与其同名的函数模板共存,并且这个同名的函数模板可以被实例化为与非模板函数具有相同类型的调用参数。在所有其它因素都相同的情况下,模板解析过程将优先选择非模板函数,而不是从模板实例化出来的函数。第一个调用就属于这种情况:1
::max(7, 42); // both int values match the nontemplate function perfectly
如果模板可以实例化出一个更匹配的函数,那么就会选择这个模板。正如第二和第三次调用max()
时那样:1
2::max(7.0, 42.0); // calls the max<double> (by argument deduction)
::max("a", "b"); //calls the max<char> (by argument deduction)
在这里模板更匹配一些,因为它不需要从double和char到int的转换。
也可以显式指定一个空的模板列表。这表明它会被解析成一个模板调用,其所有的模板参数会被通过调用参数推断出来:1
::max<>(7, 42); // calls max<int> (by argument deduction)
由于在模板参数推断时不允许自动类型转换,而常规函数是允许的,因此最后一个调用会选择非模板参函数(‘a’和42.7都被转换成int):1
::max("a", 42.7); //only the nontemplate function allows nontrivial conversions
一个有趣的例子是我们可以专门为max()
实现一个可以显式指定返回值类型的模板:1
2
3
4
5
6
7
8
9
10
11template<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()
:1
2auto a = ::max(4, 7.2); // uses first template
auto b = ::max<long double>(7.2, 4); // uses second template
但是像下面这样调用的话:1
auto c = ::max<int>(4, 7.2); // ERROR: both function templates match
两个模板都是匹配的,这会导致模板解析过程不知道该调用哪一个模板,从而导致未知错误。因此当重载函数模板的时候,你要保证对任意一个调用,都只会有一个模板匹配。一个比较有用的例子是为指针和C字符串重载max()
模板:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 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字符串的最大值:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// maximum of two values of any type (call-by-reference)
template<typenameT> 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()
的时候,1
return max (max(a,b), c);
会遇到run-time error,这是因为对C字符串,max(max(a, b), c)
会创建一个用于返回的临时局部变量,而在返回语句接受后,这个临时变量会被销毁,导致max()
使用了一个悬空的引用。不幸的是,这个错误几乎在所有情况下都不太容易被发现。
作为对比,在求三个int最大值的max()
调用中,则不会遇到这个问题。这里虽然也会创建三个临时变量,但是这三个临时变量是在main()
里面创建的,而且会一直持续到语句结束。这只是模板解析规则和期望结果不一致的一个例子。再者,需要确保函数模板在被调用时,其已经在前方某处定义。这是由于在我们调用某个模板时,其相关定义不一定是可见的。比如我们定义了一个三参数的max()
,由于它看不到适用于两个int的max()
,因此它最终会调用两个参数的模板函数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 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)
}
难道,我们不应该…?
按值传递还是按引用传递?
通常而言,建议将按引用传递用于除简单类型(比如基础类型和std::string_view
)以外的类型,这样可以免除不必要的拷贝成本。不过出于以下原因,按值传递通常更好一些:
- 语法简单。
- 编译器能够更好地进行优化。
- 移动语义通常使拷贝成本比较低。
- 某些情况下可能没有拷贝或者移动。
再有就是,对于模板,还有一些特有情况:
- 模板既可以用于简单类型,也可以用于复杂类型,因此如果默认选择适合于复杂类型可能方式,可能会对简单类型产生不利影响。
- 作为调用者,你通常可以使用
std::ref()
和std::cref()
来按引用传递参数。 - 虽然按值传递string literal和raw array经常会遇到问题,但是按照引用传递它们通常只会遇到更大的问题。
为什么不适用inline?
通常而言,函数模板不需要被声明成inline。不同于非inline函数,我们可以把非inline的函数模板定义在头文件里,然后在多个编译单元里include这个文件。唯一一个例外是模板对某些类型的全特化,这时候最终的code不在是“泛型”的。
严格地从语言角度来看,inline只意味着在程序中函数的定义可以出现很多次。不过它也给了编译器一个暗示,在调用该函数的地方函数应该被展开成inline的:这样做在某些情况下可以提高效率,但是在另一些情况下也可能降低效率。现代编译器在没有关键字inline暗示的情况下,通常也可以很好的决定是否将函数展开成inline的。
为什么不用constexpr?
从C++11开始,你可以通过使用关键字constexpr来在编译阶段进行某些计算。对于很多模板,这是有意义的。比如为了可以在编译阶段使用求最大值的函数,你必须将其定义成下面这样:1
2
3
4
5template<typename T1, typename T2>
constexpr auto max (T1 a, T2 b)
{
return b < a ? a : b;
}
如此你就可以在编译阶段的上下文中,实时地使用这个求最大值的函数模板:1
int a[::max(sizeof(char),1000u)];
或者指定std::array<>
的大小:1
std::array<std::string, ::max(sizeof(char),1000u)> arr;
在这里我们传递的1000是unsigned int类型,这样可以避免直接比较一个有符号数值和一个无符号数值时产生的警报。
总结
- 函数模板定义了一组适用于不同类型的函数。
- 当向模板函数传递变量时,函数模板会自行推断模板参数的类型,来决定去实例化出那种类型的函数。
- 你也可以显式的指出模板参数的类型。
- 你可以定义模板参数的默认值。这个默认值可以使用该模板参数前面的模板参数的类型,而且其后面的模板参数可以没有默认值。
- 函数模板可以被重载。
- 当定义新的函数模板来重载已有的函数模板时,必须要确保在任何调用情况下都只有一个模板是最匹配的。
- 当你重载函数模板的时候,最好只是显式地指出了模板参数得了类型。
- 确保在调用某个函数模板之前,编译器已经看到了相对应的模板定义。
类模板(Class Templates)
和函数类似,类也可以被一个或多个类型参数化。容器类(Container classes)就是典型的一个例子,它可以被用来处理某一指定类型的元素。
Stack类模板的实现
和函数模板一样,我们把类模板Stack<>
的声明和定义都放在头文件里:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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<>
实现的。这样我们就不需要自己来实现内存管理,拷贝构造函数和赋值构造函数了,从而可以把更多的精力放在这个类模板的接口实现上。
声明一个类模板
声明类模板和声明函数模板类似:在开始定义具体内容之前,需要先声明一个或者多个作为模板的类型参数的标识符。同样地,这一标识符通常用T表示:1
2
3
4template<typename T>
class Stack {
...
};
在这里,同样可以用关键字class
取代typename
:1
2
3
4template<class T>
class Stack {
...
};
在类模板内部,T可以像普通类型一样被用来声明成员变量和成员函数。在这个例子中,T被用于声明vector中元素的类型,用于声明成员函数push()
的参数类型,也被用于成员函数top
的返回类型:1
2
3
4
5
6
7
8
9
10
11
12template<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>
类型用于声明的时候,除非可以推断出模板参数的类型,否则就必须使用Stack<T>
(Stack后面必须跟着<T>
)。不过,如果在类模板内部使用Stack
而不是Stack<T>
,表明这个内部类的模板参数类型和模板
类的参数类型相同。
比如,如果需要定义自己的复制构造函数和赋值构造函数,通常应该定义成这样:1
2
3
4
5
6template<typename T>
class Stack {
... Stack (Stack const&); // copy constructor
Stack& operator= (Stack const&); // assignment operator
...
};
它和下面的定义是等效的:1
2
3
4
5
6template<typename T>
class Stack {
... Stack (Stack<T> const&); // copy constructor
Stack<T>& operator= (Stack<T> const&); // assignment operator
...
};
一般<T>
暗示要对某些模板参数做特殊处理,所以最好还是使用第一种方式。但是如果在类模板的外面,就需要这样定义:1
2template<typename T>
bool operator== (Stack<T> const& lhs, Stack<T> const& rhs);
注意在只需要类的名字而不是类型的地方,可以只用Stack。这和声明构造函数和析构函数的情况相同。
另外,不同于非模板类,不可以在函数内部或者块作用域内({…})声明和定义模板。通常模板只能定义在global/namespace作用域,或者是其它类的声明里面。
成员函数的实现
定义类模板的成员函数时,必须指出它是一个模板,也必须使用该类模板的所有类型限制。因此,要像下面这样定义Stack<T>
的成员函数push()
:1
2
3
4
5template<typename T>
void Stack<T>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}
这里调用了其vector
成员的push_back()
方法,它向vector的尾部追加一个元素。注意vector
的pop_back()
方法只是删除掉尾部的元素,并不会返回这一元素。这主要是为了异常安全(exception safety)。不过如果忽略掉这一风险,我们依然可以实现一个返回被删除元素的pop()
。为了达到这一目的,我们只需要用T定义一个和vector元素有相同类型的局部变量就可以了:1
2
3
4
5
6
7
8template<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
}
由于vector
的back()
(返回其最后一个元素)和pop_back()
(删除最后一个元素)方法在vector为空的时候行为未定义,因此需要对vector是否为空进行测试。在程序中我们断言(assert)vector不能为空,这样可以确保不会对空的Stack调用pop()
方法。在top()
中也是这样,它返回但是不删除首元素:1
2
3
4
5
6template<typename T>
T const& Stack<T>::top () const
{
assert(!elems.empty());
return elems.back(); // return copy of last element
}
当然,就如同其它成员函数一样,你也可以把类模板的成员函数以内联函数的形式实现在类模板的内部。比如:1
2
3
4
5
6
7template<typename T>
class Stack {
...
void push (T const& elem) {
elems.push_back(elem); // append copy of passed elem
}
};
Stack类模板的使用
直到C++17,在使用类模板的时候都需要显式的指明模板参数。下面的例子展示了该如何使用Stack<>
类模板:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
。被创建的instStack
会使用一个存储int
的vector作为其elems
成员,而且所有被用到的成员函数都会被用int实例化。同样的,对于用Stack<std::string>
定义的对象,它会使用一个存储std::string
的vector作为其elems
成员,所有被用到的成员函数也都会用std::string
实例化。注意,模板函数和模板成员函数只有在被调用的时候才会实例化。这样一方面会节省时间和空间,同样也允许只是部分的使用类模板。在这个例子中,对int
和std::string
,默认构造函数,push()
以及top()
函数都会被实例化。而pop()
只会针对std::string
实例化。如果一个类模板有static成员,对每一个用到这个类模板的类型,相应的静态成员也只会被实例化一次。
被实例化之后的类模板类型(Stack<int>
之类)可以像其它常规类型一样使用。可以用const
以及volatile
修饰它,或者用它来创建数组和引用。可以通过typedef和using将它用于类型定义的一部分,也可以用它来实例化其它的模板类型。比如:1
2
3
4
5
6
7void 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:1
2Stack<float*> floatPtrStack; // stack of float pointers
Stack<Stack<int>> intStackStack; // stack of stack of ints
模板参数唯一的要求是:它要支持模板中被用到的各种操作(运算符)。
部分地使用类模板
一个类模板通常会对用来实例化它的类型进行多种操作(包含构造函数和析构函数)。这可能会让你以为,要为模板参数提供所有被模板成员函数用到的操作。但是事实不是这样:模板参数只需要提供那些会被用到的操作(而不是可能会被用到的操作)。比如Stack<>
类可能会提供一个成员函数printOn()
来打印整个stack的内容,它会调用operator <<
来依次打印每一个元素:1
2
3
4
5
6
7
8template<typename T>
class Stack {
void printOn() (std::ostream& strm) const {
for (T const& elem : elems) {
strm << elem << ""; // call << for each element
}
}
};
这个类依然可以用于那些没有提供operator <<
运算符的元素:1
2
3
4
5Stack<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<<
的调用:1
ps.printOn(std::cout); // ERROR: operator<< not supported for element type
Concept
这样就有一个问题:我们如何才能知道为了实例化一个模板需要哪些操作?名词concept通常被用来表示一组反复被模板库要求的限制条件。例如C++标准库是基于这样一些concepts的:可随机进入的迭代器(random access iterator)和可默认构造的(default constructible)。
从C++11开始,你至少可以通过关键字static_assert
和其它一些预定义的类型萃取(type traits)来做一些简单的检查。比如:1
2
3
4
5
6template<typename T>
class C
{
static_assert(std::is_default_constructible<T>::value,
"Class C requires default-constructible elements");
};
即使没有这个static_assert
,如果需要T的默认构造函数的话,依然会遇到编译错误。然而还有更复杂的情况需要检查,比如模板类型T的实例需要提供一个特殊的成员函数,或者需要能够通过operator <
进行比较。
友元
相比于通过printOn()
来打印stack的内容,更好的办法是去重载stack的operator <<
运算符。而且和非模板类的情况一样,operator<<
应该被实现为非成员函数,在其实现中可以调用printOn()
:1
2
3
4
5
6
7
8
9template<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<<
并不是一个函数模板,而是在需要的时候,随类模板实例化出来的一个常规函数。然而如果你试着先声明一个友元函数,然后再去定义它,情况会变的很复杂。
事实上我们有两种选择:
- 可以隐式的声明一个新的函数模板,但是必须使用一个不同于类模板的模板参数,比如用U:
1 | template<typename T> |
无论是继续使用T还是省略掉模板参数声明,都不可以(要么是里面的T隐藏了外面的T,要么是在命名空间作用域内声明了一个非模板函数)。
- 也可以先将
Stack<T>
的operator<<
声明为一个模板,这要求先对Stack<T>
进行声明:
1 | template<typename T> |
接着就可以将这一模板声明为Stack<T>
的友元:1
2
3
4template<typename T>
class Stack {
friend std::ostream& operator<< <T> (std::ostream&, Stack<T> const&);
}
注意这里在operator<<
后面用了<T>
,这相当于声明了一个特例化之后的非成员函数模板作为友元。如果没有<T>
的话,则相当于定义了一个新的非模板函数。
无论如何,你依然可以将Stack<T>
用于没有定义operator <<
的元素,只是当你调用operator<<
的时候会遇到一个错误:1
2
3
4
5
6Stack<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
模板类的特例化
可以对类模板的某一个模板参数进行特化。和函数模板的重载类似,类模板的特化允许我们对某一特定类型做优化,或者去修正类模板针对某一特定类型实例化之后的行为。不过如果对类模板进行了特化,那么也需要去特化所有的成员函数。为了特化一个类模板,在类模板声明的前面需要有一个template<>
,并且需要指明所希望特化的类型。这些用于特化类模板的类型被用作模板参数,并且需要紧跟在类名的后面:1
2
3
4template<>
class Stack<std::string> {
...
};
对于被特化的模板,所有成员函数的定义都应该被定义成“常规”成员函数,也就是说所有出现T的地方,都应该被替换成用于特化类模板的类型:1
2
3
4void Stack<std::string>::push (std::string const& elem)
{
elems.push_back(elem); // append copy of passed elem
}
下面是一个用std::string
实例化Stack<>
类模板的完整例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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()
传递参数的时候使用了引用语义,对当前std::string
类型这是有意义的,这可以提高性能。
另一个不同是使用了一个deque
而不再是vector
来存储stack
里面的元素。虽然这样做可能不会有什么好处,不过这能够说明,模板类特例化之后的实现可能和模板类的原始实现有很大不同。
部分特例化
类模板可以只被部分的特例化。这样就可以为某些特殊情况提供特殊的实现,不过使用者还是要定义一部分模板参数。比如,可以特殊化一个Stack<>
来专门处理指针:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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
}
通过1
2template<typename T>
class Stack<T*> { };
定义了一个依然是被类型T参数化,但是被特化用来处理指针的类模板Stack<T*>
。同样的,特例化之后的函数接口可能不同。比如对pop()
,他在这里返回的是一个指针,因此如果这个指针是通过new创建的话,可以对这个被删除的值调用delete:1
2
3
4Stack<int*> ptrStack; // stack of pointers (specialimplementation)
ptrStack.push(new int{42});
std::cout << *ptrStack.top() << "\n";
delete ptrStack.pop();
多模板参数的部分特例化
类模板也可以特例化多个模板参数之间的关系。比如对下面这个类模板:1
2
3template<typename T1, typename T2>
class MyClass {
};
进行如下这些特例化都是可以的:1
2
3
4
5
6
7
8
9
10
11
12// 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*> {
};
下面的例子展示了以上各种类模板被使用的情况:1
2
3
4MyClass<int, float> mif; // uses MyClass<T1,T2>
MyClass<float, float> mff; // uses MyClass<T,T>
MyClass<float, int> mfi; // uses MyClass<T,int>
MyClass<int*, float*> mp; // uses MyClass<T1*,T2*>
如果有不止一个特例化的版本可以以相同的情形匹配某一个调用,说明定义是有歧义的:1
2MyClass<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*>
为了消除第二种歧义,你可以提供一个单独的特例化版本来处理相同类型的指针:1
2
3template<typename T>
class MyClass<T*,T*> {
};
默认类模板参数
和函数模板一样,也可以给类模板的模板参数指定默认值。比如对Stack<>
,你可以将其用来容纳元素的容器声明为第二个模板参数,并指定其默认值是std::vector<>
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
}
由于现在有两个模板参数,因此每个成员函数的定义也应该包含两个模板参数:1
2
3
4
5template<typename T, typename Cont>
void Stack<T,Cont>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}
这个Stack<>
模板可以像之前一样使用。如果只提供第一个模板参数作为元素类型,那么vector
将被用来处理Stack中的元素:1
2
3
4
5template<typename T, typename Cont = std::vector<T>>
class Stack {
private:
Cont elems; // elements
};
而且在程序中,也可以为Stack指定一个容器类型:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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();
}
通过1
Stack<double,std::deque<double>>
定义了一个处理double型元素的Stack,其使用的容器是std::deque<>
。
类型别名(Type Aliases)
通过给类模板定义一个新的名字,可以使类模板的使用变得更方便。
Typedefs和Alias声明
为了简化给类模板定义新名字的过程,有两种方法可用:
使用关键字typedef
:1
2
3typedef Stack<int> IntStack; // typedef
void foo (IntStack const& s); // s is stack of ints
IntStack istack[10]; // istack is array of 10 stacks of ints
我们称这种声明方式为typedef
,被定义的名字叫做typedef-name.
使用关键字using
(从C++11开始)1
2
3using 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
在这两种情况下我们都只是为一个已经存在的类型定义了一个别名,并没有定义新的类型。因此在:1
typedef Stack <int> IntStack;
或者:1
using IntStack = Stack <int>;
之后,IntStack
和Stack<int>
将是两个等效的符号。以上两种给一个已经存在的类型定义新名字的方式,被称为type alias declaration。新的名字被称为type alias。
Alias Templates(别名模板)
不同于typedef,alias declaration也可以被模板化,这样就可以给一组类型取一个方便的名字。这一特性从C++11开始生效,被称作alias templates。下面的DequeStack
别名模板是被元素类型T参数化的,代表将其元素存储在std::deque
中的一组Stack
:1
2template<typename T>
using DequeStack = Stack<T, std::deque<T>>;
因此,类模板和alias templates都是可以被参数化的类型。同样地,这里alias template只是一个已经存在的类型的新名字,原来的名字依然可用。DequeStack<int>
和Stack<int, std::deque<int>>
代表的是同一种类型。
同样的,通常模板(包含Alias Templates)只可以被声明和定义在global/namespace作用域,或者在一个类的声明中。
Alias Templates for Member Types(class成员的别名模板)
使用alias templates可以很方便的给类模板的成员类型定义一个快捷方式,在:1
2
3struct C {
typedef ... iterator;
};
或者1
2
3struct MyType {
using iterator = ...;
};
之后,下面这样的定义:1
2template<typename T>
using MyTypeIterator = typename MyType<T>::iterator;
允许我们使用:1
MyTypeIterator<int> pos;
取代:1
typename MyType<T>::iterator pos;
Type Traits Suffix_t (Suffix_t类型萃取)
从C++14开始,标准库使用上面的技术,给标准库中所有返回一个类型的type trait定义了快捷方式。比如为了能够使用:1
std::add_const_t<T> // since C++14
而不是:1
typename std::add_const<T>::type // since C++11
标准库做了如下定义:1
2
3
4namespace std {
template<typename T>
using add_const_t = typename add_const<T>::type;
}
类模板的类型推导
直到C++17,使用类模板时都必须显式指出所有的模板参数的类型(除非它们有默认值)。从C++17开始,这一要求不在那么严格了。如果构造函数能够推断出所有模板参数的类型(对那些没有默认值的模板参数),就不再需要显式的指明模板参数的类型。
比如在之前所有的例子中,不指定模板类型就可以调用copy constructor:1
2
3Stack<int> intStack1; // stack of strings
Stack<int> intStack2 = intStack1; // OK in all versions
Stack intStack3 = intStack1; // OK since C++17
通过提供一个接受初始化参数的构造函数,就可以推断出Stack的元素类型。比如可以定义下面这样一个Stack,它可以被一个元素初始化:1
2
3
4
5
6
7
8
9
10template<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:1
Stack intStack = 0; // Stack<int> deduced since C++17
通过用0初始化这个stack时,模板参数T被推断为int,这样就会实例化出一个Stack<int>
。但是请注意下面这些细节:
- 由于定义了接受int作为参数的构造函数,要记得向编译器要求生成默认构造函数及其全部默认行为,这是因为默认构造函数只有在没有定义其它构造函数的情况下才会默认生成,方法如下:
1 | Stack() = default; |
- 在初始化Stack的vector成员
elems
时,参数elem
被用{}
括了起来,这相当于用只有一个元素elem
的初始化列表初始化了elems
:
1 | : elems({elem}) |
这是因为vector没有可以直接接受一个参数的构造函数。
和函数模板不同,类模板可能无法部分的推断模板类型参数(比如在显式的指定了一部分类模板参数的情况下)
类模板对字符串常量参数的类型推断(Class Template Arguments Deduction with String Literals)
原则上,可以通过字符串常量来初始化Stack:1
Stack stringStack = "bottom"; // Stack<char const[7]> deduced since C++17
不过这样会带来一堆问题:当参数是按照T的引用传递的时候(上面例子中接受一个参数的构造函数,是按照引用传递的),参数类型不会被decay,也就是说一个裸的数组类型不会被转换成裸指针。这样我们就等于初始化了一个这样的Stack:1
Stack<char const[7]>
类模板中的T都会被实例化成char const[7]
。这样就不能继续向Stack追加一个不同维度的字符串常量了,因为它的类型不是char const[7]
。不过如果参数是按值传递的,参数类型就会被decay,也就是说会将裸数组退化成裸指针。这样构造函数的参数类型T会被推断为char const *
,实例化后的类模板类型会被推断为1
Stack<char const *>。
基于以上原因,可能有必要将构造函数声明成按值传递参数的形式:1
2
3
4
5
6
7
8
9
10template<typename T>
class Stack {
private:
std::vector<T> elems; // elements
public:
Stack (T elem) // initialize stack with one element by value
: elems({elem}) { // to decay on class tmpl arg deduction
}
...
};
这样下面的初始化方式就可以正常工作:1
Stack stringStack = "bottom"; // Stack<char const*> deduced since C++17
在这个例子中,最好将临时变量elem
move到stack
中,这样可以免除不必要的拷贝:1
2
3
4
5
6
7
8
9
10template<typename T>
class Stack {
private:
std::vector<T> elems; // elements
public:
Stack (T elem) // initialize stack with one element by value
: elems({std::move(elem)}) {
}
...
};
推断指引(Deduction Guides)
针对以上问题,除了将构造函数声明成按值传递的,还有一个解决方案:由于在容器中处理裸指针容易导致很多问题,对于容器一类的类,不应该将类型推断为字符的裸指针(char const *
)。
可以通过提供“推断指引”来提供额外的模板参数推断规则,或者修正已有的模板参数推断规则。比如你可以定义,当传递一个字符串常量或者C类型的字符串时,应该用std::string
实例化Stack模板类:1
Stack( char const*) -> Stack<std::string>;
这个指引语句必须出现在和模板类的定义相同的作用域或者命名空间内。通常它紧跟着模板类的定义。->
后面的类型被称为推断指引的“guided type”。现在,根据这个定义:1
Stack stringStack{"bottom"}; // OK: Stack<std::string> deduced since C++17
Stack将被推断为Stack<std::string>
。但是下面这个定义依然不可以:1
Stack stringStack = "bottom"; // Stack<std::string> deduced, but still not valid
此时模板参数类型被推断为std::string
,也会实例化出Stack<std::string>
:1
2
3
4
5
6
7
8class Stack {
private:
std::vector<std::string> elems; // elements
public:
Stack (std::string const& elem) // initialize stack with one element
: elems({elem}) {
}
};
但是根据语言规则,不能通过将字符串字面量传递给一个期望接受std::string
的构造函数来拷贝初始化(使用=初始化)一个对象,因此必须要像下面这样来初始化这个Stack:1
Stack stringStack{"bottom"}; // Stack<std::string> deduced and valid
如果还不是很确信的话,这里可以明确告诉你,模板参数推断的结果是可以拷贝的。在将stringStack
声明为Stack<std::string>
之后,下面的初始化语句声明的也将是Stack<std::string>
类型的变量(通过拷贝构造函数),而不是用Stack<std::string>
类型的元素去初始化一个stack(也就是说,Stack
存储的元素类型是std::string
,而不是Stack<std::string>
):1
2
3Stack stack2{stringStack}; // Stack<std::string> deduced
Stack stack3(stringStack); // Stack<std::string> deduced
Stack stack4 = {stringStack}; // Stack<std::string> deduced
聚合类的模板化(Templatized Aggregates)
聚合类(这样一类class或者struct:没有用户定义的显式的,或者继承而来的构造函数,没有private或者protected的非静态成员,没有虚函数,没有virtual,private或者protected的基类)也可以是模板。比如:1
2
3
4
5template<typename T>
struct ValueWithComment {
T value;
std::string comment;
};
定义了一个成员val的类型被参数化了的聚合类。可以像定义其它类模板的对象一样定义一个聚合类的对象:1
2
3ValueWithComment<int> vc;
vc.value = 42;
vc.comment = "initial value";
从C++17开始,对于聚合类的类模板甚至可以使用“类型推断指引” :1
2
3ValueWithComment(
char const*, char const*) -> ValueWithComment<std::string>;
ValueWithComment vc2 = {"hello", "initial value"};
没有“推断指引”的话,就不能使用上述初始化方法,因为ValueWithComment没有相应的构造函数来完成相关类型推断。
标准库的std::array<>
类也是一个聚合类,其元素类型和尺寸都是被参数化的。
总结
- 类模板是一个被实现为有一个或多个类型参数待定的类。
- 使用类模板时,需要显式或者隐式地传递相应的待定类型参数作为模板参数。之后类模板会被按照传入的模板参数实例化(并且被编译)。
- 对于类模板,只有其被用到的成员函数才会被实例化。
- 可以针对某些特定类型对类模板进行特化。
- 也可以针对某些特定类型对类模板进行部分特化。
- 从C++17开始,可以(不是一定可以)通过类模板的构造函数来推断模板参数的类型。
- 可以定义聚合类的类模板。
- 调用参数如果是按值传递的,那么相应的模板类型会decay。
- 模板只能被声明以及定义在global或者namespace作用域,或者是定义在其它类的定义里面。
非类型模板参数
和类模板使用类型作为参数类似,可以使代码的另一些细节留到被使用时再确定,只是对非类型模板参数,待定的不再是类型,而是某个数值。在使用这种模板时需要显式的指出待定数值的具体值,之后代码会被实例化。
类模板的非类型参数
作为和之前章节中Stack实现方式的对比,可以定义一个使用固定尺寸的array作为容器的Stack。这种方式的优点是可以避免由开发者或者标准库容器负责的内存管理开销。不过对不同应用,这一固定尺寸的具体大小也很难确定。如果指定的值过小,那么Stack就会很容易满。如果指定的值过大,则可能造成内存浪费。因此最好是让Stack的用户根据自身情况指定Stack的大小。
为此,可以将Stack的大小定义成模板的参数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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类型的。通过它指定了Stack中array的大小:1
2
3
4
5template<typename T, std::size_t Maxsize>
class Stack {
private:
std::array<T,Maxsize> elems; // elements
};
成员函数push()
也用它来检测Stack是否已满:1
2
3
4
5
6
7template<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
}
为了使用这个类模板,需要同时指出Stack中元素的类型和Stack的最大容量:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
是两种不同的类型,而且由于它们之间没有定义隐式或者显式的类型转换规则。也就不能使用其中一个取代另一个,或者将其中一个赋值给另一个。
对非类型模板参数,也可以指定默认值:1
2
3
4template<typename T = int, std::size_t Maxsize = 100>
class Stack {
...
};
但是从程序设计的角度来看,这可能不是一个好的设计方案。默认值应该是直观上正确的。不过对于一个普通的Stack,无论是默认的int类型还是Stack的最大尺寸100,看上去都不够直观。
函数模板的非类型参数
同样也可以给函数模板定义非类型模板参数。比如下面的这个函数模板,定义了一组可以返回传入参数和某个值之和的函数:1
2
3
4
5template<int Val, typename T>
T addValue (T x)
{
return x + Val;
}
当该类函数或操作是被用作其它函数的参数时,可能会很有用。比如当使用C++标准库给一个集合中的所有元素增加某个值的时候,可以将这个函数模板的一个实例化版本用作第4个参数:1
2
3std::transform (source.begin(), source.end(), //start and end of source
dest.begin(), //start of destination
addValue<5,int>); // operation
第4个参数是从addValue<>()
实例化出一个可以给传入的int型参数加5的函数实例。这一实例会被用来处理集合source中的所有元素,并将结果保存到目标集合dest中。注意在这里必须将addValue<>()
的模板参数T指定为int类型。因为类型推断只会对立即发生的调用起作用,而std::transform()
又需要一个完整的类型来推断其第四个参数的类型。目前还不支持先部分地替换或者推断模板参数的类型,然后再基于具体情况去推断其余的模板参数。
同样也可以基于前面的模板参数推断出当前模板参数的类型。比如可以通过传入的非类型模板参数推断出返回类型:1
2template<auto Val, typename T = decltype(Val)>
T foo();
或者可以通过如下方式确保传入的非类型模板参数的类型和类型参数的类型一致:1
2template<typename T, T Val = T{}>
T bar();
非类型模板参数的限制
使用非类型模板参数是有限制的。通常它们只能是整形常量(包含枚举),指向objects
/functions
/members
的指针,objects
或者functions
的左值引用,或者是std::nullptr_t
(类型是nullptr
)。浮点型数值或者class类型的对象都不能作为非类型模板参数使用:1
2
3
4
5
6
7
8
9template<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++版本的每次更新都会放宽以上限制,因此还有一些针对不同版本的限制:
- 在C++11中,对象必须要有外部链接。
- 在C++14中,对象必须是外部链接或者内部链接。
因此下面的写法是不对的:1
2
3
4
5template<char const* name>
class MyClass {
...
};
MyClass<"hello"> x; //ERROR: string literal "hello" not allowed
不过有如下变通方法(视C++版本而定):1
2
3
4
5
6
7
8
9extern char const s03[] = "hi"; // external linkage
char const s11[] = "hi"; // internal linkage
int main()
{
MyClass<s03> m03; // OK (all versions)
MyClass<s11> m11; // OK since C++11
static char const s17[] = "hi"; // no linkage
MyClass<s17> m17; // OK since C++17
}
上面三种情况下,都是用”hello”初始化了一个字符串常量数组,然后将这个字符串常量数组对象用于类模板中被声明为char const *
的模板参数。如果这个对象有外部链接(s03),那么对所有版本的C++都是有效的,如果对象有内部链接(s11),那么对C++11和C++14也是有效的,而对C++17,即使对象没有链接属性也是有效的。
避免无效表达式
非类型模板参数可以是任何编译期表达式。比如:1
2
3
4template<int I, bool B>
class C;
C<sizeof(int) + 4, sizeof(int)==4> c;
不过如果在表达式中使用了operator >
,就必须将相应表达式放在括号里面,否则>
会被作为模板参数列表末尾的>
,从而截断了参数列表:1
2C<42, sizeof(int) > 4> c; // ERROR: first > ends the template argument list
C<42, (sizeof(int) > 4)> c; // OK
用auto作为非模板类型参数的类型
从C++17开始,可以不指定非类型模板参数的具体类型(代之以auto),从而使其可以用于任意有效的非类型模板参数的类型。通过这一特性,可以定义如下更为泛化的大小固定的Stack类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 isempty
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的如下定义:1
2
3
4template<typename T, auto Maxsize>
class Stack {
...
};
定义了类型待定的Maxsize。它的类型可以是任意非类型参数所允许的类型。在模板内部,既可以使用它的值:1
std::array<T,Maxsize> elems; // elements
也可以使用它的类型:1
using size_type = decltype(Maxsize);
然后可以将它用于成员函数size()
的返回类型:1
2
3size_type size() const { //return current number of elements
return numElems;
}
从C++14开始,也可以通过使用auto,让编译器推断出具体的返回类型:1
2
3auto size() const { //return current number of elements
return numElems;
}
根据这个类的声明,Stack中numElems成员的类型是由非类型模板参数的类型决定的,当像下面这样使用它的时候:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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";
}
}
对于1
Stack<int,20u> int20Stack; // stack of up to 20 ints
由于传递的非类型参数是20u,因此内部的size_type
是unsigned int
类型的。
对于1
Stack<std::string,40> stringStack; // stack of up to 40 strings
由于传递的非类型参数是int,因此内部的size_type
是int类型的。因为这两个Stack中成员函数size()
的返回类型是不一样的,所以1
2
3auto size1 = int20Stack.size();
...
auto size2 = stringStack.size();
中size1
和size2
的类型也不一样。这可以通过标准类型萃取std::is_same
和decltype
来验证:1
2
3if (!std::is_same<decltype(size1), decltype(size2)>::value) {
std::cout << "size types differ" << "\n";
}
输出结果将是:1
size types differ
从C++17开始,对于返回类型的类型萃取,可以通过使用下标_v
省略掉::value
:1
2
3if (!std::is_same_v<decltype(size1), decltype(size2)>) {
std::cout << "size types differ" << "\n";
}
注意关于非类型模板参数的限制依然存在。比如:1
Stack<int,3.14> sd; // ERROR: Floating-point nontype argument
由于可以将字符串作为常量数组用于非类型模板参数,下面的用法也是可以的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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实例化成引用类型:1
2
3
4
5
6template<decltype(auto) N>
class C {
...
};
int i;
C<(i)> x; // N is int&
总结
- 模板的参数不只可以是类型,也可以是数值。
- 不可以将浮点型或者class类型的对象用于非类型模板参数。使用指向字符串常量,临时变量和子对象的指针或引用也有一些限制。
- 通过使用关键字auto,可以使非类型模板参数的类型更为泛化。
变参模板
从C++11开始,模板可以接受一组数量可变的参数。这样就可以在参数数量和参数类型都不确定的情况下使用模板。一个典型应用是通过class或者framework向模板传递一组数量和类型都不确定的参数。另一个应用是提供泛型代码处理一组数量任意且类型也任意的参数。
变参模板
可以将模板参数定义成能够接受任意多个模板参数的情况。这一类模板被称为变参模板(variadic template)。
变参模板实列
比如,可以通过调用下面代码中的print()
函数来打印一组数量和类型都不确定的参数:1
2
3
4
5
6
7
8
9
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
的剩余参数,是一个函数参数包(function parameter pack):1
void print (T firstArg, Types... args)
这里使用了通过模板参数包(template parameter pack)定义的类型“Types” :1
template<typename T, typename... Types>
为了结束递归,重载了不接受参数的非模板函数print()
,它会在参数包为空的时候被调用。比如,这样一个调用:1
2std::string s("world");
print (7.5, "hello", s);
输出如下结果:1
2
37.5
hello
World
因为这个调用首先会被扩展成:1
print<double, char const*, std::string> (7.5, "hello", s);
其中:
- firstArg的值是7.5,其类型T是double。
- args是一个可变模板参数,它包含类型是char const*的“hello”和类型是std::string的“world”
在打印了firstArg对应的7.5之后,继续调用print()
打印剩余的参数,这时print()
被扩展为:1
print<char const*, std::string> ("hello", s);
其中:
- firstArg的值是“hello”,其类型T是
char const *
。 - args是一个可变模板参数,它包含的参数类型是
std::string
。
在打印了firstArg对应的“hello”之后,继续调用print()
打印剩余的参数,这时print()
被扩展为:1
print<std::string> (s);
其中:
- firstArg的值是“world”,其类型T是std::string。
- args是一个空的可变模板参数,它没有任何值。
这样在打印了firstArg对应的“ world”之后,就会调用被重载的不接受参数的非模板函数print()
,从而结束了递归。
变参和非变参模板的重载
上面的例子也可以这样实现:1
2
3
4
5
6
7
8
9
10
11
12
13
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 remainingarguments
}
也就是说,当两个函数模板的区别只在于尾部的参数包的时候,会优先选择没有尾部参数包的那一个函数模板。
sizeof…运算符
C++11为变参模板引入了一种新的sizeof运算符:sizeof...
。它会被扩展成参数包中所包含的参数数目。因此:1
2
3
4
5
6
7template<typename T, typename... Types>
void print (T firstArg, Types... args)
{
std::cout << firstArg << "\n"; //print first argument
std::cout << sizeof...(Types) << "\n"; //print number of remaining types
std::cout << sizeof...(args) << "\n"; //print number of remaining args
}
在将第一个参数打印之后,会将参数包中剩余的参数数目打印两次。如你所见,运算符sizeof...
既可以用于模板参数包,也可以用于函数参数包。这样可能会让你觉得,可以不使用为了结束递归而重载的不接受参数的非模板函数print()
,只要在没有参数的时候不去调用任何函数就可以了:1
2
3
4
5
6
7
8template<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语句的两个分支都会被实例化。是否使用被实例化出来的代码是在运行期间(run-time)决定的,而是否实例化代码是在编译期间(compile-time)决定的。因此如果在只有一个参数的时候调用print()
函数模板,虽然args...
为空,if语句中的print(args...)
也依然会被实例化,但此时没有定义不接受参数的print()
函数,因此会报错。
不过从C++17开始,可以使用编译阶段的if语句,这样通过一些稍微不同的语法,就可以实现前面想要的功能。8.5节会对这一部分内容进行讨论。
折叠表达式
从C++17开始,提供了一种可以用来计算参数包(可以有初始值)中所有参数运算结果的二元运算符。比如,下面的函数会返回s中所有参数的和:1
2
3
4template<typename... T>
auto foldSum (T... s) {
return (... + s); // ((s1 + s2) + s3) ...
}
如果参数包是空的,这个表达式将是不合规范的(不过此时对于运算符&&
,结果会是true,对运算符||
,结果会是false,对于逗号运算符,结果会是void()
)。
表4.1列举了可能的折叠表达式:
|Fold Expression |Evaluation|
|—-|—-|
|( … 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 )))|
几乎所有的二元运算符都可以用于折叠表达式。比如可以使用折叠表达式和运算符->*
遍历一条二叉树的路径:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// 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 expression:
template<typename T, typename... TP>
Node* traverse (T np, TP... paths) {
return (np ->* ... ->* paths); // np ->* paths1 ->* paths2 ...
}
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);
...
}
这里1
(np ->* ... ->* paths)
使用了折叠表达式从np开始遍历了paths中所有可变成员。通过这样一个使用了初始化器的折叠表达式,似乎可以简化打印变参模板参数的过程,像上面那样:1
2
3
4
5template<typename... Types>
void print (Types const&... args)
{
(std::cout << ... << args) << "\n";
}
不过这样在参数包各元素之间并不会打印空格。为了打印空格,还需要下面这样一个类模板,它可以在所有要打印的参数后面追加一个空格:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16template<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... Args>
void print (Args... args) {
( std::cout << ... << AddSpace<Args>(args) ) << "\n";
}
注意在表达式AddSpace(args)
中使用了类模板的参数推导(见2.9节),相当于使用了AddSpace<Args>(args)
,它会给传进来的每一个参数创建一个引用了该参数的AddSpace
对象,当将这个对象用于输出的时候,会在其后面加一个空格。
变参模板的使用
一个重要的作用是转发任意类型和数量的参数。比如在如下情况下会使用这一特性:
- 向一个由智能指针管理的,在堆中创建的对象的构造函数传递参数:
1 | // create shared pointer to complex<float> initialized by 4.2 and 7.7: |
- 向一个由库启动的thread传递参数:
1 | std::thread t (foo, 42, "hello"); //call foo(42,"hello") in a separate thread |
- 向一个被push进vector中的对象的构造函数传递参数:
1 | std::vector<Customer> v; |
通常是使用移动语义对参数进行完美转发(perfectly forwarded),它们像下面这样进行声明:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18namespace std {
template<typename T, typename... Args>
shared_ptr<T> make_shared(Args&&... args);
class thread {
public:
template<typename F, typename... Args>
explicit thread(F&& f, Args&&... args);
...
};
template<typename T, typename Allocator = allocator<T>>
class vector {
public:
template<typename... Args>
reference emplace_back(Args&&... args);
...
};
}
注意,之前关于常规模板参数的规则同样适用于变参模板参数。比如,如果参数是按值传递的,那么其参数会被拷贝,类型也会退化(decay)。如果是按引用传递的,那么参数会是实参的引用,并且类型不会退化:1
2
3
4// 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);
变参类模板和变参表达式
除了上面提到的例子,参数包还可以出现在其它一些地方,比如表达式,类模板,using声明,甚至是推断指引中。
变参表达式
除了转发所有参数之外,还可以做些别的事情。比如计算它们的值。下面的例子先是将参数包中的所有的参数都翻倍,然后将结果传给print()
:1
2
3
4
5template<typename... T>
void printDoubled (T const&... args)
{
print (args + args...);
}
如果这样调用它:1
printDoubled(7.5, std::string("hello"), std::complex<float>(4,2));
效果上和下面的调用相同(除了构造函数方面的不同):1
2print(7.5 + 7.5, std::string("hello") + std::string("hello"),
std::complex<float>(4,2) + std::complex<float>(4,2));
如果只是想向每个参数加1,省略号…中的点不能紧跟在数值后面:1
2
3
4
5
6
7template<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
}
编译阶段的表达式同样可以像上面那样包含模板参数包。比如下面这个例子可以用来判断所有参数包中参数的类型是否相同:1
2
3
4
5template<typename T1, typename... TN>
constexpr bool isHomogeneous (T1, TN...)
{
return (std::is_same<T1,TN>::value && ...); // since C++17
}
这是折叠表达式的一种应用。对于:1
isHomogeneous(43, -1, "hello")
会被扩展成:1
std::is_same<int,int>::value && std::is_same<int,char const*>::value
结果自然是false。而对:1
isHomogeneous("hello", "", "world", "!")
结果则是true,因为所有的参数类型都被推断为char const *
(这里因为是按值传递,所以发生了类型退还,否则类型将依次被推断为:char const[6]
, char const[1]
, char const[6]
和char const[2]
)。
变参下标(Variadic Indices)
作为另外一个例子,下面的函数通过一组变参下标来访问第一个参数中相应的元素:1
2
3
4
5template<typename C, typename... Idx>
void printElems (C const& coll, Idx... idx)
{
print (coll[idx]...);
}
当调用:1
2std::vector<std::string> coll = {"good", "times", "say", "bye"};
printElems(coll,2,0,3);
时,相当于调用了:1
print (coll[2], coll[0], coll[3]);
也可以将非类型模板参数声明成参数包。比如对:1
2
3
4
5template<std::size_t... Idx, typename C>
void printIdx (C const& coll)
{
print(coll[Idx]...);
}
可以这样调用:1
2std::vector<std::string> coll = {"good", "times", "say", "bye"};
printIdx<2,0,3>(coll);
效果上和前面的例子相同。
变参类模板
类模板也可以是变参的。一个重要的例子是,通过任意多个模板参数指定了class相应数据成员的类型:1
2template<typename... Elements>class Tuple;
Tuple<int, std::string, char> t; // t can hold integer, string, and character
另一个例子是指定对象可能包含的类型:1
2
3template<typename... Types>
class Variant;
Variant<int, std::string, char> v; // v can hold integer, string, or character
也可以将class定义成代表了一组下表的类型:1
2
3
4// type for arbitrary number of indices:
template<std::size_t...>
struct Indices {
};
可以用它定义一个通过print()
打印std::array
或者std::tuple
中元素的函数,具体打印哪些元素由编译阶段的get<>
从给定的下标中获取:1
2
3
4
5template<typename T, std::size_t... Idx>
void printByIdx(T t, Indices<Idx...>)
{
print(std::get<Idx>(t)...);
}
可以像下面这样使用这个模板:1
2std::array<std::string, 5> arr = {"Hello", "my", "new", "!", "World"};
printByIdx(arr, Indices<0, 4, 3>());
或者像下面这样:1
2auto t = std::make_tuple(12, "monkeys", 2.0);
printByIdx(t, Indices<0, 1, 2>());
变参推断指引
推断指引也可以是变参的。比如在C++标准库中,为std::array
定义了如下推断指引:1
2
3
4namespace std {
template<typename T, typename... U> array(T, U...)
-> array<enable_if_t<(is_same_v<T, U> && ...), T>, (1 + sizeof...(U))>;
}
针对这样的初始化:1
std::array a{42,45,77};
会将指引中的T推断为array(首)元素的类型,而U...
会被推断为剩余元素的类型。因此array
中元素总数目是1 + sizeof...(U)
,等效于如下声明:1
std::array<int, 3> a{42,45,77};
其中对array
第一个参的操作std::enable_if<>
是一个折叠表达式,可以展开成这样:1
is_same_v<T, U1> && is_same_v<T, U2> && is_same_v<T, U3> ...
如果结果是false(也就是说array中元素不是同一种类型),推断指引会被弃用,总的类型推断失败。这样标准库就可以确保在推断指引成功的情况下,所有元素都是同一种类型。
变参基类及其使用
最后,考虑如下例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 customers in one type:
using CustomerOP = Overloader<CustomerHash,CustomerEq>;
std::unordered_set<Customer,CustomerHash,CustomerEq> coll1;
std::unordered_set<Customer,CustomerOP,CustomerOP> coll2;
...
}
这里首先定义了一个Customer
类和一些用来比较Customer
对象以及计算这些对象hash值的函数对象。通过1
2
3
4
5template<typename... Bases>
struct Overloader : Bases...
{
using Bases::operator()...; // OK since C++17
};
从个数不定的基类派生出了一个新的类,并且从其每个基类中引入了operator()
的声明。比如通过:1
using CustomerOP = Overloader<CustomerHash,CustomerEq>;
从CustomerHash
和CustomerEq
派生出了CustomerOP
,而且派生类中会包含两个基类中的operator()
的实现。
总结
- 通过使用参数包,模板可以有任意多个任意类型的参数。
- 为了处理这些参数,需要使用递归,而且需要一个非变参函数终结递归(如果使用编译期判断,则不需要非变参函数来终结递归)。
- 运算符sizeof…用来计算参数包中模板参数的数目。
- 变参模板的一个典型应用是用来发送(forward)任意多个任意类型的模板参数。
- 通过使用折叠表达式,可以将某种运算应用于参数包中的所有参数。
基础技巧
本章将涉及一些和模板实际使用有关的晋级知识,包含:关键字typename的使用,定义为模板的成员函数以及嵌套类,模板参数模板(template template parameters),零初始化以及其它一些关于使用字符串常量作为模板参数的细节讨论。
typename关键字
关键字typename在C++标准化过程中被引入进来,用来澄清模板内部的一个标识符代表的是某种类型,而不是数据成员。考虑下面这个例子:1
2
3
4
5
6
7template<typename T>
class MyClass {
public:
void foo() {
typename T::SubType* ptr;
}
};
其中第二个typename被用来澄清SubType是定义在class T
中的一个类型。因此在这里ptr
是一个指向T::SubType
类型的指针。
如果没有typename的话,SubType会被假设成一个非类型成员(比如static成员或者一个枚举常量,亦或者是内部嵌套类或者using声明的public别名)。这样的话,表达式T::SubType* ptr
会被理解成class T
的static
成员SubType
与ptr
的乘法运算,这不是一个错误,因为对MyClass<>
的某些实例化版本而言,这可能是有效的代码。
通常而言,当一个依赖于模板参数的名称代表的是某种类型的时候,就必须使用typename
。使用typename
的一种场景是用来声明泛型代码中标准容器的迭代器:1
2
3
4
5
6
7
8
9
10
11
12
// 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的标准容器。为了遍历容器中的所有元素,使用了声明于每个标准容器中的迭代器类型:1
2
3
4
5
6class stlcontainer {
public:
using iterator = ...; // iterator for read/write access
using const_iterator = ...; // iterator for read access
...
};
因此为了使用模板类型T的cons_iterator
,必须在其前面使用typename
:1
typename T::const_iterator pos;
零初始化
对于基础类型,比如int,double以及指针类型,由于它们没有默认构造函数,因此它们不会被默认初始化成一个有意义的值。比如任何未被初始化的局部变量的值都是未定义的:1
2
3
4
5void foo()
{
int x; // x has undefined value
int* ptr; // ptr points to anywhere (instead of nowhere)
}
因此在定义模板时,如果想让一个模板类型的变量被初始化成一个默认值,那么只是简单的定义是不够的,因为对内置类型,它们不会被初始化:1
2
3
4
5template<typename T>
void foo()
{
T x; // x has undefined value if T is built-in type
}
出于这个原因,对于内置类型,最好显式的调用其默认构造函数来将它们初始化成0(对于bool类型,初始化为false,对于指针类型,初始化成nullptr)。通过下面你的写法就可以保证即使是内置类型也可以得到适当的初始化:1
2
3
4
5template<typename T>
void foo()
{
T x{}; // x is zero (or false) if T is a built-in type
}
这种初始化的方法被称为“值初始化(value initialization)”,它要么调用一个对象已有的构造函数,要么就用零来初始化这个对象。即使它有显式的构造函数也是这样。
在C++11之前,确保一个对象得到显示初始化的方式是:1
T x = T(); // x is zero (or false) if T is a built-in type
在C++17之前,只有在与拷贝初始化对应的构造函数没有被声明为explicit的时候,这一方式才有效(目前也依然被支持)。从C++17开始,由于强制拷贝省略(mandatory copy elision)的使用,这一限制被解除,因此在C++17之后以上两种方式都有效。不过对于用花括号初始化的情况,如果没有可用的默认构造函数,它还可以使用列表初始化构造函数(initializer-list constructor)。
为确保类模板中类型被参数化了的成员得到适当的初始化,可以定义一个默认的构造函数并在其中对相应成员做初始化:1
2
3
4
5
6
7
8
9
10template<typename T>
class MyClass {
private:
T x;
public:
MyClass() : x{} { // ensures that x is initialized even for
built-in types
}
...
};
C++11之前的语法:1
2MyClass() : x() { //ensures that x is initialized even forbuilt-in types
}
也依然有效。从C++11开始也可以通过如下方式对非静态成员进行默认初始化:1
2
3
4
5
6template<typename T>
class MyClass {
private:
T x{}; // zero-initialize x unless otherwise specified
...
};
但是不可以对默认参数使用这一方式,比如:1
2
3
4template<typename T>
void foo(T p{}) { //ERROR
...
}
对这种情况必须像下面这样初始化:1
2
3
4template<typename T>
void foo(T p = T{}) { //OK (must use T() before C++11)
...
}
使用this->
对于类模板,如果它的基类也是依赖于模板参数的,那么对它而言即使x是继承而来的,使用this->x
和x
也不一定是等效的。比如:1
2
3
4
5
6
7
8
9
10
11
12template<typename T>
class Base {
public:
void bar();
};
template<typename T>
class Derived : Base<T> {
public:
void foo() {
bar(); // calls external bar() or error
}
};
Derived
中的bar()
永远不会被解析成Base
中的bar()
。因此这样做要么会遇到错误,要么就是调用了其它地方的bar()
(比如可能是定义在其它地方的global的bar()
)。
作为经验法则,建议当使用定义于基类中的、依赖于模板参数的成员时,用this->
或者Base<T>::
来修饰它。
使用裸数组或者字符串常量的模板
当向模板传递裸数组或者字符串常量时,需要格外注意以下内容:
第一,如果参数是按引用传递的,那么参数类型不会退化(decay)。也就是说当传递”hello”作为参数时,模板类型会被推断为char const[6]
。这样当向模板传递长度不同的裸数组或者字符串常量时就可能遇到问题,因为它们对应的模板类型不一样。只有当按值传递参数时,模板类型才会退化(decay),这样字符串常量会被推断为char const *
。
不过也可以像下面这样定义专门用来处理裸数组或者字符串常量的模板:1
2
3
4
5
6
7
8
9
10
11
12template<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 false;
}
return N < M;
}
当像下面这样使用该模板的时候:1
2
3int x[] = {1, 2, 3};
int y[] = {1, 2, 3, 4, 5};
std::cout << less(x,y) << "\n";
less<>
中的T会被实例化成int,N被实例化成3,M被实例化成5。也可以将该模板用于字符串常量:1
std::cout << less("ab","abc") << "\n";
这里less<>
中的T会被实例化成char const
,N被实例化成3,M被实例化成4。如果想定义一个只是用来处理字符串常量的函数模板,可以像下面这样:1
2
3
4
5
6
7
8
9template<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 false;
}
return N < M;
}
请注意你可以某些情况下可能也必须去为边界未知的数组做重载或者部分特化。下面的代码展示了对数组所做的所有可能的重载:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
template<typename T>
struct MyClass; //主模板
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<>
做了特化:边界已知和未知的数组,边界已知和未知的数组的引用,以及指针。它们之间互不相同,在各种情况下的调用关系如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename T1, typename T2, typename T3>
void foo(int a1[7], int a2[], // pointers 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*> a1, a2退化成指针
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
注意,根据语言规则,如果调用参数被声明为数组的话,那么它的真实类型是指针类型。而
且针对未知边界数组定义的模板,可以用于不完整类型,比如:1
extern int i[];
当这一数组被按照引用传递时,它的类型是int(&)[]
,同样可以用于模板参数。
成员模板
类的成员也可以是模板,对嵌套类和成员函数都是这样。这一功能的作用和优点同样可以通过Stack<>
类模板得到展现。通常只有当两个stack类型相同的时候才可以相互赋值(stack的类型相同说明它们的元素类型也相同)。即使两个stack的元素类型之间可以隐式转换,也不能相互赋值:1
2
3
4
5Stack<int> intStack1, intStack2; // stacks for ints
Stack<float> floatStack; // stack for floats
...
intStack1 = intStack2; // OK: stacks have same type
floatStack = intStack1; // ERROR: stacks have different types
默认的赋值运算符要求等号两边的对象类型必须相同,因此如果两个stack之间的元素类型不同的话,这一条件将得不到满足。但是,只要将赋值运算符定义成模板,就可以将两个元素类型可以做转换的stack相互赋值。新的Stack<>
定义如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15template<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&);
};
以上代码中有如下两点改动:
- 赋值运算符的参数是一个元素类型为T2的stack。
- 新的模板使用
std::deque<>
作为内部容器。这是为了方便新的赋值运算符的定义。新的赋值运算符被定义成下面这样:
1 | template<typename T> |
下面先来看一下成员模板的定义语法。在模板类型为T的模板内部,定义了一个模板类型为T2
的内部模板:1
2
3template<typename T>
template<typename T2>
...
在模板函数内部,你可能希望简化op2中相关元素的访问。但是由于op2属于另一种类型,因此最好使用它们的公共接口。这样访问元素的唯一方法就是通过调用top()
。这就要求op2中所有元素相继出现在栈顶,为了不去改动op2,就需要做一次op2的拷贝。由于top()
返回的是最后一个被添加进stack的元素,因此需要选用一个支持在另一端插入元素的容器,这就是为什么选用std::deque<>
的原因,因为它的push_front()
方法可以将元素添加到另一端。
为了访问op2的私有成员,可以将其它所有类型的stack模板的实例都定义成友元:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<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;
};
如你所见,由于模板参数的名字不会被用到,因此可以被省略掉:1
template<typename> friend class Stack;
这样就就可以将赋值运算符定义成如下形式:1
2
3
4
5
6
7
8
9
10template<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的stack赋值给存储float的stack:1
2
3
4
5Stack<int> intStack; // stack for ints
Stack<float> floatStack; // stack for floats
...
floatStack = intStack; // OK: stacks have different types,
// but int converts to float
当然,这样的赋值就不会改变floatStack
的类型,也不会改变它的元素的类型。在赋值之后,floatStack
存储的元素依然是float类型,top()
返回的值也依然是float类型。看上去这个赋值运算符模板不会进行类型检查,这样就可以在存储任意类型的两个stack之间相互赋值,但是事实不是这样。必要的类型检查会在将源stack中的元素插入到目标stack中的时候进行:1
elems.push_front(tmp.top());
比如如果将存储string的stack赋值给存储int的stack,那么在编译这一行代码的时候会遇到如下错误信息:不能将通过tmp.top()
返回的string
用作elems.push_front()
的参数1
2
3
4Stack<std::string> stringStack; // stack of strings
Stack<float> floatStack; // stack of floats
...
floatStack = stringStack; // ERROR: std::string doesn"t convert to float
同样也可以将内部的容器类型参数化:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<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 elements 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;
};
此时赋值运算符的实现会像下面这样:1
2
3
4
5
6
7
8
9
10template<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之间赋值的话,甚至可以使用vector(没有push_front
方法)作为内部容器:1
2
3
4
5// 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";
由于没有用到赋值运算符模板,程序运行良好,不会报错说vector
没有push_front()
方法。
成员模板的特例化
成员函数模板也可以被全部或者部分地特例化。比如对下面这个例子:1
2
3
4
5
6
7
8
9
10
11class BoolString {
private:
std::string value;
public:
BoolString (std::string const& s)
: value(s) {}
template<typename T = std::string>
T get() const { // get value (converted to T)
return value;
}
};
可以像下面这样对其成员函数模板get()
进行全特例化:1
2
3
4
5// full specialization for BoolString::getValue<>() for bool
template<>
inline bool BoolString::get<bool>() const {
return value == "true" || value == "1" || value == "on";
}
注意我们不需要也不能够对特例化的版本进行声明;只能定义它们。由于这是一个定义于头文件中的全实例化版本,如果有多个编译单include了这个头文件,为避免重复定义的错误,必须将它定义成inline的。
可以像下面这样使用这个class以及它的全特例化版本:1
2
3
4
5
6std::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
特殊成员函数的模板
如果能够通过特殊成员函数copy或者move对象,那么相应的特殊成员函数(copy构造函数以及move构造函数)也将可以被模板化。和前面定义的赋值运算符类似,构造函数也可以是模板。但是需要注意的是,构造函数模板或者赋值运算符模板不会取代预定义的构造函数和赋值运算符。成员函数模板不会被算作用来copy或者move对象的特殊成员函数。在上面的例子中,如果在相同类型的stack之间相互赋值,调用的依然是默认赋值运算符。这种行为既有好处也有坏处:
- 某些情况下,对于某些调用,构造函数模板或者赋值运算符模板可能比预定义的copy/move构造函数或者赋值运算符更匹配,虽然这些特殊成员函数模板可能原本只打算用于在不同类型的stack之间做初始化。
- 想要对copy/move构造函数进行模板化并不是一件容易的事情,比如该如何限制其存在的场景。
.template的使用
某些情况下,在调用成员模板的时候需要显式地指定其模板参数的类型。这时候就需要使用关键字template来确保符号<会被理解为模板参数列表的开始,而不是一个比较运算符。考虑下面这个使用了标准库中的bitset的例子:1
2
3
4
5
6template<unsigned long N>
void printBitset (std::bitset<N> const& bs) {
std::cout << bs.template to_string<char,
std::char_traits<char>,
std::allocator<char>>();
}
对于bitset类型的bs,调用了其成员函数模板to_string()
,并且指定了to_string()
模板的所有模板参数。如果没有.template
的话,编译器会将to_string()
后面的<
符号理解成小于运算符,而不是模板的参数列表的开始。这一这种情况只有在点号前面的对象依赖于模板参数的时候才会发生。在我们的例子中,bs依赖于模板参数N。
.template
标识符(标识符->template
和::template
也类似)只能被用于模板内部,并且它前面的对象应该依赖于模板参数。
泛型lambdas和成员模板
在C++14中引入的泛型lambdas,是一种成员模板的简化。对于一个简单的计算两个任意类型参数之和的lambda:1
2
3[] (auto x, auto y) {
return x + y;
}
编译器会默认为它构造下面这样一个类:1
2
3
4
5
6
7
8class SomeCompilerSpecificName {
public:
SomeCompilerSpecificName(); // constructor only callable by compiler
template<typename T1, typename T2>
auto operator() (T1 x, T2 y) const {
return x + y;
}
};
变量模板
从C++14开始,变量也可以被某种类型参数化。称为变量模板。例如可以通过下面的代码定义pi,但是参数化了其类型:1
2template<typename T>
constexpr T pi{3.1415926535897932385};
注意,和其它几种模板类似,这个定义最好不要出现在函数内部或者块作用域内部。
在使用变量模板的时候,必须指明它的类型。比如下面的代码在定义pi<>
的作用域内使用了两个不同的变量:1
2std::cout << pi<double> << "\n";
std::cout << pi<float> << "\n";
变量模板也可以用于不同编译单元:1
2
3
4
5
6
7
8
9
10
11
12
13
14template<typename T> T val{}; // zero initialized value
//== translation unit 1:
int main()
{
val<long> = 42;
print();
}
//== translation unit 2:
void print()
{
std::cout << val<long> << "\n"; // OK: prints 42
}
也可有默认模板类型:1
2template<typename T = long double>
constexpr T pi = T{3.1415926535897932385};
可以像下面这样使用默认类型或者其它类型:1
2std::cout << pi<> << "\n"; //outputs a long double
std::cout << pi<float> << "\n"; //outputs a float
只是无论怎样都要使用尖括号<>
,不可以只用pi:1
std::cout << pi << "\n"; //ERROR
同样可以用非类型参数对变量模板进行参数化,也可以将非类型参数用于参数器的初始化。比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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的时候,使用的都是同一个全局作用域里的1
std::array<int, 10> arr。
用于数据成员的变量模板
变量模板的一种应用场景是,用于定义代表类模板成员的变量模板。比如如果像下面这样定义一个类模板:1
2
3
4
5template<typename T>
class MyClass {
public:
static constexpr int max = 1000;
};
那么就可以为MyClass<>
的不同特例化版本定义不同的值:1
2template<typename T>
int myMax = MyClass<T>::max;
应用工程师就可以使用下面这样的代码:1
auto i = myMax<std::string>;
而不是:1
auto i = MyClass<std::string>::max;
这意味着对于一个标准库的类:1
2
3
4
5
6
7namespace std {
template<typename T>
class numeric_limits {
public:
static constexpr bool is_signed = false;
};
}
可以定义:1
2template<typename T>
constexpr bool isSigned = std::numeric_limits<T>::is_signed;
这样就可以用:1
isSigned<char>
代替:1
std::numeric_limits<char>::is_signed
类型萃取Suffix_v
从C++17开始,标准库用变量模板为其用来产生一个值(布尔型)的类型萃取定义了简化方式。比如为了能够使用:1
std::is_const_v<T> // since C++17
而不是:1
std::is_const<T>::value //since C++11
标准库做了如下定义:1
2
3
4namespace std {
template<typename T>
constexpr bool is_const_v = is_const<T>::value;
}
模板参数模板
如果允许模板参数也是一个类模板的话,会有不少好处。在这里依然使用Stack类模板作为例子。对5.5节中的stack模板,如果不想使用默认的内部容器类型std::deque
,那么就需要两次指定stack元素的类型。也就是说为了指定内部容器的类型,必须同时指出容器的类型和元素
的类型:1
Stack<int, std::vector<int>> vStack; // integer stack that uses a vector
使用模板参数模板,在声明Stack类模板的时候就可以只指定容器的类型而不去指定容器中元素的类型:1
Stack<int, std::vector> vStack; // integer stack that uses a vector
为此就需要在Stack的定义中将第二个模板参数声明为模板参数模板。可能像下面这样:1
2
3
4
5
6
7
8
9
10
11
12
13template<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();
}
...
};
区别在于第二个模板参数被定义为一个类模板:1
template<typename Elem> class Cont
默认类型也从std::deque<T>
变成std::deque
。这个参数必须是一个类模板,它将被第一个模板参数实例化:Cont<T> elems;
。
用第一个模板参数实例化第二个模板参数的情况是由Stack自身的情况决定的。实际上,可以在类模板内部用任意类型实例化一个模板参数模板。和往常一样,声明模板参数时可以使用class代替typename。在C++11之前,Cont只能被某个类模板的名字取代。1
2
3
4template<typename T, template<class Elem> class Cont = std::deque>
class Stack { //OK
...
};
从C++11开始,也可以用别名模板(alias template)取代Cont,但是直到C++17,在声明模板参数模板时才可以用typename代替class:1
2
3
4template<typename T, template<typename Elem> typename Cont = std::deque>
class Stack { //ERROR before C++17
...
};
这两个变化的目的都一样:用class代替typename不会妨碍我们使用别名模板(alias template)作为和Cont对应的模板参数。由于模板参数模板中的模板参数没有被用到,作为惯例可以省略它:1
2
3
4template<typename T, template<typename> class Cont = std::deque>
class Stack {
...
};
成员函数也要做相应的更改。必须将第二个模板参数指定为模板参数模板。比如对于push()
成员,其实现如下:1
2
3
4
5template<typename T, template<typename> class Cont>
void Stack<T,Cont>::push (T const& elem)
{
elems.push_back(elem); // append copy of passed elem
}
注意,虽然模板参数模板是类或者别名类(alias templates)的占位符,但是并没有与其对应的函数模板或者变量模板的占位符。
模板参数模板的参数匹配
如果你尝试使用新版本的Stack,可能会遇到错误说默认的std::deque
和模板参数模板Cont
不匹配。这是因为在C++17之前,template<typename Elem> typename Cont = std::deque
中的模板参数必须和实际参数(std::deque
)的模板参数匹配。而且实际参数(std::deque
有两个参数,第二个是默认参数allocator
)的默认参数也要被匹配,这样template<typename Elem> typename Cont = std::dequ
就不满足以上要求。
作为变通,可以将类模板定义成下面这样:1
2
3
4
5
6
7template<typename T, template<typename Elem,
typename Alloc = std::allocator<Elem>> class Cont = std::deque>
class Stack {
private:
Cont<T> elems; // elements
...
};
其中的Alloc同样可以被省略掉。因此最终的Stack模板会像下面这样(包含了赋值运算符模板):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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 elements of type T2
template<typename T2, template<typename Elem2,
typename = std::allocator<Elem2> >class Cont2>
Stack<T,Cont>& operator= (Stack<T2,Cont2> const&);
// to get access to private members 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 element
}
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中的元素,将其它所有类型的Stack声明为friend(省略模板参数的名称):1
2template<typename, template<typename, typename>class>
friend class Stack;
同样,不是所有的标准库容器都可以用做Cont参数。比如std::array就不行,因为它有一个非类型的代表数组长度的模板参数,在上面的模板中没有与之对应的模板参数。下面的例子用到了最终版Stack模板的各种特性:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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> vStack;
vStack.push(5.5);
vStack.push(6.6);
std::cout << "vStack.top(): " << vStack.top() << "\n";
vStack = fStack;
std::cout << "vStack: ";
while (! vStack.empty()) {
std::cout << vStack.top() << "";
vStack.pop();
}
std::cout << "\n";
}
程序输出如下:1
2
3
4
5iStack.top(): 2
fStack.top(): 3.3
fStack.top(): 4.4
vStack.top(): 6.6
vStack: 4.4 2 1
总结
- 为了使用依赖于模板参数的类型名称,需要用typename修饰该名称。
- 为了访问依赖于模板参数的父类中的成员,需要用this->或者类名修饰该成员。
- 嵌套类或者成员函数也可以是模板。一种应用场景是实现可以进行内部类型转换的泛型代码。
- 模板化的构造函数或者赋值运算符不会取代预定义的构造函数和赋值运算符。
- 使用花括号初始化或者显式地调用默认构造函数,可以保证变量或者成员模板即使被内置类型实例化,也可以被初始化成默认值。
- 可以为裸数组提供专门的特化模板,它也可以被用于字符串常量。
- 只有在裸数组和字符串常量不是被按引用传递的时候,参数类型推断才会退化。(裸数组退化成指针)
- 可以定义变量模板(从C++14开始)。
- 模板参数也可以是类模板,称为模板参数模板(template template parameters)。
- 模板参数模板的参数类型必须得到严格匹配。
移动语义和enable_if<>
移动语义(move semantics)是C++11引入的一个重要特性。在copy或者赋值的时候,可以通过它将源对象中的内部资源move(“steal” )到目标对象,而不是copy这些内容。当然这样做的前提是源对象不在需要这些内部资源或者状态(因为源对象将会被丢弃)。
完美转发(Perfect Forwarding)
假设希望实现的泛型代码可以将被传递参数的基本特性转发出去:
- 可变对象被转发之后依然可变。
- Const对象被转发之后依然是const的。
- 可移动对象(可以从中窃取资源的对象)被转发之后依然是可移动的。
不使用模板的话,为达到这一目的就需要对以上三种情况分别编程。比如为了将调用f()
时传递的参数转发给函数g()
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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() forward argument val to g():
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 ::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()
:1
2
3
4
5
6
7
8
9void 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&&)
}
注意其中针对可移动对象(一个右值引用)的代码不同于其它两组代码;它需要用std::move()
来处理其参数,因为参数的移动语义不会被一起传递。虽然第三个f()
中的val
被声明成右值引用,但是当其在f()
内部被使用时,它依然是一个非常量左值,其行为也将和第一个f()
中的情况一样。因此如果不使用std::move()
的话,在第三个f()
中调用的将是g(X&)
而不是g(X&&)
。如果试图在泛型代码中统一以上三种情况,会遇到这样一个问题:1
2
3
4template<typename T>
void f (T val) {
g(val);
}
这个模板只对前两种情况有效,对第三种用于可移动对象的情况无效。
基于这一原因,C++11引入了特殊的规则对参数进行完美转发(perfect forwarding)。实现这一目的的惯用方法如下:1
2
3
4template<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()
传递的参数)。它的值总是可变的,而且总是可以被“窃取”。 - 模板参数
T
的T&&
声明了一个转发引用(亦称万能引用)。可以被绑定到可变、不可变(比如const)或者可移动对象上。在函数内部这个参数也可以是可变、不可变或者指向一个可以被窃取内部数据的值。
注意T必须是模板参数的名字。只是依赖于模板参数是不可以的。对于模板参数T,形如typename T::iterator&&
的声明只是声明了一个右值引用,不是一个转发引用。因此,一个可以完美转发其参数的程序会像下面这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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&&)
}
完美转发同样可以被用于变参模板。
特殊成员函数模板
特殊成员函数也可以是模板,比如构造函数,但是有时候这可能会带来令人意外的结果。考虑下面这个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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 = "sname";
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
}
//copying string-CONSTR for sname
//moving string-CONSTR for tmp
//COPY-CONSTR Persosname
//MOVE-CONSTR Person sname
例子中Person
类有一个string
类型的name
成员和几个初始化构造函数。为了支持移动语义,重载了接受std::string
作为参数的构造函数:
- 一个以
std::string
对象为参数,并用其副本来初始化name成员:
1 | Person(std::string const& n) : name(n) { |
- 一个以可移动的
std::string
对象作为参数,并通过std:move()
从中窃取值来初始化name
:
1 | Person(std::string&& n) : name(std::move(n)) { |
和预期的一样,当传递一个正在使用的值(左值)作为参数时,会调用第一个构造函数,而以可移动对象(右值)为参数时,则会调用第二个构造函数:1
2
3std::string s = "sname";
Person p1(s); // init with string object => calls copying string-CONSTR
Person p2("tmp"); // init with string literal => calls moving string-CONSTR
除了这两个构造函数,例子中还提供了一个拷贝构造函数和一个移动构造函数,从中可以看出Person对象是如何被拷贝和移动的:1
2Person p3(p1); // copy Person => calls COPY-CONSTR
Person p4(std::move(p1)); // move Person => calls MOVE-CONSTR
现在将上面两个以std::string
作为参数的构造函数替换为一个泛型的构造函数,它将传入的参数完美转发(perfect forward)给成员name:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Person
{
private:
std::string name;
public:
// generic constructor for passed initial name:
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
的话,依然能够正常工作:1
2
3std::string s = "sname";
Person p1(s); // init with string object => calls TMPL-CONSTR
Person p2("tmp"); //init with string literal => calls TMPL-CONS
注意这里在构建p2的时候并不会创建一个临时的std::string
对象:STR
的类型被推断为char const[4]
。但是将std::forward<STR>
用于指针参数没有太大意义。成员name
将会被一个以null
结尾的字符串构造。但是,当试图调用拷贝构造函数的时候,会遇到错误:1
Person p3(p1); // ERROR
而用一个可移动对象初始化Person
的话却可以正常工作:1
Person p4(std::move(p1)); // OK: move Person => calls MOVECONST
如果试图拷贝一个Person
的const
对象的话,也没有问题:1
2Person const p2c("ctmp"); //init constant object with string literal
Person p3c(p2c); // OK: copy constant Person => calls COPY-CONSTR
问题出在这里:根据C++重载解析规则,对于一个非const左值的Person p
,成员模板1
2template<typename STR>
Person(STR&& n)
通常比预定义的拷贝构造函数更匹配:1
Person (Person const& p)
这里STR
可以直接被替换成Person&
,但是对拷贝构造函数还要做一步const转换。
额外提供一个非const的拷贝构造函数看上去是个不错的方法:1
Person (Person& p)
不过这只是一个部分解决问题的方法,更好的办法依然是使用模板。我们真正想做的是当参数是一个Person对象或者一个可以转换成Person对象的表达式时,不要启用模板。这可以通过std::enable_if<>
实现。
通过std::enable_if<>禁用模板
从C++11开始,通过C++标准库提供的辅助模板std::enable_if<>
,可以在某些编译期条件下忽略掉函数模板。比如,如果函数模板foo<>
的定义如下:1
2
3template<typename T>
typename std::enable_if<(sizeof(T) > 4)>::type foo() {
}
这一模板定义会在sizeof(T) > 4
不成立的时候被忽略掉。如果sizeof<T> > 4
成立,函数模板会展开成:1
2
3template<typename T>
void foo() {
}
也就是说std::enable_if<>
是一种类型萃取(type trait),它会根据一个作为其(第一个)模板参数的编译期表达式决定其行为:
- 如果这个表达式结果为true,它的type成员会返回一个类型:
- 如果没有第二个模板参数,返回类型是void。
- 否则,返回类型是其第二个参数的类型。
- 如果表达式结果false,则其成员类型是未定义的。根据模板的一个叫做SFINAE(substitute failure is not an error,替换失败不是错误)的规则,这会导致包含
std::enable_if<>
表达式的函数模板被忽略掉。
由于从C++14开始所有的模板萃取(type traits)都返回一个类型,因此可以使用一个与之对应的别名模板std::enable_if_t<>
,这样就可以省略掉template
和::type
了。如下:1
2
3template<typename T>
std::enable_if_t<(sizeof(T) > 4)> foo() {
}
如果给std::enable_if<>
或者std::enable_if_t<>
传递第二个模板参数:1
2
3
4
5template<typename T>
std::enable_if_t<(sizeof(T) > 4), T>
foo() {
return T();
}
那么在sizeof(T) > 4
时,enable_if
会被扩展成其第二个模板参数。因此如果与T对应的模板参数被推断为MyType
,而且其size大于4,那么其等效于:1
MyType foo();
但是由于将enable_if
表达式放在声明的中间不是一个明智的做法,因此使用std::enable_if<>
的更常见的方法是使用一个额外的、有默认值的模板参数:1
2
3template<typename T, typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {
}
如果sizeof(T) > 4
,它会被展开成:1
2
3template<typename T, typename = void>
void foo() {
}
如果你认为这依然不够明智,并且希望模板的约束更加明显,那么你可以用别名模板(alias template)给它定义一个别名:1
2
3
4
5template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;
template<typename T, typename = EnableIfSizeGreater4<T>>
void foo() {
}
使用enable_if<>
我们要解决的问题是:当传递的模板参数的类型不正确的时候,禁用如下构造函数模板:1
2template<typename STR>
Person(STR&& n);
为了这一目的,需要使用另一个标准库的类型萃取,std::is_convertiable<FROM, TO>
。在C++17中,相应的构造函数模板的定义如下:1
2
3template<typename STR, typename =
std::enable_if_t<std::is_convertible_v<STR, std::string>>>
Person(STR&& n);
如果STR
可以转换成std::string
,这个定义会扩展成:1
2template<typename STR, typename = void>
Person(STR&& n);
否则这个函数模板会被忽略。
这里同样可以使用别名模板给限制条件定义一个别名:1
2
3
4
5
6template<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类如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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";
}
};
所有的调用也都会表现正常:1
2
3
4
5
6
7
8
9
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
的别名,必须使用如下定义:1
2
3template<typename T>
using EnableIfString =
std::enable_if_t<std::is_convertible<T,std::string>::value>;
而在C++11中,由于没有给产生一个类型的类型萃取定义带_t
的别名,必须使用如下定义:1
2
3
4template<typename T>
using EnableIfString
= typename std::enable_if<std::is_convertible<T,
std::string>::value >::type;
但是通过定义EnableIfString
,这些复杂的语法都被隐藏了。
除了使用要求类型之间可以隐式转换的std::is_convertible<>
之外,还可以使用std::is_constructible<>
,它要求可以用显式转换来做初始化。但是需要注意的是,它的参数顺序和std::is_convertible<>
相反:1
2
3template<typename T>
using EnableIfString =
std::enable_if_t<std::is_constructible_v<std::string, T>>;
禁用某些成员函数
注意我们不能通过使用enable_if<>
来禁用copy/move构造函数以及赋值构造函数。这是因为成员函数模板不会被算作特殊成员函数(依然会生成默认构造函数),而且在需要使用copy构造函数的地方,相应的成员函数模板会被忽略掉。因此即使像下面这样定义类模板:1
2
3
4
5
6
7class C {
public:
template<typename T>
C (T const&) {
std::cout << "tmpl copy constructor\n";
}
};
在需要copy构造函数的地方依然会使用预定义的copy构造函数:1
2C x;
C y{x}; // still uses the predefined copy constructor (not the member template)
删掉copy构造函数也不行,因为这样在需要copy构造函数的地方会报错说该函数被删除了。但是也有一个办法:可以定义一个接受const volatile的copy构造函数并将其标示为delete。这样做就不会再隐式声明一个接受const参数的copy构造函数。在此基础上,可以定义一个构造函数模板,对于non volatile的类型,它会优选被选择(相较于已删除的copy构造函数):1
2
3
4
5
6
7
8
9
10
11
12
13class 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";
}
...
};
这样即使对常规copy,也会调用模板构造函数:1
2C x;
C y{x}; // uses the member template
于是就可以给这个模板构造函数添加enable_if<>
限制。比如可以禁止对通过int类型参数实例化出来的C<>
模板实例进行copy:1
2
3
4
5
6
7
8
9
10
11
12
13
14template<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;
// if T is no integral type, provide copy constructor template with better match:
template<typename U, typename = std::enable_if_t<!std::is_integral<U>::value>>
C (C<U> const&) {
...
}
...
};
使用concept简化enable_if<>表达式
即使使用了模板别名,enable_if
的语法依然显得很蠢,因为它使用了一个变通方法:为了达到目的,使用了一个额外的模板参数,并且通过“滥用”这个参数对模板的使用做了限制。原则上我们所需要的只是一个能够对函数施加限制的语言特性,当这一限制不被满足的时候,函数会被忽略掉。
这个语言特性就是人们期盼已久的concept,可以通过其简单的语法对函数模板施加限制条件。不幸的是,虽然已经讨论了很久,但是concept依然没有被纳入C++17标准。一些编译器目前对concept提供了试验性的支持,不过其很有可能在C++17之后的标准中得到支持。通过使用concept可以写出下面这样的代码:1
2
3
4
5template<typename STR>
requires std::is_convertible_v<STR,std::string>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}
甚至可以将其中模板的使用条件定义成通用的concept:1
2template<typename T>
concept ConvertibleToString = std::is_convertible_v<T,std::string>;
然后将这个concept用作模板条件:1
2
3
4
5template<typename STR>
requires ConvertibleToString<STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}
也可以写成下面这样:1
2
3
4template<ConvertibleToString STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}
总结
- 在模板中,可以通过使用“转发引用” (亦称“万能引用”,声明方式为模板参数
T
加&&
)和std::forward<>
将模板调用参完美地数转发出去。 - 将完美转发用于成员函数模板时,在copy或者move对象的时候它们可能比预定义的特殊成员函数更匹配。
- 可以通过使用
std::enable_if<>
并在其条件为false的时候禁用模板。 - 通过使用
std::enable_if<>
,可以避免一些由于构造函数模板或者赋值构造函数模板比隐式产生的特殊构造函数更加匹配而带来的问题。 - 可以通过删除对const volatile类型参数预定义的特殊成员函数,并结合使用
std::enable_if<>
,将特殊成员函数模板化。 - 通过concept可以使用更直观的语法对函数模板施加限制。
按值传递还是按引用传递
- X const &(const左值引用):参数引用了被传递的对象,并且参数不能被更改。
- X &(非const左值引用):参数引用了被传递的对象,但是参数可以被更改。
- X &&(右值引用):参数通过移动语义引用了被传递的对象,并且参数值可以被更改或者被“窃取”。
仅仅对已知的具体类型,决定参数的方式就已经很复杂了。在参数类型未知的模板中,就更难选择合适的传递方式了。
我们曾经建议在函数模板中应该优先使用按值传递,除非遇到以下情况:
- 对象不允许被copy。
- 参数被用于返回数据。
- 参数以及其所有属性需要被模板转发到别的地方。
- 可以获得明显的性能提升。
按值传递
当按值传递参数时,原则上所有的参数都会被拷贝。因此每一个参数都会是被传递实参的一份拷贝。对于class的对象,参数会通过class的拷贝构造函数来做初始化。调用拷贝构造函数的成本可能很高。但是有多种方法可以避免按值传递的高昂成本:事实上编译器可以通过移动语义(move semantics)来优化掉对象的拷贝,这样即使是对复杂类型的拷贝,其成本也不会很高。比如下面这个简单的按值传递参数的函数模板:1
2
3
4template<typename T>
void printV (T arg) {
...
}
当将该函数模板用于int类型参数时,实例化后的代码是:1
2
3void printV (int arg) {
...
}
参数arg
变成任意实参的一份拷贝,不管实参是一个对象,一个常量还是一个函数的返回值。
如果定义一个std::string
对象并将其用于上面的函数模板:1
2std::string s = "hi";
printV(s);
模板参数T
被实例化为std::string
,实例化后的代码是:1
2
3
4void printV (std::string arg)
{
...
}
在传递字符串时,arg
变成s的一份拷贝。此时这一拷贝是通过std::string
的拷贝构造函数创建的,这可能会是一个成本很高的操作,因为这个拷贝操作会对源对象做一次深拷贝,它需要开辟足够的内存来存储字符串的值。
但是并不是所有的情况都会调用拷贝构造函数。考虑如下情况:1
2
3
4
5
6std::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
在第一次调用中,被传递的参数是左值(lvalue),因此拷贝构造函数会被调用。但是在第二和第三次调用中,被传递的参数是纯右值,此时编译器会优化参数传递,使得拷贝构造函数不会被调用。从C++17开始,C++标准要求这一优化方案必须被实现。在C++17之前,如果编译器没有优化掉这一类拷贝,它至少应该先尝试使用移动语义,这通常也会使拷贝成本变得比较低廉。
在最后一次调用中,被传递参数是xvalue
(一个使用了std::move()
的已经存在的非const对象),这会通过告知编译器我们不在需要s的值来强制调用移动构造函数(move constructor)。
综上所述,在调用printV()
(参数是按值传递的)的时候,只有在被传递的参数是lvalue
(对象在函数调用之前创建,并且通常在之后还会被用到,而且没有对其使用std::move()
)时,调用成本才会比较高。不幸的是,这唯一的情况也是最常见的情况,因为我们几乎总是先创建一个对象,然后在将其传递给其它函数。
按值传递会导致类型退化(decay)
关于按值传递,还有一个必须被讲到的特性:当按值传递参数时,参数类型会退化(decay)。也就是说,裸数组会退化成指针,const和volatile等限制符会被删除(就像用一个值去初始化一个用auto声明的对象那样):1
2
3
4
5
6
7
8
9template<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 int *
当传递字符串常量“hi”的时候,其类型char const[3]
退化成char const *
,这也就是模板参数T
被推断出来的类型。此时模板会被实例化成:1
2
3
4void printV (char const* arg)
{
...
}
这一行为继承自C语言,既有优点也有缺点。通常它会简化对被传递字符串常量的处理,但是缺点是在printV()
内部无法区分被传递的是一个对象的指针还是一个存储一组对象的数组。
按引用传递
现在来讨论按引用传递。按引用传递不会拷贝对象(因为形参将引用被传递的实参)。而且,按引用传递时参数类型也不会退化(decay)。
按const引用传递
为了避免(不必要的)拷贝,在传递非临时对象作为参数时,可以使用const引用传递。比如:1
2
3
4template<typename T>
void printR (T const& arg) {
...
}
这个模板永远不会拷贝被传递对象(不管拷贝成本是高还是低):1
2
3
4
5
6std::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类型的变量,虽然这样可能会事与愿违,也依然不会拷贝。因此如下调用:1
2int i = 42;
printR(i); // passes reference instead of just copying i
会将printR()
实例化为:1
2
3void printR(int const& arg) {
...
}
这样做之所以不能提高性能,是因为在底层实现上,按引用传递还是通过传递参数的地址实现的。
按引用传递不会做类型退化(decay)
按引用传递参数时,其类型不会退化(decay)。也就是说不会把裸数组转换为指针,也不会移除const和volatile等限制符。而且由于调用参数被声明为T const &
,被推断出来的模板参数T
的类型将不包含const。比如:1
2
3
4
5
6
7
8
9template<typename T>
void printR (T const& arg) {
...
}
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
声明的变量,它们的类型中也不会包含const。
按非const引用传递
如果想通过调用参数来返回变量值(比如修改被传递变量的值),就需要使用非const引用(要么就使用指针)。同样这时候也不会拷贝被传递的参数。被调用的函数模板可以直接访问被传递的参数。考虑如下情况:1
2
3
4template<typename T>
void outR (T& arg) {
...
}
注意对于outR()
,通常不允许将临时变量(prvalue)或者通过std::move()
处理过的已存在的变量(xvalue)用作其参数:1
2
3
4
5
6std::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
同样可以传递非const类型的裸数组,其类型也不会decay:1
2int arr[4];
outR(arr); // OK: T deduced as int[4], arg is int(&)[4]
这样就可以修改数组中元素的值,也可以处理数组的长度。比如:1
2
3
4
5
6template<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
的类型就有可能被推断为const引用,也就是说这时可以传递一个右值(rvalue)作为参数,但是模板所期望的参数类型却是左值(lvalue):1
2
3
4
5std::string const c = "hi";
outR(c); // OK: T deduced as std::string const
outR(returnConstString()); // OK: same if returnConstString() returns const string
outR(std::move(c)); // OK: T deduced as std::string const6
outR("hi"); // OK: T deduced as char const[3]
在这种情况下,在函数模板内部,任何试图更改被传递参数的值的行为都是错误的。在调用表达式中也可以传递一个const对象,但是当函数被充分实例化之后(可能发生在接接下来的编译过程中),任何试图更改参数值的行为都会触发错误。
如果想禁止向非const应用传递const对象,有如下选择:
- 使用static_assert触发一个编译期错误:
1 | template<typename T> |
- 通过使用
std::enable_if<>
禁用该情况下的模板:
1 | template<typename T, |
- 或者是在concepts被支持之后,通过concepts来禁用该模板:
1 | template<typename T> |
按转发引用传递参数(Forwarding Reference)
使用引用调用(call-by-reference)的一个原因是可以对参数进行完美转发(perfect forward)。但是请记住在使用转发引用时,有它自己特殊的规则。考虑如下代码:1
2
3
4template<typename T>
void passR (T&& arg) { // arg declared as forwarding reference
...
}
可以将任意类型的参数传递给转发引用,而且和往常的按引用传递一样,都不会创建被传递参数的备份:1
2
3
4
5
6std::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)
但是,这种情况下类型推断的特殊规则可能会导致意想不到的结果:1
2
3
4
5std::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
的类型得知被传递的参数是一个右值(rvalue)还是一个const或者非const的左值(lvalue)。这是唯一一种可以传递一个参数,并用它来区分以上三种情况的方法。
看上去将一个参数声明为转发引用总是完美的。但是,没有免费的午餐。比如,由于转发引用是唯一一种可以将模板参数T隐式推断为引用的情况,此时如果在模板内部直接用T声明一个未初始化的局部变量,就会触发一个错误(引用对象在创建的时候必须被初始化):1
2
3
4
5
6
7template<typename T>
void passR(T&& arg) { // arg is a forwarding reference
T x; // for passed lvalues, x is a reference, which requires an initializer
}
foo(42); // OK: T deduced as int
int i;
foo(i); // ERROR: T deduced as int&, which makes the declaration of x in passR() invalid
使用std::ref()和std::cref()
从C++11开始,可以让调用者自行决定向函数模板传递参数的方式。如果模板参数被声明成按值传递的,调用者可以使用定义在头文件<functional>
中的std::ref()
和std::cref()
将参数按引用传递给函数模板。比如:1
2
3
4
5
6
7template<typename T>
void printT (T arg) {
...
}
std::string s = "hello";
printT(s); //pass s By value
printT(std::cref(s)); // pass s “as if by reference”
但是请注意,std::cref()
并没有改变函数模板内部处理参数的方式。相反,在这里它使用了一个技巧:它用一个行为和引用类似的对象对参数进行了封装。事实上,它创建了一个std::reference_wrapper<>
的对象,该对象引用了原始参数,并被按值传递给了函数模板。
std::reference_wrapper<>
可能只支持一个操作:向原始类型的隐式类型转换,该转换返回原始参数对象。因此当需要操作被传递对象时,都可以直接使用这个std::reference_wrapper<>
对象。比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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,这样std::reference_wrapper<string const>
对象被传进函数模板并被转换为原始参数类型std::string
。
注意,编译器必须知道需要将std::reference_wrapper<string const>
对象转换为原始参数类型,才会进行隐式转换。因此std::ref()
和std::cref()
通常只有在通过泛型代码传递对象时才能正常工作。比如如果尝试直接输出传递进来的类型为T的对象,就会遇到错误,因为std::reference_wrapper<string const>
中并没有定义输出运算符:1
2
3
4
5
6
7template<typename T>
void printV (T arg) {
std::cout << arg << "\n";
}...
std::string s = "hello";
printV(s); //OK
printV(std::cref(s)); // ERROR: no operator << for reference wrapper defined
同样下面的代码也会报错,因为不能将一个std::reference_wrapper<string const>
对象和一个char const*
或者std::string
进行比较:1
2
3
4
5
6
7
8template<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,也不会有帮助:1
2
3
4
5template<typename T>
bool isless(T arg1, T arg2)
{
return arg1 < arg2;
}
因为编译器在推断arg1
和arg2
的类型时会遇到类型冲突。
综上,std::reference_wrapper<>
是为了让开发者能够像使用“第一类对象(first class object)”一样使用引用,可以对它进行拷贝并将其按值传递给函数模板。也可以将它用在class内部,比如让它持有一个指向容器中对象的引用。但是通常总是要将其转换会原始类型。
处理字符串常量和裸数组
到目前为止,我们看到了将字符串常量和裸数组用作模板参数时的不同效果:
- 按值传递时参数类型会decay,参数类型会退化成指向其元素类型的指针。
- 按引用传递是参数类型不会decay,参数类型是指向数组的引用。
两种情况各有其优缺点。将数组退化成指针,就不能区分它是指向对象的指针还是一个被传递进来的数组。另一方面,如果传递进来的是字符串常量,那么类型不退化的话就会带来问题,因为不同长度的字符串的类型是不同的。比如:1
2
3
4
5
6template<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]
,但是函数模板要求两个参数的类型必须相同。这种code只有在两个字符串常量的长度相同时才能通过编译。因此,强烈建议在测试代码中使用长度不同的字符串。如果将foo()
声明成按值传递的,这种调用可能可以正常运行:1
2
3
4
5
6template<typename T>
void foo (T arg1, T arg2)
{
...
}
foo("hi", "guy"); //compiles, but ...
但是这样并不能解决所有的问题。反而可能会更糟,编译期间的问题可能会变为运行期间的问题。考虑如下代码,它用==运算符比较两个传进来的参数:1
2
3
4
5
6
7
8template<typename T>
void foo (T arg1, T arg2)
{
if (arg1 == arg2) { //OOPS: compares addresses of passed arrays
...
}
}
foo("hi", "guy"); //compiles, but ...
如上,此时很容易就能知道需要将被传递进来的的字符指针理解成字符串。但是情况并不总是这么简单,因为模板还要处理类型可能已经退化过了的字符串常量参数。然而,退化在很多情况下是有帮助的,尤其是在需要验证两个对象(两个对象都是参数,或者一个对象是参数,并用它给另一个赋值)是否有相同的类型或者可以转换成相同的类型的时候。这种情况的一个典型应用就是用于完美转发(perfect forwarding)。但是使用完美转发需要将参数声明为转发引用。这时候就需要使用类型萃取std::decay<>()
显式的退化参数类型。
注意,有些类型萃取本身可能就会对类型进行隐式退化,比如用来返回两个参数的公共类型的std::common_type<>
。
关于字符串常量和裸数组的特殊实现
有时候可能必须要对数组参数和指针参数做不同的实现。此时当然不能退化数组的类型。为了区分这两种情况,必须要检测到被传递进来的参数是不是数组。通常有两种方法:
- 可以将模板定义成只能接受数组作为参数:
1 | template<typename T, std::size_t L1, std::size_t L2> |
参数arg1
和arg2
必须是元素类型相同、长度可以不同的两个数组。但是为了支持多种不同类型的裸数组,可能需要更多实现方式。
- 可以使用类型萃取来检测参数是不是一个数组:
1 | template<typename T, typename = |
由于这些特殊的处理方式过于复杂,最好还是使用一个不同的函数名来专门处理数组参数。或者更近一步,让模板调用者使用std::vector
或者std::array
作为参数。但是只要字符串还是裸数组,就必须对它们进行单独考虑。
处理返回值
返回值也可以被按引用或者按值返回。但是按引用返回可能会带来一些麻烦,因为它所引用的对象不能被很好的控制。不过在日常编程中,也有一些情况更倾向于按引用返回:
- 返回容器或者字符串中的元素(比如通过[]运算符或者front()方法访问元素)
- 允许修改类对象的成员
- 为链式调用返回一个对象(比如>>和<<运算符以及赋值运算符)
另外对成员的只读访问,通常也通过返回const引用实现。但是如果使用不当,以上几种情况就可能导致一些问题。比如:1
2
3
4std::string* s = new std::string("whatever");
auto& c = (*s)[0];
delete s;
std::cout << c; //run-time ERROR
这里声明了一个指向字符串中元素的引用,但是在使用这个引用的地方,对应的字符串却不存在了(成了一个悬空引用),这将导致未定义的行为。比如:1
2
3
4auto s = std::make_shared<std::string>("whatever");
auto& c = (*s)[0];
s.reset();
std::cout << c; //run-time ERROR
因此需要确保函数模板采用按值返回的方式。但是正如接下来要讨论的,使用函数模板T作为返回类型并不能保证返回值不会是引用,因为T在某些情况下会被隐式推断为引用类型:1
2
3
4
5template<typename T>
T retR(T&& p) // p is a forwarding reference
{
return T{...}; // OOPS: returns by reference when called for lvalues
}
即使函数模板被声明为按值传递,也可以显式地将T指定为引用类型:1
2
3
4
5
6
7template<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); // retT() instantiated for T as int&
安全起见,有两种选择:
- 用类型萃取
std::remove_reference<>
将T转为非引用类型:
1 | template<typename T> |
std::decay<>
之类的类型萃取可能也会有帮助,因为它们也会隐式的去掉类型的引用。
- 将返回类型声明为auto,从而让编译器去推断返回类型,这是因为auto也会导致类型退化:
1 | template<typename T> |
关于模板参数声明的推荐方法
正如前几节介绍的那样,函数模板有多种传递参数的方式:
- 将参数声明成按值传递:这一方法很简单,它会对字符串常量和裸数组的类型进行退化,但是对比较大的对象可能会受影响性能。在这种情况下,调用者仍然可以通过
std::cref()
和std::ref()
按引用传递参数,但是要确保这一用法是有效的。 - 将参数声明成按引用传递:对于比较大的对象这一方法能够提供比较好的性能。尤其是在下面几种情况下:
- 将已经存在的对象(lvalue)按照左值引用传递,
- 将临时对象(prvalue)或者被
std::move()
转换为可移动的对象(xvalue)按右值引用传递, - 或者是将以上几种类型的对象按照转发引用传递。
由于这几种情况下参数类型都不会退化,因此在传递字符串常量和裸数组时要格外小心。对于转发引用,需要意识到模板参数可能会被隐式推断为引用类型(引用折叠)。
对于函数模板有如下建议:
- 默认情况下,将参数声明为按值传递。这样做比较简单,即使对字符串常量也可以正常工作。对于比较小的对象、临时对象以及可移动对象,其性能也还不错。对于比较大的对象,为了避免成本高昂的拷贝,可以使用
std::ref()
和std::cref()
。 - 如果有充分的理由,也可以不这么做:
- 如果需要一个参数用于输出,或者即用于输入也用于输出,那么就将这个参数按非const引用传递。
- 如果使用模板是为了转发它的参数,那么就使用完美转发(perfect forwarding)。也就是将参数声明为转发引用并在合适的地方使用
std::forward<>()
。考虑使用std::decay<>
或者std::common_type<>
来处理不同的字符串常量类型以及裸数组类型的情况。 - 如果重点考虑程序性能,而参数拷贝的成本又很高,那么就使用const引用。不过如果最终还是要对对象进行局部拷贝的话,这一条建议不适用。
- 如果你更了解程序的情况,可以不遵循这些建议。但是请不要仅凭直觉对性能做评估。
不要过分泛型化
值得注意的是,在实际应用中,函数模板通常并不是为了所有可能的类型定义的,而是有一定的限制。这时候最好不要将该函数模板定义的过于泛型化,否则,可能会有一些令人意外的副作用。针对这种情况应该使用如下的方式定义模板:1
2
3
4
5template<typename T>
void printVector (std::vector<T> const& v)
{
...
}
这里通过的参数v,可以确保T不会是引用类型,因为vector不能用引用作为其元素类型。而且将vector类型的参数声明为按值传递不会有什么好处,因为按值传递一个vector的成本明显会比较高昂(vector的拷贝构造函数会拷贝vector中的所有元素)。此处如果直接将参数v的类型声明为T
,就不容易从函数模板的声明上看出该使用那种传递方式了。
以std::make_pair<>为例
std::make_pair<>()
是一个很好的介绍参数传递机制相关陷阱的例子。使用它可以很方便的通过类型推断创建std::pair<>
对象。它的定义在各个版本的C++中都不一样:
- 在第一版C++标准C++98中,
std::make_pair<>
被定义在std命名空间中,并且使用按引用传递来避免不必要的拷贝:
1 | template<typename T1, typename T2> |
但是当使用std::pair<>
存储不同长度的字符串常量或者裸数组时,这样做会导致严重的问题。
- 因此在C++03中,该函数模板被定义成按值传递参数:
1 | template<typename T1, typename T2> |
- 不过在C++11中,由于
make_pair<>()
需要支持移动语义,就必须使用转发引用。因此,其定义大体上是这样:
1 | template<typename T1, typename T2> |
完整的实现还要复杂的多:为了支持std::ref()
和std::cref()
,该函数会将std::reference_wrapper
展开成真正的引用。
总结
- 最好使用不同长度的字符串常量对模板进行测试。
- 模板参数的类型在按值传递时会退化,按引用传递则不会。
- 可以使用
std::decay<>
对按引用传递的模板参数的类型进行退化。 - 在某些情况下,对被声明成按值传递的函数模板,可以使用
std::cref()
和std::ref()
将参数按引用进行传递。 - 按值传递模板参数的优点是简单,但是可能不会带来最好的性能。
- 除非有更好的理由,否则就将模板参数按值传递。
- 对于返回值,请确保按值返回(这也意味着某些情况下不能直接将模板参数直接用于返回类型)。
- 在比较关注性能时,做决定之前最好进行实际测试。不要相信直觉,它通常都不准确。
编译期编程
模板元编程
模板的实例化发生在编译期间(而动态语言的泛型是在程序运行期间决定的)。事实证明C++模板的某些特性可以和实例化过程相结合,这样就产生了一种C++自己内部的原始递归的“编程语言”。因此模板可以用来“计算一个程序的结果”。下面的代码在编译期间就能判断一个数是不是质数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22template<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
中。为了计算出模板参数是不是质数,它实例化了DoIsPrime<>
模板,这个模板会被递归展开,以计算p
除以p/2
和2
之间的数之后是否会有余数。
- 我们通过递归地展开
DoIsPrime<>
来遍历所有介于p/2
和2之间的数,以检查是否有某个数可以被p整除。 - 用
d
等于2偏特例化出来的DoIsPrime<>
被用于终止递归调用。
但是以上过程都是在编译期间进行的。也就是说:1
IsPrime<9>::value
在编译期间就被扩展成false了。
通过constexpr进行计算
C++11引入了一个叫做constexpr的新特性,它大大简化了各种类型的编译期计算。如果给定了合适的输入,constexpr函数就可以在编译期间完成相应的计算。虽然C++11对constexpr函数的使用有诸多限制,但是在C++14中这些限制中的大部分都被移除了。当然,为了能够成功地进行constexpr函数中的计算,依然要求各个计算步骤都能在编译期进行:目前堆内存分配和异常抛出都不被支持。
在C++11中,判断一个数是不是质数的实现方式如下:1
2
3
4
5
6
7
8
9
10
11constexpr 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++11中只能有一条语句的要求,此处只能使用条件运算符来进行条件选择。不过由于这个函数只用到了C++的常规语法,因此它比第一版中,依赖于模板实例化的代码要容易理解的多。
在C++14中,constexpr函数可以使用常规C++代码中大部分的控制结构。因此为了判断一个数是不是质数,可以不再使用笨拙的模板方式以及略显神秘的单行代码方式,而直接使用一个简单的for循环:1
2
3
4
5
6
7
8constexpr 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
}
在C++11和C++14中实现的constexpr isPrime()
,都可以通过直接调用:1
isPrime(9)
来判断9是不是一个质数。但是上面所说的“可以”在编译期执行,并不是一定会在编译期执行。在其他上下文中,编译期可能会也可能不会尝试进行编译期计算,如果在编译期尝试了,但是现有条件不满足编译期计算的要求,那么也不会报错,相应的函数调用被推迟到运行期间执行。比如:1
constexpr bool b1 = isPrime(9); // evaluated at compile time
会在编译期进行计算(因为b1
被constexpr
修饰)。而对1
const bool b2 = isPrime(9); // evaluated at compile time if in namespace scope
如果b2
被定义于全局作用域或者namespace作用域,也会在编译期进行计算。如果b2
被定义于块作用域({}内),那么将由编译器决定是否在编译期间进行计算。下面这个例子就属于这种情况:1
2
3bool fiftySevenIsPrime() {
return isPrime(57); // evaluated at compile or running time
}
此时是否进行编译期计算将由编译期决定。
另一方面,在如下调用中:1
2
3int x;
... st
d::cout << isPrime(x); // evaluated at run time
不管x是不是质数,调用都只会在运行期间执行。
通过部分特例化进行路径选择
诸如isPrime()
这种在编译期进行相关测试的功能,有一个有意思的应用场景:可以在编译期间通过部分特例化在不同的实现方案之间做选择。
比如,可以以一个非类型模板参数是不是质数为条件,在不同的模板之间做选择:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// 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<>
的size
是不是一个质数,实现了两种Helper<>
模板。这一偏特例化的使用方法,被广泛用于基于模板参数属性,在不同模板实现方案之间做选择。在上面的例子中,对两种可能的情况实现了两种偏特例化版本。但是也可以将主模板用于其中一种情况,然后再特例化一个版本代表另一种情况:1
2
3
4
5
6
7
8
9
10
11
12// primary helper template (used if no specialization fits):
template<int SZ, bool = isPrime(SZ)>
struct Helper
{
...
};
// special implementation if SZ is a prime number:
template<int SZ>
struct Helper<SZ, true>
{
...
};
由于函数模板不支持部分特例化,当基于一些限制在不同的函数实现之间做选择时,必须要使用其它一些方法:
- 使用有static函数的类,
- 使用
std::enable_if
, - 使用SFINAE特性,
- 或者使用从C++17开始生效的编译期的if特性。
SFINAE (Substitution Failure Is Not An Error,替换失败不是错误)
在一个函数调用的备选方案中包含函数模板时,编译器首先要决定应该将什么样的模板参数用于各种模板方案,然后用这些参数替换函数模板的参数列表以及返回类型,最后评估替换后的函数模板和这个调用的匹配情况。但是这一替换过程可能会遇到问题:替换产生的结果可能没有意义。不过这一类型的替换不会导致错误,C++语言规则要求忽略掉这一类型的替换结果。这一原理被称为SFINAE(发音类似sfee-nay),代表的是“substitution failure is not an error”。
但是上面讲到的替换过程和实际的实例化过程不一样:即使对那些最终被证明不需要被实例化的模板也要进行替换(不然就无法知道到底需不需要实例化)。不过它只会替换直接出现在函数模板声明中的相关内容(不包含函数体)。考虑如下的例子:1
2
3
4
5
6
7
8
9
10
11
12// 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()
:
- 第一个函数模板的参数类型是
T (&)[N]
,也就是说它是一个包含了N个T型元素的数组。 - 第二个函数模板的参数类型就是简单的T,除了返回类型要是
T::size_type
之外没有别的限制,这要求被传递的参数类型必须有一个size_type
成员。
当传递的参数是裸数组或者字符串常量时,只有那个为裸数组定义的函数模板能够匹配:1
2
3int 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<>
作为参数的话,则只有第二个模板参数能够匹配:1
2std::vector<int> v;
std::cout << len(v); // OK: only len() for a type with size_type matches
如果传递的是裸指针话,以上两个模板都不会被匹配上(但是不会因此而报错)。此时编译期会抱怨说没有发现合适的len()
函数:1
2int* p;
std::cout << len(p); // ERROR: no matching len() function found
但是这和传递一个有size_type
成员但是没有size()
成员函数的情况不一样。比如如果传递的参数是std::allocator<>
:1
2std::allocator<int> x;
std::cout << len(x); // ERROR: len() function found, but can"t size()
此时编译器会匹配到第二个函数模板。因此不会报错说没有发现合适的len()
函数,而是会报一个编译期错误说对std::allocator<int>
而言size()
是一个无效调用。此时第二个模板函数不会被忽略掉。
如果忽略掉那些在替换之后返回值类型为无效的备选项,那么编译器会选择另外一个参数类型匹配相差的备选项。比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 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();
}
//对所有类型的应急选项:
std::size_t len (...)
{
return 0;
}
此处额外提供了一个通用函数len()
,它总会匹配所有的调用,但是其匹配情况也总是所有重载选项中最差的。
此时对于裸数组和vector,都有两个函数可以匹配上,但是其中不是通过省略号(…)匹配的那一个是最佳匹配。对于指针,只有应急选项能够匹配上,此时编译器不会再报缺少适用于本次调用的len()
。不过对于std::allocator<int>
的调用,虽然第二个和第三个函数都能匹配上,但是第二个函数依然是最佳匹配项。因此编译器依然会报错说缺少size()
成员函数:1
2
3
4
5
6
7
8
9int 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掉了一个函数”时,意思是我们通过让模板在一些限制条件下产生无效代码,从而确保在这些条件下会忽略掉该模板。当你在C++标准里读到“除非在某些情况下,该模板不应该参与重载解析过程”时,它的意思就是“在该情况下,使用SFINAE方法SFINAE掉了这个函数模板”。比如std::thread
类模板声明了如下构造函数:1
2
3
4
5
6
7
8
9namespace std {
class thread {
public:
...
template<typename F, typename... Args>
explicit thread(F&& f, Args&&... args);
...
};
}
并做了如下备注:如果decay_t<F>
的类型和std:thread
相同的话,该构造函数不应该参与重载解析过程。
它的意思是如果在调用该构造函数模板时,使用std::thread
作为第一个也是唯一一个参数的话,那么这个构造函数模板就会被忽略掉。这是因为一个类似的成员函数模板在某些情况下可能比预定义的copy或者move构造函数更能匹配相关调用。通过SFINAE掉将该构造函数模板用于thread
的情况,就可以确保在用一个thread
构造另一个thread
的时候总是会调用预定义的copy或者move构造函数。
但是使用该技术逐项禁用相关模板是不明智的。幸运的是标准库提供了更简单的禁用模板的方法。其中最广为人知的一个就是std::enable_if<>
。因此典型的std::thread
的实现如下:1
2
3
4
5
6
7
8
9namespace 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);
...
};
}
通过decltype进行SFINAE(此处是动词)的表达式
对于有些限制条件,并不总是很容易地就能找到并设计出合适的表达式来SFINAE掉函数模板。
比如,对于有size_type
成员但是没有size()
成员函数的参数类型,我们想要保证会忽略掉函数模板len()
。如果没有在函数声明中以某种方式要求size()
成员函数必须存在,这个函数模板就会被选择并在实例化过程中导致错误:1
2
3
4
5
6
7template<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()
处理这一情况有一种常用模式或者说习惯用法:
- 通过尾置返回类型语法(trailing return type syntax)来指定返回类型(在函数名前使用
auto
,并在函数名后面的->
后指定返回类型)。 - 通过decltype和逗号运算符定义返回类型。
- 将所有需要成立的表达式放在逗号运算符的前面(为了预防可能会发生的运算符被重载的情况,需要将这些表达式的类型转换为void)。
- 在逗号运算符的末尾定义一个类型为返回类型的对象。
比如:1
2
3
4
5template<typename T>
auto len (T const& t) -> decltype( (void)(t.size()), T::size_type() )
{
return t.size();
}
这里返回类型被定义成:1
decltype( (void)(t.size)(), T::size_type() )
类型指示符decltype
的操作数是一组用逗号隔开的表达式,因此最后一个表达式T::size_type()
会产生一个类型为返回类型的对象(decltype会将其转换为返回类型)。而在最后一个逗号前面的所有表达式都必须成立,在这个例子中逗号前面只有t.size()
。之所以将其类型转换为void
,是为了避免因为用户重载了该表达式对应类型的逗号运算符而导致的不确定性。注意decltype
的操作数是不会被计算的,也就是说可以不调用构造函数而直接创建其“dummy”对象。
编译期if
部分特例化,SFINAE以及std::enable_if
可以一起被用来禁用或者启用某个模板。而C++17又在此基础上引入了同样可以在编译期基于某些条件禁用或者启用相应模板的编译期if语句。通过使用if constexpr(...)
语法,编译器会使用编译期表达式来决定是使用if语句的then对应的部分还是else对应的部分。
作为第一个例子,考虑变参函数模板print()
。它用递归的方法打印其参数(可能是任意类型)。如果使用constexp if
,就可以在函数内部决定是否要继续递归下去,而不用再单独定义一个函数来终结递归:1
2
3
4
5
6
7
8template<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。这样if语句里面的语句就会被丢弃掉,也就是说这部分代码不会被实例化。因此也就不再需要一个单独的函数来终结递归。
事实上上面所说的不会被实例化,意思是对这部分代码只会进行第一阶段编译,此时只会做语法检查以及和模板参数无关的名称检查。比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15template<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 discarded)
static_assert(false, "no integral"); // always asserts (even if discarded)
static_assert(!std::is_integral_v<T>, "no integral"); //OK
}
}
此处if constexpr
的使用并不仅限于模板函数,而是可以用于任意类型的函数。它所需要的只是一个可以返回布尔值的编译期表达式。比如:1
2
3
4
5
6
7
8
9
10int 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
}
}
利用这一特性,也可以让编译期函数isPrime()
在非类型参数不是质数的时候执行一些额外的代码:1
2
3
4
5
6
7template<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
}
}
总结
- 模板提供了在编译器进行计算的能力(比如使用递归进行迭代以及使用部分特例化或者?:进行选择)。
- 通过使用constexpr函数,可以用在编译期上下文中能够被调用的“常规函数(要有constexpr)”替代大部分的编译期计算工作。
- 通过使用部分特例化,可以基于某些编译期条件在不同的类模板实现之间做选择。
- 模板只有在被需要的时候才会被使用,对函数模板声明进行替换不会产生有效的代码。这一原理被称为SFINAE。
- SFINAE可以被用来专门为某些类型或者限制条件提供函数模板。
- 从C++17开始,可以通过使用编译期if基于某些编译期条件启用或者禁用某些语句。
在实践中使用模板
包含模式
有很多中组织模板源码的方式。本章讨论这其中最流行的一种方法:包含模式。
链接错误
大多数C和C++程序员都会按照如下方式组织代码:
- 类和其它类型被放在头文件里。其文件扩展名为.hpp
- 对于全局变量(非inline)和函数(非inline),只将其声明放在头文件里,定义则被放在一个被当作其自身编译单元的文件里。这一类文件的扩展名为.cpp。
这样做效果很好:既能够在整个程序中很容易的获得所需类型的定义,同时又避免了链接过程中的重复定义错误。
受这一惯例的影响,刚开始接触模板的程序员通常都会遇到下面这个程序中的错误。和处理“常规代码”的情况一样,在头文件中声明模板:1
2
3
4
5
6
// declaration of template
template<typename T>
void printTypeof (T const&);
其中printTypeof()
是一个简单的辅助函数的声明,它会打印一些类型相关信息。而它的具体实现则被放在了一个CPP文件中:1
2
3
4
5
6
7
8
9
// 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的参数的类型。
接着在另一个CPP文件中使用该模板,它会include该模板的头文件:1
2
3
4
5
6
7
// use of the template
int main()
{
double ice = 3.0;
printTypeof(ice); // call function template for type double
}
编译器很可能会正常编译这个程序,但是链接器则可能会报错说:找不到函数printTypeof()
的定义。出现这一错误的原因是函数模板printTypeof()
的定义没有被实例化。为了实例化一个模板,编译器既需要知道需要实例化哪个函数,也需要知道应该用哪些模板参数来进行实例化。不���的是,在上面这个例子中,这两组信息都是被放在别的文件里单独进行编译的。因此当编译器遇到对printTypeof()
的调用时,却找不到相对应的函数模板定义来针对double类型进行实例化
头文件中的模板
解决以上问题的方法和处理宏以及inline函数的方法一样:将模板定义和模板声明都放在头文件里。
也就是说需要重写myfirst.hpp
,让它包含所有模板声明和模板定义,而不再提供myfirst.cpp
文件:1
2
3
4
5
6
7
8
9
10
11
12
13
// 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";
}
这种组织模板相关代码的方法被称为“包含模式”。使用这个方法,程序的编译,链接和执行都可以正常进行。
目前有几个问题需要指出。最值得注意的一个是,这一方法将大大增加include头文件myfirst.hpp
的成本。在这个例子中,成本主要不是由模板自身定义导致的,而是由那些为了使用这个模板而必须包含的头文件导致的,比如<iostream>
和<typeinfo>
。由于诸如<iostream>
的头文件还会包含一些它们自己的模板,因此这可能会带来额外的数万行的代码。
模板和inline
提高程序运行性能的一个常规手段是将函数声明为inline的。Inline关键字的意思是给编译器做一个暗示,要优先在函数调用处将函数体做inline替换展开,而不是按常规的调用机制执行。
和inline函数类似,函数模板也可以被定义在多个编译单元中。比如我们通常将模板定义放在头文件中,而这个头文件又被多个CPP文件包含。但是这并不意味着函数模板在默认情况下就会使用inline替换。在模板调用处是否进行inline替换完全是由编译器决定的事情。编译器通常能够更好的评估inline替换一个被调用函数是否能够提升程序性能。因此不同编译器之间对inline函数处理的精准原则也是不同的,这甚至会受编译选项的影响。
程序员希望自己能够决定是否需要进行inline替换。有时候这只能通过编译器的具体属性实现,比如noinline
和always_inline
。
预编译头文件
即使不适用模板,C++的头文件也会大到需要很长时间进行编译。而模板的引入则进一步加剧了这一问题,程序员对这一问题的抱怨促使编译器供应商提供了一种叫做预编译头文件(PCH: precomplied header)的方案来降低编译时间。
预编译头文件方案的实现基于这样一个事实:在组织代码的时候,很多文件都以相同的几行代码作为开始。为了便于讨论,假设那些将要被编译文件的前N行内容都相同。这样就可以单独编译这N行代码,并将编译完成后的状态保存在一个预编译头文件中(precompiledheader)。接着所有以这N行代码开始的文件,在编译时都会重新载入这个被保存的状态,然后从第N+1行开始编译。在这里需要指出,重新载入被保存的前N行代码的预编译状态可能会比再次编译这N行代码要快很多很多倍。但是保存这个状态可能要比单次编译这N行代码慢的多,编译时间可能延长20%到200%。
因此利用预编译头文件提高编译速度的关键点是;让尽可能多的文件,以尽可能多的相同的代码作为开始。也就是说在实践中,文件要以相同的#include指令(它们可能占用大量的编译时间)开始。因此如果#include头文件的顺序相同的话,就会对提高编译性能很有帮助。但是对下面的文件:1
2
和1
2
预编译头文件不会起作用,因为它们的起始状态并不一致(顺序不一致)。一些程序员认为,即使可能会错过一个利用预编译头文件加速文件编译的机会,也应该多#include一些可能用不到的头文件。这样做可以大大简化预编译头文件的使用方式。比如通常可以创建一个包含所有标准头文件的头文件,称之为std.hpp
:1
2
3
4
5
这个文件可以被预编译,其它所有用到标准库的文件都可以直接在文件开始处include这个头文件:
破译大篇幅的错误信息
常规函数的编译错误信息通常非常简单且直中要点。比如当编译器报错说”class X has no member ‘fun’”时,找到代码中相应的错误并不会很难。但是模板并不是这样。看下面这些例子。
简单的类型不匹配情况
考虑下面这个使用了C++标准库的简单例子:1
2
3
4
5
6
7
8
9
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函数被用来找到第一个匹配的字符串,它依次将map中的元素和一个字符串比较。但是,由于map中的元素是key/value对,因此传入lambda的元素也将是一个std::pair<std::string const, double>
,而它是不能直接和字符串进行比较的。针对这个错误,主流的GUN C++编译器会报如下错误:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
161 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::<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:[with _InputIterator = std::_Rb_tree_iterator<std::pair<const <char>, double> >; _Predicate = __gnu_cxx::__ops::_Iter_pred<<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 pair<const std::__cxx11::basic_string<char>, double> >; _Predic 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_it std::__cxx11::basic_string<char>, double> >; _Predicate = main <lambda(const string&)>]'
9 errornovel1.cpp:13:29: required from here /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<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 2arguments, 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 std::__cxx11::basic_string<char>, double>' to 'const string& {a basic_string<char>&}'
以上错误信息中第一部分的意思是,在一个函数模板的实例中遇到了错误,这个模板位于一个内部头文件predefined_ops.h
中。在这一行以及后面的几行中,编译器报告了哪些模板被用哪些参数实例化了。本例子中从以下开始:1
2
3
4auto pos = std::find_if (coll.begin(), coll.end(),
[] (std::string const& s) {
return s != "";
});
这导致了一个find_if
的实例化,这个在stl_algo.h
头文件1
_IIter = std::_Rb_tree_iterator<std::pair<const std::__cxx11::basic_string<char>, double> >_Predicate = main()::<lambda(const string&)>
编译器会报告所有这些,以防我们根本不期望所有这些模板都被实例化。它允许我们确定导致实例化的事件链。然而,在我们的示例中,我们愿意相信各种模板都需要实例化,我们只是想知道为什么它不起作用。此信息出现在消息的最后部分:“调用不匹配”部分表示由于参数类型和参数类型不匹配,因此无法解析函数调用。它列出了调用的内容:1
(main()::<lambda(const string&)>) (std::pair<const std::__cxx11::basic_string<char>, double>&)
此外,就在这之后,包含“note: candidate:”的行解释说有一个候选类型需要一个const string&
,并且这个候选类型在errornovel1.cpp
的第 11 行中定义lambda [] (std::string const& s)
毫无疑问,错误信息可能会更好。实际问题可能会在实例化之前发出,而不是使用完全扩展的模板实例化名称,如std::__cxx11::basic_string<char>
,仅使用std::string
可能就足够了。但是,此诊断中的所有信息在某些情况下可能很有用。
总结
- 模板的包含模式被广泛用来组织模板代码。第14章会介绍另一种替代方法。
- 当被定义在头文件中,且不在类或者结构体中时,函数模板的全特例化版本需要使用inline。
- 为了充分发挥预编译的特性,要确保#include指令的顺序相同。
- Debug模板相关代码很有挑战性。
模板基本术语
“类模板”还是“模板类”
在C++中,structs,classes以及unions都被称为class types。如果没有特殊声明的话,“class”的字面意思是用关键字class或者struct声明的class types。注意class types包含unions,但是class不包含。关于该如何称呼一个是模板的类,有一些困扰:
- 术语class template是指这个class是模板。也就是说它是一组class的参数化表达。
- 术语template class则被:
- 用作class template的同义词。
- 用来指代从template实例化出来的classes。
- 用来指代名称是一个template-id(模板名+ <模板参数>)的类。
替换,实例化,和特例化
在处理模板相关的代码时,C++编译器必须经常去用模板实参替换模板参数。有时后这种替换只是试探性的:编译器需要验证这个替换是否有效。用实际参数替换模板参数,以从一个模板创建一个常规类、类型别名、函数、成员函数或者变量的过程,被称为“模板实例化”。
不过令人意外的是,目前就该如何表示通过模板参数替换创建一个声明(不是定义)的过程,还没有相关标准以及基本共识。有人使用“部分实例化(partial instantiation)”或者“声明的实例化(instantiation of a declaration)”,但是这些用法都不够普遍。或许使用“不完全实例化(incomplete instantiation)”会更直观一些。
通过实例化或者不完全实例化产生的实体通常被称为特例化(specialization)。但是在C++中,实例化过程并不是产生特例化的唯一方式。另外一些方式允许程序员显式的指定一个被关联到模板参数的、被进行了特殊替换的声明。这一类特例化以一个template<>
开始:1
2
3
4
5
6
7
8template<typename T1, typename T2> // primary class template
class MyClass {
...
};
template<> // explicit specialization
class MyClass<std::string,float> {
...
};
严格来说,这被称为显式特例化(explicit specialization)。
如果特例化之后依然还有模板参数,就称之为部分特例化。1
2
3
4
5
6
7
8template<typename T> // partial specialization
class MyClass<T,T> {
...
};
template<typename T> // partial specialization
class MyClass<bool,T> {
...
};
声明和定义
到目前为止,“声明”和“定义”只在本书中使用了几次。但是在标准C++中,这些单词有着明确的定义,我们也将采用这些定义。
“声明”是一个C++概念,它将一个名称引入或者再次引入到一个C++作用域内。引入的过程中可能会包含这个名称的一部分类别,但是一个有效的声明并不需要相关名称的太多细节。比如:1
2
3class 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
注意,在C++中虽然宏和goto标签也都有名字,但是它们并不是声明。对于声明,如果其细节已知,或者是需要申请相关变量的存储空间,那么声明就变成了定义。对于class类型的定义和函数定义,意味着需要提供一个包含在{}中的主体,或者是对函数使用了=defaul/=delete。对于变量,如果进行了初始化或者没有使用extern,那么声明也会变成定义。下面是一些“定义”的例子:1
2
3
4
5
6class 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
作为扩展,如果一个类模板或者函数模板有包含在{}中的主体的话,那么声明也会变成定义。1
2template<typename T>
void func (T);
是一个声明。而:1
2template<typename T>
class S {};
则是一个定义。
完整类型和非完整类型(complete versus incomplete types)
类型可以是完整的(complete)或者是不完整的(incomplete),这一名词和声明以及定义之间的区别密切相关。有些语言的设计要求完整类型,有一些也适用于非完整类型。非完整类型是以下情况之一:
- 一个被声明但是还没有被定义的class类型。
- 一个没有指定边界的数组。
- 一个存储非完整类型的数组。
- Void类型。
- 一个底层类型未定义或者枚举值未定义的枚举类型。
- 任何一个被const或者volatile修饰的以上某种类型。
其它所有类型都是完整类型。比如:1
2
3
4
5
6
7class 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
唯一定义法则
C++语言中对实体的重复定义做了限制。这一限制就是“唯一定义法则(one-definition rule, ODR)”。目前只要记住以下基础的ODR就够了:
- 常规(比如非模板)非inline函数和成员函数,以及非inline的全局变量和静态数据成员,在整个程序中只能被定义一次。
- Class类型(包含struct和union),模板(包含部分特例化,但不能是全特例化),以及inline函数和变量,在一个编译单元中只能被定义一次,而且不同编译单元间的定义应该相同。
编译单元是通过预处理源文件产生的一个文件;它包含通过#include指令包含的内容以及宏展开之后的内容。
在后面的章节中,可链接实体(linkable entity)指的是下面的任意一种:一个函数或者成员函数,一个全局变量或者静态数据成员,以及通过模板产生的类似实体,只要对linker可见就行。
Template Arguments versus Template Parameters
考虑如下类模板:1
2
3
4
5template<typename T, int N>
class ArrayInClass {
public:
T array[N];
};
和一个类似的类:1
2
3
4class DoubleArrayInClass {
public:
double array[10];
};
如果将前者中的模板参数T和N替换为double和10,那么它将和后者相同。在C++中这种类型的模板参数替换被表示为:1
ArrayInClass<double,10>
注意模板名称后面的尖括号以及其中的模板实参。
不管这些实参是否和模板参数有关,模板名称以及其后面的尖括号和其中的模板实参,被称为template-id。其用法和非模板类的用法非常相似。比如:1
2
3
4
5int main()
{
ArrayInClass<double,10> ad;
ad.array[0] = 1.0;
}
有必要对模板参数(template parameters)和模板实参(template arguments)进行区分。简单来讲可以说“模板参数是被模板实参初始化的”。或者更准确的说:
- 模板参数是那些在模板定义或者声明中,出现在template关键字后面的尖括号中的名称。
- 模板实参是那些用来替换模板参数的内容。不同于模板参数,模板实参可以不只是“名称”。
当指出模板的template-id的时候,用模板实参替换模板参数的过程就是显式的,但是在很多情况这一替换则是隐式的(比如模板参数被其默认值替换的情况)。
一个基本原则是:任何模板实参都必须是在编译期可知的。就如接下来会澄清的,这一要求对降低模板运行期间的成本很有帮助。由于模板参数最终都会被编译期的值进行替换,它们也可以被用于编译期表达式。在ArrayInClass模板中指定成员array的尺寸时就用到了这一特性。数组的尺寸必须是一个常量表达式,而模板参数N恰好满足这一要求。
对这一特性的使用可以更进一步:由于模板参数是编译期实体,它们也可以被用作模板实参。就像下面这个例子这样:1
2
3
4
5template<typename T>
class Dozen {
public:
ArrayInClass<T,12> contents;
};
其中T既是模板参数也是模板实参。这样这一原理就可以被用来从简单模板构造更复杂的模板。当然,在原理上,这和我们构造类型和函数并没有什么不同。
总结
- 对那些是模板的类,函数和变量,我们称之为类模板,函数模板和变量模板。
- 模板实例化过程是一个用实参取代模板参数,从而创建常规类或者函数的过程。最终产生的实体是一个特化。
- 类型可以是完整的或者非完整的。
- 根据唯一定义法则(ODR),非inline函数,成员函数,全局变量和静态数据成员在整个程序中只能被定义一次。
泛型库
可调用对象(Callables)
一些库包含这样一种接口,客户端代码可以向该类接口传递一个实体,并要求该实体必须被调用。相关的例子有:必须在另一个线程中被执行的操作,一个指定该如何处理hash值并将其存在hash表中的函数(hash函数),一个指定集合中元素排序方式的对象。标准库也不例外:它定义了很多可以接受可调用对象作为参数的组件。
这里会用到一个叫做回调(callback)的名词。传统上这一名词被作为函数调用实参使用,我们将保持这一传统。比如一个排序函数可能会接受一个回调参数并将其用作排序标准,该回调参数将决定排序顺序。
在C++中,由于一些类型既可以被作为函数调用参数使用,也可以按照f(...)
的形式调用,因此可以被用作回调参数:
- 函数指针类型
- 重载了
operator()
的class类型(有时被称为仿函数(functors)),这其中包含lambda函数 - 包含一个可以产生一个函数指针或者函数引用的转换函数的class类型
这些类型被统称为函数对象类型(function object types),其对应的值被称为函数对象(function object)。
如果可以接受某种类型的可调用对象的话,泛型代码通常可以从中受益,而模板使其称为可能。
函数对象的支持
来看一下标准库中的for_each()
算法是如何实现的:1
2
3
4
5
6
7
8template<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
}
}
下面的代码展示了将以上模板用于多种函数对象的情况:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 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) { //lambda as callable
std::cout << "lambda called for: " << i << "\n";
});
}
详细看一下以上各种情况:
- 当把函数名当作函数参数传递时,并不是传递函数本体,而是传递其指针或者引用。和数组情况类似,在按值传递时,函数参数退化为指针,如果参数类型是模板参数,那么类型会被推断为指向函数的指针。和数组一样,按引用传递的函数的类型不会decay。但是函数类型不能真正用const限制。如果将
foreach()
的最后一个参数的类型声明为Callable const &
,const会被省略。 - 在第二个调用中,函数指针被显式传递(传递了一个函数名的地址)。这和第一中调用方式相同(函数名会隐式的decay成指针),但是相对而言会更清楚一些。
- 如果传递的是仿函数,就是将一个类的对象当作可调用对象进行传递。通过一个class类型进行调用通常等效于调用了它的
operator()
。因此下面这样的调用:
1 | op(*current); |
会被转换成:1
op.operator()(*current); // call operator() with parameter *current for op
注意在定义operator()
的时候最好将其定义成const成员函数。否则当一些框架或者库不希望该调用会改变被传递对象的状态时,会遇到很不容易debug的error。
- Lambda表达式会产生仿函数(也称闭包),因此它与仿函数(重载了
operator()
的类)的情况没有不同。不过Lambda引入仿函数的方法更为简便,因此它们从C++11开始变得很常见。- 有意思的是,以
[]
开始的lambdas(没有捕获)会产生一个向函数指针进行转换的运算符。
- 有意思的是,以
处理成员函数以及额外的参数
在以上例子中漏掉了另一种可以被调用的实体:成员函数。这是因为在调用一个非静态成员函数的时候需要像下面这样指出对象:object.memfunc(...)
或者ptr->memfunc(...)
,这和常规情况下的直接调用方式不同:func(...)
。
幸运的是,从C++17开始,标准库提供了一个工具:std::invlke()
,它非常方便的统一了上面的成员函数情况和常规函数情况,这样就可以用同一种方式调用所有的可调用对象。下面代码中foreach()
的实现使用了std::invoke()
:1
2
3
4
5
6
7
8
9
10
11
12
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()
还可以接受任意数量的参数。然后foreach()
将参数传递给std::invoke()
。std::invoke()
会这样处理相关参数:
- 如果可调用对象是一个指向成员函数的指针,它会将
args...
中的第一个参数当作this
对象(不是指针)。args...
中其余的参数则被当做常规参数传递给可调用对象。 - 否则,所有的参数都被直接传递给可调用对象。
注意这里对于可调用对象和agrs...
都不能使用完美转发(perfect forward):因为第一次调用可能会steal(偷窃)相关参数的值,导致在随后的调用中出现错误。
现在既可以像之前那样调用foreach()
,也可以向它传递额外的参数,而且可调用对象可以是一个成员函数。正如下面的代码展现的那样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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(), //elements 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()
时,第四个参数被作为lambda函数的第一个参数传递给lambda,而vector中的元素被作为第二个参数传递给lambda。第二次调用中,第三个参数memfunc()
被第四个参数obj调用。
函数调用的包装
std::invoke()
的一个常规用法是封装一个单独的函数调用。此时可以通过完美转发可调用对象以及被传递的参数来支持移动语义:1
2
3
4
5
6
7
8
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
:1
2template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args)
decltype(auto)
(在C++14中引入)是一个占位符类型,它根据相关表达式决定了变量、返回值、或者模板实参的类型。
如果你想暂时的将std::invoke()
的返回值存储在一个变量中,并在做了某些别的事情后将其返回(比如处理该返回值或者记录当前调用的结束),也必须将该临时变量声明为decltype(auto)
类型:1
2
3
4decltype(auto) ret{std::invoke(std::forward<Callable>(op),
std::forward<Args>(args)...)};
...
return ret;
注意这里将ret
声明为auto &&
是不对的。auto&&
作为引用会将变量的生命周期扩展到作用域的末尾,但是不会扩展到超出return的地方。不过即使是使用decltype(auto)
也还是有一个问题:如果可调用对象的返回值是void,那么将ret
初始化为decltype(auto)
是不可以的,这是因为void是不完整类型。此时有如下选择:
- 在当前行前面声明一个对象,并在其析构函数中实现期望的行为。比如:
1 | struct cleanup { |
- 分别实现void和非void的情况:
1 |
|
其中:1
if constexpr(std::is_same_v<std::invoke_result_t<Callable, Args...>, void>)
在编译期间检查使用Args...
的callable的返回值是不是void类型。后续的C++版本可能会免除掉这种对void的特殊操作。
其他一些实现泛型库的工具
std::invoke()
只是C++标准库提供的诸多有用工具中的一个。在接下来的内容中,我们会介绍其他一些重要的工具。
类型萃取
标准库提供了各种各样的被称为类型萃取(type traits)的工具,它们可以被用来计算以及修改类型。这样就可以在实例化的时候让泛型代码适应各种类型或者对不同的类型做出不同的响应。比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 convertible to T
} if constexpr(std::has_virtual_destructor_v<V>) {
... // special code if V has virtual destructor
}
}
};
如上所示,通过检查某些条件,可以在模板的不同实现之间做选择。在这里用到了编译期的if
特性,该特性从C++17开始可用,作为替代选项,这里也可以使用std::enable_if
、部分特例化或者SFINAE。但是使用类型萃取的时候需要额外小心:其行为可能和程序员的预期不同。比如:1
std::remove_const_t<int const&> // yields int const&
这里由于引用不是const类型的(虽然你不可以改变它),这个操作不会有任何效果。这样,删除引用和删除const的顺序就很重要了:1
2std::remove_const_t<std::remove_reference_t<int const&>> // int
std::remove_reference_t<std::remove_const_t<int const&>> // int const
另一种方法是,直接调用:1
std::decay_t<int const&> // yields int
但是这同样会让裸数组和函数类型退化为相应的指针类型。
当然还有一些类型萃取的使用是有要求的。这些要求不被满足的话,其行为将是未定义的。比如:1
2make_unsigned_t<int> // unsigned int
make_unsigned_t<int const&> // undefined behavior (hopefully error)
某些情况下,结果可能会让你很意外。比如:1
2add_rvalue_reference_t<int const> // int const&&
add_rvalue_reference_t<int const&> // int const& (lvalueref remains lvalue-ref)
这里我们期望add_rvalue_reference总是能够返回一个右值引用,但是C++中的引用塌缩会令左值引用和右值引用的组合返回一个左值引用。另一个例子是:1
2is_copy_assignable_v<int> // yields true (generally, you can assign an int to an int)
is_assignable_v<int,int> // yields false (can"t call 42 = 42)
其中is_copy_assignable
通常只会检查是否能够将一个int赋值给另外一个(检查左值的相关操作),而is_assignable
则会考虑值的种类(value category,会检查是否能将一个右值赋值给另外一个)。也就是说第一个语句等效于:1
is_assignable_v<int&,int&> // yields true
对下面的例子也是这样:1
2
3is_swappable_v<int> // yields true (assuming lvalues)
is_swappable_v<int&,int&> // yields true (equivalent to the previous check)
is_swappable_with_v<int,int> // yields false (taking value category into account)
std::addressoff()
函数模板std::addressof<>()
会返回一个对象或者函数的准确地址。即使一个对象重载了运算符&
也是这样。虽然后者中的情况很少遇到,但是也会发生(比如在智能指针中)。因此,如果需要获得任意类型的对象的地址,那么推荐使用addressof()
:1
2
3
4
5
6
7template<typename T>
void f (T&& x)
{
auto p = &x; // might fail with overloaded operator &
auto q = std::addressof(x); // works even with overloaded operator &
...
}
std::declval()
函数模板std::declval()
可以被用作某一类型的对象的引用的占位符。该函数模板没有定义,因此不能被调用(也不会创建对象)。因此它只能被用作不会被计算的操作数(比如decltype
和sizeof
)。也因此,在不创建对象的情况下,依然可以假设有相应类型的可用对象。比如在如下例子中,会基于模板参数T1
和T2
推断出返回类型RT:1
2
3
4
5
6
7
8
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的(默认)构造函数,这里使用了std::declval
,这样可以在不创建对象的情况下“使用”它们。不过该方式只能在不会做真正的计算时(比如decltype)使用。不要忘了使用std::decay<>
来确保返回类型不会是一个引用,因为std::declval<>
本身返回的是右值引用。否则,类似max(1,2)
这样的调用将会返回一个int&&
类型。
完美转发临时变量
可以使用转发引用(forwarding reference)以及std::forward<>
来完美转发泛型参数:1
2
3
4
5template<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()
:1
2
3
4template<typename T>void foo(T x)
{
set(get(x));
}
假设以后我们需要更新代码对get()
的返回值进行某些操作,可以通过将get()
的返回值存储在一个被声明为auto &&
的变量中实现:1
2
3
4
5
6
7template<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));
}
这样可以避免对中间变量的多余拷贝。
作为模板参数的引用
虽然不是很常见,但是模板参数的类型依然可以是引用类型。比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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的类型是被引用的类型,表达式(expression)的类型永远不可能是引用类型)。不过我们可以显示指定T的类型化为引用类型:1
2tmplParamIsReference<int&>(r);
tmplParamIsReference<int&>(i);
这样做可以从根本上改变模板的行为,不过由于这并不是模板最初设计的目的,这样做可能会触发错误或者不可预知的行为。考虑如下例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18template<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来初始化非参数模板参数。
- 最让人意外的是,赋值运算符也不再可用,因为对于具有非static引用成员的类,其默赋值运算符会被删除掉。
而且将引用类型用于非类型模板参数同样会变的复杂和危险。考虑如下例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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<>
中很深层次的错误,因为其元素类型不能被实例化为引用类型:1
2Arr<int&,size> y; // compile-time ERROR deep in the code of class
std::vector<>
可能更糟糕的是将引用用于size这一类参数导致的运行时错误:可能在容器不知情的情况下,自身的size却发生了变化(比如size值变得无效)。如下这样使用size的操作就很可能会导致未定义的行为:1
2
3
4
5int 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 &
来修正这一错误,因为size本身依然是可变的。看上去这一类问题根本就不会发生。但是在更复杂的情况下,确实会遇到此类问题。比如在C++17中,非类型模板参数可以通过推断得到:1
2template<typename T, decltype(auto) SZ>
class Arr;
使用decltype(auto)
很容易得到引用类型,因此在这一类上下文中应该尽量避免使用auto。基于这一原因,C++标准库在某些情况下制定了很特殊的规则和限制。比如:
- 在模板参数被用引用类型实例化的情况下,为了依然能够正常使用赋值运算符,
std::pair<>
和std::tuple<>
都没有使用默认的赋值运算符,而是做了单独的定义。比如:
1 | namespace std { |
- 由于这些副作用可能导致的复杂性,在C++17中用引用类型实例化标准库模板
std::optional<>
和std::variant<>
的过程看上去有些古怪。为了禁止用引用类型进行实例化,一个简单的static_assert
就够了:
1 | template<typename T> |
通常引用类型和其他类型有很大不同,并且受一些语言规则的限制。这会影响对调用参数的声明以及对类型萃取的定义。
推迟计算(Defer Evaluation)
在实现模板的过程中,有时候需要面对是否需要考虑不完整类型的问题。考虑如下的类模板:1
2
3
4
5
6
7template<typename T>
class Cont {
private:
T* elems;
public:
...
};
到目前为止,该class可以被用于不完整类型。这很有用,比如可以让其成员指向其自身的类型。1
2
3
4
5struct Node
{
std::string value;
Cont<Node> next; // only possible if Cont accepts incomplete types
};
但是,如果使用了某些类型萃取的话,可能就不能将其用于不完整类型了。比如:1
2
3
4
5
6
7
8template<typename T>
class Cont {
private:
T* elems;
public:
...
typename std::conditional<std::is_move_constructible<T>::value, T&&, T& >::type foo();
};
这里通过使用std::conditional
来决定foo()
的返回类型是T&&
还是T&
。决策标准是看模板参数T是否支持move语义。问题在于std::is_move_constructible
要求其参数必须是完整类型。使用这种类型的foo()
,struct node
的声明就会报错。为了解决这一问题,需要使用一个成员模板代替现有foo()
的定义,这样就可以将std::is_move_constructible
的计算推迟到foo()
的实例化阶段:1
2
3
4
5
6
7
8template<typename T>
class Cont {
private:
T* elems;
public:
template<typename D = T>
typename std::conditional<std::is_move_constructible<D>::value, T&&, T&>::type foo();
};
现在,类型萃取依赖于模板参数D(默认值是T),并且编译器会一直等到foo()
被以完整类型(比如Node)为参数调用时,才会对类型萃取部分进行计算(此时Node是一个完整类型,其只有在定义时才是非完整类型)。
在写泛型库时需要考虑的事情
- 在模板中使用转发引用来转发数值。如果数值不依赖于模板参数,就使用
auto &&
。 - 如果一个参数被声明为转发引用,并且传递给它一个左值的话,那么模板参数会被推断为引用类型。
- 在需要一个依赖于模板参数的对象的地址的时候,最好使用
std::addressof()
来获取地址,这样能避免因为对象被绑定到一个重载了operator &的类型而导致的意外情况。 - 对于成员函数,需要确保它们不会比预定义的copy/move构造函数或者赋值运算符更能匹配某个调用。
- 如果模板参数可能是字符串常量,并且不是被按值传递的,那么请考虑使用
std::decay
- 如果你有被用于输出或者即用于输入也用于输出的、依赖于模板参数的调用参数,请为可能的、 const类型的模板参数做好准备。
- 请为将引用用于模板参数的副作用做好准备。尤其是在你需要确保返回类型不会是引用的时候。
- 请为将不完整类型用于嵌套式数据结构这一类情况做好准备。
- 为所有数组类型进行重载,而不仅仅是
T[SZ]
。
总结
- 可以将函数,函数指针,函数对象,仿函数和lambdas作为可调用对象(callables)传递给模板。
- 如果需要为一个class重载
operator()
,那么就将其声明为const的(除非该调用会修改它的状态)。 - 通过使用
std::invoke()
,可以实现能够处理所有类型的、可调用对象(包含成员函数)的代码。 - 使用
decltype(auto)
来完美转发返回值。 - 类型萃取是可以检查类型的属性和功能的类型函数。
- 当在模板中需要一个对象的地址时,使用
std::addressof()
。 - 在不经过表达式计算的情况下,可以通过使用
std::declval()
创建特定类型的值。 - 在泛型代码中,如果一个对象不依赖于模板参数,那么就使用
auto&&
来完美转发它。 - 可以通过模板来延迟表达式的计算(这样可以在class模板中支持不完整类型)。
深入模板
C++ 目前支持四种基本类型的模板:类模板、函数模板、变量模板和别名模板。这些模板类型中的每一种都可以出现在命名空间范围内,也可以出现在类范围内。在类范围内,它们成为嵌套类模板、成员函数模板、静态数据成员模板和成员别名模板。注意 C++17 引入了另一个构造:演绎指南。这些在本书中不被称为模板,但选择的语法是为了让人想起函数模板。首先,一些例子说明了四种模板。它们可以出现在命名空间范围内(全局或在命名空间中),如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18template<typename T> // a namespace scope class template
class Data {
public:
static constexpr bool copyable = true;
...
};
template<typename T> // a namespace scope function template
void log (T x) {
...
}
template<typename T> // a namespace scope variable template (since C++14)
T zero = 0;
template<typename T> // a namespace scope variable template (since C++14)
bool dataCopyable = Data<T>::copyable;
template<typename T> // a namespace scope alias template
using DataList = Data<T*>;
请注意,在此示例中,静态数据成员Data<T>::copyable
不是变量模板,即使它是通过类模板 Data 的参数化间接参数化的。但是,变量模板可以出现在类范围内(如下例所示),在这种情况下,它是一个静态数据成员模板。以下示例将四种模板显示为在其父类中定义的类成员:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Collection {
public:
template<typename T> // an in-class member class template definition
class Node {
...
};
template<typename T> // an in-class (and therefore implicitly inline)
T* alloc() { //member function template definition
...
}
template<typename T> // a member variable template (since C++14)
static T zero = 0;
template<typename T> // a member alias template
using NodePtr = Node<T>*;
};
请注意,在 C++17 中,变量(包括静态数据成员)和变量模板可以“内联”,这意味着它们的定义可以跨翻译单元重复。这对于变量模板来说是多余的,它总是可以在多个翻译单元中定义。然而,与成员函数不同的是,在其封闭类中定义的静态数据成员不会使其内联:关键字 inline 在所有情况下都必须指定。
最后,以下代码演示了如何在类外定义非别名模板的成员模板:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30template<typename T> // a namespace scope class template
class List {
public:
List() = default; // because a template constructor is defined
template<typename U> // another member class template,
class Handle; // without its definition
template<typename U> // a member function template
List (List<U> const&); // (constructor)
template<typename U> // a member variable template (since C++14)
static U zero;
};
template<typename T> // out-of-class member class template definition
template<typename U>
class List<T>::Handle {
...
};
template<typename T> // out-of-class member function template definition
template<typename T2>
List<T>::List (List<T2> const& b)
{
...
}
template<typename T> // out-of-class static data member template definition
template<typename U>
U List<T>::zero = 0;
在其封闭类之外定义的成员模板可能需要多个模板参数化子句:一个用于每个封闭类模板,一个用于成员模板本身。从最外层的类模板开始列出子句。
另请注意,构造函数模板(一种特殊的成员函数模板)禁用默认构造函数的隐式声明(因为只有在没有声明其他构造函数时才隐式声明它)。添加默认声明List() = default
;确保List<T>
的实例是默认可构造的,具有隐式声明的构造函数的语义。
联合模板联合模板也是可能的(它们被认为是一种类模板):1
2
3
4
5template<typename T>
union AllocChunk {
T object;
unsigned char bytes[sizeof(T)];
};
函数模板可以像普通函数声明一样具有默认调用参数:1
2
3
4
5template<typename T>
void report_top (Stack<T> const&, int number = 10);
template<typename T>
void fill (Array<T>&, T const& = T{}); // T{} is zero for built-in types
后一个声明表明默认调用参数可能依赖于模板参数。也可以定义为1
2template<typename T>
void fill (Array<T>&, T const& = T()); // T() is zero for built-in types
调用fill()
函数时,如果提供了第二个函数调用参数,则不会实例化默认参数。这样可以确保如果无法为特定 T 实例化默认调用参数,则不会发出错误。例如:1
2
3
4
5
6
7
8
9
10class Value {
public:
explicit Value(int); // no default constructor
};
void init (Array<Value>& array)
{
Value zero(0);
fill(array, zero); // OK: default constructor not used
fill(array); // ERROR: undefined default constructor for Value is used
}
类模板的非模板成员
除了在类中声明的四种基本模板之外,您还可以通过作为类模板的一部分来参数化普通类成员。它们有时(错误地)也称为成员模板。它们的参数完全由它们所属的模板决定。例如:1
2
3
4
5
6
7
8template<int I>
class CupBoard
{
class Shelf; // ordinary class in class template
void open(); // ordinary function in class template
enum Wood : unsigned char; // ordinary enumeration type in class template
static double totalWeight; // ordinary static data member in class template
};
相应的定义只为父类模板指定了参数化子句,但没有为成员本身指定一个参数化子句,因为它不是模板(即,没有参数化子句与出现在最后一个::
之后的名称相关联):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15template<int I> // definition of ordinary class in class template
class CupBoard<I>::Shelf {
...
};
template<int I> // definition of ordinary function in class template
void CupBoard<I>::open()
{
...
}
template<int I> // definition of ordinary enumeration type class in class template
enum CupBoard<I>::Wood {
Maple, Cherry, Oak
};
template<int I> // definition of ordinary static member in class template
double CupBoard<I>::totalWeight = 0.0;
从C++17开始,静态的totalWeight
成员可以在模板类内初始化1
2
3
4template<int I>
class CupBoard {
inline static double totalWeight = 0.0;
};
尽管此类参数化定义通常称为模板,但该术语并不完全适用于它们。偶尔为这些实体提出的术语是temploid
。自 C++17 以来,C++ 标准确实定义了模板化实体的概念,它包括模板和模板以及递归地在模板化实体中定义或创建的任何实体。到目前为止,模板实体和模板实体都没有获得太大的吸引力,但它们可能是将来更准确地传达 C++ 模板的有用术语。
虚拟成员函数
成员函数模板不能声明为虚拟的。施加此约束是因为虚函数调用机制的通常实现使用一个固定大小的表,每个虚函数有一个条目。然而,成员函数模板的实例化数量在整个程序被翻译之前是不固定的。因此,支持虚拟成员函数模板需要在 C++ 编译器和链接器中支持一种全新的机制。
相反,类模板的普通成员可以是虚拟的,因为它们的数量在类被实例化时是固定的:
模板外联
每个模板都必须有一个名称,并且该名称在其范围内必须是唯一的,除了函数模板可以重载。特别注意,与类类型不同,类模板不能与不同类型的实体共享名称:1
2
3
4
5
6
7
8
9
10int C;
class C; // OK: class names and nonclass names are in a different “space”
int X;
...
template<typename T>
class X; // ERROR: conflict with variable X
struct S;
...
template<typename T>
class S; // ERROR: conflict with struct S
模板名称有链接,但不能有 C 链接。非标准链接可能具有依赖于实现的含义(但是,我们不知道支持模板的非标准名称链接的实现):1
2
3
4
5
6
7
8
9extern "C++" template<typename T>
void normal(); //this is the default: the linkage specification could be left out
extern "C" template<typename T>
void invalid(); //ERROR: templates cannot have C linkage
extern "Java" template<typename T>
void javaLink(); //non standard, but maybe some compiler will someday
// support linkage compatible with Java generics
模板通常具有外部链接。唯一的例外是具有静态说明符的命名空间范围函数模板、作为未命名命名空间的直接或间接成员(具有内部链接)的模板以及未命名类的成员模板(没有链接)。例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21template<typename T> // refers to the same entity as a declaration of the same name (and scope) in another file
void external();
template<typename T> // unrelated to a template with the same name in another file
static void internal();
template<typename T> // redeclaration of the previous declaration
static void internal();
namespace {
template<typename> // also unrelated to a template with the same name
void otherInternal(); // in another file, even one that similarly appears
} //in an unnamed namespace
namespace {
template<typename> // redeclaration of the previous template declaration
void otherInternal();
}
struct {
template<typename T> void f(T) {} //no linkage: cannot be redeclared
} x;
请注意,由于后一个成员模板没有链接,因此必须在未命名的类中定义它,因为无法在类之外提供定义。
目前模板不能在函数作用域或局部类作用域中声明,但是具有包含成员函数模板的相关闭包类型的通用 lambdas可以出现在局部作用域中,这实际上意味着一种本地成员函数模板。
模板实例的链接就是模板的链接。例如,从上面声明的模板 internal 实例化的函数internal<void>()
将具有内部链接。这在变量模板的情况下会产生一个有趣的结果。实际上,请考虑以下示例:1
template<typename T> T zero = T{};
zero
的所有实例都有外部链接,甚至像zero<int const>
这样的东西。考虑到int const zero_int = int{};
,这可能是违反直觉的,但也具有内部链接,因为它是用 const 类型声明的。同样,模板的所有实例化都有外部链接,尽管所有这些实例也具有 int const 类型。1
template<typename T> int const max_volume = 11;
主要模板
模板的正常声明声明了主模板。此类模板声明的声明无需在模板名称后的尖括号中添加模板参数:1
2
3
4
5
6template<typename T> class Box; // OK: primary template
template<typename T> class Box<T>; // ERROR: does not specialize
template<typename T> void translate(T); // OK: primary template
template<typename T> void translate<T>(T); // ERROR: not allowed for functions
template<typename T> constexpr T zero = T{}; // OK: primary template
template<typename T> constexpr T zero<T> = T{}; // ERROR: does not specialize
声明类或变量模板的部分特化时会出现非主模板。函数模板必须始终是主模板。
模板参数
有三个基本的模板参数
- 类型参数
- 非类型参数
- 模板模板参数
模板参数在临时声明的介绍性参数化子句中声明,这些声明不需要命名:1
2template<typename, int>
class X
当然,如果稍后在模板中引用参数,则需要参数名。还请注意,模板参数名称可以在后续参数声明中引用(但不能在此之前):1
2
3
4
5template<typename T, T Root, template<T> class Buf>
//the first parameter is used
//in the declaration of the second one and
// in the declaration of the third one
class Structure;
类型参数
类型参数与关键字typename或关键字class一起引入:两者完全等效。关键字后面必须跟一个简单标识符,该标识符后面必须跟一个逗号以表示下一个参数声明的开始,一个收尾角括号(>)以表示参数化子句的结束,或者一个等号(=)以表示默认模板参数的开始。在模板声明中,类型参数的作用很像类型别名。例如,当T是模板参数时,即使T被类类型替换,也不可能使用格式类T的详细名称:1
2
3
4
5
6template<typename Allocator>
class List {
class Allocator* allocptr; // ERROR: use “Allocator* allocptr”
friend class Allocator; // ERROR: use “friend Allocator”
...
};
非类型参数
非类型模板参数代表可在编译或链接时确定的常量值。此类参数的类型(换句话说,它所代表的值的类型)必须是以下类型之一:
- 整数类型或枚举类型
- 指针类型
- 指向成员类型的指针
- 左值引用类型(对象引用和函数引用均可接受)
std::nullptr_t
- 包含auto或decltype(自动)的类型
目前排除了所有其他类型(尽管将来可能会添加浮点类型)。也许令人惊讶的是,在某些情况下,非类型模板参数的声明也可以以关键字typename或者class开始:1
2
3
4
5
6template<typename T, //a type parameter
typename T::Allocator* Allocator> // a nontype parameter
class List;
template<class X*> // a nontype parameter of pointer type
class Y;
这两种情况很容易区分,因为第一种情况后面跟着一个简单的标识符,然后是一组小标记中的一个(“=”表示默认参数,“,”表示后面跟着另一个模板参数,或是结束>表示模板参数列表)。
可以指定函数和数组类型,但它们会隐式地调整为它们所退化到的指针类型:1
2
3
4template<int buf[5]> class Lexer; // buf is really an int*
template<int* buf> class Lexer; // OK: this is a redeclaration
template<int fun()> struct FuncWrap; // fun really has pointer to function type
template<int (*)()> struct FuncWrap; // OK: this is a redeclaration
非类型模板参数的声明非常类似于变量,但它们不能具有静态、可变等非类型说明符。它们可以有常量和volatile限定符,但如果这样的限定符出现在参数类型的最外层,它将被忽略:1
2template<int const length> class Buffer; // const is useless here
template<int length> class Buffer; // same as previous declaration
最后,在表达式中使用非引用非类型参数时,它们始终是prvalues。他们的地址无法获取,也无法分配给。另一方面,左值引用类型的非类型参数可用于表示左值:1
2
3
4
5template<int& Counter>
struct LocalIncrement {
LocalIncrement() { Counter = Counter + 1; } //OK: reference to an integer
~LocalIncrement() { Counter = Counter - 1; }
};
模板模板参数
模板模板参数是类模板或别名模板的占位符。它们的声明非常类似于类模板,但不能使用关键字struct和union:1
2
3
4
5
6
7
8template<template<typename X> class C> // OK
void f(C<int>* p);
template<template<typename X> struct C> // ERROR: struct not valid here
void f(C<int>* p);
template<template<typename X> union C> // ERROR: union not valid here
void f(C<int>* p);
C++17允许使用typename而不是class:这一变化的动机是,模板参数不仅可以由类模板替换,还可以由别名模板(实例化为任意类型)替换。因此,在C++17中,我们上面的示例可以写成1
2template<template<typename X> typename C> // OK since C++17
void f(C<int>* p);
在其声明范围内,模板参数的使用与其他类或别名模板一样。
模板参数的参数可以具有默认模板参数。如果在使用模板参数时未指定相应的参数,则这些默认参数适用:1
2
3
4
5template<template<typename T, typename A = MyAllocator> class Container>
class Adaptation {
Container<int> storage; // implicitly equivalent to Container<int,MyAllocator>
...
};
T和A是模板参数容器的模板参数的名称。这些名称只能在声明该模板参数的其他参数时使用。以下模板说明了此概念:1
2
3
4
5template<template<typename T, T*> class Buf> // OK
class Lexer {
static T* storage; // ERROR: a template template parameter cannot be used here
...
};
但是,通常在声明其他模板参数时不需要模板参数的模板参数名称,因此通常不命名。例如,我们早期的适应模板可以声明如下:1
2
3
4
5template<template<typename, typename = MyAllocator> class Container>
class Adaptation {
Container<int> storage; // implicitly equivalent to Container<int,MyAllocator>
...
};
模板参数包
自C++11以来,任何类型的模板参数都可以通过在模板参数名称之前引入省略号(…)转换为模板参数包,或者,如果模板参数未命名,则模板参数名称将出现在以下位置:1
2template<typename... Types> // declares a template parameter pack named Types
class Tuple;
模板参数包的行为与其基础模板参数类似,但有一个关键区别:虽然普通模板参数只匹配一个模板参数,但模板参数包可以匹配任意数量的模板参数。这意味着上面声明的元组类模板接受任意数量(可能不同)的类型作为模板参数:1
2
3
4using IntTuple = Tuple<int>; // OK: one template argument
using IntCharTuple = Tuple<int, char>; // OK: two template arguments
using IntTriple = Tuple<int, int, int>; // OK: three template arguments
using EmptyTuple = Tuple<>; // OK: zero template arguments
类似地,非类型和模板参数的模板参数包可以分别接受任意数量的非类型或模板参数:1
2
3
4
5template<typename T, unsigned... Dimensions>
class MultiArray; // OK: declares a nontype template parameter pack
using TransformMatrix = MultiArray<double, 3, 3>; // OK: 3x3 matrix
template<typename T, template<typename, typename>... Containers>
void testContainers(); // OK: declares a template template parameter pack
MultiArray示例要求所有非类型模板参数都是同一类型的无符号参数。C++17引入了推导非类型模板参数的可能性,这允许我们在某种程度上绕过该限制
主类模板、变量模板和别名模板最多可以有一个模板参数包,如果存在,模板参数包必须是最后一个模板参数。函数模板有一个较弱的限制:允许使用多个模板参数包,只要模板参数包后面的每个模板参数都有一个默认值(请参见下一节)或可以推断:1
2
3
4
5
6
7
8
9
10template<typename... Types, typename Last>
class LastType; // ERROR: template parameter pack is not the last template parameter
template<typename... TestTypes, typename T>
void runTests(T value); // OK: template parameter pack is followed by a deducible template parameter
template<unsigned...> struct Tensor;
template<unsigned... Dims1, unsigned... Dims2>
auto compose(Tensor<Dims1...>, Tensor<Dims2...>);
// OK: the tensor dimensions can be deduced
最后一个例子是一个带有推导返回类型的函数声明。
类和变量模板的部分特化声明可以有多个参数包,这与它们的主要模板对应物不同。这是因为部分专业化是通过与用于函数模板的推导过程几乎相同的推导过程来选择的。1
2
3
4
5
6template<typename...> Typelist;
template<typename X, typename Y> struct Zip;
template<typename... Xs, typename... Ys>
struct Zip<Typelist<Xs...>, Typelist<Ys...>>;
// OK: partial specialization uses deduction to determine
// theXs and Ys substitutions
也许并不奇怪,类型参数包不能在其自己的参���子句中扩展。例如:1
2template<typename... Ts, Ts... vals> struct StaticValues {};
// ERROR: Ts cannot be expanded in its own parameter list
但是,嵌套模板可以创建类似的有效情况:1
2
3
4template<typename... Ts> struct ArgList {
template<Ts... vals> struct Vals {};
};
ArgList<int, char, char>::Vals<3, 'x', 'y'> tada;
默认模板参数
任何不是模板形参包的模板形参都可以配备默认实参,尽管它必须与相应的实参相匹配(例如,类型形参不能有非类型默认实参)。默认参数不能依赖于它自己的参数,因为参数的名称在默认参数之后才在范围内。但是,它可能取决于以前的参数:1
2template<typename T, typename Allocator = allocator<T>>
class List;
仅当还为后续参数提供了默认参数时,类模板、变量模板或别名模板的模板参数才能具有默认模板参数。(默认函数调用参数存在类似的约束。)后续的默认值通常在同一个模板声明中提供,但它们也可以在该模板的先前声明中声明。下面的例子清楚地说明了这一点:1
2
3
4
5
6
7
8template<typename T1, typename T2, typename T3, typename T4 = char, typename T5 = char>
class Quintuple; // OK
template<typename T1, typename T2, typename T3 = char, typename T4, typename T5>
class Quintuple; // OK: T4 and T5 already have defaults
template<typename T1 = char, typename T2, typename T3, typename T4, typename T5>
class Quintuple; // ERROR: T1 cannot have a default argument because T2 doesn’t have a default
函数模板的模板形参的默认模板实参不需要后续模板形参具有默认模板实参:1
2template<typename R = void, typename T>
R* addressof(T& value); // OK: if not explicitly specified, R will be void
默认模板参数不能重复:1
2
3
4
5template<typename T = void>
class Value;
template<typename T = void>
class Value; // ERROR: repeated default argument
许多地方不允许默认模板参数:
- 部分特化:
1 | template<typename T> |
- 参数包:
1 | template<typename... Ts = int> struct X; // ERROR |
- 类模板成员的类外定义:
1 | template<typename T> struct X |
- 友类模板声明:
1 | struct S { |
- 友元函数模板声明,除非它是一个定义并且在翻译单元的其他任何地方都没有出现它的声明:
1 | struct S { |
模板参数
实例化模板时,模板参数由模板参数替换。可以使用几种不同的机制来确定参数:
- 显式模板参数:模板名称后面可以跟用尖括号括起来的显式模板参数。生成的名称称为模板 ID。
- 注入的类名:在具有模板参数 P1、P2、……的类模板 X 的范围内,该模板的名称 (X) 可以等同于模板ID
X<P1, P2, ...>
。 - 默认模板参数:如果默认模板参数可用,则可以从模板实例中省略显式模板参数。但是,对于类或别名模板,即使所有模板参数都有默认值,也必须提供(可能为空的)尖括号。
- 参数推导:未显式指定的函数模板参数可以从调用中的函数调用参数的类型推导出来。在其他一些情况下也进行了演绎。如果可以推导出所有模板参数,则不需要在函数模板名称后指定尖括号。C++17 还引入了从变量声明或函数符号类型转换的初始化程序推导出类模板参数的能力。
函数模板参数
函数模板的模板参数可以显式指定,从模板的使用方式推导出来,或者作为默认模板参数提供。例如:1
2
3
4
5
6
7
8
9
10
11
12template<typename T>
T max (T a, T b)
{
return b < a ? a : b;
}
int main()
{
::max<double>(1.0, -3.0); // explicitly specify template argument
::max(1.0, -3.0); // template argument is implicitly deduced to be double
::max<int>(1.0, 3.0); // the explicit <int> inhibits thededuction;
// hence the result has type int
}
某些模板参数永远无法推导出来,因为它们对应的模板参数没有出现在函数参数类型中或出于某些其他原因。相应的参数通常放置在模板参数列表的开头,因此可以显式指定它们,同时允许推导其他参数。例如:1
2
3
4
5
6
7
8
9template<typename DstT, typename SrcT>
DstT implicit_cast (SrcT const& x) // SrcT can be deduced, but DstT cannot
{
return x;
}
int main()
{
double value = implicit_cast<double>(-1);
}
如果我们在此示例中颠倒了模板参数的顺序(换句话说,如果我们编写了template<typename SrcT, typename DstT>
),则对implicit_cast
的调用将必须显式指定两个模板参数。此外,此类参数不能有用地放置在模板参数包之后或出现在部分特化中,因为无法显式指定或推断它们。1
2
3template<typename ... Ts, int N>
void f(double (&)[N+1], Ts ... ps); // useless declaration because N
// cannot be specified or deduced
因为函数模板可以重载,显式提供函数模板的所有参数可能不足以标识单个函数:在某些情况下,它标识一组函数。以下示例说明了此观察的结果:1
2
3
4
5
6
7
8
9
10
11
12
13template<typename Func, typename T>
void apply (Func funcPtr, T x)
{
funcPtr(x);
}
template<typename T> void single(T);
template<typename T> void multi(T);
template<typename T> void multi(T*);
int main()
{
apply(&single<int>, 3); // OK
apply(&multi<int>, 7); // ERROR: no single multi<int>
}
在此示例中,第一次调用apply()
有效,因为表达式&single<int>
的类型是明确的。结果,很容易推导出Func
参数的模板参数值。然而,在第二次调用中,&multi<int>
可能是两种不同类型之一,因此在这种情况下无法推断出Func
。
此外,在函数模板中替换模板参数可能会导致尝试构造无效的 C++ 类型或表达式。考虑以下重载函数模板(RT1 和 RT2 是未指定的类型):1
2template<typename T> RT1 test(typename T::X const*);
template<typename T> RT2 test(...);
表达式test<int>
对于两个函数模板中的第一个没有意义,因为 int 类型没有成员类型 X。但是,第二个模板没有这样的问题。因此,表达式&test<int>
标识单个函数的地址。将 int 替换为第一个模板失败的事实并不会使表达式无效。
非类型参数
非类型模板参数是替代非类型参数的值。这样的值必须是以下事物之一:
- 另一个具有正确类型的非类型模板参数。
- 整数(或枚举)类型的编译时常量值。仅当相应参数具有与值的类型匹配的类型或值可以隐式转换为的类型而不缩小时,这才是可接受的。例如,可以为 int 参数提供 char 值,但赋值 500 对 8 位 char 参数无效。
- 以内置一元&(“地址”)运算符开头的外部变量或函数的名称。对于函数和数组变量,& 可以省略。这样的模板参数匹配指针类型的非类型参数。C++17 放宽了这个要求,允许任何产生指向函数或变量的指针的常量表达式。
- 前一种类型的参数但没有前导& 运算符是引用类型的非类型参数的有效参数。在这里,C++17 也放宽了约束,允许函数或变量使用任何常量表达式泛左值。
- 指向成员常量的指针;换句话说,
&C::m
形式的表达式,其中 C 是类类型,m 是非静态成员(数据或函数)。这仅匹配指向成员类型的非类型参数。再一次,在 C++17 中,实际的句法形式不再受到限制:允许任何对匹配的指向成员常量的指针求值的常量表达式。 - 空指针常量是指针或成员指针类型的非类型参数的有效参数。
对于整型的非类型参数——可能是最常见的非类型参数——考虑到参数类型的隐式转换。随着 C++11 中 constexpr 转换函数的引入,这意味着转换前的参数可以具有类类型。
在 C++17 之前,当将参数与作为指针或引用的参数匹配时,不考虑用户定义的转换(一个参数的构造函数和转换运算符)和派生到基的转换,即使在其他情况下也是如此它们将是有效的隐式转换。使参数更 const 和/或更易变的隐式转换是可以的。以下是非类型模板参数的一些有效示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23template<typename T, T nontypeParam>
class C;
C<int, 33>* c1; // integer type
int a;
C<int*, &a>* c2; // address of an external variable
void f();
void f(int);
C<void (*)(int), f>* c3; // name of a function: overload resolution selects
// f(int) in this case; the & is implied
template<typename T> void templ_func();
C<void(), &templ_func<double>>* c4; // function template instantiations are functions
struct X {
static bool b;
int n;
constexpr operator int() const { return 42; }
};
C<bool&, X::b>* c5; // static class members are acceptable variable/function names
C<int X::*, &X::n>* c6; // an example of a pointer-to-member constant
C<long, X{}>* c7; // OK: X is first converted to int viaa constexpr conversion
// function and then to long via a standard integer conversion
模板参数的一般约束是编译器或链接器必须能够在构建程序时表达它们的值。直到程序运行才知道的值(例如,局部变量的地址)与构建程序时实例化模板的概念不兼容。即便如此,有一些常量值当前无效:
- 浮点数字
- 字符串文字(在 C++11 之前,也不允许空指针常量。)
字符串文字的问题之一是两个相同的文字可以存储在两个不同的地址。表达通过常量字符串实例化的模板的另一种(但繁琐)方法涉及引入一个额外的变量来保存字符串:1
2
3
4
5
6
7
8
9
10
11
12
13template<char const* str>
class Message {
...
};
extern char const hello[] = "Hello World!";
char const hello11[] = "Hello World!";
void foo()
{
static char const hello17[] = "Hello World!";
Message<hello> msg03; // OK in all versions
Message<hello11> msg11; // OK since C++11
Message<hello17> msg17; // OK since C++17
}
要求是声明为引用或指针的非类型模板参数可以是具有所有 C++ 版本中的外部链接、自 C++11 以来的内部链接或自 C++17 以来的任何链接的常量表达式。1
2
3
4
5
6
7
8
9
10template<typename T, T nontypeParam>class C;
struct Base {
int i;
} base;
struct Derived : public Base {
} derived;
C<Base*, &derived>* err1; // ERROR: derived-to-base conversions are not considered
C<int&, base.i>* err2; // ERROR: fields of variables aren’t considered to be variables
int a[10];
C<int*, &a[0]>* err3; // ERROR: addresses of array elements aren’t acceptable either
模板模板参数
模板模板参数通常必须是类模板或别名模板,其参数与它所替代的模板模板参数的参数完全匹配。在 C++17 之前,模板模板实参的默认模板实参被忽略(但如果模板模板形参具有默认实参,则在模板的实例化过程中会考虑它们)。C++17 放宽了匹配规则,只要求模板模板参数至少与相应的模板模板参数一样专门化。这使得以下示例在 C++17 之前无效:1
2
3
4
5
6
7
8
9
10
11
// declares in namespace std:
// template<typename T, typename Allocator = allocator<T>>
// class list;
template<typename T1, typename T2, template<typename> class Cont> // Cont expects one parameter
class Rel {
...
};
Rel<int, double, std::list> rel; // ERROR before C++17: std::list has more than
// one template parameter
这个例子的问题是标准库的std::list
模板有多个参数。第二个参数(描述分配器)具有默认值,但在 C++17 之前,将std::list
与 Container 参数匹配时不考虑该值。
可变参数模板模板参数是上述 C++17 之前的“精确匹配”规则的一个例外,并提供了对此限制的解决方案:它们可以对模板模板参数进行更一般的匹配。模板模板参数包可以匹配模板模板参数中的零个或多个相同类型的模板参数:1
2
3
4
5
6
7
8
template<typename T1, typename T2,
template<typename... > class Cont> // Cont expects any number of
class Rel { // type parameters
...
};
Rel<int, double, std::list> rel; // OK: std::list has two template parameters
// but can be used with one argument
模板中的名称
名称是大多数编程语言中的基本概念。它们是程序员可以引用先前构造的实体的方法。当 C++ 编译器遇到名称时,它必须“查找”以识别所引用的实体。从实现者的角度来看,C++ 在这方面是一门硬语言。考虑 C++ 语句 x*y;。如果 x 和 y 是变量的名称,则该语句是乘法,但如果 x 是类型的名称,则该语句将 y 声明为指向 x 类型的实体的指针。
这个小例子表明 C++(和 C 一样)是一种上下文相关的语言:一个结构在不知道其更广泛的上下文的情况下总是无法被理解。这与模板有什么关系?好吧,模板是必须处理多个更广泛上下文的构造:
- 模板出现的上下文,
- 模板实例化的上下文,
- 与模板参数相关联的上下文
因此,在 C++ 中必须非常小心地处理“名称”也就不足为奇了。
名称分类
C++ 以多种方式对名称进行分类——事实上,方式多种多样。幸运的是,您可以通过熟悉两个主要的命名概念来深入了解大多数 C++ 模板问题:
- 如果名称所属的范围使用范围解析运算符 (::) 或成员访问运算符 (. 或 ->) 明确表示,则名称是限定名称。例如,
this->count
是一个限定名,但count
不是(即使普通的count
实际上可能指的是一个类成员)。 - 如果名称以某种方式依赖于模板参数,则该名称是从属名称。例如,
std::vector<T>::iterator
通常是一个从属名称,如果T 是模板参数,但如果 T 是已知类型别名,则它是非依赖名称。
查找名称
在限定构造所暗示的范围内查找限定名称。如果该范围是一个类,则还可以搜索基类。但是,在查找限定名称时不考虑封闭范围。以下说明了这一基本原则:1
2
3
4
5
6
7
8
9
10
11
12int x;
class B {
public:
int i;
};
class D : public B {
};
void f(D* pd)
{
pd->i = 3; // finds B::i
D::x = 2; // ERROR: does not find ::x in the enclosing scope
}
相比之下,非限定名称通常在连续更封闭的范围内查找(尽管在成员函数定义中,类及其基类的范围在任何其他封闭范围之前搜索)。这称为普通查找。这是一个基本示例,显示了普通查找的主要思想:1
2
3
4
5
6
7
8
9extern int count; // #1
int lookup_example(int count) // #2
{
if (count < 0) {
int count = 1; // #3
lookup_example(count); // unqualified count refers to #3
}
return count + ::count; // the first (unqualified) count refers to #2;
} //the second (qualified) countrefers to #1
对非限定名称的查找最近的一个转折是它们有时可能会进行参数相关的查找 (ADL)。在继续详细介绍 ADL 之前,让我们激发该机制:1
2
3
4
5template<typename T>
T max (T a, T b)
{
return b < a ? a : b;
}
现在假设我们需要将此模板应用到另一个命名空间中定义的类型:1
2
3
4
5
6
7
8
9
10
11
12namespace BigMath {
class BigNumber {
...
};
bool operator < (BigNumber const&, BigNumber const&);
...
}
using BigMath::BigNumber;
void g (BigNumber const& a, BigNumber const& b)
{
BigNumber x = ::max(a,b);
}
这里的问题是max()
模板不知道BigMath
命名空间,但普通查找不会找到适用于BigNumber
类型值的运算符<
。如果没有一些特殊的规则,这会大大降低模板在存在 C++ 命名空间的情况下的适用性。ADL 是对这些“特殊规则”的 C++ 答案。
参数相关查找ADL
ADL 主要适用于看起来像是在函数调用或运算符调用中命名非成员函数的非限定名称。如果普通查找发现以下情况,ADL 不会发生
- 成员函数的名称,
- 变量的名称,
- 类型的名称,或
- 块作用域函数声明的名称。
如果要调用的函数的名称用括号括起来,ADL 也会被禁止。
否则,如果名称后跟括在括号中的参数表达式列表,ADL 会继续在名称空间和与调用参数类型“关联”的类中查找名称。这些关联的命名空间和相关类的精确定义在后面给出,但直观地它们可以被认为是与给定类型相当直接连接的所有命名空间和类。例如,如果类型是指向类 X 的指针,则关联的类和命名空间将包括 X 以及 X 所属的任何命名空间或类。
给定类型的关联命名空间和关联类集的精确定义由以下规则确定:
- 对于内置类型,这是空集。
- 对于指针和数组类型,关联命名空间和类的集合是基础类型的集合。
- 对于枚举类型,关联的命名空间是声明枚举的命名空间。
- 对于类成员,封闭类是关联类。
- 对于类类型(包括联合类型),关联类的集合是类型本身、封闭类以及任何直接和间接基类。关联命名空间的集合是声明相关类的命名空间。如果类是类模板实例,则还包括模板类型参数的类型以及声明模板模板参数的类和命名空间。
- 对于函数类型,相关命名空间和类的集合包括与所有参数类型相关的命名空间和类以及与返回类型相关的命名空间和类。
- 对于指向X 类成员的指针类型,关联的命名空间和类集包括与X 关联的那些以及与成员类型关联的那些。然后,ADL 在所有关联的命名空间中查找名称,就好像该名称已被这些命名空间中的每一个依次限定一样,除了 using 指令被忽略。以下示例说明了这一点:
1 |
|
解析模板
大多数编程语言的编译器的两个基本活动是标记化(也称为扫描或词法分析)和解析。标记化过程将源代码作为字符序列读取,并从中生成标记序列。例如,在看到字符序列int* p = 0;
时,“tokenizer”将为关键字int、符号/运算符 、标识符 p、符号/运算符 =、整数文字 0 生成token描述,和一个符号/运算符;
。然后,解析器将通过递归地将标记或先前找到的模式减少到更高级别的构造中来找到标记序列中的已知模式。例如,标记 0 是一个有效的表达式,后跟标识符 p 的组合`` 是一个有效的声明符,而后跟“=”的声明符和表达式“0”是一个有效的 init 声明符。最后,关键字 int 是一个已知的类型名称,并且当其后跟 init-declarator *p = 0 时,您将获得 p 的初始化声明。
非模板中的上下文敏感性
正如您可能知道或期望的那样,标记化比解析更容易。幸运的是,解析是一个已经发展了坚实理论的学科,并且许多有用的语言使用这个理论并不难解析。然而,该理论最适用于上下文无关语言,我们已经注意到 C++ 是上下文敏感的。为了处理这个问题,C++ 编译器将符号表耦合到标记器和解析器:当解析声明时,它被输入到符号表中。当标记器找到一个标识符时,它会查找它并在找到类型时注释结果标记。
例如,如果 C++ 编译器看到x*
,分词器查找x
。如果它找到一个类型,解析器会看到1
2identifier, type, x
symbol, *
并得出声明已开始的结论。但是,如果 x 不是类型,则解析器从分词器接收1
2identifier, nontype, x
symbol, *
并且该构造只能作为乘法进行有效解析。这些原则的细节取决于特定的实施策略,但要点应该在那里。以下表达式说明了上下文敏感性的另一个示例:1
X<1> (0)
如果 X 是类模板的名称,则前面的表达式将整数 0 转换为从该模板生成的类型X<1>
。如果 X 不是模板,那么前面的表达式等价于1
(X<1)>0
换句话说,X 与 1 进行比较,并且比较的结果与 0 进行比较。虽然这样的代码很少使用,但它是有效的 C++。因此,C++ 解析器将查找出现在 < 之前的名称,并且仅当已知名称是模板的名称时,才将 < 视为尖括号;否则,< 被视为普通的小于运算符。
这种形式的上下文敏感性是选择尖括号来分隔模板参数列表的不幸结果。这是另一个这样的后果:1
2
3
4
5
6
7
8
9template<bool B>
class Invert {
public:
static bool const result = !B;
};
void g()
{
bool test = Invert<(1>0)>::result; // parentheses required!
}
如果省略Invert<(1>0)>
中的括号,则大于号将被误认为是模板参数列表的结束。这将使代码无效,因为编译器会将其读取为等效于((Invert<1>))0>::result
。分词器也不能幸免尖括号符号的问题。例如,在1
2List<List<int>> a;
// ^-- no space between right angle brackets
两个>
字符组合成一个右移标记>>
,因此标记器永远不会将其视为两个单独的标记。C++ 实现必须将尽可能多的连续字符收集到一个标记中。
从 C++11 开始,C++ 标准专门修改了这种情况。
实例化
模板实例化是从通用模板定义生成类型、函数和变量的过程。C++ 模板实例化的概念是基本的,但也有些复杂。这种复杂性的根本原因之一是模板生成的实体的定义不再局限于源代码中的单个位置。模板的位置、使用模板的位置以及定义模板参数的位置都对实体的含义起作用。
在本章中,我们将解释如何组织源代码以启用正确的模板使用。此外,我们调查了最流行的 C++ 编译器用于处理模板实例化的各种方法。尽管所有这些方法在语义上都应该是等价的,但了解编译器实例化策略的基本原理还是很有用的。在构建实际软件时,每种机制都有其一组小怪癖,相反,每一种都会影响标准 C++ 的最终规范。
按需实例化
当 C++ 编译器遇到模板特化的使用时,它将通过用所需的参数替换模板参数来创建该特化。这是自动完成的,不需要客户端代码(或模板定义,就此而言)的指示。这种按需实例化功能将 C++ 模板与其他早期编译语言(如 Ada 或 Eiffel;其中一些语言需要显式实例化指令,而另一些使用运行时调度机制来完全避免实例化过程)中的类似设施区分开来。它有时也称为隐式或自动实例化。
按需实例化意味着编译器通常需要在使用时访问模板及其某些成员的完整定义(换句话说,不仅仅是声明)。考虑以下微小的源代码文件:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16template<typename T> class C; // #1 declaration only
C<int>* p = 0; // #2 fine: definition of C<int> not needed
template<typename T>
class C {
public:
void f(); // #3 member declaration
}; // #4 class template definition completed
void g (C<int>& c) // #5 use class template declaration only
{
c.f(); // #6 use class template definition;
} // will need definition of
C::f()
// in this translation unit
template<typename T>
void C<T>::f() //required definition due to #6
{}
在源代码中的第 1 点,只有模板的声明可用,而不是定义(这样的声明有时称为前向声明)。与普通类的情况一样,我们不需要类模板的定义可见来声明对该类型的指针或引用,就像在第 #2 点所做的那样。例如,函数g()
的参数类型不需要模板 C 的完整定义。但是,只要组件需要知道模板特化的大小或访问此类特化的成员,整个类模板定义必须是可见的。这就解释了为什么在源代码中的#6 处,必须看到类模板定义;否则,编译器无法验证该成员是否存在且可访问(不是私有的或受保护的)。此外,还需要成员函数定义,因为调用点 #6 需要存在C<int>::f()
。这是另一个需要实例化前一个类模板的表达式,因为C<void>
的大小是需要:1
C<void>* p = new C<void>;
在这种情况下,需要实例化,以便编译器可以确定C<void>
的大小,new-expression 需要该大小来确定要分配多少存储空间。您可能会观察到,对于这个特定的模板,用 X 代替 T 的参数类型不会影响模板的大小,因为在任何情况下,C<X>
都是一个空类。但是,编译器不需要通过分析模板定义来避免实例化(并且所有编译器都会在实践中执行实例化)。此外,在此示例中还需要实例化来确定C<void>
是否具有可访问的默认构造函数,并确保C<void>
不声明成员运算符 new 或 delete。访问类模板成员的需要并不总是非常明确可见 在源代码中。例如,C++ 重载需要对候选函数参数的类类型的可见性:1
2
3
4
5
6
7
8
9
10
11template<typename T>
class C {
public:
C(int); // a constructor that can be called with a single parameter
}; // may be used for implicit conversions
void candidate(C<double>); // #1
void candidate(int) { } // #2
int main()
{
candidate(42); // both previous function declarations can be called
}
调用Candidate(42)
将解析为点 #2 处的重载声明。但是,也可以实例化点 #1 处的声明以检查它是否是调用的可行候选者(在这种情况下,因为单参数构造函数可以将 42 隐式转换为C<double>
类型的右值)。请注意,如果编译器可以在没有它的情况下解析调用,则允许(但不是必需)执行此实例化(本示例中可能就是这种情况,因为不会在完全匹配上选择隐式转换)。另请注意,C<double>
的实例化可能会触发错误,这可能会令人惊讶。
惰性实例化
到目前为止的示例说明了与使用非模板类时的需求没有根本区别的需求。许多用途需要一个完整的类类型。对于模板的情况,编译器将从类模板定义中生成这个完整的定义。现在出现了一个相关的问题:有多少模板被实例化了?一个模糊的答案如下:只有真正需要的量。换句话说,编译器在实例化模板时应该是“惰性的”。让我们看看这种懒惰到底意味着什么。
部分和全部实例化
正如我们所见,编译器有时不需要替换类或函数模板的完整定义。例如:1
2template<typename T> T f (T p) { return 2*p; }
decltype(f(2)) x = 2;
在此示例中,由decltype(f(2))
指示的类型不需要函数模板f()
的完整实例化。因此,编译器只允许替换f()
的声明,而不是它的“主体”。这有时称为部分实例化。
类似地,如果引用类模板的实例而不需要该实例是完整类型,则编译器不应执行该类模板实例的完整实例化。考虑以下示例:1
2
3
4template<typename T> class Q {
using Type = typename T::Type;
};
Q<int>* p = 0; // OK: the body of Q<int> is not substituted
在这里,Q<int>
的完整实例化会触发错误,因为当T
为int
时T::Type
没有意义。但是因为在这个例子中Q<int>
不需要是完整的,所以没有执行完整的实例化并且代码是好的(尽管可疑)。
变量模板也有“完整”与“部分”实例化的区别。以下示例说明了这一点:1
2template<typename T> T v = T::default_value();
decltype(v<int>) s; // OK: initializer of v<int> not instantiated
v<int>
的完整实例化会引发错误,但如果我们只需要变量模板实例的类型,则不需要这样做。有趣的是,别名模板没有这种区别:没有两种方法可以替代它们。在 C++ 中,当谈到“模板实例化”而不具体说明完全或部分实例化时,前者是有意的。也就是说,默认情况下,实例化是完全实例化。
实例化组件
当一个类模板被隐式(完全)实例化时,其成员的每个声明也被实例化,但相应的定义不是(即,成员被部分实例化)。有几个例外。首先,如果类模板包含匿名联合,则该联合定义的成员也会被实例化。另一个例外发生在虚拟成员函数中。它们的定义可能会或可能不会作为实例化类模板的结果而被实例化。在实例化模板时,会单独考虑默认函数调用参数。具体来说,除非调用实际使用默认参数的函数(或成员函数),否则它们不会被实例化。另一方面,如果使用覆盖默认值的显式参数调用函数,则不会实例化默认参数。同样,除非需要,否则不会实例化异常规范和默认成员初始化程序。让我们一起举一些例子来说明一些这些原则:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25template<typename T>
class Safe {
};
template<int N>
class Danger {
int arr[N]; // OK here, although would fail for N<=0
};
template<typename T, int N>class Tricky {
public:
void noBodyHere(Safe<T> = 3); // OK until usage of default value results in an error
void inclass() {
Danger<N> noBoomYet; // OK until inclass() is used with N<=0
}
struct Nested {
Danger<N> pfew; // OK until Nested is used with N<=0
};
union { //due anonymous union:
Danger<N> anonymous; // OK until Tricky is instantiated with N<=0
int align;
};
void unsafe(T (*p)[N]); // OK until Tricky is instantiated with N<=0
void error() {
Danger<-1> boom; // always ERROR (which not all compilers detect)
}
};
标准 C++ 编译器将检查这些模板定义以检查语法和一般语义约束。这样做时,它会在检查涉及模板参数的约束时“假设最好”。例如,成员Danger::arr
中的参数 N 可以为零或负数(这将是无效的),但假设不是这种情况。inclass()
、struct Nested
和匿名联合的定义是因此不成问题。同理,成员unsafe(T (*p)[N])
的声明也不成问题,只要 N 是未替换的模板形参即可。membernoBodyHere()
的声明是可疑的,因为模板Safe<>
不能用整数初始化,但假设是Safe<T>
的通用定义实际上不需要默认参数或Safe<T>
将被专门化以启用整数值初始化。但是,即使没有实例化模板,成员函数error()
的定义也是错误的,因为使用Danger<-1>
需要完整定义类Danger<-1>
,并且生成该类会尝试定义一个负大小的数组。有趣的是,虽然标准明确指出此代码无效,但它也允许编译器在未实际使用模板实例时不诊断错误。也就是说,由于Tricky<T,N>::error()
不用于任何具体的 T 和 N,因此不需要编译器针对这种情况发出错误。
例如,在撰写本文时,GCC 和 Visual C++ 并未诊断此错误。现在让我们分析当我们添加以下定义时会发生什么:1
Tricky<int, -1> inst;
这会导致编译器(完全)通过在模板Tricky<>
的定义中用int
代替T
和-1
代替N
来实例化Tricky<int, -1>
。并不是所有的成员定义都需要,但是默认构造函数和析构函数(在这种情况下都是隐式声明的)肯定会被调用,因此它们的定义必须以某种方式可用(在我们的示例中就是这种情况,因为它们是隐式生成的)。如上所述,Tricky<int, -1>
的成员被部分实例化(即,它们的声明被替换):该过程可能会导致错误。例如,unsafe(T (*p) [N])
的声明创建了一个包含负数元素的数组类型,这是一个错误。同样,成员匿名现在会触发错误,因为类型Danger<-1>
无法完成。相反,成员inclass()
和struct Nested
的定义尚未实例化,因此它们需要完整类型Danger<-1>
不会发生错误。
如前所述,在实例化模板时,实际上还应提供虚拟成员的定义。否则,很可能会发生链接器错误。例如:1
2
3
4
5
6
7
8
9
10template<typename T>
class VirtualClass {
public:
virtual ~VirtualClass() {}
virtual T vmem(); // Likely ERROR if instantiated without definition
};
int main()
{
VirtualClass<int> inst;
}
最后是对operator->
的讨论。考虑:1
2
3
4
5template<typename T>
class C {
public:
T operator-> ();
};
通常,operator->
必须返回一个指针类型或operator->
应用到的另一个类类型。这表明C<int>
的完成会触发错误,因为它为operator->
声明了int
的返回类型。但是,由于某些自然类模板定义会触发这些类型的定义,语言规则更加灵活。用户定义的operator->
只需要返回一个类型,如果该运算符实际上是通过重载决议选择的,则另一个(例如,内置的)operator->
适用于该类型。即使在模板之外也是如此(尽管宽松的行为在这些情况下不太有用)。因此,这里的声明不会触发错误,即使 int 被替换为返回类型。
C++实例化模型
模板实例化是通过适当替换模板参数从相应的模板实体中获取常规类型、函数或变量的过程。这听起来可能相当简单,但实际上需要正式确定许多细节。
两阶段查找
在第 13 章中,我们看到解析模板时无法解析依赖名称。相反,在实例化点再次查找它们。但是,不依赖的名称会被尽早查找,以便在第一次看到模板时可以诊断出许多错误。这就引出了两阶段查找的概念:第一阶段是模板的解析,第二阶段是它的实例化:
- 在第一阶段,在解析模板时,使用普通查找规则和(如果适用)参数相关查找 (ADL) 规则查找非依赖名称。使用普通查找规则查找未限定的依赖名称(它们是依赖的,因为它们看起来像具有依赖参数的函数调用中的函数名称),但在执行附加查找之前,查找结果不被认为是完整的第二阶段(当模板被实例化时)。
- 在第二阶段,在称为实例化点 (POI) 的点实例化模板时,会查找相关的限定名称(模板参数替换为该特定实例化的模板参数),并且额外的 ADL 是对在第一阶段使用普通查找查找的非限定从属名称执行。
对于不合格的从属名称,初始普通查找(虽然不完整)用于确定名称是否为模板。考虑以下示例:1
2
3
4
5
6
7
8
9
10
11
12namespace N {
template<typename> void g() {}
enum E { e };
}
template<typename> void f() {}
template<typename T> void h(T P) {
f<int>(p); // #1
g<int>(p); // #2 ERROR
}
int main() {
h(N::e); // calls template h with T = N::E
}
在第 #1 行中,当看到名称 f 后跟 < 时,编译器必须确定该 < 是尖括号还是小于号。这取决于是否知道 f 是模板的名称;在这种情况下,普通查找会找到 f 的声明,它确实是一个模板,因此使用尖括号解析成功。
但是,第 #2 行会产生错误,因为使用普通查找没有找到模板 g; < 因此被视为小于号,在本例中这是一个语法错误。如果我们能解决这个问题,我们最终会在为T = N::E
实例化 h 时使用 ADL 找到模板 N::g(因为 N 是与 E 关联的命名空间),但我们无法做到这一点,直到我们成功解析 h 的通用定义。
实例化点
我们已经说明,在模板客户端的源代码中,C++ 编译器必须能够访问模板实体的声明或定义。当代码构造以这样的方式引用模板特化时创建实例化点 (POI),即需要实例化相应模板的定义以创建该特化。POI 是源中可以插入替换模板的点。例如:
1 | class MyInt { |
当 C++ 编译器看到调用f<Int>(42)
时,它知道需要将模板f
实例化为用MyInt
替换的T
:创建一个 POI。点 #2 和 #3 非常接近调用点,但它们不能是 POI,因为 C++ 不允许我们在那里插入::f<Int>(Int)
的定义。第 1 点和第 4 点之间的本质区别在于,在第 4 点,函数g(Int)
是可见的,因此可以解决依赖于模板的调用g(-i)
。但是,如果点 #1 是 POI,则无法解析该调用,因为g(Int)
尚不可见。幸运的是,C++ 将函数模板特化引用的 POI 定义为紧跟在最近的命名空间范围声明或包含该引用的定义之后。在我们的示例中,这是第 4 点。
您可能想知道为什么这个示例涉及类型MyInt
而不是simpleint
。答案在于在 POI 执行的第二次查找只是一个 ADL。因为 int 没有关联的命名空间,所以 POI 查找不会发生,也不会找到函数 g。因此,如果我们将 Int 的类型别名声明替换为using Int = int;
,前面的示例将不再编译。以下示例遇到了类似的问题:
1 | template<typename T> |
调用f1(7)
为f1<int>(int)
就在main()
之外的点 #2 创建一个 POI。在这个实例化中,关键问题是函数g1
的查找。当第一次遇到模板f1
的定义时,注意到非限定名称g1
是依赖的,因为它是带有依赖参数的函数调用中的函数名称(参数 x 的类型取决于模板参数 T)。因此,使用普通查找规则在点 #1 查找g1
; 但是,此时看不到g1
。在点 #2,POI,函数在关联的命名空间和类中再次查找,但唯一的参数类型是 int,它没有关联的命名空间和类。因此,即使在 POI 上的普通查找会找到 g1,也永远找不到 g1。变量模板的实例化点与函数模板的处理类似。对于类模板特化,情况有所不同,如下例所示:1
2
3
4
5
6
7
8
9
10
11
12template<typename T>
class S {
public:
T m;
};
// #1
unsigned long h()
{
// #2
return (unsigned long)sizeof(S<int>);
// #3
}// #4
同样,函数作用域点#2 和#3 不能是 POI,因为命名空间作用域类S<int>
的定义不能出现在那里(并且模板通常不能出现在函数作用域中)。如果我们要遵循函数模板实例的规则,POI 将在点 #4 ,但是表达式sizeof(S<int>)
是无效的,因为 S
当模板被实际实例化时,可能会出现对额外实例化的需求。考虑一个简短的例子:
1 | template<typename T> |
我们前面的讨论已经确定f<double>()
的 POI 位于 #2 处。函数模板f()
还引用了类特化S<char>
,其 POI 因此位于点 #1 。它也引用了S<T>
,但是因为它仍然是依赖的,所以我们现在不能真正实例化它。但是,如果我们在点 #2 实例化f<double>()
,我们注意到我们还需要实例化S<double>
的定义。此类次要或可传递 POI 的定义略有不同。对于功能模板,辅助 POI 与主 POI 完全相同。对于类实体,次要 POI 紧接在(在最近的封闭命名空间范围内)主要 POI 之前。在我们的示例中,这意味着f<double>()
的 POI 可以放置在点 #2b 处,而就在它之前——在点 #2a——是S<double>
的辅助 POI。请注意这与S<char>
的 POI 有何不同。一个翻译单元通常包含同一个实例的多个 POI。对于类模板实例,仅保留每个翻译单元中的第一个 POI,而忽略后面的 POI(它们并不真正被视为 POI)。对于函数和变量模板的实例,保留所有 POI。在任何一种情况下,ODR 都要求在任何保留的 POI 上发生的实例化是等效的,但 C++ 编译器不需要验证和诊断违反此规则的情况。这允许 C++ 编译器只选择一个非类 POI 来执行实际实例化,而不必担心另一个 POI 可能会导致不同的实例化。
在实践中,大多数编译器将大多数函数模板的实际实例化延迟到翻译单元的末尾。某些实例化不能延迟,包括需要实例化来确定推导的返回类型的情况以及函数为 constexpr 并且必须评估以产生恒定结果的情况.一些编译器在第一次使用内联函数时会立即实例化内联函数。这有效地将相应模板专业化的 POI 移动到翻译单元的末尾,这是 C++ 标准允许的替代 POI。
编译期if段
C++增加了编译期if,它还在实例化过程中引入了一个新问题。
1 | template<typename T> bool f(T p) { |
编译时 if 是一个 if 语句,其中 if 关键字紧跟constexpr
关键字(如本例所示)。后面的带括号的条件必须有一个常量布尔值(到 bool 的隐式转换包含在该考虑中)。因此,编译器知道将选择哪个分支;另一个分支称为丢弃的分支。特别有趣的是,在模板(包括通用 lambda)的实例化过程中,丢弃的分支不会被实例化。这对于我们的示例有效是必要的:我们用 T = int 实例化 f(T),这意味着 else 分支被丢弃。如果它没有被丢弃,它将被实例化,并且我们会遇到表达式p.compare(0)
的错误(当 p 是一个简单整数时它是无效的)。在 C++17 及其 constexpr if 语句之前,避免此类错误需要显式模板特化或重载以实现类似效果。
上面的例子,在 C++14 中,可能实现如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16template<bool b> struct Dispatch { //only to be instantiated when b is false
static bool f(T p) { //(due to next specialization for true)
return p.compare(0) > 0;
}
};
template<> struct Dispatch<true> {
static bool f(T p) {
return p > 0;
}
};
template<typename T> bool f(T p) {
return Dispatch<sizeof(T) <= sizeof(long long)>::f(p);
}
bool g(int n) {
return f(n); // OK
}
显然,constexpr if
替代方案更清楚、更简洁地表达了我们的意图。但是,它需要实现来细化实例化单元:虽然以前函数定义总是作为一个整体实例化,但现在必须可以禁止部分实例化。constexpr if
的另一个非常方便的用法是表示处理函数参数包所需的递归。1
2
3
4
5
6
7
8template<typename Head, typename... Remainder>
void f(Head&& h, Remainder&&... r) {
doSomething(std::forward<Head>(h));
if constexpr (sizeof...(r) != 0) {
// handle the remainder recursively (perfectly forwarding the arguments):
f(std::forward<Remainder>(r)...);
}
}
如果没有constexpr if
语句,这需要f()
模板的额外重载以确保递归终止。
即使在非模板上下文中,constexpr if
语句也有一些独特的效果:1
2
3
4
5
6void h();
void g() {
if constexpr (sizeof(int) == 1) {
h();
}
}
在大多数平台上,g()
中的条件为假,因此对h()
的调用被丢弃。因此,h()
根本不需要定义(当然,除非它在其他地方使用)。如果在此示例中省略了关键字constexpr
,则缺少h()
的定义通常会在链接时引发错误。
在标准库中
C++ 标准库包含许多模板,这些模板通常只与少数基本类型一起使用。例如,std::basic_string
类模板最常与 char(因为std::string
是std::basic_string<char>
的类型别名)或wchar_t
一起使用,尽管可以用其他类似字符的方式实例化它。因此,标准库实现通常会为这些常见情况引入显式实例化声明。例如:
1 | namespace std { |
模板参数推导
在每次调用函数模板时显式指定模板参数(例如,concat<std::string, int>(s, 3)
)会很快导致代码笨拙。幸运的是,C++ 编译器通常可以使用称为模板参数推导的强大过程自动确定预期的模板参数。尽管模板参数推导最初是为了简化函数模板的调用而开发的,但它后来被扩展以适用于其他几种用途,包括从它们的初始值设定项中确定变量的类型。
推导过程
基本推导过程将函数调用的参数类型与函数模板的相应参数化类型进行比较,并尝试得出对一个或多个推导参数的正确替换。每个参数-参数对都是独立分析的,如果最终得出的结论不同,则推理过程失败。考虑以下示例:
1 | template<typename T> |
这里第一个调用参数是 int 类型,所以我们最初的max()
模板的参数T
被初步推导出为int
。然而,第二个调用参数是双精度的,因此对于这个参数,T
应该是双精度的:这与前面的结论相冲突。请注意,我们说“扣除过程失败”,而不是“程序无效”。毕竟,对于另一个名为max
的模板,推演过程可能会成功(函数模板可以像普通函数一样被重载)。
如果所有推导的模板参数都是一致确定的,如果在函数声明的其余部分中替换参数导致无效构造,则推导过程仍然会失败。例如:1
2
3
4
5
6
7
8
9template< typename T>
typename T::ElementT at (T a, int i)
{
return a[i];
}
void f (int* p)
{
int x = at(p, 7);
}
这里T被推断为int*
(T出现的参数类型只有一种,所以显然没有分析冲突)。但是,在返回类型T::ElementT
中用int*
代替T
显然是无效的 C++,推演过程失败。
我们仍然需要探索参数-参数匹配是如何进行的。我们根据将类型 A(从调用参数类型派生)与参数化类型 P(从调用参数声明派生)匹配来描述它。如果调用参数是用引用声明符声明的,则 P 被认为是引用的类型,A 是参数的类型。然而,否则,P 是声明的参数类型,而 A 是通过将数组和函数类型退化为指针类型从参数类型中获得的,忽略const 和 volatile 限定符。例如:1
2
3
4
5
6
7
8
9template<typename T> void f(T); // parameterized type P is T
template<typename T> void g(T&); // parameterized type P is also T
double arr[20];
int const seven = 7;f(arr); // nonreference parameter: T is double*
g(arr); // reference parameter: T is double[20]
f(seven); // nonreference parameter: T is int
g(seven); // reference parameter: T is int const
f(7); // nonreference parameter: T is int
g(7); // reference parameter: T is int => ERROR: can’t pass 7 to int&
对于调用f(arr)
,arr
的数组类型退化为double*
类型,这是为T
推导出的类型。在f(seven)
中,const
限定被去除,因此T
被推导出为int
。相反,调用g(x)
将T
推导出为double[20]
类型(不发生退化)。类似地,g(seven)
有一个int const
类型的左值参数,并且因为在匹配引用参数时不会删除 const 和 volatile 限定符,所以T
被推导出为int const
。但是,请注意g(7)
会将T
推导出为int
(因为非类右值表达式从不具有 const 或 volatile 限定类型),并且调用将失败,因为参数无法传递给int&
类型的参数。
当参数是字符串文字时,绑定到引用参数的参数不会发生退化这一事实可能令人惊讶。重新考虑使用引用声明的max()
模板:1
2template<typename T>
T const& max(T const& a, T const& b);
可以合理地预期,对于表达式max("Apple", "Pie")
,T 被推导出为char const*
。但是,“Apple”的类型是char const[6]
,而“Pie”的类型是char const[4]
。不会发生数组到指针的退化(因为推导涉及参考参数),因此T
必须同时是char[6]
和char[4]
才能成功推导。那当然是不可能的。
推断的上下文
比“T”复杂得多的参数化类型可以匹配给定的参数类型。以下是一些仍然相当基本的示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<typename T>
void f1(T*);template<typename E, int N>
void f2(E(&)[N]);
template<typename T1, typename T2, typename T3>
void f3(T1 (T2::*)(T3*));
class S {
public:
void f(double*);
};
void g (int*** ppp)
{
bool b[42];
f1(ppp); // deduces T to be int**
f2(b); // deduces E to be bool and N to be 42
f3(&S::f); // deduces T1 = void, T2 = S, and T3 = double
}
复杂类型声明是从更基本的构造(指针、引用、数组和函数声明符;指向成员声明符的指针;模板标识符等)构建的,匹配过程从顶层构造开始,并通过组合进行递归元素。公平地说,大多数类型声明构造都可以通过这种方式匹配,这些被称为推导上下文。但是,一些构造不是推断的上下文。例如:
- 限定类型名称。例如,像
Q<T>::X
这样的类型名称永远不会用于推断模板参数 T。 - 不只是非类型参数的非类型表达式。例如,像
S<I+1>
这样的类型名称永远不会用于推断 I。也不会通过匹配int(&)[sizeof(S<T>)]
类型的参数来推断 T。这些限制应该不足为奇,因为推导通常不是唯一的(甚至是有限的),尽管这种限定类型名称的限制有时很容易被忽略。非推导的上下文并不自动暗示程序有错误,甚至分析的参数不能参与类型推导。为了说明这一点,请考虑以下更复杂的示例:
1 | template<int N>class X { |
在函数模板fppm()
中,子构造X<N>::I
是非推导上下文。但是,成员指针类型的成员类组件X<N>
是一个可推导的上下文,并且当从它推导的参数 N 插入到非推导上下文中时,获得与实际参数&X<33>::f
的类型兼容的类型。因此,推论在该参数-参数对上成功。
相反,可以为完全从推断的上下文构建的参数类型推断出矛盾。例如,假设适当声明的类模板 X 和 Y:
1 | template<typename T> |
第二次调用函数模板f()
的问题是两个参数为参数 T 推导了不同的参数,这是无效的。(在这两种情况下,函数调用参数都是通过调用类模板 X 的默认构造函数获得的临时对象。)
特殊推导情况
从函数调用的实参和函数模板的形参中获取不到用于推导的对(A, P)有几种情况。第一种情况发生在获取函数模板的地址时。在这种情况下,P 是函数模板声明的参数化类型,A 是初始化或分配给指针的函数类型。例如:1
2
3template<typename T>
void f(T, T);
void (*pf)(char, char) = &f;
在此示例中,P 为void(T, T)
,A 为void(char, char)
。用char
代替 T 推演成功,并且 pf 被初始化为特化f<char>
的地址。类似地,函数类型用于 P 和 A 用于其他一些特殊情况:
- 确定重载函数模板之间的偏序
- 将显式特化与函数模板匹配
- 将显式实例化与模板匹配
- 将友元函数模板特化与模板匹配
- 将放置操作符
delete
或operator delete[]
与相应的放置操作符new
或operator new[]
模板匹配
另一种特殊情况发生在转换函数模板中。例如:1
2
3
4class S {
public:
template<typename T> operator T&();
};
在这种情况下,获得对 (P, A) 就好像它涉及我们尝试转换的类型的参数和作为转换函数的返回类型的参数类型。以下代码说明了一种变体:1
2
3
4
5void f(int (&)[20]);
void g(S s)
{
f(s);
}
在这里,我们尝试将S
转换为int (&)[20]
。因此类型A
是int[20]
,类型 P 是 T。推演成功,T 被int[20]
替换。
初始化列表
当函数调用的参数是初始化列表时,该参数没有特定类型,因此通常不会从给定的对 (A, P) 中执行推导,因为没有 A。例如:1
2
3
4
5
template<typename T> void f(T p);
int main() {
f({1, 2, 3}); // ERROR: cannot deduce T from a braced list
}
但是,如果参数类型 P 在删除引用和 const 和 volatile 限定符后,对于某些具有可推导模式的类型 P’ 等价于std::initializer_list<P'>
,则推断过程仅当所有元素都具有相同类型时才成功:1
2
3
4
5
6
7
template<typename T> void f(std::initializer_list<T>);
int main()
{
f({2, 3, 5, 7, 9}); // OK: T is deduced to int
f({’a’, ’e’, ’i’, ’o’, ’u’, 42}); //ERROR: T deduced to both char and int
}
类似地,如果参数类型 P 是对具有可推导模式的某些类型 P’ 的数组类型的引用,则通过将 P’ 与初始化器列表中每个元素的类型进行比较来进行推导,仅当所有元素具有相同的类型。此外,如果具有可推导的模式(即,仅命名非类型模板参数),则被推导为列表中的元素数。
参数包
推导过程将每个参数与每个参数匹配以确定模板参数的值。 然而,在对可变参数模板执行模板实参推导时,形参和实参之间的 1:1 关系不再成立,因为形参包可以匹配多个实参。 在这种情况下,相同的参数包 (P) 与多个参数 (A) 匹配,每次匹配都会为 P 中的任何模板参数包生成附加值:1
2
3
4
5
6template<typename First, typename... Rest>
void f(First first, Rest... rest);
void g(int i, double j, int* k)
{
f(i, j, k); // deduces First to int, Rest to {double, int*}
}
这里,第一个函数参数的推导很简单,因为它不涉及任何参数包。第二个函数参数rest
是一个函数参数包。它的类型是一个包扩展 (Rest...
),其模式是Rest
类型:该模式用作P
,与第二个和第三个调用参数的类型 A 进行比较。当与第一个这样的 A(double 类型)进行比较时,模板参数包Rest
中的第一个值被推导出为double
。类似地,当与第二个这样的 A(类型int*
)进行比较时,模板参数包 Rest 中的第二个值被推导出为int*
。因此,推导确定参数包Rest 的值是序列{double, int*}
。
将该推导的结果和第一个函数参数的推导替换为函数类型void(int, double, int*)
,它与调用站点的参数类型匹配。因为函数参数包的推导使用扩展的模式进行比较,所以模式可以任意复杂,并且可以从每个参数类型确定多个模板参数和参数包的值。考虑函数h1()
和h2()
的推演行为,如下所示:1
2
3
4
5
6
7
8
9
10
11
12template<typename T, typename U> class pair { };
template<typename T, typename... Rest>
void h1(pair<T, Rest> const&...);
template<typename... Ts, typename... Rest>
void h2(pair<Ts, Rest> const&...);
void foo(pair<int, float> pif, pair<int, double> pid, pair<double, double> pdd)
{
h1(pif, pid); // OK: deduces T to int, Rest to {float, double}
h2(pif, pid); // OK: deduces Ts to {int, int}, Rest to {float, double}
h1(pif, pdd); // ERROR: T deduced to int from the 1st arg, but to double from the 2nd
h2(pif, pdd); // OK: deduces Ts to {int, double}, Rest to {float, double}
}
对于h1()
和h2()
,P 是一个引用类型,它被调整为引用的非限定版本(pair<T, Rest>
或pair<Ts, Rest>
),以针对每个参数类型进行推导。由于所有参数和参数都是类模板对的特化,因此模板参数被比较。对于h1()
,第一个模板参数 (T) 不是参数包,因此它的值是为每个参数独立推导的。如果推导不同,如第二次调用h1()
,则推导失败。对于h1()
和h2() (Rest)
中的第二对模板参数,以及h2() (Ts)
中的第一对参数,推导从 A 中的每个参数类型确定模板参数包的连续值.
参数包的推导不限于参数-参数对来自调用参数的函数参数包。事实上,只要包展开位于函数参数列表或模板参数列表的末尾,就会使用此推导。例如,考虑对简单 Tuple 类型的两个类似操作:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15template<typename... Types> class Tuple { };
template<typename... Types>
bool f1(Tuple<Types...>, Tuple<Types...>);
template<typename... Types1, typename... Types2>bool f2(Tuple<Types1...>, Tuple<Types2...>);
void bar(Tuple<short, int, long> sv, Tuple<unsigned short, unsigned, unsigned long> uv)
{
f1(sv, sv); // OK: Types is deduced to {short, int, long}
f2(sv, sv); // OK: Types1 is deduced to {short, int, long},
// Types2 is deduced to {short, int, long}
f1(sv, uv); // ERROR: Types is deduced to {short, int, long} from the 1st arg, but
// to {unsigned short, unsigned, unsigned long} from the 2nd
f2(sv, uv); // OK: Types1 is deduced to {short, int, long},
// Types2 is deduced to {unsigned short, unsigned, unsigned long}
}
在f1()
和f2()
中,模板参数包是通过将嵌入在Tuple
类型(例如,h1()
的类型)中的包扩展模式与由提供的Tuple
类型的每个模板参数进行比较来推导出的调用参数,推导出相应模板参数包的连续值。函数f1()
在两个函数参数中使用相同的模板参数包类型,确保只有当两个函数调用参数具有与其类型相同的Tuple
特化时,推导才会成功。另一方面,函数f2()
对每个函数参数中的Tuple
类型使用不同的参数包,因此函数调用参数的类型可以不同——只要两者都是 Tuple 的特化。
模板的多态性
多态在C++中它主要由继承和虚函数实现。由于这一机制主要(至少是一部分)在运行期间起作用,因此我们称之为动态多态(dynamic polymorphism)。模板也允许我们用单个统一符号将不同的特定行为关联起来,不过该关联主要发生在编译期间,我们称之为静态多态(static polymorphism)。
动态多态(dynamic polymorphism)
由于历史原因,C++在最开始的时候只支持通过继承和虚函数实现的多态。在此情况下,多态设计的艺术性主要体现在从一些相关的对象类型中提炼出一组统一的功能,然后将它们声明成一个基类的虚函数接口。
这一设计方式的范例之一是一种用来维护多种几何形状、并通过某些方式将其渲染的应用。在这样一种应用中,我们可以发现一个抽线基类(abstract base class,ABC),在其中声明了适用于几何对象的统一的操作和属性。其余适用于特定几何对象的类都从它做了继承:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// common abstract base class GeoObj for geometric objects
class GeoObj {
public:
// draw geometric object:
virtual void draw() const = 0;
// return center of gravity of geometric object:
virtual Coord center_of_gravity() const = 0;
...
virtual ~GeoObj() = default;
};
// concrete geometric object class Circle
// - derived from GeoObj
class Circle : public GeoObj {
public:
virtual void draw() const override;
virtual Coord center_of_gravity() const override;
...
};
// concrete geometric object class Line
// - derived from GeoObj
class Line : public GeoObj {
public:
virtual void draw() const override;
virtual Coord center_of_gravity() const override;
...
};
在创建了具体的对象之后,客户端代码可以通过指向公共基类的指针或者引用,使用虚函数的派发机制来操作它们。在通过基类的指针或者引用调用一个虚函数的时候,所调用的函数将是指针或者引用所指对象的真正类型中的相应函数。在我们的例子中,具体的代码可以被简写成这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// draw any GeoObj
void myDraw (GeoObj const& obj)
{
obj.draw(); // call draw() according to type of object
}
// compute distance of center of gravity between two GeoObjs
Coord distance (GeoObj const& x1, GeoObj const& x2)
{
Coord c = x1.center_of_gravity() - x2.center_of_gravity();
return c.abs(); // return coordinates as absolute values
} // draw heterogeneous collection of GeoObjs
void drawElems (std::vector<GeoObj*> const& elems)
{
for (std::size_type i=0; i<elems.size(); ++i) {
elems[i]->draw(); // call draw() according to type of element
}
}
int main(){
Line l;
Circle c, c1, c2;
myDraw(l); // myDraw(GeoObj&) => Line::draw()
myDraw(c); // myDraw(GeoObj&) => Circle::draw()
distance(c1,c2); // distance(GeoObj&,GeoObj&)
distance(l,c); // distance(GeoObj&,GeoObj&)
std::vector<GeoObj*> coll; // heterogeneous collection
coll.push_back(&l); // insert line
coll.push_back(&c); // insert circle
drawElems(coll); // draw different kinds of GeoObjs
}
关键的多态接口是函数draw()
和center_of_gravity()
,都是虚成员函数。上述例子在函数mydraw()
,distance()
,以及drawElems()
中展示了这两个虚函数的用法。而后面这几个函数使用的都是公共基类GeoObj
。这一方式的结果是,在编译期间并不能知道将要被真正调用
的函数。但是,在运行期间,则会基于各个对象的完整类型来决定将要调用的函数。因此,取决于集合对象的真正类型,适当的操作将会被执行:如果mydraw()
处理的是Line的对象,表达式obj.draw()
将调用Line::draw()
,如果处理的是Circle的对象,那么就会调用Circle::draw()
。
能够处理异质集合中不同类型的对象,或许是动态多态最吸引人的特性。这一概念在drawElems()
函数中得到了体现:表达式elems[i]->draw()
会调用不同的成员函数,具体情况取决于元素的动态类型。
静态多态
模板也可以被用来实现多态。不同的是,它们不依赖于对基类中公共行为的分解。取而代之的是,这一“共性(commonality)”隐式地要求不同的“形状(shapes)”必须支持使用了相同语法的操作(比如,相关函数的名字必须相同)。在定义上,具体的class之间彼此相互独立。在用这些具体的class去实例化模板的时候,这一多态能力得以实现。
比如,上一节中的myDraw()
:1
2
3
4
5void myDraw (GeoObj const& obj) // GeoObj is abstract base
class
{
obj.draw();
}
也可以被实现成下面这样:1
2
3
4
5template<typename GeoObj>
void myDraw (GeoObj const& obj) // GeoObj is template parameter
{
obj.draw();
}
比较myDraw()
的两种实现,可以发现其主要的区别是将GeoObj用作模板参数而不是公共基类。但是,在表象之下还有很多区别。比如,使用动态多态的话,在运行期间只有一个myDraw()
函数,但是在使用模板的情况下,却会有多种不同的函数,例如myDraw<Line>()
和myDraw<Circle>()
。
我们可能希望用static多态重新实现上一节中的完整例子。首先,我们不再使用有层级结构的几何类,而是直接使用一些彼此独立的几何类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// concrete geometric object class Circle
// - not derived from any class
class Circle {
public:
void draw() const;
Coord center_of_gravity() const;
};
// concrete geometric object class Line
// - not derived from any class
class Line {
public:
void draw() const;
Coord center_of_gravity() const;
...
};
现在,可以像下面这样使用这些类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// draw any GeoObj
template<typename GeoObj>
void myDraw (GeoObj const& obj)
{
obj.draw(); // call draw() according to type of object
}
// compute distance of center of gravity between two GeoObjs
template<typename GeoObj1, typename GeoObj2>
Coord distance (GeoObj1 const& x1, GeoObj2 const& x2)
{
Coord c = x1.center_of_gravity() - x2.center_of_gravity();
return c.abs(); // return coordinates as absolute values
}
// draw homogeneous collection of GeoObjs
template<typename GeoObj>
void drawElems (std::vector<GeoObj> const& elems)
{
for (unsigned i=0; i<elems.size(); ++i) {
elems[i].draw(); // call draw() according to type of element
}
}
int main()
{
Line l;
Circle c, c1, c2;
myDraw(l); // myDraw<Line>(GeoObj&) => Line::draw()
myDraw(c); // myDraw<Circle>(GeoObj&) => Circle::draw()
distance(c1,c2); //distance<Circle,Circle>(GeoObj1&,GeoObj2&)
distance(l,c); // distance<Line,Circle>(GeoObj1&,GeoObj2&)
// std::vector<GeoObj*> coll; //ERROR: no heterogeneous collection possible
std::vector<Line> coll; // OK: homogeneous collection possible
coll.push_back(l); // insert line
drawElems(coll); // draw all lines
}
和myDraw()
类似,我们不能够再将GeoObj作为具体的参数类型用于distance()
。我们引入了两个模板参数,GeoObj1和GeoObj2,来支持不同类型的集合对象之间的距离计算:1
distance(l,c); // distance<Line,Circle>(GeoObj1&,GeoObj2&)
但是使用这种方式,我们将不再能够透明地处理异质容器。这也正是static多态中的static部分带来的限制:所有的类型必须在编译期可知。不过,我们可以很容易的为不同的集合对象类型引入不同的集合。这样就不再要求集合的元素必须是指针类型,这对程序性能和类型安全都会有帮助。
动态多态VS静态多态
让我们来对这两种多态性形式进行分类和比较。
Static和dynamic多态提供了对不同C++编程术语的支持:
- 通过继承实现的多态是有界的(bounded)和动态的(dynamic):
- 有界的意思是,在设计公共基类的时候,参与到多态行为中的类型的相关接口就已经确定(该概念的其它一些术语是侵入的(invasive和intrusive))。
- 动态的意思是,接口的绑定是在运行期间执行的。
- 通过模板实现的多态是无界的(unbounded)和静态的(static):
- 无界的意思是,参与到多态行为中的类型的相关接口是不可预先确定的
- 静态的意思是,接口的绑定是在编译期间执行的
因此,严格来讲,在C++中,动态多态和静态多态分别是有界动态多态和无界静态多态的缩写。在其它语言中还会有别的组合(比如在Smakktalk中的无界动态多态)。但是在C++语境中,更简洁的动态多态和静态多态也不会带来困扰。
C++中的动态多态有如下优点:
- 可以很优雅的处理异质集合。
- 可执行文件的大小可能会比较小(因为它只需要一个多态函数,不像静态多态那样,需要为不同的类型进行各自的实例化)。
- 代码可以被完整的编译;因此没有必须要被公开的代码(在发布模板库时通常需要发布模板的源代码实现)。
作为对比,下面这些可以说是C++中static多态的优点:
- 内置类型的集合可以被很容易的实现。更通俗地说,接口的公共性不需要通过公共基类实现。
- 产生的代码可能会更快(因为不需要通过指针进行重定向,先验的(priori)非虚函数通常也更容易被inline)。
- 即使某个具体类型只提供了部分的接口,也可以用于静态多态,只要不会用到那些没有被实现的接口即可。
通常认为静态多态要比动态多态更类型安全(type safe),因为其所有的绑定都在编译期间进行了检查。例如,几乎不用担心将一个通过模板实例化得到的、类型不正确的对象插入到一个已有容器中(编译期间会报错)。但是,对于一个存储了指向公共基类的指针的容器,其所存储的指针却有可能指向一个不同类型的对象。
在实际中,当相同的接口后面隐藏着不同的语义假设时,模板实例化也会带来一些问题。比如,当关联运算符operator +被一个没实现其所需的关联操作的类型实例化时,就会遇到错误。在实际中,对于基于继承的设计层次,很少会遇到这一类的语义不匹配,这或许是因为相应的接口规格得到了较好的说明。
使用concepts
针对使用了模板的静态多态的一个争议是,接口的绑定是通过实例化相应的模板执行的。也就是说没有可供编程的公共接口或者公共class。取而代之的是,如果所有实例化的代码都是有效的,那么对模板的任何使用也都是有效的。否则,就会导致难以理解的错误信息,或者是产生了有效的代码却导致了意料之外的行为。
基于这一原因,C++语言的设计者们一直在致力于实现一种能够为模板参数显式地提供(或者是检查)接口的能力。在C++中这一接口被称为concept。它代表了为了能够成功的实例化模板,模板参数必须要满足的一组约束条件。
Concept可以被理解成静态多态的一类“接口”。在我们的例子中,可能会像下面这样:1
2
3
4
5
6
7
template<typename T>
concept GeoObj = requires(T x) {
{ x.draw() } -> void;
{ x.center_of_gravity() } -> Coord;
...
};
在这里我们使用关键字concept定义了一个GeoObj concept
,它要求一个类型要有可被调用的成员函数draw()
和center_of_gravity()
,同时也对它们的返回类型做了限制。现在我们可以重写样例模板中的一部分代码,以在其中使用requires子句要求模板参数满足GeoObj concept:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// draw any GeoObj
template<typename T>
requires GeoObj<T>
void myDraw (T const& obj)
{
obj.draw(); // call draw() according to type of object
}
// compute distance of center of gravity between two GeoObjs
template<typename T1, typename T2>
requires GeoObj<T1> && GeoObj<T2>
Coord distance (T1 const& x1, T2 const& x2)
{
Coord c = x1.center_of_gravity() - x2.center_of_gravity();
return c.abs(); // return coordinates as absolute values
}
// draw homogeneous collection of GeoObjs
template<typename T>
requires GeoObj<T>
void drawElems (std::vector<T> const& elems)
{
for (std::size_type i=0; i<elems.size(); ++i) {
elems[i].draw(); // call draw() according to type of element
}
}
对于那些可以参与到静态多态行为中的类型,该方法依然是非侵入的:1
2
3
4
5
6
7
8// concrete geometric object class Circle
// - not derived from any class or implementing any interface
class Circle {
public:
void draw() const;
Coord center_of_gravity() const;
...
};
也就是说,这一类类型的定义中依然不包含特定的基类,或者require子句,而且它们也依然可以是基础数据类型或者来自独立框架的类型。
泛型编程(Generic Programming)
在C++的语境中,泛型编程有时候也被定义成模板编程(而面向对象编程被认为是基于虚函数的编程)。在这个意义上,几乎任何C++模板的使用都可以被看作泛型编程的实例。但是,开发者通常认为泛型编程还应包含如下这一额外的要素:
- 该模板必须被定义于一个框架中,且必须能够适用于大量的、有用的组合。
到目前为止,在该领域中最重要的一个贡献是标准模板库(Standard Template Library, STL)。STL的设计者们找到了一种可以用于任意线性集合、称之为迭代器(iterators)抽象概念。从本质上来说,容器操作中针对于集合的某些方面已经被分解到迭代器的功能中,这样就可以不用去给所有的线性容器都提供一些诸如max_element()
的操作,容器本身只要提供一个能够遍历序列中数值的迭代器类型,以及一些能够创建这些迭代器的成员函数就可以了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20namespace std {
template<typename T, ...>
class vector {
public:
using const_iterator = ...; // implementation-specific iterator
... // type for constantvectors
const_iterator begin() const; // iterator for start of collection
const_iterator end() const; // iterator for end of collection
...
};
template<typename T, ...>
class list {
public:
using const_iterator = ...; // implementation-specific iterator
... // type for constant lists
const_iterator begin() const; // iterator for start of collection
const_iterator end() const; // iterator for end of collection
...
};
}
现在就可以通过调用泛型操作max_element()
(以容器的beginning和end伟参数)来寻找任意集合中的最大值:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename T>
void printMax (T const& coll){
// compute position of maximum value
auto pos = std::max_element(coll.begin(),coll.end());
// print value of maximum element of coll (if any):
if (pos != coll.end()) {
std::cout << *pos << "\n";
}
else {
std::cout << "empty" << "\n";
}
}
int main()
{
std::vector<MyClass> c1;
std::list<MyClass> c2;
...
printMax(c1);
printMax(c2);
}
泛型的关键是迭代器,它由容器提供并被算法使用。这样之所以可行,是因为迭代器提供了特定的、可以被算法使用的接口。这些接口通常被称为concept,它代表了为了融入该框架,模板必须满足的一组限制条件。此外,该概念还可用于其它一些操作和数据结构。
原则上,类似于STL方法的一类功能都可以用动态多态实现。但是在实际中,由于迭代器的concept相比于虚函数的调用过于轻量级,因此多态这一方法的用途有限。基于虚函数添加一个接口层,很可能会将我们的操作性能降低一个数量级(甚至更多)。泛型编程之所以实用,正是因为它依赖于静态多态,这样就可以在编译期间就决定具体的接口。另一方面,需要在编译期间解析出接口的这一要求,又催生出了一些与面向对象设计原则(object oriented principles)不同的新原则。
萃取的实现
萃取(或者叫萃取模板,traits/traits template)是C++编程的组件,它们对管理那些在设计工业级应用模板时所需要管理的多余参数很有帮助。
一个例子:对一个序列求和
计算一个序列中所有元素的和是一个很常规的任务。也正是这个简单的问题,给我们提供了一个很好的、可以用来介绍各种不同等级的萃取应用的例子。
固定的萃取(Fixed Traits)
让我们先来考虑这样一种情况:待求和的数据存储在一个数组中,然后我们有一个指向数组中第一个元素的指针,和一个指向最后一个元素的指针。由于本书介绍的是模板,我们自然也希望写出一个适用于各种类型的模板。下面是一个看上去很直接的实现:1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
T accum (T const* beg, T const* end)
{
T total{}; // assume this actually creates a zero value
while (beg != end) {
total += *beg;
++beg;
}
return total;
}
例子中唯一有些微妙的地方是,如何创建一个类型正确的零值(zero value)来作为求和的起始值。
这就意味着这个局部的total对象要么被其默认值初始化,要么被零(zero)初始化(对应指针是用nullptr初始化,对应bool值是用false初始化)。为了引入我们的第一个萃取模板,考虑下面这一个使用了accum()
的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main()
{
// create array of 5 integer values
int num[] = { 1, 2, 3, 4, 5 };
// print average value
std::cout << "the average value of the integer values is " << accum(num, num+5) / 5 << "\n";
// create array of character values
char name[] = "templates";
int length = sizeof(name)-1;
// (try to) print average character value
std::cout << "the average value of the characters in \"" << name << "\" is " << accum(name, name+length) / length << "\n";
}
在例子的前半部分,我们用accum()
对5个整型遍历求和:1
2
3int num[] = { 1, 2, 3, 4, 5 };
...
accum(num0, num+5)
接着就可以用这些变量的和除变量的数目得到平均值。
例子的第二部分试图为单词“templates”中所有的字符做相同的事情。结果应该是a到z之间的某一个值。在当今的大多数平台上,这个值都是通过ASCII码决定的: a被编码成97,z被编码成122。因此我们可能会期望能够得到一个介于97和122之间的返回值。但是在我们的平台上,程序的输出却是这样的:1
2the average value of the integer values is 3
the average value of the characters in "templates" is -5
问题在于我们的模板是被char实例化的,其数值范围即使是被用来存储相对较小的数值的和也是不够的。很显然,为了解决这一问题我们应该引入一个额外的模板参数AccT,并将其用于返回值total的类型。但是这会给模板的用户增加负担:在调用这一模板的时候,他们必须额外指定一个类型。对于上面的例子,我们可能需要将其写称这个样子:1
accum<int>(name,name+5)
这并不是一个过于严苛的要求,但是确实是可以避免的。一个可以避免使用额外的模板参数的方式是,在每个被用来实例化accum()
的T和与之对应的应该被用来存储返回值的类型之间建立某种联系。这一联系可以被认为是T的某种属性。正如下面所展示的一样,可以通过模板的偏特化建立这种联系:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22template<typename T>
struct AccumulationTraits;
template<>
struct AccumulationTraits<char> {
using AccT = int;
};
template<>
struct AccumulationTraits<short> {
using AccT = int;
};
template<>
struct AccumulationTraits<int> {
using AccT = long;
};
template<>
struct AccumulationTraits<unsigned int> {
using AccT = unsigned long;
};
template<>
struct AccumulationTraits<float> {
using AccT = double;
};
AccumulationTraits
模板被称为萃取模板,因为它是提取了其参数类型的特性。(通常而言可以有不只一个萃取,也可以有不只一个参数)。我们选择不对这一模板进行泛型定义,因为在不了解一个类型的时候,我们无法为其求和的类型做出很好的选择。但是,可能有人会辩解说T类型本身就是最好的待选类型(很显然对于我们前面的例子不是这样)。
有了这些了解之后,我们可以将accum()
按照下面的方式重写:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
auto accum (T const* beg, T const* end)
{
// return type is traits of the element type
using AccT = typename AccumulationTraits<T>::AccT;
AccT total{}; // assume this actually creates a zero value
while (beg != end) {
total += *beg;
++beg;
}
return total;
}
此时程序的输出就和我们所预期一样了:1
2the average value of the integer values is 3
the average value of the characters in "templates" is 108
考虑到我们为算法加入了很好的检查机制,总体而言这些变化不算太大。而且,如果要将accum()
用于新的类型的话,只要对AccumulationTraits再进行一次显式的偏特化,就会得到一个AccT。值得注意的是,我们可以为任意类型进行上述操作:基础类型,声明在其它库
中的类型,以及其它诸如此类的类型。
值萃取(Value Traits)
到目前为止我们看到的萃取,代表的都是特定“主”类型的额外的类型信息。在本节我们将会看到,这一“额外的信息”并不仅限于类型信息。还可以将常量以及其它数值类和一个类型关联起来。
在最原始的accum()
模板中,我们使用默认构造函数对返回值进行了初始化,希望将其初始化为一个类似零(zero like)的值:1
2
3AccT total{}; // assume this actually creates a zero value
...
return total;
很显然,这并不能保证一定会生成一个合适的初始值。因为AccT可能根本就没有默认构造函数。
萃取可以再一次被用来救场。对于我们的例子,我们可以为AccumulationTraits添加一个新的值萃取(value trait,似乎翻译成值特性会更好一些):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<typename T>
struct AccumulationTraits;
template<>
struct AccumulationTraits<char> {
using AccT = int;
static AccT const zero = 0;
};
template<>
struct AccumulationTraits<short> {
using AccT = int;
static AccT const zero = 0;
};
template<>
struct AccumulationTraits<int> {
using AccT = long;
static AccT const zero = 0;
};
在这个例子中,新的萃取提供了一个可以在编译期间计算的,const的zero成员。此时,accum()
的实现如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
auto accum (T const* beg, T const* end)
{
// return type is traits of the element type
using AccT = typename AccumulationTraits<T>::AccT;
AccT total = AccumulationTraits<T>::zero; // init total by trait value
while (beg != end) {
total += *beg;
++beg;
}
return total;
}
在上述代码中,存储求和结果的临时变量的初始化依然很直观:1
AccT total = AccumulationTraits<T>::zero;
这一实现的一个不足之处是,C++只允许我们在类中对一个整形或者枚举类型的static const数据成员进行初始化。
Constexpr的static数据成员会稍微好一些,允许我们对float类型以及其它字面值类型进行类内初始化:1
2
3
4
5template<>
struct AccumulationTraits<float> {
using Acct = float;
static constexpr float zero = 0.0f;
};
但是无论是const还是constexpr都禁止对非字面值类型进行这一类初始化。比如,一个用户定义的任意精度的BigInt类型,可能就不是字面值类型,因为它可能会需要将一部分信息存储在堆上(这会阻碍其成为一个字面值类型),或者是因为我们所需要的构造函数不是constexpr的。下面这个实例化的例子就是错误的:1
2
3
4
5
6
7
8
9
10class BigInt {
BigInt(long long);
...
};
...
template<>
struct AccumulationTraits<BigInt> {
using AccT = BigInt;
static constexpr BigInt zero = BigInt{0}; // ERROR: not a literal type
};
一个比较直接的解决方案是,不再\在类中定义值萃取(只做声明):1
2
3
4
5template<>
struct AccumulationTraits<BigInt> {
using AccT = BigInt;
static BigInt const zero; // declaration only
};
然后在源文件中对其进行初始化,像下面这样:1
BigInt const AccumulationTraits<BigInt>::zero = BigInt{0};
这样虽然可以工作,但是却有些麻烦(必须在两个地方同时修改代码),这样可能还会有些低效,因为编译期通常并不知晓在其它文件中的变量定义。在C++17中,可以通过使用inline变量来解决这一问题:1
2
3
4
5template<>
struct AccumulationTraits<BigInt> {
using AccT = BigInt;
inline static BigInt const zero = BigInt{0}; // OK since C++17
};
在C++17之前的另一种解决办法是,对于那些不是总是生成整型值的值萃取,使用inline成员函数。同样的,如果成员函数返回的是字面值类型,可以将该函数声明为constexpr的。比如,我们可以像下面这样重写AccumulationTraits:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37template<typename T>
struct AccumulationTraits;
template<>
struct AccumulationTraits<char> {
using AccT = int;
static constexpr AccT zero() {
return 0;
}
};
template<>
struct AccumulationTraits<short> {
using AccT = int;
static constexpr AccT zero() {
return 0;
}
};
template<>
struct AccumulationTraits<int> {
using AccT = long;
static constexpr AccT zero() {
return 0;
}
};
template<>
struct AccumulationTraits<unsigned int> {
using AccT = unsigned long;
static constexpr AccT zero() {
return 0;
}
};
template<>
struct AccumulationTraits<float> {
using AccT = double;
static constexpr AccT zero() {
return 0;
}
};
然后针我们自定义的类型对这些萃取进行扩展:1
2
3
4
5
6
7template<>
struct AccumulationTraits<BigInt> {
using AccT = BigInt;
static BigInt zero() {
return BigInt{0};
}
};
在应用端,唯一的区别是函数的调用语法(不像访问一个static数据成员那么简洁):1
AccT total = AccumulationTraits<T>::zero(); // init total by trait function
很明显,萃取可以不只是类型。在我们的例子中,萃取可以是一种能够提供所有在调用accum()
时所需的调用参数的信息的技术。这是萃取这一概念的关键:萃取为泛型编程提供了一种配置(configure)具体元素(通常是类型)的手段。
参数化的萃取
在前面几节中,在accum()
里使用的萃取被称为固定的(fixed),这是因为一旦定义了解耦合萃取,在算法中它就不可以被替换。但是在某些情况下,这一类重写(overriding)行为却又是我们所期望的。比如,我们可能碰巧知道某一组float数值的和可以被安全地存储在一个float变量中,而这样做可能又会带来一些性能的提升。
为了解决这一问题,可以为萃取引入一个新的模板参数AT,其默认值由萃取模板决定:1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T, typename AT = AccumulationTraits<T>>
auto accum (T const* beg, T const* end)
{
typename AT::AccT total = AT::zero();
while (beg != end) {
total += *beg;
++beg;
}
return total;
}
采用这种方式,一部分用户可以忽略掉额外模板参数,而对于那些有着特殊需求的用户,他们可以指定一个新的类型来取代默认类型。但是可以推断,大部分的模板用户永远都不需要显式的提供第二个模板参数,因为我们可以为第一个模板参数的每一种(通过推断得到的)类型都配置一个合适的默认值。
萃取还是策略以及策略类
到目前为止我们并没有区分累积(accumulation)和求和(summation)。但是我们也可以相像其它种类的累积。比如,我们可以对一组数值求积。或者说,如果这些值是字符串的话,我们可以将它们连接起来。即使是求一个序列中最大值的问题,也可以转化成一个累积问题。在所有这些例子中,唯一需要变得的操作是accum()
中的total += *beg
。我们可以称这一操作为累积操作的一个策略(policy)。
下面是一个在accum()
中引入这样一个策略的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename T,
typename Policy = SumPolicy,
typename Traits = AccumulationTraits<T>>
auto accum (T const* beg, T const* end)
{
using AccT = typename Traits::AccT;
AccT total = Traits::zero();
while (beg != end) {
Policy::accumulate(total, *beg);
++beg;
}
return total;
}
在这一版的accum()
中,SumPolicy是一个策略类,也就是一个通过预先商定好的接口,为算法实现了一个或多个策略的类。SumPolicy可以被实现成下面这样:1
2
3
4
5
6
7
8
9
10
class SumPolicy {
public:
template<typename T1, typename T2>
static void accumulate (T1& total, T2 const& value) {
total += value;
}
};
如果提供一个不同的策略对数值进行累积的话,我们可以计算完全不同的事情。比如考虑下面这个程序,它试图计算一组数值的乘积:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MultPolicy {
public:
template<typename T1, typename T2>
static void accumulate (T1& total, T2 const& value) {
total *= value;
}
};
int main()
{
// create array of 5 integer values
int num[] = { 1, 2, 3, 4, 5 };
// print product of all values
std::cout << "the product of the integer values is " <<
accum<int,MultPolicy>(num, num+5) << "\n";
}
但是这个程序的输出却和我们所期望的有所不同:1
the product of the integer values is 0
问题出在我们对初始值的选取:虽然0能很好的满足求和的需求,但是却不适用于求乘积(初始值0会让乘积的结果也是0)。这说明不同的萃取和策略可能会相互影响,也恰好强调了仔细设计模板的重要性。
在这种情况下,我们可能会认识到,累积循环的初始值应该是累计策略的一部分。这个策略可以使用也可以不使用其zero()萃取。其它一些方法也应该被记住:不是所有的事情都要用萃取和策略才能够解决的。比如,C++标准库中的std::accumulate()
就将其初始值当作了第三个参数。
萃取和策略:有什么区别
可以设计一个合适的例子来证明策略只是萃取的一个特例。相反地,也可以认为萃取只是编码了一个特定的策略。
引入了萃取技术的Nathan Myers则建议使用如下更为开放的定义:
- 萃取类:一个用来代替模板参数的类。作为一个类,它整合了有用的类型和常量;作为一个模板,它为实现一个可以解决所有软件问题的“额外的中间层”提供了方法。
总体而言,我们更倾向于使用如下(稍微模糊的)定义:
- 萃取代表的是一个模板参数的本质的、额外的属性。
- 策略代表的是泛型函数和类型(通常都有其常用地默认值)的可以配置的行为。
为了进一步阐明两者之间可能的差异,我们列出了如下和萃取有关的观察结果:
- 萃取在被当作固定萃取(fixed traits)的时候会比较有用(比如,当其不是被作为模板参数传递的时候)。
- 萃取参数通常都有很直观的默认参数(很少被重写,或者简单的说是不能被重写)。
- 萃取参数倾向于紧密的依赖于一个或者多个主模板参数。
- 萃取在大多数情况下会将类型和常量结合在一起,而不是成员函数。
- 萃取倾向于被汇集在萃取模板中。
对于策略类,我们有如下观察结果:
- 策略类如果不是被作为模板参数传递的话,那么其作用会很微弱。
- 策略参数不需要有默认值,它们通常是被显式指定的(虽有有些泛型组件通常会使用默认策略)。
- 策略参数通常是和其它模板参数无关的。
- 策略类通常会包含成员函数。
- 策略可以被包含在简单类或者类模板中。
但是,两者之间并没有一个清晰的界限。比如,C++标准库中的字符萃取就定义了一些函数行为(比如比较,移动和查找字符)。通过替换这些萃取,我们定义一个大小写敏感的字符类型,同时又可以保留相同的字符类型。因此,虽然它们被称为萃取,但是它们的一些属性和策略确实有联系的。
成员模板还是模板模板参数?
为了实现累积策略(accumulation policy),我们选择将SumPolicy和MultPolicy实现为有成员模板的常规类。另一种使用类模板设计策略类接口的方式,此时就可以被当作模板模板参数使用。比如,我们可以将SumPolicy重写为如下模板:1
2
3
4
5
6
7
8
9
10
template<typename T1, typename T2>
class SumPolicy {
public:
static void accumulate (T1& total, T2 const& value) {
total += value;
}
};
此时就可以调整Accum,让其使用一个模板模板参数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T, template<typename,typename> class Policy = SumPolicy,
typename Traits = AccumulationTraits<T>>
auto accum (T const* beg, T const* end)
{
using AccT = typename Traits::AccT;
AccT total = Traits::zero();
while (beg != end) {
Policy<AccT,T>::accumulate(total, *beg);
++beg;
}
return total;
}
相同的转化也可以被用于萃取参数。
通过模板模板参数访问策略类的主要优势是,让一个策略类通过一个依赖于模板参数的类型携带一些状态信息会更容易一些(比如static数据成员)。(在我们的第一个方法中,static数据成员必须要被嵌入到一个成员类模板中。)但是,模板模板参数方法的一个缺点是,策略类必须被实现为模板,而且模板参数必须和我们的接口所定义的参数一样。这可能会使萃取本身的表达相比于非模板类变得更繁琐,也更不自然。
结合多个策略以及/或者萃取
该如何给这些模板参数排序?一个简单的策略是,根据参数默认值被选择的可能型进行递增排序(也就是说,越是有可能使用一个参数的默认值,就将其排的越靠后)。比如说,萃取参数通常要在策略参数后面。
如果我们不介意增加代码的复杂性的话,还有一种可以按照任意顺序指定非默认参数的方法。
通过普通迭代器实现累积
在结束萃取和策略的介绍之前,最好再看下另一个版本的accum()
的实现,在该实现中添加了处理泛化迭代器的能力(不再只是简单的指针),这是为了支持工业级的泛型组件。有意思的是,我们依然可以用指针来调用这一实现,因为C++标准库提供了迭代器萃取。此时我们就可以像下面这样定义我们最初版本的accum()
了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename Iter>
auto accum (Iter start, Iter end)
{
using VT = typename std::iterator_traits<Iter>::value_type;
VT total{}; // assume this actually creates a zero value
while (start != end) {
total += *start;
++start;
}
return total;
}
这里的std::iterator_traits
包含了所有迭代器相关的属性。由于存在一个针对指针的偏特化,这些萃取可以很方便的被用于任意常规的指针类型。标准库对这一特性的支持可能会像下面这样:1
2
3
4
5
6
7
8
9
10namespace std {
template<typename T>
struct iterator_traits<T*> {
using difference_type = ptrdiff_t;
using value_type = T;
using pointer = T*;
using reference = T&;
using iterator_category = random_access_iterator_tag ;
};
}
但是,此时并没有一个适用于迭代器所指向的数值的累积的类型;因此我们依然需要设计自己的AccumulationTraits。
类型函数
最初的示例说明我们可以基于类型定义行为。传统上我们在C和C++里定义的函数可以被更明确的称为值函数(value functions):它们接收一些值作为参数并返回一个值作为结果。对于模板,我们还可以定义类型函数(type functions):它们接收一些类型作为参数并返回一个类型或者常量作为结果。一个很有用的内置类型函数是sizeof
,它返回了一个代表了给定类型大小(单位是byte)的常数。类模板依然可以被用作类型函数。此时类型函数的参数是模板参数,其结果被提取为成员类型或者成员常量。比如,sizeof运算符可以被作为如下接口提供:1
2
3
4
5
6
7
8
9
10
template<typename T>
struct TypeSize {
static std::size_t const value = sizeof(T);
};
int main()
{
std::cout << "TypeSize<int>::value = " << TypeSize<int>::value << "\n";
}
这看上去可能没有那么有用,因为我们已经有了一个内置的sizeof
运算符,但是请注意此处的TypeSize<T>
是一个类型,它可以被作为类模板参数传递。或者说,TypeSize
是一个模板,也可以被作为模板模板参数传递。
元素类型
假设我们有很多的容器模板,比如std::vector<>
和std::list<>
,也可以包含内置数组。我们希望得到这样一个类型函数,当给的一个容器类型时,它可以返回相应的元素类型。这可以通过偏特化实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>
struct ElementT; // primary template
template<typename T>
struct ElementT<std::vector<T>> { //partial specialization for std::vector
using Type = T;
};
template<typename T>
struct ElementT<std::list<T>> { //partial specialization for std::list
using Type = T;
};
...
template<typename T, std::size_t N>
struct ElementT<T[N]> { //partial specialization for arrays of known bounds
using Type = T;
};
template<typename T>
struct ElementT<T[]> { //partial specialization for arrays of unknown bounds
using Type = T;
};
...
注意此处我们应该为所有可能的数组类型提供偏特化。我们可以像下面这样使用这些类型函数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
void printElementType (T const& c)
{
std::cout << "Container of " <<
typeid(typename ElementT<T>::Type).name() << " elements.\n";
}
int main()
{
std::vector<bool> s;
printElementType(s);
int arr[42];
printElementType(arr);
}
偏特化的使用使得我们可以在容器类型不知道具体类型函数存在的情况下去实现类型函数。但是在某些情况下,类型函数是和其所适用的类型一起被设计的,此时相关实现就可以被简化。比如,如果容器类型定义了value_type
成员类型(标准库容器都会这么做),我们就可以有如下实现:1
2
3
4template<typename C>
struct ElementT {
using Type = typename C::value_type;
};
这个实现可以是默认实现,它不会排除那些针对没有定义成员类型value_type
的容器的偏特化实现。
虽然如此,我们依然建议为类模板的类型参数提供相应的成员类型定义,这样在泛型代码中就可以更容易的访问它们(和标准库容器的处理方式类似)。下面的代码体现了这一思想:1
2
3
4
5
6
7template<typename T1, typename T2, ...>
class X {
public:
using ... = T1;
using ... = T2;
...
};
那么类型函数的作用体现在什么地方呢?它允许我们根据容器类型参数化一个模板,但是又不需要提供代表了元素类型和其它特性的参数。比如,相比于使用1
2template<typename T, typename C>
T sumOfElements (C const& c);
这一需要显式指定元素类型的模板(sumOfElements<int> list
),我们可以定义这样一个模板:1
2template<typename C>
typename ElementT<C>::Type sumOfElements (C const& c);
其元素类型是通过类型函数得到的。
注意观察萃取是如何被实现为已有类型的扩充的;也就是说,我们甚至可以为基本类型和封闭库的类型定义类型函数。
在上述情况下,ElementT
被称为萃取类,因为它被用来访问一个已有容器类型的萃取(通常而言,在这样一个类中可以有多个萃取)。因此萃取类的功能并不仅限于描述容器参数的特性,而是可以描述任意“主参数”的特性。
为了方便,我们可以为类型函数创建一个别名模板。比如,我们可以引入:1
2template<typename T>
using ElementType = typename ElementT<T>::Type;
这可以让sumOfEkements的定义变得更加简单:1
2template<typename C>
ElementType<C> sumOfElements (C const& c);
转换萃取(Transformation Traits)
除了可以被用来访问主参数类型的某些特性,萃取还可以被用来做类型转换,比如为某个类型添加或移除引用、 const以及volatile限制符。
删除引用
比如,我们可以实现一个RemoveReferenceT萃取,用它将引用类型转换成其底层对象或者函数的类型,对于非引用类型则保持不变:1
2
3
4
5
6
7
8
9
10
11
12template<typename T>
struct RemoveReferenceT {
using Type = T;
};
template<typename T>
struct RemoveReferenceT<T&> {
using Type = T;
};
template<typename T>
struct RemoveReferenceT<T&&> {
using Type = T;
};
同样地,引入一个别名模板可以简化上述萃取的使用:1
2template<typename T>
using RemoveReference = typename RemoveReference<T>::Type;
当类型是通过一个有时会产生引用类型的构造器获得的时候,从一个类型中删除引用会很有意义。
添加引用
我们也可以给一个已有类型添加左值或者右值引用:1
2
3
4
5
6
7
8
9
10
11
12template<typename T>
struct AddLValueReferenceT {
using Type = T&;
};
template<typename T>
using AddLValueReference = typename AddLValueReferenceT<T>::Type;
template<typename T>
struct AddRValueReferenceT {
using Type = T&&;
};
template<typename T>
using AddRValueReference = typename AddRValueReferenceT<T>::Type;
引用折叠的规则在这一依然适用。比如对于AddLValueReference<int &&>
,返回的类型是int&
,因为我们不需要对它们进行偏特化实现。
如果我们只实现AddLValueReferenceT和AddRValueReferenceT,而又不对它们进行偏特化的话,最方便的别名模板可以被简化成下面这样:1
2
3
4template<typename T>
using AddLValueReferenceT = T&;
template<typename T>
using AddRValueReferenceT = T&&;
此时不通过类模板的实例化就可以对其进行实例化(因此称得上是一个轻量级过程)。但是这样做是由风险的,因此我们依然希望能够针对特殊的情况对这些模板进行特例化。比如,如果适用上述简化实现,那么我们就不能将其用于void类型。一些显式的特化实现可以被用来处理这些情况:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16template<>
struct AddLValueReferenceT<void> {
using Type = void;
};
template<>
struct AddLValueReferenceT<void const> {
using Type = void const;
};
template<>
struct AddLValueReferenceT<void volatile> {
using Type = void volatile;
};
template<>
struct AddLValueReferenceT<void const volatile> {
using Type = void const volatile;
};
有了这些偏特化之后,上文中的别名模板必须被实现为类模板的形式,这样才能保证相应的篇特换在需要的时候被正确选取(因为别名模板不能被特化)。
C++标准库中也提供了与之相应的类型萃取:std::add_lvalue_reference<>
和std::add_rvalue_reference<>
。该标准模板也包含了对void类型的特化。
移除限制符
转换萃取可以分解或者引入任意种类的复合类型,并不仅限于引用。比如,如果一个类型中存在const限制符,我们可以将其移除:1
2
3
4
5
6
7
8
9
10template<typename T>
struct RemoveConstT {
using Type = T;
};
template<typename T>
struct RemoveConstT<T const> {
using Type = T;
};
template<typename T>
using RemoveConst = typename RemoveConstT<T>::Type;
而且,转换萃取可以是多功能的,比如创建一个可以被用来移除const和volatile的RemoveCVT萃取:1
2
3
4
5
6
7
template<typename T>
struct RemoveCVT : RemoveConstT<typename RemoveVolatileT<T>::Type>
{};
template<typename T>
using RemoveCV = typename RemoveCVT<T>::Type;
RemoveCVT
中有两个需要注意的地方。第一个需要注意的地方是,它同时使用了RemoveConstT
和相关的RemoveVolitleT
,首先移除类型中可能存在的volatile,然后将得到了类型传递给RemoveConstT
。第二个需要注意的地方是,它没有定义自己的和RemoveConstT
中Type类似的成员,而是通过使用元函数转发(metafunction forwarding)从RemoveConstT
中继承了Type成员。这里元函数转发被用来简单的减少RemoveCVT
中的类型成员。但是,即使是对于没有为所有输入都定义了元函数的情况,元函数转发也会很有用。
RemoveCVT
的别名模板可以被进一步简化成:1
2template<typename T>
using RemoveCV = RemoveConst<RemoveVolatile<T>>;
同样地,这一简化只适用于RemoveCVT
没有被特化的情况。但是和AddLValueReference
以及AddRValueReference
的情况不同的是,我们想不出一种对其进行特化的原因。
C++标准库也提供了与之对应的std::remove_volatile<>
,std::remove_const<>
,以及std::remove_cv<>
。
退化(Decay)
为了使对转换萃取的讨论变得更完整,我们接下来会实现一个模仿了按值传递参数时的类型转化行为的萃取。该类型转换继承自C语言,这意味着参数类型会发生退化(数组类型退化成指针类型,函数类型退化成指向函数的指针类型),而且会删除相应的顶层const,volatile以及引用限制符(因为在解析一个函数调用时,会会忽略掉参数类型中的顶层限制符)。下面的程序展现了按值传递的效果,它会打印出经过编译器退化之后的参数类型:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<typename T>
void f(T)
{}
template<typename A>
void printParameterType(void (*)(A))
{
std::cout << "Parameter type: " << typeid(A).name() << "\n";
std::cout << "- is int: " <<std::is_same<A,int>::value << "\n";
std::cout << "- is const: " <<std::is_const<A>::value << "\n";
std::cout << "- is pointer: " <<std::is_pointer<A>::value << "\n";
}
int main()
{
printParameterType(&f<int>);
printParameterType(&f<int const>);
printParameterType(&f<int[7]>);
printParameterType(&f<int(int)>);
}
在程序的输出中,除了int
参数保持不变外,其余int const
,int[7]
,以及int(int)
参数分别退化成了int
,int*
,以及int(*)(int)
。我们可以实现一个与之功能类似的萃取。为了和C++标准库中的std::decay
保持匹配,我们称之为DecayT
。它的实现结合了上文中介绍的多种技术。首先我们对非数组、非函数的情况进行定义,该情况只需要删除const和volatile限制符即可:1
2
3template<typename T>
struct DecayT : RemoveCVT<T>
{};
然后我们处理数组到指针的退化,这需要用偏特化来处理所有的数组类型(有界和无界数组):1
2
3
4
5
6
7
8template<typename T>
struct DecayT<T[]> {
using Type = T*;
};
template<typename T, std::size_t N>
struct DecayT<T[N]> {
using Type = T*;
};
最后来处理函数到指针的退化,这需要应对所有的函数类型,不管是什么返回类型以及有多数参数。为此,我们适用了变参模板:1
2
3
4
5
6
7
8template<typename R, typename... Args>
struct DecayT<R(Args...)> {
using Type = R (*)(Args...);
};
template<typename R, typename... Args>
struct DecayT<R(Args..., ...)> {
using Type = R (*)(Args..., ...);
};
注意,上面第二个偏特化可以匹配任意使用了C-style可变参数的函数。下面的例子展示了DecayT主模板以及其全部四种偏特化的使用:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
void printDecayedType()
{
using A = typename DecayT<T>::Type;
std::cout << "Parameter type: " << typeid(A).name() << "\n";
std::cout << "- is int: " << std::is_same<A,int>::value << "\n";
std::cout << "- is const: " << std::is_const<A>::value << "\n";
std::cout << "- is pointer: " << std::is_pointer<A>::value << "\n";
}
int main()
{
printDecayedType<int>();
printDecayedType<int const>();
printDecayedType<int[7]>();
printDecayedType<int(int)>();
}
和往常一样,我们也提供了一个很方便的别名模板:1
2template typename T>
using Decay = typename DecayT<T>::Type;
预测型萃取
到目前为止,我们学习并开发了适用于单个类型的类型函数:给定一个类型,产生另一些相关的类型或者常量。但是通常而言,也可以设计基于多个参数的类型函数。这同样会引出另外一种特殊的类型萃取—类型预测(产生一个bool数值的类型函数)。
IsSameT
将判断两个类型是否相同:1
2
3
4
5
6
7
8template<typename T1, typename T2>
struct IsSameT {
static constexpr bool value = false;
};
template<typename T>
struct IsSameT<T, T> {
static constexpr bool value = true;
};
这里的主模板说明通常我们传递进来的两个类型是不同的,因此其value成员是false。但是,通过使用偏特化,当遇到传递进来的两个相同类型的特殊情况,value成员就是true的。比如,如下表达式会判断传递进来的模板参数是否是整型:1
if (IsSameT<T, int>::value) ...
对于产生一个常量的萃取,我们没法为之定义一个别名模板,但是可以为之定义一个扮演可相同角色的constexpr的变量模板:1
2template<typename T1, typename T2>
constexpr bool isSame = IsSameT<T1, T2>::value;
true_type和false_type
通过为可能的输出结果true和false提供不同的类型,我们可以大大的提高对IsSameT的定义。事实上,如果我们声明一个BoolConstant模板以及两个可能的实例TrueType和FalseType:1
2
3
4
5
6
7template<bool val>
struct BoolConstant {
using Type = BoolConstant<val>;
static constexpr bool value = val;
};
using TrueType = BoolConstant<true>;
using FalseType = BoolConstant<false>;
就可以基于两个类型是否匹配,让相应的IsSameT分别继承自TrueType和FalseType:1
2
3
4
5
template<typename T1, typename T2>
struct IsSameT : FalseType{};
template<typename T>
struct IsSameT<T, T> : TrueType{};
现在IsSameT<T, int>
的返回类型会被隐式的转换成其基类TrueType或者FalseType,这样就不仅提供了相应的value成员,还允许在编译期间将相应的需求派发到对应的函数实现或者类模板的偏特化上。比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>
void fooImpl(T, TrueType)
{
std::cout << "fooImpl(T,true) for int called\n";
}
template<typename T>
void fooImpl(T, FalseType)
{
std::cout << "fooImpl(T,false) for other type called\n";
}
template<typename T>
void foo(T t)
{
fooImpl(t, IsSameT<T,int>{}); // choose impl. depending on whether T is int
}
int main()
{
foo(42); // calls fooImpl(42, TrueType)
foo(7.7); // calls fooImpl(42, FalseType)
}
这一技术被称为标记派发(tag dispatching)。注意在BoolConstant的实现中还有一个Type成员,这样就可以通过它为IsSameT引入一个
别名模板:1
2template<typename T>
using isSame = typename IsSameT<T>::Type;
这里的别名模板可以和之前的变量模板isSame并存。
通常而言,产生bool值的萃取都应该通过从诸如TrueType和FalseType的类型进行派生来支持标记派发。但是为了尽可能的进行泛化,应该只有一个类型代表true,也应该只有一个类型代表false,而不是让每一个泛型库都为bool型常量定义它自己的类型。幸运的是,从C++11开始C++标准库在<type_traits>
中提供了相应的类型:std::true_type
和std::false_type
。在C++11和C++14中其定义如下:1
2
3
4namespace std {
using true_type = integral_constant<bool, true>;
using false_type = integral_constant<bool, false>;
}
在C++17中,其定义如下:1
2
3
4namespace std {
using true_type = bool_constant<true>;
using false_type = bool_constant<false>;
}
其中bool_constant
的定义如下:1
2
3
4namespace std {
template<bool B>
using bool_constant = integral_constant<bool, B>;
}
返回结果类型萃取
另一个可以被用来处理多个类型的类型函数的例子是返回值类型萃取。在编写操作符模板的时候它们会很有用。为了引出这一概念,我们来写一个可以对两个Array容器求和的函数模板:1
2template<typename T>
Array<T> operator+ (Array<T> const&, Array<T> const&);
这看上去很好,但是由于语言本身允许我们对一个char型数值和一个整形数值求和,我们自然也很希望能够对Array也执行这种混合类型(mixed-type)的操作。这样我们就要处理该如何决定相关模板的返回值的问题:1
2template<typename T1, typename T2>
Array<???> operator+ (Array<T1> const&, Array<T2> const&);
一个可以解决上述问题的方式就是返回值类型模板:1
2
3template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type>
operator+ (Array<T1> const&, Array<T2> const&);
如果有便捷别名模板可用的话,还可以将其写称这样:1
2
3template<typename T1, typename T2>
Array<PlusResult<T1, T2>>
operator+ (Array<T1> const&, Array<T2> const&);
其中的PlusResultT萃取会自行判断通过+操作符对两种类型(可能是不同类型)的数值求和所得到的类型:1
2
3
4
5
6template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(T1() + T2());
};
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;
这一萃取模板通过使用decltype来计算表达式T1()+T2()
的类型,将决定结果类型这一艰巨的工作(包括处理类型增进规则(promotion rules)和运算符重载)留给了编译器。
但是对于我们的例子而言,decltype却保留了过多的信息。比如,我们的PlusResultT可能会返回一个引用类型,但是我们的Array模板却很可能不是为引用类型设计的。更为实际的例子是,重载的operator+
可能会返回一个const类型的数值:1
2class Integer { ... };
Integer const operator+ (Integer const&, Integer const&);
对两个Array<Integer>
的值进行求和却得到了一个存储了Integer const数值的Array,这很可能不是我们所期望的结果。事实上我们所期望的是将返回值类型中的引用和限制符移除之后所得到的类型,正如我们在上一小节所讨论的那样:1
2
3template<typename T1, typename T2>
Array<RemoveCV<RemoveReference<PlusResult<T1, T2>>>>
operator+ (Array<T1> const&, Array<T2> const&);
这一萃取的嵌套形式在模板库中很常见,在元编程中也经常被用到。
到目前为止,数组的求和运算符可以正确地计算出对两个元素类型可能不同的Array进行求和的结果类型。但是上述形式的PlusResultT却对元素类型T1和T2施加了一个我们所不期望的限制:由于表达式T1() + T2()
试图对类型T1和T2的数值进行值初始化,这两个类型必须要有可访问的、未被删除的默认构造函数(或者是非class类型)。Array类本身可能并没有要求其元素类型可以被进行值初始化,因此这是一个额外的、不必要的限制。
declval
好在我们可以很简单的在不需要构造函数的情况下计算+表达式的值,方法就是使用一个可以为一个给定类型T生成数值的函数。为了这一目的,C++标准提供了std::declval<>
。在<utility>
中其定义如下:1
2
3
4namespace std {
template<typename T>
add_rvalue_reference_t<T> declval() noexcept;
}
表达式declval<>
可以在不需要使用默认构造函数(或者其它任意操作)的情况下为类型T生成一个值。该函数模板被故意设计成未定义的状态,因为我们只希望它被用于decltype,sizeof或者其它不需要相关定义的上下文中。它有两个很有意思的属性:
- 对于可引用的类型,其返回类型总是相关类型的右值引用,这能够使declval适用于那些不能够正常从函数返回的类型,比如抽象类的类型(包含纯虚函数的类型)或者数组类型。因此当被用作表达式时,从类型
T
到T&&
的转换对declval<T>()
的行为是没有影响的:其结果都是右值(如果T是对象类型的话),对于右值引用,其结果之所以不会变是因为存在引用塌缩。 - 在noexcept异常规则中提到,一个表达式不会因为使用了declval而被认成是会抛出异常的。当declval被用在noexcept运算符上下文中时,这一特性会很有帮助
有了declval,我们就可以不用在PlusResultT中使用值初始化了:1
2
3
4
5
6
7
template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;
返回值类型萃取提供了一种从特定操作中获取准确的返回值类型的方式,在确定函数模板的返回值的类型的时候,它会很有用。
基于SFINAE的萃取(SFINAE-Based Traits)
SFINAE会将在模板参数推断过程中,构造无效类型和表达式的潜在错误(会导致程序出现语法错误)转换成简单的推断错误,这样就允许重载解析继续在其它待选项中间做选择。虽然SFINAE最开始是被用来避免与函数模板重载相关的伪错误,我们也可以用它在编译期间判断特定类型和表达式的有效性。比如我们可以通过萃取来判断一个类型是否有某个特定的成员,是否支持某个特定的操作,或者该类型本身是不是一个类。
基于SFINAE的两个主要技术是:用SFINAE排除某些重载函数,以及用SFINAE排除某些偏特化。
用SFINAE排除某些重载函数
我们触及到的第一个基于SFINAE的例子是将SFINAE用于函数重载,以判断一个类型是否是默认可构造的,对于可以默认构造的类型,就可以不通过值初始化来创建对象。也就是说,对于类型T,诸如T()
的表达式必须是有效的。一个基础的实现可能会像下面这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
struct IsDefaultConstructibleT {
private:
// test() trying substitute call of a default constructor for
//T passed as U :
template<typename U, typename = decltype(U())>
static char test(void*);// test() fallback:
template<typename>
static long test(...);
public:
static constexpr bool value =
IsSameT<decltype(test<T>(nullptr)), char>::value;
};
通过函数重载实现一个基于SFINAE的萃取的常规方式是声明两个返回值类型不同的同名(test()
)重载函数模板:1
2template<...> static char test(void*);
template<...> static long test(...);
第一个重载函数只有在所需的检查成功时才会被匹配到(后文会讨论其实现方式)。第二个重载函数是用来应急的:它会匹配任意调用,但是由于它是通过”…”(省略号)进行匹配的,因此其它任何匹配的优先级都比它高。
返回值value的具体值取决于最终选择了哪一个test函数:1
2static constexpr bool value
= IsSameT<decltype(test<...>(nullptr)), char>::value;
如果选择的是第一个test()
函数,由于其返回值类型是char,value会被初始化为isSame<char, char>
,也就是true。否则,value会被初始化为isSame<long, char>
,也就是false。
现在,到了该处理我们所需要检测的属性的时候了。目标是只有当我们所关心的测试条件被满足的时候,才可以使第一个test()
有效。在这个例子中,我们想要测试的条件是被传递进来的类型T是否是可以被默认构造的。为了实现这一目的,我们将T传递给U,并给第一个test()
声明增加一个无名的(dummy)模板参数,该模板参数被一个只有在这一转换有效的情况下才有效的构造函数进行初始化。在这个例子中,我们使用的是只有当存在隐式或者显式的默认构造函数U()
时才有效的表达式。我们对U()
的结果施加了deltype操作,这样就可以用其结果初始化一个类型参数了。
第二个模板参数不可以被推断,因为我们不会为之传递任何参数。而且我们也不会为之提供显式的模板参数。因此,它只会被替换,如果替换失败,基于SFINAE,相应的test()
声明会被丢弃掉,因此也就只有应急方案可以匹配相应的调用。
因此,我们可以像下面这样使用这一萃取:1
2
3
4
5IsDefaultConstructibleT<int>::value //yields true
struct S {
S() = delete;
};
IsDefaultConstructibleT<S>::value //yields false
但是需要注意,我们不能在第一个test()
声明里直接使用模板参数T:1
2
3
4
5
6
7
8
9
10
11
12
13template<typename T>
struct IsDefaultConstructibleT {
private:
// ERROR: test() uses T directly:
template<typename, typename = decltype(T())>
static char test(void*);
// test() fallback:
template<typename>
static long test(...);
public:
static constexpr bool value
= IsSameT<decltype(test<T>(nullptr)), char>::value;
};
但是这样做并不可以,因为对于任意的T,所有模板参数为T的成员函数都会被执行模板参数替换,因此对一个不可以默认构造的类型,这些代码会遇到编译错误,而不是忽略掉第一个test()
。通过将类模板的模板参数T传递给函数模板的参数U,我们就只为第二个test()
的重载创建了特定的SFINAE上下文。
另一种基于SFINAE的萃取的实现策略
远在1998年发布第一版C++标准之前,基于SFINAE的萃取的实现就已经成为了可能。该方法的核心一致都是实现两个返回值类型不同的重载函数模板:1
2template<...> static char test(void*);
template<...> static long test(...);
但是,在最早的实现技术中,会基于返回值类型的大小来判断使用了哪一个重载函数(也会用到0和enum,因为在当时nullptr和constexpr还没有被引入):1
enum { value = sizeof(test<...>(0)) == 1 };
在某些平台上,sizeof(char)
的值可能会等于sizeof(long)
的值。基于此,我们希望能够确保test()
的返回值类型在所有的平台上都有不同的值。比如,在定义了:1
2using Size1T = char;
using Size2T = struct { char a[2]; };
或者:1
2using Size1T = char(&)[1];
using Size2T = char(&)[2];
之后,可以像下面这样定义test()
的两个重载版本:1
2template<...> static Size1T test(void*); // checking test()
template<...> static Size2T test(...); // fallback
这样,我们要么返回Size1T,其大小为1,要么返回Size2T,在所有的平台上其值都至少是2。使用了上述某一种方式的代码目前依然很常见。但是要注意,传递给test()
的调用参数的类型并不重要。我们所要保证的是被传递的参数和所期望的类型能够匹配。比如,可以将其定义成能够接受整型常量42的形式:1
2
3
4template<...> static Size1T test(int); // checking test()
template<...> static Size2T test(...); // fallback
...
enum { value = sizeof(test<...>(42)) == 1 };
用SFINAE排除偏特化
另一种实现基于SFINAE的萃取的方式会用到偏特化。这里,我们同样可以使用上文中用来判断类型T是否是可以被默认初始化的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
//别名模板,helper to ignore any number of template parameters:
template<typename ...> using VoidT = void;
// primary template:
template<typename, typename = VoidT<>>
struct IsDefaultConstructibleT : std::false_type
{ };
// partial specialization (may be SFINAE"d away):
template<typename T>
struct IsDefaultConstructibleT<T, VoidT<decltype(T())>> :
std::true_type
{ };
和上文中优化之后的IsDefaultConstructibleT预测萃取类似,我们让适用于一般情况的版本继承自std::false_type,因为默认情况下一个类型没有size_type成员。此处一个比较有意思的地方是,第二个模板参数的默认值被设定为一个辅助别名模板VoidT。这使得我们能够定义各种使用了任意数量的编译期类型构造的偏特化。针对我们的例子,只需要一个类型构造:1
decltype(T())
这样就可以检测类型T是否是可以被默认初始化的。如果对于某个特定的类型T,其默认构造函数是无效的,此时SIFINEAE就是使该偏特化被丢弃掉,并最终使用主模板。否则该偏特化就是有效的,并且会被选用。
在C++17中,C++标准库引入了与VoidT对应的类型萃取std::void_t<>
。在C++17之前,向上面那样定义我们自己的std::void_t
是很有用的,甚至可以将其定义在std命名空间里:1
2
3
4
5
6
namespace std {
template<typename...> using void_t = void;
}
从C++14开始,C++标准委员会建议通过定义预先达成一致的特征宏(feature macros)来标识那些标准库的内容以及被实现了。这并不是标准的强制性要求,但是实现者通常都会遵守这一建议,以为其用户提供方便。__cpp_lib_void_t
就是被建议用来标识在一个库中是否实现了std::void_t
的宏,所以在上面的code中我们将其用于了条件判断。
将泛型Lambdas用于SFINAE
无论使用哪一种技术,在定义萃取的时候总是需要用到一些样板代码:重载并调用两个test()
成员函数,或者实现多个偏特化。接下来我们会展示在C++17中,如何通过指定一个泛型lambda来做条件测试,将样板代码的数量最小化。作为开始,先介绍一个用两个嵌套泛型lambda表达式构造的工具:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// helper: checking validity of f (args...) for F f and Args... args:
template<typename F, typename... Args, typename = decltype(std::declval<F>() (std::declval<Args&&>()...))>
std::true_type isValidImpl(void*);
// fallback if helper SFINAE"d out:
template<typename F, typename... Args>
std::false_type isValidImpl(...);
// define a lambda that takes a lambda f and returns whether calling f with args is valid
inline constexpr
auto isValid = [](auto f) {
return [](auto&&... args) {
return decltype(isValidImpl<decltype(f),
decltype(args)&&...>(nullptr)){};
};
};
// helper template to represent a type as a value
template<typename T>
struct TypeT {
using Type = T;
};
// helper to wrap a type as a value
template<typename T>
constexpr auto type = TypeT<T>{};
// helper to unwrap a wrapped type in unevaluated contexts
template<typename T>
T valueT(TypeT<T>); // no definition needed
先从isValid
的定义开始:它是一个类型为lambda闭包的constexpr变量。声明中必须要使用一个占位类型(placeholder type,代码中的auto),因为C++没有办法直接表达一个闭包类型。在C++17之前,lambda表达式不能出现在const表达式中,因此上述代码只有在C++17中才有效。因为isValid
是闭包类型的,因此它可以被调用,但是它被调用之后返回的依然是一个闭包类型,返回结果由内部的lambda表达式生成。
在深入讨论内部的lambda表达式之前,先来看一个isValid的典型用法:1
2constexpr auto isDefaultConstructible
= isValid([](auto x) -> decltype((void)decltype(valueT(x))() {});
我们已经知道isDefaultConstructible的类型是闭包类型,而且正如其名字所暗示的那样,它是一个可以被用来测试某个类型是不是可以被默认构造的函数对象。也就是说,isValid
是一个萃取工厂(traits factory):它会为其参数生成萃取,并用生成的萃取对对象进行测试。
辅助变量模板type允许我们用一个值代表一个类型。对于通过这种方式获得的数值x,我们可以通过使用decltype(valueT(x))
得到其原始类型,这也正是上面被传递给isValid
的lambda所做的事情。如果提取的类型不可以被默认构造,我们要么会得到一个编译错误,要么相关联的声明就会被SFINAE掉(得益于isValid
的具体定义,我们代码中所对应的情况是后者)。可以像下面这样使用isDefaultConstructible:1
2isDefaultConstructible(type<int>) //true (int is defaultconstructible)
isDefaultConstructible(type<int&>) //false (references are not default-constructible)
为了理解各个部分是如何工作的,先来看看当isValid的参数f被绑定到isDefaultConstructible
的泛型lambda参数时,isValid
内部的lambda表达式会变成什么样子。通过对isValid
的定义进行替换,我们得到如下等价代码:1
2
3
4
5constexpr auto isDefaultConstructible= [](auto&&... args) {
return decltype(isValidImpl<decltype([](auto x) ->
decltype((void)decltype(valueT(x))())),
decltype(args)&&...> (nullptr)){};
};
如果我们回头看看第一个isValidImpl()
的定义,会发现它还有一个如下形式的默认模板参数:1
decltype(std::declval<F>()(std::declval<Args&&>()...))>
它试图对第一个模板参数的值进行调用,而这第一个参数正是isDefaultConstructible
定义中的lambda的闭包类型,调用参数为传递给isDefaultConstructible
的(decltype(args)&&...
)类型的值。由于lambda中只有一个参数x,因此args
就需要扩展成一个参数;在我们上面的static_assert
例子中,参数类型为TypeT<int>
或者TypeT<int&>
。对于TypeT<int&>
的情况,decltype(valueT(x))
的结果是int&
,此时decltype(valueT(x))()
是无效的,因此在第一个isValidImpl()
的声明中默认模板参数的替换会失败,从而该isValidImpl()
声明会被SFINAE掉。这样就只有第二个声明可用,且其返回值类型为std::false_type
。整体而言,在传递type<int&>
的时候,isDefaultConstructible
会返回false_type。而如果传递的是type<int>
的话,替换不会失败,因此第一个isValidImpl()
的声明会被选择,返回结果也就是true_type类型的值。
我们的isDefaultConstructible萃取和之前的萃取在实现上有一些不同,主要体现在它需要执行函数形式的调用,而不是指定模板参数。这可能是一种更为刻度的方式,但是也可以按照之前的方式实现:1
2template<typename T>using IsDefaultConstructibleT
= decltype(isDefaultConstructible(std::declval<T>()));
虽然这是传统的模板声明方式,但是它只能出现在namespace作用域内,然而isDefaultConstructible
的定义却很可能被在一个块作用域内引入。
到目前为止,这一技术看上去好像并没有那么有竞争力,因为无论是实现中涉及的表达式还是其使用方式都要比之前的技术复杂得多。但是,一旦有了isValid
,并且对其进行了很好的理解,有很多萃取都可以只用一个声明实现。比如,对是否能够访问名为first的成员进行测试,就非常简洁:1
2constexpr auto hasFirst
= isValid([](auto x) -> decltype((void)valueT(x).first) {});
SFINAE友好的萃取
通常,类型萃取应该可以在不使程序出现问题的情况下回答特定的问题。基于SFINAE的萃取解决这一难题的方式是“小心地将潜在的问题捕获进一个SFINAE上下文中”,将可能出现的错误转变成相反的结果。
但是,到目前为止我们所展示的一些萃取在应对错误的时候表现的并不是那么好。回忆一下之前关于PlusResultT的定义:1
2
3
4
5
6
7template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;
在这一定义中,用到+的上下文并没有被SFINAE保护。因此,如果程序试着对不支持+运算符的类型执行PlusResultT的话,那么PlusResultT计算本身就会使成勋遇到错误,比如下面这个例子中,试着为两个无关类型A和B的数组的求和运算声明返回类型的情况:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<typename T>
class Array {
...
};
// declare + for arrays of different element types:
template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type> operator+ (Array<T1> const&,
Array<T2> const&);
很显然,如果没有为数组元素定义合适的+运算符的话,使用PlusResultT<>就会遇到错误。
class A {
};
class B {
};
void addAB(Array<A> arrayA, Array<B> arrayB) {
auto sum = arrayA + arrayB; // ERROR: fails in instantiation of PlusResultT<A, B>
...
}
这里的问题并不是错误会发生在代码明显有问题的地方(没办法对元素类型分别为A和B的数组进行求和),而是错误会发生在对operator+
进行模板参数推断的时候,在很深层次的PlusResultT<A,B>
的实例化中。这会导致一个很值得注意的结果:即使我们为A和B的数组重载一个求和函数,程序依然可能会遇到编译错误,因为C++不指定如果另一个重载更好的话,一个函数模板中的类型是否真的实例化。1
2
3
4
5
6
7
8
9
10// declare generic + for arrays of different element types:
template<typename T1, typename T2>
Array<typename PlusResultT<T1, T2>::Type>
operator+ (Array<T1> const&, Array<T2> const&);
// overload + for concrete types:
Array<A> operator+(Array<A> const& arrayA, Array<B> const& arrayB);
void addAB(Array<A> const& arrayA, Array<B> const& arrayB) {
auto sum = arrayA + arrayB; // ERROR?: depends on whether the compiler
... // instantiates PlusResultT<A,B>
}
如果编译器可以在不对第一个operator+
模板声明进行推断和替换的情况下,就能够判断出第二个operator+
声明会更加匹配的话,上述代码也不会有问题。
但是,在推断或者替换一个备选函数模板的时候,任何发生在类模板定义的实例化过程中的事情都不是函数模板替换的立即上下文(immediate context),SFINAE也不会保护我们不会在其中构建无效类型或者表达式。此时并不会丢弃这一函数模板待选项,而是会立即报出试图在PlusResult<>
中为A和B调用operator+
的错误:1
2
3
4template<typename T1, typename T2>
struct PlusResultT {
using Type = decltype(std::declval<T1>() + std::declval<T2> ());
}
为了解决这一问题,我们必须要将PlusResultT变成SFINAR友好的,也就是说需要为之提供更恰当的定义,以使其即使会在decltype中遇到错误,也不会诱发编译错误。参考在之前章节中介绍的HassLessT,我们可以通过定义一个HasPlusT
萃取,来判断给定的类型是有一个可用的+运算符:1
2
3
4
5
6
7
8
9
10
template<typename, typename, typename = std::void_t<>>
struct HasPlusT : std::false_type
{};
// partial specialization (may be SFINAE"d away):
template<typename T1, typename T2>
struct HasPlusT<T1, T2, std::void_t<decltype(std::declval<T1>() + std::declval<T2> ())>>
: std::true_type
{};
如果其返回结果为true,PlusResultT就可以使用现有的实现。否则,PlusResultT就需要一个安全的默认实现。对于一个萃取,如果对某一组模板参数它不能生成有意义的结果,那么最好的默认行为就是不为其提供Type成员。这样,如果萃取被用于SFINAE上下文中(比如之前代码中array类型的operator+
的返回值类型),缺少Type成员会导致模板参数推断出错,这也正是我们所期望的、 array类型的operator+
模板的行为。下面这一版PlusResultT的实现就提供了上述的行为:1
2
3
4
5
6
7
8
template<typename T1, typename T2, bool = HasPlusT<T1, T2>::value>
struct PlusResultT { //primary template, used when HasPlusT yields true
using Type = decltype(std::declval<T1>() + std::declval<T2>());
};
template<typename T1, typename T2>
struct PlusResultT<T1, T2, false> { //partial specialization, used otherwise
};
在这一版的实现中,我们引入了一个有默认值的模板参数,它会使用上文中的HasPlusT来判断前面的两个模板参数是否支持求和操作。然后我们对于第三个模板参数的值为false的情况进行了偏特化,而且在该偏特化中没有任何成员,从而避免了我们所描述过的问题。对与支持求和操作的情况,第三个模板参数的值是true,因此会选用主模板,也就是定义了Type
成员的那个模板。这样就保证了只有对支持+操作的类型,PlusResultT才会提供返回类型。
再次考虑Array<A>
和Array<B>
的求和:如果使用最新的PlusResultT
实现,那么PlusResultT<A, B>
的实例化将不会有Type成员,因为不能够对A和B进行求和。因此对应的operator+
模板的返回值类型是无效的,该函数模板也就会被SFINAE掉。这样就会去选择专门为Array<A>
和Array<B>
指定的operator+的重载版本。
作为一般的设计原则,在给定了合理的模板参数的情况下,萃取模板永远不应该在实例化阶段出错。其实先方式通常是执行两次相关的检查:
- 一次是检查相关操作是否有效
- 一次是计算其结果
在PlusResultT中我们已经见证了这一原则,在那里我们通过调用HasPlusT<>
来判断PlusResultImpl<>
中对operator+
的调用是否有效。
IsConvertibleT
细节很重要。因此基于SIFINAE萃取的常规方法在实际中会变得更加复杂。为了展示这一复杂性,我们将定义一个能够判断一种类型是否可以被转化成另外一种类型的萃取,比如当我们期望某个基类或者其某一个子类作为参数的时候。·就可以判断其第一个类型参数是否可以被转换成第二个类型参数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<typename FROM, typename TO>
struct IsConvertibleHelper {
private:
// test() trying to call the helper aux(TO) for a FROM passed as F :
static void aux(TO);
template<typename F, typename T,
typename = decltype(aux(std::declval<F>()))>
static std::true_type test(void*);
// test() fallback:
template<typename, typename>
static std::false_type test(...);
public:
using Type = decltype(test<FROM>(nullptr));
};
template<typename FROM, typename TO>
struct IsConvertibleT : IsConvertibleHelper<FROM, TO>::Type {
};
template<typename FROM, typename TO>
using IsConvertible = typename IsConvertibleT<FROM, TO>::Type;
template<typename FROM, typename TO>
constexpr bool isConvertible = IsConvertibleT<FROM, TO>::value;
我们在一个辅助类中定义了两个名为test()
的返回值类型不同的重载函数,并为该辅助类声明了Type成员类型:1
2
3
4
5
6
7
8template<...> static std::true_type test(void*);
template<...> static std::false_type test(...);
...
using Type = decltype(test<FROM>(nullptr));
...
template<typename FROM, typename TO>
struct IsConvertibleT : IsConvertibleHelper<FROM, TO>::Type {
};
和往常一样,第一个test()
只有在所需的检查成功的时候才会被匹配到,第二个test()
则是应急方案。因此问题的关键就是让第一个test()只有在类型FROM可以被转换成TO的时候才有效。为了实现这一目的,我们再次给第一个test()
分配了一个dummy(并且无名)的模板参数,并将其初始化成只有当转换又消失才有效的内容。该模板参数不可以被推断,我们也不会为之提供显式的模板参数。因此它会被替换,而且当替换失败之后,该test()
声明会被丢弃掉。
请再次注意,下面这种声明是不可以的:1
2
3static void aux(TO);
template<typename = decltype(aux(std::declval<FROM>()))>
static char test(void*);
这样当成员函数模板被解析的时候,FROM和TO都已经完全确定了,因此对一组不适合做相应转换的类型,在调用test()
之前就会立即触发错误。由于这一原因,我们引入了作为成员函数模板参数的F:1
2
3static void aux(TO);
template<typename F, typename = decltype(aux(std::declval<F> ()))>
static char test(void*);
并在value
的初始化中将FROM
类型用作调用test()
时的显式模板参数:1
2static constexpr bool value
= isSame<decltype(test<FROM>(nullptr)), char>;
请注意这里是如何在不调用任何构造函数的情况下,通过使用std::declval
生成一个类型的值的。如果这个值可以被转换成TO,对aux()
的调用就是有效的,相应的test()
调用也就会被匹配到。否则,会触发SFINAE错误,导致应急test()
被调用。然后,我们就可以像下面这样使用该萃取了:1
2
3
4IsConvertibleT<int, int>::value //yields true
IsConvertibleT<int, std::string>::value //yields false
IsConvertibleT<char const*, std::string>::value //yields true
IsConvertibleT<std::string, char const*>::value //yields false
处理特殊情况
下面3种情况还不能被上面的IsConvertibleT
正确处理:
- 向数组类型的转换要始终返回false,但是在上面的代码中,
aux()
声明中的类型为TO的参数会退化成指针类型,因此对于某些FROM类型,它会返回true。 - 向指针类型的转换也应该始终返回false,但是和1中的情况一样,上述实现只会将它们当作退化后的类型。
- 向(被const/volatile修饰)的void类型的转换需要返回true。但是不幸的是,在TO是void的时候,上述实现甚至不能被正确实例化,因为参数类型不能包含void类型(而且aux()的定义也用到了这一参数)。
对于这几种情况,我们需要对它们进行额外的偏特化。但是,为所有可能的与const以及volatile的组合情况都分别进行偏特化是很不明智的。相反,我们为辅助类模板引入了一个额外的模板参数:1
2
3
4
5
6
7
8
9
10template<typename FROM, typename TO, bool = IsVoidT<TO>::value ||
IsArrayT<TO>::value || IsFunctionT<TO>::value>
struct IsConvertibleHelper {
using Type = std::integral_constant<bool, IsVoidT<TO>::value && IsVoidT<FROM>::value>;
};
template<typename FROM, typename TO>
struct IsConvertibleHelper<FROM,TO,false> {
... //previous implementation of IsConvertibleHelper here
};
额外的bool型模板参数能够保证,对于上面的所有特殊情况,都会最终使用主辅助萃取(而不是偏特化版本)。如果我们试图将FROM转换为数组或者函数,或者FROM是void而TO不是,都会得到false_type
的结果,不过对于FROM和TO都是false_type
的情况,它也会返回false_type
。其它所有的情况,都会使第三个模板参数为false,从而选择偏特化版本的实现(对应于我们之前介绍的实现)。
探测成员(Detecting Members)
另一种对基于SFINAE的萃取的应用是,创建一个可以判断一个给定类型T是否含有名为X的成员(类型或者非类型成员)的萃取。
探测类型成员
首先定义一个可以判断给定类型T是否含有类型成员size_type的萃取:1
2
3
4
5
6
7
8
9
10
11
12
// defines true_type and false_type
// helper to ignore any number of template parameters:
template<typename ...> using VoidT = void;
// primary template:
template<typename, typename = VoidT<>>
struct HasSizeTypeT : std::false_type
{};
// partial specialization (may be SFINAE"d away):
template<typename T>
struct HasSizeTypeT<T, VoidT<typename T::size_type>> : std::true_type
{} ;
和往常已有,对于预测萃取,我们让一般情况派生自std::false_type,因为某些情况下一个类型是没有size_type成员的。在这种情况下,我们只需要一个条件:1
2
3
4
5
6
7
8
9
10typename T::size_type
···
该条件只有在T含有类型成员size_type的时候才有效,这也正是我们所想要做的。如果对于某个类型T,该条件无效,那么SFINAE会使偏特化实现被丢弃,我们就退回到主模板的情况。否则,偏特化有效并且会被有限选取。可以像下面这样使用萃取:
```C++
std::cout << HasSizeTypeT<int>::value; // false
struct CX {
using size_type = std::size_t;
};
std::cout << HasSizeType<CX>::value; // true
需要注意的是,如果类型成员size_type是private的,HasSizeTypeT会返回false,因为我们的萃取模板并没有访问该类型的特殊权限,因此typename T::size_type
是无效的(触发SFINAE)。也就是说,该萃取所做的事情是测试我们是否能够访问类型成员size_type。
探测任意类型成员
在定义了诸如HasSizeTypeT的萃取之后,我们会很自然的想到该如何将该萃取参数化,以对任意名称的类型成员做探测。
不幸的是,目前这一功能只能通过宏来实现,因为还没有语言机制可以被用来描述“潜在”的名字。当前不使用宏的、与该功能最接近的方法是使用泛型lambda。
如下的宏可以满足我们的需求:1
2
3
4
5
6
7
8
9
}; \
template<typename T> \
struct HasTypeT_##MemType<T, std::void_t<typename T::MemType>> \
: std::true_type { } // ; intentionally skipped
每一次对DEFINE_HAS_TYPE(MemberType)
的使用都相当于定义了一个新的HasTypeT_MemberType
萃取。比如,我们可以用之来探测一个类型是否有value_type
或者char_type
类型成员:1
2
3
4
5
6
7
8
9
10
11
12
DEFINE_HAS_TYPE(value_type);
DEFINE_HAS_TYPE(char_type);
int main()
{
std::cout << "int::value_type: " << HasTypeT_value_type<int>::value << "\n";
std::cout << "std::vector<int>::value_type: " << HasTypeT_value_type<std::vector<int>>::value << "\n";
std::cout << "std::iostream::value_type: " << HasTypeT_value_type<std::iostream>::value << "\n";
std::cout << "std::iostream::char_type: " << HasTypeT_char_type<std::iostream>::value << "\n";
}
探测非类型成员
可以继续修改上述萃取,以让其能够测试数据成员和(单个的)成员函数:1
2
3
4
5
6
7
8
9
std::void_t<decltype(&T::Member)>> \
: std::true_type { } // ; intentionally skipped
当&::Member
无效的时候,偏特化实现会被SFINAE掉。为了使条件有效,必须满足如下条件:
- Member必须能够被用来没有歧义的识别出T的一个成员(比如,它不能是重载成员你函数的名字,也不能是多重继承中名字相同的成员的名字)。
- 成员必须可以被访问。
- 成员必须是非类型成员以及非枚举成员(否则前面的&会无效)。
- 如果
T::Member
是static的数据成员,那么与其对应的类型必须没有提供使得&T::Member
无效的operator&
(比如,将operator&
设成不可访问的)。
所有以上条件都满足之后,我们可以像下面这样使用该模板:1
2
3
4
5
6
7
8
9
10
11
12
DEFINE_HAS_MEMBER(size);
DEFINE_HAS_MEMBER(first);
int main()
{
std::cout << "int::size: " << HasMemberT_size<int>::value << "\n";
std::cout << "std::vector<int>::size: " << HasMemberT_size<std::vector<int>>::value << "\n";
std::cout << "std::pair<int,int>::first: " << HasMemberT_first<std::pair<int,int>>::value << "\n";
}
修改上面的偏特化实现以排除那些&T::Member
不是成员指针的情况(比如排除static数据成员的情况)并不会很难。类似地,也可以限制该偏特化仅适用于数据成员或者成员函数。
注意,HasMember萃取只可以被用来测试是否存在“唯一”一个与特定名称对应的成员。如果存在两个同名的成员的话,该测试也会失败,比如当我们测试某些重载成员函数是否存在的时候:1
2DEFINE_HAS_MEMBER(begin);
std::cout << HasMemberT_begin<std::vector<int>>::value; // false
用泛型Lambda探测成员
下面这个例子展示了定义可以检测数据或者类型成员是否存在(比如first或者size_type),或者有没有为两个不同类型的对象定义operator <的萃取的方式:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int main()
{
using namespace std;
cout << boolalpha;
// define to check for data member first:
constexpr auto hasFirst = isValid([](auto x) -> decltype((void)valueT(x).first) {});
cout << "hasFirst: " << hasFirst(type<pair<int,int>>) << "\n"; // true
// define to check for member type size_type:
constexpr auto hasSizeType = isValid([](auto x) -> typename decltype(valueT(x))::size_type { });
struct CX {
using size_type = std::size_t;
};
cout << "hasSizeType: " << hasSizeType(type<CX>) << "\n"; // true
if constexpr(!hasSizeType(type<int>)) {
cout << "int has no size_type\n";
}
// define to check for <:
constexpr auto hasLess = isValid([](auto x, auto y) -> decltype(valueT(x) < valueT(y)) {});
cout << hasLess(42, type<char>) << "\n"; //yields true
cout << hasLess(type<string>, type<string>) << "\n"; //yields true
cout << hasLess(type<string>, type<int>) << "\n"; //yields false
cout << hasLess(type<string>, "hello") << "\n"; //yields true
}
请再次注意,hasSizeType
通过使用std::decay
将参数x中的引用删除了,因为我们不能访问引用中的类型成员。如果不这么做,该萃取(对于引用类型)会始终返回false,从而导致第二个重载的isValidImpl<>
被使用。
为了能够使用统一的泛型语法(将类型用于模板参数),我们可以继续定义额外的辅助工具。比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
constexpr auto hasFirst = isValid([](auto&& x) -> decltype((void)&x.first) {});
template<typename T>
using HasFirstT = decltype(hasFirst(std::declval<T>()));
constexpr auto hasSizeType = isValid([](auto&& x) -> typename std::decay_t<decltype(x)>::size_type {});
template<typename T>
using HasSizeTypeT = decltype(hasSizeType(std::declval<T>()));
constexpr auto hasLess = isValid([](auto&& x, auto&& y) -> decltype(x < y) { });
template<typename T1, typename T2>
using HasLessT = decltype(hasLess(std::declval<T1>(), std::declval<T2>()));
int main()
{
using namespace std;
cout << "first: " << HasFirstT<pair<int,int>>::value << "\n";
// true
struct CX {
using size_type = std::size_t;
};
cout << "size_type: " << HasSizeTypeT<CX>::value << "\n"; // true
cout << "size_type: " << HasSizeTypeT<int>::value << "\n"; // false
cout << HasLessT<int, char>::value << "\n"; // true
cout << HasLessT<string, string>::value << "\n"; // true
cout << HasLessT<string, int>::value << "\n"; // false
cout << HasLessT<string, char*>::value << "\n"; // true
}
现在可以像下面这样使用HasFirstT
:1
HasFirstT<std::pair<int,int>>::value
它会为一个包含两个int的pair调用hasFirst,其行为和之前的讨论一致。
其它的萃取技术
最后让我们来介绍其它一些在定义萃取时可能会用到的方法。
If-Then-Else
在上一小节中,PlusResultT的定义采用了和之前完全不同的实现方法,该实现方法依赖于另一个萃取(HasPlusT)的结果。我们可以用一个特殊的类型模板IfThenElse来表达这一if-then-else的行为,它接受一个bool型的模板参数,并根据该参数从另外两个类型参数中间做选择:
1 |
|
下面的例子展现了该模板的一种应用,它定义了一个可以为给定数值选择最合适的整形类型的函数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<auto N>
struct SmallestIntT {
using Type = typename IfThenElseT<N <= std::numeric_limits<char> ::max(), char,
typename IfThenElseT<N <=
std::numeric_limits<short> ::max(), short,
typename IfThenElseT<N <=
std::numeric_limits<int> ::max(), int,
typename IfThenElseT<N <=
std::numeric_limits<long>::max(), long,
typename IfThenElseT<N <=
std::numeric_limits<long long>::max(), long long, //then
void //fallback
>::Type
>::Type
>::Type
>::Type
>::Type;
};
需要注意的是,和常规的C++ if-then-else语句不同,在最终做选择之前,then和else分支中的模板参数都会被计算,因此两个分支中的代码都不能有问题,否则整个程序就会有问题。考虑下面这个例子,一个可以为给定的有符号类型生成与之对应的无符号类型的萃取。已经有一个标准萃取(std::make_unsigned)可以做这件事情,但是它要求传递进来的类型是有符号的整形,而且不能是bool类型;否则它将使用未定义行为的结果。
这一萃取不够安全,因此最好能够实现一个这样的萃取,当可能的时候,它就正常返回相应的无符号类型,否则就原样返回被传递进来的类型(这样,当传递进来的类型不合适时,也能避免触发未定义行为)。下面这个简单的实现是不行的:1
2
3
4
5
6// ERROR: undefined behavior if T is bool or no integral type:
template<typename T>
struct UnsignedT {
using Type = IfThenElse<std::is_integral<T>::value
&& !std::is_same<T,bool>::value, typename std::make_unsigned<T>::type, T>;
};
因为在实例化UnsingedT<bool>
的时候,行为依然是未定义的,编译期依然会试图从下面的代码中生成返回类型:1
typename std::make_unsigned<T>::type
为了解决这一问题,我们需要再引入一层额外的间接层,从而让IfThenElse的参数本身用类型函数去封装结果:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// yield T when using member Type:
template<typename T>
struct IdentityT {
using Type = T;
};
// to make unsigned after IfThenElse was evaluated:
template<typename T>
struct MakeUnsignedT {
using Type = typename std::make_unsigned<T>::type;
};
template<typename T>
struct UnsignedT {
using Type = typename IfThenElse<std::is_integral<T>::value
&& !std::is_same<T,bool>::value,
MakeUnsignedT<T>,
IdentityT<T>
>::Type;
};
在这一版UnsignedT的定义中,IfThenElse的类型参数本身也都是类型函数的实例。只不过在最终IfThenElse做出选择之前,类型函数不会真正被计算。而是由IfThenElse选择合适的类型实例(MakeUnsignedT或者IdentityT)。最后由::Type
对被选择的类型函数实例进行计算,并生成结果Type。
此处值得强调的是,之所以能够这样做,是因为IfThenElse中未被选择的封装类型永远不会被完全实例化。下面的代码也不能正常工作:1
2
3
4
5
6
7
8template<typename T>
struct UnsignedT {
using Type = typename IfThenElse<std::is_integral<T>::value
&& !std::is_same<T,bool>::value,
MakeUnsignedT<T>::Type,
T
>::Type;
};
我们必须要延后对MakeUnsignedT<T>
使用::Type
,也就是意味着,我们同样需要为else分支中的T引入IdentyT辅助模板,并同样延后对其使用::Type
。
我们同样不能在当前语境中使用如下代码:1
2template<typename T>
using Identity = typename IdentityT<T>::Type;
我们当然可以定义这样一个别名模板,在其它地方它可能也很有用,但是我们唯独不能将其用于IfThenElse
的定义中,因为任意对Identity<T>
的使用都会立即触发对IdentityT<T>
的完全实例化,不然无法获取其Type成员。
在C++标准库中有与IfThenElseT模板对应的模板。使用这一标准库模板实现的UnsignedT萃取如下:1
2
3
4
5
6
7
8template<typename T>
struct UnsignedT {
using Type = typename std::conditional_t<std::is_integral<T>::value
&& !std::is_same<T,bool>::value,
MakeUnsignedT<T>,
IdentityT<T>
>::Type;
};
探测不抛出异常的操作
我们可能偶尔会需要判断某一个操作会不会抛出异常。比如,在可能的情况下,移动构造函数应当被标记成noexcept的,意思是它不会抛出异常。但是,某一特定class的move constructor是否会抛出异常,通常决定于其成员或者基类的移动构造函数会不会抛出异常。
比如对于下面这个简单类模板(Pair)的移动构造函数:1
2
3
4
5
6
7
8
9
10template<typename T1, typename T2>
class Pair {
T1 first;
T2 second;
public:
Pair(Pair&& other)
: first(std::forward<T1>(other.first)),
second(std::forward<T2>(other.second)) {
}
};
当T1或者T2的移动操作会抛出异常时,Pair的移动构造函数也会抛出异常。如果有一个叫做IsNothrowMoveConstructibleT的萃取,就可以在Pair的移动构造函数中通过使用noexcept将这一异常的依赖关系表达出来:1
2
3
4
5
6Pair(Pair&& other)
noexcept(IsNothrowMoveConstructibleT<T1>::value &&
IsNothrowMoveConstructibleT<T2>::value)
: first(std::forward<T1>(other.first)),
second(std::forward<T2>(other.second))
{}
现在剩下的事情就是去实现IsNothrowMoveConstructibleT
萃取了。我们可以直接用noexcept运算符实现这一萃取,这样就可以判断一个表达式是否被进行nothrow修饰了:1
2
3
4
5
6
template<typename T>
struct IsNothrowMoveConstructibleT
: std::bool_constant<noexcept(T(std::declval<T>()))>
{};
这里使用了运算符版本的noexcept,它会判断一个表达式是否会抛出异常。由于其结果是bool型的,我们可以直接将它用于std::bool_constant<>
基类的定义(std::bool_constant
也被用来定义std::true_type
和std::false_type
)。
但是该实现还应该被继续优化,因为它不是SFINAE友好的:如果它被一个没有可用移动或者拷贝构造函数的类型(这样表达式T(std::declval<T&&>())
就是无效的)实例化,整个程序就会遇到问题:1
2
3
4
5
6class E {
public:
E(E&&) = delete;
};
...
std::cout << IsNothrowMoveConstructibleT<E>::value; // compiletime ERROR
在这种情况下,我们所期望的并不是让整个程序奔溃,而是获得一个false类型的值。就像在第19.4.4节介绍的那样,在真正做计算之前,必须先对被用来计算结果的表达式的有效性进行判断。在这里,我们要在检查移动构造函数是不是noexcept之前,先对其有效性进行判断。因此,我们要重写之前的萃取实现,给其增加一个默认值是void的模板参数,并根据移动构造函数是否可用对其进行偏特化:1
2
3
4
5
6
7
8
9
10
11
12
// primary template:
template<typename T, typename = std::void_t<>>
struct IsNothrowMoveConstructibleT : std::false_type
{ };
// partial specialization (may be SFINAE"d away):
template<typename T>
struct IsNothrowMoveConstructibleT<T,
std::void_t<decltype(T(std::declval<T>()))>>
: std::bool_constant<noexcept(T(std::declval<T>()))>
{};
如果在偏特化中对std::void_t<...>
的替换有效,那么就会选择该偏特化实现,在其父类中的noexcept(...)
表达式也可以被安全的计算出来。否则,偏特化实现会被丢弃(也不会对其进行实例化),被实例化的也将是主模板(产生一个std::false_type
的返回值)。
值得注意的是,除非真正能够调用移动构造函数,否则我们无法判断移动构造函数是不是会抛出异常。也就是说,移动构造函数仅仅是public和未被标识为delete的还不够,还要求对应的类型不能是抽象类(但是抽象类的指针或者引用却可以)。因此,该类型萃取被命名为IsNothrowMoveConstructible
,而不是HasNothrowMoveConstructor
。对于其它所有的情况,我们都需要编译期支持。
萃取的便捷性
一个关于萃取的普遍不满是它们相对而言有些繁琐,因为对类型萃取的使用通需要提供一个::Type
尾缀,而且在依赖上下文中(dependent context),还需要一个typename前缀,两者几成范式。当同时使用多个类型萃取时,会让代码形式变得很笨拙,就如同在我们的operator+例子中一样,如果想正确的对其进行实现,需要确保不会返回const或者引用类型:1
2
3
4template<typename T1, typename T2>
Array< typename RemoveCVT<typename RemoveReferenceT<typename
PlusResultT<T1, T2>::Type >::Type >::Type>
operator+ (Array<T1> const&, Array<T2> const&);
通过使用别名模板(alias templates)和变量模板(variable templates),可以让对产生类型或者数值的萃取的使用变得很方便。但是也需要注意,在某些情况下这一简便方式并不使用,我们依然要使用最原始的类模板。我们已经讨论过一个这一类的例子(MemberPointerToIntT),但是更详细的讨论还在后面。
别名模板和萃取
别名模板为降低代码繁琐性提供了一种方法。相比于将类型萃取表达成一个包含了Type类型成员的类模板,我们可以直接使用别名模板。比如,下面的三个别名模板封装了之前的三种类型萃取:1
2
3
4
5
6template<typename T>
using RemoveCV = typename RemoveCVT<T>::Type;
template<typename T>
using RemoveReference = typename RemoveReferenceT<T>::Type;
template<typename T1, typename T2>
using PlusResult = typename PlusResultT<T1, T2>::Type;
有了这些别名模板,我们可以将operator+的声明简化成:1
2
3template<typename T1, typename T2>
Array<RemoveCV<RemoveReference<PlusResultT<T1, T2>>>>
operator+ (Array<T1> const&, Array<T2> const&);
这一版本的实现明显更简洁,也让人更容易分辨其组成。这一特性使得别名模板非常适用于某些类型萃取。
但是,将别名模板用于类型萃取也有一些缺点:
- 别名模板不能够被进行特化,但是由于很多编写萃取的技术都依赖于特化,别名模板最终可能还是需要被重新导向到类模板。
- 有些萃取是需要由用户进行特化的,比如描述了一个求和运算符是否是可交换的萃取,此时在很多使用都用到了别名模板的情况下,对类模板进行特换会很让人困惑。
- 对别名模板的使用最会让该类型被实例化(比如,底层类模板的特化),这样对于给定类型我们就很难避免对其进行无意义的实例化。对最后一点的另外一种表述方式是,别名模板不可以和元函数转发一起使用。
变量模板和萃取
对于返回数值的萃取需要使用一个::value(或者类似的成员)来生成萃取的结果。在这种情况下,constexpr修饰的变量模板提供了一种简化代码的方法。比如,下面的变量模板封装了IsSameT萃取和IsConvertibleT萃取:1
2
3
4template<typename T1, typename T2>
constexpr bool IsSame = IsSameT<T1,T2>::value;
template<typename FROM, typename TO>
constexpr bool IsConvertible = IsConvertibleT<FROM, TO>::value;
此时我们可以将这一类代码:1
if (IsSameT<T,int>::value || IsConvertibleT<T,char>::value) ...
简化成:1
if (IsSame<T,int> || IsConvertible<T,char>) ...
类型分类
如果能够知道一个模板参数的类型是内置类型,指针类型,class类型,或者是其它什么类型,将会很有帮助。在接下来的章节中,我们定义了一组类型萃取,通过它们我们可以判断给定类型的各种特性。这样我们就可以单独为特定的某些类型编写代码:1
2
3if (IsClassT<T>::value) {
...
}
或者是将其用于编译期if以及某些为了萃取的便利性而引入的特性:1
2
3if constexpr (IsClass<T>) {
...
}
或者时将其用于偏特化:1
2
3
4
5
6
7
8template<typename T, bool = IsClass<T>>
class C { //primary template for the general case
...
};
template<typename T>
class C<T, true> { //partial specialization for class types
...
};
此外,诸如IsPointerT<T>::value
一类的表达式的结果是bool型常量,因此它们也将是有效的非类型模板参数。这样,就可以构造更为高端和强大的模板,这些模板可以被基于它们的类型参数的特性进行特化。
判断基础类型
作为开始,我们先定义一个可以判断某个类型是不是基础类型的模板。默认情况下,我们认为类型不是基础类型,而对于基础类型,我们分别进行了特化:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// primary template: in general T is not a fundamental type template<typename T>
struct IsFundaT : std::false_type {
};
// macro to specialize for fundamental types
MK_FUNDA_TYPE(void)
MK_FUNDA_TYPE(bool)
MK_FUNDA_TYPE(char)
MK_FUNDA_TYPE(signed char)
MK_FUNDA_TYPE(unsigned char)
MK_FUNDA_TYPE(wchar_t)
MK_FUNDA_TYPE(char16_t)
MK_FUNDA_TYPE(char32_t)
MK_FUNDA_TYPE(signed short)
MK_FUNDA_TYPE(unsigned short)
MK_FUNDA_TYPE(signed int)
MK_FUNDA_TYPE(unsigned int)
MK_FUNDA_TYPE(signed long)
MK_FUNDA_TYPE(unsigned long)
MK_FUNDA_TYPE(signed long long)
MK_FUNDA_TYPE(unsigned long long)
MK_FUNDA_TYPE(float)
MK_FUNDA_TYPE(double)
MK_FUNDA_TYPE(long double)
MK_FUNDA_TYPE(std::nullptr_t)
主模板定义了常规情况。也就是说,通常而言IfFundaT<T>::value
会返回false:1
2
3
4template<typename T>
struct IsFundaT : std::false_type {
static constexpr bool value = false;
};
对于每一种基础类型,我们都进行了特化,因此IsFundaT<T>::value
的结果也都会返回true。为了简单,我们定义了一个可以扩展成所需代码的宏。比如:1
MK_FUNDA_TYPE(bool)
会扩展成:1
2
3template<> struct IsFundaT<bool> : std::true_type {
static constexpr bool value = true;
};
下面的例子展示了该模板的一种可能的应用场景:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T>
void test (T const&)
{
if (IsFundaT<T>::value) {
std::cout << "T is a fundamental type" << "\n";}
else {
std::cout << "T is not a fundamental type" << "\n";
}
}
int main()
{
test(7);
test("hello");
}
其输出如下:1
2T is a fundamental type
T is not a fundamental type
采用同样会的方式,我们也可以定义类型函数IsIntegralT和IsFloatingT来区分哪些类型是整形标量类型以及浮点型标量类型。
C++标准库采用了一种更为细粒度的方法来测试一个类型是不是基础类型。它先定义了主要的类型种类,每一种类型都被匹配到一个相应的种类,然后合成诸如std::is_integral
和std::is_fundamental
类型种类。
判断复合类型
复合类型是由其它类型构建出来的类型。简单的复合类型包含指针类型,左值以及右值引用类型,指向成员的指针类型(pointer-to-member types),和数组类型。它们是由一种或者两种底层类型构造的。Class类型以及函数类型同样也是复合类型,但是它们可能是由任意数量的类型组成的。在这一分类方法中,枚举类型同样被认为是复杂的符合类型,虽然它们不是由多种底层类型构成的。简单的复合类型可以通过偏特化来区分。
我们从指针类型这一简单的分类开始:1
2
3
4
5
6
7template<typename T>
struct IsPointerT : std::false_type { //primary template: by default not a pointer
};
template<typename T>
struct IsPointerT<T*> : std::true_type { //partial specialization for pointers
using BaseT = T; // type pointing to
};
主模板会捕获所有的非指针类型,和往常一样,其值为fase的value成员是通过基类std::false_type
提供的,表明该类型不是指针。偏特化实现会捕获所有的指针类型(T*
),其为true的成员value表明该类型是一个指针。偏特化实现还额外提供了类型成员BaseT
,描述了指针所指向的类型。注意该类型成员只有在原始类型是指针的时候才有,从其使其变成SFINAE友好的类型萃取。
C++标准库也提供了相对应的萃取std::is_pointer<>
,但是没有提供一个成员类型来描述指针所指向的类型。
相同的方法也可以被用来识别左值引用:1
2
3
4
5
6
7template<typename T>
struct IsLValueReferenceT : std::false_type { //by default no lvalue reference
};
template<typename T>
struct IsLValueReferenceT<T&> : std::true_type { //unless T is lvalue references
using BaseT = T; // type referring to
};
以及右值引用:1
2
3
4
5
6
7template<typename T>
struct IsRValueReferenceT : std::false_type { //by default no rvalue reference
};
template<typename T>
struct IsRValueReferenceT<T&&> : std::true_type { //unless T is rvalue reference
using BaseT = T; // type referring to
};
它俩又可以被组合成IsReferenceT<>
萃取:1
2
3
4
5
6
7
8
9
10
template<typename T>
class IsReferenceT
: public IfThenElseT<IsLValueReferenceT<T>::value,
IsLValueReferenceT<T>,
IsRValueReferenceT<T>
>::Type {
};
在这一实现中,我们用IfThenElseT
从ISLvalueReference<T>
和IsRValueReferenceT<T>
中选择基类,这里还用到了元函数转发。如果T是左值引用,我们会从IsLReference<T>
做继承,并通过继承得到相应的value和BaseT成员。否则,我们就从IsRValueReference<T>
做继承,它会判断一个类型是不是右值引用。
C++标准库也提供了相应的std::is_lvalue_reference<>
和std::is_rvalue_reference<>
萃取,还有std::is_reference<>
。同样的,这些萃取也没有提供代表其所引用的类型的类型成员。
在定义可以判断数组的萃取时,让人有些意外的是偏特化实现中的模板参数数量要比主模板多:1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
struct IsArrayT : std::false_type { //primary template: not an array
};
template<typename T, std::size_t N>
struct IsArrayT<T[N]> : std::true_type { //partial specialization for arrays
using BaseT = T;
static constexpr std::size_t size = N;
};
template<typename T>
struct IsArrayT<T[]> : std::true_type { //partial specialization for unbound arrays
using BaseT = T;
static constexpr std::size_t size = 0;
};
在这里,多个额外的成员被用来描述被用来分类的数组的信息:数组的基本类型和大小(被用来标识未知大小的数组的尺寸)。
C++标准库提供了相应的std::is_array<>
来判断一个类型是不是数组。除此之外,诸如std::rank<>
和std::extent<>
之类的萃取还允许我们去查询数组的维度以及某个维度的大小。
也可以用相同的方式处理指向成员的指针:1
2
3
4
5
6
7
8template<typename T>
struct IsPointerToMemberT : std::false_type { //by default no pointer-to-member
};
template<typename T, typename C>
struct IsPointerToMemberT<T C::*> : std::true_type { //partial specialization
using MemberT = T;
using ClassT = C;
};
这里额外的成员(MemberT和ClassT)提供了与成员的类型以及class的类型相关的信息。 C++标准库提供了更为具体的萃取,std::is_member_object_pointer<>
和std::is_member_function_pointer<>
,std::is_member_pointer<>
。
识别函数类型
函数类型比较有意思,因为它们除了返回类型,还可能会有任意数量的参数。因此,在匹配一个函数类型的偏特化实现中,我们用一个参数包来捕获所有的参数类型,就如同我们在对DecayT
所做的那样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
struct IsFunctionT : std::false_type { //primary template: no function
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params...)> : std::true_type
{ //functions
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = false;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...)> : std::true_type { //variadic functions
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};
上述实现中函数类型的每一部分都被暴露了出来:返回类型被Type标识,所有的参数都被作为ParamsT捕获进了一个typelist中(在第24章有关于typelist的介绍),而可变参数(…)表示的是当前函数类型使用的是不是C风格的可变参数。
不幸的是,这一形式的IsFunctionT并不能处理所有的函数类型,因为函数类型还可以包含const和volatile修饰符,以及左值或者右值引用修饰符,在C++17之后,还有noexcept修饰符。比如:1
using MyFuncType = void (int&) const;
这一类函数类型只有在被用于非static成员函数的时候才有意义,但是不管怎样都算得上是函数类型。而且,被标记为const的函数类型并不是真正意义上的const类型,因此RemoveConst并不能将const从函数类型中移除。因此,为了识别有限制符的函数类型,我们需要引入一大批额外的偏特化实现,来覆盖所有可能的限制符组合(每一个实现都需要包含C风格和非C风格的可变参数情况)。这里,我们只展示所有偏特化实现中的5中情况:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30template<typename R, typename... Params>
struct IsFunctionT<R (Params...) const> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = false;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...) volatile> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...) const volatile> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...) &> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};
template<typename R, typename... Params>
struct IsFunctionT<R (Params..., ...) const&> : std::true_type {
using Type = R;
using ParamsT = Typelist<Params...>;
static constexpr bool variadic = true;
};
当所有这些都准备完毕之后,我们就可以识别除class类型和枚举类型之外的所有类型了。我们会在接下来的章节中除了这两种例外情况。C++标准库也提供了相应的std::is_function<>
萃取。
判断class类型
和到目前为止我们已经处理的各种复合类型不同,我们没有相应的偏特化模式来专门匹配class类型。也不能像处理基础类型一样一一列举所有的class类型。相反,我们需要用一种间接的方法来识别class类型,为此我们需要找出一些适用于所有class类型的类型或者表达式(但是不能适用于其它类型)。
Class中可以被我们用来识别class类型的最为方便的特性是:只有class类型可以被用于指向成员的指针类型(pointer-to-member types)的基础。也就是说,对于X Y::*
一类的类型结构,Y只能是class类型。下面的IsClassT<>
就利用了这一特性(将X随机选择为int):1
2
3
4
5
6
7
8
9
template<typename T, typename = std::void_t<>>
struct IsClassT : std::false_type { //primary template: by default no
class
};
template<typename T>
struct IsClassT<T, std::void_t<int T::*>> // classes can have pointer-to-member
: std::true_type {
};
C++语言规则指出,lambda表达式的类型是“唯一的,未命名的,非枚举class类型”。因此在将IsClassT
萃取用于lambda表达时,我们得到的结果是true:1
2auto l = []{};
static_assert<IsClassT<decltype(l)>::value, "">; //succeeds
需要注意的是,int T::*
表达式同样适用于unit类型(更具C++标准,枚举类型也是class类型)。
C++标准库提供了std::is_class<>
和std::is_union
萃取。但是,这些萃取需要编译期进行专门的支持,因为目前还不能通过任何核心的语言技术(standard core language techniques)将class和struct从union类型中分辨出来。
识别枚举类型
目前通过我们已有的萃取技术还唯一不能识别的类型是枚举类型。我们可以通过编写基于SFINAE的萃取来实现这一功能,这里首先需要测试是否可以像整形类型(比如int)进行显式转换,然后依次排除基础类型,class类型,引用类型,指针类型,还有指向成员的指针类型(这些类型都可以被转换成整形类型,但是都不是枚举类型)。但是也有更简单的方法,因为我们发现所有不属于其它任何一种类型的类型就是枚举类型,这样就可以像下面这样实现该萃取:1
2
3
4
5
6
7
8
9
10template<typename T>
struct IsEnumT {
static constexpr bool value = !IsFundaT<T>::value
&& !IsPointerT<T>::value &&
!IsReferenceT<T>::value
&& !IsArrayT<T>::value &&
!IsPointerToMemberT<T>::value
&& !IsFunctionT<T>::value &&
!IsClassT<T>::value;
};
C++标准库提供了相对应的std::is_enum<>
萃取。通常,为了提高编译性能,编译期会直接提供这一类萃取,而不是将其实现为其它的样子。
策略萃取(Policy Traits)
到目前为止,我们例子中的萃取模板被用来判断模板参数的特性:它们代表的是哪一种类型,作用于该类型数值的操作符的返回值的类型,以及其它特性。这一类萃取被称为特性萃取(property traits)。
最为对比,某些萃取定义的是该如何处理某些类型。我们称之为策略萃取(policy traits)。这里会对之前介绍的策略类(policy class,我们已经指出,策略类和策略萃取之间的界限并不青霞)的概念进行回顾,但是策略萃取更倾向于是模板参数的某一独有特性(而策略类却通常和其它模板参数无关)。
虽然特性萃取通常都可以被实现为类型函数,策略萃取却通常将策略包装进成员函数中。为了展示这一概念,先来看一下一个定义了特定策略(必须传递只读参数)的类型函数。
只读参数类型
在C++和C中,函数的调用参数(call parameters)默认情况下是按照值传递的。这意味着,调用函数计算出来的参数的值,会被拷贝到由被调用函数控制的位置。对于比较大的结构体,这一拷贝的成本会非常高,因此对于这一类结构体最好能够将其按照常量引用(reference-to-const)或者是C中的常量指针(pointer-to-const)进行传递。对于小的结构体,到底该怎样实现目前还没有定论,从性能的角度来看,最好的机制依赖于代码所运行的具体架构。在大多数情况下这并没有那么关键,但是某些情况下,即使是对小的结构体我们也要仔细应对。
正如之前暗示的那样,这一类问题通常应当用策略萃取模板(一个类型函数)来处理:该函数将预期的参数类型T映射到最佳的参数类型T或者是T const&。作为第一步的近似,主模板会将大小不大于两个指针的类型按值进行传递,对于其它所有类型都按照常量引用进行传递:1
2
3
4
5
6template<typename T>
struct RParam {
using Type = typename IfThenElseT<sizeof(T) <=2*sizeof(void*),
T,
T const&>::Type;
};
另一方面,对于那些另sizeof运算符返回一个很小的值,但是拷贝构造函数成本却很高的容器类型,我们可能需要分别对它们进行特化或者偏特化,就像下面这样:1
2
3
4template<typename T>
struct RParam<Array<T>> {
using Type = Array<T> const&;
};
由于这一类类型在C++中很常见,如果只将那些拥有简单拷贝以及移动构造函数的类型按值进行传递,当需要考虑性能因素时,再选择性的将其它一些class类型加入按值传递的行列(C++标准库中包含了std::is_trivially_copy_constructible
和std::is_trivially_move_constructible
类型萃取)。1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
struct RParam {
using Type = IfThenElse<(sizeof(T) <= 2*sizeof(void*)
&& std::is_trivially_copy_constructible<T>::value
&& std::is_trivially_move_constructible<T>::value),
T,
T const&>;
};
无论采用哪一种方式,现在该策略都可以被集成到萃取模板的定义中,客户也可以用它们去实现更好的效果。比如,假设我们有两个class,对于其中一个class我们指明要按值传递只读参数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyClass1 {
public:
MyClass1 () {
}
MyClass1 (MyClass1 const&) {
std::cout << "MyClass1 copy constructor called\n";}
};
class MyClass2 {
public:
MyClass2 () { }
MyClass2 (MyClass2 const&) {
std::cout << "MyClass2 copy constructor called\n";
}
};
// pass MyClass2 objects with RParam<> by value
template<>
class RParam<MyClass2> {
public:
using Type = MyClass2;
};
现在,我们就可以定义将PParam<>
用于只读参数的函数了,并对其进行调用:1
2
3
4
5
6
7
8
9
10
11
12
13
14
// function that allows parameter passing by value or by reference
template<typename T1, typename T2>
void foo (typename RParam<T1>::Type p1, typename RParam<T2>::Type p2)
{
...
}
int main()
{
MyClass1 mc1;
MyClass2 mc2;
foo<MyClass1,MyClass2>(mc1,mc2);
}
不幸的是,PParam
的使用有一些很大的缺点。第一,函数的声明很凌乱。第二,可能也是更有异议的地方,就是在调用诸如foo()
一类的函数时不能使用参数推断,因为模板参数只出现在函数参数的限制符中。因此在调用时必须显式的指明所有的模板参数。一个稍显笨拙的权宜之计是:使用提供了完美转发的inline封装函数(inline wrapper function),但是需要假设编译器将省略inline函数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// function that allows parameter passing by value or by reference
template<typename T1, typename T2>
void foo_core (typename RParam<T1>::Type p1, typename RParam<T2>::Type p2)
{
...
}
// wrapper to avoid explicit template parameter passing
template<typename T1, typename T2>
void foo (T1 && p1, T2 && p2)
{
foo_core<T1,T2>(std::forward<T1>(p1),std::forward<T2>(p2));
}
int main()
{
MyClass1 mc1;
MyClass2 mc2;
foo(mc1,mc2); // same as foo_core<MyClass1,MyClass2> (mc1,mc2)
}
在标准库中的情况
在C++11中,类型萃取变成了C++标准库中固有的一部分。它们或多或少的构成了在本章中讨论的所有的类型函数和类型萃取。但是,对于它们中的一部分,比如个别的操作探测,以及有过讨论的std::is_union
,目前都还没有已知的语言解决方案。而是由编译器为这些萃取提供了支持。同样的,编译器也开始支持一些已经由语言本身提供了解决方案的萃取,这主要是为了减少编译时间。
C++标准库也定义了一些策略和属性萃取:
- 类模板
std::char_traits
被std::string
和I/O stream当作策略萃取使用。 - 为了将算法简单的适配于标准迭代器的种类,标准库提供了一个很简单的
std::iterator_traits
属性萃取模板。 - 模板
std::numeric_limits
作为属性萃取模板也会很有帮助。 - 最后,为标准库容器类型进行的内存分配是由策略萃取类处理的(参见
std::shared_ptr
的实现)。从C++98开始,标准库专门为了这一目的提供了std::allocator
模板。从C++11开始,标准库引入了std::allocator_traits
模板,这样就能够修改内存分配器的策略或者行为了。
基于类型属性的重载
函数重载使得相同的函数名能够被多个函数使用,只要能够通过这些函数的参数类型区分它们就行。比如:1
2void f (int);
void f (char const*);
对于函数模板,可以在类型模式上进行重载,比如针对指向T的指针或者Array<T>
:1
2template<typename T> void f(T*);
template<typename T> void f(Array<T>);
在类型萃取的概念流行起来之后,很自然地会想到基于模板参数对函数模板进行重载。比如:1
2template<typename Number> void f(Number); // only for numbers
template<typename Container> void f(Container);// only for containers
但是,目前C++还没有提供任何可以直接基于类型属性进行重载的方法。事实上,上面的两个模板声明的是完全相同的函数模板,而不是进行了重载,因为在比较两个函数模板的时候不会比较模板参数的名字。
算法特化
函数模板重载的一个动机是,基于算法适用的类型信息,为算法提供更为特化的版本。考虑一个交换两个数值的swap()
操作:1
2
3
4
5
6
7template<typename T>
void swap(T& x, T& y)
{
T tmp(x);
x = y;
y = tmp;
}
这一实现用到了三次拷贝操作。但是对于某些类型,可以有一种更为高效的swap()
实现,比如对于存储了指向具体数组内容的指针和数组长度的Array<T>
:1
2
3
4
5
6template<typename T>
void swap(Array<T>& x, Array<T>& y)
{
swap(x.ptr, y.ptr);
swap(x.len, y.len);
}
俩种swap()
实现都可以正确的交换两个Array<T>
对象的内容。但是,后一种实现方式的效率要高很多,因为它利用了Array<T>
中额外的特性。因此后一种实现方式要(在概念上)比第一种实现方式更为“特化”,这是因为它只为适用于前一种实现的类型的一个子集提供了交换操作。幸运的是,基于函数模板的部分排序规则,第二种函数模板也是更为特化的,在有更为特化的版本(也更高效)可用的时候,编译器会优先选择该版本,在其不适用的时候,会退回到更为泛化的版本(可能会不那么高效)。
对于特定类型的迭代器(比如提供了随机访问操作的迭代器),我们可以为该操作提供一个更为高效的实现方式:1
2
3
4template<typename RandomAccessIterator, typename Distance>
void advanceIter(RandomAccessIterator& x, Distance n) {
x += n; // constant time
}
但是不幸的是,同时定义以上两种函数模板会导致编译错误,正如我们在序言中介绍的那样,这是因为只有模板参数名字不同的函数模板是不可以被重载的。
标记派发(Tag Dispatching)
算法特化的一个方式是,用一个唯一的、可以区分特定变体的类型来标记(tag)不同算法变体的实现。比如为了解决上述advanceIter()
中的问题,可以用标准库中的迭代器种类标记类型,来区分advanceIter()
算法的两个变体实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16template<typename Iterator, typename Distance>
void advanceIterImpl(Iterator& x, Distance n,
std::input_iterator_tag)
{
while (n > 0) { //linear time
++x;
--n;
}
}
template<typename Iterator, typename Distance>
void advanceIterImpl(Iterator& x, Distance n,
std::random_access_iterator_tag)
{
x += n; // constant time
}
然后,通过advanceIter()
函数模板将其参数连同与之对应的tag一起转发出去:1
2
3
4
5
6template<typename Iterator, typename Distance>
void advanceIter(Iterator& x, Distance n)
{
advanceIterImpl(x, n, typename
std::iterator_traits<Iterator>::iterator_category())
}
萃取类模板std::iterator_traits
通过其成员类型iterator_category
返回了迭代器的种类。迭代器种类是前述_tag
类型中的一种,它指明了相关类型的具体迭代器种类。在C++标准库中,可用的tags被定义成了下面这样,在其中使用了继承来反映出一个用tag表述的种类是不是从另一个种类派生出来的:1
2
3
4
5
6
7
8namespace std {
struct input_iterator_tag { };
struct output_iterator_tag { };
struct forward_iterator_tag : public input_iterator_tag { };
struct bidirectional_iterator_tag : public forward_iterator_tag
{ };
struct random_access_iterator_tag : public bidirectional_iterator_tag { };
}
有效使用标记派发(tag dispatching)的关键在于理解tags之间的内在关系。我们用来标记两个advanceIterImpl变体的标记是std::input_iterator_tag
和std::random_access_iterator_tag
,而由于std::random_access_iterator_tag
继承自std::input_iterator_tag
,对于随机访问迭代器,会优先选择更为特化的advanceIterImpl()
变体(使用了std::random_access_iterator_tag
的那一个)。因此,标记派发依赖于将单一的主函数模板的功能委托给一组_impl
变体,这些变体都被进行了标记,因此正常的函数重载机制会选择适用于特定模板参数的最为特化的版本。
当被算法用到的特性具有天然的层次结构,并且存在一组为这些标记提供了值的萃取机制的时候,标记派发可以很好的工作。而如果算法特化依赖于专有(ad hoc)类型属性的话(比如依赖于类型T是否含有拷贝赋值运算符),标记派发就没那么方便了。对于这种情况,我们需要一个更强大的技术。
Enable/Disable函数模板
算法特化需要提供可以基于模板参数的属性进行选择的、不同的函数模板。不幸的是,无论是函数模板的部分排序规则还是重载解析,都不能满足更为高阶的算法特化的要求。C++标准库为之提供的一个辅助工具是std::enable_if
。本节将介绍通过引入一个对应的模板别名,实现该辅助工具的方式,为了避免名称冲突,我们将称之称为EnableIf
。
和std::enable_if
一样,EnableIf
模板别名也可以被用来基于特定的条件enable
(或disable
)特定的函数模板。比如,随机访问版本的advanceIter()
算法可以被实现成这样:1
2
3
4
5
6
7template<typename Iterator>
constexpr bool IsRandomAccessIterator = IsConvertible< typename std::iterator_traits<Iterator>::iterator_category, std::random_access_iterator_tag>;
template<typename Iterator, typename Distance>
EnableIf<IsRandomAccessIterator<Iterator>>
advanceIter(Iterator& x, Distance n){
x += n; // constant time
}
这里使用了基于EnableIf
的偏特化,在迭代器是随机访问迭代器的时候启用特定的advanceIter()
变体。EnableIf
包含两个参数,一个是标示着该模板是否应该被启用的bool型条件参数,另一个是在第一个参数为true时,EnableIf应该包含的类型。
EnableIf的实现非常简单:1
2
3
4
5
6
7
8
9template<bool, typename T = void>
struct EnableIfT {
};
template< typename T>
struct EnableIfT<true, T> {
using Type = T;
};
template<bool Cond, typename T = void>
using EnableIf = typename EnableIfT<Cond, T>::Type;
EnableIf会扩展成一个类型,因此它被实现成了一个别名模板(alias template)。我们希望为之使用偏特化,但是别名模板(alias template)并不能被偏特化。幸运的是,我们可以引入一个辅助类模板(helper class template)EnableIfT,并将真正要做的工作委托给它,而别名模板EnableIf所要做的只是简单的从辅助模板中选择结果类型。当条件是true的时候,EnableIfT<...>::Type
(也就是EnableIf<...>
)的计算结果将是第二个模板参数T。当条件是false的时候,EnableIf
不会生成有效的类型,因为主模板EnableIfT
没有名为Type的成员。通常这应该是一个错误,但是在SFINAE中它只会导致模板参数推断失败,并将函数模板从待选项中移除。
对于advanceIter()
,EnableIf
的使用意味着只有当Iterator
参数是随机访问迭代器的时候,函数模板才可以被使用(而且返回类型是void),而当Iterator不是随机访问迭代器的时候,函数模板则会被从待选项中移除。我们可以将EnableIf理解成一种在模板参数不满足特定需
求的时候,防止模板被实例化的防卫手段。由于advanceIter()
需要一些只有随机访问迭代器才有操作,因此只能被随机访问迭代器实例化。有时候这样使用EnableIf也不是绝对安全的,此时EnableIf可以被用来帮助尽早的发现这一类错误。
我们还需要“去激活(de-activate)”不够特化的模板,因为在两个模板都适用的时候,编译期没有办法在两者之间做决断(order),从而会报出一个模板歧义错误。幸运的是,实现这一目的方法并不复杂:我们为不够特化的模板使用相同模式的EnableIf,只是适用相反的判断条件。这样,就可以确保对于任意Iterator类型,都只有一个模板会被激活。因此,适用于非随机访问迭代器的advanceIter()
会变成下面这样:1
2
3
4
5
6
7
8
9template<typename Iterator, typename Distance>
EnableIf<!IsRandomAccessIterator<Iterator>>
advanceIter(Iterator& x, Distance n)
{
while (n > 0) {//linear time
++x;
--n;
}
}
提供多种特化版本
上述模式可以被继续泛化以满足有两种以上待选项的情况:可以为每一个待选项都配备一个EnableIf
,并且让它们的条件部分,对于特定的模板参数彼此互斥。这些条件部分通常会用到多种可以用类型萃取(type traits)表达的属性。
比如,考虑另外一种情况,第三种advanceIter()
算法的变体:允许指定一个负的距离参数,以让迭代器向“后”移动。很显然这对一个“输入迭代器(input itertor)”是不适用的,对一个随机访问迭代器却是适用的。但是,标准库也包含一种双向迭代器(bidirectional iterator)的概念,这一类迭代器可以向后移动,但却不要求必须同时是随机访问迭代器。实现这一情况需要稍微复杂一些的逻辑:每个函数模板都必须使用一个包含了在所有函数模板间彼此互斥EnableIf条件,这些函数模板代表了同一个算法的不同变体。这样就会有下面一组条件:
- 随机访问迭代器:适用于随机访问的情况(常数时间复杂度,可以向前或向后移动)
- 双向迭代器但又不是随机访问迭代器:适用于双向情况(线性时间复杂度,可以向前或向后移动)
- 输入迭代器但又不是双向迭代器:适用于一般情况(线性时间复杂度,只能向前移动)
相关函数模板的具体实现如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// implementation for random access iterators:
template<typename Iterator, typename Distance>
EnableIf<IsRandomAccessIterator<Iterator>>
advanceIter(Iterator& x, Distance n) {
x += n; // constant time
}
template<typename Iterator>
constexpr bool IsBidirectionalIterator = IsConvertible< typename
std::iterator_traits<Iterator>::iterator_category,
std::bidirectional_iterator_tag>;
// implementation for bidirectional iterators:
template<typename Iterator, typename Distance>
EnableIf<IsBidirectionalIterator<Iterator>
&& !IsRandomAccessIterator<Iterator>>
advanceIter(Iterator& x, Distance n) {
if (n > 0) {
for ( ; n > 0; ++x, --n) { //linear time
}
} else {
for ( ; n < 0; --x, ++n) { //linear time
}
}
}
// implementation for all other iterators:
template<typename Iterator, typename Distance>
EnableIf<!IsBidirectionalIterator<Iterator>>
advanceIter(Iterator& x, Distance n) {
if (n < 0) {
throw "advanceIter(): invalid iterator category for negative n";
}
while (n > 0) { //linear time
++x;
--n;
}
}
通过让每一个函数模板的EnableIf条件与其它所有函数模板的条件互相排斥,可以保证对于一组参数,最多只有一个函数模板可以在模板参数推断中胜出。
上述例子已体现出通过EnableIf实现算法特化的一个缺点:每当一个新的算法变体被加入进来,就需要调整所有算法变体的EnableIf条件,以使得它们之间彼此互斥。作为对比,当通过标记派发(tag dispatching)引入一个双向迭代器的算法变体时,则只需要使用标记std::bidirectional_iterator_tag
重载一个advanceIterImpl()
即可。
标记派发(tag dispatching)和EnableIf两种技术所适用的场景有所不同:一般而言,标记派发可以基于分层的tags支持简单的派发,而EnableIf则可以基于通过使用类型萃取(type trait)获得的任意一组属性来支持更为复杂的派发。
EnableIf所之何处
EnableIf通常被用于函数模板的返回类型。但是,该方法不适用于构造函数模板以及类型转换模板,因为它们都没有被指定返回类型。而且,使用EnableIf也会使得返回类型很难被读懂。对于这一问题,我们可以通过将EnableIf嵌入一个默认的模板参数来解决,比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename Iterator>
constexpr bool IsInputIterator = IsConvertible< typename
std::iterator_traits<Iterator>::iterator_category,
std::input_iterator_tag>;
template<typename T>
class Container {
public:
// construct from an input iterator sequence:
template<typename Iterator, typename =
EnableIf<IsInputIterator<Iterator>>>
Container(Iterator first, Iterator last);
// convert to a container so long as the value types are convertible:
template<typename U, typename = EnableIf<IsConvertible<T, U>>>
operator Container<U>() const;
};
但是,这样做也有一个问题。如果我们尝试再添加一个版本的重载的话,会导致错误:1
2
3
4
5
6
7
8// construct from an input iterator sequence:
template<typename Iterator,
typename = EnableIf<IsInputIterator<Iterator>
&& !IsRandomAccessIterator<Iterator>>>
Container(Iterator first, Iterator last);
template<typename Iterator, typename = EnableIf<IsRandomAccessIterator<Iterator>>>
Container(Iterator first, Iterator last); // ERROR: redeclaration
//of constructor template
问题在于这两个模板唯一的区别是默认模板参数,但是在判断两个模板是否相同的时候却又不会考虑默认模板参数。
该问题可以通过引入另外一个模板参数来解决,这样两个构造函数模板就有数量不同的模板参数了:1
2
3
4
5
6
7
8
9// construct from an input iterator sequence:
template<typename Iterator, typename =
EnableIf<IsInputIterator<Iterator>
&& !IsRandomAccessIterator<Iterator>>>
Container(Iterator first, Iterator last);
template<typename Iterator, typename =
EnableIf<IsRandomAccessIterator<Iterator>>, typename = int> // extra
dummy parameter to enable both constructors
Container(Iterator first, Iterator last); //OK now
编译期if
值得注意的是,C++17的constexpr if特性使得某些情况下可以不再使用EnableIf
。比如在C++17中可以像下面这样重写advanceIter()
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25template<typename Iterator, typename Distance>
void advanceIter(Iterator& x, Distance n) {
if constexpr(IsRandomAccessIterator<Iterator>) {
// implementation for random access iterators:
x += n; // constant time
} else if constexpr(IsBidirectionalIterator<Iterator>) {
// implementation for bidirectional iterators:
if (n > 0) {
for ( ; n > 0; ++x, --n) { //linear time for positive n
}
} else {
for ( ; n < 0; --x, ++n) { //linear time for negative n
}
}
} else {
// implementation for all other iterators that are at least input iterators:
if (n < 0) {
throw "advanceIter(): invalid iterator category for negative n";
}
while (n > 0) { //linear time for positive n only
++x;
--n;
}
}
}
这样会更好一些。更为特化的代码分支只会被那些支持它们的类型实例化。因此,对于使用了不被所有的迭代器都支持的代码的情况,只要它们被放在合适的constexpr if分支中,就是安全的。
但是,该方法也有其缺点。只有在泛型代码组件可以被在一个函数模板中完整的表述时,这一使用constexpr if的方法才是可能的。在下面这些情况下,我们依然需要EnableIf:
- 需要满足不同的“接口”需求
- 需要不同的class定义
- 对于某些模板参数列表,不应该存在有效的实例化。
对于最后一种情况,下面这种做法看上去很有吸引力:1
2
3
4
5
6
7
8
9
10template<typename T>
void f(T p) {
if constexpr (condition<T>::value) {
// do something here...
}
else {
// not a T for which f() makes sense:
static_assert(condition<T>::value, "can't call f() for such a T");
}
}
Concepts
上述技术到目前为止都还不错,但是有时候却稍显笨拙,它们可能会占用很多的编译器资源,以及在某些情况下,可能会产生难以理解的错误信息。因此某些泛型库的作者一直都在盼望着一种能够更简单、直接地实现相同效果的语言特性。为了满足这一需求,一个被称为conceptes的特性很可能会被加入到C++语言中。比如,我们可能希望被重载的container的构造函数可以像下面这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18template<typename T>
class Container {
public:
//construct from an input iterator sequence:
template<typename Iterator>
requires IsInputIterator<Iterator>
Container(Iterator first, Iterator last);
// construct from a random access iterator sequence:
template<typename Iterator>
requires IsRandomAccessIterator<Iterator>
Container(Iterator first, Iterator last);
// convert to a container so long as the value types are convertible:
template<typename U>
requires IsConvertible<T, U>
operator Container<U>() const;
};
其中requires条款描述了使用当前模板的要求。如果某个要求不被满足,那么相应的模板就不会被当作备选项考虑。因此它可以被当作EnableIf这一想法的更为直接的表达方式,而且是被语言自身支持的。
Requires条款还有另外一些优于EnableIf的地方。约束包容(constraint subsumption)为只有requires不同的模板进行了排序,这样就不再需要标记派发了(tag dispatching)。而且,requires条款也可以被用于非模板。比如只有在T的对象可以被<
运算符比较的时候,才为容器提供sort()
成员函数:1
2
3
4
5
6
7template<typename T>
class Container {
public:
requires HasLess<T>
void sort() {
}
};
类的特化
类模板的偏特化可以被用来提供一个可选的、为特定模板参数进行了特化的实现,这一点和函数模板的重载很相像。而且,和函数模板的重载类似,如果能够基于模板参数的属性对各种偏特化版本进行区分,也会很有意义。考虑一个以key和value的类型为模板参数的泛型Dictionary类模板。只要key的类型提供了operator==()运算符,就可以实现一个简单(但是低效)的Dictionary:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20template<typename Key, typename Value>
class Dictionary
{
private:
vector<pair<Key const, Value>> data;
public:
//subscripted access to the data:
value& operator[](Key const& key)
{
// search for the element with this key:
for (auto& element : data) {
if (element.first == key){
return element.second;
}
}
// there is no element with this key; add one
data.push_back(pair<Key const, Value>(key, Value()));
return data.back().second;
}
};
如果key的类型提供了operator <()
运算符的话,则可以基于标准库的map容器提供一种相对高效的实现方式。类似的,如果key的类型提供了哈希操作的话,则可以基于标准库的unordered_map提供一种更为高效的实现方式。
启用/禁用类模板
启用/禁用类模板的不同实现方式的方法是使用类模板的偏特化。为了将EnableIf用于类模板的偏特化,需要先为Dictionary引入一个未命名的、 默认的模板参数:1
2
3
4
5template<typename Key, typename Value, typename = void>
class Dictionary
{
... //vector implementation as above
};
这个新的模板参数将是我们使用EnableIf的入口,现在它可以被嵌入到基于map的偏特化Dictionary的模板参数例表中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34template<typename Key, typename Value>
class Dictionary<Key, Value, EnableIf<HasLess<Key>>>
{
private:
map<Key, Value> data;
public:
value& operator[](Key const& key) {
return data[key];
}
};
···
和函数模板的重载不同,我们不需要对主模板的任意条件进行禁用,因为对于类模板,任意偏特化版本的优先级都比主模板高。但是,当我们针对支持哈希操作的另一组keys进行特化时,则需要保证不同偏特化版本间的条件是互斥的:
```C++
template<typename Key, typename Value, typename = void>
class Dictionary
{
... // vector implementation as above
};
template<typename Key, typename Value>
class Dictionary<Key, Value, EnableIf<HasLess<Key> && !HasHash<Key>>> {
{
... // map implementation as above
};
template typename Key, typename Value>
class Dictionary Key, Value, EnableIf HasHash Key>>>
{
private:
unordered_map Key, Value> data;
public:
value& operator[](Key const& key) {
return data[key];
}
};
类模板的标记派发
同样地,标记派发也可以被用于在不同的模板特化版本之间做选择。为了展示这一技术,我们定义一个类似于之前章节中介绍的advanceIter()
算法的函数对象类型Advance<Iterator>
,它同样会以一定的步数移动迭代器。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57// primary template (intentionally undefined):
template<typename Iterator,
typename Tag = BestMatchInSet< typename
std::iterator_traits<Iterator> ::iterator_category,
std::input_iterator_tag,
std::bidirectional_iterator_tag,
std::random_access_iterator_tag>>
class Advance;
// general, linear-time implementation for input iterators:
template<typename Iterator>
class Advance<Iterator, std::input_iterator_tag>
{
public:
using DifferenceType = typename std::iterator_traits<Iterator>::difference_type;
void operator() (Iterator& x, DifferenceType n) const
{
while (n > 0) {
++x;
--n;
}
}
};
// bidirectional, linear-time algorithm for bidirectional iterators:
template<typename Iterator>
class Advance<Iterator, std::bidirectional_iterator_tag>
{
public:
using DifferenceType =typename
std::iterator_traits<Iterator>::difference_type;
void operator() (Iterator& x, DifferenceType n) const
{
if (n > 0) {
while (n > 0) {
++x;
--n;
}
} else {
while (n < 0) {
--x;
++n;
}
}
}
};
// bidirectional, constant-time algorithm for random access iterators:
template<typename Iterator>
class Advance<Iterator, std::random_access_iterator_tag>
{
public:
using DifferenceType =
typename std::iterator_traits<Iterator>::difference_type;
void operator() (Iterator& x, DifferenceType n) const
{
x += n;
}
};
这一实现形式和函数模板中的标记派发很相像。但是,比较困难的是BestMatchInSet的实现,它主要被用来为一个给定的迭代器选择选择最匹配tag。本质上,这个类型萃取所做的是,当给定一个迭代器种类标记的值之后,要判断出该从以下重载函数中选择哪一个,并返回其参数类型:1
2
3void f(std::input_iterator_tag);
void f(std::bidirectional_iterator_tag);
void f(std::random_access_iterator_tag);
模拟重载解析最简单的方式就是使用重载解析,就像下面这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// construct a set of match() overloads for the types in Types...:
template<typename... Types>
struct MatchOverloads;
// basis case: nothing matched:
template<>
struct MatchOverloads<> {
static void match(...);
};
// recursive case: introduce a new match() overload:
template<typename T1, typename... Rest>
struct MatchOverloads<T1, Rest...> : public MatchOverloads<Rest...>
{
static T1 match(T1); // introduce overload for T1
using MatchOverloads<Rest...>::match;// collect overloads from bases
};
// find the best match for T in Types...
template<typename T, typename... Types>
struct BestMatchInSetT {
using Type = decltype(MatchOverloads<Types...>::match(declval<T> ()));
};
template<typename T, typename... Types>
using BestMatchInSet = typename BestMatchInSetT<T, Types...>::Type;
MatchOverloads
模板通过递归继承为输入的一组Types
中的每一个类型都声明了一个match()
函数。每一次递归模板MatchOverloads
偏特化的实例化都为列表中的下一个类型引入了一个新的match()
函数。然后通过使用using
声明将基类中的match()
函数引入当前作用域。当递归地使用该模板的时候,我们就有了一组和给定类型完全对应的match()
函数的重载,每一个重载函数返回的都是其参数的类型。然后BestMatchInSetT
模板会将T类型的对象传递给一组match()
的重载函数,并返回最匹配的match()
函数的返回类型。如果没有任何一个match()
函数被匹配上,那么返回基本情况对应的void(使用省略号来捕获任意参数)将代表出现了匹配错误。总结来讲,BestMatchInSetT
将函数重载的结果转化成了类型萃取,这样可以让通过标记派发,在不同的模板偏特化之间做选择的情况变得相对容易一些。
实例化安全的模板
EnableIf
技术的本质是:只有在模板参数满足某些条件的情况下才允许使用某个模板或者某个偏特化模板。比如,最为高效的advanceIter()
算法会检查迭代器的参数种类是否可以被转化成std::random_access_iterator_tag
,也就意味着各种各样的随机访问迭代器都适用于该算
法。
如果我们将这一概念发挥到极致,将所有模板用到的模板参数的操作都编码进EnableIf的条件,会怎样呢?这样一个模板的实例化永远都不会失败,因为那些没有提供EnableIf所需操作的模板参数会导致一个推断错误,而不是任由可能会出错的实例化继续进行。我们称这一类模板为“实例化安全(instantiation-safe )”的模板,接下来会对其进行简单介绍。先从一个计算两个数之间的最小值的简单模板min()开始。我们可能会将其实现成下面这样:1
2
3
4
5
6
7
8template<typename T>
T const& min(T const& x, T const& y)
{
if (y < x) {
return y;
}
return x;
}
这个模板要求类型为T的两个值可以通过<运算符进行比较,并将比较结果转换成bool类型给if语句使用。可以检查类型是否支持<操作符,并计算其返回值类型的类型萃取。为了方便,我们此处依然列出LessResultT的实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
template<typename T1, typename T2>
class HasLess {
template<typename T> struct Identity;
template<typename U1, typename U2>
static std::true_type
test(Identity<decltype(std::declval<U1>() < std::declval<U2>())>*);
template<typename U1, typename U2>
static std::false_type
test(...);
public:
static constexpr bool value = decltype(test<T1, T2> (nullptr))::value;
};
template<typename T1, typename T2, bool HasLess>
class LessResultImpl {
public:
using Type = decltype(std::declval<T1>() < std::declval<T2>());
};
template<typename T1, typename T2>
class LessResultImpl<T1, T2, false> {
};
template<typename T1, typename T2>
class LessResultT : public LessResultImpl<T1, T2, HasLess<T1, T2>::value> {
};
template<typename T1, typename T2>
using LessResult = typename LessResultT<T1, T2>::Type;
现在就可以通过将该萃取和IsConvertible一起使用,使min()
变成实例化安全的:1
2
3
4
5
6
7
8
9
10
11
template<typename T>
EnableIf<IsConvertible<LessResult<T const&, T const&>, bool>, T const&>
min(T const& x, T const& y)
{
if (y < x) {
return y;
}
return x;
}
通过各种实现了不同<
运算符的类型来调用min()
,要更能说明问题一些,就像下面这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct X1 { };
bool operator< (X1 const&, X1 const&) { return true; }
struct X2 { };
bool operator<(X2, X2) { return true; }
struct X3 { };
bool operator<(X3&, X3&) { return true; }
struct X4 { };
struct BoolConvertible {
operator bool() const { return true; } // implicit conversion to bool
};
struct X5 { };
BoolConvertible operator< (X5 const&, X5 const&)
{
return BoolConvertible();
}
struct NotBoolConvertible { // no conversion to bool
};
struct X6 { };
NotBoolConvertible operator< (X6 const&, X6 const&)
{
return NotBoolConvertible();
}
struct BoolLike {
explicit operator bool() const { return true; } // explicit conversion to bool
};
struct X7 { };
BoolLike operator< (X7 const&, X7 const&) { return BoolLike(); }
int main()
{
min(X1(), X1()); // X1 can be passed to min()
min(X2(), X2()); // X2 can be passed to min()
min(X3(), X3()); // ERROR: X3 cannot be passed to min()
min(X4(), X4()); // ERROR: X4 cannot be passed to min()
min(X5(), X5()); // X5 can be passed to min()
min(X6(), X6()); // ERROR: X6 cannot be passed to min()
min(X7(), X7()); // UNEXPECTED ERROR: X7 cannot be passed to min()
}
在编译上述程序的时候,要注意虽然针对min()
函数会报出4个错误(X3,X4,X6,以及X7),但它们都不是从min()
的函数体中报出来的(如果不是实例化安全的话,则会从函数体中报出错误)。相反,编译器只会抱怨说没有合适的min()函数,因为唯一的选择已经被SFINAE排除了。
我们需要一个可以判断某个类型是否是“语境上可以转换成bool”的萃取技术。控制流程语句对该萃取技术的实现没有帮助,因为语句不可以出现在SFINAE上下文中,同样的,可以被任意类型重载的逻辑操作也不可以。幸运的是,三元运算符?:是一个表达式,而且不可以被重载,因此它可以被用来测试一个类型是否是“语境上可以转换成bool”的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T>
class IsContextualBoolT {
private:
template<typename T> struct Identity;
template<typename U>
static std::true_type test(Identity<decltype(declval<U>()? 0 : 1)>*);
template<typename U>
static std::false_type test(...);
public:
static constexpr bool value = decltype(test<T> (nullptr))::value;
};
template<typename T>
constexpr bool IsContextualBool = IsContextualBoolT<T>::value;
有了这一萃取,我们就可以实现一个使用了正确的EnableIf条件且实例化安全的min()
了:1
2
3
4
5
6
7
8
9
10
11
template<typename T>
EnableIf<IsContextualBool<LessResult<T const&, T const&>>, T const&>
min(T const& x, T const& y)
{
if (y < x) {
return y;
}
return x;
}
将各种各样的条件检查,组合进描述了类型种类(比如前向迭代器)的萃取技术,并将这些萃取技术一起放在EnableIf的条件检查中,这一使min()变得实例化安全的技术可以被推广到用于描述其它重要模板的条件。
在标准库中的情况
C++标准库为输入,输出,前向,双向以及随机访问迭代器提供了迭代器标记,我们对这些都已经做了展示。这些迭代器标记是标准迭代器萃取std::iterator_traits
技术以及施加于迭代器的需求的一部分,因此它们可以被安全得用于标记派发。
C++11标准库中的std::enable_if
模板提供了和我们所展示的EnableIf相同的行为。唯一的不同是标准库用了一个小写的成员类型type,而我们使用的是Type。
算法的偏特化在C++标准库中被用在了很多地方。比如,std::advance()
以及std::distance()
基于其迭代器参数的种类的不同,都有很多变体。虽然很多标准库的实现都倾向于使用标记派发(tag dispatch),但是最近其中一些实现也已经使用std::enable_if
来进行算法特化了。而且,很多的C++标准库的实现,在内部也都用这些技术去实现各种标准库算法的偏特化。比如,当迭代器指向连续内存且它们所指向的值有拷贝赋值运算符的时候,std::copy()
可以通过调用std::memory()
和std::memmove()
来进行偏特化。同样的,std::fill()
也可以通过调用std::memset
进行优化,而且在知晓一个类型有一个普通的析构函数(trivial destructor)的情况下,很多算法都可以避免去调用析构函数。C++标准并没有对这些算法特化的实现方式进行统一(比如统一采用std::advance()
和std::distance()
的方式),但是实现者还是为了性能
而选择类似的方式。
正如第8.4节介绍的那样,C++标准库强烈的建议在其所需要施加的条件中使用std::enable_if<>
或者其它类似SFINAE的技术。比如,std::vector
就有一个允许其从迭代器序列进行构造的构造函数模板:1
2
3template<typename InputIterator>
vector(InputIterator first, InputIterator second,
allocator_type const& alloc = allocator_type());
它要求“当通过类型InputIterator调用构造函数的时候,如果该类型不属于输入迭代器(input iterator),那么该构造函数就不能参与到重载解析中”。这一措辞并没有精确到足以使当前最高效的技术被应用到实现当中,但是在其被引入到标准中的时候,std::enable_if<>
确实被寄予了这一期望。
模板和继承
空基类优化
C++中的类经常是“空”的,也就是说它们的内部表征在运行期间不占用内存。典型的情况是那写只包含类型成员,非虚成员函数,以及静态数据成员的类。而非静态数据成员,虚函数,以及虚基类,在运行期间则是需要占用内存的。然而即使是空的类,其所占用的内存大小也不是零。如果愿意的话,运行下面的程序可以证明这一点:1
2
3
4
5
6
7
class EmptyClass {
};
int main()
{
std::cout << "sizeof(EmptyClass):" << sizeof(EmptyClass) << "\n";
}
在某些平台上,这个程序会打印出1。在少数对class类型实施了严格内存对齐要求的平台上,则可能会打印出其它结果(典型的结果是4)。
布局原则
C++的设计者有很多种理由不去使用内存占用为零的class。比如,一个存储了内存占用为零的class的数组,其内存占用也将是零,这样的话常规的指针运算规则都将不在适用。假设ZeroSizedT是一个内存占用为零的类型:1
2
3ZeroSizedT z[10];
...
&z[i] - &z[j] //compute distance between pointers/addresses
正常情况下,上述例子中的结果可以用两个地址之间的差值,除以该数组中元素类型的大小得到,但是如果元素所占用内存为零的话,上述结论显然不再成立。虽然在C++中没有内存占用为零的类型,但是C++标准却指出,在空class被用作基类的时候,如果不给它分配内存并不会导致其被存储到与其它同类型对象或者子对象相同的地址上,那么就可以不给它分配内存。下面通过一些例子来看看实际应用中空基类优化(empty class optimization,EBCO)的意义。考虑如下程序:1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Empty {
using Int = int;// type alias members don"t make a class nonempty
};
class EmptyToo : public Empty {
};
class EmptyThree : public EmptyToo {
};
int main()
{
std::cout << "sizeof(Empty): " << sizeof(Empty) << "\n";
std::cout << "sizeof(EmptyToo): " << sizeof(EmptyToo) << "\n";
std::cout << "sizeof(EmptyThree): " << sizeof(EmptyThree) << "\n";
}
如果你所使用的编译器实现了EBCO的话,它打印出来的三个class的大小将是相同的,但是它们的结果也都不会是零。这意味着在EmptyToo中,Empty没有被分配内存。注意一个继承自优化后的空基类(且只有这一个基类)的空类依然是空的。这就解释了为什么EmptyThree的大小和Empty相同。如果你所用的编译器没有实现EBCO的话,那么它打印出来的各个class的大小将不同。
考虑一种EBCO不适用的情况:1
2
3
4
5
6
7
8
9
10
11
12
13
class Empty {
using Int = int; // type alias members don"t make a class nonempty
};
class EmptyToo : public Empty {
};
class NonEmpty : public Empty, public EmptyToo {
};
int main(){
std::cout <<"sizeof(Empty): " << sizeof(Empty) <<"\n";
std::cout <<"sizeof(EmptyToo): " << sizeof(EmptyToo) <<"\n";
std::cout <<"sizeof(NonEmpty): " << sizeof(NonEmpty) <<"\n";
}
可能有点意外的是,NonEmpty不再是一个空的类。毕竟它以及它的基类都没有任何数据成员。但是NonEmpty的基类Empty和EmptyToo不可以被分配到相同的地址上,因为这会导致EmptyToo的基类Empty和NonEmpty的基类Empty被分配到相同的地址。或者说两个类型相同的子对象会被分配到相同的地址上,而这在C++布局规则中是不被允许的。你可能会想到将其中一个Empty基类的子对象放在偏移量为“0字节”的地方,将另一个放在偏移量为“1字节”的地方,但是完整的NonEmpty对象的内存占用依然不能是1字节,因为在一个包含了两个NonEmpty对象的数组中,第一个元素的Empty子对象不能和第二个元素中的Empty子对象占用相同的地址。
EBCO之所以会有这一限制,是因为我们希望能够通过比较两个指针来确定它们所指向的是不是同一个对象。由于指针在程序中几乎总是被表示为单纯的地址,因此就需要我们来确保两个不同的地址(比如指针的值)指向的总是两个不同的对象。
将数据成员实现为基类
EBCO和数据成员之间没有对等关系,因为(其中一个问题是)它会在用指针指向数据成员的表示上造成一些问题。结果就是,在有些情况下会期望将其实现为一个private的基类,这样粗看起来就可以将其视作成员变量。但是,这样做也并不是没有问题。由于模板参数经常会被空class类型替换,因此在模板上下文中这一问题要更有意思一些,但是通常我们不能依赖这一规则。如果我们对类型参数一无所知,就不能很容易的使用EBCO。考虑下面的例子:1
2
3
4
5
6
7template<typename T1, typename T2>
class MyClass {
private:
T1 a;
T2 b;
...
};
其中的一个或者两个模板参数完全有可能被空class类型替换。如果真是这样,那么MyClass<T1, T2>
这一表达方式可能不是最优的选择,它可能会为每一个MyClass<T1,T2>
的实例都浪费一个字的内存。这一内存浪费可以通过把模板参数作为基类使用来避免:1
2
3template<typename T1, typename T2>
class MyClass : private T1, private T2 {
};
但是这一直接的替代方案也有其自身的缺点:
- 当T1或者T2被一个非class类型或者union类型替换的时候,该方法不再适用。
- 在两个模板参数被同一种类型替换的时候,该方法不再适用(虽然这一问题简单地通过增加一层额外的继承来解决,参见513页)。
- 用来替换T1或者T2的类型可能是final的,此时尝试从其派生出新的类会触发错误。
即使这些问题能够很好的解决,也还有一个严重的问题存在:给一个class添加一个基类,可能会从根本上改变该class的接口。对于我们的MyClass类,由于只有很少的接口会被影响到,这可能看上去不是一个重要的问题。但是正如在本章接下来的内容中将要看到的,从一个模板参数做继承,会影响到一个成员函数是否可以是virtual的。很显然,EBCO的这一适用方式会带来各种各样的问题。
当已知模板参数只会被class类型替换,以及需要支持另一个模板参数的时候,可以使用另一种更实际的方法。其主要思想是通过使用EBCO将可能为空的类型参数与别的参数“合并”。比如,相比于这样:1
2
3
4
5
6
7template<typename CustomClass>
class Optimizable {
private:
CustomClass info; // might be empty
void* storage;
...
};
一个模板开发者会使用如下方式:1
2
3
4
5
6template<typename CustomClass>
class Optimizable {
private:
BaseMemberPair<CustomClass, void*> info_and_storage;
...
};
虽然还没有看到BaseMemberPari的具体实现方式,但是可以肯定它的引入会使Optimizable的实现变得更复杂。但是很多的模板开发者都反应,相比于复杂度的增加,它带来的性能提升是值得的。BaseMemberPair
的实现可以非常简洁:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<typename Base, typename Member>
class BaseMemberPair : private Base {
private:
Member mem;
public:// constructor
BaseMemberPair (Base const & b, Member const & m)
: Base(b), mem(m) {
} // access base class data via first()
Base const& base() const {
return static_cast<Base const&>(*this);
}
Base& base() {
return static_cast<Base&>(*this);
} // access member data via second()
Member const& member() const {
return this->mem;
}
Member& member() {
return this->mem;
}
};
相应的实现需要使用base()
和member()
成员函数来获取被封装的(或者被执行了内存优化的)数据成员。
The Curiously Recurring Template Pattern (CRTP)
另一种模式是CRTP。这一个有着奇怪名称的模式指的是将派生类作为模板参数传递给其某个基类的一类技术。该模式的一种最简单的C++实现方式如下:1
2
3
4
5
6
7template<typename Derived>
class CuriousBase {
...
};
class Curious : public CuriousBase<Curious> {
...
};
上面的CRTP的例子使用了非依赖性基类(nondependent base class):Curious不是一个模板类,因此它对在依赖性基类中遇到的名称可见性问题是免疫的。但是这并不是CRTP的固有特征。事实上,我们同样可以使用下面的这一实现方式:1
2
3
4
5
6
7
8template<typename Derived>
class CuriousBase {
...
};
template<typename T>
class CuriousTemplate : public CuriousBase<CuriousTemplate<T>> {
...
};
将派生类通过模板参数传递给其基类,基类可以在不使用虚函数的情况下定制派生类的行为。这使得CRTP对那些只能被实现为成员函数的情况(比如构造函数,析构函数,以及下表运算符)或者依赖于派生类的特性的情况很有帮助。
一个CRTP的简单应用是将其用于追踪从一个class类型实例化出了多少对象。这一功能也可以通过在构造函数中递增一个static数据成员、并在析构函数中递减该数据成员来实现。但是给不同的class都提供相同的代码是一件很无聊的事情,而通过一个基类(非CRTP)实现这一功能又会将不同派生类实例的数目混杂在一起。事实上,可以实现下面这一模板:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<typename CountedType>
class ObjectCounter {
private:
inline static std::size_t count = 0; // number of existing objects
protected:
// default constructor
ObjectCounter() {
++count;
} // copy constructor
ObjectCounter (ObjectCounter<CountedType> const&) {
++count;
} // move constructor
ObjectCounter (ObjectCounter<CountedType> &&) {
++count;
} // destructor
~ObjectCounter() {
--count;
}
public:
// return number of existing objects:
static std::size_t live() {
return count;
}
};
注意这里为了能够在class内部初始化count成员,使用了inline。在C++17之前,必须在class模板外面定义它:1
2
3
4
5
6
7
8
9template<typename CountedType>
class ObjectCounter {
private:
static std::size_t count; // number of existing objects
...
};
// initialize counter with zero:
template<typename CountedType>
std::size_t ObjectCounter<CountedType>::count = 0;
当我们想要统计某一个class的对象(未被销毁)数目时,只需要让其派生自ObjectCounter即可。比如,可以按照下面的方式统计MyString的对象数目:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename CharT>
class MyString : public ObjectCounter<MyString<CharT>> {
...
};
int main()
{
MyString<char> s1, s2;
MyString<wchar_t> ws;
std::cout << "num of MyString<char>: "
<< MyString<char>::live() << '\n';
std::cout << "num of MyString<wchar_t>: "
<< ws.live() << '\n';
}
The Barton-Nackman Trick
该技术产生的动力之一是:在当时,函数模板的重载是严重受限的,而且namespace在当时也不为大多数编译器所支持。
为了说明这一技术,假设我们有一个需要为之定义operator ==的类模板Array。一个可能的方案是将该运算符定义为类模板的成员,但是由于其第一个参数(绑定到this指针上的参数)和第二个参数的类型转换规则不同。由于我们希望operator ==
对其参数是对称的,因此更倾向与将其定义为某一个namespace中的函数。一种很直观的实现方式可能会像下面这样:1
2
3
4
5
6
7
8
9template<typename T>
class Array {
public:
...
};
template<typename T>bool operator== (Array<T> const& a, Array<T> const& b)
{
...
}
不过如果函数模板不可以被重载的话,这会引入一个问题:在当前作用域内不可以再声明其它的operator ==
模板,而其它的类模板却又很可能需要这样一个类似的模板。通过将operator ==
定义成class内部的一个常规友元函数解决了这一问题:1
2
3
4
5
6
7
8
9
10template<typename T>
class Array {
static bool areEqual(Array<T> const& a, Array<T> const& b);
public:
...
friend bool operator== (Array<T> const& a, Array<T> const& b)
{
return areEqual(a, b);
}
};
假设我们用float实例化了该Array类。作为实例化的结果,该友元运算符函数也会被连带声明,但是请注意该函数本身并不是一个函数模板的实例。作为实例化过程的一个副产品,它是一个被注入到全局作用域的常规非模板函数。由于它是非模板函数,即使在重载函数模板的功能被引入之前,也可以用其它的operator ==
对其进行重载。由于这样做避免了去定义一个适用于所有类型T
的operator ==(T, T)
模板,称为restricted template expansion。
由于operator== (Array<T> const&, Array<T> const&)
被定义在一个class的定义中,它会被隐式地当作inline函数,因此我们决定将其实现委托给一个static成员函数(不需要是inline的)。
运算符的实现
在给一个类重载运算符的时候,通常也需要重载一些其它的(当然也是相关的)运算符。比如,一个实现了operator ==
的类,通常也会实现operator !=
,一个实现了operator <
的类,通常也会实现其它的关系运算符(>,<=,>=)。在很多情况下,这些运算符中只有一个运算符的定义比较有意思,其余的运算符都可以通过它来定义。例如,类X的operator !=
可以通过使用operator ==
来定义:1
2
3bool operator!= (X const& x1, X const& x2) {
return !(x1 == x2);
}
对于那些operator !=
的定义类似的类型,可以通过模板将其泛型化:1
2
3
4template<typename T>
bool operator!= (T const& x1, T const& x2) {
return !(x1 == x2);
}
事实上,在C++标准库的<utility>
头文件中已经包含了类似的定义。但是,一些别的定义在标准化过程中则被放到了namespace std::rel_ops
中,因为当时可以确定如果让它们在std中可见的话,会导致一些问题。
虽然上述第一个问题可以通过SFINAE技术解决,这样的话这个!= operator
的定义只会在某种类型有合适的== operator
时才会被进行相应的实例化。但是第二个问题依然存在:相比于用户定义的需要进行从派生类到基类的转化的!= operator
,上述通用的!=operator
定义总是会被优先选择,这有时会导致意料之外的结果。
另一种基于CRTP的运算符模板形式,则允许程序去选择泛型的运算符定义:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21template<typename Derived>
class EqualityComparable
{
public:
friend bool operator!= (Derived const& x1, Derived const& x2)
{
return !(x1 == x2);
}
};
class X : public EqualityComparable<X>
{
public:
friend bool operator== (X const& x1, X const& x2) {
// implement logic for comparing two objects of type X
}
};
int main()
{
X x1, x2;
if (x1 != x2) { }
}
EqualityComparable<>
为了基于派生类中定义的operator==
给其派生类提供operator !=
,使用了CRTP。事实上这一定义是通过friend
函数定义的形式提供的,这使得两个参数在类型转换时的operator !=
行为一致。
Mixins
考虑一个包含了一组点的简单Polygon类:1
2
3
4
5
6
7
8
9
10
11
12
13
14class Point
{
public:
double x, y;
Point() : x(0.0), y(0.0) { }
Point(double x, double y) : x(x), y(y) { }
};
class Polygon
{
private:
std::vector<Point> points;
public:
... //public operations
};
如果可以扩展与每个Point相关联的一组信息的话(比如包含特定应用中每个点的颜色,或者给每个点加个标签),那么Polygon类将变得更为实用。实现该扩展的一种方式是用点的类型对Polygon进行参数化:1
2
3
4
5
6
7
8template<typename P>
class Polygon
{
private:
std::vector<P> points;
public:
... //public operations
};
用户可以通过继承创建与Point类似,但是包含了特定应用所需数据,并且提供了与Point相同的接口的类型:1
2
3
4
5
6
7
8class LabeledPoint : public Point
{
public:
std::string label;
LabeledPoint() : Point(), label("") { }
LabeledPoint(double x, double y) : Point(x, y), label("") {
}
};
这一实现方式有其自身的缺点。比如,首先需要将Point类型暴露给用户,这样用户才能从它派生出自己的类型。而且LablePoint的作者也需要格外小心地提供与Point完全一样的接口(比如,继承或者提供所有与Point相同的构造函数),否则在Polygon中使用LabledPoint的时候会遇到问题。这一问题在Point随Polygon模板版本发生变化时将会变得更加严重:
- 如果给Point新增一个构造函数,就需要去更新所有的派生类。
Mixins是另一种可以客制化一个类型的行为但是不需要从其进行继承的方法。事实上,Mixins反转了常规的继承方向,因为新的类型被作为类模板的基类“混合进”了继承层级中,而不是被创建为一个新的派生类。这一方式允许在引入新的数据成员以及某些操作的时候,不需要去复制相关接口。一个支持了mixins的类模板通常会接受一组任意数量的class,并从之进行派生:1
2
3
4
5
6
7
8template<typename... Mixins>
class Point : public Mixins...
{
public:
double x, y;
Point() : Mixins()..., x(0.0), y(0.0) { }
Point(double x, double y) : Mixins()..., x(x), y(y) { }
};
现在,我们就可以通过将一个包含了label的基类“混合进来(mix in)”来生成一个LabledPoint:1
2
3
4
5
6
7class Label
{
public:
std::string label;
Label() : label("") { }
};
using LabeledPoint = Point<Label>;
甚至是“mix in”几个基类:1
2
3
4
5
6class Color
{
public:
unsigned char red = 0, green = 0, blue = 0;
};
using MyPoint = Point<Label, Color>;
有了这个基于mixin的Point,就可以在不改变其接口的情况下很容易的为Point引入额外的信息,因此Polygon的使用和维护也将变得相对简单一些。为了访问相关数据和接口,用户只需进行从Point到它们的mixin类型(Label或者Color)之间的隐式转化即可。而且,通过提供给Polygon类模板的mixins,Point类甚至可以被完全隐藏:1
2
3
4
5
6
7
8template<typename... Mixins>
class Polygon
{
private:
std::vector<Point<Mixins...>> points;
public:
... //public operations
};
当需要对模板进行少量客制化的时候,Mixins会很有用,比如在需要用用户指定的数据去装饰内部存储的对象时,使用mixins就不需要将内部数据类型和接口暴露出来并写进文档。
Curious Mixins
一个CRTP-mixin版本的Point可以被下称下面这样:1
2
3
4
5
6
7
8template<template<typename>... Mixins>
class Point : public Mixins<Point>...
{
public:
double x, y;
Point() : Mixins<Point>()..., x(0.0), y(0.0) { }
Point(double x, double y) : Mixins<Point>()..., x(x), y(y) { }
};
这一实现方式需要对那些将要被混合进来(mix in)的类做一些额外的工作,因此诸如Label和Color一类的class需要被调整成类模板。但是,现在这些被混合进来的class的行为可以基于其降要被混合进的派生类进行调整。比如,我们可以将前述的ObjectCounter模板混合进Point,这样就可以统计在Polygon中创建的点的数目。
Parameterized Virtuality
Minxins还允许我们去间接的参数化派生类的其它特性,比如成员函数的虚拟性。下面的简单例子展示了这一令人称奇的技术:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class NotVirtual {
};
class Virtual {
public:
virtual void foo() {
}
};
template<typename... Mixins>
class Base : public Mixins...
{
public:
// the virtuality of foo() depends on its declaration
// (if any) in the base classes Mixins...
void foo() {
std::cout << "Base::foo()" << "\n";
}
};
template<typename... Mixins>
class Derived : public Base<Mixins...> {
public:
void foo() {
std::cout << "Derived::foo()" << "\n";
}
};
int main()
{
Base<NotVirtual>* p1 = new Derived<NotVirtual>;
p1->foo(); // calls Base::foo()
Base<Virtual>* p2 = new Derived<Virtual>;
p2->foo(); // calls Derived::foo()
}
该技术提供了这样一种工具,使用它可以设计出一个既可以用来实例化具体的类,也可以通过继承对其进行扩展的类模板。但是,要获得一个可以为某些更为特化的功能产生一个更好的基类的类,仅仅是针对某些成员函数进行虚拟化还是不够的。这一类开发方法需要更为基础的设计决策。更为实际的做法是设计两个不同的工具(类或者类模板层级),而不是将它们集成进一个模板层级。
Named Template Arguments
不少模板技术有时会导致类模板包含很多不同的模板类型参数。但是,其中一些模板参数通常都会有合理的默认值。其中一种这一类模板的定义方式可能会向下面这样:1
2
3
4
5
6
7template<typename Policy1 = DefaultPolicy1,
typename Policy2 = DefaultPolicy2,
typename Policy3 = DefaultPolicy3,
typename Policy4 = DefaultPolicy4>
class BreadSlicer {
...
};
可以想象,在使用这样一个模板时通常都可以使用模板参数的默认值。但是,如果需要指定某一个非默认参数的值的话,那么也需要指定该参数前面的所有参数的值(虽然使用的可能是它们的默认值)。
很显然,我们更倾向于使用BreadSlicer<Policy3 = Custom>
的形式,而不是BreadSlicer<DefaultPolicy1, DefaultPolicy2, Custom>
。在下面的内容在,我们开发了一种几乎可以完全实现以上功能的技术。
我们的技术方案是将默认类型放在一个基类中,然后通过派生将其重载。相比与直接指定类型参数,我们会通过辅助类(helper classes)来提供相关信息。比如我们可以将其写成这样BreadSlicer<Policy3_is<Custom>>
。由于每一个模板参数都可以表述任一条款,默认值就不能不同。或者说,在更高的层面上,每一个模板参数都是等效的:1
2
3
4
5
6
7
8
9
10
11
12template<typename PolicySetter1 = DefaultPolicyArgs,
typename PolicySetter2 = DefaultPolicyArgs,
typename PolicySetter3 = DefaultPolicyArgs,
typename PolicySetter4 = DefaultPolicyArgs>
class BreadSlicer {
using Policies = PolicySelector<PolicySetter1,
PolicySetter2,
PolicySetter3,
PolicySetter4>;
// use Policies::P1, Policies::P2, ... to refer to the various policies
...
};
剩余的挑战就是该如何设计PolicySelector模板了。必须将不同的模板参数融合进一个单独的类型,而且这个类型需要用那个没有指定默认值的类型去重载默认的类型别名成员。可以通过继承实现这一融合:1
2
3
4
5
6
7
8
9
10
11
12// PolicySelector<A,B,C,D> creates A,B,C,D as base classes
// Discriminator<> allows having even the same base class more than once
template<typename Base, int D>
class Discriminator : public Base {
};
template<typename Setter1, typename Setter2,
typename Setter3, typename Setter4>
class PolicySelector : public Discriminator<Setter1,1>,
public Discriminator<Setter2,2>,
public Discriminator<Setter3,3>,
public Discriminator<Setter4,4>
{ };
注意此处对中间的Discriminator模板的使用。其要求不同的Setter类型是类似的(不能使用多个类型相同的直接基类。而非直接基类,则可以使用和其它基类类似的类型)。
正如之前提到的,我们将全部的默认值收集到基类中:1
2
3
4
5
6
7
8// name default policies as P1, P2, P3, P4
class DefaultPolicies {
public:
using P1 = DefaultPolicy1;
using P2 = DefaultPolicy2;
using P3 = DefaultPolicy3;
using P4 = DefaultPolicy4;
};
但是,如果我们最终会从该基类继承很多次的话,需要额外小心的避免歧义。因此,此处需要确保对基类使用虚继承:1
2
3
4// class to define a use of the default policy values
// avoids ambiguities if we derive from DefaultPolicies more than once
class DefaultPolicyArgs : virtual public DefaultPolicies {
};
最后,我们也需要一些模板来重载掉那些默认的策略值:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20template<typename Policy>
class Policy1_is : virtual public DefaultPolicies {
public:
using P1 = Policy; // overriding type alias
};
template<typename Policy>
class Policy2_is : virtual public DefaultPolicies {
public:
using P2 = Policy; // overriding type alias
};
template<typename Policy>
class Policy3_is : virtual public DefaultPolicies {
public:
using P3 = Policy; // overriding type alias
};
template<typename Policy>
class Policy4_is : virtual public DefaultPolicies {
public:
using P4 = Policy; // overriding type alias};
}
有了Discriminator<>
类模板的帮助,这就会产生一种层级关系,在其中所有的模板参数都是基类。重要的一点是,所有的这些基类都有一个共同的虚基类DefaultPolicies
,也正是它定义了P1,P2,P3和P4的默认值。但是P3在某一个派生类中被重新定义了(比如在Policy3_is<>
中)。根据作用域规则,该定义会隐藏掉在基类中定义的相应定义。这样,就不会有歧义了。
在模板BreadSlicer
中,可以使用Policies::P3
的形式引用以上4中策略。比如:1
2
3
4
5
6
7
8
9template<...>
class BreadSlicer {
...
public:
void print () {
Policies::P3::doPrint();
}
...
};
桥接static和dynamic多态
本章将介绍在C++中把static多态和dynamic多态桥接起来的方式,该方式具备了各种模型的部分优点:比较小的可执行代码量,几乎全部的动态多态的编译期特性,以及(允许内置类型无缝工作的)静态多态的灵活接口。作为例子,我们将创建一个简化版的std::function<>
模板。
函数对象,指针,以及std:function<>
在给模板提供定制化行为的时候,函数对象会比较有用。比如,下面的函数模板列举了从0到某个值之间的所有整数,并将每一个值都提供给了一个已有的函数对象f:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
template<typename F>
void forUpTo(int n, F f){
for (int i = 0; i != n; ++i)
{
f(i); // call passed function f for i
}
}
void printInt(int i)
{
std::cout << i << "";
}
int main()
{
std::vector<int> values;
// insert values from 0 to 4:
forUpTo(5,
[&values](int i) {
values.push_back(i);
}
);
// print elements:
forUpTo(5, printInt); // prints 0 1 2 3 4
std::cout << "\n";
}
其中forUpTo()
函数模板适用于所有的函数对象,包括lambda,函数指针,以及任意实现了合适的operator()
运算符或者可以转换为一个函数指针或引用的类,而且每一次对forUpTo()
的使用都很可能产生一个不同的函数模板实例。上述例子中的函数模板非常小,但是如果该模板非常大的话,这些不同应用导致的实例化很可能会导致代码量的增加。
一个缓解代码量增加的方式是将函数模板转变为非模板的形式,这样就不再需要实例化。比如,我们可能会使用函数指针:1
2
3
4
5
6void forUpTo(int n, void (*f)(int))
{
for (int i = 0; i != n; ++i) {
f(i); // call passed function f for i
}
}
但是,虽然在给其传递printInt()
的时候该方式可以正常工作,给其传递lambda却会导致错误:1
2
3
4
5
6forUpTo(5, printInt); //OK: prints 0 1 2 3 4
forUpTo(5,
[&values](int i) { //ERROR: lambda not convertible to a function pointer
values.push_back(i);
}
);
标准库中的类模板std::functional<>
则可以用来实现另一种类型的forUpTo()
:1
2
3
4
5
6
7
void forUpTo(int n, std::function<void(int)> f)
{
for (int i = 0; i != n; ++i) {
f(i) // call passed function f for i
}
}
std::functional<>
的模板参数是一个函数类型,该类型体现了函数对象所接受的参数类型以及其所需要产生的返回类型,非常类似于表征了参数和返回类型的函数指针。
这一形式的forUpTo()
提供了static多态的一部分特性:适用于一组任意数量的类型(包含函数指针,lambda,以及任意实现了适当operator()
运算符的类),同时又是一个只有一种实现的非模板函数。为了实现上述功能,它使用了一种称之为类型消除(type erasure)的技术,该技术将static和dynamic多态桥接了起来。
广义函数指针
std::functional<>
类型是一种高效的、广义形式的C++函数指针,提供了与函数指针相同的基本操作:
- 在调用者对函数本身一无所知的情况下,可以被用来调用该函数。
- 可以被拷贝,move以及赋值。
- 可以被另一个(函数签名一致的)函数初始化或者赋值。
- 如果没有函数与之绑定,其状态是“null”。
但是,与C++函数指针不同的是,std::functional<>
还可以被用来存储lambda,以及其它任意实现了合适的operator()
的函数对象,所有这些情况对应的类型都可能不同。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void forUpTo(int n, FunctionPtr<void(int)> f)
{
for (int i = 0; i != n; ++i)
{
f(i); // call passed function f for i
}
}
void printInt(int i)
{
std::cout << i << "";
}
int main()
{
std::vector<int> values;
// insert values from 0 to 4:
forUpTo(5,[&values](int i) {
values.push_back(i);
});
// print elements:
forUpTo(5, printInt); // prints 0 1 2 3 4
std::cout << "\n";
}
FunctionPtr的接口非常直观的提供了构造,拷贝,move,析构,初始化,以及从任意函数对象进行赋值,还有就是要能够调用其底层的函数对象。接口中最有意思的一部分是如何在一个类模板的偏特化中对其进行完整的描述,该偏特化将模板参数(函数类型)分解为其组成部分(返回类型以及参数类型):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57// primary template:
template<typename Signature>
class FunctionPtr;
// partial specialization:
template<typename R, typename... Args>
class FunctionPtr<R(Args...)>
{
private:
FunctorBridge<R, Args...>* bridge;
public:
// constructors:
FunctionPtr() : bridge(nullptr) {
}
FunctionPtr(FunctionPtr const& other); // see functionptrcpinv.hpp
FunctionPtr(FunctionPtr& other)
: FunctionPtr(static_cast<FunctionPtr const&>(other)) {
}
FunctionPtr(FunctionPtr&& other) : bridge(other.bridge) {
other.bridge = nullptr;
}
//construction from arbitrary function objects:
template<typename F> FunctionPtr(F&& f); // see functionptrinit.hpp
// assignment operators:
FunctionPtr& operator=(FunctionPtr const& other) {
FunctionPtr tmp(other);
swap(*this, tmp);
return *this;
}
FunctionPtr& operator=(FunctionPtr&& other) {
delete bridge;
bridge = other.bridge;
other.bridge = nullptr;
return *this;
}
//construction and assignment from arbitrary function objects:
template<typename F> FunctionPtr& operator=(F&& f) {
FunctionPtr tmp(std::forward<F>(f));
swap(*this, tmp);
return *this;
}
// destructor:
~FunctionPtr() {
delete bridge;
}
friend void swap(FunctionPtr& fp1, FunctionPtr& fp2) {
std::swap(fp1.bridge, fp2.bridge);
}
explicit operator bool() const {
return bridge == nullptr;
}
// invocation:
R operator()(Args... args) const; // see functionptr-cpinv.hpp
};
该实现包含了唯一一个非static的成员变量,bridge,它将负责被存储函数对象的储存和维护。该指针的所有权被绑定到了一个FunctionPtr的对象上,因此相关的大部分实现都只需要去操纵这个指针即可。
桥接接口
FunctorBridge类模板负责持有以及维护底层的函数对象,它被实现为一个抽象基类,为FunctionPtr的动态多态打下基础:1
2
3
4
5
6
7
8
9template<typename R, typename... Args>
class FunctorBridge
{
public:
virtual ~FunctorBridge() {
}
virtual FunctorBridge* clone() const = 0;
virtual R invoke(Args... args) const = 0;
};
FunctorBridge
通过虚函数提供了用来操作被存储函数对象的必要操作:一个析构函数,一个用来执行copy
的clone()
操作,以及一个用来调用底层函数对象的invoke()
操作。不要忘记将clone()
和invoke()
声明为const的成员函数。
有了这些虚函数,就可以继续实现拷贝构造函数和函数调用运算符了:1
2
3
4
5
6
7
8
9
10
11
12
13template<typename R, typename... Args>
FunctionPtr<R(Args...)>::FunctionPtr(FunctionPtr const& other)
: bridge(nullptr)
{
if (other.bridge) {
bridge = other.bridge->clone();
}
}
template<typename R, typename... Args>
R FunctionPtr<R(Args...)>::operator()(Args&&... args) const
{
return bridge->invoke(std::forward<Args>(args)...);
}
类型擦除(Type Erasure)
FunctorBridge
的每一个实例都是一个抽象类,因此其虚函数功能的具体实现是由派生类负责的。为了支持所有可能的函数对象(一个无界集合),我们可能会需要无限多个派生类。幸运的是,我们可以通过用其所存储的函数对象的类型对派生类进行参数化:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15template<typename Functor, typename R, typename... Args>
class SpecificFunctorBridge : public FunctorBridge<R, Args...> {
Functor functor;
public:
template<typename FunctorFwd>
SpecificFunctorBridge(FunctorFwd&& functor)
: functor(std::forward<FunctorFwd>(functor)) {
}
virtual SpecificFunctorBridge* clone() const override {
return new SpecificFunctorBridge(functor);
}
virtual R invoke(Args&&... args) const override {
return functor(std::forward<Args>(args)...);
}
};
每一个SpecificFunctorBridge
的实例都存储了函数对象的一份拷贝(类型为Functor),它可以被调用,拷贝,以及销毁(通过隐式调用析构函数)。SpecificFunctorBridge
实例会在FunctionPtr
被实例化的时候顺带产生,FunctionPtr
的剩余实现如下:1
2
3
4
5
6
7
8
9template<typename R, typename... Args>
template<typename F>
FunctionPtr<R(Args...)>::FunctionPtr(F&& f)
: bridge(nullptr)
{
using Functor = std::decay_t<F>;
using Bridge = SpecificFunctorBridge<Functor, R, Args...>;
bridge = new Bridge(std::forward<F>(f));
}
注意,此处由于FunctionPtr
的构造函数本身也被函数对象类型模板化了,该类型只为SpecificFunctorBridge
的特定偏特化版本(以Bridge类型别名表述)所知。一旦新开辟的Bridge实例被赋值给数据成员bridge,由于从派生类到基类的转换(Bridge* --> FunctorBridge<R,
Args...>*
),特定类型F的额外信息将会丢失。类型信息的丢失,解释了为什么名称“类型擦除”经常被用于描述用来桥接static和dynamic多态的技术。
该实现的一个特点是在生成Functor的类型的时候使用了std::decay,这使得被推断出来的类型F可以被存储,比如它会将指向函数类型的引用decay成函数指针类型,并移除了顶层const,volatile和引用。
可选桥接(Optional Bridging)
上述FunctionPtr实现几乎可以被当作一个函数指针的非正式替代品适用。但是它并没有提供对下面这一函数指针操作的支持:检测两个FunctionPtr的对象是否会调用相同的函数。为了实现这一功能,需要在FunctorBridge
中加入equals操作:1
virtual bool equals(FunctorBridge const* fb) const = 0;
在SpecificFunctorBridge
中的具体实现如下:1
2
3
4
5
6
7
8
9
10virtual bool equals(FunctorBridge<R, Args...> const* fb) const override
{
if (auto specFb = dynamic_cast<SpecificFunctorBridge const*> (fb))
{
return functor == specFb->functor;
}
//functors with different types are never equal:
return false;
}
最后可以为FunctionPtr
实现operator==
,它会先检查对应内容是否是null,然后将比较委托给FunctorBridge
:1
2
3
4
5
6
7
8
9
10
11friend bool
operator==(FunctionPtr const& f1, FunctionPtr const& f2) {
if (!f1 || !f2) {
return !f1 && !f2;
}
return f1.bridge->equals(f2.bridge);
}
friend bool
operator!=(FunctionPtr const& f1, FunctionPtr const& f2) {
return !(f1 == f2);
}
该实现是正确的,但是不幸的是,它也有一个缺点:如果FunctionPtr
被一个没有实现合适的operator==
的函数对象(比如lambdas)赋值,或者是被这一类对象初始化,那么这个程序会遇到编译错误。这可能会很让人意外,因为FunctionPtrs
的operator==
可能根本就没有被使用,却遇到了编译错误。而诸如std::vector
之类的模板,只要它们的operator==
没有被使用,它们就可以被没有相应operator==
的类型实例化。
这一operator==
相关的问题是由类型擦除导致的:因为在给FunctionPtr
赋值或者初始化的时候,我们会丢失函数对象的类型信息,因此在赋值或者初始化完成之前,就需要捕捉到所有所需要知道的该类型的信息。该信息就包含调用函数对象的operator==
所需要的信息,因为我们并不知道它在什么时候会被用到。
幸运的是,我们可以使用基于SFINAE的萃取技术,在调用operator==
之前,确认它是否可用,如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
class IsEqualityComparable
{
private:
// test convertibility of == and ! == to bool:
static void* conv(bool); // to check convertibility to bool
template<typename U>
static std::true_type test(decltype(conv(std::declval<U
const&>() == std::declval<U const&>())),
decltype(conv(!(std::declval<U const&>() == std::declval<U
const&>()))));
// fallback:
template<typename U>
static std::false_type test(...);
public:
static constexpr bool value = decltype(test<T>(nullptr,
nullptr))::value;
};
上述IsEqualityComparable
技术使用表达式测试萃取的典型形式:两个test()
重载,其中一个包含了被封装在decltype中的用来测试的表达式,另一个通过省略号接受任意数量的参数。第一个test()
试图通过==去比较两个T const
类型的对象,然后确保两个结果都可以被隐式的转换成bool,并将可以转换为bool的结果传递给operator!=()
。如果两个运算符都正常的话,参数类型都将是void *
。
使用IsEqualityComparable
,可以构建一个TryEquals
类模板,它要么会调用==
运算符,要么就在没有可用的operator==
的时候抛出一个异常:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename T, bool EqComparable =
IsEqualityComparable<T>::value>
struct TryEquals
{
static bool equals(T const& x1, T const& x2) {
return x1 == x2;
}
};
class NotEqualityComparable : public std::exception
{ };
template<typename T>
struct TryEquals<T, false>
{
static bool equals(T const& x1, T const& x2) {
throw NotEqualityComparable();
}
}
最后,通过在SpecificFunctorBridge
中使用TryEquals
,当被存储的函数对象类型一致,而且支持operator==
的时候,就可以在FunctionPtr
中提供对operator==
的支持:1
2
3
4
5
6
7
8virtual bool equals(FunctorBridge<R, Args...> const* fb) const override
{
if (auto specFb = dynamic_cast<SpecificFunctorBridge const*>(fb)) {
return TryEquals<Functor>::equals(functor, specFb->functor);
}
//functors with different types are never equal:
return false;
}
性能考量
类型擦除技术提供了static和dynamic多态的一部分优点,但是并不是全部。尤其是,使用类型擦除技术产生的代码的性能更接近于动态多态,因为它们都是用虚函数实现了动态分配。因此某些static多态的传统优点(比如编译期将函数调用进行inline的能力)可能就被丢掉了。这一性能损失是否能够被察觉到,取决于具体的应用,但是通过比较被调用函数的运算量以及相关虚函数的运算量,有时候也很容易就能判断出来:如果二者比较接近,(比如FunctionPtr所作的只是对两个整数进行求和),类型擦除可能会比static多态要满很多。而如果函数调用执行的任务量比较大的话(比如访问数据库,对容器进行排列),那么type erasure带来的性能损失就很难被察觉到。
元编程
现代C++元编程的现状
C++元编程是随着时间发展逐渐成形的。我们先来分类讨论多种在现代C++中经常使用的元
编程方法。
值元编程(Value Metaprogramming)
在C++14中,一个在编译期计算平方根的函数可以被简单的写成这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24template<typename T>
constexpr T sqrt(T x)
{
// handle cases where x and its square root are equal as a special case to simplify
// the iteration criterion for larger x:
if (x <= 1) {
return x;
}
// repeatedly determine in which half of a [lo, hi] interval the square root of x is located,
// until the interval is reduced to just one value:
T lo = 0, hi = x;
for (;;) {
auto mid = (hi+lo)/2, midSquared = mid*mid;
if (lo+1 >= hi || midSquared == x) {
// mid must be the square root:
return mid;
}//continue with the higher/lower half-interval:
if (midSquared < x) {
lo = mid;
} else {
hi = mid;
}
}
}
该算法通过反复地选取一个包含x的平方根的中间值来计算结果(为了让收敛标准比较简单,对0和1的平方根做了特殊处理)。该sqrt()
函数可以被在编译期或者运行期间计算:1
2
3
4
5static_assert(sqrt(25) == 5, ""); //OK (evaluated at compile time)
static_assert(sqrt(40) == 6, ""); //OK (evaluated at compile time)
std::array<int, sqrt(40)+1> arr; //declares array of 7 elements (compile time)
long long l = 53478;
std::cout << sqrt(l) << "\n"; //prints 231 (evaluated at run time)
在运行期间这一实现方式可能不是最高效的(在这里去开发机器的各种特性通常是值得的),但是由于该函数意在被用于编译期计算,绝对的效率并没有可移植性重要。
上面介绍的值元编程(比如在编译期间计算某些数值)偶尔会非常有用,但是在现代C++中还有另外两种可用的元编程方式(在C++14和C++17中):类型元编程和混合元编程。
类型元编程
考虑如下例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// primary template: in general we yield the given type:
template<typename T>
struct RemoveAllExtentsT {
using Type = T;
};
// partial specializations for array types (with and without bounds):
template<typename T, std::size_t SZ>
struct RemoveAllExtentsT<T[SZ]> {
using Type = typename RemoveAllExtentsT<T>::Type;
};
template<typename T>
struct RemoveAllExtentsT<T[]> {
using Type = typename RemoveAllExtentsT<T>::Type;
};
template<typename T>
using RemoveAllExtents = typename RemoveAllExtentsT<T>::Type;
这里RemoveAllExtents
就是一种类型元函数(比如一个返回类型的计算设备),它会从一个类型中移除掉任意数量的顶层“数组层”。就像下面这样:1
2
3
4RemoveAllExtents<int[]> // yields int
RemoveAllExtents<int[5][10]> // yields int
RemoveAllExtents<int[][10]> // yields int
RemoveAllExtents<int(*)[5]> // yields int(*)[5]
元函数通过偏特化来匹配高层次的数组,递归地调用自己并最终完成任务。如果数值计算的功能只适用于标量,那么其应用会很受限制。幸运的是,几乎有所得语言都至少有一种数值容器,这可以大大的提高该语言的能力。对于元编程也是这样:增加一个“类型容器”会大大的提高其自身的适用范围。幸运的是,现代C++提供了可以用来开发类似容器的机制。
混合元编程
通过使用数值元编程和类型元编程,可以在编译期间计算数值和类型。但是最终我们关心的还是在运行期间的效果,因此在运行期间的代码中,我们将元程序用在那些需要类型和常量的地方。不过元编程能做的不仅仅是这些:我们可以在编译期间,以编程的方式组合一些有运行期效果的代码。我们称之为混合元编程。
下面通过一个简单的例子来说明这一原理:计算两个std::array
的点乘结果。回忆一下,std::array
是具有固定长度的容器模板,其声明如下:1
2
3namespace std {
template<typename T, size_t N> struct array;
}
其中N
是std::array
的长度。假设有两个类型相同的std::array
对象,其点乘结果可以通过如下方式计算:1
2
3
4
5
6
7
8
9template<typename T, std::size_t N>
auto dotProduct(std::array<T, N> const& x, std::array<T, N> const& y)
{
T result{};
for (std::size_t k = 0; k<N; ++k) {
result += x[k]*y[k];
}
return result;
}
如果对for循环进行直接编译的话,那么就会生成分支指令,相比于直接运行如下命令,这在一些机器上可能会增加运行成本:1
2
3
4
5result += x[0]*y[0];
result += x[1]*y[1];
result += x[2]*y[2];
result += x[3]*y[3];
...
幸运的是,现代编译器会针对不同的平台做出相应的最为高效的优化。但是为了便于讨论,下面重新实现一版不需要loop的dotProduct()
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19template<typename T, std::size_t N>
struct DotProductT {
static inline T result(T* a, T* b)
{
return *a * *b + DotProduct<T, N-1>::result(a+1,b+1);
}
};
// partial specialization as end criteria
template<typename T>
struct DotProductT<T, 0> {
static inline T result(T*, T*) {
return T{};
}
};
template<typename T, std::size_t N>
auto dotProduct(std::array<T, N> const& x, std::array<T, N> const& y)
{
return DotProductT<T, N>::result(x.begin(), y.begin());
}
新的实现将计算放在了类模板DotProductT
中。这样做的目的是为了使用类模板的递归实例化来计算结果,并能够通过部分特例化来终止递归。注意例子中DotProductT
的每一次实例化是如何计算点乘中的一项结果、以及所有剩余结果的。对于std::arrat<T,N>
,会对主模板进行N次实例化,对部分特例化的模板进行一次实例化。为了保证效率,编译期需要将每一次对静态成员函数result()
的调用内联(inline)。
这段代码的主要特点是它融合了编译期计算(这里通过递归的模板实例化实现,这决定了代码的整体结构)和运行时计算(通过调用result()
,决定了具体的运行期间的效果)。我们之前提到过,“类型容器”可以大大提高元编程的能力。我们同样看到固定长度的array在混合元编程中也非常有用。但是混合元编程中真正的“英雄容器”是tuple(元组)。Tuple是一串数值,且其中每个值的类型可以分别指定。C++标准库中包含了支持这一概念的类模板std::tuple
。比如:1
std::tuple<int, std::string, bool> tVal{42, "Answer", true};
定义的变量tVal包含了三个类型分别为int, std::string和bool的值。因为tuple这一类容器在现代C++编程中非常重要。tVal
的类型和下面
这个简单的struct类型非常类似:1
2
3
4
5struct MyTriple {
int v1;
std::string v2;
bool v3;
};
既然对于array类型和(简单)的struct类型,我们有比较灵活的std::array
和std::tuple
与之对应,那么你可能会问,与简单的union对应的类似类型是否对混合元编程也很有益。答案是“yes”。C++标准库在C++17中为了这一目的引入了std::variant
模板。
由于std::tuple
和`std::variant都是异质类型(与struct类似),使用这些类型的混合元编程有时也被称为“异质元编程”。
将混合元编程用于“单位类型”
另一个可以展现混合元编程威力的例子是那些实现了不同单位类型的数值之间计算的库。相应的数值计算发生在程序运行期间,而单位计算则发生在编译期间。
下面会以一个极度精简的例子来做讲解。我们将用一个基于主单位的分数来记录相关单位。比如如果时间的主单位是秒,那么就用1/1000表示1微秒,用60/1表示一分钟。因此关键点就是要定义一个比例类型,使得每一个数值都有其自己的类型:1
2
3
4
5
6template<unsigned N, unsigned D = 1>
struct Ratio {
static constexpr unsigned num = N; // numerator
static constexpr unsigned den = D; // denominator
using Type = Ratio<num, den>;
};
现在就可以定义在编译期对两个单位进行求和之类的计算:1
2
3
4
5
6
7
8
9
10
11
12
13// implementation of adding two ratios:
template<typename R1, typename R2>
struct RatioAddImpl
{
private:
static constexpr unsigned den = R1::den * R2::den;
static constexpr unsigned num = R1::num * R2::den + R2::num * R1::den;
public:
typedef Ratio<num, den> Type;
};
// using declaration for convenient usage:
template<typename R1, typename R2>
using RatioAdd = typename RatioAddImpl<R1, R2>::Type;
这样就可以在编译期计算两个比率之和了:1
2
3
4
5
6using R1 = Ratio<1,1000>;
using R2 = Ratio<2,3>;
using RS = RatioAdd<R1,R2>; //RS has type Ratio<2003,2000>
std::cout << RS::num << "/"<< RS::den << "\n"; //prints 2003/3000
using RA = RatioAdd<Ratio<2,3>,Ratio<5,7>>; //RA has type Ratio<29,21>
std::cout << RA::num << "/"<< RA::den << "\n"; //prints 29/21
然后就可以为时间段定义一个类模板,用一个任意数值类型和一个Ratio<>
实例化之后的类型作为其模板参数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// duration type for values of type T with unit type U:
template<typename T, typename U = Ratio<1>>
class Duration {
public:
using ValueType = T;
using UnitType = typename U::Type;
private:
ValueType val;
public:
constexpr Duration(ValueType v = 0)
: val(v) {
}
constexpr ValueType value() const {
return val;
}
};
比较有意思的地方是对两个Durations
求和的operator+
运算符的定义:1
2
3
4
5
6
7
8
9
10
11
12
13// adding two durations where unit type might differ:
template<typename T1, typename U1, typename T2, typename U2>
auto constexpr operator+(Duration<T1, U1> const& lhs, Duration<T2, U2> const& rhs)
{
// resulting type is a unit with 1 a nominator and
// the resulting denominator of adding both unit type fractions
using VT = Ratio<1,RatioAdd<U1,U2>::den>;
// resulting value is the sum of both values
// converted to the resulting unit type:
auto val = lhs.value() * VT::den / U1::den * U1::num +
rhs.value() * VT::den / U2::den * U2::num;
return Duration<decltype(val), VT>(val);
}
这里参数所属的单位类型可以不同,比如分别为U1和U2。然后可以基于U1和U2计算最终的时间段,其类型为一个新的分子为1的单位类型。基于此,可以编译如下代码:1
2
3
4
5
6int x = 42;
int y = 77;
auto a = Duration<int, Ratio<1,1000>>(x); // x milliseconds
auto b = Duration<int, Ratio<2,3>>(y); // y 2/3 seconds
auto c = a + b; //computes resulting unit type 1/3000 seconds
//and generates run-time code for c = a*3 + b*2000
此处“混合”的效果体现在,在计算c的时候,编译器会在编译期决定结果的单位类型Ratio<1,3000>
,并产生出可以在程序运行期间计算最终结果的代码(结果会被根据单位类型进行调整)。
由于数值类型是由模板参数决定的,因此可以将int甚至是异质类型用于Duration类:1
2
3
4auto d = Duration<double, Ratio<1,3>>(7.5); // 7.5 1/3 seconds
auto e = Duration<int, Ratio<1>>(4); // 4 seconds
auto f = d + e; //computes resulting unit type 1/3 seconds
// and generates code for f = d + e*3
而且如果相应的数值在编译期是已知的话,编译器甚至可以在编译期进行以上计算(因为上文中的operator+
是constexpr
)。
反射元编程的维度
上文中介绍了基于constexpr的“值元编程”和基于递归实例化的“类型元编程”。这两种在现代C++中可用的选项采用了明显不同的方式来驱动计算。事实证明“值元编程”也可以通过模板的递归实例化来实现,在引入C++11的constexpr函数之前,这也正是其实现方式。比如下面的代码使用递归实例化来计算一个整数的平方根:1
2
3
4
5
6
7
8
9
10
11
12
13
14// primary template to compute sqrt(N)
template<int N, int LO=1, int HI=N>
struct Sqrt {
// compute the midpoint, rounded up
static constexpr auto mid = (LO+HI+1)/2;
// search a not too large value in a halved interval
static constexpr auto value = (N<mid*mid) ?
Sqrt<N,LO,mid-1>::value : Sqrt<N,mid,HI>::value;
};
// partial specialization for the case when LO equals HI
template<int N, int M>
struct Sqrt<N,M,M> {
static constexpr auto value = M;
};
这里元函数的输入是一个非类型模板参数,而不是一个函数参数,用来追踪中间值边界的“局部变量”也是非类型模板参数。显然这个方法远不如constexpr
函数友好,但是我接下来依然会探讨这段代码是如何消耗编译器资源的。
无论如何,我们已经看到元编程的计算引擎可以有多种潜在的选择。但是计算不是唯一的一个我们应该在其中考虑相关选项的维度。一个综合的元编程解决方案应该在如下3个维度中间做选择:
- 计算维度(Compution)
- 反射维度(Reflection)
- 生成维度(Generation)
反射维度指的是以编程的方式检测程序特性的能力。生成维度指的是为程序生成额外代码的能力。
我们已经见过计算维度中的两个选项:递归实例化和constexpr计算。目前已有的类型萃取是基于模板实例化的,而且C++总是会提供额外的语言特性或者是“固有的”库元素来在编译期生成包含反射信息的类模板实例。这一方法和基于模板递归实例化进行的计算比较相似。但是不幸的是,类模板实例会占用比较多的编译器内存,而且这部分内存要直到编译结束才会被释放(否则的话编译时间会大大延长)。
递归实例化的代价
现在来分析Sqrt<>
模板。主模板是由模板参数N(被计算平方根的值)和其它两个可选参数触发的、常规的递归计算。两个可选的参数分别是结果的上限和下限。如果只用一个参数调用该模板,那么其平方根最小是1,最大是其自身。递归会按照二分查找的方式进行下去。在模板内部会计算value是在从LO到HI这个区间的上半部还是下半部。这一分支判断是通过运算符?:
实现的。如果mid2比N大,那么就继续在上半部分查找,否在就在下半部分查找。
偏特例化被用来在LO和HI的值都为M的时候结束递归,这个值也就是我们最终所要计算的结果。
实例化模板的成本并不低廉:即使是比较适中的类模板,其实例依然有可能占用数KB的内存,而且这部分被占用的内存在编译完成之前不可以被回收利用。我们先来分析一个使用了Sqrt模板的简单程序:1
2
3
4
5
6
7
8
9
int main()
{
std::cout << "Sqrt<16>::value = " << Sqrt<16>::value << "\n";
std::cout << "Sqrt<25>::value = " << Sqrt<25>::value << "\n";
std::cout << "Sqrt<42>::value = " << Sqrt<42>::value << "\n";
std::cout << "Sqrt<1>::value = " << Sqrt<1>::value << "\n";
}
表达式Sqrt<16>::value
被扩展成Sqrt<16,1,16>::value
。在模板内部,元程序按照如下方式计算Sqrt<16,1,16>::value
的值:1
2
3
4mid = (1+16+1)/2 = 9
value = (16<9*9) ? Sqrt<16,1,8>::value : Sqrt<16,9,16>::value
= (16<81) ? Sqrt<16,1,8>::value : Sqrt<16,9,16>::value
= Sqrt<16,1,8>::value
接着这个值会被以Sqrt<16,1,8>::value
的形式计算,其会被接着展开为:1
2
3
4mid = (1+8+1)/2 = 5
value = (16<5*5) ? Sqrt<16,1,4>::value : Sqrt<16,5,8>::value
= (16<25) ? Sqrt<16,1,4>::value : Sqrt<16,5,8>::value
= Sqrt<16,1,4>::value
追踪所有的实例化过程
上文中主要分析了被用来计算16的平方根的实例化过程。但是当编译期计算:(16<=8*8) ? Sqrt<16,1,8>::value : Sqrt<16,9,16>::value
的时候,它并不是只计算真正用到了的分支,同样也会计算没有用到的分支Sqrt<16,9,16>
。而且,由于代码试图通过运算符::访问最终实例化出来的类的成员,该类中所有的成员都会被实例化。也就是说Sqrt<16,9,16>
的完全实例化会导致Sqrt<16,9,12>
和Sqrt<16,13,16>
都会被完全实例化。仔细分析以上过程,会发现最终会实例化出很多的实例,数量上几乎是N的两倍。
幸运的是,有一些技术可以被用来降低实例化的数目。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// primary template for main recursive step
template<int N, int LO=1, int HI=N>
struct Sqrt {
// compute the midpoint, rounded up
static constexpr auto mid = (LO+HI+1)/2;
// search a not too large value in a halved interval
using SubT = IfThenElse<(N<mid*mid),
Sqrt<N,LO,mid-1>,
Sqrt<N,mid,HI>>;
static constexpr auto value = SubT::value;
};
// partial specialization for end of recursion criterion
template<int N, int S>
struct Sqrt<N, S, S> {
static constexpr auto value = S;
};
IfThenElse
模板被用来基于一个布尔常量在两个类型之间做选择。如果布尔型常量是true,那么会选择第一个类型,否则就选择第二个类型。一个比较重要的、需要记住的点是:为一个类模板的实例定义类型别名,不会导致C++编译器去实例化该实例。因此使用如下代码时:1
2
3using SubT = IfThenElse<(N<mid*mid),
Sqrt<N,LO,mid-1>,
Sqrt<N,mid,HI>>;
既不会完全实例化Sqrt<N,LO,mid-1>
也不会完全实例化Sqrt<N,mid,HI>
。在调用SubT::value
的时候,只有真正被赋值给SubT的那一个实例才会被完全实例化。和之前的方法相比,这会让实例化的数量和log2N成正比:当N比较大的时候,这会大大降低元程序实例化的成本。
计算完整性
从以上的Sqrt<>
的例子可以看出,一个模板元程序可能会包含以下内容:
- 状态变量:模板参数
- 循环结构:通过递归实现
- 执行路径选择:通过条件表达式或者偏特例化实现
- 整数运算
递归实例化和递归模板参数
考虑如下递归模板:1
2
3
4
5
6
7
8
9
10
11
12
13
14template<typename T, typename U>
struct Doublify {
};
template<int N>
struct Trouble {
using LongType = Doublify<typename Trouble<N-1>::LongType,
typename Trouble<N-1>::LongType>;
};
template<>
struct Trouble<0> {
using LongType = double;
};
Trouble<10>::LongType ouch;
Trouble<10>::LongType
的使用并不是简单地触发形如Trouble<9>
, Trouble<8>
, …,Trouble<0>
地递归实例化,还会用越来越复杂地类型实例化Doublify。表展示了其快速地增长方式:
类型别名 | 底层类型 |
---|---|
Trouble<0>::LongType | double |
Trouble<1>::LongType | Doublify |
Trouble<2>::LongType | Doublify |
Trouble<3>::LongType | Doublify |
就如从表23.1中看到的那样,Trouble<N>::LongType
类型的复杂度与N成指数关系。在早期C++中,这一编码方式的实现和模板签名template-id
的长度成正比。这些编译器会使用大于10,000个字符来表达Trouble<N>::LongType
。
新的C++实现使用了一种聪明的压缩技术来大大降低名称编码(比如对于Trouble<N>::LongType
,只需要用数百个字符)的增长速度。如果没有为某些模板实例生成低层级的代码,那么相关类型的名字就是不需要的,新的编译器就不会为这些类型产生名字。除此之外,其它情况都没有改善,因此在组织递归实例化代码的时候,最好不要让模板参数也嵌套递归。
枚举值还是静态常量
在早期C++中,枚举值是唯一可以用来在类的声明中、创建可用于类成员的“真正的常量”(也称常量表达式)的方式。比如通过它们可以定义Pow3元程序来计算3的指数:1
2
3
4
5
6
7
8
9
10// primary template to compute 3 to the Nth
template<int N>
struct Pow3 {
enum { value = 3 * Pow3<N-1>::value };
};
// full specialization to end the recursion
template<>
struct Pow3<0> {
enum { value = 1 };
};
在C++98标准中引入了类内静态常量初始化的概念,因此Pow3元程序可以被写成这样:1
2
3
4
5
6
7
8
9
10// primary template to compute 3 to the Nth
template<int N>
struct Pow3 {
static int const value = 3 * Pow3<N-1>::value;
};
// full specialization to end the recursion
template<>
struct Pow3<0> {
static int const value = 1;
};
但是上面代码中有一个问题:静态常量成员是左值。因此如果我们有如下函数:1
void foo(int const&);
然后我们将元程序的结果传递给它:1
foo(Pow3<7>::value);
编译器需要传递Pow3<7>::value
的地址,因此必须实例化静态成员并为之开辟内存。这样该计算就不是一个纯正的“编译期”程序了。
枚举值不是左值(也就是说它们没有地址)。因此当将其按引用传递时,不会用到静态内存。几乎等效于将被计算值按照字面值传递。因此本书第一版建议在这一类应用中使用枚举值,而不是静态常量。
不过在C++中,引入了constexpr静态数据成员,并且其使用不限于整型类型。这并没有解决上文中关于地址的问题,但是即使如此,它也是用来产生元程序结果的常规方法。其优点是,它可以有正确的类型(相对于人工的枚举类型而言),而且当用auto声明静态成员的类型时,可以对其类型进行推断。C++17则引入了inline的静态数据成员,这解决了上面提到的地址问题,而且可以和constexpr一起使用。
类型列表(Typelists)
类型列表剖析
类型列表指的是一种代表了一组类型,并且可以被模板元编程操作的类型。它提供了典型的列表操作方法:遍历列表中的元素,添加元素或者删除元素。但是类型列表和大多数运行期间的数据结构都不同(比如std::list
),它的值不允许被修改。向类型列表中添加一个元素并不会修改原始的类型列表,只是会创建一个新的、包含了原始类型列表和新添加元素的类型列表。
类型列表通常是按照类模板特例的形式实现的,它将自身的内容(包含在模板参数中的类型以及类型之间的顺序)编码到了参数包中。一种将其内容编码到参数包中的类型列表的直接实现方式如下:1
2
3template<typename... Elements>
class Typelist
{};
Typelist
中的元素被直接写成其模板参数。一个空的类型列表被写为Typelist<>
,一个只包含int的类型列表被写为Typelist<int>
。下面是一个包含了所有有符号整型的类型列表:1
2using SignedIntegralTypes =
Typelist<signed char, short, int, long, long long>;
操作这个类型列表需要将其拆分,通常的做法是将第一个元素(the head)从剩余的元素中分离(the tail)。比如Front元函数会从类型列表中提取第一个元素:1
2
3
4
5
6
7
8
9
10template<typename List>
class FrontT;
template<typename Head, typename... Tail>
class FrontT<Typelist<Head, Tail...>>
{
public:
using Type = Head;
};
template<typename List>
using Front = typename FrontT<List>::Type;
这样FrontT<SignedIntegralTypes>::Type
(或者更简洁的记作FrontT<SignedIntegralTypes>
)返回的就是signed char
。同样PopFront元函数会删除类型列表中的第一个元素。在实现上它会将类型列表中的元素分为头(head)和尾(tail)两部分,然后用尾部的元素创建一个新的Typelist特例。1
2
3
4
5
6
7
8
9template<typename List>
class PopFrontT;
template<typename Head, typename... Tail>
class PopFrontT<Typelist<Head, Tail...>> {
public:
using Type = Typelist<Tail...>;
};
template<typename List>
using PopFront = typename PopFrontT<List>::Type;
PopFront<SignedIntegralTypes>
会产生如下类型列表:1
Typelist<short, int, long, long long>
同样也可以向类型列表中添加元素,只需要将所有已经存在的元素捕获到一个参数包中,然后在创建一个包含了所有元素的TypeList特例就行:1
2
3
4
5
6
7
8
9template<typename List, typename NewElement>
class PushFrontT;
template<typename... Elements, typename NewElement>
class PushFrontT<Typelist<Elements...>, NewElement> {
public:
using Type = Typelist<NewElement, Elements...>;
};
template<typename List, typename NewElement>
using PushFront = typename PushFrontT<List, NewElement>::Type;
和预期的一样,1
PushFront<SignedIntegralTypes, bool>
会生成:1
Typelist<bool, signed char, short, int, long, long long>
类型列表的算法
基础的类型列表操作Front,PopFront和PushFront可以被组合起来实现更有意思的列表操作。比如通过将PushFront
作用于PopFront
可以实现对第一个元素的替换:1
2using Type = PushFront<PopFront<SignedIntegralTypes>, bool>;
// equivalent to Typelist<bool, short, int, long, long long>
更近一步,我们可以按照模板原函数的实现方式,实现作用于类型列表的诸如搜索、转换和反转等操作。
索引(Indexing)
类型列表的一个非常基础的操作是从列表中提取某个特定的类型。接下来我们将这一操作推广到可以提取第Nth个元素。比如,为了提取给定类型列表中的第2个元素,可以这样:1
using TL = NthElement<Typelist<short, int, long>, 2>;
这相当于将TL
作为long的别名使用。NthElement
操作的实现方式是使用一个递归的元程序遍历typelist中的元素,直到找到所需元素为止:1
2
3
4
5
6
7
8
9
10// recursive case:
template<typename List, unsigned N>
class NthElementT : public NthElementT<PopFront<List>, N-1>
{};
// basis case:
template<typename List>
class NthElementT<List, 0> : public FrontT<List>
{ };
template<typename List, unsigned N>
using NthElement = typename NthElementT<List, N>::Type;
首先来看由N = 0部分特例化出来的基本情况。这一特例化会通过返回类型列表中的第一个元素来终止递归。其方法是对FrontT<List>
进行public继承,这样FrontT<List>
作为类型列表中第一个元素的Type类型别名,就可以被作为NthElement的结果使用了。
作为模板主要部分的递归代码,会遍历类型列表。由于偏特化部分保证了N > 0
,递归部分的代码会不断地从剩余列表中删除第一个元素并请求第N-1个元素。在我们的例子中:1
NthElementT<Typelist<short, int, long>, 2>
继承自:1
NthElementT<Typelist<int, long>, 1>
而它又继承自:1
NthElementT<Typelist<long>, 0>
这里遇到了最基本的N = 0的情况,它继承自提供了最终结果Type的FrontT<Typelist<long>>
。
寻找最佳匹配
有些类型列表算法会去查找类型列表中的数据。例如可能想要找出类型列表中最大的类型(比如为了开辟一段可以存储类型列表中任意类型的内存)。这同样可以通过递归模板元程序实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21template<typename List>
class LargestTypeT;
// recursive case:
template<typename List>
class LargestTypeT
{
private:
using First = Front<List>;
using Rest = typename LargestTypeT<PopFront<List>>::Type;
public:
using Type = IfThenElse<(sizeof(First) >= sizeof(Rest)), First, Rest>;
};
// basis case:
template<>
class LargestTypeT<Typelist<>>
{
public:
using Type = char;
};
template<typename List>
using LargestType = typename LargestTypeT<List>::Type;
LargestType
算法会返回类型列表中第一个最大的类型。比如对于Typelist<bool, int, long, short>
,该算法会返回第一个大小和long相同的类型,可能是int也可能是long,取决于你的平台。
由于递归算法的使用,对LargestTypeT
的调用次数会翻倍。它使用了first/rest的概念,分三步完成任务。在第一步中,它先只基于第一个元素计算出部分结果,在本例中是将第一个元素放置到First中。接下来递归地计算类型列表中剩余部分的结果,并将结果放置在Rest中。比如对于类型列表Typelist<bool, int, long, short>
,在递归的第一步中First是bool,而Rest是该算法作用于Typelist<int, long, short>
得到的结果。最后在第三步中综合First和Rest得到最终结果。此处,IfThenElse
会选出列表中第一个元素(First)和到目前为止的最优解(Rest)中类型最大的那一个。>=的使用会倾向于选择第一个出现的最大的类型。
递归会在类型列表为空时终结。默认情况下我们将char用作哨兵类型来初始化该算法,因为任何类型都不会比char小。
注意上文中的基本情况显式的用到了空的类型列表Typelist<>
。这样有点不太好,因为它可能会妨碍到其它类型的类型列表的使用。为了解决这一问题,引入了IsEmpty
元函数,它可以被用来判断一个类型列表是否为空:1
2
3
4
5
6
7
8
9
10
11template<typename List>
class IsEmpty
{
public:
static constexpr bool value = false;
};
template<>
class IsEmpty<Typelist<>> {
public:
static constexpr bool value = true;
};
结合IsEmpty
,可以像下面这样将LargestType
实现成适用于任意支持了Front
,PopFront
和IsEmpty
的类型:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21template<typename List, bool Empty = IsEmpty<List>::value>
class LargestTypeT;
// recursive case:
template<typename List>
class LargestTypeT<List, false>
{
private:
using Contender = Front<List>;
using Best = typename LargestTypeT<PopFront<List>>::Type;
public:
using Type = IfThenElse<(sizeof(Contender) >= sizeof(Best)),Contender, Best>;
};
// basis case:
template<typename List>
class LargestTypeT<List, true>
{
public:
using Type = char;
};
template<typename List>
using LargestType = typename LargestTypeT<List>::Type;
默认的LargestTypeT
的第二个模板参数Empty
会检查一个类型列表是否为空。如果不为空,就递归地继续在剩余的列表中查找。如果为空,就会终止递归并返回作为初始结果的char。
向类型类表中追加元素
通过PushFront
可以向类型列表的头部添加一个元素,并产生一个新的类型列表。除此之外我们还希望能够像在程序运行期间操作std::list
和std::vector
那样,向列表的末尾追加一个元素。对于我们的Typelist模板,为实现支持这一功能的PushBack,只需要PushFront
做一点小的修改:1
2
3
4
5
6
7
8
9
10
11template<typename List, typename NewElement>
class PushBackT;
template<typename... Elements, typename NewElement>
class PushBackT<Typelist<Elements...>, NewElement>
{
public:
using Type = Typelist<Elements..., NewElement>;
};
template<typename List, typename NewElement>
using PushBack = typename PushBackT<List, NewElement>::Type;
不过和实现LargestType
的算法一样,可以只用Front
,PushFront
,PopFront
和IsEmpty
等基础操作实现一个更通用的PushBack算法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24template<typename List, typename NewElement, bool = IsEmpty<List>::value>
class PushBackRecT;
// recursive case:
template<typename List, typename NewElement>
class PushBackRecT<List, NewElement, false>
{
using Head = Front<List>;
using Tail = PopFront<List>;
using NewTail = typename PushBackRecT<Tail, NewElement>::Type;
public:
using Type = PushFront<Head, NewTail>;
};
// basis case:
template<typename List, typename NewElement>
class PushBackRecT<List, NewElement, true>
{
public:
using Type = PushFront<List, NewElement>;
};
// generic push-back operation:
template<typename List, typename NewElement>
class PushBackT : public PushBackRecT<List, NewElement> { };
template<typename List, typename NewElement>
using PushBack = typename PushBackT<List, NewElement>::Type;
PushBackRecT
会自行管理递归。对于最基本的情况,用PushFront
将NewElement
添加到空的类型列表中。递归部分的代码则要有意思的多:它首先将类型列表分成首元素(Head)和一个包含了剩余元素的新的类型列表(Tail)。新元素则被追加到Tail的后面,这样递归的进行下去,就会生成一个NewTail。然后再次使用PushFront
将Head添加到NewTail的头部,生成最终的类型列表。接下来以下面这个简单的例子为例展开递归的调用过程:1
PushBackRecT<Typelist<short, int>, long>
在最外层的递归代码中,Head会被解析成short,Tail则被解析成Typelist<int>
。然后递归到:1
PushBackRecT<Typelist<int>, long>
其中Head会被解析成int,Tail则被解析成Typelist<>
。然后继续递归计算:1
PushBackRecT<Typelist<>, long>
这会触发最基本的情况并返回PushFront<Typelist<>, long>
,其结果是Typelist<long>
。然后返回上一层递归,将之前的Head添加到返回结果的头部:1
PushFront<int, Typelist<long>>
它会返回Typelist<int, long>
。然后继续返回上一层递归,将最外层的Head(short)添加到返回结果的头部:1
PushFront<short, Typelist<int, long>>
然后就得到了最终的结果:1
Typelist<short, int, long>
通用版的PushBackRecT
适用于任何类型的类型列表。计算过程中它需要的模板实例的数量和类型列表的长度N成正比(如果类型列表的长度为N,那么PushBackRecT
实例和PushFrontT
实例的数目都是N+1,FrontT
和PopFront
实例的数量为N)。
类型列表的反转
当类型列表的元素之间有某种顺序的时候,对于某些算法而言,如果能够反转该顺序的话,事情将会变得很方便。比如SignedIntegralTypes
中元素是按整型大小的等级递增的。但是对其元素反转之后得到的Typelist<long, long, long, int, short, signed char>
可能会更有用。下面的Reverse算法实现了相应的元函数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15template<typename List, bool Empty = IsEmpty<List>::value>
class ReverseT;
template<typename List>
using Reverse = typename ReverseT<List>::Type;
// recursive case:
template<typename List>
class ReverseT<List, false>:public PushBackT<Reverse<PopFront<List>>,
Front<List>> { };
// basis case:
template<typename List>
class ReverseT<List, true>{
public:
using Type = List;
};
该元函数的基本情况是一个作用于空的类型列表的函数。递归的情况则将类型列表分割成第一个元素和剩余元素两部分。比如对于Typelist<short, int, long>
,递归过程会先将第一个元素(short)从剩余元素(Typelist<int, long>
)中分离开。然后递归得反转
列表中剩余的元素(生成Typelist<long, int>
),最后通过调用PushBackT
将首元素追加到被反转的列表的后面(生成Typelist<long, int, short>
)。
结合Reverse,可以实现移除列表中最后一个元素的PopBackT
操作:1
2
3
4
5
6
7template<typename List>
class PopBackT {
public:
using Type = Reverse<PopFront<Reverse<List>>>;
};
template<typename List>
using PopBack = typename PopBackT<List>::Type;
该算法先反转整个列表,然后删除首元素并将剩余列表再次反转,从而实现删除末尾元素的目的。
类型列表的转换
之前介绍的类型列表的相关算法允许我们从类型列表中提取任意元素,在类型列表中做查找,构建新的列表以及反转列表。但是我们还需要对类型列表中的元素执行一些其它的操作。比如可能希望对类型列表中的所有元素做某种转换,例如通过AddConst给列表中的元素加上const修饰符:1
2
3
4
5
6
7template<typename T>
struct AddConstT
{
using Type = T const;
};
template<typename T>
using AddConst = typename AddConstT<T>::Type;
为了实现这一目的,相应的算法应该接受一个类型列表和一个元函数作为参数,并返回一个将该元函数作用于类型列表中每个元素之后,得到的新的类型列表。比如:1
Transform<SignedIntegralTypes, AddConstT>
返回的是一个包含了signed char const
,short const
,int const
,long const
,long long const
的类型列表。元函数被以模板参数模板的形式提供,它负责将一种类型转换为另一种类型。Transform算法本身和预期的一样是一个递归算法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17template<typename List, template<typename T> class MetaFun, bool Empty = IsEmpty<List>::value>
class TransformT;
// recursive case:
template<typename List, template<typename T> class MetaFun>
class TransformT<List, MetaFun, false>
: public PushFrontT<typename TransformT<PopFront<List>,
MetaFun>::Type, typename MetaFun<Front<List>>::Type>
{};
// basis case:
template<typename List, template<typename T> class MetaFun>
class TransformT<List, MetaFun, true>
{
public:
using Type = List;
};
template<typename List, template<typename T> class MetaFun>
using Transform = typename TransformT<List, MetaFun>::Type;
此处的递归情况虽然句法比较繁琐,但是依然很直观。最终转换的结果是第一个元素的转换结果,加上对剩余元素执行执行递归转换后的结果。
类型列表的累加(Accumulating Typelists)
转换(Transform)算法在需要对类型列表中的元素做转换时很有帮助。通常将它和累加(Accumulate)算法一起使用,它会将类型列表中的所有元素组合成一个值。Accumulate
算法以一个包含元素T1
,T2
,…,TN
的类型列表T
,一个初始类型I
,和一个接受两个类型作为参数的元函数F
为参数,并最终返回一个类型。它的返回值是F (F (F (...F(I, T1), T2), ..., TN−1), TN )
,其中在第ith步,F
将作用于前i-1步的结果以及Ti
。
取决于具体的类型列表,F的选择以及初始值I的选择,可以通过Accumulate
产生各种不同的输出。比如如果F可以被用来在两种类型中选择较大的那一个,Accumulate
的行为就和LargestType
差不多。而如果F接受一个类型列表和一个类型作为参数,并且将类型追加到类型列表的后面,其行为又和Reverse
算法差不多。Accumulate
的实现方式遵循了标准的递归元编程模式:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18template<typename List, template<typename X, typename Y> class F, typename I, bool = IsEmpty<List>::value>
class AccumulateT;
// recursive case:
template<typename List, template<typename X, typename Y> class F, typename I>
class AccumulateT<List, F, I, false> : public AccumulateT<PopFront<List>, F, typename F<I, Front<List>>::Type>
{};
// basis case:
template<typename List,
template<typename X, typename Y> class F, typename I>
class AccumulateT<List, F, I, true>
{
public:
using Type = I;
};
template<typename List,
template<typename X, typename Y> class F,
typename I>
using Accumulate = typename AccumulateT<List, F, I>::Type;
这里初始类型I也被当作累加器使用,被用来捕捉当前的结果。因此当递归到类型列表末尾的时候,递归循环的基本情况会返回这个结果。在递归情况下,算法将F作用于之前的结果(I)以及当前类型列表的首元素,并将F的结果作为初始类型继续传递,用于下一级对剩余列表的求和(Accumulating)。
有了Accumulate
,就可以通过将PushFrontT
作为元函数F,将空的类型列表(TypeList<T>
)作为初始类型I,反转一个类型列表:1
2using Result = Accumulate<SignedIntegralTypes, PushFrontT, Typelist<>>;
// produces TypeList<long long, long, int, short, signed char>
如果要实现基于Accumulate
的LargestType
(称之为LargestTypeAcc
),还需要做一些额外的工作,因为首先要实现一个返回两种类型中类型较大的那一个的元函数:1
2
3
4
5
6
7
8
9
10
11template<typename T, typename U>
class LargerTypeT
: public IfThenElseT<sizeof(T) >= sizeof(U), T, U>
{ };
template<typename Typelist>
class LargestTypeAccT : public AccumulateT<PopFront<Typelist>, LargerTypeT,
Front<Typelist>>
{ };
template<typename Typelist>
using LargestTypeAcc = typename LargestTypeAccT<Typelist>::Type;
值得注意的是,由于这一版的LargestType
将类型列表的第一个元素当作初始类型,因此其输入不能为空。我们可以显式地处理空列表的情况,要么是返回一个哨兵类型(char或者void),要么让该算法很好的支持SFINASE:1
2
3
4
5
6
7
8
9
10
11
12
13
14template<typename T, typename U>
class LargerTypeT : public IfThenElseT<sizeof(T) >= sizeof(U), T, U>
{ };
template<typename Typelist, bool = IsEmpty<Typelist>::value>
class LargestTypeAccT;
template<typename Typelist>
class LargestTypeAccT<Typelist, false> : public AccumulateT<PopFront<Typelist>, LargerTypeT,
Front<Typelist>>
{ };
template<typename Typelist>
class LargestTypeAccT<Typelist, true>
{ };
template<typename Typelist>
using LargestTypeAcc = typename LargestTypeAccT<Typelist>::Type;
Accumulate
是一个非常强大的类型列表算法,利用它可以实现很多种操作,因此可以将其看作类型列表操作相关的基础算法。
插入排序
作为最后一个类型列表相关的算法,我们来介绍插入排序。和其它算法类似,其递归过程会将类型列表分成第一个元素(Head)和剩余的元素(Tail)。然后对Tail进行递归排序,并将Head插入到排序后的类型列表中的合适的位置。该算法的实现如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20template<typename List, template<typename T, typename U> class Compare, bool = IsEmpty<List>::value>
class InsertionSortT;
template<typename List, template<typename T, typename U> class Compare>
using InsertionSort = typename InsertionSortT<List, Compare>::Type;
// recursive case (insert first element into sorted list):
template<typename List, template<typename T, typename U> class Compare>
class InsertionSortT<List, Compare, false>
: public InsertSortedT<InsertionSort<PopFront<List>, Compare>,
Front<List>, Compare>
{};
// basis case (an empty list is sorted):
template<typename List, template<typename T, typename U> class Compare>
class InsertionSortT<List, Compare, true>
{
public:
using Type = List;
};
在对类型列表进行排序时,参数Compare被用来作比较。它接受两个参数并通过其value成员返回一个布尔值。将其用来处理空列表的情况会稍嫌繁琐。
插入排序算法的核心时元函数InsertSortedT
,它将一个值插入到一个已经排序的列表中(插入到第一个可能的位置)并保持列表依然有序:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
template<typename List, typename Element, template<typename T, typename U> class Compare, bool =
IsEmpty<List>::value>
class InsertSortedT;
// recursive case:
template<typename List, typename Element, template<typename T,
typename U> class Compare>
class InsertSortedT<List, Element, Compare, false>
{
// compute the tail of the resulting list:
using NewTail = typename IfThenElse<Compare<Element,
Front<List>>::value, IdentityT<List>,
InsertSortedT<PopFront<List>,
Element, Compare>>::Type;
// compute the head of the resulting list:
using NewHead = IfThenElse<Compare<Element, Front<List>>::value,
Element, Front<List>>;
public:
using Type = PushFront<NewTail, NewHead>;
};
// basis case:
template<typename List, typename Element, template<typename T,
typename U> class Compare>
class InsertSortedT<List, Element, Compare, true>
: public PushFrontT<List, Element>
{};
template<typename List, typename Element,template<typename T, typename U> class Compare>
using InsertSorted = typename InsertSortedT<List, Element, Compare>::Type;
由于只有一个元素的列表是已经排好序的,因此相关代码不是很复杂。对于递归情况,基于元素应该被插入到列表头部还是剩余部分,其实现也有所不同。如果元素应该被插入到(已经排序的)列表第一个元素的前面,那么就用PushFront直接插入。否则,就将列表分成head和tail两部分,这样递归的尝试将元素插入到tail中,成功之后再用PushFront将head插入到tail的前面。
上述实现中包含了一个避免去实例化不会用到的类型的编译期优化,下面这个实现在技术上也是正确的:1
2
3
4
5template<typename List, typename Element, template<typename T,
typename U> class Compare>
class InsertSortedT<List, Element, Compare, false>
: public IfThenElseT<Compare<Element, Front<List>>::value, PushFront<List, Element>, PushFront<InsertSorted<PopFront<List>, Element, Compare>, Front<List>>>
{};
但是由于这种递归情况的实现方式会计算IfThenElseT
的两个分支(虽然只会用到一个),其效率会受到影响。在这个实现中,在IfThenElseT
的then分支中使用PushFront
的成本非常低,但是在else分支中递归地使用InsertSorted
的成本则很高。在我们的优化实现中,第一个IfThenElse
会计算出列表的tail(NewTail)。其第二和第三个参数是用来计算特定结果的元函数。Then分支中的参数使用IdentityT
来计算未被修改的List。Else分支中的参数用InsertSorted
T来计算将元素插入到已排序列表之后的结果。在较高层面上,Identity
和InsertSortedT
两者中只有一个会被实例化,因此不会有太多的额外工作。
第二个IfThenElse
会计算上面获得的list的head,其两个分支的计算代价都很低,因此都会被立即计算。最终的结果由NewHead
和NewTail
计算得到。
这一实现方案所需要的实例化数目,与被插入元素在一个已排序列表中的插入位置成正比。这表现为更高级别的插入排序属性:排序一个已经有序的列表,所需要实例化的数目和列表的长度成正比(如果已排序列表的排列顺序和预期顺序相反的话,所需要的实例化数目和列表长度的平方成正比)。
下面的程序会基于列表中元素的大小,用插入排序对其排序。比较函数使用了sizeof
运算符并比较其结果:1
2
3
4
5
6
7
8
9
10template<typename T, typename U>
struct SmallerThanT {
static constexpr bool value = sizeof(T) < sizeof(U);
};
void testInsertionSort()
{
using Types = Typelist<int, char, short, double>;
using ST = InsertionSort<Types, SmallerThanT>;
std::cout << std::is_same<ST,Typelist<char, short, int, double>>::value << "\n";
}
非类型类型列表(Nontype Typelists)
通过类型列表,有非常多的算法和操作可以用来描述并操作一串类型。某些情况下,还会希望能够操作一串编译期数值,比如多维数组的边界,或者指向另一个类型列表中的索引。有很多种方法可以用来生成一个包含编译期数值的类型列表。一个简单的办法是定义一个类模板CTValue
(compile time value),然后用它表示类型列表中某种类型的值:1
2
3
4
5template<typename T, T Value>
struct CTValue
{
static constexpr T value = Value;
};
用它就可以生成一个包含了最前面几个素数的类型列表:1
2
3using Primes = Typelist<CTValue<int, 2>, CTValue<int, 3>,
CTValue<int, 5>, CTValue<int, 7>,
CTValue<int, 11>>;
这样就可以对类型列表中的数值进行数值计算,比如计算这些素数的乘积。
首先MultiPlyT
模板接受两个类型相同的编译期数值作为参数,并生成一个新的、类型相同的编译期数值:1
2
3
4
5
6
7
8
9template<typename T, typename U>
struct MultiplyT;
template<typename T, T Value1, T Value2>
struct MultiplyT<CTValue<T, Value1>, CTValue<T, Value2>> {
public:
using Type = CTValue<T, Value1 * Value2>;
};
template<typename T, typename U>
using Multiply = typename MultiplyT<T, U>::Type;
然后结合MultiplyT
,下面的表达式就会返回所有Primes
中素数的乘积:1
Accumulate<Primes, MultiplyT, CTValue<int, 1>>::value
不过这一使用Typelist
和CTValue
的方式过于复杂,尤其是当所有数值的类型相同的时候。可以通过引入CTTypelist
模板别名来进行优化,它提供了一组包含在Typelist
中、类型相同的数值:1
2template<typename T, T... Values>
using CTTypelist = Typelist<CTValue<T, Values>...>;
这样就可以使用CTTypelist
来定义一版更为简单的Primes(素数):1
using Primes = CTTypelist<int, 2, 3, 5, 7, 11>;
这一方式的唯一缺点是,别名终归只是别名,当遇到错误的时候,错误信息可能会一直打印到CTValueTypes
中的底层Typelist
,导致错误信息过于冗长。为了解决这一问题,可以定义一个能够直接存储数值的、全新的类型列表类Valuelist
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24template<typename T, T... Values>
struct Valuelist {
};
template<typename T, T... Values>
struct IsEmpty<Valuelist<T, Values...>> {
static constexpr bool value = sizeof...(Values) == 0;
};
template<typename T, T Head, T... Tail>
struct FrontT<Valuelist<T, Head, Tail...>> {
using Type = CTValue<T, Head>;
static constexpr T value = Head;
};
template<typename T, T Head, T... Tail>
struct PopFrontT<Valuelist<T, Head, Tail...>> {
using Type = Valuelist<T, Tail...>;
};
template<typename T, T... Values, T New>
struct PushFrontT<Valuelist<T, Values...>, CTValue<T, New>> {
using Type = Valuelist<T, New, Values...>;
};
template<typename T, T... Values, T New>
struct PushBackT<Valuelist<T, Values...>, CTValue<T, New>> {
using Type = Valuelist<T, Values..., New>;
};
通过代码中提供的IsEmpty
,FrontT
,PopFrontT
和PushFrontT
,Valuelist
就可以被用于本章中介绍的各种算法了。PushBackT
被实现为一种算法的特例化,这样做可以降低编译期间该操作的计算成本。比如Valuelist
可以被用于前面定义的算法InsertionSort
:1
2
3
4
5
6
7
8
9
10
11
12
13template<typename T, typename U>
struct GreaterThanT;
template<typename T, T First, T Second>
struct GreaterThanT<CTValue<T, First>, CTValue<T, Second>> {
static constexpr bool value = First > Second;
};
void valuelisttest()
{
using Integers = Valuelist<int, 6, 2, 4, 9, 5, 2, 1, 7>;
using SortedIntegers = InsertionSort<Integers, GreaterThanT>;
static_assert(std::is_same_v<SortedIntegers, Valuelist<int, 9, 7,
6, 5, 4, 2, 2, 1>>, "insertion sort failed");
}
注意在这里可以提供一种用字面值常量来初始化CTValue的功能,比如:1
auto a = 42_c; // initializes a as CTValue<int,42>
可推断的非类型参数
在C++17中,可以通过使用一个可推断的非类型参数(结合auto)来进一步优化CTValue
的实现:1
2
3
4
5template<auto Value>
struct CTValue
{
static constexpr auto value = Value;
};
这样在使用CTValue
的时候就可以不用每次都去指定一个类型了,从而简化了使用方式:1
using Primes = Typelist<CTValue<2>, CTValue<3>, CTValue<5>, CTValue<7>, CTValue<11>>;
在C++17中也可以对Valuelist
执行同样的操作,但是结果可能不一定会变得更好。对一个非类型参数包进行类型推断时,各个参数可以不同:1
2
3
4template<auto... Values>
class Valuelist { };
int x;
using MyValueList = Valuelist<1,"a", true, &x>;
虽然这样一个列表可能也很有用,但是它和之前要求元素类型必须相同的Valuelist
已经不一样了。虽然我们也可以要求其所有元素的类型必须相同,但是对于一个空的Valuelist<>
而言,其元素类型却是未知的。
元组
本章将会讨论tuples,它采用了类似于class和struct的方式来组织数据。比如,一个包含int,double和std::string的tuple
,和一个包含int,double以及std::string类型的成员的struct类似,只不过tuple中的元素是用位置信息(比如0,1,2)索引的,而不是通过名字。元组的位置接口,以及能够容易地从typelist
构建tuple的特性,使得其相比于struct更适用于模板元编程技术。
另一种观点是将元组看作在可执行程序中,类型列表的一种表现。比如,类型列表Typelist<int, double, std::string>
,描述了一串包含了int,double和std::string的、可以在编译期间操作的类型,而Tuple<int,double, std::string>
则描述了可以在运行期间操作的、对int,double和std::string的存储。比如下面的程序就创建了这样一个tuple的实例:1
2
3
4
5template<typename... Types>
class Tuple {
... // implementation discussed below
};
Tuple<int, double, std::string> t(17, 3.14, "Hello, World!");
通常会使用模板元编程和typelist来创建用于存储数据的tuple。比如,虽然在上面的程序中随意地选择了int,double和std::string作为元素类型,我们也可以用元程序创建一组可被tuple存储的类型。
基本的元组设计
存储(Storage)
元组包含了对模板参数列表中每一个类型的存储。这部分存储可以通过函数模板get
进行访问,对于元组t
,其用法为get<I>(t)
。比如,对于之前例子中的t,get<0>(t)
会返回指向int 17
的引用,而get<1>(t)
返回的则是指向double 3.14
的引用。
元组存储的递归实现是基于这样一个思路:一个包含了N > 0个元素的元组可以被存储为一个单独的元素(元组的第一个元素,Head)和一个包含了剩余N-1个元素(Tail)的元组,对于元素为空的元组,只需当作特例处理即可。因此一个包含了三个元素的元组Tuple<int, double, std::string>
可以被存储为一个int和一个Tuple<double, std::string>
。
这个包含两个元素的元组又可以被存储为一个double和一个Tuple<std::string>
,这个只包含一个元素的元组又可以被存储为一个std::string
和一个空的元组Tuple<>
。事实上,在类型列表算法的泛型版本中也使用了相同的递归分解过程,而且实际递归元组的存储实现也
以类似的方式展开:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25template<typename... Types>
class Tuple;
// recursive case:
template<typename Head, typename... Tail>
class Tuple<Head, Tail...>
{
private:
Head head;
Tuple<Tail...> tail;
public:
// constructors:
Tuple() {
}
Tuple(Head const& head, Tuple<Tail...> const& tail): head(head), tail(tail) {
}...
Head& getHead() { return head; }
Head const& getHead() const { return head; }
Tuple<Tail...>& getTail() { return tail; }
Tuple<Tail...> const& getTail() const { return tail; }
};
// basis case:
template<>
class Tuple<> {
// no storage required
};
在递归情况下,Tuple的实例包含一个存储了列表首元素的head,以及一个存储了列表剩余元素的tail。基本情况则是一个没有存储内容的简单的空元组。而函数模板get则会通过遍历这个递归的结构来提取所需要的元素:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// recursive case:
template<unsigned N>
struct TupleGet {
template<typename Head, typename... Tail>
static auto apply(Tuple<Head, Tail...> const& t) {
return TupleGet<N-1>::apply(t.getTail());
}
};
// basis case:
template<>
struct TupleGet<0> {
template<typename Head, typename... Tail>
static Head const& apply(Tuple<Head, Tail...> const& t) {
return t.getHead();
}
};
template<unsigned N, typename... Types>
auto get(Tuple<Types...> const& t) {
return TupleGet<N>::apply(t);
}
注意,这里的函数模板get只是封装了一个简单的对TupleGet的静态成员函数调用。在不能对函数模板进行部分特例化的情况下,这是一个有效的变通方法,在这里针对非类型模板参数N进行了特例化。在N > 0的递归情况下,静态成员函数apply()
会提取出当前tuple的tail,递减N,然后继续递归地在tail中查找所需元素。对于N=0的基本情况,apply()
会返回当前tuple的head,并结束递归。
构造
除了前面已经定义的构造函数:1
2
3
4
5Tuple() {
}
Tuple(Head const& head, Tuple<Tail...> const& tail)
: head(head), tail(tail)
{ }
为了让元组的使用更方便,还应该允许用一组相互独立的值(每一个值对应元组中的一个元素)或者另一个元组来构造一个新的元组。从一组独立的值去拷贝构造一个元组,会用第一个数值去初始化元组的head,而将剩余的值传递给tail:1
2
3
4Tuple(Head const& head, Tail const&... tail)
: head(head), tail(tail...)
{
}
这样就可以像下面这样初始化一个元组了:1
Tuple<int, double, std::string> t(17, 3.14, "Hello, World!");
不过这并不是最通用的接口:用户可能会希望用移动构造(move-construct)来初始化元组的一些(可能不是全部)元素,或者用一个类型不相同的数值来初始化元组的某个元素。因此我们需要用完美转发来初始化元组:1
2
3
4template<typename VHead, typename... VTail>
Tuple(VHead&& vhead, VTail&&... vtail)
: head(std::forward<VHead>(vhead)), tail(std::forward<VTail>(vtail)...)
{ }
下面的这个实现则允许用一个元组去构建另一个元组:1
2
3
4template<typename VHead, typename... VTail>
Tuple(Tuple<VHead, VTail...> const& other)
: head(other.getHead()), tail(other.getTail())
{ }
但是这个构造函数不适用于类型转换:给定上文中的t,试图用它去创建一个元素之间类型兼容的元组会遇到错误:1
2// ERROR: no conversion from Tuple<int, double, string> to long
Tuple<long int, long double, std::string> t2(t);
这是因为上面这个调用,会更匹配用一组数值去初始化一个元组的构造函数模板,而不是用一个元组去初始化另一个元组的构造函数模板。为了解决这一问题,就需要用到std::enable_if<>
,在tail的长度与预期不同的时候就禁用相关模板:1
2
3
4
5
6
7template<typename VHead, typename... VTail, typename = std::enable_if_t<sizeof... (VTail)==sizeof... (Tail)>>
Tuple(VHead&& vhead, VTail&&... vtail)
: head(std::forward<VHead>(vhead)), tail(std::forward<VTail>(vtail)...)
{ }
template<typename VHead, typename... VTail, typename = std::enable_if_t<sizeof... (VTail)==sizeof... (Tail)>>
Tuple(Tuple<VHead, VTail...> const& other)
: head(other.getHead()), tail(other.getTail()) { }
函数模板makeTuple()
会通过类型推断来决定所生成元组中元素的类型,这使得用一组数值创建一个元组变得更加简单:1
2
3
4
5template<typename... Types>
auto makeTuple(Types&&... elems)
{
return Tuple<std::decay_t<Types>...>(std::forward<Types> (elems)...);
}
这里再一次将std::decay<>
和完美转发一起使用,这会将字符串常量和裸数组转换成指针,并去除元素的const和引用属性。比如:1
makeTuple(17, 3.14, "Hello, World!")
生成的元组的类型是:1
Tuple<int, double, char const*>
基础元组操作
比较
元组是包含了其它数值的结构化类型。为了比较两个元组,就需要比较它们的元素。因此可以像下面这样,定义一种能够逐个比较两个元组中元素的operator==
:1
2
3
4
5
6
7
8
9
10
11
12// basis case:
bool operator==(Tuple<> const&, Tuple<> const&)
{
// empty tuples are always equivalentreturn true;
}
// recursive case:
template<typename Head1, typename... Tail1,
typename Head2, typename... Tail2, typename = std::enable_if_t<sizeof...(Tail1)==sizeof...(Tail2)>>
bool operator==(Tuple<Head1, Tail1...> const& lhs, Tuple<Head2, Tail2...> const& rhs)
{
return lhs.getHead() == rhs.getHead() && lhs.getTail() == rhs.getTail();
}
和其它适用于类型列表和元组的算法类似,逐元素的比较两个元组,会先比较首元素,然后递归地比较剩余的元素,最终会调用operator的基本情况结束递归。运算符!=,<,>,以及>=的实现方式都与之类似。
输出
贯穿本章始终,我们一直都在创建新的元组类型,因此最好能够在执行程序的时候看到这些元组。下面的operator<<
运算符会打印那些元素类型可以被打印的元组:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void printTuple(std::ostream& strm, Tuple<> const&, bool isFirst = true)
{
strm << ( isFirst ? "(": ")");
}
template<typename Head, typename... Tail>
void printTuple(std::ostream& strm, Tuple<Head, Tail...> const& t, bool isFirst = true)
{
strm << ( isFirst ? "(" : ", " );
strm << t.getHead();
printTuple(strm, t.getTail(), false);
}
template<typename ... Types>
std::ostream& operator<<(std::ostream& strm, Tuple<Types...> const& t)
{
printTuple(strm, t);
return strm;
}
这样就可以很容易地创建并打印元组了。比如:1
std::cout << makeTuple(1, 2.5, std::string("hello")) << "\n";
会打印出:1
(1, 2.5, hello)
元组的算法
元组是一种提供了以下各种功能的容器:可以访问并修改其元素的能力(通过get<>
),创建新元组的能力(直接创建或者通过使用makeTuple<>
创建),以及将元组分割成head
和tail
的能力(通过使用getHead()
和getTail()
)。使用这些功能足以创建各种各样的元组算法,比如添加或者删除元组中的元素,重新排序元组中的元素,或者选取元组中元素的某些子集。
元组很有意思的一点是它既需要用到编译期计算也需要用到运行期计算。将某种算法作用与元组之后可能会得到一个类型迥异的元组,这就需要用到编译期计算。比如反转元组Tuple<int, double, string>
会得到Tuple<string, double, int>
。
但是和同质容器的算法类似(比如作用域std::vector的std::reverse()),元组算法是需要在运行期间执行代码的,因此我们需要留意被产生出来的代码的效率问题。
将元组用作类型列表
通过使用一些部分特例化,可以将Tuple
变成一个功能完整的Typelist
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29// determine whether the tuple is empty:
template<>
struct IsEmpty<Tuple<>> {
static constexpr bool value = true;
};
// extract front element:
template<typename Head, typename... Tail>
class FrontT<Tuple<Head, Tail...>> {
public:
using Type = Head;
};
// remove front element:
template<typename Head, typename... Tail>
class PopFrontT<Tuple<Head, Tail...>> {
public:
using Type = Tuple<Tail...>;
};
// add element to the front:
template<typename... Types, typename Element>
class PushFrontT<Tuple<Types...>, Element> {
public:
using Type = Tuple<Element, Types...>;
};
// add element to the back:
template<typename... Types, typename Element>
class PushBackT<Tuple<Types...>, Element> {
public:
using Type = Tuple<Types..., Element>;
};
这样就可以很方便的处理元组的类型了。比如:1
2
3
4Tuple<int, double, std::string> t1(17, 3.14, "Hello, World!");
using T2 = PopFront<PushBack<decltype(t1), bool>>;
T2 t2(get<1>(t1), get<2>(t1), true);
std::cout << t2;
会打印出:1
(3.14, Hello, World!, 1)
很快就会看到,将typelist算法用于tuple,通常是为了确定tuple算法返回值的类型。
添加以及删除元素
对于Tuple,能否向其头部或者尾部添加元素,对开发相关的高阶算法而言是很重要的。和typelist
的情况一样,向头部插入一个元素要远比向尾部插入一个元素要简单,因此我们从pushFront
开始:1
2
3
4
5
6template<typename... Types, typename V>
PushFront<Tuple<Types...>, V>
pushFront(Tuple<Types...> const& tuple, V const& value)
{
return PushFront<Tuple<Types...>, V>(value, tuple);
}
将一个新元素(称之为value)添加到一个已有元组的头部,需要生成一个新的、以value
为head、以已有tuple为tail的元组。返回结过的类型是Tuple<V, Types...>
。不过这里我们选择使用typelist
的算法PushFront
来获得返回类型,这样做可以体现出tuple算法中编译期部分和运行期部分之间的紧密耦合关系:编译期的PushFront
计算出了我们应该生成的运行期结果的类型。
将一个新元素添加到一个已有元组的末尾则会复杂得多,因为这需要遍历一个元组。1
2
3
4
5
6
7
8
9
10
11
12
13// basis case
template<typename V>
Tuple<V> pushBack(Tuple<> const&, V const& value)
{
return Tuple<V>(value);
}
// recursive case
template<typename Head, typename... Tail, typename V>
Tuple<Head, Tail..., V>
pushBack(Tuple<Head, Tail...> const& tuple, V const& value)
{
return Tuple<Head, Tail..., V>(tuple.getHead(), pushBack(tuple.getTail(), value));
}
对于基本情况,和预期的一样,会将值追加到一个长度为零的元组的后面。对于递归情况,则将元组分为head和tail两部分,然后将首元素以及将新元素追加到tail的后面得到结果组装成最终的结果。虽然这里我们使用的返回值类型是Tuple<Head, Tail..., V>
,但是它和编译期的PushBack<Tuple<Hrad, Tail...>, V>
是一样的。
同样地,popFront()
也很容易实现:1
2
3
4
5template<typename... Types>
PopFront<Tuple<Types...>> popFront(Tuple<Types...> const& tuple)
{
return tuple.getTail();
}
现在我们可以像下面这样编写例子:1
2
3Tuple<int, double, std::string> t1(17, 3.14, "Hello, World!");
auto t2 = popFront(pushBack(t1, true));
std::cout << std::boolalpha << t2 << "\n";
打印结果为:1
(3.14, Hello, World!, true)
元组的反转
元组的反转可以采用另一种递归的类型列表的反转方式实现:1
2
3
4
5
6
7
8
9
10
11// basis case
Tuple<> reverse(Tuple<> const& t)
{
return t;
}
// recursive case
template<typename Head, typename... Tail>
Reverse<Tuple<Head, Tail...>> reverse(Tuple<Head, Tail...> const& t)
{
return pushBack(reverse(t.getTail()), t.getHead());
}
基本情况比较简单,而递归情况则是递归地将head追加到反转之后的tail的后面。也就是说:
1 | reverse(makeTuple(1, 2.5, std::string("hello"))) |
会生成一个包含了string(“hello”)
,2.5,和1的类型为Tuple<string, double, int>
的元组。和类型列表类似,现在就可以简单地通过先反转元组,然后调用popFront()
,然后再次反转元组实现popBack()
:1
2
3
4template<typename... Types>
PopBack<Tuple<Types...>> popBack(Tuple<Types...> const& tuple){
return reverse(popFront(reverse(tuple)));
}
索引列表
虽然上文中反转元组用到的递归方式是正确的,但是它在运行期间的效率却非常低。为了展现这一问题,引入下面这个可以计算其实例被copy次数的类:1
2
3
4
5
6
7
8
9
10template<int N>
struct CopyCounter
{
inline static unsigned numCopies = 0;
CopyCounter()
{ }
CopyCounter(CopyCounter const&) {
++numCopies;
}
};
然后创建并反转一个包含了CopyCounter
实例的元组:1
2
3
4
5
6
7
8
9
10void copycountertest()
{
Tuple<CopyCounter<0>, CopyCounter<1>, CopyCounter<2>, CopyCounter<3>, CopyCounter<4>> copies;
auto reversed = reverse(copies);
std::cout << "0: " << CopyCounter<0>::numCopies << " copies\n";
std::cout << "1: " << CopyCounter<1>::numCopies << " copies\n";
std::cout << "2: " << CopyCounter<2>::numCopies << " copies\n";
std::cout << "3: " << CopyCounter<3>::numCopies << " copies\n";
std::cout << "4: " << CopyCounter<4>::numCopies << " copies\n";
}
这个程序会打印出:1
2
3
4
50: 5 copies
1: 8 copies
2: 9 copies
3: 8 copies
4: 5 copies
这确实进行了很多次copy!在理想的实现中,反转一个元组时,每一个元素只应该被copy一次:从其初始位置直接被copy到目的位置。我们可以通过使用引用来达到这一目的,包括对中间变量的类型使用引用,但是这样做会使实现变得很复杂。
在反转元组时,为了避免不必要的copy,考虑一下我们该如何实现一个一次性的算法,来反转一个简单的、长度已知的元组(比如包含5个元素)。可以像下面这样只是简单地使用makeTuple()
和get()
:1
2auto reversed = makeTuple(get<4>(copies), get<3>(copies), get<2>(copies),
get<1>(copies), get<0>(copies));
这个程序会按照我们预期的那样进行,对每个元素只进行一次copy:1
2
3
4
50: 1 copies
1: 1 copies
2: 1 copies
3: 1 copies
4: 1 copies
索引列表通过将一组元组的索引捕获进一个参数包,推广了上述概念,本例中的索引列表是4,3,2,1,0,这样就可以通过包展开进行一组get函数的调用。采用这种方法可以将索引列表的计算(可以采用任意复杂度的模板源程序)和使用(更关注运行期的性能)分离开。在C++14中引入的标准类型std::integer_sequence
,通常被用来表示索引列表。
元组的展开
在需要将一组相关的数值存储到一个变量中时(不管这些相关数值的数量是多少、类型是什么),元组会很有用。在某些情况下,可能会需要展开一个元组(比如在需要将其元素作为独立参数传递给某个函数的时候)。作为一个简单的例子,可能需要将一个元组的元素传递给变参print()
:1
2Tuple<std::string, char const*, int, char> t("Pi", "is roughly", 3, "\n");
print(t...); //ERROR: cannot expand a tuple; it isn"t a parameter pack
正如例子中注释部分所讲的,这个“明显”需要展开一个元组的操作会失败,因为它不是一个参数包。不过我们可以使用索引列表实现这一功能。下面的函数模板apply()接受一个函数和一个元组作为参数,然后以展开后的元组元素为参数,去调用这个函数:1
2
3
4
5
6
7
8
9
10
11template<typename F, typename... Elements, unsigned... Indices>
auto applyImpl(F f, Tuple<Elements...> const& t,
Valuelist<unsigned, Indices...>) ->decltype(f(get<Indices>(t)...))
{
return f(get<Indices>(t)...);
}
template<typename F, typename... Elements, unsigned N = sizeof...(Elements)>
auto apply(F f, Tuple<Elements...> const& t) ->decltype(applyImpl(f, t, MakeIndexList<N>()))
{
return applyImpl(f, t, MakeIndexList<N>());
}
函数模板applyImpl()
会接受一个索引列表作为参数,并用其将元组中的元素展开成一个适用于函数对象f的参数列表。而供用户直接使用的apply()
则只是负责构建初始的索引列表。这样就可以将一个元组扩展成print()
的参数了:1
2Tuple<std::string, char const*, int, char> t("Pi", "is roughly", 3, "\n");
apply(print, t); //OK: prints Pi is roughly 3
元组的优化
元组是一种基础的、潜在用途广泛的异质容器。因此有必要考虑下该怎么在运行期(存储和执行时间)和编译期(实例化的数量)对其进行优化。本节将介绍一些适用于上文中实现的元组的特定优化方案。
元组和EBCO
我们实现的元组,其存储方式所需要的存储空间,要比其严格意义上所需要的存储空间多。其中一个问题是,tail成员最终会是一个空的数值(因为所有非空的元组都会以一个空的元组作为结束),而任意数据成员又总会至少占用一个字节的内存。为了提高元组的存储效率,可以使用空基类优化(EBCO,empty base class optimization),让元组继承自一个尾元组(tail tuple),而不是将尾元组作为一个成员。比如:1
2
3
4
5
6
7
8
9
10
11
12// recursive case:
template<typename Head, typename... Tail>
class Tuple<Head, Tail...> : private Tuple<Tail...>
{
private:
Head head;
public:
Head& getHead() { return head; }
Head const& getHead() const { return head; }
Tuple<Tail...>& getTail() { return *this; }
Tuple<Tail...> const& getTail() const { return *this; }
};
这和BaseMemberPair
使用的优化方式一致。不幸的是,这种方式有其副作用,就是颠倒了元组元素在构造函数中被初始化的顺序。在之前的实现中,head成员在tail成员前面,因此head总是会先被初始化。在新的实现方式中,tail则是以基类的形式存在,因此它会在head成员之前被初始化。
这一问题可以通过将head成员放入其自身的基类中,并让这个基类在基类列表中排在tail的前面来解决。该方案的一个直接实现方式是,引入一个用来封装各种元素类型的TupleElt模板,并让Tuple继承自它:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35template<typename... Types>
class Tuple;
template<typename T>
class TupleElt
{
T value;
public:
TupleElt() = default;
template<typename U>
TupleElt(U&& other) : value(std::forward<U>(other) { }
T& get() { return value; }
T const& get() const { return value; }
};
// recursive case:
template<typename Head, typename... Tail>
class Tuple<Head, Tail...>
: private TupleElt<Head>, private Tuple<Tail...>
{
public:
Head& getHead() {
// potentially ambiguous
return static_cast<TupleElt<Head> *>(this)->get();
}
Head const& getHead() const {
// potentially ambiguous
return static_cast<TupleElt<Head> const*>(this)->get();
}
Tuple<Tail...>& getTail() { return *this; }
Tuple<Tail...> const& getTail() const { return *this; }
};
// basis case:
template<>
class Tuple<> {
// no storage required
};
虽然这一方式解决了元素初始化顺序的问题,但是却引入了一个更糟糕的问题:如果一个元组包含两个类型相同的元素(比如Tuple<int, int>
),我们将不再能够从中提取元素,因为此时从Tuple<int, int>
向TupleElt<int>
的转换(自派生类向基类的转换)不是唯一的(有歧义)。为了打破歧义,需要保证在给定的Tuple中每一个TupleElt基类都是唯一的。一个方式是将这个值的“高度”信息(也就是tail元组的长度信息)编码进元组中。元组最后一个元素的高度会被存储生0,倒数第一个元素的长度会被存储成1,以此类推:1
2
3
4
5
6
7
8
9
10template<unsigned Height, typename T>
class TupleElt {
T value;
public:
TupleElt() = default;
template<typename U>
TupleElt(U&& other) : value(std::forward<U>(other)) { }
T& get() { return value; }
T const& get() const { return value; }
};
通过这一方式,就能够实现一个即使用了EBCO优化,又能保持元素的初始化顺序,并支持包含相同类型元素的元组:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23template<typename... Types>
class Tuple;
// recursive case:
template<typename Head, typename... Tail>
class Tuple<Head, Tail...>
: private TupleElt<sizeof...(Tail), Head>, private Tuple<Tail...>
{
using HeadElt = TupleElt<sizeof...(Tail), Head>;
public:
Head& getHead() {
return static_cast<HeadElt *>(this)->get();
}
Head const& getHead() const {
return static_cast<HeadElt const*>(this)->get();
}
Tuple<Tail...>& getTail() { return *this; }
Tuple<Tail...> const& getTail() const { return *this; }
};
// basis case:
template<>
class Tuple<> {
// no storage required
};
基于这一实现,下面的程序:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct A {
A() {
std::cout << "A()" << "\n";
}
};
struct B {
B() {
std::cout << "B()" << "\n";
}
};
int main()
{
Tuple<A, char, A, char, B> t1;
std::cout << sizeof(t1) << " bytes" << "\n";
}
会打印出:1
2
3
4A()
A()
B()
5 bytes
从中可以看出,EBCO使得内存占用减少了一个字节(减少的内容是空元组Tuple<>)。但是请注意A和B都是空的类,这暗示了进一步用EBCO进行优化的可能。如果能够安全的从其元素类型继承的话,那么就让TupleElt继承自其元素类型(这一优化不需要更改Tuple的定义):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
template<unsigned Height, typename T,
bool = std::is_class<T>::value && !std::is_final<T>::value>
class TupleElt;
template<unsigned Height, typename T>
class TupleElt<Height, T, false>
{
T value;
public:
TupleElt() = default;
template<typename U>
TupleElt(U&& other) : value(std::forward<U>(other)) { }
T& get() { return value; }
T const& get() const { return value; }
};
template<unsigned Height, typename T>
class TupleElt<Height, T, true> : private T
{
public:
TupleElt() = default;
template<typename U>
TupleElt(U&& other) : T(std::forward<U>(other)) { }
T& get() { return *this; }
T const& get() const { return *this; }
};
当提供给TupleElt的模板参数是一个可以被继承的类的时候,它会从该模板参数做private继承,从而也可以将EBCO用于被存储的值。有了这些变化,之前的程序会打印出:1
2
3
4A()
A()
B()
2 bytes
常数时间的get()
在使用元组的时候,get()
操作的使用是非常常见的,但是其递归的实现方式需要用到线性次数的模板实例化,这会影响编译所需要的时间。幸运的是,基于在之前章节中介绍的EBCO,可以实现一种更高效的get,我们接下来会对其进行讨论。
主要的思路是,当用一个(基类类型的)参数去适配一个(派生类类型的)参数时,模板参数推导会为基类推断出模板参数的类型。因此,如果我们能够计算出目标元素的高度H,就可以不用遍历所有的索引,也能够基于从Tuple的特化结果向TupleElt<H,T>
(T的类型由推断得到)的转化提取出相应的元素:1
2
3
4
5
6
7
8
9
10
11
12
13template<unsigned H, typename T>
T& getHeight(TupleElt<H,T>& te)
{
return te.get();
}
template<typename... Types>
class Tuple;
template<unsigned I, typename... Elements>
auto get(Tuple<Elements...>& t) ->
decltype(getHeight<sizeof...(Elements)-I-1>(t))
{
return getHeight<sizeof...(Elements)-I-1>(t);
}
由于get<I>(t)
接收目标元素(从元组头部开始计算)的索引I作为参数,而元组的实际存储是以高度H来衡量的(从元组的末尾开始计算),因此需要用H来计算I。真正的查找工作是由调用getHeight()
时的参数推导执行的:由于H是在函数调用时显示指定的,因此它的值是确定的,这样就只会有一个TupleElt
会被匹配到,其模板参数T则是通过推断得到的。
这里必须要将getHeight()
声明Tuple
的friend,否则将无法执行从派生类向private父类的转换。比如:1
2
3
4// inside the recursive case for class template Tuple:
template<unsigned I, typename... Elements>
friend auto get(Tuple<Elements...>& t)
-> decltype(getHeight<sizeof...(Elements)-I-1>(t));
由于我们已经将繁杂的索引匹配工作转移到了编译器的模板推断那里,因此这一实现方式只需要常数数量的模板实例化。
元组下标
理论上也可以通过定义operator[]
来访问元组中的元素,这和在std::vector
中定义operator[]
的情况类似。不过和std::vector
不同的是,元组中元素的类型可以不同,因此元组的operator[]
必须是一个模板,其返回类型也需要随着索引的不同而不同。这反过来也就要求每一个索引都要有不同的类型,因为需要根据索引的类型来决定元素的类型。
使用类模板CTValue
,可以将数值索引编码进一个类型中。将其用于Tuple下标运算符定义的代码如下:1
2
3
4template<typename T, T Index>
auto& operator[](CTValue<T, Index>) {
return get<Index>(*this);
}
然后就可以基于被传递的CTValue类型的参数,用其中的索引信息去执行相关的get<>()
调用。上述代码的用法如下:1
2
3auto t = makeTuple(0, "1", 2.2f, std::string{"hello"});
auto a = t[CTValue<unsigned, 2>{}];
auto b = t[CTValue<unsigned, 3>{}];
变量a
和b
分别会被Tuple t
中的第三个和第四个参数初始化成相应的类型和数值。为了让常量索引的使用变得更方便,我们可以用constexpr
实现一种字面常量运算符,专门用来直接从以_c结尾的常规字面常量,计算出所需的编译期数值字面常量:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// convert single char to corresponding int value at compile time:
constexpr int toInt(char c) {
// hexadecimal letters:
if (c >= "A"&& c <= "F") {
return static_cast<int>(c) - static_cast<int>("A") + 10;
}
if (c >= "a"&& c <= "f") {
return static_cast<int>(c) - static_cast<int>("a") + 10;
}
// other (disable "."for floating-point literals):
assert(c >= "0"&& c <= "9");
return static_cast<int>(c) - static_cast<int>("0");
}
// parse array of chars to corresponding int value at compile time:
template<std::size_t N>
constexpr int parseInt(char const (&arr)[N]) {
int base = 10; // to handle base (default: decimal)
int offset = 0; // to skip prefixes like 0x
if (N > 2 && arr[0] == "0") {
switch (arr[1]) {
case "x": //prefix 0x or 0X, so hexadecimal
case "X":
base = 16;
offset = 2;
break;
case "b": //prefix 0b or 0B (since C++14), so binary
case "B":
base = 2;offset = 2;
break;
default: //prefix 0, so octal
base = 8;
offset = 1;
break;
}
}
// iterate over all digits and compute resulting value:
int value = 0;
int multiplier = 1;
for (std::size_t i = 0; i < N - offset; ++i) {
if (arr[N-1-i] != "\"") { //ignore separating single quotes (e.g. in 1’ 000)
value += toInt(arr[N-1-i]) * multiplier;
multiplier *= base;
}
}
return value;
}
// literal operator: parse integral literals with suffix _c as sequence of chars:
template<char... cs>
constexpr auto operator"" _c() {
return CTValue<int, parseInt<sizeof...(cs)>({cs...})>{};
}
此处我们用到了这样一个事实,对于数值字面常量,可以用字面常量运算符推导出该字面常量的每一个字符,并将其用作字面常量运算符模板的参数。然后将这些字符传递给一个constexpr
类型的辅助函数parseInt()
(它可以计算出字符串序列的值,并将其按照CTValue类型返回)。比如:
42_c
生成CTValue<int,42>
0x815_c
生成CTValue<int,2069>
0b1111’1111_c
生成CTValue<int,255>
注意该程序不会处理浮点型字面值常量。对这种情况,相应的assert语句会触发编译期错误,因为这是一个运行期的特性,不能用在编译期上下文中。基于以上内容,可以像下面这样使用元组:1
2
3auto t = makeTuple(0, "1", 2.2f, std::string{"hello"});
auto c = t[2_c];
auto d = t[3_c];