好多年写的一篇博客,分享下。
开发C++项目的时候,我们可能会关心以下这两个需求:
1) 尽量减少编译的时间。这也是C++的顽疾了,往往会有这样的体检:仅仅修改了某个文件,编译却需要很长时间。我们知道,这是因为头文件之间依赖引起的,某个文件的改动会导致所有包含它的文件的重新编译。
2) 尽量减少接口和实现耦合。对于这个需求我们一般的做法是写一个类似JAVA接口的抽象类,然后继承它。那么,除了这个方法还有没有其它方法呢?
对于上面这两个需求,现代C++有个比较好的方法PIMPL(Private Implementation)机制,也叫编译器防火墙。Pimpl机制,顾名思义,将实现私有化力图使得头文件对改变不透明。主要思想是把客户与所有关于类的私有部分的实现隔离开,从而达到减少编译的时间和接口和实现的分离的目的。
下面通过一段代码的对比,详细了解一下PIMPL机制。
首先举个简单的例子:
// Base.h
#include "Other.h"
class Base
{
public:
int fun();
private:
int m_data;
COther m_other; //Other为第三方接口
};
// Derived.h
#include "Base.h"
class Derived : public Base
{
public:
//其他的方法
};
这是个很常见的类继承例子,没有任何错误。但是,我们仍然可以找到不足:
1) 引入的头文件降低编译速度。
我们一般将声明写在一个头文件中,而头文件是不能预编译或增量编译的,如果你因此而引入一个诸如Osp.h之类的头文件,那么产生的代价可能是一杯咖啡的编译时间,而且每次编译都这样。
对于上面的例子,假设你现在希望在Base.h中加入一个新的private和protected成员函数,那么Derived和所有包含Base.h的源文件都需要重新编译。在一个大工程中,这样的修改可能导致重新编译时间的激增。一般来说,不在头文件中包含头文件是一个比较好的习惯,但是这也不能完全消除修改Base.h带来的重新编译代价,只是在表现上更完美了一些。
2) 提高了模块的耦合度。
在上面代码中,Derived从此与 Base紧紧绑定。在一个库里的模块互相耦合当然可以忍受,不过这里有两种耦合度:一个是编译期的,一个是运行期的。这种方式下,无论编译还是运行,它们都耦合在一起,只要 Base发生任何变更,Derived的模块也必须重新编译;
3) 降低了接口的稳定程度。
接口的稳定,至少有两个方面:一个是对于库的运用,即方法调用不能变;一个是对于库的编译,即动态库的变更最好能让客户程序不用重编译。
对于上面的例子,如果COther 变了,Derived显然必须重新编译,因为Cother的 private 部分,虽然对于客户程序Derived不可用,但并不是不可见,尤其是对编译器来说。对于一个动态链接库,这个问题可能会让人无法忍受。
为了解决上面提出的三个问题,我们可以对代码进行如下改写:
// XImpl.h
class XImpl {
public:
int fun();
private:
int m_data;
COther m_other; //Other为第三方接口
};
// X.h
class XImpl;
class X {
public:
X();
~X();
int fun();
private:
XImpl* m_pXImpl;
};
// X.cpp
#include "XImpl.h"
#include "X.h"
X::X():m_pXImpl( new XImpl() ){ }
X::~X(){
try { delete m_pXImpl; }
catch(...) {}
}
int X::fun() {
return m_pXImpl->fun();
}
现在,除非我们自己修改XImpl的公有接口,否则这个头文件是不会被修改了。从语义上说,成员数据是属于XImpl的实现部分,并没有暴露给X类。然后,我们可以用这个XImpl类的实现来完成X类的细节实现;接着,在X类的构造函数中实例化XImpl类指针的。
对比2.1的代码,我们不难发现:
1) 降低了编译依赖,进而可以提高编译速度。
我们知道,引用头文件是不能直接编译的,它包含于源文件中。而上面的代码,将Ximpl.cpp和X隔离开,也就是说COther类和X类是无关的。
2) 隐藏数据细节。
因为X类只关心Ximpl的公共方法,并不关心Ximpl内部是如何实现的。对于Ximpl类我们完全可以封装成DLL组件。这样我们就可以将X类的一些细粒度的操作放到Ximpl中实现,而没必要采用耦合度比较大的继承方法。
但是,我们注意到这样修改带来了两个问题:
1) 增加了内存占用。
我们知道指针会占用4或8字节(目前大部分系统),很多64bit程序跑不过32bit就和这个指针有莫大关系。如果涉及到字节对齐问题,可能会增加更多的无用字节。
2) 增加了运行时间。
指针是一个变量,所以取指针指向的内容,就要先加载指针的值,然后把这个值看成地址,再去地址取真正的数据,也就是所谓的“解引用”(dereference)。那么对m_pXImpl这个指针的任何调用都相当于多了一层解引用的开销。多一个new的开销,我们知道栈比堆的数据开辟速度快,栈只需要移动一下栈顶指针,而堆需要考虑内存碎片,多线程竞争等等。
上面的两个例子对比,我们知道了PIMPL机制的优缺点。下面简单将一下原理。
桥接模式(Bridge)的定义:将抽象部分与它的实现部分分离,使它们都可以独立地变化。
这里说的意思不是让抽象基类与具体类分离,而是现实系统可能有多角度分类,每一种分类都有可能变化,那么把这种多角度分离出来让它们独立变化,减少它们之间的耦合性,即如果继承不能实现“开放-封闭原则”的话,就应该考虑用桥接模式。

上图翻译为代码为:
class Implementor{
public:
virtual void Run()=0;
};
class ConcreteImplementorA : public Implementor{
public:
virtual void Run(){
//ConcreteImplementorA 具体实现
}
};
class ConcreteImplementorB : public Implementor{
public:
virtual void Run(){
//ConcreteImplementorB 具体实现
}
};
class Abstraction{
protected:
Implementor* m_pImpl;
public:
void SetImplementor(Implementor* pImpl){
m_pImpl = pImpl;
}
virtual void Run()=0;
};
class RefinedAbstraction : public Abstraction{
public:
virtual void Run(){
m_pImpl->Run();
}
};
int main(){
Abstraction *ab;
ab = new RefinedAbstraction();
ab->SetImplementor(new ConcreteImplementorA());
ab->Run();
ab->SetImplementor(new ConcreteImplementorB());
ab->Run();
return 0;
}
PIMPL机制大体上和桥接模一致。不同之处是:一般通过SetImplementor函数将pImpl指针传进来,而PIMPL机制一般是在构造函数中初始化(参考2.2的X::X():m_pXImpl( new XImpl() ){ })。
PIMPL机制又叫编译器防火墙。既然叫防火墙那有两个问题就必须提出:
1) 谁造防火墙的?2)如何穿透防火墙?
下面简述这两个问题
C++的编译模式为“分离式编译”,即不同的源文件是分开编译的。就是说,不同的源文件之间有一道天然的防火墙,一个源文件“失火”并不会影响到另一个源文件。
头文件是不能直接编译的,它包含于源文件中,并作为源文件的一部分被一起编译。这也就是说,只有源文件使用的开放接口变动了,才会导致源文件重新编译。
pImpl指针在这里充当了一座桥。将依赖信息“推”到了另一个编译单元,与用户隔绝开来。任何的访问都必须经过这座“指针桥”。而防火墙是C++编译器的固有属性。
是什么穿越了C++编译期防火墙?是指针!使用指针的源文件“知道”指针所指的是什么对象,但是不必直接“看到”那个对象。它可能在另一个编译单元,是指针穿越了编译期防火墙,连接到了那个对象。
从某种意义上说,只要是代表地址的符号都能够穿越C++编译期防火墙,而代表结构的符号则不能。例如函数名,它指的是函数代码的始地址。所以,函数能够声明在一个编译单元,但定义在另一个编译单元,编译器会负责将它们连接起来。用户只要得到函数的声明就可以使用它。而类则不同,类名代表的是一个语言结构,使用类,必须知道类的定义,否则无法生成二进制代码。变量的符号实质上也是地址,但是使用变量一般需要变量的定义,而使用extern修饰符则可以将变量的定义置于另一个编译单元中。
PIMPL机制主要优点是:可以降低编译依赖,进而可以提高编译速度;可以减小接口和实现之间的耦合。主要缺点是会增加代码复杂度。
因此,对于小项目或代码片段,PIMPL机制并没什么优势。但是,对于像McsLib这样的大项目还是值得使用的。一方面,程序越大,越是需要结构清晰;另一方面,我们程序员有很多时间都在调试,经常需要编译。如果每次重新编译都要几分钟,那肯定会让人恼火。因此,赶紧将PIMPL机制加入到你的项目中吧。
The Joy of Pimpls (or, More About the Compiler-Firewall Idiom)
Exceptional C++中文版(第7章) 侯捷译