對C++11中的`移動語義`與`右值引用`的介紹與討論

張浮生發表於2019-05-16

本文主要介紹了C++11中的移動語義右值引用, 並且對其中的一些坑做了深入的討論. 在正式介紹這部分內容之前, 我們先介紹一下rule of three/five原則, 與copy-and-swap idiom最佳實踐.

本文參考了stackoverflow上的一些回答. 不能算是完全原創

rule of three/five

rule of three是自從C++98標準問世以來, 大家總結的一條最佳實踐. 這個實踐其實很簡單, 用一句話就能說明白:

解構函式, 拷貝建構函式, =操作符過載應當同時出現, 或同時不出現

那麼, 這背後的緣由是什麼呢? 這裡就來說道說道.

C++中, 所有變數都是值型別變數, 這意味著在C++程式碼中, 隱式的拷貝是非常常見的, 最常見的一個隱式拷貝就是引數傳遞: 非引用型別的引數傳遞時, 實質上發生的是一次拷貝, 首先我們要明白, 所謂的發生了一次拷貝, 所謂的拷貝, 到底是指什麼.

我們從一段短的程式碼片斷開始:

class person
{
    std::string name;
    int age;

public:

    person(const std::string& name, int age) : name(name), age(age)
    {
    }
};

int main()
{
    person a("Bjarne Stroustrup", 60);  // Line 1: 這裡顯然是呼叫了建構函式
    person b(a);                        // Line 2: 這裡發生了什麼?
    b = a;                              // Line 3: 這裡又發生了什麼?
}

上面是一個簡單的類, 僅實現了一個建構函式.

到底什麼是拷貝的本質? 在上面程式碼片斷中, Line 1顯然不是拷貝, 這是一個非常顯然的初始化, 它呼叫的也很顯然是我們定義的唯一的那個建構函式: person(const std::string& name, int age). Line 2和Line 3呢?

Line 2: 也是一個初始化: 初始化了物件b. 它呼叫的是類person拷貝建構函式.
Line 3: 是一個賦值操作. 它呼叫的是person=操作符過載

但問題是, 在Line 2中, 我們並沒有定義某個建構函式符合person b(a)的呼叫. 在Line 3中, 我們也並沒有實現=操作符的過載. 但上面那段程式碼, 是可以被正常編譯執行的. 所以, 誰在背後搞鬼?

答案是編譯器, 編譯器在背後給你偷偷實現了拷貝建構函式(person(const person & p))與=操作符過載(person& operator =(const person & p)). 根據C++98的標準:

  1. 拷貝建構函式(copy constructor), =操作符(copy assignment operator), 解構函式(destructor)特殊的成員函式(special member functions
  2. 當使用者沒有顯式的宣告特殊的成員函式的時候, 編譯器應當隱式的宣告它們.
  3. 當使用者沒有顯式的宣告特殊的成員函式(顯然也並沒有實現它們)的時候, 如果程式碼中使用了這些特殊的成員函式, 編譯器應當為被使用到的特殊的成員函式提供一個預設的實現

並且, 根據C++98標準, 編譯器提供的預設實現遵循下面的準則:

  1. 拷貝建構函式的預設實現, 是對所有類成員的拷貝. 所謂拷貝, 就是對類成員拷貝建構函式的呼叫.
  2. =操作符過載的預設實現, 是對所有類成員的=呼叫.
  3. 解構函式預設情況下什麼也不做

也就是說, 編譯器為person類偷偷實現的拷貝建構函式=操作符大概長這樣:

// 拷貝建構函式
person(const person& that) : name(that.name), age(that.age)
{
}

// =操作符
person& operator=(const person& that)
{
    name = that.name;
    age = that.age;
    return *this;
}

// 解構函式
~person()
{
}

問題來了: 我們需要在什麼情況下顯式的宣告且實現特殊的成員函式呢? 答案是: 當你的類管理資源的時候, 即類的物件持有一個外部資源的時候. 這通常也意味著:

  1. 資源是在物件構造的時候, 交給物件的. 換句話說, 物件是在建構函式被呼叫的時候獲取到資源的
  2. 資源是在物件析構的時候被釋放的.

為了形象的說明管理資源的類與普通的POD類之間的區別, 我們把時光倒退到C++98之前, 那時沒有什麼標準庫, 也沒有什麼std::string, C++僅是C的一個超集, 在那個舊時光, person類可能會被寫成下面這樣:

class person
{
    char* name;
    int age;

public:

    // 建構函式獲取到了一個資源: 即是C風格的字串
    // 本例中, 是將資源資料拷貝一份, 物件以持有資源的副本: 儲存在動態分配的記憶體中
    // 物件所持有的資源, 即是動態分配的這段記憶體(資源的副本)
    person(const char* the_name, int the_age)
    {
        name = new char[strlen(the_name) + 1];
        strcpy(name, the_name);
        age = the_age;
    }

    // 析構的時候需要釋放資源, 在本例中, 就是要釋放資源副本佔用的記憶體
    ~person()
    {
        delete[] name;
    }
};

這種上古風格的程式碼, 其實直到今天都還在有人這樣寫, 並且在將這種殘缺的類套進std::vector, 並且呼叫push_back後發出痛苦的嚎叫: "MMP為什麼程式碼一跑起來一大堆記憶體錯誤?", 就像下面這樣:

int main(void ) {
    std::vector<person> vec;

    vec.push_back(person("allen", 27));
    
    return 0;
}

這是因為: 你並沒有提供拷貝建構函式, 所以編譯器給你實現了一個. 你呼叫vec.push_back(person("allen", 27))的時候, 呼叫了編譯器的預設實現版本. 編譯器的預設實現版本僅是簡單的複製了值, 意味著同一段記憶體被兩個物件同時持有著. 然後這兩個物件在析構的時候都會去試圖delete[]同一段記憶體, 所以就炸了.

這就是為什麼, 如果你寫了解構函式的話, 就應當再寫複製建構函式=操作符過載, 它的邏輯是這樣的:

  1. 你自行實現了解構函式, 說明這個類並不是簡單的POD類, 它有一些資源需要在析構的時候進行釋放, 或者是記憶體, 或者是其它控制程式碼
  2. 為了避免上面示例中的資源重複釋放問題, 你需要自行實現物件的拷貝語義, 根據資源是否能被安全的重複釋放, 或者資源是否能被安全的多個物件持有多份拷貝, 來決定拷貝的語義
  3. 為了實現拷貝的語義, 你需要自行實現拷貝建構函式=操作符過載

所以一個安全的person類應當實現如下的拷貝建構函式=操作符過載

// 拷貝建構函式
person(const person& that)
{
    name = new char[strlen(that.name) + 1];
    strcpy(name, that.name);
    age = that.age;
}

// =操作符過載
person& operator=(const person& that)
{
    if (this != &that)
    {
        delete[] name;
        // 這其實是一個很危險的寫法, 但如何正確的寫一個=操作符過載並不屬於本節所要討論的範疇
        // 所以暫時先可以湊合這樣寫著
        name = new char[strlen(that.name) + 1];
        strcpy(name, that.name);
        age = that.age;
    }
    return *this;
}

注意上面的=操作符過載的實現是很不安全的, 但如何正確的寫一個=操作符過載並不是本節所要討論的內容(下一節"copy-and-swap idiom"中再進行討論). 這裡只要明白為什麼解構函式, 拷貝建構函式, =操作符過載應當同生共死就行了.

某些場合中, 物件所持有的資源是不允許被拷貝的, 比如檔案控制程式碼或互斥量. 在這種場合, 與其費盡心機的去研究如何讓多個物件同時持有同一個資源, 不如直接禁止這種行為: 禁止物件的拷貝與賦值. 要實現這種功能, 在C++03標準之前, 有一個很簡單的方式, 即是把拷貝建構函式=操作符在類內宣告為private, 並且不去實現它們, 如下:

private:

    person(const person& that);
    person& operator=(const person& that);

在C++11標準下, 你可以使用delete關鍵字顯式的說明, 不允許拷貝操作, 如下:

person(const person& that) = delete;
person& operator=(const person& that) = delete;

所以, 至此, 就基本說明白了為什麼rule of three是大家自從C++98以來, 總結出來的一個最佳實踐. 比較遺憾的是, 在語言層面, C++並沒有強制要求所有程式設計師都必須這樣寫, 不然不給編譯通過. 所以說呀, C++這門語言還真是危險呢.

而自C++11以來, 類內的特殊的成員函式由三個, 擴充到了五個. 由於移動語義的引入, 拷貝建構函式=操作符過載都可以有其右值引用引數版本以支援移動語義, 所以rule of three就自然而然的被擴充成了rule of five, 下面是例子:

class person
{
    std::string name;
    int age;

public:
    person(const std::string& name, int age);        // 建構函式
    
    person(const person &) = default;                // 拷貝建構函式
    person(person &&) noexcept = default;            // 拷貝建構函式: 右值引用版. 也被稱為移動建構函式
    person& operator=(const person &) = default;     // =操作符過載
    person& operator=(person &&) noexcept = default; // =操作符過載: 右值引用版
    ~person() noexcept = default;                    // 解構函式
};

copy-and-swap idiom

rule of three/five小節, 我們已經討論了, 任何一個管理資源的類, 都應當實現拷貝建構函式, 解構函式=操作符過載. 這三者中, 實現拷貝建構函式解構函式的目的是很顯而易見的, 但=操作符過載的實現目的, 以及實現手段在很長一段時間內都是有爭論的, 人們在實踐中發現, 要實現一個完善的=操作符過載, 其實並不像表面上想象的那麼簡單, 那麼, 到底應當如何去寫一個完美的=操作符過載呢? 這其中又有哪些坑呢? 這一節我們將進行深入討論.

簡單來說, copy-and-swap就是一種實現=操作符過載的最佳實踐, 它主要解決(或者說避免了)兩個坑:

  1. 避免了重複程式碼的書寫
  2. 提供了強異常安全的保證

邏輯上來講, copy-and-swap在內部複用了拷貝建構函式去拷貝資源, 然後將拷貝好的資源副本, 通過一個swap函式(注意, 這不是標準庫的std::swap模板函式), 將舊資源與拷貝好的資源副本進行交換. 然後複用解構函式將舊資源進行析構. 最終僅保留拷貝後的資源副本.

上面這段話你看起來可能很頭暈, 這不要緊, 後面會進行詳細說明.

copy-and-swap套路的核心有三:

  1. 一個實現良好的拷貝建構函式
  2. 一個實現良好的解構函式
  3. 一個實現良好的swap函式.

所謂的swap函式, 是指這樣的函式:

  1. 不拋異常
  2. 交換兩個物件的所有成員
  3. 不使用std::swap去實現這個swap函式. 因為std::swap內部呼叫了=操作符, 而我們要寫的這個swap函式, 正是為了實現=操作符過載

上面說了那麼多, 可能看的你腦殼越來越痛, 不要緊, 現在我們用程式碼來闡述. 比如下面這個dump_array類, 內部持有堆區的資源(也就是一個通過new分配的陣列), 我們先給它把拷貝建構函式解構函式實現掉.

#include <algorithm>    // std::copy
#include <cstddef>      // std::size_t

class dumb_array
{
public:
    // 建構函式
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // 拷貝建構函式
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr),
    {
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // 解構函式
    ~dumb_array()
    {
        delete [] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};

我們先來看一個失敗的=操作符過載實現

// the hard part
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other)         // (1)
    {
        delete [] mArray;       // (2)
        mArray = nullptr;       // (2) *(see footnote for rationale)

        mSize = other.mSize;                                    // (3)
        mArray = mSize ? new int[mSize] : nullptr;              // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray);  // (3)
    }

    return *this;
}

