現代 C++ 效能飛躍之:移動語意

2023-06-09 06:00:37

*以下內容為本人的學習筆記,如需要轉載,請宣告原文連結 微信公眾號「ENG八戒」https://mp.weixin.qq.com/s/Xd_FwT8E8Yx9Vnb64h6C8w

帶給現代 C++ 效能飛躍的特性很多,今天一邊聊技術,一邊送福利!


過去寫 C/C++ 程式碼,大家對資料做傳遞時,都習慣先拷貝再賦值。比如,把資料從 t1 複製到 t2,複製完成後 t2 和 t1 的狀態是一致的,t1 狀態沒變。這裡的狀態指的是物件內部的非靜態成員資料集合。

在程式執行過程中,複製過程既要分配空間又要拷貝內容,對於空間和時間都是種損耗。複製操作,無疑是一門很大的開銷,何況經常觸發資源複製的時候。

來看看普通的函數返回值到底有哪些開銷,

std::string getString()
{
    std::string s;
    // ...

    return s;
}

int main()
{
    std::string str = getString();
    // ...
}

假設你的編譯器還不支援 C++ 11,那麼,在 main() 函數裡呼叫 getString() 時,需要在呼叫棧裡分配臨時物件用於複製 getString() 的返回值 s,複製完成呼叫 s 的解構函式釋放物件。然後,再呼叫 std::string 類的複製賦值運運算元函數將臨時物件複製到 str,同時呼叫臨時物件的解構函式執行釋放。

那麼,有沒有技巧可以實現上面範例程式碼同樣的效果,同時避免複製?

有的,就是接下來重點介紹的移動(和中國移動無關)。

相對於複製,移動無須重新分配空間和拷貝內容,只需把源物件的資料重新分配給目標物件即可。移動後目標物件狀態與移動前的源物件狀態一致,但是移動後源物件狀態被清空。

實際上,大部份的情況下,資料僅僅需要移動即可,拷貝複製顯得多餘。就像,你從圖書館借書,把自己手機的 SIM 卡拔出來再插到其它手機上,去商店買東西你的錢從口袋移動到收銀櫃等等。

那麼,是不是可以對所有的資料都執行移動?

答案是否定的。在現代 C++ 中,只有右值可以被移動。

左右值概念

在 C++ 11 之前,左右值的劃分比較簡單,只有左值和右值兩種。

但是從 C++ 11 開始,重新把值類別劃分成了五種,左值(lvalue, left value),將亡值(xvalue, expiring value),純右值(prvalue, pure right value),泛左值(glvalue, generalized left value),右值(rvalue, right value)。不過後邊的兩種 glvalue 和 rvalue 是基於前面的三種組合而成。從集合概念來看,glvalue 包含 lvalue 和 xvalue,rvalue 包含 xvalue 和 prvalue。

左右值劃分的依據是:具名和可被移動。

具名,簡單點理解就是定址。可被移動,允許對量的內部資源移動到其它位置,並且保持量自身是有效的,但是狀態不確定。

  • lvalue:具名且不可移動
  • xvalue:具名且可移動
  • prvalue:不具名且可移動

那麼,可以看到泛左值(glvalue)其實就是具名的量,右值就是可移動的量。

以往在往函數傳參的時候,經常有用到值參照的模式,形式如下:

function(T& obj)

T 是型別,obj 是引數。

到了現代 C++,原來的值參照就變成了左值參照,另外還出現了右值參照,形式如下:

function(T&& obj)

那麼 C++ 11 是怎樣實現移動操作的呢?

實現移動操作

移動操作依賴於類內部特殊成員函數的執行,但前提是該物件是可移動的。如果恰好物件是左值(lvalue)呢?

C++ 11 的標準庫就提供了 std::move() 實現左右值轉換操作。std::move() 用於將表示式從 lvalue(左值) 轉換成 xvalue(將亡值),但不會對數值執行移動。當然,使用強制型別轉換也是可以達到同樣目的。

std::move(obj); // 等價於 static_cast<T&&>(obj);

