做個地道的c++程式猿:copy and swap慣用法

apocelipes發表於2021-06-02

如果你對外語感興趣,那肯定聽過“idiom”這個詞。牛津詞典對於它的解釋叫慣用語,再精簡一些可以叫“成語”。想要掌握一門語言,其中的“成語”是不能不學的,而希望成為地道的語言使用者,“idiom”則是必不可少的。程式語言其實和外語也很類似,兩者都有自己的語法,一個個函式也就像一個個詞彙,大部分的外語都是自然語言,有著深厚的歷史文化底蘊,因此有不少idiom,而程式語言雖然只有短短數十歲,idiom卻不比前者少。不過對於程式設計語言的idiom來說比起文化歷史的積累倒更像是工程經驗指導下的最佳實踐。

話說回來,我並不是在推薦你像學外語一樣學c++,然而想要做個一個地道的c++程式設計師,常見的idiom是不可不知的。今天我們就來看看copy and swap idiom是怎麼一回事。

本文索引

設計一個二維陣列

前排提示:不要模仿這個例子,有類似的需求應該尋找第三方庫或者使用容器/智慧指標來實現類似的功能。

現在我們來設計一個二維陣列,這個二維陣列可以存任意的資料,所以我們需要泛型;我還想要能在初始化時指定陣列的長度,所以我們需要一個建構函式來分配動態陣列,於是我們的程式碼第一版是這樣的:

template <typename T>
class Matrix {
public:
    Matrix(unsigned int _x, unsigned int _y)
    : x{_x}, y{_y}
    {
        data = new T*[y];
        for (auto i = 0; i < y; ++i) {
            data[i] = new T[x]{};
        }
    }

    ~Matrix() noexcept
    {
        for (auto i = 0; i < y; ++i) {
            delete [] data[i];
        }
        delete [] data;
    }

private:
    unsigned int x = 0;
    unsigned int y = 0;
    T **data = nullptr;
};

x是橫向長高度,y是縱向長度,而在c++裡想要表示這樣的結構正好得把x和y對調,這樣一個x=4, y=3的matrix看上去是下面的效果:

顯而易見,我們的二維陣列其實是多個單獨分配的一維陣列組合而成的,這也意味著他們之間的記憶體可能不是連續的,這也是我不推薦模仿這種實現的原因之一。

在建構函式中我們分配了記憶體,並且對陣列使用了方括號初始化器,所以陣列內如果是類型別資料則會預設初始化,如果是標量型別(int, long等)則會進行零初始化,因此不用擔心我們的陣列裡會出現未初始化的垃圾值。

接著我們還定義了解構函式用於釋放資源。

看起來一個簡易的二維陣列類Matrix定義好了。

還缺些什麼

對,直覺可能告訴你是不是還有什麼遺漏。

直覺通常是不可靠的,然而這次它卻十分準,而且我們遺漏的東西不止一個!

不過在查漏補缺之前請允許我對兩個早就人盡皆知的c++原則炒個冷飯。

rule of zero

c++的類型別裡有幾種特殊成員函式:預設建構函式、複製建構函式、移動建構函式、解構函式、複製賦值運算子和移動賦值運算子。

如果使用者沒有定義(哪怕是空函式體,除非是=default)這些特殊成員函式,且沒有其他語法定義的衝突(比如定義了任何建構函式都會導致預設建構函式不進行自動合成),那麼編譯器會自動合成這些特殊成員函式並用在需要它們的地方。

其中複製構造/賦值、移動構造/賦值是針對每一項類的非靜態資料成員進行復制/移動。解構函式則自動呼叫每一項類的非靜態資料成員的解構函式(如果有的話)。

看起來是很便利的功能吧,假如我的類有10個成員變數,那編譯器自動合成這些函式可以省去不少煩惱了。

這就是rule of zero:如果你的類沒有自己定義任何一個除了預設建構函式外的特殊成員函式,那麼就不應該定義任何一個複製/移動建構函式、複製/移動賦值運算子、解構函式

標準庫的容器都定義了自己的資源管理手段,如果我們的類只使用這些標準庫裡的內容,或者沒有自己申請分配資源(檔案控制程式碼,記憶體)等,則應該遵守“rule of zero”,編譯器會自動為我們合成合適的函式。

預設只進行淺複製

如果我要在類裡分配點資源呢?比如某些系統的檔案控制程式碼,共享記憶體什麼的。

那就要當心了,比如對於我們的Matrix,編譯器合成的複製賦值運算子是類似這樣的:

template <typename T>
class Matrix {
public:
    /* ... */
    // 合成的複製賦值運算子類似下面這樣
    Matrix& operator=(const Matrix& rhs)
    {
        x = rhs.x;
        y = rhs.y;
        data = rhs.data;
    }

private:
    unsigned int x = 0;
    unsigned int y = 0;
    T **data = nullptr;
};

問題很明顯,data被淺複製了。對於指標的複製操作,預設只會複製指標本身,而不會複製指標所指向的記憶體。

然而即使能複製指標指向的記憶體,在我們這個Matrix裡還是有問題的,因為data指向的記憶體裡存的幾個也是指標,它們分別指向別的記憶體區域!

這樣會有什麼危害呢?

兩個指標指向同一個區域,而且兩個指標最後都會被解構函式delete,當delete第二個指標的時候就會導致雙重釋放的bug;如果只刪除其中一個指標,兩個指標指向的記憶體會失效,對另一個指標指向的失效記憶體進行訪問將會導致更著名的“釋放後重用”漏洞。

這兩類缺陷猶如c++er永遠無法甦醒的夢魘。這也是我不推薦你模仿這個例子的又一個原因。

rule of five

如果“rule of zero”不適用,那麼就要遵循“rule of five”的建議了:如果複製類特殊成員函式、移動類特殊成員函式、解構函式這5個函式中定義了任意一個(顯式定義,不包括編譯器合成和=default,那麼其他的函式使用者也應該顯式定義

有了自定義解構函式所以需要其他特殊成員函式很好理解,因為自定義解構函式通常意味著釋放了一些類自己申請到的資源,因此我們需要其他函式來管理類例項被複制/移動時的行為。

而通常移動類特殊成員函式和複製類的是相互排斥的。

移動意味著所有權的轉移,複製意味著所有權共享或是從當前類複製出一個一樣的但是完全獨立的新例項,這些對於所有權移動模型來說都是禁止的行為,因此一些類只能移動不能複製,比如mutexunique_ptr

而一些東西是支援複製的,但移動的意義不大,比如陣列或者一塊被申請的記憶體。

最後一種則同時支援移動和複製,通常複製產生副本是有意義的,而移動則在某些情況下幫助從臨時物件那裡提高效能。比如vector

我們的Matrix恰好屬於後者,移動可以提高效能,而複製出副本可以讓同一個二維陣列被多種演算法處理。

Matrix本身定義了解構函式,因此根據“rule of five”應該至少實現移動類或複製類特殊成員函式中的一種,而我們的類要同時支援兩種語義,自然是一個也不能落下。

copy and swap慣用法

說了這麼多也該進入正題了,篇幅有限,所以我們重點看複製類函式的實現。

實現自定義複製

因為淺拷貝的一系列問題,我們重新實現了正確的複製建構函式和複製賦值運算子:

// 普通建構函式
Matrix<T>::Matrix(unsigned int _x, unsigned int _y)
    : x{_x}, y{_y}
{
    data = new T*[y];
    for (auto i = 0; i < y; ++i) {
        data[i] = new T[x]{};
    }
}

Matrix<T>::Matrix(const Matrix &obj)
    : x{obj.x}, y{obj.y}
{
    data = new T*[y];
    for (auto i = 0; i < y; ++i) {
        data[i] = new T[x];
        for (auto j = 0; j < x; ++j) {
            data[i][j] = obj.data[i][j];
        }
    }
}

Matrix<T>& Matrix<T>::operator=(const Matrix &rhs)
{
    // 檢測自賦值
    if (&rhs == this) {
        return *this;
    }

    // 清理舊資源,重新分配後複製新資料
    for (auto i = 0; i < y; ++i) {
        delete [] data[i];
    }
    delete [] data;
    x = rhs.x;
    y = rhs.y;
    data = new T*[y];
    for (auto i = 0; i < y; ++i) {
        data[i] = new T[x];
        for (auto j = 0; j < x; ++j) {
            data[i][j] = rhs.data[i][j];
        }
    }
    return *this;
}

這樣做正確,但非常囉嗦。比如複製建構函式裡初始化xy和分配記憶體的工作實際上和建構函式中的沒有區別,一句老話叫“Don't repeat yourself”,所以我們可以藉助c++11的新語法建構函式轉發把這部分工作委託給建構函式,我們的複製建構函式只進行陣列元素的複製:

Matrix<T>::Matrix(const Matrix &obj)
    : Matrix(obj.x, obj.y)
{
    for (auto i = 0; i < y; ++i) {
        for (auto j = 0; j < x; ++j) {
            data[i][j] = obj.data[i][j];
        }
    }
}

複製賦值運算子裡也有和建構函式+解構函式重複的部分,我們能簡化嗎?遺憾的是我們不能在賦值運算子裡轉發操作給建構函式,而delete this後再使用建構函式也是未定義行為,因為this代指的類例項如果不是new分配的則不合法,如果是new分配的也會因為delete後對應記憶體空間已失效再次進行訪問是“釋放後重用”。那我們先呼叫解構函式再在同一個記憶體空間上構造Matrix呢?對於能平凡析構的型別來說,這是完全合法的,可惜的是自定義解構函式會讓類無法“平凡析構”,所以我們也不能這麼做。

雖說不能簡化程式碼,但我們的類不是也能正確工作了嗎,先上線再說吧。

如果發生了異常

看起來Matrix可以正常執行了,然而上線幾天後程式崩潰了,因為複製賦值運算子的new語句或是某次陣列元素拷貝丟擲了一個異常。

你想這樣什麼大不了的,我早就未雨綢繆了:

try {
    Matrix<T> a{10, 10};
    Matrix<T> b{20, 20};
    // 一些操作
    a = b; 
} catch (exception &err) {
    // 打些log,然後對a和b做些善後
}

這段程式碼天衣無縫的外表下卻暗藏殺機:a在複製失敗後原始資料已經刪除,而新資料也可能只初始化了一半,這是訪問a的資料會導致多種未定義行為,其中一部分會讓系統崩潰。

關鍵在於如何讓異常發生的時候a和b都能保持有效狀態,現在我們可以保證b有效,需要做到的是如何保證a能回到初始化狀態或者更好的辦法——讓a保持賦值前的狀態不變。

至於為什麼不讓賦值運算不拋異常,因為我們控制不了使用者存入的T型別的例項會不會拋異常,所以不能進行控制。

copy and swap

現在我們不僅沒解決重複程式碼的問題,我們的賦值運算子幾乎把解構函式和複製建構函式抄了一遍;還引入了新的問題賦值運算的異常安全性——要麼賦值成功,要麼別對運算的運算元產生任何影響。

該輪到“copy and swap慣用法”閃亮登場了,它可以幫我們一次解決這兩個問題。

我們來看看它有什麼妙招:

  1. 首先我們用複製建構函式從rhs複製出一個tmp,這一步複用了複製建構函式;
  2. 接著用一個保證不會發生錯誤的swap函式交換tmp和this的成員變數;
  3. 函式返回,交換後的tmp銷燬,等於複用了解構函式,舊資源也得到了正確清理。

如果複製發生錯誤,那麼前面例子裡的a不會被改變;如果tmp析構發生錯誤,當然這是不可能的,因為我們已經把解構函式宣告成noexcept了,還要拋異常只能說明程式遇到了非常嚴重的錯誤會被系統立即中止執行。

顯然,重點是swap函式,我們看看是怎麼實現的:

template <typename T>
class Matrix {
    friend void swap(Matrix &a, Matrix &b) noexcept
    {
        using std::swap; // 這一步允許編譯器基於ADL尋找合適的swap函式
        swap(a.x, b.x);
        swap(a.y, b.y);
        swap(a.data, b.data);
    }
};

通過ADL,我們可以利用std::swap或是某些型別針對swap實現的優化版本,而noexcept則保證了我們的swap不會丟擲異常(簡單的交換通常都基於移動語義實現,一般保證不會產生異常)。本質上swap的邏輯是很簡潔明瞭的。

有了swap幫忙,現在我們的賦值運算子可以這麼寫了:

Matrix<T>& Matrix<T>::operator=(const Matrix &rhs)
{
    // 檢測自賦值
    if (&rhs == this) {
        return *this;
    }

    Matrix tmp = rhs; // copy
    swap(tmp, *this); // swap
    return *this;
}

你甚至還可以省去自賦值檢測,因為現在使用了copy and swap後自賦值除了浪費了點效能外已經無害了

使用“copy and swap慣用法”不僅解決了程式碼複用,還保證了賦值操作的安全性,真正的一箭雙鵰。

對於移動賦值

移動賦值運算本身只是釋放左運算元的資料,再移動一些已經獲得的資源然後把rhs重置會安全的初始化狀態,這些通常都不會產生異常,程式碼也很簡單沒有太多重複,只不過釋放資料和把資料從rhs移動到lhs,這兩個操作是不是有點眼熟?

對,swap寫出來就是為了幹這種雜活的,所以我們還能實現move and swap

Matrix<T>& Matrix<T>::operator=(Matrix2 &&rhs) noexcept
{
    Matrix2 tmp{std::forward<Matrix2>(rhs)};
    swap(*this, tmp);
    return *this;
}

當然,正如我說的,通常沒必要這麼寫。

效能對比

現在我們的Matrix已經可以健壯地管理自己申請的記憶體資源了。

然而還有最後一點疑問:我們知道copy and swap會多建立一個臨時物件並多出一次交換操作,這對效能會帶來多大的影響呢?

我只能說會有一點影響,但這個“一點”到底是多少不跑測試我也口說無憑。所以我基於google benchmark寫了個簡單測試,如果還不瞭解benchmark怎麼用,可以看看我寫的教程

全部的測試程式碼有200行,實在是太長了,所以我把它貼在了gist上,你可以在這裡檢視。

下面是在我的機器上的測試結果:

可以看到效能差異幾乎可以忽略不計,因為Matrix只有三個簡單的成員變數,自然也不會有太大的開銷。

所以我的建議是:能上copy and swap的地方儘量上,除非你測試顯示copy and swap帶來了嚴重的效能瓶頸。

參考

https://stackoverflow.com/questions/3279543/what-is-the-copy-and-swap-idiom

https://stackoverflow.com/questions/6687388/why-do-some-people-use-swap-for-move-assignments

https://stackoverflow.com/questions/32234623/using-swap-to-implement-move-assignment

http://www.vollmann.ch/en/blog/implementing-move-assignment-variations-in-c++.html

https://cpppatterns.com/patterns/copy-and-swap.html

相關文章