多态性,是一种能给程序带来灵活性的东西。看过《设计模式》的程序员应该都知道,相当多的模式(几乎所有)都是依靠多态来实现的,以此给程序提供可扩展、可重用性。在《再谈多态--向上映射及VMT/DMT》一文中,提到了多态性是依赖于虚函数/虚方法(即动态绑定)来实现的,也介绍了虚函数/虚方法(virtual)的实现方法。那么本文就来谈一下,如何使用virtual、善用virtual来获取多态性给我们带来的灵活性。 实例是最好的教材,因此本文还是假设一个需求,写一个实例来讲解。不过,我想没有必要给出所有源码,因此在本文中有些实现的代码会粗略带过。另外,本文所有代码均为Object Pascal语言编写,实现环境为Delphi。 另外,由于“方法(Method)”一词已经成为Object Pascal的术语,因此,以下称成员函数都为“方法”。也许C++程序员会不太适应这样的称呼(呵呵,我自己也不太适应),见谅吧。 假设我们要编写一个纯文本内的编辑器,也就是记事本(呵呵,别嫌例子老套,记事本程序在相当多方面都是很好的教材),编辑控件我们一般会用TMemo或TRichEdit,但是它们的功能都不甚强大,也许我们目前没有,但日后会找到一个更好的第三方文本编辑控件(比如,支持语法着色的)。因此,我们必须为未来的改进留下方便之门,否则到时候再重写全部程序真是太傻了。 界面层(菜单响应、状态显示等)对文本编辑器控件的控制的代码对于所有编辑器来说都是类似的,应该可以被重用。那么就必须将界面层的代码与编辑器控件的控制代码隔离开来。 如何隔离?我们可以为所有的编辑器控件指定一个公共的接口(抽象类),界面层只看得到这个接口,只使用接口提供的功能,那么,我们更换任何编辑器控件时,都不必更改界面层的代码了。 首先,抽象出编辑器的基本操作,如:Load(打开文本)、Save(保存到文件)、Copy(复制到剪贴板)等等,将这些操作作为public方法。其次,考虑这些操作中有哪些会涉及到具体相关控件的,对于这些操作,你有两种选择:1、如果完全依赖控件本身的,可以选择将其定义为虚方法或抽象虚方法(即C++中的纯虚函数);2、如果只是有部分代码依赖控件本身的,将这部分操作抽象到一个protected的抽象虚方法中,而将相同的部分代码写在基类中,并由基类的方法中调用protected的抽象虚方法。 如果还没有完全明白,请看代码: TEditor = class // 抽象基类 private m_FileName : String; protected function DoLoad(FileName : String) : Boolean; virtual; abstract; public function Load(FileName : String) : Boolean; function Save() : Boolean; function SaveAs(FileName : String) : Boolean; virtual; abstract; // ... 其他需要的操作,由需求而定 end; 好,我们来详细说明一下TEditor为什么是这个样子的。其有一个私有成员,保存编辑器所对应的文件名。然后看public部分,它至少提供了三个操作:Load--从某文件中读取文本到编辑器;Save--将文本保存到文件,文件名为m_FileName所保存的;SaveAs--以指定的一个文件名保存文本。
三个操作中,SaveAs为抽象虚方法,因为它的实际动作与每个编辑控件相关,而基类本身并不知道该如何保存文件。
Save和Load都是非虚方法,其中Save的任务很简单,就是将文本以m_FileName所保存的文件名保存,其实现可以是调用抽象的SaveAs,只需要将文件名传给SaveAs即可。基类完全可以确定如何完成Save(因为它可以保证派生类实现了SaveAs),因此其为非虚方法。
而Load呢?Load操作的步骤是:1、将文本从文件中读取到编辑器控件中;2、将m_FileName的值设置为所读取的文件名。其中第一个步骤与具体编辑器控件相关,应该是由派生类去实现它,第二个步骤派生类无法实现,因为派生类看不到私有的m_FileName成员。但即使把m_FileName移到protected节中,以使派生类可以访问它,也并非好办法,因为这样的话,在实现每个派生类时,都要记住设置m_FileName的值,这不仅造成代码重复(每个派生类都要这样做),而且这不应该是派生类的义务。因为m_FileName应该是基类的内部实现,对外不可见。因此,第二个步骤应该由基类来完成。如何达成这个目的呢?
我们可以注意到,protected节中有一个DoLoad方法,它就被用来完成第一个步骤--每个编辑器控件去将文本读入编辑器。然后,DoLoad由Load方法中被选择在适当的时机调用。 基类的实现如下:
function TEditor.Load(FileName : String) : Boolean; begin Result := DoLoad(FileName); if Result then m_FileName := FileName; end; function TEditor.Save() : Boolean; begin SaveAs(m_FileName); // 调用抽象的 SaveAs end; 接着,我们使用TMemo来实现一个编辑器类: TMemoEditor = class(TEditor) private m_Editor : TMemo; protected function DoLoad(FileName : String) : Boolean; override; public constructor Create(); destrcutor Destroy(); override; function SaveAs(FileName : String) : Boolean; override; // ...其它需要的操作 end; 在该派生类中,有一个私有成员,即TMemo控件的实例。然后覆盖(override)了基类的两个抽象虚方法:DoLoad和Save。 其实现如下: function TMemoEditor.Create(); begin // 创建TMemo实例 m_Editor := TMemo.Create(nil); // 接着完成将TMemo实例置于界面上显示出来等操作,省略 end; function TMemoEditor.Destroy(); begin // 其他清理工作 m_Editor.Free(); m_Editor := nil; end; function TMemoEditor.DoLoad(FileName : String) : Boolean; begin Result := false; try m_Editor.LoadFromFile(FileName); except end; Result := true; end; function TMemoEditor.SaveAs(FileName : String) : Boolean; begin Result := false; try m_Editor.SaveToFile(FileName); except end; Result := true; end; 很好,这样的实现已经可以使个部分运作正常了。如果,今后找到更好的编辑器控件,只需要从TEditor派生,再实现一个TXXXEditor类即可,其他部分的代码不用作任何改动。而且,具体实现的TXXXEditor类中的代码,只和具体控件本身特性相关(如:读取、保存文件的方法),而公共逻辑也已经在TEditor类中实现了。 virtual的使用方法,基于笔者个人认识与经验: 1、如果基类不知道如何实现某方法(只有派生类知道),而基类的其他方法又必须使用该方法,则把该方法声明为抽象虚方法-- virtual; abstract;(即C++的纯虚函数)。 2、如果基类能够为某方法提供一种默认实现,但派生类可能完全重写这个实现,则将该方法声明为虚方法-- virtual;并实现默认算法。
3、如果基类能够且必须提供某方法的部分的实现,而派生类必须提供另一部份的实现,则将该方法声明为非虚方法,并在基类中为其配套提供一个虚方法或抽象虚方法,以允许由基类本身调用和被派生类覆盖。犹如上例中的Load与DoLoad。 善用virtual,善用多态,你的代码将更具灵活性! 构造函数与异常 这个话题在C++社区中经常会被提起,而在Delphi社区中似乎从来没有人注意过。也许由于语言的特性,使得Delphi程序员不必关心这个问题。但我想Delphi程序员也应该对该问题有所了解,知道语言为我们提供了什么而使得我们如此轻松,不必理会它。正所谓“身在福中须知福”。
我们知道,类的构造函数是没有返回值的,如果构造函数构造对象失败,不可能依靠返回错误代码。那么,在程序中如何标识构造函数的失败呢?最“标准”的方法就是:抛出一个异常。
构造函数失败,意味着对象的构造失败,那么抛出异常之后,这个“半死不活”的对象会被如何处理呢?
在此,我想读着有必要先对C++对这种情况的处理方式先有个了解。
在C++中,构造函数抛出异常后,析构函数不会被调用。这种做法是合理的,因为此时对象并没有被完整构造。
如果构造函数已经做了一些诸如分配内存、打开文件等操作的话,那么C++类需要有自己的成员来记住做过哪些动作。当然,这样做对于类的实现者来说非常麻烦,因此一般C++类的实现者都避免在构造函数中抛出异常(可以提供一个诸如Init和UnInit的成员函数,由构造函数或类的客户去调用它们,以处理初始化失败的情况)。而每一本C++的经典著作所提供的方案是使用智能指针(STL的标准类auto_ptr)。
在Object Pascal中,这个问题变得非常的简单,程序员不必为此大费周折。如果Object Pascal的类在构造函数中抛出异常,编译器会自动调用类的析构函数(由于析构函数不允许被重载,可以保证只有唯一一个析构函数,因此编译器不会迷惑于多个析构函数之中)。析构函数中一般会析构成员对象,而Free()方法保证了不会对nil对象(即尚未被创建的成员对象)调用析构函数,因此在使得代码简洁优美的前提下,又保证了安全。
type MyClass = class
private
FStr : PChar; // 字符串指针
public
constructor Create();
destructor Destroy(); override;
end;
constructor MyClass.Create();
begin
FStr := StrAlloc(10); // 构造函数中为字符串指针分配内存
StrCopy(FStr, 'ABCDEFGHI');
raise Exception.Create('error'); // 抛出异常,没有理由,呵呵
end;
destructor A.Destroy();
begin
StrDispose(FStr); // 析构函数中释放内存
WriteLn('Free Resource');
end;
var
Obj : TMyClass;
i : integer;
begin
try
Obj := TMyClass.Create();
Obj.Free();
WriteLn('Succeeded');
except
Obj := nil;
WriteLn('Failed');
end;
Read(i); // 暂停屏幕,以便观察运行结果
end.
在这段代码中,构造函数抛出异常,执行的结果是:
Free Resource
Failed
此时的“Free Resource”输出是由编译器自动调用析构函数所产生的。
因此,如果类的说明文档或类的作者告知你,类的构造函数可能会抛出异常,那就要记得用try…except包住它!
C++与Object Pascal对于构造函数抛出异常后的不同处理方式,其实正是两种语言的设计思想的体现。C++秉承C的风格,注重效率,一切交给程序员来掌握,编译器不作多余动作。Object Pascal继承Pascal的风格,注重程序的美学意义,编译器帮助程序员完成复杂的工作。
|