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

