> 文章列表 > 条款26:避免依万能引用型别进行重载

条款26:避免依万能引用型别进行重载

条款26:避免依万能引用型别进行重载

假定你需要撰写一个函数,取用一个名字作为形参,然后记录下当前日期和时间,再把该名字添加到一个全局数据结构中。可能你以开始拿出来的函数长得有点像下面这样:

std::multiset<std::string> names; //全局数据结构
void logAndAdd(const std::string& name)
{auto now = std::chrono::system_clock::now();  //取得当前时间log(now, "logAndAdd");   //制备日志条目names.emplace(name); 
}

这段代码无可厚非,只是效率方面未能尽如意。考虑一下三种可能的调用语句:

std::string petName("Darla");
logAndAdd(petName);                       //传递左值std::string
logAndAdd(std::string("PersePhone"));     //传递右值std::string
logAndAdd("Patty Dog")                    //传递字符串字面量

在第一个调用语句中,logAndAdd的形参name绑定到了变量petName,在logAndAdd内部,name最终被传递给了names.emplace.由于name是个左值,它是被复制入names的。没有任何办法避免这个复制操作,因为传递给logAndAdd的是个左值(petName)

在第二个语句中,形参name还是绑定到了一个右值,(从persephone显示构造的std::string型别的临时对象)。name自身是个左值,所以它是被复制入names的。但我们能够认识到,原则上是可以被移入names的。所以在这个调用中,我们付出了一次复制的成本,但我们可以用一次移动来达成同样的目标。

在第三个调用语句中,形参name还是绑定到了一个右值,但这次这个std::string型别的临时对象是从"Patty Dog"隐式构造的。和第二个调用语句的情况一样,name是被复制入names的,但在本语句中,传递给logAndAdd的实参是个字符串字面量。如果该字符串字面量被直接传递给emplace,那么完全没有必要构造一个std::string型别的临时对象,emplace完全可以利用这个字符串字面量在std::multiset内部直接构造出一个std::string对象。在这第三个调用中,我们付出了复制一个std::string对象的成本,但实际上我们连一次移动的成本都没有必要付出,更别说复制了。

我们可以解决第二个和第三个调用语句的效率低下问题,只需要重写logAndAdd,让它接受一个万能引用,并且根据条款25,对该引用实施std::forward给到emplace.重写结果不言自明:

template<typename T>
void logAndAdd(T&& name)
{auto now = std::chrono::system_clock::now();log(now, "logAndAdd");names.emplace(std::forward<T>(name));
}std::string petName("Darla");   //一如此前
logAndAdd(petName);             //一如此前,将左值复制入multeset
logAndAdd(std::string("Persephone"));  //对右值实施移动而非复制
logAndAdd("Patty Dog");                //在multiset中直接构造一个std::string对象,而非复制一个 //std::string型别的临时对象

非常完美,效率达到极致!

如果故事讲到这里就结束了,那我们就可以就此罢手荣誉收工了。但我并没有告诉过你,该函数的客户并不总能直接访问到logAndAdd所要求的名字。有些客户只能访问到一个索引,logAndAdd需要根据索引来查询一张表才能找到对应的名字。为了支持这样的客户,logAndAdd提供了重载版本:

std::string nameFromIdx(int idx);   //返回索引对应的名字void logAndAdd(int idx)
{auto now = std::chrono::system_clock::now();log(now, "logAndAdd");names.emplace(nameFromIdx(idx));
}

调用时的重载决议符合期望:

std::string petName("Darla");           //一如此前
logAndAdd(petName);                     //一如此前,这三个调用语句
logAndAdd(std::string("Persephone"));   //都调用了形参型别
logAndAdd("Patty Dog");                 //为T&&的重载版本logAndAdd(22);            //本句调用了形参型别为int的重载版本

 实际上,说重载决议符合期望,只是在期望不能过高的前提之下。假设某个客户使用了short型别的变量来持有这个索引值,并将该变量传递给了logAddAdd:

