C++箴言:最小化文件之間的編譯依賴
發(fā)表時間:2024-02-19 來源:明輝站整理相關軟件相關文章人氣:
[摘要]你進入到你的程序中,并對一個類的實現(xiàn)進行了細微的改變。提醒你一下,不是類的接口,只是實現(xiàn),僅僅是 private 的東西。然后你重建(rebuild)這個程序,預計這個任務應該只花費幾秒鐘。畢竟只有一個類被改變。你在 Build 上點擊或者鍵入 make(或者其它等價行為),接著你被驚呆了,繼而被...
你進入到你的程序中,并對一個類的實現(xiàn)進行了細微的改變。提醒你一下,不是類的接口,只是實現(xiàn),僅僅是 private 的東西。然后你重建(rebuild)這個程序,預計這個任務應該只花費幾秒鐘。畢竟只有一個類被改變。你在 Build 上點擊或者鍵入 make(或者其它等價行為),接著你被驚呆了,繼而被郁悶,就像你突然意識到整個世界都被重新編譯和連接!當這樣的事情發(fā)生的時候,你不討厭它嗎?
問題在于 C++ 沒有做好從實現(xiàn)中剝離接口的工作。一個類定義不僅指定了一個類的接口而且有相當數(shù)量的實現(xiàn)細節(jié)。例如:
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; // implementation detail
Date theBirthDate; // implementation detail
Address theAddress; // implementation detail
};
在這里,如果不訪問 Person 的實現(xiàn)使用到的類,也就是 string,Date 和 Address 的定義,類 Person 就無法編譯。這樣的定義一般通過 #include 指令提供,所以在定義 Person 類的文件中,你很可能會找到類似這樣的東西:
#include <string>
#include "date.h"
#include "address.h"
不幸的是,這樣就建立了定義 Person 的文件和這些頭文件之間的編譯依賴關系。如果這些頭文件中的一些發(fā)生了變化,或者這些頭文件所依賴的文件發(fā)生了變化,包含 Person 類的文件和使用了 Person 的文件一樣必須重新編譯,這樣的層疊編譯依賴關系為項目帶來數(shù)不清的麻煩。
你也許想知道 C++ 為什么堅持要將一個類的實現(xiàn)細節(jié)放在類定義中。例如,你為什么不能這樣定義 Person,單獨指定這個類的實現(xiàn)細節(jié)呢?
namespace std {
class string; // forward declaration (an incorrect
} // one - see below)
class Date; // forward declaration
class Address; // forward declaration
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;
...
};
如果這樣可行,只有在類的接口發(fā)生變化時,Person 的客戶才必須重新編譯。
這個主意有兩個問題。第一個,string 不是一個類,它是一個 typedef (for basic_string<char>)。造成的結(jié)果就是,string 的前向聲明(forward declaration)是不正確的。正確的前向聲明要復雜得多,因為它包括另外的模板。然而,這還不是要緊的,因為你不應該試著手動聲明標準庫的部件。作為替代,直接使用適當?shù)?#includes 并讓它去做。標準頭文件不太可能成為編譯的瓶頸,特別是在你的構(gòu)建環(huán)境允許你利用預編譯頭文件時。如果解析標準頭文件真的成為一個問題。你也許需要改變你的接口設計,避免使用導致不受歡迎的 #includes 的標準庫部件。
第二個(而且更重要的)難點是前向聲明的每一件東西必須讓編譯器在編譯期間知道它的對象的大小?紤]:
int main()
{
int x; // define an int
Person p( params ); // define a Person
...
}
當編譯器看到 x 的定義,它們知道它們必須為保存一個 int 分配足夠的空間(一般是在棧上)。這沒什么問題,每一個編譯器都知道一個 int 有多大。當編譯器看到 p 的定義,它們知道它們必須為一個 Person 分配足夠的空間,但是它們怎么推測出一個 Person 對象有多大呢?它們得到這個信息的唯一方法是參考這個類的定義,但是如果一個省略了實現(xiàn)細節(jié)的類定義是合法的,編譯器怎么知道要分配多大的空間呢? 這個問題在諸如 Smalltalk 和 Java 這樣的語言中就不會發(fā)生,因為,在這些語言中,當一個類被定義,編譯器僅僅為一個指向一個對象的指針分配足夠的空間。也就是說,它們處理上面的代碼就像這些代碼是這樣寫的:
int main()
{
int x; // define an int
Person *p; // define a pointer to a Person
...
}
當然,這是合法的 C++,所以你也可以自己來玩這種“將類的實現(xiàn)隱藏在一個指針后面”的游戲。對 Person 做這件事的一種方法就是將它分開到兩個類中,一個僅僅提供一個接口,另一個實現(xiàn)這個接口。如果那個實現(xiàn)類名為 PersonImpl,Person 就可以如此定義:
#include <string> // standard library components
// shouldn’t be forward-declared
#include <memory> // for tr1::shared_ptr; see below
class PersonImpl; // forward decl of Person impl. class
class Date; // forward decls of classes used in
class Address; // Person interface
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: // ptr to implementation;
std::tr1::shared_ptr<PersonImpl> pImpl;
}; // std::tr1::shared_ptr
這樣,主類(Person)除了一個指向它的實現(xiàn)類(PersonImpl)的指針(這里是一個 tr1::shared_ptr ——參見 Item 13)之外不包含其它數(shù)據(jù)成員。這樣一個設計經(jīng)常被說成是使用了 pimpl 慣用法(指向?qū)崿F(xiàn)的指針 "pointer to implementation")。在這樣的類中,那個指針的名字經(jīng)常是 pImpl,就像上面那個。
用這樣的設計,使 Person 的客戶脫離 dates,addresses 和 persons 的細節(jié)。這些類的實現(xiàn)可以隨心所欲地改變,但 Person 的客戶卻不必重新編譯。另外,因為他們看不到 Person 的實現(xiàn)細節(jié),客戶就不太可能寫出以某種方式依賴那些細節(jié)的代碼。這就是接口和實現(xiàn)的真正分離。
這個分離的關鍵就是用對聲明的依賴替代對定義的依賴。這就是最小化編譯依賴的精髓:只要能實現(xiàn),就讓你的頭文件獨立自足,如果不能,就依賴其它文件中的聲明,而不是定義。其它每一件事都從這個簡單的設計策略產(chǎn)生。所以:
當對象的引用和指針可以做到時就避免使用對象。僅需一個類型的聲明,你就可以定義到這個類型的引用或指針。而定義一個類型的對象必須要存在這個類型的定義。
只要你能做到,就用對類聲明的依賴替代對類定義的依賴。注意你聲明一個使用一個類的函數(shù)時絕對不需要有這個類的定義,即使這個函數(shù)通過傳值方式傳遞或返回這個類:
class Date; // class declaration
Date today(); // fine - no definition
void clearAppointments(Date d); // of Date is needed
當然,傳值通常不是一個好主意,但是如果你發(fā)現(xiàn)你自己因為某種原因而使用它,依然不能為引入不必要的編譯依賴辯解。
不聲明 Date 就可以聲明 today 和 clearAppointments 的能力可能會令你感到驚奇,但是它其實并不像看上去那么不同尋常。如果有人調(diào)用這些函數(shù),則 Date 的定義必須在調(diào)用之前被看到。為什么費心去聲明沒有人調(diào)用的函數(shù),你想知道嗎?很簡單。并不是沒有人調(diào)用它們,而是并非每個人都要調(diào)用它們。如果你有一個包含很多函數(shù)聲明的庫,每一個客戶都要調(diào)用每一個函數(shù)是不太可能的。通過將提供類定義的責任從你的聲明函數(shù)的頭文件轉(zhuǎn)移到客戶的包含函數(shù)調(diào)用的文件,你就消除了客戶對他們并不真的需要的類型的依賴。
為聲明和定義分別提供頭文件。為了便于堅持上面的指導方針,頭文件需要成對出現(xiàn):一個用于聲明,另一個用于定義。當然,這些文件必須保持一致。如果一個聲明在一個地方被改變了,它必須在兩處都被改變。得出的結(jié)果是:庫的客戶應該總是 #include 一個聲明文件,而不是自己前向聲明某些東西,而庫的作者應該提供兩個頭文件。例如,想要聲明 today 和 clearAppointments 的 Date 的客戶不應該像前面展示的那樣手動前向聲明 Date。更合適的是,它應該 #include 適當?shù)挠糜诼暶鞯念^文件:
#include "datefwd.h" // header file declaring (but not
// defining) class Date
Date today(); // as before
void clearAppointments(Date d);
僅有聲明的頭文件的名字 "datefwd.h" 基于來自標準 C++ 庫的頭文件 <iosfwd>。<iosfwd> 包含 iostream 組件的聲明,而它們相應的定義在幾個不同的頭文件中,包括 <sstream>,<streambuf>,<fstream> 和 <iostream>。
<iosfwd> 在其它方面也有啟發(fā)意義,而且它解釋了本文所提出的建議對于模板和非模板一樣有效。盡管在很多構(gòu)建環(huán)境中,模板定義的典型特征是位于頭文件中,但有些環(huán)境允許模板定義在非頭文件中,所以為模板提供一個僅有聲明的頭文件依然是有意義的。<iosfwd> 就是一個這樣的頭文件。
C++ 還提供了 export 關鍵字允許將模板聲明從模板定義中分離出來。不幸的是,支持 export 的編譯器非常少,而與 export 打交道的實際經(jīng)驗就更少了。結(jié)果是,現(xiàn)在就說 export 在高效 C++ 編程中扮演什么角色還為時尚早。
像 Person 這樣的使用 pimpl 慣用法的類經(jīng)常被稱為 Handle 類。為了避免你對這樣的類實際上做什么事的好奇心,一種方法是將所有對他們的函數(shù)調(diào)用都轉(zhuǎn)送給相應的實現(xiàn)類,而使用實現(xiàn)類來做真正的工作。例如,這就是兩個 Person 的成員函數(shù)可以被如何實現(xiàn)的例子:
#include "Person.h" // we’re implementing the Person class,
// so we must #include its class definition
#include "PersonImpl.h" // we must also #include PersonImpl’s class
// definition, otherwise we couldn’t call
// its member functions; note that
// PersonImpl has exactly the same
// member functions as Person - their
// interfaces are identical
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 的成員函數(shù)是如何調(diào)用 PersonImpl 的成員函數(shù)的,以及 Person::name 是如何調(diào)用 PersonImpl::name 的。這很重要。使 Person 成為一個 Handle 類不需要改變 Person 要做的事情,僅僅是改變了它做事的方法。
另一個不同于 Handle 類的候選方法是使 Person 成為一個被叫做 Interface 類的特殊種類的抽象基類。這樣一個類的作用是為派生類指定一個接口。結(jié)果,它的典型特征是沒有數(shù)據(jù)成員,沒有構(gòu)造函數(shù),有一個虛析構(gòu)函數(shù)和一組指定接口的純虛函數(shù)。
Interface 類類似 Java 和 .NET 中的 Interfaces,但是 C++ 并不會為 Interface 類強加那些 Java 和 .NET 為 Interfaces 強加的種種約束。例如,Java 和 .NET 都不允許 Interfaces 中有數(shù)據(jù)成員和函數(shù)實現(xiàn),但是 C++ 不禁止這些事情。C++ 的巨大彈性是有用處的。在一個繼承體系的所有類中非虛擬函數(shù)的實現(xiàn)應該相同,因此將這樣的函數(shù)實現(xiàn)為聲明它們的 Interface 類的一部分就是有意義的。
一個 Person 的 Interface 類可能就像這樣:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
};
這個類的客戶必須針對 Person 的指針或引用編程,因為實例化包含純虛函數(shù)的類是不可能的。(然而,實例化從 Person 派生的類是可能的)和 Handle 類的客戶一樣,除非 Interface 類的接口發(fā)生變化,否則 Interface 類的客戶不需要重新編譯。
一個 Interface 類的客戶必須有辦法創(chuàng)建新的對象。他們一般通過調(diào)用一個為“可以真正實例化的派生類”扮演構(gòu)造函數(shù)的角色的函數(shù)做到這一點的。這樣的函數(shù)一般稱為 factory 函數(shù)或虛擬構(gòu)造函數(shù)(virtual constructors)。他們返回指向動態(tài)分配的支持 Interface 類的接口的對象的指針(智能指針更合適)。這樣的函數(shù)在 Interface 類內(nèi)部一般聲明為 static:
class Person {
public:
...
static std::tr1::shared_ptr<Person> // return a tr1::shared_ptr to a new
create(const std::string& name, // Person initialized with the
const Date& birthday, // given params; see Item 18 for
const Address& addr); // why a tr1::shared_ptr is returned
...
};
客戶就像這樣使用它們:
std::string name;
Date dateOfBirth;
Address address;
...
// create an object supporting the Person interface
std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name() // use the object via the
<< " was born on " // Person interface
<< pp->birthDate()
<< " and now lives at "
<< pp->address();
... // the object is automatically
// deleted when pp goes out of
當然,在某些地點,必須定義支持 Interface 類的接口的具體類并調(diào)用真正的構(gòu)造函數(shù)。這所有的一切發(fā)生的場合,在那個文件中所包含虛擬構(gòu)造函數(shù)的實現(xiàn)之后的地方。例如,Interface 類 Person 可以有一個提供了它繼承到的虛函數(shù)的實現(xiàn)的具體的派生類 RealPerson:
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; // implementations of these
std::string birthDate() const; // functions are not shown, but
std::string address() const; // they are easy to imagine
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 的一個更現(xiàn)實的實現(xiàn)會創(chuàng)建不同派生類型的對象,依賴于諸如,其他函數(shù)的參數(shù)值,從文件或數(shù)據(jù)庫讀出的數(shù)據(jù),環(huán)境變量等等。
RealPerson 示范了兩個最通用的實現(xiàn)一個 Interface 類機制之一:從 Interface 類(Person)繼承它的接口規(guī)格,然后實現(xiàn)接口中的函數(shù)。實現(xiàn)一個 Interface 類的第二個方法包含多繼承(multiple inheritance),在 Item 40 中探討這個話題。
Handle 類和 Interface 類從實現(xiàn)中分離出接口,因此減少了文件之間的編譯依賴。如果你是一個喜好挖苦的人,我知道你正在找小號字體寫成的限制!八羞@些把戲會騙走我什么呢?”你小聲嘀咕著。答案是計算機科學中非常平常的:它會消耗一些運行時的速度,再加上每個對象的一些額外的內(nèi)存。
在 Handle 類的情況下,成員函數(shù)必須通過實現(xiàn)的指針得到對象的數(shù)據(jù)。這就在每次訪問中增加了一個間接層。而且你必須在存儲每一個對象所需的內(nèi)存量中增加這一實現(xiàn)的指針的大小。最后,這一實現(xiàn)的指針必須被初始化(在 Handle 類的構(gòu)造函數(shù)中)為指向一個動態(tài)分配的實現(xiàn)的對象,所以你要承受動態(tài)內(nèi)存分配(以及隨后的釋放)所固有的成本和遭遇 bad_alloc (out-of-memory) 異常的可能性。
對于 Interface 類,每一個函數(shù)調(diào)用都是虛擬的,所以你每調(diào)用一次函數(shù)就要支付一個間接跳轉(zhuǎn)的成本。還有,從 Interface 派生的對象必須包含一個 virtual table 指針。這個指針可能增加存儲一個對象所需的內(nèi)存的量,依賴于這個 Interface 類是否是這個對象的虛函數(shù)的唯一來源。
最后,無論 Handle 類還是 Interface 類都不能在 inline 函數(shù)的外面大量使用。函數(shù)本體一般必須在頭文件中才能做到 inline,但是 Handle 類和 Interface 類一般都設計成隱藏類似函數(shù)本體這樣的實現(xiàn)細節(jié)。
然而,因為它們所涉及到的成本而簡單地放棄 Handle 類和 Interface 類會成為一個嚴重的錯誤。虛擬函數(shù)也是一樣,但你還是不能放棄它們,你能嗎?(如果能,你看錯書了。)作為替代,考慮以一種改進的方式使用這些技術(shù)。在開發(fā)過程中,使用 Handle 類和 Interface 類來最小化實現(xiàn)發(fā)生變化時對客戶的影響。當能看出在速度和/或大小上的不同足以證明增加類之間的耦合是值得的時候,可以用具體類取代 Handle 類和 Interface 類供產(chǎn)品使用。
Things to Remember
最小化編譯依賴后面的一般想法是用對聲明的依賴取代對定義的依賴;诖讼敕ǖ膬蓚方法是 Handle 類和 Interface 類。
庫頭文件應該以完整并且只有聲明的形式存在。無論是否包含模板都適用于這一點。