8

飞哥讲代码19:C++中的左右值引用

 3 years ago
source link: http://lanlingzi.cn/post/technical/2021/2021_code/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

飞哥讲代码19:C++中的左右值引用

时间:

2021-01-03

  |   分类: 技术  

  |  

阅读: 4140 字 ~9分钟

元旦哪里也没有去,就呆在家里,折腾了VIM配置之后又看了一些C++的开源项目。国人开发的C++ web框架 drogon 在techempower上霸榜。techempower是一个专门给web框架做性能排名的网站。drogon在 Round19测试 中,综合成绩排第一。

drogon是基于C++14/17,采用CMake构建,跨平台,全异步,自带高性能模板引擎CSP,基于模板实现了简单的反射机制的Web框架。

我10年前写过大约5年多的C++代码,使用的也是传统的C++,C++11之后称为modern C++。不再使用C++做项目之后, 也就断断续续关注自学过,并没有实际的项目实战经验。所以看drogon的源码还算能看懂,但有些用法还是不太熟悉。drogon代码中大量存在如下代码:对于一个setXXX方法,写了const T& T &&两种入参。

    void setRecvMessageCallback(const RecvMessageCallback &cb)
    {
        recvMessageCallback_ = cb;
    }
    void setRecvMessageCallback(RecvMessageCallback &&cb)
    {
        recvMessageCallback_ = std::move(cb);
    }

还有这种用法:

  for (auto &backend : config["backends"])
  {
        backendAddrs_.emplace_back(backend.asString()); //并没有使用push_back
  }

1.1 背后的知识点

两个&&是C++11搞出来的新特性:右值引用 (Rvalue Referene) 。它是用来实现了转移语义 (Move Sementics) 和完美转发(Perfect Forwarding)。此特性都是为了极致的性能:消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。

什么是左值与右值?先看代码:

int i = 0; // 其中i是左值,0是临时值,就是右值
const int &a = 1; // 在 C++11 之前,右值是不能被引用的,最大限度就是用常量引用绑定一个右值

深入浅出 C++ 11 右值引用 把右值引用讲得非常透彻,以下内容摘抄原文。

变量(variable)与(value) 是两个概念:

  • 值只有类别(category)的划分,变量只有类型(type)的划分
  • 值不一定拥有身份 (identity),也不一定拥有变量名(例如表达式中间结果 i + j + k)

值类别(value category)可以分为两种:

  • 左值(lvalue, left value)是能被取地址、不能被移动的值
  • 右值(rvalue, right value)是表达式中间结果/函数返回值(可能拥有变量名,也可能没有)

引用类型(reference type)属于一种变量类型(variable type), 引用类型变量的初始化和其他的值类型(非引用类型)变量不同:

  • 创建时,必须显式初始化,和指针不同,不允许空引用 (null reference);但可能存在 悬垂引用 (dangling reference)
  • 相当于是其引用的值的一个别名(alias)。例如,对引用变量的赋值运算 (assignment operation)会赋值到其引用的值上
  • 一旦绑定了初始值,就不能重新绑定到其他值上了,和指针不同,赋值运算不能修改引用的指向

引用类型可以分为两种:

  • 左值引用(l-ref, lvalue reference) 用 & 符号引用左值(但不能引用右值)
  • 右值引用(r-ref, rvalue reference) 用 && 符号引用右值(也可以移动左值)
void f(Data&  data);  // 1, 左值引用
void f(Data&& data);  // 2, 右值引用

Data   data;

Data&  data1 = data;     // OK
Data&  data1 = Data{};   // not compile: 左值引用变量 data1 在初始化时,不能绑定右值 Data{}
Data&& data2 = Data{};   // OK
Data&& data2 = data;     // not compile: 右值引用变量 data2 在初始化时,不能绑定左值 data
Data&& data2 = std::move(data); // OK, 通过 std::move() 将左值转为右值引用

f(data);   // 1, OK, 左值
f(Data{}); // 2, OK ,右值
f(data1);  // 1, OK, 左值引用
f(data2);  // 2, OK, 右值引用

C++ 还支持了常引用(c-ref, const reference),同时接受左值/右值进行初始化:

void g(const Data& data); // 常引用
g(data); // 接受左值
g(Data{}); // 接受右值,Data{}这类也通常也叫纯右值

常引用和右值引用都能接受右值的绑定,其区别:

  • 通过右值引用/常引用 初始化的右值,都可以将生命周期扩展 (lifetime extension) 到绑定(扩展/延长到)该右值的引用的生命周期,
  • 初始化时 绑定了右值后,右值引用 可以修改 引用的右值,而 常引用 不能修改
const Data& data1 = Data{};   // OK: extend lifetime
data1.modify();               // not compile: const

Data&& data2 = Data{};        // OK: extend lifetime
data2.modify();               // OK: non-const

如果函数重载同时接受 右值引用/常引用 参数,编译器 优先重载 右值引用参数:

void f(const Data& data);  // 1, data is c-ref
void f(Data&& data);       // 2, data is r-ref

f(Data{});  // 2, prefer 2 over 1 for rvalue

内容有些多,本文不再摘抄std:move与std:forword的说明,请继续参见深入浅出 C++ 11 右值引用

小结:引入右值引用的本质是,如果一个函数或表达式返回一个对象,那是一个纯右值,也被成为临时对象,对象会在当前语句执行完毕后即销毁。如果要使用这个临时对象里的内容,为了减少拷贝,可以把它里面的指针“拿”过来,把它的指针清空,让它能正常析构。这就是使用右值引用的中心思想。

2 引用的使用场景

问题来了,C++函数传参的时候,左值引用(T&)、右值引用(T&&)和常引用(const T&)分别在什么场景下使用。

