C++移動語義 詳細講解【Cherno C++教程】

zhangyi1357發表於2022-03-17

移動語義

本文是對《最好的C++教程》的整理,主要是移動語義部分,包含視訊85p左值和右值89p移動語義90p stdmove和移動賦值操作符

移動語義是C++11的新feature,可能許多人學習的時候尚未使用到C++11的特性,但是現在C++11已經過去了10年了,早已成為廣泛使用的基礎特性。所以絕對值得一學。在我的上一篇部落格自己動手寫Vector中就用到了相關的內容對Vector的效能做了一定的提升,學習完本文後可以到其中看看實際中的使用。

文章主題內容來自Cherno的視訊教程,但是同時也加入了一些個人的理解和思考在其中,並未一一指出,如有錯誤或疑惑之處,歡迎留言批評指正。

本文包含的知識點:左值和右值、移動語義、stdmove、移動賦值操作符

原作者Cherno視訊連結:Cherno C++視訊教程

文中程式碼GitHub連結:https://github.com/zhangyi1357/Little-stuff/tree/main/Move-Semantics

左值與右值

相信你已經在很多地方聽過左值右值了,比如編譯器的報錯等處。想要理解移動語義,左值和右值是繞不過去的概念。

如果你對左值和右值已經十分熟悉了,可以直接跳過此章節,直接閱讀移動語義部分。

如果你去看左值右值的定義或者到CSDN上去找什麼是左值右值,你可能會看得暈頭轉向。不過我們不需要對背誦左值和右值的定義,只需要用一個基本的原則指導我們去應用左值和右值就可以了。畢竟我們只是需要學習其用法而不是做語言律師。

這個基本原則就是:

  • 左值對應於一個實實在在的記憶體位置,右值只是臨時的物件,它的記憶體對程式來說只能讀不能寫。

以上原則或許不能精確描述左值和右值的定義,但是足夠我們理解左值和右值的應用。

我們結合一些具體的例子來應用上面的原則。

基本概念

int i = 10;
i = 5;
int a = i;
a = i;

這裡a, i就是左值,10, 5為右值,我們可以用右值來初始化左值或賦值,也可以用左值來初始化左值或賦值給左值。

10 = i; // error

而左值顯然不能賦給右值。

應用基本原則上述都是很自然的事情,右值沒有儲存其的位置,自然不能給它賦值,左值就當成一個變數,想怎麼賦值就怎麼賦值。

引用

// int& b = 5;  // can't reference rvalue
int& c = i;     // allowed

可以對一個有地址的變數建立引用(引用本質上就是指標的語法糖),右值沒有地址自然不能引用。

函式返回值

關於函式返回值和引數完全可以把傳參和返回過程看成是賦值來理解。

int GetValue() {
    return 5;
}
i = GetValue();
GetValue() = i; // error

這裡GetValue函式的返回值為右值,可以當成和前面一樣的情況。

int& GetLValue() {
    static int value = 10;
    return value;
}
i = GetLValue(); // true
GetLValue() = i; // true

函式的返回值一樣可以是左值,不過要注意的是函式不能返回其臨時變數,因為臨時變數雖然有其記憶體位置,但是函式呼叫結束後棧幀就銷燬了,臨時變數一併銷燬了,所以就不能作為左值了。

函式引數

void SetValue(int value) {}

void SetLValue(int& value) {}

SetValue(i);        
SetValue(5);        

SetLValue(i);       
SetLValue(5); // error

這幾個可以用作練習。

Const

上面的函式引數問題似乎有些讓人惱火,因為有時候你確實就是想傳入一個值而不是建立一個變數再傳入,實際上C++為此提供瞭解決方案。

const int & d = 5;

void SetConstValue(const int& value) {}
SetConstValue(i);
SetConstValue(5);

你可能會想說,這樣就沒法在函式裡改變value的值了。但是如果你需要改變value的值,你就不能傳入一個右值。二者不可兼得。

右值引用

現在我們介紹一個對於移動語義實現的關鍵。

前面我們說到int&只接受左值,const int&左右值都接受,那麼有沒有一種方式只接受右值呢?

void PrintName(const std::string& name) {
    std::cout << "[lvalue] " << name << std::endl;
}
void PrintName(const std::string&& name) {
    std::cout << "[rvalue] " << name << std::endl;
}

std::string firstName = "Yan";
std::string lastName = "Chernikov";
std::string fullName = firstName + lastName;

PrintName(fullName);
PrintName(firstName + lastName);

注意第二個函式的引數型別,相較於前一個多了一個&符號,代表其僅接受右值引用。

以上程式的輸出為:

[lvalue] YanChernikov
[rvalue] YanChernikov

移動語義

為什麼需要移動語義?

首先來講講我們為什麼需要移動語義,很多時候我們只是單純建立一些右值,然後賦給某個物件用作建構函式。

這時候會出現的情況是,我們首先需要在main函式裡建立這個右值物件,然後複製給這個物件相應的成員變數。

