避免在万能引用上重载

本文最后更新于:April 20, 2022 am

本篇为介绍右值引用、移动语义和完美转发系列博客的第四篇,讨论在万能引用上重载的相关问题

右值引用,移动语义、完美转发系列目录:右值、移动语义和完美转发

问题的提出:尝试使用完美转发

假设我们需要写一个函数,它接收一个参数name,然后将当前的日期和时间记录日志,再将name加入到某个全局的容器中。最终写出来的函数可能长这样:

1
2
3
4
5
6
7
8
std::mutiset<std::string> names;
//全局容器

void logAndAdd(const std::string& name){
auto now=std:chrono::system_clock::now();//获取时间
log(now,"logAndAdd");//打印日志
names.emplace(name);//添加到容器中
}

这样写并无不妥,但是在效率上稍有瑕疵:

1
2
3
4
5
std::string petName("Darla");

logAndAdd(petName); // 传递左值std::string
logAndAdd(std::string("Persephone"));// 传递右值std::string
logAndAdd("Patty Dog");// 传递字符串字面量

第一次调用时,参数name被绑定到petName,最终被传递到emplace方法上。因为name是个常量左值引用,也就是左值,它被拷贝到容器names中。也正因为它是个左值,此处无法避免拷贝。

第二次调用时,参数name被绑定到一个右值上, 此右值通过std::string("Persephone")显式创建。但由于引用本身是个左值,在调用emplace方法时,它仍然被拷贝了。我们注意到,此处在逻辑上应该执行移动,而非拷贝。

第三次调用时,参数name被绑定到右值上,但此右值是编译器通过字符串字面量“Patty Dog”隐式创建的。接下来的就如同第二次调用那样,我们做了一次不必要的拷贝。但是这一次,实参本来是一个字符串字面量,如果我们能够用某种方法将此字面量直接传递给emplace,就完全可以避免创建一个临时std::string对象,从而让emplace直接在std::multiset内部使用字符串字面量创建一个std::string对象。因此,在这次调用中,我们实际多执行了一次拷贝,而实际上甚至不需要执行一次移动。

