天天看點

Effective C++之 Item 31: 最小化檔案之間的編譯依賴

你進入到你的程式中,并對一個類的實作進行了細微的改變。提醒你一下,不是類的接口,隻是實作,僅僅是 private 的東西。然後你重建(rebuild)這個程式,預計這個任務應該隻花費幾秒鐘。畢竟隻有一個類被改變。你在 Build 上點選或者鍵入 make(或者其它等價行為),接着你被驚呆了,繼而被郁悶,就像你突然意識到整個世界都被重新編譯和連接配接!當這樣的事情發生的時候,你不讨厭它嗎?

問題在于 C++ 沒有做好從實作中剝離接口的工作。一個類定義不僅指定了一個類的接口而且有相當數量的實作細節。例如:

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 的實作使用到的類,也就是 string,Date 和 Address 的定義,類 Person 就無法編譯。這樣的定義一般通過 #include 指令提供,是以在定義 Person 類的檔案中,你很可能會找到類似這樣的東西:

#include <string>

#include "date.h"

#include "address.h"

不幸的是,這樣就建立了定義 Person 的檔案和這些頭檔案之間的編譯依賴關系。如果這些頭檔案中的一些發生了變化,或者這些頭檔案所依賴的檔案發生了變化,包含 Person 類的檔案和使用了 Person 的檔案一樣必須重新編譯,這樣的層疊編譯依賴關系為項目帶來數不清的麻煩。

你也許想知道 C++ 為什麼堅持要将一個類的實作細節放在類定義中。例如,你為什麼不能這樣定義 Person,單獨指定這個類的實作細節呢?

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;

    ...

};

如果這樣可行,隻有在類的接口發生變化時,Person 的客戶才必須重新編譯。

這個主意有兩個問題。第一個,string 不是一個類,它是一個 typedef (for basic_string<char>)。造成的結果就是,string 的前向聲明(forward declaration)是不正确的。正确的前向聲明要複雜得多,因為它包括另外的模闆。然而,這還不是要緊的,因為你不應該試着手動聲明标準庫的部件。作為替代,直接使用适當的 #includes 并讓它去做。标準頭檔案不太可能成為編譯的瓶頸,特别是在你的建構環境允許你利用預編譯頭檔案時。如果解析标準頭檔案真的成為一個問題。你也許需要改變你的接口設計,避免使用導緻不受歡迎的 #includes 的标準庫部件。

第二個(而且更重要的)難點是前向聲明的每一件東西必須讓編譯器在編譯期間知道它的對象的大小。考慮:

int main()

{

 int x;                // define an int

 Person p( params );   // define a Person

   ...

}

當編譯器看到 x 的定義,它們知道它們必須為儲存一個 int 配置設定足夠的空間(一般是在棧上)。這沒什麼問題,每一個編譯器都知道一個 int 有多大。當編譯器看到 p 的定義,它們知道它們必須為一個 Person 配置設定足夠的空間,但是它們怎麼推測出一個 Person 對象有多大呢?它們得到這個資訊的唯一方法是參考這個類的定義,但是如果一個省略了實作細節的類定義是合法的,編譯器怎麼知道要配置設定多大的空間呢?

這個問題在諸如 Smalltalk 和 Java 這樣的語言中就不會發生,因為,在這些語言中,當一個類被定義,編譯器僅僅為一個指向一個對象的指針配置設定足夠的空間。也就是說,它們處理上面的代碼就像這些代碼是這樣寫的:

int main()

{

  int x;               // define an int

  Person *p;           // define a pointer to a Person

  ...

}

當然,這是合法的 C++,是以你也可以自己來玩這種“将類的實作隐藏在一個指針後面”的遊戲。對 Person 做這件事的一種方法就是将它分開到兩個類中,一個僅僅提供一個接口,另一個實作這個接口。如果那個實作類名為 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;  // see Item 13 for info on

};                                         // std::tr1::shared_ptr

這樣,主類(Person)除了一個指向它的實作類(PersonImpl)的指針(這裡是一個 tr1::shared_ptr ——參見 Item 13)之外不包含其它資料成員。這樣一個設計經常被說成是使用了 pimpl 慣用法(指向實作的指針 "pointer to implementation")。在這樣的類中,那個指針的名字經常是 pImpl,就像上面那個。

用這樣的設計,使 Person 的客戶脫離 dates,addresses 和 persons 的細節。這些類的實作可以随心所欲地改變,但 Person 的客戶卻不必重新編譯。另外,因為他們看不到 Person 的實作細節,客戶就不太可能寫出以某種方式依賴那些細節的代碼。這就是接口和實作的真正分離。

