<form id="hz9zz"></form>
  • <form id="hz9zz"></form>

      <nobr id="hz9zz"></nobr>

      <form id="hz9zz"></form>

    1. 明輝手游網中心:是一個免費提供流行視頻軟件教程、在線學習分享的學習平臺!

      C++箴言:最小化文件之間的編譯依賴

      [摘要]你進入到你的程序中,并對一個類的實現進行了細微的改變。提醒你一下,不是類的接口,只是實現,僅僅是 private 的東西。然后你重建(rebuild)這個程序,預計這個任務應該只花費幾秒鐘。畢竟只有一個類被改變。你在 Build 上點擊或者鍵入 make(或者其它等價行為),接著你被驚呆了,繼而被...
      你進入到你的程序中,并對一個類的實現進行了細微的改變。提醒你一下,不是類的接口,只是實現,僅僅是 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;
      }; // 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

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

        不聲明 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++ 庫的頭文件 <iosfwd>。<iosfwd> 包含 iostream 組件的聲明,而它們相應的定義在幾個不同的頭文件中,包括 <sstream>,<streambuf>,<fstream> 和 <iostream>。

        <iosfwd> 在其它方面也有啟發意義,而且它解釋了本文所提出的建議對于模板和非模板一樣有效。盡管在很多構建環境中,模板定義的典型特征是位于頭文件中,但有些環境允許模板定義在非頭文件中,所以為模板提供一個僅有聲明的頭文件依然是有意義的。<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 的成員函數的,以及 Person::name 是如何調用 PersonImpl::name 的。這很重要。使 Person 成為一個 Handle 類不需要改變 Person 要做的事情,僅僅是改變了它做事的方法。

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

        Interface 類類似 Java 和 .NET 中的 Interfaces,但是 C++ 并不會為 Interface 類強加那些 Java 和 .NET 為 Interfaces 強加的種種約束。例如,Java 和 .NET 都不允許 Interfaces 中有數據成員和函數實現,但是 C++ 不禁止這些事情。C++ 的巨大彈性是有用處的。在一個繼承體系的所有類中非虛擬函數的實現應該相同,因此將這樣的函數實現為聲明它們的 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 函數或虛擬構造函數(virtual constructors)。他們返回指向動態分配的支持 Interface 類的接口的對象的指針(智能指針更合適)。這樣的函數在 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

        當然,在某些地點,必須定義支持 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 類,每一個函數調用都是虛擬的,所以你每調用一次函數就要支付一個間接跳轉的成本。還有,從 Interface 派生的對象必須包含一個 virtual table 指針。這個指針可能增加存儲一個對象所需的內存的量,依賴于這個 Interface 類是否是這個對象的虛函數的唯一來源。

        最后,無論 Handle 類還是 Interface 類都不能在 inline 函數的外面大量使用。函數本體一般必須在頭文件中才能做到 inline,但是 Handle 類和 Interface 類一般都設計成隱藏類似函數本體這樣的實現細節。

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

        Things to Remember

        最小化編譯依賴后面的一般想法是用對聲明的依賴取代對定義的依賴。基于此想法的兩個方法是 Handle 類和 Interface 類。

        庫頭文件應該以完整并且只有聲明的形式存在。無論是否包含模板都適用于這一點。


      日韩精品一区二区三区高清