我们可以通过用万能引用重写这个函数的方式,消除第二、第三次调用引起的低效行为(参见Item24),并且,根据Item25,将引用转发到emplace方法上。

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void logAndAdd(T&& name){
auto now=std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

std::string petName("Darla");
logAndAdd(petName); // 传递左值std::string,拷贝一份副本
logAndAdd(std::string("Persephone"));// 传递右值std::string,用移动右值取代拷贝
logAndAdd("Patty Dog");// 传递字符串字面量,直接在emplace方法内部创建std::string对象

这样就无敌了,我们取得了最好的效率!如果这结束了,那我们就可以光荣且自豪地退休了

类型匹配的潜在问题

函数的调用者通常没有直接访问容器names的权限,而仅仅有一个查找容器的索引,为了支持这种操作,我们额外提供了一个logAndAdd的重载版本

1
2
3
4
5
6
7
std::string nameFromIdx(int idx); //用于通过索引查询容器返回name的函数

void logAndAdd(int idx){
auto now=std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}

从这些调用的结果来看,也都没有问题

1
2
3
4
5
6
std::string petName("Darla");

logAndAdd(petName);// 万能引用版本
logAndAdd(std::string("Persephone"));// 万能引用版本
logAndAdd("Patty Dog");// 万能引用版本
logAndAdd(22);// 调用int的重载版本

事实是,这样写没有问题的前提是,你不要有太高的期待。如果调用者有一个short类型的索引

1
2
3
short nameIdx;
...// get the value of nameIdx somewhere
logAndAdd(nameIdx); // error!

我们来仔细看看这里到底发生了什么。这里有两份重载版本,一份是万能引用作为形参,编译器可以将T推导为short,随即实例化一份类型完全匹配的函数。而再看看int版本,它可以被调用,但需要付出一个额外的类型提升操作。当有两份选择摆在编译器面前时,根据一般重载原则,完全匹配优于需要类型提升的匹配。因此,编译器最终调用了万能引用版本。

在万能引用版本内,name被绑定成传入的short值,然后被转发给std::multiset::emplace方法,此方法再将这个short值转发给了std::string的构造函数,但由于std::string类没有接收一个short类型参数的构造函数,编译器报错。一切都是因为万能引用的重载版本可以提供最佳的类型匹配。

以万能引用作为参数的函数是C++中最为贪心的,它们能够为几乎所有类型的参数提供最佳匹配的版本(仅存的几种特例将在Item30中讨论)。这也是将万能引用和重载一起使用总是一个坏主意:万能引用所能创造的重载远比开发者手动敲的版本多得多,并且很多类型是开发者没有想到的。

完美转发的构造函数

在介绍解决方案之前,假设我们需要将上述操作封装在类里,于是就有了一个完美转发的构造函数。在上述代码中稍微修改一下,就能让问题暴露得更彻底:

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

有人会问,这样做了最终的结果不和上面一样吗?是的,并且实际上,写成这样的问题比看到的更为严重,因为Person类实际上的构造函数会比我们看到的要多。Item17解释了,在合适的条件下,C++将会生成拷贝和移动构造器,即使这个类已经有参数化的构造器模板,并且这个模板构造器可以被实例化成众多不同版本的构造函数。如果Person类的拷贝构造器和移动构造器被生成了,其声明大致如下:

1
2
3
4
5
6
7
8
9
10
class Person{
public:
explicit Person(T&& n):name(std::forward<T>(n)){}
explicit Person(int idx);
Person(const Person& rhs);
Person(Person&& rhs);
...
private:
std::string name;
};

当我们想要创建一个副本时,可能会想要这样写:

1
2
3
Person p("Nancy");

auto cloneOfP(p);

一些经验丰富的开发者会脱口而出,这样写有问题!因为往往要花足够多时间和编译器以及编译器的作者一起倒腾,才能形成这种不像人类的,对程序行为预测准确得可怕的直觉。

这里我们希望从p创造一个拷贝,并且很明确,我们希望调用拷贝构造函数。但是这样写调用的反而是完美转发的构造器,编译器最终将p转发到std::string的构造函数上去,导致编译错误,编译器大概会愤怒地抛出一堆迷惑的错误信息。

为什么呢?我们明明希望调用拷贝构造器,并且确实也传入了一个Person的对象啊?难道编译器背叛了我们?其实导致出现这样的情况的原因,和使得我们之前调用short版本的函数时,发现编译失败的原因一致。我们来看看编译器的推导过程。

cloneOfP被非const的左值p初始化,这意味着可以通过实例化万能引用版本的构造器来提供一个接收非constPerson类的左值来进行构造,于是,Person类成了这样:

1
2
3
4
5
6
7
8
9
10
class Person{
public:
explicit Person(Person& n):name(std::forward<Person&>(n)){}
explicit Person(int idx);
Person(const Person& rhs);
Person(Person&& rhs);
...
private:
std::string name;
};

虽然,p也可以被传入Person的拷贝构造函数,但是那需要编译器在p上额外添加一个const,于是,相比之下,编译器选择了最佳匹配——万能引用,于是悲剧发生了。

如果我们稍微改变一下调用时的代码,调用就不会乱套:

1
2
const Person p("Nancy");
auto cloneOfP(p);

这样,p就完全匹配了拷贝构造函数,也就不会被传到万能引用上去了。因为即使万能引用版本的构造器也能如上一样实例化一个同样签名的构造函数出来,C++编译器有一条原则——当非模板的函数和函数模板都是最佳匹配时,选择非函数模板。(如果正在考虑为什么编译器可以为万能引用的构造器实例化一个签名同拷贝构造器完全相同的函数时,仍然要选择为Person类生成拷贝构造函数,可以复习一下Item17)。

继承体系中的新问题

这种完美转发的构造器和编译器生成的构造器之间的联系,在继承体系被纳入讨论范围之后会变得更加错综复杂。特别是,派生类的拷贝和移动构造器的传统实现在执行时会表现得让人大吃一惊:

1
2
3
4
5
6
7
8
class SpecialPerson: public Person{
public:
SpecialPerson(const SpecialPerson& rhs):Person(rhs){...}
// try to copy ctor, call base class copy ctor

SpecialPerson(SpecialPerson&& rhs):Person(std::move(rhs)){...}
// try to move ctor, call base class move ctor
};

在第三行和第六行,我们本想调用基类的拷贝构造函数和移动构造函数,但是天不遂人愿,实际上都调用了基类的完美转发构造函数,而理由也是一模一样,因为在选择让完美转发实例化成两个相应的最佳匹配和做一次向上转型之后调用基类的拷贝/移动构造函数中,编译器选择了前者。

至此,希望我们已经充分了解到,应该尽最大可能避免在万能引用上重载。但是如果不能这么做,当我们确实需要在大部分参数类型上做完美转发,但在一小部分类型上做特别处理时,该怎么做呢?我们其实有很多种解决方案,但这将会是下一篇博客需要介绍的内容。

总结

在万能引用的上进行重载,几乎不可避免地导致万能引用版本的函数被远超我们预想地频繁调用

完美转发的构造函数值得特别关照,因为它们通常都比拷贝构造函数更适配非const的左值,并且还抢夺派生类对基类的拷贝构造/移动构造函数的调用权


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!