8.1 面向对象编程的含义
面向对象编程解决了传统编程技巧的许多问题。前面介绍的编程方法称为函数(或过程)化编程,常会导致所谓的单一应用程序,即所有功能都包含在几个代码模块(常常是一个代码模块)中。而使用OOP技术,常常要使用许多代码模块,每个模块都提供特定功能。而且,每个模块都是孤立的,甚至与其他模块完全独立。这种模块化编程方法提供了非常大的多样性,大大增加了重用代码的机会。
为进一步说明这个问题,把计算机上的一个高性能应用程序想象成一辆一流赛车。如果使用传统的编程技巧,这辆赛车就是一个单元。如果要改进这辆车,就必须替换整车,把它送回厂商那里,让汽车专家升级它,或者购买一辆新车。如果使用OOP技术,就只需要从厂商处购买新的引擎,自己按照其说明替换它,而不必用钢锯切割车体。
在传统应用程序中,执行流常是简单的、线性的。把应用程序加载到内存中,从A点开始执行,在B点结束,然后从内存中卸载,在这个过程中可能用到其他各种实体,例如存储介质上的文件或显卡的功能,但处理的主体总是位于一个地方。用到的代码一般与使用各种数学和逻辑方式处理数据相关。处理方法通常比较简单,使用基本的数据类型(例如整型和布尔值)建立比较复杂的数据表达方式。
而使用OOP,事情就不是这么直接了。尽管可以获得相同的效果,但其实现方式是完全不同的。OOP技术以结构、数据的含义以及数据和数据之间的交互操作为基础。这通常意味着要把更多精力放在项目的设计阶段,其好处是项目的可扩展性比较高。一旦对某种类型的数据的表达方式达成一致,这种表达方式就会应用到应用程序以后的版本中,甚至是全新应用程序中。这种一致的表达方式可以极大地缩短开发时间。这就是上述赛车示例的工作原理。这里的一致是指“引擎”的代码是结构化的,这样就可以很容易地替换成新代码(即新引擎),而不需要找厂商帮忙。这也表示,引擎创建出来后可用于其他目的,可以把它安装到另一辆车上,或者用它驱动潜艇。
除了数据表达方式的一致性外,OOP编程还常可以简化任务,因为较抽象实体的结构和用法也是一致的。例如,不仅把输出结果发送给设备(如打印机)所使用的数据格式是一致的,而且与该设备交换数据的方法也是一致的,这包括它理解的指令等。回到赛车示例上,要达成的一致做法包括引擎如何连接到油箱,如何把驱动力传送给车轮等。
顾名思义,OOP技术要使用对象。
8.1.1 对象的含义
对象就是OOP应用程序的一个组成部件。这个组成部件封装了部分应用程序,这部分程序可以是一个过程、一些数据或一些更抽象的实体。
简单地说,对象非常类似于本书前面讨论的结构类型,包含变量成员和函数类型。它所包含的变量组成了存储在对象中的数据,其中包含的函数可以访问对象的功能。略为复杂的对象可能不包含任何数据,而只包含函数,表示一个过程。例如,可以使用表示打印机的对象,其中的函数可以控制打印机(允许打印文档、测试页等)。
C#中的对象是从类型中创建的,就像前面的变量一样。对象的类型在OOP中有一个特殊名称:类。可以使用类的定义实例化对象,这表示创建该类的一个命名实例。“类的实例”和对象的含义相同,但“类”和“对象”是完全不同的概念。
注意:术语“类”和“对象”常常混淆,从一开始就正确区分它们是非常重要的,使用前面的赛车示例有助于区分这两个术语。在这个示例中,类是指汽车的模板,或者用于构建汽车的规划。汽车本身是这些规划的实例,所以可以看成对象。
本章将使用统一建模语言(Unified Modeling Language, UML)语法研究类和对象。UML是为应用程序建模而设计的,从组成应用程序的对象,到它们执行的操作,到我们希望有的用例,应有尽有。这里只使用这门语言的基本部分,在使用它们的过程中进行解释,但不考虑比较复杂的部分,因为UML是一个很专业的主题,有很多图书专门介绍它。
图8-1是打印机类Printer的UML表示方法。类名显示在这个框的顶部(后面将论述下面两个区域)。
图8-1
图8-2是这个Printer类的一个实例myPrinter的UML表示方法。
图8-2
在顶部,首先显示实例名,其后是类名。这两个名称用一个冒号分隔。
1.属性和字段
可以通过属性和字段访问对象中包含的数据。这个对象数据可以用于区分不同的对象,因为同一个类的不同对象在属性和字段中存储了不同的值。
包含在对象中的不同数据构成了对象的状态。假定一个对象类表示一杯咖啡,称为CupOfCoffee。在实例化这个类(即创建这个类的对象)时,必须提供对类有意义的状态。此时可以使用属性和字段,让代码能通过该对象设置要使用的咖啡品牌,咖啡中是否加牛奶或方糖,咖啡是否即溶等。于是,给定的这杯咖啡对象就有了指定的状态,例如,加牛奶和两块方糖的哥伦比亚过滤咖啡。
字段和属性都可以键入,所以可以把信息存储在字段和属性中,作为string值、int值等。但属性与字段是不同的,因为属性不提供对数据的直接访问。对象能让用户不考虑数据的细节,不需要在属性中用一对一的方式表示。如果在CupOfCoffee实例中使用一个字段表示方糖的数量,用户就可以在该字段中放置自己喜欢的值,其取值范围仅由存储该信息的类型来限制。例如,如果使用int来存储这个数据,用户就可以使用-2147483648至2147483647之间的任意值,如第3章所述。显然,并不是所有的值都有意义,尤其是负值,一些较大的正值将需要非常大的咖啡杯。但如果使用一个属性来表示,就可以限制这个值,例如介于0和2之间的一个数字。
一般情况下,在访问状态时最好提供属性而不是字段,因为这样可以更好地控制各种行为,这个选择不会影响使用对象实例的代码,因为使用属性和字段的语法是相同的。
对属性的读写访问也可以由对象来明确定义。某些属性是只读的,只能查看它们的值,而不能改变它们(至少不能直接改变)。这常常是同时读取几个状态的一个有效技巧。CupOfCoffee类有一个只读属性Description,在请求它时,就返回一个字符串,表示该类的一个实例的状态(例如前面给出的字符串)。也可以通过查看几个属性,把相同的数据组合起来,但这样的属性可以节省时间和精力。还可以有只写的属性,其操作方式是类似的。
除了对属性的读/写访问外,还可以为字段和属性指定另一种访问权限,称为可访问性。可访问性确定了什么代码可以访问这些成员,它们可用于所有代码(公共)还是只能用于类中的代码(私有),或者使用更复杂的模式(详见本章后面的内容)。常见的情况是把字段设置为私有,通过公共属性访问它们。这样,类中的代码就可以直接访问存储在字段中的数据,而公共属性禁止外部用户访问这些数据,以防他们在其中放置无效的内容。公共成员是类公开的成员。
要更清晰地阐明这个问题,可以把可访问性与变量的作用域等同起来。例如,私有字段和属性可以看成拥有它们的对象的局部成员,而公共字段和属性的作用域也包括对象以外的代码。
在类的UML表示方法中,用第二部分显示属性和字段,如图8-3所示。
图8-3
这是CupOfCoffee类的表示方式,前面为它定义了5个成员(属性或字段,在UML中,它们没有区别)。每个成员都包含下述信息:
● 可访问性:+号表示公共成员,-号表示私有成员。但一般情况下,本章的图中不显示私有成员,因为这些信息是类内部的信息。至于读/写访问,则不提供任何信息。
● 成员名。
● 成员的类型。
冒号用于分隔成员名和类型。
2.方法
“方法”这个术语用于表示对象中的函数。这些函数调用的方式与其他函数相同,使用返回值和参数的方式也相同(详见第6章)。
方法用于访问对象的功能。与字段和属性一样,方法也可以是公共的或私有的,按照需要限制外部代码的访问。它们通常使用对象的状态来影响它们的操作,在需要时访问私有成员,如私有字段。例如,CupOfCoffee类定义了一个方法AddSugar(),该方法对递增方糖数提供了比设置相应的Sugar属性更易读的语法。
在UML的类框中,方法显示在第三部分,如图8-4所示。
图8-4
其语法类似于字段和属性,但最后显示的类型是返回类型,在这一部分,还显示了方法的参数。在UML中,每个参数都带有下述标识符之一:return、in、out或inout。它们用于表示数据流的方向,其中out和inout大致对应于第6章讨论的C#关键字out和ref。in大致对应于C#中不使用这两个关键字的情形(默认情形)。return表示传回调用方法的值。
8.1.2 一切皆对象
本书一直在使用对象、属性和方法。实际上,C#和.NET Framework中的所有东西都是对象。控制台应用程序中的Main()函数就是类的一个方法。前面介绍的每个变量类型都是一个类。前面使用的每个命令都是属性或方法,例如<String>.Length和<String>.ToUpper()等。句点字符把对象实例名与属性或方法名分隔开来,方法名后面的()把方法与属性区分开来。
对象无处不在,使用它们的语法通常比较简单,至少到现在为止都足够简单,使我们可以集中精力讨论C#中一些比较基础的方面。从现在开始详细介绍对象。这里讨论的概念都具有深远影响,它们甚至适用于简单的int变量。
8.1.3 对象的生命周期
每个对象都有一个明确定义的生命周期,除了“正在使用”的正常状态之外,还有两个重要的阶段:
● 构造阶段:第一次实例化一个对象时,需要初始化该对象。这个初始化过程称为构造阶段,由构造函数完成。
● 析构阶段:在删除一个对象时,常常需要执行一些清理工作,例如释放内存,这由析构函数完成。
1.构造函数
对象的初始化过程是自动完成的。我们不需要自己寻找适于存储新对象的内存空间。但是,在初始化对象的过程中,有时需要执行一些额外工作。例如,需要初始化对象存储的数据。构造函数就是用于初始化数据的函数。
所有的类定义都至少包含一个构造函数。在这些构造函数中,可能有一个默认构造函数,该函数没有参数,与类同名。类定义还可能包含几个带有参数的构造函数,称为非默认的构造函数。代码可以使用它们以许多方式实例化对象,例如给存储在对象中的数据提供初始值。
在C#中,用new关键字来调用构造函数。例如,可用下面的方式通过其默认的构造函数实例化一个CupOfCoffee对象:
CupOfCoffee myCup = new CupOfCoffee();
还可以用非默认的构造函数来实例化对象。例如,CupOfCoffee类有一个非默认的构造函数,它使用一个参数在初始化时设置咖啡豆的品牌:
CupOfCoffee myCup = new CupOfCoffee("Blue Mountain");
构造函数与字段、属性和方法一样,可以是公共或私有的。在类外部的代码不能使用私有构造函数实例化对象,而必须使用公共构造函数。这样,通过把默认构造函数设置为私有的,就可以强制类的用户使用非默认的构造函数。
一些类没有公共的构造函数,外部的代码就不可能实例化它们,这些类称为不可创建的类,但如稍后所述,这些类并不是完全没有用的。
2.析构函数
.NET Framework使用析构函数来清理对象。一般情况下,不需要提供析构函数的代码,而由默认的析构函数自动执行操作。但是,如果在删除对象实例前需要完成一些重要操作,就应提供具体的析构函数。
例如,如果变量超出了范围,代码就不能访问它,但该变量仍存在于计算机内存的某个地方。只有在.NET运行程序执行其垃圾回收,进行清理时,该实例才被彻底删除。
8.1.4 静态成员和实例类成员
属性、方法和字段等成员是对象实例所特有的,此外,还有静态成员(也称为共享成员,尤其是Visual Basic用户常使用这个术语),例如静态方法、静态属性或静态字段。静态成员可以在类的实例之间共享,所以可以将它们看成类的全局对象。静态属性和静态字段可以访问独立于任何对象实例的数据,静态方法可以执行与对象类型相关但与对象实例无关的命令。在使用静态成员时,甚至不需要实例化对象。
例如,前面使用的Console.WriteLine()和Convert.ToString()方法就是静态的,根本不需要实例化Console或Convert类(如果试着进行这样的实例化,操作会失败,因为这些类的构造函数不是可公共访问的,如前所述)。
许多情况下,静态属性和静态方法有很好的效果。例如,可以使用静态属性跟踪给类创建了多少个实例。在UML语法中,类的静态成员带有下划线,如图8-5所示。
图8-5
1.静态构造函数
使用类中的静态成员时,需要预先初始化这些成员。在声明时,可以给静态成员提供一个初始值,但有时需要执行更复杂的初始化操作,或者在赋值、执行静态方法之前执行某些操作。
使用静态构造函数可以执行此类初始化任务。一个类只能有一个静态构造函数,该构造函数不能有访问修饰符,也不能带任何参数。静态构造函数不能直接调用,只能在下述情况下执行:
● 创建包含静态构造函数的类实例时
● 访问包含静态构造函数的类的静态成员时
在这两种情况下,会首先调用静态构造函数,之后实例化类或访问静态成员。无论创建了多少个类实例,其静态构造函数都只调用一次。为了区分静态构造函数和本章前面介绍的构造函数,也将所有非静态构造函数称为实例构造函数。
2.静态类
我们常常希望类只包含静态成员,且不能用于实例化对象(如Console)。为此,一种简单的方法是使用静态类,而不是把类的构造函数设置为私有。静态类只能包含静态成员,不能包含实例构造函数,因为按照定义,它根本不能实例化。但静态类可以有一个静态构造函数,如上一节所述。
注意:如果以前完全没有接触过OOP,在阅读本章的其他内容之前,应该停下来将OOP研究一番。在学习更复杂的OOP内容之前,全面掌握基础知识是很重要的。