如果我們可以直接把這個右值變數移動到這個成員變數而不需要做一個額外的複製行為,程式效能就這樣提高了。

例子

讓我們看下面這樣一個例子

#include <iostream>
#include <cstring>

class String {
public:
    String() = default;
    String(const char* string) {
        printf("Created!\n");
        m_Size = strlen(string);
        m_Data = new char[m_Size];
        memcpy(m_Data, string, m_Size);
    }

    String(const String& other) {
        printf("Copied!\n");
        m_Size = other.m_Size;
        m_Data = new char[m_Size];
        memcpy(m_Data, other.m_Data, m_Size);
    }

    ~String() {
        delete[] m_Data;
    }

    void Print() {
        for (uint32_t i = 0; i < m_Size; ++i)
            printf("%c", m_Data[i]);

        printf("\n");
    }
private:
    char* m_Data;
    uint32_t m_Size;
};

class Entity {
public:
    Entity(const String& name)
        : m_Name(name) {}
    void PrintName() {
        m_Name.Print();
    }
private:
    String m_Name;
};

int main(int argc, const char* argv[]) {
    Entity entity(String("Cherno"));
    entity.PrintName();

    return 0;
}

程式的輸出結果是

Created!
Copied!
Cherno

可以看到中間發生了一次copy,實際上這次copy發生在Entity的初始化列表裡。

從String的複製建構函式可以看到,複製過程中還申請了新的記憶體空間!這會帶來很大的消耗。

移動建構函式

現在讓我們為String寫一個移動建構函式併為Entity過載一個接受右值引用引數的建構函式,另外我們還將原來的建構函式註釋掉了。

    String(String&& other) {
        printf("Moved!\n");
        m_Size = other.m_Size;
        m_Data = other.m_Data;
        other.m_Data = nullptr;
        other.m_Size = 0;
    }

   ~String() {
        printf("Destroyed!\n");
        delete[] m_Data;
    }

    Entity(String&& name)
        : m_Name(name) {}

    // Entity(const String& name)
    //     : m_Name(name) {}

輸出為

Created!
Copied!
Destroyed!
Cherno
Destroyed!

幸運的是可以看到沒有報錯,確實呼叫了新寫的Entity的建構函式並輸出了結果。

但是不幸的是還是呼叫了String的賦值建構函式,問題出在哪呢?

實際上接受右值的函式在引數傳進來後其右值屬性就退化了,所以給m_Name的引數仍然是左值,還是會呼叫複製建構函式。

解決的辦法是將name轉型,

Entity(String&& name)
    :m_Name((String&&)name) {}

但是這樣的作法並不優雅,C++為了提供了更為優雅的做法

Entity(String&& name)
    :m_Name(std::move(name)) {}

修改之後的輸出結果為

Created!
Moved!
Destroyed!
Cherno
Destroyed

完美!

移動賦值運算子

上面的例子講了關於移動建構函式的例子,然而有時候我們想要將一個已經存在的物件移動給另一個已經存在的物件,就像下面這樣。

int main(int argc, const char* argv[]) {
    String apple = "apple";
    String orange = "orange";

    printf("apple: ");
    apple.Print();
    printf("orange: ");
    orange.Print();

    orange = std::move(apple);

    printf("apple: ");
    apple.Print();
    printf("orange: ");
    orange.Print();
    return 0;
}

我們需要的是一個移動賦值運算子過載

    String& operator=(String&& other) {
        printf("Moved\n");
        if (this != &other) {
            delete[] m_Data;

            m_Size = other.m_Size;
            m_Data = other.m_Data;

            other.m_Data = nullptr;
            other.m_Size = 0;
        }
        return *this;
    }

注意這裡的實現還是有點講究的,因為移動賦值相當於把別的物件的資源都偷走,那如果移動到自己頭上了就沒必要自己偷自己 。

更重要的是原來自己的資源一定要釋放掉,否則指向自己原來內容記憶體的指標就沒了,這一片記憶體就洩露了!

上述輸出結果是

Created!
Created!
apple: apple
orange: orange
Moved
apple: orange
orange:
Destroyed!
Destroyed!

很漂亮,orange的內容被apple偷走了。

C++ 三/五法則

瀏覽知乎時看到了如下的回答

陳碩大佬關於識別C++程式碼質量的回答

其實這說的就是如果有必要實現解構函式,那麼就有必要一併正確實現複製建構函式和賦值運算子,這被稱為三法則。

如果加上這一節所講的移動建構函式和移動賦值運算子,則被稱為五法則。

上述法則可以用來識別C++專案的程式碼質量,既然在用C++寫程式碼,希望就能寫出符合規範的優雅的程式碼,做一個更優秀的C++er。

更多詳細資料可以參考C++ 三/五法則 - 阿瑪尼迪迪 - 部落格園 (cnblogs.com)

參考資料

陳碩大佬關於識別C++專案程式碼質量的回答

Cherno C++視訊教程

C++ 三/五法則 - 阿瑪尼迪迪 - 部落格園 (cnblogs.com)

相關文章