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


如果你對外語感興趣,那肯定聽過“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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM