第3章 C#面向对象程序设计
第二章介绍了C#的语法和基础知识。据此我们已经可以写出一些控制台应用程序了。但是,要了解C#语言的强大功能,还需要使用面向对象编程(Object-Oriented Programming,OOP)技术。实际上,前面的例子已经在使用这些技术,但没有重点讲述。
本章先探讨OOP的原理,包括OOP的基础知识、与OOP相关的术语。接着学习如何在C#中定义类,包括基本的类定义语法、用于确定类可访问性的关键字以及接口的定义。然后讨论如何定义类成员,包括如何定义字段、数学和方法等成员。最后说明一些高级技术,包括集合、运算符重载、高级转换、深度复制和定制异常。
3.1面向对象编程简介
3.1.1 什么是面向对象编程
面向对象编程代表了一种全新的程序设计思路,与传统的面向过程开发方法不同,面向对象的程序设计和问题求解更符合人们的思维习惯。
前面介绍的编程方法都是面向过程的程序设计方法,这种方法常常会导致所谓的单一应用程序,即所有的功能都包含在几个代码模块中(常常是一个代码模块),适合解决比较小的简单问题。而OOP技术则按照现实世界的特点来管理复杂的事物,把它们抽象为对象,具有自己的状态和行为,通过对消息的反应来完成一定的任务。这种编程方法提供了非常强大的多样性,大大增加了代码的重用机会,增加了程序开发的速度;同时降低了维护负担,将具备独立性特制的程序代码包装起来,修改部分程序代码时不至于会影响到程序的其他部分。
1. 对象
什么是对象?实际上,现实世界就是由各种对象组成的,如人、汽车、动物、植物等。复杂的对象可以由简单的对象组成。对象都具有各自的属性,如形状、颜色、重量等;对外界都呈现出各自的行为,如人可以走路、说话、唱歌;汽车可以启动、加速、减速、刹车、停止等。
在OOP中,对象就是变量和相关的方法的集合。其中变量表明对象的属性,方法表明对象所具有的行为。一个对象的变量构成了这个对象的核心,包围在它外面的方法使这个对象和其他对象分离开来。例如:我们可以把汽车抽象为一个对象,用变量来表示它当前的状态,如速度、油量、型号、所处的位置等,它的行为则为上面提到的加速、刹车、换档等。操作汽车时。不用去考虑汽车内部各个零件如何运作的细节,而只需根据汽车可能的行为使用相应的方法即可。实际上,面向对象的程序设计实现了对象的封装,使我们不必关心对象的行为是如何实现的这样一些细节。通过对对象的封装,实现了模块化和信息隐藏。有利于程序的可移植性和安全性,同时也利于对复杂对象的管理。
简单地说,对象非常类似于本书前面讨论的结构类型。略为复杂的对象可能不包含任何数据,而是只包含函数,表示一个过程。
2.类
在研究对象时主要考虑对象的属性和行为,有些不同的对象会呈现相同或相似的属性和行为,如轿车、卡车、面包车。通常将属性及行为相同或相似对象归为一类。类可以看成是对象的抽象,代表了此类对象所具有的共同属性和行为。典型的类是“人类”,表明人的共同性质。比如我们可以定义一个汽车类来描述所有汽车的共性。通过类定义人们可以实现代码的复用。我们不用去描述每一个对象(如某辆汽车),而是通过创建类(如汽车类)的一个实例来创建该类的一个对象,这样大大碱化了软件的设计。
类是对一组具有相同特征的对象的抽象描述,所有这些对象都是这个类的实例。在C#中,类是一种数据类型,而对象是该类型的变量,变量名即是某个具体对象的标示名。
3.属性和字段
通过属性和字段可以访问对象中包含的数据。对象数据可以区分不同的对象,因为同一个类的不同对象可能在属性和字段中存储了不同的值。包含在对象中的不间数据统称为对象的状态。
假定一个对象类表示一杯咖啡,叫做CupOfCoffee。在实例化这个类(即创建这个类的对象)时,必须提供对于类有意义的状态。此时可以使用属性和字段,让代码能通过该对象来设置要使用的咖啡品牌,咖啡中是否加牛奶或方糖,咖啡是否即溶等。给定的咖啡对象就有一个指定的状态,例如“Columbian filter coffee with milk and two sugars”。
可以把信息存储在字段和属性中,作为string变量、int变量等。但是,属性与字段是不同的,属性不能直接访问数据。一般情况下,在访问状态时最好提供属性,而不是字段,因为这样可以更好地控制整个过程,而使用它们的语法是相同的。
对属性的读写访问也可以由对象来明确定义。某些属性是只读的,只能查看它们的值,而不能改变仑们(至少不能直接改变)。还可以有只写的属性,其操作方式类似。
除了对属性的读写访问外,还可以为字段和属性指定另—种访问许可,这种可访问性确定了什么代码可以访问这些成员,它们是可用于所有的代码(公共),还是只能用于类中的代码(私有),或者更复杂的模式。常见的情况是把字段设置为私有,通过公共属性访问它们。
例如,CupOfCoffee类,可以定义5个成员:Type、isInstant、Milk、Sugar、Description等。
4.方法
对象的所有行为都可以用方法来描述,在C#中,方法就是对象中的函数。
方法用于访问对象的功能,与字段和属性—样:方法可以是公共的或私有的,按照需要限制外部代码的访问。它们常常使用对象状态——访问私有成员。例如,CupOfCoffee类定义了一个方法AddSugar()来增加方糖数属性。
实际上,C#中的所有东西都是对象。控制台应用程序中的Main()函数就是类的一个方法。前面介绍的每个变量类型都是一个类。前面使用的每个命令都是一个属性或方法。句点字符“.”把对象实例名和属性或方法名分隔开来。
5.对象的生命周期
每个对象都一个明确定义的生命周期,即从使用类定义开始一直到删除它为止。在对象的生命周期中,除了“正在使用”的正常状态之外,还有两个重要的阶段:
● 构造阶段——对象最初进行实例化的时期。这个初始化过程称为构造阶段,由构造函数完成。
● 析构阶段——在删除一个对象时,常常需要执行一些清理工作,例如释放内存,由析构函数完成。
5.1构造函数
所有的对象都有一个默认的构造成数,该函数没有参数,与类本身有相同的名称。一个类定义可以包含几个构造函数,它们有不同的签名,代码可以使用这些签名实例化对象。带有参数的构造函数通常用于给存储在对象中的数据提供初始值。
在C#中,构造函数用new关键字来调用。例如,可以用下面的方式实例化一个CupOfCoffee对象:
CupOfCoffee myCup = new CupOfCoffee();
对象还可以用非默认的构造函数来创建。与默认的构造函数一样,非默认的构造函数与类同名,但它们还带有参数,例如:
CupOfCoffee myCup = new CupOfCoffee(“Blue Mountain”);
构造函数与字段、属性和方法一样,可以是公共或私有的。在类外部的代码不能使用私有构造函数实例化对象,而必须使用公共构造函数。—些类没有公共的构造函数,外部的代码就不可能实例化它们。
5.2 析构函数
析构函数在用于清理对象。一般情况下,不需要提供解构方法的代码,而是由默认的析构函数执行操作。但是,如果在删除对象实例前,需要完成一些重要的操作,就应提供特定的析构函数。
6.静态成员
属性、方法和字段等成员是对象实例所特有的,即改变一个对象实例的这些成员不影响其他的实例中的这些成员。除此之外,还有一种静态成员(也称为共享成员),例如静态方法、静态属性或静态字段。静态成员可以在类的实例之间共享,所以它们可以看作是类的全局对象。静态属性和静态字段可以访问独立于任何对象实例的数据,静态方法可以执行与对象类型相关、但不是特定实例的命令,在使用静态成员时,甚至不需要实例化类型的对象。例如,前画使用的Console.WriteLine()方法就是静态的。
3.1.2 OOP技术
前面介绍了一些基础知识,下面讨论OOP中的一些技术,包括:抽象与接口、继承、多态性、运算符重载等。
1. 抽象与接口
抽象化是为了要降低程序版本更新后,在维护方面的负担,使得功能的提供者和功能的用户分开,各自独立,彼此不受影响。
为了达到抽象化的目的,需要在功能提供者与功能使用者之间提供一个共同的规范,功能提供者与功能使用者都要按照这个规范来提供、使用这些功能。这个共用的规范就是接口,接口定义了功能数量、函数名称、函数参数、参数顺序等。它是一个能声明属性、字段和方法的编程构造。它不为这些成员实现,只提供定义。接口定义了功能提供者与功能使用者之间的准则,因此只要接口不变,功能提供者就可以任意更改实现的程序代码,而不影响到使用者。
一旦定义了接口,就可以在类中实现它。这样,类就可以支持接口所指定的所有属件和成员。注意,不能实例化接口,执行过程必须在实现接口的类中实现。
在前面的咖啡范例中,可以把较一般用途的属性和方法例如AddSugar(),Milk,Sugar和
Instant组合到一个接口中,称为IhotDrink(接口的名称一般用大写字母I开头)。然后就可以在其他对象上使用该接口,例如CupOfTea类。
一个类可以支持多个接口,多个类也可以支持相同的接口。
2.继承
继承是OOP最重要的特性之—。任何类都可以从另—个类继承,这就是说,这个类拥有它继承的类的所有成员。在00P中,被继承(也称为派生)的类称为父类(也称为基类)。注意C#中的对象仅能派生于一个基类。
公共汽车、出租车、货车等都是汽车,但它们是不同的汽车,除了具有汽车的共性外,它们还具有自己的特点,如不同的操作方法,不同的用途等。这时我们可以把它们作为汽车的子类来实现,它们继承父类(汽车)的所有状态和行为,同时增加自己的状态和行为。通过父类和子类,我们实现了类的层次,可以从最一般的类开始,逐步特殊化,定义一系列的子类。同时,通过继承也实现了代码的复用,使程序的复杂性线性地增长,而不是呈几何级数增长。
在继承一个基类时,成员的可访问性就成为一个重要的问题。派生类不能访问基类的私有成员,但可以访问其公共成员。不过,派生类和外部的代码都可以访问公共成员。这就是说,只使用这两个可访问性,不仅可以让一个成员被基类和派生类访问,而且也能够被外部的代码访问。为了解决这个问题,C#提供了第三种可访问性:protected,只有派生类才能访问protected成员。
除了成员的保护级别外,我们还可以为成员定义其继承行为。基类的成员可以足虚拟的,也就是说,成员可以由继承它的类重写。派生类可以提供成员的其他执行代码。这种执行代码不会删除原来的代码,仍可以在类中访问原来的代码,但外部代码不能访问它们。如果没有提供其他执行方式,外部代码就访问基类中成员的执行代码。虚拟成员不能是私有成员。
基类还可以定义为抽象类。抽象类不能直接实例化。要使用抽象类,必须继承这个类,抽象类可以有抽象成员,这些成员在基类中没有代码实现,所以这些执行代码必须在派生类中提供。
最后,类可以是密封的。密封的类不能用作基类,所以也没有派生类。
在C#中,所有的对象都有—个共同的基类object,我们在第二章中曾提到过。
3.多态性
多态是面向对象程序设计的又一个特性。在面向过程的程序设计中,主要工作是编写一个个的过程或函数,这些过程和函数不能重名。例如在一个应用中,需要对数值型数据进行排序,还需要对字符型数据进行排序,虽然使用的排序方法相同,但要定义两个不同的过程(过程的名称也不同)来实现。
在面向对象程序设计中,可以利用“重名”来提高程序的抽象度和简洁性。首先我们来理解实际的现象,例如,“启动”是所有交通工具都具有的操作,但是不同的具体交通工具,其“启动”操作的具体实现是不同的,如汽车的启动是“发动机点火——启动引擎”、“启动”轮船时要“起锚”、气球飞艇的“启动”是“充气——解缆”。如果不允许这些功能使用相同的名字,就必须分别定义“汽车启动”、“轮船启动”、“气球飞艇启动”多个方法。这样一来,用户在使用时需要记忆很多名字,继承的优势就荡然无存了。为了解决这个问题,在面向对象的程序设计中引入了多态的机制。
多态是指一个程序中同名的不同方法共存的情况。主要通过子类对父类方法的覆盖来实现多态。这样一来,不同类的对象可以响应同名的方法来完成特定的功能,但其具体的实现方法却可以不同。例如同样的加法,把两个时间加在一起和把两个整数加在一起肯定完全不同。
通过方法覆盖,子类可以重新实现父类的某些方法,使其具有自己的特征。例如对于车类的加速方法,其子类(如赛车)中可能增加了一些新的部件来改善提高加速性能,这时可以在赛车类中覆盖父类的加速方法。覆盖隐藏了父类的方法,使子类拥有自己的具体实现,更进一步表明了与父类相比,子类所具有的特殊性。
多态性使语言具有灵活、抽象、行为共享的优势,很好地解决了应用程序函数同名问题。
注意并不是只有共享同一个父类的类才能利用多态性。只要子类和孙子类在继承层次结构
中有一个相同的类,它们就可以用相同的方式利用多态性。
4.重载
方法重载是实现多态的另一个方法。通过方法重载,一个类中可以有多个具有相同名字的方法,由传递给它们的不同个数的参数来决定使用哪种方法。例如,对于一个作图的类,它有一个draw()方法用来画图或输出文字,我们可以传递给它一个字符串、一个矩形、一个圆形,甚至还可以再制定作图的初始位置、图形的颜色等。对于每一种实现,只需实现一个新的draw()方法即可,而不需要新起一个名字,这样大大简化了方法的实现和调用,程序员和用户不需要记住很多的方法名,只需要传入相应的参数即可。
因为类可以包含运算符如何运算的指令,所以可以把运算符用于从类实例化而来的对象。 我们为重载运算符编写代码,把它们用作类定义的一部分,而该运算符作用于这个类。也可以重载运算符,以相同的方式处理不同的类,其中一个(或两个)类定义包含达到这一目的的代码。
注意只能用这种方式重载现有的C#运算符,不能创建新的运算符。
5.消息和事件
对象之间必须要进行交互来实现复杂的行为。例如,要汽车加速,必须发给它一个消息,告诉它进行何种动作(这里是加速)以及实现这种动作所需要的参数(这里是需要达到的速度等)。一个消息包含三个方面的内容:消息的接收者、接收对象应采用的方法、方法所需要的参数。同时,接收消息的对象在执行相应的方法后,可能会给发送消息的对象返回一些信息。如上例中,汽车的仪表上会出现已达到的速度等。
在C#中,消息处理称为事件。对象可以激活事件,作为它们处理的一部分。为此,需要给代码添加事件处理程序,这是一种特殊类型的函数,在事件发生时调用。还需要配置这个处理程序,以监听我们感兴趣的事件。
使用事件可以创建事件驱动的应用程序,这类应用程序很多。例如,许多基于Windows的应用程序完全依赖于事件。每个按钮单击或滚动条拖动操作都是通过事件处理实现的,其中事件是通过鼠标或键盘触发的。本章的后面将介绍事件是如何工作的。
3.2 定义类
本节将重点讨论如何定义类本身。首先介绍基本的类定义语法、用于确定类可访问性的关键字、指定继承的方式以及接口的定义。
3.2.1 C#中的类定义
3.2.1.1 类的定义
C#使用class关键字来定义类。其基本结构如下:
Class MyClass
{
// class members
}
这段代码定义了一个类MyClass。定义了一个类后,就可以对该类进行实例化。在默认情况下,类声明为内部的,即只有当前代码才能访问,可以用intemal访问修饰符关键字显式指定,如下所示(但这是不必要的):
internal class MyClass
{
// class members
}
另外,还可以制定类是公共的,可以由其它任意代码访问。为此,需要使用关键字public:
public class MyClass
{
// class members
}
除了这两个访问修饰符关键字外,还可以指定类是抽象的(不能实例化,只能继承,可以有抽象成员)或密封的(sesled,不能继承)。为此,可以使用两个互斥的关键字abstract或sealed。所以,抽象类必须用下述方式声明:
public abstract class MyClass
{
// class members, may be abstract
}
密封类的声明如下所示:
public sealed class MyClass
{
//class members
}
还可以在类定义中指定继承。C#支持类的单一继承,即只能有一个基类,语法如下:
class MyClass : MyBaseClass
{
// class members
}
在C#的类定义中,如果继承了一个抽象类,就必须执行所继承的所有抽象成员(除非派生类也是抽象的)。
编译器不允许派生类的可访问性比其基类更高。也就是说,内部类可以继承于一个公共类,但公共类不能继承于一个内部类。因此,下述代码就是不合法的:
internal class MyBaseClass
{
// class members
}
public class MyClass : MyBaseClass
{
// class members
}
在C#中,类必须派生于另一个类。如果没有指定基类,则被定义的类就继承于基类System.Object。
除了以这种方式指定基类外,还可以指定支持的接口。如果指定了基类,它必须紧跟在冒号的后面,之后才是指定的接口。必须使用逗号分隔基类名(如果有基类)和接口名。
例如,给MyClass添加一接口,如下所示:
class MyClass : IMyInterface
{
// class memebrs
}
所有的接口成员都必须在支持该接口的类中实现,但如果不想使用给定的接口成员,可以提供一个“空”的执行方式(没有函数代码)。
下面的声明是无效的,因为基类MyBaseClass不是继承列表中的第一项:
class MyClass : IMyInterface, MyBaseClass
{
// class members
}
指定基类和接口的正确方式如下:
class : MyBaseClass, ImyInterface
{
// class members
}
可以指定多个接口,所以下面的代码是有效的:
public class MyClass : MyBaseClass, ImyInterface, ImySecondInterface
{
// class members
}
表3.1是类定义个可以使用的访问修饰符组合。
表3.1 访问修饰符
修饰符 | 含义 |
none或internal | 类只能在当前程序中被访问 |
public | 类可以在任何地方访问 |
abstract或internal abstract | 类只能在当前程序中被访问,不能实例化,只能继承 |
public abstract | 类可以在任何地方访问,不能实例化,只能继承 |
sealed或internal sealed | 类只能在当前程序中被访问,不能派生,只能实例化 |
public sealed | 类可以在任何地方访问,不能派生,只能实例化 |
3.2.1.2接口的定义
接口声明的方式与声明类的方式相似,但使用的是关键字interface,例如:
interface ImyInterface
{
// interface members
}
访问修饰符关键字public和internal的使用方式是相同的,所以要使接口的访问是公共的,就必须使用public关键字:
public interface ImyInterface
{
// interface members
}
关键字abstract和sealed不能在接口中使用,因为这两个修饰符在接口定义中是没有意义的(接口不包含执行代码,所以不能直接实例化,且必须是可以继承的)。
接口的继承也可以用与类继承的类似方式来指定。主要的区别是可以使用多个基接口,例如:
public interface IMyInterface : IMyBaseInterface, ImyBaseInterface2
{
// interface members
}
下面看一个类定义的范例。
【例3-1】
using System;
public abstract class MyBaseClass
{
}
class MyClass:MyBaseClass
{
}
public interface IMyBaseInterface
{
}
interface IMyBaseInterface2
{
}
interface ImyInterface : IMyBaseInterface, IMyBaseInterface2
{
}
sealed class MyComplexClass:MyClass,IMyInterface
{
}
class Class1
{
static void Main(string[] args)
{
MyComplexClass myObj = new MyComplexClass();
Console.WriteLine(myObj.ToString());
}
}
这里的Clsss1不是主要类层次结构中的一部分,而是处理Main()方法的应用程序的入口点。MyBaseClass和IMyBaseInterface被定义为公共的,其他类和接口都是内部的。其中MyComplexClass继承MyClass和IMyInterface,MyClass继承MyBassClass,IMyInterface继承IMyBaseInterface和IMyInterface2,而MyBaseClass和IMyBaseInterface、IMyBaseInterface2的共同的基类为object。Main()中的代码调用MyComplexClass的一个实例myObj的ToString()方法。这是继承System.ObJect的一种方法,功能是把对象的类名作为一个字符串返回,该类名用所有相关的命名空间来限定。
3.2.2 Object类
前面提到所有的.NET类都派生于System.Object。实际上,如果在定义类时没有指定基类,编译器就会自动假定这个类派生于object。其重要性在于,自己定义的所有类除了自己定义的方法和属性外,还可以访问为Object定义的许多公共或受保护的成员方法。在object中定义的方法如表3.2所示。
表3.2 object中的方法
方 法 | 访问修饰符 | 作 用 |
string ToString() | public virtual | 返回对象的字符串表示。在默认情况下,这是一个类类型的限定名,但它可以被重写,以便给类类型提供合适的实现方式 |
int GetHashTable() | public virtual | 在实现散列表时使用 |
bool Equals(object obj) | public virtual | 把调用该方法的对象与另一个对象相比较,如果它们相等,就返回true。以默认的执行方式进行检查,以查看对象的参数是否引用了同一对象。如果想以不同的方式来比较对象,可以重写该方法。 |
bool Equals(object objA, object objB) | public static | 这个方法比较传递给它的两个对象是否相等。如果两个对象都是空引用,这个方法会返回true。 |
bool ReferenceEquals(object objA, object objB) | public static | 比较两个引用是否指向同一个对象 |
Type GetType() | public | 返回对象类型的详细信息 |
object MemberwiseClone() | protected | 通过创建一个新对象实例并复制成员,来复制该对象。成员复制不会得到这些成员的新实例。新对象的任何引用类型成员都将引用与源类相同的对象,这个方法是受保护的,所以只能在类或派生的类中使用。 |
这些方法是.NET Framework中对象类型必须支持的基本方法,但我们可以从不使用它们。下面将简要几个方法的作用。
GetType()方法:这个方法返回从System.Type派生的类的一个实例。在利用多态性时,GetType()是一个有用的方法,它允许根据对象的类型来执行不同的操作。联合使用GetType()和typeof(),就可以进行比较,如下所示:
if (myObj.GetType() == typeof(MyComplexClass))
{
// myObj is an instance of the class MyComplexClass
}
ToString()方法:是获取对象的字符串表示的一种便捷方式。当只需要快速获取对象的内容,以用于调试时就可以使用这个方法。在数据的格式化方面,它提供的选择非常少:例如,日期在原则上可以表示为许多不同的格式,但DateTime.ToString()没有在这方面提供任何选择。例如:
int i = -50;
string str = i.ToString(); // returns "–50"
下面是另一个例子:
enum Colors {Red, Orange, Yellow};
// later on in code...
Colors favoriteColor = Colors.Orange;
string str = favoriteColor.ToString(); // returns "Orange"
Object.ToString()声明为虚类型,在这些例子中,该方法的实现代码都是为C#预定义数据类型重写过的代码,以返回这些类型的正确字符串表示。Colors枚举是一个预定义的数据类型,它实际上实现为一个派生于System.Enum的结构,而System.Enum有一个ToString()重写方法,来处理用户定义的所有枚举。
如果不在自己定义的类中重写ToString(),该类将只继承System.Object执行方式——显示类的名称。如果希望ToString()返回一个字符串,其中包含类中对象的值信息,就需要重写它。下面用一个例子Money来说明这一点。在该例子中,定义一个非常简单的类Money,表示钱数。Money是decimal类的包装器,提供了一个ToString()方法(这个方法必须声明为override,因为它将重写Object提供的ToString()方法)。该例子的完整代码如下所示:
【例3-2】
using System;
class MainEntryPoint
{
static void Main(string[] args)
{
Money cash1 = new Money();
cash1.Amount = 40M;
Console.WriteLine("cash1.ToString() returns: " + cash1.ToString());
}
}
class Money
{
private decimal amount;
public decimal Amount
{
get
{
return amount;
}
set
{
amount = value;
}
}
public override string ToString()
{
return "$" + Amount.ToString();
}
}
在Main()方法中,先实例化一个Money对象,在这个实例化过程中调用了ToString(),选择了我们自己的重写方法。运行这段代码,会得到如下结果:
StringRepresentations
cash1.ToString() returns: $40
3.2.3 构造函数和析构函数
在C#中定义类时,常常不需要定义相关的构造函数和析构函数,因为基类System.Object提供了一个默认的实现方式。但是,如果需要,也可以提供我们自己的构造函数和析构函数,以便初始化对象和清理对象。
1.构造函数
使用下述语法把简单的构造函数添加到一个类中:
class MyClass
{
public MyClass()
{
// Constructor code
}
// rest of class definition
}
这个构造函数与包含它的类同名,且没有参数,这是一个公共函数,所以用来实例化类的对象。
也可以使用私有的默认构造函数,即这个类的对象实例不能用这个构造函数来创建。例如:
class MyClass
{
private MyClass()
{
//Constructor code
}
// rest of class definition
}
构造函数也可以重载,即可以为构造函数提供任意多的重载,只要它们的签名有明显的区别,例如:
class MyClass
{
public MyClass()
{
//Default contructor code
}
public MyClass(int number)
{
//Non-default contructot code
}
//rest of class definition
}
如果提供了带参数的构造函数,编译器就不会自动提供默认的构造函数,下面的例子中,因为明确定义了一个带一个参数的构造函数,所以编译器会假定这是可以使用的唯一构造函数,不会隐式地提供其他构造函数:
public class MyNumber
{
public MyNumber(int number)
{
// Contructor code
}
// rest of class definition
}
2.构造函数的执行序列
在讨论构造函数前,先看看在默认情况下,创建类的实例时会发生什么情况。
为了实例化派生的类,必须实例化它的基类。而要实例化这个基类,又必须实例化这个基类的基类,这样一直到实例化System.Object为止。结果是无论使用什么构造函数实例化一个类,总是要先调用System.ObJect.Object()。
如果对一个类使用非默认的构造函数,默认的情况是在其基类上使用匹配十这个构造函数签名的构造函数。如果没有找到这样的构造函数,就使用基类的默认构造函数。下面介绍一个例子,说明事件的发生顺序。代码如下:
public class MyBaseClass
{
public MyBaseClass()
{
}
public MyBaseClass(int i)
{
}
}
public class MyDerivedClass : MyBaseClass
{
public MyDerivedClass()
{
}
public MyDerivedClass(int i)
{
}
public MyDerivedClass(int i, int j)
{
}
}
如果以下面的方式实例化MyDerivedClass:
MyDrivedClass myObj = new MyDerivedClass();
则发生下面的一系列事件:
● 执行System.Object.Object()构造函数。
● 执行MyBaseClass. MyBaseClass()构造函数。
● 执行MyDrivedClass. MyDerivedClass()构造函数。
另外,如果使用下面的语句:
MyDrivedClass myObj = new MyDrivedClass(4);
则发生下面的一系列事件:
● 执行System.Object.Object()构造函数。
● 执行MyBaseClass. MyBaseClass(int i)构造函数。
● 执行MyDrivedClass. MyDerivedClass(int i)构造的数。
最后,如果使用下面的语句;
MyDeivedClass myObj = new MyDerivcdClass(4, 8);
则发生下面的一系列事件:
● 执行System. Object. Object()构造函数。
● 执行MyBaseClass. MyBaseClass()构造函数。
● 执行MyDerivedClass. MyDerivedClass(int i,tnt j)构造函数。
有时需要对发生的事件进行更多的控制。例如,在上面的实例化例子中,需要有下面的事件序列:
● 执行System.Object. Object()构造函数。
● 执行MyBaseClass. MyBaseClass(int i)构造函数。
● 执行MyDerivedClass. MyDerivedClass(int i, int j)构造函数。
使用这个序列可以编写在MyBaseClass(int i)中使用int i参数的代码,即MyDerivedClass(int i, int j))构造函数要做的工作比较少,只需要处理int j参数(假定int i参数在两种情况下有相同的含义)。为此,只需指定在派生类的构造函数定义中所使用的基类的构造函数即可,如下所示:
public class MyDerivedClass : MyBaseClass
{
…
public MyDerivedClass(int i, int j) : base(i)
{
}
}
其中,base关键字指定.NET实例化过程,以使用基类中匹配指定签名的构造函数。这里使用了一个int i参数,所以应使用MyBaseClass(int i)。这么做将不调用MyBaseClass(),而是执行本例前面列出的事件序列。
也可以使用这个关键字指定基类构造函数的字面值,例如使用MyDerivedClass的默认构造函数调用MyBaseClass非默认的构造函数:
public class MyDerivedClass : MyBaseClass
{
public MyDerivedClass() : base(5)
{
}
…
}
这段代码将执行下述序列:
● 执行System. Object. Object()构造函数。
● 执行MyBaseClass. MyBaseClass(int i)构造函数。
● 执行MyDerivedClass. MyDerivedClass()构造函数。
除了base关键字外,这里还可以使用另一个关键字this。这个关键字指定在调用指定的构造函数前,.NET实例化过程对当前类使用非默认的构造函数。例如:
public class MyDerivedClass : MyBaseClass
{
public MyDerivedClass() : this(5, 6)
{
}
…
public MyDerivedClass(int i, int j) : base(i)
{
}
}
这段代码将执行下述序列:
● 执行System. Object. Object()构造函数。
● 执行MyBaseClass. MyBaseClass(int i)构造函数。
● 执行MyDerivedClass. MyDerivedClass(int i, int j)构造函数。
● 执行MyDerivedClass. MyDerivedClass()构造的数。
惟一的限制是使用this或base关键字只能指定一个构造函数。
3.析构函数
析构函数使用略微不同的语法来声明。在.NET中使用的析构函数(由System. Object类提供)叫作Finalize(),但这不是我们用于声明析构函数的名称。使用下面的代码,而不是重写Finalize():
class MyClass
{
~MyClass()
{
//destructor code
}
}
因此类的析构函数是用类名和前缀~来声明的。当进行无用存储单元收集时,就执行析构函数中的代码,释放资源。在调用这个析构函数后,还将隐式地调用基类的析构函数,包括System. Object根类中的Finalize()调用。
3.2.4 接口和抽象类
本章介绍了如何创建接口和抽象类。这两种类型在许多方向都很类似,所以应看看它们的相似和不同之处,看看哪些情况应使用什么技术。
首先讨论它们的类似之处。抽象类和接口都包含可以由派生类继承的成员。接口和抽象类都不能直接实例化,但可以声明它们的变量。如果这样做,就可以使用多态性把继承这两种类型的对象指定给它们的变量。接着通过这些变量来使用这些类型的成员,但不能直接访问派生对象的其他成员。
下面看看它们的区别。派生类只能继承一个基类,即只能直接继承一个抽象类(但可以用一个继承链包含多个抽象类)。相反,类可以使用任意多个接口。但这不会产生太大的区别——这两种情况得到的效果是类似的。只是采用接口的方式略有不同。
抽象类可以拥有抽象成员(没有代码体,旦必须在派生类中执行,否则派生类木身必须也是抽象的)和非抽象成员(它们拥有代码体,也可以是虚拟的,这样就可以在派生类中重写)。另一方面,接口成员必须都在使用接口的类上执行——它们没有代码体。另外,接口成员被定义为公共的(因为它们倾向于在外部使用),但抽象类的成员也可以是私有的(只要它们不是抽象的)、受保护的、内部的或受保护的内部成员(其中受保护的内部成员只能在应用程序的代码或派生类中访问)。此外,接口不能包含字段、构造函数、析构函数、静态成员或常量。
这说明这两种类型用于完全不同的目的。抽象类主要用作对象系列的基类,共享某些主要特性,例如共同的目的和结构。接口则主要由类来使用,其个这些类在基础水平上有所不同,但仍可以完成某些相同的任务。
例如,假定有一个对象系列表示火车,基类Train包含火车的核心定义,例如车轮的规格和引擎的类型(可以是蒸汽发动机、柴油发动机等)。但这个类是抽象的,因为并没有“一般的”火车。为了创建一辆实际的火车,需要给该火车添加特性。为此,派生一些类,例如:Passenger Train,FreightTrain等。
汽车对象系列也可以用相同的方式来定义,使用Car抽象基类,其派生类有Compact,SUV和PickUp。Car和Train可以派生于一个相同的基类Vehicle。
现在,层次结构中的一些类共享相同的特性,这是因为它们的目的是相同的,而不是因为它们派生于相同的基类。例如,PassengerTrain,Compact,SUV和PickUp都可以运送乘客,所以它们都拥有IpassengerCarrier接口,FreightTrain和PickUp可以运送货物,所以它们都拥有IHeavyLoadCarrier接口。
在进行更详细的分工前,把对象系统以这种方式进行分解,可以清楚地看到哪种情形适合使用抽象类,哪种情形适合使用接口。只使用接口或只使用抽象继承,就得不到这个范例的结果。
3.2.5 类和结构
在许多方面,可以把C#中的结构看作是缩小的类。它们基本上与类相同,但更适合于把一些数据组合起来的场合。它们与类的区别在于:
● 结构是值类型,不是引用类型。它们存储在堆栈中或存储为内联(inline)(如果它们是另一个对象的一部分,就会保存在堆中),其生存期的限制与简单的数据类型一样。
● 结构不支持继承。
● 结构的构造函数的工作方式有一些区别。尤其是编译器总是提供一个无参数的默认构造函数,这是不允许替换的。
● 使用结构,可以指定字段如何在内存中布局。
下面将详细说明类和结构之间的区别。
1.结构是值类型
虽然结构是值类型,但在语法上常常可以把它们当作类来处理。例如,在上面的Dimensions类的定义中,可以编写下面的代码:
struct Dimensions
{
public double Length;
public double Width;
}
Dimensions point = new Dimensions();
point.Length = 3;
point.Width = 6;
注意,因为结构是值类型,所以new运算符与类和其他引用类型的工作方式不同。new运算符并不分配堆中的内存,而是调用相应的构造函数,根据传送给它的参数,初始化所有的字段。对于结构,可以编写下述代码:
Dimensions point;
point.Length = 3;
point.Width = 6;
如果Dimensions是一个类,就会产生一个编译错误,因为point包含一个未初始化的引用——不指向任何地方的一个地址,所以不能给其字段设置值。但对于结构,变量声明实际上是为整个结构分配堆栈中的空间,所以就可以赋值了。
结构遵循其他数据类型都遵循的规则:在使用前所有的元素都必须进行初始化。在结构上调用new运算符,或者给所有的字段分别赋值,结构就可以完全初始化了。当然,如果结构定义为类的成员字段,在初始化包含对象时,该结构会自动初始化为0。
结构是值类型,所以会影响性能,但根据使用结构的方式,这种影响可能是正面的,也可能是负面的。正面的影响是为结构分配内存时,速度非常快,因为它们将内联或者保存在堆栈中。在结构超出了作用域被删除时,速度也很快。另一方面,只要把结构作为参数来传递或者把一个结构赋给另一个结构(例如A=B,其中A和B是结构),结构的所有内容就被复制,而对于类,则只复制引用。这样,就会有性能损失,根据结构的大小,性能损失也不同。注意,结构主要用于小的数据结构。但当把结构作为参数传递给方法时,就应把它作为ref参数传递,以避免性能损失——此时只传递了结构在内存中的地址,这样传递速度就与在类中的传递速度一样快了。另一方面,如果这样做,就必须注意被调用的方法可以改变结构的值。
2.结构和继承
不能从一个结构中继承,惟一的例外是结构(和C#中的其他类型一样)派生于类System.Object。因此,结构也可以访问System.Object的方法。在结构中,甚至可以重写System.Object中的方法—— 例如重写ToString()方法。结构的继承链是:每个结构派生于System.ValueType,System.ValueType派生于System.Object。ValueType并没有给Object添加任何新成员,但提供了一些更适合结构的执行代码。注意,不能为结构提供其他基类:每个结构都派生于ValueType。
3.结构的构造函数
为结构定义构造函数的方式与为类定义构造函数的方式相同,但不允许定义无参数的构造函数。例如:
struct Dimensions
{
public double Length;
public double Width;
Dimensions(double length, double width)
{
Length= length;
Width= width;
}
}
前面说过,默认构造函数把所有的字段都初始化为0,且总是隐式地给出,即使提供了其他带参数的构造函数,也是如此。也不能提供字段的初始值,以此绕过默认构造函数。下面的代码会产生编译错误:
struct Dimensions
{
public double Length = 1; // error. Initial values not allowed
public double Width = 2; // error. Initial values not allowed
}
当然,如果Dimensions声明为一个类,这段代码就不会有编译错误。
3.3 定义类成员
本节继续讨论在C#中如何定义类,主要介绍的是如何定义字段、属性和方法等类成员。 首先介绍每种类型需要的代码,然后将讨论—些比较高级的成员技术:隐藏基类成员、调用重写的基类成员。
3.3.1 成员定义
在类定义中,也提供了该类中所有成员的定义,也括字段、方法和属性。所有成员都有自己的访问级别,用下面的关键字之—来定义:
● public——成员可以由任何代码访问。
● private——成员只能由类中的代码访问(如果没有使用任何关键字,就默认使用这个关键字)。
● internal——成员只能由定义它的工程(程序集)内部的代码访问。
● proteded——成员只能由类或派生类中的代码访问。
最后两个关键字可以合并使用,所以也有protected internal成员。它们只能由工程(程序集)中派生类的代码来访问。
字段、方法和属性都可以使用关键字static来声明,这表示它们是用于类的静态成员,而不是对象实例的成员。
1.定义字段
字段用标准的变量声明格式和前面介绍的修饰符来声明(可以进行初始化),例如:
class MyClass
{
public int MyInt;
}
字段也可以使用关键字readonly,表示这个字段只能在执行构造函数的过程中赋值,或由初始化赋值语句赋值。例如:
class MyClass
{
public readonly int MyInt = 17;
}
字段可以使用static关键字声明为静态,例如:
class MyClass
{
public static int MyInt;
}
静态字段可以通过定义它们的类来访问(在上面的例子中,是MyClass.MyInt),而不是通过这个类的对象实例来访问。
另外,可以使用关键字const来创建一个常量。按照定义,const成员也是静态的,所以不需要用static修饰。
2.定义方法
方法使用标准函数格式,以及可访问性和可选的static修饰符来声明。例如:
class MyClass
{
public string GetString()
{
return “Here is a string.”;
}
}
注意,如果使用了static关键字,这个方法就只能通过类来访问,不能通过对象实例来访问。
也可以在方法定义中使用下述关键字:
● virtual——方法可以重写。
● abstract——方法必须重写(只用于抽象类中)。
● override——方法重写了一个基类方法(如果方法被重写,就必须使用该关键字)。
● extern——方法定义放在其他地方。
下面的代码是方法重写的一个例子:
public class MyBaseClass
{
public virtual void DoSomething()
{
//Base implementation
}
}
public class MyDerivedClass : MyBaseClass
{
public override void DoSomething()
{
//Derived class implementation, override base implementation
}
}
如果使用了override,也可以使用sealed指定在派生类中不能对这个方法作进一步的修改,即这个方法不能由派生类重写。例如:
public class MyDerivedClass : MyBaseClass
{
public override sealed void DoSomething()
{
//Derived class implementation, override base implementation
}
}
使用extern可以提供方法在工程外部使用的实现。
3.定义属性
属性定义的方式与字段定义的方式类似,但包含的内容比较多。这是因为它们在修改状态前还至执行额外的操作。属性拥有两个类似函数的块,一个块用于获取属性的值,另一个块用于设置属性的值。
这两个块分别用get和set关键字来定义,可以用于控制对属性的访问级别。可以忽略其中的一个块来创建只读或只写属性(忽略get块创建只写属性,忽略set块创建只读属性)。当然,这仅适用于外部代码,因为类中的代码可以访问这些块能访问的数据。属性至少要包含一个块,才是有效的(既不能读取也不能修改的属性没有任何用处)。
属性的基本结构包括标准访问修改关键字(public,private等)、后跟类名、属性名和get块(或set块,或者get块和set块,其中包含属性处理代码),例如:
public string SomeProperty
{
get
{
return "This is the property value";
}
set
{
// do whatever needs to be done to set the property
}
}
定义代码中的第一行非常类似于定义域的代码。区别是行末没有分号,而是一个包含嵌套get和set块的代码块。
get块不带参数,且必须返回属性声明的类型。简单的属性一般与一个私有字段相关联,以控制对这个字段的访问,此时get块可以直接返回该字段的值,例如:
//Field used by property
private int myInt;
//Property
public int MyIntProp
{
get
{
return myInt;
}
set
{
//Property set code
}
}
注意类外部的代码不能直接访问这个myInt字段,因为其访问级别是私有的。必须使用属性来访问该字段。
也不应为set代码块指定任何显式参数,但编译器假定它带一个参数,其类型也与属性相同,并表示为value。set函数以类似的方式把一个值赋给字段:
//Field used by property
private int myInt;
//Property
public int MyIntProp
{
get
{
return myInt;
}
set
{
myInt = value;
}
}
value等于类型与属性相同的一个值,所以如果字段使用相同的类型,就不必进行数据类型转换了。
这个简单的属性只是直接访问myInt字段。在对操作进行更多的控制时,属性的真正作用才能发挥出来。例如,下面的代码包含一个属性ForeName,它设置了一个字段foreName,该字段有一个长度限制。
private string foreName;
public string ForeName
{
get
{
return foreName;
}
set
{
if (value.Length > 20)
// code here to take error recovery action
// (eg. throw an exception)
else
foreName = value;
}
}
如果赋给属性的字符串长度大于20,就修改foreName。使用了无效的值,该怎么办?有4种选择:
● 什么也个做。
● 给字段赋默认值。
● 继续执行,就好像没有发生错误—样,但记录下该事件以备将来分析。
● 抛出一个异常。
一般情况下,最后两个选择比较好,使用哪个选择取决于如何使用类,以及给类的用户授予多少控制权。抛出异常给用户提供的控制权比较大,可以让他们知道发生了什么情况,并作出合适的响应。关于异常详见下一节。
记录数据,例如记录到文本文件学,对产品代码会比较有效,因为产品代码不应发生错误。它们允许开发人员检查性能,如果需要,还可以调试现有的代码。
属性可以使用virtual,override和abstract关键字,就像方法—样,但这几个关键字不能全部用于字段。
3.3.2 类成员的其他议题
前面讨论了成员定义的基本知识,下面讨论一些比较高级的成员议题,包括:隐藏基类方法和调用重写或隐藏的基类方法。
1.隐藏基类方法
当从基类继承一个(非抽象的)成员时,也就继承了其实现代码。如果继承的成员是虚拟的,就可以用override关键字重写这段执行代码。无论继承的成员是否为虚拟,都可以隐藏这些执
行代码。
使用下面的代码就可以隐藏:
public class MyBaseClass
{
public void DoSomething()
{
//Base implementation
}
}
public class MyDerivedClass : MyBaseClass
{
public void DoSomething()
{
//Derived class implementation, hides base implementation
}
}
尽管这段代码正常运行,但它会产生一个警告,说明隐藏了一个基类成员。如果是偶然地隐藏了一个需要使用的成员,此时就可以改正错误。如果确实要隐藏该成员,就可以使用new关键字显式地说明,这是我们要隐藏的成员:
public class MyDerivedClass : MyBaseClass
{
new public void DoSomething()
{
//Derived class implementation, hides base implementation
}
}
此时应注意隐藏基类成员和重写它们的区别。考虑下面的代码:
public class MyBaseClass
{
public virtual void DoSomething()
{
Console.WriteLine(“Base imp”);
}
}
public class MyDerivedClass : MyBaseClass
{
public override void DoSomething()
{
Console.WriteLine(“Derived imp”);
}
}
其中重写方法将替换基类中的执行代码,这样下面的代码就将使用新版本,即使这是通过基类进行的,情况也是这样:
MyDerivedClass myObj = new MyDerivedClass();
MyBaseClass myBaseObj;
myBaseObj = myObj;
myBaseObj.DoSomething();
结果如下:
Derivd imp
另外,还可以使用下面的代码隐藏基类方法:
public class MyBaseClass
{
public virtual void DoSomething()
{
Console.WriteLine(“Base imp”);
}
}
public class MyDerivedClass : MyBaseClass
{
new public void DoSomething()
{
Console.WriteLine(“Derived imp”);
}
}
基类方法不必是虚拟的,但结果是一样的,对于基类的虚拟方法和非虚拟方法来说,其结果如下:
Base imp
尽管隐藏了基类的执行代码,但仍可以通过基类访问它。
2.调用重写或隐藏的基类方法
无论是重写成员还是隐藏成员,都可以在类的内部访问基类成员。这在许多情况下都是很有用的,例如:
● 要对派生类的用户隐藏继承的公共成员,但仍能在类中访问其功能。
● 要给继承的虚拟成员添加执行代码,而不是简单地用新的重写的执行代码替换它。
为此,可以使用base关键字,它表示包含在派生类中的基类的执行代码(在控制构造函数时,其用法是类似的,见上节),例如:
class CustomerAccount
{
public virtual decimal CalculatePrice()
{
// implementation
return 0.0M;
}
}
class GoldAccount : CustomerAccount
{
public override decimal CalculatePrice()
{
return base.CalculatePrice() * 0.9M;
}
}
base使用对象实例,所以在静态成员中使用它会产生错误。
除了使用base关键字外,还可以使用this关键字。与base—样,thts也可以用在类成员的内部,且该关键字也引用对象实例。由this引用的对象实例是当前的对象实例(即不能在静态成员中使用this关键字,因为静态成员不是对象实例的一部分)。
this关键字最常用的功能是把一个当前对象实例的引用传递给一个方法,例如:
public void doSomething()
{
MyTargetClass myObj = new MyTargetClass();
myObj.DoSomethingWith(this);
}
其中,实例化的MyTargetClass的有一个方法DoSomethingWith(),该方法带有一个参数,其类型与包含上述方法的类兼容。
3.3.3接口的实现
1.接口的定义
上一节中介绍了接口定义的方式与类相似,接口成员的定义与类成员的定义也相似,但有几个重要的区别:
● 不允许使用访问修饰符(public,private,protected或internal),所有的接口成员都是公共的
●接口成员不能包含代码体。
● 接口不能定义字段成员。
● 接口成员不能用关键字static,virtual,abstract或sealed来定义。
但要隐藏继承了基接口的成员,可以用关键字new来定义它们,例如:
interface IMyBaseInterface
{
void DoSomething();
}
interface ImyDerivedInterface : IMyBaseInterface
{
new void DoSomething();
}
其执行方式与隐藏继承的类成员一样。
在接口中定义的属性可以确定访问块get和set中的哪一个能用于该属性,例如:
interface IMyInterface
{
int MyInt
{
get;
set;
}
}
其中int属性MyInt有get和set访问程序块。对于访问级别有更严限制的属性来说,可以省略它们中的任一个。但要注意,接口没有指定属性应如何存储。接口不能指定字段,例如用于存储属性数据的字段。
2.在类中实现接口
执行接口的类必须包含该接口所有成员的执行代码,且必须匹配指定的签名(包括匹配指定的get和set块),并且必须是公共的。可以使用关键字virtual或abstract来执行接口成员,但不能使用static或const,例如:
public interface IMyInterface
{
void DoSomething();
void DoSomethingElse();
}
public class MyClass : IMyInterface
{
public void DoSomething()
{
}
public void DoSomethingElse()
{
}
}
继承一个实现给定接口的基类,就意味着派生类隐式地支持这个接口,例如:
public interface IMyInterface
{
void DoSomething();
void DoSomethingElse();
}
public class MyBaseClass : IMyInterface
{
public virtual void DoSomething()
{
}
public virtual void DoSomethingElse()
{
}
}
public class MyDerivedClass : MyBaseClass
{
public override void DoSomething()
{
}
}
如上所示,在基类中把执行代码定义为虚拟,派生类就可以替换该执行代码,而个是隐藏它们。如果要使用new关键字隐藏一个基类成员,而不是重写宅,则方法IMyInterface.DoSomething()就总是引用基类版本,即使派生类通过这个接口来访问也是这样。
下面的例子用来说明如何定义和使用接口。这个例子建立在银行账户的基础上。假定编写代码,最终允许在银行账户之间进行计算机转账业务。许多公司可以实现银行账户,但它们都是彼此赞同表示银行账户的所有类都实现接口IBankAccount。该接口包含一个用于存取款的方法和一个返回余额的属性。这个接口还允许外部代码识别由不同银行账户执行的各种银行账户类。
【例3-3】
public interface IBankAccount
{
void PayIn(decimal amount);
bool Withdraw(decimal amount);
decimal Balance
{
get;
}
}
public class SaverAccount : IBankAccount
{
private decimal balance;
public void PayIn(decimal amount)
{
balance += amount;
}
public bool Withdraw(decimal amount)
{
if (balance >= amount)
{
balance -= amount;
return true;
}
Console.WriteLine("Withdrawal attempt failed.");
return false;
}
public decimal Balance
{
get
{
return balance;
}
}
public override string ToString()
{
return String.Format("Venus Bank Saver: Balance = {0,6:C}", balance);
}
}
class MainEntryPoint
{
static void Main()
{
IBankAccount venusAccount = new SaverAccount();
venusAccount.PayIn(200);
venusAccount.Withdraw(100);
Console.WriteLine(venusAccount.ToString());
}
}
首先,需要定义接口的名称为IbankAccount,然后编写表示银行账户的类SaverAccount,其中包含一个私有字段balance,当存款或取款时就调整这个字段。如果因为账户中的金额不足而取款失败,就会显示一个错误消息。SaverAccount派生于一个接口IbankAccount,表示它获得了IBankAccount的所有成员,但接口并不实际实现其方法,所以SaverAccount必须提供这些方法的所有实现代码。如果没有提供实现代码,编译器就会产生错误。接口仅表示其成员的存在性,类负责确定这些成员是虚拟还是抽象的(但只有在类本身是抽象的,这些成员才能是抽象的)。在本例中,接口方法不必是虚拟的。有了自己的类后,就可以测试它们了。执行结果如下:
Venus Bank Saver: Balance = £100.00
Withdrawal attempt failed.
3.4类的更多内容
3.4.1 集合
第2章介绍了如何使用数组创建包含许多对象或值的变量类型。但数组有一定的限制。最大的限制是一旦创建好数组,它们的大小就是固定的,不能在现有数组的末尾添加新项目,除非创建一个新的数组。这常常意味着用于处理数组的语法比较复杂。
C#中的数组是作为System.Array类的实例来执行的,它们只是集合类中的一种。集合类一般用于处理对象列表,其功能比简单数组要多,这些功能是通过执行System.Collections命名空间中的接口而实现的。
集合的功能可以通过接口来实现,该接口不仅没有限制我们使用基本集合类,例如System.Array。相反我们还可以创建自己的定制集合类。这些集合可以专用于要枚举的对象。这么做的一个优点是定制的集合类可以是强类型化的。也就是说,在从集合中提取项目时,不需要把它们转换为正确的类型。
在System.Collections名称空间中有许多接口都提供了基本的集合功能:
● IEnumerable提供了循环集合中项目的功能。
● ICollection(继承于IEnumerable)可以获取集合中项目的个数,并能把项目复制到一个简单的数组类型中。
● IList(继承于IEnumerable和ICollection)提供了集合的项目列表,并可以访问这些项目,以及其他一些与项目列表相关的功能。
● IDictionary(继承于IEnumerable和ICollection)类似于IList,但提供了可通过键码值而不是索引访问的项目列表。
System.Array类继承了IList,ICollection和IEnumerable,但不支持IList的一些更高级的功能,它表示大小固定的一个项目列表。
1.定义集合
如何创建自己的、强类型化的集合?一种方式是手动执行需要的方法,但这比较花时间,在某些情况下也非常复杂。我们还可以从一个类派生自己的集合,例如System.Collections.CollectionBase类,这个抽象类提供了集合类的许多执行方式。
CollectionBase类有接口IEnumerable,ICollection和IList,但只提供了一些要求的执行代码,特别是IList的Clear()和RemoveAt()方法,以及IcoIIection的Count属性。如果要使用提供的功能,就需要自己执行其他代码。
为了方便地完成任务,CollectionBase提供了两个受保护的属性,它们可以访问存储的对象本身。我们可以使用List和InnerList,其中List可以通过IList接口访问项目,InnerList则是用于存储项目的ArrayList对象。
2.索引符
索引符是一种特殊类型的属性,可以把它添加到一个类中,以提供类似于数组的访问。实际上,可以通过一个索引符提供更复杂的访问,冈为我们可以定义和使用复杂的参数类型和方括号语法。它最常见的一个用法是对项目执行一个简单的数字索引。
3.关键字值集合和IDictionary
除了Ilist接口外,集合还可以执行类似的IDictionary接口,允许项目通过一个关键字值(例
如字符串名)进行索引,而不是通过一个索引。
这也可以使用索引符来完成,但这次的索引符参数是与存储的项日相关联的一个关键字,而不是一个int索引,这样集合的用户友好性就更高了。
与索引的集合一样,我们可以使用一个基类简化IDictionary接口的实现,这个基类就是DictionaryBase,它也实现IEnumerable和ICollection接口,提供了对任何集合都相同的集合处理功能。
DictionaryBase与CollectionBase—样,实现通过其支持的接口获得的一些成员(但不是全部成员)。DictionaryBase也执行Clear()和Count,但不执行RemoveAt()。这是的为RemoveAt()是IList接口上的一个方法,不是IDictionary接口上的一个方法。但是,Dictionary有一个Remove()方法,这是一个应执行基于DictionaryBase的定制集合类的方法。
【例3-4】用集合类移动显示产品信息。
using System;
using System.Collections;
using System.Text;
namespace Exp3_4
{
class Program
{
class Products
{
public string ProductName;
public double UnitPrice;
public int UnitsInStock;
public int UnitsOnOrder;
// 带参构造器
public Products(string ProductName, double UnitPrice, int UnitsInStock, int UnitsOnOrder)
{
this.ProductName = ProductName;
this.UnitPrice = UnitPrice;
this.UnitsInStock = UnitsInStock;
this.UnitsOnOrder = UnitsOnOrder;
}
}
// 实现接口Ienumerator和IEnumerable类Iterator
public class ProductsIterator:IEnumerator,IEnumerable
{
// 初如化Products 类型的集合
private Products[] ProductsArray;
int Index;
public ProductsIterator()
{
// 使用带参构造器赋值
ProductsArray = new Products[4];
ProductsArray[0] = new Products("Maxilaku", 20.00, 10, 60);
ProductsArray[1] = new Products("Ipoh Coffee", 46.00, 17, 10);
ProductsArray[2] = new Products("Chocolade", 12.75, 15, 70);
ProductsArray[3] = new Products("Pavlova", 17.45, 29, 0);
Index = -1;
}
// 实现IEnumerator的Reset()方法
public void Reset()
{
Index = -1;
}
// 实现IEnumerator的MoveNext()方法
public bool MoveNext()
{
return (++Index < ProductsArray.Length);
}
// 实现IEnumerator的Current属性
public object Current
{
get
{
return ProductsArray[Index];
}
}
// 实现IEnumerable的GetEnumerator()方法
public IEnumerator GetEnumerator()
{
return (IEnumerator)this;
}
static void Main()
{
ProductsIterator ProductsIt = new ProductsIterator();
Products Product;
ProductsIt.Reset();
for (int i = 0; i < ProductsIt.ProductsArray.Length; i++)
{
ProductsIt.MoveNext();
Product = (Products)ProductsIt.Current;
Console.WriteLine("ProductName : " + Product.ProductName.ToString());
Console.WriteLine("UnitPrice : " + Product.UnitPrice.ToString());
Console.WriteLine("UnitsInStock : " + Product.UnitsInStock.ToString());
Console.WriteLine("UnitsOnOrder : " + Product.UnitsOnOrder.ToString());
}
Console.ReadLine();
}
}
}
}
3.4.2 运算符重载
可以通过我们设计的类使用标准的运算符,例如+,>等,这称为重载,因为在使用特定的参数类型时,我们为这些运算符提供了自己的执行代码,其方式与重载方法相同,方法的重载是为同名的方法提供不同的参数。
运算符重载非常有用,因为我们可以在运算符重载中执行需要的任何操作,在类实例上不能总是只调用方法或属性,有时还需要做一些其他的工作,例如对数值进行相加、相乘或逻辑操作,如比较对象等。假定要定义一个类,表示一个数学矩阵,在数学中,矩阵可以相加和相乘,就像数字一样。这并不像“把这两个操作数相加”这么简单。
1.运算符重载的基本语法
要重载运算符,可给类添加运算符类型成员(它们必须是static)。一些运算符有多种用途,(例如 - 运算符就有一元和二元两种功能),因此我们还指定了要处理多少个操作数,以及这些操作数的类型。一般情况下,操作数的类型与定义运算符的类类型相同,但也可以定义处理混合类型的运算符,详见后面的内容。
例如,考虑一个简单的类AddClass1,如下所示:
public class AddClass1
{
public int val;
}
这仅是int值的一个包装器,对于这个类,下面的代码不能编译:
AddClass1 op1 = new AddClass1();
op1.val = 5;
AddClass1 op2 = new AddClass1();
op2.val = 5;
AddClass1 op3 = op1 + op2;
其错误是+运算符不能应用于AddClass1类型的操作数,下面的代码则可执行,但得不到希望的结果:
AddClass1 op1 = new AddClass1();
op1.val = 5;
AddClass1 op2 = new AddClass1();
op2.val = 5;
bool op3 = op1 == op2;
其中,使用==二元运算符来比较op1和op2,看看是否引用的是同一个对象,而不是验证它们的值是否相等。在上述代码中,即使op1.val和op2.val相等,op3也是false。要重载+运算符,可使用下述代码:
public class AddClass1
{
public int val;
public static AddClass1 operator +(AddClass1 op1, AddClass1 op2)
{
AddClass1 returnVal = new AddClass1();
returnVal.val = op1.val + op2.val;
return returnVal;
}
}
可以看出,运算符重载看起来与标准静态方法声明类似,但它们使用关键字operatoe和运算符本身,而不是一个方法名。
现在可以成功地使用+运算符和这个类,如上面的例子所示:
AddClass1 op3=opl + op2;
重载所有的二元运算符都是一样的,一元运算符看起来也是类似的,但只有一个参数:
public class AddClass1
{
public int val;
public static AddClass1 operator +(AddClass1 op1, AddClass1 op2)
{
AddClass1 returnVal = new AddClass1();
returnVal.val = op1.val + op2.val;
return returnVal;
}
public static AddClass1 operator –(AddClass1 op1)
{
AddClass1 returnVal = new AddClass1();
returnVal.val = -op1.val;
return returnVal;
}
}
这两个运算符处理的操作数的类型与类相同,返回值也是该类型,但考虑下面的类定义:
public class AddClass2
{
public int val;
public static AddClass3 operator +(AddClass1 op1, AddClass2 op2)
{
AddClass3 returnVal = new AddClass3();
returnVal.val = op1.val + op2.val;
return returnVal;
}
}
public class AddClass2
{
public int val;
}
public class AddClass3
{
public int val;
}
下面的代码就可以执行:
AddClass1 op1 = new AddClass1();
op1.val = 5;
AddClass2 op2 = new AddClass2();
op2.val = 5;
AddClass3 op3 = op1 + op2;
在合适时,可以用这种方式混合类型。但要注意,如果把相同的运算符添加到AddClass2中,上面的代码就会失败,因为它将不知道要使用哪个运算符。因此应注意不要把签名相同的运算符添加到多个类中。
还要注意,如果混合了类型,操作数的顺序必须与运算符重载的参数的顺序相同。用了重载的运算符和顺序错误的操作数,操作就会失败。所以不能像下面这样使用运算符:
AddClass3 op3=op2 + op1;
当然,除非提供了另一个重载运算符和倒序的参数:
public static AddClass3 operator +(AddClass2 op1, AddClass1 op2)
{
AddClass3 returnVal = new AddClass3();
returnVal.val = op1.val + op2.val;
return returnVal;
}
下述运算符可以重载:
● 一元运算符:+,-,!,~,++,--,true,false
● 二元运算符:+,-,*,/,%,&,|,^,<<,>>
● 比较运算符:==,!=,<,>,<=,>=
注意:如果重载true和false运算符,就可以在布尔表达式中使用类,例如if(opl){}。
不能重载赋值运算符,例如+=,但这些运算符使用它们的对应运算符的重载形式,例如+。也不能重载&&和||,但它们可以在计算中使用对应的运算符&和|。
一些运算符如<和>必须成对重载。这就是说,不能重载<,除非也重载了>。在许多情况下,可以在这些运算符中调用其他运算符,以减少需要的代码(和可能发生的错误),例如:
public c1ass AddClass1
{
public int val;
public static bool operator >=(AddClass1 op1, AddClass1 op2)
{
return(op1.val >= op2.val);
}
public static bool operator <(AddClass1 op1, AddClass1 op2)
{
return !(op1>=op2);
}
//Also need implementations for <= and > operators
}
在比较复杂的运算符定义中,这可以减少代码,且只要修改一个执行代码,其他运算符也会被修改。
这同样适用于==和!=,但对于这些运算符,常常需要重写Object.Equals()和Object.GetHashCode(),因为这两个函数也可以用于比较对象。重写这些方法,可以确保无论类的用户使用什么技术,都能得到相同的结果。这不太重要,但应增加进来,以保证其完整性。它需要下述非静态重写方法:
public class AddClass1
{
public int val;
public static bool operator ==(AddClass1 op1, AddClass1 op2)
{
return (op1.val == op2.val);
}
public static bool operator !=(AddClass1 op1, AddClass1 op2)
{
return !(op1 == op2);
}
public override bool Equals(object op1)
{
return val == ((AddClass1)op1).val;
}
public override int GetHashCode()
{
return val;
}
}
注意Equals()使用Object类型参数。我们需要使用这个签名,否则就将重载这个方法,而不是重写它。类的用户仍可以访问默认的执行代码。这样就必须使用数据类型转换得到需要的结果。GetHashCode()可根据其状态,获取对象实例的—个惟一的int值。这里使用vaI就可以了,因为它也是个int值。
2.转换运算符
除了重载如上所述的数学运算符之外,还可以定义类型之间的隐式和显式转换。如果要在不相关的类型之间转换,这是必须的,例如,如果在类型之间没有继承关系,也没有共享接口,这就是必须的。
下面定义convclassl和convclass2之间的隐式转换,即编写下述代码:
ConvClass1 op1 = new ConvClass1();
ConvClass2 op2 = op1;
另外,还可以定义—个显式转换,在下面的代码中调用:
ConvClass1 opl = new ConvClass1();
ConvClass2 op2 = (ConvClass2)opl;
例如,考虑下面的代码;
public class ConvClass1
{
public int val;
public static implicit operator ConvClass2(ConvClass1 op1)
{
ConvClass2 returnVal = new ConvClass2();
returnVal.val = op1.val;
return returnVal;
}
}
public class ConvClass2
{
public double val;
public static explicit operator ConvClass1(ConvClass2 op1)
{
ConvClass1 returnVal = new ConvClass1();
checked{returnVal.val = (int)op1.Val;};
return returnVal;
}
}
其中,ConvClass1包含一个int值,ConvClass2包含—个double值。因为int值可以隐式转换为double值,所以可以在ConvClassl和convClass2之间定义一个隐式转换。但反过来就不行了,应把convClass1和ConvClass2之间的转换定义为显式。在代码中,用关键字implicit和explicit来指定这些转换,如上所示。
对于这些类,下面的代码就很好;
ConvClass1 op1 = new ConvClass1();
op1.val = 3;
ConvClass2 op2 = op1;
但反方向的转换需要下述显式数据类型转换:
ConvClass2 op1 = new ConvClass2();
op1.val = 3.15
ConvClass1 op2 = (ConvClass1)op1;
3.4.3 高级转换
1.封箱和拆箱
上一节中讨论了引用和值类型之间的区别,并通过比较结构(值类型)和类(引用类型)进行了说明。封箱(boxing)是把值类型转换为System.Object类型,或者转换为由值类型执行的接口类型。拆箱(unboxing)是相反的转换过程。
例如,下面的结构类型:
struct MyStruct
{
public int Val;
}
可以把这种类型的结构放在object类型的变量中,以封箱它:
MyStruct valType1 = new MyStruct();
valType1.Val = 5;
object refType = valType1;
其中创建了一个类型为MyStruct的新变量(valTypel),并把一个值赋予这个结构的Val成员,然后把它封箱在object类型的变量(refType)中。
以这种方式封箱变量而创建的对象,包含值类型变量的一个副本的引用,而不过含源值类型变量的引用。如果要进行验证,可以修改源结构的内容,把对象中包含的结构拆箱到新变量中,检查其内容:
valType1.Val = 6;
MyStruct valType2 = (MyStruct)refType;
Console.WriteLine(“valType2.Val = {0}”,valType2.Val);
这段代码将得到如下结果:
valType2.Val = 5
但在把一个引用类型赋予对象时,将执行不同的操作。通过把MyStruct转化为—个类(不考虑这个类名不再合适的情况):
class MyStruct
{
public int Val;
}
如果不修改上面的客户代码,就会得到如下结果
valType.Val = 6
也可以把值类型封箱到一个接口中,只要它们执行这个接口即可。例如,假设MyStruct类实现IMyInterface接口,如下所示:
interface IMyInterface
{
}
struct MyStruct : IMyInterface
{
public int Val;
}
接着把结构封箱到一个IMyInterface类型中,如下所示:
MyStruct valType1 = new MyStruct();
IMyInterface refType = valType1;
然后使用一般的数据类型转换语法拆箱它:
MyStruct ValType2 = (MyStruct)refType;
从这些范例中可以看出,封箱是在没有用户干涉的情况下进行的(即不需要编写任何代码),但拆箱一个值需要进行显式转换,即需要进行数据类型转换(封箱是隐式的,所以不需要进行数据类型转换)。
封箱非常有用,有两个非常重要的原因。首先,它允许使用集合中的值类型(例如ArrayList),集合中项目的类型是object。其次,有一个内部机制允许在值类型上调用object,例如int和结构。最后要注意的是,在访问值类型的内容前,必须进行拆箱。
2.is运算符
is运算符可以检查未知的变量(该变量能用作对象参数,传送给一个方法)是否可为约定的类型,如果可以进行转换,该值就是true。在对对象调用方法前,可以使用该运算符查看执行该方法的对象的类型。is运算符不会检查两个类型是否相同,但可以检查它们是否兼容。
is运算符的语法如下:
<operand> is <type>
这个表达式的结果如下:
● 如果<type>是一个类类型,而<operand>也是该类型,或者它继承了该类型,或者它封箱到该类型中,则结果为true。
● 如果<type>是一个接口类型,而<operand>也是该类型,或者它是实现该接口的类型,则结果为true。
● 如果<type>是一个值类型,而<operand>也是该类型,或者它被拆箱到该类型中,则结果为true。
下面的例子用于说明该运算符的使用
【例3-5】
using System;
class Checker
{
public void Check(object param1)
{
if(param1 is ClassA)
Console.WriteLine("Variable can be converted to ClassA.");
else
Console.WriteLine("Variable can't be converted to ClassA.");
if(param1 is IMyInterface)
Console.WriteLine("Variable can be converted to IMyInterface.");
else
Console.WriteLine("Variable can't be converted to IMyInterface.");
if(param1 is MyStruct)
Console.WriteLine("Variable can be converted to MyStruct.");
else
Console.WriteLine("Variable can't be converted to MyStruct.");
}
}
interface IMyInterface
{
}
class ClassA:IMyInterface
{
}
class ClassB:IMyInterface
{
}
class ClassC
{
}
class ClassD:ClassA
{
}
struct MyStruct:IMyInterface
{
}
class Class1
{
static void Main(string[] args)
{
Checker check = new Checker();
ClassA try1 = new ClassA();
ClassB try2 = new ClassB();
ClassC try3 = new ClassC();
ClassD try4 = new ClassD();
MyStruct try5 = new MyStruct();
object try6 = try5;
Console.WriteLine("Analyzing ClassA type variable:");
check.Check(try1);
Console.WriteLine("\nAnalyzing ClassB type variable:");
check.Check(try2);
Console.WriteLine("\nAnalyzing ClassC type variable:");
check.Check(try3);
Console.WriteLine("\nAnalyzing ClassD type variable:");
check.Check(try4);
Console.WriteLine("\nAnalyzing Mystruct type variable:");
check.Check(try5);
Console.WriteLine("\nAnalyzing boxed MyStruct type variable:");
check.Check(try6);
}
}
运行结果如下:
Analyzing ClassA type variable:
Variable can be converted to ClassA.
Variable can be converted to IMyInterface.
Variable can't be converted to MyStruct.
Analyzing ClassB type variable:
Variable can't be converted to ClassA.
Variable can be converted to IMyInterface.
Variable can't be converted to MyStruct.
Analyzing ClassC type variable:
Variable can't be converted to ClassA.
Variable can't be converted to IMyInterface.
Variable can't be converted to MyStruct.
Analyzing ClassD type variable:
Variable can be converted to ClassA.
Variable can be converted to IMyInterface.
Variable can't be converted to MyStruct.
Analyzing Mystruct type variable:
Variable can't be converted to ClassA.
Variable can be converted to IMyInterface.
Variable can be converted to MyStruct.
Analyzing boxed MyStruct type variable:
Variable can't be converted to ClassA.
Variable can be converted to IMyInterface.
Variable can be converted to MyStruct.
这个例子说明了使用is运算符的各种可能的结果。其中定义了3个类、一个接口和一个结构,并把它们用作类的方法的参数,使用is运算符确定它们是否可以转换为ClassA类型、接口类型和结构类型。只有ClassA和ClassD(继承于ClassA)类型与ClassA兼容。如果类型没有继承一个类,就不会与该类兼容。ClassA、ClassB和MyStruct类都实现IMyInterface,所以它们都与IMyInterface类型兼容,ClassD继承了ClassA,所以它们两个也兼容。因此,只有ClassC是不兼容的。最后,只有MyStuct类型的变量本身和该类型的封箱变量与MyStruct兼容,因为不能把引用类型转换位值类型。
3.as运算符
as运算将使用下面的语法,把一种类型转换为指定的引用类型:
<operand> as <type>
这只适用于下列情况:
● <operand>的类型是<type>类型
● <operand>可以隐式转换为<type>类型
● <operand>可以封箱到类型<typer>.中
如果不能从<operand>显式转换为<type>,则表达式的结果就是null。从基类到派生类之间的转换可以显式进行,但这常常是无效的。考虑上面例子中的ClassA和ClassD两个类,其中ClassD派生于ClassA。下面的代码使用as运算符把存储在obj1中的ClassA实例转换为ClassD类型:
ClassA obj1 = new ClassA();
ClassD obj2 = obj1 as ClassD();
这样,就使obj2的结果为null。
但利用多态性可以把ClassD实例存储在ClassA类型的变量中。下面的代码就验证了这一点,使用as运算符把包含ClassD类型实例的ClassA类型变量转换为ClassD类型:
ClassD obj1 = new ClassD();
ClassA obj2 = obj1;
ClassD obj3 = obj2 as ClassD ;
这次的结果是obj3包含与obj1相同的对象引用,而不是null。
因此,as运算符非常有用,因为下面使用简单数据类型转换的代码会抛出一个异常:
ClassA obj1 = new ClassA();
ClassD obj2 = (ClassD)obj1;
但上面的as表达式只会把null赋给obj2,不会抛出一个异常。
3.4.4 深度复制
上一节介绍了如何使用受保护的方法System.Object.MemberwiseClone()进行引用复制,使用一个方法GetCopy(),如下所示:
public class Cloner
{
public int Val;
public Cloner(int newVal)
{
Val = newVal;
}
public object GetCopy()
{
return MemberwiseClone();
}
}
假定有引用类型的字段,而不是值类型的字段,例如:
public class Content
{
public int Val;
}
public class Cloner
{
public Content MyContent = new Content();
public Cloner(int newVal)
{
MyContent.Val = newVal;
}
public object GetCopy()
{
return MemberwiseClone();
}
}
此时,通过GetCopy()得到的引用复制有一个字段,它引用的对象与源对象相同。下面的代码使用这个类来说明这一点:
Cloner mySource = new Cloner(5);
Cloner myTafget = (Cloner) mySource.GetCopy();
Console.WriteLine(“myTarget.MyContent.Val = {0}”,myTarget.MyContent.Val) ;
mySource.MyContent.Val = 2;
Console.WriteLine(“myTarget.MyContent.Val = {0}”,myTarget.MyContent.Val) ;
第4行把一个值赋给mySource.MyContent.Val,源对象中公共字段MyContent的公共字段Val,也改变了myTarget.MyContent.Val的值。这是因为mySource.MyContent引用了与myTarget.MyContent相同的对象实例。上述代码的结果如下:
myTarget.MyContent.Val = 5
myTarget.MyContent.Val = 2
修改上面的GetCopy()方法就可以进行深度复制,但最好使用.NET Framewok的标淮方式。为此,实现ICloneable接口,该接口有一个方法Clone(),这个方法不带参数,返回一个对象类型,其签名和上面使用的GetCopy()方法相同。
修改上面使用的类,可以使用下面的深度复制代码:
public class Content
{
public int Val;
}
public class Cloner : ICloneable
{
public Content MyContent = new Content();
public Cloner(int newVal)
{
MyContent.Val = newVal;
}
public object Clone()
{
Cloner clonerCloner = new Cloner(MyContent.Val);
return clonerCloner;
}
}
其中使用包含在源Cloner对象(MyContent)中的Content对象的Val字段,创建一个新Cloner对象(MyContent)。这个字段是一个值类型,所以不需要深度复制。
使用与上面类似的代码测试引用复制,但使用Clone()而不是GetCopy(),得到如下结果:
myTarget.MyContent.Val = 5
myTarget.MyContent.Val = 5
这次包含的对象是独立的。
有时在比较复杂的对象系统中,调用Clone()应是一个递归过程。例如,如果Cloner类的MyContent字段也需要深度复制,就要使用下面的代码:
public class Cloner : Icloneable
{
public Content MyContent = new Content();
…
public object Clone()
{
Cloner clonedCloner = new Cloner();
clonedCloner.MyContent = MyContent.Clone();
return clonedCloner;
}
}
这里调用了默认的构造函数,简化了创建一个新Cloner()对象的语法。为了使这段代码能正常工作,还需要在Content类上执行ICloneable接口。
3.4.5 定制异常
检测错误并相应进行处理是正确设计软件的基本原则。理想情况下,编写代码时,每一行代码都按预想的运作,要用到的每种资源总是可以利用。但是,在现实世界中却远非如此顺利。其他程序员可能会犯错,网络连接可能会中断,数据库服务器可能会停止运行,磁盘文件不一定有应用程序想要的内容。总之,编写的代码必须能够检测出类的这些错误并采取相应的对策。
报告错误的机制与错误本身一样多种多样。有些方法设计为返回一个布尔值,用它来指示方法的成功或者失败。还有一些方法设计为将错误写入某个日志文件或者数据库中。丰富的错误报告模型意味着监控错误的代码必须相对健壮。使用的每种方法可能以不同的方式报告错误,这也就是说,应用程序可能会因为各种方法检测错误的方式不同而导致代码杂乱。
.NET框架提供了一种用于报告错误的标准机制,称之为结构化异常处理(SEH,Structured Exception Handling)。这种机制依靠异常指明失败。异常是描述错误的类。.NET架使用异常来报告错误,并且在代码中也可以使用异常。编写代码来监视任何代码段生成的异常。不管它是来自CLR还是程序员自己的代码,并且相应处理生成的异常。使用SEH,只需要在代码中创建一个错误处理设计模式即可。
这种统一的错误处理方法对于启用多语种.NET编程也是很重要的。当使用SEH设计所有代码时,能够安全并容易地混合以及匹配代码(例如,C#,C++或者VB.NET)。作为对遵循SEH规则的回报。.NET框架确保通过各种语言正确地传播和处理所有错误。
在C#代码中检测并处理异常非常简单。在处理异常时需要标识三个代码块:使用异常处理的代码块;在处理第一个代码块时,如果找到某个异常,就执行代码块;在处理完异常之后执行选择的代码块。
在C#中,异常的生成称之为抛出(throwing)异常。被通知抛出了一个异常则称之为捕获(catching)异常。处理完异常之后执行的代码块是终结(finally)代码块。将介绍如何在C#中使用这些结构。还会介绍异常层次结构的成员。
与所有设计准则一样,盲目地针对每种错误使用异常足不必要的。当错误对于代码块来说是本地的时候,使用错误返回代码方法是比较合适的。在实现表单的有效性验证时,经常会看到这种方法。这是可以接受的作法,因为通常有效性验证错误对于收集输入的表单来说是本地的。换句话说,当有效性验证错误发生时,显示一条错误消息并请求用户正确地重新输入所需的信息。因为错误和处理代码都是本地的,所以处理资源泄漏很简单。另一个示例是在读取文件时处理一个文件结束条件。不用付出异常所需的开销,就可以很好地处理这个条件。当错误发生时,完全在这个代码块中处理该错误条件。当调用发生错误所在代码块之外的代码时,倾向于使用SEH处理错误。
1.指定异常处理
C#的关键字try指定让某个代码块监视代码执行时抛出的任何异常。使用try关键字很简单。使用时,try关键字后面跟一对花括号,花括号中的语句用来监视代码执行时抛出的异常。
try
{
//place satements here
}
在执行try代码块中的任何语句时,如果有异常抛出,就可以在代码中捕获该异常并相应进行处理。
2.捕获异常
如果使用try关键字来指定希望被通知有关的异常抛出,就需要编写捕获异常的代码并在代码中处理报告的错误。
try代码块后面的C#关键字catch用于指定当捕获到异常时应该执行哪段代码。catch关键字的工作机理与try关键字类似。
2.1使用try关键字
最简单形式的catch代码块捕获前面try代码块中代码抛出的任何异常。catch代码块的结构类似try代码块,以下面的代码为例:
try
{
//place statements here
}
catch
{
//place statements here
}
如果从try代码块中抛出一个异常,就执行catch代码块中语句。如果try代码块中的语句没有抛出异常,就不执行catch代码块中的代码。
2.2捕获特定类的异常
还可以编写catch代码块来处理由try代码块中一条语句抛出的特定类的异常。在本章后面的“由.NET框架定义的异常”一节中,格介绍类异常的更多内容。catch代码块使用下列语法:
catch(要处理的异常所属的类 异常的变量标识符)
{
当try代码块抛出指定类型的异常时,将执行的语句
}
以上面的代码为例:
try
{
//place statements here
}
catch(Exception thrownException)
{
//palce statements here
}
在上述示例中,catch代码块捕获try代码块抛出的Exception类型的异常。其中定义了一个Exception类型的变量ThrownException。在catch代码块中使用ThrownException变量来获得有关抛出的异常的更多信息。
try代码块中的代码可能会抛出各种类的异常,此时就要处理每个不同的类。C#允许指定多个catch代码块,每个代码块处理一种特定类的错误;
try
{
//place statements here
}
catch(Exception ThrownException)
{
//Catch Block 1
}
catch(Exception ThrownException2)
{
//Catch Block2
}
在上述示例中,检测try代码块中的代码以便抛出异常。 如果CLR发现try代码块中的代码抛出了—个异常,那么就检查该异带所属的类并执行相应的catch代码块。如果抛出的异常是类Exception2的一个对象.则不执行任何catch代码块。
还可以在catch代码块列表中添加一个普通的catch代码块,以下面的代码为例:
try
{
//place statements here
}
catch(Exception ThrownException)
{
//Catch Block 1
}
catch
{
//Catch Block 2
}
在上述示例中,检测try代码块中的代码以便抛出异常。如果抛出的异常是类Exception的一个对象,则执行catch Block 1中的代码。如果抛出的异常是其他类的对象,则执行普通catch代码块——catch Block 2中的代码。
2.3出现异常之后进行消除
在catch代码块之后可能会有一代码块。在处理完异常之后以及没有异常发生时都将执行这个代码块。如果要执行这类代码,可以编写一个finally代码块。C#的关键字finally指定在执行try代码块之后应该执行的代码块。finally代码块的结构与try代码块的结构相同:
finally
{
//place statements here
}
在finally代码块中释放先前在方法中分配的资源,例如,假设编写一个打开三个文件的方法。如果在try代码块中包含文件访问代码,那么将能够捕获与打开、读、写文件有关的异常。但是,在代码的最后,即使抛出一个异常,也要关闭这三个文件。最好将关闭文件语句放入finally代码块,代码如下所示:
try
{
//open files
//read files
}
catch
{
//catch exceptions
}
finally
{
//close files
}
如果没任何catch块,C#编译器可以定义一个finally块。用户可以在try块后编写一个finally代码块。
3.由.NET框架定义的异常
.NET Framework定义了各种异常,在C#代码或者调用的方法中找到某些错误时均可抛出这些异常。所有这些异常都是标准的.NET异常并且可以使用C#的catch代码块捕获。每个.NET异常都定义在.NLT System名字空间中。下面描述了其中—些公共的异常。这些异常只代表.NET框架的基类库中定义的一小部分异常。
3.1OutOfMemoryException异常
当用完内存时,CLR抛出OutOfMemoryException异常。如果试图使用new运算符创建一个对象,而CLR没有足够的内存满足这项请求,那么CLR就会抛出OutOfMenoryException异常,如下所示。
【例3-6】
using System;
Class MainClass
{
public static void Main()
{
int [] LargeArray;
try
{
LargeArray = new int[2000000000];
}
catch (OutOfMemeoryException)
{
Console.WriteLine("The CLR is out of memory.");
}
}
}
程序中的代码试图给一个拥有20亿个整数的数组分配内存空间。因为—个整数要占用4字节内存空间,所以这么大的数组需要占用80亿字节内存空间。而机器没有这么大的内存空间可以使用,所以分配内存的操作将以失败告终。try代码块中包含内存分配代码,另外,还定义了一个catch代码块来处理CLR抛出的任何OutOfMemoryException异常。
3.2StackOverflowException异常
当用完堆栈空间时,CLR抛出StackOverflowException异常。CLR管理称为堆栈(stack)的数据结构,堆栈用于跟踪调用的方法以及这些方法的调用次序。CLR的堆栈空间有限,如果堆栈已满,就会抛出StackOverflowException异常,如下所示。
【例3-7】
using System;
class MainClass
{
public static void Main()
{
try
{
Recursive();
}
catch(StackOverflowException)
{
Console.WriteLine("The CLR is out of stack space.");
}
}
public static void Recursive()
{
Recursive();
}
}
程序中的代码实现了方法Recursive(),该方法在返回之前调用它本身。Main()方法调用Recursive()方法,并且最终会导致CLR消耗完堆栈空间,因为Recursive()方法从不真正地返回。Main()方法调用Recursive()方法,Recursive()方法反过来又调用Recursive()方法,不停地调用这种方法。最终,CLR消耗完堆栈空间并抛出StackOverflowException异常。
3.3NullReferenceException异常
在下面例子中,编译器将捕获试图间接访问一个空对象的异常。
【例3-8】
using System;
class MyClass
{
public int value;
}
class MainClass
{
public static void Main()
{
try
{
MyObject = new MyClass();
MyObject = null;
MyObject.value =123;
}
catch(NullReferenceExcption)
{
Console.WriteLine("Cannot reference a null object.");
}
}
}
程序中的代码声明了一个MyClass类型的对象变量,并将该变量设置为null(如果在语句中没有使用new运算符,而只是声明了一个MyClass类型的对象变量,那么在编译时,编译器将发出如下错误消息:“使用了未赋值的局部变量MyObject”)。然后,使用该对象的公共字段value,因为不能引用null对象,所以这种做法是非法的。cLR捕获这类错误并抛出NullReferenceException异常。
3.4 TypeInitializationException异常
当某个类定义了一个静态构造函数并且该构造因数抛出异常时,CLR就抛出TypeInitializationException异常。如果该构造函数中没有catch代码块捕获这类异常.那么CLR就抛出TypeInitializationException异常。
3.5 InvalidCastException异常
如果显式类型转换失败,CLR就抛出InvalidCastException异常。在接口环境下容易产生这类情况。下面的例子说明了InvalidCastException异常。
【例3-9】
using System;
class MainClass
{
public static void Main()
{
try
{
MainClass MyObject = new MainClass();
IFormattable Formattable;
Formattable = (IFormattable)MyObject;
}
catch(InvalidCastException)
{
Console.WriteLine("MyObject does not implement the IFormattable interface");
}
}
}
程序中的代码使用一个类型转换运算符试图获得对.NKT的IFormattable接口的引用。因为MainClass类并没有实现IFormattable接口,所以类型转换操作会失败,而且CLR 还会抛出InvalidCastException异常。
3.6 ArrayTypeMismatchException异常
当代码将某个元素存储到一个数组中时,如果元素类型与数组不匹配,CLR就会抛出ArrayTypeMismatchException异常。
3.7 IndexOutOfRangeException异常
当代码使用元素索引号将元素存储到数组时,如果元素索引号超出了数组的范围.CLR就会抛出IndexOutOfRangeException异常。下面的程序说明了IndexOutOfRangeException异常。
【例3-10】
using System;
class MianClass
{
public static void Main()
{
try
{
int [] IntegerArray = new int[5];
IntegerArray[10] = 123;
}
catch(IndexOutOfRangeException)
{
Console.WriteLine("An invald element index access was attempted.");
}
}
}
程序中的代码创建了一个拥有五个元素的数组,然后试图给数组中的第十个元素指定值。因为索引号10已经超出了整型数组的范围,所以CLR抛出IndexOutOfRangeException异常。
3.8 DivideByZeroException异常
当代码执行数学运算时,如果导致用零作为除数,则CLR抛出DivideByZeroException异常。
3.9 OverflowException异常
当在数学运算中使用了C#的checked运算符时,如果导致溢出,则CLR抛出0verflowException异常。下面的程序说明了0verflowException异常。
【例3-11】
using System;
class MainClass
{
public static void Main()
{
try
{
checked
{
int Integer1;
int Integer2;
int Sum;
Integer1 = 2000000000;
Integer2 = 2000000000;
Sum = Integer1 + Integer2;
}
}
catch(OverflowException)
{
Console.WriteLine("A mathematical operation caused an overflow.");
}
}
}
程序中的代码将两个整数相加,每个整数的位为20亿。结果即40亿赋给第二个整型变量。问题是加法的结果大于C#整型值所允许的最大值,因此抛出数学运算溢出异常。
4.使用自定义的异常
可以自己定义异常,并在代码中使用它们,就像它们是由.NET框架定义的异常一样。这种设计一致性使得程序员能够编写catch代码块来处理从任何代码段抛出的任何异常,不管代码是.NET框架的,还是自定义类中的,或者是运行时执行的某个程序集中的。
4.1自定义异常
.NET框架声明了一个类System.Exception,用它来作为.NET框架中的所有异常的基类。预先定义好的公共语言运行时类就从System.System.Exception类派生出来,而这个类是从System.Exception类派生出来的。按照这种规则,DivideByZeroException,NotFiniteNumberException和OverflowException异常从System.ArithmeticException类派生出来,而这个类又是从System. System.Exception类派生而来。定义的任何异常类必须从Systcm.ArithmeticException类派生而来,而这个类又派生自System.Exception基类。
System.Exception类包含四个只渎属性,在catch代码块中可以使用这些属性来获取有关抛出的异常的更多信息:
● Message属性包含对异常原因的描述。
● InnerException属性包含引起抛出当前异常的异常。该属性的值可以为null,它表示没 有可用的内异常。如果该属性的值不为null,就会指向抛出的异常对象,而该对象导 致当前异常抛出。对于catch代码块来说,捕获一种异常而抛出另一个异常也是可能 的。在这种情况下,InnerException属性将包含对catch代码块捕获的原异常对象的引 用。
● StackTrace属性包含一个字符串,用它来显示抛出异常时正在使用的方法调用的堆栈。
最后,堆栈服踪将跟踪所有返回CLR原始调用的路线至应用程序的Main()方法、TargetSite属性包含抛出异常的方法。
其中某些属性可以在System.Exception类的一个构造函数中指定:
public Exception(string message);
public Exception(string message, Exception innerException);
在构造函数中,自定义的异常可以调用基类的构造函数,以便设置属性,以下面的代码
为例:
using System;
class MyException:ApplicationException
{
public MyException(): base("This is my exception message.")
{
}
}
上述代码定义了类MyException,该类从ApplicationException类派生出来。MyException类的构造函数使用base关键字调用了这个基类的构造函数。将基类的Message属性设置为“Thisis my exception message”。
4.2抛出自定义的异常
可以用C#的throw关键字抛出自定义的异常。throw关键字后面必须跟一个表达式,该表达式的值为类System.Exception或者其派生类的一个对象。例如:
【例3-12】
using System;
class MyException: ApplicationException
{
public MyException():base("This is my exception message.")
{
}
}
class MainClass
{
public static void Main()
{
try
{
MainClass MyObject = new MainClass();
MyObject.ThrowException();
}
catch(MyException CaughtException)
{
Console.WriteLine(CaughException.Message);
}
}
public void ThrowException()
{
throw new MyException();
}
}
}
程序中的代码声明了一个新类MyException,该类派生自.NET框架定义的一个基类APPlicationException。MainClass类包括方法ThrowException,该方法抛出一个类型为MyException的new对象。该方法由Main()方法调用,调用代码位于try代码块中。Main()方法还包含一个catch代码块,该代码块实现将异常的消息输出到控制台上。因为是在构造MyException类对象时设置的消息,所以现在可以用这条消息并将其打印出来。运行清代码后,将在控制台上打印出下列消息:
This is my exception message.
3.4.6 事件和委托
在典型的面向对象软件的一般流程中,代码段创建类的对象并在该对象上调用力法。在这种情况下,调用程序是主动代码,因为它们是调用方法的代码。而对象是被动的,因为只有当某种方法被调用时才会用上对象并执行某种动作。
然而,也可能存在相反的情况。对象可以执行一些任务并在执行过程中发生某些事情时通知调用程序。称这类事情为事件(event),对象的事件发布称为引发事件。
事件驱动处理对于.NET来说并不是什么技术,在事件驱动处理中,当有事件发生时,某些代码段会通知其他对事件感兴趣的代码段。当用户使用鼠标、敲击键盘或者移动窗口时,Windows用户接口层一直使用事件的形式通知Windows应用程序。当用户采取影响ActiveX控件的动作时,ActiveX控件就会引发事件至ActiveX控件容器。
为了在C#代码中激发、发布和预约事件更容易,C#语言提供了一些特殊的关键字。使用这些关键字允许C#类毫不费力地激发和处理事件。
1.定义委托
当设计C#类引发的事件时,需要决定其他代码段如何接收事件。其他代码段需要编写一种方法来接收和处理发布的事件。例如,假设类实现了一个Web服务器,并想在任何时间从Internet发来页面请求时激发一个事件。在类激发这个new request事件时,其他代码段执行某种动作,并且代码中应该包含一种方法,在激发事件时执行该方法。
类用户实现接受和处理事件的方法由C#中的概念——委托(delegate)定义。委托是一种“函数模板”,它描述了用户的事件处理程序必须有的结构。委托也是一个类,其中包含一个签名以及对方法的引用。就像一个函数指针,但是它又能包含对静态和实例方法的引用。对于实例方法来说,委托存储了对函数人口点的引用以及对对象的引用。委托定义丁用户事件处理程序内该返回的内容以及应该具备的参数表。
要在C#中定义一个委托,使用下列语法:
delegate 事件处理程序的返回类型 委托标识符(事件处理程序的参数表)
如果在激发事件的类中声明委托,可以在委托前加上前缀public,protected,internal或者 private关键字,如下面的delegate定义示例所示:
public delegate void EvenNumberHandler(int Number);
在上述示例中,创建了一个称为EvenNumberHandler的公共委托(public delegate),它不返回任何值。该委托只定义了一个要传递给它的参数,该参数为int类型。委托标识符(这里是EvenNumberHandler)可以是任何名称,只要不与C#关键字的名称重复即可。
2.定义事件
为了阐述清楚事件的概念,我们以一个示例开始讲述。假设正驱车在路上并且仪表板上显示燃料不足的灯亮了。在这个过程中。汽缸中的传感器给计算机发出燃料快要耗尽的信号。然后,计算机又激发一个事件来点亮仪表板上的灯,这样司机就知道要购买更多的油了。用最简单的话来说,事件是计算机警告你发发生某种状况的一种方式。
使用C#的关键字event来定义类激发的事件。在C#中事件声明的最简单形式包含下列内
容:
evet 事件类型 事件标识符 事件类型委托标识符匹配
以下面的Web服务器示例为例:
public delegate void NewRequestHandler(string URL);
public class WebSever
{
public event NewRequestHandler NewRequestEvent;
//...
}
上述示例声明了一个称为NewRequestHandler的委托。NewRequestHandler定义了一个委托,作为处理new request事件的方法的方法模板。任何处理new request事件的方法都必须遵循委托的调用规则:必须不返回任何数据,必须用一个字符串作为参数表。事件处理程序的实现可以拥有任意的方法名称,只要返回值和参数表符合委托模板的要求即可。
WebServer类定义了一个事件NewRequestEvent。该事件的类型为NewRequestHandle。这意味着,只有与该委托的调用规则相匹配的事件处理程序才可以用于处理NewRequestEvent事件。
3.安装事件
编写完事件处理程序之后,必须用new运算符创建一个它的实例并将它安装到激发事件的类中。创建一个事件处理程序new实例时,要用new运算符创建一个属于这种委托类型的变量,并且作为参数传递事件处理程序方法的名称。以Web服务器的示例为例,对事件处理程序new实例的创建如下代码所示:
public void MyNewRequestHandler(string URL)
{
}
NewRequestHandler HandlerInstance;
HandlerInstance = new NewRequestHandler(MyNewRequestHandler);
在创建了事件处理程序的new实例之后,使用+=运算符将其添加到事件变量中:
NewRequestEvent += HandlerInstance;
上述语句连接了HandleInstance委托实例与NewRequestEvent事件,该委托支持MyNewRequestMethod方法。使用+=运算符,可以将任意多的委托实例与一个事件相连接。同理,可以使用-=运算符从事件连接中删除委托实例:
NewRequestEvent -= HandlerInstance;
上述语句解除了HandlerInstance委托实例与NewRequestEvent事件的连接。
4.激发事件
可以使用事件标识符(比如事件的名称)从类中激发事件.就像事件是一个方法一样。作为方法调用事件将激发该事件c在Web浏览器示例中,使用如下语句激发new request事件:
NewRequestEvent(strURLOfNewRequest);
事件激发调用中使用的参数必须与事件委托的参数表匹配。定义NewRequestEvent事件的委托接受一个字符串参数;因此,当从Web浏览器类中激发该事件时,必须提供一个字符串。
下面的例子说明了委托和事件的概念。其中实现了一个类,该类从0计数到100,并在计数过程中找到偶数时激发一个事件。
【例3-13】
using System;
public delegate void EvenNumberHandler(int Number);
class Counter
{
public event EvenNumberHandler OnEvenNumber;
public Counter()
{
OnEvenNumber = null;
}
public void CountTo100()
{
int CurrentNumber;
for(CurrentNumber = 0; CurrentNumber <= 100;CurrentNumber++)
{
if(CurrentNumber % 2 == 0)
{
if(OnEvenNumber != null)
{
OnEventNumber(CurrentNumber);
}
}
}
}
}
classEvenNumberHandlerClass
{
public void EvenNumberFound(int EvenNumber)
{
Console.WriteLine(EvenNumber);
}
}
class ClassMain
{
public static void Main()
{
Counter MyCounter = new Counter();
EvenNumberHandlerClass MyEvenNumberHandlerClass = new
EvenNumberHandlerClass();
MyCounter.OnEvenNUmber += new
EvenNumberHandler(MyEvenNumberHandlerClass.EvenNumberFound);
}
}
程序实现了三个类
● Counter类执行计数功能。其中实现了一个公共方法CountTo100()和一个公共事件 OnEvenNumber。OnEvenNumber事件的委托类型为EvenNumberHandler。
● EvenNumherHandlerClass类包含一个公共方法EvenNumberFound。该方法为Counter类 的OnEvenNumber事件的事件处理程序。它将作为参数提供的整数打印到控制台上。
● MainClass类包含应用程序的Main()方法。Main()方法创建类Counter的一个对象并将该对象命名为MyCounter。还创建了类EvenNumberHandlerC1ass的一个new对象,井调用了对象MyEvenNumberHandlerC1ass。Majn()方法调用MyCounter对象的CountTo100()方法,但是不是在将委托实例安装到Counter类中之前调用的。其中的代码创建了一个new委托实例,用它来管理MyEvenNumber。HandlerClass对象的EvenNumberFound方法,并使用+=运算法将其添加到MyCounter对象的0nEvenNumber事件中。
CountTol00方法的实现使用一个局部变量从0计数到100。在每一次计数循环中,代码都会检测数字是否是偶数,方法是看数字被2除后是否有余数。如果数字确实是偶数,代码就激发OnEvenNumber事件,将偶数作为参数提供,以便与事件委托的参数表匹配。
因为MyEvenNumherHandlerClass的EvenNumberFound方法是作为事件处理程序安装的,而且该方法将提供的参数打印到控制台上,所以编译并运行代码后,0到100之间的所有偶数都会打印到控制台上。
6.标准化事件的设计
尽管C#可以接受它编译的任何委托设计,但是.NET框架还是鼓励开发人员采用标准的委托设计方式。最好委托设计使用两个参数;例如,SystemEventhandler委托:
● 对引发事件的对象的引用
● 包含与事件有关的数据的对象
第二个参数包含了所有事件数据,应该是某个类的一个对象.这个类由.NET的System.EventArgs类派生出来。
对上面代码进行修改,其中使用了这种标准的设计方式。
【例3-14】
using System;
public delegate void EvenNumberHandler(object Originator,
OnEvenNumberEventArgs EventNumberEventArgs);
class Counter
{
public event EvenNumberHandler OnEvenNumber;
public Counter()
{
OnEvenNumber = null;
}
public void CountTo100()
{
int CurrentNumber;
for(CurrentNumber = 0; CurrentNumber <= 100;CurrentNumber++)
{
if(CurrentNumber % 2 == 0)
{
if(OnEvenNumber != null)
{
OnEvenNumberEventArgs EventArguments;
EventArguments = new OnEvenNumberEvenArgs(CurrentNumber);
OnEventNumber(this,EventArguments);
}
}
}
}
}
public class OnEvenNumberEventArgs : EventArgs
{
private int EventNumber;
public OnEvenNumberEventArgs(int EvenNumber)
{
this.EvenNumber = EvenNumber;
}
public int Number
{
get
{
return EventNumber;
}
}
}
class EvenNumberHandlerClass
{
public void EvenNumberFound(object Originator,
OnEvenNumberEventArgs EvenNumberEventArgs)
{
Console.WriteLine(EvenNumberEventArgs.Number);
}
}
Class MainClass
{
public static void Main()
{
Counter MyCounter = new Counter();
EvenNumberHandlerClass MyEvenNumberHandlerClass = new
EvenNumberHandlerClass();
MyCounter.OnEvenNUmber += new
EvenNumberHandler(MyEvenNumberHandlerClass.EvenNumberFound);
MyCounter.CountTo100();
}
}
本章小结
本章学习了面向对象编程的基本概念,包括什么是对象、类、属性和字段、方法、静态成员等,还说明了对象的生命周期。讨论了OOP中的一个技术,包括:抽象与接口、继承、多态性、运算符重载等。
基本的类定义语法、用于确定类可访问性的关键字、指定继承的方式以及接口的定义也是在本章学习的。所有的.NET类都派生于System.Object。介绍了object中定义的方法。如何提供我们自己的构造函数和析构函数,以便初始化对象和清理对象。接口和抽象类的相似和不同之处,看看哪些情况应使用什么技术。讲述了类和结构的区别。
本章学习了如何定义字段、属性和方法等类成员,隐藏基类成员、调用重写的基类成员,接口的实现。集合类一般用于处理对象列表,其功能比简单数组要多,这些功能是通过执行System.Collections命名空间中的接口而实现的。可以通过我们设计的类使用标准的运算符,例如+,>等,这称为重载。如何进行运算符重载。封箱(boxing)是把值类型转换为System.Object类型,或者转换为由值类型执行的接口类型。拆箱(unboxing)是相反的转换过程。is和as运算符的使用方法,如何深度复制,如何定制异常,什么是事件和委托,如何使用。
下一章将学习如何用Windows组件设计可视化应用程序。
习题3
1.“必须手动调用对象的析构函数,否则就会浪费资源”,对吗?
2.需要创建一个对象以调用其类的静态方法吗?
3.下面的代码有什么错误?
public sealed class MyClass
{
// class members
}
public class myDerivedClass : MyClass
{
// class members
}
4.如何定义不能创建的类?
5.为什么不能创建的类仍旧有用?如何利用它们的功能?
6.编写代码,用虚拟方法GetString()定义一个基类MyClsss。这个方法应返回存储在受保
护的字段myString中的字符串,该字段可以通过只写公共属性ContainedString来访问。
7.从类MyClass派生一个类MyDerivdClass。重写GetString()方法,使用该方法的基类执行代码从基类中返回一个字符串,但在返回的字符串中添加文本“(output from derived class)”。
8. 编写—个类MyCopyableClass,该类可以使用方法GetCopy()返回它本身的一个副本。这个方法应使用派生干System.0bject的MemberwiseClone()方法。给该类添加一个简单的属性,并且编写使用该类检查任务是否成功执行的客户代码。
9.创建一个集合类People,它是下述Person类的集合,该集合中的项目可以通过一个字符串索引符来访问,该字符串索引符是人的姓名,与Person.Name属性相同:
public class Person
{
private string name;
private int age;
public string Name
{
get
{
return name;
}
set
{
name = value;
}
}
public int Age
{
get
{
return age;
}
set
{
age = value;
}
}
}
10.扩展上一题中的Person类,重载>,<,>=和<=运算符,比较Person实例的Age属性。
11.给People类添加GetOldest()方法,使用上面定义的重载运算符,返回其Age属性的值为最大的Person对象数组(1个或多个对象,因为对于这个属性而言,多个项目可以有相问的值)。
12.在People类上执行ICloneable接口,提供深度复制功能。