性能文章>【全网首发】Modern Cpp丨从万能引用到完美转发>

【全网首发】Modern Cpp丨从万能引用到完美转发原创

242835

 

你好,我是雨乐!

但凡阅读过源码,就知道STL里面充斥着大量的T&&以及std::forward,如果对这俩特性或者原理不甚了解,那么对源码的了解将不会很彻底,或者说是一知半解。之所以这么说,是因为当初吃过这个亏,在研究某个特性的时候,仅仅关注大体逻辑,而这种阅读方式往往忽略了某些非常重要的细节,以为自己了解了整个原理,结果往往就是这种被忽略的细节导致了线上故障(详见之前文章P1级故障,年终奖不保)。所以,今天借助本文,聊聊STL中两个常见的特性万能引用 和 完美转发,相信读完本文后,对这俩特性会有一个彻底的了解,然后嘴里不自觉吐出俩字:就这?😁

引言

记得几年前,同事在review我代码的时候,提了个意见,建议使用emplace_back()来替代push_back()。后面随着对Mordern Cpp的使用和了解,发现STL在几乎所有的容器中都提供了emplace()或者emplace_back()函数,旨在提高程序性能,示例如:

class MyClass {
public:
  MyClass(int a, float b) {}
  ~MyClass() {
    std::cout << "hello" << std::endl; // just test
  }
};

int main() {
  std::vector<MyClass> v;

  v.push_back(MyClass(2, 3.14));
  v.emplace_back(2, 3.14);
}

编译&运行后,发现,使用push_back()方式的时候,析构了两次;而使用emplace_back()方式则只析构了一次。

单从输出对push_back()进行分析:

  • • 创建临时对象:MyClass(2, 3.14)

  • • 在std::vector中分配内存,并调用拷贝构造函数(其实,源码中调用的是移动构造函数,因为在上述示例定义中没有移动构造,所以使用了拷贝构造)

  • • 释放临时对象

这个时候,不妨思考个问题,为什么在使用push_back()的时候要创建一个临时对象,然后通过拷贝的方式将其插入std::vector中,有没有办法直接避免这个临时对象操作,直接在push_back()中构造对象呢?

为了解决上述性能问题,自C++11起,std::vector<>中引入了一个新的成员函数emplace_back(),只需要将构造对象所需要的参数传入emplace_back(),该函数会自动创建对象并将其添加到std::vector<>中。如上面的代码:

v.emplace_back(2, 3.14);

在上述示例中,std::vector<>直接接收参数(2, 3.14),并在其内部构造MyClass对象,进而省略了移动构造或者拷贝构造这一步骤。

下面我们看下STL中对emplace_back()的实现:

template<typename... _Args>
reference emplace_back(_Args&&... __args)
         {
           bool __realloc = this->_M_requires_reallocation(this->size() + 1);
           _Base::emplace_back(std::forward<_Args>(__args)...);
           if (__realloc)
             this->_M_invalidate_all();
           this->_M_update_guaranteed_capacity();
           return back();
         }

忽略实现细节,我们看到对于emplace_back()函数,其有T&& 与 std::forward()...

继续看下一个示例,如下:

#include <iostream>

template <typename T>
void wrapper(T u) {
    fun(u);
}

class MyClass {};

void fun(MyClass& a) { std::cout << "in fun(MyClass&)\n"; }
void fun(const MyClass& a) { std::cout << "in fun(const MyClass&)\n"; }
void fun(MyClass&& a) { std::cout << "in fun(MyClass &&)\n"; }

int main(void) {
    MyClass a;
    const MyClass b;

    fun(a);
    fun(b);
    fun(MyClass());

    std::cout << "----- Wrapper ------\n";
    wrapper(a);
    wrapper(b);
    wrapper(MyClass());

    return 0;
}

在上述代码中,有一个模板函数wrapper(),其内部调用fun()函数。在main()函数中,通过使用wrapper()来调用fun()函数(可能有人会有疑问,为什么需要额外增加一个封装wrapper,其实这样做正是为了引入后面的主题,否则后面内容没法写了😁)。

编译&运行,输出如下:

in fun(MyClass&)
in fun(const MyClass&)
in fun(MyClass &&)
----- Wrapper ------
in fun(MyClass&)
in fun(MyClass&)
in fun(MyClass&)

通过如上输出,可以知道,通过fun()函数调用的时候,分别调用了参数左值引用(MyClass &)、左值常量引用(const MyClass &)以及右值引用(MyClass &&)的fun()函数,调用结果符合预期。然而,使用wrapper()函数调用后,结果都是调用了参数为左值引用(MyClass &)的fun()函数。

使用wrapper()函数调用后的结果,之所以如上,这是因为编译器在进行模板类型推断时,如果模板参数T是非引用类型,就会会忽略const。也就是说,编译器在wrapper()模板类型T进行推断时,所有T都被推断为MyCalss类,进而调用了参数为左值引用(MyClass &)的fun()函数。

