程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> Effective Modern C++ 條款22 當使用Pimpl Idiom時,在實現文件中定義特殊成員函數

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

編輯:關於C++

這csdn的markdown真心有問題,多次把我的代碼縮進吃掉,編輯頁面明明有空格,顯示效果卻沒有~

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

如果你曾經與過長的編譯時間斗爭過,你應該熟悉Pimpl(“pointer to implementation”) Idiom。這項技術通過把類中的成員變量替換成指向一個實現類(或結構體)的指針,成員變量被放進單獨的實現類中,然後通過該指針間接獲取原來的成員變量。例如,Widget是這樣的:
class Widget { // 在頭文件“widget.h”中
public:
Widget();
...
private:
std::string name;
std::vector 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
#include

struct Widget::Impl { // 用原來對象的成員變量來定義實現類
std::string name;
std::vector 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 pImpl; // 用智能指針代替原生指針
};

然後這是實現文件:
#include "widget.h" // 在“widget.cpp”
#include "gadget.h"
#include
#include

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

Widget::Widget() // 見條款21
: pImpl(std::make_unique()) // 借助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對象,即一個使用默認刪除器的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 pImpl;
};

在“widget.cpp”中,定義了Widget::Impl之後才定義析構函數:
#include "widget.h" // 如前, 在"widget.cpp"
#include "gadget.h
#include
#include

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

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

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 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 pImpl;
};

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

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

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

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 pImpl;
};

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

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

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

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

Widget& Widget::operator=(const Widget& rhs) // 拷貝賦值
{
*pImpl = *rhs.Impl;
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 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
  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved