面向对象编程理论基础
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面向对象编程理论基础相关的知识,希望对你有一定的参考价值。
面向对象是一种思维方式(理念),是一种方法论。
每个软件开发人员都会经常听到、 看到“ 面向对象” 这个词,程序员们也时常会把它挂在嘴上。那么, 什么是面向对象?什么是面向对象编程?是不是写几个类就算面向对象了?为什么要面向对象?因为别人都用,所以我也要用?显然, 并不是在程序中写了几个类就算面向对象编程了, 用面向对象编程也并不是为了赶时髦。
“结构化编程” ( SP) 是一种编程方法, 是用计算机的视角来抽象问题的方法。 而“ 面向对象编程” ( OOP)也是一种编程方法, 它从更接近真实世界的视角来分析问题,使用更接近人们理解真实世界的方法来抽象问题,这种方法称为“面向对象”( OO)。
“ 面向对象” 这个词代表的是一种认识世界、分析问题、解决问题的方法, 因此它是一种方法论。而面向对象编程( OOP) 则是将之应用于编程的方法。 当用户会使用面向对象的方法去思考,用面向对象的模式去分析和解决问题的时候,才是真正的“面向对象”了。
本章会试图使用 Object Pascal 语言来告诉用户面向对象编程的理论知识,包括面向对象编程的特性以及这些特性在语言中的实现、在语义上对程序设计的影响。
2.1 类和对象的本质
“类” 和“ 对象” 是面向对象编程中最基本的概念, 很多人都可以轻易地回答出什么是类, 什么是对象: “类” 是对一类事物的抽象( abstract) , 是创建对象的模板; “对象”是类的实例( instance)。
但这只是简单的概念解释而已,除此以外,还必须清楚类和对象的本质,即从语言和语义本身的角度来看,它们分别代表什么,它们是如何支撑起庞大的面向对象的世界的。
2.1.1 语言的“类”和“对象”
从语言的角度来说,“ 类”是用户自定义的数据类型,“ 对象” 则是“类” 类型的变量。类定义了所生成的对象的模板,于是也决定了对象所占用的内存空间。类成员分为两种: 方法(即 C++中的“成员函数” , 笔者个人比较喜欢“成员函数” 的叫法, 但“方法”已经是 Object Pascal 的一个术语。 在 Delphi 相关的书中, 称“方法” 更合理一些, 因此下文全部以“ 方法”称呼)和数据成员。数据成员表示类对象的状态, 而方法则是改变类状态的操作。
当用 class 关键字声明一种类型时,就创造了一个类:
type
TMyClass = class
end;
虽然在 TMyClass 中,还没有为它定义任何成员,但是,它的确是一个类,完全可以
创建这个类的对象实例:
var MyObj : TMyClass; begin MyObj := TMyClass.Create(); ......
Create()是这个类的构造函数,它负责初始化对象数据成员。
生老病死是人之常情,对象也一样会有被销毁的时候:
......
MyObj.Free();
不过, Free()方法不是类的析构函数, Free()负责的是调用类的析构函数来销毁对象。
至于采用这种机制的原因,稍后会讲述。
现在就来探讨一下 Object Pascal 中对象生存与销毁的秘密吧!
每个应用程序可以获得的内存空间分为两种:堆( heap) 和栈( stack)。
堆又称为“ 自由存储区”,其中的内存空间的分配与释放是必须由程序员来控制的。
例如, 用 GetMem 函数获取了一定大小的内存空间, 则在使用完后, 必须调用 FreeMem 函
数将空间释放,否则就会发生所谓的“内存泄漏”。“借债还钱,天经地义”。
栈又称为“ 自动存储区”,其中的内存空间的分配与释放是由编译器和系统自动完成
的,不需要程序员过问。函数调用时按值传递的参数所占空间、函数中的局部变量等, 都
是在栈中被分配空间的。比如函数:
var i : Integer; j : Integer; begin for i := 1 to 10 do ...... ...... end;
其中, i 和 j 的空间是由编译器在栈中分配的。 在函数末尾, 也不需要程序员手动去释放这两个变量所占的内存空间。Objecgt Pascal 遵循所谓的“引用/值” 模型。 无论在参数传递还是变量定义中, 简单类型( 如 Integer、 Cardinal、 char 以及 record 等) 被按值传递或使用, 其内存空间从栈中分配。
而复杂类型( class) 则被按引用传递或使用, 其内存空间从堆中分配。 在 Object Pascal 中,
所有对象( 类类型的) 都被建立在内存的堆空间上,而非栈上。因此在创建对象时,其构
造函数不会被编译器自动调用,也没有 C++中所谓的“默认构造函数”。调用构造函数来
创建对象以及调用析构函数来消灭对象都是程序员的职责。
如何为自己的类编写构造函数呢?在调用了诸如
MyObj := TMyClass.Create();
与
MyObj.Free();
之后究竟发生了哪些事情呢?
? 构造函数与对象内存的分配
定义构造函数使用 Constructor 关键字。按惯例,构造函数名称为 Create(当然也可以
用其他名称,但那绝非优良的设计)。如:
type TMyFamily = class // 为你的家庭定义的类 Private FMyFatherName : String; // 你父亲的名字 FMyMotherName : String; // 你母亲的名字 …… // 你家庭中的其他成员 Public Constructor Create(strFatherName, strMotherName : String); …… // 其他方法 End;
创建对象时则直接调用构造函数,形式如下:
MyFamilyObject := TMyFamily.Create(′Zhang′, ′Li′);
也许有人会问, 如果没有为自己的类提供构造函数, 它的对象能否被建立呢?答案是:
可以。在了解了构造对象的过程之后,就会明白为什么答案是“可以”。
要创建出一个对象,首先需要分配对象本身所占用的内存空间, 然后执行类的构造函
数,以初始化各数据成员、申请对象需要的资源或创建其内部包含的子对象。
编译器在执行类似 MyFamilyObject := TMyFamily.Create(‘Zhang’, ‘Li’);这样的构造函
数之前,会插入以下几行汇编代码:
test dl, dl jz +$08 add esp, -$10 call @ClassCreate // 注意这行代码
以上代码的最后一行代码调用的是 system.pas 文件的第 8949 行的_ClassCreate 函数 (以
Delphi 6 为准),该函数具体为每个对象分配合适的内存。这个动作也就是所谓的“编译
器魔法” ( Compiler Magic) , 由这个动作完成真正的对象的内存分配, 一个对象在这个时
候已经有了外壳。
内存分配完成后是调用类的构造函数,即 TMyClass.Create(),以初始化数据成员。构
造函数由定义类的程序员编写, 也就是说, 将对象初始化成何种模样是由程序员决定的。
至此,一个对象已经诞生了。
之后,编译器会再插入以下几行汇编代码:
test dl, dl
jz +$0f
call @AfterConstruction
pop dword ptr fs:[$00000000]
add esp, $0c
其中主要的工作是调用每个对象实例的 AfterConstruction。 Borland 宣称这个调用在
Delphi 中没有用, 它的存在是为 C++ Builder 所保留的。 不过, 由于 AfterConstruction 被声
明为虚方法( virtual method),因此完全可以利用它做一些善后的工作,尤其是在编写组
件时。这些内容将在后续章节讲述,暂且不必理会。
可见,创建一个对象的步骤并不十分复杂,比之“十月怀胎”轻松了不知道多少倍。
由于对象本身所占内存的分配是由编译器完成的,因此即使没有构造函数, 对象也一
样可以被构造。构造函数的职责只是初始化对象的数据成员,没有构造函数只意味着不会
对数据成员进行初始化而已,编译器会对所有数据进行清零初始化。此外,由于 Object Pascal
中所有类 (除了 TObject 类本身)都是从 TObject 类派生,因此编译器会调用 TObject.Create()
构造函数。不过,这个函数只是一个空函数。
假如上面定义的 TMyFamily 类没有定义构造函数,则 TObject.Create 也不会对
TMyFamily 的 数 据 成 员 (FMyFatherName 、 FMyMotherName ) 进 行 初 始 化 , 因 为
TObject.Create()根本就不认识你的父、母亲!
? 析构函数与对象内存的回收
定义析构函数使用 Destructor 关键字。按惯例,析构函数名称为 Destroy。如:
type TMyClass = class Public Destructor Destroy(); override; End;
之所以在析构函数声明最后加上 override 声明, 是为了保证在多态的情况下对象能正确被析构(关于多态, 将在 2.4 节中详述) 。 如果不加 override 关键字, 则编译器会给出类
似“ Method ‘Destroy’ hides virtual method of base type ‘TObject’” 的警告提示。警告的意思
是用户定义的 Destroy 隐藏了基类的虚方法 TObject.Destroy(), 那样的话, 在多态的情况下
就无法正确析构对象了(具体原因请查 2.4 节) 。
È注意: 析构函数都需要加 override 声明。
与构造函数类似, 如果在类中没有特殊的资源需要被释放,也可以不定义析构函数,
TObject 同样定义了一个空的析构函数。
在析构对象的时候,应该调用对象的 Free()方法而不是直接调用 Destroy()。
MyFamilyObject.Free();
这是因为, 在 TObject 的 Free()方法中会判断对象本身是否为 nil,如果不为 nil 则调用
对象的 Destroy(),以增加安全性。既然有这样更安全的做法,当然没有理由不这么做了。
È注意: 永远不要直接调用对象的 Destroy(),而应该是 Free()。
要销毁一个对象, 其顺序与创建对象正好相反。首先是释放对象申请的资源以及销毁
内部的子对象,之后是回收对象本身所占的内存空间。
当程序执行到诸如 MyFamilyObject.Free();这样的代码时,首先执行 TObject.Free()
方法:
procedure TObject.Free; begin if Self <> nil then Destroy; end;
在 TObject 的 Free()方法中, 调用了对象的析构函数。 然后, 编译器会在执行完 Free()
方法之后,插入以下几行汇编代码以完成第二个步骤(回收对象本身所占的内存空间):
call @BeforeDestruction test dl, dl jle +$05 call @ClassDestroy
这 些 代 码 所 做 的 工 作 与 构 造 对 象 分 配 内 存 时 所 做 的 是 对 应 的 , 其 中 所 调 用 的
_ClassDestroy 函数会精确地回收对象内存空间。
以下一个例程说明了如何使用构造函数和析构函数:
unit DllLoader; interface uses windows; Type TDllLoader = class Protected // 之所以是 protected 成员, 是为了在其派生类中具体实现加载某 DLL 时, 派生类能够访问该句柄 FhDLL : HMODULE; Public Constructor Create(strDLLName : String); Destroctor Destroy(); override; End; Implementation Constructor TDllLoader.Create(strDLLName : String); Begin FhDLL := LoadLibrary(strDLLName); // 构造函数中加载 DLL ASSERT(FhDLL <> 0); End; Destructor TDllLoader.Destroy(); Begin If FhDLL <> 0 then begin FreeLibrary(FhDLL); // 析构函数中释放 DLL FhDLL := 0; End; End; End.
对象所占空间大小前面为对象分配内存空间时谈到, 每个对象会占用一定的内存空间, 那么这个大小是如何确定的呢?对象的大小,就是其数据成员所占用的内存空间的总和, 其方法(函数)是不占用对象空间的。不过, 它不是一个简单的加法,还与编译器的“数据域对齐方式优化”有关,稍后会详述。
注意: 对象的大小只取决于其拥有的数据成员。
TObject 实现了一个 InstanceSize()方法, 它可以取得对象实例的大小。 下面以一个示例
说明对象在内存中的布局。首先定义一个 TMyClass,其中包含 4 个数据成员和 1 个方法。
先看一下类的定义:
Type TMyClass = class Public FMember1 : Integer; FMember2 : Integer; FMember3 : WORD; FMember4 : Integer; Procedure Method(); End;
然后,在 Application 的主 Form 中放入一个 Memo 和一个 Button,并在 Button 的 OnClick
事件中写下在 Memo 中显示出对象位置的代码。该程序源代码清单如下:
unit Unit1; interface uses Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls,Forms,Dialogs, StdCtrls; type TForm1 = class(TForm) Button1: TButton; Memo1: TMemo; Label1: TLabel; procedure Button1Click(Sender: TObject); end; // 自定义的 TMyClass 类 TMyClass = class Public FMember1 : Integer; FMember2 : Integer; FMember3 : WORD; FMember4 : Integer; Procedure Method(); End; var Form1: TForm1; implementation {$R *.dfm} procedure TForm1.Button1Click(Sender: TObject); var Obj : TMyClass; begin Obj := TMyClass.Create(); with memo1.Lines do begin Add(‘对象大小: ‘ + IntToStr(Obj.InstanceSize)); Add(‘对象所在地址 : ‘ + IntToStr(Integer(Obj))); Add(‘FMember1 所在地址: ‘ + IntToStr(Integer(@Obj.FMember1))); Add(‘FMember2 所在地址: ‘ + IntToStr(Integer(@Obj.FMember2))); Add(‘FMember3 所在地址: ‘ + IntToStr(Integer(@Obj.FMember3))); Add(‘FMember4 所在地址: ‘ + IntToStr(Integer(@Obj.FMember4))); end; Obj.Free(); end; { TMyClass } procedure TMyClass.Method; begin //no code end; end.
Button 的 Click 事件中所做的事情是, 首先创建 TMyClass 类的实例, 然后将对象大小
以及每个数据成员的地址输出到 Memo 中。
该程序的代码和可执行文件可在配书光盘的 ObjectSize 目录下找到,运行程序并单击
“开始”按钮后,其界面如图 2.1 所示。
图 2.1 ObjectSize 程序界面
图 2.1 的 Memo 中显示出了想要的结果。也许读者会看不清图片中的结果,不妨介绍
一下:其中显示了对象大小为 20 个字节,对象所在首地址是 13443352(也许每次运行对
象被创建的地址会不同, 这没有关系, 在此主要关心的是各地址之间的差值) , FMember1
所在地址是 13443356, FMember2 为 13443360, FMember3 为 13443364, FMember4 为
13443368。 现在来分析一下:
根 据 对 象 首地 址 以 及 大小 可 以 算 出, 对 象 占 用的 内 存 空 间范 围 为 13443352 ~
13443371。 而第一个成员却在 13443356, 它与对象的首地址之间有一个 4 字节的空缺, 这
4 个字节存放的是一个指向对象的 VMT(虚方法表)的指针。关于 VMT 将在 2.4 节多态
的本质中详细讨论,此处暂且不表。
13443356~13443359 这 4 个字节即 FMember1 所占空间 ( 32 位整数)。同样,13443360~
13443363 为 FMember2 所占空间。比较容易令人疑惑的是 FMember3,计算可知,它所占
地址范围为 13443364~13443367,同样也是 4 个字节,但在此定义的 FMember3 其实是
Word 类型( 16 位) , 为什么它会占用 32 位空间呢?这与编译器的字节对齐优化有关, 编
译器会将无法合并的小于 32 位空间的数据域填充到 32 位大小,以加快存取速度。也就是
说, FMember3 同样需要占用 4 个字节空间。 可以自己试一下, 如果将以上 TMyClass 类定
义中的 FMember2 也改成 Word 类型,编译器会把 FMember2 和 FMember3 合并成一个 32
位空间,于是对象大小就变成了 16。
FMember4 所占的空间没什么意外,为 13443368~13443371。
整个对象的内存布局如图 2.2 所示。
另外,也可以看到, TMyClass 类中惟一的方法 Method()没有在对象的空间中出现。
? “类方法”与“类引用”类型
一般所称的“方法”, 都是指“对象方法”。 也就是说, 执行该方法,将可能导致对
象的状态发生改变,即该方法可以更改对象的数据成员的值。如:
其中 SetValue 即为典型的对象方法。
除了“ 对象方法” 外, 还有所谓的“类方法” ,也就是属于类级别的函数( 而非对象
级别的)。它可以改变类的状态(而非对象的状态)。
定义类方法,只需要在一般的方法声明前加上 class 关键字。 如:
class function TObject.ClassName: ShortString;
既然类方法是进行类级别的操作, 因此在类方法中是无法对对象的数据成员进行访
问的。
在 Object Pascal 中,还有一种“类之类”的类型,也就是所谓的“类引用”。一般所
称的类,是对其实例对象的抽象。定义一个类:
TMyClass = class;
而“类引用”类型却是对“类”的抽象( 元类),所以被称为“ 类之类”。 定义一个
“类之类”:
TMyClassClass = class of TMyClass;
“类之类”可以直接调用“类”的“类方法”。如:
TMyClass = class public class procedure Show(); end; TMyClassClass = class of TMyClass; var MyClass : TMyClassClass; MyObj : TMyClass; begin MyObj := MyClass.Create(); MyClass.Show(); MyObj.Free(); end;
在此例中, TMyClassClass 作为 TMyClass 的元类, 可以直接调用 TMyClass 的类方法。
此前提到过的类构造函数,其实就是一个类方法,因此可以如同
MyObj := MyClass.Create();
来创建对象,其结果与
MyObj := TMyClass.Create();
完全相同。
但是, 析构函数则不是类方法,而是普通的对象方法。因为析构函数只能销毁一个对
象实例, 其操作结果并非作用于该类的所有对象。因此, 销毁对象只能通过对象来调用析
构函数而不能通过类方法:
MyObj.Free();
“类方法” 和“类引用”有什么作用呢? 它主要用在类型参数化上, 因为有时在编译
时无法得知某个对象的具体类型, 而需要调用其类方法( 如构造函数),此时可以将类型
作为一个参数来传递。在 Delphi 6 的帮助文档中,有这样一个例子:
type TControlClass = class of TControl; function CreateControl( ControlClass: TControlClass; const ControlName: string; X, Y, W, H: Integer ): TControl; begin Result := ControlClass.Create(MainForm); with Result do begin Parent := MainForm; Name := ControlName; SetBounds(X, Y, W, H); Visible := True; end; end;
以上是关于面向对象编程理论基础的主要内容,如果未能解决你的问题,请参考以下文章