目录
一.引用分类
1.名词解释
1).左右值
二.引用(左值引用)
1.左值引用(Lvalue Reference):
2.本质
3.形式
4.注意
5.示例
1)引用做左值
2)引用做函数返回值
三.右值引用
1.右值引用绑定一个常量
2.右值引用绑定一个变量
3.move移动语义
四.const引用
1.作为只读变量(常量)
2.作为只读变量的引用
3.const引用绑定将亡对象
五.auto与引用折叠
1.引用折叠
2.auto&
3.auto &&
六.引用传递失效与完美转发
左值是表达式结束后仍然存在的持久对象。比如:变量、函数或数据成员的名字
返回左值引用的表达式,如 ++x、x = 1、cout << ' '、字符串字面量如 "hello world"
右值是指表达式结束时就不存在的临时对象。返回非引用类型的表达式,如 x++、x + 1、make_shared(42),除字符串字面量之外的字面量,如 42、true
区分左值和右值的便捷方法是看能不能对表达式取地址,如果能则为左值,否则为右值;
2).将亡值
将亡值是C++11新增的、与右值引用相关的表达式,比如:将要被移动的对象、T&&函数返回的
值、std::move返回值和转换成T&&的类型的转换函数返回值。
C++11中的所有的值必将属于左值、将亡值、纯右值三者之一,将亡值和纯右值都属于右值
C++11中引用了右值引用和移动语义,可以避免无谓的复制,提高了程序性能。根据左右值使用可分为左值引用,右值引用,以及常量左值引用。这些引用类型在C++中用于不同的场景,允许对对象进行不同级别的操作和访问控制。左值引用和右值引用还涉及到移动语义,用于提高资源管理的效率。
左值引用是最常见的引用类型。它通过在变量名前加上 &
符号来声明,用于创建已存在对象的别名。左值引用可以用于读取和修改已存在对象的值
引用是为已存在的变量取了一个别名,引用和引用的变量共用同一块内存空间。
int &b = a;
int num= 42;
int& ptrNum = num;
#include
#include
using namespace std;
int & Function(int & a)
{
return a;
}
int main()
{
int x1=5;
cout<< x1<
需要注意以前几点:
引用作为函数的返回值时,必须在定义函数时在函数名前将&。
用引用作函数的返回值的最大的好处是在内存中不产生返回值的副本。
函数返回值可以是一个对象引用,在这种情况下函数中的return语句必须返回一个变量或可以做为左值的表达式。不要返回局部变量的引用,原因是局部变量会在函数返回后被销毁,因此被返回的引用就成为”无所指”的引用,程序进入未知状态。(可以使用右值引用避免这种情况)
根据左右值定义,常量只能做右值,而变量做右值时仅会读取。按照这个定义来理解,“右值引用”就是对“右值”的引用了,而右值可能是常量,也可能是变量,那么右值引用自然也是分两种情况来不同处理(右值引用常量,右值引用变量)。右值引用就是对一个右值进行引用的类型。因为右值没有名字,所以我们只能通过引用的方式找到它。通过右值引用的声明,该右值又“重获新生”,其生命周期其生命周期与右值引用类型变量的生命周期一样,只要该变量还活着,该右值临时量将会一直存活下去。
与左值引用不同的是,右值引用并不是为了让引用的对象只能做右值。右值引用的本意是可以作为左值。
和const引用一样,常量没有地址,没有存储位置,只有值,因此,要把这个值保存下来的话,同样得按照“新定义变量”的形式,因此,当右值引用绑定常量时,相当于定义了一个普通变量
int &&a = 5;
// 等价于
int a = 5; // a就是个普通的int变量而已,并不是引用
因此,右值一旦引用,就相当于一个左值!
因此,不能以下这样写法!
int &&a = 1;
int &&b = a;
先看此段代码:
#include
using namespace std;
class A
{
public:
A() :m_ptr(new int(0))
{
cout << "constructor A" << endl;
}
~A()
{
cout << "destructor A, m_ptr:" << m_ptr << endl;
delete m_ptr;
m_ptr = nullptr;
}
private:
int* m_ptr;
};
// 为了避免返回值优化,此函数故意这样写
A Get(bool flag)
{
A a;
A b;
cout << "ready return" << endl;
if (flag)
return a;
else
return b;
}
int main()
{
{
A a = Get(false); // 运行报错
}
cout << "main finish" << endl;
return 0;
}
在解读这段代码之前,我们先来复习以下拷贝构造
拷贝构造函数(const 类名& 引用名){ … }
在以下三种情况下拷贝构造函数会自动被调用
1.已经创建完毕的对象初始化一个新的对象。
2.值传递方式给函数传参
3.以值方式返回局部对象
上述代码属于第三种情况导致的拷贝构造。
而根据规则,当用户没有定义拷贝构造的时候,C++会执行默认拷贝构造函数,进行浅拷贝(直接将原内容的地址交给要拷贝的类,两个类共同指向同一空间),这样执行上述代码,则会造成两次析构。
[root@fedora quote]# ./a.out
constructor A
constructor A
ready return
destructor A, m_ptr:0x19522e0
destructor A, m_ptr:0x1951eb0
destructor A, m_ptr:0x19522e0
free(): double free detected in tcache 2
已放弃(核心已转储)
因此我们应该提供深拷贝操作
A(const A& a) :m_ptr(new int(*a.m_ptr))
{
cout << "copy constructor A" << endl;
}
面代码中的 Get 函数会返回临时变量,然后通过这个临时变量拷贝构造了一个新的对
象 b,临时变量在拷贝构造完成之后就销毁了,如果堆内存很大,那么,这个拷贝构造的代价会很大,带来了额外的性能损耗。因此,移动构造(右值引用)作用就体现出来了。
移动构造是一种特殊的构造函数,用于将资源从一个对象转移到另一个对象,而不是创建新的资源拷贝。通常与右值引用连用。在移动构造函数内部,通过右值引用,将“将亡对象”转移到新的对象,原对象变成有效但未指定(一般多为指针或资源的转移)。
A(A&& a) :m_ptr(a.m_ptr)
{
a.m_ptr = nullptr;
cout << "move constructor A" << endl;
}
注意:编译器如果想要重载移动构造,需要满足以下几个条件之一:
move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转义(左值转换为右值),没有内存拷贝。换句话说就是把一个对象强制转换为一个将亡对象。
#include
#include
#include
class MyString {
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;
}
void print() const {
std::cout << data << std::endl;
}
private:
char* data;
size_t length;
};
int main() {
MyString str1("Hello, World!");
// 使用 std::move 来移动 str1 到 str2
MyString str2(std::move(str1)); //强制让buf1将亡,那么右值引用就可以接收
// 注意:此时 str1 不再有效,因为它的资源已经被移动到 str2
// str1.print(); // 这将导致未定义行为,因为 str1 的资源已被移动
str2.print(); // 输出 "Hello, World!"
// 使用移动赋值运算符来将 str2 移动给 str3
MyString str3 = std::move(str2);
// 同样,str2 不再有效,其资源已经被移动到 str3
// str2.print(); // 这将导致未定义行为
str3.print(); // 输出 "Hello, World!"
return 0;
}
值得注意的是,move语义并不是真的将变量转换为“将亡变量”。只是将引用(左值)强制转换为右值引用罢了。
总结,右值引用作用主要是两个:
避免深拷贝,与move语义来连用提升性能。
延长临时变量生命周期,避免不必要的复制。
由于C++保留了C的const
关键字,其主旨更想希望表达其“不可变”的含义。既然不可变,就有常量和只读变量之分。所以const引用分为以下两个方面含义:
对于const引用,其实根本不是引用,就是一个普通的只读变量
const int &a = 8;
// 等价于
const int a = 8; // a其实就是个独立的变量,而并不是谁的引用
当用一个const引用来接收一个变量的时候,这时的引用是真正的引用,其实在p1
内部保存了a
的地址,当我们操作r
的时候,会通过解指针的语法来访问到a
const int a = 5;
const int &p1 = a;
// 等价于
const int *p1 = &a; // 引用初始化其实是指针的语法糖
const引用同样可以让将亡对象延长生命周期,但其实设计初衷是const引用更倾向于“引用一个不可变的量。
struct Test {
int a, b;
};
Test GetAnObj() {
Test t {1, 2};
return t; // t会复制给临时空间
}
void Demo() {
const Test &t1 = GetAnObj(); // 我设法引用这片临时空间,并且让他不要立刻释放
// 临时空间被t1引用了,并不会立刻释放
}
当套用模板或者某些情况下出现下述表达式,就会造成引用折叠。
void f(int & &t);
void f(int && &t);
引用折叠最终推导结果:
& + & -> &
& + && -> &
&& + & -> &
&& + && -> &&
原则:由于&比&&优先级高,因此auto &
一定推出左值引用,如果用auto &
绑定常量或将亡对象则会报错
看示例:
auto &r1 = 5; //错误
上述表达式中,5属于临时非常量对象,他没有具体的内存位置,因此不能绑定到一个非常量引用上。可以修改为 const auto &r1 = 5;
auto &r2 = GetAnObj(); //错误
左值引用不能绑定将亡对象
int &&b = 1;
auto &r3 = b; //OK
左值引用可以绑定右值引用(因为右值引用一旦绑定后,相当于左值)
原则:auto&&遇到左值会推导出左值引用,遇到右值才会推导出右值引用。
当出现下述场景的时候:
auto &&r1 = 5;
5是右值,所以等价于int &&r1 = 5.
int a;
auto &&r2 = a;
a是左值, 推导出int &
int &&b = 1;
auto &&r3 = b;
等价int b = 1,相当于普通变量,b是左值。推导出int &r3=b;
在第3.1节曾提到过:
int &&a = 10;
int &&b = a; //错误
右值一旦绑定,就会变成左值。在上述示例中,a是一个右值引用,但其本身a也有内存名字,所以a是一个左值(或者按照第一节给出的方法,看是否可以加取地址)。在用右值引用b去引用,这是不对的。
使用std::forward()完美转发
std::forward()
是 C++ 语言中的一个函数模板,用于实现完美转发。它主要用于在函数模板中保持传递参数时的值类别和引用限定符的完整性。在 C++ 中,当我们将参数传递给另一个函数时,我们通常会使用引用来避免不必要的拷贝。然而,传递参数时,参数的值类别(lvalue 或 rvalue)和引用限定符(const 或 non-const)会被编译器推断并保留。在某些情况下,我们希望将这些信息保留并传递给另一个函数,这就是完美转发的目的。
std::forward()
函数模板的定义位于
头文件中。它接受一个参数,并将该参数转发给另一个函数。该函数模板的定义如下:
template
T&& forward(typename std::remove_reference::type& arg) noexcept;
template
T&& forward(typename std::remove_reference::type&& arg) noexcept;
std::forward()
函数模板有两个重载版本,分别用于左值和右值引用。它们通过引用折叠规则来保持原始参数的值类别和引用限定符。传递给 std::forward()
的参数是一个引用,然后 std::forward()
返回一个相同类型的引用。这种转发可以确保参数被保留并以相同的方式传递。
经过修改编译就可以通过了:
int &&a = 10;
int &&b = std::forward(a);
再看下面示例:
#include
using namespace std;
template
void Print(T &t)
{
cout << "L" << t << endl;
}
template
void Print(T &&t)
{
cout << "R" << t << endl;
}
template
void func(T &&t)
{
Print(t);
Print(std::move(t));
Print(std::forward(t));
}
int main()
{
cout << "-- func(1)" << endl;
func(1); //L R R
int x = 10;
int y = 20;
cout << "-- func(x)" << endl;
func(x); // x本身是左值 L R L
cout << "-- func(std::forward(y))" << endl;
func(std::forward(y)); //L R R
return 0;
}
输出结果:
func(1):
1是一个右值,根据下述传参规则,最终确定是右值引用。在形式上为int &&t = 1 等价 int t=1,所以t是一个左值。在函数调用的时候,会访问Print(int &t),输出L1.
左值t经过move后强制转换为右值,访问Print(int && t),输出r1
由于std::forward会按参数原来的类型转发,因此,它还是一个右值,输出r1
func(x):
x是一个左值,输出L,R,L
func(std::forward
其实对于前几个,大家应该都能推断出来。重点说一下最后一个(func(std::forward
实际上,std::forward
的行为是根据模板参数的值类别来确定的,而不是根据传递给它的参数的值类别。
在这个表达式中,func(std::forwardfunc(std::forward
中的 T
推断为 int&&
程序调用了接受右值引用的 Print()
版本,所以最终执行结果是R20
对于函数模板的参数,根据传入的实参,编译器会根据以下规则来决定参数的类型是左值引用还是右值引用:
- 如果传入的实参是左值,模板参数类型会被推导为左值引用。
- 如果传入的实参是右值,模板参数类型会被推导为右值引用