short nameIdx;
...                   //赋值给nameIdx
logAndAdd(nameIdx);   //错误!

上述代码最后一行的注释并不能很好说明问题,所以请容我解释这里到底法神了什么情况。

logAndAdd有两个重载版本。形参型别为T&&的版本可以将T推导为short,从而产生一个精确匹配。而形参型别为int的版本却只能在型别提升以后才能匹配到short型别的实参。按照普适的重载决议规则,精确匹配优先于提升后才能匹配 。所以形参型别为万能引用的版本才是被调用到的版本。

调用执行后,形参name被绑定到传入的short型别的变量上,然后name被std::forward传递给names(一个std::multiset<std::string>型别的对象)的emplace成员函数,再然后,又被例行公事地被转发给std::string的构造函数。而std::string的构造函数中并没有形参为short的版本,所以,由logAndAdd的调用触发了multiset::emplace的调用,后者又触发了std::string的构造函数的调用,到了这一步失败了。这一切的原因归根结底在于,对于short型别的实参来说,万能引用产生了比int更好的匹配。

形参为万能引用的函数,是C++中最贪婪的。它们会在具现过程中,和几乎任何实参型别都会产生精确匹配(条款30描述了几种不属于该情况的实参)。这就是为何把重载和万能引用这两者结合起来几乎总是馊主意:一旦万能引用成为重载候选,它就会吸引走大批的实参型别,远比撰写重载代码的程序员期望的要多。

填上这个坑的一个简单办法, 是撰写一个带完美转发的构造函数,对logAndAdd这个示例作了一点点修改,就暴露了问题。我们先不去撰写一个自由函数来同时取用std::string或一个用以查表返回std::string的索引,而是先考虑一个Person类,它的构造函数有相同的功能:

class Person
{
public:template<typename T>explicit Person(T&& n)           //完美转发构造函数:name(std::forward<T>(n)) {}    //初始化了数据成员explicit Person(int idx)     //形参为int的构造函数:name(nameFromIdx(idx)){}private:std::string name;
};

在logAndAdd的情景中,传入一个非int型别的整型(例如std::size_t,short,long等)都会导致调用形参为万能引用的构造函数重载版本,从而引发编译失败。但是上例中的情景则要糟糕得多,因为Person中还有比我们肉眼所见更多的重载版本。条款17解释了,在适当条件下,C++会同时生成复制和移动构造函数,并且这一点在即使类中包含着一个模板化的构造函数,且它可以具现出复制和移动构造函数的签名来的前提下也依然成立。假如Person中真的如此生成了复制和移动构造函数,那么它实际上是长成这样的:

class Person
{
public:template<typename T>explicit Person(T&& n)           //完美转发构造函数:name(std::forward<T>(n)) {}    //初始化了数据成员explicit Person(int idx)     //形参为int的构造函数:name(nameFromIdx(idx)){}Person(const Person& rhs);   //复制构造函数(由编译器生成)Person(Person&& rhs);        //移动构造函数(由编译器生成)...
private:std::string name;
};

只有花费了大量时间与编译器和写编译器的人打交道,才能形成对于程序行为的直觉,并忘记普通人的思维方式:

Person p("Nancy");
auto cloneOfP(p);   //从p出发创建新的Person型别对象;//上述代码无法通过编译

在这里我们尝试了从一个Person出发创建另一个Person,看起来再明显不过会是调用复制构造的情况(p是个左值,这就足以打消一切会将“复制”通过移动来完成的想法)。在这段代码竟没有调用复制构造函数,而是调用了完美转发构造函数。该函数是在尝试从一个Person型别的对象(p)出发来初始化另一个Person型别的对象的std::string型别的数据成员。而std::string型别却并不具备接受Person型别形参的构造函数,你的编译器只能悲愤地举手投降,也许会丢出一堆冗长且无法理解错误信息作为惩罚和发泄。

