避免对通用引用进行重载
假如你要写一个函数,参数是name,它先记录当前日期和时间,然后把name添加到全局数据结构中。你可能会想出这样的一个函数:
std::multiset<std::string> names; // 全局数据结构
void logAndAdd(const string& name)
{
auto now = std::chrono::system_clock::now(); // 获取当前时间
log(now, "logAndAdd"); // 记录日记
names.emplace(name); // 把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对象是由字符串隐式创建而来。和第二个调用一样,name
是被拷贝到names
,但在这个例子中,一开始传递给logAndAdd
的参数是字符串。如果将字符串直接传递给emplace
,是不需要创建临时的std::string对象的。取而代之的是,emplace
会直接在std::multiset中用字符串构建std::string对象。在第三个调用中,我们还是要拷贝一个std::string对象的,不过我们真的没必要承担移动的开销,更何况移动。
通过重新写logAndAdd
,让其接受一个通用引用,然后服从条款25对通用引用使用std::forward,我们可以消除第二个调用和第三个调用的低效率。代码是这样的:
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"); // 在multisest内创建std::string,来代替创建临时std::string对象
yoooohu!最佳工作效率!
这就是故事的结尾了吗,我们可以功成身退了,不过,我没有告诉你,用户并不总是直接持有logAndAdd
需要的name
。一些用户只有名字表的索引,为了支持这些用户,我们重载了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); // 如前,这三个都是使用T&&的重载
logAndAdd(std::string("Persephone"));
logAndAdd("PattyDog");
logAndAdd(22); // 使用int重载
实际上,决策工作正常是因为你想的太少了。假如用户有个short类型持有索引,然后把它传递给logAndAdd
:
short nameIdx;
... // 给nameIdx赋值
logAndAdd(nameIdx); // 错误
最后一行的注释讲得不清楚,让我来解释这里发生了什么。
logAndAdd
有两个重载,其中的接受通用引用的重载可以将T推断为short,因此产生了精确匹配。而接受int的重载需要提升才能匹配short参数。任何一个正常的重载决策规则,精确匹配都会打败需要提升的匹配,所以会调用接受通用引用的重载。
在那个重载中,参数name
被绑定到传进来的short,然后name
被完美转发到names
(std::multiset<std::string>)的成员函数emplace
中,在那里,相应地,emplace
尽职地把short转发到std::string的构造函数。std::string不存在接收short的构造函数,因此multiset.emplace
内的std::string构造调用会失败。这所有的所有都是因为对于short类型,通用引用的重载比int的重载更好。
接受通用引用作为参数的函数是C++最贪婪的函数,它们可以为几乎所有类型的参数实例化,从而创建的精确匹配。这就是为什么结合重载和通用引用几乎总是个糟糕的想法:通用引用重载吸收的参数类型远多于开发者的期望。
一种容易掉进这个坑的方法是写完美转发的构造函数。对logAndAdd
这个例子进行小小的改动就可以展示这个问题,相比于写一个接受std::string或索引的函数,试着想象一个类Person,它的构造函数就是做那个函数的事情:
class Person {
public:
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的整型数会调用通用引用构造函数,然后那会导致编译失败。但是,这里问题更糟,因为比起看见的,Person会出现更多重载。条款17解释过在合适的条件下,C++会生成拷贝和移动构造,就算这个类有模板化的构造函数,它在实例化时也会生成拷贝和移动构造的签名。如果Person类生成移动和拷贝构造,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); // 编译器生成的移动构造
...
};
当你花大量时间在编译器和写编译器的人上时,才能忘记常人的想法,知道这会导致个直观的问题::
Person p("Nancy");
auto cloneOfP(p); // 从p创建一个新Person,这不能通过编译
在这里我们尝试用一个Person创建两一个Person,看起来明显是用拷贝构造的情况。(p是个左值,所以我们消除将“拷贝”换成移动的想法。)但这个代码不会调用拷贝构造函数,它会调用完美转发的构造函数,这个函数尝试用一个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既可以传递给拷贝构造,又可以传递给实例化模板。调用拷贝构造的话需要对p添加const才能匹配拷贝构造的参数类型,但是调用实例化模板不用添加什么。因此生成的模板是更加匹配的函数,所以编译器做了它们应该做的事情:调用更加匹配的函数。因此,“拷贝”一个非const的左值Person,会被完美转发构造函数处理,而不是拷贝构造函数。
如果我们稍稍改一下代码,让对象拷贝const对象,我们就会得到完全不一样结果:
const Person cp("Nancy"); // 对象是**const**的
auto cloneOfP(cp); // 调用拷贝构造!
因为对象拷贝的对象是const的,它会精确匹配拷贝构造函数。模板化构造函数可以实例化出一样的签名,
class Person {
public:
explicit Person(const Person& n); // 实例化的模板构造
Person(const Person& rhs); // 编译器生成的拷贝构造
...
};
不过这没关系,因为C++重载决策的一个规则是:当一个模板实例化函数和一个非模板函数(即,一个普通函数)匹配度一样时,优先使用普通函数。因此,在相同的签名下,拷贝构造(普通函数)胜过实例化模板函数。
(如果你想知道为什么在实例化模板构造函数可以得到拷贝构造的签名的情况下,编译器还能生成拷贝构造函数,请去复习条例17。)
完美转发构造函数与编译器生成的拷贝和移动构造函数之间的纠纷在继承介入后变得更加杂乱。特别是,派生类的拷贝和移动构造的常规实现的行为让你大出所料。看这里:
class SpecialPerson : public Person {
public:
SpecialPerson(const SpecialPerson& rhs) // 拷贝构造函数
: Person(rhs) // 调用基类的完美转发构造
{ ... }
SpecialPerson(SpecialPerson&& rhs) // 移动构造函数
: Person(std::move(rhs)) // 调用基类的完美构造函数
{ ... }
};
就像注释表明那样,派生类的拷贝和移动构造并没有调用基类的拷贝和移动构造,而是调用了基类的完美转发构造!要理解为什么,你要注意到派生类的函数把类型SpecialPerson传递给基类,然后实例化模板,重载决策,对Person使用完美构造函数。最终,代码不能通过编译,因为std::string没有以SpecialPerson为参数的构造函数。
我希望我现在可以说服你应该尽可能地避免以通用引用重载函数。不过,如果对通用引用进行重载是个糟糕的想法,而你需要转发参数,或者特殊处理一些参数,该怎么做呢?方法有很多种,因为太多了,所以我决定把它放进一个条款中,它就是条款27,也就是下一条款,继续看吧,你会和它撞个满怀的。
总结
需要记住的2点:
- 对通用引用进行重载几乎总是会导致这个重载函数频繁被调用,超出预期。
- 完美转发构造函数是特别有问题的,因为在接受非const左值作为参数时,它们通常比拷贝构造匹配度高,然后它们还能劫持派生类调用的基类的拷贝和移动构造。