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

来源:http://www.cnblogs.com/boydfd/archive/2016/01/26/5161128.html
-Advertisement-
Play Games

本文翻譯自《effective modern C++》,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!如果你曾經同過久的編譯時間鬥爭過,那麼你肯定對Pimpl("point to implementation",指向實現)機制很熟悉了。這種技術讓你把類的數據成員替換成指向一個實現類(或....


本文翻譯自《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上面。

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...