這個分離的關鍵就是用對聲明的依賴替代對定義的依賴。這就是最小化編譯依賴的精髓:隻要能實作,就讓你的頭檔案獨立自足,如果不能,就依賴其它檔案中的聲明,而不是定義。其它每一件事都從這個簡單的設計政策産生。是以:

  • 當對象的引用和指針可以做到時就避免使用對象。僅需一個類型的聲明,你就可以定義到這個類型的引用或指針。而定義一個類型的對象必須要存在這個類型的定義。
  • 隻要你能做到,就用對類聲明的依賴替代對類定義的依賴。注意你聲明一個使用一個類的函數時絕對不需要有這個類的定義,即使這個函數通過傳值方式傳遞或傳回這個類:

class Date;                        // class declaration

Date today();                      // fine - no definition

void clearAppointments(Date d);    // of Date is needed

當然,傳值通常不是一個好主意(參見 Item 20),但是如果你發現你自己因為某種原因而使用它,依然不能為引入不必要的編譯依賴辯解。

不聲明 Date 就可以聲明 today 和 clearAppointments 的能力可能會令你感到驚奇,但是它其實并不像看上去那麼不同尋常。如果有人調用這些函數,則 Date 的定義必須在調用之前被看到。為什麼費心去聲明沒有人調用的函數,你想知道嗎?很簡單。并不是沒有人調用它們,而是并非每個人都要調用它們。如果你有一個包含很多函數聲明的庫,每一個客戶都要調用每一個函數是不太可能的。通過将提供類定義的責任從你的聲明函數的頭檔案轉移到客戶的包含函數調用的檔案,你就消除了客戶對他們并不真的需要的類型的依賴。

  • 為聲明和定義分别提供頭檔案。為了便于堅持上面的指導方針,頭檔案需要成對出現:一個用于聲明,另一個用于定義。當然,這些檔案必須保持一緻。如果一個聲明在一個地方被改變了,它必須在兩處都被改變。得出的結果是:庫的客戶應該總是 #include 一個聲明檔案,而不是自己前向聲明某些東西,而庫的作者應該提供兩個頭檔案。例如,想要聲明 today 和 clearAppointments 的 Date 的客戶不應該像前面展示的那樣手動前向聲明 Date。更合适的是,它應該 #include 适當的用于聲明的頭檔案:

#include "datefwd.h"            // header file declaring (but not

                                // defining) class Date

Date today();                   // as before

void clearAppointments(Date d);

僅有聲明的頭檔案的名字 "datefwd.h" 基于來自标準 C++ 庫(參見 Item 54)的頭檔案 <iosfwd>。<iosfwd> 包含 iostream 元件的聲明,而它們相應的定義在幾個不同的頭檔案中,包括 <sstream>,<streambuf>,<fstream> 和 <iostream>。

<iosfwd> 在其它方面也有啟發意義,而且它解釋了本 Item 的建議對于模闆和非模闆一樣有效。盡管 Item 30 解釋了在很多建構環境中,模闆定義的典型特征是位于頭檔案中,但有些環境允許模闆定義在非頭檔案中,是以為模闆提供一個僅有聲明的頭檔案依然是有意義的。<iosfwd> 就是一個這樣的頭檔案。

C++ 還提供了 export 關鍵字允許将模闆聲明從模闆定義中分離出來。不幸的是,支援 export 的編譯器非常少,而與 export 打交道的實際經驗就更少了。結果是,現在就說 export 在高效 C++ 程式設計中扮演什麼角色還為時尚早。

像 Person 這樣的使用 pimpl 慣用法的類經常被稱為 Handle 類。為了避免你對這樣的類實際上做什麼事的好奇心,一種方法是将所有對他們的函數調用都轉送給相應的實作類,而使用實作類來做真正的工作。例如,這就是兩個 Person 的成員函數可以被如何實作的例子:

#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 的成員函數是如何調用 PersonImpl 的成員函數的(通過使用 new ——參見 Item 16),以及 Person::name 是如何調用 PersonImpl::name 的。這很重要。使 Person 成為一個 Handle 類不需要改變 Person 要做的事情,僅僅是改變了它做事的方法。

另一個不同于 Handle 類的候選方法是使 Person 成為一個被叫做 Interface 類的特殊種類的抽象基類。這樣一個類的作用是為派生類指定一個接口(參見 Item 34)。結果,它的典型特征是沒有資料成員,沒有構造函數,有一個虛析構函數(參見 Item 7)和一組指定接口的純虛函數。

