在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
第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 访问修饰符
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中的方法
这些方法是.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 amou |
2023-10-27
2022-08-15
2022-08-17
2022-09-23
2022-08-13
请发表评论