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)。

图源[4]

解释下图中的 可移动 与 有身份:

  • 可被移动:移动构造函数、移动赋值运算符或实现了移动语义的其他函数重载能够绑定于这个表达式。换句话说是否允许将所有权移交给这些实现移动语义的函数
    • 可移动: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
  • 亡值(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, 取址表达式

std::move()

此函数将左值转换为右值,但不产生任何机器码,而是在编译期影响函数调用的重载决议[5](根据入参的引用类型决定调用拷贝构造函数还是移动构造函数),实现上是一个static_cast。move 的语义是 “我放弃调用 move 后对此对象的所有权”,因此使用 move 返回值的函数可以以破坏性的方式使用被 move 的对象,从而实现更高性能的操作。编码者不应使用被 move 后的对象,否则是 UB [6]。

有些STL类是move-only的,比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝)

1
2
3
std::unique_ptr<A> ptr_a = std::make_unique<A>();
std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr只有‘移动赋值重载函数‘,参数是&& ,只能接右值,因此必须用std::move转换类型
std::unique_ptr<A> ptr_b = ptr_a; // 编译不通过

引用类型

引用类型包含左值引用、右值引用、通用引用:

  • 语法上,使用 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
2
3
4
5
6
void func_recv_rval_ref(MyClass&& m);

MyClass myClass = "aaa";
MyClass&& myClassRvalRef = std::move(myClass);
func_recv_rval_ref(myClassRvalRef); // Do not compile: expects an rvalue for 1st argument 显然 myClassRvalRef 是左值
func_recv_rval_ref(std::move(myClass));

Question:在我们希望放弃原有对象的所有权时,右值引用可以用于避免拷贝,但是为什么不直接使用指针呢?[14]

仍然考虑通过移动来避免拷贝的场景:

  1. 如果传递一个左值 lval,f(std::move(lval)) 相比于 f(&lval) 有更好的语义,前者意味着我们完全放弃 lval 的所有权
  2. 如果传递一个右值 如 MyClass{},MyClass(MyClass{}) 会创建一个临时变量,之后移动到函数内,而我们无法对右值取地址,如 MyClass(&MyClass{}) 是不合法的
    (在 C++17 中,rvalue 的定义发生了变化,临时变量,即 MyClass{} 会推迟到 destination 处被初始化,从而省略了一次移动,这个优化称为 copy elision)
  3. 引用确保非空,在接受指针的函数中,我们需要对指针是否为空进行判断,而引用则不需要

顺便提一下 copy elision,C++17 在特定场景下保证此优化一定发生,但在之前版本通常也会做此优化,看下面的例子

1
2
3
4
5
6
MyClass mc = MyClass{};
// C++11 -fno-elide-constructors
// Default constructor
// Copy constructor
// Otherwise
// Default constructor

Question: 函数返回值是左值还是右值?[7]

  • 函数返回值是值传递(return by value)的,返回值就是个右值;
  • 函数返回值是引用传递(return by reference)的,返回值就是个左值。

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// i可以通过&取地址,位于等号左边,i是一个左值,5位于等号右边,无法&5,因此5是一个右值
int i = 17;
// 左值引用大家都很熟悉,能指向左值,不能指向右值的就是左值引用(由于右值没有地址,没法被修改,所以左值引用无法指向右值)
A a = A(); // 同理,A()是一个临时的,无法取地址的值,是右值,而a是左值
int& r = i;
double& s = d;
const int &ref_a = 5; // 例外情况:const左值引用不会修改指向值,因此可以指向右值
// 右值引用的标志是&&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值
int &&ref_a_right = 5; // ok
int a = 5;
int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值
ref_a_right = 6;

int a = 5; // a是个左值
int &ref_a_left = a; // 左值引用指向左值
int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向
// std::move 唯一的功能是把左值强制转化为右值,其实现等同于一个类型转换:static_cast<T&&>(lvalue)
int &&ref = std::move(a) // std::move返回一个右值引用
// 注意右值引用ref本身是一个左值

// std::move显式地将左值转换为右值,下面看一个隐式左值转换为右值的例子
int a = 1; // a 是左值
int b = 2; // b 是左值
int c = a + b; // a和b自动转换为右值求和

void func(std::unique_ptr<Clss> &ref);
std::unique_ptr<Clss> a = std::make_unique<Clss>();
func(a); // 函数形参是左值引用,可以接受左值 a
1
2
3
4
5
6
7
// 基于右值引用实现的复制
Array(Array&& temp_array) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时销毁data_,提前置空其data_,同时在析构函数中需要添加判空逻辑
temp_array.data_ = nullptr;
}

