C++ 移动语义
1.左值和右值
左值可以位于等号的左边或右边,也就是说左值可以用来初始化左值,也可以赋值给左值,它对应于一个实实在在的内存位置。右值则只是一个临时对象,它的内存位置只能读不能写,也即是,右值可以用来初始化左值或为左值赋值,而自身不能被赋值。例如:
int a = 10; // 正确,使用右值来初始化左值
a = 5; // 正确,使用右值对左值进行赋值
int b = a; // 正确,使用左值为左值初始化
b = a; // 正确,使用左值为左值赋值
10 = a; // 错误,不能对右值进行赋值
由于右值的内存位置不能写,自然无法对其进行赋值或引用,左值就是一个变量,想怎么赋值就怎么赋值,也可以对其进行引用。
int &m = 5; // 错误,不能对右值进行引用
int &c = a; // 正确
2.函数参数
函数传递参数和返回值参数可以将传参和返回过程可以堪称赋值来理解。
int getData() {
return 3;
}
void setData(int &value) {
static int v = 20;
v = value;
}
int a = getData();
setData(10); // 错误
上述过程中,setData(10);
失败的原因是:
setData(10);
// 等价于下面过程
// ********************
int &value = 10;
static int v = 20;
v = value;
// ********************
由于10是右值,所以 int &value = 10;
这个赋值过程就不成立,因此会出现错误,那么将其改成如下形式就可以通过:
void setData(int value) {
static int v = 20;
v = value;
}
// const引用,表示不会对传参进行修改
void setDataConst(const int &value) {
static int v = 20;
v = value;
}
setData(10); // 正确
setDataConst(10); // 正确
第一种直接传值的方式,对于平凡数据类型参数而言,不存在多大问题,但是如果你传入参数是一个大的 vector
数组,这个过程相当于拷贝一份再传入的方式就会存在较大性能损耗。因此,对于非平凡数据传参,通常会考虑传入引用或指针的方式来进行参数传递。
3.右值引用
前面例子中 const int &
可以接收左值和右值参数,int &
只能接收左值参数,那么,有没有一种方式只接收右值参数呢?
#include <iostream>
void Print(const std::string &name) {
std::cout << "[LValue]: " << name << std::endl;
}
void Print(const std::string &&name) {
std::cout << "[RValue]: " << name << std::endl;
}
int main(int argc, char *argv[]) {
std::string name = "MirrorYuChen!";
Print(name);
Print("Hello, MirrorYuChen?");
Print("Hello, " + name);
// 可以使用std::move将一个左值转换成一个右值
Print(std::move(name));
return 0;
}
输出结果:
[LValue]: MirrorYuChen!
[RValue]: Hello, MirrorYuChen?
[RValue]: Hello, MirrorYuChen!
[RValue]: MirrorYuChen!
注意 &&
代表右值引用,仅接收右值参数传入。
4.移动语义
4.1 移动构造函数和移动赋值函数
在 C++11
中,移动语义允许将一个对象的所有权从一个对象转移到另一个对象,而不需要进行数据拷贝。它的意义在于,对一些即将被销毁的对象,可以不用复制它,而窃取它的资源,以此来减少一次复制操作,进而大大地提升性能。
#include <iostream>
#include <string>
class Str {
public:
explicit Str(const char *str) {
std::cout << "Constructor called" << std::endl;
size_ = strlen(str) + 1;
data_ = new char[size_];
memcpy(data_, str, size_);
}
Str(const Str &other) {
std::cout << "Copy constructor called" << std::endl;
size_ = other.size_;
data_ = new char[size_];
memcpy(data_, other.data_, size_);
}
Str(Str &&other) noexcept {
std::cout << "Move constructor called" << std::endl;
size_ = other.size_;
data_ = other.data_;
other.data_ = nullptr;
other.size_ = 0;
}
Str &operator=(const Str &other) {
std::cout << "Copy assignment operator called" << std::endl;
if (this == &other) {
return *this;
}
delete[] data_;
size_ = other.size_;
data_ = new char[size_];
memcpy(data_, other.data_, size_);
return *this;
}
Str &operator=(Str &&other) noexcept {
std::cout << "Move assignment operator called" << std::endl;
if (this == &other) {
return *this;
}
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.data_ = nullptr;
other.size_ = 0;
return *this;
}
~Str() {
std::cout << "Destructor called" << std::endl;
delete[] data_;
}
[[nodiscard]] const char *c_str() const {
std::cout << "c_str() called" << std::endl;
return data_;
}
[[nodiscard]] size_t size() const {
std::cout << "size() called" << std::endl;
return size_ - 1;
}
private:
char *data_;
size_t size_;
};
int main(int argc, char *argv[]) {
Str str("Hello, world!");
Str other(str);
Str third(std::move(other));
other = Str("Goodbye, world!");
third = std::move(other);
return 0;
}
运行结果如下:
Constructor called
Copy constructor called
Move constructor called
Constructor called
Move assignment operator called
Destructor called
Move assignment operator called
Destructor called
Destructor called
Destructor called
可以看到中间调用了一次拷贝构造函数,而在拷贝构造函数中,需要申请一次内存,相较于移动构造函数而言,需要更多的资源损耗。
4.2 三/五法则
三/五法则:若有必要实现析构函数,那么就有必要一起实现拷贝构造函数和赋值运算符,这称为三法则。若加上移动构造函数和移动赋值运算符,则称为五法则。
5.完美转发
完美转发基于万能引用、引用折叠及 std::forward
模板函数。先从一个简单的例子出发:
#include <iostream>
#include <string>
template <typename T>
void Print(T &t) {
std::cout << "[LValue reference]." << std::endl;
}
template <typename T>
void Print(T &&t) {
std::cout << "[RValue reference]." << std::endl;
}
template <typename T>
void TestPrint(T &&t) {
Print(t);
Print(std::forward<T>(t));
Print(std::move(t));
}
int main(int argc, char *argv[]) {
int x = 10;
TestPrint(x);
TestPrint(std::move(x));
return 0;
}
输出结果如下:
[LValue reference].
[LValue reference].
[RValue reference].
[LValue reference].
[RValue reference].
[RValue reference].
可以看到 std::forward<T>(t)
调用过程中,当传参t左值时,会自动调用左值接口 Print(T &t)
,当传参t为右值时,会自动调用右值接口 Print(T &&t)
,背后原理如下:
5.1 引用折叠
引用折叠只发生在以下两种情况:(1) 模板实例化;(2) 自动类型推导(auto
或 decltype
关键字)。引用折叠规则如下:
- (1)
T& &
,T& &&
,T&& &
都折叠为T&
; - (2)
T&& &&
折叠为T&&
。
引用折叠对于实现完美转发至关重要,折叠规则确保引用最终类型要么为左值引用 T&
,要么是右值引用 T&&
。
5.2 完美转发
完美转发意味着函数模板可以接受任何类型参数(左值或右值),并将其以原始值类别(保持其左值或右值特性)转发给另一个函数:
template <typename T>
void Wrapper(T &&args) {
Print(std::forward<T>(args));
}
上述例子中,T&&
是一个通用引用类型,它可以绑定左值或右值。std::forward<T>(args)
用于保持 args
的原始值类型,并将其传递给 Func
。无论 args
是左值还是右值,引用折叠规则确保在转发过程中,参数的左值/右值特性被保持不变。
#include <iostream>
#include <string>
template <typename T>
void Wrapper(T &&arg) {
Print(std::forward<T>(arg));
}
template <typename T>
void Print(T &t) {
std::cout << "[LValue reference]: " << t << std::endl;
}
template <typename T>
void Print(T &&t) {
std::cout << "[RValue reference]: " << t << std::endl;
}
int main(int argc, char *argv[]) {
int x = 20;
Wrapper(x);
Wrapper(30);
return 0;
}
输出结果如下:
[LValue reference]: 20
[RValue reference]: 30
在 Wrapper
函数模板中,T&&
类型参数 arg
可以根据传入 Wrapper
参数是左值还是右值来接受任何类型参数,通过 std::forward
,arg
被完美转发到 Print
,同事保持原始左值或右值属性。
6.参考资料
- [1] C++移动语义详细讲解
- [2] C++11新特性:完美转发