表面上看這個實現好像也沒什麼大問題, 但實際上它有三個缺陷:

  1. (1)處, 需要首先判斷=操作符左右是不是同一個物件. 這個邏輯上來講其實沒什麼問題. 但實際應用中, =左右兩邊都是同一個物件的可能性非常低, 幾乎為0. 但這種判斷你又不得不做, 你做了就是拖慢了程式碼執行速度. 但坦白講這並不是一個什麼大問題.
  2. (2)處, 先是把舊資源釋放掉, 然後再在(3)處進行新資源記憶體的再申請與資料拷貝. 但如果第(3)步, 申請記憶體的時候拋異常失敗了呢? 整個就垮掉了.一個改進的實現是先申請記憶體與資料拷貝, 成功了再做舊資源的釋放, 如下
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other)
    {
        std::size_t newSize = other.mSize;
        int* newArray = newSize ? new int[newSize]() : nullptr;
        std::copy(other.mArray, other.mArray + newSize, newArray);

        delete [] mArray;
        mSize = newSize;
        mArray = newArray;
    }

    return *this;
}
  1. 整個=過載的實現, 幾乎就是抄了拷貝建構函式中的程式碼(雖然在本例中不是很明顯: 因為拷貝建構函式中使用了成員初始化列表).

看到這裡你可能覺得我在吹毛求疵, 但你稍微想一下, 如果我們要管理的資源的非常複雜的初始化步驟的話, 上面的寫法其實就很噁心了. 首先是異常安全的保證就需要非常小心, 其次就是抄程式碼的情況就會非常明顯: 同樣的邏輯, 你要在拷貝建構函式=操作符過載裡, 寫兩遍!

那麼一個正確的實現應當怎麼寫呢? 我們上面說過, copy-and-swap套路能規避掉上面的三個缺陷, 但在繼續討論之前, 我們首先要實現一個swap函式. 這個swap函式是如此的重要與核心, 我甚至願意為此, 將所謂的rule of three改名叫成rule of three and a half, 其中的a half就是指這個swap函式. 多說無益, 我們來看swap的實現, 如下:

class dumb_array
{
public:
    // ...

    // 首先, 這是一個函式, 只是宣告與實現都放在了類定義中, 而不是一個成員函式
    // 其次, 這個函式不拋異常
    friend void swap(dumb_array& first, dumb_array& second)
    {
        // 通過這條指令, 在找不到合適的swap函式時, 去呼叫std::swap
        using std::swap;

        // 由於兩個成員都是基礎型別, 它們沒有自己的 swap 函式
        // 所以這裡呼叫的是兩次 std::swap 
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};

這個swap的實現初看起來很平平無奇, 其目的也十分顯而易見(交換兩個物件中的所有成員), 但實際上, 上面這個寫法裡也是有一些門道的, 限於篇幅關係, 這裡不會掰開揉碎細細講, 你最好仔細琢磨一下這個swap的寫法, 比如:

  1. 為什麼它非要寫成friend void swap, 而不是寫成一個普通函式
  2. 裡面那句using std::swap有什麼玄機? 想一想, 如果dumb_array的成員變數不是基礎型別, 而是一個類型別, 並且這個類型別也完整的實現了rule of three and a half, 會發生什麼?

總之, 現在先不關心swap實現上的一些細節, 僅僅只需要關注它的功能即可: 它是一個函式, 它能完成兩個dumb_array物件的交換, 而所謂的交換, 是交換兩個物件的成員的值.

在此基礎上, 我們的=操作符過載可以實現成下面這樣:

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}

是的, 就這是麼簡潔. 你沒有看錯, 就是這麼有魔力! 那麼, 為什麼說它規避了我們先前提到的三個缺陷呢? 它又是如何規避的呢?

首先再回顧一下, 我們實現=操作符過載的邏輯思路:

  1. fuck = shit的內部, 我們先將shit拷貝一份, 稱其為shit2好了
  2. 然後使用一個swap函式, 將fuckshit2進行交換: 即交換兩個物件的所有成員變數的值. 這樣就達到了"把shit的值賦給fuck"的目的
  3. 第三步, 在=操作符實現的內部退棧的時候, shit2會自動由於退棧而被析構.

整個過程沒有風險, 沒有異常, 很是流暢.

這裡有幾個點也需要額外說明一下:

  1. 首先, 這個=操作符過載的實現, 其引數是值型別, 而不是const dumb_array & other. 這裡面是有門道的, 如果採用引用型別, 如下所示:
dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}

