考虑下面的类:
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.程序库头文件应该仅仅只含有声明式,不应该含有定义式