目录
- 一、左值引用
- 1.左值
- 2.左值引用
- 3.左值引用的用途
- (1)修改实参
- (2)减少拷贝
- (3)使用左值引用可以在外部修改对象内的成员变量的值
- 二、右值引用
- 1.右值
- (1)纯右值
- (2)将亡值
- 2.右值引用
- 三、移动语义
- 1.std::move()
- 2.移动语义的使用
- 3.移动语义的注意点:
- (1)std::move()本身不移动
- (2)移动语义是一种规范
- 四、完美转发
- 1.引用折叠
- (1)两个左值引用(&)结合:它们会折叠成一个左值引用(&)。
- (2)左值引用(&)和右值引用(&&)结合:它们会折叠成一个左值引用(&)。
- (3)两个右值引用(&&)结合:它们会折叠成一个右值引用(&&)。
- (3)非引用类型与右值引用(&&)结合:在模板参数推导中,非引用类型与右值引用结合时,会推导出右值引用类型。
- 2.完美转发的简单实现
- 3.完美转发的标准实现
- 五、总结
一、左值引用
左值引用,顾名思义即左值的引用。在详细介绍左值引用之前,我们先探讨一下什么是左值。
1.左值
左值是一个表示对象内存位置的表达式,它出现在赋值操作符的左边。也就是说它有一个持久的身份,并且可以通过地址访问。例如,变量、数组元素、结构体的成员等都是左值。一般来说,在表达式左边的值就是左值。更通俗来说,在大多数情况下,能够进行取地址操作的值,就是左值。(同时左值也一定能够被取地址)
int a = 10; //a是左值
int b = a; // b是左值
int c = std::max(3, 4); // c是左值
int* p = &a; // p是左值
2.左值引用
左值引用就是对左值的引用。左值引用的语法十分简单,只需要在类型后加上“&”即可,形如
T&
。 你可以将一个左值赋值给一个左值引用。
int a = 10; //a是左值
int& b = a; //b 是左值引用
int& c = 10; // 报错,这里10是字面量,是右值,不能直接赋给左值引用
3.左值引用的用途
(1)修改实参
左值引用可以起到类似于指针的效果,在函数拷贝传参后,对左值引用的值做出修改会改变原来的值。
void add(int& a) {a += 10;
}
int main() {int a = 10;add(a);std::cout << a << std::endl; // 20return 0;
}
在函数中修改实参的值,也是左值引用的一大功能之一
(2)减少拷贝
减少拷贝的作用其实和指针类似,当一个数据类型占用内存量过大时,我们就可以通过传递该类型的左值引用来减少拷贝提高性能。常见于函数传参,尤其是,类的拷贝构造函数。
struct A {int* _data{};A() : _data(new int[100]) {}A(const A& other) { //拷贝构造函数,使用常量左值引用,可以减少拷贝(因为有const修饰所以无法修改该引用的值)//todo}~A() {delete[] _data;}
};void acquire_A(A& a) { //普通函数传参时,使用左值引用可以减少拷贝// todo
}
当构造函数的形参为常量左值引用时(
const T& a
),c++会将其认为是拷贝构造函数,当构造对象传入的参数为该类的左值时,默认调用该拷贝构造函数。
(3)使用左值引用可以在外部修改对象内的成员变量的值
使用左值引用,我们就可以通过成员函数获取成员变量的值,并且修改获取的值时,也会影响原有成员变量的值。
struct A {int _data{};A(int a) : _data(a) {}int& get_data() {return _data;}
};int main() {A a{9};std::cout << a._data << std::endl; // 9int& t = a.get_data(); // 使用左值引用来接收t = 90; // 在外部改变引用的值std::cout << a._data << std::endl; // 90 说明成员变量的值也被改变了int t2 = a.get_data(); // 不使用左值引用来接收值t2 = 100; // 改变值std::cout << a._data << std::endl; // 90 说明没有改变成员变量的值return 0;
}
这里需要特殊说明的是,当一个类或结构体的成员函数返回一个左值引用时,会出现可以直接给函数赋值一样的情况。
a.get_data() = 23;std::cout << a._data << std::endl; // 23
我们知道,函数返回的是一个右值(这个在介绍右值是会详细说明),但右值是无法被赋值的。这里之所以能直接”给函数赋值“,是因为函数返回的是左值引用,引用了_data
,本质是在给_data
赋值。
二、右值引用
右值引用就是对右值的引用,在介绍右值引用之前,需要对右值有个清晰的理解。所以我们先来认识一下右值。
1.右值
右值,即表达式右边的值,右值不能被取地址,也就是说不能被赋值。右值又可以被分为纯右值(Prvalues)和将亡值(Xvalues)
右值通常表示临时对象、字面量、或者不需要或不应该有持久性存储位置的其他值。从C++11开始,右值被进一步细分为纯右值和将亡值,但通常我们简单地用“右值”来统称这两类。
(1)纯右值
纯右值包括:
字面量(如42
、3.14
)
临时对象(如表达式std::string("hello") + " world"
的结果)
不与任何对象关联的结果(如非引用
返回的函数的返回值、类型转换表达式的结果等)
需要额外注意的一点是,并不是所有的字面量都是右值,例如字符串字面量就不是右值,因为单纯的字符串字面量是可以取值的(本质是因为字符串字面量是常量)
(2)将亡值
将亡值,也叫做“右值引用表达式”,是C++11中引入的新概念,主要包括:
移动操作的源对象(如std::move(obj)
的结果)
返回值是右值引用的函数返回的表达式
以下的例子都为右值:
a++; a--; //注意,前置自增和前置自减是左值
a + b; a << b; a > b; a && b; !a// 表达式
a[n]; //数组下标
[&](int a) {return a * 2;}//lambda表达式
&a;//取地址
this->a
p->a
A();//构造函数
2.右值引用
右值引用的语法同样十分简单,其语法为在数据类型之后加上"&&",即类似于T&&
的形式。
int get_num() {return 10;
}
int main() {int&& a = 10; // a是右值引用,10是纯右值std::string&& s = std::string("hello") + " world";// s是右值引用, std::string("hello") + " world"是纯右值int&& b = get_num();bool&& t = a > b;int*&& p = &a;return 0;
}
这里尤其需要注意的一点是,右值引用本身是左值。因为右值引用是具名化的,且本身也能够被取值,只是右值引用只能引用右值。
int main() {int&& a = 10;int* p = &a; //合法,说明右值引用本身是左值int&& b = a; //报错,因为右值引用本身是左值,所以不能把右值引用赋值给另一个右值引用return 0;
}
接下来,笔者将详细介绍右值引用的重要用途。
三、移动语义
移动语义是C++11及以后版本中引入的一个重要特性,它允许通过转移资源所有权的方式,将一个对象(通常为临时对象)的资源“移动”到另一个对象,而不是进行传统的复制操作。这种机制可以显著提高程序的性能和资源管理效率。如果有同学熟悉rust语言的话,移动语义也可以理解为rust的所有权转移,即移动。移动语义也是右值和右值引用的关键用途之一。
1.std::move()
std::move()
函数的作用简单来说,就是可以把一个左值转化为右值,在移动语义中经常需要使用。
int main() {int a = 10;int&& b = a; //报错,a是左值int&& c = std::move(a); //合法,a被转化为了右值return 0;
}
2.移动语义的使用
移动语义,简单来说就是将另一个对象的数据或内存资源”偷取“过来,目的是为了提高代码性能,避免不必要的拷贝。移动语义常用于移动构造函数和移动赋值函数中。 需要注意的是,在使用移动语义时,应当保证对象中的资源始终只有一个所有者。c++标准库中有许多容器或数据结构都提供了移动语义相关功能。
#include <iostream>
#include <vector> class MyString {
private: char* data; size_t length;
public: // 普通构造函数 MyString(const char* str) : length(strlen(str)) { data = new char[length + 1]; strcpy(data, str); } // 移动构造函数 MyString(MyString&& other) noexcept : data(other.data), length(other.length) { //移交资源所有权后,将原对象资源指针置为空,即失效other.data = nullptr; other.length = 0; } // 移动赋值函数 MyString& operator=(MyString&& other) noexcept { //移交资源的所有权 if (this != &other) { delete[] data; data = other.data; length = other.length; //使原对象失效other.data = nullptr; other.length = 0; } return *this; } // 析构函数 ~MyString() { delete[] data; } }; int main() { MyString s1("Hello, world!"); MyString s2 = std::move(s1); //std::move将返回一个右值 使用移动语义初始化s2,避免不必要的内存复制 // s1现在处于有效但未定义的状态,不应该再被使用 // s2包含了原本s1的资源,可以正常使用 std::cout << s2.data << std::endl; // 输出 "Hello, world!" return 0;
}
在上述代码中,
MyString
有一个接收相同类型数据的右值引用的形参,这个构造函数又叫移动构造函数,它可以将另一个对象的数据或资源”移动“过来,这样就可以避免拷贝带来的性能损耗。当然,在上述例子中,移动之后,便把other
中的指针置为空,即other已被移动,因此,一般情况下而言,被移动后的对象就无法被使用了。这也就是为什么,移动语义提倡使用右值,因为移动后的值就无法使用,而右值本身是临时的或将亡的,正好符合移动语义的场景。
同样的,上述代码中的移动赋值函数也是起着同样的效果,也能将其它对象的资源进行转移。
3.移动语义的注意点:
(1)std::move()本身不移动
虽然移动语义有时需要结合std::move
来将一个左值来转化为右值来进行移动,且本身也具有”移动“的意思,但是std::move
本身是不会去移动资源的。真正移动资源的是程序员自己编写的那些能触发移动语义的代码(例如移动赋值函数或拷贝构造函数)。
(2)移动语义是一种规范
就像上述的MyString的例子一样,在移动构造函数中我们把other的data指针置为空。然而在具体编写代码时,尽管函数中的代码逻辑由程序员说了算,但为了确保资源的正确管理,应该遵循移动语义的规范,即在移动资源后,源对象就不再拥有该资源。因此,在C++中,移动语义是一种规范或约定,它要求程序员在移动资源后,将源对象的资源指针置为无效状态(如nullptr),以确保资源的唯一所有权,并避免潜在的悬挂指针或双重释放等问题。移动后的对象就不应该再被用作拥有该资源的对象。
四、完美转发
完美转发是C++11引入的一项特性,它允许函数模板将其参数以原始的状态(包括值类别、const限定符等)传递给另一个函数。这对于泛型编程和避免不必要的拷贝或移动操作非常有用。如果你对c++的模板还不是很了解,推荐你去看看我的上一篇文章:C++进阶,一文带你迅速入门c++模板元编程!
完美转发主要解决的问题是,当需要将一个函数的参数转发给另一个函数时,通常需要保留原始参数的左右值属性。如果不使用完美转发,那么为了保证能保留原始参数的属性就需要编写大量重复代码,否则参数在传递过程中可能会发生额外的拷贝或移动操作,导致性能下降或语义错误。
当然在介绍完美转发之前,需要了解一下什么是引用折叠。
1.引用折叠
引用折叠主要针对于模板函数,使用引用折叠可以让一个函数即接收左值,也能接收右值。引用折叠的规则可以归纳为以下几点:
(1)两个左值引用(&)结合:它们会折叠成一个左值引用(&)。
例如:X& & 折叠为 X&
(2)左值引用(&)和右值引用(&&)结合:它们会折叠成一个左值引用(&)。
例如:X& && 折叠为 X&
这里的右值引用作为模板参数时,可能会与左值结合,编译器会在模板形参类型前自动加&,形成& &&,然后依据规则折叠为&。
(3)两个右值引用(&&)结合:它们会折叠成一个右值引用(&&)。
例如:X&& && 折叠为 X&&
(3)非引用类型与右值引用(&&)结合:在模板参数推导中,非引用类型与右值引用结合时,会推导出右值引用类型。
例如:在模板函数template void f(T&& param);中,如果param被左值初始化,类型推导会使T成为左值的引用类型(如int&),然后T&&变为左值引用;如果param被右值初始化,T则是右值引用的类型(如int&&),此时T&&保持为右值引用。
具体可参照下表:
模板类型 | 实际类型 | 最终类型 |
---|---|---|
T& | T | T& |
T& | T& | T& |
T& | T&& | T& |
T&& | T | T&& |
T&& | T& | T& |
T&& | T&& | T&& |
template<class T>
//T&& 也称为万能引用
void f(T&& arg) {//todo
}
int main() {int a = 10;f(a); // 既能接收左值f(10); // 也能接收右值return 0;
}
简单一句话来讲,就是”遇左则左,同右则右“
2.完美转发的简单实现
完美转发需要借助一个名为static_cast<T>()
的函数,该函数的作用是将参数的类型转为T,并返回。如下面代码所示,我们重载了3个接收不同类型函数的fun,并且定义了一个名为my_forward
的
模板函数;参数为T&&
(万能引用),可以接收任意的数据类型(即左值、右值都能接收)并且发生引用折叠(具体规则请看上文表格)。这里需要尤其注意的是,引用(无论左值引用右值引用)本身是左值,所以在把参数转发给fun时,需要使用static_cast<T&&>
来将其再次强转为T&&
,来对参数进行转发,配合引用折叠,这样就能保留参数原有的属性并转发给正确的函数处理。
void fun(int&& a) {std::cout << "rvalue" << std::endl;
}
void fun(int& a) {std::cout << "lvalue" << std::endl;
}
void fun(const int& a) {std::cout << "const lvalue" << std::endl;
}
template<class T>
void my_forward(T&& arg) {fun(static_cast<T&&>(arg));
}
int main() {int a = 10;my_forward(std::move(a)); // rvaluemy_forward(a); // lvaluemy_forward(10); // rvalueconst int& t = 90;my_forward(t); // const lvaluereturn 0;
}
3.完美转发的标准实现
事实上c++为我们提供了用于完美转发的函数forward<T>()
;我们只需要把上述代码中的static_cast<T&&>
替换为forward<T>()
即可。
void fun(int&& a) {std::cout << "rvalue" << std::endl;
}
void fun(int& a) {std::cout << "lvalue" << std::endl;
}
void fun(const int& a) {std::cout << "const lvalue" << std::endl;
}
template<class T>
void perfect_forward(T&& arg) { // 完美转发fun(std::forward<T>(arg));
}
int main() {int a = 10;const int b = 20;perfect_forward(a); // lvalueperfect_forward(b); // const valueperfect_forward(std::move(a));// rvalueperfect_forward(7); // rvaluereturn 0;
}
事实上forward<T>
的原理和static_cast<T&&>
差不多,这是forward<T>()
的源码,可以发现其内部也使用了static_cast<T&&>
。
template<typename _Tp>_GLIBCXX_NODISCARDconstexpr _Tp&&forward(typename std::remove_reference<_Tp>::type& __t) noexcept{ return static_cast<_Tp&&>(__t); }
五、总结
左值引用、右值引用、移动语义和完美转发是C++11及以后版本中引入的重要特性,它们提供了更强大的资源管理和性能优化手段。左值引用和右值引用的引入使得C++能够更清晰地表达对象的生命周期和资源的所有权,而移动语义则允许程序员在不需要复制资源的情况下“移动”它们,从而提高程序的性能。完美转发则是一种模板编程技术,它允许函数模板将其参数原封不动地转发给另一个函数,包括参数的值类别,在编写通用的包装函数或转发函数时非常有用。