Effective C++——条款31(第5章)

来源:互联网 发布:js走马灯效果 编辑:程序博客网 时间:2024/05/23 23:20

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

Minimize compilation dependencies between files.

    假设对C++程序的某个 class 实现文件做了轻微修改.注意修改的不是 class 接口,而是实现,而且只改 private 成分.然后重新build这个程序,并预计花费数秒就好.毕竟只有一个 class 被修改,结果大吃一惊!貌似整个世界都被重新编译和连接了!
    问题出在C++并没有把"将接口从实现中分离"这件事做的很好. class 的定义式不只是详细叙述了 class 接口,还包括实现细目.例如:
class Person {public:    Person(const std::string& name, const Date& birthday, const Address& addr);    std::string name() cnst;    std::string birthDate() const;    std::string address() const;    ...private:    std::string theName;    Date theBirthDate;    Address theAddress;};
    这里的 class Person无法通过编译——如果编译器没有取得实现代码所用到的 class string, Date和Address的定义式.这样的定义式通常是由#include提示符提供,所以Person定义文件的最上方很可能存在这样的东西:
#include <string>#include "date.h"#include "address.h"
不幸的是,这样一来便是在Person定义文件和其含入文件之间形成了一种编译依存关系.如果这些头文件中有任何一个被改变,或者这些头文件所依赖的其他头文件有任何改变,那么每一个含入Person class 的文件就要重新编译,任何使用Person class 的文件也必须重新编译.
为什么C++坚持将 class 的实现细目置(数据成员)于 class 定义式中?为什么不在定义式中只是定义函数?
因为编译器必须在编译期间知道对象的大小.考虑这个:
int main() {int x;Person p(params);// 定义一个Person}
当编译器看到 x 的定义式,它知道必须分配多少内存(通常位于stack内)才够维持有一个 int.OK,每一个编译器都知道一个 int 有多大.当编译器看到 p 的定义式,它也知道必须分配足够空间以放置一个Person,但它如何知道一个Person对象有多大呢?编译器获得这项信息的唯一办法就是询问 class 定义式.然而如果 class 定义式可以合法地不列出实现细目(成员变量),编译器如何知道分配多少空间?
此问题在Java语言上并不存在,因为当以Java定义对象时,编译器只分配足够空间给一个指针(用以指向该对象)使用,也就是将上述代码视为下面这样:
int main() {int x;Person *p;// 定义一个指针指向Person对象}
这对C++也是合法的代码,所以可以将对象实现细目隐藏于一个指针背后.针对Person可以这样做:把Person分割为两个 class,一个只提供接口,另一个负责实现该接口.如果负责实现的那个所谓implementation class 取名为PersonImpl,Person将定义如下:
#include <string>#include <memory>// 为了tr1::shared_ptrclass PersonImpl;// Person实现类的前置声明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;// 指针,指向实现物};
main class(Person)只内含一个指针成员(这里使用tr1::shared_ptr,详见条款13),指向其实现类PersonImpl.这样的设计称为pimpl idiom(pimpl是pointer to implementation).这种 class 内的指针名称往往就是pImpl.
这样的设计,Person的客户就完全与Date,Address以及Person的实现细目分离了.那些 class 的任何实现修改都不需要Person客户端重新编译.此外由于客户无法看到Person的实现细目,也就不可能写出"取决于那些细目"的代码.这是真正的"接口与实现分离"!
这个分离的关键在于以"声明的依存性"替换"定义的依存性",那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依.其他每一件事都源自于这个简单的设计策略:
1.如果使用object reference或object pointer可以先完成任务,就不要使用object.可以只靠一个类型声明式就定义出指向该类型的reference和pointer,但如果定义类型的object,就需要用到该类型的定义式.
2.如果能够,尽量以 class 声明式替换 class 定义式.注意,当声明一个函数而它用到某个 class 时,并不需要该 class 的定义:纵使函数以by value方式传递该类型的参数(或返回值)亦然:
class Date;// class声明式Date today();// OK,这里并不需要Date的定义式void clearAppointment(Date d);// OK
声明tody函数和clearAppointment函数而无需定义Date,这种能力可能令人惊讶.一旦任何人调用那些函数,调用之前Date定义式一定要先曝光.那么或许会疑惑,为何费心声明一个没有人调用的函数呢?其实,并非没人调用,而是并非每个人都调用.假设一个函数库内包含数百个函数声明,不可能每个客户都调用每一个函数.如果能够将"提供class的定义"(通过#include完成)的义务从"函数声明所在"的头文件转到"内含函数调用"的客户文件,便可将"并非真正必要的类型定义"与客户端之间的编译依存性去除掉.
3.为声明式和定义式提供不同的头文件.为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式.因此程序库客户应该总是#include一个声明文件而非前置声明若干函数.
像Person这样使用pimpl idiom的 class,往往被称为Handle class.那么这样的 class如何真正做点事情?其中一个办法是将它们的所有函数转交给相应的实现类(implementation class)并由后者完成实际工作.例如下面是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();}
请注意,Person构造函数以 new(详见条款16)调用PersonImpl构造函数,以及Person::name函数内调用PersonImpl::name.这是重要的,让Person变成一个Handle class 并不会改变它做的事,只会改变它做事的方法.
另一个制作Handle class 的办法是,令Person成为一种特殊的abstract base class(抽象基类),称为Interface class.这种 class 的目的是详细描述derived class 的接口(详见条款34),因此它通常不带成员变量,也没有构造函数,只有一个 virtual 析构函数(详见条款7)以及一组pure virtual 函数,用来叙述整个接口.
Interface class 类似Jave和.Net的Interface,但C++的Interface class 并不需要负担Java和.NET的Interface所需负担的责任.例如,Java和.NET都不允许在Interfaces内实现成员变量或成员函数,但C++不禁止这两样东西.
一个针对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和reference来撰写应用程序,因为它不可能针对"内含pure virtual函数"的Person class 具现出实体.就像Handle class 的客户一样,除非Interface class 的接口被修改否则客户不需要重新编译.
Interface class 的客户必须有办法为这种 class 创建新对象,他们通常调用一个特殊函数,此函数扮演"真正将被具现化"的那个derived class 的构造函数角色.这样的函数通常称为factory(工厂)函数(详见条款13)或 virtual 构造函数.它们返回指针(或更为可取的智能指针,详见条款18),指向动态分配所得对象,而该对象支持Interface class 的接口.这样的函数又往往在Interface class 内被声明为static.
class Person {public:    ...    static std::tr1::shared_ptr<Person> // 返回一个tr1::shared_ptr指向一个新的Person     create(const std::string& name, const Date& birthday, const Address& addr);    ...};
    客户会这样使用它们:
std::string name;Date dateOfBirth;Address address;... // 创建一个对象,支持Person接口std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));...std::cout << pp->name()         // 通过Person的接口使用这个对象    << "was born on " << pp->birthDate() << " and now lives at "     << pp->address();...         // 当pp离开作用域,对象会被自动删除,详见条款13
    当然,支持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), 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 继承接口规格,然后实现出接口所覆盖的函数.Interface class 的第二个实现方法涉及多重继承,详见条款40.
    Handle class 和Interface class 解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性.这些方法付出的代价是:在运行期丧失若干速度,又为每个对象超额付出若干内存.
    在Handle class 身上,成员函数必须通过implementation pointer取得对象数据.那会为每一次访问增加一层间接性.
而每一个对象消耗的内存数量必须增加implementation pointer的大小.最后,implementation pointer必须初始化(在Handle class 构造函数内),指向一个动态分配得来的implementation object,所以将蒙受动态内存分配(及其后的释放动作)而来的额外开销,以及遭遇bad_alloc异常(内存不足)的可能性.
    至于Interface class,由于每个函数都是 virtual,所以必须为每次函数调用付出一个间接跳跃(indirect jump)成本(详见条款7).此外Interface class 派生的对象必须内含一个vptr(virtual table pointer,详见条款7),这个指针可能会增加存放对象所需的内存数量——实际取决于这个对象除了Interface class 之外是否还有其他 virtual 函数来源.
    最后,不论Handle class 或Interface class,一旦脱离 inline 函数都无法有太大作为.
    如果只因为若干额外成本就不考虑Handle class 和Interface class,将是严重的错误. virtual 函数不也带来成本吗?但是绝对不会放弃它们.因此应该考虑以渐进方式使用这些技术.在程序发展过程中使用Handle class 和Interface class以求实现码有所变化时对其客户带来最小冲击.而当它们导致速度或大小差异过大以至于 class 之间的额外相比之下不成为关键时,就以具象类(contrete class)替换Handle class 和Interface class.
    注意:
    支持"编译依存性最小化"的一般构想是:相依于声明式,不要相依于定义式.基于此构想的两个手段是Handle class 和Interface class .
    程序库头文件应该以"完全仅有声明式"(full and declaration-only forms)的形式存在,这种做法不论是否涉及 template 都适用.
0 0
原创粉丝点击