從功能上看, 和先前的值引用版本沒什麼區別. 但內在上, 你實質上放棄了一個"讓編譯器自動優化程式碼"的契機. 這個細節展開來說也比較複雜, 具體緣由在這裡 有詳細解釋, 但總結起來就是: 在C++中, 普通的拷貝操作(呼叫拷貝建構函式), 比起在函式傳參時, 編譯器在背後執行的拷貝操作(雖然從表面看它也是在呼叫拷貝建構函式), 效率要低, 並且還低得多!

  1. 使用值傳遞來自動使編譯器在背後呼叫拷貝建構函式(實質上編譯器會做一些優化, 但你可以這樣理解), 保證了只要執行流程進入到了=操作符的內部, 資料拷貝就已經完成了. 這暗地裡還複用了拷貝建構函式的程式碼. 所以, 程式碼重複的問題解決了.
  2. 並且由於這樣的寫法, 只要函式呼叫這個動作被成功發起了, 就代表著資料拷貝已經成功: 這意味著拷貝過程中發生的記憶體分配等其它高危操作已經完成, 如果有異常, 應當在函式呼叫之前被扔出來, 而一旦程式碼執行進呼叫內部, 就不可能再拋異常了. 這解決了異常安全的問題
  3. 我們也規避了用以檢查=左右兩邊是否為同一個物件的邏輯. 雖然如果這種情況發生, 這種寫法會導致一次額外的資料拷貝與析構, 但這也是可以接受的, 畢竟, 如果出現了這種情況, 你應當反思的是為什麼出現了自己 = 自己這種奇怪的邏輯, 而不是去苛責自己 = 自己執行的不夠快.

至此, 就是copy-and-swap套路的所有內容.

那麼, 在C++11中, 事情發生了任何變化了嗎? 我們在rule of three/five這一小節說過, 由於C++11引入了右值引用移動語義, 所以threefive: 你要新增一個移動建構函式, 與右值引用版的=操作符過載. 但實質上, 使用copy-and-swap套路的話, 你並不需要為=操作符再寫一個右值引用版本的過載, 你只需要像下面這樣, 新增一個移動建構函式就可以了:

class dumb_array
{
public:
    // ...

    // 移動建構函式
    dumb_array(dumb_array&& other)
        : dumb_array() // 呼叫預設建構函式, 這在本例中不是必須的.
    {
        swap(*this, other);
    }

    // ...
};

關於為什麼不需要再寫一個右值引用版的=操作符過載, 這個, 你可以先了解一下下一節的內容: 移動語義後, 再來看這裡. 總之, 就是, 使用copy-and-swap套路, 在C++11中, 可以將所謂的rule of five變成rule of four and a half, 分別是:

1.      解構函式
2.      移動建構函式
3.      拷貝建構函式
4.      `=`操作符過載 
4.5.    `swap`函式

移動語義

要理解移動語義, 其實用程式碼說話更容易讓人理解. 我們就從下面的程式碼片斷開始: 這是一個非常簡單簡陋的string的實現(注意它不是標準庫中的std::string, 這裡僅是我們自己實現的一個非常簡陋的字串類), 它內部使用一個指標成員持有著資料:

#include <cstring>
#include <algorithm>

class string
{
    char* data;

public:

    string(const char* p)
    {
        size_t size = strlen(p) + 1;
        data = new char[size];
        memcpy(data, p, size);
    }

由於我們在這個簡陋的實現裡選擇使用指標來管理資料, 即是作為類的設計者, 我們需要手動管理具體資料佔用的記憶體的分配與釋放, 所以按C++03標準的最佳實踐, 我們應當遵循rule of three. 即是: 解構函式, 拷貝建構函式, =操作符的過載三者必須同時在場. 我們先在這裡把解構函式拷貝建構函式補上, 關於=的過載, 後面一點再談

    ~string()
    {
        delete[] data;
    }

    string(const string& that)
    {
        size_t size = strlen(that.data) + 1;
        data = new char[size];
        memcpy(data, that.data, size);
    }

拷貝建構函式實現了拷貝的語義, 引數const string & thatconst引用, 這代表著它可以指向C++03標準中的右值, 即是一個表示式的值的最終型別是為上面這個簡陋的string, 都可以作為拷貝建構函式的引數使用. 所以, 在假定我們還實現了類似於標準庫中std::string+的過載的話, 我們可以以如下三種方式呼叫拷貝建構函式:

...
// x和y是兩個string型別的變數
string a(x);                                    // Line 1
string b(x + y);                                // Line 2, 這裡假設我們實現了+的過載, 使得表示式 x + y 的型別也是 string
string c(some_function_returning_a_string());   // Line 3

現在就到了理解移動語義的關鍵點:

注意在第一行, 我們使用x作為引數去呼叫拷貝建構函式初始化a, 拷貝建構函式內部實現了深拷貝: 即完整的把x底層持有的資料拷貝了一份. 這沒有任何毛病, 因為這就是我們想要的, 完成初始化a之後, ax分別持有兩份資料, 後續再對x做一些資料修改的操作, 不會影響到a, 反之亦然. x顯然也是C++03標準中的左值.

而第二行和第三行的引數, 無論是x + y還是some_function_returning_a_string(), 顯然都不能算是C++03中的左值, 顯然它們都是右值. 因為這兩個表示式的運算結果雖然確實是一個string的例項, 但沒有一個變數名去持有這些例項, 這些例項都是臨時性的例項: 閱後即焚. 即在這個表示式之後, 你沒有任何辦法再去訪問先前表示式指代的那個string例項. 按照C++03的規範, 這種臨時量佔用的記憶體在下一個分號之後就可以被扔掉了(更精確一點的說: 在整個包含著這個右值的表示式單元執行完畢之後. 再精確一點: 編譯器的實現是不確定的, 你應當假定在表示式執行完畢後這個物件就被析構了, 但編譯器多數情況下只會在遇到下個}的時候才析構這種臨時物件).

這就給了我們一個靈感: 既然在下個分號之後, 再也無法訪問x + ysome_function_returning_a_string()這兩個表示式指向的臨時string物件, 意味著我們可以在下個分號之前(換句話說, 在初始化bc的過程中: 在拷貝建構函式中), 隨意蹂躪這兩個臨時量! 反正蹂躪完了也不會產生任何額外副作用.

基於這種思路, C++11標準中引入了一種新的機制叫右值引用, 右值引用一般用於函式過載(的引數列表)中, 它的目的是探測呼叫者傳入的引數是否是C++03中的臨時量. 一旦探測到呼叫者傳入的是一個臨時量的話, 過載呼叫機制就會匹配到有右值引用引數的過載中. 在這種函式內部, 你通過右值引用可以去訪問這個臨時量, 並在內部隨意蹂躪這個臨時量.

說起來有一點繞, 我們直接使用右值引用這個機制去寫一個拷貝建構函式的過載, 如下所示:

    string(string&& that)   // string&& is an rvalue reference to a string
    {
        data = that.data;
        that.data = nullptr;
    }

在向string的內部新增了這個拷貝建構函式後, string類內部目前就有了兩個拷貝建構函式: string(const string& that)string(string&& that). 我們再回到上面的a, b, c三個初始化語句上. 這時, 由於x是一個左值, 所以a的初始化會匹配至string(const string& that). 而由於x + ysome_function_returning_a_string()是兩個顯然的臨時量右值, 所以對於bc的初始化, 就會匹配到string(string&& that).

那麼string(string&& that)內部到底做了什麼事情呢? 看上面的程式碼就很顯然, 它並沒有像string(const string& that)那樣去真正的拷貝一份資料, 而僅僅是把臨時量內部持有的資料偷了過來, 用讀書人的說法, 就叫移動.

這裡需要注意, 在string(string&& that)執行結束之後, 臨時量x + ysome_function_returning_a_string()還是會和C++03一樣, 閱後即焚. 這兩個臨時物件依然會被析構. 臨時量始終都是臨時量, 從C++03到C++11, 這個行為沒有變化. 只不過, 在析構之前, 我們已經通過string(string&& that)把它內部的資料偷掉了! 真正這兩個臨時量被析構的時候, 執行的只不過是delete nullptr罷了.

恭喜你, 到目前為止, 理解了C++11中移動語義的基本概念.

現在, 在進一步討論之前, 讓我們先把string類的=操作符過載再補上. 根據C++03的最佳實踐之copy and swap idiom, 一個行為正確異常安全的=操作符過載應當被實現成下面這樣:

    string& operator=(string that)
    {
        std::swap(data, that.data);
        return *this;
    }
};

看到上面這個程式碼你是不是準備問我, "右值引用哪去了? ". 我的回答是: "這裡並不需要右值引用", 至於為什麼, 我們再來看下面三行程式碼:

