26.避免针对universal reference作出重载

问题实例

 
假设当前需要一个以name作为参数的函数,该函数负责记录当前日期和时间,然后将name添加到一个全局数据结构中,它的一大可能实现如下所示:

1
2
3
4
5
6
std::multiset<std::string> names; // global data structure
void logAndAdd(const std::string& name){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd"); // make log entry
names.emplace(name);
}

该程序能够运行,但存在性能上的提升空间,考虑下述三种调用情况:
1
2
3
4
std::string petName("Darla");
logAndAdd(petName); // pass lvalue std::string
logAndAdd(std::string("Persephone")); // pass rvalue std::string
logAndAdd("Patty Dog"); // pass string literal

在第一次调用中,name被绑定于一个左值,转入logAndAdd后,name最终被传入names.emplace。因为name是一个左值,因此最终它被copy至names,我们无法阻止这种拷贝,因为移动左值会导致局部变量(petname)的失效。

在第二次调用中,name被绑定于一个右值,但需要明确的是name自身是一个左值,因此它被copy至names,此处可用move代替copy以提升性能。

在第三次调用中,我们首先从const char *处构造了一个临时string对象,然后用name绑定了该右值,随后将name复制到names,可见这里做了大量的无用功,我们本应直接利用const char*在names中构建string。


解决方案

 
我们可以按照Item24、25中所提及的方法重写logAndAdd:

1
2
3
4
5
6
7
8
9
10
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


更进一步的问题实例

 
事实上客户端并非总是能够直接获取name,有些客户端只拥有一个获取对应name的索引,针对这种情况,我们将logAndAdd重载:

1
2
3
4
5
6
std::string nameFromIdx(int idx); // return name corresponding to idx
void logAndAdd(int idx){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}

但这个重载版本的鲁棒性不太好:
1
2
3
short nameIdx;
// give nameIdx a value
logAndAdd(nameIdx); // error!


问题剖析

 
当前存在两个重载版本,带有universal reference的版本可以轻松地推衍出short,然后实现精准匹配,然后雪崩,参数为int的版本根本说不上话。

我们可以认为以universal reference为参数的函数几乎可以与任何参数完成精准匹配(因为它几乎可以绑定至一切类型,不能匹配的几种情况可见Item30),这也说明重载以universal reference的函数并不可取。


转发函数与默认函数

 
我们设想一下当前存在一个class Person:

1
2
3
4
5
6
7
8
9
class Person {
public:
template<typename T>
explicit Person(T&& n):name(std::forward<T>(n)) {}
explicit Person(int idx):name(nameFromIdx(idx)) {}

private:
std::string name;
};

类似于logAndAdd,当传入size_t或者short时直接可能会导致Person构造函数雪崩,不过当前问题比那个还要严重。原因在于重载函数的个数远比你肉眼所见的要多。Item17曾经提及在适当的条件下,C++将会自动生成copy constructor与move constructor,即使该class已经具备了能够实例化构造函数的模板。因此,我们可以认为Person实际上如下所示:
1
2
3
4
5
6
7
8
class Person {
public:
template<typename T>
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)
};

考虑一下以下调用:
1
2
Person p("Nancy");
auto cloneOfP(p); // create new Person from p;this won't compile!

上述操作不会调用copy ctor,而是会调用转发构造函数,然后该函数将尝试使用Person对象p初始化Person的std::string数据成员。std::string没有接受Person类型的构造函数,因此触发雪崩。有人会疑惑编译器为什么不调用copy ctor,这是由C++重载函数调用准则决定的。

cloneOfP正在使用非const左值(p)进行初始化,这表明当前可以实例化出一个以non-const Person为参数的构造函数:

1
2
3
4
5
class Person {
public:
explicit Person(Person& n):name(std::forward<Person&>(n)) {}
explicit Person(int idx);
}

在调用语句中,
1
auto cloneOfP(p);

p似乎既可以触发copy ctor也能触发实例化函数,但实际上调用copy ctor时需要为p添加const,而实例化函数则不需要这项要求,因此编译器选择最佳匹配方案:调用转发构造函数。

如果我们将p改为const,那么copy ctor则可被顺利调用:

1
2
const Person cp("Nancy"); // object is now const
auto cloneOfP(cp); // calls copy constructor!

此时同样会实例化一个以const Person&为参数的转发构造函数,但在C++重载规则中,非模板函数优先级总是高于模板函数,因此本次将调用copy ctor。

一旦引入继承,转发函数与自动生成的函数之间会产生更多的交互,例如:

1
2
3
4
5
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs):Person(rhs){ … }
SpecialPerson(SpecialPerson&& rhs):Person(std::move(rhs)){ … }
};

需要注意的是,派生类的拷贝、移动构造函数不会调用它们的基类对应的此类函数,而是将调用基类的转发构造函数。原因在于传入的实参优先匹配于转发函数。


总结

 
本节并未描述问题解决方案,在下一节中我们将对此继续讨论。

  1. universal refernce作为参数的函数不适合被重载。
  2. 转发函数经常会干涉别的函数的调用,并可能会因此产生极为恶劣的后果。