《Effective C++》條款34: 將檔案間的編譯依賴性降至最低
假設某一天你開啟自己的C++程式程式碼,然後對某個類的實現做了小小的改動。提醒你,改動的不是介面,而是類的實現,也就是說,只是細節部分。然後你準備重新生成程式,心想,編譯和連結應該只會花幾秒種。畢竟,只是改動了一個類嘛!於是你點選了一下"Rebuild",或輸入make(或其它類似命令)。然而,等待你的是驚愕,接著是痛苦。因為你發現,整個世界都在被重新編譯、重新連結!
當這一切發生時,你難道僅僅只是憤怒嗎?
問題發生的原因在於,在將介面從實現分離這方面,C++做得不是很出色。尤其是,C++的類定義中不僅包含介面規範,還有不少實現細節。例如:
class Person { public: Person(const string& name, const Date& birthday, const Address& addr, const Country& country); virtual ~Person(); ... // 簡化起見,省略了拷貝構造 // 函式和賦值運算子函式 string name() const; string birthDate() const; string address() const; string nationality() const; private: string name_; // 實現細節 Date birthDate_; // 實現細節 Address address_; // 實現細節 Country citizenship_; // 實現細節 };
這很難稱得上是一個很高明的設計,雖然它展示了一種很有趣的命名方式:當私有資料和公有函式都想用某個名字來標識時,讓前者帶一個尾部下劃線就可以區別了。這裡要注意到的重要一點是,Person的實現用到了一些類,即string, Date,Address和Country;Person要想被編譯,就得讓編譯器能夠訪問得到這些類的定義。這樣的定義一般是通過#include指令來提供的,所以在定義Person類的檔案頭部,可以看到象下面這樣的語句:
#include <string> // 用於string型別 (參見條款49) #include "date.h" #include "address.h" #include "country.h"
遺憾的是,這樣一來,定義Person的檔案和這些標頭檔案之間就建立了編譯依賴關係。所以如果任一個輔助類(即string, Date,Address和Country)改變了它的實現,或任一個輔助類所依賴的類改變了實現,包含Person類的檔案以及任何使用了Person類的檔案就必須重新編譯。對於Person類的使用者來說,這實在是令人討厭,因為這種情況使用者絕對是束手無策。
那麼,你一定會奇怪為什麼C++一定要將一個類的實現細節放在類的定義中。例如,為什麼不能象下面這樣定義Person,使得類的實現細節與之分開呢?
class string; // "概念上" 提前宣告string 型別 // 詳見條款49 class Date; // 提前宣告 class Address; // 提前宣告 class Country; // 提前宣告 class Person { public: Person(const string& name, const Date& birthday, const Address& addr, const Country& country); virtual ~Person(); ... // 拷貝建構函式, operator= string name() const; string birthDate() const; string address() const; string nationality() const; };
如果這種方法可行的話,那麼除非類的介面改變,否則Person 的使用者就不需要重新編譯。大系統的開發過程中,在開始類的具體實現之前,介面往往基本趨於固定,所以這種介面和實現的分離將大大節省重新編譯和連結所花的時間。
可惜的是,現實總是和理想相抵觸,看看下面你就會認同這一點:
int main()
{
int x; // 定義一個int
Person p(...); // 定義一個Person
// (為簡化省略引數)
...
}
當看到x的定義時,編譯器知道必須為它分配一個int大小的記憶體。這沒問題,每個編譯器都知道一個int有多大。然而,當看到p的定義時,編譯器雖然知道必須為它分配一個Person大小的記憶體,但怎麼知道一個Person物件有多大呢?唯一的途徑是藉助類的定義,但如果類的定義可以合法地省略實現細節,編譯器怎麼知道該分配多大的記憶體呢?
原則上說,這個問題不難解決。有些語言如Smalltalk,Eiffel和Java每天都在處理這個問題。它們的做法是,當定義一個物件時,只分配足夠容納這個物件的一個指標的空間。也就是說,對應於上面的程式碼,他們就象這樣做:
int main()
{
int x; // 定義一個int
Person *p; // 定義一個Person指標
...
}
你可能以前就碰到過這樣的程式碼,因為它實際上是合法的C++語句。這證明,程式設計師完全可以自己來做到 "將一個物件的實現隱藏在指標身後"。
下面具體介紹怎麼採用這一技術來實現Person介面和實現的分離。首先,在宣告Person類的標頭檔案中只放下面的東西:
// 編譯器還是要知道這些型別名,
// 因為Person的建構函式要用到它們
class string; // 對標準string來說這樣做不對,
// 原因參見條款49
class Date;
class Address;
class Country;
// 類PersonImpl將包含Person物件的實
// 現細節,此處只是類名的提前宣告
class PersonImpl;
class Person {
public:
Person(const string& name, const Date& birthday,
const Address& addr, const Country& country);
virtual ~Person();
... // 拷貝建構函式, operator=
string name() const;
string birthDate() const;
string address() const;
string nationality() const;
private:
PersonImpl *impl; // 指向具體的實現類
};
現在Person的使用者程式完全和string,date,address,country以及person的實現細節分家了。那些類可以隨意修改,而Person的使用者卻落得個自得其樂,不聞不問。更確切的說,它們可以不需要重新編譯。另外,因為看不到Person的實現細節,使用者不可能寫出依賴這些細節的程式碼。這是真正的介面和實現的分離。
分離的關鍵在於,"對類定義的依賴" 被 "對類宣告的依賴" 取代了。所以,為了降低編譯依賴性,我們只要知道這麼一條就足夠了:只要有可能,儘量讓標頭檔案不要依賴於別的檔案;如果不可能,就藉助於類的宣告,不要依靠類的定義。其它一切方法都源於這一簡單的設計思想。
下面就是這一思想直接深化後的含義:
· 如果可以使用物件的引用和指標,就要避免使用物件本身。定義某個型別的引用和指標只會涉及到這個型別的宣告。定義此型別的物件則需要型別定義的參與。
· 儘可能使用類的宣告,而不使用類的定義。因為在宣告一個函式時,如果用到某個類,是絕對不需要這個類的定義的,即使函式是通過傳值來傳遞和返回這個類:
class Date; // 類的宣告
Date returnADate(); // 正確 ---- 不需要Date的定義
void takeADate(Date d);
當然,傳值通常不是個好主意(見條款22),但出於什麼原因不得不這樣做時,千萬不要還引起不必要的編譯依賴性。
如果你對returnADate和takeADate的宣告在編譯時不需要Date的定義感到驚訝,那麼請跟我一起看看下文。其實,它沒看上去那麼神祕,因為任何人來呼叫那些函式,這些人會使得Date的定義可見。"噢" 我知道你在想,"為什麼要勞神去宣告一個沒有人呼叫的函式呢?" 不對!不是沒有人去呼叫,而是,並非每個人都會去呼叫。例如,假設有一個包含數百個函式宣告的庫(可能要涉及到多個名字空間----參見條款28),不可能每個使用者都去呼叫其中的每一個函式。將提供類定義(通過#include 指令)的任務從你的函式宣告標頭檔案轉交給包含函式呼叫的使用者檔案,就可以消除使用者對型別定義的依賴,而這種依賴本來是不必要的、是人為造成的。
· 不要在標頭檔案中再(通過#include指令)包含其它標頭檔案,除非缺少了它們就不能編譯。相反,要一個一個地宣告所需要的類,讓使用這個標頭檔案的使用者自己(通過#include指令)去包含其它的標頭檔案,以使使用者程式碼最終得以通過編譯。一些使用者會抱怨這樣做對他們來說很不方便,但實際上你為他們避免了許多你曾飽受的痛苦。事實上,這種技術很受推崇,並被運用到C++標準庫(參見條款49)中;標頭檔案<iosfwd>就包含了iostream庫中的型別宣告(而且僅僅是型別宣告)。
Person類僅僅用一個指標來指向某個不確定的實現,這樣的類常常被稱為句炳類(Handle class)或信封類(Envelope class)。(對於它們所指向的類來說,前一種情況下對應的叫法是主體類(Body class);後一種情況下則叫信件類(Letter class)。)偶爾也有人把這種類叫 "Cheshire貓" 類,這得提到《艾麗絲漫遊仙境》中那隻貓,當它願意時,它會使身體其它部分消失,僅僅留下微笑。
你一定會好奇句炳類實際上都做了些什麼。答案很簡單:它只是把所有的函式呼叫都轉移到了對應的主體類中,主體類真正完成工作。例如,下面是Person的兩個成員函式的實現:
#include "Person.h" // 因為是在實現Person類,
// 所以必須包含類的定義
#include "PersonImpl.h" // 也必須包含PersonImpl類的定義,
// 否則不能呼叫它的成員函式。
// 注意PersonImpl和Person含有一樣的
// 成員函式,它們的介面完全相同
Person::Person(const string& name, const Date& birthday,
const Address& addr, const Country& country)
{
impl = new PersonImpl(name, birthday, addr, country);
}
string Person::name() const
{
return impl->name();
}
請注意Person的建構函式怎樣呼叫PersonImpl的建構函式(隱式地以new來呼叫,參見條款5和M8)以及Person::name怎麼呼叫PersonImpl::name。這很重要。使Person成為一個控制代碼類並不改變Person類的行為,改變的只是行為執行的地點。
除了控制代碼類,另一選擇是使Person成為一種特殊型別的抽象基類,稱為協議類(Protocol class)。根據定義,協議類沒有實現;它存在的目的是為派生類確定一個介面(參見條款36)。所以,它一般沒有資料成員,沒有建構函式;有一個虛解構函式(見條款14),還有一套純虛擬函式,用於制定介面。Person的協議類看起來會象下面這樣:
class Person {
public:
virtual ~Person();
virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
virtual string nationality() const = 0;
};
Person類的使用者必須通過Person的指標和引用來使用它,因為例項化一個包含純虛擬函式的類是不可能的(但是,可以例項化Person的派生類----參見下文)。和控制代碼類的使用者一樣,協議類的使用者只是在類的介面被修改的情況下才需要重新編譯。
當然,協議類的使用者必然要有什麼辦法來建立新物件。這常常通過呼叫一個函式來實現,此函式扮演建構函式的角色,而這個建構函式所在的類即那個真正被例項化的隱藏在後的派生類。這種函式叫法挺多(如工廠函式(factory function),虛建構函式(virtual constructor)),但行為卻一樣:返回一個指標,此指標指向支援協議類介面(見條款M25)的動態分配物件。這樣的函式象下面這樣宣告:
// makePerson是支援Person介面的
// 物件的"虛建構函式" ( "工廠函式")
Person*
makePerson(const string& name, // 用給定的引數初始化一個
const Date& birthday, // 新的Person物件,然後
const Address& addr, // 返回物件指標
const Country& country);
使用者這樣使用它:
string name;
Date dateOfBirth;
Address address;
Country nation;
...
// 建立一個支援Person介面的物件
Person *pp = makePerson(name, dateOfBirth, address, nation);
...
cout << pp->name() // 通過Person介面使用物件
<< " was born on "
<< pp->birthDate()
<< " and now lives at "
<< pp->address();
...
delete pp; // 刪除物件
makePerson這類函式和它建立的物件所對應的協議類(物件支援這個協議類的介面)是緊密聯絡的,所以將它宣告為協議類的靜態成員是很好的習慣:
class Person {
public:
... // 同上
// makePerson現在是類的成員
static Person * makePerson(const string& name,
const Date& birthday,
const Address& addr,
const Country& country);
這樣就不會給全域性名字空間(或任何其他名字空間)帶來混亂,因為這種性質的函式會很多(參見條款28)。
當然,在某個地方,支援協議類介面的某個具體類(concrete class)必然要被定義,真的建構函式也必然要被呼叫。它們都背後發生在實現檔案中。例如,協議類可能會有一個派生的具體類RealPerson,它具體實現繼承而來的虛擬函式:
class RealPerson: public Person {
public:
RealPerson(const string& name, const Date& birthday,
const Address& addr, const Country& country)
: name_(name), birthday_(birthday),
address_(addr), country_(country)
{}
virtual ~RealPerson() {}
string name() const; // 函式的具體實現沒有
string birthDate() const; // 在這裡給出,但它們
string address() const; // 都很容易實現
string nationality() const;
private:
string name_;
Date birthday_;
Address address_;
Country country_;
有了RealPerson,寫Person::makePerson就是小菜一碟:
Person * Person::makePerson(const string& name,
const Date& birthday,
const Address& addr,
const Country& country)
{
return new RealPerson(name, birthday, addr, country);
}
實現協議類有兩個最通用的機制,RealPerson展示了其中之一:先從協議類(Person)繼承介面規範,然後實現介面中的函式。另一種實現協議類的機制涉及到多繼承,這將是條款43的話題。
是的,控制代碼類和協議類分離了介面和實現,從而降低了檔案間編譯的依賴性。"但,所有這些把戲會帶來多少代價呢?",我知道你在等待罰單的到來。答案是電腦科學領域最常見的一句話:它在執行時會多耗點時間,也會多耗點記憶體。
控制代碼類的情況下,成員函式必須通過(指向實現的)指標來獲得物件資料。這樣,每次訪問的間接性就多一層。此外,計算每個物件所佔用的記憶體大小時,還應該算上這個指標。還有,指標本身還要被初始化(在控制代碼類的建構函式內),以使之指向被動態分配的實現物件,所以,還要承擔動態記憶體分配(以及後續的記憶體釋放)所帶來的開銷 ---- 見條款10。
對於協議類,每個函式都是虛擬函式,所有每次呼叫函式時必須承擔間接跳轉的開銷(參見條款14和M24)。而且,每個從協議類派生而來的物件必然包含一個虛指標(參見條款14和M24)。這個指標可能會增加物件儲存所需要的記憶體數量(具體取決於:對於物件的虛擬函式來說,此協議類是不是它們的唯一來源)。
最後一點,控制代碼類和協議類都不大會使用行內函數。使用任何行內函數時都要訪問實現細節,而設計控制代碼類和協議類的初衷正是為了避免這種情況。
但如果僅僅因為控制代碼類和協議類會帶來開銷就把它們打入冷宮,那就大錯特錯。正如虛擬函式,你難道會不用它們嗎?(如果回答不用,那你正在看一本不該看的書!)相反,要以發展的觀點來運用這些技術。在開發階段要儘量用控制代碼類和協議類來減少 "實現" 的改變對使用者的負面影響。如果帶來的速度和/或體積的增加程度遠遠大於類之間依賴性的減少程度,那麼,當程式轉化成產品時就用具體類來取代控制代碼類和協議類。希望有一天,會有工具來自動執行這類轉換。
有些人還喜歡混用控制代碼類、協議類和具體類,並且用得很熟練。這固然使得開發出來的軟體系統執行高效、易於改進,但有一個很大的缺點:還是必須得想辦法減少程式重新編譯時消耗的時間。