Item 22: 當使用Pimpl機制時,在實現文件中給出特殊成員函數的實現

本文翻譯自《effective modern C++》,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!

博客已經遷移到這裏啦

如果你曾經同過久的編譯時間鬥爭過,那麼你肯定對Pimpl(”point to implementation”,指向實現)機制很熟悉了。這種技術讓你把類的數據成員替換成指向一個實現類(或結構)的指針,把曾經放在主類中的數據成員放到實現類中去,然後通過指針間接地訪問那些數據成員。舉個例子,假設Widget看起來像這個樣子:

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

因爲Widget的數據成員包含std::string,std::vector和Gadget類型,這些類型的頭文件必須出現在Widget的編譯中,這就意味着Widget的客戶必須#include <string>,<vector>,和gadget.h。這些頭文件增加了Widget客戶的編譯時間,加上它們使得這些客戶依賴於頭文件的內容。如果頭文件的內容改變了,Widget的客戶必須重編譯。標準頭文件<string><vector>不會經常改變,但是gadget.h有頻繁更替版本的傾向。

在C++98中應用Pimpl機制需要在Widget中把它的數據成員替換成一個原始指針,指向一個已經被聲明卻還沒有定義的結構:

class Widget{                       // 還是在頭文件"widget.h"中
public:
    Widget();
    ~Widget();                     // 看下面的內容可以得知析構函數是需要的
    ...

private:
    struct Impl;                    // 聲明一個要實現的結構
    Impl *pImpl;                    // 並用指針指向它
};

因爲Widget不在涉及類型std::string, std::vector和Gadget,所以Widget的客戶不再需要#include這些類型的頭文件了。這加快了編譯速度,並且這也意味着如果頭文件有了一些變化,Widget的客戶是不受影響的。

一個被聲明卻還沒有定義的類型被稱爲一個不完整類型(incomplete type)。Widget::Impl就是這樣的類型。對於一個不完整類型,你能做的事情很少,但是定義一個指針指向它們是可以的。Pimpl機制就是利用了這一點。

Pimpl機制的第一步就是聲明一個數據成員指向一個不完整類型。第二步是動態分配和歸還這個類型的對象,這個對象持有曾經在源類(沒使用Pimpl機制時的類)中的數據成員。分配和歸還代碼寫在實現文件中,比如,對於Widget來說,就在widget.cpp中:

#include "widget.h"             //在實現文件"widget.cpp"中
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl{            // 帶有之前在Widget中的數據成員的
    std::string name;           // Widget::Impl的定義
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget()                // 分配Widget對象的數據成員
: pImpl(new Impl)   
{}

Widget::~Widget()              // 歸還這個對象的數據成員
{ delete pImpl; }

這裏我顯示的#include指令表明了,總的來說,對std::string, std::vector, 和Gadget的頭文件的依賴性還是存在的,但是,這些依賴性已經從widget.h(這是對Widget客戶可見以及被他使用的)轉移到了widget.cpp(這是隻對Widget的實現者可見以及只被實現者所使用的)。我已經高亮了代碼中動態分配和歸還Impl對象的地方(譯註:就是new Impl和 delete pImpl)。爲了當Widget銷燬的時候歸還這個對象,我們就需要使用Widget的析構函數。

但是我顯示給你的是C++98的代碼,並且這散發着濃濃的舊時代的氣息。它使用原始指針和原始的new,delete,怎麼說呢,就是太原始了。這一章的主題是智能指針優於原始指針,所以如果我們想在Widget構造函數中動態分配一個Widget::Impl對象,並且讓它的銷燬時間和Widget一樣,std::unique_ptr(看Item 18)這個工具完全符合我們的需要。把原始pImpl指針替換成std::unique_ptr在頭文件中產生出這樣的代碼:

class Widget{
public:
    Widget();
    ...

private:
    struct Impl;                            // 使用智能指針來替換原始指針
    std::unique_ptr<Impl> pImpl;
};

然後在實現文件中是這樣的:

#include "widget.h"                 
#include "gadget.h"
#include <string>
#include <vector>

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

Widget::Widget()                            // 通過std::make_unique
: pImpl(std::make_unique<Impl>())           // 來創建一個std::unique_ptr
{}                                          

你應該已經注意到Widget的析構函數不存在了。這是因爲我們沒有任何代碼要放到它裏面。當std::unique_ptr銷燬時,它自動銷燬它指向的對象,所以我們自己沒必要再delete任何東西。這是智能指針吸引人的一個地方:它們消除了手動釋放資源的需求。

這段代碼能編譯通過,但是,可悲的是,客戶無法使用:

#include "widget.h"

Widget w;                   // 錯誤

你收到的錯誤信息取決於你使用的編譯器,但是它通常涉及到把sizeof或delete用到一個不完整類型上。這些操作都不是你使用這種類型(不完整類型)能做的操作。

使用std::unique_ptr造成的這種表面上的錯誤是很令人困擾的,因爲(1)std::unique_ptr聲稱自己是支持不完整類型的,並且(2)Pimpl機制是std::unique_ptr最常見的用法。幸運的是,讓代碼工作起來是很容易的。所有需要做的事就是理解什麼東西造成了這個問題。

問題發生在w銷燬的時候產生的代碼(比如,離開了作用域)。在這個時候,它的析構函數被調用。在類定義中使用std::unique_ptr,我們沒有聲明一個析構函數,因爲我們不需要放任何代碼進去。同通常的規則(看Item 17)相符合,編譯器爲我們產生出析構函數。在析構函數中,編譯器插入代碼調用Widget的數據成員pImpl的析構函數。pImpl是一個std::unique_ptr,也就是一個使用了默認deleter的std::unique_ptr。默認deleter是一個函數,這個函數在std::unqieu_ptr中把delete用在原始指針上,但是,實現中,常常讓默認deleter調用C++11的static_assert來確保原始指針沒有指向一個不完整類型。然後,當編譯器爲Widget w產生析構函數的代碼時,它就碰到一個失敗的static_assert,這也就是導致錯誤消息的原因了。這個錯誤消息應該指向w銷燬的地方,但是因爲Widget的析構函數和所有的“編譯器產生的”特殊成員函數一樣,是隱式內聯的。所以錯誤消息常常指向w創建的那一行,因爲它的源代碼顯式創建的對象之後會導致隱式的銷燬調用。

調整起來很簡單,在widget.h中聲明Widget的的析構函數,但是不在這定義它:

class Widget {
public:
    Widget();
    ~Widget();                         // 只聲明
    ...

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

然後在widget.cpp中於Widget::Impl之後進行定義:

#include "widget.h" 
#include "gadget.h"
#include <string>
#include <vector>

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

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

Widget::~Widget()                      // ~Widget的定義
{}

這工作得很好,並且它要碼的字最少,但是如果你想要強調“編譯器產生的”析構函數可以做到正確的事情(也就是你聲明它的唯一原因就是讓它的定義在Widget的實現文件中產生),那麼你就能在定義析構函數的時候使用“=default”:

Widget::~Widget() = default;           //和之前的效果是一樣的

使用Pimpl機制的類是可以支持move操作的,因爲“編譯器產生的”move操作是我們需要的:執行一個move操作在std::unique_ptr上。就像Item 17解釋的那樣,在Widget中聲明一個析構函數會阻止編譯器產生move操作,所以如果你想支持move操作,你必須自己聲明這些函數。如果“編譯器產生的”版本是正確的行爲,你可能會嘗試像下面這樣實現:

class Widget {
public:
    Widget();
    ~Widget();

    Widget(Widget&& rhs) = default;                 // 想法是對的
    Widget& operator=(Widget&& rhs) = default;      // 代碼卻是錯的                           
    ...

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

這個方法將導致和不聲明析構函數同樣的問題,並且是出於同樣的根本性的原因。“編譯器產生的”operator move在重新賦值前,需要銷燬被pImpl指向的對象,但是在Widget的頭文件中,pImpl指向一個不完整類型。move構造函數的情況和賦值函數是不同的。構造函數的問題是,萬一一個異常在move構造函數中產生,編譯器通常要產生出代碼來銷燬pImpl,然後銷燬pImpl需要Impl是完整的。

因爲問題和之前一樣,所以修復方法也一樣:把move操作的定義移動到實現文件中去:

class Widget {
public:
    Widget();
    ~Widget();

    Widget(Widget&& rhs);                   // 只定義
    Widget& operator=(Widget&& rhs);        // 不實現

    ...

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

#include <string> 
…                                           // 在"widget.cpp"中

struct Widget::Impl { … };                  // 和之前一樣

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

Widget::~Widget() = default; 

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

Pimpl機制是減少類的實現和類的客戶之間的編譯依賴性的方法,但是從概念上來說,使用這個機制不會改變類所代表的東西。源Widget類包含std::string,std::vector和Gadet數據成員,並且,假設Gadget和std::string以及std::vector一樣,是能拷貝的,那麼讓Widget支持拷貝操作是有意義的。我們必須自己寫這些函數,因爲(1)編譯器不會爲“只能移動的類型”(比如std::unique_ptr)產生出拷貝操作,(2)即使他們會這麼做,產生的函數也只會拷貝std::unique_ptr(也就是執行淺拷貝),但是我們想要拷貝指針指向的東西(也就是執行深拷貝)。

按照我們已經熟悉的慣例,我們在頭文件中聲明函數,並且在實現文件中實現它:

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)    // 拷貝operator=
{
    *pImpl = *rhs.pImpl;
    return *this;
}

兩個函數的實現都很方便。每種情況,我們都只是簡單地從源對象(rhs)中把Impl結構拷貝到目標對象(*this)。比起一個個地拷貝成員,我們利用了一個事實,也就是編譯器會爲Impl創造出拷貝操作,然後這些操作會自動地拷貝每一個成員。因此我們是通過調用Widget::Impl的“編譯器產生的”拷貝操作來實現Widget的拷貝操作的,記住,我們還是要遵循Item 21的建議,比起直接使用new,優先使用std::make_unique。

爲了實現Pimpl機制,std::unique_ptr是被使用的智能指針,因爲對象(也就是Widget)內部的pImpl指針對相應的實現對象(比如,Widget::Impl對象)有獨佔所有權的語義。這很有趣,所以記住,如果我們使用std::shared_ptr來代替std::unique_ptr用在pImpl身上,我們將發現對於本Item的建議不再使用了。我們不需要聲明Widget的析構函數,並且如果沒有自定義的析構函數,編譯器將很高興地爲我們產生出move操作,這些都是我們想要的。給出widget.h中的代碼,

class Widget{                       //在"widget.h"中
public:
    Widget();                   
    ...                             //不需要聲明析構函數和move操作

private:
    struct Impl;                    
    std::shared_ptr<Impl> pImpl;    //用std::shared_ptr代替
};                                  //std::unique_ptr

然後#include widget.h的客戶代碼,

Widget w1;

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

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

所有的東西都能編譯並執行得和我們希望的一樣:w1將被默認構造,它的值將移動到w2中去,這個值之後將移動回w1,並且最後w1和w2都將銷燬(因此造成指向的Widget::Impl對象被銷燬)。

std::unique_ptr和std::shared_ptr對於pImpl指針行爲的不同源於這兩個智能指針對於自定義deleter的不同的支持方式。對於std::unique_ptr來說,deleter的類型是智能指針類型的一部分,並且這讓編譯器產生出更小的運行期數據結構和更快的運行期代碼成爲可能。這樣的高效帶來的結果就是,當“編譯器產生的”特殊函數(也就是,析構函數和move操作)被使用的時候,指向的類型必須是完整的。對於std::shared_ptr,deleter的類型不是智能指針的一部分。這就需要更大的運行期數據結構和更慢的代碼,但是當“編譯器產生的”特殊函數被使用時,指向的類型不需要是完整的。

對於Pimpl機制來說,std::unique_ptr和std::shared_ptr之間沒有明確的抉擇,因爲Widget和Widget::Impl之間的關係是獨佔所有權的關係,所以這使得std::unique_ptr成爲更合適的工具。但是,值得我們注意的是另外一種情況,這種情況下共享所有權是存在的(因此std::shared_ptr是更合適的設計選擇),我們就不需要做那麼多的函數定義了(如果使用std::unique_ptr的話是要做的)。

            你要記住的事
  • Pimpl機制通過降低類客戶和類實現之間的編譯依賴性來降低編譯時間。
  • 對於std::unique_ptr的pImpl指針,在頭文件中聲明特殊成員函數,但是實現他們的時候要放在實現文件中實現。即使編譯器提供的默認函數實現是滿足設計需要,我們還是要這麼做。
  • 上面的建議能用在std::unique_ptr上面,但是不能用在std::shared_ptr上面。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章