Interface 類類似 Java 和 .NET 中的 Interfaces,但是 C++ 并不會為 Interface 類強加那些 Java 和 .NET 為 Interfaces 強加的種種限制。例如,Java 和 .NET 都不允許 Interfaces 中有資料成員和函數實作,但是 C++ 不禁止這些事情。C++ 的巨大彈性是有用處的。就像 Item 36 解釋的,在一個繼承體系的所有類中非虛拟函數的實作應該相同,是以将這樣的函數實作為聲明它們的 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 的指針或引用程式設計,因為執行個體化包含純虛函數的類是不可能的。(然而,執行個體化從 Person 派生的類是可能的——參見後面。)和 Handle 類的客戶一樣,除非 Interface 類的接口發生變化,否則 Interface 類的客戶不需要重新編譯。

一個 Interface 類的客戶必須有辦法建立新的對象。他們一般通過調用一個為“可以真正執行個體化的派生類”扮演構造函數的角色的函數做到這一點的。這樣的函數一般稱為 factory 函數(參見 Item 13)或虛拟構造函數(virtual constructors)。他們傳回指向動态配置設定的支援 Interface 類的接口的對象的指針(智能指針更合适——參見 Item 18)。這樣的函數在 Interface 類内部一般聲明為 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

                                        // scope - see Item 13

當然,在某些地點,必須定義支援 Interface 類的接口的具體類并調用真正的構造函數。這所有的一切發生的場合,在那個檔案中所包含虛拟構造函數的實作之後的地方。例如,Interface 類 Person 可以有一個提供了它繼承到的虛函數的實作的具體的派生類 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 的一個更現實的實作會建立不同派生類型的對象,依賴于諸如,其他函數的參數值,從檔案或資料庫讀出的資料,環境變量等等。

RealPerson 示範了兩個最通用的實作一個 Interface 類機制之一:從 Interface 類(Person)繼承它的接口規格,然後實作接口中的函數。實作一個 Interface 類的第二個方法包含多繼承(multiple inheritance),在 Item 40 中探讨這個話題。

Handle 類和 Interface 類從實作中分離出接口,是以減少了檔案之間的編譯依賴。如果你是一個喜好挖苦的人,我知道你正在找小号字型寫成的限制。“所有這些把戲會騙走我什麼呢?”你小聲嘀咕着。答案是計算機科學中非常平常的:它會消耗一些運作時的速度,再加上每個對象的一些額外的記憶體。

在 Handle 類的情況下,成員函數必須通過實作的指針得到對象的資料。這就在每次通路中增加了一個間接層。而且你必須在存儲每一個對象所需的記憶體量中增加這一實作的指針的大小。最後,這一實作的指針必須被初始化(在 Handle 類的構造函數中)為指向一個動态配置設定的實作的對象,是以你要承受動态記憶體配置設定(以及随後的釋放)所固有的成本和遭遇 bad_alloc (out-of-memory) 異常的可能性。

對于 Interface 類,每一個函數調用都是虛拟的,是以你每調用一次函數就要支付一個間接跳轉的成本(參見 Item 7)。還有,從 Interface 派生的對象必須包含一個 virtual table 指針(還是參見 Item 7)。這個指針可能增加存儲一個對象所需的記憶體的量,依賴于這個 Interface 類是否是這個對象的虛函數的唯一來源。

最後,無論 Handle 類還是 Interface 類都不能在 inline 函數的外面大量使用。Item 30 解釋了為什麼函數本體一般必須在頭檔案中才能做到 inline,但是 Handle 類和 Interface 類一般都設計成隐藏類似函數本體這樣的實作細節。

然而,因為它們所涉及到的成本而簡單地放棄 Handle 類和 Interface 類會成為一個嚴重的錯誤。虛拟函數也是一樣,但你還是不能放棄它們,你能嗎?(如果能,你看錯書了。)作為替代,考慮以一種改進的方式使用這些技術。在開發過程中,使用 Handle 類和 Interface 類來最小化實作發生變化時對客戶的影響。當能看出在速度和/或大小上的不同足以證明增加類之間的耦合是值得的時候,可以用具體類取代 Handle 類和 Interface 類供産品使用。

Things to Remember

  • 最小化編譯依賴後面的一般想法是用對聲明的依賴取代對定義的依賴。基于此想法的兩個方法是 Handle 類和 Interface 類。
  • 庫頭檔案應該以完整并且隻有聲明的形式存在。無論是否包含模闆都适用于這一點。