在 stack overflow 上看到對 std::move() 的一段描述,與其說它是一個函數,不如說,它是編譯器對錶示式值評估的方式轉換器。

以往慣常使用 C++ 類定義時,我們都知道有這麼幾個特殊的成員函數:

  • 預設建構函式(default constructor)
  • 複製建構函式(copy constructor)
  • 複製賦值運運算元函數(copy assignment operator)
  • 解構函式(destructor)

來看看一個簡單的例子:

class MB // MemoryBlock
{
public:
    // 為下面程式碼演示簡單起見
    // 在 public 定義成員屬性
    size_t size;
    char *buf;

    // 預設建構函式
    explicit MB(int sz = 1024)
        : size(sz), buf(new char[sz]) {}
    // 解構函式
    ~MB() {
        if (buf != nullptr) {
            delete[] buf;
        }
    }
    // 複製建構函式
    MB(const MB& obj)
        : size(obj.size),
          buf(new char[obj.size]) {
        memcpy(buf, obj.buf, size);
    }
    // 複製賦值運運算元函數
    MB& operator=(const MB& obj) {
        if (this != &obj) {
            if (buf != nullptr) {
                delete[] buf;
            }
            size = obj.size;
            buf = new char[size]; 
            memcpy(buf, obj.buf, size);
        }
        return *this;
    }
}

為了支援移動操作,從 C++ 11 開始,類定義裡新增了兩個特殊成員函數:

  • 移動建構函式(move constructor)
  • 移動賦值運運算元函數(move assignment operator)

移動建構函式

在構造新物件時,如果傳入的引數是右值參照物件,就會呼叫移動建構函式建立物件。如果沒有自定義移動建構函式,那麼編譯器就會自動生成,預設實現是遍歷呼叫成員屬性的移動建構函式,並移動右值物件的成員屬性資料到新物件。

定義一般宣告形式如下:

T::T(C&& other);

基於上面的簡單例子:

class MB // MemoryBlock
{
public:
    // ...

    // 移動建構函式
    MB(MB&& obj)
        : size(0), buf(nullptr) {
        // 移動源物件資料到新物件
        size = obj.size;
        buf = obj.buf;
        // 清空源物件狀態
        // 避免解構函式多次釋放資源
        obj.size = 0;
        obj.buf = nullptr;
    }
}

可見,移動建構函式的執行過程,僅僅是簡單賦值的過程,不涉及拷貝資源的耗時操作,自然執行效率大大提高。

移動賦值運運算元函數

在呼叫賦值運運算元時,如果右邊傳入的引數是右值參照物件,就會呼叫移動賦值運運算元函數。同樣,如果沒有自定義移動賦值運運算元函數,那麼編譯器也會自動生成,預設實現是遍歷呼叫成員屬性的移動賦值運運算元函數並移動成員屬性的資料到左邊引數物件。

一般宣告形式如下:

T& T::operator=(C&& other);

基於上面的簡單例子:

class MB // MemoryBlock
{
public:
    // ...

    // 移動賦值運運算元函數
    MB& MB::operator=(MB&& obj) {
        if (this != &obj) {
            if (buf != nullptr) {
                delete[] buf;
            }
            // 移動源物件資料到新物件
            size = obj.size;
            buf = obj.buf;
            // 清空源物件狀態
            // 避免解構函式多次釋放資源
            obj.size = 0;
            obj.buf = nullptr;
        }
        return *this;
    }
}

移動賦值運運算元函數的執行過程,同樣僅僅是簡單賦值的過程,執行效率明顯遠超複製操作。

總結

回顧文首的範例程式碼,由於 C++ 11 加入了返回值優化 RVO(Return Value Optimization) 的特性,所以程式碼無需變更即可獲得效率提升。對於部分編譯器而言,比如 IBM Compiler、Visual C++ 2010 等,已經提前具備返回值優化的支援。

對於 RVO 的內容,暫不展開討論,有興趣的同學可以關注公眾號【ENG八戒】瞭解後續更新,關注後甚至可以參與贈書活動!