文档库 最新最全的文档下载
当前位置:文档库 › 第9章 Delphi面向对象程序设计

第9章 Delphi面向对象程序设计

第9章 Delphi面向对象程序设计
第9章 Delphi面向对象程序设计

第9章面向对象程序设计

面向过程的程序设计着眼于系统实现的功能,采用自顶向下,逐步细化的方法进行功能分解直至建立系统的功能结构和相应的程序模块。

类(Class)是具有相同属性和操作的对象的集合。类是进行数据抽象的基本单位。每一个对象都是类的一个实例(Instance)。

所谓继承是父类(Base Class)(基类)可以派生自己的子类(Derived Class)(派生类),子类除了继承父类的属性和操作之外,还具有自己独特的属性和操作。

通信是实现各个不同对象之间消息传递的方法。所谓消息实际上是一个类的对象要求另一个类的对象执行操作的指令。

9.1对象的基本概念

9.1.1对象的特性

一个对象,其最突出的特征有三个:封装性、继承性、多态性。

1.对象的封装性

对象的封装特性是把数据和代码结合在同一个结构中。将对象的数据域封闭在对象的内部,使得外部程序必须而且只能使用正确的方法才能对要读写的数据域进行访问。

2.对象的继承性

对象的继承性是指把一个新的对象定义成为已存在对象的后代。新对象继承了旧类的一切东西。

3.对象的多态性

多态性是在对象体系中把设想和实现分开的手段。多态的含义是指某一个标识符表示多种类型的变量,或者标识不同意义的函数或过程。

9.1.2从一个对象中继承数据和方法

在窗体上单击鼠标或用Object Inspector的上端的Object Selector选中Form1对象,按键查阅他的在线帮助,会在Properties和Metehod中找到它的继承到的全部属性和当在工程中加入一个新窗体时,就等于加入了一个基本模型。通过不断地在窗体中加入部件,就自行定义了一个新的窗体。要自定义任何对象,都将从已经存在的对象中继承域和方法,建立一个该种对象的子类。

9.1.3对象的范围

一个对象的范围决定了它的数据域、属性值、方法的活动范围和访问范围。在一个对象的声明部分声明的数据域、属性值、方法都只是在这个对象的范围中,而且只有这个对象和它的后代才能又拥有它们。虽然这些方法的实际程序代码可能是在这个对象之外的程序库单元中,但这些方法仍然在这个对象的范围内,因为它们是在这个对象的声明部分中的声明的。

当在一个对象的事件处理过程中编写程序代码来访问这个对象的属性值、方法或域时,不需要在这些标识符之前加上这个对象变量的名称。

9.1.4对象共有域和私有域的声明

可以在对象的Public或Private部分加入新的数据域和方法。Public和Private是Object Pascal的保留字。

在Pbulic部分中声明其他库单元中对象的方法也可以访问的数据域或方

法。在Private部分的声明有访问的限制。如果在Private中声明域和方法,那么它在声明这个对象的库单元外是不透明的,而且不能被访问。Private中可以声明只能被本可单元方法访问的数据域和本库单元对象访问的方法。

9.1.5访问对象的域和方法

当想要改变一个窗体对象的一个域的某个属性,或是调用它的一个方法是,必须在这个属性名称或调用方法之前加上这个对象的名称。

同样想改变一个窗体对象中一个对象域的多个属性或调用多个方法时,使用with语句可以简化程序。With语句在对象中可和在记录汇总一样使用。

9.1.6对象变量的赋值

如果两个变量类型相同或兼容,可以把其中一个对象变量赋给另一个对象变量。只要赋值的对象变量是被赋值的对象变量的祖先

类型,就可以将一个对象变量赋给另一个对象变量。

9.1.7建立非可视化对象

1.声明一个非可视化对象

可以用如下的方法建立一个自己的TWorker非可视化对象

Type

TWorker=Class(TObject)

Title:=String[20];

Name:= String[20];

HourlyPayRate:real;

Function CalculatePayAmount:real;

end;

2.用Create方法建立对象实例

TWorker只是一个对象类型除非通过一个构造函数的调用从而被实例取代或创建,否则一个对象并不存储在内存中。构造函数是一个方法,它为新对象配置内存并且指向这个新的对象。这个新的对象也被称为这个对象类型的一个实例。

建立一个对象的实例,需要调用Create方法,然后构造函数把这个实例赋给一个变量。如果想声明一个TWorker类型的实例,在访问这个对象的任何域之前,的程序代码必须调用Create。

Worker:= Tworker.Create;

3.撤销对象

当使用完对象后,应该及时撤销它,以便把这个对象占用的内存释放出来。可以通过调用一个注销方法来撤销的对象,它会释放分配给这个对象的内存。

Delphi的注销方法有两个:Destroy和Free。Delphi建议使用Free,因为它比Destroy更为安全,同时调用Free会生成效率更高的代码。

可以用下列的语句释放用完的Worker对象:

Worker.Free;

9.2 类类型和对象

对象是类的实例(instance),即由类定义的数据类型的变量。对象是实体,当程序运行时,对象为它们的内部表达占用一些内存。对象与类的关系就像变量与类型的关系。在Object Pascal中,声明类数据类型使用保留字Class。

类类型声明的一般格式为:

Type

<类名>=Class(<父类名>)

<类成员>

End;

有关类类型的儿点声明:

(1)类名可以是任何合法的标识符,在Delphi中,类类型的标识符一般以T 打头。

(2)Class是保留字,表示声明类型是类类型。

(3)Class后面的父类名表示当前声明的类是从父类名制定的类中派生出来的,声明的类称为父类的子类或直接后代,该子类将继承父类及所有祖先的所有成员。

(4)“父类名”是可以省略的。

(5)类类型声明中可以没有成员列表,如果需要,类类型可以有3类成员,分别是Field(字段)、Method(方法)、property(特性)。

(6)在类的声明中如果含有字段成员,那么字段成员的声明必需优先于特性和方法成员的声明。

(7)跟其他数据类型不同的是,类类型的声明只能出现在Program单元或UNIT 单元最外层作用域的类型定义部分,而不能定义在变量声明部分或一个过程或函数内。因此,类类型的作用域总是全局的。

(8)一旦声明了类类型,其使用同其他数据类型一样,可以创建这个类的多个实例(对象),所有创建的对象将共享该类的成员。

9.3 封装

封装,是抽象数据类型(或基于对象)的特性。似乎一谈到对象,能立刻想到的就是封装。因为很容易就能把对象理解成所谓的黑匣子。

为什么要封装?

可以把程序按某种方法分成很多“块”,块与块之间可能会有联系。每个块都有一个可变的部分和一个稳定的部分。我们需要把可变的部分和稳定的部分分离开来,将稳定的部分暴露给其他块,而将可变的部分隐藏起来,以便于随时可以让它改变。这项工作就是封装!

例如,在用类来实现某个逻辑时,类就是以上所说的“块”,实现功能的具体代码就是“可变的部分”,而 public 的方法(称为“接口”)则是“稳定的部分”。

9.3.1类级别的封装

类级别的封装是最常见的封装形式。

每个 Object Pascal 的类,有四种访问级别: private、 protected、public、 published。其中, public 的成员可以被外界的所有客户代码直接访问; published 和 public 差不多,区别仅在于 published 的成员可以被Delphi 开发环境的 Object Inspector 所显示,因此一般将属性或事件声明于published 段; private 成员为类的私有性质,仅有类本身和友元可访问;protected 成员基本与 private 类似,区别在于 protected 可以被该类的所有派生类访问。

在类级别的封装中,对外界的接口是 public 方法和 published 成员的集

合, private 和protected 的集合则属于类的实现细节。而对于该类的派生类来说,接口是 public、 published与 protected 的集合,而只有 private 部分为内部实现细节。

9.3.2单元级别的封装

单元级别的封装包含的含义有:

1.在一个 Unit 中声明的多个类,互为友元类。

2.在一个 Unit 的 interface 部分声明的变量为全局变量,其他 Unit 可见。3.在一个 Unit 的 implementation 部分声明的变量为该 unit 的局部变量,只在该 Unit可见。

4.每个Unit 可有单独的初始化段(initialization)和反初始化段( finalization),可在编译器支持下自动进行 Unit 级别的初始化和反初始化。Object Pascal 规定,声明在同一个 Unit 之中的多个类互为友元类,友元类之间可以互相访问所有数据,无论是 public 的,还是 private 的,或者是protected 的。也就是说,友元类之间没有秘密。如下面的两个类:

type

TFriend1 = class

private

FMember1 : Integer;

end;

TFriend2 = class

private

Friend : TFriend1;

public

function GetFriendMember() : Integer;

end;

TFriend1 和 TFriend2 之间可以互相访问私有数据成员:

function TFriend2.GetFriendMember: Integer;

begin

Result := Friend.FMember1; // 访问了 TFriend1 的 private 数据

end;

虽然 FMember1 是 TFriend1 类的 private 数据,但在 TFriend2 中可以访问,这是合法的。粗看起来,友元类似乎破坏了封装。但其实适当地使用友元的特性,可以增强封装性。

有时一个类的两部分可能会具有不同的生命周期,也许用户会将这两部分拆分成两个相关的类,此时两个类之间可能会互相访问彼此的数据,而数据成员一般都被置于 private节中。如果避免使用友元,则只能要么将数据置于 public 节中,要么提供 GetXXX、 SetXXX之类的方法。将数据置于 public 的做法是非常罕见的,而提供 GetXXX、 SetXXX 之类的方法也绝非优良设计,这些做法其实都破坏了封装性。如何保持封装性呢?答案就是使用友元!

Object Pascal 的单元文件被分成了两个部分:interface 和implementation。如同类的封装一样, Unit 的这两部分分别为接口和实现细节。因此, interface 部分对外是可见的,声明在 interface 段中的所有函数、过程、变量的集合,即单元文件作为一个模块的对外接口,而 implementation 部

而为单元文件提供初始化和反初始化机制,则保证了单元的独立性,其作用如同类的构造函数与析构函数,单元的运作由此便可脱离对其他模块的依赖。

以下是一个完整的 Unit 示例:

unit UnitDemo;

interface

uses Windows;

procedure Proc1(); // 某功能函数

procedure InitUnit(); // 单元初始化函数

procedure UnInitUnit(); // 单元反初始化函数

var

g_nGlobalVar : Integer; // 全局变量

implementation

var

l_nLocalVar : Integer; // 单元级别的局部变量

procedure InitUnit();

begin

l_nLocalVar := 0;

…… // 其他初始化工作

end;

procedure UnInitUnit();

begin

…… // 反初始化

procedure Proc1();

begin

…… // 一些代码

end;

initialization // 初始化段

InitUnit(); // 调用 InitUnit()以初始化单元

finalization //反初始化段

UnInitUnit();

end.

无论是单元的封装,还是类的封装,封装的目的都是一样的,即简化用户接口,隐藏实现细节。正如小节语义的“类”和“对象”中所述,封装的难点在于如何设计接口。首先,必须保证接口是功能的全集,即接口能够覆盖所有需求。不能完成必要功能的封装是毫无意义的。

其次,尽量让接口是最小冗余的。这是为了简化客户的学习,难用的封装是容易被人遗忘的。冗余接口的存在是被允许的,但必须保证冗余接口是有效的。也就是说,增加这个冗余接口会带来非常大的好处,比如性能的飞速提升。

最后,要保证接口是稳定的。将接口和实现分离,并将实现隐藏,就是为了能保护客户的代码在功能实现细节改变的情况下,不必随之改变。三天两头改变接口的封装是惹人讨厌的。记住一个原则:一旦接口被公布,永远也不要改变它!

9.4 继承的本质

继承是为了表现类和类之间的“是一种”关系。有了继承之后,构建多层次的类框架成为可能。同时,它也是面向对象中的另一个核心概念——多态的存在基

础。因此,继承是面向对象语言必不可少的特性,只支持封装而不支持继承的语言只能称为“基于对象”( Object-Based)而非“面向对象”( Object-Oriented)。在利用语言提供的继承特性之前,有必要先了解一下语言本身关于继承的一些特性及实现。

9.4.1语言的“继承”

首先要了解从语言层次的视角对“继承”概念的理解与语言对其的实现支持。继承关系也被称为派生。继承的关系中,被继承的称为基类;从基类继承而得的,称为派生类。比如说,类 B 从类 A 继承而得,则 B 为派生类, A 为基类。在Object Pascal语言中,定义继承关系的语法:

TB = class(TA)

表示 TB 从 TA 继承(派生), TB 是派生类,而 TA 为基类。

Object Pascal 只支持 C++中所谓的 public 继承,即派生类中基类的public 成员在其中仍然是 public 的,基类的 protected 成员在派生类中仍然是 protected 的,派生类无法访问基类的 private 成员。 Public 继承在语义上严格地奉行“是一种”关系。也就是说,类 B 若派生自类 A 的话,那么在任何时候,都可以称“ B 是一种 A”。因此,在设计继承层次时,也应该注意,如果 B 不是在任何时候都可以被当作 A,那么就不可以将 B 从 A 派生。

Object Pascal 只支持单继承,即每个派生类只能有一个基类,由此可以保证每个派生类中,只有惟一一份基类子对象。也许有些读者还不清楚什么是基类子对象,或者不清楚上面这句话的具体含义是什么。

下面就来介绍一下。

在本节类和对象的本质中曾经提到过,对象所占的内存空间大小取决于这个对象

中的数据成员。也就是说,每一个对象实例中,都包含了它所有的数据成员。更进一步,在允许继承的情况下,每个派生类的对象实例所占内存空间的大小,不但取决于自身的数据成员,还要加上其基类的数据成员。

每一个类的实例对象所占的内存空间,是其自身的数据成员与其所有基类(因为基类可能还有基类)的数据成员(不论是 private 的,还是 public 的)所占内存空间的总和(不考虑“按位对齐优化”的情况)。

每一个派生类的实例对象,内部都包含了一个完整的基类实例对象,这个完整的基类实例对象,就称为“基类子对象”,因为基类的对象永远小于或者等于派生类的对象。

虽然派生类对象无法访问基类子对象中的 private 的数据,但是,这些数据是的确存在并且占用内存空间的。

下面以一个示例程序来说明派生类对象和基类子对象的关系以及它们的内存布局情况。先定义一个三层的继承层次:

type

TBase = class

public

FBaseMember1 : Integer;

FBaseMember2 : Integer;

end;

TDerived = class(TBase)

public

FDerivedMember : Integer;

TDerived2 = class(TDerived)

public

FDerived2Member1 : Integer;

FDerived2Member2 : Integer;

end;

TBase 是基类, TDerived 派生自 TBase,因此它是 TBase 的派生类,但由于 TDerived2派生自 TDerived,因此, TDerived 同时也是 TDerived2 的基类,而 TDerived2 是 TDerived的派生类。

在此定义了一个三层的继承层次,但由于成员方法是不占用对象实例的内存空间的,因此,为方便说明起见,不定义方法成员。

定义完相关类后,在 Application 的主 Form( Form1)上放上一个ListBox( name 为:lst_rs)和一个 Button。然后在 Button 的 OnClick 事件中加入代码,使得对象位置信息显示在 ListBox 中。

该程序含有两个单元,名称为 clsinherite.pas 的单元中仅定义了 TBase、TDerived、TDerived2 3 个类(如前定义),另一个为主 Form 的代码单元,其代码清单如下:

unit Unit1;

interface

uses

Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,

Dialogs, StdCtrls;

TForm1 = class(TForm)

Button1: TButton;

lst_rs: TListBox;

Label1: TLabel;

procedure Button1Click(Sender: TObject);

private

{ Private declarations }

public

{ Public declarations }

end;

var

Form1: TForm1;

implementation

uses clsinherit;

{$R *.dfm}

procedure TForm1.Button1Click(Sender: TObject); var

Obj : TDerived2;

begin

Obj := TDerived2.Create();

with lst_rs.Items do

begin

Add('对象首地址: ' + IntToStr(Integer(Obj)));

Add('TBase 成员首地址: ' + IntToStr(Integer(@Obj.FBaseMember1))); Add('TDerived 扩展成员(FDerivedMember)首地址: ' +

IntToStr(Integer(@Obj.FDerivedMember)));

Add('TDerived2 扩展成员(FDerived2Member1)首地址: ' +

IntToStr(Integer(@Obj.FDerived2Member1)));

end;

Obj.Free();

end;

end.

Button1Click()方法创建一个 TDerived2 类的一个实例对象,然后将对象首地址、对象大小及其所有数据成员(包括从基类中派生而得来的数据成员)的地址在 ListBox 中显示出来。

运行程序并单击“开始”按钮,程序结果如图所示。

派生类内存布局演示程序界面

结果在图中也显示了:对象大小为 24 字节;首地址为 13443344(也许在各位的计算机上运行该地址值有所不同,没有关系,在此只关心这些地址值之间

13443356; TDerived2 扩展成员首地址为 13443360。

推算可知,对象所占内存地址范围为 13443344~13443367。 TBase 成员首地址和整个对象首地址之间存在 4 个字节的差值,这个空缺还是那个指向 VMT 的指针。 TBase 的两个整型数据成员占用 8 个字节,因此 TDerived 的FDerivedMember 的首地址就是 13443356 了。同理,再步进 4 个字节,就是TDerived2 的FDerived2Member1 的首地址了。

根据上面的演算,可以画出 Obj 对象的内存布局图,如图所示。

派生对象内存布局

(图中 3 个深色矩形框,由里向外分别表示 TBase、 TDerived、 TDerived2 的完整实例)从图中可以清楚地看到,Obj 对象(最外层的深色矩形框)中完整地包含了 TDerived类的实例对象(中间层的深色矩形框)和 TBase 类的实例对象(最内层的深色矩形框)。最内层的矩形框表示了 TDerived2 类实例对象中的TBase 基类子对象,中间层的矩形框表示了TDerived2 类实例对象中的TDerived 基类子对象。

注意:每个基类子对象都是完整的。

可以直接访问类的所有数据成员并将它们的地址打印出来,因此在定义类时,将所有数据成员都声明为 public。但实际程序运行结果(指每个数据成员所在地址)并不依赖于它是处于 public的还是 private 的。也就如同上面所说的,即使派生类对象无法访问基类子对象中的 private的数据,它们依然是存在并占用内存空间的,无法访问它只是因为编译器为它做了额外的保护。

9.4.2语义的“继承”

了解语言对于继承的理解与实现支持,对于设计是有所助益的。但是,设计更多的时侯是根据语义的。

语义上的“继承”,更多的是作为一种“特化”的机制。也就是说,能够被继承的类(基类)总是含有并且只含有所抽象的那一类事物的共性,当需要抽象该类事中的某一种特例时,将表示特例的类从基类继承(派生),派生类含有这类事物的所有共性,并且自己拥有特性。

例如,“水果”这个概念抽象了很多事物,这些事物有一些共性,如可以食用、属于农作物等。“苹果”这个概念表示了一种特殊的水果,它作为“水果”的特例,包含水果的一切属性——可以被食用、属于农作物等,同时它也包含自己的特性——果实为圆形、味道甜美等。

语义上的“继承”表示“是一种”的关系,派生类可以被看作“是一种”基类,这是一个最基本的、必须满足的前提。正如苹果是一种水果这么理所当然。在设计类关系时,可以将若干类的共性抽象出来,集中在它们的基类中实现。但如果类 A 不是一种类 B,也就是说, A 不能无条件地出现在 B 的位置上取代 B,那么无论如何,不要把 A 设计成 B 的派生类。这被称为“多态置换原则”。

在此需要特别指出一个最容易使设计者混淆的问题,那就是“容器”问题。必须牢记一个法则:当 A 是一种 B 时,那么 A 的容器(绝对)不是一种 B 的容器!

将苹果与水果代入以上法则:苹果是一种水果,苹果袋(苹果的容器)不是一种水果袋(水果的容器)!

看上去,这与常识有些不一样,然而,这是事实,至少在面向对象设计领域!也许一时还无法接受它,但要先强迫自己接受它,然后再看以下的分析。

先说明概念,在上面所定义的苹果袋中,只能存放苹果,如果可以放入其他水果,那就叫水果袋了。

根据“多态置换原则”,任何出现“水果”的地方,可以用“苹果”进行替换。例如,水果是可以食用的,水果是农作物……

这些都可以变换成:苹果是可以食用的,苹果是农作物……

如果有人对你说:“请给我一个水果”,那么当你递给他一个苹果时,他应该不会有任何意见,还会对你说一声“谢谢”。

现在假设,如果苹果袋是一种水果袋成立的话,同样根据“多态置换原则”,任何出现水果袋的地方,也都可以用苹果袋取代。

“水果袋可以放任何水果”就变成了“苹果袋可以放任何水果”,这个结论显然让人无法接受,它违背了我们最初的定义。

如果有人对你说:“请给我一个水果袋,我要放一些香蕉在里面”,这样的要求非常合理,因为香蕉是被允许放入水果袋的。然而,如果你递给他一个苹果袋的话,他就会有意见,因为他无法把香蕉放进去!这时也就无法指望他会对你说“谢谢”了。

我们都相信,“可乐”是一种“液体”,那么你是否会相信“可乐罐”是一种“液体储存罐”呢?

“可乐罐”被定义为只能存放可乐的容器,而“液体储存罐”可以存放任何液体。

如果你坚信“可乐罐”是一种“液体储存罐”,那么可能有如下的代码定义“液体储存罐”类和“可乐罐”类:

TLiquidBottle = class // 液体储存罐

// ...... 省略

public

// 关于“ virtual;”的话题,将在下一节详述

// 倒出其中的液体

procedure Spill(); virtual;

// 向其中装入某种液体

procedure Contain(const Liquid : TLiquid); virtual;

end;

TCokeBottle = class(TLiquidBottle) // 可乐罐

// ...... 省略

public

procedure Spill(); override;

procedure Contain(const Liquid : TLiquid); override;

end;

另外有一个将液体灌入储存罐的函数和一个将某种“液体储存罐”中的液体倒出

procedure Contain(const Liquid : TLiquid; var Bottle : TLiquidBottle); begin

Bottle.Contain(Liquid);

end;

procedure Spill(var Bottle : TLiquidBottle);

begin

Bottle.Spill();

end;

好了,现在可乐罐是一种液体储存罐了,从此可以安全、合法地将汽油倒入可乐罐中:

Contain(AGas, ACokeBottle); // 编译器完全接受

能够安心、合法地这么做,只因为可乐罐是一种液体储存罐!

今后,也许某天打开这个可乐罐准备享受甜美的可乐时,却发现倒出的是汽油,恐怕也只能悻悻作罢了。

注意:一种事物的容器,“不是一种”任何其他的容器!

这就是著名的“容器”问题。关于“多态置换原则”,还有一个和日常生活常识相悖的例子,那就是“圆/椭圆”问题。

中学的数学老师和课本告诉我们,“圆”是一种“椭圆”,然而 OOP 却说,“圆不是一种椭圆”。同样,先强迫自己接受这个观点,然后再看原因。

“圆”如果是一种“椭圆”,那么“椭圆”一定具有比“圆”更普遍的性质,而“圆”则是特化的“椭圆”,因此圆必须能做到所有的椭圆都能做到的事情。

相关文档