那么,如果将wrapper()函数里面的参数改成引用形式呢?

template <typename T>
void wrapper(T &u) {
    fun(u);
}

尝试编译,报错如下:

错误:用类型为‘MyClass’的右值初始化类型为‘MyClass&’的非常量引用无效
     wrapper(MyClass());

根据提示,可知发生错误的代码如下:

wrapper(MyClass());

在上面的代码中,MyClass()是一个右值,而在wrapper()函数中,T被推断为MyClass类。而出现编译错误是因为wrapper()函数的参数是一个左值引用(即MyClass&),而传入的参数是一个右值(MyClass()),也就是说不能将一个右值传递给一个参数为左值引用的函数。

根据经验,const T&可以接收一个右值,因此我们可以额外新增一个函数,其参数为**const T&**,代码如下:

#include <iostream>

template <typename T>
void wrapper(T &u) {
    fun(u);
}

template <typename T>
void wrapper(const T &u) {
    fun(u);
}

class MyClass {};

void fun(MyClass& a) { std::cout << "in fun(MyClass&)\n"; }
void fun(const MyClass& a) { std::cout << "in fun(const MyClass&)\n"; }
void fun(MyClass&& a) { std::cout << "in fun(MyClass &&)\n"; }

int main(void) {
    MyClass a;
    const MyClass b;

    fun(a);
    fun(b);
    fun(MyClass());

    std::cout << "----- Wrapper ------\n";
    wrapper(a);
    wrapper(b);
    wrapper(MyClass());

    return 0;
}

编译&运行后,输出如下:

in fun(MyClass&)
in fun(const MyClass&)
in fun(MyClass &&)
----- Wrapper ------
in fun(MyClass&)
in fun(const MyClass&)
in fun(const MyClass&)

分析上面的输出可知,对于a和b分别调用了wrapper(T &)和wrapper(const T&),但是对于右值c,也调用了wrapper(const T&)函数,截止此时,程序运行上没问题,但多少欠缺点什么(没有将真实的类型反应出来,比如传入的是右值,却调用的是参数为const T&的函数),而且这种改动代码量有点大

此时,假设fun()函数有两个参数,那么我们的wrapper()函数则需要如下编写:

template <typename T>
void wrapper(T& u, T& v) {
    fun(u, v);
}
template <typename T>
void wrapper(const T& u, T& v) {
    fun(u, v);
}
 
template <typename T>
void wrapper(T& u, const T& v) {
    fun(u, v);
}
template <typename T>
void wrapper(const T& u, const T& v) {
    fun(u, v);
}

呃,以此类推,如果有n个参数,那么wrapper()的函数将为2的n次方个,这工作量,啧啧啧。。。

综上,可以看到,上面的实现方案,有两个缺点:

  • • wrapper()的个数随参数个数呈指数级增长

  • • 没法根据实际类型调用对应的函数(比如传入的右值也调用参数为右值的函数)

对于上面提到的两个缺点,自C++11起,可以使用万能引用完美转发来实现,下面将针对这两个新特性进行详细分析,从问题分析、解决以及原理的角度去进行讲解。

万能引用

万能引用(Universal Reference)由Effective C++系列的作者Scott Meyers提出,其对万能引用的定义如下:

If a variable or parameter is declared to have type T&& for some deduced type T, that variable or parameter is a universal reference.

也就是说对于形如T&&的变量或者参数,如果T可以进行推导,那么T&&称之为万能引用。换句话说,对于形如T&&的类型来说,其既可以绑定左值,又可以绑定右值,而这个的前提是T需要进行推导(如果T不能进行推导,那么T&&就代表右值引用,只能绑定右值)。

那么,对于万能引用,需要具备两个条件:形如T&&以及对T进行类型推导。既然提到了类型推导,在C++中涉及到类型推导的往往有模板(此处需为函数模板,类模板可行的原因在下面会有分析)和auto两类,最常见的万能引用方式如以下两种:

函数模板:

template<typename T>
void f(T&& param); // 存在类型推导,param是一个万能引用

auto类型推导:

auto&& var = var1; // 存在类型推导,var是一个万能引用

好了,接着我们看下面一段代码:

void fun(int &&a) { // a为右值引用
  // do sth
}

int main() {
  int a = 1;
  fun(a); // 编译器报错
  fun(1); // OK
}

编译上述代码,提示:

错误:无法将左值‘int’绑定到‘int&&’

从上面报错信息可以看出,a是一个左值(对于左值右值等概念,请阅读[Modern CPP]深入理解左值、右值),而函数fun()的参数是一个右值引用,因此,正如编译器所提示的那样,无法将一个左值绑定到右值引用上,这会导致编译器报错。

当然了,也可以采用上节的方式,新增一个参数为int&的函数(void fun(int &a)())来实现,不过缺点跟前面的一样,那就是新增函数的个数随着参数个数呈指数级增长。

尝试使用万能引用来解决此问题:

template <typename T>
fun(T &&a) {
  // do sth
}

int main() {
  int a = 1;
  fun(a);
  fun(0);
}

在上述代码中,我们定义了一个模板函数 fun,它接受一个名为 t 的万能引用参数。这意味着 t 可以是任何类型的引用,既可以是左值引用又可以是右值引用;当我们传递一个左值参数给 fun 函数时,编译器会自动推断出参数类型,并将 t 解释为一个左值引用。当我们传递一个右值参数时,编译器会将 t 解释为一个右值引用。

编译&运行,一切皆如所愿,完美~~

需要注意的是,万能引用依旧是一个引用,因此必须对它们执行初始化操作,并且其初始化操作决定了其表现类型:以右值初始化则表现为右值引用,反之则为左值引用。

类型推导

在上节中有提到,对于万能引用来说,条件之一就是类型推导,但是类型推导是万能引用的必要非充分条件,也就是说参数必须被声明为T&&形式不一定是万能引用。示例如下:

template<typename T>
void fun(std::vector<T>&& t); // t是左值引用

显然,在调用f时会执行类型推导,但是参数t的类型声明的形式并非T &&,而是std::vector  &&。 我们之前强调过,万能引用必须是T &&才行,因此,t是一个右值引用,如果尝试将左值传入,编译器将会报:

std::vector<int> v;
fun(v); // 错误,不能将左值绑定到右值

形如const T&&的方式也不是万能引用:

template<typename T>
void f(const T&& t); // t是右值引用

int main() {
  int a = 0;
  f(a); // 错误
}

好了,此时你可能会认为模板中的参数T &&必然是万能引用,但事实并非如此,因为模板也并非一定触发类型推导,考虑std::vector<>中的push_back成员函数:

template<class T, class Allocator = allocator<T>> // from C++
class vector { // Standards
public:
    void push_back(T&& x);
    …
};

该函数中出现了T &&这种形式,但由于成员函数在模板实例化之后才会存在,因此在实例化之前该成员函数可视为无效,假若我们当前对该模板执行实例化:

std::vector<int> v;

这直接导致该模板被实例化为:

class vector<int, allocator<int>> {
public:
    void push_back(int&& x); // rvalue reference
    …
};

也就是说T类型在声明变量v的时候就已经确定了,所以不存在类型推导,因此push_back成员函数的参数是一个右值引用。

相比之下,std::vector中的emplace_back成员函数则确实触发了类型推导:

template<class T, class Allocator = allocator<T>> // still from
class vector { // C++
public: // Standards
    template <class... Args>
    void emplace_back(Args&&... args);
    …
};

在该声明中,Args作为一个独立于类型T的参数包,将会在实例化之中仍然执行类型推衍,因此它是一个万能引用。

确定了万能引用的类型后,编译器需要推导出T&&中的T的真实类型:若传入的参数是一个左值,则T会被推导为左值引用;而如果传入的参数是一个右值,则T会被推导为原生类型(非引用类型)。这里面会涉及到编译器的 reference collapsing 规则,下面来总结一下。

引用折叠

既然提到了类型推导,就不得不提下引用折叠。

考虑以下代码:

template<typename T>
void fun(T&& u);

int a = 1;
fun(a); // int &

在前面提到过,如果传入的参数是一个左值,则T被推导成为左值引用。因此,对于上面的代码,T被推断为int &,那么u的类型难道是int& &&?

假如我们定义这样一个类型:

int b = 1;
int & && a = b;

显然,编译器会报错,而这里编译器却允许在一定的情况下进行隐含的多层引用推导,这就是 reference collapsing (引用折叠)。C++中有两种引用(左值引用和右值引用),因此引用折叠就有四种组合。引用折叠的规则:

如果两个引用中至少其中一个引用是左值引用,那么折叠结果就是左值引用;否则折叠结果就是右值引用。

为了加深理解,示例如下:

using T = int &;
T& r1; // int& & r1 -> int& r1
T&& r2; // int& && r2 -> int& r2
 
using U = int &&;
U& r3; // int&& & r3 -> int& r3
U&& r4; // int&& && r4 -> int&& r4

当编译器看到对引用的引用时,它会将结果表示为单个引用。如果原始两个引用中的任何一个是左值,则结果是左值,否则是右值(即如果两者都是右值)。

所以上面,由于T&是一个int&&,即一个左值引用的左值引用,结果是一个左值引用,对于T&&来说是int&&&,所以r2也是一个左值。

另一方面,在最后一个 U&& 的情况下,由于 int&& &&,r4 是一个右值引用。

