上一讲我们提到什么是封装,继承。和多态,现在我们讲解delphi中是如何实现它们的?以及为什么我们需要封装,继承和多态。
1、首先来说封装,delphi中用关键字private修饰符来说明,即表示类的成员为私有的,在类的外部是不可见了,只有在本类中可以随便使用。它的访问权限最低。
2、delphi中还有一个比较特殊的修饰符protected,表示该类的成员为保护型。受保护的成员只能由该类的和派生类可以访问。如果想要子类继承父类的某些功能的时候,可以使用某些成员为protected类型,访问权限只能是本类或子类中可以访问。
3.delphi中还有一个共有型的成员public,这个成员可以在任意的地方使用,它的访问权限不受任何限制。
4. delphi中的还有一种类型:published是一个发布类型,它与public类型相同,不同的是在发布的成员在运行时可以产生运行时的类型信息。一般用于组件。
继承就是根据父类中继承的一个新类。它具有父类的特点,同使它也有自己的一些属性和方法。这就是继承。换句话说就是对父类的基础上添加一些新的,更加有效的方法。
继承的优点是可以有效的降低代码的冗余,提高代码复用性。
举例说吧
type
TForm1 = class(TForm)
private
{ Private declarations }
public
{ Public declarations }
end;
这表示我们建立的所有窗体都是继承自TForm这个类。如果我们希望在某个项目中所有的窗体都具有相同的风格和字体标准,大不必为每个窗体一一指定,只需要定义一个标准的窗体作为项目中所有窗体的基类就可以了,这样我们只要在这个基类的窗体中定义窗体风格和字体标准即可应用到所有它的子类窗体中。假设我们设计一个MDI项目,希望所有的MDI窗体都具有相同的界面布局,就可以先建立一个MDI窗体基类,然后让所有的MDI窗体都从此类派生,而非TForm类。
TfrmMDIBase = class(TForm)
CoolBar1: TCoolBar;
ToolBar1: TToolBar;
ToolButton1: TToolButton;
ToolButton2: TToolButton;
ToolButton3: TToolButton;
ToolButton4: TToolButton;
ToolButton5: TToolButton;
dsActive: TDataSource;
ToolButton6: TToolButton;
ToolButton7: TToolButton;
ToolButton8: TToolButton;
ToolButton9: TToolButton;
ToolButton10: TToolButton;
ActionList1: TActionList;
DataSetFirst1: TDataSetFirst;
DataSetPrior1: TDataSetPrior;
DataSetNext1: TDataSetNext;
DataSetLast1: TDataSetLast;
DBGridEh1: TDBGridEh;
aqActive: TADOQuery;
actNew: TAction;
actEdit: TAction;
actExit: TAction;
actDelete: TAction;
ToolButton11: TToolButton;
actRefresh: TAction;
Panel1: TPanel;
pnlEdit: TPanel;
actCancel: TAction;
ToolButton12: TToolButton;
actSave: TAction;
ToolButton13: TToolButton;
procedure FormClose(Sender: TObject; var Action: TCloseAction);
procedure FormCreate(Sender: TObject);
procedure actNewExecute(Sender: TObject);
procedure actDeleteExecute(Sender: TObject);
procedure actExitExecute(Sender: TObject);
procedure actNewUpdate(Sender: TObject);
procedure actDeleteUpdate(Sender: TObject);
procedure actExitUpdate(Sender: TObject);
procedure aqActiveAfterInsert(DataSet: TDataSet);
procedure actRefreshUpdate(Sender: TObject);
procedure actRefreshExecute(Sender: TObject);
procedure actCancelExecute(Sender: TObject);
procedure actCancelUpdate(Sender: TObject);
procedure actSaveExecute(Sender: TObject);
procedure actSaveUpdate(Sender: TObject);
procedure aqActivePostError(DataSet: TDataSet; E: EDatabaseError;
var Action: TDataAction);
private
{ Private declarations }
procedure OpenDataSet;
procedure CloseDataSet;
protected
public
{ Public declarations }
end;
在子类中,可以使用关键字Override改写祖先类中使用Virtual关键字声明虚方法。如果在改写的方法只是想在父类的执行之后增加些自己的处理,而不是完全覆盖掉父类中的代码,可以使用inherited关键字
destructor TCollectionItem.Destroy;
begin
SetCollection(nil);
inherited Destroy;
end;
点型的用法就是在李维的《Inside VCL(深入核心--VCL架构剖析》一书中曾提到过的三明治法
destructor TStringList.Destroy;
begin
FOnChange := nil;
FOnChanging := nil;
inherited Destroy;
if FCount <> 0 then Finalize(FList^[0], FCount);
FCount := 0;
SetCapacity(0);
end;
在Delphi中没有像C++那样的多继承,即每个类只可以有一个父类。不过在Delphi中也可以通过接口(Interface)来实现这种多继承,确切的说这并不是多继承,只是看起来像是多继承。
下面讲解多态(备注:转Delphi之多态)
多态的本质
读小学时,每周都进行大扫除。老师为每个同学都分配了不同的任务,有的扫地,有
的擦桌,我的任务是擦窗,每个人拥有自己的职责。然后老师的一声“开始劳动!”就启
动了每个人的不同的工作。甚至之后每个星期的大扫除,老师都只需要下一个“开始劳动”
的指令,就可以驱动所有人完成不同的任务。
一个抽象的指令,可以让每个个体分别完成具有同一性质但不同内容的动作,多神
奇啊!
这就是多态——面向对象编程的核心概念。为了能让读者先对多态抱有足够的重视和
尊重,请相信:无论怎样强调多态在OOP中的重要性,都不为过。不理解它,也就不会真
正明白什么是OOP!
多态的概念与接口重用
首先,什么是多态(Polymorphisn)?按字面的意思来讲,就是“多种形状”。笔者也
没有找到对多态的非常学术性的描述,暂且引用一下Charlie Calvert对多态的描述——多态
性是允许用户将父对象设置成为与一个或更多的它的子对象相等的技术,赋值之后,基类
对象就可以根据当前赋值给它的派生类对象的特性以不同的方式运作。
更简单地说就是:多态性允许用户将派生类类型的指针赋值给基类类型的指针。多态
性在Object Pascal中是通过虚方法(Virtual Method)实现的。
什么是“虚方法”?虚方法就是允许被其派生类重新定义的方法。派生类重新定义基
类虚方法的做法,称为“覆盖”(override)。
这里有一个初学者经常混淆的概念:覆盖(override)和重载(overload)。如前所述,
覆盖是指派生类重新定义基类的虚方法的方法。而重载,是指允许存在多个同名函数,这
些函数的参数表不同(或许是参数个数不同,或许是参数类型不同,或许两者都不同)。
重载的概念并不属于“面向对象编程”。重载的可能的实现是:编译器根据函数不同的参
数表,对同名函数的名称做修饰,然后这些同名函数就成了不同的函数(至少对于编译器
来说)。例如,有两个重载的同名函数
function func(p : integer) : integer; overload;
function func(p : string) : integer; overload;
那么编译器做过修饰后的函数名可能是:int_func、str_func。如果调用
func(2);
func(′hello′);
那么编译器会把这两行代码分别转换成:
int_func(2);
str_func(′hello′);
这两个函数的调用入口地址在编译期间就已经静态(记住:是静态!)确定了。这样
的确定函数调用入口地址的方法称为早绑定。
而覆盖则是:当派生类重定义了基类的虚方法后,由于重定义的派生类的方法地址无
法给出,其调用地址在编译期间便无法确定,故基类指针必须根据赋给它的不同的派生类
指针,在运行期动态地(记住:是动态!)调用属于派生类的虚方法。这样的确定函数调
用地址的方法称为晚绑定。引用一句Bruce Eckel的话:“不要犯傻,如果它不是晚绑定,
它就不是多态”。
..注意:重载只是一种语言特性,与多态无关,与面向对象也无关!
多态是通过虚方法实现的,而虚方法是通过晚绑定(或动态绑定)实现的。
其次,多态的作用是什么呢?前两节已经讲到,封装可以隐藏实现细节,使得代码模
块化;继承可以扩展已存在的代码模块,它们的目的都是为了代码重用。而多态则是为了
实现另一个目的——接口重用。
什么是接口重用?举一个简单的例子,假设有一个描述飞机的基类:
type
TPlane = class
protected
FModal : String; // 型号
public
procedure fly(); virtual; abstract; // 起飞抽象方法
procedure land(); virtual; abstract; // 着陆抽象方法
function modal() : string; virtual; // 查寻型号虚方法
…… // 其他可能的操作
end;
然后,从TPlane派生出两个派生类,直升机(TCopter)和喷气式飞机(TJet):
TCopter = class(TPlane)
public
constructor Create();
destructor Destroy(); override;
procedure fly(); override;
procedure land(); override;
function modol() : string; override;
…… //其他可能的操作
end;
TJet = class(TPlane)
public
constructor Create();
destructor Destroy(); override;
procedure fly(); override;
procedure land(); override;
…… //其他可能的操作,没有覆盖modal()
end;
TPlane类的声明中,fly和land方法都是被声明为virtual和abstract的,这是向编译器
指出这些方法是抽象(纯虚)的,也就是在TPlane类中不提供这些方法的实现,而派生类
则必须实现它,即规定了一套接口。凡是含有abstract方法的类被称为“抽象类”,永远无
法创建抽象类的实例对象。抽象类是被用来作为接口的。
现在,假设要完成一个机场管理系统,在有了以上的TPlane之后,再编写一个全局的
函数g_FlyPlane(),就可以让所有传递给它的飞机起飞:
procedure g_FlyPlane(const Plane : TPlane);
begin
Plane.fly();
end;
是的,仅仅如此就可以让所有传给它的飞机(TPlane的派生类对象)正常起飞!不管
是直升机还是喷气式飞机,甚至是现在还不存在的、以后会增加的飞碟。这是因为,每个
派生类(真正的飞机)都可以通过“override”来定义适合自己的起飞方式。
可以看到,g_FlyPlane()函数接受的参数是TPlane类对象的引用,而实际传递给它的都
是 TPlane的派生类对象。现在回想一下本节开头所描述的“多态”:多态性是允许将父对
象设置成为与一个或更多的它的子对象相等的技术,赋值之后,父对象就可以根据当前赋
值给它的子对象的特性以不同的方式运作。很显然,
parent := child;
就是多态的实质!这里的飞机类(TPlane)作为一种接口,而该接口就是被重用的目标。
多态的本质就是“将派生类类型的指针赋值给基类类型的指针”(在Object Pascal中
是引用),只要这样的赋值发生了,就是在应用多态了,因为在此实行了“向上映射”(“上
下”是指类继承层次关系)。
应用多态的例子非常普遍。在Delphi的VCL类库中,最典型的就是:TObject类有一
个虚拟的Destroy析构函数和一个非虚拟的Free方法。Free方法中首先判断对象本身是否
为nil,保证不为nil时便调用Destroy。对任何对象(都是TObject的派生类对象)调用其
Free();方法,但执行的都是TObject.Free();(因为TObject.Free()为非虚拟方法,无法被覆盖),
然后由它调用被每个类重定义了的析构函数Destroy();(因为Destroy()为虚方法,派生类可
以覆盖),这就保证了任何类型的对象都可以正确、安全地被析构。
因此,在定义自己的类时,如果有析构函数存在,就必须在它的声明之后加上override
关键字。否则会发生什么呢?
还拿刚才的飞机类作为例子,有一个飞机销毁站,这个销毁站有一个函数:
procedure DestroyPlane(var Plane : TPlane)
begin
Plane.Free();
Plane := nil
end;
将正常的飞机(析构函数带有override的飞机)传给它,编译器都会正常地调用飞机
的析构函数用以将飞机拆解开,将资源正常回收。
如果建造的飞机的析构函数没有被指明override关键字,那么,将飞机传递给这个
DestroyPlane()的函数时,编译器调用的绝对不是用户所给飞机定义的析构函数,而会是
TPlane.Destroy()。能指望TPlane的析构函数会好好拆解飞机吗?祈祷吧,别把油箱弄爆
炸了。
也就是说,在被执行了
parent := child;
之后,无论调用
parent.Free();
还是调用
child.Free();
都应该产生同样的结果,从语义上来说,这两行代码必须做相同的事情。当然,这样的情
况不仅仅只对于析构函数而言,任何想要使通过基类对象指针做到的事情与通过派生类对
象指针所做的相同,就要在基类中将这个方法声明为virtual,在派生类中将该方法声明为
override。
..注意:给自己的析构函数加上override声明!
多态的实现与VMT/DMT
多态的本质是“将派生类类型的指针赋值给基类类型的指针”。那么,为什么这种赋
值是允许的,或者说是安全的呢?
从语义上来讲,继承所表现的是“是一种”的关系,也就是说,每个派生类对象必定
“是一种”基类对象。所以,任何向基类类型的请求,派生类对象都可以无条件地正常处
理。因为直升机“是一种”飞机,喷气式飞机也“是一种”飞机,所以所有对飞机的操作
请求,它们都应该可以正常处理。
从语言上来讲,由于派生类通常比基类拥有更多的数据成员而绝对不会更少,派生类
对象所占的内存空间必定大于或等于基类对象所占的内存空间。因此,将基类类型的指针
指向派生类类型的对象时,在指针的可视范围中的内存必定是可用的,这一部分内存空间
必定是属于对象的,所以这种赋值行为是合法的、安全的,并且得到编译器认可的。
例如,如下的两个类:
TBase = class
private
FMember1 : Integer;
FMember2 : Integer;
end;
TDerived = class(TBase)
private
FMember3 : Integer;
end;
当TBase类型的指针指向其派生类类型TDerived的对象后:
var
Parent : TBase;
Child : TDerived;
Begin
Child := TDerived.Create();
Parent := Child; //当执行这行代码之后……
// 以上两行代码也可以简化为Parent := TDerived.Create();
…… //之后的代码省略
End;
TBase类型的指针(Parent指针)指向了Child对象实体所在的内存首地址。在2.3节
中说过,每个派生类对象实体中都包含了一个完整的基类对象实体。此处的Parent指针可
以访问的范围,正是这个完整基类(TBase)对象实体的大小。因此,Parent指针始终可以
合法地访问其所指向的内存空间。
Parent指针的可视范围如图2.5所示。
指向VMT的指针
Child对象实体
FMember1
FMember2
FMember3
VMT
Parent指针
的可视范围
图2.5 基类指针可视范围演示
在图2.5中又一次看到了VMT,VMT究竟是何方神圣呢?为什么每个对象都会有一
个指向VMT的指针呢?这些问题可以在了解虚方法的动态绑定实现机制中找到答案。搞
清这些,便会清楚多态是如何实现的了。
当创建一个类的实例之后,编译器会在该对象的内存空间的首4个字节安插一个指针,
该指针所指向的地址称为VMT(Virtual Method Table,虚方法表),这个表中存放了该类
的所有虚方法的入口地址。在Object Pascal中,所有类实例都会有这么一个指向VMT的
指针。如果没有在类中声明虚方法,则该指针为nil。
还是以前面所说的飞机抽象类和直升机类为例:
TPlane = class
protected
FModal : String;
public
procedure fly(); virtual; abstract; // 起飞抽象方法
procedure land(); virtual; abstract; // 着陆抽象方法
function modal() : string; virtual; // 查寻型号虚方法
…… // 其他可能的操作
end;
TCopter = class(TPlane)
public
constructor Create();
destructor Destroy(); override;
procedure fly(); override;
procedure land(); override;
…… // 其他可能的操作,没有覆盖TPlane.modal()
end;
在一个全局函数中用飞机类型来创建直升机实例:
procedure g_CreateACopter(var Plane : TPlane);
begin
Plane := TCopter.Create;
end;
当执行Plane := TCopter.Create之后,一个直升机实例就被创建了,并且Plane指针指
向了它,如图2.6所示。
Plane指针 指向VMT的指针
FModal
直升机对象实例直升机类的VMTTCopter.fly()
TCopter.land()
TPlane.modal()
TCopter没有覆盖
TPlane.modal()
图2.6 plane指针指向直升机对象实例
没有被派生类覆盖的方法,编译器会将基类的该方法的实现的入口地址填入派生类的
VMT中。如图2.6所示,直升机类(TCopter)覆盖了其基类(TPlane)的虚方法fly()和land(),
因此在TCopter的VMT中,fly和land被确定为TCopter的实现方法的入口地址。但由于
TCopter没有覆盖TPlane的虚方法modal(),则在VMT的modal()项中被填入了TPlane.modal()
的入口地址,即基类中该方法的入口地址。
被派生类覆盖的方法,则会将派生类实现的方法的入口地址填入VMT中以取代基类
被覆盖的方法。
这就是“晚绑定”或“动态绑定”!
对照图2.6来看,当基类类型的指针指向了直升机实例对象后,可以通过基类类型的
指针来让这架直升机起飞:
plane.fly();
编译器通过plane所指对象的“指向VMT的指针”可以定位到TCopter.fly()的地非曲
直址,由此便可以找到属于直升机的fly()方法,而得以以直升机的起飞方式来让一架直升
机起飞。
虽然动态绑定看起来并不太复杂,但意义重大!如果没有动态绑定,那么,试想一下,
以喷气式飞机的起飞方式来让一架直升机起飞会发生什么?这可关系到飞行员的安全啊。
到这里,读者对动态绑定的实现应该清楚了。细心的读者可能又会发现一个问题,
TPlane中的析构函数也是virtual的,为什么没有出现在VMT中呢?
当然,并非VMT中没有析构函数,只能说它没有出现在我们所能见到的VMT中。之
前所说的“指向VMT的指针”所指向的VMT,其实只是真正的VMT的一部分,也就是
用户定义的第一个虚方法的位置。如果以这个位置作为原点,向正方向即刚才所说的VMT。
而向负方向,则是语言定义的另一些类信息所在的地址,析构函数地址就被放在了负方向
上了。
Delphi之所以这么做,是为了使得Object Pascal的VMT与C++的以及COM的vtable(虚函数表)兼容。
这里给出完整的VMT的地址表(来自Delphi6 Help),不过这些内容仅能作为参考,
因为Borland并不承诺将来不会改变这个格式,如表2.1所列。
表2.1 Delph6 VMT
偏移地址
类 型
描 述
-76
Pointer
pointer to virtual method table (or nil)
-72
Pointer
pointer to interface table (or nil)
-68
Pointer
pointer to Automation information table (or nil)
-64
Pointer
pointer to instance initialization table (or nil)
-60
Pointer
pointer to type information table (or nil)
-56
Pointer
pointer to field definition table (or nil)
-52
Pointer
pointer to method definition table (or nil)
-48
Pointer
pointer to dynamic method table (or nil) (指向DMT的指针)
-44
Pointer
pointer to short string containing class name
-40
Cardinal
instance size in bytes
-36
Pointer
pointer to a pointer to ancestor class (or nil)
续表
偏移地址
类 型
描 述
-32
Pointer
pointer to entry point of SafecallException method (or nil)
-28
Pointer
entry point of AfterConstruction method
-24
Pointer
entry point of BeforeDestruction method
-20
Pointer
entry point of Dispatch method
-16
Pointer
entry point of DefaultHandler method
-12
Pointer
entry point of NewInstance method
-8
Pointer
entry point of FreeInstance method
-4
Pointer
entry point of Destroy destructor
0
Pointer
entry point of first user-defined virtual method
4
Pointer
entry point of second user-defined virtual method
可以看到,偏移地址-28 ~ -4所存放的都是TObject的虚方法地址,当然析构函数也在
其中。
最后,有必要再谈一下Object Pascal所独有的DMT(动态方法表)。
在表2.1的偏移地址为-48处是一个指向DMT的指针,它是干什么用的?它和VMT
有什么关系?
在VMT中可以看到,派生类的虚方法表完全继承了基类的虚方法表,只是将被覆盖
了的虚方法的地址改变了。基类和每个派生类都有一份自己的虚方法表。可以想象,随着
类层次的扩展,虚方法表将耗费非常大的内存空间。为了防止这种情况,Object Pascal引
入了“dynamic”的概念。对于程序员来说,dynamic方法和virtual方法实现相同的功能,
只是声明的关键字不同:
Procedure fly(); dynamic; // 是dynamic而不是virtual
被声明为dynamic的方法,其入口地址将被放在DMT中。DMT和VMT的区别在于:
对于派生类没有覆盖的方法,这些方法的入口地址不会出现在DMT中,编译器要通过基
类的信息来寻找它们的入口地址。
如果将TPlane的抽象方法land和虚方法modal改成dynamic,即:
TPlane = class
protected
FModal : String;
public
procedure fly(); virtual; abstract; // 仍然保持virtual
procedure land(); dynamic; abstract; // 将virtual改成dynamic
function modal() : string; dynamic; // 将virtual改成dynamic
…… // 其他可能的操作
end;
TCopter = class(TPlane)
public
constructor Create();
destructor Destroy(); override;
procedure fly(); override;
procedure land(); override;
function modal() : string; // 不覆盖Plane.modal();
…… // 其他可能的操作
end;
则TCopter的VMT/DMT会变成如图2.7所示的样子。
对比图2.6和图2.7可知,由于DMT中不会出现没有被派生类覆盖的基类dynamic方
法,因此DMT会比VMT节省空间(大多数情况下)。当基类有许多虚方法,而派生类只
覆盖很少几个时,区别尤其明显。当派生层次越来越深,派生类数量越来越多,DMT就能
节省更多的内存空间。但是DMT中对基类的动态方法的寻址不是直接进行的,因此dynamic
方法的寻址比virtual方法要慢许多。
Plane指针 指向VMT的指针
FModal
直升机类的VMT
直升机对象实例
TCopter.land()
……
指向DMT的指针
……
……
……
TCopter.fly()
直升机类的DMT
……
DMT中没有了未被覆
盖的TPlane.modal()
图2.7 直升机类的VMT/DMT
virtual和dynamic的区别仅在于编译器采用不同的晚绑定策略而已,对于程序员来说,
它们的功能相同。
如何取舍就看实际的需求了,一般情况下,几乎每个派生类都要覆盖的方法,将它声
明为virtual;如果类层次很深,或派生类很多,但某个方法只被很少的派生类覆盖,则将
它声明为dynamic。
另外需要注意的是,只有VMT才与C++、COM的vtable兼容,因此当需要这样的兼
容性时,只能使用virtual。
2.5 小 结
传统的说法是,封装、继承、多态是面向对象编程的三个基本特性。实际上,封装只
是抽象数据类型(ADT),有了继承才能被称为面向对象。而继承的存在,除了扩展现存
类的功能外,另一个更重要的作用就是作为多态存在的基石。
多态是一种能够带来灵活性的东西,它使得通过接口重用来实现代码重用。可以毫不
夸张地说,不领会多态,不明白晚绑定,就不可能明白什么是面向对象!因为只有在会用
virtual后,才是真正在用面向对象的典范(paradigm)思考……