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 生成的解構子需要 Impl 為 complete 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::string、std::vector、Gadget)必須在 header 可見,客戶端因此被迫 include 這些 header;只要 gadget.h 改動,所有客戶端都要重編。
Pimpl("pointer to implementation")分兩步:
- Part 1:在類別中宣告一個指向 incomplete type(
struct Impl;)的指標成員。 - Part 2:
Impl物件的動態配置與釋放放在實作檔中完成。
// 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 使用 sizeof 或 delete)。原因鏈如下:
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
用 = 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 宣告+實作檔定義」 | 完全不需要,直接用即可 |
「Pimpl 一律要把特殊成員函式移到實作檔」是針對 std::unique_ptr 的規則;若 pImpl 改用 std::shared_ptr,不宣告解構子也能編譯,move/copy 也照常生成。但這不代表該改用 std::shared_ptr——Widget 與 Widget::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?」 |
Widget 對 Impl 是獨占擁有,語意正確且更小更快 |
| 「宣告解構子後 move 消失了」 | Item 17 規則:user-declared destructor 抑制 move 生成 → 自行宣告並於實作檔 = default |
Related Notes
- 05-Smart-Pointers/01-Unique-Ptr ——
std::unique_ptr獨占擁有語意與 deleter 型別屬於指標型別的效率設計(Item 18) - 05-Smart-Pointers/02-Shared-Ptr ——
std::shared_ptr的 deleter 不影響指標型別,因而不需 complete type(Item 19) - 05-Smart-Pointers/04-Make-Unique-And-Make-Shared —— 建構
pImpl與深拷貝時優先使用std::make_unique(Item 21) - 04-Moving-To-Modern-Cpp/11-Special-Member-Function-Generation —— 宣告解構子為何抑制 move operations 生成(Item 17)
- 05-Smart-Pointers/Practice-Smart-Pointers —— 本章練習題