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