案例
元旦哪里去不了,就呆在家里折腾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
}
背后的知识点
两个&&
是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); // 1, OK, data2是右值引用,但是一个左值
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 右值引用
小结:引入右值引用的本质是,如果一个函数或表达式返回一个对象,那是一个纯右值,也被成为临时对象,对象会在当前语句执行完毕后即销毁。如果要使用这个临时对象里的内容,为了减少拷贝,可以把它里面的指针“拿”过来,把它的指针清空,让它能正常析构。这就是使用右值引用的中心思想。
引用的使用场景
问题来了,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有使用到
回到案例
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++中,引用的本质是指针,左值与右值的区分是进一步细化限制了指针的生命周期管理,给使用带来了灵活性,但也带来不易理解。右值引用是一个即将消亡的对象中的内容进一步转移复用,或者在函数模板中解决完美转发问题,本质都是为了减少对象的拷贝提升效率。