C++ 拷貝語義與移動(dòng)語義的個(gè)人理解

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;
image.png

引用,我理解為一種特殊的指針。

int a = 13;    /* 數(shù)據(jù) */
int *p = &a;  /* 普通指針 */
int &r = a;    /* 引用 */
image.png

接下來,用幾個(gè)場(chǎng)景來理解 C++ 的拷貝和移動(dòng)。

(1) 怎樣定義一個(gè)具有拷貝和移動(dòng)語義的類型?

先說使用場(chǎng)景。在 C++ 里拷貝和移動(dòng)應(yīng)用得多的地方,主要是

  1. 怎樣把一個(gè)對(duì)象拷貝或移動(dòng)到一個(gè)新建的對(duì)象中
  2. 或怎樣把一個(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é)果是

image.png

那么,我們使用場(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é)果是,

image.png

細(xì)看,很明顯,在 fun() 函數(shù)里是調(diào)用正常的。但在 main() 里,就出現(xiàn)問題了。

我們先不討論為什么這個(gè)明顯的異常為什么不出現(xiàn)在編譯階段,而是在運(yùn)行階段產(chǎn)生。

在編譯時(shí),其實(shí)編譯器已經(jīng)是做了一個(gè)友善的提醒。

image.png

換言之,就是你返回了一個(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)問題。問題就是,afunc() 返回時(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)景給吸引了,就是智能指針。接下來,試讓我用一張圖來說明。

image.png

接下來是實(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)是

image.png

然后把移動(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)兩種情況。

  1. 可以 出現(xiàn)在左邊的,一定是左值
  2. 只能 出現(xiàn)在右邊的,一定是右值

所有變量,一定是左值。臨時(shí)變量,大部分是右值(在 c++ primer 里有一個(gè)函數(shù)調(diào)用放在左邊的例子,我還未弄明白,所以這里只使用 大部分)。

對(duì)于我來說,沒必要把左右值分得那么清。反正,常用的例子就比較好理解了。

接下來是左右值引用。下面說幾個(gè)例子

  1. 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)存。參考這篇文章最開始的圖。

  2. 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)存而已,故其也是左值。

  3. 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ù)。

  1. T(const T &&);
  2. 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)什么紕漏,歡迎討論。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容