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临时变量
                                        // 进行拷贝

万岁!效率达到最优了!

如果这是故事的结尾,我能就此打住很自豪地离开了,但是我还没告诉你客户端并不是总能直接访问logAndAdd所需要的name。一些客户端只有一个索引值,这个索引值可以让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);                     // 和之前一样,这些函数
logAndAdd(std::string("Persephone"));   // 都调用T&&版本的重载
logAndAdd("Patty Dog");                 

logAndAdd(22);                          // 调用int版本的重载

事实上,决议结果能符合期待只有当你不期待太多时才行。假设一个客户端有一个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构造函数。
时间: 2024-10-25 11:03:06

Item 26: 避免对universal引用进行重载的相关文章

Item 24: 区分右值引用和universal引用

本文翻译自<effective modern C++>,由于水平有限,故无法保证翻译完全正确,欢迎指出错误.谢谢! 古人曾说事情的真相会让你觉得很自在,但是在适当的情况下,一个良好的谎言同样能解放你.这个Item就是这样一个谎言.但是,因为我们在和软件打交道,所以让我们避开"谎言"这个词,换句话来说:本Item是由"抽象"组成的. 为了声明一个指向T类型的右值引用,你会写T&&.因此我们可以"合理"地假设:如果你在源代

Effective C++ Item 26 尽可能延后变量定义式的出现时间

本文为senlie原创,转载请保留此地址:http://blog.csdn.net/zhengsenlie 经验:尽可能延后变量定义式的出现.这样做可增加程序的清晰度并改善程序效率. 示例: //这个函数过早定义变量"encrypted" std::string encryptPassword(const std::string &password){ using namespace std; string encrypted; if(password.length() <

从C过渡到C++须注意的几个知识点(结构体、引用、重载运算符)

一.结构体和类(class) 下面一个使用结构体类型的例子 1 #include <iostream> 2 using namespace std; 3 struct Point{ // 声明Point结构体类型 4 double x; // 成员变量,没有使用private和public时系统默认为公有类型成员变量 5 double y; 6 }; 7 int main() 8 { 9 Point p; // 定义的p在c里称为结构体变量 10 p.x = 3.2; // 因为x是公有类型可

为什么类的拷贝构造参数加引用、重载赋值函数的返回值和参数加引用

class string { public: string(const char *str=NULL); string(const string& str);     //copy构造函数的参数为什么是引用呢? string& operator=(const string & str); //赋值函数为什么返回值是引用呢?参数为什么是引用呢? ~string(); }; 下面我就给大家解释一下: class String1 { public: String1(const char*

读书笔记 effective c++ Item 26 尽量推迟变量的定义

1. 定义变量会引发构造和析构开销 每当你定义一种类型的变量时:当控制流到达变量的定义点时,你引入了调用构造函数的开销,当离开变量的作用域之后,你引入了调用析构函数的开销.对未使用到的变量同样会产生开销,因此对这种定义要尽可能的避免. 2. 普通函数中的变量定义推迟 2.1 变量有可能不会被使用到的例子 你可能会想你永远不会定义未使用的变量,你可能要再考虑考虑.看下面的函数,此函数返回password的加密版本,提供的password需要足够长.如果password太短,函数会抛出一个logic

Effective JavaScript Item 26 使用bind来进行函数的柯里化(Curry)

本系列作为Effective JavaScript的读书笔记. 在上一个Item中介绍了bind的一种用法:用来绑定this对象.但是实际上,bind含有另一种用法,就是帮助函数进行柯里化.关于柯里化,这里有一份百科可以参考: http://zh.wikipedia.org/wiki/%E6%9F%AF%E9%87%8C%E5%8C%96 但是实际上,关于柯里化只需要记住一点就够了:柯里化是把接受多个参数的函数变换成接受一个单一参数(通常是最初函数的第一个参数,但是并无限制)的函数,并且返回这个

Effective Modern C++:05右值引用、移动语义和完美转发

移动语义使得编译器得以使用成本较低的移动操作,来代替成本较高的复制操作:完美转发使得人们可以撰写接收任意实参的函数模板,并将其转发到目标函数,目标函数会接收到与转发函数所接收到的完全相同的实参.右值引用是将这两个不相关的语言特性连接起来的底层语言机制,正是它使得移动语义和完美转发成了可能. 23:理解std::move和std::forward std::move并不进行任何移动,std::forward也不进行任何转发.这两者在运行期都无所作为,它们不会生成任何可执行代码.实际上,std::m

C++学习基础八——重载输入和输出操作符

一.重载输入操作符的要点: 1.返回值为istream &. 2.第一个参数为istream &in. 3.第二个参数为自定义类型的引用对象(例如Sales_Item &item). 二.重载输出操作符的要点: 1.返回值为ostream &. 2.第一个参数为ostream &. 3.第二个参数为自定义类的引用对象(例如const Sales_Item &item). 三.代码片段如下: 1 class Sales_Item 2 { 3 //注意:形参为引

JAVA 重载方法,参数为NULL时,调用的处理 (精确性原则)

引子:大家可以思考一下下面程序的输出结果 public class TestNull { public void show(String a){ System.out.println("String"); } public void show(Object o){ System.out.println("Object"); } public static void main(String args[]){ TestMain t = new TestMain(); t