熟悉万能引用重载的替代方法
舍弃重载
条款26的第一个例子,logAndAdd
可以作为很多函数的代表,这样的函数只需要把本来打算进行重载的版本重新命名成不同的多个名字就可以避免对万能引用类型进行重载。以logAndAdd
的两个重载版本为例,就可以分别改称logAndAddName
和logAndAddNameIdx
。
传递const T& 类型的形参
一种替代方式是回归C++98,使用传递左值常量引用类型来代替传递望能引用类型。这种方法的缺点是达不到我们想要的高效率。
传值
一种经常能够提升性能,却不用增加一点复杂性的方法,就是把传递的形参从引用类型替换成值类型。当你知道肯定需要复制形参时,考虑按值传递对象。
class Persom{
public:
explicit Person(std::string n) //替换掉T&&的构造函数
: name(std::move(n)) {}
explicit Person(int idx)
: name(nameFromIdx(idx)) {}
private:
std::string name;
};
由于std::string
类型并没有只接受单个整型形参的构造函数,所有int
或者类int
的类型(例如,size_t
, short
,long
)的实参都会汇集到接受int
类型的那个构造函数重载版本的调用。类似的,所有std::string
类型的实参都会被传递给接受std::string
类型的那个构造函数。
标签分派
重载函数在调用时的决议,会考察所有重载版本的形参,以及调用端传入的实参,然后选择全局最佳匹配的函数,这需要将所有的形参/实参组合都考虑在内。一个万能引用形参通产会导致的后果时无论传入了什么都给出一个精确匹配结果,不过,如果万能引用仅是形参列表的一部分,该列表中还有其他非万能引用类型的形参的话,那么只要该非万能引用形参具备充分差的匹配能力,则它就足以将这个带有万能引用形参的重载版本踢出局。这个想法就是标签分派的基础。
std::multiset<std::string> names; //全局数据结构
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clock::now();
log(now,"logAndAdd");
names.emplace(std::forward<T>(name));
}
就这个函数,一旦引入接受int
的重载版本,以按索引查找对象时,就会重回条款26的麻烦境地。本条款的目的,就在于避免那些问题,方法就是不再添加重载版本,而是重新实现logAndAdd
,把它委托给另外两个函数,一个接受整型值,另一个接受其他的所有的类型。而logAndAdd
本身接受所有类型的实参,无论整型和非整型都来者不拒。
这两个完成实际工作的函数名字为logAndAddImpl
,以及,我们重载的其实是logAndAddImpl
。两个函数中的一个,会接受万能引用类型的形参。这两个函数都还有第二个形参,该形参用来判断传入的实参是否是整型。正是第二个形参阻止了我们落入条款26所描述的陷阱里。第二个形参就会是选择了哪个重载版本的决定因素。
tempalte<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name), std::is_integral<T>()); //不够正确
}
上面这个函数把它的形参转发给了logAndAddImpl
,但是它还传递了另一个实参,用来表示那个形参的类型T
是否为整型。至少,它应该做到这件事件,若传给logAndAdd
的实参是右值类型,它就做到了。但是,如条款28所说,如果传给万能引用name
的实参是个左值,那么T
就会被推导为左值引用。所以,如果传递给logAndAdd
的是个左值int
,则T
就会被推导为int&
。这不是个整型,因为引用类型都不是整型,这意味着std::is_integral<T>
函数接受了任意左值实参时,会得到结果假。尽管这样的实参确实表示了一个整型值。
C++标准库中有个类型特征,std::remove_reference
,它会移除类型所附加的一切引用饰词。
因此,正确的logAndAdd
如下:
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>);
}
完成logAndAdd
以后,就可以把注意里放到被调用的函数logAndAddImpl
上了。它有两个重载版本,第一个是只针对非整形实施的(亦即,std::is_integral<typename std::remove_reference<T>::type>
的结果为假):
template<typename T>
void logAndAddImpl(T&& name, std::false_type) // 非整型实参std::false_type
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
概念上,logAndAdd
会向logAndAddImpl
传递一个布尔值,用以表示传递给logAndAdd
的实参是否为整型。不过,true
和false
都是运行期值,可是我们需要利用的是重载决议(一种编译期现象)来选择正确的logAndAddImpl
重载版本。这就意味着我们需要一个对应于true
的类型,和一个对应于false
的不同类型。C++
标准库提供了名为std::true_type
和std::false_type
的一堆类型来满足。若T是整型,则经由logAndAdd
传递给logAndAddImpl
的实参就会是个继承自std::true_type
的对象。反之,若T
不是整型,该实参就会是个继承自std::false_type
的对象。总的结果是,只有当T
不是整型时,logAndAdd
发起的调用才会从后选中选定上面这个logAndAddimpl
重载版本。
第二个重载版本则包含了T
是整型的相反情况。在此,logAndAddImpl
仅仅通过传入的索引查找到对应的名字,然后就把该名字传回给logAndAdd
。
std::string nameFromIdx(int idx); //但会索引对应的名字
void logAndAddImpl(int idx, std::true_type)
{
logAndAdd(nameFromIdx(idx));
}
通过让logAndAddImpl
按索引查找对应名字,然后传递给logAndAdd
(在那里,它会经由std::forward
转发到另一个logAndAddImpl
重载版本),就可以避免在两个重载版本都放入记录日志的代码。
在上述设计中,类型std::false_type
和std::true_type
就是所谓的标签,运用它们的唯一目的在于强制重载决议按照我们想要的方向推进。值得注意的是,这些形参甚至没有名字。它们在运行期不起任何作用,实际上,我们希望编译器能够识别出这些标签形参并未使用过,从而将他们从程序的执行镜像中优化掉。针对logAndAdd
内的重载实现函数发起的调用把工作”分派“到正确的重载版本的手法就是创建适当的标签对象,这种设计因为得名:标签分派。
对接受万能引用的模板施加限制
标签分派能够发挥作用的关键在于,存在一个单版本(无重载版本的)函数作为客户端API
。该单版本函数会把待完成的工作分派到实现函数。创建无重载的分派函数通常并不难。但条款26所关注的第二个问题,即关于Person
类的完美转发构造函数的那个问题,却是个例外。编译器可能会自行生成拷贝和移动构造函数,所以如果仅仅撰写一个构造函数,然后在其中运用标签分派,那么有些针对构造函数的调用就可能会由编译器生成的构造函数处理,从而绕过了标签分派系统。
实际上,真正的问题并不在于编译器生成的函数有时候会绕过标签分派设计,而在于编译器生成的函数并不能保证一定会绕过标签分派设计。当收到使用左值对象进行同类型对象的拷贝请求时,你几乎总会期望调用拷贝构造函数。但是,就如条款26所演示的那样,只要提供了一个接受万能引用形参的构造函数,会导致拷贝非常量左值时总会调用到万能引用构造函数(而非拷贝构造函数)。同一条款也解释了,如果基类中声明了一个完美转发构造函数,则派生类以传统方式实现其拷贝和移动构造函数时,总会调用到该构造函数,尽管正确行为应该是调用到基类的拷贝和移动构造函数。
对于这种情况,也就是接受了万能引用形参的重载函数比你想要的程度更贪婪,而却又未贪婪到能够独当一面成为单版本分派函数的程度,标签分派就不是你想寻找的好伙伴了。
你需要的是另一种独门绝技,它可以让你把含有万能引用部分的函数模板被允许采用的条件砍掉一部分。它叫std::enable_if
。
std::enable_if
可以强制编译器表现出来的行为如同特定的模板不存在一般。这样的模板称为禁用的。默认地,所有模板都是启用的。可是,实施了std::enable_if
的模板值会在满足了std::enable_if
指定的条件的前提下才会启用。在我们讨论的情况下, 仅在传递给完美转发构造函数的类型不是Person
时才启用它。如果传递的类型是Person
,我们就会想要禁用完美转发构造函数(即,让编译器忽略它),因为这么一来,就会由类的拷贝或移动构造函数来接受处理这次调用了,而这才是在用一个Person
类型的对象来初始化另一个Person
类型的对象来初始化另一个Person
类型的对象时应有的结果。
std::enable_if
的条件部分可以套用公式,所以我们就从那里着手。以下是Person类的完美转发构造函数声明,仅展示了使得std::enable_if
得以运作的够用程度。之所以仅展示该构造函数的声明,是因为std::enable_if
对实现毫无影响。
class Person{
public:
template<typename T,
typename = typename std::enable_if<condition>::type>
explicit Person(T&& n);
...
};
我们想指定的条件是,T
不是Person
类型,即,仅当T
是Person
以外的类型时,才启用该模板构造函数。正好有个类型特征能够判定两个类型是否一致。我们想要的条件好像是!std::is_same<Person, T>::value
,注意一开始的”!“。因为我们想要T
和Person
不相同。这个表达式已经接近我们想要的,但不正确,原因在于,使用做值初始化万能引用时,T
的类型推导结果总是左值引用。这就意味着,对于这样的代码。
Person p("Nancy");
auto cloneOfP(p); //从左值出发进行初始化
万能引用构造函数中的T
的类型会被推导成Person&
。类型Person
和Person&
不相同,std::is_same
的结果会反应这一事实:std::is_same<Person,Person&>::value
的值是假。
我们需要一种手段来移除T类型带有的所有引用,const
和volatile
饰词。再一次地,标准库以类型特征的形式赐予我们所需之物,该特征叫做std::decay
。std::decay<T>::type
和T
相同,区别在于它移除了T
的引用和cv
饰词(从const
或volatile
饰词)。这么一来,我们心心念念的判定构造函数是否启动的条件就成了这样。
!std::is_same<Person, typename std::decay<T>::type>::value
即,忽略了T
类型的引用和cv
饰词后,Person
和T
类型仍不同一(如条款9所言,std::decay
之前的”typename
“不能省略,因为std::decay<T>::type
对模板形参T有依赖)。
Person
类的完美转发构造函数声明:
class Person{
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_same<Person,
type std::decay<T>::type>
::value>::type>
explicit Person(T&& n);
...
};
给定了上述构造函数,当我们使用一个Person
类型的对象(无论它是左值还是右值,带不带const
或volatile
饰词)来构造另一个Person
类型的对象时,将永远不会调用到接受万能引用的构造函数。
对于条款26最后派生类调用基类的构造函数那个例子。
class SpecialPerson : public Person{
public:
SpecialPerson(const SpecialPerson& rhs) : Person(rhs) //拷贝构造函数
{ ... } //调用的是基类的完美转发构造函数
SpecialPerson(SpecialPerson&& rhs) //移动构造函数
: Person(std::move(rhs)) //调用的是基类的完美转发构造函数
{ ... }
};
当复制或者移动一个SpecialPerson
类型的对象时,我们会期望通过基类的拷贝或移动构造函数来完成该对象基类部分的复制或移动,不过在这些函数中,我们传递给基类构造函数的是SpecialPerson
类型的对象,因为SpecialPerson
和Person
类型不同,基类的万能引用构造函数会启用,后者很乐意在精确匹配了SpecialPerson
类型的实参后执行实例化。原因在于,这个精确匹配比起Person
类中的复制和移动构造函数所要求的从派生类到基类的强制类型转换才能把SpecialPerson
类型的对象绑定到Person
类型的形参来说,是更好的匹配。所以,我们现有的代码在复制和移动SpecialPerson
类型的对象时,会使用Person
类的完美转发构造函数来复制和移动它们的基类部分。
标准库有个类型特征可以判定一个类型是否由另一个类型派生而来。该类型特征名叫std::is_base_of
。若T2
由T1
派生而来,std::is_base_of<T1, T2>::value
是真。所有类型都可以认为是从它自身派生而来,所以std::is_base_of<T1, T1>::value
。这下就方便了,因为我们想要修改Person
类的完美转发构造函数的启用条件,改成仅在类型T
去除引用和CV
饰词后,即非Person
类型,亦非Person
派生的类型。以std::is_base_of
代替std::is_same
就可以得到想要的东西。
class Person{
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_base_of<Person,
typename std::decay<T>::type
>::value>
::type>
explicit Person(T&& n);
};
到这里,就算完成了,前提是,我们使用C++11撰写代码。如果在使用的是C++14,上述代码仍然成立,不过我们可以利用别名模板来去掉std::enable_if
和std::decay
累赘的typename
和::type
,从而产生下面这段更加养眼的代码:
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
来为那些本来就想使用Person类的复制和移动构造函数的类型选择性的禁用Person类中接受万能引用的构造函数,但我们还没有看到如何运用这一技术来区分实参是整型还是非整型。
我们需要做的一切就是(1)为Person
类添加一个处理整形实参的构造函数重载版本(2)进一步限制模板构造函数,在接受整型实参时禁用。
class Person{
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person,std:;decay_t<T>>::value //接受std::string类型
&& !std::is_integral<std::remove_reference_t<T>>::value>>//以及强制转换
explicit Person(T&& n) : name(std::forward<T>(n)) //到std::string
{ ... } //实参类型的构造函数
explicit Person(int idx) : name(nameFromIdx(idx)) //接受整型实参的构造函数
{ ... }
private:
std::string name;
};
权衡
本条款关注的头三种技术(舍弃重载,传递const T&
类型的形参和传值)都需要对带调用的函数形参逐一指定类型,而后两种技术(标签分派和对模板的启用资源施加限制)则利用了完美转发,因此无须指定形参类型。
通常,完美转发效率更高,因为它出于和形参声明时的类型严格保持一致的目的,会避免创建临时对象。在Person
类的构造函数中,完美转发就允许把形如“Nancy”的字符串字面量转发给某个接受std::string
的构造函数,而未使用完美转发的技术一定得先从字符串字面量出发创建一个临时的std::string
对象,方能满足Person
的构造函数的形参规格。
但是完美转发亦有不足,首先是针对某些类型无法实施完美转发,尽管它们可以被传递到接受特定类型的函数,条款30探索了这些完美转发的失效案例。
其次是在客户传递非法形参时,错误信息的可理解性,例如,假设在创建Person
类型的对象时,传递的是个char16_t
而非char
类型的字符组成的字符串:
Person p(u"Konrad Zuse"); //字符串由char16_t类型的字符组成
如果时本条款介绍的前三种技术,编译器会发现,可用的构造函数只能接受int
或std::string
。所以,它们会产生出多少算是直截了当的错误信息来解释:无法将const char16_t[12]
强制转换到int
或std::string
。
如果使用的是基于完美转发的技术时,const char16_t
类型的数组在绑定到构造函数的形参时,会一声不吭,它又被转发到Person
的std::string
类型的成员变量的构造函数中,唯有在哪里,调用者的传递之物(一个const char16_t
类型的数组)与所要求的形参(std::string
的构造函数可接受的形参类型)之间的不匹配才会被发现。
在本例中,万能引用只转发了一次(从Person
类的构造函数到std::string
类的构造函数)。但系统越是复杂,就越有可能某个万能引用要经由数层函数完成转发,才会抵达决定实参类型是否可接受的场所。万能引用转发的次数越多,某些地方出错时给的错误信息就越让人摸不着头脑。
在Person
一例中,我们了解转发函数的万能引用形参应该用作std::string
类型的对象的初始化物,所以可以使用static_assert
来验证其能扮演这个角色。std::is_constructible
这个类型特征能够在编译期间判定具备某个类型的对象是否从另一个类型的对象出发完成构造,所以这个断言很容易撰写。
class Person{
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person,std:;decay_t<T>>::value //接受std::string类型
&& !std::is_integral<std::remove_reference_t<T>>::value>>//以及强制转换
explicit Person(T&& n) : name(std::forward<T>(n)) //到std::string
{ //实参类型的构造函数
//断言可以从T类型的对象构造一个std::string类型的对象
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
}
explicit Person(int idx) : name(nameFromIdx(idx)) //接受整型实参的构造函数
{ ... }
private:
std::string name;
};
这会在客户代码尝试从一个无法构造出std::string
类型的对象的类型出发来创建一个Person
类型的对象时,产生出该指定错误信息。不幸的是,在本例中,static_assert
位于构造函数体内,而转发代码属于成员初始化列表的一部分,位于它之前。
要点速记
- 如果不适用万能引用和重载的组合,则替代方案包括使用彼此不同的函数名字,传递
const T&
类型的形参,传值和标签分派 - 经由
std::enable_if
对模板施加限制,就可以将万能引用和重载一起使用,不过这种技术控制了编译器可以调用到接受万能引用的重载版本的条件。 - 万能引用形参通常在性能方面具有优势,在易用性方面一般会有劣势