看一个vector::push_back的例子[1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 例2:std::vector和std::string的实际例子
int main() {
std::string str1 = "aacasxs";
std::vector<std::string> vec;

vec.push_back(str1); // 传统方法,copy
vec.push_back(std::move(str1)); // 调用移动语义的push_back方法,避免拷贝,str1会失去原有值,变成空字符串
// 可移动对象在<需要拷贝且被拷贝者之后不再被需要>的场景,建议使用std::move触发移动语义,提升性能
vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值
vec.emplace_back("axcsddcas"); // 也可以直接传右值
}

// std::vector方法定义
void push_back (const value_type& val);
void push_back (value_type&& val);

void emplace_back (Args&&... args);

TODO 自动生成 各种构造函数的条件

std::forward()

此函数的目的是为了保证参数传递时 value category 不变,考虑如下例子[15]

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void g(T&& x)
{
...
}

template<typename T>
void f(T&& x)
{
g(x);
}

根据调用 f 时传入的是左值还是右值,x 可以是左值/右值引用,但 x 本身是左值,即 g 的参数永远是左值,g 函数内部的 x 永远是一个左值引用。如果我们希望 x 的左/右值引用属性透传到 g,此时就需要使用 std::forward

对于 std::forward(x),如果 x 是右值引用,那么返回值是一个右值,此时 forward 等价于 move,如果 x 是左值引用,那么返回值是一个左值,此时 forward 什么也不做

应用

std::make_unique 的实现大致如下[4]

1
2
3
4
5
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args)
{
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

我们希望将外部参数的 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 补充使用场景
https://learn.microsoft.com/en-us/cpp/cpp/how-to-create-and-use-shared-ptr-instances?view=msvc-170

weak_ptr:用于解决shared_ptr循环引用的问题
细节见下方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A { shared_ptr<B> b; ... };
class B { shared_ptr<A> a; ... };
shared_ptr<A> x(new A); // x count +1
x->b = new B; // x->b count +1
x->b->a = x; // x count +1
// Ref count of 'x' is 2.
// Ref count of 'x->b' is 1.
// When 'x' leaves the scope, there will be a memory leak:
// 2 is decremented to 1, and so both ref counts will be 1.
// (Memory is deallocated only when ref count drops to 0)

// class B 使用 weak_ptr

class A { shared_ptr<B> b; ... };
class B { weak_ptr<A> a; ... };
shared_ptr<A> x(new A); // +1
x->b = new B; // +1
x->b->a = x; // No +1 here
// Ref count of 'x' is 1.
// Ref count of 'x->b' is 1.
// When 'x' leaves the scope, its ref count will drop to 0.
// While destroying it, ref count of 'x->b' will drop to 0.
// So both A and B will be deallocated.

智能指针相对于原始指针解决了什么问题

smart pointer 将堆上空间的生命周期与栈上的只能指针的生命周期绑定,从而实现自动内存管理

TODO 可以看这个 https://zhuanlan.zhihu.com/p/365765483,大致意思就是随着退栈自动释放

为什么应当尽量使用 make_unique(而非直接用 unique_ptr)

  1. 考虑如下代码 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>());

  1. unique_ptr 中重复出现了两次 T(仍然用上面的例子),而 make_unique 只需要指定一次

智能指针实现

TODO 三种 unique_ptr, shared_ptr, weak_ptr 如何实现

参考文章

  1. (primary) 一文读懂C++右值引用和std::move
  2. https://www.runoob.com/cplusplus/cpp-references.html
  3. Reference declaration
  4. c++为什么要搞个引用岀来,特别是右值引用,感觉破坏了语法的简洁和条理,拷贝一个指针不是很好吗? - 郭不昂的回答 - 知乎
  5. C++ 的移动 move 是怎么运作的? - sin1080的回答 - 知乎
  6. What can I do with a moved-from object?
  7. C++左值、右值和构造函数们 - lyf的文章 - 知乎
  8. 如何评价 C++11 的右值引用(Rvalue reference)特性? - Tinro的回答 - 知乎
  9. C++返回值优化
  10. 比起直接使用new,更偏爱使用std::make_unique和std::make_shared
  11. shared_ptr and weak_ptr differences
  12. Value categories
  13. c++中,引用和指针的区别是什么? - 编程指北的回答 - 知乎
  14. What’s the advantage of using move constructor vs passing a pointer?
  15. 【推荐阅读】Understanding lvalues, rvalues and their references

C++ 值类型、引用类型与智能指针
https://vicety.github.io/2022/01/28/智能指针-左右值引用-std-move/
作者
vicety
发布于
2022年1月28日
许可协议