当你想使用C++写自己的类库(1)

差不多花了一年时间,才终于对C++有了点感觉。开始想要用它做一些事。就在这时候,刚好看到了《COM本质论》,读完第一章,惊为神作。

在刚毕业那会儿有个误区(也许误区都说不上,因为当时对这个职业根本就是没想法),喜欢追新,什么新跟着什么跑。加上第一份工作是使用.net,花了两个月啃完那本红皮的C#入门经典,后续任务基本上可以总结为“通过MSDN查找需要的API来完成对功能需求的堆砌”。C#确实是非常高级的语言,.net库也非常了不起。C#很成功,如它所期望的,让程序员真的摆脱了底层原理,也不关心内存效率,脑子里只有业务逻辑,开发过程中遇到任何困境,只要使用MS提供的功能词典(MSDN)去查找,总能找到一个神奇的类来解决问题,找不到的时候就会热切期待下一版本的升级。这种情况不是绝对的,但就我的情况而言,使用c#没有让我过多地感受到编程这件事需要创造力。但这对用户(只有用户没有客户)来说是仿佛是一件好事,当时我的精力除了完善功能,剩下的都在琢磨UI,甚至自己画图标,界面美观易用,用户体验很好,最后还做了一版参加行业软件展览。不得不再说,MS这套东西商用的优势非常明显非常明显。

但C++就不一样了。

虽然C++也有许多成熟的类库,但没有一个像.net framework这样大包大揽的。自己写一个类库或框架,这是很容易冒出来的念头。这时候各种问题就出现了。比如内存管理,比如跨编译器的调用。

同时,C++的开源库特别多,其维护者不可能像MS一样提供那么一本MSDN大辞典供你查阅,对开源库的理解最后都会变成对源码的理解,在这个过程中,最终都会遇到同样的问题。

这些问题,是用C++构建一个自己的类库所必须解决的,也是在1988年至1993年之间,COM的设计者们思考的问题,最终他们解决了,并且提出了COM的概念。《COM本质论》的第一章就是对这些问题的一个汇总和一次思考过程的复盘。以下是这一章的笔记。

C++为什么会出现:懒惰的智慧

相对C而言,C++中最终要的概念也就是类,即对象。为什么要有对象,也纯粹是为了源代码的可读性,也就是为了让程序设计中的概念与现实生活中的对象有更好的逻辑关系。而代码的可读性,也是为了维护的成本,为了代码可以被其他程序员或者本人在其他时刻重复使用。就像那句话说的,懒惰使人类进步。

因此C++的一个重要目标就是允许程序员建立用户自定义类型(UDT,user-defined type),并且(!重要)这些类型可以在原始实现环境之外被重复使用。简单的说如果不能被重复使用,那C++的出现基本上就是个茶几。(实际上,C++的所有特性都只是围绕这一个目的,就是偷懒,虚函数也好,模板也好,都是为了重用。)

为了程序员和计算机都更给力的偷懒,我们通常希望一个类库是这样的:

程序使用的是同一个类库,而且不是同一类库的多个拷贝

如果某个程序员写了一个算法类FastString(类库),提供给其他程序员使用,其他程序员在自己编写的程序(客户程序)中都要使用到了该算法类库。

如果客户程序将FastString类库的源码加入到自己的项目中并编译,最终会得到如下的三个二进制文件。

01

这样得到的应用程序,会有两个问题:

1、FastString类重复占用内存资源,如果这个库很庞大,尤其不可取;

2、如果应用程序发布之后发现FastString类有缺陷,没有办法直接在客户机上直接替换它。

小结一下,也就是FastString类没有二进制文件下的模块化特性。(也就是它没有完成在二进制文件状态下的重用。能在二进制文件下进行重用,这样的类库才能被称作组件。)

解决这个问题的办法是,把FastString类以动态链接库的形式包装起来。方法有很多种,一个最简的方法是用一个类层次上的编译器指示符,强迫FastString类的所有方法都从DLL中引出去。MS的C++编译器为此提供的是__declspec(dllexport)关键字。

class __declspec(dllexport) FastString {

char *m_psz;

public:

FastString(const char *psz);

~FastString(void);

int Length(void) const;

int Find(const char *psz) const;

};

此后FastString类的所有方法都会被加到FastString.DLL的引出表(export list)中,允许在运行时把每个方法的名字解析到内存中对应的地址。而且,链接器会产生一个引入库(import library),这个库包含了FastString的方法的符号。

应用程序ABC如果是引入了链接库形式的FastString类,其运行时模型如下:
02

从上图来看,FastString类的实现和调用已经分开了,也就是说看上去我们解决了前文提到的两个问题。

未完成的编译器无差别工作

为什么说是看上去,因为在这里我们默默的假设了一件事。就是前文提到的“强迫FastString类的所有方法都从DLL中引出去”,假设了在这里引出来的所有方法的符号(符号可以理解为硬件能识别的函数名,一串自定义的指令的标识符),都是可以明确可以预期不会出现意外的。

这句话好像听起来似乎有点不可思议,但一说到C++中的重载,就会明白了。如果函数名就是函数的符号,也就意味着add(int a,int b)和add(double a, double b)具有同一个函数符号,这样的话,编译器是不可能完成函数重载的。因为无论参数是什么,编译器都会链接同一个函数符号名。

