4. 深入理解特化与偏特化
4.1. 正确的理解偏特化
4.1.1. 偏特化与函数重载的比较
在前面的章节中,我们介绍了偏特化的形式、也介绍了简单的用例。因为偏特化和函数重载存在着形式上的相似性,因此初学者便会借用重载的概念,来理解偏特化的行为。只是,重载和偏特化尽管相似但仍有差异。
我们来先看一个函数重载的例子:
1 | void doWork(int); |
在这个例子中,我们展现了函数重载可以在两种条件下工作:参数数量相同、类型不同;参数数量不同。
仿照重载的形式,我们通过特化机制,试图实现一个模板的“重载”:
1 | template <typename T> struct DoWork; // (0) 这是原型 |
这个例子在字面上“看起来”并没有什么问题,可惜编译器在编译的时候仍然提示出错了goo.gl/zI42Zv:
1 | 5 : error: too many template arguments for class template 'DoWork' |
从编译出错的失望中冷静一下,在仔细看看函数特化/偏特化和一般模板的不同之处:
1 | template <typename T> class X {}; |
对,就是这个<T*>,跟在X后面的“小尾巴”,我们称作实参列表,决定了第二条语句是第一条语句的跟班。所以,第二条语句,即“偏特化”,必须要符合原型X的基本形式:那就是只有一个模板参数。这也是为什么DoWork尝试以template <> struct DoWork<int, int>的形式偏特化的时候,编译器会提示模板实参数量过多。
另外一方面,在类模板的实例化阶段,它并不会直接去寻找 template <> struct DoWork<int, int>这个小跟班,而是会先找到基本形式,template <typename T> struct DoWork;,然后再去寻找相应的特化。
我们以DoWork<int> i;为例,尝试复原一下编译器完成整个模板匹配过程的场景,帮助大家理解。看以下示例代码:
1 | template <typename T> struct DoWork; // (0) 这是原型 |
首先,编译器分析(0), (1), (2)三句,得知(0)是模板的原型,(1),(2),(3)是模板(0)的特化或偏特化。我们假设有两个字典,第一个字典存储了模板原型,我们称之为TemplateDict。第二个字典TemplateSpecDict,存储了模板原型所对应的特化/偏特化形式。所以编译器在处理这几句时,可以视作
1 | // 以下为伪代码 |
然后 (4) 试图以int实例化类模板DoWork。它会在TemplateDict中,找到DoWork,它有一个形式参数T接受类型,正好和我们实例化的要求相符合。并且此时T被推导为int。(5) 中的float*也是同理。
1 | { // 以下为 DoWork<int> 查找对应匹配的伪代码 |
那么根据上面的步骤所展现的基本原理,我们随便来几个练习:
1 | template <typename T, typename U> struct X ; // 0 |
在上面这段例子中,有几个值得注意之处。首先,偏特化时的模板形参,和原型的模板形参没有任何关系。和原型不同,它的顺序完全不影响模式匹配的顺序,它只是偏特化模式,如<U, int>中U的声明,真正的模式,是由<U, int>体现出来的。
这也是为什么在特化的时候,当所有类型都已经确定,我们就可以抛弃全部的模板参数,写出template <> struct X<int, float>这样的形式:因为所有列表中所有参数都确定了,就不需要额外的形式参数了。
其次,作为一个模式匹配,偏特化的实参列表中展现出来的“样子”,就是它能被匹配的原因。比如,struct X<T, T>中,要求模板的两个参数必须是相同的类型。而struct X<T, T*>,则代表第二个模板类型参数必须是第一个模板类型参数的指针,比如X<float***, float****>就能匹配上。当然,除了简单的指针、const和volatile修饰符,其他的类模板也可以作为偏特化时的“模式”出现,例如示例8,它要求传入同一个类型的unique_ptr和shared_ptr。C++标准中指出下列模式都是可以被匹配的:
N3337, 14.8.2.5/8
令
T是模板类型实参或者类型列表(如 int, float, double 这样的,TT是template-template实参(参见6.2节),i是模板的非类型参数(整数、指针等),则以下形式的形参都会参与匹配:
T,cv-list T,T*,template-name <T>,T&,T&&
T [ integer-constant ]
type (T),T(),T(T)
T type ::*,type T::*,T T::*
T (type ::*)(),type (T::*)(),type (type ::*)(T),type (T::*)(T),T (type ::*)(T),T (T::*)(),T (T::*)(T)
type [i],template-name <i>,TT<T>,TT<i>,TT<>
对于某些实例化,偏特化的选择并不是唯一的。比如v4的参数是<float*, float*>,能够匹配的就有三条规则,1,6和7。很显然,6还是比7好一些,因为能多匹配一个指针。但是1和6,就很难说清楚谁更好了。一个说明了两者类型相同;另外一个则说明了两者都是指针。所以在这里,编译器也没办法决定使用那个,只好报出了编译器错误。
其他的示例可以先自己推测一下, 再去编译器上尝试一番:goo.gl/9UVzje。
4.1.2. 不定长的模板参数
不过这个时候也许你还不死心。有没有一种办法能够让例子DoWork像重载一样,支持对长度不一的参数列表分别偏特化/特化呢?
答案当然是肯定的。
首先,首先我们要让模板实例化时的模板参数统一到相同形式上。逆向思维一下,虽然两个类型参数我们很难缩成一个参数,但是我们可以通过添加额外的参数,把一个扩展成两个呀。比如这样:
1 | DoWork<int, void> i; |
这时,我们就能写出统一的模板原型:
1 | template <typename T0, typename T1> struct DoWork; |
继而偏特化/特化问题也解决了:
1 | template <> struct DoWork<int, void> {}; // (1) 这是 int 类型的特化 |
显而易见这个解决方案并不那么完美。首先,不管是偏特化还是用户实例化模板的时候,都需要多撰写好几个void,而且最长的那个参数越长,需要写的就越多;其次,如果我们的DoWork在程序维护的过程中新加入了一个参数列表更长的实例,那么最悲惨的事情就会发生 —— 原型、每一个偏特化、每一个实例化都要追加上void以凑齐新出现的实例所需要的参数数量。
所幸模板参数也有一个和函数参数相同的特性:默认实参(Default Arguments)。只需要一个例子,你们就能看明白了goo.gl/TtmcY9:
1 | template <typename T0, typename T1 = void> struct DoWork; |
所有参数不足,即原型中参数T1没有指定的地方,都由T1自己的默认参数void补齐了。
但是这个方案仍然有些美中不足之处。
比如,尽管我们默认了所有无效的类型都以void结尾,所以正确的类型列表应该是类似于<int, float, char, void, void>这样的形态。但你阻止不了你的用户写出类似于<void, int, void, float, char, void, void>这样不符合约定的类型参数列表。
其次,假设这段代码中有一个函数,它的参数使用了和类模板相同的参数列表类型,如下面这段代码:
1 | template <typename T0, typename T1 = void> struct X { |
那么,每加一个参数就要多写一个偏特化的形式,甚至还要重复编写一些可以共享的实现。
不过不管怎么说,以长参数加默认参数的方式支持变长参数是可行的做法,这也是C++98/03时代的唯一选择。
例如,Boost.Tuple就使用了这个方法,支持了变长的Tuple:
1 | // Tuple 的声明,来自 boost |
此外,Boost.MPL也使用了这个手法将boost::mpl::vector映射到boost::mpl::vector _n_上。但是我们也看到了,这个方案的缺陷很明显:代码臃肿和潜在的正确性问题。此外,过度使用模板偏特化、大量冗余的类型参数也给编译器带来了沉重的负担。
为了缓解这些问题,在C++11中,引入了变参模板(Variadic Template)。我们来看看支持了变参模板的C++11是如何实现tuple的:
1 | template <typename... Ts> class tuple; |
是不是一下子简洁了很多!这里的typename... Ts相当于一个声明,是说Ts不是一个类型,而是一个不定常的类型列表。同C语言的不定长参数一样,它通常只能放在参数列表的最后。看下面的例子:
1 | template <typename... Ts, typename U> class X {}; // (1) error! |
为什么第(1)条语句会出错呢?(1)是模板原型,模板实例化时,要以它为基础和实例化时的类型实参相匹配。因为C++的模板是自左向右匹配的,所以不定长参数只能结尾。其他形式,无论写作Ts, U,或者是Ts, V, Us,,或者是V, Ts, Us都是不可取的。(4) 也存在同样的问题。
但是,为什么(3)中, 模板参数和(1)相同,都是typename... Ts, typename U,但是编译器却并没有报错呢?
答案在这一节的早些时候。(3)和(1)不同,它并不是模板的原型,它只是Y的一个偏特化。回顾我们在之前所提到的,偏特化时,模板参数列表并不代表匹配顺序,它们只是为偏特化的模式提供的声明,也就是说,它们的匹配顺序,只是按照<U, Ts...>来,而之前的参数只是告诉你Ts是一个类型列表,而U是一个类型,排名不分先后。
在这里,我们只提到了变长模板参数的声明,如何使用我们将在第四章讲述。
4.1.3. 模板的默认实参
在上一节中,我们介绍了模板对默认实参的支持。当时我们的例子很简单,默认模板实参是一个确定的类型void或者自定义的null_type:
1 | template < |
实际上,模板的默认参数不仅仅可以是一个确定的类型,它还能是以其他类型为参数的一个类型表达式。
考虑下面的例子:我们要执行两个同类型变量的除法,它对浮点、整数和其他类型分别采取不同的措施。
对于浮点,执行内置除法;对于整数,要处理除零保护,防止引发异常;对于其他类型,执行一个叫做CustomeDiv的函数。
第一步,我们先把浮点正确的写出来:
1 | include <type_traits> |
在实例化的时候,尽管我们只为SafeDivide指定了参数T,但是它的另一个参数IsFloat在缺省的情况下,可以根据T,求出表达式std::is_floating_point<T>::value的值作为实参的值,带入到SafeDivide的匹配中。
嗯,这个时候我们要再把整型和其他类型纳入进来,无外乎就是加这么一个参数goo.gl/0Lqywt:
1 | include <complex> |
当然,这时也许你会注意到,is_integral,is_floating_point和其他类类型三者是互斥的,那能不能只使用一个条件量来进行分派呢?答案当然是可以的:goo.gl/jYp5J2:
1 | include <complex> |
我们借助这个例子,帮助大家理解一下这个结构是怎么工作的:
- 对
SafeDivide<int>
通过匹配类模板的泛化形式,计算默认实参,可以知道我们要匹配的模板实参是
SafeDivide<int, true_type>计算两个偏特化的形式的匹配:A得到
<int, false_type>,和B得到<int, true_type>最后偏特化B的匹配结果和模板实参一致,使用它。
- 针对
SafeDivide<complex<float>>
通过匹配类模板的泛化形式,可以知道我们要匹配的模板实参是
SafeDivide<complex<float>, true_type>计算两个偏特化形式的匹配:A和B均得到
SafeDivide<complex<float>, false_type>A和B都与模板实参无法匹配,所以使用原型,调用
CustomDiv
4.2. 后悔药:SFINAE
考虑下面这个函数模板:
1 | template <typename T, typename U> |
到本节为止,我们所有的例子都保证了一旦咱们敲定了模板参数中 T 和 U,函数参变量 t 和 u 的类型都是成立的,比如下面这样:
1 | struct X { |
那么这里有一个可能都不算是问题的问题 —— 对于下面的代码,你认为它会提示怎么样的错误:
1 | struct X { |
这个时候你也许会说:啊,这个简单,Y 没有 type 这个成员自然会出错啦!嗯,这个时候咱们来看看Clang给出的结果:
1 | error: no matching function for call to 'foo' |
完整翻译过来就是,直接的出错原因是没有匹配的 foo 函数,间接原因是尝试用 [T = int, U = y] 做类型替换的时候失败了,所以这个函数模板就被忽略了。等等,不是出错,而是被忽略了?那么也就是说,只要有别的能匹配的类型兜着,编译器就无视这里的失败了?
银河火箭队的阿喵说,就是这样。不信邪的朋友可以试试下面的代码:
1 | struct X { |
这下相信编译器真的是不关心替换失败了吧。我们管这种只要有正确的候选,就无视替换失败的做法为SFINAE。
我们不用纠结这个词的发音,它来自于 Substitution failure is not an error 的首字母缩写。这一句之乎者也般难懂的话,由之乎者 —— 啊,不,Substitution,Failure和Error三个词构成。
我们从最简单的词“Error”开始理解。Error就是一般意义上的编译错误。一旦出现编译错误,大家都知道,编译器就会中止编译,并且停止接下来的代码生成和链接等后续活动。
其次,我们再说“Failure”。很多时候光看字面意思,很多人会把 Failure 和 Error 等同起来。但是实际上Failure很多场合下只是一个中性词。比如我们看下面这个虚构的例子就知道这两者的区别了。
假设我们有一个语法分析器,其中某一个规则需要匹配一个token,它可以是标识符,字面量或者是字符串,那么我们会有下面的代码:
1 | switch(token) |
假如我们当前的token是 LITERAL_STRING 的时候,那么第一步它在匹配 IDENTIFIER 时,我们可以认为它失败(failure)了,但是它在第三步就会匹配上,所以它并不是一个错误。
但是如果这个token既不是标识符、也不是数字字面量、也不是字符串字面量,而且我们的语法规定除了这三类值以外其他统统都是非法的时,我们才认为它是一个error。
大家所熟知的函数重载也是如此。比如说下面这个例子:
1 | struct A {}; |
那么 foo( A() ) 虽然匹配 foo(B const&) 会失败,但是它起码能匹配 foo(A const&),所以它是正确的;foo( B() ) 能同时匹配两个函数原型,但是 foo(B const&) 要更好一些,因此它选择了这个原型。而 foo( C() ); 因为两个函数都匹配失败(Failure)了,所以它找不到相应的原型,这时才会报出一个编译器错误(Error)。
所以到这里我们就明白了,在很多情况下,Failure is not an error。编译器在遇到Failure的时候,往往还需要尝试其他的可能性。
好,现在我们把最后一个词,Substitution,加入到我们的字典中。现在这句话的意思就是说,我们要把 Failure is not an error 的概念,推广到Substitution阶段。
所谓substitution,就是将函数模板中的形参,替换成实参的过程。概念很简洁但是实现却颇多细节,所以C++标准中对这一概念的解释比较拗口。它分别指出了以下几点:
什么时候函数模板会发生实参 替代(Substitute) 形参的行为;
什么样的行为被称作 Substitution;
什么样的行为不可以被称作 Substitution Failure —— 他们叫SFINAE error。
我们在此不再详述,有兴趣的同学可以参照这里,这是标准的一个精炼版本。这里我们简单的解释一下。
考虑我们有这么个函数签名:
1 | template < |
那么,在这个函数模板被实例化的时候,所有函数签名上的“和模板参数有关的一大坨”被推导出具体类型的过程,就是替换。一个更具体的例子来解释上面的“一大坨”:
1 | template < |
所有标记为 1 的部分,都是需要替换的部分,而它们在替换过程中的失败(failure),就称之为替换失败(substitution failure)。
下面的代码是提供了一些替换成功和替换失败的示例:
1 | struct X { |
在这个例子中,当我们指定 foo<Y> 的时候,substitution就开始工作了,而且会同时工作在三个不同的 foo 签名上。如果我们仅仅因为 Y 没有 type,匹配 Foo0 失败了,就宣布代码有错,中止编译,那显然是武断的。因为 Foo1 是可以被正确替换的,我们也希望 Foo1 成为 foo<Y> 的原型。
std/boost库中的 enable_if 是 SFINAE 最直接也是最主要的应用。所以我们通过下面 enable_if 的例子,来深入理解一下 SFINAE 在模板编程中的作用。
假设我们有两个不同类型的计数器(counter),一种是普通的整数类型,另外一种是一个复杂对象,它从接口 ICounter 继承,这个接口有一个成员叫做increase实现计数功能。现在,我们想把这两种类型的counter封装一个统一的调用:inc_counter。那么,我们直觉会简单粗暴的写出下面的代码:
1 | struct ICounter { |
我们非常希望它展现出预期的行为。因为其实我们是知道对于任何一个调用,两个 inc_counter 只有一个是能够编译正确的。“有且唯一”,我们理应当期望编译器能够挑出那个唯一来。
可惜编译器做不到这一点。首先,它就告诉我们,这两个签名
1 | template <typename T> void inc_counter(T& counterObj); |
其实是一模一样的。我们遇到了 redefinition。
我们看看 enable_if 是怎么解决这个问题的。我们通过 enable_if 这个 T 对于不同的实例做个限定:
1 | template <typename T> void inc_counter( |
然后我们解释一下,这个 enable_if 是怎么工作的,语法为什么这么丑:
首先,替换(substitution)只有在推断函数类型的时候,才会起作用。推断函数类型需要参数的类型,所以, typename std::enable_if<std::is_integral<T>::value>::type 这么一长串代码,就是为了让 enable_if 参与到函数类型中;
其次, is_integral<T>::value 返回一个布尔类型的编译器常数,告诉我们它是或者不是一个 integral type,enable_if<C> 的作用就是,如果这个 C 值为 True,那么 enable_if<C>::type 就会被推断成一个 void 或者是别的什么类型,让整个函数匹配后的类型变成 void inc_counter<int>(int & counterInt, void* dummy = nullptr); 如果这个值为 False ,那么 enable_if<false> 这个特化形式中,压根就没有这个 ::type,于是替换就失败了。和我们之前的例子中一样,这个函数原型就不会被产生出来。
所以我们能保证,无论对于 int 还是 counter 类型的实例,我们都只有一个函数原型通过了substitution —— 这样就保证了它的“有且唯一”,编译器也不会因为你某个替换失败而无视成功的那个实例。
这个例子说到了这里,熟悉C++的你,一定会站出来说我们只要把第一个签名改成:
1 | void inc_counter(ICounter& counterObj); |
就能完美解决这个问题了,根本不需要这么复杂的编译器机制。
嗯,你说的没错,在这里这个特性一点都没用。
这也提醒我们,当你觉得需要写 enable_if 的时候,首先要考虑到以下可能的替代方案:
重载(适用于函数模板)
偏特化(适用于类模板)
虚函数
但是问题到了这里并没有结束。因为 increase 毕竟是个虚函数。假如 Counter 需要调用的地方实在是太多了,这个时候我们会非常期望 increase 不再是个虚函数以提高性能。此时我们会调整继承层级:
1 | struct ICounter {}; |
那么原有的 void inc_counter(ICounter& counterObj) 就无法再执行下去了。这个时候你可能会考虑一些变通的办法:
1 | template <typename T> |
对于调用 1,因为 cntObj 到 ICounter 是需要类型转换的,所以比 void inc_counter(T&) [T = Counter] 要更差一些。然后它会直接实例化后者,结果实现变成了 ++cntObj,BOOM!
那么我们做 2 试试看?嗯,工作的很好。但是等等,我们的初衷是什么来着?不就是让 inc_counter 对不同的计数器类型透明吗?这不是又一夜回到解放前了?
所以这个时候,就能看到 enable_if 是如何通过 SFINAE 发挥威力的了:
1 | include <type_traits> |
这个代码是不是看起来有点脏脏的。眼尖的你定睛一瞧,咦, ICounter 不是已经空了吗,为什么我们还要用它作为基类呢?
这是个好问题。在本例中,我们用它来区分一个counter是不是继承自ICounter。最终目的,是希望知道 counter 有没有 increase 这个函数。
所以 ICounter 只是相当于一个标签。而于情于理这个标签都是个累赘。但是在C++11之前,我们并没有办法去写类似于:
1 | template <typename T> void foo(T& c, decltype(c.increase())* = nullptr); |
这样的函数签名,因为假如 T 是 int,那么 c.increase() 这个函数调用就不存在。但它又不属于Type Failure,而是一个Expression Failure,在C++11之前它会直接导致编译器出错,这并不是我们所期望的。所以我们才退而求其次,用一个类似于标签的形式来提供我们所需要的类型信息。以后的章节,后面我们会说到,这种和类型有关的信息我们可以称之为 type traits。
到了C++11,它正式提供了 Expression SFINAE,这时我们就能抛开 ICounter 这个无用的Tag,直接写出我们要写的东西:
1 | struct Counter { |
此外,还有一种情况只能使用 SFINAE,而无法使用包括继承、重载在内的任何方法,这就是Universal Reference。比如,
1 | // 这里的a是个通用引用,可以准确的处理左右值引用的问题。 |
假如我们要限定ArgT只能是 float 的衍生类型,那么写成下面这个样子是不对的,它实际上只能接受 float 的右值引用。
1 | void foo(float&& a); |
此时的唯一选择,就是使用Universal Reference,并增加 enable_if 限定类型,如下面这样:
1 | template <typename ArgT> |
从上面这些例子可以看到,SFINAE最主要的作用,是保证编译器在泛型函数、偏特化、及一般重载函数中遴选函数原型的候选列表时不被打断。除此之外,它还有一个很重要的元编程作用就是实现部分的编译期自省和反射。
虽然它写起来并不直观,但是对于既没有编译器自省、也没有Concept的C++11来说,已经是最好的选择了。
4.3. Concept “概念”:对模板参数约束的直接描述
4.3.1. “概念” 解决了什么问题
从上一节可以看出,我们兜兜转转了那么久,就是为了解决两个问题:
在模板进行特化的时候,盘算一下并告诉编译器这里能不能特化;
在函数决议面临多个候选的时候,如果有且仅有其中一个原型能够被函数决议接纳,那就决定是你了!
如果语言能允许用户直接描述需求并传达给编译器,不就不用这么麻烦了么。其实在很多现代语言中,都有类似的语言要素存在,比如C的约束(constraint on type parameters):
1 | public class Employee { |
上例就非常清晰的呈现了我们对GenericList中T的要求是:它得是一个Employee或Employee的子类。
这种“清晰的”类型约束,在C++中称作概念(Concept)。最早有迹可循的概念相关工作应当从2003年后就开始了。2006年Bjarne在POPL 06上的一篇报告“Specifying C++ concepts”算是“近代”Concept工作的首次公开亮相。委员会为Concept筹划数年,在2008年提出了第一版Concepts提案,试图进入C++0x的标准中。这也是Concept第一次在C++社群当中被广泛“炒作”。不过2009年的会议,让“近代”Concept在N2617草案戛然而止。
2013年之后,Concept改头换面为Concept Lite提案(N3701)卷土重来,历经多方博弈和多轮演化,最终形成了我们在C++20里看到的Concept。有关于Concept的方法论和比较,B.S. 在白皮书中有过比较详细的交代。
总之,在concept进入标准之后,模板特化的类型约束写起来就方便与直接多了。而且这些约束之间还可以像表达式一样复用和组合。虽然因为C++类型系统自身的琐碎导致基础库中的concept仍然相当的冗长,但是比起之前起码具备了可用性。
比如我们拿上一节中最后一个例子作为对比:
1 | // SFINAE |
可以看到,concept之后的表达式消除了语法噪音,显得更为简洁一些。而对于之前++的例子,concept下则更为扼要:
1 | template <typename T> concept Incrementable = requires (T t) { ++t; } |
直接告诉编译器,我们对T的要求是你得有++。
当然有人会问,那能不能直接写成以下形式,不是更简单吗?
1 | template <typename T> requires (T t) { ++t; } |
答案是:不能。
因为requires作为关键字/保留字是存在二义性的。当它用于函数模板或者类模板的声明时,它是一个constraint,后面需要跟着concept表达式;而用于concept中,则是一个required expression,用于concept的求解。既然constraint后面跟着一个concept表达式,而requires也可以用来定义一个concept expression,那么一个风骚的想法形成了:我能不能用 requires (requires (T t) {++t;}) 来约束模板参数呢?
当然是可以的!C++就是这么的简(有)单(病)!
1 | template <typename T> requires (requires (T t) { ++t; }) |
总而言之,除了这些烦人的问题,“概念”的出现,使得模板的出错提示也清爽了些许 —— 虽然大佬们都在鼓吹concept让模板出错多么好调试,但是实际上模板出错,有一半是来源自类型系统本质上的复杂性,概念并不能解决这一问题。
比如这里使用SFINAE的提示:
1 | <source>:23:5: error: no matching function for call to 'Inc' |
而这里是使用了concept的提示。
1 | <source>:25:5: error: no matching function for call to 'Inc_Concept' |
虽然在这个例子中,通过 Concept 获得出错提示看起来要比使用 SFINAE 所获得的错误描述要更长一点,但是对于更加复杂类型来说,则会友善许多。以后会找个例子给大家陈述。
4.3.2. “概念”入门
原文链接: https://kettycode.github.io/2024/02/06/cpp/泛型编程/template4/
版权声明: 转载请注明出处.