1.左值和右值表达式
先要明确,左值和右值都是表达式。值(value)是无法进一步求值的表达式:例如表达式“1+1”就不是一个值,因为它可以被简化为表达式“2”,不能继续被化简,因此“2”是一个值。
根据左值和右值的特性,可以简单地用是否能取到地址来判断类型:
- 左值(l-value):能被取地址;
- 右值(r-value):不能被取到地址。
1.1概念
左值具有确定的、可以被获得的内存地址。这意味着左值可以是变量,也可以是对指向特定内存地址的指针解引用的结果。
在C语言中,“左值”最初表示可以被赋值的对象,也就是赋值运算符左侧的对象,但是随着const
关键字的引入,这类对象就被称作“可更改的左值”。在C++中,左值和右值是表达式的分类,也就是说,一个表达式非左值即右值。
对于C++标准有两点:
1. C++11将左值性(lvalueness)扩充为更复杂的值类别(value category):
包含左值(lvalue),纯右值(prvalue)和临终值(xvalue)三个基本分类(fundamental classification),一个表达式必然是三者之一。
2. C++11标准引入了右值引用数据类型与移动语义,因而左值与右值的定义发生了很大变化。右值引用变量绑定到右值上,延长了右值对应的临时对象的生存期。移动语义把临时对象的内容移动(move)到左值对象上。
1.2左值和右值
左值表达式:
- 可以被取地址,可以被修改(除了const修饰的左值),例如变量名或解引用的指针。
- 可以在赋值运算符的两侧。
int* pa = new int(1); // 指针
int a = *pa; // 被解引用的指针
int b = 2; // 变量名
const int c = 3; // 不可被修改
右值表达式:非左即右
- 不能被取地址,也不能被修改,例如字面常量、表达式的返回值、函数的(非左值引用)返回值。
- 只能在赋值运算符的右侧。
// 错误写法
//1 = 11;
//x + y = 2;
//func(a, b) = 3;
// 正确写法
int x = 1;
int c = a + b;
int d = func(a, b);
通过示例可以知道右值和左值的区别:
右值的本质就是常量值或临时值,如上例中的
1
就是字面常量,x+y
和函数的返回值就是一个临时值。临时值一般作为计算过程的中间值,因此它被保存在寄存器中,没有地址。而常量值存储在数据段中。
上面的函数的返回值是一个临时变量,如果返回一个引用就是左值了。
关于左值和右值,最后再用一个例子理解:
左值是一张纸,它里面的值就是字;右值是单纯的字。
如上例中的a + b
,就是1+2
,那么c = a + b
就是c=3
。用c
这个空间存储3这个值,c
就是纸,是左值;3就是字,是右值。假如d=c
,就是把c
这张纸中的字抄到d
这张纸上,那么c
就是右值,而这与d
上原来的内容是无关的。
小结:
在C++中,左值和右值是两种表达式的分类。
左值是指可以出现在赋值号 = 的左边或右边的表达式,它有一个明确的内存地址,可以被引用或修改。
右值是指只能出现在赋值号 = 的右边的表达式,它没有一个明确的内存地址,不能被引用或修改。右值一般是常量、临时对象或函数返回值。
2. 左值引用和右值引用
C++中引用就是给变量起别名,C++11以后,将C++11之前的引用称为左值引用,进而引入C++11中最强有力的特性:右值引用。
左值引用:
语法:左值引用和之前的引用一样,给左值取别名:
int main()
{
int* pa = new int(1);
int a = *pa;
int b = 2;
const int c = 3;
// 左值引用:起别名
int*& Pa = pa;
int& A = a;
int& B = b;
const int& C = c;
return 0;
}
右值引用:
右值引用的主要用途
- 避免不必要的拷贝:通过右值引用,你可以避免不必要的拷贝操作,提高程序的效率。
- 实现移动语义:右值引用是实现移动语义的基础,能够显著提高对象管理的效率,特别是在资源管理方面(如动态内存、文件句柄等)。
语法:右值引用就是给右值取别名,通过&&
声明:
int func(int a, int b)
{
return a - b;
}
int main()
{
1;
int a = 1, b = 2;
a + b;
func(a, b);
// 右值引用:起别名
int&& x = 1;
int&& c = a + b;
int&& d = func(a, b);
return 0;
}
右值本身是不能被取地址的,但是右值被起了别名以后会被存储到特定位置,并且可以被修改,可以使用const
关键字限制。
2.1 相互引用
2.1.1左值引用右值
左值引用不能直接引用右值,原因是左值可以被修改,而右值不行。这就类似非const
变量能否接收普通变量,涉及到权限问题。权限只能被缩小或平移,不能被放大。
int main()
{
int a = 1, b = 2;
// int& c = a + b; // 错误
const int& c = a + b; // 正确
// c是左值引用
return 0;
}
这里我们可以想到在这之前我们总是有这样的习惯:函数的参数类型不仅是引用,而且还是被const
修饰的,认为“为保证安全性,避免被修改”是没错的,但从更深层次看来,加上const也是为了参数能够兼容左值引用和右值引用。
2.1.2右值引用左值
- 右值引用只能引用右值。
- 右值引用可以引用move以后的左值。
//右值引用左值?
int x = 1, y = 2;
int&& e2 = x + y;//x+y仍是右值,所以这样写合法
int p = 3, q = 4;
//int&& e3 = move(p + q);//std::move 是多余的,因为它将左值转换为右值,但在这里已经是右值。
int&& e3 = move(p);//正确写法
move结构定义
move移动语义允许资源(如动态内存,文件句柄等)从一个对象移动到另一个对象,而不必进行昂贵的复制操作。
#include <utility> // 包含std::move的定义
move函数的定义:template<class _Ty> inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT { //forward _Arg as movable return ((typename remove_reference<_Ty>::type&&)_Arg); }
详细解释
remove_reference
typename remove_reference<_Ty>::type
remove_reference
是一个类型特征,它会去掉类型中的引用部分。
- 如果
_Ty
是int&
,那么remove_reference<_Ty>::type
是int
。- 如果
_Ty
是int&&
,那么remove_reference<_Ty>::type
也是int
。- 如果
_Ty
是int
,那么remove_reference<_Ty>::type
还是int
。转换为右值引用
(typename remove_reference<_Ty>::type&&)_Arg
- 这个部分是强制类型转换,将
_Arg
转换为一个右值引用。_Arg
原本是一个万能引用(可以是左值引用或右值引用),这个转换确保了返回值是一个右值引用。
move作用
std::move
的主要作用是将一个对象显式地转换为右值引用,从而可以调用对象的移动构造函数或移动赋值运算符,而不是复制构造函数或复制赋值运算符。
move的注意事项:
std::move
本身并不执行移动操作,它只是将其参数显式地转换为右值引用,从而启用移动语义。实际的移动操作是由对象的移动构造函数或移动赋值运算符完成的。
- _Arg 参数的类型不是右值引用,而是万能引用。万能引用跟右值引用的形式一样,但是右值引用需要是确定的类型。
- 被move的左值的状态是未知的,它有可能已经被破坏了。
启动移动语义
被move后的左值能够赋值给右值引用,例如:
int main() { int a = 1, b = 2; int&& c = move(a + b); return 0; }
这段代码的核心思想是使用
std::move
函数将a + b
的结果转换为右值引用,从而在某些情况下可以通过移动语义提高效率。原本a + b
是一个右值,而std::move
只是将其参数转换为右值引用,用于标记移动操作的意图。
2.2 示例代码
C++中右值引用的使用场景主要用于移动语义,或者说,移动语义利用右值引用实现资源转移,即从一个对象将其内容移动到另一个对象,而不需要执行复制操作。
右值引用的意义在于提供了一种更加灵活的方式来处理右值,在函数调用中,右值引用可以用来避免拷贝构造函数的调用,从而提高程序的效率;在容器中,右值引用可以用来实现移动语义,从而更加高效地移动容器中的数据。
例如,当将一个临时变量赋值给一个普通变量时,会发生复制操作,如果这个对象很大,带来的开销是不可忽略的,而使用右值引用可以避免该复制操作,从而提高程序性能。
用一个简单的string类示例,它只实现了拷贝构造函数和赋值运算符重载函数等基本成员函数,并分别在它们内部增加提示语句。
#define _CRT_SECURE_NO_WARNINGS 1 #include <iostream> #include <cstring> using namespace std; namespace xy { class string { public: // 构造函数 string(const char* str = "") { cout << "string(const char* str) -- 构造" << endl; _size = strlen(str); _capacity = _size; _str = new char[_capacity + 1]; strcpy(_str, str); } // void swap(string& s) { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); } // *拷贝构造函数 // 拷贝构造函数 string(const string& s) : _str(new char[s._capacity + 1]), // 分配新内存 _size(s._size), _capacity(s._capacity) { cout << "string(const string& s) -- 拷贝构造" << endl; strcpy(_str, s._str); // 拷贝数据 } // *赋值运算符重载 // 赋值运算符重载 string& operator=(const string& s) { cout << "string& operator=(const string& s) -- 深拷贝" << endl; if (this != &s) // 检查自赋值 { // 释放当前对象的内存 delete[] _str; // 分配新内存 _size = s._size; _capacity = s._capacity; _str = new char[_capacity + 1]; // 复制数据 strcpy(_str, s._str); } return *this; } void push_back(char ch) { if (_size == _capacity) { reserve(_capacity == 0 ? 4 : _capacity * 2); } _str[_size] = ch; _str[_size + 1] = '\0'; _size++; } // +=运算符重载 string& operator+=(char ch) { push_back(ch); return *this; } //扩容 void reserve(size_t n) { if (n > _capacity) { char* tmp = new char[n + 1]; strcpy(tmp, _str); delete[] _str; _str = tmp; _capacity = n; } } // 其他接口... private: char* _str; size_t _size; size_t _capacity; }; }
注意,这个string类是深拷贝。且为了方便稍后和库中的string一起测试,用一个命名空间包了起来。
理解了拷贝的方式,就能理解左值引用和右值引用的作用:
区分左值和右值的目的是优化程序的性能和内存管理。
- 左值一般是有名字的变量,它们占用一定的内存空间,可以被重复使用或修改。
- 右值一般是临时的表达式结果,它们在使用后就会被销毁,不能被重复使用或修改。
- 如果能够将右值转移给左值,就可以避免不必要的拷贝和内存分配,提高程序的运行效率。C++11 之后引入了右值引用(&&)和移动语义(move),使得这种转移成为可能。
2.3 左值引用
使用场景
既然引用本身的价值就是减少拷贝,那么左值引用的优点也是类似的:
- 做参数:减少拷贝,提高效率;做输出型参数。
- 做返回值:减少拷贝,提高效率;引用返回,可以修改返回的对象(如operator[])。
输出型参数就像鱼钩一样,它作为参数被传入函数,执行完毕后便能取出。OJ常常将它用于(尤其是C语言)返回多个返回值。
然而,在“减少拷贝”方面左值引用并未覆盖所有情况。
例如to_string这个接口就是传值返回,原因是它的返回值是一个类型,它有析构函数,当跳出函数的作用域时析构函数会被自动调用,返回值就是一个局部对象了。除此之外,还有使用容器实现的二维数组,从语言角度上string和vector都是一个类型(class),都有自己的构造和析构函数。
注意,C++11之前的所有引用从C++11的角度看都是左值引用,此处讨论的也是如此。
而且C++98不支持返回二维数组的引用,只能返回指针。同样地,只支持返回string的拷贝,而不能返回string的引用。
例如,C++98难以解决上面这两种情况:
string to_string(int val); // to_string原型
vector<vector<int>> func(int num) // 返回值是二维数组,例如杨辉三角
如何避免返回值拷贝,提高效率?
不难想到,可以用输出型参数解决这个问题:
使用指针传递结果
void to_string(int val, string& str);
void func(int num, vector<vector<int>>& vv);
使用引用传递结果
void to_string(int val, string& result) {
// 将整数转换为字符串并存储在 result 中
}
void func(int num, vector<vector<int> >& result) {
// 生成二维数组并存储在 result 中
}
当然,这样能解决问题,但没人会这样做,因为它不符合习惯。使用输出型参数也不那么优雅。
在全局中新增一个to_string接口,但是不用实现内部功能,只需要保证to_string的返回值是一个临时对象即可:
xy::string to_string(int val)
{
xy::string str;
// ...
return str;
}
测试:
int main()
{
xy::string str = to_string(1);
return 0;
}
这时输出:
string(const char* str) -- 构造
编译器做出的返回值优化:
这是因为不同的编译器做了不同程度的优化,正如我现在使用的(vs2022)省略了临时变量的构造和拷贝构造str,而是直接构造了str。
完整的可能输出(如果没有RVO/NRVO优化):
to_string
中的构造函数调用。to_string
返回值中的拷贝构造函数调用。main
中的赋值运算符重载(可能不会触发,因为直接初始化)。string(const char* str) -- 构造 string(const string& s) -- 拷贝构造 string& operator=(const string& s) -- 深拷贝
输出中的第一个构造函数是因为
to_string
中的创建,第二个拷贝构造函数是因为返回值的返回是值返回,最后一个深拷贝是因为赋值运算符重载的调用。
回到程序本身,str = to_string(1)
的本意就是让1这个值初始化str,从以往的经验来看,这个1就是一个临时值,经过编译器优化以后是不会对临时变量调用构造函数的。
VS优化的只会打印一次,它忽略了to_string的临时对象。
原因是在不进行返回值优化的情况下,编译器会在
main
函数的栈帧中预留一些空间来存放函数to_string
的返回值,即这个临时对象。然后,这个临时对象会从to_string
函数拷贝到main
函数栈帧中的返回值(ret)上,最后再拷贝到str
对象中。
缺点
左值引用虽然能在很多情况下避免拷贝带来的开销,但是左值引用在函数的返回值中不能起到作用,原因是函数内部的变量都是局部变量,出了函数的作用域就会被销毁,不论是否是引用,都无法在函数外部取到。
这就是右值引用存在的意义。
2.4 移动语义
移动语义简单概述
移动语义是C++11提供的一种优化技术,它可以将一个对象的资源(如内存、文件句柄等)从一个对象转移到另一个对象,而不需要复制或者销毁资源。
移动语义可以利用右值引用来实现,通过重载移动构造函数和移动赋值运算符来定义对象如何被移动。移动语义可以提高程序的性能,避免不必要的拷贝和内存分配。
简单来说,移动语义通过移动构造函数实现。移动构造函数就是一个资本家,它的参数是右值引用类型,这个构造函数想:“右值既然是临时值,在它消亡之前不如利用一下”,在内部会将右值的资源直接转移到自己身上,用来构造自己。
因此就左值引用不能用于返回值这一问题,可以在string类中写一个移动构造函数,在函数内部将传入的右值的资源通过swap函数转移到自己身上,增加语句以提示:
namespace xy
{
class string
{
public:
//移动构造
string(string&& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
swap(s);
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
这样,就能解决左值引用在函数返回值中的问题。
右值引用和移动语义之间的关系:
- 右值引用是一种特殊的引用类型,它可以绑定到一个临时对象或者一个即将被销毁的对象,从而表示这个对象可以被移动而不需要拷贝。
- 移动语义需要右值引用来实现,因为右值引用可以区分出哪些对象是可以被安全地移动的,而不会影响其他地方的使用。
- 通过重载移动构造函数和移动赋值运算符,我们可以定义当一个对象被右值引用绑定时,如何将它的资源转移到另一个对象中,从而避免不必要的拷贝和内存分配。
移动构造函数和拷贝构造函数区别:
移动构造函数和拷贝构造函数都是用来利用一个已有对象构造出一个新的对象的,但是它们的区别如下:
- 移动构造函数的参数是一个右值引用,而拷贝构造函数的参数是一个左值引用。在没有增加移动构造之前,由于拷贝构造采用的是 const 左值引用接收参数,因此无论拷贝构造对象时传入的是左值还是右值,都会调用拷贝构造函数;增加移动构造之后,由于移动构造采用的是右值引用接收参数,因此如果拷贝构造对象时传入的是右值,那么就会调用移动构造函数(最匹配原则)。
- 移动构造函数可以直接将已有对象的资源转移到新对象中,而不需要分配新的空间或者复制数据。这样可以提高性能,降低成本。例如 string 的拷贝构造函数是深拷贝,而移动构造函数中只需要调用 swap 函数进行资源转移,因此移动构造的代价比拷贝构造的成本小。
- 拷贝构造函数会保留已有对象的资源和数据,而移动构造函数会使已有对象失去资源和数据。因此,在使用移动构造函数后,已有对象可能处于不可预期的状态。
注意:
- to_string中 对象str 在当前函数调用结束后就会立即被销毁,返回的是该对象的拷贝构造的临时变量,这种即将被消耗的值叫做 “将亡值”,比如匿名对象也是 “将亡值”。
- 既然 “将亡值” 马上就要被销毁了,那还不如物尽其用,最后再利用一下。因此编译器在识别 “将亡值” 时会将它识别为右值,这样就可以匹配到参数类型为右值引用的移动构造函数。
编译器的优化:
当一个函数在返回局部对象时,会先用这个局部对象拷贝构造出一个临时对象,然后再用这个临时对象来拷贝构造接收返回值的对象:
如上所说,C++11之后,编译器会将这两次拷贝构造优化为1次拷贝构造:
不同接收返回值的方式对拷贝次数带来的影响:
如果是用一个已经定义的对象来接收返回值,就相当于拷贝构造了临时变量然后赋值重载拷贝,此时虽然是两次拷贝构造,编译器也无法再优化下去了:
输出:
- 编译器没有对这种情况进行优化,因此在 C++11 之前,有深拷贝的类就会有两次深拷贝,因为深拷贝的类的赋值运算符重载函数都以深拷贝的方式实现。
- 但在深拷贝的类中引入 C++11 的移动构造后,这里仍然需要再调用一次赋值运算符重载函数进行深拷贝,因此深拷贝的类不仅需要实现移动构造,还需要实现移动赋值。
注意:
对于有返回局部对象的函数,即使只是调用函数不接收返回值,也会存在一次拷贝构造或移动构造,因为返回值不论如何都存在,当函数结束后函数内的局部对象都会被销毁,所以就算不接收函数的返回值也会调用一次拷贝构造或移动构造创建临时对象。
小结
右值引用和移动语义是C++11引入的新特性,它们可以提高程序的性能和效率,避免不必要的内存分配和拷贝。
- 右值引用是一种特殊的引用类型,它只能绑定到临时对象或没有名称的变量,也就是右值。右值引用使用&&符号来声明,例如int&& r = 10;。
- 右值引用可以让编译器区分左值和右值,从而选择合适的构造函数或赋值运算符。
- 移动语义是一种利用右值引用来实现对象所有权转移的机制。当一个对象被移动后,它的资源(例如指针、内存等)被转移到另一个对象上,而自身变成一个空对象,只能被析构。这样可以避免资源的复制和释放,提高效率。
- 移动语义需要定义移动构造函数和移动赋值运算符,并使std::move()函数来将左值转换为右值引用。
何时使用右值引用和移动语义:
- 当你需要返回一个大型或占用资源的对象时,你可以使用右值引用作为返回类型,让编译器执行强制或可选的复制/移动省略(copy/move elision),从而避免创建临时对象。
- 当你需要传递一个大型或占用资源的对象作为参数时,你可以使用右值引用作为参数类型,并在调用时使用std::move()函数将左值转换为右值引用,从而让编译器调用移动构造函数或移动赋值运算符
- 当你需要定义一个自己管理资源(例如指针、内存等)的类时,你可以定义移动构造函数和移动赋值运算符,并在其中实现资源所有权的转移逻辑。
使用移动语义的注意事项
如果在使用移动语义时不小心,可能会导致一些问题,例如:
未定义行为:如果移动了资源但之后还尝试使用被移动对象,可能会导致未定义行为,包括访问空指针或无效数据。这会产生崩溃或不可预测的行为。
资源泄漏:如果在移动资源后没有适当地释放资源,可能会导致资源泄漏。例如,在移动构造函数或移动赋值运算符中,应该释放被移动对象的资源,并将被移动对象的指针设置为
nullptr
,以避免重复释放资源。重复释放:如果在移动资源后未将被移动对象的指针设置为空,而在对象的析构函数中又尝试释放资源,可能导致重复释放资源,这也会导致未定义行为。
关于内存泄露:
移动语义本身不会导致内存泄露的问题,但是如果使用不当,可能会出现一些意想不到的后果。例如,如果你使用std::move函数来转移一个对象的数据,那么你必须保证被转移的对象之后不再被使用,否则可能会访问到空指针或者无效数据。另外,如果你定义了自己的移动构造函数或者移动赋值运算符,那么你必须确保在转移资源的同时,把被转移对象的成员指针置为空,并且正确处理异常情况。否则,可能会出现资源泄露或者重复释放的问题。
2.5 移动赋值
移动赋值运算符是一种重载的赋值运算符,它的参数是自身类的右值引用,返回值是自身类的左值引用。它可以实现对象之间的资源所有权转移,而不是复制。
例如,如果你有一个字符串类,它有一个指向动态分配内存的指针成员变量。当你用一个临时字符串对象或一个即将被销毁的字符串对象来给另一个字符串对象赋值时,你可以使用移动赋值运算符来将源对象的指针直接赋给目标对象,并将源对象置为空。这样就避免了内存分配和复制的开销,并保证了源对象在析构时不会释放已经转移的资源。
移动赋值运算符需要使用noexcept关键字(非必要)来指定不抛出任何异常,并使用std::move()函数来将左值转换为右值引用。
以下是一个简单的示例:
namespace xy
{
class string {
public:
// 移动赋值运算符
string& operator=(string&& s) noexcept
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
swap(s);
return *this;
}
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
移动赋值和赋值运算符的区别
移动赋值运算符和赋值运算符都是用于给类类型的对象赋值的特殊成员函数,但是它们有一些区别:
- 移动赋值运算符的参数是一个右值引用,它可以接受临时对象或者被std::move转换为右值的对象,它不会分配新的内存,而是直接移动数据成员。
- 赋值运算符的参数是一个左值引用或者一个非引用形参,它可以接受左值或者右值,它会执行按位复制或者调用复制构造函数或者移动构造函数来生成临时对象。
对此,STL也根据C++11新增了移动构造函数,例如string类。
移动构造:
string (string&& str) noexcept;
移动赋值:
string& operator= (string&& str) noexcept;
2.6 右值引用的其他使用场景
右值引用版本的插入函数
如果vector容器中存储的是string对象,可以有以下几种插入方式:
int main()
{
vector<xy::string> v;
xy::string s("1");
v.push_back(s); // 调用string的拷贝构造
v.push_back("2"); // 调用string的移动构造
v.push_back(xy::string("3")); // 调用string的移动构造
v.push_back(std::move(s)); // 调用string的移动构造
return 0;
}
STL容器中右值引用的插入接口是C++11新增的一种方式,它可以提高性能和效率。
- 右值引用的插入接口可以接受临时对象或者被std::move转换为右值的对象,它不会创建新的对象,而是直接移动数据到容器中。
- 右值引用的插入接口可以避免不必要的拷贝和内存分配,可以实现数据控制权的转移。
3.完美转发
C++11中的完美转发是一种技术,它可以保持参数的值属性不变,即左值还是左值,右值还是右值。
- 完美转发可以避免不必要的拷贝和内存分配,提高性能和效率。
- 完美转发需要使用右值引用和std::forward函数来实现。
- 完美转发在变长模板中非常有用,因为它可以处理不同类型和数量的参数。
C++11中的forward函数的原型是一个用于实现完美转发的模板函数。它有两种重载版本,一种接收左值引用,一种接收右值引用。它的作用是根据传入参数的左右值属性来返回相应的左值或右值,从而避免不必要的拷贝。
3.1 万能引用
万能引用是一种C++11中的特殊引用,它可以绑定到左值或右值,而不需要指定类型。它的作用是实现完美转发,即保持参数的原始值类别。
万能引用可以让一个引用类型的参数既能绑定到右值,也能绑定到左值。万能引用的形式是T&&
,其中T
是一个模板类型或者auto类型。万能引用的本质是根据实参的类型来推导T的类型,从而实现右值引用或者左值引用。
例如:
template<typename T>
void f(T&& param); // param是万能引用
int x = 10;
f(x); // T被推导为int&,param是左值引用
f(20); // T被推导为int,param是右值引用
auto&& var = x; // var是万能引用
下面重载了4个func函数,根据参数是左值和右值,const与否,一共有4个func重载函数:
#include <iostream>
using namespace std;
void Func(int& x)
{
cout << "左值引用" << endl;
}
void Func(const int& x)
{
cout << "const 左值引用" << endl;
}
void Func(int&& x)
{
cout << "右值引用" << endl;
}
void Func(const int&& x)
{
cout << "const 右值引用" << endl;
}
template<class T>
void PerfectForward(T&& t)
{
Func(t);
}
int main()
{
int a = 1;
PerfectForward(a); // 左值
PerfectForward(move(a)); // 右值
const int b = 2;
PerfectForward(b); // const 左值
PerfectForward(move(b)); // const 右值
return 0;
}
输出:
左值引用
左值引用
const 左值引用
const 左值引用
结果却与设想的不同,传入不同类型的参数,就是想让编译器匹配不同参数类型的重载函数,但是最终都调用了(const)左值引用。
原因是:右值被引用后会导致右值被存储到特定位置,此时这个右值可以被取到地址,且可以被修改,所以在PerfectForward函数中调用Func函数时会将t识别成左值。
也就是说,右值在经过一次参数传递后其属性会退化成左值,如果想要在这个过程中保持右值的属性,就需要进行完美转发。
3.2 如何实现完美转发
完美转发使得一个函数可以将参数原封不动地传递给另一个函数,保持其值类别。它的实现需要使用std::forward函数和万能引用。例如:
#include <iostream>
using namespace std;
// 一个普通的函数
void func(int& a)
{
cout << "left" << endl;
}
// 一个重载的函数
void func(int&& a)
{
cout << "right" << endl;
}
// 一个模板函数,使用万能引用
template<typename T>
void func1(T&& a)
{
// 使用std::forward进行完美转发
func(forward<T>(a));
}
int main()
{
int x = 10; // x是左值
func1(x); // 输出left
func1(20); // 输出right
}
完美转发的目的是保持参数的原始值类别,即左值还是右值,const还是非const。但是在3.1的这段代码中,PerfectForward函数只是简单地将t传递给Func函数,而不使用std::forward进行类型转换。这样会导致t在PerfectForward函数内部被视为左值 ,从而调用错误的重载版本。例如,当传递一个右值给PerfectForward时,它应该调用Func(int&& x)或Func(const int&& x),但实际上却调用了Func(int& x)或Func(const int& x)。
为了解决这个问题,需要在PerfectForward函数中使用std::forward<T>(t)
来将t转换为正确的类型 。这样就可以实现完美转发了。修改后的代码如下:
#include <iostream>
using namespace std;
void Func(int& x)
{
cout << "左值引用" << endl;
}
void Func(const int& x)
{
cout << "const 左值引用" << endl;
}
void Func(int&& x)
{
cout << "右值引用" << endl;
}
void Func(const int&& x)
{
cout << "const 右值引用" << endl;
}
template<class T>
void PerfectForward(T&& t)
{
// 使用std::forward进行类型转换
Func(std::forward<T>(t));
}
int main()
{
int a = 1;
PerfectForward(a); // 左值
PerfectForward(move(a)); // 右值
const int b = 2;
PerfectForward(b); // const 左值
PerfectForward(move(b)); // const 右值
return 0;
}
不难看出,充当转发功能的是调用是:
void PerfectForward(T&& t) { // 使用std::forward进行类型转换 Func(std::forward<T>(t)); }
std::forward
可以根据t的原始类型来返回相应的左值或右值引用。这样就可以保持参数的原始值类别不变,并调用正确的重载版本了。
3.3 完美转发的应用
#include <iostream>
#include <stdlib.h>
using namespace std;
namespace xy
{
template<typename T>
class vector
{
public:
vector() // 默认构造函数
: data(nullptr), size(0), capacity()
{}
~vector() // 析构函数
{
delete[] data;
}
void init()
{
data = (T*) malloc(sizeof(T) * 100);
}
void push_back(const T &x) // 在末尾添加一个元素(拷贝)
{
insert(size, x);
}
void push_back(T &&x) // 在末尾添加一个元素(移动)
{
insert(size, std::forward<T>(x));
}
void insert(size_t pos, const T &x) // 在指定位置插入一个元素(拷贝)
{
for (size_t i = size; i > pos; i--)
{
data[i] = data[i - 1]; // 将pos之后的元素后移一位
}
data[pos] = x; // 拷贝元素到pos位置,并更新size
size++;
}
void insert(size_t pos, T &&x) // 在指定位置插入一个元素(移动)
{
for (size_t i = size; i > pos; i--)
{
data[i] = std::forward<T>(data[i - 1]);
}
data[pos] = std::forward<T>(x);
size++;
}
private:
T *data; // 指向动态数组的指针
size_t size; // 已使用的空间
size_t capacity; // 总容量
};
}
测试:vector的元素类型是之前自定义的string类型(注意,这个string类在2.5中已经增加了移动赋值重载函数),分别传入左值引用和右值引用的参数:
int main()
{
xy::vector<xy::string> v;
v.init();
v.push_back("1"); // 右值引用
cout << "右值引用↑----------------左值引用↓" << endl;
xy::string s1("3"); // 左值引用
v.push_back(s1);
return 0;
}
输出:
使用完美转发后,右值引用版本的push_back函数接收到右值后,其属性不会退化到左值,所以调用的insert函数还是右值引用版本,匹配到string类的移动赋值版本的operator=
重载函数。
同理,对于左值引用的参数最终只会匹配到左值引用版本的insert函数,对应string类原来的operator=
重载函数。
3.4补充
注意:
1. 想要保证右值的属性一直不发生变化,需要在每次右值被传参时都进行完美转发(事实上STL也是这么做的)。例如vector每次扩容后的资源转移,其中的赋值操作就必须使用std::forward()或std::move()
。
想保证右值的属性不发生变化,有以下几种方法:
- 使用 const 关键字来修饰右值引用,例如 const int&& r = 10;。这样就可以防止对 r 的修改。
- 使用 std::move() 函数来将一个左值转换为右值,然后再绑定到右值引用上,例如 int x = 10; int&& r = std::move(x);。这样就可以避免对 x 的修改影响到 r。
- 使用 std::forward() 函数来实现完美转发,即根据参数的类型自动选择左值引用或右值引用。这样就可以保持参数的原始属性不变。
2.push_back和insert接口中的参数T&&
已经被推断为右值引用了,因为实例化vector对象时的参数类型就已经确定了。
测试所实现的简易vector与STL之间最大的区别就是开辟空间方式的不同,STL首先通过空间配置器获取内存,在申请到内存之后不会立刻调用构造函数初始化,而是用定位new用左值或右值对申请的内存空间初始化,此时调用的函数是拷贝构造或移动构造。
简易实现的vector使用的是malloc开辟空间,申请到空间以后,会立刻调用构造函数初始化对象,所以在上面的例子中会调用string原有的operator=
,而不是string的拷贝构造函数(深拷贝)或移动构造函数(资源转移)。
ck函数接收到右值后,其属性不会退化到左值,所以调用的insert函数还是右值引用版本,匹配到string类的移动赋值版本的operator=
重载函数。
同理,对于左值引用的参数最终只会匹配到左值引用版本的insert函数,对应string类原来的operator=
重载函数。
下面是一个简单的示例,演示了在使用 malloc
开辟空间后立即调用对象构造函数的情况:
#include <iostream>
#include <cstdlib>
#include <cstring>
using namespace std;
namespace xy
{
class string
{
public:
string(const char* str = "")
{
cout << "string(const char* str) -- 构造" << endl;
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 移动构造函数
string(string&& s) noexcept
: _str(nullptr), _size(0), _capacity(0)
{
cout << "string(string&& s) -- 移动构造" << endl;
swap(s);
}
// 移动赋值运算符
string& operator=(string&& s) noexcept
{
cout << "string& operator=(string&& s) -- 移动赋值" << endl;
swap(s);
return *this;
}
// 析构函数
~string()
{
delete[] _str;
}
private:
char* _str;
size_t _size;
size_t _capacity;
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
};
template<typename T>
class vector
{
public:
vector()
: data(nullptr), size(0), capacity()
{}
~vector()
{
if (data != nullptr) {
free(data);
}
}
void push_back(const T& x)
{
insert(size, x);
}
void push_back(T&& x)
{
insert(size, std::move(x));
}
private:
T* data;
size_t size;
size_t capacity;
void insert(size_t pos, const T& x)
{
if (size >= capacity)
{
data = (T*)realloc(data, sizeof(T) * (capacity == 0 ? 4 : capacity * 2));
capacity = (capacity == 0 ? 4 : capacity * 2);
}
new(&data[pos]) T(x);
size++;
}
void insert(size_t pos, T&& x)
{
if (size >= capacity)
{
data = (T*)realloc(data, sizeof(T) * (capacity == 0 ? 4 : capacity * 2));
capacity = (capacity == 0 ? 4 : capacity * 2);
}
new(&data[pos]) T(std::move(x));
size++;
}
};
}
int main()
{
xy::vector<xy::string> v;
v.push_back("Hello"); // 构造函数调用
v.push_back("World"); // 构造函数调用
}
输出:
这个章节的分享到此结束了啦,我们下期再见~