也就是说,C++编译器要完成重载,就必然有一套方法,从函数名修改为独一无二的符号名的方法,也就意味着前文中提到的在链接器产生的FastString的引入库中,FastString类的方法名是经过了修改的。而且这个修改规则不是C++标准规定死的,C++标准只是规定了要实现这个名字改编(name mangling)的功能以实现重载,但名字改编的具体规则,是由各编译器自己来设计。也就意味着,FastString类的Find方法,在一个编译器产生的引入库中可能是Find1,而在另一个编译器产生的引入库中可能是Find2。

对于名字改编,如果是普通函数,可以使用extern “C”的方式,强迫编译器使用C规则,也就是不修改名字的方式来引出函数符号。但extern不能限定类中的函数,即成员函数

那么换个思路,我们用一份文件来说明每个成员函数在不同编译器环境下的引出符号似乎也能解决问题。这样的文件已经存在了,就是模块定义文件(Module Definition File,通称为DEF文件)。

除了引入符号的名字改编规则不同,任何语言特征的实现在不同的两个编译器上都极有可能是不相同的。因此要真正实现FastString类库无视编译器差别的在二进制文件状态下的可重用,到这里也仅能说未完成。

进一步隔离类库程序和客户程序:使用接口隐藏实现

一个类,必然会面临修改。我们的FastString也不例外。可能有一天,为了某个原因,给原先的FastString增加了一个private成员数据m_cch。修改后如下:

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;

};

不要去想为什么要增加,像这样的private成员的增加简直是一定会遇到的。而且也不会多加思考,因为是private呀,对客户程序来说,类的可见部分仍然是一模一样的,调用方式也是一样的,客户程序不需要做任何修改,就可以提高效率(假设m_cch是为了提高效率而增加的一个私有变量)。这是多么欢饮鼓舞的一件事。

于是所有的客户机上原先的FastString.dll被覆盖为新版本的。然后程序A正常,程序B也正常,程序C,咦,混进来了奇怪的东西,突然弹了关于内存错误的对话框。不要惊奇,这简直是必然的。

因为private虽然封装了FastString类的私有成员,客户程序ABC都看不到它内部已经增加了私有数据,或者说客户程序看上去好像一点也不关心那些私有的数据和方法。是的,看上去是这样,但实际上并不是。

不需要关心FastString类的内部构造的只是编写客户程序的程序员。客户程序ABC必须要知道FastString类的内存布局的信息(也就是成员的大小和顺序),编译器才能够在客户程序中构造FastString类的实例,或者使用它的成员函数。

这句话意味着,在使用旧版本的FastString.dll出的应用程序ABC中,FastString被定义为一片长度为sizeof(char *)的内存空间,运行时也就只分配给一个FastString对象这么大的空间,而在新版本的FastString.dll中一个FastString对象占sizeof(char *)+sizeof(int)这么大的空间。在新版本FastString.dll执行自己的工作想要访问int m_cch所在的空间时,应用程序ABC就会发现,它越界了。于是警告。

要解决这个问题不难。新加一个类,在这个类中把客户程序的调用函数都转交到FastString类手中。也就是用一个从FastString类抽象出来的接口类代替现在的FastString类导出。客户程序对FastString类的调用都是通过接口类转交。数据成员都包含在FastString中,实现的修改也都只是发生在FastString类,接口类始终相对固定。

这个解决方法虽然好用,但设想一下,我们要包装的是一个巨大的类库。工作量繁复冗长,而且所有的功能实现都增加了一次call开销。

比接口类调用转交更好的是虚基类

没错,C++有一个非常好用的虚函数,它完全可以完成上面的这种调用转交的工作,而且不需要增加一次call开销。

[疑问1的开始]

这时候暴露给客户程序的类是FastString的纯虚基类。

// ifaststring.h

class IFastString

{

public:

    virtual int length(void) const =0;

    virtual int Find(const char *psz) const = 0;

};

但是功能的实现最终是始终需要实例化出一个对象,纯虚基类是不能被实例化的,因此在IFastString模块的cpp文件中,需要写一个全局函数来返回一个FastString类的实例。

// ifaststring.h

extern “C”

IFastString *CreateFastString(const char *psz)

// ifaststring.cpp

IFastString *CreateFastString(const char *psz){

    Return new FastString(psz);

}

[疑问1的结束]

[疑问1:在这里,为什么要用一个纯虚基类感觉比较疑惑,如果不使用纯虚基类而是普通的基类,那么客户程序不就可以自由的实例化IFastString了?也就不再需要一个专门返回FastString对象的全局函数。唯一的不同就是如果这样做实例化类的编译器是和客户程序相同的编译器,上文那样做使用的是和类库相同的编译器。]

仔细看这个虚基类IFastString,会发现它的析构函数并没有virtual。也就意味着,在析构时,它并不能正确调用继承类的析构函数。也就是说有内存泄露的隐忧。但这里并不建议将析构函数virtual。[疑问2的开始]因为虚函数表的实现方式是由编译器决定的,虚析构函数在虚函数表中的位置取决与编译器。这会破坏类库的编译器独立性。[疑问2的结束]一个可行的替代方案是增加一个显式的virtual delete方法,并且让派生类在这个方法中实现删除自身。

[疑问2:其他虚函数怎么就不会破坏类库的编译器独立性了呢?]

 

--未完--

你可能感兴趣的:(C++)