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

目录

1.前言

2.实例分析

3.总结


1.前言

假设对c++程序的某个class实现文件做了轻微的修改。注意,这里修改的不是class接口,而是实现,而且只修改了private成分。然后重新建置这个程序,并预计只花几秒就完成。当你按下Build按钮或键入make,此时你意识到所有程序都被重新编译和连接,与当初预想的不一样

2.实例分析

这里问题出现在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 birthData() const;
        std::string address() const;
        ...
    private:
        std::string theName;//实现细节
        Date theBirthDate;//实现细节
        Address theAddress;//实现细节
}

这里的class Person无法通过编译-如果编译器没有取得实现代码所用到的classes string.Date和Address的定义式。这样的定义式通常由#include指示符提供,所以Person定义文件的最上方很可能存在这样的东西:

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

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

为什么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而做的前置声明并不准确。

关于“前置声明的每一个变量”,编译器在编译期间必须知道对象的大小。比如:

int main()
{

    int x;//定义一个int
    Person p(params);//定义一个Person
    ...
}

当编译器看到x的定义式,它知道必须分配多少内存(通常位于stack内)才够维持一个int。每个编译器都知道一个int有多大。当编译器看到p的定义式,它也知道必须分配足够的空间来放置一个Person,但它如何知道一个Person对像有多大呢?编译器获得这项信息的唯一办法就是询问class定义式。然而如果class定义式可以合法的不列出实现细目,编译器又该如何知道分配多少空间呢?

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

int main()
{
    int x;//定义一个int
    person* p;//定义一个指针指向Person
    ...
}

这当然也是合法的c++代码,所以你也可以自己玩完“将对象实现细节隐藏于一个指针背后”的游戏。针对Person我们可以这样做: 把Person分割为两个classes,一个只提供接口,另一个负责实现该接口。如果负责实现的那个所谓implementation class取名为PersonImpl,Person将定义如下:

#include<string>
#include<memory>
class PersonImpl;//person实现类的前值声明
class Data;
class Address;
class Person{

    public:
        Person(const std::string& name,const Data& birthday,const Address& addr);c
        std::string name() const;
        std::string birthDate() const;
        std::string address() const;
        ...
    private:
        std::trl::shared_ptr<PersonImpl> pImpl;//指针,指向实现物
};

在这里,main class(person)只内含一个指针成员,指向其实现类(PersonImpl),这般设计常被称为pimpl idiom

这样的设计之下,Person的客户就完全与Datas,Address以及Persons的实现细目分离了。那些classes的任何修改都不需要Person客户端重新编译。此外由于客户无法看到Person的实现细目,也就不可能写出什么“取决于那些细目”的代码,这正是“接口=与实现分离”。

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

如果使用object reference或object pointers可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义出指向该类型的references和pointers;但如果定义某类型的objects,就需要用到该类型的定义式。

尽量以class声明式替换class定义式。注意当你声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以by value

方式传递该类型的参数亦然:

class Data;//class声明式
Data today();//
void clearAppointments(Data d);//Data的定义式

当然,pass-by-value一般而言是个糟糕的主意,但如果你发现因为某种因素被迫使用它,并不能够就此为“非必要之编译依存关系”导入正当性。

声明today函数和clearAppointments函数无需定义Data,这种能力可能会令你惊讶,这种能力会令你惊讶,但它并不是真的那么神奇。一旦任何人调用那些函数,调用之前Data定义式一定得先声明才行。如果能够将“提供class定义式的义务从”函数声明所在“之头文件转移到”内含函数调用“之客户文件,便可将”并非真正必要之类型定义“与客户端之间的编译依存性去除掉。

为声明式和定义式提供不同的头文件,为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式。当然,这些文件必要保持一致性,如果有个声明式被改变了,两个文件都得改变。因此程序库客户应该总是#include一个声明文件而非前置声明若干函数,程序库作者也应该提供这两个头文件。举个例子,Data的客户如果希望声明today和clearAppointments,它们不该像先前那样以手工方式前置声明Data,而是应该#include适当的,内含声明式的头文件:

#include"datafwd.h"//该头文件内声明(未定义)class Data
Data today();//同前
void clearAppointments(Data d);

只含声明式的那个头文件名为“datafwd.h”,命名方式取法c++标准程序库头文件的<iosfwd>。<iosfwd>内含iostream各组件的声明式,其对应定义则分布在若干不同的头文件内,包括<sstream>,<streambuf>,<fstream>和<iostream>。

