C++之移动语义
为了解决指针使用中的一些问题, C++98引入了’引用’这个概念;
引用
int b = 10;
int& b_ref = b;
引用的一些特性:
- 引用定义时必须初始化绑定变量, 且一旦绑定不能再绑定别的别的变量, 更不能置空什么的
- 引用作为类的成员的时候, 类的构造函数里必须对其初始化;
- 引用相当于别名, 本身不具有单独的内存空间
- 没有二级引用多级引用这个东西, 不允许引用绑定引用
可以看出引用在设计之上就规避类似空指针, 野指针, 垂悬指针的问题; 提供了一种更直观, 方便, 安全的操作变量或对象的方式.
很多编译器在实现的引用时候其实还是使用了指针或类似指针的方式, 但是程序员无需关系编译器是怎么实现的
左值和右值
左值和右值用来描述c++中值的类型, 是一种语义.
左值(Lvalues):指向具体内存地址的表达式, 可以取地址, 有名字的变量都是左值.左值具有持久性, 可以被多次引用.
右值(Rvalues):临时创建的、无法取地址的表达式, 例如字面量、临时对象和移动构造函数中的参数.右值是临时的, 通常只能被引用一次.
int a = 10;//a是左值, 10是右值
int b = a; //b和a都是左值
int c = a + b;//a, b, c是左值, a + b表达式返回的是右值
int *c_ptr = &c;//c_ptr和&c都是左值
std::string getName() {
return "John"; // 返回右值
}
std::string name = getName();//getName()表达式返回的是右值
从上面可以看出左值和右值语义的特性
- 左值可以在赋值表达式的左边, 也可以在赋值表达式的右边, 但是右值只能在赋值表达式的右边;
- 右值(通常)不可以修改;
- 右值不能通过取地址运算;
- 右值的生命周期不会超过表达式的生命周期;
右值引用
C++11为了优化对象的传递和管理, 特别是在涉及到临时对象、指针和动态内存分配的情况, 引入了移动语义这个概念
而支撑这个移动语义的基石就是右值引用
让我们来实现一个简单的vector顺序表, 并重载一下操作符, 使用引用传参, 防止发生实参拷贝(实参->形参);
class vector{
private:
int* data;
size_t size;
public:
vector():data(nullptr), size(0){}
~vector(){delete[] data;}
vector& operator=(const vector& other){
if (this == &other) return *this;
delete[] data;
data = other.data;
size = other.size;
return *this;
}
};
这没有实现深拷贝, 这很显然不行, 深拷贝的需求还是很普遍; 那就让我们以深拷贝的方式实现一下
class vector{
private:
int* data;
size_t size;
public:
vector():data(nullptr), size(0){}
~vector(){delete[] data;}
vector& operator=(const vector& other){
if (this == &other) return *this;
delete[] data;
size = other.size;
data = new int[size];
for(size_t i = 0; i < size; ++i) data[i] = other.data[i];
return *this;
}
};
这样就好了哇?! 这样相当于每次使用赋值操作符, 都是深拷贝, 确保不会由浅拷贝所带来的问题.
但是我们仔细想一下, 如果赋值表达式的右操作数是一个右值的时候, 也进行深拷贝是不是太浪费了, 毕竟深拷贝要重新开辟空间, 还有迭代赋值, 而右值本身就是个临时对象, 内存空间也会随着表达式的结束而释放; 这个对象是现成的, 不用白不用啊;
如果这个时候右值不释放直接给左值进行浅拷贝, 岂不是两全其美!
为了实现这个特性, 我们需要将右值作为参数传进来, 上述重载赋值操作符的函数都是以引用传参以避免发生实参到形参的拷贝;
所以此时右值引用 的概念就诞生了, 以专门为右值定义一种引用方式
且与之区分, 原来的一般引用就称为左值引用
int&& r_value_ref = 10;//右值引用
int a = 10;
int& l_value_ref = a;//左值引用
让我们再实现一个基于右值引用的赋值操作符重载
class vector{
private:
int* data;
size_t size;
public:
vector():data(nullptr), size(0){}
~vector(){delete[] data;}
//左值引用
vector& operator=(const vector& other){
if (this == &other) return *this;
delete[] data;
size = other.size;
data = new int[size];
for(size_t i = 0; i < size; ++i) data[i] = other.data[i];
return *this;
}
//右边值引用, 重载
vector& operator=(const vector&& other){
if (this == &other) return *this;
delete[] data;
size = other.size;
data = oter.data;
other.data = nullptr;//确保右值能顺利释放, 且不会有垂悬指针
other.size = 0;
return *this;
}
};
这样当赋值运算符的右操作符是个右值的时候, 就不会发生深拷贝了
同理, 让我们实现拷贝构造函数和基于右值引用传参版本的构造函数
class vector{
private:
int* data;
size_t size;
public:
vector():data(nullptr), size(0){}
//拷贝构造函数-深拷贝
vector(const vector& other):data(nullptr), size(0){ *this = other; }
//右值,拷贝构造函数-浅拷贝
vector(const vector&& other):data(nullptr), size(0){ *this = other; }
~vector(){delete[] data; size = 0;}
//左值引用
vector& operator=(const vector& other){
if (this == &other) return *this;
delete[] data;
size = other.size;
data = new int[size];
for(size_t i = 0; i < size; ++i) data[i] = other.data[i];
return *this;
}
//右值引用, 重载
vector& operator=(const vector&& other){
if (this == &other) return *this;
delete[] data;
size = other.size;
data = oter.data;
other.data = nullptr;//确保右值能顺利释放, 且不会有垂悬指针
other.size = 0;
return *this;
}
};
基于右值引用传参版本的构造函数也被称为: 移动构造函数
std::move-移动语义
很多时候, 我们其实操作的都是左值, 但是很多情况, 我们没有必要总是深拷贝一个左值, 而是以触发右值引用传参的方式来移动(浅拷贝且将原变量清空);
比如:
-
移动一个容器对象到移动到一个新对象, 注意是移动, 也就是旧的标识符不再持有对象
std::vector<int> source = {1, 2, 3, 4, 5}; std::vector<int> destination; destination = source;
-
返回局部变量, 我们知道返回局部对象, 是会自动发生拷贝的, 一般编译器是会自动选择拷贝构造函数或移动拷贝构造函数;
std::vector<int> createVector() { std::vector<int> vec = {1, 2, 3, 4, 5}; return return vec; }
当然很多编译器实际上是有RVO机制的, 并不会任何函数返回地时候都傻傻的真的去拷贝一份, 但是这是题外话. 现在先考虑没有RVO的情况或者RVO没有起效的情况.
很显然上述两个例子中的code的实参都是左值, 触发的都是左值引用传参, 但是我们明显希望能触发右值引用传参, 因为我们浅拷贝到对象到新变量之后, 不需要原变量持有当前对象了.
那怎么才能触发右值引用传参的函数重载版本呢, 很明显–答案就是将实参强转为右值类型;
c++11允许将左值强转为右值引用, 并在标准库封装了一个强转函数, 就是``std::move()`
template <typename T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept{
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
由上述std::move源码我们得知, move其实没有move任何东西, 只是类型转换;
std::vector<int> source = {1, 2, 3, 4, 5};
std::vector<int> destination;
destination = std::move(source);//触发右值引用传参
万能引用和引用折叠
我们看到std::move
的形参数类型是T&& t
, 看上去它接收的参数类型必须是右值类型, 但实际上不是, c++编译器允许T&& t类型的形参接收左值, 右值, 左值引用, 右值引用四种类型的参数; 这就是所谓的万能引用
ps: 万能引用只能是模板参数, 普通的引用参数不具备这种效果, 比如
template <typename T>
void test(T&& arg){} //arg是万能引用类型, 能接收左值, 右值, 左值引用, 右值引用4种类型的实参
void test1(int&& arg){}//arg就是右值类型, 只能接收int类型的右值,
那么为啥呢, 难道是编译器特地实现了这个万能引用类型?
答案是否定的, 编译器没有特地专门地实现万能引用这个东西, 不要把它类比成void*
之类的东西, 虽然作用比较像;
我们知道编译器会在编译时期为模板函数生成具体地函数版本, 比如
template <typename T>
void test(T arg){}
编译器在编译时会分析上下文, 假如发现一个test(42)
之类地调用, 编译器就会生成一个test(int i)
的函数, 当然也有可能是test_int(int i)
; 这叫做实例化模板函数, 然后运行时根据实参类型对应地调用这个函数.
那么也就是说, arg这个形参在具体地函数版本究竟是什么类型, 是要由编译器去根据调用场景推导的. 谁让你是模板参数呢?
那么问题就来了?
万一T本身就是个引用类型, 比如int&, int&&
之类的, 那arg不就成了int& &&
或者int&& &&
类型. 那该怎么办,c++可没有也不允许引用的引用这个东西.
那么编译器遇到这种情况怎么办, 他就得裁剪掉多余几个&
了, 这个裁剪就是所谓的引用折叠, 引用折叠规则如下
- 所有的引用折叠的结果都是一个引用,要么是左值引用,要么是右值引用
- 如果任一引用为左值引用,则结果为左值引用. 否则结果为右值引用
那么我们基于上述引用折叠规则, 推导一下, 发现如下
template <typename T>
void test(T&& arg){}
int&& r_ref= 10; // 右值引用绑定到右值
int a = 1;
int& l_ref = a; // 左值引用绑定到左值
test(10);// 右值 + &&,直接绑定到 T&&,T 推导为 int&&
test(r_ref);// int&& + &&,折叠为 int&& 类型
test(a); // 左值 + &&,绑定左值到右值引用,但 T 推导为 int&
test(l_ref); // int& + &&,折叠为 int& 类型
所以其实是编译器在编译时具备引用折叠的特性, 导致T&& t看起来像一个万能的引用参数类型
std::forword–完美转发
基于上面的万能引用, 我们能不能优化一下代码呢, 只使用T&& t作为形参, 然后在函数里面判断是左值还是右值, 如果是右值, 我们就进行浅拷贝, 反之则进行深拷贝. 在这里我们先不去细究
class vector{
private:
int* data;
size_t size;
public:
vector():data(nullptr), size(0){}
vector(const vector&& other):data(nullptr), size(0){ *this = other; }
~vector(){delete[] data; size = 0;}
vector& operator=(const vector&& other){
if(std::is_lvalue_reference_v<decltype(other)>){//如果是左值, 就执行浅拷贝
if (this == &other) return *this;
delete[] data;
size = other.size;
data = new int[size];
for(size_t i = 0; i < size; ++i) data[i] = other.data[i];
}else{
if (this == &other) return *this;
delete[] data;
size = other.size;
data = oter.data;
other.data = nullptr;//确保右值能顺利释放, 且不会有垂悬指针
other.size = 0;
}
return *this;
}
};
这样看好像就万事大吉了, 但是并不是, other在函数内部其实是个总是个左值, 因为在函数内部, *形参一定是一个左值, 它满足左值的特性:
- 具名
- 可以寻址
当other传入std::is_lvalue_reference_v<decltype(other)> 的时候, other被当成了左值, 为了能将other的值类型能够传递进去, c++标准库提供了std::forward()
函数, 来保持函数参数在传递时的值类型, 这就是完美转发
class vector{
private:
int* data;
size_t size;
public:
vector():data(nullptr), size(0){}
vector(const vector&& other):data(nullptr), size(0){ *this = other; }
~vector(){delete[] data; size = 0;}
vector& operator=(const vector&& other){
if(std::is_lvalue_reference_v<decltype(std::forward(other))>){//使用std::forward保持other的类型
if (this == &other) return *this;
delete[] data;
size = other.size;
data = new int[size];
for(size_t i = 0; i < size; ++i) data[i] = other.data[i];
}else{
if (this == &other) return *this;
delete[] data;
size = other.size;
data = oter.data;
other.data = nullptr;//确保右值能顺利释放, 且不会有垂悬指针
other.size = 0;
}
return *this;
}
};
总结
- 移动语义其实就是给程序员提供一种触发浅度拷贝的方式, 以避免不必要的深度拷贝;
- 右值引用特性的引入是支撑移动语义的核心手段
- 引用折叠其实就是在编译器在实例化模板函数参数的时候, "裁剪掉/合并"多余的
&
以使语义能正常编译 - 利用引用折叠, T&& t其实可以接收多种值类型参数, 使得其成为了万能引用
- std::move只是单纯的类型转换, 将其他值类型或值引用类型转换为右值引用
- std::forward保证了函数参数在函数内部传递给其他函数的时候, 保持值类型不变