C++ 核心指南之資源管理(下)—— 智慧指標最佳實踐

2023-07-02 06:00:47

C++ 核心指南(C++ Core Guidelines)是由 Bjarne Stroustrup、Herb Sutter 等頂尖 C+ 專家建立的一份 C++ 指南、規則及最佳實踐。旨在幫助大家正確、高效地使用「現代 C++」。

這份指南側重於介面、資源管理、記憶體管理、並行等 High-level 主題。遵循這些規則可以最大程度地保證靜態型別安全,避免資源洩露及常見的錯誤,使得程式執行得更快、更好。

R.smart:智慧指標

  • R.20:使用 unique_ptr 或 shared_ptr 來表示所有權
  • R.21:除非需要共用所有權,否則優先使用 unique_ptr 而不是 shared_ptr
  • R.22:使用 make_shared() 建立 shared_ptr
  • R.23:使用 make_unique() 建立 unique_ptr
  • R.24:使用 std::weak_ptr 打破 shared_ptr 的迴圈參照
  • R.30: 僅在需要明確表示生命週期語意時才將智慧指標作為引數傳遞
  • R.31: 如果你使用的是非 std 智慧指標,遵循 std 的基本模式
  • R.32: 將形參宣告為 unique_ptr<widget> 來表明函數對 widget 的所有權
  • R.33: 將形參宣告為 unique_ptr<widget>& 來表明函數對 widget 的重新賦值語意
  • R.34: 將形參宣告為 shared_ptr<widget> 來明確表明函數共用所有權
  • R.35: 將形參宣告為 shared_ptr<widget>& 來表明函數可能重新賦值共用指標
  • R.36: 將形參宣告為 const shared_ptr<widget>& 來表明它可能增加物件的參照計數
  • R.37: 不要傳遞從「智慧指標別名」取得的指標或參照

R.20:使用 unique_ptr 或 shared_ptr 來表示所有權

可以避免資源洩露

void f()
{
    // bad, p1 可能洩露
    X* p1 { new X };
    // good, 獨佔所有權
    auto p2 = make_unique<X>();
    // good, 共用所有權
    auto p3 = make_shared<X>();
}

程式碼檢查

  • 如果 new 的結果賦給了裸指標,給出警告
  • 如果函數返回的「擁有指標」賦給了裸指標,給出警告

R.21:除非需要共用所有權,否則優先使用 unique_ptr 而不是 shared_ptr

unique_ptr 在概念上更簡單、可預測(知道何時發生解構),而且更快(無需隱式維護使用計數)。

反面例子

增加和維護了一個不必要的參照計數

void f()
{
    shared_ptr<Base> base = make_shared<Derived>();
    // 在本地使用 base,不需要拷貝,參照計數永遠不會超過 1
} // 銷燬 base

例子

void f()
{
    unique_ptr<Base> base = make_unique<Derived>();
    // 在本地使用 base
} // 銷燬 base

程式碼檢查

如果一個函數使用了一個在函數內部分配的 shared_ptr,但從不返回該 shared_ptr 或將其傳遞給形參為 shared_ptr& 的函數,則給出警告。建議使用 unique_ptr 替代。

R.22:使用 make_shared() 建立 shared_ptr

make_shared 提供了更簡潔的構造語句。而且 make_shared 有機會將參照計數儲存在其關聯物件的相鄰位置。

範例

shared_ptr<X> p1 { new X{2} }; // BAD
auto p = make_shared<X>(2); // GOOD

使用 make_shared() 版本只提到了 X 一次,所以它通常比使用顯式 new 的版本更短,同時也更快。

程式碼檢查

如果一個 shared_ptr 是由 new 的結果構造而不是 make_shared,則給出警告。

R.23:使用 make_unique() 建立 unique_ptr

make_unique 提供了更簡潔的構造語句。它還確保在複雜表示式中的異常安全。

make_unique 是 C++14 引入的,而 make_shared 在 C++11 就已經有了

範例

// 可行,但重複出現 Foo
unique_ptr<Foo> p {new Foo{7}};
// 更好的寫法:避免重複的 Foo
auto q = make_unique<Foo>(7);

程式碼檢查

如果一個 unique_ptr 是由 new 的結果構造而不是 make_unique,則給出警告。

R.24:使用 std::weak_ptr 打破 shared_ptr 的迴圈參照

shared_ptr 依賴於參照計數,而回圈結構的參照計數永遠不為 0,因此我們需要一種機制來打破迴圈結構。

例子

#include <memory>

class bar;

class foo {
public:
  explicit foo(const std::shared_ptr<bar>& forward_reference)
    : forward_reference_(forward_reference)
  { }
private:
  std::shared_ptr<bar> forward_reference_;
};

class bar {
public:
  explicit bar(const std::weak_ptr<foo>& back_reference)
    : back_reference_(back_reference)
  { }
  void do_something()
  {
    if (auto shared_back_reference = back_reference_.lock()) {
      // 使用*shared_back_reference
    }
  }
private:
  std::weak_ptr<foo> back_reference_;
}

Herb Sutter:有很多人說「打破迴圈參照」,但我認為「臨時共用所有權」更準確。

