8.2 OOP技术
前面介绍了一些基础知识,知道对象是什么,以及对象的工作原理,下面讨论对象的其他一些特性,包括:
● 接口
● 继承
● 多态性
● 对象之间的关系
● 运算符重载
● 事件
● 引用类型和值类型
8.2.1 接口
接口是把公共实例(非静态)方法和属性组合起来,以封装特定功能的一个集合。一旦定义了接口,就可以在类中实现它。这样,类就可以支持接口所指定的所有属性和成员。
注意,接口不能单独存在。不能像实例化一个类那样实例化接口。另外,接口不能包含实现其成员的任何代码,而只能定义成员本身。实现过程必须在实现接口的类中完成。
在前面的咖啡示例中,可以把通用属性和方法,例如AddSugar()、Milk、Sugar和Instant组合到一个接口中,这个接口可以命名为IHotDrink(接口名一般以大写字母I开头)。然后就可以在其他对象上使用该接口,例如CupOfTea类的对象。所以可以采用类似方式处理这些对象,而对象仍保有自己的属性(例如CupOfCoffee仍有属性BeanType, CupOfTea仍有属性LeafType)。
在UML中,在对象上实现的接口用“棒棒糖”语法来表示。在图8-6中,用与类相似的语法把IHotDrink的成员放在一个单独的框中。
图8-6
一个类可以支持多个接口,多个类也可以支持相同的接口。所以接口的概念让用户和其他开发人员更容易理解其他人的代码。例如,有一些代码使用一个带某接口的对象。假定不使用这个对象的其他属性和方法,就可以用另一个对象代替这个对象(例如,使用上述IHotDrink接口的代码可以处理CupOfCoffee和CupOfTea实例)。另外,该对象的开发人员可以提供该对象的更新版本,只要它支持已经在用的接口,就可以在代码中使用这个新版本。
发布接口后,即接口可以用于其他开发人员或终端用户后,最好不要修改它。理解这一点的一种方式是把接口看成类的创建者和使用者之间的协定,即“每个支持接口X的类都支持这些方法和属性”。如果以后修改了接口,也许是升级了底层的代码,该接口的使用者就不能正确运行接口,甚至失败。所以,我们应做的是创建一个新接口,使其扩展旧接口,可能还包含一个版本号,如X2。这是创建接口的标准方式,以后我们会常常遇到已编号的接口。
可删除的对象
IDisposable接口特别有趣。支持IDisposable接口的对象必须实现Dispose()方法,即它们必须提供这个方法的代码。当不再需要某个对象(例如,在对象超出作用域之前)时,就调用这个方法,释放重要资源,否则,等到对垃圾回收调用析构方法时才会释放该资源。这样可以更好地控制对象所用的资源。
C#允许使用一种可以优化使用这个方法的结构。using关键字可以在代码块中初始化使用重要资源的对象,在这个代码块的末尾会自动调用Dispose()方法,用法如下:
<ClassName> <VariableName> = new <ClassName>(); ... using (<VariableName>) { ... }
或者把初始化对象<VariableName>作为using语句的一部分:
using (<ClassName> <VariableName> = new <ClassName>()) { ... }
这两种情况下,可在using代码块中使用变量<VariableName>,并在代码块的末尾自动删除(在代码块执行完毕后,调用Dispose())。
8.2.2 继承
继承是OOP最重要的特性之一。任何类都可以从另一个类继承,这就是说,这个类拥有它继承的类的所有成员。在OOP中,被继承(也称为派生)的类称为父类(也称为基类)。注意,C#中的对象仅能直接派生于一个基类,当然基类也可以有自己的基类。
继承性可从一个较一般的基类扩展或创建更多的特定类。例如,考虑一个代表农场家畜的类(由80多岁的资深开发人员MacDonald在他的家畜应用程序中使用)。这个类名为Animal,拥有EatFood()或Breed()等方法,我们可以创建一个派生类Cow; Cow支持所有这些方法,也有自己的方法,如Moo()和SupplyMilk()。还可以创建另一个派生类Chicken,该类有Cluck()和LayEgg()方法。
在UML中,用箭头表示继承,如图8-7所示。
图8-7
注意:为简洁起见,图8-7中省略了成员的返回类型。
在继承一个基类时,成员的可访问性就成了一个重要问题。派生类不能访问基类的私有成员,但可以访问其公共成员。不过,派生类和外部的代码都可以访问公共成员。这就是说,只使用这两个级别的可访问性,不能让一个成员可由基类和派生类访问,而不能由外部的代码访问。
为解决这个问题,C#提供了第三种可访问性:protected,只有派生类才能访问protected成员。对于外部代码来说,这个可访问性与私有成员一样:外部代码不能访问private成员和protected成员。
除了定义成员的保护级别外,我们还可以为成员定义其继承行为。基类的成员可以是虚拟的,也就是说,成员可以由继承它的类重写。派生类可以提供成员的另一种实现代码。这种实现代码不会删除原来的代码,仍可以在类中访问原来的代码,但外部代码不能访问它们。如果没有提供其他实现方式,通过派生类使用成员的外部代码就自动访问基类中成员的实现代码。
注意:虚拟成员不能是私有成员,因为这样会自相矛盾——不能既要求派生类重写成员,又不让派生类访问该成员。
在前面的家畜示例中,可以把EatFood()变成虚拟成员,在派生类中为它提供新的实现代码,例如为Cow类提供新的实现代码,如图8-8所示。这里显示了Animal和Cow类的EatFood()方法,说明它们有自己的实现代码。
图8-8
基类还可以定义为抽象类。抽象类不能直接实例化。要使用抽象类,必须继承这个类,抽象类可以有抽象成员,这些成员在基类中没有实现代码,所以派生类必须实现它们。如果Animal是一个抽象类,UML就会如图8-9所示。
图8-9
注意:抽象类名以斜体显示(有时它们的方框有一个短横线)。
在图8-9中,EatFood()和Breed()都显示在派生类Chicken和Cow中,这说明这些方法是抽象的(必须在派生类中重写)或者虚拟的(这里已经在Chicken和Cow中重写)。当然,抽象基类可以提供成员的实现代码,这是十分常见的。不能实例化抽象类,并不意味着不能在抽象类中封装功能。
最后,类可以是密封(seal)的。密封的类不能用作基类,所以没有派生类。
在C#中,所有对象都有一个共同的基类object(在.NET Framework中,它是System.Object类的别名)。第9章将详细介绍这个类。
注意:如本章前面所述,接口也可以继承自其他接口。与类不同的是,接口可以继承多个基接口(与类可以支持多个接口的方式类似)。
8.2.3 多态性
继承的一个结果是派生于基类的类在方法和属性上有一定的重叠,因此,可以使用相同的语法处理从同一个基类实例化的对象。例如,如果基类Animal有一个EatFood()方法,则在其派生类Cow和Chicken中调用这个方法的语法是类似的:
Cow myCow = new Cow(); Chicken myChicken = new Chicken(); myCow.EatFood(); myChicken.EatFood();
多态性则更推进了一步。可以把某个派生类型的变量赋给基本类型的变量,例如:
Animal myAnimal = myCow;
不需要进行强制类型转换,就可以通过这个变量调用基类的方法:
myAnimal.EatFood();
结果是调用派生类中的EatFood()的实现代码。注意,不能以相同的方式调用派生类上定义的方法。下面的代码无法运行:
myAnimal.Moo();
但可以把基本类型的变量转换为派生类变量,调用派生类的方法,如下所示:
Cow myNewCow = (Cow)myAnimal; myNewCow.Moo();
如果原始变量的类型不是Cow或派生于Cow的类型,这个强制类型转换就会引发一个异常。有许多方式说明对象的类型是什么,详见下一章。
在派生于同一个类的不同对象上执行任务时,多态性是一种极有效的技巧,其使用的代码最少。注意并不是只有共享同一个父类的类才能利用多态性。只要子类和孙子类在继承层次结构中有一个相同的类,它们就可以用同样的方式利用多态性。
还要注意,在C#中,所有类都派生于同一个类object, object是继承层次结构中的根。所以可以把所有对象看成object类的实例。这就是在建立字符串时,WriteLine()可以处理无数多种参数组合的原因。第一个参数后面的每个参数都可以看成一个object实例,所以可以把任何对象的输出结果写到屏幕上。为此,需要调用方法ToString()(object的一个成员)。我们可以重写这个方法,为自己的类提供合适的实现代码,或者使用默认实现代码,返回类名(根据它所在的名称空间,返回类的限定名称)。
接口的多态性
尽管不能像对象那样实例化接口,但可以建立接口类型的变量,然后就可以在支持该接口的对象上,使用这个变量来访问该接口提供的方法和属性。
例如,假定不使用基类Animal提供的EatFood()方法,而是把该方法放在IConsume接口上。Cow和Chicken类也支持这个接口,唯一的区别是它们必须提供EatFood()方法的实现代码(因为接口不包含实现代码),接着就可以使用下述代码访问该方法了:
Cow myCow = new Cow(); Chicken myChicken = new Chicken(); IConsume consumeInterface; consumeInterface = myCow; consumeInterface.EatFood(); consumeInterface = myChicken; consumeInterface.EatFood();
这就提供了以相同方式访问多个对象的简单方式,且不依赖于一个公共的基类。例如,这个接口可以由派生于Vegetable而不是Animal的VenusFlyTrap类实现:
VenusFlyTrap myVenusFlyTrap = new VenusFlyTrap(); IConsume consumeInterface; consumeInterface = myVenusFlyTrap; consumeInterface.EatFood();
在这段代码中,调用consumeInterface.EatFood()的结果是调用Cow、Chicken或VenusFlyTrap类的EatFood()方法,这取决于把哪个实例赋予接口类型的变量。
注意,派生类会继承其基类支持的接口。在上面的第一个示例中,要么是Animal支持IConsume,要么是Cow和Chicken支持IConsume。有共同基类的类不一定有共同接口,反之亦然。
8.2.4 对象之间的关系
继承是对象之间的一种简单关系,可以让派生类完整地获得基类的特性,而且派生类也可以访问基类内部的一些工作代码(通过受保护的成员)。对象之间还具有其他一些重要关系。
本节简要讨论下述关系:
● 包含关系:一个类包含另一个类。这类似于继承关系,但包含类可以控制对被包含类的成员的访问,甚至在使用被包含类的成员前进行其他处理。
● 集合关系:一个类用作另一个类的多个实例的容器。这类似于对象数组,但集合具有其他功能,包括索引、排序和重新设置大小等。
1.包含关系
用一个成员字段包含对象实例,就可以实现包含(containment)关系。这个成员字段可以是公共字段,此时与继承关系一样,容器对象的用户就可以访问它的方法和属性,但不能像继承关系那样,通过派生类访问类的内部代码。
另外,可以让被包含的成员对象变成私有成员。如果这么做,用户就不能直接访问任何成员,即使这些成员是公共的。但可以使用包含类的成员访问这些私有成员。也就是说,可以完全控制被包含的类对外提供什么成员(或者不提供任何成员),还可以在访问被包含类的成员前,在包含类的成员上执行其他处理。
例如,Cow类包含一个Udder类,Udder类有一个公共方法Milk()。Cow对象可以按照要求调用这个方法,作为其SupplyMilk()方法的一部分,但Cow对象的用户看不到这些细节,或者这些细节对Cow对象的用户并不重要。
在UML中,被包含类可以用关联线条来表示。对于简单包含关系,可以用带有1的线条说明一对一的关系(一个Cow实例包含一个Udder实例)。为清晰起见,也可以把被包含的Udder类实例表示为Cow类的私有字段,如图8-10所示。
2.集合关系
图8-10
第5章讨论了如何使用数组存储多个同类型变量,这也适用于对象(前面使用的变量类型实际上是对象)。例如:
Animal[] animals = new Animal[5];
集合基本上就是一个增加了功能的数组。集合以与其他对象相同的方式实现为类。它们通常以所存储的对象名称的复数形式来命名,例如用类Animals包含Animal对象的一个集合。
数组与集合的主要区别是,集合通常实现额外的功能,例如Add()和Remove()方法可添加和删除集合中的项。而且集合通常有一个Item属性,它根据对象的索引返回该对象。通常,这个属性还允许实现更复杂的访问方式。例如,可以设计一个Animals,让Animal对象根据其名称来访问。
其UML表示如图8-11所示。图8-11中没有包含成员,因为这里描述的是关系。连接线末尾的数字表示一个Animals对象可以包含0个或多个Animal对象。第11章将详细论述集合。
图 8-11
8.2.5 运算符重载
本书前面介绍了如何使用运算符处理简单的变量类型。有时也可以把运算符用于从类实例化而来的对象,因为类可以包含如何处理运算符的指令。
例如,给Animal添加一个新属性Weight。接着使用下述代码比较家畜的体重:
if (cowA.Weight > cowB.Weight)
{
...
}
使用运算符重载,可在代码中提供隐式使用Weight属性的逻辑,如下面的代码所示:
if (cowA > cowB) { ... }
大于运算符>被重载了。我们为重载运算符编写代码,执行上述操作,这段代码用作类定义的一部分,而该运算符作用于这个类。在上面的示例中,使用了两个Cow对象,所以运算符重载定义包含在Cow类中。也可以采用相同的方式重载运算符,使其处理不同的类,其中一个(或两个)类定义包含达到这一目的的代码。
注意,只能采用这种方式重载现有的C#运算符,不能创建新的运算符。但可以为一元和二元运算符(如+或>)提供实现代码。详见第13章。
8.2.6 事件
对象可以激活和使用事件,作为它们处理的一部分。事件是非常重要的,可以在代码的其他部分起作用,类似于异常(但功能更强大)。例如,可以在把Animal对象添加到Animals集合中时,执行特定的代码,而这部分代码不是Animals类的一部分,也不是调用Add()方法的代码的一部分。为此,需要给代码添加事件处理程序,这是一种特殊类型的函数,在事件发生时调用。还需要配置这个处理程序,以监听自己感兴趣的事件。
使用事件可以创建事件驱动的应用程序,此类应用程序比读者此时所能想到的多得多。例如,许多Windows应用程序完全依赖于事件。每个按钮单击或滚动条拖动操作都是通过事件处理实现的,其中事件是通过鼠标或键盘触发的。
本章后面将介绍Windows应用程序中事件的工作原理,第13章将深入讨论事件。
8.2.7 引用类型和值类型
在C#中,数据根据变量的类型以两种方式中的一种存储在一个变量中。变量的类型分为两种:引用类型和值类型,其区别如下:
● 值类型在内存的同一处存储它们自己和它们的内容。
● 引用类型存储指向内存中其他某个位置(称为堆)的引用,实际内容存储在这个位置。
实际上,在使用C#时,不必过多地考虑这个问题。到目前为止,所使用的string变量(这是引用类型)与使用其他简单变量(大多数是值类型,例如int)的方式完全相同。
值类型和引用类型的一个主要区别是:值类型总是包含一个值,而引用类型可以是null,表示它们不包含值。但是,可使用可空类型创建值类型,使值类型在这个方面的行为方式类似于引用类型(即可以为null)。第12章在介绍泛型(包括可空类型)这一高级主题时将讨论这方面的内容。
只有string和object类型是简单的引用类型。数组也是隐式的引用类型。我们创建的每个类都是引用类型,这就是在这里说明这一点的原因。