-
本节是 Item 26 的延续。上一节中我们已经了解了为什么对万能引用参数进行重载是种糟糕的做法,这一节就来讨论其替代做法。本节讲解的后两种方法使用的技术非常重要,读者可以在该例之上继续延伸学习。
-
方法一:放弃使用重载
- 最简单的方法。例如,上一节中的
logAndAdd
函数可以拆为两个logAndAddName
和logAndAddIdx
。但这种方法对构造函数的情景不适用,因为构造函数的名称是固定的。
- 最简单的方法。例如,上一节中的
-
方法二:按
const T&
传递- 也就是回到上一节的最初版本,放弃使用万能引用。如之前讨论,这样的代码效率不是最高的,不过考虑到万能引用和重载有这么多问题,牺牲一些性能换来代码的简洁在某些情景中或许是值得的。
- 也就是回到上一节的最初版本,放弃使用万能引用。如之前讨论,这样的代码效率不是最高的,不过考虑到万能引用和重载有这么多问题,牺牲一些性能换来代码的简洁在某些情景中或许是值得的。
-
方法三:按值传递
- 如果你需要在函数中拷贝参数的值,那么就应该按值而不是左值常引用传递。原理在 Item 41 中讨论。这里仅展示应用(改写上一节的
Person
类):class Person { public: explicit Person(std::string n) // replaces T&& ctor; see : name(std::move(n)) {} // Item 41 for use of std::move explicit Person(int idx) // as before : name(nameFromIdx(idx)) {} private: std::string name; };
- 如果你需要在函数中拷贝参数的值,那么就应该按值而不是左值常引用传递。原理在 Item 41 中讨论。这里仅展示应用(改写上一节的
-
方法四:Tag Dispatch
- 笔者注:理解方法4和5需要一些模板和 type trait 的知识。
- 以上两种方法都放弃了万能引用参数。如果想要保留,那么解决方法就是从重载上下功夫。既然对万能引用参数本身重载会带来问题,那么通过其它参数的不同区分重载版本不就可以了吗?
- 仍用上节
logAndAdd
的例子。首先我们保持原函数对外接口不变,但是内部实现使用两个重载函数logAndAddImpl
。logAndAddImpl
接受两个参数:第一个是原参数,第二个是“标签”值——一个在调用时临时创建的变量,用于区分调用哪个重载函数,在函数内部没有作用。 - 如何区分原参数是整型还是字符串?标准C++中的 type trait 提供了一系列类型相关的辅助函数:
std::is_integral<T>
用于判断类型T
是否是整型。需要注意如果入参是一个左值引用int&
,is_integral<T>
会判断为假,因此应先使用对类型的操作std::remove_reference<T>
,去除T
身上可能带有的引用。logAndAdd
调用如下:template<typename T> void logAndAdd(T&& name) { logAndAddImpl( std::forward<T>(name), std::is_integral<typename std::remove_reference_t<T>>() ); }
std::is_integral<T>
的结果不是 true/false 值,而是两个类型std::true_type
和std::false_type
。两个类型分别与以下两个重载版本的参数类型匹配,选择调用(至于传递的该类型标签的变量本身在函数中没有作用,因此我们甚至不需要给它名称):template<typename T> // non-integral argument: add it to global void logAndAddImpl(T&& name, std::false_type) // data structure { auto now = std::chrono::system_clock::now(); log(now, "logAndAdd"); names.emplace(std::forward<T>(name)); } std::string nameFromIdx(int idx); // as in Item 26 void logAndAddImpl(int idx, std::true_type) // integral argument: look up name and { // call logAndAdd with it logAndAdd(nameFromIdx(idx)); }
- 这种技术也常用于模板元编程中。
-
方法五:限制万能引用参数的模板函数的使用范围
-
方法4还不能完全解决构造函数的问题:传入对象类型为
const T&
(准确匹配)的调用会走编译器生成的拷贝构造函数,从而跳过Tag Dispatch的分发处理。换句话说,虽然万能引用参数版本的函数会抢走很多调用,但还没有“贪婪”到能抢走所有调用。这种情况不适用 tag dispatch 方法。我们可以换一个思路:禁止万能引用抢走那些它不应该被选择的调用,即限制其能被调用的范围。 -
实现这个思路的核心是 SFINAE 和基于它的
std::enable_if
。如果你对它们不熟悉,下面是一个简单的介绍:template<typename T> void f(const T& t, typename T::iterator* it = nullptr) { } void f(...) { } f(1);
函数
f
有两个重载版本,其中下面的版本接受任意参数,在编译器选择重载调用时优先级最低。Substitution Failure Is Not An Error 规则告诉我们,在将模板参数类型替换为实际的调用参数类型时如果失败,则编译器不会立刻报错,而是继续寻找其它可能的重载。上例中,f(1)
调用按照优先级会选择上面的版本;然而,将第二个参数的类型T::iterator
替换为int::iterator
失败,此时编译器不会报错,而是继续考虑其它重载版本,最终选择调用下面的版本。template <bool _Test, class _Ty = void> struct enable_if {}; // no member "type" when !_Test template <class _Ty> struct enable_if<true, _Ty> { // type is _Ty for _Test using type = _Ty; };
std::enable_if
的实现就基于这一规则。以上是 MSVC 中std::enable_if
的定义,可以看出其通过模板偏特化实现了当第一个布尔值_Test
为 true 时,std::enable_if<_Test, _Ty>::type
才有定义(_Ty
)。我们只要在定义函数时添加一个这个标识性参数,那么只有条件_Test
满足时,该函数才有可能被调用,否则该类型是没有定义的,编译器根据 SFINAE 会跳过该函数寻找其它重载调用。具体来说可以有多种做法:// 注:enable_if_t<_Test, _Ty>等价于enable_if<_Test, _Ty>::type // 作为返回值 template<typename T> std::enable_if_t<cond, return_type> function(T param); // 作为一个函数参数 template<typename Type> return_type function(T param, std::enable_if_t<cond,int> = 0) // 作为一个模板参数 template<typename Type, std::enable_if_t<cond,int> = 0> return_type function(T param)
其中条件
cond
通常与类型T
的特性有关。在我们的例子中,应该让Person
类的万能引用参数版构造函数不要捕获任何参数为Person
相关类型的调用(交给拷贝构造函数处理)。type trait 提供了判断类型相同的工具:std::is_same<_Ty1, _Ty2>::value
(或std::is_same_v<_Ty1, _Ty2>
)。但是不要忘了上节最后讨论的派生类拷贝时向基类构造函数传参为派生类对象的问题,它和基类不是相同类型,所以这里更合适使用的是:std::is_base_of<_Ty1, _Ty2>::value
,它判断_Ty1
是否是_Ty2
的基类。这里的_Ty1
自然应该是Person
,但_Ty2
能直接放万能引用参数类型T
吗?不要忘了引用性和 const / volatile 修饰符的问题,应该将它们去除,为此我们可以使用另一个工具:std::decay<T>::type
,它可以一次性去除以上所有(不止如此,如其名称“衰变”所形容,它还会使数组和函数类型变为指针)。OK,至此我们完成了所有技术上的准备。我们最初的需求:“如果入参的类型是
Person
或其派生类(或引用、const/volatile),那么就不要调用万能引用参数版本”,可以变成以下代码:class Person { public: template<typename T, typename = std::enable_if_t< !std::is_base_of<Person, std::decay_t<T>>::value > > explicit Person(T&& n); ... // 其它重载版本 };
如果还要继续限制万能引用版本被调用的范围,则在
std::enable_if
中添加其它条件即可。
-
-
反思
- 前三种方法和后两种方法的核心区别在于前者改为传统的声明类型的参数,而后者坚持使用万能引用参数。
- 通常来说,声明具体类型的参数的效率低于万能引用参数,因为各种不完全匹配声明类型(const,引用,字面值,派生类等)的调用可能会造成一些冗余的临时对象创建和拷贝。
- 万能引用参数 + 完美转发的方法也有缺陷:(1)有些类型的参数无法被完美转发,Item 30 中讨论这样的情形;(2)当调用者传递的参数无效时,编译器产生的错误信息会十分冗长。这是因为参数类型并没有在第一层调用处立刻被检查,而是延迟到真正使用时才发生错误。如果是在复杂的系统中,参数可能已经被转发了多层,产生的错误信息更是难以理解。这个问题一定程度上可以通过主动的检查解决:使用
static_assert
结合 type trait。假设我们确定某个万能引用参数最终会被用于构造一个std::string
,那么可以利用std::is_constructible
,在函数第一层调用入口处判断:template<typename T> void f(T&& n) { static_assert(is_constructible<std::string, T>::value, "Parameter n can't be used to construct a std::string"); // 其他代码... // 也许将n转发给其它函数 }
总结
- 结合万能引用和函数重载的替代方案有:(1)放弃重载(函数名区分);(2)左值常引用传参(牺牲效率换简洁性);(3)按值传参(如果函数内涉及参数的拷贝,则优于2);(4)tag dispatch。
- 使用
std::enable_if
通过条件限制模板的调用范围,可以使万能引用和重载同时使用。 - 万能引用通常在性能上有优势,但易用性不足。