一、左值、右值和右值引用到底是什么
学习移动语义之前,先要知道:
左值
右值
大概是什么意思。
先看:
int a = 10;
这里:
a
是一个:
左值
因为它:
有名字
有明确的存储位置
可以多次访问
例如:
a = 20;
std::cout << a << std::endl;
都没有问题。
而:
10
通常可以看成一个右值。
因为:
它只是一个临时值
一般不会长期存在
再比如:
int a = 10;
int b = 20;
int c = a + b;
这里:
a
b
c
都是左值。
而:
a + b
产生的临时结果:
30
可以看成右值。
所以初学时可以先简单理解:
左值
↓
有名字、能够找到地址、生命周期较稳定
右值
↓
临时值、即将被销毁
左值引用我们已经非常熟悉:
int a = 10;
int &ref = a;
这里:
int&
就是:
左值引用
但是不能这样:
int &ref = 10;
因为:
普通左值引用
不能直接绑定到临时右值
C++11 引入了:
&&
也就是:
右值引用
例如:
int &&ref = 10;
这里:
ref
就是一个右值引用。
再例如:
int &&ref = 10 + 20;
也可以。
所以:
T&
主要用于绑定:
左值
而:
T&&
主要用于绑定:
右值
例如:
void func(int &x) {
std::cout << "左值引用" << std::endl;
}
void func(int &&x) {
std::cout << "右值引用" << std::endl;
}
调用:
int a = 10;
func(a);
func(20);
输出:
左值引用
右值引用
为什么要专门区分右值?
因为右值通常具有一个很重要的特点:
它马上就要被销毁了
既然一个对象马上就没用了,那么它内部的资源:
是不是可以直接拿走?
这就是移动语义出现的原因。
二、为什么需要移动构造
先看一个简单的类:
#include <cstring>
#include <iostream>
class String {
private:
char *data_;
public:
explicit String(const char *str = "") {
size_t len = std::strlen(str);
data_ = new char[len + 1];
std::strcpy(data_, str);
}
~String() {
delete[] data_;
}
};
这个类内部保存:
char *data_;
也就是说,它真正的数据放在:
堆内存
里面。
现在如果需要拷贝一个对象:
String s1("hello");
String s2 = s1;
如果正确实现深拷贝,拷贝构造大概需要:
String(const String &other) {
size_t len = std::strlen(other.data_);
data_ = new char[len + 1];
std::strcpy(data_, other.data_);
}
过程就是:
s1
data_
↓
"hello"
拷贝:
重新申请内存
↓
复制hello
↓
s2拥有新的内存
最终:
s1 ───→ "hello"
s2 ───→ "hello"
虽然内容一样,但是:
两块不同的内存
如果字符串非常大,例如:
100MB
那么每次拷贝都需要:
申请100MB
+
复制100MB
成本就比较高。
但是现在有一个临时对象:
String createString() {
return String("hello");
}
调用:
String s = createString();
假设临时对象马上就要销毁。
那么完全没有必要:
重新申请一块内存
+
重新复制数据
因为临时对象内部的:
data_
马上也没用了。
所以可以:
直接把它的data_拿过来
这就是:
移动
移动构造可以写成:
String(String &&other) noexcept : data_(other.data_) {
other.data_ = nullptr;
}
原来:
临时对象
other.data_
↓
"hello"
执行:
data_ = other.data_;
以后:
当前对象 ───→ "hello"
临时对象 ───→ "hello"
这时两个对象还指向同一块内存。
所以必须:
other.data_ = nullptr;
最终:
当前对象
↓
"hello"
临时对象
↓
nullptr
这样临时对象析构时:
delete[] nullptr;
是安全的。
而真正的:
"hello"
已经交给新的对象管理。
这就是移动构造的核心:
不复制资源
而是直接接管资源
所以:
拷贝
可以理解成:
复制一份资源
而:
移动
可以理解成:
把原来的资源直接交给别人
三、移动构造和移动赋值怎么写
先来看完整一点的类:
#include <cstring>
#include <iostream>
#include <utility>
class String {
private:
char *data_;
public:
explicit String(const char *str = "") {
size_t len = std::strlen(str);
data_ = new char[len + 1];
std::strcpy(data_, str);
}
~String() {
delete[] data_;
}
// 拷贝构造
String(const String &other) {
size_t len = std::strlen(other.data_);
data_ = new char[len + 1];
std::strcpy(data_, other.data_);
}
// 移动构造
String(String &&other) noexcept : data_(other.data_) {
other.data_ = nullptr;
}
void show() const {
if (data_) {
std::cout << data_ << std::endl;
}
}
};
现在:
String s1("hello");
然后:
String s2 = s1;
调用的是:
拷贝构造
因为:
s1
是一个左值。
如果:
String s3 = std::move(s1);
就会调用:
移动构造
移动之前:
s1
↓
"hello"
移动以后:
s1
↓
nullptr
s3
↓
"hello"
所以移动以后:
s1仍然是一个合法对象
但是:
它内部的资源已经被转移
因此一般不应该再依赖它原来的值。
除了移动构造,还需要:
移动赋值
例如:
String s1("hello");
String s2("world");
s2 = std::move(s1);
赋值之前:
s1 → "hello"
s2 → "world"
如果直接:
s2.data_ = s1.data_;
那么原来:
"world"
这块内存地址就丢失了。
会造成:
内存泄漏
所以移动赋值应该先释放自己原来的资源。
String &operator=(String &&other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
other.data_ = nullptr;
}
return *this;
}
整个过程:
s2原来拥有"world"
↓
delete[] data_
↓
释放world
↓
接管s1的"hello"
↓
s1.data_ = nullptr
最终:
s1 → nullptr
s2 → "hello"
所以可以把移动构造和移动赋值分别记成:
移动构造
当前对象本来没有资源
↓
直接接管other
移动赋值
当前对象可能已经有资源
↓
先释放自己的资源
↓
再接管other
完整版本:
#include <cstring>
#include <iostream>
#include <utility>
class String {
private:
char *data_;
public:
explicit String(const char *str = "") {
size_t len = std::strlen(str);
data_ = new char[len + 1];
std::strcpy(data_, str);
std::cout << "普通构造" << std::endl;
}
~String() {
delete[] data_;
}
// 拷贝构造
String(const String &other) {
size_t len = std::strlen(other.data_);
data_ = new char[len + 1];
std::strcpy(data_, other.data_);
std::cout << "拷贝构造" << std::endl;
}
// 移动构造
String(String &&other) noexcept : data_(other.data_) {
other.data_ = nullptr;
std::cout << "移动构造" << std::endl;
}
// 拷贝赋值
String &operator=(const String &other) {
if (this != &other) {
char *newData = new char[std::strlen(other.data_) + 1];
std::strcpy(newData, other.data_);
delete[] data_;
data_ = newData;
}
return *this;
}
// 移动赋值
String &operator=(String &&other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
other.data_ = nullptr;
}
return *this;
}
void show() const {
if (data_) {
std::cout << data_ << std::endl;
} else {
std::cout << "nullptr" << std::endl;
}
}
};
int main() {
String s1("hello");
// 拷贝
String s2 = s1;
// 移动
String s3 = std::move(s1);
s2.show();
s3.show();
s1.show();
return 0;
}
这实际上已经涉及:
拷贝构造
拷贝赋值
移动构造
移动赋值
析构函数
也就是经常提到的:
Rule of Five
五法则
如果一个类自己管理资源,一般需要认真考虑这几个特殊成员函数应该如何实现。
四、std::move到底移动了什么
这是面试中非常容易说错的地方。
很多人会说:
std::move把对象移动了。
实际上:
std::move 本身并不会移动任何数据。
例如:
String s1("hello");
String s2 = std::move(s1);
真正完成资源转移的是:
String(String &&other)
也就是:
移动构造函数
而:
std::move(s1)
真正做的事情,可以先简单理解成:
把s1强制转换成一个右值
也就是告诉编译器:
这个对象后面的资源可以被转移。
它的核心思想类似:
static_cast<T &&>(obj);
所以:
std::move(s1)
本身不会执行:
复制
释放
修改内存
它只是完成:
类型转换
让后续函数能够匹配:
T &&
版本。
例如:
void func(const String &s) {
std::cout << "拷贝版本" << std::endl;
}
void func(String &&s) {
std::cout << "移动版本" << std::endl;
}
调用:
String s("hello");
func(s);
因为:
s是左值
所以匹配:
const String &
而:
func(std::move(s));
std::move(s) 把表达式转换为右值类别,因此更容易匹配:
String &&
所以执行:
移动版本
因此面试时一定不要说:
std::move负责搬内存
更准确的回答是:
std::move本质上是一个类型转换工具,它把对象转换为可以绑定到右值引用的表达式,本身并不会真正移动资源。真正的资源转移发生在移动构造函数或移动赋值运算符中。
这也是为什么:
std::move(x);
单独写一句:
std::move(x);
基本不会对:
x
产生什么实际效果。
必须有后续操作:
T y = std::move(x);
或者:
func(std::move(x));
才可能真正触发移动逻辑。
另外要注意:
std::move之后
原对象不是"不能再用"
而是:
仍然处于合法状态
但具体值通常不应该再依赖
例如:
std::string s1 = "hello";
std::string s2 = std::move(s1);
之后 s1 仍然必须满足:
可以析构
可以重新赋值
但:
s1里面具体还剩什么内容
一般不应该依赖。
所以更准确的表述是:
moved-from对象
仍然有效
但状态通常未指定
五、移动语义在实际开发中有什么用
理解移动语义以后,再回头看很多 C++ 代码,就会突然顺很多。
例如前面手写的:
unique_ptr
为什么:
std::unique_ptr<int> p1(new int(10));
std::unique_ptr<int> p2 = p1;
不允许?
因为:
unique_ptr是独占所有权
不能拷贝。
但是可以:
std::unique_ptr<int> p2 = std::move(p1);
因为:
不是复制资源
而是转移所有权
最终:
p1 → nullptr
p2 → int对象
所以:
unique_ptr
本身就是移动语义非常典型的应用。
再看前面写过的:
vector扩容
vector 空间不足时需要:
申请新内存
↓
把旧元素搬到新空间
如果对象支持:
T(T &&other) noexcept;
那么 vector 可以考虑直接移动元素。
例如:
旧vector
Object1
Object2
Object3
扩容:
新空间
↓
移动Object1
移动Object2
移动Object3
如果对象内部保存大量动态资源:
移动指针
会比:
重新申请
+
完整复制
高效很多。
这也是为什么移动构造经常写:
noexcept
例如:
String(String &&other) noexcept;
因为标准容器扩容时需要考虑:
异常安全
如果移动构造可能抛异常,而对象又支持拷贝,标准库实现可能更倾向使用:
拷贝
以便在异常发生时更容易保证原容器状态。
所以:
移动构造 + noexcept
经常一起出现。
最后可以把整个知识链整理成:
左值
↓
有名字、可长期访问
右值
↓
临时对象、即将销毁
右值引用 T&&
↓
绑定右值
↓
允许我们识别"资源可以被拿走"的对象
移动构造
↓
直接接管资源
移动赋值
↓
释放自己的旧资源
↓
接管对方资源
std::move
↓
把表达式转换成适合触发移动语义的右值类别
如果面试官问:
什么是移动语义?
可以这样回答:
移动语义主要用于避免不必要的深拷贝。对于拥有动态资源的对象,如果源对象即将销毁,就可以通过移动构造或移动赋值直接接管它内部的资源,而不是重新申请并复制一份。移动完成后需要把源对象置于合法但通常不再依赖其原值的状态。
如果继续问:
std::move到底做了什么?
可以回答:
std::move本身并不移动数据,它本质上是一个类型转换,把对象转换为可以绑定到右值引用的表达式,从而让编译器有机会调用移动构造或者移动赋值。真正的资源转移发生在移动构造函数和移动赋值函数中。
如果继续问:
移动构造和拷贝构造有什么区别?
可以回答:
拷贝构造会为新对象复制一份独立资源,源对象仍然保持原来的资源;移动构造则直接接管源对象的资源,并把源对象对应的资源指针置空或调整到合法状态,因此对于拥有大量动态资源的对象通常更加高效。
所以右值引用与移动语义真正需要理解的不是:
&&
这个符号本身,而是:
对象马上不用了
↓
资源能不能别复制
↓
直接转移给另一个对象
这就是现代 C++ 移动语义最核心的思想。