COM本质论的第一章看了很多遍, 看了忘, 忘了看, 一直觉得写的太好了, 看了有醍醐灌顶的感觉, 最终觉得这么忘下去不是个事, 尤其年纪大了, 还是来记些笔记吧.
这本书属于如果你不是COM的设计者, 没有那种陪COM一路走来的过程, 是绝对写不出来的. 个人认为任何复杂的理论, 只要你了解它的始末, 都可以用比较容易理解的方式描述出来, 这就是科普的可行性. 某些故作高深的专家, 所谓的那些不知其所云的书和文章, 真是让人深深的鄙视...
本书的这一章主要是描述了从C++静态库, 一直演化到COM的C++原型的过程, 到这章的结束, 我们看到的仍然是C++, 它是可以跨编译器的, 但它已经具备了所有COM所具备的主要特性.
C++对于C而言, 主要就是增加了面向对象特性, 即允许程序员建立用户自定义类型(user-defined type), 类, 而面向对象的主要动机之一就是重用性, 这些类可以在原始实现环境之外被重复使用.
但事实上, 要编写很容易被重用的C++类非常的困难, 除了在设计和开发时的不规范导致的重用障碍, 更重要的时在运行时也有大量的障碍, 使的C++对象模型不能够成为"可重用软件组件"的理想底层基础.
这种运行时的障碍主要来自, C++没有二进制级别的规范, 导致各个编译器厂家对很多比较灵活的语言特性的二进制实现是不兼容的, 所以C++开发的库无法跨编译器调用. 下面就从C++静态库开始一步步的讲.
静态库
对于静态库而言, 编译器会把你用到的库函数, 拷贝一份到你的程序中, 然后一起编译.
静态库问题就是,
1. 一旦库开发者更新实现, 你必须重新编译你的程序才能更新库, 这个是比较烦的, 尤其当程序规模比较大的时候.
2. 对于静态库, 如果你有多个程序同时用到该库, 那必须在每个程序中copy一份, 经行编译. 当库比较大时, 对内存是比较浪费的.
静态库在编译完成之后, 就完全融入你的程序, 模块化特征几乎消失.
动态链接库 (DLL)
动态链接库可以作为解决静态库问题的一种技术, 当把静态库封装成DLL时, 静态库的所有方法会被加到DLL的引出表(export list)中, 引出表允许在运行时把每个方法的名字解析到内存中对应的地址. 并且链接器还会产生一个引入库(import library), 引入库只是包含了一些引用, 这些引用指向DLL的文件名和被引出的符号名字.
所以当你的程序编译时, 只需要链接引入库, 引入库是很小的, 不浪费空间, 里面只是记录了DLL文件的地址和DLL暴露的接口符号. 这样也缩短了编译时间.
当程序运行时, 当调用到DLL中的库函数时, 程序才根据引入库中的DLL文件地址取装载DLL, 并且通过引出接口符号去引出表中查到方法在内存中的地址, 进行调用.
这样的话, 如果有多个程序调用这个库函数, 我们也只需要在内存中保留一份库, 而不用想静态库一样在每个程序中保留一份.
那么动态链接库是从原始的C++类走向可替换的, 有效的可重用组件的重要一步.
但是这只是其中一步, 光这一步还无法完成重用组件, 因为C++有底下两个问题.
可移植性问题
C++基本弱点之一是象前面讲的缺乏二进制一级的标准, 就是各个编译器是不兼容的.
最明显的问题是, 为了允许函数重载, 编译器往往会篡改每个函数的函数名, 称为名字改编(name mangling).
要命的是各个编译器厂商的改编规则是不一样的, 这个没有个标准.
所以用不用的编译器编译的程序和DLL库, 可能无法完成调用, 因为你在程序中的改编好的函数名, 到引入库中找不到, 引入库里面的函数名被另一个编译器改编了.
有个解决的方法是加上extern 'c' 阻止名字改编, 具体参见C语言易混淆关键词详解-const, static, extern, typedef, 声明
但这个方法只能用于全局函数, 对于成员函数不可行
另一个方法,是为不同的编译器产生不用的引入库, 这个方法明显很麻烦
而且上面只是移植性问题的一个例子, 除了最简单的语言结构外, 所有编译器厂商都会选择自己专有的方式来实现语言的其他特征.这个问题就很崩溃了, 当时标准没指定完善导致重用性的问题很难解决.
封装性问题
C++通过类对数据和操作进行封装, 类只暴露public的成员函数作为接口, 其他的成员变量和函数对用户透明.
这样在改动类的内部实现时, 不需要修改客户的代码. 这样是体现了代码的封装性.
对于动态链接库而言, 客户代码和类库是分开编译的, 当类库中的类的实现发生改变, 增加了成员变量, 是否可以只编译类库, 而不编译客户代码, 答案是有可能是会出错的.
原因在于C++的封装性不是二进制级别的, 看如下例子
class __declspec(dllexport) FastString {
const int m_cch; //新增加的成员变量
char *m_psz;
public:
FastString(const char *psz);
~FastString(void);
int Length(void) const;
int Find(const char *psz) const;
} ;
为类库中的FastString类, 增加了成员变量m_cch, 编译完类库后, 客户是否可以直接使用.
首先肯定的是, 由于C++代码级别的封装性, 客户的代码是不用改变的, 但是客户的代码需要从新编译, 否则会报错.
原因是, 客户使用该类时, 首先要用new FastString产生类对象, 所以编译器在编译时要根据类成员的大小为该对象分配空间, 这样对于老的FastString, 编译时分配了4个字节, 但是新的FastString确占用了8个字节. 所以如果不从新编译客户代码, 会导致溢出, 即类对象后4个字节会错误覆盖其他数据.
针对这种问题, 通用的解决方法就是, 每次新版本的DLL发布时, 把DLL改成新的名字, 这也是MFC采用的策略.
当我们把版本号放到库名中时(e.g., FastString10.DLL, FastString20.DLL), 客户总是可以装入与其相对应的DLL版本, 而不需要顾虑是否有其他版本存在. 可是这个方法随着时间的推移, 明显会有越来越多的库的不同的版本同时存在, 相当的混乱.
所以根本问题是, C++编译模型要求客户必须知道对象的布局结构, 从而导致了客户和对象可执行代码之间的二进制耦合关系. 这种紧密的耦合性可以使编译器产生非常高效的代码, 不过在不重新编译客户代码的情况下, 类的实现无法被替换.
封装性问题的解决--把接口从实现中分离出来
那么有了问题就要解决, 封装性问题貌似容易解决一下, 先来解决这个问题.
上面说了, 这个封装性issue, 是因为编译器在生成类对象时(即new类对象时), 必须知道类成员的布局. 想想也是, 不知道成员布局, 怎么为对象分配空间了.
那么简单的方法就是不要让客户在代码里直接new 类对象, 我们可以在类库中封装一层接口层, 在这层中new 类对象, 并把类对象指针传出给客户, 客户拿到这个对象指针就可以调用库函数, 这样在客户代码中只需要定义一个指针, 指针的大小是不变的4字节.
//faststringitf.h
class __declspec(dllexport) FastStringltf {
class FastString;
FastString *m_pThis; //类对象指针
public:
FastStringltf(const char *psz);
~FastStringltf(void);
int Length(void) const;
int Find(const char *psz) const;
} ;
上面就是定义了这个接口类, 底下给出实现, 可以看出在接口类的构造函数中去new FastString对象.
//faststringitf.cpp
#include "faststring.h"
#include "faststringitf.h"
FastStringltf::FastStringltf(const char *psz)
:m_pThis(new FastString(psz)) {
assert(m_pThis != 0);
}
FastStringltf: :~FastStringltf(void) {
delete m_pThis;
}
int FastStringltf::Lengthltf(void) const {
return m_pThis->Length();
}
int FastStringltf::Find(const char *psz) const {
return m_pThis->Find(psz);
}
而这个接口类, 在类库中随着实现类的更新一起做更新编译, 就不存在之前的问题了. 由于在接口类中完全不涉及实现细节, 所以开发者可以任意改变实现, 而不会影响到客户. 这个接口类相当于在客户和实现类之间加了个二进制的防火墙.
接口类的缺点也很明显, 就是接口类必须要把每个方法调用显式的传递给实现类. 对于一个简单的类, 这还好, 但是对于大的类库, 可能成千上百个方法, 那么编写这个接口类就非常的麻烦了. 关键对于性能敏感的应用, 每个方法增加两个函数调用的开销并不理想.
兼容性问题解决--抽象基类作为二进制接口
前面说了引入接口类可以作为二进制接口来解决封装性问题, 不过由于它本身的缺点, 而且更主要的是这种方法无法解决编译器兼容性问题, 而这是我们实现可重用组件必须要解决的问题. 我们需要新的二进制接口...
如前面所说, 编译器兼容性问题主要来自于不同的编译器对下面两点采用的不同的方案:
1. 如何在运行时表现语言的特征
2. 在链接时刻, 如果表达符号的名字(name mangling标准)
首先那么只要我们二进制接口只使用与编译器无关的语言特征, 就可以部分解决编译器兼容问题.
那么那些语言特征是编译器无关的
1. the runtime representation of composite types such as C-style structs can be held invariant across compilers
2.all compilers pass function parameters in the same order (right to left, left to right) and that stack cleanup can be done in a uniform manner.
3. 最关键的假设, 某个给定平台上的所有C++编译器都实现了同样的虚函数调用机制
至于虚函数的具体情况可以参见"Inside the C++ Object Model", 这儿就不多说, 反正只要知道在虚函数的调用机制上, 各个编译器都采用几乎相同的方案.
基于上面的编译器无关假设, 我们可以把接口类中的函数都定义为虚函数(纯虚函数), 并且这个接口类满足下面两个条件时, 可以保证所有的编译器对方法的调用产生等价的机器码.
1.接口类不包含数据成员
2. 接口类不能直接从多个其他接口类派生
这样定义的接口类就是抽象基类, 一种更好的二进制接口形式. 那么上面给出的例子定义为如下
//ifaststring.h
class IFastString {
public:
virtual int Length(void) canst = 0;
virtual int Find(const char *psz) canst 0;
} ;
这接口类只定义了"调用这些方法的可能性", 而没有定义实际的方法实现.
那么对应的C++实现类必须从接口类继承, 并且重载每个纯虚函数, 实现这些函数. 这就导致对象的内存结构是接口类的内存结构的二进制超集. 例子如下
class FastString : public IFastString {
const int m_cch;
char *m_psz;
public:
FastString(const char *psz);
~FastString(void);
int Length(void) const;
int Find(const char *psz) const;
} ;
从而可以看出, 用抽象基类也可以免去对实现类函数的直接调用, 这样也就避免前面介绍的接口类的缺点
对于解决封装性问题, 主要就是要避免在客户代码里去new 类对象, 因为那样必须把实现类的定义暴露给客户,等于绕过了接口的二进制封装.
那么一个可行的技术就是让DLL引出一个全局函数, 由它代表客户调用new操作符. 于是在ifaststring.h加上如下的全局接口,
extern 'C'
IFastString *CreateFastString(const char *psz);
注意这个全局函数必须是extern 'c'的, 来避免编译器的name mangling
这样在客户代码中就可以这样来使用这个类,
int f(void){
IFastString *pfs = CreateFastString("Deface me");
int n = pfs->Find("ace me");
delete pfs;
return n;
}
不过这样使用会导致内存泄漏, 因为接口类的析构函数不是虚函数, 这意味对delete操作符的调用并不会动态找到最终派生类的析构函数.
但这儿不能使用虚析构函数, 因为虚析构函数在vtbl中的位置随编译不同而不同, 使用会破坏编译器对立性.
所以就显式的增加一个delete虚函数,
//ifaststring.h
class IFastString {
public:
virtual void Delete(void) = 0;
virtual int Length(void) canst = 0;
virtual int Find(const char *psz) canst 0;
} ;
extern 'C'
IFastString *CreateFastString(const char *psz);
客户代码改成如下
int f(void){
int n =-1;
IFastString *pfs = CreateFastString("Deface me");
if (pfs) {
n = pfs->Find("ace me");
pfs->Delete();
}
return n;
}
这个用抽象基类作为二进制接口的方法, 同时也解决了name mangling的问题, 整个接口除了CreateFastString全局函数, 都是虚函数.
虚函数总是通过保存在vtbl中的函数指针被间接调用, 客户程序不需要在开发时链接这些函数的符号名. 而CreateFastString被声明为extern 'c'的也不存在name mangling问题.
可见抽象基类是个比较好的方法, 它可以同时解决编译器兼容问题, 封装性问题, 基于它我们已经可以开发基于C++的可重用组件.
对象扩展性
到现在展示的技术, 可以使二进制组件, 随着时间的推移不断升级他们的实现, 而客户无需重新编译. 并且客户可以动态的选择并转载二进制组件来满足不同的需求.
比如上面的例子, string匹配的例子, 默认是从左到右进行搜索, 但是对于特殊的语言, 需要从右到左的搜索功能. 这时, 我们可以实现一个FastStringRL.dll, 这个库和FastString.dll的接口一样, 只是实现搜索的顺序不同, 那么客户可以根据实际的语言来动态加载匹配的库进行处理.
但是我们通常要在一个接口已经被设计好之后, 希望能够加入原先没有预见到的新功能, 这个就是接口的可扩展性.
有种简单的方法就是把新方法追加到现有接口定义的尾部,
class IFastString {
public:
//faux version 1.0
virtual void Delete(void) = 0;
virtual int Length(void) = 0;
virtual int Find(const char *psz) = 0;
//faux version 2.0
virtual int FindN(const char *psz, int n)
} ;
这种方法在当新的客户程序, 碰到老的库组件时, 会导致崩溃. 因为新的客户程序试图调用FindN在旧组件中没有.
所以这意味接口必须是不可改变的, 一旦公开之后, 接口就不能再变化. 那么接口的扩展性的解决办法是"允许实现类暴露多个接口".
这可以通过两种途径获得, 设计一个接口使他继承另一个相关接口, 或让实现类继承多个不相干的接口类. 采用哪一种途径视情况而定, 比如上面的情况是对相关接口功能的扩展, 就应该采用继承的方法.
class IFastString2 : public IFastString {
public:
//real version 2.0
virtual int FindN(const char *psz, int n) 0;
} ;
当要求增加永久性接口时, 应该声明新的接口, 并让实现类继承多个接口,
class IPersistentObject {
public:
virtual void Delete(void) = 0;
virtual bool Load(const char *pszFileName) 0;
virtual bool Save(const char *pszFileName) 0;
} ;
class FastString : public IFastString,public IPersistentObject {}
客户可以使用C++ RTTI的dynamic_cast操作符, 来判断对象是否与某接口相容, 即是否继承于此接口, 只有相容时才调用该接口的函数, 这样可以帮助客户建立稳定的动态调用系统.
bool SaveString(IFastString *pfs, canst char *pszFN){
bool bResult = false;
IPersistentObject *ppo = dynamic_cast<IPersistentObject*> (pfs);
if (ppo)
bResult = ppo->Save(pszFN);
return bResult;
}
但是RTTI是一个与编译器极为相关的特征.每个编译器厂商对RTTI的实现都是独有的, 所以使用RTTI大大破坏了编译器对立性.
那么和上面的delete处理方法相似, 我们就在每个接口中显式的暴露一个Dynamic_Cast方法, 此方法完成与dynamic_cast等价的功能, 但是使用编译器无关的代码.
你应该发现对于每个接口Dynamic_Cast和Delete方法都是必须的, 于是很自然把这些公共的方法提升到一个基接口中,
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(void) = 0;
virtual int Find(const char *psz) 0;
} ;
而对Dynamic_Cast实现如下, 我们使用static_cast来代替RTTI的dynamic_cast, 对于static_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);
else
return 0; //request for unsupported interface
}
大家注意, 当用户请求IExtensibleObject接口时, 调用的是static_cast <IFastString *>(this);
原因是static_cast<IExtensibleObject*>(this)有二义性,
IFastString and IPersistentObject both derive from IExtensibleObject, 所以你直接cast IExtensibleObject, 编译器不知道你要哪个接口中的IExtensibleObject
解决这个方法的通常的方法是把IExtensibleObject声明成虚基类, 但是象大多数高级的特性一样, 虚基类也是编译器相关的. 所以这儿采用了这样一个取巧的方法来解决二义性问题.
客户代码就可以改成这样, 就可以做到编译器独立,
bool SaveString(IFastString *pfs, const char *pszFN){
bool bResult = false;
IPersistentObject *ppo = (IPersistentObject*) pfs->Dynamic_Cast ("IPersistentObject");
if (ppo)
bResult = ppo->Save(pszFN);
return bResult;
}
资源管理
直接看下面的这个例子,
void f(void) {
IFastString *pfs = 0;
IPersistentObject *ppo = 0;
pfs = CreateFastString("Feed BOB");
if (pfs) {
ppo = (lPersistentObject *) pfs->Dynamic_Cast("IPersistentObject");
if (! ppo)
pfs->Delete();
else {
ppo->Save(IC://autoexec.bat");
ppo->Delete();
}
}
}
当你调用CreateFastString创建对象后, 必须在用完该对象后, 释放此对象, 否则会造成内存泄漏.
当前的做法是在确认程序中不会再调用该对象后, 调用delete释放该对象.
这样做对于上面这个简单的例子是没有问题的, 不过对于复杂的代码,就很容易出错.
因为代码中有很多对象指针, 你要分析清楚每个指针具体指向哪一个对象, 只有该对象没有任何指针调用时, 你才能去调用delete,释放对象.
所以这个明显是很容易出错的, 指针使用很灵活, 多次赋值后, 你很容易遗漏或多次delete同一个对象.
所以为了简化用户, 应该把管理对象生存周期的责任推给对象实现.
对于这个问题, 普遍的解决方法就是引用计数, 这个想法很简单
对于生成的对象, 如果想使用它, 必须通过指向他的指针, 那么就对指向该对象的指针进行计数, 只要还有指针指向它, 你就可以认为这个对象是有用的, 只有当没有指针指向该对象了, 就可以认为该对象没用了, 可以被delete
于是将IExtensibleObject接口定义为如下, 增加两个指针计数方法, 而不是直接把delete方法暴露给用户
class IExtensibleObject {
public:
virtual void *Dynamic_Cast(const char* pszType) =0;
virtual void DuplicatePointer (void) = 0; //增加指针引用时调用
virtual void DestroyPointer (void) = 0; //减少指针引用时调用
} ;
这个两个计数方法, 在FastString中可以实现如下
class FastString : public IFastString, public IPersistentObject {
int m_cPtrs;
public:
FastString(const char *psz) : m_cPtrs(O) {}
void DuplicatePointer (void) {
++m_cPtrs;
}
void DestroyPointer (void) {
if (--m_cPtrs == 0)
delete this;
}
} ;
这样用户就只需要遵守这两条规则, 在增加指针引用的地方调用DuplicatePointer, 在减少指针引用的地方调用DestoryPointer.
就可以使资源对象, 在不需要时, 自动被delete, 而不需要用户显式的调用.
如上面的例子, 应该在CreateFastString, Dynamic_Cast中增加DuplicatePointer的调用, 并在ppo, pfs两指针引用使用完后, 调用DestoryPointer.
总结
我们从一个简单的C++类开始, 一步步的讨论如何把这个类设计成可重用的二进制组件. 简而言之, 我们设计了组件对象模型(COM).