促使我们从VB6转向VB.NET的一个最大的原因就是VB.NET对面向对象编程(OOP)这一概念的完全支持。然而,要运用这个功能,只学习一些新的关键字是远远不够的。面对这么多可供选择的新的功能,你可能会感到困惑。因此,我将在本文中说明如何在你的应用程序中运用面向对象的功能。我不会深入讲述每个新的功能(要进行深入讲述,用整本杂志的篇幅都不够),另外我也提供了代码例子,在可执行的代码中有注释,它们有助于你对一般概念的理解。你可能对我用的一些词不太熟悉,因此我提供了一个最常用的OOP术语表。
你在应用程序中可能会经常用到的第一个面向对象的语言的特征是方法重载(method
overloading)。VB.NET可以让你用一个特定的名字定义多个方法或属性,只要它们的参数定义不同;这就是说,它们的参数的数量或类型必须是不同的。例如,一个类可以定义一个GetItem方法,它带有的参数可以是一个数字或字符串,我们根据参数类型来返回元素:
Function GetItem(ByVal index As _
Integer) As Object
' return an element by its index
End Property
Function GetItem(ByVal key As String) _
As Object
' return an element by its key
End Property
|
编译器通过查看参数的类型来调用正确的版本:
res = obj.GetItem(1) ' numeric key
res = obj.GetItem("Joe") ' string key
|
当你有一个可以采用任何数据类型的很普通的方法时,方法重载尤其有用——例如,一个将参数值添加到一个文本文件的Log方法。你可能想定义一个采用Object参数的单独的版本,因为你想将任何类型的数据传递到这个方法:
Sub Log(ByVal value As Object)
' TW is a TextWriter object
tw.Write("LOG:" & value.ToString())
End Sub
|
然而,如果你将一个值类型的参数(一个数字、日期/时间、一个布尔值或一个结构)传递到一个Object参数,那么你就暗中强加了一个封装操作。.NET
runtime必须用一个对象来封装值——这么做就会从托管堆(managed
heap)分配内存,而且浪费了宝贵的CPU周期。
一个更好的方法就是为你支持的每种数据类型定义同一个方法的重载的版本。如果你不想为每种可能的参数类型写代码,你可以实现一个采用Long参数的版本(它可以处理Boolean、Short、Integer和Long类型的值)、一个采用Double参数的版本(它也可以处理Single类型的值)和另外两个分别采用DateTime值和Decimal参数的重载的版本。这四种版本可以处理最常用的值类型,而让采用一个Object参数的重载的版本来处理引用类型(如字符串)或更特殊的对象(如Person)。将一个字符串或一个特殊的对象传递到采用一个Object参数的版本不会增加CPU的费用,因为它没有强加封装操作。
构造器提供了强大的类
在创建一个类库时,你应该用多个重载的方法,而不要用采用可选参数的一个单独的方法,因为有些.NET语言(C#最明显)不能识别可选参数。记住,两个重载的方法的不同不仅体现在它们的返回值或你用于每个参数前的ByVal/ByRef关键字上。(ByVal/ByRef关键字适用于VB.NET和其它一些.NET语言;C#可以让你定义两个只在ref或out关键字上有区别的方法。)
接下来我们要探究的一个面向对象的特征就是构造器(constructor)。VB.NET构造器是一个名为Sub
New的过程,当客户端创建类的一个实例时,就会调用这个过程。如果你的代码不包含一个明确的构造器,VB.NET编译器就会自动添加一个缺省的构造器——一个不带任何参数的构造器。如果没有明确的(explicit)或隐含的(implicit)构造器,你就不能实例化类。VB.NET也可以让你定义一个带有参数的构造器,所以你可以让客户端实例化在有效状态创建对象所必需的字段:
' a read-only field can be set only
' from inside a constructor procedure
Public ReadOnly Filename As String
Sub New(ByVal filename As String)
' ensure filename isn't null
If filename Is Nothing OrElse _
Filename.Length = 0 Then
Throw New ArgumentException("Invalid file name")
End If
' assign to the read-only field
Me.FileName = filename
End Sub
|
带有参数的多个构造器通常有共同的代码——例如,验证一个或多个参数的代码。这时候,你就可以简化你的类的结构,让一个构造器调用另一个构造器:
Public ReadOnly Overwrite As Boolean
Sub New(ByVal filename As String, _
ByVal overwrite As Boolean)
' a call to another constructor MUST
' be the first executable statement
Me.New(filename)
' assign remaining fields
Me.Overwrite = overwrite
End Sub
|
当你既需要缺省的构造器,也需要一个或多个带有参数的构造器时,就会出现一个有趣的问题。在这种情况下,你必须明确声明一个空的Sub
New过程,因为编译器不会自动为你创建它:
Sub New()
' no need to add code here
End Sub
|
构造器的范围对类的行为有重要的含义。一个Public类中的Friend构造器使我们只可以从同一个程序集内部创建这个类,所以它同你在VB6的类中用的PublicNotCreatable设置有很多共同之处。一个私有的(private)构造器使这个类根本不能创建,如果类只是共享方法的一个容器,这种构造器就很有用。(这样的类的例子有System.Console和System.Environment。)更确切地说,一个代码片段可以实例化一个带有私有构造器的类,只要那个代码位于类内部或嵌套的类中,因为一个嵌套的类型可以访问包含它的类型的私有的成员。创建只包含一个共享成员的一个VB.NET类的更简单的方法就是定义一个Module。Module是规则的、不能创建的类,它的成员是静态的。注意,.NET
runtime对模块并不很重视(C#中没有Module):VB.NET对模块的支持只可以简化VB6代码的移植,而且编译器将一个Module中的所有成员都明确地转换成静态成员。
注意初始化字段
前面的讲述可能意味着私有构造器只有在很少的情况下才有用,但实际并不是这样的。例如,当你的类包含许多字段的初始化设置时,定义一个空的Private
Sub New过程就很方便:
Public MinSize As Integer = 10
Public MaxSize As Integer = 1000
' ...(other fields with initializers)
|
编译器在每个构造器开始处都会进行隐含的赋值,保证在构造器运行时,所有的字段都包含正确的初始值。如果你有20个初始化字段和10个构造器,那么你的类就会包含多达200个隐含的赋值,这样就会浪费内存中和磁盘上的字节。如果你定义一个虚拟的不带参数的私有构造器,并让所有的公有构造器调用它,那么编译器就只添加20个隐含的语句到私有构造器中。通过Microsoft
Intermediate Language Disassembler(ILDASM)运行产生的可执行的文件,你就可以看到在每种情况下编译器创建的代码。
当客户端要通过一个共享的函数(作为类的工厂方法(factory
method ))来创建类的实例时,就体现了私有构造器的另一个好处。一个共享的方法可以让你在创建类的一个新实例前运行一些代码——例如,查看一个具有相同属性的对象是否在你内部管理的对象池中。你不能用一个规则的构造器来实现这种功能,因为只有在一个新实例已经运行时,规则的构造器的代码才运行。
你在从一个类派生一个更简单的新类时,可以看到OOP的强大。派生的类自动继承基类的所有字段、属性、事件和接口,所以你只需要关注你想添加到派生的类中的成员:
Class Person
Public FirstName As String
Public LastName As String
Function CompleteName() As String
Return FirstName & " " & LastName
End Function
End Class
Class Customer
Inherits Person
' a new field and a new method
Public Title As String
Function ReverseName() As String
Return LastName & ", " & FirstName
End Function
End Class
|
更好的是,如果你期望派生的类有不同的行为,你还可以覆盖基类中的属性或方法。例如,你可能想让Customer.CompleteName方法以“Mr.
John Doe”的形式返回一个字符串。你必须做两件事来覆盖一个成员:将基类的成员标记为Overridable,使它成为一个虚拟的成员,用关键字Overrides来标记派生的类的成员:
' in Person class
Overridable Function CompleteName() As String
' ...(as before)
End Function
' in Customer class
Overrides Function CompleteName() _
As String
Return Title & " " & FirstName _
& " "& LastName
End Function
|
|
图1. 让 Visual Studio .NET创建被覆盖的方法
|
重用基类中的代码
Visual Studio .NET为我们在一个派生的类中写被覆盖的成员的代码提供了一个很好的捷径:在编辑窗口上方最左边的ComboBox中选择类名字下的(Overrides)成员,然后在最右边的ComboBox中选择你想覆盖的成员(见图1)。在派生的类中你不需要用关键字Overridable,因为被覆盖的方法本身就是可以被覆盖的。如果你出于某种原因想停止进一步
覆盖那个方法,你必须用关键字NotOverridable标记它:
' derived classes can't override this
NotOverridable Overrides Function _
CompleteName()As String
' ...
End Function
|
重新定义的方法中的代码通常从重用基类的方法中的代码中受益——例如,插入语句,使它在最初方法中代码之前或之后运行。我们继续前面的例子,在MyBase关键字的帮助下,Customer.CompleteName方法可以重用Person.CompleteName中的代码:
' in Customer class
Overrides Function CompleteName() _
As String
Return Title & " " & _
MyBase.CompleteName()
End Function
|
你也可以将一个对象赋值给一个变量,变量的类型是该对象的基类。例如,你可以将一个Customer对象赋给一个Person变量:
Dim cust As New Customer()
cust.Title = "Mr."
cust.FirstName = "John"
cust.LastName = "Doe"
Dim pers As Person = cust
|
(顺便说一下,这种方法也就说明了为什么可以将任何对象赋给一个Object变量。)如果变量pers指的不是Customer对象,那么反方向的赋值就会失败,所以你必须用CType或DirectCast操作符来明确告诉编译器,你知道赋值是合法的:
Dim cust2 As Customer = _
DirectCast(pers, Customer)
|
CType和DirectCast的主要区别是,DirectCast只能将一个值转换为一个不同的引用类型,而CType还可以将一个值转换成一个不同的类型。CType也可以用于值类型。不管你用什么操作符,如果源变量所指向的对象的类型与目标变量不一致,那么.NET就会抛出一个异常。
将一个对象赋值给一个不同类型的变量不会影响对象的本质。例如,通过基类的变量调用一个被覆盖的方法就调用了派生类的实现方法,而不是基类的方法:
' pers points to a Customer, so this
' statement displays "Mr. John Doe"
Console.Write(pers.CompleteName)
|
继承是在一个.NET应用程序中实现多态性的最常用的方式。例如,假设你有一个数组或一个集合,它包含Person对象、Customer对象和其它继承自Person的对象。通过运行下面循环中CompleteName方法的代码,每个对象就会作出响应:
Dim p As Person
For Each p in personCollection
Console.WriteLine(p.CompleteName)
Next
|
这里有一个提高性能的技巧:调用一个被覆盖的(虚拟的)成员比调用一个非虚拟的成员慢。另外,JIT编译器不能通过内嵌方法(将代码移到调用过程内从而避免进行调用和传递参数而带来的额外的费用)优化对一个虚拟的方法的调用。所以你不应该将一个方法标记为overridable,除非你预见到需要在派生的类中覆盖它。
运用.NET继承性
如果你覆盖在一个基类中重载的成员,你必须用Override和Overloads关键字。如果你添加的一个成员的名字与基类中一个成员的名字一样,但有不同的参数定义,你就必须用关键字Overloads,而不要用Overrides:只有一个成员的名字和定义与基类中一个方法的名字和定义一样,你才能覆盖它。
一个派生的类继承其基类的所有的成员,除了构造器外。你必须明确实现派生的类需要呈现的所有的构造器,并坚持两个原则。首先,只要基类有一个明确的或隐含的不带参数的构造器,编译器就会在派生的类中创建一个隐含的缺省的构造器。如果你用派生的类中的参数定义其它构造器,你不需要调用基类的构造器,因为编译器可以为你实现它。第二,如果基类没有一个不含参数的构造器,你必须在派生的类中至少定义一个构造器,使它是可以创建的。派生的类中的所有构造器的第一个可执行的语句必须明确调用基类中的一个构造器。
我将说明如何将这些原则运用到实际情况中。Person类有一个隐含的缺省的构造器,所以你不需要在Customer类中定义一个构造器,因为VB.NET已经为你创建了一个缺省的构造器了。另外,具有Customer类的参数的一个构造器不需要明确调用基类中的构造器,因为编译器也同样自动添加了这样的调用。但是,假设Person类提供了一个非缺省的构造器(它的出现就告诉编译器不要创建隐含的不含参数的构造器):
' in the Person class
Sub New(ByVal firstName As String, _
ByVal lastName As String)
Me.FirstName = firstName
Me.LastName = lastName
End Sub
|
在这种情况下,Customer类必须明确包含一个构造器,它的第一个语句就是调用MyBase.New。基类和派生类中的构造器没有必要采用一样的参数:
' in the Customer class
Sub New(ByVal title As String, _
ByVal firstName As String, _
ByVal lastName As String)
MyBase.New(firstName, lastName)
Me.Title = title
End Sub
|
派生的类必须至少可以访问基类中的一个构造器,这种要求会产生两个有趣的结果。第一,你不能从一个不同程序集的类继承一个只有Friend构造器的类。第二,你不能从一个只有Private构造器的类继承。(更确切地说,只有其嵌套的类可以继承它。)定义一个密封类(一个不能被继承的类)的首选方法是将它标记为NotInheritable:
NotInheritable Class Person
'...
End Class
|
正如你预计的,一个密封的类不能包含可以覆盖的成员。
Shadow基类的成员
如果你在继承一个不是你创建的类,你必须做好准备解决这个问题:基类可以在以后用一个成员扩展,这个成员的名字与你已经提供的派生的类的成员名字一样。在代码例子中,如果Person类的一个新版本添加了一个ReverseName方法,就会出现这个问题。当你重新编译Customer类时,VB.NET编译器就会发出警告:“函数‘ReverseName’与基类‘Person’中的函数‘ReverseName’发生冲突,所以应该被声明为‘Shadows’”。
根据该信息的提示,你可以用Shadows关键字来修饰Customer.ReverseName方法,明确表明该方法不会覆盖Person.ReverseName方法,从而避免编译器出现警告信息:
Shadows Function ReverseName() As String
Return LastName & ", " & FirstName
End Function
|
你可以将Shadows关键字用于任何类型的成员,包括嵌套的类。当一个成员遮蔽(shadow)住基类中具有同样名字的一个成员时——用或者不用明确的Shadows关键字——你就不能通过一个基类变量来访问它了。例如,下面的代码的行为会与你期望的不同:
Dim cust As New Customer()
'... init cust properties...
Dim pers As Person = cust
' next line invokes the ReverseName
' in Person class, not in Customer
Console.Write(pers.ReverseName)
|
记住:不管你是否用Shadows关键字,你没有用Overrides关键字标记的一个方法会隐藏在基类中用同样名字定义的所有的重载的方法,而不只是具有同样参数定义的方法。
我建议将Shadow方法作为最后的一种方法,因为它会使一个类的行为更复杂。永远不要定义一个方法,使它遮蔽已经在基类中存在的方法,试着去用基类创建者在以后的版本中不太可能添加的成员名。
一个基类也可以用Protected范围限定符定义成员。一个protected成员只能被它的类和它派生的类(包括在不同程序集中定义的派生的类)看到。例如,所有.NET类从System.Object继承protected方法:Finalize和MemberwiseClone。
当准备从托管堆删除对象时,.NET
runtime就调用了Finalize方法。你不能自己调用这个方法,但在对象被终止时,你可以覆盖它来执行特殊的清除工作,如调用一个Windows
API函数来释放你得到的操作系统句柄。
MemberwiseClone方法返回运行对象的一个副本;你不应该覆盖它。在实现ICloneable接口和它唯一的成员时,这个方法尤其有用:
Class Person
Implements ICloneable
Function Clone() As Object _
Implements ICloneable.Clone
Return Me.MemberwiseClone()
End Function
End Class
|
你也可以通过创建一个新的实例并设置它的属性来复制运行的对象,但MemberwiseClone更有效,因为它直接拷贝你的对象运用的内存。记住,MemberwiseClone方法只是从表面上返回你的对象的一个副本(shallow
copy):它只复制字段,但不复制这些字段指向的对象。
用Protected方法添加灵活性
你不应该声明protected字段,因为它们会让你不能在以后的版本中改变你的基类的内部实现。作为替代,你应该声明封装私有字段的protected属性,这样你就可以改变属性过程的实现而不会影响现有的派生的类了。
当你想给继承者提供一种改变基类行为的方法时,protected方法尤其有用。例如,设想你有一个打印报表的Report类:
Class Report
Sub Print()
' ...print the header...
' ...print the body...
' ...print the footer...
End Sub
End Class
|
这种很简单的实现方法的问题是我们不能方便地定制Print方法:一个派生的类既可以按其原样继承它,也可以完全覆盖它,提供全新的代码来打印页眉、正文和页脚。一个更好的方法是用辅助的protected方法来形成一个更灵活的解决方案:
Sub Print()
OnHeader()
OnBody()
OnFooter()
End Sub
Protected Overridable Sub OnHeader()
' ...print the header...
End Sub
Protected Overridable Sub OnBody()
' ...print the body...
End Sub
Protected Overridable Sub OnFooter()
' ...print the footer...
End Sub
|
现在,一个派生的类就可以只覆盖报表的页眉、页脚或正文了。例如,你可以用一个不包含可执行代码的被覆盖的OnFooter方法来不打印任何页脚:
Class NoFooterReport
Inherits Report
Protected Overrides Sub OnFooter()
' prints nothing here
End Sub
End Class
|
在派生的类中被覆盖的protected方法一般都命名为Onxxxx。例如,System.Windows.Forms.Form类呈现了许多Onxxxx
protected方法,你可以用它们来定制你的窗体的行为。通过覆盖OnWndProc方法,你可以处理Windows发送到一个窗体的所有的消息,包括Form基类没有传递到事件的消息(如通过鼠标在窗体的标题栏上生成的那些事件)。这种方法可以将窗体分成子集,因此比相应的VB6技巧更安全、更简单。
说到事件,你不能像覆盖一个方法或属性那么简单地去覆盖基类对一个事件的实现,因为你不能将Overridable和Overrides关键字用于事件。你必须在一个protected、可覆盖的方法(一般命名为Oneventname)中封装RaiseEvent语句,让派生的类在基类中重新定义事件:
' base class with re-definable event
Class Report
Event Sub EndOfReport(pages As Short)
Sub Print()
' ...same code as before...
OnEndOfReport(numOfPages)
End Sub
Protected Overridable Sub _
EndOfReport(pages As Short)
RaiseEvent EndOfReport(pages)
End Sub
End Class
|
一个派生的类可以覆盖OnEndOfReport方法来控制EndOfReport事件——例如,为报表统计总数。然后,通过调用MyBase.OnEndOfReport方法,派生的类可以触发原始的事件,或者通过省略该调用来取消该事件:
' a class that redefines the event
Class ReportWithTotal
Inherits Report
Protected Overrides Sub _
EndOfReport(pages As Short)
' ... print the grand total...
' cancel the event if pages=1
If pages > 1 Then
' this fires the event
MyBase.EndOfReport(pages)
End If
End Sub
End Class
|
你必须熟悉另外两个VB.NET的关键字:MustInherit和MustOverride。你可以将MustInherit用于抽象的类——就是说,不能被实例化、只能作为其它类的基类的类。你可以将MustOverride用于抽象的方法——没有具体实现方式、必须在被继承的类中覆盖的方法。这两个关键字是相关连的:包含一个MustOverride方法的类就是一个抽象的类,你必须用MustInherit关键字声明它。(但反过来并不成立,你可以有一个不包含抽象方法的抽象的类。)
你通常用抽象的类来模拟一般的或概念上的与特殊的对象不相应的实体。例如,假设你正在创建一个可以处理几何图形的CAD应用程序。我们可以提取图形概念,定义一个Shape类,它包含所有实际图形共享的代码——例如,它们的X、Y坐标,以及可以被移动到另一个地方的特点:
MustInherit Class Shape
Public X, Y As Double
Sub Move(dx As Double, dy As Double)
' move the shape and redraw it
X += dx: Y += dy
Me.Draw()
End Sub
' notice that no End Sub is used
MustOverride Sub Draw()
End Class
Class Square: Inherits Shape
Overrides Sub Draw()
' ...(code to draw a square)
End Sub
End Class
|
Shape类必须包含抽象的Draw方法的定义(否则它不编译),但只有一个具体的如Square的具体的类可以(而且必须)实际实现这个方法。
OOP是个复杂的话题,要运用其所有的功能并理解所有的含义是需要花些时间的。我在本文中已经压缩了许多信息,只是提供了一些实用的设计和性能技巧。对你在本文中学到的技巧和在VB.NET书籍中学到的东西进行实践,你就会发现VB.NET是个全新的、令人兴奋的编程方法,它可以让你创建出强大的、灵活的和易于维护的应用程序。
关于作者:
Francesco Balena是Programming Microsoft Visual Basic 6.0和Programming
Microsoft Visual Basic .NET(Microsoft Press)的作者,他经常在VSLive!和其它开发人员大会上做演讲,并为Wintellect(
www.wintelect.com)教授VB.NET课程。他是Code
Architects Srl的负责人,这是一家意大利公司,专门从事.NET的开发,他也是
www.vb2themax.com的创办人。他的联系方式是
fbalena@vb2themax.com。