C++ 值类型、引用类型与智能指针
TL;DR 省流,但不完全准确
- 可以放在等号左边的就是左值(如变量名)
- 只能放在等号右边的是右值(如常量,表达式,函数调用)
- 能指向左值,不能指向右值的就是左值引用(
int &ref_a = a
),例外是 const 左值引用可以指向右值 - 可以指向右值,不能指向左值的就是右值引用(
int &&ref_a = 1
) - 左/右值引用都是左值
- 左值可以通过
std::move()
转换为右值,这只是 compile-time 的,不产生机器码
TODO
引用与指针
- 引用在生成汇编代码后与指针的用法相同,只是在编译器层面做了语法上的限制[13]
- &出现在等号右侧是取地址的意思,这是C语言就有的功能
值类型 (value category)
根据[12],C++ 表达式都有两个独立的属性:类型与值类别(value category)。值类别有三种:纯右值(prvalue)、亡值(xvalue)与左值(lvalue)。
解释下图中的 可移动 与 有身份:
- 可被移动:移动构造函数、移动赋值运算符或实现了移动语义的其他函数重载能够绑定于这个表达式。换句话说是否允许将所有权移交给这些实现移动语义的函数
- 可移动:xvalue prvalue
- 不可移动:lvalue
- 拥有身份:可以确定表达式是否与另一表达式指代同一实体,例如通过比较它们所标识的对象或函数的(直接或间接获得的)地址;
- 有身份:lvalue xvalue
- 无身份:prvalue
下面大致讨论每种值类别,细节请参考[12]
泛左值 glvalue(generalized lvalue) = lvalue + xvalue(等号表包含,下同)
- 性质:可以是不完整类型,可以是多态的
右值 rvalue = xvalue + prvalue
- 性质:不能取地址,不能放在等号左边,可以初始化 const lvalue ref,可以初始化 rvalue ref
左值:
- 性质:在 glvalue 的性质基础上
- 可以用 & 取地址
- 值可以被修改(如果它本身是 modifiable 的)
- 可以用来初始化左值引用
- 包含:
- 变量、函数、模板形参对象或数据成员的名字,如 std::cin
- 返回类型为左/右值引用的函数调用或重载运算符表达式,如 std::cout << 1
- ++a/–a
- *ptr
- 性质:在 glvalue 的性质基础上
亡值(expiring value, xvalue):
- 性质:glvalue 与 rvalue 的交集
- 包含:
- 返回类型为对象的右值引用的函数调用或重载运算符表达式,如 std::move(x)
- 转换为对象的右值引用类型的转型表达式,如 static_cast(x)
TODO xvalue 和 prvalue 的区别
纯右值(prvalue):
- 性质:在 rvalue 的基础上
- 非多态:其 dynamic type(实际类型)等于其 static type(声明类型)
- 类型不能是不完整类型或抽象类类型
- TODO cv-qualified 相关,没懂
- 包含:
- 返回非引用类型的函数调用:
f()
- 字面量,this指针,lambda表达式
- 所有内建数值运算表达式:a + b, a % b, a & b, a << b
- &a, 取址表达式
- 返回非引用类型的函数调用:
- 性质:在 rvalue 的基础上
std::move()
此函数将左值转换为右值,但不产生任何机器码,而是在编译期影响函数调用的重载决议[5](根据入参的引用类型决定调用拷贝构造函数还是移动构造函数),实现上是一个static_cast。move 的语义是 “我放弃调用 move 后对此对象的所有权”,因此使用 move 返回值的函数可以以破坏性的方式使用被 move 的对象,从而实现更高性能的操作。编码者不应使用被 move 后的对象,否则是 UB [6]。
有些STL类是move-only的,比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝)
1 |
|
引用类型
引用类型包含左值引用、右值引用、通用引用:
- 语法上,使用
T&
声明的就是左值引用,使用T&&
声明的就是右值引用[3] - 对于以
template<typename T> retType funcName(T&& paraName)
或auto&&
格式声明的引用,它们不是右值引用,而是通用引用(universal references / forwarding references)
通用引用的初始值可以是左/右值,如果初始值是左值,那么这个通用引用是一个左值引用,反之亦然 - 能指向 lvalue,不能指向 rvalue(xvalue + prvalue 下同)的就是左值引用,反之既是右值引用[1]。一个例外是 const 左值引用可以指向右值 (
const int &ref_a = 5;
) - 右值引用延长了右值的生命周期,同时也可以用于对右值进行修改
- 左值引用、右值引用本身是左值还是右值?
与[1]不同,我的看法,左/右值引用只能是声明出来的,因此都是左值。看一个例子
1 |
|
Question:在我们希望放弃原有对象的所有权时,右值引用可以用于避免拷贝,但是为什么不直接使用指针呢?[14]
仍然考虑通过移动来避免拷贝的场景:
- 如果传递一个左值 lval,
f(std::move(lval))
相比于f(&lval)
有更好的语义,前者意味着我们完全放弃 lval 的所有权 - 如果传递一个右值 如 MyClass{},
MyClass(MyClass{})
会创建一个临时变量,之后移动到函数内,而我们无法对右值取地址,如MyClass(&MyClass{})
是不合法的
(在 C++17 中,rvalue 的定义发生了变化,临时变量,即 MyClass{} 会推迟到 destination 处被初始化,从而省略了一次移动,这个优化称为 copy elision) - 引用确保非空,在接受指针的函数中,我们需要对指针是否为空进行判断,而引用则不需要
顺便提一下 copy elision,C++17 在特定场景下保证此优化一定发生,但在之前版本通常也会做此优化,看下面的例子
1 |
|
Question: 函数返回值是左值还是右值?[7]
- 函数返回值是值传递(return by value)的,返回值就是个右值;
- 函数返回值是引用传递(return by reference)的,返回值就是个左值。
举例
1 |
|
1 |
|
看一个vector::push_back的例子[1]
1 |
|
TODO 自动生成 各种构造函数的条件
std::forward()
此函数的目的是为了保证参数传递时 value category 不变,考虑如下例子[15]
1 |
|
根据调用 f 时传入的是左值还是右值,x 可以是左值/右值引用,但 x 本身是左值,即 g 的参数永远是左值,g 函数内部的 x 永远是一个左值引用。如果我们希望 x 的左/右值引用属性透传到 g,此时就需要使用 std::forward
对于 std::forward(x)
,如果 x 是右值引用,那么返回值是一个右值,此时 forward 等价于 move,如果 x 是左值引用,那么返回值是一个左值,此时 forward 什么也不做
应用
std::make_unique
的实现大致如下[4]
1 |
|
我们希望将外部参数的 value category 属性保留到 new T
调用时,从而确保 new T(a)
能够与 make_unique<T>(a)
使用相同的构造函数,此时就需要使用 std::forward
,如果不使用 std::forward
,那么 new T
的参数永远是左值,因此 new T
的构造函数永远是拷贝构造函数,而无法移动构造函数
智能指针
unique_ptr:栈上对象 unique_ptr 内部维护一个指向堆上的具体对象的指针,利用RAII,当 unique_ptr 离开 lexical scope 后,执行析构释放资源,从而将堆上资源lifetime与栈上的智能指针绑定
shared_ptr: 引用计数,为0析构
TODO 补充使用场景
weak_ptr:用于解决shared_ptr循环引用的问题
细节见下方
1 |
|
智能指针相对于原始指针解决了什么问题
smart pointer 将堆上空间的生命周期与栈上的只能指针的生命周期绑定,从而实现自动内存管理
TODO 可以看这个 https://zhuanlan.zhihu.com/p/365765483,大致意思就是随着退栈自动释放
为什么应当尽量使用 make_unique(而非直接用 unique_ptr)
- 考虑如下代码
foo(unique_ptr<T>(new T()), unique_ptr<U>(new U()));
编译器生成的代码可能先执行 new T()
, 然后执行 new U()
,最后执行两个 unique_ptr
的初始化,如果 new U()
的过程中抛出异常,T 的内存就泄漏了。若使用 make_unique
,则确保了对象初始化与智能指针初始化是原子的
使用 make_unique 后:foo(make_unique<T>(), make_unique<U>());
unique_ptr
中重复出现了两次 T(仍然用上面的例子),而make_unique
只需要指定一次
智能指针实现
TODO 三种 unique_ptr, shared_ptr, weak_ptr 如何实现
参考文章
- (primary) 一文读懂C++右值引用和std::move
- https://www.runoob.com/cplusplus/cpp-references.html
- Reference declaration
- c++为什么要搞个引用岀来,特别是右值引用,感觉破坏了语法的简洁和条理,拷贝一个指针不是很好吗? - 郭不昂的回答 - 知乎
- C++ 的移动 move 是怎么运作的? - sin1080的回答 - 知乎
- What can I do with a moved-from object?
- C++左值、右值和构造函数们 - lyf的文章 - 知乎
- 如何评价 C++11 的右值引用(Rvalue reference)特性? - Tinro的回答 - 知乎
- C++返回值优化
- 比起直接使用new,更偏爱使用std::make_unique和std::make_shared
- shared_ptr and weak_ptr differences
- Value categories
- c++中,引用和指针的区别是什么? - 编程指北的回答 - 知乎
- What’s the advantage of using move constructor vs passing a pointer?
- 【推荐阅读】Understanding lvalues, rvalues and their references