一个常见的业务背景
假设一个业务场景是这样的:
取用一个名字作为形参,然后记录下当前时间,在把该名字添加到一个全局数据结构中。也许你会这样实现:
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
,让它接收一个万能引用(见Item 24
),并且根据Item 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); //这里和上面一样,左值进行复制到multiset
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); //和forward版本行为一致
logAndAdd(std::string("Persephone")); //和forward版本行为一致
logAndAdd("Patty Dog"); //和forward版本行为一致
logAndAdd(22); //本句调用了形参型别为int的重载版本
但事情不是总是想象的那么美好,假设客户使用了一个short
型别的变量来持有这个索引值,并将该变量传递给了logAndAdd
:
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
为传参的版本。这一切的原因归根结底在于,对于short
的型别实参来说,万能引用产生了比int
更好的匹配。
形参为万能引用的函数,是C++中最贪婪的。他们会在具现过程中,和几乎任何实参型别都会产生精确匹配(例外情况详见Item 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 indx) : name(nameFromIdx(idx))
{} //形参为int的构造函数
private:
std::string name;
}
在logAndAdd
的情景中,传入一个非int型别的整型(例如std::size_t
、short
、long
等)都会导致调用形参为万能引用的构造函数重载版本,从而引发编译失败。但是上例中都的情景则要糟糕的多,因为在Person
中还有比我们肉眼所见更多的重载版本。Item 17
解释了,在适当条件下,C++会同时生成复制和移动构造函数,并且这一点在即使类中包含着一个模板化的构造函数,且它可以具现出复制和移动构造函数的前面来的前提下也依然成立。假如Person
中真的如此生成了复制和移动构造函数,那么它实际上会是呈现成这样的:
class Person {
public:
template<typename T>
explicit Person(T&& n) :name(std::forward<T>(n))
{} //完美转发构造函数
explicit Person(int idx); //形参为int的构造函数
Person(const Person& rhs); //复制构造函数(由编译器生成)
Person(Person&& rhs); //移动构造函数(由编译器生成)
...
};
只有花费了大量时间与编译器和写编译器的人打交道,才能形成对于程序行为的直觉,并忘记普通人的思维方式:
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:
template<typename T>
explicit Person(Person& n) :name(std::forward<Person&>(n))
{} //完美转发构造函数
explicit Person(int idx); //形参为int的构造函数
Person(const Person& rhs); //复制构造函数(由编译器生成)
...
};
在下述语句中,
auto cloneOfP(p);
p
既可以传递给复制构造函数,也可以传递给实例化了的模板。但是调用复制构造的话,就要先对p
添加const
修饰。因而,模板生成的重载版本是更加匹配,所以编译器的做法完全符合设计:它调用了符合更加匹配原则的函数。这么一来,“复制”一个非常量左值的左值Person
型别对象,会由完美转发构造函数,而不是复制构造函数来完成。
如果我们稍微修改一下代码,使得复制之物成为一个常量对象,反响就完全不同了:
const Person p("Nancy"); //对象成为常量了
auto cloneOfP(p); //这回调用的是复制构造函数了!
因为想要复制的对象是个常量,就形成了对复制构造函数形参的精确匹配。另一方面,那个模板化的构造函数可以经由实例化得到的同样的签名:
class Person {
public:
explicit Person(const Person& n); //从模板触发实例化
//而得到的构造函数
Person(const Person& rhs); //复制构造函数(由编译器生成)
...
};
但这并不要紧,因为C++重载决议规则中有这么一条:若在函数调用时,一个模板实力化函数和一个非函数模板(即,一个“常规”函数)具备相等的匹配程度,则优先选用常规函数。
根据这一条,在签名相同时,复制构造函数(它是个普通函数)就会压过实例化了的函数模板。
(如果你想知道,为什么明明实例化了的模板构造函数已经生成了和复制构造函数一模一样的签名,编译器还会生成复制构造函数,请参考Item 17
)
更有甚者,当继承遇上完美转发构造函数模板时
完美转发构造函数与编译器生成的复制和移动操作之间的那些错综复杂的关系,再加上继承以后就变的让人更加眉头紧锁。特别的,派生类的复制和移动操作的平凡实现会表现出让人大跌眼镜的行为。请看好:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) //复制构造函数:
:Person(rhs) //调用的是基类的完美转发函数!
{...}
SpecialPerson(SpecialPerson&& rhs) //移动构造函数:
:Person(std::move(rhs)) //调用的是基类的完美转发函数!
{...}
};
注释说的很明白,派生类的复制和移动构造函数并未调用到基类的移动和复制构造函数,调用到的是基类的完美转发构造函数!要理解背后的原因,请注意,派生类函数把型别为SpecialPerson
的实参传递给了基类,然后在Person类的构造函数中完成模板实例化和重载决议。最终,代码无法通过编译,因为std::string
的构造函数中没有任何一个会接受SpecialPerson
型别的形参。
小小总结
我希望我已经说服了你去尽可能避免以把万能引用型别作为重载函数的形参选项。不过,如果使用万能引用进行重载是个糟糕的思路,而你有需要针对绝大多数的实参型别实施转发,只针对某些实参型别实施特别处理,这时应该怎么做呢?解决的办法多种多样,后续Item 27
将详细展开。
要点速记 |
---|
1. 把万能引用作为重载候选型别,几乎总会让该重载版本在始料未及的情况下被调用。 |
2. 完美转发构造函数的问题尤其严重,因为对于非常量的左值型别而言,他们一般都会形成相对于复制构造函数的更加匹配,并且他们还会劫持派生类中对于基类的复制和移动构造函数的调用。 |