Item 26: 避免对universal引用进行重载
本文翻译自《effective modern C++》,由于水平有限。故无法保证翻译全然正确。欢迎指出错误。
谢谢!
博客已经迁移到这里啦
假设你须要写一个以名字作为參数,并记录下当前日期和时间的函数。在函数中还要把名字加入到全局的数据结构中去的话。你可能会想出看起来像这种一个函数:
std::multiset<std::string> name; // 全局数据结构
void logAndAdd(const std::string& name)
{
auto now = // 得到当前时间
std::chrono::system_clock::now();
log(now, "logAndAdd"); // 产生log条目
names.emplace(name); // 把name加入到全局的数据结构中去
// 关于emplace的信息,请看Item 42
}
这段代码并非不合理。仅仅是它能够变得更加有效率。考虑三个可能的调用:
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中去的。可是我们知道,从原则上来说。它的值能被move到names中。在这个调用中,我们多做了一次拷贝,可是我们本应该通过一个move来实现的。
在第三个调用中,name參数再一次被绑定到了一个右值上,可是这次是由“Patty Dog”字符串隐式创建的暂时变量—std::string。就和另外一种调用一样。name试被复制到names中去的,可是在这种情况下,被传给logAndAdd原始參数是字符串。假设把字符串直接传给emplace的话,我们就不须要创建一个std::string暂时变量了。取而代之,在std::multiset内部,emplace将直接使用字符串来创建std::string对象。在第三种调用中,我们须要付出拷贝一个std::string的代价,可是我们甚至真的没理由去付出一次move的代价,更别说是一次拷贝了。
我们能通过重写logAndAdd来消除第二个以及第三个调用的低效性。我们使logAndAdd以一个universal引用(看Item24)为參数,而且依据Item 25,再把这个引用std::forward(转发)给emplace。结果就是以下的代码了:
templace<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")); // 用move操作代替拷贝操作
logAndAdd("Patty Dog"); // 在multiset内部创建
// std::string,代替对
// std::string暂时变量
// 进行拷贝
万岁。效率达到最优了!
假设这是故事的结尾,我能就此打住非常自豪地离开了,可是我还没告诉你client并非总能直接訪问logAndAdd所须要的name。一些client仅仅有一个索引值,这个索引值能够让logAndAdd用来在表中查找相应的name。为了支持这种client,logAndAdd被重载了:
std::string nameFromIdx(int idx); // 返回相应于idx的name
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")); // 都调用T&&版本号的重载
logAndAdd("Patty Dog");
logAndAdd(22); // 调用int版本号的重载
其实。决议结果能符合期待仅仅有当你不期待太多时才行。
假设一个client有一个short类型的索引,并把它传给了logAndAdd:
short nameIdx;
... // 给nameIdx一个值
logAndAdd(nameIdx); // 错误。
最后一行的凝视不是非常明白。所以让我来解释一下这里发生了什么。
这里有两个版本号的logAndAdd。一个版本号以universal引用为參数,它的T能被推导为short,因此产生了一个确切的匹配。以int为參数的版本号仅仅有在一次提升转换(译注:也就是类型转换。从小精度数据转换为高精度数据类型)后才干匹配成功。依照正常的重载函数决议规则,一个确切的匹配击败了须要提升转换的匹配。所以universal引用重载被调用了。
在这个重载中,name參数被绑定到了传入的short值。因此name就被std::forwarded到names(一个std::multiset<std::string>
)的emplace成员函数,然后在内部又把name转发给std::string的构造函数。可是std::string没有一个以short为參数的构造函数,所以在logAndAdd调用中的multiset::emplace调用中的std::string构造函数的调用失败了。这都是由于比起int版本号的重载,universal引用版本号的重载是short參数更好的匹配。
在C++中,以universal引用为參数的函数是最贪婪的函数。
它们能实例化出大多数不论什么类型參数的准确匹配。(它无法匹配的一小部分类型将在Item 30中描写叙述。)这就是为什么把重载和universal引用结合起来使用是个糟糕的想法:比起开发人员通常所能预想到的,universal引用版本号的重载使得參数类型失效的数量要多非常多。
一个简单的让事情变复杂的办法就是写一个完美转发的构造函数。一个对logAndAdd样例中的小修改能说明这个问题。
比起写一个以std::string或索引(能用来查看一个std::string)为參数的函数。我们不如写一个能做相同事情的Person类:
class Person {
publci:
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)将不会调用int版本号的构造函数,而是调用universal引用版本号的构造函数。然后这将导致编译失败。可是这里的问题更加糟糕。由于除了我们能看到的以外,这里还有别的重载出如今Person中。Item 17解释了在适当的条件下,C++将同一时候产生拷贝和move构造函数。即使类中包括一个能实例化出同拷贝或move构造函数相同函数签名的模板构造函数,它还是会这么做。因此。假设Person的拷贝和move构造函数被产生出来了,Person实际上看起来应该像是这样:
class Person {
public:
template<typename T>
explicit Person(T&& n)
: name(std::forward<T>(n)) {}
explicit Person(int idx);
Person(const Person& rhs); // 拷贝构造函数
// (编译器产生的)
Person(Person&& rhs); // move构造函数
… // (编译器产生的)
};
仅仅有你花了大量的时间在编译期和写编译器上,你才会忘记以人类的想法去思考这个问题,知道这将导致一个非常直观的行为:
Person p("Nancy");
auto cloneOfP(p); // 从p创建一个新的Person
// 这将无法通过编译。
在这里我们试着从另外一个Person创建一个Person。这看起来就拷贝构造函数的情况是一样的。(p是一个左值,所以我们能不去考虑“拷贝”可能通过move操作来完毕)。
可是这段代码不能调用拷贝构造函数。它将调用完美转发构造函数。然后这个函数将试着用一个Person对象(p)来初始化Person的std::string数据成员。std::string没有以Person为參数的构造函数,因此你的编译器将愤慨地举手投降,可能会用一大串无法理解的错误消息来表达他们的不快。
“为什么?”你可能非常奇怪,“难道完美转发构造函数代替拷贝构造函数被调用了?可是我们在用另外一个Person来初始化这个Person啊。”。我们确实是这么做的,可是编译器却是誓死维护C++规则的,然后和这里相关的规则是对于重载函数。应该调用哪个函数的规则。
编译器的理由例如以下:cloneOfP被用一个非const左值(p)初始化。而且这意味着模板化的构造函数能实例化出一个以非const左值类型为參数的Person构造函数。在这个实例化过后,Person类看起来像这样:
class Person {
public:
explicit Person(Person& n) // 从完美转发构造函数
: name(std::forward<Person&>(n)) {} // 实例化出来的构造函数
explicit Person(int idx); // 和之前一样
Person(const Person& rhs); // 拷贝构造函数
... // (编译器产生的)
};
在语句
auto cloneOfP(p);
中,p既能被传给拷贝构造函数也能被传给实例化的模板。
调用拷贝构造函数将须要把const加到p上去来匹配拷贝构造函数的參数类型,可是调用实例化的模板不须要这种条件。因此产生自模板的版本号是更佳的匹配,所以编译器做了它们该做的事:调用更匹配的函数。
因此,“拷贝”一个Person类型的非const左值会被完美转发构造函数处理。而不是拷贝构造函数。
假设我们略微改变一下样例,使得要被拷贝的对象是const的。我们将得到一个全然不同的结果:
const Person cp("Nancy"); // 对象如今是const的
auto cloneOfP(cp); // 调用拷贝构造函数!
由于被拷贝的对象如今是const的。它全然匹配上拷贝构造函数的參数。
模板化的构造函数能被实例化成有相同签名的函数,
class Person {
public:
explicit Person(const Person& n); //从模板实例化出来
Person(const Person& rhs); // 拷贝构造函数
// (编译器产生的)
...
};
可是这不要紧,由于C++的“重载决议”规则中有一条就是当模板实例和一个非模板函数(也就是一个“正常的”函数)都能非常好地匹配一个函数调用时,正常的函数是更好的选择。因此拷贝构造函数(一个正常的函数)用相同的函数签名打败了被实例化的模板。
(假设你好奇为什么当编译器能用模板构造函数实例化出同拷贝构造函数一样的签名时,它们还是会产生一个拷贝构造函数,请复习Item 17。)
当继承介入当中时,完美转发构造函数、编译器产生的拷贝和move构造函数之间的关系将变得更加扭曲。尤其是传统的派生类对于拷贝和move操作的实现将变得非常奇怪,让我们来看一下:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) // 拷贝构造函数。调用
: Person(rhs) // 基类的转发构造函数
{ … }
SpecialPerson(SpecialPerson&& rhs) // move构造函数,调用
: Person(std::move(rhs)) // 基类的转发构造函数
{ … }
};
就像凝视标明的那样,派生的类拷贝和move构造函数没有调用基类的拷贝和move构造函数,它们调用基类的完美转发构造函数!
为了理解为什么。注意派生类函数传给基类的參数类型是SpecialPerson类型,然后产生了一个模板实例,这个模板实例成为了Person类构造函数的重载决议结果。
最后,代码无法编译,由于std::string构造函数没有以SpecialPerson为參数的版本号。
我希望如今我已经让你确信,对于universal引用參数进行重载是你应该尽可能去避免的事情。可是假设重载universal引用是一个糟糕的想法的话,那么假设你须要一个函数来转发不同的參数类型,而且须要对一小部分的參数类型做特殊的事情。你该怎么做呢?其实这里有非常多方式来完毕这件事,我将花一整个Item来解说它们,就在Item 27中。下一章就是了。继续读下去,你会碰到的。
你要记住的事
- 重载universal引用经常导致universal引用版本号的重载被调用的频率超过你的预期。
- 完美转发构造函数是最有问题的,由于比起非const左值。它们经常是更好的匹配,而且它们会劫持派生类调用基类的拷贝和move构造函数。