熟悉用于替代在万能引用上重载的方法
本文最后更新于:April 20, 2022 am
右值引用,移动语义、完美转发系列目录:右值、移动语义和完美转发
抛弃重载
最简单粗暴的方式就是给函数模板和普通的重载函数取不同的名字,比如在上一节的例子中,给函数模板取名为logAndAdd
, 给接收int
型参数的函数取名为logAndAddInt
。但当这种情况发生在构造函数时便不可行了,因为构造函数的函数名是固定的。况且,一般情况下,谁会想放弃重载特性呢?
const T&
式的传参
在上一节中,我们希望函数根据接收的参数是左值还是右值做出不同的反应,以优化效率。在提出问题时,我们用的就是常量左值引用——const T&
进行参数传递。诚然,通通使用const T&
式的传参会使得运行效率降低,但是考虑到在以万能引用为唯一参数的函数模板[1]上重载所带来的一系列问题,这也不失为一种好选择。
值传递
一种既能够使我们减少编写源代码,也能取得近似使用万能引用的函数模板的性能的解决方案是,把const T&
式的传参改为值传递。这虽然听上去非常反直觉,但是只要我们认真遵守 Item41:何时考虑值传递一节中提出的准则,按值传递这一方案就有它的应用价值。这里,直接给出修改版本的代码:
1 |
|
因为std::string
类的构造函数没有能够通过int
类型和其相似类型直接构造的,这两个版本的构造器不会争抢参数。类似地,任何std::string
类型的参数,以及能够被构造成std::string
类型的参数(比如字符串字面量)都会被传入第三行的构造器中。因此,对于函数的调用者而言,不会有任何意外发生。有些人会发现,使用0
或者NULL
来表示一个空指针时,会不小心调用到int
版本的构造函数。如果是这样,应该反复体会Item8,直到一提起用0
或者NULL
当空指针就恶心为止。
标签分派
const T&
式的传参和值传递的方式都没有使用万能引用,但如果我们使用万能引用的目的是完美转发,那万能引用就不可或缺,如果我们同时也并不想放弃重载,那么有什么方法时可以保全两者的吗?
实际上也没有那么困难,调用重载函数时,编译器会检查所有重载版本函数的参数,并且检查调用处的实参,然后选一个最佳匹配的重载版本——通过考虑所有的形参/实参组合。万能引用的形参普遍提供实参的最佳匹配,因此万能引用的函数模板总能立于不败之地。
如果万能引用的形参仅仅是参数列表的一部分,并且参数列表还包含着其它的非万能引用的参数,那么在实际调用时,在非万能引用形参上的不匹配就能使得普通重载版本的函数胜过万能引用的函数模板了,这就是标签分派的想法。
这里是之前的,没有引入标签分派的万能引用版本
1 |
|
如果只有这一个函数,那一切ok,现在我们要为它加上一个int
版本的重载。如果单纯使用重载,那我们就掉到上一节的坑里去了。
与重载并不相同的是,我们会重新实现logAndAdd
函数,让它调用其他两个函数——一个是专门为了整型准备的,一个是为了其它类型准备的,logAndAdd
函数仍然会接收任意类型的参数。
被logAndAdd
调用的其它两个函数名字相同,假设都是logAndAddImpl
,并且它们其中一个会接收万能引用,显然,它俩组成重载关系。与之前不同的是,它俩还会接收一个参数,用于表明其接受的第一个参数是否是整数类型,而正是这第二个参数,会扭转两个函数之间的战局。
这里是一个几乎正确的logAndAdd
版本
1 |
|
新版的logAndAdd
函数除了将自己的万能引用参数转发给logAndAddImpl
,还向后者传递第二个参数,用于表示第一个参数T
是否是整数类型——至少,它应该起到这样的作用。对于右值的整数类型,它也这样做。但是,正如下一节将要提到的,如果一个左值的实参被传递到万能引用中,也就是说name
被绑定到它上面,那么类型参数T
将被推导为T
类型的左值引用。因此,当向logAndAdd
函数传递左值的int
类型实参时,T
将被推导为int&
,而引用并不是整数类型,所以只要实参为左值,std::is_integral<T>
必定将会返回false
,不论实参到底是不是int
。
找到问题所在就基本相当于解决了它,因为C++标准库就提供了一个我们所要的类型萃取——std::remove_reference
,它将任何引用修饰符从传入的类型拿掉。因此,正确的写法应该是这样:
1 |
|
当然,在C++14中,可以写成std::is_integral<std::remove_reference_t<T>>
来省几个字
解决了类型的问题,现在我们再来看看真正干活的函数addAndLogImpl
1 |
|
这段代码看起来平平无奇,要说唯一让人略感疑惑的地方,应该就是std::false_type
,为什么我们不直接写true
或者false
呢?
因为true
和false
时运行时的概念,而在函数重载时,编译器需要在编译时期就决定调用哪一个函数。显然,直接用布尔值是不对的。我们需要一个能在编译时期就能对应逻辑上的真和假的机制。加上现在我们在讨论模板,那么答案就呼之欲出了,std::false_type
和std::true_type
实际上是两种类型。
那么,我们再来看看第二个重载版本的代码:
1 |
|
第四行重新调用了logAndAdd
,一是可以让父函数自行转发的传入的右值,二是可以少写几行代码0v0
稍微总结以下,在这种解决方案中,logAndAdd
函数仍是个函数模板,但它不自己干活,而是把任务分派给两个重载的函数,并且其中一个的参数列表中也有万能引用,然后我们利用两种类型std::false_type
和std::true_type
来使得编译器选择我们所希望的对应重载版本。我们注意到,我们在函数定义中甚至没有给形参加上名字,因为它们对于函数的执行的确没有任何作用,在运行时也一无是处,我们甚至会希望编译器自动将这两个变量从执行镜像中优化掉(至少某些编译器在某些情况下会这样做)。在logAndAdd
函数内部,它通过创建对应的标签来将任务分派给正确的重载函数,正如其名所示。标签分派是模板元编程(template metaprogramming)的标准构建模块,在现代C++的库中有广泛应用。
限制万能引用的函数模板的启用
使用标签分派的基础,是存在单个的、没有被重载的函数作为对外暴露的API,然后这个函数再将工作分派给实际干活的一堆函数中的一个,当然这堆函数要构成重载关系。通常情况下,创建一个没有被重载的函数并不困难,但是当此函数作为构造函数时,问题就来了。在上一篇博客中,我们详细讨论了当构造函数是一个万能引用的函数模板时,编译器生成的构造函数的重载版本会和模板发生的奇妙的化学反应。即使我们只在类的声明中实现一个万能引用版本的函数模板作为构造函数,编译器也会贴心地帮我们再生成几个,并且这几个使我们所需要的。这样一来,对构造函数的调用有可能绕过我们的模板构造函数,从而不被标签分派,而是直接被编译器给安排了——这和标签分派的初衷不符。
事实上,真正的问题不是这些编译器生成的构造函数有可能绕过标签分派,而是它们并不总是绕过标签分派。举例而言,回看当时的代码:
1 |
|
因为此处我们传入的值的类型是Person
,而不是const Person
,因此在构造函数模板中,编译器会将类型参数推导为Person&
,选择模板实例化的构造器: 这个场景下我们反而希望对构造函数的调用绕过标签分派,直接调用拷贝构造器,然而事与愿违。
上一节我们还发现,当进一步讨论继承体系之后,子类的拷贝构造函数和移动构造函数调用父类的对应构造函数时也会被其父类的构造函数模板给劫持。
对于这种情况,当万能引用的函数模板比我们想象中的要贪心一些,但是因为还有其它的重载存在,它并不能一人包揽所有类型的参数,标签分派就不适用了。我们转而寻找不同的技巧——一种使得我们可以适当缩小或者限制万能引用的函数模板被编译器选择的范围的技巧,它就是std::enable_if
std::enable_if
使得我们能够在某种情况下强迫编译器忽略特定的模板的存在,直白点说就是,我们可以自定义一种条件,使得编译器在这种条件下忽略模板,只在其它重载版本中挑选函数。我们称被忽略的模板为被禁用了。当然,默认情况下,所有模板都是启用的,但是使用了std::enable_if
的模板,只在std::enable_if
中的条件被满足时,才会启用。在我们的情境下,我们只希望在传入的实参的类型不是Person
类时,才调用万能引用版本的构造函数模板,在其它情况下,我们希望这个函数直接蒸发掉,转而让编译器选择它生成的拷贝构造函数或者移动构造函数等等。
用自然语言来表达std::enable_if
的作用并不困难,但是是它的语法多少有点奇怪,我们慢慢来。这里先给出一些和std::enable_if
中的条件相关的样板,我们从这里开始。先给出修改过后的万能引用构造函数模板的“声明”:
1 |
|
篇幅所限,没有太多的时间来详细解释第四行到底在干啥(可以详细了解std::enable_if
和”SFINAE”,后者是实现std::enable_if
的技术),这里我们专心研究一下第四行的condition。此处我们需要的条件是,当类型参数T
和类型Person
不一致时,启用本函数模板。标准库中提供了判断两个类型是否一致的模板:std::is_same
,所以,我们所期望的模板就是 !std::is_same<Person,T>::value
。我们离正确的答案已经和接近了,但正如下一节会提到的,当万能引用被左值初始化时,类型参数总是会被推导为左值引用,因此如果我们像这样调用:
1 |
|
最终类型参数T
会被推导为Person&
,它和Person
并不一样,因此std::is_same<Person,T>::value
会变成false
。如果我们重新审视上一段中我们提出的所希望启用函数模板的条件,会发现,当讨论类型参数T
时:
- 我们希望忽略它是否是个引用。
Person
,Person&
,Person&&
都应该视为是Person
类,此时禁用函数模板 - 我们不关心它是否有
const
或者volatile
关键字修饰
也就是说,我们需要一个拿掉所有引用、const
和volatile
关键字的工具,好在标准库已经提供了相应的类型萃取模板std::decay
, std::decay<T>
就能将所有引用和修饰符const
、volatile
去除[2]。综上,应该填入第四行condition处的代码,应该是!std::is_same<Person, typename std::decay<T>::type>::value
(这里必须要有typename
,因为std::decay<T>::type
取决于类型参数T
,详情见Item9)
那么,将这里的条件插入到代码中:
1 |
|
如果一个开发者没有看过上面的分析,同时对模板也不是特别了解,看到这一坨是不是会非常害怕?当上文提到的任意一种方案可以解决问题时,我们应该尽量避免使用std::enable_if
。
不过,一旦熟悉了这种函数式的语法和一大堆的尖括号,这里也不是非常晦涩。并且,这种机制使得我们能够限制函数模板只在传入类型T
和Person
密切相关时才出现在编译器面前(忽略索引、const和volatile),从而避免了意外调用。
这样就大功告成了!
添加继承体系的支持
其实还有最后一个问题——上一节在最后讨论了继承体系,如果我们的函数像这样实现,当子类调用父类的构造函数时,由于子类和父类并不一致,又会意外地调用到构造函数模板了:
1 |
|
调用父类的构造函数,由于SpecialPerson
类和Person
类并不相同,因此我们设置的条件被意外地判定为满足,函数模板启用了,结果就是我们又被编译器的报错糊了一脸。
仔细分析,子类的函数实现是按照正常的流程编写的,毕竟父类再怎么玩得花也不应该影响子类的正常的函数的实现,这里的问题需要在父类上进行修复。现在我们发现,需要启用模板的情况变成了——当传入类型不是Person
类也不是Person
类的子类时,启用模板。不出意外地,标准库已经实现了一个类型萃取用来确定某个类是不是另一个类的子类,它就是std::is_base_of
std::is_base_of<T1, T2>::value
将为真,仅当T2
从T1
派生(标准规定某个类也从它自己派生),于是,我们将代码改成了
1 |
|
这样就的确算是大功告成了!如果使用的是C++14,还可以这样写来简化一下
1 |
|
添加重载
现在,我们可以很自豪地说,只要往构造函数中传入任何Person
类的子类,不管其是否是索引、是否有const
或者volatile
修饰符,都不会调用到万能引用函数模板了。但是,回望我们在上一节的最开始的问题——我们希望给构造函数加一个接收int
类型的重载。Ooops,现在如果传一个整数到构造函数中去,我们又一次意外地调用了万能引用的构造函数模板,因此还需要打一个补丁,使得传入int
型数据时,也不要启用模板:
1 |
|
这下终于可以松一口气了,完美的实现!它不仅使用了完美转发,最大化了效率,还限制了函数模板的启用时机,使得函数模板同其它类型的重载和平共处,虽然代价似乎有一些大,包括报错信息的可读性等等。
权衡利弊
本节提出的前三条技术——抛弃重载、constT&
传参以及值传递,采用这种方案的函数都对自身参数的具体类型做了声明,而后两种方法——标签分派和限制万能引用的函数模板的启用,都利用了完美转发,所以没有声明参数的类型。这一点是本质上的区别,即是否声明参数类型,有其相应的影响
原则上来讲,完美转发是高效的,因为它可以避免为了适配函数签名的参数类型而单独创建一个临时对象。在这一场情景里,如果向Person
类的构造函数中传入字符串字面量,它就会将至转发到类的成员变量name
的构造函数上去,从而避免产生一个临时对象。反观前三种方案,都会引起一次临时对象的创建。
但是,完美转发也是有缺点的,有一些类型的实参就是不能被完美转发,即使它们能被传递给已经声明了参数类型的函数。在Item30中,我们将会详细讨论完美转发失败的情形。
另一个问题是,当调用者传入了无效的参数类型时的编译报错信息的可读性。假设,调用者传入了一条由char16_t
组成的字符串,而这和std::string
所需要的char
所不同
1 |
|
如果我们的使用了前三种方案之一,那么编译器就会给出比较直截了当的解释:无法将const chart16_t[12]
转换成int
或者std::string
类型。但如果我们使用的完美转发来实现,类型参数会被正常推导,然后这个字符串字面量被转发到Person
类的成员变量name
的构造函数上去,std::string
的构造函数发现自己处理不了这个家伙,这才引起编译器报错。我们这时瞄一眼终端吐出的错误信息,emmm,一定会印象深刻——它大概有160多行。
在这个例子里面,参数只被转发了一次,如果在大型项目中,接收参数的函数模板又将参数转发出去了,一层层地转发下去最后终于达到症结所在,一旦再次报错,那编译器输出的错误信息将是难以想象的。因此,许多开发者认为,单这一条理由就够将完美转发从接口中踢出去了,即使程序性能是开发时的最重要指标。
不过,在本例中,我们知道被转发的参数是将要用于构造std::string
对象的,因此我们可以使用static_assert
和std::is_constructible
来确保检查出上述问题:
1 |
|
有一点小小的遗憾,因为需要在初始化列表中构造成员name
,因此static_assert
出现在之后的函数体里,有的编译器会将后者的报错信息放在相应的靠后的位置输出。
总结
通过使用不同的函数名以放弃重载、使用const T&式传参、值传递和标签分派四种方式可以避免万能引用的函数模板和普通函数重载打架的情况
通过std::enable_if来限制函数模板的启用时机,可以让万能引用的函数模板和普通的重载函数和平共处
用万能引用作为参数在性能上有优势,但是这通常会引起易用性上的问题
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!