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

        假设你对C++程序的某个class实现文件做了些轻微修改。注意,修改的不是class接口,而是实现,而且只改private成分。然后重新建置这个程序,并预计只花数秒就好。毕竟只有一个class被修改。你按下“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无法通过编译——如果编译器没有取得其实现代码所用到的classes string,Date和Address的定义式。这样的定义式通常由#include指示符提供,所以Person定义文件的最上方很可能存在这样的东西:

#include <string>

#include "date.h"

#include "address.h"

        不幸的是,这么一来便是在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果这些头文件中有任何一个被改变,或这些头文件所依赖的其他头文件有任何改变,那么每一个含入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(定义为basic_string<char>)。因此上述针对string而做的前置声明并不正确;正确的前置声明比较复杂,因为涉及额外的templates。然而那并不要紧,因为你本来就不该尝试手工声明一部分标准程序库。你应该仅仅使用适当的#includes完成目的。标准头文件不太可能成为编译瓶颈,特别是如果你的建置环境允许你使用预编译头文件。如果解析标准头文件真的是个问题,你可能需要改变你的接口设计,避免使用标准程序库中“引发不受欢迎之#includes”那一部分。

       第二: 关于“前置声明每一件东西”的另一个(同时也是比较重要的)困难是,编译器必须在编译期间知道对象的大小。考虑这个:

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

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

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

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

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

#include <string>
#include <memory>    // 此乃为了tr1::shared_ptr而含入

class PersonImpl;    // Person实现类的前置声明
class Date;          // Person接口用到的classes的前置声明 
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;  // 指针,指向实现物:std::tr1::shared_ptr 见条款13
};

        在这里,main class(Person)只内含一个指针成员(这里使用tr1::shared_ptr,见条款13),指向其实现类(PersonImpl)。这般设计常被称为pimpi idiom(pimpl是“point to implementation”的缩写)。这种classes内的指针名称往往就是pImpl,就像上面代码那样。

        这样的设计下,Person的客户就完全与Dates,Addresses以及Persons的实现细目分离了。那些classes的任何实现修改都不需要Person客户端重新编译。这真正是“接口与实现分离”!

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

  • 如果使用object references或object pointers可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义出指向该类型的references和pointers;但如果定义某类型的objects,就需要用到该类型的定义式。
  • 如果可以,尽量以class声明式替换class定义式。注意,当你声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以by value方式传递该类型的参数(或返回值)亦然:
class Date;                      // class声明式 
Date today();                    // 没问题,这里并不需要
void clearAppointments(Date d);  // Date的定义式

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

        声明today函数和clearAppointments函数而无需定义Date,这种能力可能会令你惊讶,但它并不是真的那么神奇。一旦任何人调用那些函数,调用之前Date定义式一定得先曝光才行。

  • 为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义。当然,这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。

        像Person这样使用pimpl idiom的classes,往往被称为Handles classes。也许你会纳闷,这样的classes如何真正做点事情。办法之一是将它们的所有函数转交给相应的实现类(implementation classes)并有后者完成实际工作。例如下面是Person两个成员函数的实现:

#include "Person.h"       // 我们正在实现Person class,所以必须#include其class定义式
#include "PersonImpl.h"   // 我们也必须#include PersonImpl的class定义式,否则无法调用其成员函数;
                          // 注意,PersonImpl有着和Person完全相同的成员函数,两者接口完全相同。
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。

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

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

        最后,不论Handle classes或Interface classes,一旦脱离inline函数都无法有太大作为。条款30解释过为什么函数本体为了被inlined必须(很典型地)置于头文件内,但Handle classes和Interface classes正是特别被设计用来隐藏实现细节,如函数本体。

请记住

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

个人观点:该条款描述的内容挺多,用了很多实例来论证,刚开始阅读时不太理解,反复看了4遍才感觉理解其中的观点。其中的核心思想是为了降低文件编译依存关系,尽量以指针来代替类定义。在实际项目开发中,一般情况下都很少在意这种情况,改动之后编译时间长一点都能接受,不过开发时尽量避免在.h文件中include大量的其它文件,最好能用@class。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值