Delphi 中的DLL 封装和调用对象技术
Delphi 中的DLL 封装和调用对象技术
本文刊登2003 年10 月份出版的Dr.Dobb's 软件研发第3 期
刘 艺
摘 要
DLL 是一种应用最为广泛的动态链接技术但是由于在DLL 中封装和调用对象受到对
象动态绑定机制的限制使得DLL 在封装对象方面有一定的技术难度导致有些Delphi 程
序员误以为DLL 只支持封装函数不支持封装对象本文着重介绍了DLL 中封装和调用对
象的原理和思路并结合实例给出了多种不同的实现方法
关键字动态链接库DLL 对象接口虚方法动态绑定类引用面向对象
1 物理封装与动态链接
物理上的封装意味着将程序封装成若干个独立的物理组成部分各部分之间通过动态链
接共同完成系统的功能而且各个物理组成部分可以单独维护和编译不影响其他部分要
理解物理封装首先要搞清楚静态链接和动态链接
在Delphi 中如果程序的各个模块分别保存在不同的单元文件中并通过uses 指令来
互相调用这就是一个典型的静态链接于是各个静态的子例程编译之后连接器从Delphi
编译过的单元或静态库中取出子例程编译代码并添加到执行文件中最终EXE 文件包
括了程序及其所属单元的所有代码显然静态链接的单元或模块最终以一个独立的物理形
式可执行文件存在除了自己编写的单元文件Delphi 还自动uses 了一些预设的单元
如Windows Messages 等这些都是静态链接
静态链接无法实现物理上的切割和封装而且一旦其中某个单元或模块改动其他所有
单元或模块都得随之重新编译和连接
用于实现物理切割和封装的bpl 包DLL 动态链接库或COM+组件都是一种动态链接
的形式在动态链接情况中连接器只使用子例程external 声明中的信息在执行文件中产生
一些数据表格当Windows 向内存中装载执行文件时它首先装载所有必需的DLL 然后
程序才会启动在装载过程中Windows 用函数在内存中的地址填充程序的内部表格
每当程序调用一个外部函数时它就会使用该内部数据表格直接对DLL 代码它当前
装载在程序的地址空间中进行调用注意该模式不会涉及两个不同的应用程序DLL
已经变成了应用程序的一部分并装载在同一地址空间所有参数的传递都发生在堆栈上
与其它任何函数调用一样这里我们不打算讨论DLL 的编译因为我们首先想重点介绍
Delphi 中的DLL 封装和调用对象技术
2 用DLL 封装对象
DLL Dynamic Link Library 动态链接库就目前来讲已经不再是什么新技术读者可
以在书店过时的Delphi 书籍里随便找到讨论DLL 编程的章节但这些涉及DLL 编程的书
中几乎都是谈论用DLL 来封装函数的实际上大量的程序员也是在使用DLL 来封装函数
或面向过程的一个模块一个函数集合而在这里我只想讨论如何用DLL 来封装对象
这可能是读者未曾有过的DLL 使用经验但这却是这本完全围绕面向对象编程的书中重要
的部分之一或许你能从中发现一些与众不同的实用技巧
参见考虑到目前关于DLL的现成资料很多这里我省略了DLL的基本知识和编写
方法假设读者已经有了一定的DLL编程基础如果你没有这样的基础建议参阅
拙作Delphi6企业级解决方案及应用剖析DLL编程技术一节P271
一般来说使用DLL 封装对象主要有以下好处
节约内存多个程序可以使用同一个DLL 时该DLL 只需加载一次而且可以只
在使用时加载不用时销毁
使程序代码实现复用这就是说用DLL 封装的对象可以重复使用甚至可以让不
同的程序语言调用
使程序模块化组件化这样利于团队开发维护和更新方便
然而DLL 在封装对象方面却有一定的技术难度这方面资料极少甚至有的程序员误
以为DLL 只支持封装函数不支持封装对象
通过研究我们发现DLL 在封装对象上主要的限制在于
调用DLL 的应用程序只能使用DLL 中对象的动态绑定的方法
DLL 封装对象的实例只能在DLL 中创建
在DLL 和调用DLL 的应用程序中都需要对封装的对象及其被调用的方法进行声
明
下面我先通过一个简单的例子来演示如何使用DLL 封装对象并在应用程序中调用该
对象然后再讨论相关的技术细节
3 一个简单的例子
读者一定还记得我们在前面章节中演示了车的继承关系和合成关系这个程序由逻辑单
元的Demo 和界面单元frmDemo 组成我们现在就用DLL 封装Demo 单元的所有对象并
在frmDemo 单元实现调用读者可以通过这个具体的例子来学习如何使用DLL 封装对象
打开项目文件ObjDemo.dpr 如图 1 所示在项目管理器Project Manager 中鼠标
右击ProjectGroup1 然后在弹出菜单中选择Add New Project...菜单项此时弹出如图 2 所
示的New Items 对话框选择DLL Wizard Delphi 的DLL 向导将创建一个DLL 项目我
们将该项目重新命名为DemoSvr 并保存在项目组同一目录下
图 1 鼠标右击ProjectGroup1 在弹出菜单中选择Add New Project...菜单项
图 2 在New Items 对话框中选择DLL Wizard
修改DemoSvr 中的代码如示例程序 1 所示
示例程序 1 动态链接库DemoSvr 的主程序
library DemoSvr;
{ Important note about DLL memory management: ShareMem must be the
first unit in your library's USES clause AND your project's (select
Project-View Source) USES clause if your DLL exports any procedures or
functions that pass strings as parameters or function results. This
applies to all strings passed to and from your DLL--even those that
are nested in records and classes. ShareMem is the interface unit to
the BORLNDMM.DLL shared memory manager, which must be deployed along
with your DLL. To avoid using BORLNDMM.DLL, pass string information
using PChar or ShortString parameters. }
uses
ShareMem,
SysUtils,
Classes,
Demo in 'Demo.PAS';
{$R *.res}
function CarObj:TCar;
begin
Result:=TCar.create;
end;
function BicycleObj:TBicycle;
begin
Result:=TBicycle.create;
end;
exports
CarObj,
BicycleObj;
end.
由此可见 DLL 封装对象的实例是在DLL 中创建的CarObj 和BicycleObj 函数创建
并输出了Car 对象和Bicycle 对象的引用这样DemoSvr 动态链接库就可以通过CarObj 和
BicycleObj 函数输出Car 对象和Bicycle 对象了但是Car 对象和Bicycle 对象是在Demo.pas
文件中声明和实现的所以这里uses 了Demo.PAS
为了能够使用Demo.PAS 在项目管理器中直接把Demo.pas 文件从ObjDemo 项目中拖
放到DemoSvr 项目中如图 3 所示
图 3 将Demo.pas 文件从ObjDemo 项目中拖放到DemoSvr 项目中
打开Demo.pas 修改TBicycle 和TCar 的声明如下
TBicycle = class(TVehicle)
public
constructor create;
destructor Destory;
procedure ride;virtual;
end;
TCar = class(TVehicle)
protected
FEngine: TEngine;
public
constructor create;
destructor Destory;
procedure drive;virtual;
end;
请注意这里我把应用程序中需要调用的对象方法ride 和drive 改成了虚方法显然
这么做不是为了让TBicycle 和TCar 的派生类来覆盖ride 和drive 方法这是因为编译连接
应用程序时编译器无法知道也无需知道对象在DLL 中的方法是如何实现的这就意
味着对于应用程序来说要使用动态绑定晚绑定技术所以调用DLL 的应用程序只能使
用DLL 中对象的动态绑定的方法前面我们讲过虚方法的动态绑定技术是把虚方法的入
口放到虚方法表VMT 中VMT 是一块包含对象方法指针的内存区通过VMT 调用程序可
以得到虚方法的指针如果我们不把ride 和drive 声明为虚方法VMT 中就不会有这些方
法的入口指针因此调用程序也就无法得到这个方法的入口指针
接下来回到frmDemo 单元在调用DLL 的应用程序中同步声明需要调用的的对象及其
被调用的方法这里除了将ride 和drive 声明为虚方法外还要声明为抽象方法因为
frmDemo 单元不提供ride 和drive 方法的实现不把它们声明为抽象方法则编译时无法通过
应用程序frmDemo 单元的完整代码如示例程序 2 所示
示例程序 2 调用DLL 对象的应用程序
unit frmDemo;
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, StdCtrls;
type
//---这里声明需要用到的DLL 中对象的方法---
TVehicle = class(TObject);
TCar = class(TVehicle)
public
procedure drive;virtual;abstract;
end;
TBicycle = class(TVehicle)
public
procedure ride;virtual;abstract;
end;
//----------------------------
TForm1 = class(TForm)
Button1: TButton;
Button2: TButton;
procedure Button2Click(Sender: TObject);
procedure Button1Click(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
end;
var
Form1: TForm1;
//---这里导入DLL 文件及其函数---
function CarObj:TCar ;external 'DemoSvr.dll';
function BicycleObj:TBicycle ;external 'DemoSvr.dll';
implementation
{$R *.dfm}
procedure TForm1.Button2Click(Sender: TObject);
var MyCar:TCar;
begin
MyCar:=CarObj;
if Mycar=nil then exit;
try
MyCar.drive;
finally
MyCar.Free;
end;
end;
procedure TForm1.Button1Click(Sender: TObject);
var Bicycle:TBicycle;
begin
Bicycle:=BicycleObj;
try
Bicycle.ride;
finally
Bicycle.Free;
end;
end;
end.
最后选择Build All Projects 菜单项编译和连接所有的项目如图 4 所示我们就得
到了需要的应用程序可执行文件以及DLL 运行测试可以看到这个程序实现了和原先一
样的功能
但是我对这样的DLL 封装对象实现不是太满意因为在DLL 和应用程序中都需要声明
封装的对象还要使用好virtual 和abstract 限定符很容易造成阅读程序理解上的错觉
如果一旦对象发生变化就需要分别在两边修改对象声明以保持同步稍有不慎就会出错
对此Steve Teixeira 在Delphi6 开发人员指南机械工业出版社2003 年出版相关内容
参见该书209 页一书中提出了使用头文件的方法并通过加上编译指令来控制DLL 和应
用程序分别读到不同的头文件内容这个方法虽然可以通过只修改头文件来保持声明的同
步但编译指令和头文件使得阅读程序更加困难
在这里我有一个更好的方法供读者分享这就是使用接口的方法
4 利用Delphi 接口实现DLL 中对象的动态绑定
前面我们分析过调用DLL 的应用程序只能使用DLL 中对象的动态绑定的方法理解
这一点是实现DLL 封装和使用对象的关键那么Delphi 接口技术为我们提供了一个最佳
选择
图 4 选择Build All Projects 菜单项编译和连接所有的项目
为此我们创建一个接口单元IDemo 分别声明ICar 和IBicycle 接口接口方法分别是
应用程序要用到的Drive 和Ride 完整代码如示例程序 3 所示
示例程序 3 接口单元IDemo 的代码
unit IDemo;
interface
type
ICar = interface (IInterface)
['{ED52E264-6683-11D7-B847-001060806215}']
procedure Drive;
end;
IBicycle = interface (IInterface)
['{ED52E264-6683-11D7-B847-001060806216}']
procedure Ride;
end;
implementation
end.
注意接口单元IDemo 中没有也不能有任何实现它同时被应用程序和DLL 所用
Use 这样当需要修改应用程序调用的对象方法时只要在一个地方即该接口单元
修改即可避免了可能出现的声明不一致错误
使用接口还带来了更多的好处首先无需使用virtual 和abstract 限定符修改对象方法声
明避免了程序阅读上的错觉其次利用接口实例计数器自动管理对象的生命期避免了
程序员遗忘销毁对象造成的内存泄漏
为了使用接口我将Demo 单元的类型声明部分作了以下修改以便TBicycle 和TCar
类能够实现接口方法值得高兴的是该单元仅仅需要修改声明部分而程序实现部分根本
不需要做任何改动
unit Demo;
interface
uses
SysUtils, Windows, Messages, Classes, Dialogs,IDemo;
type
TVehicle = class(TInterfacedObject)
protected
FColor: string;
FMake: string;
FTopSpeed: Integer;
FWheel: TWheel;
FWheels: TList;
procedure SlowDown;
procedure SpeedUp;
procedure Start;
procedure Stop;
end;
TBicycle = class(TVehicle,IBicycle)
public
constructor create;
destructor Destory;
procedure ride;
end;
TCar = class(TVehicle,ICar)
protected
FEngine: TEngine;
public
constructor create;
destructor Destory;
procedure drive;
end;
最后检查一下项目管理器确保在应用程序项目和DLL 项目中都添加了IDemo 单元
如图 5 所示
图 5 确保在应用程序项目和DLL 项目中都添加了接口单元IDemo
示例程序 4 使用接口技术调用DLL 对象的应用程序
unit frmDemo;
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, StdCtrls, IDemo;//在这里Use IDemo 单元
type
TForm1 = class(TForm)
Button1: TButton;
Button2: TButton;
procedure Button2Click(Sender: TObject);
procedure Button1Click(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
end;
var
Form1: TForm1;
function CarObj:ICar ;external 'DemoSvr.dll';
function BicycleObj:IBicycle ;external 'DemoSvr.dll';
implementation
{$R *.dfm}
procedure TForm1.Button2Click(Sender: TObject);
var MyCar:ICar;
begin
MyCar:=CarObj;
MyCar.drive;
Mycar:=nil;
end;
procedure TForm1.Button1Click(Sender: TObject);
var Bicycle:IBicycle;
begin
Bicycle:=BicycleObj;
Bicycle.ride;
Bicycle:=nil;
end;
end.
在示例程序 4 中改动不是很多这里Use 了IDemo 单元而没有额外的声明实现
部分通过接口调用了DLL 中的接口方法也可以说是对象方法运行示例程序 4 和运行示
例程序 2 实现的功能完全一样
5 使用抽象类实现DLL 中对象的动态绑定
既然DLL 中封装和调用对象受到了对象动态绑定机制的限制那么除了利用Delphi 接
口技术外我们还可以考虑使用抽象类来实现DLL 中对象的动态绑定机制
图 6 显示了一个基于数据库应用的示例程序的面向对象设计我将界面部分设计成一
个瘦客户机的形式这是一个供用户交互的可执行文件distributabel2.exe 它封装了外观
类TfrmUsers 我把业务部分包括数据模块设计成提供服务的服务器这是一个动态链
接库文件UserSvr.dll 它封装了业务类TuserMaint 和数据库访问类TuserDM 这种设计
体现了界面和业务分离的思想
图 6 界面和业务的物理分离的设计
由于原来的逻辑独立的类和代码存放在不同的单元文件中我们很容易重新将它们划分
到不同的项目里如图 7 所示
图 7 重新将逻辑独立的单元文件划分到不同的项目里
瘦客户机其实上就是一个空壳只提供交互的界面它的外观类TfrmUsers 向
TUserMaint 的实例对象请求服务该对象封装在DLL 中前面我已经讲过如何用DLL 来封
装对象除了前面讲过的两种方法外这里我想介绍第三种方法即使用抽象类做接口的方
法
由于调用DLL 的应用程序只能使用DLL 中对象的动态绑定的方法我们不妨专门设计
一个抽象类TIUserMaint 作为提供对象方法的接口在抽象类TIUserMaint 中有供应用程
序使用的对象方法不过它们都是虚抽象方法目的是支持动态绑定而又无需提供实现
我将新增的TIUserMaint 放在抽象类接口单元uIUserMaint.pas 文件中其源代码如示例
程序 5 所示这个单元将作为接口文件分别包含在UserSvr 和Distributable2 项目中如图 7
所示
示例程序 5 抽象类接口单元uIUserMaint 代码
unit uIUserMaint;
interface
uses
Classes;
type
TIUserMaint = class (TObject)
public
function GetDepList: TStrings;virtual;abstract;
function GetUserList(strName:String): OLEVariant;virtual;abstract;
procedure UpdateUserData(UserData:OleVariant; out ErrCount: Integer);
virtual;abstract;
constructor create;virtual;abstract;
end;
TIUserMaintClass=class of TIUserMaint;
implementation
//没有实现代码
end.
在示例程序 5 中还定义了TIUserMaintClass 类型它是TIUserMaint 的类引用这对
于把实现类从DLL 传递到进行调用的应用程序是必要的
一般抽象类只定义接口它由虚抽象方法组成而没有实际的数据为了实现抽象类
TIUserMaint 的抽象方法原来的TUserMaint 类需要继承TIUserMaint 类并覆盖其所有的
虚抽象方法新的TUserMaint 类声明如下
TUserMaint = class (TIUserMaint)
private
UserDM:TUserDM;
public
function GetDepList: TStrings;override;
function GetUserList(strName:String): OLEVariant;override;
procedure UpdateUserData(UserData:OleVariant; out ErrCount: Integer);
override;
constructor create;override;
destructor Destroy;override;
end;
但实际上TUserMaint 类原有的实现部分并不需要改动所以我们的工作量不大
示例程序 6 是动态链接库UserSvr.dll 的源代码这里我使用了TObjUsers 函数该函
数返回了一个类型为TIUserMaintClass 的类引用而不是对象引用所以在应用程序中可以使
用这样的代码来创建DLL 封装的对象
objUsers:=TObjUsers.Create;
但这不意味着TObjUsers 是一个类记住这里TObjUsers 是一个DLL 输出的函数它
的返回类型是一个类引用类型
示例程序 6 动态链接库UserSvr.dll 的源代码
library UserSvr;
uses
ShareMem,
SysUtils,
Classes,
uUserMaint in 'uUserMaint.pas',
udmUser in 'udmUser.pas' {UserDM: TDataModule},
uIUserMaint in 'uIUserMaint.pas';
{$R *.res}
function TObjUsers:TIUserMaintClass;
begin
result:=TUserMaint;
end;
exports
TObjUsers;
begin
end.
细心的读者可能已经发现既然TObjUsers 函数的返回类型为TIUserMaintClass
TIUserMaintClass 在示例程序 5 中声明为:
TIUserMaintClass=class of TIUserMaint;
那么result:=TUserMaint 会不会是写错了呢
没有写错我们在应用程序中声明了TIUserMaint 类型的对象 objUsers 通过传递类引
用那条objUsers:=TObjUsers.Create 语句实现的是objUsers:= TUserMaint.Create 功能这里
面隐含了TUserMaint 向TIUserMaint 转型的过程当然TIUserMaint 作为抽象类本身也无
法直接创建自己的实例所以必须通过转型才行另外在TIUserMaint 的派生类中可以随
意改变方法的实现却不会影响到方法的接口这就是说你以后通过进一步修改DLL 封
装对象的实现方法来升级DLL 无需重新修改和编译应用程序因为TIUserMaint 作为抽象
类提供的方法接口没有改变
示例程序 7 是应用程序实现界面和业务的物理分离后的界面单元的源代码这里要注
意几点
在interface 部分要Uses 抽象类接口单元uIUserMaint
objUsers 声明为TIUserMaint 类型
声明DLL 函数function TObjUsers:TIUserMaintClass; external 'UserSvr.dll';
除此之外几乎不需要进行其他的改动由此可见从界面和业务的逻辑分离演化到界
面和业务的物理分离实际上并不是想象的那样困难
示例程序 7 物理分离后的界面单元代码
unit ufrmUsers;
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, DB, DBClient, StdCtrls, DBCtrls, Grids, DBGrids, Mask, ExtCtrls,
Buttons,uIUserMaint;
type
TfrmUsers = class(TForm)
btnExit: TButton;
btnQryByName: TSpeedButton;
Label1: TLabel;
Label2: TLabel;
Label3: TLabel;
Label5: TLabel;
Label6: TLabel;
Label7: TLabel;
edtQryByName: TLabeledEdit;
DBEdit1: TDBEdit;
DBEdit2: TDBEdit;
DBEdit3: TDBEdit;
DBEdit4: TDBEdit;
DBGrid1: TDBGrid;
dbcbSex: TDBComboBox;
dbcbDep: TDBComboBox;
DataSource1: TDataSource;
cdsUserMaint: TClientDataSet;
cdsUserMaintID: TWideStringField;
cdsUserMaintNAME: TWideStringField;
cdsUserMaintSEX: TWideStringField;
cdsUserMaintJOB: TWideStringField;
cdsUserMaintTEL: TWideStringField;
cdsUserMaintCALL: TWideStringField;
cdsUserMaintDEP: TWideStringField;
cdsUserMaintGROUP_ID: TWideStringField;
cdsUserMaintPASSWORD: TWideStringField;
btnUpdate: TBitBtn;
procedure btnUpdateClick(Sender: TObject);
procedure btnQryByNameClick(Sender: TObject);
procedure FormCreate(Sender: TObject);
procedure btnExitClick(Sender: TObject);
procedure FormDestroy(Sender: TObject);
private
objUsers:TIUserMaint;
public
{ Public declarations }
end;
var
frmUsers: TfrmUsers;
const
M_TITLE='操作提示';//所有提示对话框的标题
implementation
{$R *.dfm}
function TObjUsers:TIUserMaintClass;
external 'UserSvr.dll';
procedure TfrmUsers.btnUpdateClick(Sender: TObject);
var
nErr:integer;
begin
if cdsUserMaint.State=dsEdit then cdsUserMaint.Post;
if (cdsUserMaint.ChangeCount > 0) then
begin
objUsers.UpdateUserData(cdsUserMaint.Delta,nErr);
if nErr>0 then
application.MessageBox('更新失败',M_TITLE,MB_ICONWARNING)
else
begin
application.MessageBox('更新成功',M_TITLE,MB_ICONINFORMATION) ;
btnQryByNameClick(nil);
end;
end;