假設某一天你開啟自己的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)。這個指標可能會增加Object Storage Service所需要的記憶體數量(具體取決於:對於對象的虛函數來說,此協議類是不是它們的唯一來源)。
最後一點,控制代碼類和協議類都不大會使用內嵌函式。使用任何內嵌函式時都要訪問實現細節,而設計控制代碼類和協議類的初衷正是為了避免這種情況。
但如果僅僅因為控制代碼類和協議類會帶來開銷就把它們打入冷宮,那就大錯特錯。正如虛函數,你難道會不用它們嗎?(如果回答不用,那你正在看一本不該看的書!)相反,要以發展的觀點來運用這些技術。在開發階段要盡量用控制代碼類和協議類來減少
"實現"
的改變對使用者的負面影響。如果帶來的速度和/或體積的增加程度遠遠大於類之間依賴性的減少程度,那麼,當程式轉化成產品時就用具體類來取代控制代碼類和協議類。希望有一天,會有工具來自動執行這類轉換。
有些人還喜歡混用控制代碼類、協議類和具體類,並且用得很熟練。這固然使得開發出來的軟體系統運行高效、易於改進,但有一個很大的缺點:還是必須得想辦法減少程式重新編譯時間消耗的時間。