《Effective C++》:条款31:将文件间的编译依存关系降至最低

来源:互联网 发布:mac电影下载软件 编辑:程序博客网 时间:2024/05/04 09:39

    假如你在修改程序,只是修改了某个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,这种做法都是适用。

    0 0
    原创粉丝点击