Bjarne Stroustrup:「打破迴圈」是必須要做的,「臨時共用所有權」是你如何「打破迴圈」。你可以通過使用另一個 shared_ptr 來「臨時共用所有權」。(這裡不太好翻譯,貼出原文:breaking cycles is what you must do; temporarily sharing ownership is how you do it. You could 「temporarily share ownership」 simply by using another shared_ptr

程式碼檢查

如果可以靜態地檢測到迴圈(可能無法實現),就不需要 weak_ptr。


R.30: 僅在需要明確表示生命週期語意時才將智慧指標作為引數傳遞

參見 F.7: 對於一般用途,使用 T* 或 T& 引數而不是智慧指標

R.31: 如果你使用的是非 std 智慧指標,遵循 std 的基本模式

任何過載了一元 *-> 運運算元的型別(包括模板及特化模板)都被認為是智慧指標:

  • 如果它是可複製的,則應被視為 shared_ptr
  • 如果它不可複製,則應被視為 unique_ptr

反面例子

// Boost 的 intrusive_ptr
#include <boost/intrusive_ptr.hpp>
void f(boost::intrusive_ptr<widget> p)
{
    p->foo();
}

// Microsoft 的 CComPtr
#include <atlbase.h>
void f(CComPtr<widget> p)
{
    p->foo();
}

p 是一個共用指標,但在這裡沒有用到它的共用性,並且通過值傳遞會導致效能下降;函數只有在需要參與 widget 的生命週期管理的時候使用智慧指標。否則,應該使用 widget& 或者 widget*(如果實參可能是 nullptr)作為形參。

這些第三方/自定義的智慧指標與 std::shared_ptr 概念一致,因此接下來的規則也適用於其他型別的第三方和自定義智慧指標。這對於排查常見的智慧指標錯誤、效能問題時非常有用。

R.32: 將形參宣告為 unique_ptr<widget> 來表明函數對 widget 的所有權

R.33: 將形參宣告為 unique_ptr<widget>& 來表明函數對 widget 的重新賦值語意

以這種方式使用 unique_ptr 既可以起到 self-documented 的作用,又可以強制執行函數呼叫的所有權轉移或重新賦值(reseat)語意。

注意:「重新賦值」(reseat)的意思是:使指標或智慧指標指向不同的物件。

例子

// 接受 widget 的所有權
void sink(unique_ptr<widget>);
// 僅使用 widget
void uses(widget*);
// 可能重新賦值指標
void reseat(unique_ptr<widget>&);

反面例子

// 通常不是你想要的
void thinko(const unique_ptr<widget>&);

程式碼檢查

  • 如果一個函數通過左值參照接受 unique_ptr<T> 引數,並且在某條程式碼路徑上沒有對其進行賦值或呼叫 reset(),則給出警告。建議改為接受 T*T&
  • 如果一個函數通過 const 參照接受 unique_ptr<T> 引數,則給出警告。建議改為接受 const T* 或 const T&。

R.34: 將形參宣告為 shared_ptr<widget> 來明確表明函數共用所有權

R.35: 將形參宣告為 shared_ptr<widget>& 來表明函數可能重新賦值共用指標

R.36: 將形參宣告為 const shared_ptr<widget>& 來表明它可能增加物件的參照計數

注:「重新賦值」(reseat)的意思是:使參照或智慧指標指向不同的物件。

例子

class WidgetUser
{
public:
    // WidgetUser 將共用 widget 的所有權
    explicit WidgetUser(std::shared_ptr<widget> w) noexcept:
        m_widget{std::move(w)} {}
    // ...
private:
    std::shared_ptr<widget> m_widget;
};
void ChangeWidget(std::shared_ptr<widget>& w)
{
    // 改變呼叫者的 widget
    w = std::make_shared<widget>(widget{});
}
// 共用所有權,增加參照計數
void share(shared_ptr<widget>);
// 可能重新賦值指標
void reseat(shared_ptr<widget>&);
// 可能增加參照計數
void may_share(const shared_ptr<widget>&);

程式碼檢查

  • 如果函數通過左值參照接受 shared_ptr<T> 引數,並且在某條程式碼路徑上沒有對其進行賦值或呼叫 reset(),則給出警告。建議改為接受 T* 或 T&。
  • 如果函數通過值傳遞或 const 參照接受 shared_ptr<T> 引數,並且在某條程式碼路徑上沒有將其複製或移動到另一個 shared_ptr,則給出警告。建議改為接受 T* 或 T&。
  • 如果函數通過右值參照接受 shared_ptr<T> 引數,建議改為值傳遞。

R.37: 不要傳遞從「智慧指標別名」取得的指標或參照

違反本規則是導致丟失參照計數、產生懸空指標的首要原因。函數應優先考慮傳遞裸指標或參照到呼叫鏈的下游。在呼叫樹的頂部,從智慧指標取得裸指標或參照時,需要確保智慧指標在呼叫樹內部不會無意中被重置或重新賦值。

注:有時需要對智慧指標進行本地拷貝,以保證在函數呼叫樹的整個期間保持物件不被釋放。

例子

// 全域性(靜態或堆),或者本地智慧指標的別名
shared_ptr<widget> g_p = ...;

void f(widget& w)
{
    g();
    use(w);  // A
}

void g()
{
    // 如果這是最後一個指向 widget 的 shared_ptr,銷燬 widget
    g_p = ...;
}

下面的程式碼無法通過 code review

void my_code()
{
    // BAD: 傳遞一個「從非本地智慧指標獲得的指標或參照」,可能在 f(或 f 呼叫的函數)中不小心被重置
    f(*g_p);

    // BAD: 原因同上,只是作為 "this" 指標傳遞
    g_p->func();
}

解決辦法:拷貝一份副本,確保函數呼叫樹整個期間參照計數不為 0

void my_code()
{
    // 參照計數增加了 1,可以覆蓋整個函數執行及呼叫樹
    auto pin = g_p;

    // GOOD: 傳遞一個從「本地非別名的指標指標」獲得的指標或參照
    f(*pin);

    // GOOD: 同上
    pin->func();
}

程式碼檢查

  • 如果在函數呼叫過程中使用的指標或參照是從一個非原生的 shared_ptr 或 unique_ptr 取得,或者從一個本地、但可能是別名的智慧指標取得,給出警告。
  • 如果是 shared_ptr,建議儲存一個本地副本,然後從該副本獲取指標或參照。