上面的规则可能比较绕,可以把单个&当做1,&&当做0,做一个OR运算。根据这个规则,对于上面的内容:

using T = int &;
T& r1; // 相当于1 OR 1,结果为1,即int& & r1 -> int& r1
T&& r2; // 相当于1 OR 1,结果为1,即int& && r2 -> int& r2
 
using U = int &&;
U& r3; // 相当于0 OR 1,结果为1,即int&& & r3 -> int& r3
U&& r4; // 相当于0 OR 0,结果为0,即int&& && r4 -> int&& r4

了解了引用折叠的规则,我们接着回到前面章节中,仍然看下wrapper()函数:

wrapper(a);
wrapper(b);

推导出参数类型为MyClass& 和const MyConst &(这是引用折叠的结果,即MyClass & && 和 const MyClass & &&),这个推导与前面的编译器输出一致。

那么对于如下操作呢?

wrapper(MyClass());

 

T被推导为类型MyClass(如果传入的参数是一个右值,则T会被推导为原生类型(非引用类型)),因此参数的类型被推导为MyClass&&即一个右值。

接着看如下代码:

fun(u);

此时u的类型为右值引用,但是其本身是一个左值(这块非常重要),所以调用的是fun(MyClass&)函数。

从以上可以看出,对于使用万能引用,在进行函数调用的时候,会丢失类型,为了解决这个问题,c++提供了另外一个特性-完美转发(std::forward ,在前面的内容中已经有提现,只不过没有特意提罢了)。

完美转发

std::forward ()是C++11标准库提供的专门为转发而存在的函数。这个函数要么返回一个左值,要么返回一个右值。

其定义一般如下:

template<class T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept {
  return static_cast<T&&>(t);
}

template <class T>
T&& forward(typename std::remove_reference<T>::type&& t) noexcept {
  return static_cast<T&&>(t);
}

其内部实现只有一行代码,即static_cast<T&&>(t)使用static_cast<>进行类型转换,这个实现方式是否似曾相识,对,std::move()实现方式类似。

结合前面引用折叠(reference collapsing),当接收一个左值作为参数时,std::forward<>()返回左值引用,相应的,当接收一个右值作为参数时,std::forward<>()返回右值引用。

示例代码如下:

template<typename T>
wrapper(T &&t) {
  func(forward<T>(t));
}

如下调用wrapper()函数:

int i = 0;

wrapper(0);

在前面类型推导一节中,有提到:若传入的参数是一个左值,则T会被推导为左值引用;而如果传入的参数是一个右值,则T会被推导为原生类型(非引用类型),下面结合std::forward实现,我们分析下上述代码。

对于wrapper(i),由于i是一个左值,因此T会被推导成int &,所以会有形如如下代码:

int& && forward(int& t) noexcept {
    return static_cast<int& &&>(t);
}

经过引用折叠后,则变成:

int& forward(int& t) noexcept {
    return static_cast<int&>(t);
}

对于wrapper(0),T被推导成int,所以会有如下代码:

int&& forward(int&& t) noexcept {
    return static_cast<int&&>(t);
}

现在,使用万能引用和完美转发来修改我们的例子,如下:

代码如下:

#include <iostream>

template <typename T>
void wrapper(T &&u) { // 万能引用
    fun(std::forward<T>(u)); // 完美转发
}

class MyClass {};

void fun(MyClass& a) { std::cout << "in fun(MyClass&)\n"; }
void fun(const MyClass& a) { std::cout << "in fun(const MyClass&)\n"; }
void fun(MyClass&& a) { std::cout << "in fun(MyClass &&)\n"; }

int main(void) {
    MyClass a;
    const MyClass b;

    fun(a);
    fun(b);
    fun(MyClass());

    std::cout << "----- Wrapper ------\n";
    wrapper(a);
    wrapper(b);
    wrapper(MyClass());

    return 0;
}

编译&运行,输出如下:

in fun(MyClass&)
in fun(const MyClass&)
in fun(MyClass &&)
----- Wrapper ------
in fun(MyClass&)
in fun(const MyClass&)
in fun(MyClass &&)

好了,从上面输出可以看出,结果符合我们的预期。

需要说明的一点是,std::forward ()建议仅用于模板函数,对于非模板的,因为不涉及到类型推导,所以使用完美转发是没有意义的。

今天的文章就到这,我们下期见!

你好,我是雨乐,从业十二年有余,历经过传统行业网络研发、互联网推荐引擎研发,目前在广告行业从业8年。
目前任职某互联网公司高级技术专家一职,负责广告引擎的架构和研发。

 

点赞收藏
高性能架构探索

公众号《高性能架构探索》

请先登录,查看3条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

【全网首发】聊聊C语言中的malloc申请内存的内部原理

【全网首发】聊聊C语言中的malloc申请内存的内部原理

5
3