// x, y, a, b, c 均是string型別的變數

a = x;                                      // Line 4
b = x + y;                                  // Line 5
c = some_function_returning_a_string();     // Line 6

我們先來分析第四行(Line 4).

  1. 由於string& operator=(string that)是值型別引數, 所以在呼叫發生時, 引數的傳遞會使用x先去初始化that, 你可以理解為string that(x)這種. 由於x是一個左值. 所以that的初始化使用的是string(const string& that)這個建構函式: 即thatx的一個完整副本, 深度拷貝了x的資料
  2. 在執行std::swap(data, that.data)的過程中, a持有的資料與that持有的資料相互交換. 至此, a持有的資料其實就是x資料的一個完整副本.
  3. return *this執行之後, that由於函式退棧, 被析構. that中持有的資料(其實是原a持有的資料)被解構函式安全釋放

總結起來: a = x內部, 將x的資料完整的複製了一份給a, 再把a原持有的資料安全析構掉了.

我們再來分析第五行(Line 5)

  1. 由於string& operator=(string that)是值型別引數, 所以在呼叫發生時, 引數的傳遞會使用x + y先去初始化that, 你可以理解為string that(x)這種. 由於x + y是一個臨時量右值, 所以that的初始化使用的是string(string&& that)這個建構函式, 在這個建構函式內部, that偷掉了x + y內部持有的資料, 並沒有發生資料拷貝.
  2. 在執行std::swap(data, that.data)的過程中, b持有的資料與that持有的資料相互交換. 至此, x + y原持有的資料經過二次轉手, 來到了b的手上. 而b原持有的資料, 則交換給了that
  3. return *this執行之後, that由於函式退棧, 被析構. that中持有的資料(其實是原b持有的資料)被解構函式安全釋放

總結起來: b = x + y內部, 經過兩次轉手, 將x + y持有的資料轉交給了b, 而b原持有的資料被完全的析構掉了.

第六行和第五行類似.

至此, 你可算是基本明白了C++11中的移動語義. 現在, 請回頭再看copy-and-swap小節的末尾, 你就會明白, 為什麼copy-and-swap + rule of three + C++11 == rule of four and a half

移動語義, 值類別, 右值引用, 將亡值等新概念的深入討論

我們在這裡, 再對移動語義, 右值引用等內容做一些補充

概覽

移動語義允許一個物件, 在一些受限的上下文中, 去奪取另外一個同型別物件的內部資源. 這有兩個點:

  1. 它將C++03標準中, 代價昂貴的拷貝操作進行了優化. 但如果一個類型別, 內部並不掌管任何外部資源的話(無論是直接掌管, 還是由成員物件間接掌管), 移動語義是沒有任何卵用的: 它實質上就是拷貝! 也就是說, 在這種情況下, 移動拷貝, 指的是同一件事. 比如下面這個POD類:
class cannot_benefit_from_move_semantics
{
    int a;        // 移動一個int, 其實就是拷貝
    float b;      // 移動一個float, 其實也是拷貝
    double c;     // 移動一個double, 其實還是拷貝
    char d[64];   // 移動一個位元組陣列, 其實還他媽是拷貝

    // ...
};
  1. 移動語義的引入可以讓程式設計師寫出這樣一種類: 它的物件僅能移動, 而不能被拷貝. 這種物件中或許掌管著諸如鎖, 檔案控制程式碼, 智慧指標這樣的全域性或區域性單例資源.

移動的本質是什麼?

C++98中的標準庫提供了一個智慧指標模板類, 其語義是唯一性的指向一個物件. 即是大家熟悉的std::auto_ptr<T>. 如果你不熟悉auto_ptr, 可以將它理解為一個"保證new出來的物件一定會妥善析構(甚至在有異常丟擲的場合裡)而不需要程式設計師手動delete"的小工具, 比如下面這種用法:

{
    std::auto_ptr<Shape> a (new Triangle);
}   // <- 當程式碼執行流程跳出這個作用域的時候, 物件a就會被自動析構

其實, auto_ptr中值得稱道的就是它的"拷貝"操作, 下面用一個簡略的ASCII圖來說明:

auto_ptr<Shape> a(new Triangle);            // 智慧指標a指向一個新建立和Triangle物件

      +---------------+
      | triangle data |
      +---------------+
        ^
        |
        |
        |
  +-----|---+
  |   +-|-+ |
a | p | | | |
  |   +---+ |
  +---------+

auto_ptr<Shape> b(a);                       // 使用a去初始化另外一個智慧指標b, 其實a與b均指向了同一個Triangle物件

      +---------------+
      | triangle data |
      +---------------+
        ^
        |
        +----------------------+
                               |
  +---------+            +-----|---+
  |   +---+ |            |   +-|-+ |
a | p |   | |          b | p | | | |
  |   +---+ |            |   +---+ |
  +---------+            +---------+

關鍵點在於, 用a去初始化b的時候, 在智慧指標物件這一層, 確實新建了一個智慧指標物件, 也就是auto_ptr<Shape>類的例項. 但在內部, 並沒有新建一個Triangle物件, 兩個智慧指標物件指向的是同一個Triangle物件. 這就是移動語義的最初發源, 所以, 正確理解移動語義需要理解下面兩句話:

  1. 當我們講, 將a移動到b的時候. ab其實還是相互獨立的兩個例項, 各自在記憶體中佔用著各自的空間.
  2. 移動語義中的移動, 其實說的是a將它"持有的資源"交給了b, 這種資源一般都是以指標形式指向的動態資源.

移動並不是說, 記憶體中的a物件本身改名叫b了, 並不是. ab還是各自獨立的兩個物件, 分別有自己的記憶體. 這一點一定要正確理解.

auto_ptr之所以能實現這種功能, 其實是auto_ptr<T>拷貝建構函式使用瞭如下的實現方式(就說這麼個意思, 但並不是真的是這樣寫的程式碼):

auto_ptr(auto_ptr & source) {
    p = source.p;
    source.p = 0;
}

移動語義的錯誤理解導致的誤用

auto_ptr時至今日已經被拋棄, 其緣由就是, 它的行為看起來讓程式設計師以為是"拷貝", 但實際上是"移動". 比如下面的例子:

auto_ptr<Shape> a(new Triangle);
auto_ptr<Shape> b(a);           // a的資源, 也就是實際的"Triangle物件", 已經交由b了
double area = a->area();        // 完犢子

上面進行到b(a)這一步的時候, 其實a已經丟失了對Triangle物件的所有權, 其所有權轉交給了b. 之後, a其實已經不持有任何物件了. 再往後a->area()顯然就是做無米之炊.

當然, auto_ptr雖然來說比較危險, 但也有它自己適合的應用場合. 工廠函式就是一個特別適合auto_ptr發光發熱的地方, 如下:

auto_ptr<Shape> make_triangle() {
    return auto_ptr<Shape>(new Triangle);
}

auto_ptr<Shape> c(make_triangle());     // c指向的是一個工廠生產的全新的Triangle物件
double area = make_triangle()->area();  // area是另外一個全新Triangle物件算出來的面積

上面就是一個安全的例子: 確實有兩個Triangle物件. 其實這個安全的例子, 和上面那個完犢子的例子, 其實都有同樣的程式碼書寫方式:

auto_ptr<Shape> var(狗);
double area = 狗->area();

你心裡明白, 同樣的寫法, 完犢子的例子之所以完犢子, 是因為把自己的資源在第一步交給了var, 自己一無所有了. 而工廠例子中, 每次狗都持有一個全新的資源.

但從另外一個角度來看問題: 兩個例子中, amake_triangle()這兩個表示式還有什麼其它區別嗎? 表面上看, 它們都是同一型別的表示式(auto_ptr<Shape>), 那為什麼二者表現不一致呢? 這是因為二者的值類別(value categories)不同: a是一個左值(lvalue), make_triangle()是一個右值(rvalue).

值類別, value categories

我們依然從C++98與C++03的標準來一步一步的看這個問題. 在C++98與C++03中, 值類別是如此的不言自明(注意, 當我們討論值類別的時候, 討論的是表示式, 而不是變數), 以至於很長一段時間裡, 大家都不去關心這個事情, 值類別只有兩種選擇: 左值(lvalue)右值(rvalue). 所謂左值, 就是可以出現在賦值操作符左側的表示式. 所謂右值, 是指僅可以出現在賦值操作符右側的表示式.

