Effective Modern C++ 條款22 當使用Pimpl Idiom時,在實現文件中定義特殊成員函數

當使用Pimpl Idiom時,在實現文件中定義特殊成員函數

如果你曾經與過長的編譯時間鬥爭過,你應該熟悉Pimpl(“pointer to implementation”) Idiom。這項技術通過把類中的成員變量替換成指向一個實現類(或結構體)的指針,成員變量被放進單獨的實現類中,然後通過該指針間接獲取原來的成員變量。例如,Widget是這樣的:

class Widget {      // 在頭文件“widget.h”中
public:
    Widget();
    ...
private:
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;     // Gadget是某個用戶定義的類型
};

因爲Widget的成員變量有std::stringstd::vector和Gadget,那麼這些類型的頭文件在Widget編譯時必須出現,這意味Widget的用戶必須包含,和“gadget.h”。這些增加的頭文件會增加Widget用戶的編譯時間,而且這使得用戶依賴於這些頭文件,即如果某個頭文件的內容被改變了,Widget的用戶就要重新編譯。標準庫頭文件和不會經常改變,但是“gadget.h”可能會經常修改。

在C++98中使用Pimpl Idiom,讓Widget的成員變量替換成一個指向結構體的原生指針,這個結構體只被聲明,沒有被實現:

class Widget {     // 依然在頭文件“widget.h”中
public:
    Widget();
    ~Widget();
    ...
private:
    struct Impl;    // 聲明實現類
    Impl *pImpl;    // 聲明指針指向實現類
};

因爲Widget不再提起std::stringstd::vector和Gadget類型,所以Widget的用戶不再需要“#include”那些頭文件了。那樣加快了編譯速度,也意味着當頭文件內容改變時,Widget的用戶不會受到影響。

一個被聲明,卻沒定義的類型稱爲不完整類型(incomplete type)。Widget::Impl就是這樣的類型,不完整類型能做的事情很少,不過可以聲明一個指針指向它們,Pimpl Idiom就是利用了這個特性。

Pimpl Idiom的第一部分是聲明一個指向不完整類型的指針作爲成員變量,第二部分是動態分配和回收一個裝有原來成員變量的對象,分配和回收的代碼要寫在實現文件,例如,對於Widget,寫在“Widget.cpp”中:

#include "widget.h"      // 在實現文件“widget.cpp”
#include "gadget.h"
#include <string>
#include <vector>
                              `
struct Widget::Impl {   // 用原來對象的成員變量來定義實現類
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};
                            `
