美文网首页C++ 杂记
条款 31:将文件间的编译依存关系降至最低

条款 31:将文件间的编译依存关系降至最低

作者: 赵者也 | 来源:发表于2017-07-30 09:46 被阅读15次

    Effective C++ 中文版 第三版》读书笔记

    ** 条款 31:将文件间的编译依存关系降至最低 **

    假设你对 C++ 程序的某个 class 实现文件做了些轻微改变,修改的不是接口,而是实现,而且只改 private 成分。

    然后重新建置这个程序,并预计只花数秒就好,当按下 “Build” 或键入 make,会大吃一惊,因为你意识到整个世界都被重新编译和链接了!

    问题是在 C++ 并没有把 “将接口从实现中分离” 做得很好。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 theBirthDate;          //实现细目 
        Address theAddress;         //实现细目 
    };
    

    这个 class Person 无法通过编译,Person 定义文件的最上方可能存在这样的东西:

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

    这样一来,便在 Person 定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果这些头文件中有任何一个被改变,或这些文件所依赖的其他头文件有任何改变,那么每个含入 Person class 的文件就得重新编译,任何使用 Person class 的文件也必须重新编译。这样的的连串编译依存关系(cascading compilation dependencies)会对许多项目造成难以形容的灾难。

    为什么 C++ 坚持将 class 的实现细目置于 class 定义式中?为什么不这样定义 Person,将实现细目分开叙述:

    namespace std { class string;} // 前置声明(不正确) 
    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; 
        ... 
    };
    

    如果这样,Person 的客户就只有在 Person 接口被修改时才重新编译。

    两个问题:第一,string 不是个 class,它是个 typedef。因此 string 前置声明并不正确,而且你本来就不应该尝试手工声明一部分标准程序库。你应该仅仅使用适当的 #includes 完成目的。标准头文件不太可能成为编译瓶颈,

    第二,编译器必须在编译期间知道对象的大小:

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

    编译器知道必须分配足够空间放置一个 Person,但是他必须知道一个 Person 对象多大,获得这一信息的唯一办法是询问 class 定义式。然而,如果 class 定义式可以合法的不列出实现细目,编译器如何知道该分配多少空间?

    此问题在 smalltalk,java 等语言上并不存在,因为当我们以那种语言定义对象时,编译器只分配足够空间给一个指针(用于指向该对象)使用。就是说它们将上述代码视同这样子:

    int main() 
    { 
        int x; 
        Person* p; 
    }
    

    这当然也是合法的 C++ 代码,所以你可以玩玩 “将对象实现细目隐藏在一个指针背后” 的游戏。可以把 Person 分割为两个 classes,一个提供接口,另一个负责实现接口。负责实现的那个所谓的 implementation class 取名为 PersonImpl,Person 将定义如下:

    #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; // 指向实现物的指针 
    };
    

    这里,Person 只内含一个指针成员,指向其实现类(PersonImpl)。这个设计常被称为 pimpl idiom(pimpl 是 “pointer to implementation” 的缩写)。

    这样,Person 的客户就完全与 Date,Address 以及 Person 的实现细目分离了。那些 classes 的任何实现修改都不需要 Person 客户端重新编译。

    这个分离的关键在于以 “声明的依存性” 替换 “定义的依存性”,那正是编译依存性最小化的本质:让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每件事都源自于这个简单的涉及策略。

    如果用 object reference 或 object pointer 可以完成任务,就不要用 objects。

    可以只靠声明式定义出指向该类型的 pointer 和 reference;但如果定义某类型的 objects,就需要用到该类型的定义式。

    如果能够,尽量以 class 声明式替换 class 定义式。

    当你声明一个函数而它用到某个 class 时,你并不需要该 class 的定义式,纵使函数以 by value 方式传递该类型的参数(或返回值)亦然:

    class Date; // class 声明式 
    Date today(); 
    void clearAppiontments(Date d);
    

    声明 today 函数和 clearAppointments 函数无需定义 Date,但是一旦有任何人调用那些函数,调用之前 Date 定义式一定得先曝光才行。如果能够将 “提供 class 定义式”(通过 #include 完成)的义务从 “函数声明所在” 之头文件移转到 “内含函数调用” 之客户文件,便可将 “并非真正必要之类型定义” 与客户端之间的编译依存性去除掉。

    为声明式和定义式提供不同的头文件。

    因此程序库客户应该总是 #inlcude 一个声明文件而非前置声明若干函数,

    #include "datefwd,h" // 这个头文件内声明 class Date 
    Date today(); 
    void clearAppointments(Date d);
    

    只含声明式的那个头文件名为 “datefwd.h”,像标准程序库的头文件 “<iosfwd>”。他分外彰显 “本条款适用于 templates 也适用于 non-templates”。许多建置环境中 template 定义式同常被置于头文件中,但也有某些建置环境允许 tamplates 定义式放在 “非头文件中”,这样就可以将 “只含声明式” 的头文件提供给 templates。

    这种使用 pimpl idiom 的 classes,往往被称为 Handle classes。

    这种 classes 的办法之一就是将他们的所有函数转交给相应的实现类(implementation classes)并由后者完成实际工作。

    #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(); 
    }
    

    另一个制作 Handle class 的办法是,令 Person 称为一种特殊的 abstract base class(抽象基类)称为 Interface classes。这种 class 的目的是详细一一描述 derived classes 的接口,因此它通常不带成员变量,也没有构造函数,只有一个 virtual 析构函数以及一组 pure virtual 函数,又来叙述整个接口。

    一个针对 Person 而写的 Interface class 或许看起来像这样:

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

    这个 class 的客户必须以 Person 的 pointers 和 reference 来撰写应用程序,不能针对 “内含 pure virtual 函数” 的 Person classes 具现出实体。除非 Interface class 的接口被修改否则其客户不需要重新编译。

    Interface class 的客户必须有办法为这种 class 创建新对象。它们通常调用一个特殊函数,此函数扮演一个 “真正将被具现化” 的那个 derived class 的构造函数角色。通常称为工厂 factory 函数或 virtual 构造函数。它们返回指针(或者更为可取的智能指针),指向动态分配所得对象,而该对象支持 interface class 的接口。这样的函数又往往在 interface class 内被声明为 static:

    class Person{ 
    public: 
        ... 
        static std::tr1::shared_ptr<Person> 
        create(const std::string& name, const Date& birthday, const Address& addr); 
    };
    

    客户可能会这样使用它们:

    std::string name; 
    Date dateBirth; 
    Address address; 
    std::tr1::shared_ptr<Person> pp(Person::create(name, dateBirth, address)); 
    ... 
    std::cout << pp->name() 
                << "was born on " 
                << PP->birthDate() 
                << " and now lives at " 
                << pp->address(); 
    ...
    

    当然支持 interface class 接口的那个具象类(concrete classes)必须被定义出来,而真正的构造函数必须被调用。

    假设有个 derived class RealPerson,提供继承而来的 virtual 函数的实现:

    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; 
        std::string birthDate() const; 
        std::string address() 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)); 
    }
    

    一个更现实的 Person::create 实现代码会创建不同类型的 derived class 对象,取决于诸如额外参数值、独自文件或数据库的数据、环境变量等等。

    RealPerson 示范实现了 Interface class 的两个最常见机制之一:从 interface class 继承接口规格,然后实现出接口所覆盖的函数。

    handle classes 和 interface classes 解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性。

    handle classed 身上,成员函数必须通过 implementation pointer 取得对象数据。那会为每一次访问增加一层间接性。每个对象消耗的内存必须增加一个 implementation pointer 的大小。 implementation pointer 必须初始化指向一个动态分配的 implementation object,所以还得蒙受因动态内存分配儿带来的额外开销。

    Interface classes,由于每个函数都是 virtual,必须为每次函数调用付出一个间接跳跃。此外 Interface class 派生的对象必须内含一个 vptr(virtual table pointer)。

    在程序开发过程中使用 handle class 和 interface class 以求实现码有所改变时对其客户带来最小冲击。

    而当他们导致速度和/或大小差异过于重大以至于 class 之间的耦合相形之下不成为关键时,就以具象类(concrete class)替换 handle class 和 interface class。

    ** 请记住: **

    1. 支持 “编译依存最小化” 的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是 Handle class 和 Interface class。
    2. 程序库头文件应该以 “完全且仅有声明式”(full and declaration-only forms)的形式存在。这种做法不论是否涉及 templates 都适用。

    相关文章

      网友评论

        本文标题:条款 31:将文件间的编译依存关系降至最低

        本文链接:https://www.haomeiwen.com/subject/hxhzkxtx.html