值类别和引用浅析

本文使用的Clang/LLVM环境:

Homebrew clang version 17.0.6
Target: arm64-apple-darwin23.0.0
Thread model: posix

此文中对象的概念沿用primer的说法,即认为对象是具有某种数据类型的内存空间

引用

平时说的引用 reference 一般是指左值引用 即lvalue reference

引用在定义的时候必须绑定对象 并且不能更改

常量引用,或者说常引用,一般都是”对const的引用”的简称,要知道不存在真正意义上的常量引用,因为引用不是一个对象,因此它也不可能恒定不变

1
2
3
4
5
int a = 10;
const int &b = a;
std::cout << b << ' ';
a = 15, std::cout << b << '\n';
// b = 15; // 会发生编译错误

常引用可以绑定到右值

1
const int& a = 10;

是不是有点一头雾水了,对于一个萌新来说搞不明白什么是左值什么是右值,左值引用又能绑定哪些东西?

值类别

每个 C++ 表达式(带有操作数的操作符、字面量、变量名等)可按照两种独立的特性加以辨别:类型和值类别 (value category)。每个表达式都具有某种非引用类型,且每个表达式只属于三种基本值类别中的一种:纯右值 (prvalue)、亡值 (xvalue)、左值 (lvalue)。
—— cppreference

  • 泛左值 glvalue 一个
  • lvalue 左值:可被取地址的值
  • xvalue 亡值:可以取地址,又像右值一样不会再被访问
  • prvallue 纯右值:也就是c++11之前的右值概念 纯计算结果,不具有实际意义的地址(编译器需要一个地址去存储运算,但对程序员来说感知不到,就和不存在一样)

为什么需要从单纯的左值和右值。因为要配合移动语义。std::move()

考虑一个东东

1
2
3
std::vector<int> a{...};
std::vector<int> b;
b = a;

如果$a$之后再也不会用到 并且复制的开销也很大

那么我们可以把a的内存转移到b上 这就是move语义

然而等号右边并不是单纯只有计算用途的右值 a可以拿来取地址 这就是亡值

看个实现

1
2
3
4
5
6
7
std::string str="hello";
std::vector<std::string> v;
v.push_back(str);
std::cout << str << '\n';
v.push_back(std::move(str));
std::cout << str << '\n';
std::cout << v[0] << ' ' << v[1] << '\n';

这个输出就会是

hello

hello hello

这就是移动语义干的事,直接转移内存(对象),在某些情况下节省了构造和析构的开销

那么回到上面的vector例子,如果我们想要把a中对象所有权交给b,之后销毁a或者将它重新初始化,而非使用(这一点很重要!),那么我们可以

1
2
std::vector<int> a{1,1,4,5,1,4};
std::vector<int> b{std::move(a)};

这样a“生前的”的内存就交给了b

这个过程中,move函数除了语义上的移动声明,事实上除了将左值表达式转换成亡值以外,并没有干什么事情

即单独的move实际上不能干什么事。在上个例子中,move事实上配合了vector对于右值的初始化重载函数{}

针对右值重载过的{}配合移动语义将对象直接给了b

而且我在初学的时候,尽管试着用手头的环境去随便测试,但还是踏入了一个误区,就是主要用内置类型在测试,事实上这很容易让move和copy没毛的区别,自然也就没有推测出什么move的目标功能

右值引用

理解了右值之后,右值引用自然就是绑定到右值的引用,通过&&来获得

右值引用只能绑定到一个将要销毁的对象 一般用来延长对象的生命周期

标准库move函数也是 如果对一个左值使用move函数

若使用move函数 意味着承诺 销毁它 或者赋新值 而不是再次直接使用它

当然我们已经知道,只是调用move而不搭配对应语义的操作,什么事都不会发生

不过这里我一直以来有个误解

比方说以下的行为

1
2
vector<int> a{1,1,4,5,1,4};
vector<int>&& b = std::move(a);

手玩了以后发现似乎b似乎还是对a的左值引用,一开始不知道为什么,后来发现自己不知道在写什么东西

其实如果想把a中对象所有权交给b的话,只需要

1
2
vector<int> a{1,1,4,5,1,4};
vector<int> b=std::move(a);

就行了,这样b在接受右值作为初始化时,会自动重载,而如果只是用一个右值类型去接受转换成右值的a的话,不知道为什么最终表现b像是a的左值引用

估计还是得去编译器里看parser才知道,语法上面好像不能推断出来


返回值优化

进一步的 C++ 17以后rvo优化成为了标准,尽管在rvo加入标准之前,大部分编译器在C++11提供右值引用和移动语义之后就实现了 复制省略 - copy elision的编译器优化技术

若要关闭,请使用编译参数

1
-fno-elide-constructors

简单说就是直接在调用者的栈上构造,避免触发拷贝、移动构造函数

当然因为C++17开始这成为了标准,用不用-fno-elide-constructors都没有影响了

所以如果自己想试试的话用C++14

看到有人说让标准来干这事,这不够C++什么的,就应当自己写引用和指针,我现在的想法就有点呵呵,那按照这种思想,编译器除了中间代码优化和后端生成优化之外,前面parser帮忙做的一些事情都叫程序员来做好了

比较经典的一个例子就是register的移除,原来OIer有些半吊子卡常方法就是说循环变量用register,这样会节省时间.. 真是呵呵了,那严格来说,想要用好这玩意儿事实上得先懂寄存器这块的知识,不然就是在寄希望于玄学和未知,怎么就能够肯定自己设置的register一定是效果最优的呢。事实上编译器能做的事就应该交给它做。rvo也是,如果想要绕过那显然有办法。但是对于没学过的人,肯定是提供一个更好的实现好,这就是很重要的一个理念,不必为不用到的东西付出代价,也是C++的优势所在。

当然这也只是我个人的想法,也许我过段时间学了点编译器之后会改变看法。

参考资料