上面的例子中, 表示式a是一個auto_ptr<Shape>型別的變數, 這顯然是一個左值, 因為a是一個變數, 可以被賦值. 而make_triangle()這個表示式是一個函式呼叫表示式, 其值是為其返回的物件(按值返回的物件), 每次呼叫它都會在內部建立一個新的auto_ptr<Shape>然後通過值返回的方式返回回來. 這顯然是一個右值表示式.

我們從值型別的角度來看auto_ptr中的移動語義, 結合上面提到的"完犢子"與"工廠"兩個鮮活的例子, 可以得出一個例子:

  1. 移動左值是一種危險的行為. 因為移動代表著剝奪, 但左值可能在內部資源被剝奪之後, 錯誤的再去嘗試使用內部資源
  2. 移動諸如make_triangle()這樣的右值則是一種安全的行為. 因為這種右值本身就是一次性的, 閱後即焚的, 即便你不剝奪它的內部資源, 它也會在下個;後被析構.

或者形象一點, 左值就像是一個正常的人, 能活到90歲(所屬作用域終止), 你不能隨便就把一個正常人的腎挖掉. 但右值就像是一個被判決死刑立即執行的人一樣, 我們可以心安理得的將死刑犯的腎挖掉. 反正下午就嗝屁了, 不如死前給和諧社會做一點貢獻.

auto_ptr<Shape> c(make_triangle());
                                  ^ make_triangle()表示式的值所指向的Triangle物件, 活不過這個分號

其實左值右值這個概念是從C語言一脈相承繼承過來的. 左值可以出現在=左邊, 右值只能出現在=右邊這句話在C語言範疇中, 是絕對正確的. 但在C++98或C++03中, 並不完全正確, 這裡舉幾個反例:

  1. 陣列變數, 或刪除了=操作符的類變數, 是沒法出現在=左邊的, 但它們都是貨真價實的左值
  2. 如果一個類, 實現了=操作符, 但它的語義並不是賦值的話, 這種類的變數也有可能出現了=的左邊(這確實有點抬槓了, 但你不能說這不是一個反例)

在C++98與C++03中, 或許我們這樣定義左值右值可能會更精確一點:

  1. 無論左值還是右值, 本質都是, 都是物件, 都在記憶體中佔用一塊區域
  2. 左值是有名字的, 像變數就是典型的左值(變數名, 或者引用名就是名字), 意味著這塊記憶體區域在它的作用域範圍內, 可以通過名字被多次訪問. 它的生命週期一般與作用域一致
  3. 右值則是沒有名字的, 一般僅在表示式求值的那一個時刻可以訪問這塊記憶體區域, 之後就沒有辦法再去訪問這塊記憶體了.

注意, 我們當前的討論, 還沒有超出C++03的範疇.

 右值引用

auto_ptr的例子我們可以看出移動語義本身的效能潛力, 但也看到了潛在的安全風險. 那麼, 有沒有一種機制, 能自動判斷表示式的值類別, 如果是左值, 就對其執行拷貝, 如果是右值, 就對其執行移動呢?

在C++11中, 這個問題的答案就是右值引用. 右值引用是一種新的引用型別, 但它僅能繫結在右值上, 語法是T &&, 我們將原來C++03/98中的引用型別T &稱為左值引用. (注意, T &&不是"對引用的引用", 就是右值引用, C++中沒有"引用的引用"這種東西).

現在, 我們有兩種引用: 左值引用右值引用, 如果再加上const修飾符, 我們能得到四種引用型別, 下圖是一個表格, 展示了何種表示式能繫結到何種引用上:

            lvalue   const lvalue   rvalue   const rvalue
---------------------------------------------------------              
X&          yes
const X&    yes      yes            yes      yes
X&&                                 yes
const X&&                           yes      yes

是不是有點蛋疼呢? 其實實踐中, 你完全可以把上表中的最後一行抹掉, const X&&代表了一種對右值的, 不可更改其值的引用, 這種型別你告訴我有什麼用?

所以, 右值引用其實就是一種引用型別, 但它僅能繫結在右值

注意, 此時我們對值類別的討論依然沒有超出C++03的範疇, 我們僅是介紹了一種新的引用型別: 右值引用

隱式轉換

C++在進行函式呼叫的時候, 預設會執行一步型別轉換, 比如下面就是一個生動的例子:

#include <iostream>

class Fuck {
friend std::ostream & operator << (std::ostream & out, const Fuck & fuck) {
    return out << "Fuck[" << fuck._name << "]";
}
private:
    std::string _name;
public:
    Fuck(const std::string & name) {        // 該建構函式可以在函式呼叫時, 將std::string隱式的轉換成Fuck物件
        _name = name;
    }

};

// 這個函式接受右值引用引數
void Jesus(Fuck && fuck) {
    std::cout << fuck << std::endl;
}

int main(void) {

    // 我們傳遞給Jesus的引數其實是 std::string 型別
    // 在函式呼叫時會被轉換成 Fuck 型別
    // 並且由於表示式 std::string("shit") 是一個右值
    // 所以轉換後的 Fuck 物件也是一個右值
    // 故能匹配呼叫Jesus成功
    Jesus(std::string("shit"));

    // 這裡的fuck是一個左值, 所以呼叫Jesus會失敗, 因為Jesus僅接受右值引用引數
    // 左值是不能匹配函式參數列的
    Fuck fuck("you");
    Jesus(fuck);

    
    return 0;
}

上例中的Jesus函式接受右值引用引數, 但實際呼叫的時候我們傳遞的是std::string("shit"), 這是一個型別為std::string的右值, 但經過型別轉換被轉換成Fuck型別, 這個過程中相當建立了兩個臨時物件:

  1. std::string("shit")建立了一個臨時的std::string物件
  2. Jesus函式的呼叫, 由於引數的自動型別轉換, 相當於再建立了一個臨時的Fuck物件

最終在函式內部, 右值引用引數繫結的是2中建立的那個臨時的Fuck物件.

上例中Jesus(fuck)的呼叫是失敗的, 並且無法成功編譯, 原因在於fuck是一個左值, 不匹配函式參數列.

移動建構函式

右值引用一個很重要的應用場合就是作為建構函式的引數, 即所謂的移動建構函式. 其目的是從右值中奪取資源初始化當前物件, 以節省拷貝開銷.

在C++11中, std::auto_ptr<T>這個模板類被正式蓋上了廢棄的章, 取而代之的是std::unique_ptr<T>, 上位的手段就是右值引用. 下面我們會寫一個簡化版的unique_ptr的實現, 首先, 我們需要將指標型別包裹起來, 並且過載->*操作符以提供更好的使用體驗:

template<typename T>
class unique_ptr{
private:
    T * _ptr;

public:
    T* operator->() const {
        return _ptr;
    }
    
    T& operator*() const {
        return *_ptr;
    }
};

然後給它加上一個建構函式與解構函式, 建構函式的目的是接管物件, 解構函式用以釋放物件:

    explicit unique_ptr(T * p = nullptr) {
        _ptr = p;
    }
    
    ~unique_ptr() {
        delete _ptr;
    }

接下來就是有意思的地方: 我們來寫一個移動建構函式:

    unique_ptr(unique_ptr && source) {
        _ptr = source.ptr;
        source._ptr = nullptr;
    }

這個移動建構函式所做的事情, 其實就是上面我們說的auto_ptr中的拷貝建構函式做的事情, 但是: 這個移動建構函式僅能通過右值去呼叫. 這樣就避免了像auto_ptr那樣, 掠奪左值內部資源的危險操作.

unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a);                 // 這一步呼叫不能成功, 因為a是一個左值, 並且我們沒有定義任何拷貝建構函式
unique_ptr<Shape> c(make_triangle());   // 沒有毛病, 因為表示式 `make_triangle()`的值是右值, c其實內部掠奪了`make_triangle()`表示式值的資源

b(a)是不能通過編譯的, 這是因為:

  1. 由於我們已經顯式的定義了一個移動建構函式, 所以編譯器不再提供預設的拷貝建構函式的實現
  2. a是一個左值, 並不能匹配移動建構函式. 而它想匹配的拷貝建構函式, 沒有實現

這種行為就避免了像auto_ptr那樣, 對左值資源的錯誤掠奪

