本文使用的Clang/LLVM环境:
Homebrew clang version 17.0.6
Target: arm64-apple-darwin23.0.0
Thread model: posix
此文中对象的概念沿用primer的说法,即认为对象是具有某种数据类型的内存空间
引用
平时说的引用 reference 一般是指左值引用 即lvalue reference
引用在定义的时候必须绑定对象 并且不能更改
常量引用,或者说常引用,一般都是”对const的引用”的简称,要知道不存在真正意义上的常量引用,因为引用不是一个对象,因此它也不可能恒定不变
int a = 10; |
常引用可以绑定到右值
const int& a = 10; |
是不是有点一头雾水了,对于一个萌新来说搞不明白什么是左值什么是右值,左值引用又能绑定哪些东西?
值类别
每个 C++ 表达式(带有操作数的操作符、字面量、变量名等)可按照两种独立的特性加以辨别:类型和值类别 (value category)。每个表达式都具有某种非引用类型,且每个表达式只属于三种基本值类别中的一种:纯右值 (prvalue)、亡值 (xvalue)、左值 (lvalue)。
—— cppreference
- 泛左值 glvalue 一个
- lvalue 左值:可被取地址的值
- xvalue 亡值:可以取地址,又像右值一样不会再被访问
- prvallue 纯右值:也就是c++11之前的右值概念 纯计算结果,不具有实际意义的地址(编译器需要一个地址去存储运算,但对程序员来说感知不到,就和不存在一样)
为什么需要从单纯的左值和右值。因为要配合移动语义。std::move()
考虑一个东东
std::vector<int> a{...}; |
如果$a$之后再也不会用到 并且复制的开销也很大
那么我们可以把a的内存转移到b上 这就是move语义
然而等号右边并不是单纯只有计算用途的右值 a可以拿来取地址 这就是亡值
看个实现
std::string str="hello"; |
这个输出就会是
hello
hello hello
这就是移动语义干的事,直接转移内存(对象),在某些情况下节省了构造和析构的开销
那么回到上面的vector例子,如果我们想要把a中对象所有权交给b,之后销毁a或者将它重新初始化,而非使用(这一点很重要!),那么我们可以
std::vector<int> a{1,1,4,5,1,4}; |
这样a“生前的”的内存就交给了b
这个过程中,move函数除了语义上的移动声明,事实上除了将左值表达式转换成亡值以外,并没有干什么事情
即单独的move实际上不能干什么事。在上个例子中,move事实上配合了vector对于右值的初始化重载函数{}
针对右值重载过的{}
配合移动语义将对象直接给了b
而且我在初学的时候,尽管试着用手头的环境去随便测试,但还是踏入了一个误区,就是主要用内置类型在测试,事实上这很容易让move和copy没毛的区别,自然也就没有推测出什么move的目标功能
右值引用
理解了右值之后,右值引用自然就是绑定到右值的引用,通过&&来获得
右值引用只能绑定到一个将要销毁的对象 一般用来延长对象的生命周期
标准库move函数也是 如果对一个左值使用move函数
若使用move函数 意味着承诺 销毁它 或者赋新值 而不是再次直接使用它
当然我们已经知道,只是调用move而不搭配对应语义的操作,什么事都不会发生
不过这里我一直以来有个误解
比方说以下的行为
vector<int> a{1,1,4,5,1,4}; |
手玩了以后发现似乎b似乎还是对a的左值引用,一开始不知道为什么,后来发现自己不知道在写什么东西
其实如果想把a中对象所有权交给b的话,只需要
vector<int> a{1,1,4,5,1,4}; |
就行了,这样b在接受右值作为初始化时,会自动重载,而如果只是用一个右值类型去接受转换成右值的a的话,不知道为什么最终表现b像是a的左值引用
估计还是得去编译器里看parser才知道,语法上面好像不能推断出来
返回值优化
进一步的 C++ 17以后rvo优化成为了标准,尽管在rvo加入标准之前,大部分编译器在C++11提供右值引用和移动语义之后就实现了 复制省略 - copy elision的编译器优化技术
若要关闭,请使用编译参数
-fno-elide-constructors |
简单说就是直接在调用者的栈上构造,避免触发拷贝、移动构造函数
当然因为C++17开始这成为了标准,用不用-fno-elide-constructors都没有影响了
所以如果自己想试试的话用C++14
看到有人说让标准来干这事,这不够C++什么的,就应当自己写引用和指针,我现在的想法就有点呵呵,那按照这种思想,编译器除了中间代码优化和后端生成优化之外,前面parser帮忙做的一些事情都叫程序员来做好了
比较经典的一个例子就是register的移除,原来OIer有些半吊子卡常方法就是说循环变量用register,这样会节省时间.. 真是呵呵了,那严格来说,想要用好这玩意儿事实上得先懂寄存器这块的知识,不然就是在寄希望于玄学和未知,怎么就能够肯定自己设置的register一定是效果最优的呢。事实上编译器能做的事就应该交给它做。rvo也是,如果想要绕过那显然有办法。但是对于没学过的人,肯定是提供一个更好的实现好,这就是很重要的一个理念,不必为不用到的东西付出代价,也是C++的优势所在。
当然这也只是我个人的想法,也许我过段时间学了点编译器之后会改变看法。
参考资料
- OI-wiki 值类别
- 知乎讨论
- C++ Primer
- C++ 编译器优化之 RVO 与 NRVO