1. 程式人生 > >《Effective C++》:條款31:將檔案間的編譯依存關係降至最低

《Effective C++》:條款31:將檔案間的編譯依存關係降至最低

假如你在修改程式,只是修改了某個class的介面的實現,而且修改的是private部分。之後,你編譯時,發現好多檔案都被重新編譯了。這種問題的發生,在於沒有把“將介面從實現中分離”。Class的定義不只是詳細敘述class介面,還包括許多實現細目:

    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; Date the BirthDate; Address theAddress; };

要想編譯,還要把class中用到的string、Date、Address包含進來。在Person定義檔案的最前面,應該有:

    #include<string>
    #include"date.h"
    #include
"address.h"

這樣一來,Person定義檔案和其含入檔案之間形成了一種編譯依存關係(compilation dependency)。如果這些標頭檔案有一個修改,那麼使用Person class的檔案要重新編譯。這樣的連串編譯依存關係(cascading compliation dependencies)會給專案造成許多不便。
那麼為什麼C++把class的實現細目置於class定義式中?可以把實現細目分開:

    namespace std{
        class string;//前置宣告
    }
    class Date;//前置宣告
    class Address;//前置宣告
class Person{ public: …… };

首先不討論前置宣告是否正確(實際上是錯誤的),如果可以這麼做,Person的客戶只需要在Person介面被修改時才重新編譯。但是這個想法有兩個問題。
- 1、string不是個class,它是個typedef,定義為basic_string。上面對string的前者宣告並不正確,正確的前置宣告比較複雜,因為涉及額外的templates。實際上,我們不應該宣告標準庫,使用#include即可。標準標頭檔案一般不會成為編譯瓶頸,尤其是在你的建置環境中允許使用預編譯標頭檔案(precompiled headers)。如果解析(parsing)標準標頭檔案是個問題,一般情況是你需要修改你的介面設計。
- 2、前置宣告的每一件東西,編譯器必須在編譯期間知道物件的大小。例如

 int main()
    {
        int x;
        Person p( params);
        ……
    }

當編譯器看到x定義式,必須知道給x分配多少記憶體;之後當編譯器看到p的定義時,也應該知道必須給p分配多少記憶體。如果class的定義式不列出實現細目,編譯器無法知道給p分配多少空間。

這個問題在Java等語言上不存在,因為它們在定義物件時,編譯器只是分配一個指標(用來指向該物件)。上述程式碼實現是這個樣子:

 int main()
    {
        int x;
        Person* p;//定義一個指向Person的指標
        ……
    }

在C++中,也可以這樣做,將物件實現細目隱藏在一個指標背後。針對Person,可以把它分為兩個classes,一個負責提供介面,另一個負責實現該介面。負責實現的介面取名為PersonImpl(Person implementation):

    #include<string>
    #include<memory>
    class PersonImpl; //前置宣告
    class Date;
    class Address;

    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::tr1::shared_ptr<PersonImpl> pImpl;//指標,指向實現
    };

這樣的設計稱為pimpl idiom(pimpl:pointer to implementation)。Person的客戶和Date、Address以及Person的實現細目分離了。classes的任何實現修改都不要客戶端重新編譯。此外,客戶還無法看到Person的實現細目,也就不會寫出“取決於那些細目的程式碼”,真正實現了“介面與實現分離”。

這個分離的關鍵在於“宣告的依存性”替換了“定義的依存性”,這正是編譯依存性最小化的本質:現實中,讓標頭檔案儘可能自我滿足,萬一做不到,則讓它與其他檔案內的宣告(不是定義)相依。其他都源於以下設計策略:

  • 如果使用object references或object pointers可以完成任務,就不要使用object。因為,使用references或pointers只需要一個宣告,而定義objects需要使用該型別的定義。
  • 如果可以,儘量以class宣告式替換class定義式。但是,當宣告函式使用某個class時,即使是by value方式傳遞該型別引數/返回值,都不需要class定義。但是在使用這些函式時,這些classes在呼叫函式前一定要先曝光。客戶終究是要知道classes的定義式,但是這樣做的意義在於:將提供class定義式(通過#include完成)的義務,從函式宣告所在標頭檔案,轉移到函式呼叫的客戶檔案。
  • 為宣告式和定義式提供兩個不同的標頭檔案。程式中,不應該讓客戶給出前置宣告,程式作者一般提供兩個標頭檔案,一個用於宣告式,一個用於定義式。在C++標準庫的標頭檔案中(條款54),<iosfwd>內含iostream各元件的宣告式,其對應定義分佈在不同檔案件,包括<sstream>,<streambuf>,<fstream>,<iostream>

<iosfwd>說明,本條款同樣適用於templates和non-templates。條款 30中提到,template通常定義在標頭檔案內,但也有些建置環境允許template定義在非標頭檔案;這樣就可以將“只含宣告式”的標頭檔案提供給templates。<iosfwd>就是這樣一個檔案。

C++中提供關鍵字export來將template宣告和定義分割在不同檔案內。但是支援export關鍵字的編譯器並不多。

像Person這樣使用pimpl idiom的classes叫做Handle classes。這樣的class真正做事的方法之一是將他們所有的函式轉交給相應的實現類(implementation classes),由實現類完成實際工作。例如Person的實現:

    #include"Person.h"
    #include"PersonImpl.h"
    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();
    }
    ……

