左值右值,有没有通俗易懂且具体的资料

2022-01-11 17:49:04 +08:00
 ligiggy

看了几篇文档了

比如巨硬的这个: Lvalues and Rvalues

比如 cpprefrence 的这个: value categories

4181 次点击
所在节点    C++
38 条回复
Jooooooooo
2022-01-12 10:15:45 +08:00
@msg7086 注重这个没问题, 但这种事是不是应该 c 自己干而不是让开发人员关心?

举个例子, java 有对于类中字段的重排使得内存对齐浪费更少的内存, 但这是 jvm 自动的, 普通开发人员并不需要关心. (这个东西冷门到面试八股文都很少出现
Aspector
2022-01-12 10:26:40 +08:00
我这条命都是 CppCon 里 Back to Basics 系列给的,强烈推荐。
ligiggy
2022-01-12 10:35:13 +08:00
@Jooooooooo 看得出来,你没有写过多少 C/C++,建议秉持开放的态度,尝试拥抱一下,哈哈哈哈
ligiggy
2022-01-12 10:35:24 +08:00
@Aspector 马上去看,哈哈哈哈,谢谢
Jooooooooo
2022-01-12 10:43:03 +08:00
@ligiggy 更合理的是, 性能高的路线 /方案是自动的, 参考 java 里的 jit. 变量用不上, 自动优化掉, 方法调用没意义, 直接不调了.
Jooooooooo
2022-01-12 10:43:37 +08:00
@ligiggy 上学的时候学过, 后来接触了 Java, 再也没有烫烫烫了.
justfly
2022-01-12 11:07:31 +08:00
一般用来控制拷贝和移动。
ColorfulBoar
2022-01-12 11:08:49 +08:00
Effective Modern C++是写于 C++14 版本的,现在已经发生了一些变化(在使用上变化不是很大,概念上变化比较大)。cppreference 定义照抄标准而且后面全是枚举例子,好像只看它很难看懂发生了什么。CppCon 里的好像很多是教你怎么用的,没咋看过,或许用多了也能发展出一些直觉吧。

为了理解它,如果不想在那 1800 多页的标准构成的粪坑里游泳的话,现成的材料里 C++ Templates: The Complete Guide 第二版关于 value categories 的附录可能好一点,就几页纸,概念又比较正确(不过 reference collapsing 放在正文中间的好像作者还建议第一次读跳过去的部份)。


简单来说大概是这么理解的(这只是我的理解方式,估计肯定有某些不符合标准的地方,另外可能先跳到 4 会好一点):

1. value categories 是表达式的属性而不是变量的属性或者别的什么玩意的属性。比如 int a = -1;这里的 a 并不是一个表达式,它不是啥左值,写在等号左边就是左值这个想法是错的。而 C++11 里为了 move semantics 等需求引入的 lvalue/rvalue reference 是两种不同的类型,它们是和表达式正交的属性,比如 void push_back(string&& x);里面的 x 的类型是右值引用 string&&,但函数定义里面如果直接写一个表达式 x 的话则是一个 glvalue 的表达式。

2. 自 C++17 起表达式分为两类:glvalue 和 prvalue ,前者提供某个位置信息,后者提供初始化或者修改的时候所需的值。从这个版本起只需要以上二者就可以理解我们需要的东西,不再需要另外定义 lvalue 和 rvalue 了。乍看之下存在一些显然有问题的地方,比如 int a = b;里面的 b 既提供了 b 这个变量的位置又提供了初始化 a 所需要的值。实际上 C++里存在以下两种 value categories 之间的直接转换(具体行为受到类型影响,但大致上符合直觉):
------2.1 lvalue-to-rvalue ,名字叫这个,但它实际上是把一个 glvalue 转换成一个 prvalue (正如前面所说,我们已经不需要 lvalue 和 rvalue 这两种 value categories 了),这个 prvalue 在用来提供值的时候会从转换成它的 glvalue 提供的位置信息里面来获取相应的值(不同的类型的具体行为不太一样,但大致上是符合直觉的)。比如之前提到的 int a = b;里面的 b 就是先转换成了 prvalue 才能提供用来初始化 a 的值的。
------2.2 temporary materialization conversion 机制允许通过一个 prvalue 生成一个临时对象然后把它的位置作为一个 glvalue 放在好像需要 glvalue 的地方,用这种机制可以在需要 const lvalue reference 的地方传进去一个 prvalue 。但这种机制发挥的场合是受限的,直觉上只有找不到 glvalue 让这个 prvalue 起到初始化的作用的时候才会发生这种转换。比如 string a = string(string(string()));里内层表达式是 prvalue ,它会一直往外抛,直到找到 glvalue a 后进行一次初始化,C++17 里利用这个特性实现了所谓的 guaranteed copy elision 。这种特性生成的 glvalue 表达式不能用在=的左边,也不能用来初始化 non-const lvalue reference ,由此我们在 glvalue 中分出一个新的子类 xvalue ,它只通过屈指可数的几种情况生成,感觉上更像是一种技术手段。

3. 作为 type 的 lvalue 和 rvalue reference 是与表达式的 value categories 正交的性质。但二者存在下面的相互作用:
------3.1 lvalue reference 只能被 glvalue 里面不是 xvalue 的那一类所初始化(这一类叫作 lvalue ),而 rvalue reference 只能被剩下的表达式初始化(即 xvalue 与 prvalue ,这一类叫作 rvalue ),并且不接受从 glvalue 转化来的 prvalue 。如果试图扔掉 lvalue/rvalue 的话,大概可以说 l/rvalue reference 只能被本来是 glvalue/prvalue 的表达式初始化,这么说看起来更简单,但我不知道能不能严格地定义。
------3.2 函数返回值的分类,或者说由调用这个函数构成的表达式的 value category ,受到返回值类型的影响:返回值为左值引用给出 lvalue ,右值引用给出 xvalue ,非引用类型给出 prvalue 。如果试图扔掉 lvalue/rvalue 的话大概思路和 3.1 一样沿着返回路径回溯看看源头是什么(碰到引用的时候看是谁初始化的引用类型),同样我也不知道能不能严格定义这种偷鸡理解。
------3.3 存在向 rvalue reference 的强制转换 std::move ,以及利用 reference collapsing (引用的引用会变成一个单层引用,左引用会传染下去)机制做的所谓 universal reference 与 perfect forwarding
------3.4 借由这种相互作用,我们可以通过表达式本身匹配上不同的函数调用 copy/move 相关的重载实现 move semantics ,也可以通过包含函数的表达式本质上的 value category 可以一直追溯到产生那个值的地方这个特性来实现 guaranteed copy elision 。

4. 总的来说细节很多,并且散布在标准的各处,它们之间还有相互作用,我已经看吐了(虽然吐过之后好像不怕读标准了),所以在不出意外的时候我打算用这个简化的理解:表达式分两类,一类提供广义的位置(最终用来取出来一个值或者修改一个值),一类提供广义的值(最终被某个位置吃掉),二者可以但只应该在必要的时候相互转化,毕竟从位置里取出来一个值或者用某些值创建一个临时对象是有代价的,我们尽量晚地做这件事,说不好可以少干一点或者干脆不用干了。左 /右引用是类型而不是表达式分类,但它们大致上可以通过追溯源头来反应表达式是哪类的:当源头是位置的时候引用的类型是左值引用,是值的时候引用的类型是右值引用,其中左值引用具有传染性,除非被某种转换(比如 std::move )截断。我们利用这个机制来实现 Modern C++里面的诸多特性。剩下的都是技术细节。


题外话:我一直很好奇 2022 年咋还有 C 和 C++都分不清楚的……
msg7086
2022-01-12 13:11:17 +08:00
@Jooooooooo
编译器已经干了很多了,但还是比不过人类的手调优化。
当然,更细致的优化还是要用汇编来手调。

你说的那些变量用不上自动优化掉这些,C 家族编译器不知道多少年前就已经实现了。
现在的编译器已经在做远远强于你说的这些事情了。

比如之前我有一个项目,用 intrinsics 写 SIMD 汇编,用 clang 编译以后速度快得惊人,比其他编译器都要快。
后来我去仔细检查了 clang 生成的汇编代码,发现编译器直接把我写的汇编指令等价重写成了另一批指令集,而那些指令集运行速度要比我用的指令集快不少。现在的编译器开关开得多了以后,会更激进地帮你重写代码。比如说循环里顺序读写内存的指令,会被自动矢量化成 SIMD ;比如为了减少跳转,而把短小的循环 unroll 展开;等等各种。

然后在这之上,专业的优化人员可以通过检查 CPU 核心的状态,再去微调和重排指令。
比如说根据 CPU 执行流水线上某个指令周期是否在摸鱼,而把某几条顺序无关的 CPU 指令往前或者往后移动等等。当然这个在比较新的 CPU 上可能也没有什么用了,现代 CPU 都会重新译码成 uop 然后在内部重排。

左值右值的概念也会随着编译器的进步而逐渐淡出人们的视线吧。提到这些概念的地方大多也是编译器或者公理规范这些东西,只是使用的话,一把梭随便搞,一般不太会翻车的。

PS: 和 C/汇编比,Java 是真的慢……
3dwelcome
2022-01-12 13:18:01 +08:00
右值就是 std::move 和&&,可能写游戏的,才会比较重视性能优化。

我这种写前端逻辑的,性能真的是无关紧要。

把代码整体逻辑清晰化呈现,写便于维护的代码,才是首先要考虑的。
ericgui
2022-01-12 15:33:14 +08:00
能被赋值的就是左值

比如数字 5 ,只能是右值,赋值给 x ,就是 x=5

但 x * 4 = 20 这也不行,不能类似解方程,你只能 x=5 ,不能 x*4= 20

个人理解,轻拍
ipwx
2022-01-12 16:01:56 +08:00
楼上( @statumer )已经开喷 C++ 要关心左值右值,是为了解决 s = s1 + s2 这个 = 如何实现的问题。在别的语言这
种问题都不存在。
====

我倒也不是黑这句话,我只是想补充一点:C++ 为啥要关心 = 如何实现?

是因为 C++ 没有垃圾回收。

没有垃圾回收就要求区分栈对象和堆对象。而栈对象的赋值传值就会带来一系列拷贝的问题。而拷贝就会关心 =,就会关心左值右值。

你看别的不用关心这些的语言,哪个没有垃圾回收?

但是没有垃圾回收是罪过吗?不是,因为有些地方就是忌讳垃圾回收。

所以,这只是取舍罢了。
ipwx
2022-01-12 16:04:14 +08:00
比如系统响应要求延迟稳定在 10 微秒以下,你敢用垃圾回收吗?对你不敢。

哪怕号称最先进的 zgc ,java 里面的那个,现在也不过号称稳定版 STW 回收 <10ms ,努力向着 1ms 进发。这和 10 微秒可还相差 2 个数量级呢 hhh
ipwx
2022-01-12 16:06:09 +08:00
哦对顺便 Go 的 STW 是 1ms 。但是这和 10 微秒依然差了 2 个数量级。

C++ 在延迟关键的领域就是无可替代,唯一的候选人只有 Rust 。但是 Rust ,ummm ,我感觉这东西的心智负担 tm 比 C++ 还要高。
amiwrong123
2022-01-12 16:23:31 +08:00
所以楼主觉得哪个文档比较通俗易懂😂
jones2000
2022-01-12 17:12:29 +08:00
大学“编译原理”都有的。转换成 AST 以后,递归执行 AST 每个节点的计算,全局变量表, 临时变量表...... 计算机专业都应该学过的。
GeruzoniAnsasu
2022-01-12 17:36:16 +08:00
@Jooooooooo 歪个楼,你以为的高性能是在减少冷分支……实际上写「高性能代码」的人都已经在 probe CPU 的 register file 有多大了
chzmwfg
2022-02-07 13:09:45 +08:00
@jones2000 只是很难结合起来去思考,如果能够都结合起来,就很好了

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/827635

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX