我的文章-《剖析Delphi中的多态》

剖析Delphi中的多态

1什么是多态? 2
1.1概念 2
1.2多态的意义 2
1.3多态在delphi中如何实现的? 2
1.3.1 继承(Inheritance) 2
1.3.2 虚方法、动态方法与抽象方法,VMT/DMT,静态绑定与动态绑定 2
1.3.3 重载(Overload)与多态 2
1.4多态种类的探讨 2
1.4.1 两级多态 2
1.4.2 不安全的多态 2
2 VCL中多态的应用 2
2.1构造与析构方法 2
2.2 Tstrings 2
2.3其他(请soul来补充) 2

摘  要 多态是面向对象的灵魂所在,理解多态是掌握面向对象技术的关键之一,本文着重分析多态的基本原理、多态的实质以及在VCL中的应用。
关键字 多态、继承、面向对象、VCL、虚函数(virtual Method)、覆载(override)


问题
多态是面向对象的灵魂所在,理解多态是掌握面向对象技术的关键之一。但是到底什么是多态?多态有何意义?怎么实现多态?多态的概念我能懂,但不知道如何使用以及什么时候该使用呢?请看本文细细道来。
专家分析
   天地生物(物,即对象),千变万化;而在计算机世界里,只有一行行机器指令,两者似乎毫不相干,过去要用计算机语言来很好地描述现实世界是一件很困难的事情,虽然有人用C语言写出面向对象的程序来,但我敢断定其写法是极其烦琐的,直到面向对象(Oriented-Object 简称OO)的出现,一切都随之改观,整个软件业发生了翻天覆地的变化,从编程语言的变化开始,出现了一系列面向对象编程语言(OOP)如SmallTalk、C++、Java、Object Pascal、C#等;随之各种面向对象开发工具也出现了如VC、Delphi、BCB、JBuilder等,并出现了许多优秀的类库如VCL、.net Framework和一些商业类库等;再发展到了面向对象的设计(OOD),面向对象的分析(OOA)以及面向对象的数据库(OODB),面向对象技术几乎贯穿了整个软件领域,程序员的思考方式也发生了根本性的变化!在一些OO纯化论者眼中,一切皆是对象!虽然我不完全同意这种看法。但我认为这种方式最符合人们的思维习惯,它使程序员能集中精力考虑业务逻辑,由计算机来完成面向对象到机器指令的转换(由面向对象的编译器来完成),程序员的大脑从此解放出来了!这是一场革命!
面向对象的核心内容是对象,封装,继承,多态和消息机制,其中多态就是为了描述现实世界的多样性的,也是面向对象中最为重要的特性,可以这么说,不掌握多态,就没有真正地掌握面向对象技术。
1什么是多态?
1.1概念
多态的概念众说纷纭,下面是几种代表性的说法:
“This ability to manipulate more than one type with a pointer or a reference to a base classis spoken of as polymorphism” (《C++ Primer》第838页)。即用基类的指针/引用来操作多种类(基类和其派生类)的对象的能力称之为多态。它是从语言实现的角度来考虑的。
“polymorphism provides another dimension of separation of interface from implementation, to decouple what from how”(《Think in Java》3rd edtion),即多态提供了另外一种分离接口和实现(即把“做什么”与“怎么做”分开)的一种尺度。它是从设计的角度考虑的。
“The ability to use the same expression to denote different operations is refered to as Polymorphism”,(《Object-Oriented Methods Principles & Practice》3rd Edition,第16页)。简单的说,多态就是“相同的表达式,不同的操作”,也可以说成“相同的命令,不同的操作”。这是从面向对象的语义的角度来看的。
三种说法分别从不同的角度来阐述了多态的实质。其中第三种说法尤为确切,下面着重分析第三种说法。
先解释这句话的含义:
相同的表达式—函数调用
不同的操作  —根据不同的对象就有不同的操作。
举个例子来说明,比如在公司中有各种职责不同的员工(程序员,业务员,文管等),他们“上班”时,做不同的事情(也可以看作是一种业务逻辑),我们把他们各自的工作都抽象为"上班",关系如下:
                      员工
                    /   |   /     ——继承关系
              程序员 业务员 文管 
                            
每天上班时间一到,相当于发了一条这样的命令:
  “员工们.开始上班”(同一条表达式)
每个员工接到这条命令(同样的命令)后,就“开始上班”,但是他们做的是各自的工作,程序员就开始“Coding”,业务员就开始“联系业务”,文管员就开始“整理文档”。即“相同的表达式(函数调用),(在运行期根据不同的对象来执行)不同的操作”。
从语言实现多态的角度来说,多态是通过基类指针或引用指向派生类的对象,调用其虚方法实现的。下面是Object Pascal语言的实现
TEmployee=class    //把员工抽象为一个抽象类
  public
    procedure startWorking;virtual;abstract;
{抽象函数(即C++中纯虚函数),什么也不做,实际的意义是,先预留一个接口。在其派生类中覆载实现它。}
  end;

  TProgramer=class(TEmployee)     //程序员
  public
    procedure startWorking;override;
  end;

  TBusinessMan=class(TEmployee)  //业务员
  public
    procedure startWorking;override;
  end;

  TDocManager=class(TEmployee)  //文管
  public
    procedure startWorking;override;
  end;
procedure TProgramer.startWorking;
begin
  showmessage('coding');
end;

{ TbusinessMan }

procedure TbusinessMan.startWorking;
begin
  showmessage('Linking Business');
end;

{ TDocManager }

procedure TDocManager.startWorking;
begin
  showmessage('Managing Document');
end;

procedure TForm1.Button1Click(Sender: TObject);
const
  eNum=3;
var
  Employee:array of TEmployee;
  i:integer;
begin
  setLength(Employee,eNum);
  Employee[0]:=TProgramer.Create; 
//把基类引用employee[0]指向刚创建的TProgramer对象
  Employee[1]:=TBusinessMan.Create;
  //把基类引用employee[1]指向刚创建的TBusinessMan对象
  Employee[2]:=TDocManager.Create;
    //把基类引用employee[2]指向刚创建的TDocManager对象
  for i:=0 to Length(Employee)-1 do
    Employee[i].startWorking; //在运行期根据实际的对象类型动态绑定相应的方法。
{从语言实现多态的角度来说,多态是通过基类指针或引用指向派生类的对象,调用其虚方法来实现的。Employee []为基类对象引用数组,其成员分别指向不同的派生类对象,当调用虚方法,就实现了多态}
end;
 试一试
大家可以敲入上面一些代码(或Demo程序),并编译运行,单击按扭就可以看多态性的神奇效果了。

1.2多态的意义
封装和继承的意义是它们实现了代码重用,而多态的意义在于,它实现了接口重用(同一的表达式),接口重用带来的好处是程序更易于扩展,代码重用更加方便,更具有灵活性,也就能真实地反映现实世界。
比如为了更好地管理,把程序员分为C++程序员,Delphi程序员。…
                      员工
                    /   |   /      ——继承关系
              程序员 业务员 文管 
               /   /               ——继承关系
      C++程序员  Delphi程序员
在程序员添加TCppProgramer,TDelphiProgramer两个派生类后,调用的方式还是没有变,还是“员工们.开始上班”,用Object Pascal来描述:

setLength(Employee,eNum+2);
Employee[ENum]:=TCppProgramer.create;
//创建一个TcppProgramer对象,并把基类引用employee[ENum]指向它
Employee[eNum+1]:=TDelphiProgramer.Create;

{员工们.开始上班}
for i:=0 to Length(Employee)-1 do
Employee[i].startWorking; //还是同一的调用方法(因为接口并没变)。
     …
1.3多态在delphi中如何实现的?
实现多态的必要条件是继承,虚方法,动态绑定(或滞后联编),在Delphi是怎么实现多态的呢?
1.3.1 继承(Inheritance)
继承指类和类之间的“AKO(A Kind Of,是一种)”关系,如程序员“是一种”员工表示一种继承关系。在Delphi中,只支持单继承(不考虑由接口实现的多重继承),这样虽然没有多继承的那种灵活性,但给我们带来了极大的好处,由此我们可以在任意出现基类对象的地方都可以用派生类对象来代替(反之不然),这也就是所谓的“多态置换原则”,我们就可以把派生类的对象的地址赋给基类的指针/引用,为实现多态提供了先决条件。
 提  示
在UML中:
AKO: A Kind Of 表示继承(Inheritance)关系
APO: A Part Of 表示组合(Composition)关系
IsA: Is A表示对象和所属类的关系
1.3.2 虚方法、动态方法与抽象方法,VMT/DMT,静态绑定与动态绑定
对于所有的方法而言,在对象中是没有任何踪影的。其方法指针(入口地址)保存在类中,实际代码则存储在代码段。对于静态方法(非虚方法),在编译时由编译器直接根据对象的引用类型确定对象方法的入口地址,这就是所谓的静态绑定;而对于虚方法由于它可能覆载了,在编译时编译器无法确定实际所属的类,所以只有在运行期通过VMT表入口地址(即对象的首四个字节)确定方法的入口地址,这就是所谓的动态绑定(或滞后联编)。
虚方法
虚方法,表示一种可以被覆载(Override)的方法,若没有声明为抽象方法,就要求在基类中提供一个默认实现。类中除存储了自己虚方法指针,还存储所有基类的虚方法指针。
声明方法:
  procedure 方法名;virtual;
这样,相当于告诉Delphi编译器:
可以在派生类中进行覆载(Override)该方法,覆载(Override)后还是虚方法。
不要编译期时确定方法的入口地址。而在运行期,通过动态绑定来确定方法的入口地址。
在基类中提供一个默认实现,如果派生类中没有覆载(Override)该方法,就使用基类中的默认实现。
动态方法
动态方法和虚方法本质上是一样的,与虚方法不同的是,动态方法在类中只存储自身动态方法指针,因此虚拟方法比动态方法用的内存要多,但它执行得比较快。但这对用户完全是透明的。
声明方法:
  procedure 过程名;dynamic;
抽象方法
一种特殊的虚方法,在基类它不需提供默认实现,只是一个调用的接口用,相当于C++中的纯虚函数。含有抽象方法的类,称之为抽象类。
声明方法:
  procedure 过程名;virtual;abstract;
VMT/DMT
在Delphi中,虚拟方法表(Virtual Method Table,VMT),其实在物理上本没有,是为了更好地阐述多态,人为地在逻辑上给了它一个定义,实际上它只是类中的虚方法的地址的集合,这个集合中还包括其基类的的虚方法。在对象的首四个字节中存储的“Vmt 入口地址”,实际上就是其所属的类的地址(参考Demo程序)。有了实际的类,和方法名就可以找到虚方法地址了。
   Obj(对象名)        实际的对象                     所属的类
 
Vmt 入口地址   
 数据成员
   
类虚方法表vmt入口地址   
数据成员模板信息   
静态方法等   
虚方法(VMT)   
动态方法(DMT) 

 

 


图3 对象名、对象与类的关系
DMT和VMT类似,也是逻辑上的一个概念,不同的是,在类中只保存了自身动态方法指针,而没有基类的动态方法的地址,这样就节省了一些内存,但速度不如虚方法,是一种牺牲时间换空间的策略,一般情况不推荐使用。
引用上面的例子来解释一下:
Employee[i].startWorking;
Employee[i]是一个基类Temployee的对象引用,有上面的程序知道,它可能指向了一个Tprogramer对象,也可以可能指向一个TbusinessMan,还有可能是其他的对象,而且这些都是不确定的、动态的,所以在编译时无法知道实际的对象,也就无法确定方法地址。而在运行期,当然知道对象的“庐山真面目”了,根据实际对象的首四个字节的内容,也就是虚拟方法表VMT的入口地址,找到实际要调用的函数,即实现了多态。
1.3.3 重载(Overload)与多态
很多网友认为函数重载也是一种多态,其实不然。对于“不同的操作”,重载无法提供同一的调用的方式,虽然函数名相同,但其参数不同!实现多态的前提,是相同的表达式!如Employee[i].startWoring,而重载的调用,有不同的参数或参数类型。重载只是一种语言机制,C语言中也有重载,但C语言没有多态性,C语言也不是面向对象编程语言。除非重载函数同时还虚方法,不然编译器就可以根据参数的类型就可以确定函数的入口地址了,还是静态绑定!引用C++之父的话“不要犯傻,如果不是动态绑定,就不是多态”。
1.4多态种类的探讨
1.4.1 两级多态
对象级:用基类指针/引用指向其派生类对象,调用虚方法(或动态方法、抽象方法),这是用的最多一种。
类级:用类引用(指向类而不是对象的引用)指向派生类,调用虚类方法(或动态类方法、抽象类方法),常用在对象创建的多态性(因为构造方法是一种“特殊的”类方法,请参考我的另一篇拙作《剖析Delphi中的构造和析构》,第2.1节)。
提  示
类引用,是类本身的引用变量,而不是类,更不是对象引用。就和对象名表示对象引用一样,类名就表示一个类引用,因为在Delphi中,类也是作为对象处理的。类引用类型就是类引用的类型,类引用类型的声明方法:
类引用类型名称=class of 类名
我们在VCL的源代码中可以看到很多的类引用的声明,如:
TClass=class of Tobject;
TComponentClass=class of Tcomponent;
TControlClass=class of Tcontrol;
 注  意 
在类方法中,方法中隐含的self,是一个类引用,而不是对象引用。
1.4.2 不安全的多态
  用派生类指针/引用指向基类对象也可以实现多态!虽然这是一种错误的使用方法:
procedure TForm1.btnBadPolyClick(Sender: TObject);
var
  cppProgramer:TCppProgramer;//定义一个cpp程序员引用,一个派生类的引用!
begin
   {*****************************声  明***********************************
    用派生类指针/引用指向基类对象实现的多态。是一种病态的多态!
    这种多态的使用方法,它就象一个实际很小的事物(基类对象)披上一个强大
    的外表(派生类引用),因而带来了许多潜在的不安全因素(如访问异常),所
    以几乎没有任何价值。"杜撰"这样一个例子,旨在说明在Delphi中的多态的本质,多态的本质:使用一个合法的(通常是基类的)指针/引用来操作对象,在运行期根据实际的对象,来执行不同的操作方法,或者更形象的说法:由对象自己来决定自己操作方式,编译器只需下达做什么的命令(做什么what),而不要管怎么做(how),"怎么做"由为对象自己负责。这样实现了接口和实现的分离,使接口重用变得可能。
   ***********************************************************************}

  cppProgramer:=TCppProgramer(TProgramer.Create);
   {为了实现这种病态的多态,把对象引用强制转换为TCppProgramer类型,
    从而逃过编译器的检查}
  cppProgramer.startWorking;
   {调用的TProgramer.startWorking而不是TcppProgramer.startWorking
    这就是用派生类指针/引用指向基类对象实现的多态。}
  cppProgramer.Free;

  cppProgramer:=TCppProgramer(TDocManager.Create);
  cppProgramer.startWorking;
  {调用的竟然是TDocManager.startWorking,
   这就是用派生类指针/引用指向基类对象实现的多态。这种方法极不安全,
   而且没有什么必要}
  cppProgramer.Free;
end;
 试一试
为获得这种多态的感性认识,建议动手试试,上面说到这种使用方法会有潜在的不安全性(如访问异常),而上面的程序运行一点错误都没有出现,想想为什么?什么情况下会出现访问异常,动手写个访问异常的例子,你将收获更多。(参考Demo程序)
2 VCL中多态的应用
2.1构造与析构方法
构造方法的多态
由于构造方法可以看作“特殊的”类方法,在Tcomponent之后的所有的派生类的又被重新定义为虚类方法,因此要实现构造方法的多态性,就得使用类引用,在Delphi中有个经典的例子,就在每一个工程文件中都有一个类似下面的代码:
Application.CreateForm(TForm1, Form1);
其方法的定义:
procedure TApplication.CreateForm(InstanceClass: TComponentClass; var Reference);
var// InstanceClass为类引用。
  Instance: TComponent;
begin
  Instance := TComponent(InstanceClass.NewInstance);
{NewInstance方法的声明:class function NewInstance: TObject; virtual; (system单元 432行)是一个类方法,同时也是虚方法,我们把它称之为虚类方法。InstanceClass是一个类引用,实现了类一级的多态,从而实现了创建组件的接口重用}
  TComponent(Reference) := Instance;
  try
    Instance.Create(Self);//调用构造方法,进行初始化
  except
    TComponent(Reference):= nil;//消除“野“指针!good
    raise;
  end;
  {如果创建的是窗口且还没有主窗体的话,就把刚创建的窗体设为主窗体}
  if (FMainForm = nil) and (Instance is TForm) then
  begin
    TForm(Instance).HandleNeeded;
    FMainForm := TForm(Instance);//设置主窗体
    { 实际上,在项目选项(project->options)中设置主窗体,实际上就把工程文件中相应的窗体语句提到所有创建窗体语句之前。}
  end;
end;
2) 析构方法的多态请参考《剖析Delphi中的构造和析构》,第3.3节
2.2 Tstrings
字符串数组处理在Delphi的控件中十分常见,通常是一些Items属性,我们用起来也特别地方便(因为都是一样的使用接口),这得益于Delphi中字符串数组的架构的设计。这是一个成功的设计。
由于很多控件中要用到字符串数组,如ComboBox,TstringGrid等等,但每个控件中的字符串数组又不同,Delphi由此把字符串数组但抽象出来,从而出现了很多与之相关的类。其中基类Tstrings只是提供为各种调用提供接口,具体实现完全可由其派生类中实现,因此,把Tstrings定义为抽象类。
下面就来看看基类TStrings类的常用方法的定义(参见Classes单元第442行):
  TStrings = class(TPersistent)
  protected
    ...
    function   Get(Index: Integer): string; virtual; abstract;
    procedure  Put(Index: Integer; const S: string); virtual;
    function   GetCount: Integer; virtual; abstract;
    …
  public
    function  Add(const S: string): Integer; virtual; //实际调用的是Insert
      {添加一字符串S到字符串列表末尾}
    procedure AddStrings(Strings: TStrings); virtual;
      {添加字符串列表Strings到该字符串列表末尾}
    procedure Insert(Index: Integer; const S: string); virtual; abstract;
      {抽象方法,在第Index位置插入一新字符串S}
    procedure Clear; virtual; abstract;
      {清除所有的字符串}
    procedure Delete(Index: Integer); virtual; abstract;
      {删除某个位置上的字符串}
    function  IndexOf(const S: string): Integer; virtual;
      {获取S在字符串列表中的位置}
    function  IndexOfName(const Name: string): Integer; virtual;
      {Returns the position of the first string with the form Name=Value with the specified name part}
    function IndexOfObject(AObject: TObject): Integer; virtual;
{获取对象名为AObject:的对象在字符串列表中的位置}
    procedure LoadFromFile(const FileName: string); virtual;
      {Fills the list with the lines of text in a specified file}
    procedure LoadFromStream(Stream: TStream); virtual;
      {Fills the list with lines of text read from a stream}
    procedure SaveToStream(Stream: TStream); virtual;
      {Writes the value of the Text property to a stream object}
    property Strings[Index: Integer]: string read Get write Put; default;
      {References the strings in the list by their positions}
    property Values[const Name: string]: string read GetValue write SetValue;
{Represents the value part of a string associated with a given Name, on strings with the form Name=Value.}
    …
  end;
从Tstrings的定义可以看出,它的大部分Protected和Public的方法都是虚方法或是抽象方法。(请Soul来补充一些,TstringList->TstringGridString)
2.3其他(请soul来补充)
如果你对多态还不明白的话,那请你记住多态的实质:
“相同的表达式,不同的操作”(就这么简单)
从OOP语言的实现来讲,多态就是使用基类的指针/引用来操作(派生类)对象,在运行期根据实际的对象,来执行不同的操作方法;或者换一种更形象的说法:由对象自己来决定自己操作方式,编译器只需下达做什么的命令(做什么what),而不要管怎么做(how),"怎么做"由为对象自己负责。这样就实现了接口和实现的分离,使接口重用变得可能。
其实多态也简单!那么使用多态应该注意什么呢?下面我的两点几点建议:
分析业务逻辑,然后把相关的事物抽象为“对象”,再用对象方法封装业务逻辑。把一些具有多态性的操作,在基类中声明为虚方法(virtual Method),对于在基类没有必要实现的就声明为抽象方法(virtual Abstract Method),然后在其派生类中再覆载它(Override),在使用的时候用基类的引用/指针来调用,这样顺理成章地实现了现实世界中的多态性。记住千万不要为了多态,而去实现多态,这是一种走形式化的做法,是没有意义的。
由于基类与派生类有一种天然“耦合”关系,修改基类就会导致“牵一发而动全身”,这将是非常麻烦的事情!因此要尽量弱化基类的功能实现,必要时把它设计为“抽象类”,并保证稳定的接口,这可以通过预留一些冗余的虚函数(或抽象函数)来实现。
相关问题
讨论Delphi的多态: http://www.delphibbs.com/delphibbs/dispq.asp?lid=1753965
关于多态性: http://www.delphibbs.com/delphibbs/dispq.asp?lid=1854895
什么是多态?在日常编程中有哪些运用?http://www.delphibbs.com/delphibbs/dispq.asp?lid=960465
overload 与 override有何区别,请执教?http://www.delphibbs.com/delphibbs/dispq.asp?lid=296739
派生类的指针指向基类对象的问题 http://www.delphibbs.com/delphibbs/dispq.asp?lid=2104106
(最后一个问题是我在深入学习多态时在DelphiBBS上提的,曾引起热烈的讨论,建议看看)

阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页