<iosfwd>深具启发意义的另一个原因是它分外彰显“本条款适用于templates也适用于non-templates"(在许多build environments中template定义式通常被置于头文件内。但也有某些build environments允许template定义式放在“非头文件”内)这么一来就可以将“只含声明式”的头文件提供给templates,<iosfwd>就是这样一份头文件。

c++也提供关键字export,允许将template声明式和template定义式分割于不同的文件内。不幸的是支持这个关键字的编译器目前非常少,因此现实中使用这个关键字的经验也非常少。目前若要【评论export在高效c++中扮演什么角色也为时过早。

像Person这样使用pimpl idiom的classes,往往被称为Handle classes,这样的classes要想起到作用,办法之一就是将它们的所有函数转交给相应的实现类(implementation classes)并由后者完成实际工作。例如下面是Person两个成员函数的实现:

#include"Person.h"//正在实现Person class,所以必须#include其class定义式
#include"PersonImpl.h"//我们也必须#include PersonImpl的class定义式,否则无法调用其成员函数
Person::Person(const std:;string& name,const Data& birthday,const Address& addr):pImpl(new PersonImpl(name,birthday,addr)){}
std::string Person::name() const
{
    return pImpl->name();
}

请注意,Person构造函数以new调用PersonImpl构造函数,以及Person::name函数内调用PersonImpl::name。这是重要的,让Person变成一个Handle class并不会改变它做的事,只会改变它做事的方法。

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

Interface classes类似java,但c++的interface classes并不需要负担java的Interface所需负担的责任。举个例子,java都不允许在Interface内实现成员变量和成员函数,但c++不禁止这两样东西。比如一个针对Person而写的Interface class或许看起来像这样:

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

这个class的客户必须以Person的pointers和references来撰写应用程序,因为它不可能针对“内含pure virtual函数”的Person classes具体实体。(然而却有可能派生自Person的classes具现出实体),就像Handle classes的客户一样,除非Interface class的接口被修改,否则其客户不需要重新编译。

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

class Person{
    public:
    ...
    static std::trl::shared_ptr<Person> create(const std::string& name,const Data& birthday,const Address& addr);//返回一个trl::share_ptr,指向一个新的Person,并以给定之参数初始化
    ...
};

客户会这样使用它们:

std::string name;
Data dataOfBirth;
Address address;
...
//创建一个对象,支持Person接口
std::trl::shared_ptr<Person> pp(Person::create(name,dataOfBiorth,address));
...
std::cout<<pp->name<<"was born onn"<<pp->birthData()<<"and now lives at"<<pp->address();//当pp离开作用域,对象会自动删除

当然,支持Interface class接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用。一切都在virtual构造函数实现码所在的文件内秘密发生。结社Interface class Person有个具象的derived class RealPerson,后者提供继承而来的virtual函数实现:

class RealPerson:public Person{

    public:
        RealPerson(const std::string& name,const Date& birthday,const Address& addr):theName(name),theBirthData(birthday),theAddress(addr){}
        virtual ~RealPerson(){}
        std::string name() const;//这些函数的实现码并不显示于此
        std::string birthDate() const;
        std::string address() const;
    private:
        std::string theName;
        Data theBirthday;
        Address theAddress;
};

有了RealPerson之后,写出Person::create就一点也不稀奇了:

std::trl::shared_ptr<Person> Person::create(const std::string& name,const Data& birthday,const Address& addr){

    return std::trl::shared_ptr<Person>(new RealPerson(name,birthday,addr));
}

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

RealPerson示范实现Interface class的两个最常见的机制之一:从Interface class(Person)继承接口规格,然后实现出接口所覆盖的函数。Interface class的第二个实现法涉及多重继承。

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

在Handle classes身上,成员函数必须通过implementation pointer取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation pointer的大小。最后,implementation pointer必须初始化,指向一个动态分配得来的implementation object,所以你将蒙受因动态分配而来的额外开销,以及bad_alloc异常的可能性。

至于Interface classes,由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃成本。此外Interface class派生的对象必须内含一个vptr,这个指针可能会增加存放对象所需的内存数量-实际取决于这个对象除了Interface class之外是否还有其它virtual函数来源。

最后,不论Handle classes或Interface classes,一旦脱离inline函数都无法有太大作为

3.总结

支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes 和Interface classes.

程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法无论是否涉及templates都适用。

  • 28
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值