记得以前学习C++,指针和引用都是地址的概念。引用的代码编译后与指针的通常没有什么区分,引用可以理解为指针的语法糖。正如前文提到,引用是别名,引用在定义时就被初始化,之后无法改变。

  • 指针存在指针的指针,并且理论上没有级数没有限制,如T*** p
  • 引用只有一级,&&并不是引用的引用,而是右值引用

单纯一个左值引用真实就是指针的用法,常说引用比指针安全:

  • 引用在定义时就与变量绑定了,而指针不一定,指针在定义后没有初始化就是野指针
  • 引用与被引用的变量是同一个地址,使引用不用进行地址操作,这样使地址是不可修改的,使访问更加安全

普通函数的参数,整体原则是使用左值引用的值而不做修改,常见场景:

  • 如果传递的参数对象很小,如内置数据类型或者小型结构,则按照值传递
  • 如果传递的参数对象是数组,只能使用指针,并且通常要求是常指针
  • 如果传递的参数对象是较大的结构,可以使用常指针与常引用
  • 如果传递的参数对象是类对象,则使用常引用

左值引用隐含有不可修改的意义,所以常引用相比引用作为传递参数很常见,由于const的只读语义,参数的值也可以是一个纯右值。若把左值引用做为函数参数,则带来歧义,函数内到底要不要修改值,若修改,则建议是采用指针:

  • 左值引用(T&):不推荐使用,但在std库的swap,foward,move是出于其充分设计的考虑
  • 常引用(const T&):用于传递比较大的只读上下文对象

const T&能接受左值或右值,而T&&相较于const T&多了一个修改右值的能力,右值引用(T&&)在普通的函数中两种使用场景:

  • 一般只用于移动构建函数与移动赋值函数,用于转移使用权
  • 用于想移到它的值的场景,结合std::move使用

如果T是函数模板的类型参数,const T&的意义不变,而 T&& 的意义就变了。这时T&&则是一个“转发引用”,也叫通用引用,T&&并不一定表示右值引用,它可能是个左值引用又可能是个右值引用。

  • 没存在感的中间层,函数模板其实并不关心是具体类型,使用 T&&可以接收左值或右值的参数,并一般配合使用 std::forward 来完美转发到另外的函数里

对于函数的返回值,也是可以返回引用的:

  • 左值引用(T&):返回局部静态对象或类的成员对象引用,不能返回临时对象引用
  • 常引用(const T&):返回引用不可修改
  • 右值引用(T&&):很少见,在std库中move与forward有使用到

3 回到案例

grodon能霸榜,它能充分使用现代C++的特性,减少内存拷贝也是其中原因之一。

再来看一下代码本身,为什么要重载函数,实现两个不同(const T&)T&&,因为RecvMessageCallback其实是std::function,当它作为函数入参:

  • 若只有(const T&)函数,setRecvMessageCallback(RecvMessageCallback{}),也就可以接受lambda表达式作为函数的入参,lambda表达式生成了一个临时的std::function对象,是一个右值。如果只是简单的调用一下std::function类,那么没有问题;如果函数内部需要保存这个std::function,就必须做一次拷贝,否则当临时的对象销毁时,有可能出现引用悬空的问题。而这个拷贝是不经意的,难以发现主动优化,细节是恶魔
  • 若只有T&&函数,使用std::move,实现上述临时lambda对象移动转发(完美转发),不需要做一次拷贝,这样的效率更高了。但它不做了左值使用,所以还得需要(const T&)函数

对于stl中的容器,C++11也相应做了改进,基本上emplace_back()对应push_bakc(), emplce()对应insert()。打开源码发现emplace方法实现了完美转发(利用了c++ 11的新特性变长参数模板(variadic template),直接构造了一个新的对象,不需要拷贝或者移动内存):

vector<_Tp, _Allocator>::emplace_back(_Args&&... __args)
{
    if (this->__end_ < this->__end_cap())
    {
        __construct_one_at_end(_VSTD::forward<_Args>(__args)...);
    }
    else
        __emplace_back_slow_path(_VSTD::forward<_Args>(__args)...);
#if _LIBCPP_STD_VER > 14
    return this->back();
#endif
  • push_back:会优先选择调用移动构造函数,如果没有才会调用拷贝构造函数
  • emplace_back:可以减少一次拷贝或移动构造的过程,提升容器插入数据的效率

一路学习一下,C++太难了,为了性能,对开发人员要求太高,总结记录一下:

  • 两种值类型: 左值和右值
  • 两种->四种引用类型:
    • 左值引用(T&)只能绑定左值; 常量左值引用(const T&), 既可以绑定左值又可以绑定右值(将右值的生命期延长),
    • 右值引用(T&&)只能绑定右值; 通用引用(T&&)由初始化时绑定的值的类型确定(模板参数类型或auto推导)
  • 独立于类型:左值和右值是独立于他们的类型,右值引用可能是左值可能是右值,已经被命名的右值引用,是左值
  • 移动语义:可以减少无谓的内存拷贝,要想实现移动语义,需要实现移动构造函数和移动赋值函数。std::move()将一个左值转换成一个右值,强制使用移动拷贝和赋值函数。
  • 完美转发:通过一个函数将参数继续转交给另一个函数进行处理,原参数可能是右值,可能是左值,如果还能继续保持参数的原有特征,那么它就是完美。std::forward()和通用引用共同实现完美转发

在C++中,引用的本质是指针,左值与右值的区分是进一步细化限制了指针的生命周期管理,给使用带来了灵活性,但也带来不易理解。右值引用是一个即将消亡的对象中的内容进一步转移复用,或者在函数模板中解决完美转发问题,本质都是为了减少对象的拷贝提升效率。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK