C++ 慣用法之 Copy-Swap 拷貝交換

2023-07-08 21:01:09

C++ 慣用法之 Copy-Swap 拷貝交換

這是「C++ 慣用法」合集的第 3 篇,前面 2 篇分別介紹了 RAII 和 PIMPL 兩種慣用法:

正式介紹 Copy-Swap 之前,先看下《劍指 Offer》裡的第☝️題:

如下為型別 CMyString 的宣告,請為該型別新增賦值運運算元函數。

class CMyString {
public:
  CMyString(char* pData = nullptr);
  CMyString(const CMyString& str);
  ~CMyString();

private:
  char* m_pData;
};

這道題目雖然基礎,但考察點頗多,有區分度:

  • 返回值型別應為參照型別,否則將無法支援形如 s3 = s2 = s1 的連續賦值
  • 形參型別應為 const 參照型別
  • 無資源洩露,正確釋放賦值運運算元左側的物件的資源
  • 自賦值安全,能夠正確處理 s1 = s1 的語句
  • 考慮異常安全

解法 1

CMyString& operator=(const CMyString& str)
{
    if(this == &str)
        return *this;

    delete[] m_pData;
    m_pData = nullptr;
    m_pData = new char[strlen(str.m_pData) + 1];
    strcpy(m_pData, str.m_pData);
    return *this;
}

上面程式碼有些細節需要注意:

  • 刪除陣列使用 delete[] 運運算元
  • strlen 計算長度不含字串末尾的結束符 \0
  • strcpy 會拷貝結束符 \0

解法 1 滿足考察點中除異常安全外的所有要求:new 的時候可能由於記憶體不足拋異常,但此時賦值運運算元左側的的物件已被釋放,m_pData 為空指標,導致左側物件處於無效狀態。

解決方案:只要先 new 分配空間,再 delete 釋放原來的空間即可。這樣可以保證即使 new 失敗拋異常,賦值運運算元左側物件也尚未修改,仍處於有效狀態。

解法 2

《劍指 Offer》中給出了更好的解法:先建立賦值運運算元右側物件的一個臨時副本,然後交換賦值運運算元左側物件和該臨時副本的 m_pData,當臨時物件 strTemp 離開作用域時,自動呼叫其解構函式,釋放 m_pData 指向的資源(即賦值運運算元左側物件原來的記憶體):

CMyString& operator=(const CMyStirng& str)
{
    if(this != &str)
    {
        CMyString strTemp(str);
        char* pTemp = m_pData;
        m_pData = strTemp.m_pData;
        strTemp.m_pData = pTemp;
    }
    return *this;
}

解法 2 巧妙地利用了類原本的拷貝構造、解構函式自動進行資源管理,同時又不涉及底層的 new[]/delete[] 操作,可讀性更強,也不容易出錯。

解法 2 是 Copy-Swap 的雛形。C++ 中管理資源類通常會定義自己的 swap 函數,與其他拷貝控制成員(拷貝/移動構造、拷貝/移動賦值運運算元、解構)不同,swap 不是必須,但卻是重要的優化手段,以下是使用 Copy-Swap 慣用法的解法:

解法 3

class CMyString {
    friend void Swap(CMyString& lhs, CMyString& rhs) noexcept
    {
        // 對 CMyString 的成員逐一交換
        std::swap(lhs.m_pData, rhs.m_pData);
    }
    // ...
};

CMyString(CMyString&& str) : CMyString()
{
    Swap(*this, str);
}

CMyString& operator=(CMyStirng str)
{
    Swap(*this, str);
    return *this;
}

這裡有幾點需要注意:

  • 拷貝賦值運運算元的形參型別不再是 const 參照,因為 Copy-Swap 需要先對賦值運運算元右側物件進行拷貝,這裡直接使用值傳遞。這樣一來,也使得 Copy-Swap 天然地異常安全、自賦值安全。
    • 異常安全:進入函數 operator=() 之前,先進行拷貝
    • 自賦值安全:形參是一個新建立的臨時物件,永遠不可能是物件自身
  • 不需要額外實現移動賦值運運算元:如果賦值運運算元右側是一個右值,則自動呼叫 CMyString 的移動構造來構造形參

這還沒完...

標準庫 std::swap 及 ADL

C++ 標準庫也提供了 swap 函數,理論上需要一次拷貝,兩次賦值:

void swap(CMyString& lhs, CMyString& rhs)
{
    CMyString tmp(lhs);
    lhs = rhs;
    rhs = tmp;
}

其中 CMyString tmp(lhs) 會呼叫 CMyString 的拷貝構造進行深拷貝,效率上不如 CMyString 類自己實現的直接交換指標的效率高。

在進行 swap(v1, v2) 的呼叫時,如果類實現了自己的 swap 版本,其匹配程度優於標準庫的版本。如果類沒有定義自己的 swap,則使用標準庫的 swap。這種查詢匹配方式被稱為 ADL(Argument-Dependent Lookup)。

注意不能使用 std::swap 形式,因為這樣會強制使用標準庫的 swap。正確的做法是提前使用 using std::swap 宣告,而後續所有的 swap 都應該是不加限制的(這一點剛好和 std::move 相反):

void swap(Bar& lhs, Bar& rhs)
{
    using std::swap;
    swap(lhs.m1, rhs.m1);
    swap(lhs.m2, rhs.m2);
    swap(lhs.m3, rhs.m3);
}

最終的結果

class CMyString {
    friend void swap(CMyString& lhs, CMyString& rhs) noexcept
    {
        // 對 CMyString 的成員逐一交換
        using std::swap;
        swap(lhs.m_pData, rhs.m_pData);
    }
    // ...
};

CMyString(CMyString&& str) : CMyString()
{
    swap(*this, str);
}

CMyString& operator=(CMyStirng str)
{
    swap(*this, str);
    return *this;
}