移動賦值操作符

我在先前的陳述中一直避免使用移動賦值操作符這個術語, 這是我個人的習慣, 因為我更習慣將其稱之為使用右值引用作為引數的=操作符過載.

移動賦值操作符的目的是釋放舊資源, 並從=右邊獲取(奪取)新的資源. 下面我們給unique_ptr實現一個移動賦值操作符

    unique_ptr & operator = (unique_ptr && source) {
        if (this != &source) {
            delete ptr;
            ptr = source.ptr;
            source.ptr = nullptr;
        }
        return *this;
    }
};

我們在copy-and-swap idiom中已經講過了這種寫法的缺陷, 上面的寫法有兩個特點:

  1. 它僅能實現資源的移動, 真是移動賦值操作符, 但並不能實現拷貝賦值的語義. 即如果=右邊是一個左值, 會編譯失敗
  2. 這個實現只是很直觀的在向你展示移動賦值操作符的語義

真正的良好實踐, 如我們在copy-and-swap idiom中講的那樣, 應當如下寫:

    unique_ptr & operator = (unique_ptr source) {
        std::swap(_ptr, source.ptr);
        return *this;
    }
};

這個寫法有兩個特點:

  1. 是否需要實現拷貝語義, 要看拷貝建構函式是否存在
  2. 避免了程式碼重複, 異常不安全等缺陷.

這些內容在copy-and-swap我們已經講過了, 這裡就不再重複陳述了.

在左值上實施移動: 掠奪左值的內部資源

有時候我們確實想掠奪一個左值的資源, 並且我們確實明白這樣做風險的話, C++11也為我們提供了一個途徑: std::move.

在繼續講之前我實再是忍不住要吐槽一下, 這就是C++吸引人的地方, 也是C++ Fucked up的地方: 真他媽是給你自由過了火, 你想怎麼整都行, C++恨不得把挖祖墳的能力給你.

在標頭檔案<utility>中, C++11新提供了一個標準庫設計:模板函式std::move. 坦白講這個模板函式的名字取的非常有誤導性, 其實std::move並不實施任何與移動有關的操作, 它的功能僅是把一個左值, 轉換成一個右值, 從而使得可以呼叫僅接受右值引用的函式. 其實講道理這個模板函式應該把名字取成std::cast_to_rvaluestd::enable_move, 但標準已經是這樣了, 我們就少吐槽乖乖接受算了.

下面是如何使用它的示例:

unique_ptr<std::string> a(new string("fuck"));
unique_ptr<std::string> b(a);                   // 按先前的講解, 我們知道這一句會編譯錯誤, 因為unique_ptr並沒有實現拷貝建構函式, 而a是一個左傳
unique_ptr<std::string> c(std::move(a));        // 這樣就可以通過編譯了, 但這是一個危險操作: a中的資源被掠奪了, 像我們在討論auto_ptr的實現時那樣

將亡值(xvalue)

std::move最神奇的一點就是, 雖然std::move從表面上把一個左值改成了一個右值, 但std::move本身並沒有建立一個新的臨時物件. 是的, std::move的求值結果, 本質上還是指向之前的那個物件, 那麼, std::move的運算結果, 到底算是左值, 還是右值呢?

在C++11中, std::move的運算結果, 是一種全新的值類別, 叫將亡值(xvalue,(eXpiring value)). , 所以讓人頭大的事情就來了: 先來看下面這張圖:

        expressions
          /     \
         /       \
        /         \
    glvalues   rvalues
      /  \       /  \
     /    \     /    \
    /      \   /      \
lvalues   xvalues   prvalues

最底層的三種值型別, 是C++11中每個表示式的值類別: 你要麼是一個lvalue(左值), 要麼是一個xvalue(將亡值), 要麼是一個prvalue(純右值). 其中lvalue就是C++03中的左值, 而prvalue就是C++03中的右值. 而xvalue, 則是一個全新的概念: 你可以暫時將它理解為, std::move的值類別.

函式返回值的移動(大坑)

截止目前, 我們對移動語義的討論還未涉及函式返回, 下面是一個函式返回時移動資源的例子:

unique_ptr<Shape> make_triangle()
{
    return unique_ptr<Shape>(new Triangle);
}          \-----------------------------/
                  |
                  | return 語句中建立的臨時物件中的資源, 被移動到了c中
                  | 這個過程和移動建構函式無關, 是編譯器的優化行為
                  v
unique_ptr<Shape> c(make_triangle());

函式返回的過程中, make_triangle返回的是一個臨時量, 用這個臨時量去初始化c時, 編譯器會自動將臨時量的資源移動給c, 特別弔詭的事情是: 這個移動操作的過程, 移動建構函式並沒有參與, 拷貝建構函式也沒有參與!

函式按值返回時, 發生的詭異的移動行為, 與右值引用無關, 和C++11甚至都沒有關係, 這就是一個編譯器的優化行為, 這個優化行為詭異的點在於:

  1. 按C++98或03標準的眼光去看, c的初始化應當呼叫拷貝建構函式. 但實際上, 並沒有
  2. 按C++11標準的眼光去看, c的初始化應當呼叫移動建構函式. 但實際上, 也沒有
  3. 既然既沒呼叫拷貝建構函式, 也沒呼叫移動建構函式, 好吧你編譯器要搞黑魔法你去搞, 那我把拷貝建構函式和移動建構函式都宣告為delete行不行呢? 不行, 編譯器(至少是gcc)會提示你: 類缺乏拷貝建構函式, 故函式無法返回.

編譯器看起來讓你以為, 它在呼叫拷貝建構函式或者移動建構函式, 但實際上並沒有. 它內部實現了一個很詭異的移動操作: 是的, 這個臨時量持有的資源, 被轉交給了c. 而不是拷貝.

更詭異的事情在下面: 你按值將一個函式內部的自動變數返回的時候, 編譯器都會進行資源的移動操作!!!

unique_ptr<Shape> make_square()
{
    unique_ptr<Shape> result(new Square);
    return result;
}          \----/
              |
              |    編譯器將result的資源交給了d, 是移動
              |    是的, 是那種既不調移動建構函式, 也不調拷貝建構函式的, 詭異的移動
              v
unique_ptr<Shape> d(make_square());

這個編譯器的鬼邏輯是這樣的: 雖然從函式內部看, result是一個變數, 一個左值, 但從函式外部呼叫來看, result的生命週期也是短暫的, 函式呼叫結束後, 它就不存在了. 這和臨時量很像, 所以, 我將這個result中的資源剝奪出來, 是一種安全的行為.

從下面這個簡單而又完整的例子你就會看到: 函式返回時, 返回值本身就是按移動返回的, 這種移動甚至更高階: 被返回的變數, 無論是自動變數還是臨時變數, 其實並沒有在函式退棧的時候被析構, 這種被返回的變數, 是真真切切的存在於記憶體中, 只是把其名字改成了返回值的接收者! 這個點並不容易被人理解, 特別是對函式呼叫在彙編層面上的原理不熟悉的人來說, 顯得特別詭異.

#include <iostream>

struct POD{
    int _f1;
    int _f2;
};

class Foo {
public:
    POD* _pod;

public:
    // 預設建構函式
    Foo() {
        _pod = new POD{1,2};
        std::cout << "constructor: _pod == " << _pod << std::endl;
    }

    // 拷貝建構函式
    Foo(const Foo & foo) {
        std::cout << "copy constructor" << std::endl;
        _pod = new POD{
            foo._pod->_f1,
            foo._pod->_f2,
        };
    }

    // 移動建構函式
    Foo(Foo && foo) {
        std::cout << "move constructor" << std::endl;
        _pod = foo._pod;
        foo._pod = nullptr;
    }

    // 拷貝賦值操作符
    Foo& operator = (const Foo & foo) {
        if(this != &foo) {
            _pod = new POD{
                foo._pod->_f1,
                foo._pod->_f2,
            };
        }

        return *this;
    }

    // 移動賦值操作符
    Foo& operator =(Foo && foo) {
        _pod = foo._pod;
        foo._pod = nullptr;
        return *this;
    }

    // 解構函式
    ~Foo() {
        std::cout << "destructor: _pod == " << _pod << std::endl;
        delete _pod;
        _pod = nullptr;
    }
};

Foo ReturnAutoVariableFooFromFunc() {
    std::cout << "create auto variable inside func then return it" << std::endl;
    Foo foo;    // 呼叫預設建構函式
    return foo; // <- 這裡並沒有對foo這個內部變數的析構操作
}

Foo ReturnTempVariableFooFromFunc() {
    std::cout << "create temp variable inside func then return it" << std::endl;
    return Foo(); // <- 這裡也並沒有對Foo()表示式建立的臨時變數的析構操作
}

int main(void) {
    Foo foo = ReturnAutoVariableFooFromFunc();
    std::cout << "foo._pod outside function == " << foo._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    Foo foo2(ReturnAutoVariableFooFromFunc());
    std::cout << "foo2._pod outside function == " << foo2._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    Foo foo3 = ReturnTempVariableFooFromFunc();
    std::cout << "foo3._pod outside function == " << foo3._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    Foo foo4(ReturnTempVariableFooFromFunc());
    std::cout << "foo4._pod outside function == " << foo4._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    return 0;
}

這段程式碼的輸出長下面這樣:

create auto variable inside func then return it
constructor: _pod == 0x1ae9010
foo._pod outside function == 0x1ae9010              <- 觀察這裡, 並沒有對解構函式的呼叫, 並沒有對拷貝建構函式或移動建構函式的呼叫
--------------------
create auto variable inside func then return it
constructor: _pod == 0x1ae9030
foo2._pod outside function == 0x1ae9030             <- 觀察這裡, 並沒有對解構函式的呼叫, 並沒有對拷貝建構函式或移動建構函式的呼叫
--------------------
create temp variable inside func then return it
constructor: _pod == 0x1ae9050
foo3._pod outside function == 0x1ae9050             <- 觀察這裡, 並沒有對解構函式的呼叫, 並沒有對拷貝建構函式或移動建構函式的呼叫
--------------------
create temp variable inside func then return it
constructor: _pod == 0x1ae9070  
foo4._pod outside function == 0x1ae9070             <- 觀察這裡, 並沒有對解構函式的呼叫, 並沒有對拷貝建構函式或移動建構函式的呼叫
--------------------
destructor: _pod == 0x1ae9070
destructor: _pod == 0x1ae9050
destructor: _pod == 0x1ae9030
destructor: _pod == 0x1ae9010                       <- 這裡倒是有四個次析構, 不過這是由於main函式退棧而對 foo/foo2/foo3/foo4 的析構

然而, 更大的坑在這裡: C++11引入了右值引用, 我能不能手動的, 顯式的返回一個右值引用, 將函式內部的臨時量, 或自動變數的資源, 交給呼叫者呢? 答案是: 不行.

我們在上面那個小例子的基礎上, 寫這樣的一個函式, 然後試圖去呼叫它:

Foo && TryToReturnAnRvalueReference() {
    std::cout << "create auto variable inside func then return std::move(it)" << std::endl;
    Foo foo;
    return std::move(foo);
}

int main(void) {
    Foo foo5 = TryToReturnAnRvalueReference();
    std::cout << "foo5._pod outside function == " << foo5._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    Foo foo6(TryToReturnAnRvalueReference());
    std::cout << "foo6._pod outside function == " << foo6._pod << std::endl;
    std::cout << "--------------------" << std::endl;

    return 0;
}

這段程式碼的輸出在不同的編譯器下面還有點不一樣, 在clang++ 3.4.2編譯後, 長下面這樣:

