C++的 copy (拷贝)比较好理解,而 move (移动)则相对难理解一点。
我个人认为,C++ 的 移动 只是提供一种 语义上 的内存所有权移动。
注: 本例的测试环境是在
RT-Thread
里进行的。其使用arm-none-eabi-gcc
是使用不了 c++ 里的一些标准库。
我先插一点关于 C++ 中,初始化操作语义的个人理解。
int a = 3;
引用,我理解为一种特殊的指针。
int a = 13; /* 数据 */
int *p = &a; /* 普通指针 */
int &r = a; /* 引用 */
接下来,用几个场景来理解 C++ 的拷贝和移动。
(1) 怎样定义一个具有拷贝和移动语义的类型?
先说使用场景。在 C++ 里拷贝和移动应用得多的地方,主要是
- 怎样把一个对象拷贝或移动到一个新建的对象中
- 或怎样把一个对象拷贝或移动到另一个对象中。
第一种是使用构造函数来定义,第二种是使用赋值操作来完成。看下面的实例
/* 场景实例 1 */
T a; /* 已经存在的一个实例 */
T b(a); /* 语义是 把 a 的内容拷贝到新的对象 b 中 */
T c(move(a)); /* 语义是 把 a 的内容移动到新的对象 c 中 */
/* 场景实例 2 */
T a, b, c; /* 已经存在的三个实例 */
b = a; /* 把 a 拷贝到 b 中 */
c = move(b); /* 把 b 移动到 a 中 */
那么怎样定义这个类型呢?
class T {
public:
T() = default;
/* 构造函数 */
T(const T &); /* 语义上的拷贝构造函数 */
T(T &&); /* 语义上的移动构造函数 */
/* 赋值操作 */
T & operator=(const T &); /* 语义上的拷贝 */
T & operator=(T &&); /* 语义上的移动 */
};
现在先不要理会左右值的概念,你只需要知道,C++ 上是把参数里的 T&& 在语义上多用于移动就行。
(2)移动这个动作是想移动什么?移动的是内存吗?
最开始我接触移动这个概念是 Rust 上的(一门挺优秀的语言,如果说 C 是接近底层硬件的是话,Rust 就是接近编译的,我是这样理解的,哈哈)。
在 Rust 上,每个非引用类型的变量都拥有一块内存,而且是这块内存的所有权就是只有这个变量所拥有。如果这个变量出让了(就是移动)这个所有权,那么之后他想再使用这块内存,就没有权利了。
例如说,
// Rust
// 假设类型 T 没有实现 copy 这个 trait
let a : T = ...;
let b = a;
a.xxx(); // 出错,a 没有权利操作了
这样做会有一个好处,就是不用对返回值进行多一次拷贝和析构的操作。
fn function() -> T
{
let a: T = T::new();
a // Rust 上没有分号,在函数内是返回值的意思
}
就是说,在 function()
创建一个对象 a
,直接把这个对象转移到调用者手上,让他来处理并析构。
如果按照 C 这样写的话,其返回后,还要进行一次拷贝操作。也就是说,对象 a
的内容拷贝到调用者那,然后在 function
内析构。而在调用者那的副本就由其来负责析构。
#include <rtthread.h>
int fun() {
int a = 3;
rt_kprintf("fun a = %d, addr is %d\n", a, &a);
return a;
}
int main() {
int b = fun();
rt_kprintf("main b = %d, addr is %d\n", b, &b);
return 0;
}
在调试模式下(避免被编译器优化了),其输出结果是
那么,我们使用场景 1 中的右值引用来尝试能不能模仿出移动的效果。
#include <rtthread.h>
int &&fun() {
int a = 3;
rt_kprintf("fun a = %d, addr is %d\n", a, &a);
return static_cast<int &&>(a);
}
int main() {
auto b = fun();
rt_kprintf("main b = %d, addr is %d\n", b, &b);
return 0;
}
其中 static_case<int &&>(a);
是把 a
的类型由 int
强转为 int &&
右值引用。这也是 std::move
里移动的本质,但因 arm-none-eabi-gcc
没法使用,故这里直接强转了。
结果是,
细看,很明显,在 fun()
函数里是调用正常的。但在 main()
里,就出现问题了。
我们先不讨论为什么这个明显的异常为什么不出现在编译阶段,而是在运行阶段产生。
在编译时,其实编译器已经是做了一个友善的提醒。
换言之,就是你返回了一个本地变量的引用。
这个时候,你会突然想起 Rust 引用的生命周期的概念(万恶的开始)。C++ 右值的概念虽然长期和移动绑定,但他还真的只是引用,不是真正的移动。
在上面的例子里,func()
返回的是其内部的一个变量的引用,即其地址。当调用者 main()
想通过引用变量(相当于指针)b
去使用这块内存时,就会出现问题。问题就是,a
在 func()
返回时,就被析构了。换言之,b
指向的是一块无效的内存。
最后,我终于明白了,C++ 里的 "移动",是真的什么也没移动。。。
(3) 既然其什么也没移动,那还费这么大劲来定这个标准,有何用?
如果你是这样想,你就是太小看那群 “老家伙” 了(经验老到)。
我个人理解为,这样可以做到语义上的统一。因为我开始想去理解 C++ 移动,就是为了上面的那种场景。但是结果被另一种场景给吸引了,就是智能指针。接下来,试让我用一张图来说明。
接下来是实例,我先把使用场景写出来,
int main() {
SmartPoint p1, p2(13);
rt_kprintf("...before move...\n");
rt_kprintf("p1: ");
p1.print();
rt_kprintf("p2: ");
p2.print();
p1 = move(p2);
rt_kprintf("...move...\n");
rt_kprintf("p1: ");
p1.print();
rt_kprintf("p2: ");
p2.print();
return 0;
}
输出结果应是
然后把移动的细节给写出来
template <typename T>
T && move(T &obj) {
return static_cast<T &&>(obj);
}
最后把类型的定义给写出来,
class SmartPoint {
public:
typedef SmartPoint Self;
public:
SmartPoint(): ptr(nullptr) {}
// 下面使用了左值引用和右值引用
// 1. 左值引用 int & 是为了方便传递的是 int 对象
// 2. 右值引用 int &&是为了方便传递的是 字面量
SmartPoint(int &data): ptr(new int(data)) {}
SmartPoint(int &&data): ptr(new int(data)) {}
/* 析构里保证其能在 RAII 机制下,把堆内存自动析构 */
virtual ~SmartPoint() {
if (this->ptr != nullptr) {
delete this->ptr;
}
this->ptr = nullptr;
}
/* 移动的构造函数 */
SmartPoint(Self &&obj) {
/* 避免自己做了自己 */
if (this != &obj) {
this->ptr = obj.ptr;
obj.ptr = nullptr;
}
}
/* 移动的赋值 */
Self & operator= (Self &&obj) {
if (this != &obj) {
this->ptr = obj.ptr;
obj.ptr = nullptr;
}
return *this;
}
/* 测试用 */
void print(char end='\n') const {
if (this->ptr == nullptr) {
rt_kprintf("nullptr%c", end);
} else {
rt_kprintf("%d%c", *(this->ptr), end);
}
}
private:
int *ptr;
};
============================================================================
上面关于移动的语义说了很多,就只为说明一点个人理解,C++ 的移动 真的只是语义上的移动。其定义的目标是为使用者提供一种方便。所以,你要把移动的动作实现封装在你的框架内,提供给用户的是移动构造函数和移动赋值操作 这两个接口。
然后,说一下,左右值和左右值引用的个人理解。
A = B
,就出现两种情况。
- 可以 出现在左边的,一定是左值
- 只能 出现在右边的,一定是右值
所有变量,一定是左值。临时变量,大部分是右值(在 c++ primer 里有一个函数调用放在左边的例子,我还未弄明白,所以这里只使用 大部分)。
对于我来说,没必要把左右值分得那么清。反正,常用的例子就比较好理解了。
接下来是左右值引用。下面说几个例子
-
T &a = obj;
左值引用,其右边的值只能是左值。int obj = 3; int func(); int &r1 = obj; /* 正确 */ int &r2 = r1; /* 正确 */ int &r3 = 13; /* 错误, 13 是字面量,属于临时变量 */ int &r4 = func(); /* 错误,返回值未知其绑定的变量名称,故也是临时变量 */
临时变量,就是没名字的内存。参考这篇文章最开始的图。
-
T &&a = 13
右值引用,其右边的值只能是右值。int obj = 13; int func(); int &lr = obj; int &&r1 = 3; /* 正确 */ int &&r2 = func(); /* 正确 */ int &&r3 = lr; /* 错误,左值引用变量本身是左值 */
注意,不管是左值引用,还是右值引用,其本质也是变量,只是没有在运行时占用内存而已,故其也是左值。
const T&a = 13;
常量左值引用,这是正确的。这个左值引用比较特殊。我个人理解为,编译器看到const
后,知道这个引用是不被对内容进行修改的,故把13
这个临时变量放在 常量 区内,这就拥有了全局的生命周期了,非临时变量所能冀望的。
上面就是我对左右值所了解到的情况了。
==================================================================================
写到这,我突然产生一个疑惑,为什么右值引用会和移动沾上边呢?
一定要对比一下 C++ 和 Rust 这两门语言的差异。
在 Rust 上移动的是内存的所有权,而引用,没有太明显的左右值之分。也就是说,引用就只是引用,就是指针。
而在学 C++ 时,我一直有一个误区,强制把一个变量变成右值引用,就是返回其所有权,这样就产生了场景 2 里的情况。
在 Rust 里,场景 2 如果返回的是引用,那这个引用所指向的内存,一定存在生命周期。所以,我们要通过一系列生命周期标记系统(可爱,又揪心)来标记,从面使得编译器能在编译时发现悬挂指针的问题。这个指针只能指向没有被销毁的内存,即有效内存上。而 C++ 里好像对这个检查功能只是警告,没有强制错误(同样也是揪心啊)。
当你学到 Rust 引用和生命周期时,你就能体会到其有多难,同时也多重要。此时回顾到 C++ 的设计时,你对引用和指针的使用就能更多一分谨慎。
这里只是记录一个疑惑,我也还没有答案。。。
==================================================================
还有一样的是,C++ 里拷贝构造函数和拷贝赋值操作是默认存在的,不管你是否有定义其他的构造函数和赋值函数。
T(const T &&);
T & operator= (const T &&);
若你想你的类不要存在拷贝功能,则这样声明
class T {
public:
T(const T &&) = delete;
T & operator= (const T &&) = delete;
};
而移动操作是没有默认版本,只能手动实现。举例如下,
/* 可以拷贝,但没有移动 */
class T1 { };
/* 既可以拷贝,也可以移动 */
class T2 {
public:
T2(T2 &&);
T2 & operator= (T2 &&);
};
/* 既不能拷贝,也不能移动 */
class T3 {
public:
T(const T &&) = delete;
T & operator= (const T &&) = delete;
};
/* 不能拷贝,但可以移动 */
class T4 {
public:
T(const T &&) = delete;
T & operator= (const T &&) = delete;
T2(T2 &&);
T2 & operator= (T2 &&);
};
================================================================================
上面,是我对这段时间看 《C++ primer》 关于移动的笔记整理。如果发现什么纰漏,欢迎讨论。