你可能感觉莫名其妙,“这是怎么回事?怎么会调用的是完美转发构造函数而不是复制构造函数呢?这不是明明在用一个Person型别的对象初始化另一个Person型别的对象吗?”,确实是在做这件事,但是编译器是宣誓效忠于C++规则的,而在这里用到的规则关乎调用重载函数时的决议。

编译器的推理过程如下:cloneOfP被非常量左值(p)初始化,那意味着模板构造函数可以实例化来接受Person型别的非常量左值形参。如此实例化后,Person的代码应该变换成下面这样:

class Person
{
public: explicit Person(Person& n)       //从完美转发模板触发实例化:name(std::forward<Person&>(n)){}explicit Person(int idx);Person(const Person& rhs);   //复制构造函数(由编译器生成)...
};

在下述语句中,

auto clineOfP(p);

p既可以传递给复制构造函数,也可以传递给实例化了的模板。但是,调用复制构造的话就要先对p添加const饰词才能匹配复制构造函数的形参型别,而调用了实例化了的模板却不要求添加任何饰词。因为,模板生成的重载版本是更佳匹配,所以编译器的做法完全符合设计:它调用了符合更佳匹配原则的函数。这么一来,“复制”一个非常量的左值Person型别对象,会由完美转发构造函数而不是复制构造函数来完成。

如果我们稍微改一下代码,使得所复制之物成为一个常量对象,反响就完全不同了:

const Person cp("Nancy");   //对象成为常量了
auto cloneOfP(cp);        //这回调用的是复制构造函数了

因为欲复制的对象是个常量,就形成了对复制构造函数形参的精确匹配。另一方面,那个模板化的构造函数可以经由实例化得到同样的签名,

class Person
{
public:explicit Person(const Person& n);   //从模板出发实例化//而得到的构造函数Person(const Person& rhs);        //复制构造函数(由编译器生成)
};

但不要紧,因为C++重载决议规则中有这么一条:若在函数调用时,一个模板实例化函数和一个非函数模板(亦即,一个常规函数)具备相等的匹配程度,则优先选用常规函数,根据这一条,在签名相同时,复制构造函数(它是个普通函数)就会压过实例化了的函数模板

(如果你想知道,为什么明明实例化了的模板构造函数已经生成了和复制构造函数一模一样的签名,编译器还会生成复制构造函数,请参考条款17)

完美转发构造函数与编译器生成的复制和移动操作之间的那些错综复杂的关系,再加上继承以后就变得更让人眉头紧锁。特别的,派生类的复制和移动操作的平凡实现会表现出让人大跌眼镜的行为。请看好:

class SpecialPerson: public Person
{
public:SpecialPerson(const SpecialPerson& rhs)  //复制构造函数:Person(rhs)                             //调用的是{...}                                    //基类的完美转发构造函数!SpecialPerson(SpecialPerson&& rhs)       //移动构造函数:Person(std::move(rhs))                  //调用的是{...}                                    //基类的完美转发构造函数!
};

注释写的很明白,派生类的复制和移动构造函数并未调用到基类的复制和移动构造函数,调用的是基类的完美转发构造函数!要理解背后的原因,请注意,派生类函数把型别为SpecialPerson的实参传递给了基类,然后子啊Person类的构造函数中完成模板实例化和重载决议。最终代码无法通过编译,因为std::string的构造函数中没有任何一个会接受SpecialPerson型别的形参。

我希望现在已经说服了你去尽可能避免以把万能引用型别作为重载函数的形参选项,不过,如果使用万能引用进行重载是个糟糕的思路,而你又需要针对绝大多数的实参型别实施转发,只针对某些实参型别实施特殊的处理,这时该怎么办呢?解决之道多种多样,方法实在太多,专门定制条款27进行讲述。

要点速记:

1.把万能引用作为重载候选型别,几乎总会让该重载版本在始料未及的情况下被调用到

2.完美转发构造函数的问题尤其严重,因为对于非常量的左值型别而言,他们一般都会形成相对于复制构造函数的更佳匹配,并且它们还会劫持派生类中对基类的复制和移动构造函数的调用。