C++的右值与移动语义
[TOC]
本文总结一下我对 C++ 右值与移动语义的理解,主要分为值类型,右值引用,以及移动语义的实现.
问题介绍
回答第一个问题, 把大象从一个冰箱移到另一个冰箱里, 问总共分几步? 第一步打开冰箱 A, 第二步把大象从冰箱 A 中取出来, 第三步把大象塞进 B, 第三步关上冰箱 B. 如果把 C++ 里的泛化的容器概念(包括数组, 函数返回值等)理解成冰箱, 对象理解成大象的话, 对于 C++11 之前的版本则不是上面的过程了. 大象会在 A 中被分解蒸发(析构与释放), 在 B 中重新按照配方(构造函数)直接生产出一个一模一样的大象. 按常理怎么想都是移动的方法更有效率.
这个问题对应的例子为 std::vector
的添加元素的方法.
C++11 以后推荐使用 emplace_back()
代替之前的 push_back()
. 原因是前者应用了移动语义, 后者只能复制. 具体例子如下:
1 | class MyKlass { |
第二个问题, 函数返回值的效率问题. 这个问题在《more effective C++》的 item20 中有介绍. 如果按照 pass-by-value 的方式传值, 则系统会产生临时对象, 如果只是内建类型倒也无所谓, 一旦返回的是数据量较大或者是结构复杂的对象的话, 或者是语义上来看很简单, 但实际语法执行时却很复杂, 这时候构造与析构成本都很高. 后者最简单的例子就是 std::string
.
1 | string result = |
上面的代码在 C++11 之前很不推荐, 效率非常低下, 具体的过程如下:
- 调用构造函数
string(const char*)
, 生成临时对象 1; “Hello, “ 复制 1 次. - 调用
operator+(const string&, const string&)
, 生成临时对象 2;”Hello,” 复制 2 次, name 复制 1 次. - 调用
operator+(const string&, const char*)
, 生成对象 3; “Hello, “ 复制 3 次, name 复制 2 次, “.” 复制 1 次. - 假设返回值优化能够生效(最佳情况), 对象 3 可以直接在 result 里构造完成.
- 临时对象 2 析构, 释放指向 string(“Hello, “) + name 的内存.
- 临时对象 1 析构, 释放指向 string(“Hello, “) 的内存.
如果想要规避 pass-by-value 的临时对象问题, 思路有 2 个, 1 是返回引用 pass-by-reference, 2 是返回指针 pass-by-pointer.
先说 pass-by-reference, 这会导致返回的值为悬空的对象, 因为函数里的本地变量都是临时的在函数结束时会被销毁, 回临时对象的引用最终会导致返回悬空. 不可取.
再说 pass-by-pointer, 在函数里 new 出新的本地对象然后返回的仍然为悬空的指针. 这里所说的 pass-by-pointer 是指入参里有对象的指针(作为返回值的容器), 最后函数通过解引对象指针修改对象, 返回对象. 但是只要是指针就涉及到资源周期管理的问题, 虽然智能指针能一定程度上解决这个问题, 但是有没有更加简洁优美的做法呢.
为此 C++11 引入了移动语义和相关的概念, 来解决上面临时对象的效率问题. 右值与移动语义有很深的关联, 这里先介绍.
右值与右值引用
为什么要区分左值与右值, 甚至左值引用与右值引用呢? 因为有些时候我们需要移动临时对象(例如函数返回值, lambda 表达式等), 这里的移动可不一定真的是内存上数据的移动, 有可能只是把内存的指针告诉移动后的容器(冰箱), 请到指针所指的内存处找到对象, 进行访问, 更新, 析构, 销毁. 然而临时对象根本没有内存地址可以取, 例如 a = 10.1;
中的 10.1
, 因此有必要把这种数据区别开来, 然后想办法看看能不能用跟其他数据同样的操作方法操作, 从而提升效率. 这种区分就产生了左值与右值.
区分开后, 我们还要进一步地考虑如何把临时对象传出去. 传值最方便的是引用了, 那如何对临时对象进行引用呢, 同时临时对象是临时的很快会被销毁(也是悬空现象出现的根源), 生命周期也可能需要我们手动延长, 这些又如何实现呢? 让我们带着疑问进入下文吧.
值类别(value categories)介绍
首先我们需要区分 2 个概念: 值类别(value categories)与类型(type).
我们都知道 C++ 的程序由一系列的表达式(expressions)构成. 表达式是运算符和操作数的序列, 表达式指定一项计算.
每个表达式有两个互相独立但是非常重要的属性:
- 类型(type): 例如 int, double 和 std::string.
- 值类别(value category):例如 prvalue, xvalue, lvalue.
左右值(lvalue,rvalue)的概念来源于 C++ 的祖先语言: CPL, 一般理解为等号 =
的左右侧的值. 但在后续的 C 与 C++ 中等号来区分的意义不够全面, 而是使用能否取得内存地址来区分左右值, lvalue 可以用 locator value 来解读. 因此简单的理解为: 左值对应了具有内存地址的对象, 而右值仅仅是临时使用的值.
从 C++11 标准开始, 值类别不止是 lvalue 和 rvalue 两种, 主要的值类别分为: lvalue, prvalue 和 xvalue 三种. 加上两种混合类别: glvalue 和 rvalue, 一共有五种. 他们之间的关系如下:
大致的解释如下:
- lvalue 是通常可以放在等号左边的表达式, 左值
- rvalue 是通常只能放在等号右边的表达式, 右值
- glvalue 是 generalized lvalue, 广义左值
- xvalue 是 expiring lvalue, 将亡值
- prvalue 是 pure rvalue, 纯右值
先解释最下方的三个类别:
左值 lvalue
左值 lvalue 是有标识符, 可以取地址的表达式, 最常见的情况有: 变量, 函数或数据成员的名字.
返回左值引用的表达式, 如 ++x
, x = 1
, cout << ' '
.
字符串字面量如 "hello world"
.
纯右值 prvalue
纯右值 prvalue 是没有标识符, 不可以取地址的表达式, 一般也称之为”临时对象”. 最常见的情况有:
返回非引用类型的表达式, 如 x++
,x + 1
,make_shared<int>(42)
.
除字符串字面量之外的字面量, 如 42
,true
.
将亡值 xvalue
xvalue 与 prvalue 不同, 它与 lvalue 一样也指向了一个对象, 是有标识符的, 不过这个对象已经接近了生命周期的末尾.
1 | smart_ptr<shape> ptr1{new circle()}; |
std::move(ptr1)
是将 ptr1
转换成了右值, 但是 std::move(ptr1)
仍旧指向 ptr1
. 这与 prvalue 不同.
xvalue 的概念为我们创造了后续实现移动语义提升效率的可能.
rvalue 右值 与 glvalue 广义左值
可以看到上面对 3 个具体类别的划分使用了 2 个维度: 有无标识符, 是否可移动. 关系如下:
较正式的定义如下:
- A glvalue(generalized lvalue) is an expression whose evaluation determines the identity of an object, bit-field, or function.
- A prvalue(pure rvalue) is an expression whose evaluation initializes an object or a bit-field, or computes the value of an operand of an operator, as specified by the context in which it appears, or an expression that has type cv void.
- An xvalue(eXpiring value) is a glvalue that denotes an object or bit-field whose resources can be reused (usually because it is near the end of its lifetime).
- An lvalue is a glvalue that is not an xvalue.
- An rvalue is a prvalue or an xvalue.
左值引用与右值引用介绍
上面引入指类别, 都是为了引入右值引用的概念(简化地说, 方便理解).
先注意一点引用是值类别哪一种, 答案: 都不是, 因为他们在表达式的最左侧与对象 type 组合, 组成表达式的类型(type).
在 C++ 里,所有的原生类型, 枚举, 结构, 联合, 类都代表值类型, 只有引用 &
以及 &&
和指针 *
才是引用类型(也是**组合类型(compound type)**的一种).
在 Java 里, 数字等原生类型是值类型, 类则属于引用类型. 在 Python 里, 一切类型都是引用类型.
概述
一般而言的引用都是左值引用(lvalue reference), 在 C++11 之前, 引用分为 const 引用和非 const 引用. 这两种引用在 C++11 中都称做左值引用.
存在的问题是我们无法将非 const 左值引用指向右值. 例如, 下面这行代码是无法通过编译的:
1 | int& a = 10; |
编译器的报错是:
1 | error: non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int' |
意思是: 无法将一个非 const 左值引用指向一个临时的值.
但是 const 类型的左值引用是可以绑定到右值上的, 所以下面这行代码是没问题的:
1 | const int& a = 10; |
不过, 由于这个引用是 const 的, 因此无法修改其值的内容.
为了解决可修改的对右值的引用, C++11 引入了右值引用(rvalue reference)的概念.
左值引用的写法是 &
, 右值引用的写法是 &&
.
生命周期延长
右值是一个临时的值, 右值引用是指向右值的引用. 右值引用延长了临时值的生命周期, 并且允许我们修改其值.
更一般地, 对于生命周期 C++ 中的规则:
一个临时对象会在包含这个临时对象的完整表达式估值完成后, 按生成顺序的逆序被销毁, 除非有生命周期延长发生.
例如下面没有生命周期延长的情况:
1 |
|
输出结果可能会是(circle 和 triangle 的顺序在标准中没有规定):
1 | main() |
可以看到 something else
之前 ~result()
已经被析构.
但是如果使用引用可以延长其生命周期:
如果一个 prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长.
- 使用左值引用延长
对上面的process_shape
修改
- 使用左值引用延长
1 | const result& r = process_shape(circle(), triangle()); |
输出可能如下:
1 | main() |
可以看到 result 的生成还在原来的位置, 但析构被延到了 main 的最后.
注意此处 r
的返回值必须为 const 类型, 如前文所说, 虽然延长了生命周期, 但是我们无法修改它, 因此是鸡肋.
- 使用右值引用延长
1 | result&& r = process_shape(circle(), triangle()); |
这时可以对 r
进行修改.
- 对 xvalue 无效
需要十分注意的是, 这条生命期延长规则只对 prvalue 有效, 而对 xvalue 无效.
1 |
|
这时的代码输出就回到了前一种情况. 虽然执行到 something else 那儿我们仍然有一个有效的变量 r
, 但它指向的对象已经不存在了, 对 r
的解引用是一个未定义行为. 由于 r
指向的是栈空间, 通常不会立即导致程序崩溃, 而会在某些复杂的组合条件下才会引致问题.
- 对无 virtual destructor 的基类, 可以把子类对象绑定到基类的引用变量上, 使得子类析构正常.
一个小的技巧.
1 | Derived factory(); //生成子类的函数 |
右值引用的重载性
右值引用的返回值可以与左值引用区别形成重载.
1 | void func(X& x) { |
输出是:
1 | lvalue reference version |
形成重载性好处的最明显的例子是拷贝构造函数与移动构造函数.
这里不详细展开移动构造函数, 只举出一个例子.
移动构造函数应当从另一个对象获取资源, 清空其资源, 并将其置为一个可析构的状态. 这样可以把一个对象里的资源全部移交给另一个新建的对象, 这样不需要通过拷贝构造函数先构造出一个临时的对象然后拷贝进去.
1 |
|
输出如下:
1 | construct! |
小结
左值引用既可以绑定到左值(非 const), 也可以绑定到右值(const).
右值引用只能绑定到右值.
右值引用既可能是 lvalue, 也可能是 rvalue. 如果它有名称, 则是 lvalue, 否则是 rvalue.
对于指针, 我们通常使用值传递, 并不关心它是左值还是右值.
移动语义的实现
介绍完基础的概念后, 我们开始来解决开头的大象移动问题.
首先, 我们对上面的 C++ 的特性再做深一些的探讨, C++ 为什么要这么做, 其他语言是怎么做的呢.
C++ 里的对象缺省都是值语义.
例如下面的 A 中的 B 与 C 都是完整的对象, 而不是像 Java 和 Python 里放的会是 B 与 C 的指针(虽然这些语言里本身没有指针的概念). 这种行为既是优点也是缺点. 说它是优点, 是因为它保证了内存访问的局域性, 而局域性在现代处理器架构上是绝对具有性能优势的. 说它是缺点, 是因为复制对象的开销大大增加. 因此移动语义势在必行.
1 | class A { |
对象支持移动
需要考虑的方面如下:
- 有分开的拷贝构造和移动构造函数(除非你只打算支持移动, 不支持拷贝——如
std::unique_ptr
). - 有 swap 成员函数, 支持和另外一个对象快速交换成员.
- 对象的名空间下, 应当有一个全局的 swap 函数, 调用成员函数 swap 来实现交换. 支持这种用法会方便别人(包括你自己在将来)在其他对象里包含你的对象, 并快速实现它们的 swap 函数.
- 实现通用的移动赋值(move assignment)运算符
operator=
. - 上面各个函数如果不抛异常的话, 应当标为
noexcept
. 这对移动构造函数尤为重要.
以智能指针的实现(类似于 std::shared_ptr
)为例子:
对于智能指针无所谓构造与析构, 这里的拷贝与析构需要类比到类上.
1 | //引用计数 |
深拷贝与浅拷贝
拷贝构造函数中需要注意深拷贝与浅拷贝的问题.
对于移动则不存在这个深浅的问题, 因为移动只是修改记录(有点指针指向修改的感觉), 内存块里的东西还在那里, 并没有什么改变.
std::move()
上面的值类别的小结中, 可能会有人跟我一样在意一点, 右值引用只能绑定到右值上, 那我们想把左值绑定到右值引用上该怎么转换一下呢? 因为很多时候我们不一定是故意把左值绑定到右值引用上, 而是我们不好判断传进来的到底是左值还是右值. 这个时候需要使用 std::move()
进行统一的转换.std::move()
的名称其实具有一定的迷惑性, 因为它并没有进行任何”移动”的操作, 它仅仅是: 无条件的将实参强制转换成右值引用, 仅此而已. 因此 C++ 之父认为它的名字叫做 rval() 应该更合适. 但是不管怎么样, 由于历史原因, 它已经叫做 std::move()
.
实际的例子如下:
1 | smart_ptr<shape> ptr1{new circle()}; |
上面例子中的 std::move(ptr1)
等价于 static_cast<smart_ptr<shape>&&>(ptr1)
.
std::move()
的函数原型( C++11 )如下:
1 | template <typename T> |
实现的效果
- 传递的是左值, 推导为左值引用 ,static_cast 转换为右值引用.
- 传递的是右值, 推导为右值引用, 仍旧 static_cast 转换为右值引用.
从功能来看该函数能接受左值的入参, 那如何实现左值入参仍旧工作正常的呢? 这就引出引用折叠以及 Universal reference 的概念(有点多的内容, 这里先挖个坑, 后面填).
在说引用折叠以及 Universal reference 之前, 先说一下一个疑问, 那是不是所有涉及到返回值的时候, 我们都应该加上 std::move
提升效率呢.
答案是否定的, 详细如下.
使用 std::move
对于移动行为没有帮助, 反而会影响返回值优化
在 C++11 之前, 返回一个本地对象意味着这个对象会被拷贝, 除非编译器发现可以做返回值优化(named return value optimization,或 NRVO), 能把对象直接构造到调用者的栈上. 从 C++11 开始, 返回值优化仍可以发生, 但在没有返回值优化的情况下, 编译器将试图把本地对象移动出去, 而不是拷贝出去. 这一行为不需要程序员手工用 std::move
进行干预——使用 std::move
对于移动行为没有帮助, 反而会影响返回值优化.
下面是个例子:
1 |
|
输出通常为:
1 | *** 1 *** |
也就是, 用了 std::move
反而妨碍了返回值优化.
参考链接:
https://herbsutter.com/2008/01/01/gotw-88-a-candidate-for-the-most-important-const/
https://paul.pub/cpp-value-category/