Item 22:使用 Pimpl Idiom 時,於實作檔中定義特殊成員函式 (When Using the Pimpl Idiom, Define Special Member Functions in the Implementation File)

Overview Table

主題 重點
Pimpl Idiom 目的 以「指向實作類別的指標」取代資料成員,降低編譯相依性、縮短 build time
incomplete type 只宣告未定義的型別;能做的事極少,但宣告指向它的指標是合法的
C++11 作法 std::unique_ptr<Impl> 取代 raw pointer,自動管理 Impl 物件生命週期
關鍵陷阱 不宣告解構子會編譯失敗:compiler 生成的解構子需要 Implcomplete type
核心解法 特殊成員函式在 header 宣告、在 實作檔(Impl 定義之後)以 = default 定義
move 支援 宣告解構子會抑制 move operations(Item 17),需自行宣告並同樣移到實作檔定義
copy 支援 必須手寫深拷貝std::unique_ptr 是 move-only,compiler 不會生成 copy)
std::shared_ptr 例外 換成 std::shared_ptr<Impl> 則本 Item 建議不適用(deleter 型別不屬於指標型別)

Pimpl Idiom:用指標切斷編譯相依

傳統寫法中,Widget 的資料成員型別(std::stringstd::vectorGadget)必須在 header 可見,客戶端因此被迫 include 這些 header;只要 gadget.h 改動,所有客戶端都要重編。

Pimpl("pointer to implementation")分兩步:

  1. Part 1:在類別中宣告一個指向 incomplete type(struct Impl;)的指標成員。
  2. Part 2Impl 物件的動態配置與釋放放在實作檔中完成。
// widget.h —— 客戶端只看得到這裡
class Widget {
public:
  Widget();
private:
  struct Impl;                   // 只宣告:incomplete type
  std::unique_ptr<Impl> pImpl;   // 指向實作的智慧指標(Item 18)
};
// widget.cpp —— 相依性全部搬到這裡
#include "widget.h"
#include "gadget.h"              // 對 gadget.h 的相依只存在於實作檔
#include <string>
#include <vector>

struct Widget::Impl {            // Impl 在此成為 complete type
  std::string name;              // 原本在 Widget 裡的資料成員
  std::vector<double> data;
  Gadget g1, g2, g3;
};

Widget::Widget()
: pImplmake_unique<Impl>()  // 依 Item 21 用 make 函式
{}
相依方向(誰 include 誰):

  client.cpp ──► widget.h ──X──► gadget.h / <string> / <vector>
                    │              ▲
                    │   相依被搬到  │
                    └─► widget.cpp ┘   gadget.h 改動 → 只重編 widget.cpp,
                                       客戶端不受影響 → build time 下降

為什麼會編譯失敗:incomplete type 與生成的解構子

上面的 header 若不宣告解構子,客戶端寫 Widget w; 會編譯失敗(錯誤訊息通常提到對 incomplete type 使用 sizeofdelete)。原因鏈如下:

w 離開 scope → 呼叫 ~Widget()(compiler 生成、implicitly inline)
            → 內含銷毀成員 pImpl 的程式碼
            → std::unique_ptr 的 default deleter 執行 delete
            → deleter 內的 static_assert 檢查指向型別必須 complete
            → 在 widget.h 中 Impl 是 incomplete type → static_assert 失敗

解法:讓「生成解構 std::unique_ptr 程式碼」的位置落在 Impl 定義之後——即在 header 只宣告解構子,在實作檔中定義:

// widget.h
class Widget {
public:
  Widget();
  ~Widget();                     // 只宣告,不定義
private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};

// widget.cpp(在 struct Widget::Impl { … }; 之後)
Widget::~Widget() = default;     // 此處 Impl 已是 complete type
Tip

= default 定義可明確表達「compiler 生成的版本就是對的,宣告它只是為了把定義位置移到實作檔」。即使預設實作完全可用,也必須這麼做——這正是本 Item 標題的意義。

Move 與 Copy:同樣的儀式

Move operations:宣告了解構子後,compiler 不再生成 move operations(Item 17),想支援 move 就得自行宣告。但在 header 內寫 = default錯誤寫法——move assignment 需先銷毀 pImpl 所指物件;move constructor 則因例外處理路徑中可能需銷毀 pImpl,兩者都要求 Impl 為 complete type。正確作法一樣是「header 宣告、實作檔 = default」:

// widget.h
Widget(Widget&& rhs);                       // 只宣告
Widget& operator=(Widget&& rhs);

// widget.cpp(Impl 定義之後)
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;

Copy operations:必須手寫深拷貝,因為 (1) std::unique_ptr 是 move-only 型別,compiler 不會為含有它的類別生成 copy operations;(2) 就算生成,也只會淺拷貝指標本身,而我們要的是複製指標所指的 Impl

// widget.cpp
Widget::Widget(const Widget& rhs)                 // 深拷貝:copy ctor
: pImplmake_unique<Impl>(*rhs.pImpl)       // 複製整個 Impl
{}

Widget& Widget::operator=(const Widget& rhs)      // 深拷貝:copy assignment
{
  *pImpl = *rhs.pImpl;    // 借用 Impl 的 compiler 生成 copy 逐欄複製
  return *this;
}

std::unique_ptr vs std::shared_ptr:為何建議只適用前者

比較 std::unique_ptr<Impl> std::shared_ptr<Impl>
deleter 型別 指標型別的一部分 不是指標型別的一部分
執行期資料結構 較小、較快 較大、稍慢
生成特殊成員函式時 指向型別必須 complete 指向型別不必 complete
Pimpl 需要的儀式 解構子/move 都要「header 宣告+實作檔定義」 完全不需要,直接用即可
Warning

「Pimpl 一律要把特殊成員函式移到實作檔」是針對 std::unique_ptr 的規則;若 pImpl 改用 std::shared_ptr,不宣告解構子也能編譯,move/copy 也照常生成。但這不代表該改用 std::shared_ptr——WidgetWidget::Impl獨占擁有關係,std::unique_ptr 才是語意正確的工具(Item 18)。

Exam/Test Patterns

情境關鍵字 答案
「Pimpl Idiom 的目的?」 降低類別客戶端與實作間的編譯相依性,縮短 build time
std::unique_ptr<Impl> pImpl、未宣告解構子、Widget w; 編不過」 生成的解構子對 incomplete type 執行 delete,default deleter 的 static_assert 失敗
「錯誤訊息提到 sizeof/delete applied to incomplete type」 特殊成員函式定義位置錯誤 → 移到實作檔中 Impl 定義之後
「header 內 Widget(Widget&&) = default; 為何仍編不過?」 = default 於 header 展開仍需 complete type;move assignment 要銷毀舊 pImpl,move ctor 例外路徑也要銷毀 pImpl
「Pimpl 類別如何支援複製?」 手寫深拷貝:copy ctor 用 std::make_unique<Impl>(*rhs.pImpl),copy assignment 用 *pImpl = *rhs.pImpl
「換成 std::shared_ptr<Impl> 會怎樣?」 一切照常編譯執行,不需宣告任何特殊成員函式(deleter 型別不屬於指標型別)
「為何 Pimpl 仍應選 std::unique_ptr?」 WidgetImpl獨占擁有,語意正確且更小更快
「宣告解構子後 move 消失了」 Item 17 規則:user-declared destructor 抑制 move 生成 → 自行宣告並於實作檔 = default