- 假设有下面这样一个函数,它将一个字符串参数
name
加入到全局数据集合names
中,同时记录当前时间:
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
// 注: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
- 第一个调用中,入参是一个左值,置入容器时需要一次拷贝(
emplace
内部构造时调用string
的拷贝构造函数),行为正确且已无法进一步优化。 - 第二个调用中,入参是一个右值;然而函数参数
name
的声明类型是左值(const string&
),所以在置入容器时仍进行一次拷贝操作,而非移动。 - 第三个调用中,入参是一个字符串字面值(string literal)。尽管字面值可以直接被用在
emplace
中构造string
,这里实际发生的是:先隐式地用字面值构造一个临时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");
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
- 问题到此没有结束。假设现在有一个新需求:用户可能手里没有
name
,而是一个查找name
的索引idx
。为了支持,我们需要添加一个重载版本的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));
}
之前的所有调用都原样运行,一个新调用 logAndAdd(22)
的行为也如同预期,调用了新的重载版本。问题是,假设入参不是一个准确的 int
型,而比如是 short
:
short nameIdx;
... // give nameIdx a value
logAndAdd(nameIdx); // compile error!
结果是编译失败!原因是,两个重载版本,T&&
版可以实例化为 short
类型参数匹配,而 int
版需要一次类型转换参数才匹配。二者相比,编译器会选择前者,emplace
中试图用 short
去 构造一个 string
,结果自然是失败。 当用 int
参数调用时,两个版本都不用类型转换,编译器基于普通函数优先于模板函数实例化的原则选择了后者。
-
至此你应该已经能看出问题所在:万能引用参数太“贪婪”了——它能实例化以准确匹配几乎任何参数类型(例外在 Item 30 中描述),因此想要将其和重载同时使用通常是一个坏主意:万能引用版本会劫走远多于开发者预期的调用。
-
很容易踩进这个坑的场景是类的完美转发的构造函数。假设有一个类
Person
,其有一个string
成员name
:
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;
};
上例同样的问题仍然存在,而且还有更糟糕的:Item 17 描述了编译器会自动生成特殊函数,包括拷贝和移动构造函数:
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!
竟然会编译失败!而原因与之前相同:p
是一个 Person
,拷贝构造函数的参数是 const Person&
,需要一次 const
转换,而万能引用版本不需要,所以编译器会选择后者。这等同于试图用 Person
对象构造 string
,导致调用失败。只有当 p
的类型准确为 const Person
,编译器才会正确地调用拷贝构造版本。
- 如果再加上继承关系,问题还会更多:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) // copy ctor;
: Person(rhs) // calls base class forwarding ctor!
{ ... }
SpecialPerson(SpecialPerson&& rhs) // move ctor;
: Person(std::move(rhs)) // calls base class forwarding ctor!
{ ... } //
};
派生类的拷贝构造中对基类的拷贝又错误地调用了万能引用版本的构造函数,这次是因为参数 rhs
的类型是 const SpecialPerson&
而非 const Person&
——拷贝构造版本的匹配还是需要类型转换!
- 既然对万能引用参数进行重载是个坏主意,那么如果需要一个函数既能转发大部分类型参数,又需要特殊处理某几种类型参数该怎么办呢?请见下一节 Item 27。
总结
- 对万能引用参数进行重载几乎总会导致万能引用的原版本被调用得比预期更频繁。
- 完美转发构造函数的问题尤其多,因为它们通常会劫走拷贝和移动构造函数的 non-const 左值参数的调用和派生类参数的调用。