假设我们要编写一个以“name”作为参数的函数,该函数记录当前的日期和时间,然后将“name”添加到全局数据结构中。你可能会这样编码:
std::multiset<std::string> names; // global data structure
void logAndAdd(const std::string& name)
{
auto now = // get current time
std::chrono::system_clock::now();
log(now, "logAndAdd"); // make log entry
names.emplace(name); // add name to global data
} // structure; see Item 42
// for info on emplace
这段代码没什么毛病,但它并没有达到应有的效率。考虑下面三种可能的调用:
std::string petName("Darla");
logAndAdd(petName); // pass lvalue std::string
logAndAdd(std::string("Persephone")); // pass rvalue std::string
logAndAdd("Patty Dog"); // pass string literal
在第一个调用中,logAndAdd的形参name绑定到了变量petName上。在logAndAdd函数内,name最终被传给了names.emplace。name是一个左值,所以它被拷贝了names里。因为传入logAndAdd的是一个左值(petName),所以没有任何办法避免这个拷贝操作。
在第二个调用中,形参name绑定了一个右值(从"Persephone"显式构建的临时std::string)。name本身是一个左值,所以它被拷贝进了names,但我们意识到,原则上,它的值是可以被移动到names中的。
在第三个调用中,形参name绑定到了一个右值,但这次是一个从"Patty Dog"隐式构建的临时std::string。同第二个调用类似,name也是被拷贝进names的,但在这个调用中,传递给logAndAdd的实参是个字符串字面值。如果字符串字面值被直接传给emplace,那就完全没必要构建一个临时std::string。相反,emplace可以直接利用字面值在std::multiset内部创建一个std::string对象。这么看来的话,在这个调用中,我们多付出了一个拷贝std::string的代价,但其实压根没必要付出拷贝的代价,甚至连移动的代价都没必要付出。
为了解决上述第二和第三个调用的低效问题,我们改造代码如下:
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"); // as before
logAndAdd(petName); // as before, copy
// lvalue into multiset
logAndAdd(std::string("Persephone")); // move rvalue instead
// of copying it
logAndAdd("Patty Dog"); // create std::string
// in multiset instead
// of copying a temporary
// std::string
很棒,效率达到了极致
如果故事到这里,就可以结束了。但是但有时客户无法直接获得logAndAdd需要的name。某些客户只能得到一个索引,logAndAdd需要根据索引,在一个表才能查找到对应的名字。为了支持这部分客户,logAndAdd提供了重载版本:
std::string nameFromIdx(int idx); // return name
// corresponding to idx
void logAndAdd(int idx) // new overload
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}
调用时的重载决议符合预期:
std::string petName("Darla"); // as before
logAndAdd(petName); // as before, these
logAndAdd(std::string("Persephone")); // calls all invoke
logAndAdd("Patty Dog"); // the T&& overload
logAndAdd(22); // calls int overload
其实,只是恰巧符合预期罢了。假设客户传给logAndAdd的index是一个short型:
short nameIdx;
… // give nameIdx a value
logAndAdd(nameIdx); // error!
为什么?
我们这里有两个logAndAdd的重载。形参为万能引用的版本可以将T推导为short,从而产生一个精确匹配。形参为int的版本只能通过提升类型才能匹配到short实参。所以,万能引用的logAndAdd版本被调用了。
在这个版本中,形参name绑定到了short变量上,随后name被std::forwarded到names的emplace函数上,然后又被转发到std::string的构造函数。但是std::string没有形参为short的构造函数版本,所以在这一步就失败了。
在C++中,形参为万能引用的函数时最贪心的。它们在具化过程中,几乎能和任何实参类型产生精确匹配(Item 30描述了几种不属于该情况的实参)。这就是本条款想说的,不要把万能引用和重载扯到一起。
为了解决这个坑,考虑写一个带完美转发的构造函数(我是没怎么想明白,作者打算怎么重新设计,但是接下来的例子还得继续翻译)。假设如下类:
class Person {
public:
template<typename T>
explicit Person(T&& n) // perfect forwarding ctor;
: name(std::forward<T>(n)) {} // initializes data member
explicit Person(int idx) // int ctor
: name(nameFromIdx(idx)) {}
…
private:
std::string name;
};
上面这个例子,比logAndAdd更糟糕!因为这里有很多我们肉眼看不到的重载函数。Item 17解释了,在适当的条件下,C++会同时生成拷贝构造函数和移动构造函数,即使类中包含一个能实例化出同拷贝或move构造函数同样函数签名的模板构造函数,它还是会这么做。如果Person类真的生成了拷贝构造和移动构造,它实际上应该是这样:
class Person {
public:
template<typename T> // perfect forwarding ctor
explicit Person(T&& n)
: name(std::forward<T>(n)) {}
explicit Person(int idx); // int ctor
Person(const Person& rhs); // copy ctor
// (compiler-generated)
Person(Person&& rhs); // move ctor
… // (compiler-generated)
};
有如下调用:
Person p("Nancy");
auto cloneOfP(p); // create new Person from p;
// this won't compile!
在这里我们尝试从一个Person再创建一个Person,看起来肯定会调用到拷贝构造函数。但这段代码没有调用拷贝构造函数,却调用了完美转发构造函数,进而导致用对象p初始化std::string类型的成员变量,但是std::string类的构造函数不接受Person类型。编译器随后就报告了一堆冗长且无法理解的错误。
我们分析一下编译器的推导过程:cloneOfP正在被非常量左值§初始化,意味着模板构造函数可以始化来接受非常量的Person类型的左值。如此实例化后,Person的代码看起来应该是这样:
class Person {
public:
explicit Person(Person& n) // instantiated from
: name(std::forward<Person&>(n)) {} // perfect-forwarding
// template
explicit Person(int idx); // as before
Person(const Person& rhs); // copy ctor
… // (compiler-generated)
};
在语句 **auto cloneOfP§;**中,P既可以传给拷贝构造,也可以传给实例化了的模板。但是,调用拷贝构造的话需要对P添加const修饰才能匹配拷贝构造的形参类型,但是调用实例化了的模板却不需要任何其他条件。如此一来,模板的重载就是更好的匹配了,编译器也做了它应该做的。
如果我们稍微修改一下代码,结果就不同了:
const Person cp("Nancy"); // object is now const
auto cloneOfP(cp); // calls copy constructor!
这样的话,就与拷贝构造函数的形参精确匹配了。但是模板化的构造函数也可以实例化为相同的签名:
class Person {
public:
explicit Person(const Person& n); // instantiated from
// template
Person(const Person& rhs); // copy ctor
// (compiler-generated)
…
};
不过没关系,因为C++重载决议规则中有一条:若在函数调用时,一个模板实例化函数和一个非模板函数具备相同的匹配程度,优先选用常规函数。
如果再把继承的场景引进来,就更乱了:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) // copy ctor; calls
: Person(rhs) // base class
{ … } // forwarding ctor!
SpecialPerson(SpecialPerson&& rhs) // move ctor; calls
: Person(std::move(rhs)) // base class
{ … } // forwarding ctor!
};
注释说的很明白,继承类的拷贝构造和移动构造并没有调用基类的拷贝构造和移动构造,而是调用基类的完美转发构造。原因是派生类将SpecialPerson类型传给它的基类。
总而言之吧,本条款就是想建议我们尽量避免利用万能引用作为函数重载的形参。不过,实际应用中,会遇到想转发大多数实参,进特别处理某些类型的实参的场景,该怎么办呢?请看Item 27。
Things to Remember
- 万能引用的重载几乎总是导致万能引用的版本被调用;
- 完美转发构造函数的问题尤其严重,因为比起非const左值,它们常常是更好的匹配,并且它们会劫持派生类调用基类的拷贝和move构造函数;