在PersonImpl中,有著和Person完全相同的成員函式,兩者介面完全相同。

還有一種實現Handle class的辦法,那就是令Person成為一種特殊的abstract base class(抽象基類),稱作Interface class。這樣的class成員變數,只是描述derived classes介面(條款 34),也沒有建構函式,只有一個virtual解構函式( 條款 7)和這一組pure virtual函式,用來敘述整個介面。

Interface classes類似Java和.NET的Interface,但是C++的Interface class不同於Java和.NET中的Interface,它允許有變數,更具有彈性。正如 條款 36所言,“non-virtual函式的實現”對繼承體系內所有classes都應該相同。將這樣的函式實現為Interface class(其中寫有相應宣告)的一部分也是合理的。

像Person這樣的Interface class可以這些寫:

 class Person{
    public:
        virtual ~Person();
        virtual std::string name() const=0;
        virtual std::string birthDate()const=0;
        virtual std::string address()const=0;
        ……
    };

這個class的客戶必須使用Person的pointers或references,因為內含pure virtual函式的class無法例項化。這樣一來,只要Interface class的介面不被修改,其他客戶就不需要重新編譯。

Interface class的客戶在為class建立新物件時,通常使用一個特殊函式,這個函式扮演“真正將被具體化”的那個derived classes的建構函式的角色。這樣的函式叫做工程函式factory(條款13)或virtual建構函式,它們返回指標(更有可能為智慧指標,**條款**18),指向動態分配所得物件,這個物件支援Interface class介面。factory函式通常宣告為static

 class Person{
    public:
        ……
        static std::tr1::shared_ptr<Person create(……)
        ……
    };

客戶這樣使用

    std::string name;
    Date dateOfBirth;
    Address address;
    std::tr1::shared_ptr<Person> pp(Person::create(……));
    std::cout<<pp->name()<<"was born"<<pp->birthDate()<<"and now lives at"<<pp->address();

支援Interface class介面的那個具體類(concrete classes)在真正的建構函式呼叫之前要被定義好。例如,有個RealPerson繼承了Person

    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;
        ……
    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));
    }

RealPerson實現Interface class的機制是:從Interface class繼承介面,然後實現出介面所覆蓋的函式。還有一種實現方法,設計多重繼承,在**條款**40探討。

Handle classes和Interface classes解除了介面和實現之間的耦合關係,從而降低了編譯依存性。但是為了也帶了一些代價:使你喪失了執行期間若干速度,又開闢了超出物件若干記憶體。

在Handle classes身上,成員函式通過implementation pointer取得物件資料。這樣為訪問增加了一層間接性,記憶體也增加了implementation pointer的大小。implementation pointer的初始化,還要帶了動態開闢記憶體的額外開銷,蒙受遭遇bad_alloc異常的可能性。

在Interface classes身上,每個函式都是virtual的,所以每次呼叫要付出一個間接跳躍(indirect jump)成本。其派生物件會有一個vptr(virtual table pointer,**條款**7),增加了物件所需記憶體。

Handle classes和Interface classes,一旦脫離inline函式,都無法有太大作為。**條款**30說明為什麼inline函式要置於標頭檔案,但Handle classes和Interface classes被設計用來隱藏實現細節。

我們要做的是,在程式中使用Handle classes和Interface classes,以求實現程式碼有所變化時,對其客戶帶來最小影響。但如果它們導致的額外成本過大,例如導致執行速度或物件大小差異過大,以至於classes之間的耦合相比之下不成為關鍵時,就以具體類(concrete classes)替換Handle classes和Interface classes。

總結
- 支援“編譯依存性最小化”的一般構想是:相依於宣告式,不要相依於定義時。基於此構想的兩個手段是Handle classes和Interface classes。
- 程式庫標頭檔案應該以“完全且僅有宣告式”(full and declaration-only forms)的形式存在。不論是否這幾templates,這種做法都是適用。