C++的 copy (拷貝)比較好理解,而 move (移動(dòng))則相對(duì)難理解一點(diǎn)。
我個(gè)人認(rèn)為,C++ 的 移動(dòng) 只是提供一種 語義上 的內(nèi)存所有權(quán)移動(dòng)。
注: 本例的測(cè)試環(huán)境是在
RT-Thread里進(jìn)行的。其使用arm-none-eabi-gcc是使用不了 c++ 里的一些標(biāo)準(zhǔn)庫。
我先插一點(diǎn)關(guān)于 C++ 中,初始化操作語義的個(gè)人理解。
int a = 3;

引用,我理解為一種特殊的指針。
int a = 13; /* 數(shù)據(jù) */
int *p = &a; /* 普通指針 */
int &r = a; /* 引用 */

接下來,用幾個(gè)場(chǎng)景來理解 C++ 的拷貝和移動(dòng)。
(1) 怎樣定義一個(gè)具有拷貝和移動(dòng)語義的類型?
先說使用場(chǎng)景。在 C++ 里拷貝和移動(dòng)應(yīng)用得多的地方,主要是
- 怎樣把一個(gè)對(duì)象拷貝或移動(dòng)到一個(gè)新建的對(duì)象中
- 或怎樣把一個(gè)對(duì)象拷貝或移動(dòng)到另一個(gè)對(duì)象中。
第一種是使用構(gòu)造函數(shù)來定義,第二種是使用賦值操作來完成。看下面的實(shí)例
/* 場(chǎng)景實(shí)例 1 */
T a; /* 已經(jīng)存在的一個(gè)實(shí)例 */
T b(a); /* 語義是 把 a 的內(nèi)容拷貝到新的對(duì)象 b 中 */
T c(move(a)); /* 語義是 把 a 的內(nèi)容移動(dòng)到新的對(duì)象 c 中 */
/* 場(chǎng)景實(shí)例 2 */
T a, b, c; /* 已經(jīng)存在的三個(gè)實(shí)例 */
b = a; /* 把 a 拷貝到 b 中 */
c = move(b); /* 把 b 移動(dòng)到 a 中 */
那么怎樣定義這個(gè)類型呢?
class T {
public:
T() = default;
/* 構(gòu)造函數(shù) */
T(const T &); /* 語義上的拷貝構(gòu)造函數(shù) */
T(T &&); /* 語義上的移動(dòng)構(gòu)造函數(shù) */
/* 賦值操作 */
T & operator=(const T &); /* 語義上的拷貝 */
T & operator=(T &&); /* 語義上的移動(dòng) */
};
現(xiàn)在先不要理會(huì)左右值的概念,你只需要知道,C++ 上是把參數(shù)里的 T&& 在語義上多用于移動(dòng)就行。
(2)移動(dòng)這個(gè)動(dòng)作是想移動(dòng)什么?移動(dòng)的是內(nèi)存嗎?
最開始我接觸移動(dòng)這個(gè)概念是 Rust 上的(一門挺優(yōu)秀的語言,如果說 C 是接近底層硬件的是話,Rust 就是接近編譯的,我是這樣理解的,哈哈)。
在 Rust 上,每個(gè)非引用類型的變量都擁有一塊內(nèi)存,而且是這塊內(nèi)存的所有權(quán)就是只有這個(gè)變量所擁有。如果這個(gè)變量出讓了(就是移動(dòng))這個(gè)所有權(quán),那么之后他想再使用這塊內(nèi)存,就沒有權(quán)利了。
例如說,
// Rust
// 假設(shè)類型 T 沒有實(shí)現(xiàn) copy 這個(gè) trait
let a : T = ...;
let b = a;
a.xxx(); // 出錯(cuò),a 沒有權(quán)利操作了
這樣做會(huì)有一個(gè)好處,就是不用對(duì)返回值進(jìn)行多一次拷貝和析構(gòu)的操作。
fn function() -> T
{
let a: T = T::new();
a // Rust 上沒有分號(hào),在函數(shù)內(nèi)是返回值的意思
}
就是說,在 function() 創(chuàng)建一個(gè)對(duì)象 a,直接把這個(gè)對(duì)象轉(zhuǎn)移到調(diào)用者手上,讓他來處理并析構(gòu)。
如果按照 C 這樣寫的話,其返回后,還要進(jìn)行一次拷貝操作。也就是說,對(duì)象 a 的內(nèi)容拷貝到調(diào)用者那,然后在 function 內(nèi)析構(gòu)。而在調(diào)用者那的副本就由其來負(fù)責(zé)析構(gòu)。
#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;
}
在調(diào)試模式下(避免被編譯器優(yōu)化了),其輸出結(jié)果是

那么,我們使用場(chǎng)景 1 中的右值引用來嘗試能不能模仿出移動(dòng)的效果。
#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 強(qiáng)轉(zhuǎn)為 int && 右值引用。這也是 std::move 里移動(dòng)的本質(zhì),但因 arm-none-eabi-gcc 沒法使用,故這里直接強(qiáng)轉(zhuǎn)了。
結(jié)果是,

細(xì)看,很明顯,在 fun() 函數(shù)里是調(diào)用正常的。但在 main() 里,就出現(xiàn)問題了。
我們先不討論為什么這個(gè)明顯的異常為什么不出現(xiàn)在編譯階段,而是在運(yùn)行階段產(chǎn)生。
在編譯時(shí),其實(shí)編譯器已經(jīng)是做了一個(gè)友善的提醒。

換言之,就是你返回了一個(gè)本地變量的引用。
這個(gè)時(shí)候,你會(huì)突然想起 Rust 引用的生命周期的概念(萬惡的開始)。C++ 右值的概念雖然長(zhǎng)期和移動(dòng)綁定,但他還真的只是引用,不是真正的移動(dòng)。
在上面的例子里,func() 返回的是其內(nèi)部的一個(gè)變量的引用,即其地址。當(dāng)調(diào)用者 main() 想通過引用變量(相當(dāng)于指針)b 去使用這塊內(nèi)存時(shí),就會(huì)出現(xiàn)問題。問題就是,a 在 func() 返回時(shí),就被析構(gòu)了。換言之,b 指向的是一塊無效的內(nèi)存。
最后,我終于明白了,C++ 里的 "移動(dòng)",是真的什么也沒移動(dòng)。。。
(3) 既然其什么也沒移動(dòng),那還費(fèi)這么大勁來定這個(gè)標(biāo)準(zhǔn),有何用?
如果你是這樣想,你就是太小看那群 “老家伙” 了(經(jīng)驗(yàn)老到)。
我個(gè)人理解為,這樣可以做到語義上的統(tǒng)一。因?yàn)槲议_始想去理解 C++ 移動(dòng),就是為了上面的那種場(chǎng)景。但是結(jié)果被另一種場(chǎng)景給吸引了,就是智能指針。接下來,試讓我用一張圖來說明。

接下來是實(shí)例,我先把使用場(chǎng)景寫出來,
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;
}
輸出結(jié)果應(yīng)是

然后把移動(dòng)的細(xì)節(jié)給寫出來
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 對(duì)象
// 2. 右值引用 int &&是為了方便傳遞的是 字面量
SmartPoint(int &data): ptr(new int(data)) {}
SmartPoint(int &&data): ptr(new int(data)) {}
/* 析構(gòu)里保證其能在 RAII 機(jī)制下,把堆內(nèi)存自動(dòng)析構(gòu) */
virtual ~SmartPoint() {
if (this->ptr != nullptr) {
delete this->ptr;
}
this->ptr = nullptr;
}
/* 移動(dòng)的構(gòu)造函數(shù) */
SmartPoint(Self &&obj) {
/* 避免自己做了自己 */
if (this != &obj) {
this->ptr = obj.ptr;
obj.ptr = nullptr;
}
}
/* 移動(dòng)的賦值 */
Self & operator= (Self &&obj) {
if (this != &obj) {
this->ptr = obj.ptr;
obj.ptr = nullptr;
}
return *this;
}
/* 測(cè)試用 */
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;
};
============================================================================
上面關(guān)于移動(dòng)的語義說了很多,就只為說明一點(diǎn)個(gè)人理解,C++ 的移動(dòng) 真的只是語義上的移動(dòng)。其定義的目標(biāo)是為使用者提供一種方便。所以,你要把移動(dòng)的動(dòng)作實(shí)現(xiàn)封裝在你的框架內(nèi),提供給用戶的是移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值操作 這兩個(gè)接口。
然后,說一下,左右值和左右值引用的個(gè)人理解。
A = B ,就出現(xiàn)兩種情況。
- 可以 出現(xiàn)在左邊的,一定是左值
- 只能 出現(xiàn)在右邊的,一定是右值
所有變量,一定是左值。臨時(shí)變量,大部分是右值(在 c++ primer 里有一個(gè)函數(shù)調(diào)用放在左邊的例子,我還未弄明白,所以這里只使用 大部分)。
對(duì)于我來說,沒必要把左右值分得那么清。反正,常用的例子就比較好理解了。
接下來是左右值引用。下面說幾個(gè)例子
-
T &a = obj;左值引用,其右邊的值只能是左值。int obj = 3; int func(); int &r1 = obj; /* 正確 */ int &r2 = r1; /* 正確 */ int &r3 = 13; /* 錯(cuò)誤, 13 是字面量,屬于臨時(shí)變量 */ int &r4 = func(); /* 錯(cuò)誤,返回值未知其綁定的變量名稱,故也是臨時(shí)變量 */臨時(shí)變量,就是沒名字的內(nèi)存。參考這篇文章最開始的圖。
-
T &&a = 13右值引用,其右邊的值只能是右值。int obj = 13; int func(); int &lr = obj; int &&r1 = 3; /* 正確 */ int &&r2 = func(); /* 正確 */ int &&r3 = lr; /* 錯(cuò)誤,左值引用變量本身是左值 */注意,不管是左值引用,還是右值引用,其本質(zhì)也是變量,只是沒有在運(yùn)行時(shí)占用內(nèi)存而已,故其也是左值。
const T&a = 13;常量左值引用,這是正確的。這個(gè)左值引用比較特殊。我個(gè)人理解為,編譯器看到const后,知道這個(gè)引用是不被對(duì)內(nèi)容進(jìn)行修改的,故把13這個(gè)臨時(shí)變量放在 常量 區(qū)內(nèi),這就擁有了全局的生命周期了,非臨時(shí)變量所能冀望的。
上面就是我對(duì)左右值所了解到的情況了。
==================================================================================
寫到這,我突然產(chǎn)生一個(gè)疑惑,為什么右值引用會(huì)和移動(dòng)沾上邊呢?
一定要對(duì)比一下 C++ 和 Rust 這兩門語言的差異。
在 Rust 上移動(dòng)的是內(nèi)存的所有權(quán),而引用,沒有太明顯的左右值之分。也就是說,引用就只是引用,就是指針。
而在學(xué) C++ 時(shí),我一直有一個(gè)誤區(qū),強(qiáng)制把一個(gè)變量變成右值引用,就是返回其所有權(quán),這樣就產(chǎn)生了場(chǎng)景 2 里的情況。
在 Rust 里,場(chǎng)景 2 如果返回的是引用,那這個(gè)引用所指向的內(nèi)存,一定存在生命周期。所以,我們要通過一系列生命周期標(biāo)記系統(tǒng)(可愛,又揪心)來標(biāo)記,從面使得編譯器能在編譯時(shí)發(fā)現(xiàn)懸掛指針的問題。這個(gè)指針只能指向沒有被銷毀的內(nèi)存,即有效內(nèi)存上。而 C++ 里好像對(duì)這個(gè)檢查功能只是警告,沒有強(qiáng)制錯(cuò)誤(同樣也是揪心啊)。
當(dāng)你學(xué)到 Rust 引用和生命周期時(shí),你就能體會(huì)到其有多難,同時(shí)也多重要。此時(shí)回顧到 C++ 的設(shè)計(jì)時(shí),你對(duì)引用和指針的使用就能更多一分謹(jǐn)慎。
這里只是記錄一個(gè)疑惑,我也還沒有答案。。。
==================================================================
還有一樣的是,C++ 里拷貝構(gòu)造函數(shù)和拷貝賦值操作是默認(rèn)存在的,不管你是否有定義其他的構(gòu)造函數(shù)和賦值函數(shù)。
T(const T &&);T & operator= (const T &&);
若你想你的類不要存在拷貝功能,則這樣聲明
class T {
public:
T(const T &&) = delete;
T & operator= (const T &&) = delete;
};
而移動(dòng)操作是沒有默認(rèn)版本,只能手動(dòng)實(shí)現(xiàn)。舉例如下,
/* 可以拷貝,但沒有移動(dòng) */
class T1 { };
/* 既可以拷貝,也可以移動(dòng) */
class T2 {
public:
T2(T2 &&);
T2 & operator= (T2 &&);
};
/* 既不能拷貝,也不能移動(dòng) */
class T3 {
public:
T(const T &&) = delete;
T & operator= (const T &&) = delete;
};
/* 不能拷貝,但可以移動(dòng) */
class T4 {
public:
T(const T &&) = delete;
T & operator= (const T &&) = delete;
T2(T2 &&);
T2 & operator= (T2 &&);
};
================================================================================
上面,是我對(duì)這段時(shí)間看 《C++ primer》 關(guān)于移動(dòng)的筆記整理。如果發(fā)現(xiàn)什么紕漏,歡迎討論。