《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,这种做法都是适用。

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。