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

考虑下面的类:

class Person
{
private:
	std::string theName;
	Date theBirthDate;
	Address theAddress;
public:
	...
};

如果没有取得string,Date和Address的定义式,编译无法通关。
所以Person定义文件的最上方很可能存在这样的东西:

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

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

如果像下面这定义呢?

namespace std
{
	class string;
};
class Date;//前置声明
class Address;//前置声明
class Person
{
private:
	std::string theName;
	Date theBirthDate;
	Address theAddress;
public:
	...
};

如果可以像上面这样做,Person的客户只需要在Person接口修改过时才重新编译.
但是这个想法存在两个问题:
1.string不是一个class,它是typedef(定义为basic_string<char>).
针对string的前置声明不正确
2.编译器必须在编译期间知道对象的大小
。考虑下面的代码:

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

当编译器看到x的定义式,它知道分配多少内存才够持有一个int.
这没问题,因为编译器知道int多大。当编译器看到Person的定义式,它也知道必须分配足够空间以放置一个Person.
问题在于:编译器如何知道一个Person对象有多大?
编译器获得这项信息的唯一办法就是询问class定义式。然而如果class定义式可以合法地不列出实现细目,那编译器这么知道该分配多少空间?

针对Person我们可以这样做:把Person分割为两个类,一个只提供接口,另一个负责实现接口.
Person将定义如下:

#include <string>
#include <memory>
class PersonImpl;
class Date;
class Person
{
public:
	...
private:
	std::shared_prt<PersonImpl>p;
}

上面代码中,Person只内含一个指针成员,指向其实现类(PersonImpl).
这种设计叫做pimpl idiom(pointer to implementation)

这样的设计下,Person的客户完全与Date,Address以及Person的实现细目分离了。
那些类的任何实现修改都不需要Person客户端重新编译。这才是真正的接口与实现分离

这种设计策略如下:
1.如果使用对象引用或对象指针可以完成任务,就不要使用对象.
2.如果能够,尽量以类声明式替换定义式。

3.为声明式和定义式提供不同的头文件

像Person这样使用pimpl idiom的类,往往被称为Handle 类。也许你会纳闷,这样的类如何真正做点事情。办法之一是将它们的所有函数转交给相应的实现类,并由后者完成实际工作.
比如:

#include "Person.h"
#include "PersonImpl.h"

std::string Person::name()const
{
	return pImpl->name();
}

另一个制作Handle class的方法是特殊的抽象基类.
称为接口类.这样类的目的是详细一一描述派生类的接口,它通常不带成员变量,也没有构造函数,只有一个virtual析构函数和一组pure virtual函数
这种接口类有点类似C#的接口,但C++的接口可以在接口内实现成员变量或成员函数,而C#不允许.

一个针对Person而写的接口类看起来像这样:

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

就像Handle 类的客户一样,除非接口类的接口被修改否则其客户不需重新编译.

这样的接口类必须要有办法为这种类创建新对象,这个方法通常是一个工厂函数或virtual构造函数。它们返回指针,指向动态分配所得对象,而该对象支持接口类的接口。
比如:

class Person
{
public:
static std::shared_prt<Person> create(...);
	...
};

可以这样使用:

string name;
Date dateOfBirth;
Address address;

std::shared_ptr<Person>p(Person::create(name,dateOfBirth,address));
...
std::cout << p->name()
		  << p->birthDate()
		  << p->address();
...

当然支持接口类接口的那个具象类必须被定义出来,而且真正的构造函数必须被调用,假如接口类Person有一个具象的派生类RealPeroson如下:

class RealPerson: public Person
{
public:
	RealPerson(...){...}
	...
	string name()const;
	string birthDate()const;
	string address()const;
private:
	string theName;
	Date theBirthDate;
	Address theAddress;
};

有了RealPerson,写出create就很正常了:

std::shared_ptr<Person>Person::create(....)
{
	return std::shared_ptr<Person>(new RealPerson(...));
}

Handle 类和接口类解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性.有利也有弊,我们要付出什么代价呢?
它会让你在运行期间丧失一定的速度,也让你为每个对象超额付出一定的内存.

在Handle类身上,成员函数必须通过指针取得对象数据,每一次访问增加一层间接性,而每一个对象消耗的内存数量必须增加指针的大小。
最后指针必须初始化,指向一个动态分配得来的对象。所以必须负担动态内存分配以及释放所带来的消耗,以及遭遇内存不足异常的可能性

接口类由于每个函数都是虚函数,每次函数调用都付出一个间接跳跃的成本,此外接口类派生的对象必须含有一个虚函数表指针.这个指针会增加存放对象所需的内存数量.

总结:

1.编译依存性最小化的一般思路是:相依于声明式,不要相依于定义式,常用的两个方法是:Handle classes和Interface classes.
2.程序库头文件应该仅仅只含有声明式,不应该含有定义式

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值