城门失火
殃及池鱼
pImpl
惯用手法的运用方式大家都很清楚,其主要作用是解开类的使用接口和实现的耦合。如果不使用
pImpl
惯用手法,代码会像这样:
//c.hpp
#include<x.hpp>
class C
{
public:
void f1();
private:
X x; //
与
X
的强耦合
};
像上面这样的代码,
C
与它的实现就是强耦合的,从语义上说,
x
成员数据是属于
C
的实现部分,不应该暴露给用户。从语言的本质上来说,在用户的代码中,每一次使用
”new C”
和
”C c1”
这样的语句,都会将
X
的大小硬编码到编译后的二进制代码段中(如果
X
有虚函数,则还不止这些)——这是因为,对于
”new C”
这样的语句,其实相当于
operator new(sizeof(C) )
后面再跟上
C
的构造函数,而
”C c1”
则是在当前栈上腾出
sizeof(C)
大小的空间,然后调用
C
的构造函数。因此,每次
X
类作了改动,使用
c.hpp
的源文件都必须重新编译一次,因为
X
的大小可能改变了。
在一个大型的项目中,这种耦合可能会对
build
时间产生相当大的影响。
pImpl
惯用手法可以将这种耦合消除,使用
pImpl
惯用手法的代码像这样:
//c.hpp
class X; //
用前导声明取代
include
class C
{
...
private:
X* pImpl; //
声明一个
X*
的时候,
class X
不用完全定义
};
在一个既定平台上,任何指针的大小都是相同的。之所以分为
X*
,
Y*
这些各种各样的指针,主要是提供一个高层的抽象语义,即该指针到底指向的是那个类的对象,并且,也给编译器一个指示,从而能够正确的对用户进行的操作(如调用
X
的成员函数)决议并检查。但是,如果从运行期的角度来说,每种指针都只不过是个
32
位的长整型(如果在
64
位机器上则是
64
位,根据当前硬件而定)。
正由于
pImpl
是个指针,所以这里
X
的二进制信息(
sizeof(C)
等)不会被耦合到
C
的使用接口上去,也就是说,当用户
”new C”
或
”C c1”
的时候,编译器生成的代码中不会掺杂
X
的任何信息,并且当用户使用
C
的时候,使用的是
C
的接口,也与
X
无关,从而
X
被这个指针彻底的与用户隔绝开来。只有
C
知道并能够操作
pImpl
成员指向的
X
对象。
防火墙
“修改
X
的定义会导致所有使用
C
的源文件重新编译”这种事就好比“城门失火,殃及池鱼”,其原因是“护城河”离“城门”太近了(耦合)。
pImpl
惯用手法又被成为“编译期防火墙”,什么是“防火墙”,指针?不是。
C++
的编译模式为“分离式编译”,即不同的源文件是分开编译的。也就是说,不同的源文件之间有一道天然的防火墙,一个源文件“失火”并不会影响到另一个源文件。
但是,这里我们考虑的是头文件,如果头文件“失火”又当如何呢?头文件是不能直接编译的,它包含于源文件中,并作为源文件的一部分被一起编译。
这也就是说,如果源文件
S.cpp
使用了
C.hpp
,那么
class C
的(接口部分的)变动将无可避免的导致
S.CPP
的重新编译。但是作为
class C
的实现部分的
class X
却完全不应该导致
S.cpp
的重新编译。
因此,我们需要把
class X
隔绝在
C.hpp
之外。这样,每个使用
class C
的源文件都与
class X
隔离开来(与
class X
不在同一个编译单元)。但是,既然
class C
使用了
class X
的对象来作为它的实现部分,就无可避免的要“依赖”于
class X
。只不过,这个“依赖”应该被描述为:“
class C
的实现部分依赖于
class X
”,而不应该是“
class C
的用户使用接口部分依赖于
class X
”。
如果我们直接将
X
的对象写在
class C
的数据成员里面,则显而易见,使用
class C
的用户“看到”了不该“看到”的东西——
class X
——它们之间产生了耦合。然而,如果使用一个指向
class X
的指针,就可以将
X
的二进制信息“推”到
class C
的实现文件中去,在那里,我们
#include”x.hpp”
,定义所有的成员函数,并依赖于
X
的实现,这都无所谓,因为
C
的实现本来就依赖于
X
,重要的是:此时
class X
的改动只会导致
class C
的实现文件重新编译,而用户使用
class C
的源文件则安然无恙!
指针在这里充当了一座桥。将依赖信息“推”到了另一个编译单元,与用户隔绝开来。而防火墙是
C++
编译器的固有属性。
穿越
C++
编译期防火墙
是什么穿越了
C++
编译期防火墙?是指针!使用指针的源文件“知道”指针所指的是什么对象,但是不必直接“看到”那个对象——它可能在另一个编译单元,是指针穿越了编译期防火墙,连接到了那个对象。
从某种意义上说,只要是代表地址的符号都能够穿越
C++
编译期防火墙,而代表结构
(constructs)
的符号则不能。
例如函数名,它指的是函数代码的始地址,所以,函数能够声明在一个编译单元,但定义在另一个编译单元,编译器会负责将它们连接起来。用户只要得到函数的声明就可以使用它。而类则不同,类名代表的是一个语言结构,使用类,必须知道类的定义,否则无法生成二进制代码。变量的符号实质上也是地址,但是使用变量一般需要变量的定义,而使用
extern
修饰符则可以将变量的定义置于另一个编译单元中。