在C++11及以后的版本中,可以将变量简单的分为左值,右值。其中右值又可以分为纯右值和将亡值。 不同类型的变量的生命周期与作用域也是有差别的,这次就来浅谈一下C++中变量的生命周期。
事情的起因是最近在修一个C++的bug时,发现是因为变量的生命周期问题导致的,所以就来总结一下。
bug分析
有bug的代码如下:
1HashesMetaValue tmf_meta_value2(DataType::kHashes, std::string(str, sizeof(int32_t)));
1using HashesMetaValue = BaseMetaValue;
2
3class BaseMetaValue : public InternalValue {
4 public:
5 explicit BaseMetaValue(DataType type, const Slice& user_value) : InternalValue(type, user_value) {}
6 ...................
7};
可以看到,BaseMetaValue
的构造函数接受的是const Slice&
类型的参数,
而std::string(str, sizeof(int32_t))
是一个临时变量,是一个右值(将亡值),
所以这个临时变量的生命周期只在这一行代码中,当这一行代码执行完后,这个临时变量就会被销毁,
所以tmf_meta_value2
的user_value
就是一个悬空指针,所以在后面的代码中就会出现问题。
Slice
是一个类似于std::string_view
的类,Slice
不拥有数据,只是一个指向数据的指针。所以必须由调用者保证数据的生命周期。
解决方法也很简单,将BaseMetaValue
的构造函数改为接受std::string
类型的参数即可。
1auto key = std::string(str, sizeof(int32_t));
2HashesMetaValue tmf_meta_value1(DataType::kHashes, key);
这样key
就是一个左值,生命周期会持续到tmf_meta_value1
的生命周期结束。所以就不会出现悬空指针的问题了。
C++中没有严格限制变量的生命周期,我没有系统的学习过rust,但是了解过rust的一些概念,rust中有严格的生命周期检查,这样就可以避免很多悬空指针的问题, 上述的bug在rust中是不会出现的。因为rust中有严格的变量所有权的,生命周期检查。 所有权 这个概念在rust中是非常重要,我觉得这个概念在C++中也是很重要的, 想要写出高质量的代码,就需要了解这所有权这个概念。
左值、右值
左值
最简单的一个例子就是变量,变量是一个左值,它有一个内存地址,可以取地址,可以修改。
1int a = 10; // a 是左值
2int* p = &a; // 可以取 a 的地址
3a = 20; // a 可以被赋值
右值
右值是指不能取地址的临时对象或值,通常是短暂存在的(ephemeral),无法出现在赋值语句的左侧。
1int b = 5 + 3; // 5 + 3 是右值(纯右值)
其中右值又可以分为纯右值和将亡值。
纯右值
特性:没有身份,无法取地址,无法被赋值
常见的 字面量、临时对象、函数返回值
142; // 字面量是纯右值
25 + 3; // 计算结果是纯右值
3std::string("hello"); // 构造函数返回的临时对象是纯右值
将亡值
将亡值是“即将消亡的值”,通常是通过右值引用绑定到的临时对象,或者被显式移动(move)的对象。
特性:有身份(可以取地址),但生命周期即将结束
通常出现在使用 std::move
或函数返回右值引用的场景
1#include <utility>
2int&& getRvalue() { return 42; }
3int main() {
4 int a = 10;
5 int&& r1 = std::move(a); // std::move(a) 是将亡值
6 int&& r2 = getRvalue(); // getRvalue() 返回的临时对象是将亡值
7}
变量的生命周期
左值
左值的生命周期由存储期决定,通常是持久的,可以控制。比如局部变量,离开作用域后会被销毁。 动态分配的内存也是左值,需要手动释放。
1void example() {
2 int a = 10; // 左值 a,自动存储期,离开作用域销毁
3 static int b = 20; // 左值 b,静态存储期,程序结束销毁
4 int* p = new int(30); // 左值 *p,动态存储期,直到 delete
5 delete p;
6}
a
的生命周期从定义到函数结束。b
的生命周期贯穿整个程序。p
的生命周期从new
到delete
。
右值
- 纯右值:生命周期短暂,表达式结束后销毁,一旦表达式求值完成,纯右值要么被销毁,要么被用于初始化其他对象
1int main() {
2 5 + 3; // 纯右值,表达式结束后销毁
3 std::string s = std::string("hello"); // 临时对象是纯右值,构造 s 后销毁
4 const int& ref = 42; // 纯右值 42 的生命周期延长到 ref 的作用域结束
5}
- 将亡值:生命周期取决于底层对象,通常是短暂的,或者可以被转移,可以通过右值引用绑定延长生命周期
1std::string&& rvalue = std::move(str); // str 是将亡值,通过 std::move 转为右值引用,延长生命周期
声明周期的延长
- 绑定到常量左值引用
const &
当纯右值绑定到const T&
时,临时对象的生命周期延长到引用的作用域结束。1const std::string& str = std::string("temp"); // str 有效直到作用域结束
- 绑定到右值引用
&&
当右值(纯右值或将亡值)绑定到T&&
时,临时对象的生命周期延长到右值引用的作用域结束。1std::string&& str = std::string("temp"); // str 有效直到作用域结束
值类别 | 生命周期特点 | 是否可延长生命周期 | 示例场景 |
---|---|---|---|
左值 (lvalue) | 由存储期决定,持久且可控 | 不需要延长,本身持久 | 局部变量、全局变量 |
纯右值 (prvalue) | 临时,表达式结束后销毁 | 可通过 const & 延长 | 字面量、临时对象 |
将亡值 (xvalue) | 取决于底层对象,通常短暂或可转移 | 可通过 && 绑定延长 | std::move、函数返回右值引用 |
左值引用、右值引用
单纯的左值和右值其实还是好理解的,但是结合C++中的引用,就会有一些特殊的情况,比如右值引用绑定到左值, 右值引用绑定到右值等等,这些情况就需要我们去了解C++中的引用的特性。
左值引用
先来看一下最简单的左值引用,我的理解,左值引用可以理解为一个别名,它是一个左值,可以取地址,可以修改,可以看做是一种简化的指针。
1int a = 42;
2int& ref = a; // 左值引用,绑定到左值
3ref = 100; // 修改ref实际上就是修改a
左值引用中,还有一个特殊的情况,就是 const 左值引用,它可以绑定到右值,但是不能修改。
1const int& ref = 42; // const 左值引用,绑定到右值
const 类型的左值引用,常用在函数的参数中,可以接受左值和右值。
1void func(const int& ref) {
2 // ref 可以绑定到左值或右值
3}
4
5int main() {
6 int a = 42;
7 func(a); // a 是左值
8 func(42); // 42 是右值
9}
为什么 const 左值引用可以绑定到右值呢?因为右值是临时的,不会被修改,const修饰的变量是不可以修改的, 所以可以绑定到const左值引用。
右值引用
右值引用是C++11引入的新特性,它是一个新的引用类型,用于绑定到右值。
1int&& rref = 100; // 右值引用,绑定到右值
2int x = 101;
3rref = x; // 错误,右值引用不能绑定到左值
右值引用有容易迷惑的地方,比如一个函数的参数是右值引用,但是这个右值在函数调用时是左值。
1void func(int&& ref) {
2 // ref 是右值引用, 但是在函数调用时是左值
3 ref = 100; // 可以修改
4}
两种引用在函数调用时的区别:
1void func1(int& ref) {
2 // ref 是左值引用
3}
4
5void func2(int&& ref) {
6 // ref 是右值引用
7}
8
9void func3(const int& ref) {
10 // ref 是 const 左值引用
11}
12
13int main() {
14 int a = 100;
15 func1(a); // 正确 a 是左值
16 func1(101); // 错误 101 是右值
17 func2(102); // 正确 102 是右值
18 func2(a); // 错误 a 是左值
19 func3(a); // 正确 a 是左值
20 func3(103); // 正确 103 是右值
21}
引用折叠
引用折叠是C++11引入的新特性,也可以叫万能引用,用于简化引用的使用,主要用于模板的参数推导。 主要用在模板的参数推导中,当一个模板参数是引用时,引用折叠规则会决定最终的引用类型。
C++11引入右值引用 T&&
后,允许绑定到右值,同时在模板中可能出现复杂的类型推导情况,例如:
T& &
T& &&
T&& &
T&& &&
为了简化模板参数推导,引入了引用折叠规则:
引用折叠规则:
T& & -> T&
T& && -> T&
T&& & -> T&
T&& && -> T&&
简单来说,左值引用 &
具有优先级,只要组合中出现左值引用,最终结果就是左值引用。
右值引用 &&
只有在没有左值引用的情况下才会保留。
1template<typename T>
2void func(T&& param) {
3 // param 的类型取决于传入的值类别
4}
5
6int main() {
7 int x = 100;
8 func(x); // T 推导为 int&,T&& 折叠为 int&
9 func(101); // T 推导为 int,T&& 保持为 int&&
10}
- 当传入左值
x
时,T
推导为int&
,T&&
变成int& &&
,折叠为int&
。 - 当传入右值
101
时,T
推导为int
,T&&
保持为int&&
。
引用折叠生效的一个前提是模板参数要发生类型推导,如果模板参数是明确的类型,引用折叠规则不会生效。
1template<typename T>
2void func(std::vector<T>&& param); // 普通右值引用,只能绑定右值
1template <typename T>
2class A
3{
4public:
5 void push(T&& t)
6 {
7 data_.push_back(std::forward<T>(t));
8 }
9
10private:
11 std::vector<T> data_;
12};
13
14int main() {
15 A<int> a;
16 // int x = 100;
17 // a.push(x); // 编译错误:左值不能绑定到右值引用
18 a.push(101); // 正确:右值
19 return 0;
20}
这里的 T&&
是一个右值引用,但是在模板参数推导时,会根据传入的参数类型来决定最终的引用类型。
因为在初始化 A
类时,T
的类型已经确定了,所以 T&&
不会发生引用折叠。
改进
1template <typename T>
2class A
3{
4public:
5 template <typename U>
6 void push(U&& u)
7 {
8 data_.push_back(std::forward<U>(u));
9 }
10
11private:
12 std::vector<T> data_;
13};
14int main() {
15 A<int> a;
16 int x = 100;
17 a.push(x);
18 a.push(101);
19 return 0;
20}
这里的 U&&
是一个万能引用,它会根据传入的参数类型来决定最终的引用类型,所以可以正确的推导出左值和右值。
总结
C++中的变量可以分为左值和右值,右值又可以分为纯右值和将亡值。不同类型的变量的生命周期与作用域也是有差别的。
简单说有明确变量名,可以取地址,可以修改的是左值,没有明确变量名,不能取地址,不能修改的是右值。
左值和右值配合引用,可以更好的控制变量的生命周期,同时也会带来一些特殊的情况,比如引用折叠。
- 左值引用:左值引用是一个别名,它是一个左值,可以取地址,可以修改。作为函数参数时,只能绑定左值。
- const 左值引用:可以绑定到右值,但是不能修改。
- 右值引用:右值引用是一个新的引用类型,用于绑定到右值。作为函数参数时,只能绑定右值。
- 引用折叠:引用折叠是C++11引入的新特性,用于简化引用的使用,主要用于模板的参数推导。只有在发生类型推导时才会生效。
左值引用和右值引用,应该要配合 std::move
和 std::forward
使用,这样可以更好的控制变量的生命周期。
这里先挖一个坑,后面有空再来聊这部分内容。