1. 程式人生 > >《Effective C++》條款34: 將檔案間的編譯依賴性降至最低

《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)。這個指標可能會增加物件儲存所需要的記憶體數量(具體取決於:對於物件的虛擬函式來說,此協議類是不是它們的唯一來源)。

最後一點,控制代碼類和協議類都不大會使用行內函數。使用任何行內函數時都要訪問實現細節,而設計控制代碼類和協議類的初衷正是為了避免這種情況。

但如果僅僅因為控制代碼類和協議類會帶來開銷就把它們打入冷宮,那就大錯特錯。正如虛擬函式,你難道會不用它們嗎?(如果回答不用,那你正在看一本不該看的書!)相反,要以發展的觀點來運用這些技術。在開發階段要儘量用控制代碼類和協議類來減少 "實現" 的改變對使用者的負面影響。如果帶來的速度和/或體積的增加程度遠遠大於類之間依賴性的減少程度,那麼,當程式轉化成產品時就用具體類來取代控制代碼類和協議類。希望有一天,會有工具來自動執行這類轉換。

有些人還喜歡混用控制代碼類、協議類和具體類,並且用得很熟練。這固然使得開發出來的軟體系統執行高效、易於改進,但有一個很大的缺點:還是必須得想辦法減少程式重新編譯時消耗的時間。