條款31:將檔案間的編譯依存關係降至最低
問題提出:如果我們將某個class的實現檔案做了某些修改,修改並不是class的介面,而是其實現部分,而且只改了其private部分,然後重建整個檔案,然鵝當你進行編譯的時候,可以發現該類都被重新編譯和連結了,什麼鬼。。。
問題的原因是C++並沒有“將介面從實現中分離”該部分做的很好,class的形式不僅包括介面,同時還包括十足的實現項目,如:
class Person{public: Person(const std::string& name,const Date& birthday,const Address& addr); std::string name() const; std::string birthDate() const; std::string address() const; ...private: std::string theName;//實現細目 Date theBirthDate;//實現細目 Address theAddress;//實現細目};
這裡的class Person無法通過編譯,因為這個檔案中包含了類Date,address和string的定義是,這樣的定義式通常通過#include指示符提供。Person的上面通常包括如下代碼:
#include <string>#include "date.h"#include "address.h"
這樣Person定義檔案和匯入檔案之間形成了一種“編譯依存關係”,如果標頭檔中有任何一個被改變,或者標頭檔中依賴的其他標頭檔有任何改變,那麼每一個含入的Person class的檔案就需要重新編譯,任何使用Person class的檔案同樣也需要被重新編譯,這樣連串編譯依存關係將使得整個項目超級複雜,同時容易崩潰。
既然這樣問題很大的話,為什麼C++堅持將class的實現細目至於class的定義式中呢。為什麼不這樣定義Person,將實現細目分開敘述。。。代碼如下:
namespace std{ class string;}class Date;class Address;class Person{public: Person(const std::string& name,const Date& birthday,const Address& addr); std::string name() const; std::string birthDate() const; std::string address() const; ...};
這樣實現的話將存在兩個問題:
1)string並不是class,只是basic_string,因此針對上述string而做的前置聲明並不明確,正確的前置聲明比較複雜,因為設計額外的templates,然而這並不重要;
2)編譯器需要在編譯期間內知道對象的大小。
看看C++中是如何解決這個問題的吧。
#include <string>#include <memory>//此乃為了trl::shared_ptr而含入;class PersonImpl;class Date;class Address;class Person{public: Person(const std::string& name,const Date& birthday,const Address& addr); std::string name() const; std::string birthDate() const; std::string address() const; ...private: std::trl::shared_ptr<PersonImpl> pImpl;//指標,指向實現物
可以看到這裡main class中只包含一個指標成員,指向其實作類別,這般設計通常被稱為pimpl idiom,這種class內的指標往往就是pImpl,就像上面代碼這樣。這種設計,Person的客戶就完全與Date,Address以及Person的實現細目完全分離了。
** 這個分離的關鍵在於“聲明的依存性”替換為“定義的依存性”,那正是編譯器依存性最小化的本質,
**現實中讓標頭檔儘可能自我滿足,萬一做不到,則讓它與其他檔案內的聲明式(而非定義式)相依,其他每一件事都源自於這個簡單的設計策略:
1)如果使用object references或者object pointers可以完成任務,就不要使用object,你可以只靠一個型別宣告式就可以定義出指向該類型的references和pointers,但如果定義某類型的objects,就需要用到該類型的定義式;
2)如果能夠,盡量用class聲明式替換為class定義式,注意,當你聲明一個函數而它用到某個class時候,你並不需要class的定義;
3)為聲明式和定義式提供不同的標頭檔。
像Person這樣使用pimpl idiom的class,往往被稱為Handle classes,那麼如何使用實現Person中的成員函數呢。答案有兩種:
1)當然是利用PersonImpl實作類別中的實現函數啦。
#include "Person.h"#include "PersonImpl.h"Person::Person(const std::string& name,const Date& birthday,const Address& addr):pImpl(new PersonImpl(name,birthday,addr)){}...std::string Person::name() const{ return pImpl->name();}
這裡需要注意Person建構函式伊new調用PersonImpl的建構函式,已經Person::name函數中調用PersonImpl::name,讓Person變成一個Handle class並不會改變它做的事,只會改變它做事的方法。
2)令Person稱為一種特殊的abstract base class(抽象基類),稱為Interface class,這種class的目的是詳細一一描述derived classes的介面,因此它通常不帶成員變數,也沒有建構函式,只有一系列virtual函數和一個virtual解構函式,通常我們可以設計一個factory工廠函數或者virtual建構函式進行實現,如:
class Person{public: virtual ~Person(); virtual std::string name()const=0; virtual std::string birthDate() const=0; virtual std::string address() cosnt=0; ...};class RealPerson:public Person{public: RealPerson(const std::string& name,const Date& birthday,const Address& addr):theName(name),theBirthDate(birthday),theAddress(addr) {} virtual ~RealPerson(){} std::string name() const; std::string birthDate() const; std::string address() const;private: std::string theName; Date theBirthDate; Address theAddress;};class Person{public: ... static std::trl::shared_ptr<Person> create(const std::string& name,const Date& birthday,const Address& addr); ...};
對於Handle classes來說,成員函數必須通過implementation pointer取得對象那個資料,那回味每一次訪問添加一層間接性。每一個對象小號的記憶體數量必須增加implementation pointer大小,最後impementation pointer必須初始化,指向一個動態分配得來的implementation object,所以你需要體會因動態記憶體分配(及其後的釋放動作)而來的而外開銷,以及遭遇bad_alloc有慈航的可能性;
對於Interface classes來說,每個函數都是virtual,所以你必須為每次函數調用付出一個間接跳躍成本,同時Interface class派生出來的向必須內含一個vptr,這個只恨可能會增加存放對象所需要的記憶體數量。
總結:
1)支援“編譯依存性最小化”的一般方法是:相依賴於聲明書式,不要依賴於定義式,基於次構想的兩個手段是Handle classes和Interface classes。
2)程式庫標頭檔應該以“完全且僅有聲明式”的形式存在,這種做法不論是否設計templates都適用。