在你的開發(fā)工作中,你可能需要對(duì)于某個(gè)類的具體實(shí)現(xiàn)做出一個(gè)細(xì)小的修改。提醒你一下,修改的地方不是類接口,而是實(shí)現(xiàn)本身,并且僅僅是私有部分。完成之后,你開始對(duì)程序進(jìn)行重新構(gòu)建,這時(shí)你肯定會(huì)認(rèn)為這一過程將十分短暫,畢竟你只對(duì)一個(gè)類做出了修改。當(dāng)你按下“構(gòu)建”按鈕,或輸入make命令(或者其他什么等價(jià)的操作)之后,你驚呆了,然后你臉上便呈現(xiàn)出一個(gè)大大的囧字,因?yàn)槟惆l(fā)現(xiàn)整個(gè)世界都重新編譯并重新鏈接了!真是人神共憤!
問題的癥結(jié)在于:C++并不擅長區(qū)分接口和實(shí)現(xiàn)。一個(gè)類的定義不僅指定了類接口的內(nèi)容,而且指明了相當(dāng)數(shù)量的實(shí)現(xiàn)細(xì)節(jié)。請(qǐng)看下面的示例:
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; // 具體實(shí)現(xiàn)
Date theBirthDate; // 具體實(shí)現(xiàn)
Address theAddress; // 具體實(shí)現(xiàn)
};
這里,如果無法訪問Person具體實(shí)現(xiàn)所使用的類(即string、Date和Address)定義,那么Person類將不能夠得到編譯。通常這些定義通過#include指令來提供,因此在定義Person類的文件中,你應(yīng)該能夠找到這樣的內(nèi)容:
#include <string>
#include "date.h"
#include "address.h"
不幸的是,這樣做使得定義Person的文件對(duì)這些頭文件產(chǎn)生了依賴。如果任一個(gè)頭文件的內(nèi)容被修改了,或者這些頭文件所依賴的另外某個(gè)頭文件被修改,那么包含Person類的文件就必須重新編譯,有多少個(gè)文件包含Person,就要進(jìn)行多少次編譯操作。這種層疊式的編譯依賴將招致無法估量的災(zāi)難式后果。
你可能會(huì)考慮:為什么C++堅(jiān)持要將類具體實(shí)現(xiàn)的細(xì)節(jié)放在類定義中呢?假如說,如果我們換一種方式定義Person,單獨(dú)編寫類的具體實(shí)現(xiàn),結(jié)果又會(huì)怎樣呢?
namespace std {
class string; // 前置聲明 (這個(gè)是非法的,參見下文)
}
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;
...
};
如果這樣可行,那么對(duì)于Person的客戶來說,僅在類接口有改動(dòng)時(shí),才需要進(jìn)行重新編譯。
這種想法存在著兩個(gè)問題。首先,string不是一個(gè)類,它是一個(gè)typedef(typedef basic_string<char> string)。于是,針對(duì)string的前置聲明就是非法的。實(shí)際上恰當(dāng)?shù)那爸寐暶饕獜?fù)雜的多,因?yàn)樗婕暗狡渌哪0濉H欢@不是主要問題,因?yàn)槟惚緛砭筒粦?yīng)該嘗試手工聲明標(biāo)準(zhǔn)庫的內(nèi)容。僅僅使用恰當(dāng)?shù)?span style="font-family:"Courier New";">#include指令就可以了。標(biāo)準(zhǔn)頭文件一般都不會(huì)成為編譯中的瓶頸,尤其是在你的編譯環(huán)境允許你利用預(yù)編譯的頭文件時(shí)更為突出。如果分析標(biāo)準(zhǔn)頭文件對(duì)你來說的確是件麻煩事,那么你可能就需要改變你的接口設(shè)計(jì),以避免使用那些會(huì)帶來多余#include指令的標(biāo)準(zhǔn)類成員。
對(duì)所有的類做前置聲明會(huì)遇到的第二個(gè)(同時(shí)也是更顯著的)難題是:在編譯過程中,編譯器需要知道對(duì)象的大小。請(qǐng)觀察下面的代碼:
int main()
{
int x; // 定義一個(gè)int
Person p( 參數(shù) ); // 定義一個(gè)Person
...
}
當(dāng)編譯器看到了x的定義時(shí),它們就知道該為其分配足夠的內(nèi)存空間(通常位于棧中)以保存一個(gè)int值。這里沒有問題。每一種編譯器都知道int的大小。當(dāng)編譯器看到p的定義時(shí),他們知道該為其分配足夠的空間以容納一個(gè)Person,但是他們又如何得知Person對(duì)象的大小呢?得到這一信息的唯一途徑就是通過類定義,但是如果省略類定義具體實(shí)現(xiàn)細(xì)節(jié)是合法的,那么編譯器又如何得知需要分配多大空間呢?
同樣的問題不會(huì)在Smalltalk和Java中出現(xiàn),因?yàn)樵谶@些語言中,每當(dāng)定義一個(gè)對(duì)象時(shí),編譯器僅僅分配指向該對(duì)象指針大小的空間。也就是說,在這些語言中,上面的代碼將做如下的處理:
int main()
{
int x; // 定義一個(gè)int
Person *p; // 定義一個(gè)Person
...
}
當(dāng)然,這段代碼在C++中是合法的,于是你可以自己通過“將對(duì)象實(shí)現(xiàn)隱藏在指針之后”來玩轉(zhuǎn)前置聲明。對(duì)于Person而言,實(shí)現(xiàn)方法之一就是將其分別放在兩個(gè)類中,一個(gè)只提供接口,另一個(gè)存放接口對(duì)應(yīng)的具體實(shí)現(xiàn)。暫且將具體實(shí)現(xiàn)類命名為PersonImpl,Person類的定義應(yīng)該是這樣的:
#include <string> // 標(biāo)準(zhǔn)庫成員,不允許對(duì)其進(jìn)行前置聲明
#include <memory> // 為使用tr1::shared_ptr; 稍后介紹
class PersonImpl; // Person實(shí)現(xiàn)類的前置聲明
class Date; // Person接口中使用的類的前置聲明
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: // 指向?qū)崿F(xiàn)的指針
std::tr1::shared_ptr<PersonImpl> pImpl;
}; // 關(guān)于std::tr1::shared_ptr的更多信息
// 參見條目13
在這里,主要的類(Person)僅僅包括一個(gè)數(shù)據(jù)成員——一個(gè)指向其實(shí)現(xiàn)類(PersonImpl)的指針(這里是一個(gè)tr1::shared_ptr,參見條目13)。我們通常將這樣的設(shè)計(jì)稱為pimpl慣用法(指向?qū)崿F(xiàn)的指針)。在這樣的類中,指針名通常為pImpl,就像上面代碼中一樣。
通過這樣的設(shè)計(jì),Person的客戶將會(huì)與日期、地址和人這些具體信息隔離開。你可以隨時(shí)修改這些類的具體實(shí)現(xiàn),但是Person的客戶不需要重新編譯。另外,由于客戶無法得知Person的具體實(shí)現(xiàn)細(xì)節(jié),他們就不容易編寫出依賴于這些細(xì)節(jié)的代碼。這樣做真正起到了分離接口和實(shí)現(xiàn)的目的。
這項(xiàng)分離工作的關(guān)鍵所在,就是用聲明的依賴來取代定義的依賴。這就是最小化編譯依賴的核心所在:只要可行,就要將頭文件設(shè)計(jì)成自給自足的,如果不可行,那么就依賴于其他文件中的聲明語句,而不是定義。其他一切事情都應(yīng)遵從這一基本策略。于是有:
l 只要使用對(duì)象的引用或指針可行時(shí),就不要使用對(duì)象。只要簡單地通過類型聲明,你就可以定義出類型的引用和指針。反觀定義類型對(duì)象的情形,你就必須要進(jìn)行類型定義了。
l 只要可行,就用類聲明依賴的方式取代類定義依賴。請(qǐng)注意,如果你需要聲明一個(gè)函數(shù),該函數(shù)會(huì)使用某個(gè)類,那么在任何情況下類的定義都不是必須的。即使這個(gè)函數(shù)以傳值方式傳遞或返回這個(gè)類的對(duì)象:
class Date; // 類聲明
Date today(); // 這樣是可行的
void clearAppointments(Date d); // 沒有必要對(duì)Date類做出定義
當(dāng)然,傳值方式在通常情況下都不會(huì)是優(yōu)秀的方案(參見條目20),但是如果你出于某種原因使用了傳值方式時(shí),此時(shí)必將引入不必要的編譯依賴,你依然難擇其咎。
即使不定義Date的具體實(shí)現(xiàn),today和clearAppointments依然可以正確聲明,C++的這一能力可能會(huì)讓你感到吃驚,但是實(shí)際上這一行為又沒有想象中那么古怪。如果代碼中任意一處調(diào)用了這些函數(shù),那么在這次調(diào)用前的某處必須要對(duì)Date進(jìn)行定義。此時(shí)你又有了新的疑問:為什么我們要聲明沒有人調(diào)用的函數(shù)呢,這不是多此一舉嗎?這一疑問的答案很簡單:這種函數(shù)并不是沒有人調(diào)用,而是不是所有人都會(huì)去調(diào)用。假設(shè)你的庫中包含許多函數(shù)聲明,這并不意味著每一位客戶都會(huì)使用到所有的函數(shù)。上文的做法中,提供類定義的職責(zé)將從頭文件中的函數(shù)聲明轉(zhuǎn)向客戶端文件中包含的函數(shù)調(diào)用,通過這一過程,你就排除了手工造成的客戶端類定義依賴,這些依賴實(shí)際上是多余的。
l 為聲明和定義分別提供頭文件。為了進(jìn)一步貫徹上文中的思路,頭文件必須要一分為二:一個(gè)存放聲明,另一個(gè)存放定義。當(dāng)然這些文件必須保持相互協(xié)調(diào)。如果某處的一個(gè)聲明被修改了,那么相應(yīng)的定義處就必須做出相應(yīng)的修改。于是,庫的客戶就應(yīng)該始終使用#include指令來包含一個(gè)聲明頭文件,而不是自己進(jìn)行前置聲明,庫的作者應(yīng)提供兩個(gè)頭文件。比如說,在Date的客戶期望聲明today和clearAppointments時(shí),就應(yīng)該無需向上文中那樣,對(duì)Date進(jìn)行前置聲明。更好的方案是用#include指令來引入恰當(dāng)?shù)穆暶黝^文件:
#include "datefwd.h" // 包含Date類聲明(而不是定義)的頭文件
Date today(); // 同上
void clearAppointments(Date d);
頭文件“datefwd.h”中僅包含聲明,這一名字基于C++標(biāo)準(zhǔn)庫中的<iosfwd>(參見條目54)。<iosfwd>包含著IO流組件的聲明,這些組件相應(yīng)的定義分別存放在不同的幾個(gè)頭文件中,包括:<sstream>、<streambuf>、<fstream>以及<iostream>。
從另一個(gè)角度來講,使用<iosfwd>作示例還有一定的示范效應(yīng),因?yàn)樗嬖V我們本節(jié)中的建議不僅對(duì)非模板的類有效,而且對(duì)模板同樣適用。盡管在條目30中分析過,在許多構(gòu)建環(huán)境中,模板定義通常保存在頭文件中,一些構(gòu)建環(huán)境中還是允許將模板定義放置在非頭文件的代碼文件里,因此提供為模板提供僅包含聲明的頭文件并不是沒有意義的。<iosfwd>就是這樣一個(gè)頭文件。
C++提供了export關(guān)鍵字,它用于分離模板聲明和模板定義。但是遺憾的是,支持export的編譯器是十分有限的,現(xiàn)實(shí)中export的應(yīng)用更是寥寥無幾。因此在高效C++編程中,export究竟扮演什么角色,討論這個(gè)問題還為時(shí)尚早。
諸如Person此類使用pimpl慣用法的類通常稱為句柄類。為了避免你對(duì)這樣的類如何工作產(chǎn)生疑問,一個(gè)途徑就是將類中所有的函數(shù)調(diào)用放在相關(guān)的具體實(shí)現(xiàn)類之前,并且讓這些具體實(shí)現(xiàn)類去做真實(shí)的工作。請(qǐng)看下面的示例,其中演示了Person的成員函數(shù)應(yīng)該如何實(shí)現(xiàn):
#include "Person.h" // 我們將編寫Person類的具體實(shí)現(xiàn),
// 因此此處必須包含類定義。
#include "PersonImpl.h" // 同時(shí),此處必須包含PersonImpl的類定義,
// 否則我們將不能調(diào)用它的成員函數(shù);請(qǐng)注意,
// PersonImpl擁有與Person完全一致的成員
// 函數(shù) - 也就是說,它們的接口是一致的。
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();
}
請(qǐng)注意下面兩個(gè)問題:Person的構(gòu)造函數(shù)是如何調(diào)用PersonImpl的構(gòu)造函數(shù)的(通過使用new - 參見條目16),以及Person::name是如何調(diào)用PersonImpl::name的。這兩點(diǎn)很重要。將Person定制為一個(gè)句柄類并不會(huì)改變它所做的事情,而是僅僅改變它做事情的方式。
除了句柄類的方法,我們還可以采用一種稱為“接口類”的方法來將Person定制為特種的抽象基類。這種類的目的就是為派生類指定一個(gè)接口(參見條目34)。于是,通常情況下它沒有數(shù)據(jù)成員,沒有構(gòu)造函數(shù),但是擁有一個(gè)虛析構(gòu)函數(shù)(參見條目7),以及一組指定接口用的純虛函數(shù)。
接口類與Java和.NET中的接口一脈相承,但是C++并沒有像Java和.NET中那樣對(duì)接口做出非常嚴(yán)格的限定。比如說,無論是Java還是.NET都不允許接口中出現(xiàn)數(shù)據(jù)成員或者函數(shù)實(shí)現(xiàn),但是C++對(duì)這些都沒有做出限定。C++擁有更強(qiáng)靈活性是有實(shí)用價(jià)值的。就像條目36中所解釋的那樣,由于非虛函數(shù)的具體實(shí)現(xiàn)對(duì)于同一層次中所有的類都應(yīng)該保持一致,因此不妨將這些函數(shù)實(shí)現(xiàn)放置在聲明它們的接口類中,這樣做是有意義的。
Person的接口類可以是這樣的:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
};
這個(gè)類的客戶必須要基于Person的指針和引用來編寫程序,因?yàn)閷?shí)例化一個(gè)包含純虛函數(shù)的類是不可能的。(然而,實(shí)例化一個(gè)繼承自Person的類卻是可行的——參見下文。)就像句柄類的客戶一樣,接口類客戶除非遇到接口類的接口有改動(dòng)的情況,其他任何情況都不需要對(duì)代碼進(jìn)行重新編譯。
接口類的客戶必須有一個(gè)創(chuàng)建新對(duì)象的手段。通常情況下,它們可以通過調(diào)用真正被實(shí)例化的派生類中的一個(gè)函數(shù)來實(shí)現(xiàn),這個(gè)函數(shù)扮演的角色就是派生類的構(gòu)造函數(shù)。這樣的函數(shù)通常被稱作工廠函數(shù)(參見條目13)或者虛構(gòu)造函數(shù)。這種函數(shù)返回一個(gè)指向動(dòng)態(tài)分配對(duì)象的指針(最好是智能指針——參見條目18),這些動(dòng)態(tài)分配的對(duì)象支持接口類的接口。這樣的函數(shù)通常位于接口類中,并且聲明為static的:
class Person {
public:
...
static std::tr1::shared_ptr<Person> // 返回一個(gè)tr1::shared_ptr,
create(const std::string& name, // 它指向一個(gè)Person對(duì)象,這個(gè)
const Date& birthday, // Person對(duì)象由給定的參數(shù)初始化,
const Address& addr); // 為什么返回智能指針參見條目18
...
};
客戶這樣使用:
std::string name;
Date dateOfBirth;
Address address;
...
// 創(chuàng)建一個(gè)支持Person接口的對(duì)象
std::tr1::shared_ptr<Person>
pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name() // 通過Person的接口使用這一對(duì)象
<< " was born on "
<< pp->birthDate()
<< " and now lives at "
<< pp->address();
... // 當(dāng)程序執(zhí)行到pp的作用域之外時(shí),
// 這一對(duì)象將被自動(dòng)刪除——參見條目13
當(dāng)然,與此同時(shí),必須要對(duì)支持某一接口類的接口的具體類做出定義,并且必須有真實(shí)的構(gòu)造函數(shù)得到調(diào)用。比如說,有一個(gè)具體的派生類RealPerson使用了接口類Person,這一派生類應(yīng)為其繼承而來的虛函數(shù)提供具體實(shí)現(xiàn):
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; // 這里省略了這些函數(shù)的具體實(shí)現(xiàn),
std::string birthDate() const; // 但是很容易想象它們是什么樣子。
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
有了RealPerson,編寫Person::create便手到擒來:
std::tr1::shared_ptr<Person> Person::create(const std::string& name,
const Date& birthday,
const Address& addr)
{
return std::tr1::shared_ptr<Person>(
new RealPerson(name, birthday,addr));
}
Person::create還可以以一個(gè)更加貼近現(xiàn)實(shí)的方法來實(shí)現(xiàn),它應(yīng)能夠創(chuàng)建不同種類的派生類對(duì)象,創(chuàng)建的過程基于某些相關(guān)信息,例如:新加入的函數(shù)的參數(shù)值、從一個(gè)文件或數(shù)據(jù)庫中得到讀到的數(shù)值,環(huán)境變量,等等。
RealPerson向我們展示了實(shí)現(xiàn)接口類的兩種最通用的實(shí)現(xiàn)機(jī)制之一:它的接口規(guī)范繼承自接口類(Person),然后實(shí)現(xiàn)接口中的函數(shù)。第二種實(shí)現(xiàn)接口類的方法牽扯到多重繼承,那是條目40中探索的主題。
句柄類和接口類將接口從實(shí)現(xiàn)中分離開來,因此降低了文件間的編譯依賴。如果你喜歡吹毛求疵,那么你一定在等待我來添加一條注釋。“這么多變魔術(shù)般古怪的事情會(huì)帶來多大開銷?”這個(gè)問題的答案就是計(jì)算機(jī)科學(xué)中極為普遍的一個(gè)議題:你的程序在運(yùn)行時(shí)更慢了一步,另外,每個(gè)對(duì)象所占的空間更大了一點(diǎn)。
使用句柄類的情況下,成員函數(shù)必須通過實(shí)現(xiàn)指針來取得對(duì)象的數(shù)據(jù)。這樣無形中增加了每次訪問時(shí)迂回的層數(shù)。同時(shí),為保證每個(gè)對(duì)象都擁有足夠的內(nèi)存空間,你必須增大實(shí)現(xiàn)指針?biāo)赶虻膮^(qū)域的內(nèi)存空間。最后,你必須要對(duì)實(shí)現(xiàn)指針進(jìn)行初始化(在句柄類的構(gòu)造函數(shù)中),以便于將其指向一個(gè)動(dòng)態(tài)分配的實(shí)現(xiàn)對(duì)象,這樣做將會(huì)招致動(dòng)態(tài)內(nèi)存分配(以及由此產(chǎn)生的釋放)所帶來的固有的開銷,也有可能遭遇bad_alloc(內(nèi)存不足)異常。
由于對(duì)于接口類來說每次函數(shù)調(diào)用都是虛擬的,因此你在每調(diào)用一次函數(shù)的過程中你就會(huì)為其付出一次間接跳轉(zhuǎn)的代價(jià)(參見條目7)。同時(shí),派生自接口類的對(duì)象必須包含一個(gè)虛函數(shù)表指針(依然參見條目7)。這一指針也可能會(huì)加大保存一個(gè)對(duì)象所需要的空間,這取決于接口類是否是該對(duì)象中虛函數(shù)的唯一來源。
最后,無論是句柄類還是接口類,都不適合于過多使用內(nèi)聯(lián)。條目30中解釋了為什么一般情況下要將內(nèi)聯(lián)的函數(shù)體放置在頭文件中,然而句柄和接口類都是特別設(shè)計(jì)用來隱藏諸如函數(shù)體等具體實(shí)現(xiàn)內(nèi)容的。
然而,對(duì)于句柄類和接口類來說,僅僅由于它們會(huì)帶來一些額外的開銷就遠(yuǎn)離它們,也是一個(gè)嚴(yán)重的錯(cuò)誤。你并不會(huì)因?yàn)樘摵瘮?shù)存在缺點(diǎn)放棄使用它,不是嗎?(如果你真的想放棄,那么你可能看錯(cuò)書了。)你應(yīng)該以一個(gè)進(jìn)化演進(jìn)的方式來使用這些技術(shù)。在開發(fā)過程中,嘗試使用句柄類和接口類來減少改動(dòng)具體實(shí)現(xiàn)時(shí)為客戶帶來的影響。在生產(chǎn)環(huán)境中,如果應(yīng)用這些技術(shù)導(dǎo)致程序的速度和/或大小的變動(dòng)足夠顯著,從而沖淡了不同的類之間所增加關(guān)系度的影響,應(yīng)適時(shí)使用具體的類來取代句柄類和接口類。
時(shí)刻牢記
l 最小化編譯依賴的基本理念就是使用聲明依賴代替定義依賴。基于這一理念有兩種實(shí)現(xiàn)方式,它們是:句柄類和接口類。
l 庫的頭文件必須以完整、并且僅存在聲明的形式出現(xiàn)。無論是否涉及模板。