Effective Modern C++ Item 26 避免依万能引用型别进行重载

一个常见的业务背景

假设一个业务场景是这样的:

取用一个名字作为形参,然后记录下当前时间,在把该名字添加到一个全局数据结构中。也许你会这样实现:

std::multiset<std::string> names;       //全局数据结构

void logAndAdd(const std::string name)
{
    auto now = 
    std::chrono::system_clock::now();   //获取当前时间
    log(now, "logAndAdd");              //制备日志条目
    names.emplace(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型别的临时对象是从Patty Dog隐式构造的。和第二个调用语句的情况一样,name是被复制入names的,但在本语句中,传递给logAndAdd的实参是个字符串字面量。如果该字符串字面量是被直接传递给emplace,那就完全没有必要构造一个std::string型别的临时对象。emplace完全可以利用这个字符串字面量在std::multiset内部直接构造一个std::string对象。这里我们付出了复制一个std::string对象的成本,但实际上我们连一次移动的成本都没有必要付出,更别说复制了。

我们可以解决第二个和第三个调用语句的效率低下问题,只需重写logAndAdd,让它接收一个万能引用(见Item 24),并且根据Item 25,对该引用实施std::forward给到emplace。重写结果不言自明:

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");                 //在multiset中直接构造一个std::string对象,
                                        //而非复制一个std::string型别的临时对象

非常完美,效率达到极致了!

这个常见业务场景的后续拓展需求,打开了潘多拉魔盒

但实际上,该函数的客户并不总能直接访问到logAndAdd所需要的名字。有些客户只能访问到一个索引,logAndAdd需要根据该索引来查询一张表才能找到对应的名字。为了支持这样的客户,logAndAdd提供了重载版本:

std::string nameFromIdx(int idx);       //返回索引对应的名字

void logAndAdd(int idx)                 //新的重载函数
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));    
}

调用时的重载决议符合期望:

std::string petName("Darla");

logAndAdd(petName);                     //和forward版本行为一致
logAndAdd(std::string("Persephone"));   //和forward版本行为一致
logAndAdd("Patty Dog");                 //和forward版本行为一致

logAndAdd(22);                          //本句调用了形参型别为int的重载版本

但事情不是总是想象的那么美好,假设客户使用了一个short型别的变量来持有这个索引值,并将该变量传递给了logAndAdd:

short nameIdx;          //赋值给nameIdx
...
logAndAdd(nameIdx);     //错误!

这里解释一下,为何会错误:

logAndAdd有两个重载版本。形参型别为T&&的版本可以将T推导为short,从而产生一个精确匹配。而形参型别为int的版本却只能在型别提升以后才能匹配到short型别的实参。按照普适的重载决议规则,精确匹配优先于提升后才能匹配。所以,形参型别为万能引用的版本才是被调用到的版本。

调用执行后,形参name被绑定到传入的short型别变量上。然后,namestd::forward传递给names(一个std::multiset<std::string>)的emplace成员函数,然后,又被转发给std::string的构造函数。而在std::string的构造函数并不存在以short为传参的版本。这一切的原因归根结底在于,对于short的型别实参来说,万能引用产生了比int更好的匹配。

形参为万能引用的函数,是C++中最贪婪的。他们会在具现过程中,和几乎任何实参型别都会产生精确匹配(例外情况详见Item 30)。这就是为何把重载和万能引用两者结合起来,几乎总是馊主意:一旦万能引用成为重载候选,他就会吸引走大批的实参型别,远比撰写重载代码的程序猿期望的要多

为了解决打开魔盒的后果,我们进行了一个更“好”的尝试

填上这个坑的一个简单办法,是撰写一个带完美转发的构造函数。
请记住这话是个挖下了一个更大的坑。但我们继续看为什么这个坑更大

logAndAdd这个示例做了一点点修改,就暴露了问题。我们先不去撰写一个自由函数来同时取用std::string或一个用以查表返回std::string的索引,而是先考虑一个Person类,它的构造函数有相同的功能:

class Person {
public:
    template<typename T>
    explicit Person(T&& n) : name(std::forward<T>(n))
    {}                                  //完美转发构造函数,初始化数据成员
    explicit Person(int indx) : name(nameFromIdx(idx))
    {}                                  //形参为int的构造函数
private:
    std::string name;
}

