在这篇文章提到了通用引用,但是更多的是什么是通用引用,这篇更多的展示使用通用引用的注意事项
使用通用引用通常是希望接受的类型更广泛,减少代码量,同时也可以提升效率(避免类型的强制转换、创建因转换而产生的临时变量等),例如下面的未使用通用引用的例子:
std::multiset<std::string> names;
void logAndAdd(const std::string& name)
{
names.emplace(name);
}
{
std::string petName("Darla");
logAndAdd(petName);
logAndAdd(std::string("Persephone"));
logAndAdd("Patty Dog");
}
第1个调用logAndAdd(petName);
实参是个左值,当添加到names时触发拷贝
第2个调用logAndAdd(std::string("Persephone"));
实参是个临时变量即右值,形参是个左值,同样当添加到names时触发拷贝
第3个调用logAndAdd("Patty Dog");
实参是个字符串字面值即const char*,这时用这个字面值创建了string临时变量,即实参是个右值,形参是个左值,同样当添加到names时触发拷贝
如果使用通用引用效率会有很大提升,通用引用版本:
template<typename T>
void logAndAdd(T&& name)
{
names.emplace(std::forward<T>(name));
}
同样的调用,再分析一下每个调用:
第1个调用logAndAdd(petName);
和之前一样
第2个调用logAndAdd(std::string("Persephone"));
实参是个临时变量即右值,实参以移动的方式添加到names
第3个调用logAndAdd("Patty Dog");
实参是个字符串字面值即const char*,省去了创建临时变量的过程和拷贝临时变量的过程
这两个例子展示了使用通用引用的威力,但是如果增加一个基于void logAndAdd(T&& name)
的重载函数:
template<typename T>
void logAndAdd(T&& name)
{
names.emplace(std::forward<T>(name));
}
void logAndAdd(int nId)
{
names.emplace(FactoryFun(nId));
}
例如下面的调用:
{
int nId = 10;
logAndAdd(nId);
logAndAdd("Darla");
short nNewId = 11;
logAndAdd(nNewId);
}
这种调用方式会导致编译不通过,在分析每个调用之前先把C++关于重载决议的一项规则拿出来:
当模板函数和非模板函数同时精确匹配了其调用,那么非模板函数则具有优先权
使用通用引用的函数通常能精确匹配任何参数类型(也有特例),基于这项规则简单分析一下编译不通过的原因:
第1个调用logAndAdd(nId);
实参是个int,都能和模板函数、非模板精确匹配,所以调用了非模板函数
第2个调用logAndAdd("Darla");
实参是个const char*,只能和模板函数精确匹配
第3个调用logAndAdd(nNewId);
实参是个short,能和模板函数精确匹配,虽然一个short实参也能调用void logAndAdd(int nId)
,但是这里涉及到类型强制转换,所以调用了模板函数。
当匹配了模板函数,而string类型并没有针对short类型参数的构造函数,所以编译报错
我们继续把这项规则、应用场景放大一下,向下面这个包含模板构造函数例子:
class Person
{
public:
template<typename T>
explicit Person(T&& n) : name(std::forward<T>(n)) {}
explicit Person(int idx): name(GetNameFromIdx(idx)) {}
private:
std::string name;
};
编译器也为我们生成一些函数,最后这个例子变成这样:
class Person
{
public:
template<typename T>
explicit Person(T&& n) : name(std::forward<T>(n)) {}
explicit Person(int idx): name(GetNameFromIdx(idx)) {}
Person(const Person& rhs);
Person(Person&& rhs);
private:
std::string name;
};
像下面的调用:
{
Person p1("zhangsan");
Person p2(p1);
}
当用Person实例p1初始化p2时,那么这个类变成这个样子:
class Person
{
public:
explicit Person(Person& n) : name(std::forward<Person&>(n)) {}
explicit Person(int idx): name(GetNameFromIdx(idx)) {}
Person(const Person& rhs);
Person(Person&& rhs);
private:
std::string name;
};
此时就要在这两个函数做出选择:
explicit Person(Person& n) : name(std::forward<Person&>(n)) {}
Person(const Person& rhs);
调用拷贝构造需要const修饰,而模板函数则不需要,所以最后匹配到这个模板函数,所以最后编译不通过,修改一下调用:
{
const Person p1("zhangsan");
Person p2(p1);
}
这个调用正好符合重载决议的原则,若让这个类作为基类,例如:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs):Person(rhs)
{ … }
SpecialPerson(SpecialPerson&& rhs):Person(std::move(rhs))
{ … }
};
当调用这两个子类的构造函数时,预想是要调用基类对应的函数,可是这样的调用被基类的模板函数精确匹配了,因为要想调用对应的基类函数就涉及到类型转换,所以同样编译不通过
上节提出了若使用通用引用重载引发的一些问题,那么解决这些问题有以下方案:
避免基于通用引用重载
解决问题最重要的方法就是在源头避免——不基于通用引用做重载,例如使用重构手法里的Rename:
void logAndAddById(int nId)
{
...
}
传递const T&
上节提到当增加const修饰符,那么就与Person类的复制拷贝构造函数达成精确匹配,这样做的缺点就是牺牲了效率
去除通用引用以传值的方式
像上面的例子可以按照下面方式修改:
class Person
{
public:
explicit Person(std::string n) : name(std::move(n)) {}
explicit Person(int idx): name(GetNameFromIdx(idx)) {}
private:
std::string name;
};
此时我们传递string类型、const char*类型、int、short等均会有对应的匹配函数,只不过可能牺牲了效率,代码变的通俗易懂
标签分派
当使用通用引用又不舍弃重载,那么可以使通用引用作为形参的一部分,利用其它形参作重载函数的区分,像下面的例子,引入一个分派函数,内部对其精确区分:
string FactoryFun(int nId)
{
return "";
}
std::multiset<std::string> names;
template<typename T>
void logAndAdd(T&& name,std::false_type)
{
names.emplace(std::forward<T>(name));
}
void logAndAdd(int nId,std::true_type)
{
names.emplace(FactoryFun(nId));
}
template<typename T>
void LogFunDispatch(T&& param)
{
logAndAdd(std::forward<T>(param), std::is_integral<std::remove_reference_t<T>>());
}
{
LogFunDispatch("zhangsan");
short nId1 = 10;
int nId2 = 11;
LogFunDispatch(nId1);
LogFunDispatch(nId2);
}
我们期望整型分派给void logAndAdd(int nId,std::true_type)
,其它型别分派给void logAndAdd(T&& name,std::false_type)
,我们使用标准库提供is_integral判断传入的实参型别,
但是前提必须移除附加在T上的修饰词——std::remove_reference_t,否则当我们传入一个左值int时,那么T被推导成int&,而int&并不是整型,进而导致调用了void logAndAdd(T&& name,std::false_type)
函数
std::remove_reference_t可以移除附加在型别上的所有引用修饰词,std::remove_reference_t是C++14的写法,C++11的写法是typename std::remove_reference<T>::type
std::false_type和std::true_type就是标签,标签的作用就是使重载按照我们的预期调用,这些标签在运行期不起任何作用,重载的决议不再依赖通用引用参数而是依赖标签,这种设计就是 标签分派
对于标签分派系统的实现是有一个面向客户端的、非重载的API,像LogFunDispatch,在API内部针对重载函数发起调用
对通用引用模板增加条件
在上一节提到了使用标签分派系统,如果将标签分派系统应用于类Person时并不合适,例如有些针对构造函数的调用可能绕过标签分派系统,但又不能一定会绕过标签分派系统,可以结合上面的解释想想 什么情况会绕过标签分派系统,什么情况又不一定绕过标签分派系统
那么如何解决类Person遇到的通用引用问题,这里我们可以先针对类Person遇到的问题提出解决的条件(我们的期望):
对于模板构造函数Person(T&& n)
期望接受实参string类型对象、const char*对象,相反,除此之外任何其他类型对象拒绝接收
对于构造函数Person(int idx)
只期望接受整型对象
对于编译器生成的构造函数
对于外部的调用更符合我们的期望,例如调用复制拷贝构造函数、移动构造函数等等,言外之意就是只针对Person类型、Person子类
有了我们的期望,那么剩下的就是如何实现,这里可以借助标准库的std::enable_if、std::decay、std::is_base_of,我们先解释一下这三个模板的语义:
std::enable_if
只有满足std::enable_if指定的条件才会启用
std::decay
移除参数上的const、volatile、引用修饰词
std::is_base_of
判定一个型别是否由另一个型别派生而来
所以这个类Person通用引用模板变成这样:
template<typename T,typename = std::enable_if_t<
!std::is_base_of<Person,std::decay_t<T>>::value &&
!std::is_integral<std::remove_reference_t<T>>::value>
>
explicit Person(T&& n)
这也正符合我们对这个模板的期望:不接受整型、不接受类似Person类型(Person本身、Person子类)。只有这些条件为真时通用引用模板才会启用
费了这么多精力就是为了达到我们的目的:
对于服务器工程师来讲,高效这一特性就足以让工程师疯狂,当然使用通用引用也有缺点,易用性上有劣势,可读性上有劣势等等
优势、劣势并存,在实际开发中经常会遇到并存权衡问题,其实个人认为权衡之术就是我们的初心