32.降低文件间的编译依存关系

前言

 
某一天你对某个class中的部分实现文件做了些许更改,注意,改的不是接口,仅仅是实现,并且是private部分。当你按下build之后你估计大概要等几秒,但实际上。。。。你发现整个世界都被重新编译和连接了。


问题实例

 
上述问题出在C++对“将接口从实现中分离”完成得不好。class的定义式不仅仅描述了接口,还包括了实现细目

1
2
3
4
5
6
7
8
class Person{
public:
...
private:
//以下均为实现细目
string theName;
Address theAddress;
}

如果编译器没有取得上述程序所用到的string,Date等定义式,它就无法编译。​
当然,我们肯定会使用
1
2
#include <string>
#include "Address.h"

不幸的是,这样一来便是在Person定义文件和其含入文件间形成了一种compilation dependency。
如果这些头文件中有任何一个被改变,或这些头文件所依赖的其他头文件有任何改变,那么每一个#include "Person.h"的文件都必须重新编译,任何使用了Person对象的文件也必须重新编译。


前置声明

有人会好奇为何不使用前置声明分开实现细目:

1
2
3
4
5
6
7
8
9
10
namespace std{
class string;//前置声明
}
class Address;//前置声明
class Person{
public:
...
private:
...
}

如果这样能够完成,那么仅有Person被改过之后客户才需要重新编译。

前置声明的问题

  1. string并不是class
    string并不是一个class,它是一个typedef。并且我们不应该尝试手工声明一部分STL,而是应该使用include完成目的,标准头文件不会成为编译瓶颈。
  2. 编译器必须在编译期间知道对象的大小
    1
    2
    3
    4
    int main(){
    Person p(args);
    ...
    }
    当编译器看到Person p(args),它必须明确一个Person对象需要分配多少内存,该问题的答案只有通过询问class定义式才能得到。然而如果class可以合法地不列出实现细目,编译器如何知道该分配多少空间?

接口与实现分离

 
前置声明的第二个问题在Java中根本不是问题,因为它在定义对象时编译器只分配足够空间给一个指针。也就是说,其具体实现效果大概是这样:

1
2
3
int main(){
Person *p;
}

pimpl设计模式

当然,我们也能在c++中这么做,将对象实现细目隐藏于一个指针背后。

pimpl实例

举例而言,对于Person我们可以:把person分割为两个classes,一个只提供接口,另一个负责实现该接口。其具体实现类名为PersonImpl。

1
2
3
4
5
6
7
8
9
10
11
#include <string>
#include <memroy>

class PersonImpl;
class Address;
class Person{
public:
...
private:
shared_ptr<PersonImpl> pImpl;
};

pimp分析

Person内部只含有一个指针成员指向实现类,这种设计被称为pimpl idiom(pointer to implementation)在这种设计下,Person的客户完全与Address以及Persons的实现细目分离。

该分离的关键在于以“声明的依存性”代替了“定义的依存性”,而这正是编译依存性最小化的本质:让头文件尽可能自我满足,否则就让它与其他文件内的声明式(而非定义式)相互依存。其设计策略大致有三:

  • 如果使用object references或者object pointers就可以完成任务,就不要使用objects。
    当你使用后者时,必须使用该类型的定义式。
  • 在条件允许的情况下,尽量用class声明式替换class定义式。
  • 为声明式和定义式提供不同的头文件。
    为了维持上述的两个准则,我们会定义两个一致的头文件,一个用于声明(Person.h),一个用于定义(PersonImpl.h)。客户程序显然总是应该#include声明文件而非前置声明若干函数。

Handle class

handle class 工作原理

像person这样使用pimpl idiom的classes往往被称为handle classes.其工作方法之一是把所有函数都转交给相应的实现类并由后者完成实际工作,如下所示

1
2
3
4
5
6
7
8
9
10
//"Person.cpp"
#include "Person.h"
#include "PersonImpl.h"

Person::Person(const string&name,const Address& addr)
:pImpl(new PersonImpl(name,addr)) {}//调用PersonImpl构造函数

const Address Person::getAddress() const{
return pImpl->address();
}

这里需要注意的是,Person以new调用PersonImpl的构造函数,任何操作都在调用PersonImpl对象的相关函数。让一个class变为handle class并不会改变它做的事,只会改变它做事的方法。

Interface class

除了使用pimpl,另一种制作handle class的方法是令Person成为一种特殊的abstract base class,我们称其为Interface class.
这种class的目的是详细描述derived classes的接口,因此它通常没有成员变量和构造函数只有一个virtual析构函数以及一组pure virtual函数,用来叙述整个接口。
C++ 中的Interface class类似于java的Interfaces,但是略有不同,比如c++不禁止在interface内部实现成员变量或non-virtual成员函数。

Interface class实例

仍以Person为例:

1
2
3
4
5
6
7
class Person{
public:
virtual ~Person();
virtual const string getName() const = 0;
virtual const Address getAddress() const = 0;
...
}

该class的用户必须以指向Person的指针和或eference来编写应用程序,因为该类无法具现出实体。正如handle class的客户一样,除非interface class的接口发生改变,否则无需重新编译。

Interface class与对象构造

如何为Interface class创建新对象?
一般而言,我们会选择调用一个特殊函数,此函数扮演“真正将被具现化”的那个derived classes的构造函数角色。
这种函数通常称为factory函数或者virtual构造函数(详见More Effective C++ 25)其返回一个指针(或者智能指针)指向动态分配所得对象,而该对象又支持interface class的接口。此类函数一般被声明为static:

1
2
3
4
class person
public
static shared_ptr<person> create(const string &name,const Address &addr);
};

客户的使用操作如下所示:
1
2
3
4
5
string theName;
Address theAddress;
shared_ptr<person> pp(person::create(name,addr));//构建对象
cout << pp->getName();
//当pp离开作用域,对象自动删除

当然,支持Interface class接口的那个conrete class必须被定义出来,且真正的构造函数必须被调用。举例而言,我们现有一个具象类RealPerson继承自Person:
1
2
3
4
5
6
7
8
9
class RealPerson::public Person{
public:
RealPerson(const string& name,const Address& addr)
:theName(name),theAddr(addr) {}
...
private:
string theName;
Address theAddr;
}

那么create的定义式应当类似于如下形式(暂且忽略factory塑造不同类型对象的特性,仅仅把关注点置于构造)::
1
2
3
    shared_ptr<person> person::create(const string &name,const Addresss &addr){
return shared_ptr<person>(new realperson(name,addr));
}

handle class成本及风险

  • 在handle class中,成员函数必须通过一个implementation pointer取得对象数据,这无疑增加了访问的间接性。另外,implementation pointer必须初始化(在handle class内)指向一个动态分配的implementation object,所以我们还得承受动态分配和释放所带来的开销,以及遭遇bad_alloc的尴尬。
  • Interface class的每一个函数都是virtual,所以每一次函数使用都是一次间接函数指针访问,并且interface class派生的对象一定会含有一个vptr,这无疑增大了内存需求。(详见More Effective C++ 24)
  • handle class 与Interface class 都需要inline的支持。

总结

  1. 编译依存性最小化的一般构想:依赖于声明式而非定义式。基于此构想的两个实现分别是Handle class与Interface class.
  2. 程序库头文件应该有且仅有声明式,无论其是否涉及template。