create auto variable inside func then return std::move(it)
constructor: _pod == 0x1b0a010
destructor: _pod == 0x1b0a010                               <- 觀察這裡, 函式內的自動變數在返回之前就析構了
move constructor                                            <- 完事在這裡呼叫了移動建構函式. 將一個已經不存在(已被析構)的物件中的已被釋放(被解構函式釋放)的資源進行移動
foo5._pod outside function == 0x7ffd16ec8a60                <- 導致main中的foo5已經放飛自我了
--------------------
create auto variable inside func then return std::move(it)
constructor: _pod == 0x1b0a010
destructor: _pod == 0x1b0a010                               <- 和foo5的症狀基本一樣
move constructor
foo6._pod outside function == 0x7ffd16ec8a48
--------------------
destructor: _pod == 0x7ffd16ec8a48
*** Error in `./bin/hello': free(): invalid pointer: 0x00007ffd16ec8a48 ***
======= Backtrace: =========
//.... 輸出了錯誤發生時的呼叫棧
======= Memory map: ========
//.... 輸出了錯誤發生時的程式記憶體表

clang++的編譯二進位制在執行後, 通過echo $?檢視二進位制的最終返回值, 會發現程式臨死前發出的呻吟錯誤碼不為0, 也就是說, clang認為這是一段導致程式crash掉的程式碼.

而上面這段程式碼經過g++ 4.8.5編譯後, 輸出長下面這樣:

create auto variable inside func then return std::move(it)
constructor: _pod == 0x9c6010
destructor: _pod == 0x9c6010                                <- 觀察這裡, 函式內的自動變數在返回之前就析構了
move constructor
foo5._pod outside function == 0                             <- main中的foo5沒有放飛自我, 而是其資源控制程式碼_pod欄位的值, 被移動建構函式置為了0. 
--------------------                                           合理的解釋就是, 函式內的自動變數在析構之後, 記憶體置0了而已
create auto variable inside func then return std::move(it)
constructor: _pod == 0x9c6010
destructor: _pod == 0x9c6010
move constructor
foo6._pod outside function == 0
--------------------
destructor: _pod == 0
destructor: _pod == 0

g++編譯的二進位制在執行後, 通過echo $?檢視二進位制的最終返回值, 是0, 也就是說, g++暫且不認為程式崩掉了. 但這也並不程式碼你用g++來做開發工作就能寫這樣的程式碼!!

上面的編譯過程中, 編譯引數中的 -O均被設定為-O0

總結一下, 截止目前為止, C++11提供的所謂的右值引用+移動語義, 只能用在兩個場合:

  1. 函式呼叫時的引數傳遞(通過移動建構函式)
  2. 物件之間的相互拷貝(通過移動賦值操作符)

而關於函式返回這裡, 從C語言一路沿襲下來的記憶體模型(函式退棧返回的時候, 返回值物件在記憶體或暫存器中, 是直接改名"移動"的, 而不是進行拷貝, 這是編譯器的成果: 彙編層面的行為, 與程式設計師寫的任何建構函式都沒關係)決定了, 這和C++11中的右值引用移動語義沒有任何卵關係. 當然, 右值引用在一些很特殊的條件下, 可以作為函式的返回值, 但最佳實踐的建議是:

  1. 不要這樣做
  2. 如果你非要這樣做, 不要用std::move(函式中的自動變數或臨時變數)這種方式去返回

將資源移動給類成員(小坑)

我們來看下面這段程式碼, 然後你猜一猜它能不能編譯通過, 為了你閱讀方便, 我把完整的unique_ptr的定義都附帶上了:

#include <iostream>
#include <utility>

template<typename T>
class unique_ptr{
private:
    T * _ptr;

public:
    // 解引用操作符過載
    T* operator->() const { return _ptr; }
    
    // 取地址操作符過載
    T& operator*() const { return *_ptr; }

    // 建構函式(預設建構函式)
    explicit unique_ptr(T * p = nullptr) { _ptr = p; }

    // 拷貝建構函式被顯式刪除
    unique_ptr(const unique_ptr & other) = delete;
    
    // 解構函式
    ~unique_ptr() { delete _ptr; }

    // 移動建構函式
    unique_ptr(unique_ptr && source) { _ptr = source._ptr; source._ptr = nullptr; }

    // 移動賦值操作符, 使用了 copy-and-swap idiom
    unique_ptr & operator = (unique_ptr source) { std::swap(_ptr, source.ptr); return *this; }
};

class Foo {
private:
    unique_ptr<int> _member;

public:
    // 建構函式
    Foo(unique_ptr<int> && param) :
        _member(param)                  // <-- 關鍵在這裡, 編譯錯誤在這裡
    {
        
    }
};

int main(void) {
    return 0;
}

結果是不能編譯通過的, 編譯器(g++ 4.8.5)給出的錯誤提示大致如下:

main.cpp: In constructor ‘Foo::Foo(unique_ptr<int>&&)’:
main.cpp:39:22: error: use of deleted function ‘unique_ptr<T>::unique_ptr(const unique_ptr<T>&) [with T = int]’
         _member(param)
                      ^
main.cpp:20:5: error: declared here
     unique_ptr(const unique_ptr & other) = delete;

我們再來看看clang++ 3.4.2給出的錯誤提示資訊吧:

main.cpp:39:9: error: call to deleted constructor of 'unique_ptr<int>'
        _member(param)
        ^       ~~~~~
main.cpp:20:5: note: function has been explicitly marked deleted here
    unique_ptr(const unique_ptr & other) = delete;
    ^

看來這次gcc與clang算是達成一致了.

編譯器的意思是說, 你試圖在_member(param)這一行呼叫一個已經被刪除的拷貝建構函式. 但是這很不符合我們的直覺: 我們認為param是一個右值引用啊, 我們試圖呼叫的是移動建構函式, 而不是被顯式刪除的拷貝建構函式, 這是怎麼回事呢?

原因在於, 編譯器認為, param是一個左值...其內在邏輯是這樣的:

param作為一個形參, 被宣告為右值引用型別, 這包含了兩個意思:

  1. 你只能用一個右值引用去初始化param
  2. param本身並不是一個右值引用. 相反, 它是一個普通的左值

所以, 上面程式碼編譯失敗的原因在於: 你試圖用一個左值(param)去初始化_member成員, 但_member成員所屬的類, 並沒有實現拷貝建構函式!

有點想罵人是吧? 所以, 核心邏輯在於, 我再換個說法再說一遍: 右值引用引數限定了只能通過右值引用去初始化這個引數, 但這個引數其實是個左值

顯然這種邏輯有點不合理, 那麼如何才能把我們上面的程式碼改正確呢? 這時候就要祭出std::move了, 如下修改即可:

class Foo {
private:
    unique_ptr<int> _member;

public:
    // 建構函式
    Foo(unique_ptr<int> && param) :
        _member(std::move(param))                  // <-- 你說你是左值是吧? 我把你強轉成xvalue
    {
        
    }
};

特殊的成員函式

C++98標準定義了三個特殊的類內成員函式, 並且一直沿用至C++03標準, 這三個特殊的成員函式分別是:

  1. X::X(const X&); 拷貝建構函式
  2. X& X::operator=(const X&); 拷貝賦值操作符
  3. X::~X(); 解構函式

C++11標準由於右值引用移動語義的引入, 追加了兩個特殊的類內成員函式:

  1. X::X(X&&); 移動建構函式
  2. X& X::operator=(X&&); 移動賦值操作符

對於特殊成員函式, 編譯器在某些情況下會提供預設實現, 規則如下:

  1. 編譯器僅在以上五個特殊的成員函式都沒有宣告實現的時候, 才會去預設給你生成一個預設的移動建構函式預設的移動賦值操作符.
  2. 一旦你自己實現了移動建構函式, 或移動賦值操作符. 編譯器就不會給你生成拷貝建構函式拷貝賦值操作符的預設實現

那麼, 在日常實踐中, 應當怎麼做呢? 很簡單:

if(類內沒有掌管任何資源) {
    五個特殊成員函式一個都不用實現, 編譯器自動提供的預設實現就完全夠用
    並且你能從其提供的預設移動建構函式與預設移動賦值操作符中獲得效能提升
} else if(類掌管了資源){
    if (拷貝資源的開銷 > 移動資源的開銷) {
        五個特殊成員函式都實現一遍, 當然具體實踐的時候可以採用 rule of four and a half的方式, 不實現移動賦值操作符
    } else {
        僅實現三個古典的特殊成員函式. 不需要實現移動建構函式和移動賦值操作符
    }
}

我們在copy-and-swap idom中說過, rule of five可以被簡化為rule of four and a half, 這裡再重溫一遍, 這種最佳實踐下, 你無需顯式實現移動賦值操作符, 僅需要如下實現一個賦值操作符的過載即可:

X& X::operator=(X source)
{
    swap(source);
    return *this;
}

引用轉發

來看下面這個模板函式的簽名:

template<typename T>
void foo(T&&);

第一眼看上去, 你可以會認為, 這個模板函式的形式引數型別是為T&&, 這顯然是一個右值引用嘛, 所以你會認為: 要呼叫這個模板函式, 必須使用右值引用作為引數.

但實際情況是: 你竟然可以使用左值去呼叫這個模板函式..

#include <iostream>

template<typename T>
void foo(T&& t) {
    std::cout << t << std::endl;
}

int main(void) {
    foo(23);

    foo("i love you");

    int a = 2333;

    foo(a);         // <-- 可以使用一個左值去呼叫foo模板函式!
    return 0;
}

這他媽的....先罵會娘..

那麼到底是哪個環節出了問題呢? 明明我形參的型別寫的是T&&, 是顯而易見的右值引用型別的形參啊!! 問題出在模板函式的型別推導上了..這裡的邏輯是這樣的:

foo(23)的呼叫中, 引數是int的左值, 所以模板型別T被推導為int, 所以整個模板函式會被例項化為void foo(int&& t), 沒有任何毛病, 這個模板函式的例項確實是個接受右值引用型別引數的函式

但在foo(a)的呼叫中, 引數是int型別的左值, 這裡由於模板函式型別推導的一個特殊規則, 模板函式的型別引數T實質上會被推導為int &型別, 而不是int. 現在問題來了: 如果T被推導成了int &, 那麼, T&&的意思難道是int& &&嗎? 這是什麼鬼玩意?

C++中並不存在一種型別, 可以後面帶三個&, 真實的T&&被降格為int &, 換句話說, foo(a)呼叫的那個例項函式, 它其實長這樣:

void foo(int & t) {
    std::cout << t << std::endl;
}

這種從int & &&降格到int &的型別推導過程, 被稱為collapsed. 這個型別推導過程中的特殊邏輯, 是C++11中另外一個新特性: 完美轉發(perfect forwarding)的基石.

那麼如果你真的想寫一個函式模板, 讓它的引數僅接受右值引用, 正確的寫法應該怎麼寫呢? 正確的寫法如下:

#include <type_traits>

template<typename T>
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
foo(T&&);

下面就是生動的例子:

#include <iostream>
#include <type_traits>

template<typename T>
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
foo(T&& t) {
    std::cout << t << std::endl;
}

int main(void) {
    foo(23);

    int a = 2333;
    foo(a);         // <-- 編譯失敗

    return 0;
}

編譯失敗的資訊如下(clang 3.4.2):

main.cpp:14:5: error: no matching function for call to 'foo'
    foo(a);         // <-- 編譯失敗
    ^~~
main.cpp:5:25: note: candidate template ignored: disabled by 'enable_if' [with T = int &]
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
                        ^

這個寫法為什麼會生效, 是一個比較複雜的問題, 有興趣的話可以去研究一下標頭檔案<type_traits>中的內容. 這裡就不展開了.

std::move的實現

通過上面的陳述, 你明白了在模板引數的型別推導中, 有一個特殊邏輯叫collapsed, 而其實std::move的實現就與這個特性有關, 下面就是std::move的原始碼:

template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

上面是原樣複製自libstdc++ 4.8.5中的原始碼, 將它稍微修整一下, 長這樣:

template<typename T>
typename std::remove_reference<T>::type&&
move(T&& t)
{
    return static_cast<typename std::remove_reference<T>::type&&>(t);
}

我們來解讀一波:

首先, 由於(T&& t)的引數型別宣告, 與collapsed的推斷規則, 我們可以知道, move其實可以接受任何型別的引數.

其次, 它的返回值型別是為 typename std::remove_reference<T>::type&&, 其中std::remove_reference<T>::type保證了在入參為int &型別的情況下, 返回值型別是int&&, 即始終保證返回值是右值引用型別

最後, 函式內部的具體實現, 其實就是呼叫static_cast將入參強轉為右值引用. 由於在函式內部, 形參t已經被初始化為一個左值引用, 根據collapsed可知, 它在函式內部是一個左值(如果引發型別推斷的實參是int型別, 則形參會被推導為int && t, 但在被初始化後, t就是一個左值引用. 如果引發型別推斷的實參是int &型別, 則形參由於collapsed會被推斷為int & t, 在被初始化後, t還是一個左值引用). 所以, 這裡先用std::remove_reference脫掉引用, 再用&&將其強轉為右值引用.

相關文章