Widget::Widget() : pImpl(new Impl) {}  // 爲Widget對象動態分配成員變量
                            `
Widget::~Widget() { delete pImpl; }  // 銷燬這個對象的成員變量

在這裏,我展示了“#include”指令,只爲了說明所有對頭文件的依賴(即std::stringstd::vector和Gadget)依然存在。不過呢,依賴已經從“widget.h”(Widget用戶可見的和使用的)轉移到“widget.cpp”(只有Widget的實現者才能看見和使用)。不過這個代碼是動態分配的,需要在Widget的析構函數中回收分配的對象。

不過我展示的是C++98的代碼,這代碼充滿着腐朽的臭味。它使用原生的指針,原生的new和原生的delete,反正就是太原生了。這章節(條款18~22)的建議是智能指針比原生指針好很多很多,那麼如果想要的是在Widget構造中動態分配Widget::Impl對象,而且Widget銷燬時銷燬Widget::Impl,那麼std::unique_ptr(看條款18)是一個精確的工具啊。在頭文件中用**std::unique_ptr替代原生指針pImpl:

class Widget {       // 在“widget.h”
public:
    Widget();
    ...
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;   // 用智能指針代替原生指針
};

然後這是實現文件:

#include "widget.h"        // 在“widget.cpp”
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {       // 如前
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget()                           // 見條款21
: pImpl(std::make_unique<Impl>())          // 藉助std::make_unique創建
{}                                         // std::unique_ptr

你可能發現Widget的析構函數不見了,那是因爲我們沒有代碼要寫進析構函數,std::unique_ptr自動銷燬指向的對象當它(指的是std::unique_ptr)被銷燬時,因此不需要我們自己刪除什麼東西。這是智能指針吸引人的一個地方:消除手動刪除資源的需要。

上面的代碼是可以編譯的,但是啊,用戶這樣平常地使用就無法通過編譯:

#include "widget.h"

Widget w;      // 錯誤

你獲得的錯誤信息取決於你使用的編譯器,不過內容一般會提到對不完整類型使用了sizeofdelete。你在構造時根本沒有使用這些操作。

這個使用std::unique_ptr的Pimpl Idiom產生的明顯失敗讓人感到驚慌,因爲(1)std::unique_ptr的說明是支持不完整類型的,而且(2)Pimpl Idiom中的std::unique_ptr的使用是最常規的使用。幸運的是,讓這代碼工作很容易,不過這需要理解導致這個問題的原理。

這問題的產生是由於w被銷燬時(例如,離開作用域)生成的代碼,在那個時刻,它的析構函數被調用,而在我們的實現文件中,我們沒有聲明析構函數。根據編譯器生成特殊成員函數的普通規則(看條款17),編譯器會爲我們生成一個析構函數。在那個析構函數中,編譯器調用了Widget成員變量pImpl的析構函數。pImpl是個std::unique_ptr<Widget::Impl>對象,即一個使用默認刪除器的std::unique_ptr,而std::unique_ptr的默認刪除器是對原生指針使用delete。雖說優先使用的delete,但默認刪除器通常先會使用C++11的static_asssert來確保原生指針不會指向不完整類型。當編譯器爲Widget生成析構函數時,通常會遇到static_assert失敗,而這通常會導致錯誤信息。這信息與w在哪裏銷燬有關係,因爲Widget的析構函數,和所有的特殊成員函數一樣,都是隱式內聯的。這信息通常指向w對象創建的那一行,因爲源代碼中的顯式創建纔會導致後來的隱式銷燬。

要解決這個辦法呢,你只需確保在生成std::unique_ptr的析構函數之前,Widget::Impl是個完整類型。只有當編譯器看見它的實現,才能變爲完整類型,然後Widget::Impl的定義在“widget.cpp”中,編譯成功的關鍵是:在編譯器看到Widget析構函數體(即編譯器生成銷燬std::unique_ptr成員變量的地方)之前,“widget.h”中的Widget::Impl就已經定義了。

這樣做其實很簡單,在“widget.h”中聲明析構函數,但是不在那裏定義:

class Widget {            // 如前,在"widget.h"
public:
    Widget();
    ~Widget();            // 只是聲明
    ...
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

在“widget.cpp”中,定義了Widget::Impl之後才定義析構函數:

#include "widget.h"               // 如前, 在"widget.cpp"
#include "gadget.h
#include <string>
#include <vector>

struct Widget::Impl {            // 如前, 定義Widget::Impl
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget()       // 如前
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() {}     // 定義析構函數

這樣的話代碼就可以工作了,這個解決辦法打的字最少,不過如果你想強調編譯器生成的析構函數是正常工作的,那樣你聲明析構函數的唯一理由是讓析構的定義在Widget的實現文件中生成,那麼你可以使用“= default”:
Widget::~Widget() = default; // 和上面的效果一樣

使用Pimpl Idiom的類天生就是支持移動操作的候選人,因此編譯器生成的移動操作符合我們的需要:移動類內部的std::unique_ptr。就像條款17所說,聲明瞭Widget析構函數會阻止編譯器生成移動操作,所以如果你想要支持移動,你必須聲明這些函數。倘若編譯器生成的移動操作的行爲是正確的,你可能會這樣實現:

class Widget {          // 在“widget.h”
public:
    Widget();
    ~Widget();
    Widget(Widget&& rhs) = default;                     // 正確的想法
    Widget& operator=(Widget&& rhs) = default;          // 錯誤的代碼
    ...
private:
    struct Impl;      // 如前
    std::unique_ptr<Impl> pImpl;
};

這樣會導致與未聲明析構函數的類一樣的問題,同樣的原因。編譯器生成的移動賦值操作符需要銷燬pImpl指向的對象(即被移動賦值的Widget要先銷燬舊的),但在頭文件中,pImpl指向的是不完整類型。而移動構造函數的情況不同,移動構造的問題是:編譯器通常會生成銷燬pImpl的代碼以防移動操作拋出異常,然後銷燬pImpl需要Impl是完整類型。

因爲這個問題和之前的相同,所以解決辦法是把移動操作的定義放到實現文件中:

class Widget {                // 仍在“widget.h”
public:
    Widget();
    ~Widget();
    Widget(Widget&& rhs);              // 只聲明
    Widget& operator=(Widget&& rhs);   // 只聲明
    ...
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

 ---------------------------------------------------------

#include "widget.h"            // 在“widget.cpp”
...                            // 如前
struct Widget::Impl { ... };    //如前

Widget::Widget()                           //如前
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() {}                  // 如前

Widget::Widget(Widget&& rhs) = default;          // 定義
Widget& Widget::operator=(Widget&& rhs) = default;       // 定義

Pimpl Idiom是在類實現和類用戶之間減少編譯依賴的一個方法,不過,使用這個機制不會改變類代表的東西。最開始的Widget類的成員變量有std::stringstd::vector和Gadget,然後我們假設Gadget像string和vector那樣可以被拷貝,那麼,爲Widget實現拷貝操作是有意義的。我們必須自己寫這些函數,因爲(1)編譯器不會爲含有隻可移動類型(例如std::unique_ptr)的類生成拷貝操作,(2)就算編譯器生成代碼,生成的代碼也只是拷貝std::unique_ptr(即表現爲shallow copy),而我們想要拷貝的是指向的內容(即表現爲deep copy)。

就像老規矩那樣,我們把函數在頭文件聲明,在實現文件定義:

class Widget {               // 在“widget.h”
public:
    ...                      // 其他函數,和以前一樣
    Widget(const Widget& rhs);                 // 只是聲明
    Widget& operator=(const Widget& rhs);      // 只是聲明
private:
    struct Impl;        // 如前
    std::unique_ptr<Impl> pImpl;
};

------------------------------------------------------------

#include "widget.h"            // 在“widget.cpp”
...                           // 其他頭文件和以前一樣
struct Widget::Impl { ... };        // 如前

Widget::~Widget() = default;      // 其他函數也和以前一樣

Widget::Widget(const Widget& rhs)                    // 拷貝構造
: pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}

Widget& Widget::operator=(const Widget& rhs)        // 拷貝賦值
{
    *pImpl = *rhs.pImpl;
    return *this;
}

兩個函數都是依舊慣例實現的,我們都只是簡單地把Impl結構從源對象(rhs)拷貝到目的對象(*this),比起把Impl的變量單獨拷貝,我們利用了編譯器會爲Impl生成拷貝操作這個優勢,這些操作會自動地逐一拷貝,因此,我們通過調用編譯器生成的Widget::Impl的拷貝操作來實現Widget的拷貝操作。在拷貝構造中,請注意我們採用了條款21的建議,比起直接使用new,更偏向於使用std::make_unique

爲了實現Pimpl Idiom,我們使用了std::unique_ptr這個智能指針,因爲對象中(指的是Widget)的pImpl指針獨佔實現對象(指的是Widget::Impl)的所有權。不過,我們用std::shared_ptr代替std::unique_ptr作爲pImpl的類型會是很有趣的,我們會發現本條款的內容不再適用,不再需要在Widget中聲明析構函數(還有在實現文件定義析構),編譯器會很開心的生成移動操作(跟我們期待的操作一樣)。代碼是這樣:

class WIdget {             // 在“widget.h”
public:
    Widget();
    ...                  // 不用聲明析構函數和移動操作
private:
    struct Impl;
    std::shared_ptr<Impl> pImpl          // 用的是std::shared_ptr
};

這是用戶的代碼(已經#include “widget.h”):
Widget w1;

auto w2(std::move(w1)); // 移動構造w2

w1 = std::move(w2); // 移動賦值w1

每行代碼都可以編譯,並且運行得我們期望那樣:w1會被默認構造,它的值被移動到w2,然後那個值又被移動回w1,然後w1和w2將會銷燬(因此指向的Widget::Impl對象被銷燬)。

在這裏,std::unique_ptrstd::shared_ptr之間行爲的不同來源於它們對自定義刪除器的支持不同。對於std::unique_ptr,刪除器的類型是智能指針類型的一部分,這讓編譯器生成更小的運行時數據結構和更快的運行時代碼成爲可能。這高效導致的後果是當使用編譯器生成的特殊成員函數時,指向的類型必須是完整類型。對於std::shared_ptr,刪除器的類型不是智能指針類型的一部分,這在運行時會導致更大的數據結構和更慢的代碼,但是當使用編譯器生成的特殊成員函數時,指向的類型不需要是完整類型。

對於Pimpl Idiom,不需要真的去權衡std::unique_ptrstd::shared_ptr的特性,因爲Widget和Widget::Impl之間的關係是獨佔所有權關係,所以std::unique_ptr更適合這份工作,但是呢,值得知道在其他情況下(共享所有權的情況,std::shared_ptr是個適合的設計選擇),不需要像std::unique_ptr那樣費勁心思處理函數定義。


總結

需要記住的3點:

  • Pimpl Idiom通過減少類用戶和類實現之間的編譯依賴來減少編譯時間。
  • 對於類型爲std::unique_ptr的pImpl指針,在頭文件中聲明特殊成員函數,但在實現文件中實現它們。儘管編譯器默認生成的函數實現可以滿足需求,我們也要這樣做。
  • 上一條的建議適用於std::unique_ptr,不適用於std::shared_ptr
發佈了18 篇原創文章 · 獲贊 43 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章