logAndAdd的情景中,传入一个非int型别的整型(例如std::size_tshortlong等)都会导致调用形参为万能引用的构造函数重载版本,从而引发编译失败。但是上例中都的情景则要糟糕的多,因为在Person中还有比我们肉眼所见更多的重载版本。Item 17解释了,在适当条件下,C++会同时生成复制和移动构造函数,并且这一点在即使类中包含着一个模板化的构造函数,且它可以具现出复制和移动构造函数的前面来的前提下也依然成立。假如Person中真的如此生成了复制和移动构造函数,那么它实际上会是呈现成这样的:

class Person {
public:
    template<typename T>
    explicit Person(T&& n) :name(std::forward<T>(n))
    {}                              //完美转发构造函数
    explicit Person(int idx);       //形参为int的构造函数
    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被非常量左值(p)初始化,那意味着模板构造函数可以实例化来接受Person型别的非常量左值形参。如此实例化后,Person的代码应该变成下面这样:

class Person {
public:
    template<typename T>
    explicit Person(Person& n) :name(std::forward<Person&>(n))
    {}                              //完美转发构造函数
    explicit Person(int idx);       //形参为int的构造函数
    Person(const Person& rhs);      //复制构造函数(由编译器生成)
    ...
};

在下述语句中,

auto cloneOfP(p);

p既可以传递给复制构造函数,也可以传递给实例化了的模板。但是调用复制构造的话,就要先对p添加const修饰。因而,模板生成的重载版本是更加匹配,所以编译器的做法完全符合设计:它调用了符合更加匹配原则的函数。这么一来,“复制”一个非常量左值的左值Person型别对象,会由完美转发构造函数,而不是复制构造函数来完成。

如果我们稍微修改一下代码,使得复制之物成为一个常量对象,反响就完全不同了:

const Person p("Nancy");    //对象成为常量了

auto cloneOfP(p);           //这回调用的是复制构造函数了!

因为想要复制的对象是个常量,就形成了对复制构造函数形参的精确匹配。另一方面,那个模板化的构造函数可以经由实例化得到的同样的签名:

class Person {
public:
 explicit Person(const Person& n);  //从模板触发实例化
                                    //而得到的构造函数
Person(const Person& rhs);          //复制构造函数(由编译器生成)
...
};

但这并不要紧,因为C++重载决议规则中有这么一条:若在函数调用时,一个模板实力化函数和一个非函数模板(即,一个“常规”函数)具备相等的匹配程度,则优先选用常规函数

根据这一条,在签名相同时,复制构造函数(它是个普通函数)就会压过实例化了的函数模板。

(如果你想知道,为什么明明实例化了的模板构造函数已经生成了和复制构造函数一模一样的签名,编译器还会生成复制构造函数,请参考Item 17

更有甚者,当继承遇上完美转发构造函数模板时

完美转发构造函数与编译器生成的复制和移动操作之间的那些错综复杂的关系,再加上继承以后就变的让人更加眉头紧锁。特别的,派生类的复制和移动操作的平凡实现会表现出让人大跌眼镜的行为。请看好:

class SpecialPerson: public Person {
public:
    SpecialPerson(const SpecialPerson& rhs) //复制构造函数:
    :Person(rhs)                            //调用的是基类的完美转发函数!
    {...}
    SpecialPerson(SpecialPerson&& rhs)      //移动构造函数:
    :Person(std::move(rhs))                 //调用的是基类的完美转发函数!
    {...}
};

注释说的很明白,派生类的复制和移动构造函数并未调用到基类的移动和复制构造函数,调用到的是基类的完美转发构造函数!要理解背后的原因,请注意,派生类函数把型别为SpecialPerson的实参传递给了基类,然后在Person类的构造函数中完成模板实例化和重载决议。最终,代码无法通过编译,因为std::string的构造函数中没有任何一个会接受SpecialPerson型别的形参。

小小总结

我希望我已经说服了你去尽可能避免以把万能引用型别作为重载函数的形参选项。不过,如果使用万能引用进行重载是个糟糕的思路,而你有需要针对绝大多数的实参型别实施转发,只针对某些实参型别实施特别处理,这时应该怎么做呢?解决的办法多种多样,后续Item 27将详细展开。

要点速记
1. 把万能引用作为重载候选型别,几乎总会让该重载版本在始料未及的情况下被调用。
2. 完美转发构造函数的问题尤其严重,因为对于非常量的左值型别而言,他们一般都会形成相对于复制构造函数的更加匹配,并且他们还会劫持派生类中对于基类的复制和移动构造函数的调用。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值