《COM本质论》学习第一章
第 1 章 COM 是一个更好的 C++
1.1 软件分发和 C++
80 年代后期,C++ 库以源代码的形式分发。书中给出了一个 FastString 类作为例子:
// faststring.h ///////////////////////////////
// 快速字符串查找类
class FastString
{
char* m_psz;
public:
FastString(const char* psz);
~FastString(void);
int Length(void) const; // 返回字符数目
int Find(const char* psz) const; // 返回子串偏移量
}
// faststring.cpp /////////////////////////////
#include "faststring.h"
#include <string.h>
FastString::FastString(const char* psz)
{
m_psz = new char[strlen(psz)+1];
strcpy(m_psz, psz);
}
FastString::~FastString()
{
delete[] m_psz;
}
int FastString::Length(void) const
{
return strlen(m_psz);
}
int FastString::Find(const char* psz) const
{
// 子串查找算法,从略
return 0;
}
直接分发源代码的方式存在两个问题:
- 占用内存和磁盘。源代码直接集成到客户程序中,多个客户程序运行,也会有多个 FastString.obj 被加载到内存中;
- 难以替换。源代码一旦链接到客户程序,就必须重新编译客户程序才有可能替换。
1.2 动态链接和 C++
解决上面问题的一种技术方案是把 FastString 类以动态链接库(DLL)的形式包装起来:
class __declspec(dllexport) FastString
{
char* m_psz;
public:
FastString(const char* psz);
~FastString();
int Length(void) const;
int Find(const char* psz) const;
}
把 FastString 库放到 DLL 中,这是从原始的 C++ 类走向可替换的、有效的可重用组件的重要一步。
1.3 C++ 和可移植性
直接将 C++ 类从 DLL 中导出,会存在两个问题:
- 链接器:由于 名称改编 机制,当客户程序与 FastString 的编译器不同时,很可能无法链接成功;
- 编译器:不同编译器有自己实现语言机制的专有方式,有可能客户编译器无法使用 DLL 导出的代码。比如 Microsoft 编译器产生的函数,它抛出的异常无法被 Watcom 生成的客户程序捕捉到。
这两个问题产生的根本原因在于, C++ 缺少二进制一级的标准,各厂商按照自己的方式实现 C++ 编译器,使得创建 “厂商独立的组建软件” 比较困难。
1.4 封装性和 C++
除了上述问题外,另一个问题则与封装性有关。 C++ 的 public, private 关键字支持了语法上的关键字,但 C++ 标准并没有定义 二进制层次 上的封装性。
换句话说,修改 C++ private 成员,在语法上来说对其使用者是透明的;但在二进制角度看来,对类成员的修改将导致二进制结构的改变。
书中举了一个例子,给 FastString 增加一个成员 m_cch 用于记录字符串长度:
// faststring.h 2.0 版
class __declspec(dllexport) FastString
{
const int m_cch; // 记录字符串长度,提高 Length 函数效率
char* m_psz;
public:
FastString(const char* psz);
~FastString();
int Length(void) const;
int Find(const char* psz) const;
}
在客户看来,上述改动在语法上是没有问题的, FastString 的 public 接口没有变。但在二进制层次上, FastString 对象的大小从 4 byte 变成了 8 byte. 如果客户程序还是像之前那样为 FastString 对象分配 4 byte 的内存,就极有可能引发异常。
上述问题的根本原因在于 C++ 的编译模型,它要求客户程序必须知道对象的布局结构,从而导致了客户和库代码之间的二进制耦合关系。
这导致 FastString 类的实现难以被替换。无法修复原有组件的 Bug, 也无法为组件添加新的功能。
1.5 把接口从实现中分离出来
封装(encapsulation)的概念以 “把一个对象的外观(接口)同其实际工作方式(实现)分离开来” 为基础。 C++ 的问题在于这条原则没有被应用到二进制层次上,因为 C++ 类既是接口也是实现。
解决这一问题的思路是,把接口和实现做成两个类。一个 C++ 类代表指向一定数据类型的接口,另一个 C++ 类作为数据类型的实际实现。这样一来,实现类可以被修改,而接口类保持不变。我们还需要一种方法把 接口类 和 实现类 联系起来。
书中给出了以下示例代码:
// faststringitf.h 接口类头文件
class __declspec(dllexport) FastStringItf
{
class FastString; // 导入 实现类 的名称
FastString* m_pThis; // 实现类指针,大小保持不变,客户不需要知道实现
public:
FastStringItf(const char* psz);
~FastStringItf();
int Length(void) const;
int Find(const char* psz) const;
}
// faststringitf.cpp
FastStringItf::FastStringItf(const char* psz)
{
m_pThis = new FastString(psz);
}
FastStringItf::~FastStringItf()
{
delete m_pThis;
}
int FastStringItf::Length(void) const
{
return m_pThis->Length();
}
int FastStringItf::Find(const char* psz) const
{
return m_pThis->Find(psz);
}
接口类 FastStringItf 和 实现类 FastString 之间通过 m_pThis 来联系。书里把它叫做句柄类。它只是个句柄,所以大小不会变,并且客户不会知道 FastString 的实现细节。如此一来上一节所讲的封装性问题就得到了解决。即 FastString 的实现被更改时,客户程序不会受到影响。
但句柄类这种方式也存在问题:
- 每次都通过句柄类来转发函数调用,有一定开销且比较繁琐,容易写错;
- 编译器\链接器问题仍然没有得到解决。
1.6 抽象基类作为二进制接口
“接口与实现分离” 技术确实可以解决封装性的问题,但它的形式需要更改一下,以解决 编译器\链接器 的兼容性问题。
解决这一问题的关键思路在于 虚函数调用机制 。它的运行原理如下:
- 编译器在后台为每个包含虚函数的类产生一个静态函数指针数组。这个数组被称为虚函数表(vtbl);
- 虚函数表中包含了这个类的所有虚函数的函数指针;
- 该类的每个实例对象都包含一个不可见的数据成员,被称为虚函数指针(vptr),它指向虚函数表;
- 客户调用虚函数的时候,先根据 vptr 找到 vtbl, 然后根据偏移量找到函数指针,进行调用;
虚函数调用机制有两个特点:
- 几乎所有的编译器厂商都采用了上述 vtbl, vptr 的方式来实现该机制,这使得它在不同编译器上是等价的。也就是说它具备 编译器 兼容性;
- 这一机制的实现中不涉及任何符号,也就不涉及 名称改编 问题,所以它具备 链接器 兼容性;
因此,我们可以把 虚函数表 作为 客户程序与库 之间的 二进制协议:
- 客户程序编译器根据 接口类 生成虚表;
- 实现类 继承自 接口类,以保证两者的虚表结构一致;
- 库将 实现类对象的指针 转换为 接口类对象的指针,然后传给 客户程序,因为虚表结构一致,客户程序可以安全地访问实现类函数,而无须了解任何实现细节。
书中给出了以下示例代码:
// ifaststring.h 接口类
class IFastString
{
public:
virtual int Length(void) const = 0;
virtual int Find(const char* psz) = 0;
}
// faststring.h 实现类
class FastString : public IFastString
{
const int m_cch;
char* m_psz;
public:
FastString(const char* psz);
~FastString();
int Length() const;
int Find(const char* psz) const;
}
接口类作为二进制协议,应该只定义“调用这些方法的可能性”,不需要定义实现,把它的函数定义为纯虚函数更好一些。
为了在不暴露 FastString 的前提下让用户创建出 FastString 实例,需要额外导出一个函数:
// ifaststring.h
extern "C"
{
IFastString* CreateFastString(const char* psz);
}
// faststring.cpp
IFastString* CreateFastString(const char* psz)
{
return new FastString(psz);
}
如此一来,客户程序就可以通过抽象基类 IFastString 所定义的二进制协议来访问 实现类 FastString 的方法,而不会引起编译器、链接器兼容性问题。
实际上,库只是把实现类的 vtbl 地址传给了 客户程序,没有传递任何数据,因此也不会有 封装性 的问题。
另外,还有一个容易忽视的问题需要注意一下,假如客户执行了如下代码:
int f(void)
{
IFastString* pfs = CreateFastString("Deface me");
int n = pfs->Find("me");
delete pfs; // 内存泄漏
return n
}
注意 delete pfs;
这句代码,此刻客户程序拿到的是 IFastString 指针,而 IFastString 的析构函数并不是虚函数,则 FastString 对象的析构函数无法被执行到。
假如把 IFastString 的析构函数定义为虚函数的话,析构函数在 vtbl 中的位置随着编译器不同而不同,这会引起之前说的编译器兼容性问题。
合适的解决方案是为 IFastString 增加一个 Delete()
纯虚函数。并且让实现类在函数实现时删除自身:
// ifaststring.h
class IFastString
{
public:
virtual void Delete(void) = 0;
virtual int Length(void) const = 0;
virtual int Find(const char* psz) const = 0;
}
// faststring.h
class FastString : public IFastString
{
public:
void Delete(void)
{
delete this;
}
// 从略 ...
}
// 客户程序
int f(void)
{
IFastString* pfs = CreateFastString("Deface me");
int n = pfs->Find("me");
pfs->Delete();
return n;
}
借助于上述技术,我们可以安全地在一个 C++ 环境中暴露 DLL 中的类,并且在另一个 C++ 开发环境中访问这个类。对于建立一个与编译器厂商无关的可重用组件来说,这种能力是非常重要、非常关键的。
1.7 运行时多态性
FastString DLL 只引出了一个符号 CreateFastString
. 这使得客户可以很方便地按需动态装入 DLL(使用 LoadLibrary), 并且使用 GetProcAddress 得到唯一的入口函数:
IFastString* CallCreateFastString(const char* psz)
{
Static IFastString* (*pfn)(const char*) = 0;
if (!pfn)
{
const TCHAR szDll[] = __TEXT("FastString.dll");
const char szFn[] = "CreateFastString";
HINSTANSE hInstance = ::LoadLibrary(szDll);
if (hInstance)
{
*(FARPROC*)&pfn = GetProcAddress(h, szFn);
}
}
return pfn ? pfn(psz) : 0;
}
这项技术有几个可能的应用:
- 如果客户没有安装 FastString.dll, 它可以避免产生异常;
- 减少进程地址空间初始化的工作, DLL 只在需要的时候装载;
- 允许客户在同一接口的不同实现之间做出动态的选择(加载实现了同一接口的不同 DLL),下述代码说明了这一点:
IFastString* CallCreateFastString(const char* psz, bool bLeftToRight = true)
{
static IFastString* (*pfnlr)(const char*) = 0;
static IFastString* (*pfnrl)(const char*) = 0;
// 默认使用 FastString.dll
IFastString* *(**ppfn)(const char*) = &pfnlr;
const TCHAR* pszDll = __TEXT("FastString.dll");
// bLeftToRight == false 时使用 FastStringRL.dll
if (!bLeftToRight)
{
pszDll = __TEXT("FastStringRL.dll");
ppfn = &pfnrl;
}
if (!(*ppfn))
{
const char szFn[] = "CreateFastString";
HINSTANSE hInstance = ::LoadLibrary(pszDll);
if (hInstance)
{
*(FARPROC*)ppfn = ::GetProcAddress(hInstance, szFn);
}
}
return (*ppfn) ? (*ppfn)(psz) : 0;
}
FastString.dll 和 FastStringRL.dll 都实现了 IFastString 接口, CallCreateFastString 可以根据参数在运行时选择加载合适的 dll. 这就是这节题目中说的 运行时多态性
这种运行时多态性对于“利用二进制组件建立动态的复合系统”来说,非常有帮助。
1.8 对象扩展性
到现在为止所展现的技术使得客户可以动态地选择并装载二进制文件,从而可以随着时间的推移不断升级它们的实现,而客户无需重新编译。
然而,对象的接口却不能随着时间不断进化。一旦增删或修改接口的函数,虚表就会发生改变,客户就必须重新编译来适应这种改变。更糟糕的是,改变接口的定义完全违背了接口的封装性。
这意味着已经接口是绝对不能改变的,它即是语义的约定,也是二进制结构的约定。
尽管如此,但我们通常都会希望能够在接口已经设计好之后,为接口加入原先没有预料到的新功能。下面的例子在接口的尾部增加了一个新方法,我们看看直接修改接口会造成哪些问题:
class IFastString
{
public:
virtual void Delete() = 0;
virtual int Length() = 0;
virtual int Find(const char* psz) = 0;
// 新增一个方法
virtual int FindN(const char* psz, int n) = 0;
}
上述修改得到的 vtbl 是原先 vtbl 的超集,新增的方法将添加到 vtbl 的尾部。
下面看一下新老客户和新老接口之间调用时是否会存在问题:
- 老客户调用新接口:没有问题,因为新 vtbl 是老 vtbl 的超集;
- 新客户调用老接口:有问题,因为老 vtbl 中没有 FindN 方法的函数指针,新客户试图调用时会引起异常;
直接修改接口定义,这种技术的问题在于它打破了接口的封装性。这个问题的解决方法是 “允许实现类暴露多个接口”(这样一来新方法可以添加在新接口中,不会影响旧接口)。这可以用两种途径来实现:
-
新增一个接口让它继承自原接口,再让实现类继承它,这种做法适合于 新接口 is-a 老接口 的情形;
// ifaststring.h class IFastString2 : public IFastString { public: virtual int FindN(const char* psz, int n) = 0; } // faststring.h class FastString : public IFastString2 { // 从略 }
-
新增一个独立的接口,让实现类同时继承它和原接口,这种做法适合于 新接口和原接口 无关的情形;
// ipersistent.h class IPersistentObject { public: virtual void Delete() = 0; virtual bool Load(const char* pszFileName) = 0; virtual bool Save(const char* pszFileName) = 0; } // faststring.h class FastString : public IFastString, public IPersistentObject { // 从略 }
无论使用哪种方式,客户都可以通过 C++ 的运行时类型识别(RTTI, Runtime Type Identification) 功能,在运行时询问实现类对象,来获取需要的接口:
bool SaveString(IFastString* pfs, const char* pszFileName)
{
bool bResult = false;
// RTTI 询问 pfs 指向的对象是否能导出为 IPersistentObject*
IPersistentObject* ppo = dynamic_cast<IPersistentObject*>(pfs);
if (ppo)
{
bResult = ppo->Save(pszFileName);
}
return bResult;
}
然而, RTTI 是一个编译器极为相关的特征。让客户去执行 dynamic_cast 会引起之前讨论的编译器兼容性问题。一个简单的办法是,让每个接口都暴露出一个跟 dynamic_cast 等价的方法:
// 基接口,定义所有接口都必须有的方法
class IExtensibleObject
{
public:
virtual void* Dynamic_Cast(const char* pszType) = 0;
virtual void Delete(void) = 0;
}
class IPersistentObject : public IExtensibleObject
{
public:
virtual bool Load(const char* pszFileName) = 0;
virtual bool Save(const char* pszFileName) = 0;
}
class IFastString : public IExtensibleObject
{
public:
virtual int Length() = 0;
virtual int Find(const char* psz) = 0;
}
实现类 FastString 利用 static_cast 来实现 Dynamic_Cast:
void* FastString::Dynamic_Cast(const char* pszType)
{
if (strcmp(pszType, "IFastString") == 0)
{
return static_cast<IFastString*>(this);
}
else if (strcmp(pszType, "IPersistentObject") == 0)
{
return static_cast<IPersistentObject*>(this);
}
else if (strcmp(pszType, "IExtensibleObject") == 0)
{
return static_cast<IFastString*>(this);
}
return 0;
}
注意 Dynamic_Cast 的第三个判断条件,当客户请求 IExtensibleObject 接口时,转换的目标是 IFastString* 而不是 IExtensibleObject*, 这是因为 IFastString 和 IPersistentObject 都继承了 IExtensibleObject, static_cast<IExtensibleObject> 不能处理这种二义性。
要解决二义性的问题可以让 IFastString 和 IPersistentObject 通过 虚继承 的方式来继承 IExtensibleObject. 然而,引入虚基类将导致在结果对象中引入不必要的运行时复杂性,并且也会带来编译器兼容性问题。所以采用 虚基类 这种做法并不合适。
1.9 资源管理
上一节讨论的使实现类暴露多个接口的技术,还有一个问题需要解决。考虑如下客户的代码:
void f(void)
{
IFastString* pfs = 0;
IPersistentObject* ppo = 0;
pfs = CreateFastString("Feed Bob");
if (pfs)
{
ppo = (IPersistentObject*)pfs->Dynamic_Cast("IPersistentObject");
if (!ppo)
{
pfs->Delete();
}
else
{
ppo->Save("C:\\autoexec.bat");
ppo->Delete();
}
}
}
上述代码不会出问题,但对客户来说却会造成困扰:对象最初是通过其 IFastString 接口暴露给用户的,最后却通过 IPersistentObject 接口来销毁。在简单的代码中,这不会有什么问题,只要客户能够意识到 pfs, ppo 指向的是同一个对象就可以了。但在复杂的项目中,客户不得不记住每个接口指针对应的对象是哪个,并且对每个对象只能调用一次 Delete 方法。
简化这一问题的思路是,将管理对象生命周期的责任推给对象实现部分。一个简单的方法是让每个对象都维护一个引用计数,当接口指针被复制的时候,引用计数增加;当接口指针被销毁的时候,引用计数减少。我们修改一下 IExtensibleObject 接口:
class IExtensibleObject
{
public:
virtual void* Dynamic_Cast(const char* pszType) = 0;
virtual void DuplicatePointer(void) = 0; // 接口指针被复制时,调用此方法
virtual void DestroyPointer(void) = 0; // 接口指针不再有用时,调用此方法
}
// faststring.h
class FastString : public IFastString
public IPersistentObject
{
int m_cPtrs;
public:
FastString (const char* psz) : m_cPtrs(0) {}
void DuplicatePointer(void)
{
m_cPtrs ++;
}
void DestroyPointer(void)
{
// 引用计数为零时,销毁自己
if (--m_cPtrs == 0) {
delete this;
}
}
}
此外,FastString 中原有的函数 CreateFastString, Dynamic_Cast 都进行了复制指针的操作,所以要修改一下调用 DuplicatePointer :
IFastString* CreateFastString(const char* pszType)
{
IFastString* pfs = new FastString(psz);
if (pfs)
{
pfs->DuplicatePointer();
}
return pfs;
}
void* FastString::Dynamic_Cast(const char* pszType)
{
void* pvResult = 0;
if (strcmp(pszType == "IFastString")) {
pvResult = static_cast<IFastString*> this;
}
else if (strcmp(pszType == "IPersistentObject")) {
pvResult = static_cast<IPersistentObject*> this;
}
else if (strcmp(pszType == "IExtensibleObject")) {
pvResult = static_cast<IFastString*> this;
}
else
{
return 0;
}
// 复制出了新的指针,增加引用计数
((IExtensibleObject*)pvResult)->DuplicatePointer();
return pvResult;
}
我们看看修改后的 客户代码:
void f(void)
{
IFastString* pfs = 0;
IPersistentObject* ppo = 0;
pfs = CreateFastString("Feed Bob");
if (pfs) {
ppo = (IPersistentObject*) pfs->Dynamic_Cast("IPersistentObject");
if (ppo) {
ppo->Save("C:\\autoexec.bat");
// ppo 用完了,释放
ppo->DestroyPointer();
}
// pfs 用完了,释放
pfs->DestroyPointer();
}
}
修改后的客户代码里,每个指针都被看做了一个具有独立生命周期的实体,客户无需把接口指针与具体的对象联系起来。
利用引用计数的方案使得一个 对象可以以非常一致的方式暴露多个接口。有了这种在运行时进行接口协商的机制,这为创建一个"能够随时间不断进化"的动态组建系统奠定了基础。
1.10 我们走到哪儿了?
这一章从一个简单的 C++ 类开始,然后一步一步讨论如何把这个类做成可重用的二进制组件。
- 以动态链接库的形式发布这个类,从物理上将这个类与客户分离开来;
- (封装性)用接口和实现的概念,把实现细节封装到二进制协议后面,使得对象的布局能够随时间进化;
- (编译器兼容)采用抽象基类作为定义接口的方法,使得防火墙以编译器无关的 vtbl, vptr 形式出现;
- (运行时多态)使用 LoadLibrary 和 GetProcAddress 在运行时动态选择同一接口的不同实现,呈现运行时的多态性;
- (可扩展性)使用类似 RTTI 的形式,在运行时询问对象是否实现了指定的接口。这种结构使得我们能够扩充接口的现有版本,并且也可以从单个对象暴露多个不相关的接口。
总而言之,我们刚刚设计了组件对象模型(COM, Component Object Model).