公司培训文档-c#基础学习
第一章 Microsoft.Net 概述
本章重点:
.NET框架
公共语言运行环境(CLR)
命名空间
.NET程序运行原理
本章目的:
通过本章的学习,我们可以理解.NET平台的基本结构,理解公共语言运行环境、命名空间等相关新的名词。了解.NET平台下程序的有运行机制。
1.1 Microsoft.Net平台
Microsoft.Net提出的想法是:由.NET将计算重点从一个由单独的设备和WEB站点通过Internet简单相连的世界,转变成一个由设备,服务程序和计算机协同工作的世界。以便为用户提供更加丰富的解决方案。Microsoft.NET方案由一下四个关键部分组成:
.NET构建块服务(Buliding Block Service)
指对某些特定服务程序的访问。如文件存储的服务、日历管理或Passport.NET(一种身份鉴别服务)。
.NET设备软件
运行于新型Internet设备上的软件.
.NET用户体验
包括自然界面,信息代理和智能标签等这样的功能,这些技术可以自动建立超级链接,这些链接指向与用户创建的文档中的单词或短语相关的信息.
.NET基础结构
由.NET框架(FameWork)、Microsoft Visual Studio .NET、.NET企业服务器(Enterprise Server)和Microsoft Windows .NET组成。
在这里大多数开发人员提到.NET时,他们指的是.NET的一部分,即.NET基础结构.在本书的后边章节所提到的.NET,您也可以认为它就指.NET基础结构.因为.NET基础结构包含组成这个新的开发环境的所有技术.采用这个开发环境您可以创建和运行强大的、可升级的、分布式的应用程序.在.NET基础结构中能让我们开发这样程序的工具就是.NET框架.
.NET框架由公共语言运行环境(Comon Language Runtime,CLR)和.NET框架类库组成.类库也称为基础类库(Base Class Library,BCL).可以把CLR看作是一台虚拟机器,所有的.NET应用程序在这台机器中运行.所有的.NET语言都可以使用.NET框架基类库.该类库支持从文件I/O和数据库I/O到XML和SOAP的一切,非常庞大.
注意: 本书提到”虚拟机器”并不是指Java中的虚拟机(Java Virtual Machine,JVM),它指的是传统的定义.是指一种高级操作系统的抽象概念.别的操作系统可以在这个完全封闭的环境下运行.把CLR看作是虚拟机器是因为所有在CLR中运行的代码也是在一个封闭的、受控的环境下运行,与机器上的进程隔离开.
1.2 .Net框架
.NET框架由公共语言运行环境(CLR)和类库组成.如图:1-2;1-3
1.2.1公共语言运行环境
公共语言运行环境(CLR)是.NET的核心.顾名思义,CLR就是一个运行期环境,使用不同的语言编写的应用程序都可以在这里运行并且互不干扰----即跨语言互用(crosss-language interoperability).那么CLR又是怎样实现跨语言互用这个环节的呢?因为它们任何一种语言都必须遵守一套标准的规则,就是公共语言规范(CLS).
和公共语言运行环境相关的两个概念是受控代码和垃圾收集.受控代码是指运行在公共语言运行环境中的程序由公共语言运行环境来负则内存管理 线程管理 代码执行 代码安全验证 编译以及其它系统服务.垃圾收集是指运行程序时公共语言运行环境对内存的管理,当系统对某些对象未使用并且没有释放内存时,由公共语言运行环境来自动进行内存释放操作.从这两点看,采用公共语言运行环境后即可以让程序员专心的去考虑程序的功能,同时还提高了软件的运行效率.
图1-2
图 1-3
1.2.2 .NET框架类库
.NET Framework 类库是一个与公共语言运行库紧密集成的可重用的类型集合。该类库是面向对象的,并提供您自己的托管代码可从中导出功能的类型。这不但使 .NET Framework 类型易于使用,而且还减少了学习 .NET Framework 的新功能所需要的时间。此外,第三方组件可与 .NET Framework 中的类无缝集成。
例如,.NET Framework 集合类实现一组可用于开发您自己的集合类的接口。您的集合类将与 .NET Framework 中的类无缝地混合。
正如您对面向对象的类库所希望的那样,.NET Framework 类型使您能够完成一系列常见编程任务(包括诸如字符串管理、数据收集、数据库连接以及文件访问等任务)。除这些常见任务之外,类库还包括支持多种专用开发方案的类型。例如,可使用 .NET Framework 开发下列类型的应用程序和服务:
控制台应用程序。
Windows GUI 应用程序(Windows 窗体)。
ASP.NET 应用程序。
XML Web Services。
Windows 服务。
例如,Windows 窗体类是一组综合性的可重用的类型,它们大大简化了 Windows GUI 的开发。如果要编写 ASP.NET Web 窗体应用程序,可使用 Web 窗体类。
.NET Framework 包括类、接口和值类型,它们可加速和优化开发过程并提供对系统功能的访问。为便于语言之间进行交互操作,.NET Framework 类型是符合 CLS 的,并因此可在任何编程语言中使用,只要这种语言的编译器符合公共语言规范 (CLS)。
.NET Framework 类型是生成 .NET 应用程序、组件和控件的基础。.NET Framework 包括的类型执行下列功能:
表示基础数据类型和异常。
封装数据结构。
执行 I/O。
访问关于加载类型的信息。
调用 .NET Framework 安全检查。
提供数据访问、多客户端 GUI 和服务器控制的客户端 GUI。
.NET Framework 提供一组丰富的接口以及抽象类和具体(非抽象)类。可以按原样使用这些具体的类,或者在多数情况下从这些类派生您自己的类。若要使用接口的功能,既可以创建实现接口的类,也可以从某个实现接口的 .NET Framework 类中派生类。
1.2.2.1 .NET类库命名规定
.NET Framework 类型使用点语法命名方案,该方案隐含了层次结构的意思。此技术将相关类型分为不同的命名空间组,以便可以更容易地搜索和引用它们。全名的第一部分(最右边的点之前的内容)是命名空间名。全名的最后一部分是类型名。例如,System.Collections.ArrayList 表示 ArrayList 类型,该类型属于 System.Collections 命名空间。System.Collections 中的类型可用于操作对象集合。
此命名方案使扩展 .NET Framework 的库开发人员可以轻松创建分层类型组,并用一致的、带有提示性的方式对其进行命名。库开发人员在创建命名空间的名称时应使用以下原则:
“公司名称.技术名称”
例如,Microsoft.Word 命名空间就符合此原则。
利用命名模式将相关类型分组为命名空间是生成和记录类库的一种非常有用的方式。但是,此命名方案对可见性、成员访问、继承、安全性或绑定无效。一个命名空间可以被划分在多个程序集中,而单个程序集可以包含来自多个命名空间的类型。程序集为公共语言运行库中的版本控制、部署、安全性、加载和可见性提供外形结构。
1.2.2.2 命名空间
System 命名空间是 .NET Framework 中基本类型的根命名空间。此命名空间包括表示由所有应用程序使用的基础数据类型的类:Object(继承层次结构的根)、Byte、Char、Array、Int32、String 等。在这些类型中,有许多与编程语言所使用的基元数据类型相对应。当使用 .NET Framework 类型编写代码时,可以在应使用 .NET Framework 基础数据类型时使用编程语言的相应关键字。
除基础数据类型外,System 命名空间还包含近 100 个类,范围从处理异常的类到处理核心运行时概念的类,如应用程序域和垃圾回收器。System 命名空间还包含许多二级命名空间。
1.3 Microsoft中间语言和JITters
Microsoft开发了一种类似于汇编语言叫做Microsoft中间语言(Microsoft Intermediate Language,MSIL).要编译出在.NET上运行的程序,可以将源代码当作编译器的输入,而编译器将产生MSIL作为输出.MSIL本身就是一种完备的语言,您也可以采用它来编写程序.但是作为一种汇编语言,您可能永远也不会用它来编写程序.除非非常特殊.下面看一下C#程序的编译过程:
1. 您使用C#编写了源代码.
2. 然后使用C#编译器(csc.exe)将它编译成一个EXE文件
3. C#编译器将输出一个MSIL和一个清单,组成EXE文件的只读部分.这个EXE文件带有一个标准的PE(Win32---Portable Executable)头.
注意这里有个关键的地方:当编译器创建输出时,它同时也从.NET运行环境导入了一个名为_CorExeMain函数.
4. 应用程序执行时,操作系统载入PE,与任何独立的动态链接库(DLL)一样,如导出_CorExeMain函数的mscoree.dll,操作系统把它当作合法的PE.
5. 操作系统载入器跳转到PE内部的入口处,这个入口是由C#编译器放置的.别的PE在Windows中也是这样执行的.
但是由于操作系统并不能执行MSIL代码,因此入口仅仅是一个跳转,直接跳转到mscoree.dll中的_CorExeMain函数.
6. _CorExeMain函数开始执行PE中的MSIL代码.
7. 由于不能直接执行MSIL代码--------因为它不是一种可执行的机器码,CLR在处理MSIL的时候,就使用JIT(Just-in-Time,即时)编译器 (或叫作JITter)把MSIL代码编译成真正的CPU指令.只有当程序中的方法被调用时才会进行即时编译.编译后的可执行代码在机器上缓存起来.只有当源代码改变时才会被重新编译.
根据不同的情况,有三中不同的JITter可以用来把MSIL转换成真正的代码:
1.安装时生成代码 (install-time code generation)
2.JIT --------------默认选项
3.EconoJIT(经济型) -------------用于手持设备
1.4 编写第一个C#应用程序
我们打开记事本编写如下程序:
//My First C# Program
//Name HelloWorld.cs
using System;
class HelloWorld{
public static void Main(){
Console.WriteLine(“HelloWorld!”);
}
}
注意:保存时存成HelloWorld.cs文件,“.cs”是C#源代码文件的扩展名。
在配置好C#编辑器的命令行环境里键入”csc HelloWorld.cs”编译文件,编译结果将输出HelloWorld.exe文件。执行结果将在控制台屏幕输出”HelloWorld!”
下面我们来分析一下程序代码和整个程序的编译输出及执行过程。
先看文件开始的两行代码:”//” “/* */” 两种注释方法。
再看下面的”using System”语句,这是C#语言的using命名空间指示符,这里的"System"是Microsoft.NET系统提供的类库。C#语言没有自己的语言类库,它直接获取Microsoft.NET系统类库。Microsoft.NET类库为我们的编程提供了非常强大的通用功能。该语句使得我们可以用简短的别名"Console"来代替类型"System.Console"。当然using指示符并不是必须的,我们可以用类型的全局名字来获取类型。实际上,using语句采用与否根本不会对C#编译输出的程序有任何影响,它仅仅是简化了较长的命名空间的类型引用方式。
接着我们声明并实现了一个含有静态Main()函数的HelloWorld类。C#所有的声明和实现都要放在同一个文件里,不像C++那样可以将两者分离。Main()函数在C#里非常特殊,它是编译器规定的所有可执行程序的入口点。由于其特殊性,对Main()函数我们有以下几条准则:
1. Main()函数必须封装在类或结构里来提供可执行程序的入口点。C#采用了完全的面向对象的编程方式,C#中不可以有像C++那样的全局函数。
2. Main()函数必须为静态函数(static)。这允许C#不必创建实例对象即可运行程序。
3. Main()函数保护级别没有特殊要求, public,protected,private等都可,但一般我们都指定其为public。
4. Main()函数名的第一个字母要大写,否则将不具有入口点的语义。C#是大小写敏感的语言。
5. Main()函数的参数只有两种参数形式:无参数和string 数组表示的命令行参数,即static void Main()或static void Main(string[]args) ,后者接受命令行参数。一个C#程序中只能有一个Main()函数入口点。其他形式的参数不具有入口点语义,C#不推荐通过其他参数形式重载Main()函数,这会引起编译警告。
6. Main()函数返回值只能为void(无类型)或int(整数类型)。其他形式的返回值不具有入口点语义。
Main函数的内部实现:
Console是在命名空间System下的一个类,它表示我们通常打交道的控制台。而我们这里是调用其静态方法WriteLine()。如同C++一样,静态方法允许我们直接作用于类而非实例对象。WriteLine()函数接受字符串类型的参数"Hello World !",并把它送入控制台显示。如前所述,C#没有自己的语言类库,它直接获取Microsoft.NET系统类库。我们这里正是通过获取Microsoft.NET系统类库中的System.Console.WriteLine()来完成我们想要的控制台输出操作。这样我们便完成了"Hello World!"程序。
但事情远没那么简单!在我们编译输出执行程序的同时,Microsoft.NET底层的诸多机制却在暗地里涌动,要想体验C#的锐利,我们没有理由忽视其背靠的Microsoft.NET平台。实际上如果没有Microsoft.NET平台,我们很难再说C#有何锐利之处。我们先来看我们对"HelloWorld.cs"文件用csc.exe命令编译后发生了什么。是的,我们得到了HelloWorld.exe文件。但那仅仅是事情的表象,实际上那个HelloWorld.exe根本不是一个可执行文件!那它是什么?又为什么能够执行?
好的,下面正是回答这些问题的地方。首先,编译输出的HelloWorld.exe是一个由中间语言(IL),元数据(Metadata)和一个额外的被编译器添加的目标平台的标准可执行文件头(比如Win32平台就是加了一个标准Win32可执行文件头)组成的PE(portable executable,可移植执行体)文件,而不是传统的二进制可执行文件--虽然他们有着相同的扩展名。中间语言是一组独立于CPU的指令集,它可以被即时编译器Jitter翻译成目标平台的本地代码。中间语言代码使得所有Microsoft.NET平台的高级语言C#,VB.NET,VC.NET等得以平台独立,以及语言之间实现互操作。元数据是一个内嵌于PE文件的表的集合。元数据描述了代码中的数据类型等一些通用语言运行时(Common Language Runtime)需要在代码执行时知道的信息。元数据使得.NET应用程序代码具备自描述特性,提供了类型安全保障,这在以前需要额外的类型库或接口定义语言(Interface Definition Language,简称IDL)。
这样的解释可能还是有点让人困惑,那么我们来实际的解剖一下这个PE文件。我们采用的工具是.NET SDK Beta2自带的ildasm.exe,它可以帮助我们提取PE文件中的有关数据。我们键入命令"ildasm /output:HelloWorld.il HelloWorld.exe",一般可以得到两个输出文件:helloworld.il和helloworld.res。其中后者是提取的资源文件,我们暂且不管,我们来看helloworld.il文件。我们用"记事本"程序打开可以看到元数据和中间语言(IL)代码,由于篇幅关系,我们只将其中的中间语言代码提取出来列于下面,有关元数据的表项我们暂且不谈:
.class private auto ansi beforefieldinit HelloWorld
extends [mscorlib]System.Object
{
.method public hidebysig static void Main() cil managed
{
.entrypoint
// 代码大小 11 (0xb)
.maxstack 1
IL_0000: ldstr "HelloWorld!"
IL_0005: call void [mscorlib]System.Console::WriteLine(string)
IL_000a: ret
} // end of method HelloWorld::Main
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// 代码大小 7 (0x7)
.maxstack 1
IL_0000: ldarg.0
IL_0001: call instance void [mscorlib]System.Object::.ctor()
IL_0006: ret
} // end of method HelloWorld::.ctor
} // end of class HelloWorld
我们粗略的感受是它很类似于早先的汇编语言,但它具有了对象定义和操作的功能。我们可以看到它定义并实现了一个继承自System.Object 的HelloWorld类及两个函数:Main()和.ctor()。其中.ctor()是HelloWorld类的构造函数,可在"HelloWorld.cs"源代码中我们并没有定义构造函数呀--是的,我们没有定义构造函数,但C#的编译器为我们添加了它。你还可以看到C#编译器也强制HelloWorld类继承System.Object类,虽然这个我们也没有指定。关于这些高级话题我们将在以后的讲座中予以剖析。
那么PE文件是怎么执行的呢?下面是一个典型的C#/.NET应用程序的执行过程:
1. 用户执行编译器输出的应用程序(PE文件),操作系统载入PE文件,以及其他的DLL(.NET动态连接库)。
2. 操作系统装载器根据前面PE文件中的可执行文件头跳转到程序的入口点。显然,操作系统并不能执行中间语言,该入口点也被设计为跳转到mscoree.dll(.NET平台的核心支持DLL)的_ CorExeMain()函数入口。
3. CorExeMain()函数开始执行PE文件中的中间语言代码。这里的执行的意思是通用语言运行时按照调用的对象方法为单位,用即时编译器将中间语言编译成本地机二进制代码,执行并根据需要存于机器缓存。
4. 程序的执行过程中,垃圾收集器负责内存的分配,释放等管理功能。
5. 程序执行完毕,操作系统卸载应用程序。
清楚的知晓编译输出的PE文件的执行过程是深度掌握C#语言编程的关键,这种过程的本身就诠释着C#语言的高级内核机制以及其背后Microsoft.NET平台种种诡秘的性质。一个"Hello World !"程序的概括力已经足够,在我们对C#语言有了一个很好的起点之后,下面的专题会和大家一起领略C#基础语言,窥探Microsoft.NET平台构造,步步体验C#锐利编程的极乐世界,Let's go!
1.5 本章小结
Microsoft代表了计算模型的转变,在这个新的计算模型中,所有的设备、服务程序和计算机一起协同工作为用户提供解决方案.这个转变的中心在于.NET框架和CLR的开发.如图1-2所示.NET框架包含那些编译成在CLR中运行的语言所共享的类库.因为C#是专门为CLR设计的.没有CLR和.NET框架类库的话.C#连最简单的输入输出的功能都不能实现.
1.6 实战演练
自己采用记事本编写一个简单的控制台应用程序,然后通过C#编译器进行编译运行。在对其进行反编译,查看MSIL代码,领悟.NET应用程序的运行过程。
第二章 C#基本语法
本章重点:
类型系统
装箱和开箱
常用表达式和操作符
程序流程控制语句
本章目的:
通过本章的学习,我们掌握了.NET平台下的数据类型系统,以及C#编成所需要的基本工具:操作符和表达式的使用。控制程序结构语句的使用。可以编写简单的C#程序。
2.1 类型系统
大多数编程语言都有两种数据类型:一种是语言本身具有的类型(基本数据类型);一种是可以由用户来创建的类型(类)。基本类型就是通常的简单类型,如字符、数字和布尔等;而类则是倾向于更精巧的类型。
使用两种完全不同的类型系统会导致很多问题产生。如兼容性问题,还有就是当您希望指定一个方法使用该语言支持的“任何”类型作为参数值时,由于这些基本类型互不兼容,您就不能指定这样的参数,除非为每一个基本类型编写一个包装类。
值得高兴的是在.NET的世界中就不会有这样的问题。因为.NET的核心是一个公共类型系统。在公共类型系统中一切都是对象,而且所有的对象都是从一个基类中隐性派生出来的。这个基类是公共类型系统的一部分,就是“System.Object”。相关内容在装箱与开箱中详细介绍。
2.1.1 数值类型
当您拥有一个数值类型的变量时,实际上就拥有了一个包含实际数据的变量。因此,数值类型的首要规则就是不能为空(NULL)。如下实例:
int i = 32;
该实例通过C#的公共类型系统在堆栈上分配了一个32位的空间,并把变量名为”i”的值存入了该空间。
在C#中定义了多种数值类型,包括枚举、结构和基本类型等。不管何时声明了一个以上类型的变量,都会在堆栈上分配与该类型相关的字节空间。并且直接与这些已分配的位打交道。此外传递一个数值类型的变量时,您传递的改变量的值,而不是对包含对象的引用。
2.1.2 引用类型
引用类型与C++中的引用类似,即它们都是类型安全的指针。类型安全的引用所引用的并不是一个准确无误的地址,而且它(非空时)总能保证指向指定类型的对象。并且该对象已经被分配在堆上。这里应注意的是引用也可以为空这个事实。如下实例:
string s =”Hello World!”;
该实例分配了一个引用类型(string),事实上是在堆上分配了一个值,并且返回了一个指向该值的引用。与基本数值类型一样,C#也定义了多种引用类型,如类、数组、代理和接口等。不管你何时声明了一个以上类型的变量,都会在堆上分配与该类型相关的字节空间,并且直接与该对象的引用而不是那些已分配的位打交道。
2.2 装箱与开箱
难道C#也是两种不同的数据类型吗?不是。那它是怎么实现类型兼容的呢?那就是“装箱”(boxing)来实现的。最简单的理解就是:装箱就是将数值类型转换为引用类型。相对应的就是引用类型通过“开箱”转换为数值类型。
这项技术之所以如此“伟大”,是因为一个对象在它需要是一个对象的时候,它就仅仅是一个对象。比如:如果您声明了一个System.int32类型的数值类型变量。您可以把它作为参数传递给任何方法,如果该方法的参数类型定义为System.Object,系统会自动执行装箱操作,将它转变成一个Object。对于程序员来说,它和普通的数据类型一样,但可以当作对象来操作。但事实上它只是堆栈上的4个字节而已。如:
int temp = 58; //数值类型
System.Object bar = temp; //temp 被执行装箱操作转变成 对象类型 bar 这是编译器就生成该值装箱所需的MSIL代码。
现在,要把bar转换为数值类型,就可以执行一个显示转换。
int temp = 58;
System.Object bar = temp;
int temp2 = (int)bar;
注意:装箱就是将数值类型转换成引用类型。
开箱就是将引用类型转换成数值类型。(需指明被转换的类型,因为它可以被转换成任何类型)
2.1所有类型的根 System.Object
我们已经说过所有类型最终都是从System.Object类型派生出来的,因此保证了系统中的每一种类型都至少有一套公共功能。
图2-1 System.Object中的公共方法
方法名 描述
bool Equal() 该方法在运行的时候比较两个对象引用以确定它们是否是完全相同的对象,如果两个变量指的是一个对象,则返回true。如果两个变量类型相同并且值也相等,也返回true。反之返回false。
int GetHashCode() 获取指定对象的散列代码。当类的实现程序出于性能考虑需要将对象的散列代码放入散列代码表中时,就需要用到散列函数。
Type GetType() 在反射方法中用来获得对象的类型信息。
string ToString() 默认情况下,这个方法是用来获得对象名字的。它可以由派生类覆盖,以便返回代表对象的对用户更友好的字符串。
图2-2 System.Object中的受保护方法
方法名 描述
void Finalize() 该方法由运行环境来调用,以便在垃圾收集之前进行清除。注意这个方法也许会被调用,也许不会被调用。因此,不要把必须要执行的代码放在这个方法中。这条规则涉及到所谓的确定性结束。
Object Menberwiseclone() 该方法表示对对象的“浅复制”(shallow copy)。浅复制的意思是复制一个包含引用的对象到另一个对象时,不会复制其中被引用的对象。如果需要让您的类支持“深复制”(deep copy)也就是在复制时包含被引用对象的话,您就必须实现(ICloncable)接口,并且自己手动进行复制。
2.3类型转换
强制转换:即显示转换。如前面的开箱操作。
int temp2 = (int)bar;
将System.Object类型的对象bar转换成int型。使用该方法好处是通用性强,任意类型都可以进行转换,缺点是当转换出错时会抛出一个异常。
as 转换操作符:使用as操作符好处是:当执行非法转换时,您不用担心会产生异常。而会返回一个null值。
int temp2 = bar as int;
将System.Object类型的bar对象转换为int类型,如果出现错误则temp2=null;
2.4 表达式和操作符
表2-1 C#操作符优先级(从上至下,由高至低)
操作符类别 操作符
初级操作符 (x), x.y, f(x), a[x], x++, x--, new, typeof, sizeof, checked, unchecked
一元操作符 +, -, !, ~, ++x, --x, (T)x
乘除操作符 *, /, %
加减操作符 +, -
位移操作符 <<, >>
关系操作符 <, >, <=, >=, is
等于操作符 ==
逻辑与 &
逻辑异或 ^
逻辑或 |
条件与 &&
条件或 ||
条件操作符 ?:
赋值操作符 =, *=, /=, %=, +=, -=, <<=, >>=, &=, ^=, |=
2.4.1初级表达式操作符
(x) 这种形式的圆括号操作符被用来控制优先级,要么是在数学表达式中使用,或者在方法调用中使用。
X.y 点操作符被用来指定类或结构的一个成员。其中x代表含有y的实体,y代表x的成员。
f(x) 这种形式的圆括号被用来列出一个方法的参数。
a[x] 方括号用在数组的索引中。方括号也与索引器一起使用,其中可以把对象当作数组来对待。索引器在后边的章节叙述。
x++ x-- 在后边将叙述增量操作符和减量操作符。
new 用来根据类的定义实例化一个对象
typeof 在运行时查找关于类型或对象的信息
sizeof 可以用来得到给定类型的大小,以字节表示。注意:使用时有两点要求。第一,只可以对值类型是用sizeof操作符。因此对于类来说只能使用在类的成员上,不能使用在类自身。第二,该操作符只能使用于标记为unsafe的方法或代码段中。
checked和unchecked 可以控制数学操作的溢出检查。
1. 数学操作符
C#语言支持几乎所有语言都支持的基本数学操作符:乘(*)、除(/)、加(+)、减(-)和取模(%)。前四个操作符意义很明显;取模操作符产生两个数整除后的余数。
2. 一元操作符
C#语言中支持一元操作符加和减。一元减操作符用来告诉编译器操作数应该变为负数。
3. 复合赋值操作符
复合赋值操作符指的是一个二元操作符和一个赋值操作符(=)的组合。语法格式如下:
X op = Y
其中op 表示操作符。表达式等效于下面的表达式:
X = X op Y
4. 增量操作符和减量操作符
增量操作符和减量操作符一开始是从C语言中引入的。然后被C++和java沿用,这两个操作符提供了这样一种简明的操作,即要把一个代表数字值的变量增加1或者是减小1。因此i++就等于把i的当前值加1。
增量操作符和减量操作符现在都有两个版本,通常初学者会混淆。这两种类型通常被称为前缀(prefix)和后缀(postfix),这指出了在何时修改变量。当使用增量操作符和减量操作符的前缀版本时——分别是++i和--i——程序就先执行操作(加1或减1),然后才产生值。当使用增量操作符和减量操作符的后缀版本时——分别是i++和i--——程序就先产生值,然后才执行操作(加1或减1)。如下例:
int i=1;
int j=0;
j=++i; // j=2;i=2
j=i++; // j=2;i=3
可以看出二者区别在于何时产生值以及何时修改操作数。
2.4.2 关系操作符
绝大多数操作符都返回一个数字结果,但是关系操作符不同,它返回的是布尔型结果。它执行的是比较操作书之间的关系,当关系条件为真时返回true,否则返回false。
1.比较操作符
关系操作符中一组被称为比较操作符的操作符包括:小于(<)、小于或等于(<=)、大于(>)、大于或等于(>=)、等于(==)和不等于(!=)。在处理数字时,这些比较操作符的每一个意思都比较明显,但是比较操作符如何处理对象就不是很明显了。当实例化一个对象时,请记住您所得到的只是指向由堆分配的一个对象的引用。因此,当您使用关系操作符来比较两个对象时,C#编译器并不比较两个对象的内容。相反,编译器比较的是这两个对象的地址。那么怎样才能使两个对象按照成员一个个比较呢?答案就在所有的.NET框架对象的隐式基础类库中。类System.Object有一个名为Equals的方法,这个方法专门为这个目的而设计的。
2.4.3 简单赋值操作符
赋值操作符左边的值被称为lvalue;赋值操作符右边的值被称为rvalue。Rvalue可以是任何常数、变量、数字或者是返回值与lvalue相兼容的方法。但是,lvalue必须是一个已定义类型的变量。原因是这个值要从右边复制到左边。因此在内存中必须分配相应的物理空间,这个物理空间就是新值所存放的地方。如:您可以使用语句i=4;因为i代表内存中的一个物理地址,要么是在堆栈中,要么是在堆中,这取决于i变量的数据类型。但是,您不能执行语句4=i;因为4是一个值而不是内存中的一个内容可以被改变的变量。从技术上来说,C#的规则就是lvalue可以是变量、属性或者索引器。属性和索引器的内容在后续章节叙述。
数字的赋值操作很简单,但是涉及到对象的复制就复杂了。记住,在您处理对象时,您并不是在处理简单的、在堆栈中分配的易于复制和移动的元素。当处理对象时,您实际上得到的是一个堆分配上的实体的引用。因而,当您试图把对象(或者任何引用类型)赋值给一个变量时,您并不是像对值类型那样直接复制数据。您只是简单地把一个引用从一个地方复制到另一个地方。
如下例:/Example/Chapter2/2-3-1.cs
using System;
namespace ConsoleApplication1
{
class Foo
{
public int i;
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Foo test1 = new Foo();
test1.i = 1;
Foo test2 = new Foo();
test2.i = 2;
Console.WriteLine("Before Object Assignment:");
Console.WriteLine("test1.i={0}",test1.i);
Console.WriteLine("test2.i={0}",test2.i);
test1 = test2;
Console.WriteLine("After Object Assignment:");
Console.WriteLine("test1.i={0}",test1.i);
Console.WriteLine("test2.i={0}",test2.i);
test1.i = 20;
Console.WriteLine("After Change To Only test1 Menber:");
Console.WriteLine("test1.i={0}",test1.i);
Console.WriteLine("test2.i={0}",test2.i);
Console.Read();
}
}
}
执行结果如下:
Before Object Assignment:
test1.i=1
test2.i=2
After Object Assignment:
test1.i=2
test2.i=2
After Change To Only test1 Menber:
test1.i=20
test2.i=20
2.5 程序流程控制语句
在C#程序中,使您能够控制程序流程的语句有三类:选择语句、迭代语句和跳转语句。在每种语句中都要执行一个检测,这个检测产生一个布尔值,这个布尔值被用来控制程序的执行流程。
2.5.1 选择语句
选择语句可以用来判断执行什么代码以及何时执行这些代码。C#提供了两条选择语句:switch语句根据值来选择运行的代码,而if语句根据一个布尔条件来选择运行的代码。这两个选择语句中最常用的是if语句。
1. if语句
如果表达式的值是true,那么if语句就执行一条或多条语句。If语句的语法如下所示—方括号表示else语句的使用是可选的。
if(expression)
statement1
[else
statement2]
这里的expression是任何可以产生布尔值得表达式或变量。如果expression的值为true,则执行statement1,statement2将不被执行,反之亦然。statement1和statement2即可以是一条语句也可以是多条语句组合。如果是多条语句则需要使用大括号{}括起来。如下所示:
if(expression)
{
statement1;
statement2;
……
statementn;
}
2. 多个else子句
if语句的else子句使得在if的判断语句结果为false时,可以选择替代的一个动作过程。如下所示:
if(expression)
{
statement1;
}
else if(expression)
{
}
else if(expression)
{
}
4. switch语句
利用switch语句您可以指定一个表达式,这个表达式返回一个整数值,程序根据表达式的结果来决定执行代码块儿。当然使用多个if/else语句可以实现与switch同样的功能,区别在于switch语句中只有一个条件语句。而if/else语句中有多个条件测试语句。下面是switch语句的语法:
switch(switch_expression)
{
case constant_expression1:
statement1;
break;
case constant_expression2:
statement2;
break;
……
case constant_expression N:
statement N;
break;
[default]
}
switch语句的执行方式和if/else差不多,也是先求出expression的值,然后根据值来判断执行哪一段代码。
2.5.2 迭代语句
在C#中,语句while、do/while、for和foreach使您能够执行可控制的迭代或者循环。除了foreach以外,在每种情况下,都执行一条特定的简单语句或者是复合语句,直到表达式的计算结果为false为止。而foreach语句通常用于迭代一系列对象。
1. while语句
while语句的语法格式如下:
while(Boolean-expression)
embedded-statement
2. do/while语句
do/while语句的语法格式如下:
do
embedded-statement
while(boolean-expression)
我们可以观察while语句和do/while语句的差别。可以明显的看出while是先判断条件。而do/while语句条件判断在后边,也就是执行一次循环体代码之后。所以差别在于:当条件都为false时,while语句一次都不执行循环体代码,而do/while会执行一次。通常情况下,这两条语句都可以实现同样的功能。相比较而言while语句要更简单一些。具体怎么使用,就看您的个人爱好问题了。
3. for语句
到目前为止,您所见到的最为常见的语句就是for语句。for语句由三个部分组成。第一部分用于在循环开始处执行初始化—这部分只被执行一次。第二部分是条件检验,判断循环是否应该在次运行。最后一部分称为步长,通常用于(但不是必需)对计数器进行增值。而计数器则控制循环的继续—通常在第二部分对这个计数器加以检验。
for语句的语法格式如下:
for(initialization;boolean-expresssion;step)
embedded-statement
注意,这三个部分(initialization;boolean-expresssion;step)中的任何一个部分都可以为空。当布尔表达式的求值结果为false,循环结束。因此,如果只写boolean-expression这一个部分,则for语句的运行方式则和while语句是一样的。
for循环中发生的事情顺序如下所示:
1. 堆栈中分配了一个数值型的变量,并且执行初始化。注意,一旦for循环结束这个变量就不在作用域范围内了。
2. 当第二部分的循环条件结果为真时,执行for语句中嵌入的语句。如果循环体有多条语句时,需要使用大括号{}括起来。如果只有一条语句则可以不用。但为了规范起见,建议都使用大括号。
3. 每循环一次,循环变量就加1。
4. 嵌套循环
在一个for循环的嵌套语句中,还可以有其它的for循环。这样的循环一般被称为嵌套循环。为了说明for循环和嵌套循环,我们编写一个程序输出一个九九乘法表的例子。
如下所示:/Example/Chapter2/2-4-1.cs
public class ForTest {
public static void Print() {
int i=1,j=1;
for(i=1;i<10;i++){
for(j=1;j<=i;j++){
Console.Write(" {0}*{1}={2}",j,i,i*j);
}
Console.Write("\n");
}
Console.Read();
}
}
执行结果如下:
1*1=1
1*2=2 2*2=4
1*3=3 2*3=6 3*3=9
1*4=4 2*4=8 3*4=12 4*4=16
1*5=5 2*5=10 3*5=15 4*5=20 5*5=25
1*6=6 2*6=12 3*6=18 4*6=24 5*6=30 6*6=36
1*7=7 2*7=14 3*7=21 4*7=28 5*7=35 6*7=42 7*7=49
1*8=8 2*8=16 3*8=24 4*8=32 5*8=40 6*8=48 7*8=56 8*8=64
1*9=9 2*9=18 3*9=27 4*9=36 5*9=45 6*9=54 7*9=63 8*9=72 9*9=81
注意:观察本例代码格式和以前的代码格式有所不同吗?可以看到所有的左大括号{都和代码放在了类或者方法的同一行。这使代码变得更简练。那么怎样实现上述功能呢?是我们自己写的吗?不是,C#编译器给我们提供了这样的设置。打开工具菜单—选项—文本编辑器—C#—格式设置。就可以看到右边有一个 将左大括号与构造放在同一行 的选项。选中即可。我们写的代码,编译器会自动把左大括号与构造放在同一行。就是本例所实现的效果。同样方便我们程序调试的还有 常规 选项中的 行号 选项。在后边的调试过程中我们会发现很容易的找出错误代码的位置。
5. 使用逗号操作符
逗号操作符不仅可以在方法参数中起到分隔参数的作用,而且还可以作为for语句的一个操作符。在for语句的initialization和step部分,逗号操作符可以被用来对多条语句进行分界。我们并不提倡这样的写法,因为它会带来一些麻烦。所以在这里不多叙述。
4.2.6 foreach语句
多年以来,像Visual Basic这样的语言已经有了一些特殊的语句。这些语句专门用来对数组和集合进行迭代。C#也有这样的结构。就是foreach语句。格式如下:
foreach(type in expression)
embedded-statement
看如下示例程序:/Example/Chapter2/2-4-2.cs
public class ForeachTest{
System.Collections.ArrayList ar;
public ForeachTest(){
ar = new System.Collections.ArrayList();
ar.Add("语文");
ar.Add("英语");
ar.Add("数学");
ar.Add("政治");
}
public void Print(){
foreach (string temp in ar)
{
Console.WriteLine(temp.ToString());
}
Console.Read();
}
}
运行结果如下所示:
语文
英语
数学
政治
本例使用foreach语句实现了数组的遍历。其实采用前面学过的for循环语句同样可以实现相同的功能。相比foreach语句,for循环语句可能会出现哪些问题呢?留给大家思考。
2.5.3 跳转语句
在前面部分所涉及到的任何迭代语句中的嵌入语句内部,您都可以利用几条语句来控制程序的执行流程,这些语句称为跳转语句:break、continue、goto和return。
1. break语句
使用break语句可以中断当前的循环或者循环中出现的条件语句。然后控制流程就被传递到紧接着循环或者条件语句的嵌入语句后面的第一行代码处执行。此外break语句还可以用来中断一个无穷循环。
2. continue语句
同break语句一样,continue语句也可以改变循环的执行。然而,continue语句不是中止当前循环中嵌入的语句,而是停止当前的循环并把控制返回到当前循环的顶部,进行下次循环。
在下面的例子中,我们先声明一个数组列表,然后进行初始化。注意有一部分数据是重复的,我们用嵌套循环来找出重复的数据项,然后把它存到temp数组中,最后输出。注意在嵌套循环内部,如果两个索引相等时,表明它们是同一个数据,这种情况我们肯定不能让它进行比较。我们采用continue语句继续执行下一次循环。
代码如下:/Example/Chapter2/2-4-3.cs
using System;
namespace ConsoleApplication1
{
public class ContinueTest
{
System.Collections.ArrayList ar;
public ContinueTest()
{
ar = new System.Collections.ArrayList();
ar.Add("语文");
ar.Add("英语");
ar.Add("英语");
ar.Add("数学");
ar.Add("政治");
ar.Add("语文");
}
public void Print()
{
System.Collections.ArrayList temp = new System.Collections.ArrayList();
for (int i=0;i<ar.Count;i++)
{
for (int j=0;j<ar.Count;j++)
{
if (i==j)
continue;
if(ar!=ar[j] && !temp.Contains(ar[j]))
{
temp.Add(ar);
}
}
}
for(int n=0;n<temp.Count;n++)
{
Console.WriteLine(temp[n]);
}
Console.Read();
}
}
}
执行结果如下:
语文
英语
数学
政治
3. 使用goto语句
在结构化编程中,由于goto语句破坏了结构化思想,因此一直以来遭到大多数人的非议。认为只要是关于goto语句的所有方面都是不好的。其实不是,在C#中同样支持goto语句。在某些情况下反而使用goto语句更为简单和容易理解。
C#支持如下三种格式得goto语句:
goto identifier
goto case constant-expression
goto default
在goto语句的第一种用法中:identifier的目标是一条标签语句。标签语句采用如下格式:
identifier:
如果当前方法中标签不存在,就会产生编译器错误。另外要记住的一条很重要规则就是:goto语句可以被用来跳出一个嵌套循环。然而,如果goto语句不处于标签的作用域范围以内,那么就会发生一个编译时的错误。因此,您不可能跳转进入一个嵌套循环。关于goto语句的更多文章,在这里就不多说了,在使用时请读者自行决定。
4. return语句
return语句有两个功能。这条语句指定一个返回值,这个值被返回给调用当前代码的函数。如果没有定义返回值,则默认返回null值。return语句将导致程序直接返回到函数调用处。
语法格式如下:
return [return-expression]
如果return语句后被指定一个表达式,在编译器执行时将先对编译器进行求值,然后隐式地将值得类型转换成该方法所定义的返回值类型。转换后的结果被传递回调用当前代码的函数。
如果使用return语句时还使用了异常处理,需要理解其执行规则。如果return语句位于一个try块,该try块又含有相应的finally块,return语句执行时将传递到finally块的第一行代码执行。执行完毕后,把结果返回到调用函数。如果try块被嵌套在另一个try块中,控制将按这种方式进行执行。直到执行到最后的finally块为止。
2.5 本章小结
公共类型系统是。NET框架的一个重要特征。其定义了应用程序想要在CLR中正常运行所必须遵循的类型系统规则。公共类型分为两类:引用类型和数值类型。公共类型系统的好处是语言互用性、单根的对象层次结构以及类型安全。在C#中的类型可以使用装箱和开箱来进行转换,以便兼容的类型共享某些特性和功能。
对任何一门语言都很关键的一部分就是处理赋值、数学、关系和逻辑操作的方式。这些操作是处理现实中的应用所必须的。在代码中通过操作符来控制这些操作。影响代码中操作符的作用的就是优先级。C#除了提供一个强大的预定义的操作符集外,还可以通过用户的实现来进行扩展。即所谓的操作符重载,将在以后进行讨论。
C#条件语句使您能够控制程序的流程。流程语句的三个不同分类包括选择语句(如if和switch)、迭代语句(如while、for和foreach),以及各种不同的跳转语句(break、continue、goto和return),在使用时灵活运用,可以构造出结构更为清晰,并且更易于维护的应用程序。
2.5 实战演练
1、编写一个控制台应用程序测试所学的装箱和开箱操作,以及常用操作符的作用。
2、分别编写不同的程序来练习程序流程控制一节中的if/else、switch、for、while、foreach、break、continue、goto、return语句。
第三章 面向对象编程的基础知识
本章重点:
对象和类
实例化
封装
继承
多态性
本章目的:
通过本章的学习,带领您熟悉面向对象编程(Object-Oriented Programming,OOP)的各种术语,并使您认识到面向对象的概念对于编程的重要性。
许多编程语言,诸如C++和Microsoft Visual Basic,都声称自己是“支持对象”的,但实际上只有少数几种语言真正完全支持构成面向对象编程的所有原则。C#就是其中一种,它从最底层开始设计,是一种真正面向对象。基于组件的编程语言。因此,为了最大程度地从本书获益。您需要牢固掌握本章提出的概念。
读者通常会跳过类似这样概念性的章节而一头扎入代码之中,但是,除非您认为自己已经是“对象通”,否则,建议您还是先认真阅读本章,对于那些对面问对象编程似是而非的读者来说,肯定能从本章中学有所获。另外,别忘了本章后面的章节也会引用本章讨论的术语和概念。
前面已经说过,许多语言都被称为是面向对象或基于对象的,但实际上只有少数几种才真正是。C++不是,因为它的根深植于C语言言之中。这是不可否认、无法回避的事实。在C++中为了支持传统的C代码。已经牺牲了太多的OOP理想。即便是Java语言,尽管它已经够好了,但作为一种面向对象的语言,它仍然是有限的。比如,作者可以指出这样一个事实:在Java中存在简单类型和对象类型,它们的处理方式和行为模式都非常不同。但本章的重点并不是比较不同的语言对OOP原则的忠诚度。而是提供一个关于OOP原则本身客观的、与语言无关的指南。
在开始之前,需要指出的是:面向对象编程并非一种营销的招牌(尽管对于某些入来说是),也不是一种新的语法或一种新的应用编程接口(Application Programming Interface API)。面向对象编程是一套全新的概念和想法。它是一种用计算机程序来描述实际问题的新思路,也是一种更直观、效率更高的解决问题的方法。
回顾一下我们学过的各种语言,只要我们学懂了一种语言,后续的每种语言不论其复杂程度。学习过程都是越来越短。这是因为直到开始用C++编程为止,我们用到的所有语言都是一种结构化语言,主要的区别仅仅在于语法的不同而已。
不过,如果您是初次按触面向对象编程的话,您应当注意:您以前学过的非面向对象语言在这里不会有所帮助。面向对象编程在如何设计和实现问题的解决方案时,采取的是一种完全不同的思路。事实上有研究表明,在学习面向对象语言时,编程新手比学过诸如BASIC、COBOL和C这些结构化语言的人要快得多。因为他们不需要克服任何有碍于理解OOP的结构化学习,他们就像一张白纸,是从头学起。如果您已经具有使用结构化语言编程的多年经验,而C#又是您接触的第一种面向对象语言的话,您能得到的最好的建议就是保持开放的心胸,并且努力实现作者在这里提出的想法,而不是在心中暗想:“哼,我也能做到(当然是使用结构化语言)!”任何一个从结构化背景转向面向对象编程的人都会经历这样一个过程,而这也是值得的。使用面向对象语言编程的好处是无法估量的,无论是更有效地书写代码方面。还是构造一个更易修改和扩展的系统方面。这样的好处一开始可能看不到。那么只要正确运用,OOP的概念的确能够实现它们的承诺。
3.1一切都是“对象”
在真正的面向对象语言中,所有的问题域(problem domain)实体都是通过对象(叫Object)的概念表达出来的。您也许已经猜到。对象就是面向对象编程的核心所在。在面向对象编程中,我们不会拐着弯儿地去想什么结构、数据包、函数调用和指针,通常只考虑对象这个概念。先来看一个例子。
假设您在编写一个开发票的应用程序,您需要对发票上的明细栏进行计算。从用户的角度出发,以下哪一种思路是更直观的呢?
● 非面向对象思路
用一个数据结构来表示发票的抬头,这个发票的抬头结构包括一个指向发票明细栏结构的双向链表(linked list),每个发票明细栏结构包含该明细栏的小计。这样,要计算出发票的总计,需要再声明一个变量,就把它叫做totalInvoiceAmount吧,并将它初始化为0;使用一个指针指向发票的抬头结构,得到明细栏链表的头指针,然后遍历这个链表;访问每个明细栏结构时,将包含该明细栏小计的成员变量值加入到totalInvoiceAmount变量之中。
● 面向对象思路
使用一个发票对象,发送一条消息给该对象,要求得到发票总计。我们并不需要考虑在对象内部信息是怎样存储的。而在非面向对象数据结构中却必须考虑这一点。我们只需以一种自然的方式来对待这个对象。也就是用发送消息的方式来向它做出请求(对象能处理的消息综合起来叫做该对象的接口,下面将解释在面向对象思路中为什么只考虑接口而不是具体实现是合理的)。
显然,面向对象思路更直观,也更接近于大多数人解决问题的方式。在面向对象思路中,发票对象可能会包含多个发票明细栏对象,并对每个明细栏对象发送消息要求得到其小计。但是,尽管您需要得到总计,但却不必关心它到底是怎样得到的。这是因为面向对象编程的一个主要原则就是封装——对象能够将其内部的数据和方法隐藏起来并且提供一个接口。这个接口就是能够访问该对象的重要组成部分。只要对象能够完成任务。在其内部究竟怎样完成任务就不重要了。您只需了解对象的接口部分,然后利用接口使对象执行给定的任务即可(本章的后续部分还将进一步解释封装和接口的概念。上述的例子表明了这样一道理:编程时如果能够对问题域的现实世界对象加以模拟,程序编写起来将会十分容易,因为我们的思路更加贴近正常的思维方式。
注意在第二种思路中要求对象执行给定的任务——也就是计算出明细栏的总计。一个对象并不只包含数据,而结构却只包含数据。从定义上来看,对象由数据和加工数据的方法组成。这意味着对于—个问题域。我们能做的就不只是设计必要的数据结构了。我们还可以看看哪些方法可以附加在给定的对象上面,以便该对象成为功能齐全的封装结构。下面的示例以及后面章节中的示例都有助于说明这个概念。
注意:本章中的代码片断用于展示面向对象编程的概念。要注意书中出现C#代码片断时,这些概念本身对于OOP来说都是通用的,并不只特定于某一种编程语言。在本章中为了便于比较,也列出了C语言代码。而它并不是面向对象的。
比如说您正在编写一个应用程序,用来计算您的新公司中唯一雇员Amy的薪酬。如果使用C语言,您可能这样书写代码以将相关的数据与雇员联系起来:
下面是使用EMPLOYEE结构来计算Amy薪酬的代码;
struct EMPLOYEE{
char szFirstName[25];
char szLastName[25];
int iAge;
double dPayRate;
}
void main()
{
double dTotalPay;
struct EMPLOYEE* pEmp;
pEmp = (struct EMPLOYEE*)malloc(sizeof(struct EMPLOYEE));
if (pEmp)
{
pEmp->dPayRate = 100;
strcpy(pEmp->szFirstName, "Amy");
strcpy(pEmp->szLastName, "Anderson");
pEmp->iAge = 28;
dTotalPay = pEmp->dPayRate * 40;
pzintf("Total Payment for %s %s is %0.2f",
pEmp->szFirstName, pEmp->szLastName, dTotalPay);
}
free(pEmp);
}
在这个示例中,代码包含了结构中的数据和一些使用该结构的外部代码(相对于该结构而言)。这样会出现什么问题呢?主要问题之一就在于抽象:EMPLOYEE结构的使用者必须了解很多有关于雇员的数据:为什么?比如说以后如果您想要修改计算Amy薪酬的税率的话,假设您想将美国联邦社会保险捐款FICA和其他各种税赋考虑进来以便决定税后工资。这时您不仅必须修改所有使用EMPLOYEE结构的代码,而且需要做出文档记载——为您公司将来任何一位程序员——说明该程序在使用中已经发生过改变。
下面我们来看看C#是怎样解决这个问题的:/Example/Chapter3/3-1-1.cs
using System;
namespace ConsoleApplication1
{
public class Employee
{
protected string firstName;
protected string lastName;
protected int age; //年龄
protected double payRate; //费用
public Employee(string firstName,string lastName,int age,double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate;
}
public double CaculatePay(int hoursWorked)
{
//计算工资
return payRate*(double)hoursWorked;
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Employee emp = new Employee("Amy","Anderson",38,100);
Console.WriteLine("Amy's pay is ${0}",emp.CaculatePay(40));
}
}
执行结果如下:
Amy's pay is $4000
在上面这个C#版本的示例中。使用对象的程序员只需简单地调用该对象的CaculatePay方法就能计算出该对象的薪酬。这种思路的优点就是使用者不再需要担心具体薪酬是怎样计算出来的。如果将来您决定修改计算薪酬的方法。那么做出的修改对于已存在的代码不会有任何影响。这种程度的抽象就是使用对象的一个基本好处。
可能有人会说,创建一个访问EMPLOYEE结构的函数也可以实现C语言代码的抽象。然而问题就在于创建的这个函数与该函数要处理的数据结构是完全分离的。而使用C#这样的面向对象语言时,对象的数据和加工数据的方法(即对象的接口)总是在一起。
另外要记住只有对象的方法才能修改对象的变量。如前面的示例中所见,每个Employee成员变量声明时都带有“protected”访问限定符,除了定义为“public”的方法CaculatePay以外。访问限定符用来指定派生类和客户代码可以访问给定类成员的级别。如果指定“protected”限定符,则派生类可以访问该成员,而客户代码则不能访问。限定符“public”表示派生类和客产代码都可以访问该成员。在后面的章节中我们还将详细讨论访问限定符,现在要记住的关键问题就是限定符可以保护关键的类成员不被误用。
3.1.1 对象和类
类和对象之间的区别常常是刚接触面向对象编程的程序员们容易混淆的地方。为了说明这两个术语之间的不同。我们使用上面的示例更现实一些,假设不是处理一个雇员的数据,而是处理整个公司所有雇员的数据。
使用C语言,我们可以定义一个基于EMPLOYEE结构的雇员数组;因为我们并不清楚将来公司到底会有多少雇员,只好创建一个包含固定个数元素的数组,假设其大小是10000,但是,如果目前公司只有Amy这一个雇员的话,这样使用资源就显得很浪费。因此,我们通常会创建一个EMPLOYEE结构的链表。以便在应用程序中动态分配内存。
但是,我们现在正在做的可能恰恰是不应该由我们做的。我们在语言和机器——即分配多大的内存和何时分配内存——上绞尽脑汁,而不是专注于问题域本身。使用对象则有助于我们将精力集中在解决问题必需的逻辑思考上。
要定义一个类并将它与对象区别开来有多种方法。您可以将类简单地看作是一个拥有相关方法的类型(就像char、int或long一样),而对象就是类型或类的一个实例。但通常的定义却是:类是对象的一个蓝图。作为开发者的您描绘出这幅蓝图,就像工程师描绘出房子的蓝图一样。一旦完成蓝图。您就可以按照蓝图建造多幢房子。同时。别人也可以购买您的这幅蓝图从而建造相同的房子。同样的道理。类就是给定功能集的蓝图,基于特定类创建的对象就拥有该类集成的所有功能。
3.1.2 实例化
实例化(instantiation)是面向对象编程的专用术语,表示创建类的一个实例。这个实例就是一个对象。在下面的例子中,我们所做的一切就是为对象创建一个类。换句话说,这个阶段不涉及内存分配。因为我们只拥有对象的一个蓝图,而没有实际的对象。
public class Employee
{
protected string firstName;
protected string lastName;
protected int age; //年龄
protected double payRate; //费用
public Employee(string firstName,string lastName,int age,double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate;
}
public double CaculatePay(int hoursWorked)
{
//计算工资
return payRate*(double)hoursWorked;
}
}
为了实例化这个类并使用它,我们还必须在方法中声明一个该类的实例
static void Main(string[] args)
{
Employee emp = new Employee("Amy","Anderson",38,100);
}
在这个例子中,emp被声明为Employee类型并且使用操作符“new”将它实例化。变量emp就表示Employee类的一个实例并被看作是一个Employee对象。实例化之后,我们就可以通过其公共成员来与这个对象进行通信了。比如。我们可以调用emp对象的CaculatePay方法,而在没有实际对象之前我们不能这样做(有一个例外,那就是处理静态成员时,在后面章节我们将讨论静态成员)。
下面我们来看看以下c#代码:
static void Main(string[] args)
{
Employee emp = new Employee();
Employee emp2 = new Employee();
}
这里我们有同一个Employee类的两个实例——emp和emp2。从表面上看。每个对象的功能相同。每个实例都可以包含它自己的实例数据并被单独处理。同样的道理。我们可以创建一个包含这些Employ既对象的数组或集合。这里是指大多数面向对象的语言都能够定义对象数组。这使得您能够很轻松地组织对象。调用对象数组的方法或者使用数组的下标。将这与链表比较一下吧,在链表中您必须自己将链表中的每个条目与它前后的条目一一连接起来。
3.2 面向对象编程语言的三大原则
C++编程语言的作者认为。一种语言必须支持对象、类和继承这三个概念,才可以称得上是面向对象的。但实际上,面向对象语言已经被更普遍地认为是建立在封装、继承和多态性这三者之上的。之所以发生这种观念上的转变,是因为这么多年以来,我们逐渐认识到封装和多态性与类和继承一样。是构成面向对象系统不可缺少的组成邰分。
3.2.1 封装
在前面提到过“封装”。有时也叫做“信息隐藏(information hiding)”,能够将对象的内部隐藏起来,只提供一个接口给对象的使用者,使得只有那些能被直接操作的成员才能被使用。但同时也提到了“抽象”这个概念,在这一节里将澄清有关这两个相似概念之间容易混淆的地方。封装提供的是一道屏障,将类的外部接口(也就是类的使用者能看到的公共成员)与它的内部实现细节分隔开来。封装的优点在于类的开发者可以将静态的、不变的成员公开,而将那些动态的、容易改变的类成员隐藏起来。如本章先前所示。在C#中通过赋予每个类成员访问限定符public、private或者protected就可以实现封装。
1. 抽象设计
“抽象”指的是在程序空间中一个给定的问题是如何表示出来的。编程语言本身就提供抽象。试想:您上一次考虑如何使用CPU的寄存器和堆栈是什么时候?即使您最初学的是用汇编语言编程。您也一定有很长时间没有考虑过这样底层而且是针对特定机器的细节了。这是因为大多数编程语言都己将这些细节为您抽象出来了,使得您可以集中精力于要解决的问题域本身。
面向对象语言使得声明的类所具有的名称和接口都类似于现实世界中的问题域实体,因此使用对象时就有了更加自然的“感觉”。去掉那些与当前解决问题没有直接关联的因素之后,您就可以专注于问题本身,工作效率自然也就更高。但实际上,解决大多数问题的能力通常会由所采用的抽象质量决定。
当然,那是另外一个层次上的抽象,更进一步,作为一个类开发人员,您必须考虑到如何没计出最好的抽象,以便用户在使用类时能够集中精力于当前要解决的问题。而不陷入您的类是怎样工作这样的细节之中。从这一点上说。“类的接口怎样与抽象关联”是个很好的提问,类的接口实际上就是抽象的实现。
下面将讲一个大家熟悉的编程课上的类比。以便明确这些概念。这个类比就是自动售货机的内部工作原理。自动售货机的内部实际上非常复杂,为了完成它的职责。自动售货机必须能够接受现金和货币,找零钱,找到并送出顾客想要的东西。然而,自动售货机需要展示给顾客的却只是有限的几个功能。通过一个硬币榴、选择货品的按钮、要求找零的拉杆、放零钱的槽和发送选定物品的出口,即可表达出自动售货机展现给顾客的接口。以上每一项都表示了该机器接口的一部分。自动售货机自发明以来,大体上都保留了这样相同的功能。尽管随着技术的进步,自动售货机的内部也发生了很大的变化,但是其基本的接口部分却并不需要改变太多。设计类的接口时很重要的一步就是要深入彻底地理解问题域本身。这种理解将有助于您创建的接口既能让用户访问他们需要的信息和方法。同时也能将他们隔离在类的内部工作之外。设计出来的类不仅要能解决今天的问题,同样也要从类的内部中充分地抽象出来,以便私有类成员能够在不影响己存在代码的前提下自由修改。
设计类的抽象概念时另一个同等重要的方面是始终考虑客户程序员的方便。假设您设计了一个通用的数据库引擎,如果您是一个数据库方面的专家,您可能对游标、委托控制和元组这样的术语会很熟悉。但是,大多数不熟悉数据库编程的开发人员可能对这些术语就不甚了解。使用这些对类的使用者而言很陌生的术语的行为,其实完全违背了抽象的目的——即通过用自然术语代表问题域以便提高程序员的工作效率。
另外一个需要考虑到用户的地方是决定哪些类成员应该被公共访问。同样,这时应该多一点对问题域的理解和多为类的用户想想。仍以数据库引擎为例。您可能不会希望用户直接访问表示内部数据缓冲器的成员,这样将来就能轻易地更改这些数据缓冲器的定义,另外。由于这些缓冲器对于引擎整个的操作至关重要。您可能就会确保只能通过您的方法来修改它们,这样就能保证采取必要的预防措施。
注意: 您可能会想到面向对象系统主要是为方便创建类而设计的。尽管这个特点的 确能够带来生产效率的短期提高,但是只有认识到引入OOP的目的在于为使用类的程序员提供方便以后,才能带来长期效益。因此在设计类的时候。始终不要忘记考虑那些将要实例化您所创建的类或从您创建的类中派生类的那些程序员。
2. 良好抽象的益处
开发可复用(reusable)的软件过程中,将类抽象成一种对将使用这些类的程序员最有用的方式是极其重要的。如果开发的接口在实现方式改变之后仍能保持稳定和不变的话,那么随着时间的推移,您的应用程序也就很少需要修改。还是以先前的计算薪酬的示例代码为例,在Employee对象和计算功能中,只有少数几个方法是相关的,如:CaculatePay、GetAddress和GetEmployeeType。如果您熟悉实际的计算薪酬方法,在很大程度上就能轻松地确定使用这个类的用户将需要哪些方法。既然如此。如果在设计这个类的时候,您把对问题域的理解与远见融合在设计之中的话。当然就有理由保证在这个类的实际实现中。不管将来如何变化,类的大部分接口仍然可以保持不变。总之,从用户的角度看来,它就是一个Employee类而己。而站在用户的立场上,当然也是希望在版本更迭过程中类最好不要有什么变化。
将用户和实现细节分离还能使得整个系统易于理解和维护。在结构化语言,如C中。每个模块都需要一个明确的名称并且要访问一些给定结构的成员,这样,每当这些结构的成员发生变化时,引用这些结构的每一行代码都必须随之改变。
3.2.2 继承
“继承”指的是程序员指定的一个类与另一个类之间的某种关系。通过继承,您可以创建(或派生)一个新的类,而它以已存在的类为基础。然后您可以随心所欲地修改这个新类并且创建这个派生类型的新的对象。这个功能就是构建类层次结构的精华所在。除了抽象,在系统整体设计中继承就是最重要的部分了。派生类是被创建的新类,基类就是新类从其派生的那个类。新创建的派生类继承了基类的所有成员,因此您可以重新利用以前的工作成果。注意:在C#中,哪些基类成员将被继承取决于成员定义时所用的访问限定符。在后面我们将详细讨论。这里为了便于讨论,您可以暂时认为派生类将继承其基类的所有成员。
还是以先前的Employee为例。我们来看看何时且如何使用继承。在那个示例中,我们可能会雇用不同类型的雇员。如正式工、合同工和小时工。虽然所有这些Employee对象的接口一样,但在内部它们的功能却会有很大的不同。比如,Cal01atePay方法对正式工和合同工的计酬方法就不一样,但我们还是希望不管雇员是何种类型。他们的CaculatePay接口仍然一样。
如果您是刚接触面向对象编程,您可能就会想到:“这里我为什么要用对象?用一个带有雇员类型成员的EMPLOYEE结构和一个类似的函数?”
如下所示:
Double CaculatePay(Employee* pEmployee,int iHoursWorded)
{
if(pEmployee->type==SALARIED)
{
//正式工薪酬计算方法
}
else if(pEmployee->type==CONTRACTOR)
{
//合同工薪酬计算方法
}
else if(pEmployee->type==HOURLY)
{
//小时工薪酬计算方法
}
//return one of the salary
}
这段代码存在几个问题。首先,CalculatePay函数是否能成功调用与EMPLOYE结构关系紧密。前面已经说过,这样紧密的关系会导致EMPLOYEE结构的任何变化都会影响到这段代码。作为面向对象的程序员,最不想做的事情就是给类的用户增加深入了解设计类的细节这样的负担。这就好比是自动售货机生产厂家要求您在购买汽水之前了解自动售货机的内部工作原理一样。
其次,这段代码不便于复用。一旦您开始了解继承是如何为复用提供方便之后,您就会意识到类和对象确实不错。在这里,您只需简单地定义基类的所有成员。而不用考虑雇员的类型。派生类会继承这些成员然后做出必要的修正。
下面来看看C#是怎样解决这个问题的:/Example/Chapter3/3-1-2.cs
public class Employee{
protected string firstName;
protected string lastName;
protected int age; //年龄
protected double payRate; //费用
public Employee(string firstName,string lastName,int age,double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate;
}
public double CaculatePay(int hoursWorked)
{
//计算薪酬
return payRate*(double)hoursWorked;
}
}
class SalariedEmployee:Employee
{
public string SocialSecurityNumber; //社会保险
public double CaculatePay(int hoursWorked)
{
//正式工薪酬计算
}
}
class ContractEmployee:Employee
{
public string FederalTaxId; //税率
public double CaculatePay(int hoursWorked)
{
//合同工薪酬计算
}
}
这个例子中的三个特点值得关注:
基类Employee中定义了一个名为Employee的相关信息,它被SalariedEmployee和ContractEmployee两个类所继承,但这两个派生类与这个成员一点关系也没有——它们只是从Employee基类中派生时顺带地自动继承了它。
两个派生类都实现了它们自己的CaculatePay方法。但是,您会注意到它们都继承了同样的接口。因此,尽管它们根据自己的需要修改了内部的代码,但是用户的代码却无需改变。
两个派生类都向从基类继承的成员中添加了新的成员。SalariedEmployee类定义了一个SocialSecurityNumber字符串,而ContractEmployee类则包含了一个对FederalTaxId成员的定义。
从这个小小的示例中您已经看到:继承使得您可以通过从基类中继承功能而实现代码复用:更进一步。它使您可以通过添加自己的变量和方法来扩展类。
定义适当的继承
为了说明适当继承的重要性。我们将引用Marshall Cline和Greg Lomow合著的《C++FAQs》(Addison-Wesley,1998)中的一个术语:可替代性(substitutabiolity)。可替代性表示派生类的已通告行为对于基类来说是可替代的:仔细想想这句话——这就是您在学习创建有效的类层次结构时最重要的—个规则(“有效”表示经受得住时间的考验并且实现OOP可以复用和扩展代码的承诺)。
在创建类层次结构时另一个需要牢记的重要规则是,派生类在对待继承的接口时应该不比它的基类要求多而承诺少。不遵守这条规则将破坏已存在的代码。类的接口就是类和使用这个类的程序员之间的一个捆绑合同。当程序员引用派生类时,总是可以将该派生类当作其基类一样看待。这就叫做“向上转换”(upcasting)。在我们先前的例子中,如果用户引用了一个ContractEmployee对象,那么它实际上也隐性引用了该对象的基类一一Employee对象。因此,按照定义,ContractEmployee总是应该完成其基类能够完成的功能。请注意这条规则只应用于基类的功能。派生类可以选择添加功能,而随着它的要求和承诺增加,这些功能可能会越来越有限。因此,这条规则只应用于继承的成员。因为己存在代码只与这些成员之间有合同限制。
3.2.3 多态性
本人认为关于多态性(polymorphism)最好和最简洁的定义是:多态性就是允许老代码调用新代码的能力。这当然是面向对象编程最大的好处了,因为这样就可以在不修改或破坏已存在代码的前提下扩展或加强您的系统。
现在假设您编写了一个方法需要遍历一组Employee对象,调用每个对象的CaculatePay方法。在您的公司只有一种雇员类型的时候,这个方法很有效,因为每当增加雇员时您只需将确切的对象类型加入到集合中即可。但是。如果您开始雇佣别的类型的雇员时情况将会怎样呢?比如,如果您使用了Employee类并且它实现了正式工的计酬方法,那么在您开始雇佣合同工时又该怎么办呢?毕竟合同工的工资计算方法是不同的。在结构化语言中,您可能就需要修改函数以便处理新的雇员类型。因为旧代码不可能知道如何处理新代码。而面向对象的解决方案就可以通过多态性来处理类似这样的问题。
还是以前面的例子为例,您定义了一个名为Employee的基类,然后为每一种雇员类型定义了一个派生类(如前所见),每一个派生的雇员类都有其自己对CaculatePay方法的实现。这时奇迹发生了。利用多态性,当您使用一个指向对象的己向上转换的指针调用该对象的方法时,程序运行时会自动保证调用正确版本的方法。
看如下代码所示:/Example/Chapter3/3-2-1.cs
using System;
namespace ConsoleApplication1
{
// 本示例中的计算工资并没有引入具体数据,我们只是通过他们输出的不同内容来演示 多态性 的作用。
public class Employee
{
protected string firstName;
protected string lastName;
protected int age; //年龄
protected double payRate; //费用
public string EmployeeID; //员工编号
public Employee(string firstName,string lastName,int age,double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate;
}
public virtual double CaculatePay(int hoursWorked)
{
//计算工资
Console.WriteLine("Employee.CaculatePay");
return 0;
}
}
class SalariedEmployee:Employee
{
//正式工
public SalariedEmployee(string firstname,string lastname,int age,double payrate)
:base(firstname,lastname,age,payrate)
{
}
public override double CaculatePay(int hoursWorked)
{
Console.WriteLine("SalariedEmployee.CaculatePay");
return 0;
//return payRate * (double)hoursWorked;
}
}
class ContractEmployee:Employee
{
//合同工
public ContractEmployee(string firstname,string lastname,int age,double payrate)
:base(firstname,lastname,age,payrate)
{
}
public override double CaculatePay(int hoursWorked)
{
Console.WriteLine("ContractEmployee.CaculatePay");
return 0;
}
}
class HourlyEmployee:Employee
{
//小时工
public HourlyEmployee(string firstname,string lastname,int age,double payrate):base(firstname,lastname,age,payrate)
{
}
public override double CaculatePay(int hoursWorked)
{
Console.WriteLine("HourlyEmployee.CaculatePay");
return 0;
}
}
}
主程序如下:
static void Main(string[] args)
{
Employee[] emp; //声明一个Employee类型的数组
Console.WriteLine("Load Employees......");
emp = new Employee[3];
emp[0] = new SalariedEmployee("Amy","Anderson",28,100); //实例化一个正式工对象
emp[1] = new ContractEmployee("John","Mafei",35,110); //实例化一个合同工对象
emp[2] = new HourlyEmployee("Lan","Ma",32,10); //实例化一个小时工对象
//遍历数组,计算薪酬
foreach(Employee tempemp in emp)
{
tempemp.CaculatePay(5);
}
Console.Read();
}
编译并运行这个程序将产生以下输出结果;
Load Employees......
SalariedEmployee.CaculatePay
ContractEmployee.CaculatePay
HourlyEmployee.CaculatePay
多态性至少提供了两个好处,第一,它让您可以将具有共同基类的对象组成一组并对它们进行一致处理,在上面的例子中,尽管从技术上我们已经拥有三个不同的对象类型——SalariedEmployee、ContractEmployee和HourlyEmployee——但我们仍可将它们都作为Employee对象对待。这是因为它们都是从Employee基类中派生出来的。这样就可以把它们都放入定义为Employee对象数组的数组之中。由于多态性的存在,当调用这些对象的方法时,程序运行时就会保证调用正确的派生对象的方法。
第二个好处是曾在本节开始时提到的:老代码可以调用新代码。注意主程序中的foreach循环反复调用了由Employee对象组成的成员对象数组。由于这个方法调用对象时实际上隐性调用了己向上转换的Employee对象,而在运行时程序的多态性又保证会调用正确的派生类的方法,因此。我们可以向系统添加别的雇员类型,将它们插入到Employee对象数组之中即可,而应用程序却可以照常运行,无需更改任何源代码。
3.4 本章小结
本章带领您快速浏览了面向对象编程中的术语和概念,关于这个主题我们后面还有篇幅会加以深入研究。因此,对于本书的重点而言,它可能有点喧宾夺主了。但是,牢固掌握面向对象编程的基础知识之后,您才可以更轻松地学习C#语言。
在本章中我们接触到了很多新观念。理解面向对象系统的关键就是要弄明白类、对象和接口之间的区别,以及这些概念与有效的解决方案之间的联系。好的面向对象解决方案同样有赖于深刻理解并实现面向对象编程的三大原则:封装、继承和多态性。本章提出的概念为下一章打下了一个基础。
3.5 实战演练
1.想想生活中都有哪些类和对象的具体实例。又有哪些实现了很好的封装、继承以及多态性呢?
第四章 类
本章重点:
定义类
类的成员
访问限定符
构造函数
常量和只读字段
继承和封装
本章目的:
通过本章的学习,我们可以定义类。通过访问限定符来实现类的封装以及构造函数的概念及作用。了解类的继承、封装特性。
类是每一种面向对象编程语言的灵魂。我们在第三章中已经提到。类就是数据和加工数据的方法的一个封装。这是在任何面向对象语言中都“颠扑不破的真理”。而一种面向对象语言与其他面向对象语言的不同之处就在于作为成员存储的数据的类型和每一种类的功能。C#在有关类和其他许多功能上都从C++和Java处借用了一些概念.但同时又以它的独创性为一些旧的问题提出了新颖的解决方案。
在本章,我们将首先介绍在C#中定义类的一些基本知识。包括实例成员、访问限定符。构造函数以及初始化列表,然后将介绍静态成员和常量与只读字段之间的区别。
4.1 定义类
在C#中定义类的语法很简单。特别是对于经常在C++或Java中编程的人来讲。您只需在类的名称前加上关键字“class”,然后在大括号之间插入该类的成员即可,如下所示:
class Employee
{
private long employeeId;
}
如您所见,这个类的确很简单。它的名称叫做“Employee”,包含了单个成员“employeeId”。注意在该成员之前有关键字“private”,这叫做“访问限定符”。C#中定义了四种合法的访问限定符,稍后我们就会涉及到它们。
4.2 类的成员
在第2章中,我们讨论了由公共类型系统(Comon Type System,CTS)定义的不同的类型。这些类型就是C#所支持的类的成员,包括以下内容:
字段
字段就是用于保持一个值的成员变量。用OOP的用语来说,宇段有时也称为对象的数据。根据您希望怎样使用一个字段,您可以对该字段应用多个限定符。这些限定符包括“static”、“readonly”、“const”,稍后我们将了解这些限定符的含义以及如何使用它们。
方法
方法就是加工对象的数据(或字段)的真正的代码。在本章我们只着重介绍定义类的数据,在后面的章节将详细地介绍方法。
属性
属性有时也叫做“智能字段”,因为对于使用类的客户而言,它们就是看起来像字段的真正的方法。这使得客户可以拥有更高程度的抽象.因为客户不需要知道它访问时是直接访问了字段还是访问了被调用的存取器方法。在后面的章节将详细介绍属性。
常量
如其名所示,常量就是不能改变其值的字段,本章后面将对常量和只读字段进行比较。
索引器
与属性是智能字段一样,索引器就是智能数组,也就是说,索引踞可以让对象通过“get”和“set”存驭器方法来被索引。索引器允许您在对象中轻松实现索引。以便设置或浏览当前值。索引器和属性都将在后面章节中讨论。
事件
事件就是导致一些代码运行的东西。事件是Microsoft Windows编程中不可缺少的组成部分。比如.当鼠标移动或窗口被选中或调整大小时,就会触发事件。C#事件采用了标准的“发布/预订”设计模型(请参见Microsoft Message Queuing,MSMQ)和COM+异步事件模型(这使得应用程序具备了异步事件处理功能)。但是在C#中,这种设计模型是语言中内置的“头等”(first-class)概念.在后面将讨论如何使用事件。
操作符
C#通过操作符重载允许您可以将标准数学运算操作符添加到类中,以便使用这些操作符编写出更直接的代码.在后面将介绍操作符重载。
4.3 访问限定符
既然我们已经提到可以被定义为C#类成员的不同类型。下面就来看看一些重要的限定符。它们用于指定一个给定成员对于它所在的类之外的代码而言是可见的.还是不可见的这些限定符叫做“访问限定符”。如表5.1所示:
表4-1 C#访问限定符
访问限定符 说明
Public 表示该成员可以从它所在的类定义和派生类的层次结构之外访问
Protected 表示该成员在它所在的类之外是不可见的,只允许派生类访问
Private 表示该成员在类定义的范围以外不可访问,即使派生类也不能访问
Internal 表示该成员只能在当前编译单元内可见,根据代码所在的位置,访问限定符“internal”代表了“public”和“protected”的访问性组合
在C#中,如果我们在定义类时,不写任何限定符,则编译器会把它当作“private”对待。因为C#默认都是“private”。
4.4 构造函数
像C#这样的面向对象编程语言最大的好处之一就是:您可以定义一些特殊的方法,无论何时创建类的实例时,总可以调用这些方法。这样的方法就叫做“构造函数”(constructor)。C#还引入了一种新的构造函数,叫做“静态构造函数”,在下一节中我们将讨论这种构造函数。
使用构造函数的关键好处在于:它能保证对象被使用之前经过合适的初始化过程。当用户实例化一个对象时。该对象的构造函数首先被凋用,并在用户对该对象执行进一步操作之前必须返回。这种保证有助于确保对象的完整性,也有助于使用面向对象语言编写的应用程序更加可靠。
不过,我们应该怎样为构造函数命名才能使编译器在实现对象时能够知道如何调用它呢?C#的设计者们效仿了Stronstrup,规定在C#中类的构造函数必须与类同名。下面这个简单的类包含了一个同样简单的构造函数:
using System;
class TestApp
{
public TestApp()
{
Console.WriteLine("I am Constructor");
}
[STAThread]
static void Main(string[] args)
{
TestApp temp = new TestApp();
}
}
构造函数不返回任何值。如果您试图为构造函数加上一个某种类型的前缀,编译器就会发出错误提示,告诉您不能定义与成员所在类型同名的成员。
您还应该在C#中指定对象被实例化的方式。使用关键字“new”时参照下面的语法就可以了:
<class><object> = new <class>(constructor arguments)
如果您具有C++背景,在这一点上要特别注意。在C++中,实例化对象有两种方式。您可以在堆栈上声明它。如:
//C++ code This create an instance of Cmyclass on the stack
Cmyclass myClass;
也可以在自由存储区(或堆)上使用C++关键字“new”来实例化一个对象:
//C++ code This create an instance of Cmyclass on the heap
Cmyclass myClass = new Cmyclass();
在C#中实例化对象有所不同,这也是C#开发新手容易发生馄淆的地方之一。发生这种混淆的原因在于这两种浯言创建对象时使用的关键字相同,尽管在C++中使用关键字“new”可以让您指定对象在何处创建,但是在C#中创建一个对象却取决于被实例化的类型。在第2章中我们已经知道,引用类型是在堆上创建的,而数值类型是在堆栈上创建的。因此,在C#中关键字“new”可以让您创建对象的新实例,但却不会决定该对象在何处创建。
既然如此,下面的代码就是合法的C#代码,但如果您是一位C++开发人员的话。这段代码的含义可能跟您想的不一样:
MyClass myClass;
在C++中,运行代码将在堆栈上创建一个MyClass的实例。在前面我们已经提到,在C#中只能使用关键字“new”来创建对象。因此,在C#中运行代码只是声明myClass是MyClass类型的一个变量,但它并没有实例化这个对象。
作为这样的一个例子,如果您编译下面的程序.C#编译器就会警告您在这个应用程序中声明过的变量从没有使用过。
using System;
class TestApp
{
public TestApp()
{
Console.WriteLine("I am Constructor");
}
[STAThread]
static void Main(string[] args)
{
TestApp temp;
}
}
因此,如果声明了一个对象类型,就应该在代码中某个地方使用关键字“new”来将它实例化:
TestApp temp;
Temp = new TestApp();
为什么有时候会声明一个对象而不将它实例化呢?如果是在一个类中声明另一个类时,就应该在使用对象之前声明这些对象。类的这种嵌套就叫做“包容”(containment),或者“聚集”(aggregation)。
4.4.1 静态成员和实例成员
在C++中,您可以将类的成员定义为静态成员或实例成员。默认情况下,每一个成员都被定义为实例成员,表示类的每一个实例都会复制这些成员。如果成员被声明为静态成员,该成员就只被复制一次。静态成员是在载入包含类的应用程序时创建的,在应用程序的整个生命期内它都存在。因此,您甚至可以在类被实例化之前就访问这样的成员。不过,为什么会这样做呢?
有一点与Main方法有关。公共语言运行环境(CLR)需要一个通向应用程序的公共的入口。这样CLR就不需实例化任何一个对象,只要在某个类中定义了一个名为Main的静态方法即可。另外,从面向对象的角度看来,如果某个方法只在语义上属于某个类而并不需要实际的对象时。也需要使用静态成员——比如,如果您希望在应用程序的生命期内跟踪一个给定的对象有多少个实例,因为静态成员也存在于对象实例中。
因此可以编写出下面的代码:/Example/Chapter4/4-5-1.cs
class TestApp{
public static int instanceCount=0;
public TestApp(){
instanceCount++;
}
static void Main(string[] args){
Console.WriteLine(TestApp.instanceCount);
TestApp temp1 = new TestApp();
Console.WriteLine(TestApp.instanceCount);
TestApp temp2 = new TestApp();
Console.WriteLine(TestApp.instanceCount);
}
}
执行结果如下:
0
1
2
关于静态成员的最后一个提示就是:静态成员必须有一个合法的值。在定义成员时就可以指定这个值.如:
public static int instanceCount=10;
如果您没有初始化这个变量,CLR就会在应用程序启动时使用默认值0对它进行初始化。因此,下面两行代码实际上是等价的:
public static int instanceCount;
public static int instanceCount=0;
4.4.2构造函数的初始化函数
所有的C#对象构造函数——除了System.Object构造函数以外——在执行构造函数的第一行代码之前都会调用其基类的构造函数。这些构造函数初始化函数允许您指定希望调用哪个类和哪个构造函数,它们具有两种形式:
形如“base(…)”的初始化函数允许调用当前类的基类的构造函数。也就是由发出调用指令的构造函数的名称所指定的特定的构造函数。
形如“this(…)”的初始化函数允许当前类调用它内部定义的另一个构造函数。当您重载了多个构造函数,而希望总是调用一个默认的构造函数时,就应该使用这种初始化函数。重载方法将在后面讨论.这里给出一个粗略的定义:重载方法就是两个以上具有相同名称但参数值列表不同的方法。
为了看看实际操作中的顺序,注意下面的代码将首先执行类A的构造函数,然后执行
类B的构造函数:/Example/Chapter4/4-5-2.cs
class A{
public A(){
Console.WriteLine("A");
}
}
class B:A {
public B()
{
Console.WriteLine("B");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
B b = new B();
}
}
下面的代码功能与上面的代码相同,下面的代码中显示调用了基类的构造函数。
class A
{
public A()
{
Console.WriteLine("A");
}
}
class B:A
{
public B():base()
{
Console.WriteLine("B");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
B b = new B();
}
}
下面我们来看一个展示构造函数初始化函数作用的更好的例子。同样,我们有两个类:A和B。这次,类A有两个构造函数:一个没有参数值,一个带有一个int类型的参数值。类B有一个带有一个int类型参数值的构造函数。在类B的构造函数中就可能会发生问题。如果运行下面的代码,就会凋用类A不带参数值的那个构造函数:
/Example/Chapter4/4-5-3.cs
class A
{
public A()
{
Console.WriteLine("A");
}
public A(int foo)
{
Console.WriteLine("A={0}",foo);
}
}
class B:A
{
public B(int foo)
{
Console.WriteLine("B={0}",foo);
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
B b = new B(88);
}
}
那么,怎样才能保证调用我们希望调用的类A的构造函数呢?只需告诉编译器我们希望调用初始化函数列表中的哪个构造函数即可。
如下所示:/Example/Chapter4/4-5-4.cs
class A
{
public A()
{
Console.WriteLine("A");
}
public A(int foo)
{
Console.WriteLine("A={0}",foo);
}
}
class B:A
{
public B(int foo):base(foo)
{
Console.WriteLine("B={0}",foo);
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
B b = new B(88);
}
}
注意: 与Visual C++不同,在C#中您不能使用构造函数初始化函数去访问当前类中除构造函数以外的其他实例成员。
4.5 常量和只读字段
您可能常常会将在应用程序执行过程中不希望改变的元素作为字段使用。比如,应用程序所依赖的数据文件、某个数学类中的∏值、或者应用程序中任何您所知的永远不会改变的值。为了解决这些问题,C#允许定义两种极其相似的成员类型:常量和只读字段。
4.5.1 常量
从这个名字中就可以看出,由关键字“const”表示的常量就是在应用程序的生命期内保持不变的字段。在定义常量时只需记住两点:第一,常量的值是在编译时设定的,可以由程序员设定,默认状态是编泽器设定。第二,常量成员的值必须是数值文字。
要将字段定义成常量,只需在被定义的成员前指定关键字“const”即可。
如:/Example/Chapter4/4-6-1.cs
class MagicNumbers
{
public const double PI = 3.1415;
public const int answerToAllLifesQuestions = 42;
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Console.WriteLine("PI = {0},everything else = {1}",MagicNumbers.PI,MagicNumbers.answerToAllLifesQuestions);
Console.Read();
}
}
注意这段代码中有一个关键的地方:客户代码不需要实例化MagicNumbers类,因为默认状态下常量成员是静态的。为了更清楚的认识这一点,不防看看下面这两个成员产生的MSIL代码:
answerToAllLifesQuestions:public static literal int32 =iht32
(0x0000002A)
pi :public static literal float64 =float64(3.1415000000000002)
4.5.2 只读字段
定义为常量的字段是很有用的,因为它清楚地体现了程序员的意图:即该字段包含的是一个不变的值。不过,这只有在编译时您就知道常量值的情况下才起作用。那么,如果字段的值要到运行的时候才知道。而一旦被初始化后就不再改变。这时程序员该怎么办呢?这个问题在别的编程语言中通常都不会考虑到,但C#语言的设计者们通过所谓的“只读字段”解决了这个问题。
当您使用关键字“readonly”定义一个字段时,您可以在这个地方设置该字段的值:构造函数,除此之外,类本身或使用类的客户代码就都不能改变该字段的值了。比如,您希望在一个图形应用程序中追踪屏幕的分辨率,您不能使用常量来解决这个问题,因为直到运行的时候,应用程序才能决定终端用户的屏幕分辨率。
因此,您可以使用类似下面这样的代码:/Example/Chapter4/4-6-2.cs
class GraphicsPackage
{
public readonly int ScreenWidth;
public readonly int ScreenHeight;
public GraphicsPackage()
{
this.ScreenWidth = 1024;
this.ScreenHeight = 768;
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
GraphicsPackage graphics = new GraphicsPackage();
Console.WriteLine("Width = {0},Height = {1}",graphics.ScreenWidth,graphics.ScreenHeight);
}
}
初看起来,这段代码好像就是您所需要的。不过,这里还存在一个小问题:我们定义的只读字段都是实例字段,也就是说,用户必须实例化类之后才能使用这些字段。如果在实例化类的方式将决定只读字段值的情形下,这也许正是您所希望的。但是.如果您想要的是一个常量——按定义是静态的。在运行的时候才被初始化,又该怎么办呢?这时,您就应该给字段加上限定符“static”和“readonly”。然后再创建一个特殊类型的构造函数。叫做“静态构造函数”。静态构造函数就是用来初始化静态字段(不管只读与否)的构造函数,下面我们将前面那个例子中的屏幕分辨率字段修改为静态和只读,并添加了一个静态构造函数。注意在构造函数定义时添加的关键字“static”:
class GraphicsPackage{
public static readonly int ScreenWidth;
public static readonly int ScreenHeight;
static GraphicsPackage()
{
ScreenWidth = 1024;
ScreenHeight = 768;
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Console.WriteLine("Width = {0},Height = {1}",GraphicsPackage.ScreenWidth,GraphicsPackage.ScreenHeight);
}
}
4.6 对象的清除和资源管理
Microsoft提出了基于Dispose设计模式的解决方案。这种设计模式推荐对象公开一个公共方法,通常可以叫做“Cleanup”或“Dispose”,然后在用户结束使用该对象时来凋用这个方法。这样,就由类的设计者负责在该方法中进行必要的清除操作。实际上,在.NET框架类库中有很多类都为此实现了Dispose方法。关于Dispose设计模式相关内容,我们在本书中不重点讨论。
4.7 继承
在第3章中我们已经提到。当一个类构建于另一个类之上(数据或者行为)时就要用到继承,并且遵循可替代性规则。也就是说,派生类可以替代基类。比如说您想构造一个数据库类的层次结构,假设您想编写一个类来处理Microsoft SQL Server数据库和Oracle数据库,由于这两个数据库在某些方面有区别,您想为每个数据库分别编写一个类。但是,这两个数据库又共享很多功能,您希望将这些公共的功能放进一个基类,然后从基类中派生出另外两个类,在需要的时候再覆盖或修改所继承的基类行为。
要从一个类中继承另一个类,您可以使用如下的语法:
class <derivedClass> : <baseClass>
下面是该数据库示例程序的代码:/Example/Chapter4/4-6-1.cs
class Database
{
public int ComonField;
public Database()
{
ComonField = 58;
}
public void ComonMethod()
{
Console.WriteLine("Database.Comon Method");
}
}
class SQLServer : Database
{
public void SomeMethodSpecificToSQLServer()
{
Console.WriteLine("SQLServer.SomeMethodSpecificToSQLServer");
}
}
class Oracle : Database
{
public void SomeMethodSpecificToOracle()
{
Console.WriteLine("Oracle.SomeMethodSpecificToOracle");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
SQLServer sqlserver = new SQLServer();
sqlserver.SomeMethodSpecificToSQLServer();
sqlserver.ComonMethod();
Console.WriteLine("Inherited common field = {0}",sqlserver.ComonField);
}
}
程序运行结果如下:
SQLServer.SomeMethodSpecificToSQLServer
Database.Comon Method
Inherited common field = 58
注意Database.ComonMethod方法和Database.ComonField方法现在都是SQLServer类定义的一部分。因为SQLServer类和Oracle类都是从Database基类中派生而来的。因此它们都继承了基类中定义为“public”、“protected”或“internal”的几乎所有成员。惟一的例外是构造函数,因为它不能被继承。每个类都必须实现它自己的与其基类无关的构造函数。
第5章中将介绍方法覆盖。不过,为了本节的完整性,此处先简单介绍一下。方注覆盖就是允许您从基类中继承一个方法,然后改变这个方法的具体实现。抽象类与方法覆盖联系很紧密——这也将在第5章中介绍。
4.7.1 多接口
因为在很多新闻组和邮件列表中,对有关多继承的主题(比如:C#不支持通过派生的多继承)很有争议,因此我们来澄清一下。其实,通过实现多接口就可以将多个程序实体的行为特征聚集在一起。接口以及如问使用接口将在第7章中介绍。现在可以先将C#接口看作COM接口。
因此,下面这个程序就是非法的:
class foo
{
}
class bar
{
}
class MITest : foo,bar
{
Public static void main()
{
}
}
在这个示例中将出现的错误与您如何实现接口有关。您选定要实现的接口要列在该类的基类后面。因此,在这个例子中,C#编译器就认为bar应当是一个接口类型,这也是C#编译器带给您下面这条错误信息的原因:
‘bar’:type in interface list is not an interface
下面这个更实际的例子完全合法,因为MyFancyGrid是从Control中派生出来的,并且实现了ISerializable和真IDataBind接口:
public class Control
{
}
interface ISerializable
{
}
interface IDataBound
{
}
public class MyFanceGrid : Control,ISerializable,IDataBound
{
}
这里的关键在于:在C#中实现所谓的多继承的唯一方式就是使用接口。
4.7.2 封装类
如果您希望确保某个类永远也不用作基类,在定义该类时就应该使用限定符“seded”。定义封装类的惟一限制就是抽象类不能被定义为封装类。因为抽象类“天生”就是要被当作基类使用的。另外,尽管使用封装类的目的是阻止派生意图,但其实将类定义为封装类之后还会允许在运行的时候进行一定的优化。特别地,由于编译器保证了封装类永远也不会有任何派生类存在,因此就可以将封装类实例中的虚函数成员调用转变为非虚调用。下面是一个封装类的示例:
sealed class SaledClass
{
private int x,y;
public SaledClass(int x,int y)
{
this.x = x;
this.y = y;
}
public int X
{
get
{return x;}
set
{x = value;}
}
public int Y
{
get
{return y;}
set
{y = value;}
}
}
注意内部类成员x和y使用了访问限定符“private”。如果使用限定符“protected”就会使编译器发出警告信息,因为受保护的成员对于派生类而言是可见的,但现在我们已经知道,封装类不会有任何派生类。
4.8 本章小结
类的概念和类与对象的关系是基于对象编程思路的基础。构建于从C++传递下来的继承之上的C#的面向对象特征在。NET框架中得到了进一步的加强。在类似公共语言运行环境(CLR)这样的受控系统中,资源管理是开发人员需要特别关注的地方。CLR通过基于确定性结束的垃圾收集器来努力将程序员们从引用计算这样的“苦差事”中解放出来。C#中的继承处理也与C++中不同。尽管C#只支持单一继承,但开发人员可以通过实现多接口来获得一些多继承的好处。
4.9 实战演练
1. 列举出现实生活中都有哪些可以用类来描述,并且实例化对象出来。
2. 编写一个显示器(Displayer)类,该类包括型号、大小、厂商、价格和显示图像功能这些信息,然后实例化对象,用来描述您所使用的显示器。
3. 利用类模板实现一个通用的栈模板。要求这个栈模板能够完成一般栈的基本操作(栈元素为基本数据类型,不包括指针、数组以及对象)。向栈中压入一个元素(push)、取栈顶元素的值(top)、弹出栈顶元素(pop)、清空栈(empty)、判断栈是否为空(isEmpty)。编写一段主程序,要求主程序中生成整型、浮点型、字符型的栈实例各一个并分别测试其功能。
第五章 方法
本章重点:
方法的参数“ref”和“out”
方法重载
虚拟方法
多态性
静态方法
本章目的:
学习“ref”和“out”两种方法参数关键字,以及如何使用他们来定义方法,以便能返回多个值给应用程序。还应掌握如何定义方法重载。以便多个同名的方法根据传递给它们的不同类型和(或)参数值的个数而具有不同的功能。还有虚拟方法和静态方法的定义。
我们在第3章中已经学到,类就是封装的数据和加工数据的方法。换句话况,方法为类赋予了行为特征。而我们根据自己的意图使用希望类执行的操作来为方法命名。到目前为止。我们还没有涉及到在C#中有关定义和调用方法的更具体的问题。这也正是本章的目的——您将会学习“ref”和“out”这两个方法参数关键字,以及您如何使用它们来定义方法.以便能返回多个值给调用程序。您还会学习如何定义重载方法,以便多个同名的方法根据传递给它们的不同类型和(或)参数值的个数而具有不同的功能。然后您将学到如何处理直到运行的时候才能知道方法参数值的确切个数的情形。最后,我们将以对虚拟方法的讨论(虚拟方法构建于第3章所讨论的继承之上)以及如何定义静态方法作为本章的结束。
5.1方法参数“ref”和“out”
当您在C#中通过使用方法来试图获取一些信息时,通常只能得到一个返回值。因此,初看起来就好像每个方法调用只能返回一个值。显然,在很多时候如果为每一个必要的数据都调用一个方法会相当麻烦,比如,假设您有一个Color类,用来代表一种给定的颜色,该颜色由三个值确定,这些值使用描述颜色的标准RGB(red-green-blue,红—绿—蓝)模式来给出。如果只使用一个返回值,您就不得不书写如下的代码来得到这三个值:
// Assuming color is an instance of a Color class.
int red =color. GetRed();
int green =color. GetGreen();
int blue =color. GetBlue();
但是我们希望的却是类似于这样的代码:
int red;
int green;
int blue;
color.GetRGB(red,green,blue);
不过,这里有个问题.当调用color.GetRGB方法时,参数值red、green和blue的值会被复制到该方法的局部堆栈中,该方法对这些变量的任何修改都不会传递给调用程序的变量。
在C++中,这个问题是这样解决的。调用程序将指针或引用传递给变量,这样,被调用的方法就能加工调用程序的数据了。在C#中的解决方案与此类似。事实上,C#提供了两种相似的解决方案。第一种包含关键字“ref”,这个关键字告诉C#编译器被传递的参数值指向与调用代码中变量相同的内存。这样,如果被调用的方洼修改了这些值然后返回的话,调用代码的变量也就被修改了。
下面的程序说明了如何在color类中使用关键字“ref”:
public class Color
{
protected int red;
protected int green;
protected int blue;
public Color()
{
red = 255;
green = 0;
blue = 125;
}
public void GetRGB(ref int red,ref int green,ref int blue)
{
red = this.red;
green = this.green;
blue = this.blue;
}
}
//主程序
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Color color = new Color();
int red;
int green;
int blue;
color.GetRGB(ref red,ref green,ref blue);
Console.WriteLine("red = {0},green = {1},blue = {2}",red,green,blue);
}
}
使用关键字“ref”有一个缺点,实际上正是由于这个限制,以上的代码不会通过编泽,当您使用关键字“ref”时,您必须在调用方法之前初始化被传递的参数值。因此,要想让上面的代码能够正常运行.必须做出如下修改。
如下所示:/Example/Chapter5/5-1-1.cs
public class Color
{
protected int red;
protected int green;
protected int blue;
public Color()
{
red = 255;
green = 0;
blue = 125;
}
public void GetRGB(ref int red,ref int green,ref int blue)
{
red = this.red;
green = this.green;
blue = this.blue;
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Color color = new Color();
int red=0;
int green=0;
int blue=0;
color.GetRGB(ref red,ref green,ref blue);
Console.WriteLine("red = {0},green = {1},blue = {2}",red,green,blue);
}
}
程序执行结果如下:
red = 255,green = 0,blue = 125
在这个例子中,初始化这些就要被重写的变量看起来好像是白费功夫。因此,C#提供了另外一种途径来传递这种参数值——调用代码需要看到它们的值的变化:关键字“out”下面是同样的Color类示例程序,只不过使用的是关键字“out”:
public class Color
{
protected int red;
protected int green;
protected int blue;
public Color()
{
red = 255;
green = 0;
blue = 125;
}
public void GetRGB(out int red,out int green,out int blue)
{
red = this.red;
green = this.green;
blue = this.blue;
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Color color = new Color();
int red;
int green;
int blue;
color.GetRGB(out red,out green,out blue);
Console.WriteLine("red = {0},green = {1},blue = {2}",red,green,blue);
}
}
关键字“ref”和“out”之间的惟一区别就是关键字“out”不要求调用代码忉始化要传递的参数值。那么,关键字“ref”什么时候使用呢?当您需要确保调用方法已经初始化参数值的时候。您就应该使用关键字“ref”。
注意:如果被调用的方法需要使用参数的值时,这时必须使用关键字“ref”,因为“ref”可以强制变量进行初始化。这样才能保证被调用方法能够正常工作。
5.2 方法重载
方法重载允许C#程序员可以多次使用同名的方法而每次传递的参数值不同。这一点在两种情形下特别有用;第一种情形是您希望公开一个方法名,该方法的外部表现根据传递的参数值的类型不同而稍微有些不同。比如,假设您有一个日志类,允许您的应用程序将诊断信息写入磁盘。要想把这个类变得更灵活一些.您可能会有几个不同形式的write方法用于指定要写入的信息。除了接受要写入的字符串之外,该方法可能还需要接受一个字符串源的标识符。如果没有重载方法,您就不得不为每种情形分别实现一个方法。如WriteString和WriteFromResourceId。不过,通过方法重载,您就可以实现以下的方法——都叫做“WriteEntry”——每个方法只是参数类型不同而已。
如下所示:/Example/Chapter5/5-2-1.cs
public class Log
{
protected string filename;
public Log(string fileName)
{
this.filename = fileName;
}
public void WriteEntry(string entry)
{
Console.WriteLine("{0} was Writed into LogFile {1}",entry,filename);
}
public void WriteEntry(int resourceId)
{
Console.WriteLine("{0} was Writed into LogFile {1}",resourceId,filename);
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Log log = new Log("mylog.log");
log.WriteEntry("Entry One");
log.WriteEntry(88);
Console.Read();
}
}
执行结果如下:
Entry One was Writed into LogFile mylog.log
88 was Writed into LogFile mylog.log
第二种方法重载有用的情形是当您使用构造函数的时候,构造函数是实例化对象时要调用的基本方法。假设您希望创建一个类,这个类可以用多种方式来构造——比如,采用文件句柄(int)或者文件名(string)来打开一个文件,因为C#规则规定了类的构造函数必须与类同名,因此您不能简单地为每一种不同的变量类型创建不同名称的方法。此时,您应该重载构造函数:如下所示:/Example/Chapter5/5-2-2.cs
public class File
{
}
public class CommonFile
{
public CommonFile(string fileName)
{
Console.WriteLine("Construct with a filename");
}
public CommonFile(File file)
{
Console.WriteLine("Construct with a File Object");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
File file = new File();
CommonFile file2 = new CommonFile(file);
CommonFile file3 = new CommonFile("Some file name");
Console.Read();
}
}
关于方法重载要记住的重要一点是:每个方法的参数值列表必须有所不同,即参数个数或者类型不同。
5.3虚拟方法
在第4章中我们已经看到,可以从一个类中派生出另外—个类。这样类就可以继承并在已存在的类的功能之上构建新的功能,因为那时我们还没有讨论到方法,因此只仅仅接触到了字段和方法的继承。也就是说,我们并没有研究在派生类中修改基类行为的方法。这是通过使用虚拟方法来实现的,这也正是本节的主题所在。
5.3.1方法覆盖
首先我们来看看如何在—个继承的方法中覆盖基类的功能。还是以代表雇员的基类开始,为了使这个示例程序尽可能简单,我们只给这个基类一个方法CalculatePay。而方法内部除了让我们知道被调用的方法的名称外。别的什么也不做,这将有助干我们在稍后确定继承树中的哪个方法被调用:
class Employee
{
public void CalculatePay()
{
Console.WriteLine("Employee.CalculatePay()");
}
}
现在,假设您想要从Employee类中派生一个类,并希望覆盖掉CalculatePay方法,以便针对派生类进行一些具体的操作。要想达到这样的目的,您需要在派生类的方法定义中使用关键字“new”。下面的代码显示了其简单性:
public class Employee
{
public void CalculatePay()
{
Console.WriteLine("Employee.CalculatePay()");
}
}
public class SalariedEmployee:Employee
{
public new void CalculatePay()
{
Console.WriteLine("SalariedEmployee.CalculatePay()");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Employee emp = new Employee();
emp.CalculatePay();
SalariedEmployee emp2 = new SalariedEmployee();
emp2.CalculatePay();
Console.Read();
}
}
执行结果如下:
Employee.CalculatePay()
SalariedEmployee.CalculatePay()
5.3.2多态性
引用派生对象的时候,利用关键字“new”便可以实现方法的覆盖。那么,如果引用的是基类,如何让编译器调用派生类的方法呢?这就是多态性要解决的问题。多态性允许在类的层次结构中多次定义同一个方法,并使运行环境根据要使用的对象来调用该方法的适当版本。仍以前面的雇员程序为例来说明。应用程序TestApp能够正常运行,因为我们有两个对象:一个Employee对象和一个SalariedEmployee对象。在更接近实际的应用程序中,我们可能会从一个数据库中读出所有的雇员记录并形成一个数组。尽管这些雇员中有些是合同工。有些是正式工,但我们仍然需要将它们都以相同的类型——基类类型Employee——存放在数组中。这样,在遍历这个数组、获取对象并凋用每个对象的CalculatePay方法时。我们希望编译器能够正确调用每个对象对CalculatePay方法的实现。
在下面的例子中添加了一个新类一ContractEmployee。主要的应用程序类现在包含一个Employee类型的对象数组和两个方法——LoadEmployees将Employee对象装载到数组中.DoPayroll遍历这个数组.调用每个对象的CalculatePay方法。
如下所示:/Example/Chapter5/5-3-1.cs
public class Employee
{
protected string Name;
public Employee(string name)
{
this.Name = name;
}
public string name
{
get{return Name;}
set{Name = value;}
}
public void CalculatePay()
{
Console.WriteLine("Employee.CalculatePay called for {0}",Name);
}
}
public class SalariedEmployee:Employee
{
public SalariedEmployee(string name):base(name)
{
}
public new void CalculatePay()
{
Console.WriteLine("SalariedEmployee.CalculatePay called for {0}",Name);
}
}
public class ContractEmployee:Employee
{
public ContractEmployee(string name):base(name)
{
}
public new void CalculatePay()
{
Console.WriteLine("ContractEmployee.CalculatePay called for {0}",Name);
}
}
public class PolyApp
{
protected Employee[] employees;
public void LoadEmployees()
{
employees = new Employee[2];
employees[0] = new SalariedEmployee("Green");
employees[1] = new ContractEmployee("Jakson");
}
public void DoPayroll()
{
foreach(Employee emp in employees)
{
emp.CalculatePay();
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
PolyApp poly = new PolyApp();
poly.LoadEmployees();
poly.DoPayroll();
}
}
程序运行结果如下:
Employee.CalculatePay called for Green
Employee.CalculatePay called for Jakson
显然,这并不是我们想要的结果——每个对象都调用了基类的CaculatePay方法的实 现。这里所发生的就是所谓“早期捆绑”(early binding)现象的一个例子。当编译代码的时候,C#编译器一看到对emp.CalculatePay的调用,就确定了调用发生时在内存中它应跳转的地址。这种情况下,采用的当然就是Employee.CalculatePay方法的内存地址了。对Employee.Calculate方法的调用就是问题所在。我们希望的是发生“后期捆绑”(late binding),也就是说编译器直到运行的时候才选择要调用的方法。要强制编译器调用向上转换了的对象的方法的正确版本,可以使用两个新的关键字:“virtual”和“override”。关键字“virtual”必须用于基类方法。关键字“override”用于派生类对该方法的实现。下面更改过的代码这次就能正常工作了:/Example/Chapter5/5-3-2.cs
public class Employee
{
protected string Name;
public Employee(string name)
{
this.Name = name;
}
public string name
{
get{return Name;}
set{Name = value;}
}
public virtual void CalculatePay()
{
Console.WriteLine("Employee.CalculatePay called for {0}",Name);
}
}
public class SalariedEmployee:Employee
{
public SalariedEmployee(string name):base(name)
{
}
public override void CalculatePay()
{
Console.WriteLine("SalariedEmployee.CalculatePay called for {0}",Name);
}
}
public class ContractEmployee:Employee
{
public ContractEmployee(string name):base(name)
{
}
public override void CalculatePay()
{
Console.WriteLine("ContractEmployee.CalculatePay called for {0}",Name);
}
}
public class PolyApp
{
protected Employee[] employees;
public void LoadEmployees()
{
employees = new Employee[2];
employees[0] = new SalariedEmployee("Green");
employees[1] = new ContractEmployee("Jakson");
}
public void DoPayroll()
{
foreach(Employee emp in employees)
{
emp.CalculatePay();
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
PolyApp poly = new PolyApp();
poly.LoadEmployees();
poly.DoPayroll();
Console.Read();
}
}
程序执行结果如下:
SalariedEmployee.CalculatePay called for Green
ContractEmployee.CalculatePay called for Jakson
注意: 虚拟方法不能被声明为私有(private),因为私有成员对于派生类来说是不可见的。
5.4 静态方法
静态方法就是作为一个整体存在于类中的方法,而不是存在于类的一个特定实例中。与别的静态成员相比,静态方法的关键好处就在于它们虽然与类的特定实例分离,但却并没有影响应用程序的全局空间,也不因为没有与类实例相连而有损面向对象的风格。作者曾编写过一个C#的数据厍应用编程接口,在这个类层次结构中,有一个SQLServerDB类带有一些基本的NURD(new、update、read and delete)功能,它也公开了一个修复数据库的方法Repair。在这个Repair方法中,并不需要打开数据库。实际上,作者使用的ODBC函数SQLConfigDataSource要求在对该数据库进行操作时它必须被关闭。不过,SQLServerDB的构造函数还是打开了由传递给它的数据库名称所指定的数据库。这种情况下最好使用静态方法,这样就可以将一个方法放在它所属的SQLServerDB类中,而不用借助于类的构造函数了。显然.对于客户来说,好处就是他们也无需对SQLServerDB类进行实例化,在下一个例子中,您可以看到一个静态方法RepairDataBase被从Main方法中调用。注意我们并没有创建一个SQLServerDB的实例:
class SQLServerDB
{
public static void RepairDataBase()
{
Console.WriteLine("Repairing database.........");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
SQLServerDB.RepairDataBase();
Console.Read();
}
}
使用关键字“static”就可以将方法定义为静态方法了。用户使用class.Method这样的语法来调用静态方法。即使用户引用了类的实例,仍然需要使用这种语法格式。
关于静态方法的最后一点就是它能访问哪些类成员。您也许已经想到,静态方法可以访问类的任何静态成员,但是不能访问实例成员。
5.5 本章小结
方法赋予类以行为恃征并且按照我们的意愿执行操作。C#中的方法很有弹性,允许返回多个值以及重载等。关键字“ref”和“out”允许方法返回一个以上的值给调用程序。重载允许多个同名的方法按照传递给它们的参数值类型和个数的不同而具有不同的功能。虚拟方法允许在继承的类中控制如何修改方法。最后,关键字“static”允许方法作为类的一部分存在,而不是作为对象的一部分存在。
5.6 实战演练
1. 编写实例测试方法的“ref”和“out”参数的作用。
2. 编写类的静态方法和实例方法,注意它们的用法。
3. 编写一个加法程序,应考虑到加数的类型,如整型、浮点型等采用方法重载实现。
第六章 属性、数组和索引器
本章重点:
属性的定义及使用
数组的定义及使用
索引器
本章目的:
通过本章的学习,我们应该熟练掌握属性和数组的定义及使用,了解索引器的概念及其实现的功能。
到目前为止,我们已经了解了C#支持的基本类型以及在类和应用程序中如何声明和使用它们。本章将打破每章只介绍一种语言要素的模式,而同时介绍属性、数组和索引器这三种语言要素,因为它们之间存在某些共同的地方,它们允许C#类开发人员扩展基本的类/字段/方法结构,以便展示出更加自然的类成员接口。
6.1 属性——智能字段
设计类时的—个好的目标总是不仅仅隐藏类成员的实现,而且禁止任何对类字段成员的直接访问。通过“存取器方法”——其职责就是获取和设置字段的值,您就可以确保字段可以被正确处理,也就是说,根据您特定的问题域规则而执行必要的操作处理。
比如,假设您有—个地址类Address包含一个邮政编码字段ZipCode和一个城市字段City,当客户修改字段Address.ZipCode时,您希望通过一个数据库来验证邮政编码是否有效,并且根据邮政编码自动设置Address.City字段的值。如果客户可以直接访问一个公共的Address.ZipCode成员的话,上面这两个任务就有点难了。因为直接更改成员变量并不需要方法。因此,除了可以直接访问Address.ZipCode字段之外,更好的解决方案是将Address.ZipCode和Address.Cipy这两个字段定义为“protected”,然后提供存取器方法来获取和设置Address.ZipCode字段的值。这样,您就可以附加一些代码来执行需要的操作了。 这个邮政编码的程序在C#中的示例代码如下。注意真正的ZipCode字段被定义为“protected”,因此客户不能直接访问它。而存取器方法GetZipCode和SetZipCode被定义为“public”:
class Address
{
protected string ZipCode;
protected string City;
public string GetZipCode()
{
return ZipCode;
}
public string SetZipCode(string zipcode)
{
//Validate value against some datasource
ZipCode = zipcode;
//Update city based on validated zipCode
}
}
客户可以这样来访问 ZipCode 的值。
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Address addr = new Address();
addr.SetZipCode("710041");
string zip = addr.GetZipCode();
}
}
6.1.1定义和使用属性
使用存取器方法固然不错,这种技术也被不少面向对象语言(包括C++和Java的程序员们所采用。不过,C#提供了一种更加理想的机制——属性,它具有与存取器方法相同的功能,在客户端却表现更佳。通过属性,程序员们不必借助于存取器方法就可以让客户能够像访问公共字段一样来访问类的字段。
C#属性由一个字段声明和用于修改该字段值的存取器方法组成。这些存取器方法叫做“getter”和“setter”方法。Getter方法用于获取字段的值,而setter方法用于修改字段的值。 下面是使用C#字段重写的前面的代码:
class Address
{
protected string ZipCode;
protected string City;
public string zipCode
{
get
{
return ZipCode;
}
set
{
//Validate value against some datasource
ZipCode = zipcode;
//Update city based on validated zipCode
}
}
}
注意此处创建了一个叫做“Address。ZipCode”的字段和一个叫做“Address.City”的属性。有些人可能一开始会混淆。以为Address.ZipCode也是字段,不明白为什么一个字段要定义两次。但Address.ZipCode不是字段,而是属性。它其实就是一种定义类成员存取器的通用方式,以便可以使用更加自然的“Object.field”语法格式。如果在这个例子中省略掉Address.ZipCode字段,在setter语句中将ZipCode=value改为zipCode=value的话,就会导致setter方法被无限调用。同样要注意setter并没有任何参数值,被传递的值被自动放在一个叫做“value”的变量中,在setter方法内可以访问value变量。(很快您就会在MSIL中看到这个“奇迹”是怎样发生的)
既然我们已经写出了Address.ZipCode属性,就来看一看客户代码需要做出的改动吧:
Address addr = new Address();
addr.zipCode = "710041";
string zip = addr.zipCode;
这样,客户访问字段就很自然了,无需猜想或到文档(即源代码)中查找以便决定这个字段是否足公共的,如果不是,存取器方法的名称又是什么。
6.1.2编译器的工作原理
那么,编译器是如何允许我们以标准的Object.field语法格式来调用一个方法的呢?同样,value变量又是从哪里来的呢?要回答这些问题,我们需要看一看编译器产生的MSIL代码,首先看一看属性的getter方法。
在目前的示例程序中,我们定义了这个getter方法。如下是其编译后产生的中间代码:
.method public hidebysig specialname
instance string get_zipCode() cil managed
{
// 代码大小 11 (0xb)
.maxstack 1
.locals init ([0] string CS$00000003$00000000)
IL_0000: ldarg.0
IL_0001: ldfld string ConsoleApplication1.Address::ZipCode
IL_0006: stloc.0
IL_0007: br.s IL_0009
IL_0009: ldloc.0
IL_000a: ret
} // end of method Address::get_zipCode
从这个方法的MSIL代码中可以看到,编译器创建了一个叫做“get_ZipCode”的存取器方法。我们之所以能够识别出存取器方法的名称,是因为编译器在属性名前加上了“get”前缀(为getter方法)和“set”前缀(为settr方法)。这样,下面的代码就表示调用get_ZipCode:
string str = addr.ZipCode; //this calls Address::get_ZipCode
我们的问题是:“编译器是怎样允许我们使用标准语法格式Object。Field并调用方法的呢?答案就是编泽器在解析C#属性语法的时候,实际上为我们产生了适当的getter和setter方法。这样,出现Address。ZipCode属性的时候,编译器就产生了包含get_ZipCode和set_ZipCode方法的MSIL代码。
下面看看产生的setter方法。在Addess类中,您会看到如下代码:
set
{
//Validate value against some datasource
ZipCode = value;
//Update city based on validated zipCode
}
注意在这段代码中根本没有声明一个名为“value”的变量。但我们仍然可以使用这个来存储调用程序传递过来的值,并且设置受保护的成员字段zipcde。当C#编译器为setter方法产生MSIL代码时,它将插入这个变量作为set_ZipCode方法的一个参数值。
在产生的MSIL代码中,set_ZipCode方法将一个字符串变量作为参数值,如下所示:
.method public hidebysig specialname
instance void set_zipCode(string 'value') cil managed
{
// 代码大小 8 (0x8)
.maxstack 2
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld string ConsoleApplication1.Address::ZipCode
IL_0007: ret
} // end of method Address::set_zipCode
尽管在C#源代码中看不到这个方法,但是在设置ZipCode属性的时候,如addr.zipCode=710041时,就会产生一段MSL代码来调用Address::set_zipCode(“710041”)。但如果在C#中直接调用set_zipCode这个方法就会产生错误。
6.1.3 只读属性
在前面的例子中,Address.zipCode属性可以认为是可读写的,因为定义了getter和setter这两个方法。当然,有时候您可能不希望客户代码设置给定字段的值,这时候您可以将这个字段设为只读字段,将setter方法省略掉即可。为了进一步说明只读字段,我们现在限制客户代码设置address.city字段,只留下address.zipCode属性作为更改这个字段值的惟一途径。
6.1.4继承属性
与方法一样,属性也可以使用第4章中介绍过的“virtual”、“override”或“abstract”限定符。这些限定符允许派生类继承并覆盖属性。与它处理来自基类的任何其他成员一样。关键在于您只能在属性这个级别上指定这些限定符。也就是说.如果同时存在getter方法和setter方法的话。如果您覆盖其中一个,您必须也覆盖另外的一个。
6.1.5 属性的高级使用
到目前为止.我们谈论到属性的用途时有以下方面:
● 它们为客户代码提供了一定层次上的抽象。
● 通过Object.field语法格式,它们提供了一种访问类成员的通用方式。
● 它们允许类在修改或访问一个特定的字段时,还可以进行其他的操作。
上面第三点给我们指出了使用属性的另一个“用武之地”。实现所谓“偷懒的初始化。这是一种优化技术,能够在需要类成员的时候才对它们进行初始化。
如果类中包含了很少被引用的成员,而这些成员的初始化又会占用大量的时间和系统资源的话.比如数据必须从数据库或拥挤的网络读入的时候,这时使用“偷懒的初始化”就很有用了。因为您知道这些成员不常被引用而对它们进行初始化的代价又很大,所以可以在调用它们的getter方法时再对它们进行初始化。为了说明这一点,假设您有一个库存应用程序,销售人员会在他们的笔记本电脑中运行,以便存入顾客的订单,但他们很少会使用这个程序来检查库存水平。通过属性的使用,您就可以允许相关的类初始化时不必读入库存记录,如下面的代码所示。这样,如果销售人员的确需要访问某项货物的库存记录时,这时getter方法才访问远程数据库。
class sku
{
protected double onHand=0;
public double OnHand
{
get
{
//read from database server
return onHand;
}
}
}
本章到目前为止,您可能已经看到,属性通过为字段提供存取器方法,从而为客户代码提供了更通用和自然的接口。正因为此,属性有时候也叫做“智能字段”。现在,我们更进一步来看一看在C#中是如何定义和使用数组的,以及属性如何以“索引器”的形式与数组共用。
6.2 数组
到目前为止,本书中的大多数示例程序展示的都是如何定义有限个数、且预先能确定个数的变量。但是在实际的应用程序中,有时候您可能会直到运行的时候才知道所需对象的确切数目。比如,您开发了一个编辑器,希望跟踪添加到一个对话框里的控件,但编辑器将显示出来的控件的确切数目要到运行的时候才知道。这时,您就可以使用一个数组来存储并跟踪一组动态分配的对象——这个例子中即编辑器中的控件。
在C#中,数组对象共同的基类是System.Array。因此,虽然在C#中定义数组的语法格式与C++或Java中类似,但定义数组实际上是实例化了—个.NET类,也就是说,声明的每一个数组都从System.Array中继承了相同的成员。本节我们将介绍如何定义和实例化数组、如何使用不同的数组类型,如何遍历数组的每一个元素以及System.Array类的一些经常使用的属性和方法。
6.2.1 声明数组
在C#中声明一个数组时,只需将空的方括号放置在变量的类型和名称之间。如:
int[] numbers;
注意这种语法格式与C++中稍微有所不同,在C++中括号是在变量名之后。因为数组是基于类的,所以声明类的很多规则也可以应用于声明数组。比如,当您声明一个数组时,实际上并没有创建那个数组。与使用类一样,在分配数组的元素之前.您必须实例化该数组。在下面的例子中,在声明数组的同时实例化了数组:
Int[] numbers = new int[6];
不过,在将数组声明为类的一个成员时,您必须将声明数组与实例化数组分成两个不同的步骤,因为直到运行的时候您才能实例化对象。
6.2.2 一维数组
下面这个例子将一个一维数组声明为一个类成员,在构造函数中实例化并填充该数组,然后使用了一个for循环语句来遍历这个数组,打印出每一个元素:
/Example/Chapter6/6-2-1.cs
class SingleDimArray
{
protected int[] numbers;
public SingleDimArray()
{
numbers = new int[6];
for(int i=0;i<6;i++)
{
numbers = i*i;
}
}
public void PrintArray()
{
for(int n=0;n<numbers.Length;n++)
{
Console.WriteLine("numbers[{0}] = {1}",n,numbers[n]);
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
SingleDimArray single = new SingleDimArray();
single.PrintArray();
}
}
程序执行结果如下:
numbers[0] = 0
numbers[1] = 1
numbers[2] = 4
numbers[3] = 9
numbers[4] = 16
numbers[5] = 25
在这个例子中,SingleDimArray.PrintArray方法使用System.Array.Length属性来确定该数组中的元素个数。由于这里只是一个一维数组,看起来不是很明显,实际上Length属性返回的是数组所有维的所有元素的个数。因此,如果是一个5x4的两维数组,Length属性将返回10。下一节我们将学习多维数组以及如何确定一个特定数组维的上界。
6.2.3 多维数组
C#除了一维数组以外,还支持多维数组,多维数组的每一维由一个逗号分隔。下面的代码声明了一个double类型的三维数组:
double[ , , ] numbers;
要确定一个已声明C#数组的维数,只需将逗号的个数加1即可。
下面的例子中使用了一个两维数组的销售数字来代表今年当月的销售总量与去年同期的销售总量,要特别注意实例化该数组所用的语法格式:
如下所示:/Example/Chapter6/6-2-2.cs
class MultiDimArray
{
protected int currentMounth;
protected double[,] sales;
public MultiDimArray()
{
currentMounth = 10;
sales = new double[2,currentMounth];
for(int i=0;i<sales.GetLength(0);i++)
{
for(int j=0;j<10;j++)
{
sales[i,j] = (i*100)+j;
}
}
}
public void PrintSales()
{
for(int i=0;i<sales.GetLength(0);i++)
{
for(int j=0;j<sales.GetLength(1);j++)
{
Console.WriteLine("sales[{0}][{1}] = {2}",i,j,sales[i,j]);
}
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MultiDimArray temp = new MultiDimArray();
temp.PrintSales();
Console.Read();
}
}
程序执行结果如下:
sales[0][0] = 0
sales[0][1] = 1
sales[0][2] = 2
sales[0][3] = 3
sales[0][4] = 4
sales[0][5] = 5
sales[0][6] = 6
sales[0][7] = 7
sales[0][8] = 8
sales[0][9] = 9
sales[1][0] = 100
sales[1][1] = 101
sales[1][2] = 102
sales[1][3] = 103
sales[1][4] = 104
sales[1][5] = 105
sales[1][6] = 106
sales[1][7] = 107
sales[1][8] = 108
sales[1][9] = 109
在前面的一维数组示例程序中曾说过,Length属性将返回数组中所有元素的个数,因此在这个例子中,Length的返回值就是20。在MultiDimArray.PrintSales方法中使用了Array.GetLength方法来确定数组每一维的长度(或者叫上界),然后在PrintSales方法中就可以使用每一个特定的值了。
6.2.4 查询秩
既然我们已经看到动态遍历一维数组或多维数组是多么简单,您可能就会问:如何从程序中确定数组的确切维数呢?数组的维数就叫做该数组的“秩”(rank),而秩可以通过Array.Rank属性来获得。请看下面的示例程序:/Example/Chapter6/6-2-3.cs
class RankArray
{
int[] singleD;
int[,] doubleD;
int[,,] tripleD;
public RankArray()
{
singleD = new int[6];
doubleD = new int[6,7];
tripleD = new int[6,7,8];
}
public void PrintRanks()
{
Console.WriteLine("singleD Rank = {0}",singleD.Rank);
Console.WriteLine("doubleD Rank = {0}",doubleD.Rank);
Console.WriteLine("tripleD Rank = {0}",tripleD.Rank);
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
RankArray rank = new RankArray();
rank.PrintRanks();
Console.Read();
}
}
程序执行结果如下:
singleD Rank = 1
doubleD Rank = 2
tripleD Rank = 3
6.2.5 锯齿状数组
关于数组的最后一点就是所谓的“锯齿状数组”Gas8ed叫),锯齿状数组其实就是
一个数组的数组。下面的代码定义了一个包含整数故组的数组:
int[][] jaggedarray;
如果您要开发一个编辑器,可能就会用到锯齿状数组。在这个编辑器中,您希望在一个数组中存储代表用户创建的控件的每一个对象。假设您已经有一个按钮和组合框的数组。您可能希望将三个按钮和两个组合框都分别存储到这个数组中。声明一个锯齿状数组就能够让您拥有一个这些数组的“父”数组。这样在程序上您就可以在需要的时候轻松遍历这些控件,如下所示:/Example/Chapter6/6-2-3.cs
class Control
{
virtual public void SayHi()
{
Console.WriteLine("Base control class");
}
}
class Button : Control
{
override public void SayHi()
{
Console.WriteLine("Button control");
}
}
class ComboBox : Control
{
override public void SayHi()
{
Console.WriteLine("ComboBox control");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Control[][] controls;
controls = new Control[2][];
controls[0] = new Control[3];
for(int i=0;i<controls[0].Length;i++)
{
controls[0] = new Button();
}
controls[1] = new Control[2];
for(int i=0;i<controls[1].Length;i++)
{
controls[1] = new ComboBox();
}
for(int i=0;i<controls.Length;i++)
{
for(int j=0;j<controls.Length;j++)
{
Control control = controls[j];
control.SayHi();
}
}
Console.Read();
}
}
上面的示例程序中定义了一个基类(Control)和两个派生类(Button)和(ComboBox),并且声明了一个锯齿状数组作为包含Control对象数组的数组。这样就可以将特定的类型存放在特定的数组中了。通过“神奇”的多态性,在从数组中抽取对象的时候(通过向上转换的对象,就会得到我们希望的结果。
6.3 使用索引器将对象当作数组对待
在“数组”一节里,我们学习了如何声明和实例化数组、如何使用不同的数组类型。以及如何遍历数组中的元素,同时还了解到如何利用System.Array类的一些常用的属性和方法。下面我们继续学习有关数组的知识,C#特有的一个属性——索引器,看看它是如何允许我们在程序上如对待数组一样对待对象。
那么我们为什么会希望把对象当作数组对待呢?与程序语言的大多数属性相似,索引器的好处就在于能够让应用程序的书写更加自然一些。在本章的第一节里,我们已经看到C#属性是如何使我们能够利用标准的class.field语法格式来引用类的字段,最终导致产生了getter和setter方法。这种抽象将书写客户代码的程序员解放了出来,他们无需确定字段的getter/setter方法是否存在,无需知道这些方法的确切格式。同样地,索引器也允许类的客户代码能够在对象中进行索引,就好像对象是一个数组一样。
考虑下面这一个例子。您有了一个列表框类需要公布一些成员,以便使用这个类的用户可以插入字符串。如果您熟悉Win32 SDK的话,您就知道为了将一个字符串插入到列表框窗口,需要发送一个LB_ADDSTRING或LB_INSERTSTRING消息。这种机制在二十世纪八十年代后期出现以后,我们就认为自己真的已经是面向对象的程序员了。总之,我们不是已经给对象发送消息了吗?与那些好笑的、面向对象分析和设计的书籍告诉我们的一样。其实,在诸如C++和Object Pascal这样的面向对象和基于对象的语言扩散的同时,我们已经知道可以使用对象来为这样的任务创建更加自然的程序接口。使用C++和MFC(Microsoft Foundation Classes)就有了整个的一个类的格子。使得我们可以将窗口(比如列表框)当作对象对待,这样的对象公开了一些成员函数,这些成员函数基本上是为对象与潜在的Microsoft Windows控件发送和接收消息提供了一层薄薄的包装。在ClistBox类(也就是Window列表框控件的MFC包装)这种情况下,我们就有了AddString和InsertString这样的成员函数来完成任务,而先前这些任务是由发送LB—ADDSTRING和LB_INSERTSTRING这样的消息来完成的。
不过,为了开发出最好和最自然的语言,C#设计小组看到这些问题并开始思索:“为什么不能将一个其实就是数组的对象当作数组对待呢?”比如列表框不就是一个带有额外的显示和排序功能的字符串数组吗?正是从这个想法诞生了索引器这一概念。
6.3.1 定义索引器
因为属性有时候被叫做“智能字段”,而索引器被称为“智能数组”,因此属性和索引器共享同样的语法格式也是合理的。实际上,定义索引器与定义属性极其相似,只有两点不同:第一,索引器有索引参数值;第二,由于类本身被当作数组使用,就将关键字“this”当作索引器的名称。我们很快会看到一个更完整的示例程序,先看看下面这个索引器例子:
class myClass
{
public object this[int idx]
{
get
{
//return desired data
}
set
{
//set desired data
}
}
}
此处还没有展示一个完整的例子来说明索引器的语法格式,因为定义数据以及如何得到或设置这些数据的内部实现其实与索引器无关。要记住不管在内部如何存储数据(存储为数组、集合等等),索引器部只不过是一种途径,以便实例化类的程序员可以这样来编写代码:
myClass cls = new myClass();
cls[0] = someObject;
Console.WriteLine("{0}",cls[0]);
6.3.2 索引器示例程序
下面看看在哪些场合可以使用索引器,还是以前面用过的列表框为例。从概念上来说,列表框就是一个列表,或者说是要显示的一个字符串数组。在下面的例子中,声明了一个叫做“MyListBox”的类,它包含了一个索引器通过ArrayList对象来设置和获取字符串(ArrayList类就是一个用于存储对象集合的.NET框架类)。
示例如下所示:/Example/Chapter6/6-3-1.cs
class myListBox
{
protected ArrayList data = new ArrayList();
public object this[int idx]
{
get
{
if(idx > -1 && idx < data.Count)
{
return (data[idx]);
}
else
{
// possibly throw an exception here
return null;
}
}
set
{
if(idx > -1 && idx < data.Count)
{
data[idx] = value;
}
else if (idx == data.Count)
{
data.Add(value);
}
else
{
// possibly throw an exception here
}
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
myListBox lBox = new myListBox();
lBox[0] = "First";
lBox[1] = "Second";
lBox[2] = "Third";
Console.WriteLine("{0},{1},{2}",lBox[0],lBox[1],lBox[2]);
}
}
注意在这个例子中我们检查了数据索引时可能会发生的越界错误。这种技术并不一定要与索引器一起使用,我们已经说过,索引器只需做到使用类的客户使用这个被当作数组的对象时感觉一切正常即可.而与数据内部如何表示无关。不过,在学习一种新的语言属性的时候。了解这种属性的实际用法总比只看它的语法格式有用。因此,在索引器的getter和setter方法中,我们校验了存储在类的ArrayList成员中的数据传递过来的索引值。作者个人可能会选择在传递过来的索引值无法解析时抛出异常,但这也仅仅是个人习惯——各人的错误处理方式也许会不同。关键在于如果传递的是非法索引时,您需要对客户代码指出这个错误。
6.2.3 设计规则
索引器是C#设计小组提供的另一个很灵巧、也很强大的语言属性,有助于我们在开发过程中提高工作效率。但是,与任何语言的任何属性一样,索引器也要用得恰当。它们只能用在将对象当作数组会更自然的场合。让我们看一看开发票的那个例子。开发票的应用程序有一个Invoice类,它定义了一个InvoiceDetail对象的成员数组。这种情况下,用户要访问这些细节栏,采用如下的浯法会更自然一些:
InvoiceDetail detail = invoice[2];//Retrieves the ID 3 detail line
不过,如果更进一步试图将所有的InvoiceDetail成员都转换为数组以便通过索引器来访问的话,就会适得其反了。这里您可以看到,下面第一行代码比第二行代码更容易理解。
TermCode terms = invoice.Terms; //Property accessor to Terms member
TermCode terms = invoice[3]; // a soloution in search of a problem
这种情况下,您可以做某事但并不意味着您必须做某事,而要具体情况具体分析。或更具体一点地讲,要想想实现的任何新属性会如何影响使用您的类的客户代码,在您决定要实现这些属性时,要保证客户代码能够更轻松地使用您的类。
6.4 本章小结
C#属性由字段声明和存取器方法组成。属性允许对类的字段进行智能访问,这样使用类书写客户代码的程序员就无需确定是否应该、以及如何为该字段创建存取器方法了。C#中的数组声明时只需将空的方括号放置在变量类型和变量名之间即可,这一点与C++中使用的语法格式稍微有所不同。C#数组可以是一维数组、多维数组或者锯齿状数组。通过索引器的使用,C#中的对象可以被当作数组对待。索引器允许程序员可以更轻松地处理和跟踪同样类型的多个对象。
6.5 实战演练
1。实现描述超市的的类Suppermacket类,记录系统中现有商品(用指针实现),定义增加商品的函数Append,删除商品的函数Delete,查询商品的函数Query,并显示查询结果;
2。定义商品类Goods,具有商品名称Name,商品价格Price,商品数量number等属性,操作Sale(销售商品,余额不足时给予提示)、Add(商品上架操作)和ShowMe(显示商品信息)。
3.编写main函数,测试以上所要求的各种功能,即可以根据菜单命令增加、删除和查询商品,以及商品销售和商品上架的操作。
第七章 接口与抽象
本章重点:
接口的声明
接口的实现
接口继承
合并接口
抽象的定义
接口与抽象的区别
本章目的:
通过本章的学习,我们应理解接口实现的功能,接口的定义、接口的实现、接口继承以及抽象类的定义与使用,同时掌握二者之间的区别。
理解接口的关键是把接口与类放在一起比较。类是具有属性和在这些属性上操作的方法的集合,尽管类确实展示了一些行为特征(方法),然而类更是—种与行为相对的结构,而这正是为什么出现接口的原因。接口使您可以定义行为特征或能力,而且可以直接向对象应用这些行为,而无需考虑类的层次。例如,假设您有一个分布式应用程序,其中有些实体可以破串行化。这些实体包括Customer、Supplier和Invoice类。而有些其他类,例如MaintenanceView和Document,无法被定义成可串行化。怎样才能使自己选择的类成为可串行化的呢?一个显而易见的方法就是,创建一个基础类,可以给这个类起一个诸如Serializable之类的名字。然而,这种方法有—个主要的缺陷。单继承路径无法正常工作,因为我们不希望类的所有行为都被共享。C#并不支持多继承,因此无法使一个给定的类可以选择性地从多个类中派生。所以真正的答案就是接口。接口使您能够定义一组语义上相关的方法和属性,所选择的类可以实现这些方法和属性,而无需考虑类的层次关系。
从概念的角度来看,接口是两个不同代码段之间的约定。也就是说,一旦定义了一个接口,而且定义了一个实现这个接口的类,类的客户端就可以得到保证——这个类已经实现了接口中所定义的所有方法。让我们看几个例子,您很快就可以明白这一点了。
在本章中,我们将考虑接口为什么是C#以及一般的基于组件的编程中如此重要的一个部分。然后我们将看一看如何在C#应用程序中声明和实现接口。最后,我们将深入学习更多特定细节,这些细节是关于使用接口以及克服多继承和名字冲突所带来的问题的。
注意:当您定义了一个接口,并在接口的定义中指定了使用这个接口的类时,我们就称这个类“实现了该接口”或是“从接口继承”。这两种说法都可以使用,而且您将会见到这两种说法在其他文章中交替使用。从个人角度来说,相对于“从其他类继承”的说法,作者认为“实现”是一个语义上更为正确的术语——接口被定义了行为,并且类被定义成实现该行为——但是这两种术语都是正确的!
7.1 接口的应用
为了理解接口的用途所在,让我们先看一看Microsoft Windows开发中一个传统的编程问题,其中没有使用接口,但是里面有两段完全不同的代码需要以一种普通的方式进行通信。假如您为Microsoft工作,而且您是控制面板小组(Control Panel Team)的一个出色的程序员。您需要为控制面扳中所包含的所有客户端程序供一种通用的手段,利用这种手段可以在控制面板中显示这些客户端程序的图标,而且最终用户可以执行这些程序。请记住,在引入COM之前就要设计出这种功能。您怎样才能为所有未来的应用程序都提供一种通用手段,使之可以利用这种手段集成到控制面板中呢?多年以来,所考虑的解决方案已经成为Windows开发中的一个标准部分了。
作为控制面板开发小组中的一位出色的程序员,您要设计一个(或多个)需要客户端应用程序来实现的功能,并为这些功能编写文档,此外还要设计一些规则。在控制面板程序的情况中,Microsoft决定编写一个您所需要的控制面板程序,来创建一个动态链接库(DLL),该动态链接库实现并导出了一个名为CPIApplet的函数。您还需要用扩展名.CPL来扩展这个DLL的名字。并把DLL放到Windows/System32文件夹中(在Windows 98中),而在Windows 2000中,应该放在文件夹WINNT/System32中。一旦控制面板加载,就会(利用函数LoadLibrary)把System32文件夹中所有扩展名为.CPL的文件加载进来,然后使用函数GetProcAddress来加载CPIApplet函数,从而检验您是否遵循了这些规则,并且检验是否可以与控制面板进行通信。
正如前面听提别过的,如果要处理这种情况,即有一些代码需要与一些现在未知的代码以一种通用的方法进行通信,这已经成为Windows中一种标准的编程模型。然而,这并不是最好的解决方法,而且这种方法自身也有明显的缺陷。这种技术最大的缺点就是,它强迫客户端——在这个例子中是控制面板代码——中包含大量有效性确认代码。例如,控制面板不能仅仅只假设文件夹中的任何.CPL文件都是Windows DLL文件。此外,控制面板还需要证实在那个DLL中有正确的函数,并且要证实那些功能能够完成文件所指定的工作。我们就是要在这里使用接口。接口使您能够在完全不同的代码之间创建相同的约定安排,但是是以一种更为面向对象和灵活的方式来完成的。此外,因为接口是C#语句的一部分,所以编译器确保了当一个类被定义为实现一个给定接口时,这个类可以完成其应该完成的工作。
在C#中,接口是“头等”概念,其中声明了一个引用类型,这个引用类型中只包含方法声明。“头等概念”到底是什么含义?该术语的意思是,当前所讨论的这个特性是该语言中一种内置的、集成的部分。换句话说,这不是在该语言被设计出来以后才加入进来的特性。现在让我们来深入地了解一些细节——接口是什么以及如何声明接口。
注意:C++开发人员请注意;一个接口基本上就是一个抽象类,这个抽象类中除了声明C#类的其他成员类型——例如属性、事件和索引器以外,只声明了纯虚拟方法。在本章的后面将介绍抽象类以及抽象类和接口的区别。
7.2 声明接口
接口中可以包含方法、属性、索引器和事件——其中任何一种部不是在接口自身中来实现的。让我们看一个例子,这样就可以懂得如何使用这个特性。假设您在为您的公司设计一个编辑器,在该编辑器上面要处理不同的Windows控件。您正在编写编辑器和例程,这些例程被用来对用户放在编辑器表单中的控件进行有效性检查。开发小组的其他人员编写表单上应该存放的控件,您几乎一定需要提供一些表单级的有效性。在适当的时候——例如当用户显式地告诉表单来对所有的控件或者是在表单处理期间进行有效性检查——表单就会迭代其附加控件并且对每个控件进行有功性检查,或者更为适当地,通知控件对自身进行检查。
您如何才能向控件提供这种有效性确认能力?正是在这里接口有看重要的作用。下面有一个简单的接口例子,其中包含了一个简单的名为Validate的方法:
interface IValidate
{
bool Validate();
}
现在您可以证明这样的事实——如果控件实现了IValidate接口,控件就可以生效了。 让我们仔细考虑一下上述代码片断的几个方面。第一,您并不希需要在接口方法上指定一个访问限定符,例如public。事实上,预先考虑访问限定符的方法声明会导致产生一个编译时的错误。这是因为按照定义,所有的接口方法部是public类型的(C++程序员可能也注意到,因为按照定义,接口是抽象类,所以不需要显式地利用appending=0把方法声明为pure virtual)。
除了方法以外.接口还可以定义属性、索引器和事件,如下所示:
Interface IExampleInterface
{
// Example property declaration
int testProperty { get; }
// Example event declaration
event testEvent Changed;
// Example indexer declaration
string this[int index] { get; set; }
}
7.3 实现接口
因为接口定义了一个约定,任何实现一个接口的类都必须定义那个接口中的每一项条目,否则就无法编译代码。要想使用前面部分中的IValidate例子,一个客户端类只需要实现接口的方法就可以了。在下面的例子中,有一个名为FancyControl的基础类和一个名为IValidate的接口。还有一个类MyControl,这个类从FancyControl类派生出来,并实现了IValidate。请注意语法以及MyControl对象是如何被转换(cast)到IValidate接口来引用其成员的。 /Example/Chapter7/7-3-1.cs
class FancyControl
{
protected string data;
public string Data
{
get{return data;}
set{data = value;}
}
}
interface IValidate
{
bool Validate();
}
class MyControl:FancyControl,IValidate
{
public MyControl()
{
data = "my Grid Data";
}
#region IValidate 成员
public bool Validate()
{
Console.WriteLine("Validating.....{0}",data);
return true;
}
#endregion
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyControl myControl = new MyControl();
IValidate val = (IValidate)myControl;
bool success = val.Validate();
Console.WriteLine("The validation of {0} was {1} successful",myControl.Data,(true==success?"":"not"));
}
}
使用上述类和接口,编辑器可以向控件查询其是否实现了IValidate接口(在下一节中,您将会了解如何实现这一步骤)。如果控件实现了IValidate接口,编辑器可以确认并调用所实现的接口方法。那么您可能会问,“为什么不能仅仅定义一个基础类来使用这个编辑器(这个基础类具有名为Validate的纯虚拟函数?)编辑器就会只接受从该基础类派生出来的控件,不是吗?”
这个方案也是可以工常工作的,但是它有很强的局限性。让我们假设您创建了自己的控件,并且这些控件都是从这个假想的基础类派生的。从而,这些控件都实现了这个Validate虚方法。这种方案一直都可以正常工作,直到有一天您发现有一个很强大的控件,您希望可以用来使用编辑器。假设您发现一个由其他人编写的网格(grid),这样的话,该网格就无法从编辑器的强制控件基础类派生了。在C++中,解决的方法是使用多继承,并且让您的网格同时继承第三方网格和编辑器的基础类。然而,C#并不支持多继承。
使用接口,您可以在单个类中实现多种行为特征。在C#中,您可以从单个类中派生,并且,除了拥有那些通过继承得到的功能以外,还可以按照类的需要来实现任意多的接口。例如,如果您要编辑器应用程序对控件的内容进行有效性检查。把控件绑定到数据库上、以及把控件的内容串行地输出到磁盘上,您就应该按照如下形式声明自己的类:
public class MyGrid : ThridPartyGrid,IValidate,ISerializable,IDataBound
{
……
}
正如前面所说的,下一节将回答这样的问题。“一段代码如何才能知道一个类何时实现一个给定的接口?”
7.3.1 使用is来查询实现
在/Example/Chapter7/7-3-1例子中,您己经看到了下面的代码,这些代码被用来将一个对象(MyControl)转换到这个类的一个已实现的接口(IValidate),然后调用这些接口成员的其中一个(Validate):
MyControl myControl = new MyControl();
IValidate val = (IValidate)myControl;
bool success = val.Validate();
如果一个客户端试图使用一个类,仿佛这个类已经实现了一个方法,但是实际上这个类并没有实现这个方法.这时会发生什么?下面的例子将可以编译,因为ISerializable是一个有效的接口。虽然如此,在运行的时候还是会抛出一个System。InvalidCaseException异常,因为MyGdd并没有实现接口ISerializable。这个应用程序然后就会终止,除非这个异常可以被显式地截获。
class FancyControl
{
protected string data;
public string Data
{
get{return data;}
set{data = value;}
}
}
interface ISerializable
{
bool Save();
}
interface IValidate
{
bool Validate();
}
class MyControl:FancyControl,IValidate,ISerializable
{
public MyControl()
{
data = "my Grid Data";
}
#region IValidate 成员
public bool Validate()
{
Console.WriteLine("Validating.....{0}",data);
return true;
}
#endregion
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyControl myControl = new MyControl();
ISerializable val = (ISerializable)myControl;
bool success = val.Save();
Console.WriteLine("The Saving of {0} was {1} successful",myControl.Data,(true==success?"":"not"));
}
}
当然,截获这个异常并不能改变这样的事实,即您所希望执行的代码在这种情况下实际上无法执行,您所需要的是一种在试图转换对象之前先向其进行查询的方法。一种做法是,使用is操作符。is操作符使您能够在运行时检查一种类型是否与另外一种类型相兼容。这个操作符采用以下格式,其中expression是一种引用类型:
expression is type
这个操作符产生一个布尔型的值,并且也因此可以在条件语句中使用。在下面的例子中,已经修改了代码,以便于在试图使用方法Serializable之前先检验类MyControl和接口ISerializable之间的兼容性: /Example/Chapter7/7-3-2.cs
class FancyControl
{
protected string data;
public string Data
{
get{return data;}
set{data = value;}
}
}
interface ISerializable
{
bool Save();
}
interface IValidate
{
bool Validate();
}
class MyControl:FancyControl,IValidate
{
public MyControl()
{
data = "my Grid Data";
}
#region IValidate 成员
public bool Validate()
{
Console.WriteLine("Validating.....{0}",data);
return true;
}
#endregion
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyControl myControl = new MyControl();
if (myControl is ISerializable)
{
ISerializable val = (ISerializable)myControl;
bool success = val.Save();
Console.WriteLine("The Saving of {0} was {1} successful",myControl.Data,(true==success?"":"not"));
}
else
{
Console.WriteLine("The ISerializable interface is not implemented");
}
}
}
既然您已经看到is操作符是如何使您能够检验两种类型之间的兼容性,以确保用法正确的,那么让我们再来看一看门is操作符的“近亲”——as操作符——并且将这两个操作符加以比较。
7.3.2 使用as来查询实现
使用as操作符可以便检验处理更加有效率。as操作符在可兼容的类型之间进行转换,并且采用如下格式,其中那个expression是任何一种引用类型:
Object = expression as type
如果所讨论的两种类型是相兼容的话,您可以把as看作是is操作符和转换的组合。as操作符和is操作符之间的一个重要不同就是,如果expression和type之间不兼容,as操作符把object设定为等于null。而不是返回一个布尔值,现在我们可以以一种更有效率的方式来重新编写这个例子的主程序:/Example/Chapter7/7-3-3.cs
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyControl myControl = new MyControl();
ISerializable val = myControl as ISerializable;
if (val!=null)
{
bool success = val.Save();
Console.WriteLine("The Saving of {0} was {1} successful",myControl.Data,(true==success?"":"not"));
}
else
{
Console.WriteLine("The ISerializable interface is not implemented");
}
}
}
可以看到,同样可以实现转换操作,通常情况下,as操作符的执行效率优于is操作符。
7.4 显示的接口成员名字限定
到目前为止,您已经见到,类在实现接口时指定一个访问限定符public,紧接着后面的是接口方法名字。然而,有时候您希望(甚至是需要)明确地用接口的名字来限定成员的名字。在这—节里,我们将考虑这么做的两个理由。
7.4.1 接口的名字隐藏
调用一个从接口实现的方法的最常见做法是把那个类的一个实例转换为该接口类型,然后调用所需要的方法。尽管这种做法是有效的,而且许多人都使用这种技术,但是从技术角度来说,您并不一定要把对象转换到它所实现的接口上来调用这个接口的方法。这是因为当一个类实现了一个接口的方法,这些方法同时也就是该类的public方法。
如下例所示:
interface IDataBound
{
void Bind();
}
public class EditBox:IDataBound
{
public void Bind()
{
Console.WriteLine("Binding to data store....");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
EditBox edit = new EditBox();
Console.WriteLine("Calling EditBox.Bind()...");
edit.Bind();
IDataBound bound = (IDataBound)edit;
Console.WriteLine("Calling (IDataBound)EditBox.Bind()...");
bound.Bind();
Console.Read();
}
}
程序执行结果如下:
Calling EditBox.Bind()...
Binding to data store....
Calling (IDataBound)EditBox.Bind()...
Binding to data store....
请注意,虽然这个应用程序以两种不同的方式调用了已经实现的Bind方法。一种是利用了转换,而另一种则没有使用转换——这两种方式都正确地调用了函数.因为Bind方法被处理了。虽然乍一看,能够直接调用所实现的方法而无需把对象转换到一个接口上,这种功能似乎是种优点,但是有时候这并不是我们所需要的,最为一种明显的原因是,几个不同接口的实现——每一个接口都可能含有大量的成员——可能很快就会使您的类的public名称空间遭到那些在实现类的作用域以外没有意义的成员的污染。您可以防止接口中被实现的成员成为该类的public成员,这要用到被称为名字隐藏(name hiding)的技术。
最为简单的名字隐藏就是,能够对那些除被派生类或实现类以外的任何代码(这些代码通常破称为外合世界(outside world),隐藏其所继承的成员名字。让我们假设有一个与我们前面所使用的例子相同的例子,其中一个EditBox类需要实现接口IDataBound——然而,这次EditBox类并不想要把IDataBound方法暴露给外部世界。它只是为了实现自己的目的才需要使用该接口,或者仅仅可能是程序员不希望那些一般客户端不会用到的大量方法把类的名称空间搞混乱。为了隐藏一个被实现接口的成员,您只需要把成员的public访问限定符去掉,并且用接口的名字来限制成员的名字,如下面所示:
interface IDataBound
{
void Bind();
}
public class EditBox:IDataBound
{
void IDataBound.Bind()
{
Console.WriteLine("Binding to data store....");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
EditBox edit = new EditBox();
Console.WriteLine("Calling EditBox.Bind()...");
//error:the Bind method no longer exists in the EditBox class’s namespace
edit.Bind();
IDataBound bound = (IDataBound)edit;
Console.WriteLine("Calling (IDataBound)EditBox.Bind()...");
bound.Bind();
Console.Read();
}
}
上述代码将无法通过编译,因为成员名字Bind下再是EditBox类的一部分。因此,这种技术使您能够把成员从类的名称空间中去掉,同时仍然允许使用转换操作来进行显式的访问。
在这里需要重申的一点是,当您隐藏一个成员时,您不能使用访问限定符,如果您试图在一个被实现的接口成员上使用访问限定符,就会产生一个编译错误。可能您发现这有点奇怪,但是考虑到隐藏的原因就是为了防止对当前类的外部可见,也就不以为怪了。既然访问限定符的存在只是为了定义对于基础类外部的可见级别,您就可以明白在使用名字隐藏时,使用访问限定符是没有意义的了。
7.4.2 避免名字模糊性
C#不支持多继承的一个主要原因就在于名字冲突的问题,这是由名字模糊性造成的。虽然C#并不在(从一个类的派生)对象一级支持多继承,但是它支持从一个类的继承。此外还支持多接口的实现。然而,这样的功能会带来一些代价:名字冲突。
在下面的例子中,我们有两个接口,ISerializable和IdataStore,这两个接口支持以两种不同格式进行读取和存储,一种是以二进制格式从对象到磁盘进行读取和存储,另一种是向一个数据库读取和存储。问题是这两个接口都含有名为SaveData的方法:
interface ISerializable
{
void SaveData();
}
interface IDataStore
{
void SaveData();
}
class Test : ISerializable,IDataStore
{
public void SaveData()
{
Console.WriteLine("Test.SaveData called");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Test test = new Test();
Console.WriteLine("Calling Test.SaveData");
test.SaveData();
}
}
现在写出的这些代码确实可以通过编译。但是,我们已经得到通知,在以后所构造出来的C#编译器中,这样的代码会导致一个编译时的错误产生,因为所实现的方法SaveData是不明确的。无论这些代码是否可以编译,在运行时都会有问题产生,因为调用SaveData方法的行为结果对于使用这个类的程序员来说是不清楚的——您用的SaveData方法究竟是要串行化地把数据从对象保存到磁盘.还是要把数据保存到数据库呢?
此外,请看下面的代码:
interface ISerializable
{
void SaveData();
}
interface IDataStore
{
void SaveData();
}
class Test : ISerializable,IDataStore
{
public void SaveData()
{
Console.WriteLine("Test.SaveData called");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Test test = new Test();
if (test is ISerializable)
{
Console.WriteLine("ISerializable is implemented");
}
if (test is IDataStore)
{
Console.WriteLine("IDataStore is implemented");
}
}
}
在这段代码中,is操作符对于这两个接口都是成功的,这就表示这两个接口都被实现了,但是我们知道事实并非如此。甚至连编译器在编译这个例子的代码时也给出了下面的警告:
给定表达式始终为所提供的(“ConsoleApplication1.IDataStore”)类型
给定表达式始终为所提供的(“ConsoleApplication1.ISerializable”)类型
问题在于这个类所实现的要么是Bind方法的串行化版本,要么是数据库版本(而不是同时实现两个版本)。然而,如果客户端检查其中一个接口的实现——两个接口的实现都是成功的——而同时又碰巧试图使用那个并没有被真正实现的那个接口,这时就会发生意外的结果。
您可以使用显式的成员名字限定,这样就可以解决这个问题:去掉访问限定符,并且预先在考虑使用成员名的时候——在这个例子里的成员名是SaveData——要附带上接口名字。/Example/Chapter7/7-4-1.cs
interface ISerializable
{
void SaveData();
}
interface IDataStore
{
void SaveData();
}
class Test : ISerializable,IDataStore
{
void ISerializable.SaveData()
{
Console.WriteLine("Test.ISerializable.SaveData called");
}
void IDataStore.SaveData()
{
Console.WriteLine("Test.IDataStore.SaveData called");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Test test = new Test();
if (test is ISerializable)
{
Console.WriteLine("ISerializable is implemented");
((ISerializable)test).SaveData();
}
if (test is IDataStore)
{
Console.WriteLine("IDataStore is implemented");
((IDataStore)test).SaveData();
}
}
}
现在这里没有关于哪个方法被调用的不明确问题了。这两个方法都用完整的限定名实现了,而且程序输出结果也正是您所预期的。
ISerializable is implemented
Test.ISerializable.SaveData called
IDataStore is implemented
Test.IDataStore.SaveData called
7.5 接口和继承
有两个常见问题与接口和继承有关。第一个问题涉及的是从一个基础类派生的事项,这个类中含有的方法名与该类所需要实现的接口方法名完全相同,我们用一个例子来演示这一点:
class Control
{
public void Serializable()
{
Console.WriteLine("Control.Serializable called");
}
}
interface IDataBound
{
void Serializable();
}
class EditBox : Control,IDataBound
{
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
EditBox edit = new EditBox();
edit.Serializable();
}
}
正如您已经知道的,要想实现一个接口,您必须在该接口的声明中为每个成员提供定义。然而,在上述例子中,我们并没有这么做,而这段代码居然也通过编译了!能够通过编译的原因在于C#要在类EditBox中查找一个已经实现的Serializable方法,而且也找到了。然而,编译器所作出的判断是不正确的,它所找到的并不是一个已经实现的方法。编译器所找到的Serializable方法是从类Control类继承来的Serializable方法。并不是所需要实现的IDataBound. Serializable方法。因此,虽然代码通过了编泽,但是并不能按照预期的那样工作,正如我们接下来将看到的。
现在我们更进一步看一下该问题。请注意,下面的代码首先通过as操作符来检查接口是否被实现,然后试着调用被实现Serializable的方法。代码可以通过编译并运行。然而,正如我们所知道的,类EditBox并没有真正地实现一个Serializable方法作为IDataBound继承的结果。EditBox已经具有一个从Control类继承的Serializable方法。这就意味着客户端可能会得到一些意外的结果。
class Control
{
public void Serializable()
{
Console.WriteLine("Control.Serializable called");
}
}
interface IDataBound
{
void Serializable();
}
class EditBox : Control,IDataBound
{
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
EditBox edit = new EditBox();
IDataBound bound = edit as IDataBound;
if(bound!=null)
{
Console.WriteLine("IDataBound is supported...");
bound.Serializable();
}
else
{
Console.WriteLine("IDataBound is not supported...");
}
}
}
当某个被派生的类中有一个方法,这个方法的名字与基础类实现的一个接口方法名字相同时,可能会发生另外一个问题。让我们看一看下面的代码:
interface ITest
{
void Foo();
}
class Base : ITest
{
public void Foo()
{
Console.WriteLine("Base.Foo(ITest implementation)");
}
}
class MyDerived : Base
{
public new void Foo()
{
Console.WriteLine("MyDerived.Foo");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyDerived myDerived = new MyDerived();
myDerived.Foo();
ITest test = (ITest)myDerived;
test.Foo();
}
}
程序执行结果如下:
MyDerived.Foo
Base.Foo(ITest implementation)
在这个例子中,Base类实现了ITest接口和这个接口的Foo方法。然而,MyDerived类从Base类派生出一个新类,并且也为新的类实现了一个新的Foo方法。那么哪一个Foo方法才会被调用呢?这取决于您有哪一个引用,如果您有一个指向MyDerived对象的引用,那么就会调用MyDerived对象的Foo方法,这是因为即使MyDerived对象实现了一个继承来的ITest.Foo方法,运行时也会执行MyDerived.Foo方法,因为关键字new指定了一个被继承方法的覆盖。
然而,当您显式地把对象myDerived转换到接口ITest上时,编译器要判断接口是否已经实现。MyDerived类有一个同名方法,但是这个同名方法并不是编译器所要找的那个方法。当您把一个对象转换到一个接口时,编译器遍历继承树,直到找到一个类,这个类在其基本列中含有该接口。这就是为什么Main方法的最后两行代码会调用ITest所实现的Foo方法的原因。
现在,这些潜在问题中的一些,包括名字冲突和接口继承,已经支持了以下建议的正确性:永远都要把对象转换到您所要使用的成员变量所在的那个接口。
7.6 合并接口
C#中的另一个强大的功能就是,能够把两个或以上的接口合并到一起,这样一个类只要实现合并后的接口就可以了。例如,让我们假设您想创建一个新的TreeView类,这个类既要实现IDragDrop接口,也要实现ISrotable接口。我们可以假设其他控件,例如ListView和ListBox,可能也想把这些特征合并起来。因为这样的假想是合理的,所以您可能希望把IDragDrop接口和ISortable接口合并成一个接口。
public class Control
{
}
public interface IDragDrop
{
void Drag();
void Drop();
}
public interface ISerializable
{
void Serialize();
}
public interface ICombo : ISerializable,IDragDrop
{
}
public class MyTreeView : Control,ICombo
{
public void Serialize()
{
Console.WriteLine("MyTreeView.Serialize called");
}
public void Drag()
{
Console.WriteLine("MyTreeView.Drag called");
}
public void Drop()
{
Console.WriteLine("MyTreeView.Drop called");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyTreeView tree = new MyTreeView();
tree.Drag();
tree.Drop();
tree.Serialize();
}
}
利用合并接口的功能,您就不仅可以简化把语义上相关的接口聚集在一个接口中的能力,还可以在需要的时候,向新的“复合”接口添加方法。
7.7 抽象的定义及使用
抽象类声明了一个或多个没有实现的方法。如果把一个方法声明为抽象的,也要把类声明为抽象的。抽象类不能进行实例化,实现了抽象类中抽象方法的类才能进行实例化。抽象类不能继承。
有时候我们需要表达一种抽象的东西,它是一些东西的概括,但我们又不能真正的看到它成为一个实体在我们眼前出现,为此面向对象的编程语言便有了抽象类的概念。在实现抽象方法是需要采用“override”关键字来实现方法覆盖。
Class abstract A //定义一个含有抽象方法的抽象类
{
Public abstract void Say();
Public void PrintString(string a);
{
System.Console.WriteLine(“You input string is {0}”,a);
}
}
Class B:A
{
Public override Say()
{
System.Console.WriteLine(“实现了抽象类的抽象方法。”);
}
}
Static void main()
{
B b = new B(); //实现了抽象方法的类B才可以使用
b.Say(); //已经实现的抽象方法
b.PrintString(“test”); //抽象类A提供的方法
}
7.8 接口与抽象类的比较
创建一个接口就是创建了一个或多个方法的定义,在每个实现该接口的类中必须实现这些方法。系统不会生成任何默认的方法代码,必须自己完成实现过程。接口的优点是它提供了一种让一个类成为两个类的子类的方式:一个是继承,一个来自于接口,如果实现该接口的类漏掉了一个接口方法,编译器会产生错误。
创建一个抽象类就是创建了这样一个基类,它可能有一个或多个完整的、可以工作的方法,但至少有一个方法未实现并声明为抽象的。不能实例化一个抽象类,而必须从它派生出类,这些类包含了抽象方法的实现过程。如果一个抽象类的所有方法在基类中都未实现,它在本质上就等同于一个接口,但限制条件是,一个类不能从它继承,也不能继承其他类层次结构,而使用接口则可以这样做。抽象类的作用是对派生类如何工作提供一个基类定义,允许程序员在不同的派生类中填充这些实现过程。
7.9 本章小结
C#中的接口允许开发出这样的一些类,这些类之间可以共享特性,但是这些类并不是同一个类层次关系中的一部分。接口在C#的开发中起着特殊的作用。因为C#真并不支持多继承。为了共享语义上相关的方法和属性,类可以实现多个接口。同样,is操作符和as操作符可以被用来判断一个特定的接口是否已经被对象所实现了,这可以有助于防止发生与使用接口成员有关的错误;最后,显式的成员命名和名字隐藏可以用来控制接口的实现.并可以防止错误的发生。最后介绍了抽象类的定义以及抽象类和接口的比较。其实接口是抽象类的一个特例。
7.1实战演练
关于宠物进笼
Anne的宠物小屋有12个笼子,每个笼子可以放不同的动物,但只能放1只或0只,包括
猫Cat,狗Dog,蛇Snake.
1。实现一个简单的管理系统,可增加、删除笼子中的宠物,查询每个笼子中存放的
宠物类型,(包括笼子为空的情况),统计笼中宠物的种类和数量.
2。定义描述宠物小屋的类shelves,其中有12笼子用于存放各种宠物.
3。定义虚拟基类Animal,包括纯虚函数ShowMe,显示每个宠物的情况,包括类型、颜色、
体重和喜爱的食物。
4。定义派生类Cat,Dog,Snake,具体实现上述纯虚函数。
5。重载输入‘>>'*作符,使得可能通过Console.read()方法直接读入宠物颜色、体重和喜爱的
食物。
6。编写具有main函数的类,测试上述要求和各种功能。
第八章 代表和事件处理器
本章重点:
代表的作用
代表的定义及使用
通过代表定义事件
本章目的:
通过本章的学习,我们应掌握代表的定义及使用,通过代表实现事件响应、实现方法回调等功能。
C#中另外一种很有用的创新就是代表,代表的目的基本上与C++中的函数指针相同。但是,代表是一种具有类型保护和安全机制的对象。这意味着在运行时刻能保证一个代表总是指向一个有效的方法,这又进一步表明,我们可以得到函数指针的所有优点,却不会带来与之相关的危险,例如一个无效的地址或是一个代表破坏其他对象的存储空间。在这一章里,我们将会仔细探讨代表,它们是如何与接口相比较的,定义代表的语法,以及它们被设计用来处理的不同问题。我们还会看几个使用代表的例子,这些例子中都有回调方法和异步事件处理.
在第7章中,我们已经看到,在C#中是如问定义接口和实现的,从概念的角度来说,接口只是两段完全不同代码部分之间的简单约定。然而,接口更像是类,因为它们是在编译时被定义的,而且可以包含方法、属性、索引器以及事件。换句话说,在C#编程中,代表有两个主要的用处:回调和事件处理。下面,我们将从对回调方法的讨论作为开始。
8.1 将代表用作回调方法
回凋方法广泛应用于Microsoft Windows编程,当需要把一个函数指针传递给另一个接下来进行回调的函数(通过被传递过来的指针)时,就要用到回调方法。这里有一个Win32 API函数EnumWindows的例子。该函数列举所有屏幕上的顶层窗口,调用为每个窗口提供的函数。回调可以用于许多目的,下面几个目的是最为常见的。
1. 异步调用
当被调用的代码将占用大量时间来处理请求时,就要在异步处理中用到回调方法了。一种典型的工作方式是这样:客户端代码调用一个方法,把一个回调方法传递给它。被调用的方法启动一个线程,并且立即返回。然后,线程完成大部分工作,调用所需的回调函数。这样做的好处很明显:可以允许客户端继续处理,无需在一个可能的长同步调用上被阻塞。
2. 把自定义代码加入类的代码路径中
回调方法的另一个常见用法就是,一个类允许客户端指定一个方法,这个方法将被调用,来进行自定义处理。让找们用一个Windows中的例子来对此加以说明。通过在Windows使用类Listbox,可以指定各项按照升序或降序排列。除了一些其他的排序选择以外,类Listbox并不能给出任何真正的范围并保持一个一般类。因此,类Listbox也可以使您能够指定一个回调函数,用来排序。这样的话,当Listbox对各项进行排序时,它调用回调函数,这样就可以用自己的代码来进行自定义的排序。
现在让我们来看一个定义和使用代表的例子。在这个例子中,我们有一个数据库管理器的类,对所有数据库上的活跃连接保持追踪,并提供一个方法来列举这些连接。假设数据库管理器位于一个远程服务器上,这样也许是一个良好的设汁决策,可以使得方法异步,并且允许客户端提供一个回调方法,注意,对于一个实际的应用程序来说,我们将其创建为典型的多线程应用程序,使其成为真正的异步方式。但是,为了使例子保持简单,而且也因为目前我们还没有介绍到多线程——我们先省略多线程。
首先,让我们来定义两个主要的类:DBManager和DBConnection
class DBConnection
{
}
class DBManager
{
static DBConnection[] activeConnections;
public delegate void EnumConnectionsCallback(DBConnection connection);
public static void EnumConnections(EnumConnectionsCallback callback)
{
foreach(DBConnection connection in activeConnections)
{
callback(connection);
}
}
}
方法EnumConnectionsCallback就是代表,并且是通过把关键字代表放在方法名前面来定义的,我们可以发现,这个代表被定义为返回void,并且只有一个参数值:一个DBConnection对象。然后,EnumConnections方法被定义为把一个EnumConnectionsCallback方法作为其惟一的参数值,为了调用DBManager.EnumConnections方法,我们需要给它传递一个实例化的DBManager.EnumConnectionsCallback代表。
为了这样做,需要用new来产生一个代表,把具有相同代表符号的方法名字传递给它。下面是一个这样的例子:
DBManager.EnumConnectionsCallback myCallback = new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback);
DBManager.EnumConnections(myCallback);
还要注意,可以把上面的语句在单个调用语句中联合使用,就像这样:
DBManager.EnumConnections(new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback));
这里是所有的代表的基本语法.现在让我们来看一看完整的应用程序例子。
/Example/Chapter8/8-1-1.cs
class DBConnection
{
protected string Name;
public DBConnection(string name)
{
this.Name = name;
}
public string name
{
get {return Name;}
set {Name = value;}
}
}
class DBManager
{
static DBConnection[] activeConnections;
public delegate void EnumConnectionsCallback(DBConnection connection);
public void AddConnections()
{
activeConnections = new DBConnection[5];
for(int i=0;i<5;i++)
{
activeConnections = new DBConnection("DBConnections"+(i+1));
}
}
public static void EnumConnections(EnumConnectionsCallback callback)
{
foreach(DBConnection connection in activeConnections)
{
callback(connection);
}
}
}
class TestApp
{
[STAThread]
public static void Main(string[] args)
{
DBManager dbMgr = new DBManager();
dbMgr.AddConnections();
DBManager.EnumConnectionsCallback myCallback = new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback);
DBManager.EnumConnections(myCallback);
Console.Read();
}
public static void ActiveConnectionsCallback(DBConnection connection)
{
Console.WriteLine("Callback method called for "+connection.name);
}
}
程序执行结果如下:
Callback method called for DBConnections1
Callback method called for DBConnections2
Callback method called for DBConnections3
Callback method called for DBConnections4
Callback method called for DBConnections5
8.2 把代表定义为静态成员
因为每次要用到代表时,客户端都不得不实例化一个代表,这显得太繁琐。C#允许把用于创建代表的方法定义为类的静态成员。下面的这个例子是上一节中用到过的,但是被修改成使用现在这种格式。注意,代表现在被定义为名为myCallback类的静态成员。还要注意,这个成员可以在main方法中使用,无需客户端对代表进行实例化。
/Example/Chapter8/8-2-1.cs
class DBConnection
{
protected string Name;
public DBConnection(string name)
{
this.Name = name;
}
public string name
{
get {return Name;}
set {Name = value;}
}
}
class DBManager
{
static DBConnection[] activeConnections;
public delegate void EnumConnectionsCallback(DBConnection connection);
public void AddConnections()
{
activeConnections = new DBConnection[5];
for(int i=0;i<5;i++)
{
activeConnections = new DBConnection("DBConnections"+(i+1));
}
}
public static void EnumConnections(EnumConnectionsCallback callback)
{
foreach(DBConnection connection in activeConnections)
{
callback(connection);
}
}
}
class TestApp
{
public static DBManager.EnumConnectionsCallback myCallback = new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback);
[STAThread]
public static void Main(string[] args)
{
DBManager dbMgr = new DBManager();
dbMgr.AddConnections();
DBManager.EnumConnections(myCallback);
Console.Read();
}
public static void ActiveConnectionsCallback(DBConnection connection)
{
Console.WriteLine("Callback method called for "+connection.name);
}
}
注意:因为代表的标准命名习惯是把以代表为参数的方法加上扩展词callback,这样就容易在使用代表时,误把方法的名字当作代表的名字。在这种情况下,就会在编译时产生误导性的错误,告诉您刚才指定了一个方法,但是缺少这个方法的类。如果产生这样的错误,要记住实际上的问题是,在程序中指定的是一个方法,而不是代表。
8.3 仅在需要时创建代表
到目前为止,无论用到与否,在我们所见到的两个例子中都创建了代表。在这两个例子中没有什么问题,因为它总是会被调用的。但是,当定义您自己的代表时,很重要的一点就是要考虑何时才创建代表。让我们考虑一下,例如,创建一个特定的代表是需要耗费时间的,并且这样做并非毫无代价。在有些情况下,我们知道客户端通常并不会调用一个给定的回调方法,这样就可以推迟代表的创建,直到实际需要在属性中包装(wrap)其实例的时候,为了演示怎样做,下面对DBManager类进行修改,使用一个只读属性——因为里面只有一个方法getter——来实例化代表。直到这个属性被引用时才创建该代表。
/Example/Chapter8/8-3-1.cs
class DBConnection
{
protected string Name;
public DBConnection(string name)
{
this.Name = name;
}
public string name
{
get {return Name;}
set {Name = value;}
}
}
class DBManager
{
static DBConnection[] activeConnections;
public delegate void EnumConnectionsCallback(DBConnection connection);
public void AddConnections()
{
activeConnections = new DBConnection[5];
for(int i=0;i<5;i++)
{
activeConnections = new DBConnection("DBConnections"+(i+1));
}
}
public static void EnumConnections(EnumConnectionsCallback callback)
{
foreach(DBConnection connection in activeConnections)
{
callback(connection);
}
}
}
class TestApp
{
[STAThread]
public static void Main(string[] args)
{
TestApp app = new TestApp();
DBManager dbMgr = new DBManager();
dbMgr.AddConnections();
DBManager.EnumConnections(app.myCallback);
Console.Read();
}
public DBManager.EnumConnectionsCallback myCallback
{
get
{
return new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback);
}
}
public static void ActiveConnectionsCallback(DBConnection connection)
{
Console.WriteLine("Callback method called for "+connection.name);
}
}
8.4 代表构成
构成代表的能力——从多个代表创建出单个代表——在一开始的时候并不能让人感到方便,但是如果您需要的话,那么一定会感到高兴。C#设计组已经考虑到了这一点。让我们来看几个例子,在这些例子中,代表构成是很有用的。在第一个例子中.有一个分布式系统,并且有一个类迭代给定地点的每一个部打,为每一个具有“。卜hMd”值小于50的部分凋用一个回调方法。在一个更为现实的分布式例子中,公式中不仅要考虑到“on土Md”值,还要考虑到与订货至交货时间有关的“甽order”和“讥咖s旷值,还要减去安全库存级别等等。但是,我们先尽量将随问题简单化:如果某个部分的on个Md值小于50,就会发生一个异常。
问题在于如果某个给定部分库存不够,那么我们要调用两个小同的方法:我们要记录
事件,然后辽要向采购主管发电子邮件。因此,让我们来看一下怎样程序化地从多个代表
创建出单个复合代表:
/Example/Chapter8/8-4-1.cs
class Part
{
protected string Sku;
protected int OnHand;
public Part(string sku)
{
this.Sku = sku;
Random r = new Random(DateTime.Now.Millisecond);
double d = r.NextDouble() * 100;
this.OnHand = (int)d;
}
public string sku
{
get { return this.Sku; }
set { this.Sku = value; }
}
public int onhand
{
get { return this.OnHand; }
set { this.OnHand = value; }
}
}
class InventoryManager
{
protected const int MinOnHand = 50;
public Part[] parts;
public InventoryManager()
{
parts = new Part[5];
for(int i=0;i<5;i++)
{
Part part = new Part("Part "+(i+1));
Thread.Sleep(10); //Randomizer is seeded by time
parts = part;
Console.WriteLine("Adding part '{0}' on-hand = {1}",part.sku,part.onhand);
}
}
public delegate void OutOfStockExceptionMethod(Part part);
public void ProcessInventory(OutOfStockExceptionMethod exception)
{
Console.WriteLine("Processing inventory...");
foreach(Part part in parts)
{
if(part.onhand < MinOnHand)
{
Console.WriteLine("{0} {1} is below minimum on-hand {2}",part.sku,part.onhand,MinOnHand);
exception(part);
}
}
}
}
class CompositeDelegateApp1
{
public static void LogEvent(Part part)
{
Console.WriteLine("\tlogging event...");
}
public static void EmailPurchasingMgr(Part part)
{
Console.WriteLine("\temailing purchasing manager...");
}
[STAThread]
public static void Main(string[] args)
{
InventoryManager mgr = new InventoryManager();
InventoryManager.OutOfStockExceptionMethod LogEventCallBack = new InventoryManager.OutOfStockExceptionMethod(LogEvent);
InventoryManager.OutOfStockExceptionMethod EmailPurchasingMgrCallBack = new InventoryManager.OutOfStockExceptionMethod(EmailPurchasingMgr);
InventoryManager.OutOfStockExceptionMethod OnHandExceptionEventsCallBack = LogEventCallBack + EmailPurchasingMgrCallBack;
mgr.ProcessInventory(OnHandExceptionEventsCallBack);
}
}
程序执行结果如下:
Adding part 'Part 1' on-hand = 34
Adding part 'Part 2' on-hand = 24
Adding part 'Part 3' on-hand = 46
Adding part 'Part 4' on-hand = 69
Adding part 'Part 5' on-hand = 91
Processing inventory...
Part 1 34 is below minimum on-hand 50
logging event...
emailing purchasing manager...
Part 2 24 is below minimum on-hand 50
logging event...
emailing purchasing manager...
Part 3 46 is below minimum on-hand 50
logging event...
emailing purchasing manager...
因而,使用语言的这种功能,我们可以动态地分辨出哪些方法中包含了回凋方法。把这些方法集中到单个代表中,并且把这个复合代表当作单个代表一样地传递。运行环境将自动确保所有的方法都按顺序调用。此外,可以使用减号操作符把所需的代表从复合代表中移去。
然而,这些方法按照顺序方式被调用的事实回避了一个重要的问题:为什么不能通过让每个方法连续不断地调用下一个方法,来简单地把这些方法链接到一起呢?在这一节的例子中,我们就可以这样做。该例子中只有两个方法,而且这两个方法总是成对调用的。但是我们还是把这个例子稍微复杂化些。假设我们有几个商店位置,每个位置都指示了应该调用的方法。例如,Location1可能是仓库,因此我们要记录事件,并向采购主管发送电子邮件。而所有其他地方低于最小OnHand值的部分会导致事件被记录下来,并且商店的主管会接收到电子邮件。
根据被处理对象的位置,通过动态地创建复合代表,我们可以很轻易地满足这些需求。如果没有这些代表的话,我们就不得不写一个方法,不仅要判断应该调用哪个方法,还要在调用过程中进行追踪,看哪个方法已经被调用过了,哪个方法还未被调用。正如您在下面的代码中所见到的一样,代表把原本可能非常复杂的操作简化了。
/Example/Chapter8/8-4-2.cs
class Part
{
protected string Sku;
protected int OnHand;
public Part(string sku)
{
this.Sku = sku;
Random r = new Random(DateTime.Now.Millisecond);
double d = r.NextDouble() * 100;
this.OnHand = (int)d;
}
public string sku
{
get { return this.Sku; }
set { this.Sku = value; }
}
public int onhand
{
get { return this.OnHand; }
set { this.OnHand = value; }
}
}
class InventoryManager
{
protected const int MinOnHand = 50;
public Part[] parts;
public InventoryManager()
{
parts = new Part[5];
for(int i=0;i<5;i++)
{
Part part = new Part("Part "+(i+1));
Thread.Sleep(10); //Randomizer is seeded by time
parts = part;
Console.WriteLine("Adding part '{0}' on-hand = {1}",part.sku,part.onhand);
}
}
public delegate void OutOfStockExceptionMethod(Part part);
public void ProcessInventory(OutOfStockExceptionMethod exception)
{
Console.WriteLine("Processing inventory...");
foreach(Part part in parts)
{
if(part.onhand < MinOnHand)
{
Console.WriteLine("{0} {1} is below minimum on-hand {2}",part.sku,part.onhand,MinOnHand);
exception(part);
}
}
}
}
class CompositeDelegateApp2
{
public static void LogEvent(Part part)
{
Console.WriteLine("\tlogging event...");
}
public static void EmailPurchasingMgr(Part part)
{
Console.WriteLine("\temailing purchasing manager...");
}
public static void EmailStoreMgr(Part part)
{
Console.WriteLine("\temailing store manager...");
}
[STAThread]
public static void Main(string[] args)
{
InventoryManager mgr = new InventoryManager();
InventoryManager.OutOfStockExceptionMethod[] exceptionMethods = new InventoryManager.OutOfStockExceptionMethod[3];
exceptionMethods[0] = new InventoryManager.OutOfStockExceptionMethod(LogEvent);
exceptionMethods[1] = new InventoryManager.OutOfStockExceptionMethod(EmailPurchasingMgr);
exceptionMethods[2] = new InventoryManager.OutOfStockExceptionMethod(EmailStoreMgr);
int Location = 1;
InventoryManager.OutOfStockExceptionMethod compositeDelegate;
if (Location == 2)
{
compositeDelegate = exceptionMethods[0] + exceptionMethods[1];
}
else
{
compositeDelegate = exceptionMethods[0] + exceptionMethods[2];
}
mgr.ProcessInventory(compositeDelegate);
}
}
编译并执行这个程序,根据对变量Location赋值的不同,会得到不同的结果。
8.5 定义具有代表的事件
几乎所有的Windows应用程序都需要处理一些异步事件。这类事件中,有些事件是通用的。例如,当用户以某种方式与应用程序进行交互时,Windows向应用程序消息队列发送消息。还有一些属于更加特殊的问题域,例如为一个更新过的订单打印发票。
C#中的事件遵循“发布——预订”的设计模式。在这种模式中,一个类公布能够出现的事件,然后任意数量的类都可以预订这个事件。一旦事件产生,运行环境就负责通知每个订户,告诉它们事件已经发生了。
方法作为所产生事件的结果,由代表来定义。但是,要时时记住在这种方式下使用代表时的严格规定。首先,代表必须被定义为采用两个参数值。第二,这些参数值总是代表两个对象,引发事件的对象(发布者)和一个事件信息对象。另外,第二个对象必须是从.NET框架的EventArgs类中派生出来的。
现在假设我们要监视库存级的改变。我们可以创建一个名为InventoryManager的类,这个类将总是用于更新库存。无沦何时.当通过类似于库存接收、销售以及实际上的库存更新等动作对库存进行改变时,InventoryManager类将发布一个事件,然后,任何需要保持同步更新的类将预订这个事件。下面的例子表明了如何在C#中使用代表和事件进行编码。
/Example/Chapter8/8-5-1.cs
class InventoryChangeEventArgs : EventArgs
{
string sku;
int change;
public InventoryChangeEventArgs(string sku,int change)
{
this.sku = sku;
this.change = change;
}
public string Sku
{
get { return this.sku; }
}
public int Change
{
get { return this.change; }
}
}
class InventoryManager //publisher
{
public delegate void InventoryChangeEventHandler(object source,InventoryChangeEventArgs e);
public event InventoryChangeEventHandler OnInventoryChangeHandler;
public void UpdateInventory(string sku,int change)
{
if(change==0)
return; // no update on null change
InventoryChangeEventArgs e = new InventoryChangeEventArgs(sku,change);
if(OnInventoryChangeHandler!=null)
{
OnInventoryChangeHandler(this,e);
}
}
}
class InventoryWatcher //subscriber
{
InventoryManager inventoryManager;
public InventoryWatcher(InventoryManager inventoryManager)
{
this.inventoryManager = inventoryManager;
inventoryManager.OnInventoryChangeHandler +=new InventoryManager.InventoryChangeEventHandler(OnInventoryChange);
}
void OnInventoryChange(object source,InventoryChangeEventArgs e)
{
int change = e.Change;
Console.WriteLine("Part {0} was {1} by {2} units",e.Sku,change>0 ? "increased":"decreased",Math.Abs(e.Change));
}
}
class EventApp1
{
[STAThread]
public static void Main(string[] args)
{
InventoryManager inventoryManager = new InventoryManager();
InventoryWatcher inventoryWatcher = new InventoryWatcher(inventoryManager);
inventoryManager.UpdateInventory("111 006 116",-2);
inventoryManager.UpdateInventory("111 005 383",5);
Console.Read();
}
}
让我们来看一看InventoryManager类的前两个成员:
public delegate void InventoryChangeEventHandler
(object source,InventoryChangeEventArgs e);
public event InventoryChangeEventHandler OnInventoryChangeHandler;
代码的第一行是一个代表.现在我们知道这个代表是为一个方法符号定义的。正如前面所提到的,所有在事件中用到的代表都必须被定义为采用两个参数:一个发布者对象(在这种情况下,是事件源)和一个事件信息对象(一个从EventArgs类派生的对象)。第二行代码中用到了关键字event。通过这个成员类型,可以指定代表和一个(或多个)当事件引发时调用的方法。
在类InventoryManager中的最后一个方法是方法UpdateInventory,无论何时库存被改变,这个方法都会被调用。正如您所见到的那样,这个方法创建了一个InventoryChangeEventArgs类型的对象。这个对象被传递到所有的订户那里,用于描述所发生的事件。
现在来看一看接下来的两行代码:
if(OnInventoryChangeHandler!=null)
OnInventoryChangeHandler(this,e);
条件语句if检查事件是否有什么与方法OnInventoryChangeHandler方法相关的订户。如果有的话——换句话说,就是OnInventoryChangeHandler不等于null—实际事件就被引发了。这就是在发布方要做的全部事情。现在让我们来看一看订户代码。
在这个例子中的订户是名为InventoryWatcher的类。这个类所要做的就是执行两个简单的任务。第一、实例化一个新的InventoryManager.InventoryChangeEventHandler并把这个代表加入到事件InventoryManager.OnInventoryChangeHandler上去,通过这种作法,可以把自己加为一个订户,要特别注意所用的语法——它用到了复合赋值操作符+=,把自己加入到订户列表中,这样做不会取消以前的订户。
inventoryManager.OnInventoryChangeHandler
+=new InventoryManager.InventoryChangeEventHandler(OnInventoryChange);
这里惟一需要提供的参数值就是当事件引发时被调用的方法名字。
订户需要完成的另外一个任务就是实现其事件处理器。在这个例子中,事件处理器是InventoryWatcher.OnInventoryChange,这个事件处理器打印一条消息,其中有该部分的号码和库存的变化。
最后,运行这个程序的代码把类InventoryManager和InventoryWatcher实例化,并在每次调用InventoryManager.UpdateInventory方法时,会自动引发一个事件,该事件导致了方法InventoryWatcher.OnInventoryChanged被调用。
8.6 本章小结
代表是一种具有类型保护和实全机制的对象,与C++中函数指针所要达到的目的相同。代表不同于类和接口,因为它不是在编译时被定义的,它指的是单个的方法,且在运行时被定义。代表通常情况下被用于执行异步处理,并把自定义的代码加入到类的代码路径中。代表有许多常规用途,包括作为回调方法、定义静态方法,以及定义事件等。
8.7 实战演练
1. 打开一个C#的项目查看Page_load事件是怎么响应的?实现了系统中的哪个代表?其代表原型是什么?
第一章 MICROSOFT.NET 概述 1
1.1 MICROSOFT.NET平台 1
1.2 .NET框架 2
1.2.1公共语言运行环境 2
1.2.2 .NET框架类库 3
1.3 Microsoft中间语言和JITters 5
1.4 编写第一个C#应用程序 6
1.5 本章小结 8
1.6 实战演练 8
第二章 C#基本语法 9
2.1 类型系统 9
2.1.1 数值类型 9
2.1.2 引用类型 9
2.2 装箱与开箱 10
2.3类型转换 11
2.4 表达式和操作符 11
2.4.1初级表达式操作符 11
2.4.2 关系操作符 12
2.4.3 简单赋值操作符 13
2.5 程序流程控制语句 14
2.5.1 选择语句 14
2.5.2 迭代语句 16
2.5.3 跳转语句 18
2.5 本章小结 20
2.5 实战演练 20
第三章 面向对象编程的基础知识 21
3.1一切都是“对象” 22
3.1.1 对象和类 24
3.1.2 实例化 25
3.2 面向对象编程语言的三大原则 26
3.2.1 封装 26
3.2.2 继承 28
3.2.3 多态性 30
3.4 本章小结 33
3.5 实战演练 33
第四章 类 34
4.1 定义类 34
4.2 类的成员 34
4.3 访问限定符 35
4.4 构造函数 35
4.4.1 静态成员和实例成员 37
4.4.2构造函数的初始化函数 38
4.5 常量和只读字段 41
4.5.1 常量 41
4.5.2 只读字段 42
4.6 对象的清除和资源管理 43
4.7 继承 43
4.7.1 多接口 45
4.7.2 封装类 45
4.8 本章小结 46
4.9 实战演练 47
第五章 方法 48
5.1方法参数“REF”和“OUT” 48
5.2 方法重载 51
5.3虚拟方法 53
5.3.1方法覆盖 53
5.3.2多态性 54
5.4 静态方法 58
5.5 本章小结 59
5.6 实战演练 59
第六章 属性、数组和索引器 60
6.1 属性——智能字段 60
6.1.1定义和使用属性 61
6.1.2编译器的工作原理 62
6.1.3 只读属性 63
6.1.4继承属性 63
6.1.5 属性的高级使用 63
6.2 数组 64
6.2.1 声明数组 65
6.2.2 一维数组 65
6.2.3 多维数组 66
6.2.4 查询秩 68
6.2.5 锯齿状数组 68
6.3 使用索引器将对象当作数组对待 70
6.3.1 定义索引器 71
6.3.2 索引器示例程序 71
6.2.3 设计规则 73
6.4 本章小结 73
6.5 实战演练 73
第七章 接口与抽象 74
7.1 接口的应用 74
7.2 声明接口 75
7.3 实现接口 76
7.3.1 使用is来查询实现 78
7.3.2 使用as来查询实现 80
7.4 显示的接口成员名字限定 81
7.4.1 接口的名字隐藏 81
7.4.2 避免名字模糊性 83
7.5 接口和继承 86
7.6 合并接口 89
7.7 抽象的定义及使用 90
7.8 接口与抽象类的比较 91
7.9 本章小结 91
7.1实战演练 92
第八章 代表和事件处理器 93
8.1 将代表用作回调方法 93
8.2 把代表定义为静态成员 96
8.3 仅在需要时创建代表 97
8.4 代表构成 99
8.5 定义具有代表的事件 103
8.6 本章小结 106
8.7 实战演练 106
本章重点:
.NET框架
公共语言运行环境(CLR)
命名空间
.NET程序运行原理
本章目的:
通过本章的学习,我们可以理解.NET平台的基本结构,理解公共语言运行环境、命名空间等相关新的名词。了解.NET平台下程序的有运行机制。
1.1 Microsoft.Net平台
Microsoft.Net提出的想法是:由.NET将计算重点从一个由单独的设备和WEB站点通过Internet简单相连的世界,转变成一个由设备,服务程序和计算机协同工作的世界。以便为用户提供更加丰富的解决方案。Microsoft.NET方案由一下四个关键部分组成:
.NET构建块服务(Buliding Block Service)
指对某些特定服务程序的访问。如文件存储的服务、日历管理或Passport.NET(一种身份鉴别服务)。
.NET设备软件
运行于新型Internet设备上的软件.
.NET用户体验
包括自然界面,信息代理和智能标签等这样的功能,这些技术可以自动建立超级链接,这些链接指向与用户创建的文档中的单词或短语相关的信息.
.NET基础结构
由.NET框架(FameWork)、Microsoft Visual Studio .NET、.NET企业服务器(Enterprise Server)和Microsoft Windows .NET组成。
在这里大多数开发人员提到.NET时,他们指的是.NET的一部分,即.NET基础结构.在本书的后边章节所提到的.NET,您也可以认为它就指.NET基础结构.因为.NET基础结构包含组成这个新的开发环境的所有技术.采用这个开发环境您可以创建和运行强大的、可升级的、分布式的应用程序.在.NET基础结构中能让我们开发这样程序的工具就是.NET框架.
.NET框架由公共语言运行环境(Comon Language Runtime,CLR)和.NET框架类库组成.类库也称为基础类库(Base Class Library,BCL).可以把CLR看作是一台虚拟机器,所有的.NET应用程序在这台机器中运行.所有的.NET语言都可以使用.NET框架基类库.该类库支持从文件I/O和数据库I/O到XML和SOAP的一切,非常庞大.
注意: 本书提到”虚拟机器”并不是指Java中的虚拟机(Java Virtual Machine,JVM),它指的是传统的定义.是指一种高级操作系统的抽象概念.别的操作系统可以在这个完全封闭的环境下运行.把CLR看作是虚拟机器是因为所有在CLR中运行的代码也是在一个封闭的、受控的环境下运行,与机器上的进程隔离开.
1.2 .Net框架
.NET框架由公共语言运行环境(CLR)和类库组成.如图:1-2;1-3
1.2.1公共语言运行环境
公共语言运行环境(CLR)是.NET的核心.顾名思义,CLR就是一个运行期环境,使用不同的语言编写的应用程序都可以在这里运行并且互不干扰----即跨语言互用(crosss-language interoperability).那么CLR又是怎样实现跨语言互用这个环节的呢?因为它们任何一种语言都必须遵守一套标准的规则,就是公共语言规范(CLS).
和公共语言运行环境相关的两个概念是受控代码和垃圾收集.受控代码是指运行在公共语言运行环境中的程序由公共语言运行环境来负则内存管理 线程管理 代码执行 代码安全验证 编译以及其它系统服务.垃圾收集是指运行程序时公共语言运行环境对内存的管理,当系统对某些对象未使用并且没有释放内存时,由公共语言运行环境来自动进行内存释放操作.从这两点看,采用公共语言运行环境后即可以让程序员专心的去考虑程序的功能,同时还提高了软件的运行效率.
图1-2
图 1-3
1.2.2 .NET框架类库
.NET Framework 类库是一个与公共语言运行库紧密集成的可重用的类型集合。该类库是面向对象的,并提供您自己的托管代码可从中导出功能的类型。这不但使 .NET Framework 类型易于使用,而且还减少了学习 .NET Framework 的新功能所需要的时间。此外,第三方组件可与 .NET Framework 中的类无缝集成。
例如,.NET Framework 集合类实现一组可用于开发您自己的集合类的接口。您的集合类将与 .NET Framework 中的类无缝地混合。
正如您对面向对象的类库所希望的那样,.NET Framework 类型使您能够完成一系列常见编程任务(包括诸如字符串管理、数据收集、数据库连接以及文件访问等任务)。除这些常见任务之外,类库还包括支持多种专用开发方案的类型。例如,可使用 .NET Framework 开发下列类型的应用程序和服务:
控制台应用程序。
Windows GUI 应用程序(Windows 窗体)。
ASP.NET 应用程序。
XML Web Services。
Windows 服务。
例如,Windows 窗体类是一组综合性的可重用的类型,它们大大简化了 Windows GUI 的开发。如果要编写 ASP.NET Web 窗体应用程序,可使用 Web 窗体类。
.NET Framework 包括类、接口和值类型,它们可加速和优化开发过程并提供对系统功能的访问。为便于语言之间进行交互操作,.NET Framework 类型是符合 CLS 的,并因此可在任何编程语言中使用,只要这种语言的编译器符合公共语言规范 (CLS)。
.NET Framework 类型是生成 .NET 应用程序、组件和控件的基础。.NET Framework 包括的类型执行下列功能:
表示基础数据类型和异常。
封装数据结构。
执行 I/O。
访问关于加载类型的信息。
调用 .NET Framework 安全检查。
提供数据访问、多客户端 GUI 和服务器控制的客户端 GUI。
.NET Framework 提供一组丰富的接口以及抽象类和具体(非抽象)类。可以按原样使用这些具体的类,或者在多数情况下从这些类派生您自己的类。若要使用接口的功能,既可以创建实现接口的类,也可以从某个实现接口的 .NET Framework 类中派生类。
1.2.2.1 .NET类库命名规定
.NET Framework 类型使用点语法命名方案,该方案隐含了层次结构的意思。此技术将相关类型分为不同的命名空间组,以便可以更容易地搜索和引用它们。全名的第一部分(最右边的点之前的内容)是命名空间名。全名的最后一部分是类型名。例如,System.Collections.ArrayList 表示 ArrayList 类型,该类型属于 System.Collections 命名空间。System.Collections 中的类型可用于操作对象集合。
此命名方案使扩展 .NET Framework 的库开发人员可以轻松创建分层类型组,并用一致的、带有提示性的方式对其进行命名。库开发人员在创建命名空间的名称时应使用以下原则:
“公司名称.技术名称”
例如,Microsoft.Word 命名空间就符合此原则。
利用命名模式将相关类型分组为命名空间是生成和记录类库的一种非常有用的方式。但是,此命名方案对可见性、成员访问、继承、安全性或绑定无效。一个命名空间可以被划分在多个程序集中,而单个程序集可以包含来自多个命名空间的类型。程序集为公共语言运行库中的版本控制、部署、安全性、加载和可见性提供外形结构。
1.2.2.2 命名空间
System 命名空间是 .NET Framework 中基本类型的根命名空间。此命名空间包括表示由所有应用程序使用的基础数据类型的类:Object(继承层次结构的根)、Byte、Char、Array、Int32、String 等。在这些类型中,有许多与编程语言所使用的基元数据类型相对应。当使用 .NET Framework 类型编写代码时,可以在应使用 .NET Framework 基础数据类型时使用编程语言的相应关键字。
除基础数据类型外,System 命名空间还包含近 100 个类,范围从处理异常的类到处理核心运行时概念的类,如应用程序域和垃圾回收器。System 命名空间还包含许多二级命名空间。
1.3 Microsoft中间语言和JITters
Microsoft开发了一种类似于汇编语言叫做Microsoft中间语言(Microsoft Intermediate Language,MSIL).要编译出在.NET上运行的程序,可以将源代码当作编译器的输入,而编译器将产生MSIL作为输出.MSIL本身就是一种完备的语言,您也可以采用它来编写程序.但是作为一种汇编语言,您可能永远也不会用它来编写程序.除非非常特殊.下面看一下C#程序的编译过程:
1. 您使用C#编写了源代码.
2. 然后使用C#编译器(csc.exe)将它编译成一个EXE文件
3. C#编译器将输出一个MSIL和一个清单,组成EXE文件的只读部分.这个EXE文件带有一个标准的PE(Win32---Portable Executable)头.
注意这里有个关键的地方:当编译器创建输出时,它同时也从.NET运行环境导入了一个名为_CorExeMain函数.
4. 应用程序执行时,操作系统载入PE,与任何独立的动态链接库(DLL)一样,如导出_CorExeMain函数的mscoree.dll,操作系统把它当作合法的PE.
5. 操作系统载入器跳转到PE内部的入口处,这个入口是由C#编译器放置的.别的PE在Windows中也是这样执行的.
但是由于操作系统并不能执行MSIL代码,因此入口仅仅是一个跳转,直接跳转到mscoree.dll中的_CorExeMain函数.
6. _CorExeMain函数开始执行PE中的MSIL代码.
7. 由于不能直接执行MSIL代码--------因为它不是一种可执行的机器码,CLR在处理MSIL的时候,就使用JIT(Just-in-Time,即时)编译器 (或叫作JITter)把MSIL代码编译成真正的CPU指令.只有当程序中的方法被调用时才会进行即时编译.编译后的可执行代码在机器上缓存起来.只有当源代码改变时才会被重新编译.
根据不同的情况,有三中不同的JITter可以用来把MSIL转换成真正的代码:
1.安装时生成代码 (install-time code generation)
2.JIT --------------默认选项
3.EconoJIT(经济型) -------------用于手持设备
1.4 编写第一个C#应用程序
我们打开记事本编写如下程序:
//My First C# Program
//Name HelloWorld.cs
using System;
class HelloWorld{
public static void Main(){
Console.WriteLine(“HelloWorld!”);
}
}
注意:保存时存成HelloWorld.cs文件,“.cs”是C#源代码文件的扩展名。
在配置好C#编辑器的命令行环境里键入”csc HelloWorld.cs”编译文件,编译结果将输出HelloWorld.exe文件。执行结果将在控制台屏幕输出”HelloWorld!”
下面我们来分析一下程序代码和整个程序的编译输出及执行过程。
先看文件开始的两行代码:”//” “/* */” 两种注释方法。
再看下面的”using System”语句,这是C#语言的using命名空间指示符,这里的"System"是Microsoft.NET系统提供的类库。C#语言没有自己的语言类库,它直接获取Microsoft.NET系统类库。Microsoft.NET类库为我们的编程提供了非常强大的通用功能。该语句使得我们可以用简短的别名"Console"来代替类型"System.Console"。当然using指示符并不是必须的,我们可以用类型的全局名字来获取类型。实际上,using语句采用与否根本不会对C#编译输出的程序有任何影响,它仅仅是简化了较长的命名空间的类型引用方式。
接着我们声明并实现了一个含有静态Main()函数的HelloWorld类。C#所有的声明和实现都要放在同一个文件里,不像C++那样可以将两者分离。Main()函数在C#里非常特殊,它是编译器规定的所有可执行程序的入口点。由于其特殊性,对Main()函数我们有以下几条准则:
1. Main()函数必须封装在类或结构里来提供可执行程序的入口点。C#采用了完全的面向对象的编程方式,C#中不可以有像C++那样的全局函数。
2. Main()函数必须为静态函数(static)。这允许C#不必创建实例对象即可运行程序。
3. Main()函数保护级别没有特殊要求, public,protected,private等都可,但一般我们都指定其为public。
4. Main()函数名的第一个字母要大写,否则将不具有入口点的语义。C#是大小写敏感的语言。
5. Main()函数的参数只有两种参数形式:无参数和string 数组表示的命令行参数,即static void Main()或static void Main(string[]args) ,后者接受命令行参数。一个C#程序中只能有一个Main()函数入口点。其他形式的参数不具有入口点语义,C#不推荐通过其他参数形式重载Main()函数,这会引起编译警告。
6. Main()函数返回值只能为void(无类型)或int(整数类型)。其他形式的返回值不具有入口点语义。
Main函数的内部实现:
Console是在命名空间System下的一个类,它表示我们通常打交道的控制台。而我们这里是调用其静态方法WriteLine()。如同C++一样,静态方法允许我们直接作用于类而非实例对象。WriteLine()函数接受字符串类型的参数"Hello World !",并把它送入控制台显示。如前所述,C#没有自己的语言类库,它直接获取Microsoft.NET系统类库。我们这里正是通过获取Microsoft.NET系统类库中的System.Console.WriteLine()来完成我们想要的控制台输出操作。这样我们便完成了"Hello World!"程序。
但事情远没那么简单!在我们编译输出执行程序的同时,Microsoft.NET底层的诸多机制却在暗地里涌动,要想体验C#的锐利,我们没有理由忽视其背靠的Microsoft.NET平台。实际上如果没有Microsoft.NET平台,我们很难再说C#有何锐利之处。我们先来看我们对"HelloWorld.cs"文件用csc.exe命令编译后发生了什么。是的,我们得到了HelloWorld.exe文件。但那仅仅是事情的表象,实际上那个HelloWorld.exe根本不是一个可执行文件!那它是什么?又为什么能够执行?
好的,下面正是回答这些问题的地方。首先,编译输出的HelloWorld.exe是一个由中间语言(IL),元数据(Metadata)和一个额外的被编译器添加的目标平台的标准可执行文件头(比如Win32平台就是加了一个标准Win32可执行文件头)组成的PE(portable executable,可移植执行体)文件,而不是传统的二进制可执行文件--虽然他们有着相同的扩展名。中间语言是一组独立于CPU的指令集,它可以被即时编译器Jitter翻译成目标平台的本地代码。中间语言代码使得所有Microsoft.NET平台的高级语言C#,VB.NET,VC.NET等得以平台独立,以及语言之间实现互操作。元数据是一个内嵌于PE文件的表的集合。元数据描述了代码中的数据类型等一些通用语言运行时(Common Language Runtime)需要在代码执行时知道的信息。元数据使得.NET应用程序代码具备自描述特性,提供了类型安全保障,这在以前需要额外的类型库或接口定义语言(Interface Definition Language,简称IDL)。
这样的解释可能还是有点让人困惑,那么我们来实际的解剖一下这个PE文件。我们采用的工具是.NET SDK Beta2自带的ildasm.exe,它可以帮助我们提取PE文件中的有关数据。我们键入命令"ildasm /output:HelloWorld.il HelloWorld.exe",一般可以得到两个输出文件:helloworld.il和helloworld.res。其中后者是提取的资源文件,我们暂且不管,我们来看helloworld.il文件。我们用"记事本"程序打开可以看到元数据和中间语言(IL)代码,由于篇幅关系,我们只将其中的中间语言代码提取出来列于下面,有关元数据的表项我们暂且不谈:
.class private auto ansi beforefieldinit HelloWorld
extends [mscorlib]System.Object
{
.method public hidebysig static void Main() cil managed
{
.entrypoint
// 代码大小 11 (0xb)
.maxstack 1
IL_0000: ldstr "HelloWorld!"
IL_0005: call void [mscorlib]System.Console::WriteLine(string)
IL_000a: ret
} // end of method HelloWorld::Main
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// 代码大小 7 (0x7)
.maxstack 1
IL_0000: ldarg.0
IL_0001: call instance void [mscorlib]System.Object::.ctor()
IL_0006: ret
} // end of method HelloWorld::.ctor
} // end of class HelloWorld
我们粗略的感受是它很类似于早先的汇编语言,但它具有了对象定义和操作的功能。我们可以看到它定义并实现了一个继承自System.Object 的HelloWorld类及两个函数:Main()和.ctor()。其中.ctor()是HelloWorld类的构造函数,可在"HelloWorld.cs"源代码中我们并没有定义构造函数呀--是的,我们没有定义构造函数,但C#的编译器为我们添加了它。你还可以看到C#编译器也强制HelloWorld类继承System.Object类,虽然这个我们也没有指定。关于这些高级话题我们将在以后的讲座中予以剖析。
那么PE文件是怎么执行的呢?下面是一个典型的C#/.NET应用程序的执行过程:
1. 用户执行编译器输出的应用程序(PE文件),操作系统载入PE文件,以及其他的DLL(.NET动态连接库)。
2. 操作系统装载器根据前面PE文件中的可执行文件头跳转到程序的入口点。显然,操作系统并不能执行中间语言,该入口点也被设计为跳转到mscoree.dll(.NET平台的核心支持DLL)的_ CorExeMain()函数入口。
3. CorExeMain()函数开始执行PE文件中的中间语言代码。这里的执行的意思是通用语言运行时按照调用的对象方法为单位,用即时编译器将中间语言编译成本地机二进制代码,执行并根据需要存于机器缓存。
4. 程序的执行过程中,垃圾收集器负责内存的分配,释放等管理功能。
5. 程序执行完毕,操作系统卸载应用程序。
清楚的知晓编译输出的PE文件的执行过程是深度掌握C#语言编程的关键,这种过程的本身就诠释着C#语言的高级内核机制以及其背后Microsoft.NET平台种种诡秘的性质。一个"Hello World !"程序的概括力已经足够,在我们对C#语言有了一个很好的起点之后,下面的专题会和大家一起领略C#基础语言,窥探Microsoft.NET平台构造,步步体验C#锐利编程的极乐世界,Let's go!
1.5 本章小结
Microsoft代表了计算模型的转变,在这个新的计算模型中,所有的设备、服务程序和计算机一起协同工作为用户提供解决方案.这个转变的中心在于.NET框架和CLR的开发.如图1-2所示.NET框架包含那些编译成在CLR中运行的语言所共享的类库.因为C#是专门为CLR设计的.没有CLR和.NET框架类库的话.C#连最简单的输入输出的功能都不能实现.
1.6 实战演练
自己采用记事本编写一个简单的控制台应用程序,然后通过C#编译器进行编译运行。在对其进行反编译,查看MSIL代码,领悟.NET应用程序的运行过程。
第二章 C#基本语法
本章重点:
类型系统
装箱和开箱
常用表达式和操作符
程序流程控制语句
本章目的:
通过本章的学习,我们掌握了.NET平台下的数据类型系统,以及C#编成所需要的基本工具:操作符和表达式的使用。控制程序结构语句的使用。可以编写简单的C#程序。
2.1 类型系统
大多数编程语言都有两种数据类型:一种是语言本身具有的类型(基本数据类型);一种是可以由用户来创建的类型(类)。基本类型就是通常的简单类型,如字符、数字和布尔等;而类则是倾向于更精巧的类型。
使用两种完全不同的类型系统会导致很多问题产生。如兼容性问题,还有就是当您希望指定一个方法使用该语言支持的“任何”类型作为参数值时,由于这些基本类型互不兼容,您就不能指定这样的参数,除非为每一个基本类型编写一个包装类。
值得高兴的是在.NET的世界中就不会有这样的问题。因为.NET的核心是一个公共类型系统。在公共类型系统中一切都是对象,而且所有的对象都是从一个基类中隐性派生出来的。这个基类是公共类型系统的一部分,就是“System.Object”。相关内容在装箱与开箱中详细介绍。
2.1.1 数值类型
当您拥有一个数值类型的变量时,实际上就拥有了一个包含实际数据的变量。因此,数值类型的首要规则就是不能为空(NULL)。如下实例:
int i = 32;
该实例通过C#的公共类型系统在堆栈上分配了一个32位的空间,并把变量名为”i”的值存入了该空间。
在C#中定义了多种数值类型,包括枚举、结构和基本类型等。不管何时声明了一个以上类型的变量,都会在堆栈上分配与该类型相关的字节空间。并且直接与这些已分配的位打交道。此外传递一个数值类型的变量时,您传递的改变量的值,而不是对包含对象的引用。
2.1.2 引用类型
引用类型与C++中的引用类似,即它们都是类型安全的指针。类型安全的引用所引用的并不是一个准确无误的地址,而且它(非空时)总能保证指向指定类型的对象。并且该对象已经被分配在堆上。这里应注意的是引用也可以为空这个事实。如下实例:
string s =”Hello World!”;
该实例分配了一个引用类型(string),事实上是在堆上分配了一个值,并且返回了一个指向该值的引用。与基本数值类型一样,C#也定义了多种引用类型,如类、数组、代理和接口等。不管你何时声明了一个以上类型的变量,都会在堆上分配与该类型相关的字节空间,并且直接与该对象的引用而不是那些已分配的位打交道。
2.2 装箱与开箱
难道C#也是两种不同的数据类型吗?不是。那它是怎么实现类型兼容的呢?那就是“装箱”(boxing)来实现的。最简单的理解就是:装箱就是将数值类型转换为引用类型。相对应的就是引用类型通过“开箱”转换为数值类型。
这项技术之所以如此“伟大”,是因为一个对象在它需要是一个对象的时候,它就仅仅是一个对象。比如:如果您声明了一个System.int32类型的数值类型变量。您可以把它作为参数传递给任何方法,如果该方法的参数类型定义为System.Object,系统会自动执行装箱操作,将它转变成一个Object。对于程序员来说,它和普通的数据类型一样,但可以当作对象来操作。但事实上它只是堆栈上的4个字节而已。如:
int temp = 58; //数值类型
System.Object bar = temp; //temp 被执行装箱操作转变成 对象类型 bar 这是编译器就生成该值装箱所需的MSIL代码。
现在,要把bar转换为数值类型,就可以执行一个显示转换。
int temp = 58;
System.Object bar = temp;
int temp2 = (int)bar;
注意:装箱就是将数值类型转换成引用类型。
开箱就是将引用类型转换成数值类型。(需指明被转换的类型,因为它可以被转换成任何类型)
2.1所有类型的根 System.Object
我们已经说过所有类型最终都是从System.Object类型派生出来的,因此保证了系统中的每一种类型都至少有一套公共功能。
图2-1 System.Object中的公共方法
方法名 描述
bool Equal() 该方法在运行的时候比较两个对象引用以确定它们是否是完全相同的对象,如果两个变量指的是一个对象,则返回true。如果两个变量类型相同并且值也相等,也返回true。反之返回false。
int GetHashCode() 获取指定对象的散列代码。当类的实现程序出于性能考虑需要将对象的散列代码放入散列代码表中时,就需要用到散列函数。
Type GetType() 在反射方法中用来获得对象的类型信息。
string ToString() 默认情况下,这个方法是用来获得对象名字的。它可以由派生类覆盖,以便返回代表对象的对用户更友好的字符串。
图2-2 System.Object中的受保护方法
方法名 描述
void Finalize() 该方法由运行环境来调用,以便在垃圾收集之前进行清除。注意这个方法也许会被调用,也许不会被调用。因此,不要把必须要执行的代码放在这个方法中。这条规则涉及到所谓的确定性结束。
Object Menberwiseclone() 该方法表示对对象的“浅复制”(shallow copy)。浅复制的意思是复制一个包含引用的对象到另一个对象时,不会复制其中被引用的对象。如果需要让您的类支持“深复制”(deep copy)也就是在复制时包含被引用对象的话,您就必须实现(ICloncable)接口,并且自己手动进行复制。
2.3类型转换
强制转换:即显示转换。如前面的开箱操作。
int temp2 = (int)bar;
将System.Object类型的对象bar转换成int型。使用该方法好处是通用性强,任意类型都可以进行转换,缺点是当转换出错时会抛出一个异常。
as 转换操作符:使用as操作符好处是:当执行非法转换时,您不用担心会产生异常。而会返回一个null值。
int temp2 = bar as int;
将System.Object类型的bar对象转换为int类型,如果出现错误则temp2=null;
2.4 表达式和操作符
表2-1 C#操作符优先级(从上至下,由高至低)
操作符类别 操作符
初级操作符 (x), x.y, f(x), a[x], x++, x--, new, typeof, sizeof, checked, unchecked
一元操作符 +, -, !, ~, ++x, --x, (T)x
乘除操作符 *, /, %
加减操作符 +, -
位移操作符 <<, >>
关系操作符 <, >, <=, >=, is
等于操作符 ==
逻辑与 &
逻辑异或 ^
逻辑或 |
条件与 &&
条件或 ||
条件操作符 ?:
赋值操作符 =, *=, /=, %=, +=, -=, <<=, >>=, &=, ^=, |=
2.4.1初级表达式操作符
(x) 这种形式的圆括号操作符被用来控制优先级,要么是在数学表达式中使用,或者在方法调用中使用。
X.y 点操作符被用来指定类或结构的一个成员。其中x代表含有y的实体,y代表x的成员。
f(x) 这种形式的圆括号被用来列出一个方法的参数。
a[x] 方括号用在数组的索引中。方括号也与索引器一起使用,其中可以把对象当作数组来对待。索引器在后边的章节叙述。
x++ x-- 在后边将叙述增量操作符和减量操作符。
new 用来根据类的定义实例化一个对象
typeof 在运行时查找关于类型或对象的信息
sizeof 可以用来得到给定类型的大小,以字节表示。注意:使用时有两点要求。第一,只可以对值类型是用sizeof操作符。因此对于类来说只能使用在类的成员上,不能使用在类自身。第二,该操作符只能使用于标记为unsafe的方法或代码段中。
checked和unchecked 可以控制数学操作的溢出检查。
1. 数学操作符
C#语言支持几乎所有语言都支持的基本数学操作符:乘(*)、除(/)、加(+)、减(-)和取模(%)。前四个操作符意义很明显;取模操作符产生两个数整除后的余数。
2. 一元操作符
C#语言中支持一元操作符加和减。一元减操作符用来告诉编译器操作数应该变为负数。
3. 复合赋值操作符
复合赋值操作符指的是一个二元操作符和一个赋值操作符(=)的组合。语法格式如下:
X op = Y
其中op 表示操作符。表达式等效于下面的表达式:
X = X op Y
4. 增量操作符和减量操作符
增量操作符和减量操作符一开始是从C语言中引入的。然后被C++和java沿用,这两个操作符提供了这样一种简明的操作,即要把一个代表数字值的变量增加1或者是减小1。因此i++就等于把i的当前值加1。
增量操作符和减量操作符现在都有两个版本,通常初学者会混淆。这两种类型通常被称为前缀(prefix)和后缀(postfix),这指出了在何时修改变量。当使用增量操作符和减量操作符的前缀版本时——分别是++i和--i——程序就先执行操作(加1或减1),然后才产生值。当使用增量操作符和减量操作符的后缀版本时——分别是i++和i--——程序就先产生值,然后才执行操作(加1或减1)。如下例:
int i=1;
int j=0;
j=++i; // j=2;i=2
j=i++; // j=2;i=3
可以看出二者区别在于何时产生值以及何时修改操作数。
2.4.2 关系操作符
绝大多数操作符都返回一个数字结果,但是关系操作符不同,它返回的是布尔型结果。它执行的是比较操作书之间的关系,当关系条件为真时返回true,否则返回false。
1.比较操作符
关系操作符中一组被称为比较操作符的操作符包括:小于(<)、小于或等于(<=)、大于(>)、大于或等于(>=)、等于(==)和不等于(!=)。在处理数字时,这些比较操作符的每一个意思都比较明显,但是比较操作符如何处理对象就不是很明显了。当实例化一个对象时,请记住您所得到的只是指向由堆分配的一个对象的引用。因此,当您使用关系操作符来比较两个对象时,C#编译器并不比较两个对象的内容。相反,编译器比较的是这两个对象的地址。那么怎样才能使两个对象按照成员一个个比较呢?答案就在所有的.NET框架对象的隐式基础类库中。类System.Object有一个名为Equals的方法,这个方法专门为这个目的而设计的。
2.4.3 简单赋值操作符
赋值操作符左边的值被称为lvalue;赋值操作符右边的值被称为rvalue。Rvalue可以是任何常数、变量、数字或者是返回值与lvalue相兼容的方法。但是,lvalue必须是一个已定义类型的变量。原因是这个值要从右边复制到左边。因此在内存中必须分配相应的物理空间,这个物理空间就是新值所存放的地方。如:您可以使用语句i=4;因为i代表内存中的一个物理地址,要么是在堆栈中,要么是在堆中,这取决于i变量的数据类型。但是,您不能执行语句4=i;因为4是一个值而不是内存中的一个内容可以被改变的变量。从技术上来说,C#的规则就是lvalue可以是变量、属性或者索引器。属性和索引器的内容在后续章节叙述。
数字的赋值操作很简单,但是涉及到对象的复制就复杂了。记住,在您处理对象时,您并不是在处理简单的、在堆栈中分配的易于复制和移动的元素。当处理对象时,您实际上得到的是一个堆分配上的实体的引用。因而,当您试图把对象(或者任何引用类型)赋值给一个变量时,您并不是像对值类型那样直接复制数据。您只是简单地把一个引用从一个地方复制到另一个地方。
如下例:/Example/Chapter2/2-3-1.cs
using System;
namespace ConsoleApplication1
{
class Foo
{
public int i;
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Foo test1 = new Foo();
test1.i = 1;
Foo test2 = new Foo();
test2.i = 2;
Console.WriteLine("Before Object Assignment:");
Console.WriteLine("test1.i={0}",test1.i);
Console.WriteLine("test2.i={0}",test2.i);
test1 = test2;
Console.WriteLine("After Object Assignment:");
Console.WriteLine("test1.i={0}",test1.i);
Console.WriteLine("test2.i={0}",test2.i);
test1.i = 20;
Console.WriteLine("After Change To Only test1 Menber:");
Console.WriteLine("test1.i={0}",test1.i);
Console.WriteLine("test2.i={0}",test2.i);
Console.Read();
}
}
}
执行结果如下:
Before Object Assignment:
test1.i=1
test2.i=2
After Object Assignment:
test1.i=2
test2.i=2
After Change To Only test1 Menber:
test1.i=20
test2.i=20
2.5 程序流程控制语句
在C#程序中,使您能够控制程序流程的语句有三类:选择语句、迭代语句和跳转语句。在每种语句中都要执行一个检测,这个检测产生一个布尔值,这个布尔值被用来控制程序的执行流程。
2.5.1 选择语句
选择语句可以用来判断执行什么代码以及何时执行这些代码。C#提供了两条选择语句:switch语句根据值来选择运行的代码,而if语句根据一个布尔条件来选择运行的代码。这两个选择语句中最常用的是if语句。
1. if语句
如果表达式的值是true,那么if语句就执行一条或多条语句。If语句的语法如下所示—方括号表示else语句的使用是可选的。
if(expression)
statement1
[else
statement2]
这里的expression是任何可以产生布尔值得表达式或变量。如果expression的值为true,则执行statement1,statement2将不被执行,反之亦然。statement1和statement2即可以是一条语句也可以是多条语句组合。如果是多条语句则需要使用大括号{}括起来。如下所示:
if(expression)
{
statement1;
statement2;
……
statementn;
}
2. 多个else子句
if语句的else子句使得在if的判断语句结果为false时,可以选择替代的一个动作过程。如下所示:
if(expression)
{
statement1;
}
else if(expression)
{
}
else if(expression)
{
}
4. switch语句
利用switch语句您可以指定一个表达式,这个表达式返回一个整数值,程序根据表达式的结果来决定执行代码块儿。当然使用多个if/else语句可以实现与switch同样的功能,区别在于switch语句中只有一个条件语句。而if/else语句中有多个条件测试语句。下面是switch语句的语法:
switch(switch_expression)
{
case constant_expression1:
statement1;
break;
case constant_expression2:
statement2;
break;
……
case constant_expression N:
statement N;
break;
[default]
}
switch语句的执行方式和if/else差不多,也是先求出expression的值,然后根据值来判断执行哪一段代码。
2.5.2 迭代语句
在C#中,语句while、do/while、for和foreach使您能够执行可控制的迭代或者循环。除了foreach以外,在每种情况下,都执行一条特定的简单语句或者是复合语句,直到表达式的计算结果为false为止。而foreach语句通常用于迭代一系列对象。
1. while语句
while语句的语法格式如下:
while(Boolean-expression)
embedded-statement
2. do/while语句
do/while语句的语法格式如下:
do
embedded-statement
while(boolean-expression)
我们可以观察while语句和do/while语句的差别。可以明显的看出while是先判断条件。而do/while语句条件判断在后边,也就是执行一次循环体代码之后。所以差别在于:当条件都为false时,while语句一次都不执行循环体代码,而do/while会执行一次。通常情况下,这两条语句都可以实现同样的功能。相比较而言while语句要更简单一些。具体怎么使用,就看您的个人爱好问题了。
3. for语句
到目前为止,您所见到的最为常见的语句就是for语句。for语句由三个部分组成。第一部分用于在循环开始处执行初始化—这部分只被执行一次。第二部分是条件检验,判断循环是否应该在次运行。最后一部分称为步长,通常用于(但不是必需)对计数器进行增值。而计数器则控制循环的继续—通常在第二部分对这个计数器加以检验。
for语句的语法格式如下:
for(initialization;boolean-expresssion;step)
embedded-statement
注意,这三个部分(initialization;boolean-expresssion;step)中的任何一个部分都可以为空。当布尔表达式的求值结果为false,循环结束。因此,如果只写boolean-expression这一个部分,则for语句的运行方式则和while语句是一样的。
for循环中发生的事情顺序如下所示:
1. 堆栈中分配了一个数值型的变量,并且执行初始化。注意,一旦for循环结束这个变量就不在作用域范围内了。
2. 当第二部分的循环条件结果为真时,执行for语句中嵌入的语句。如果循环体有多条语句时,需要使用大括号{}括起来。如果只有一条语句则可以不用。但为了规范起见,建议都使用大括号。
3. 每循环一次,循环变量就加1。
4. 嵌套循环
在一个for循环的嵌套语句中,还可以有其它的for循环。这样的循环一般被称为嵌套循环。为了说明for循环和嵌套循环,我们编写一个程序输出一个九九乘法表的例子。
如下所示:/Example/Chapter2/2-4-1.cs
public class ForTest {
public static void Print() {
int i=1,j=1;
for(i=1;i<10;i++){
for(j=1;j<=i;j++){
Console.Write(" {0}*{1}={2}",j,i,i*j);
}
Console.Write("\n");
}
Console.Read();
}
}
执行结果如下:
1*1=1
1*2=2 2*2=4
1*3=3 2*3=6 3*3=9
1*4=4 2*4=8 3*4=12 4*4=16
1*5=5 2*5=10 3*5=15 4*5=20 5*5=25
1*6=6 2*6=12 3*6=18 4*6=24 5*6=30 6*6=36
1*7=7 2*7=14 3*7=21 4*7=28 5*7=35 6*7=42 7*7=49
1*8=8 2*8=16 3*8=24 4*8=32 5*8=40 6*8=48 7*8=56 8*8=64
1*9=9 2*9=18 3*9=27 4*9=36 5*9=45 6*9=54 7*9=63 8*9=72 9*9=81
注意:观察本例代码格式和以前的代码格式有所不同吗?可以看到所有的左大括号{都和代码放在了类或者方法的同一行。这使代码变得更简练。那么怎样实现上述功能呢?是我们自己写的吗?不是,C#编译器给我们提供了这样的设置。打开工具菜单—选项—文本编辑器—C#—格式设置。就可以看到右边有一个 将左大括号与构造放在同一行 的选项。选中即可。我们写的代码,编译器会自动把左大括号与构造放在同一行。就是本例所实现的效果。同样方便我们程序调试的还有 常规 选项中的 行号 选项。在后边的调试过程中我们会发现很容易的找出错误代码的位置。
5. 使用逗号操作符
逗号操作符不仅可以在方法参数中起到分隔参数的作用,而且还可以作为for语句的一个操作符。在for语句的initialization和step部分,逗号操作符可以被用来对多条语句进行分界。我们并不提倡这样的写法,因为它会带来一些麻烦。所以在这里不多叙述。
4.2.6 foreach语句
多年以来,像Visual Basic这样的语言已经有了一些特殊的语句。这些语句专门用来对数组和集合进行迭代。C#也有这样的结构。就是foreach语句。格式如下:
foreach(type in expression)
embedded-statement
看如下示例程序:/Example/Chapter2/2-4-2.cs
public class ForeachTest{
System.Collections.ArrayList ar;
public ForeachTest(){
ar = new System.Collections.ArrayList();
ar.Add("语文");
ar.Add("英语");
ar.Add("数学");
ar.Add("政治");
}
public void Print(){
foreach (string temp in ar)
{
Console.WriteLine(temp.ToString());
}
Console.Read();
}
}
运行结果如下所示:
语文
英语
数学
政治
本例使用foreach语句实现了数组的遍历。其实采用前面学过的for循环语句同样可以实现相同的功能。相比foreach语句,for循环语句可能会出现哪些问题呢?留给大家思考。
2.5.3 跳转语句
在前面部分所涉及到的任何迭代语句中的嵌入语句内部,您都可以利用几条语句来控制程序的执行流程,这些语句称为跳转语句:break、continue、goto和return。
1. break语句
使用break语句可以中断当前的循环或者循环中出现的条件语句。然后控制流程就被传递到紧接着循环或者条件语句的嵌入语句后面的第一行代码处执行。此外break语句还可以用来中断一个无穷循环。
2. continue语句
同break语句一样,continue语句也可以改变循环的执行。然而,continue语句不是中止当前循环中嵌入的语句,而是停止当前的循环并把控制返回到当前循环的顶部,进行下次循环。
在下面的例子中,我们先声明一个数组列表,然后进行初始化。注意有一部分数据是重复的,我们用嵌套循环来找出重复的数据项,然后把它存到temp数组中,最后输出。注意在嵌套循环内部,如果两个索引相等时,表明它们是同一个数据,这种情况我们肯定不能让它进行比较。我们采用continue语句继续执行下一次循环。
代码如下:/Example/Chapter2/2-4-3.cs
using System;
namespace ConsoleApplication1
{
public class ContinueTest
{
System.Collections.ArrayList ar;
public ContinueTest()
{
ar = new System.Collections.ArrayList();
ar.Add("语文");
ar.Add("英语");
ar.Add("英语");
ar.Add("数学");
ar.Add("政治");
ar.Add("语文");
}
public void Print()
{
System.Collections.ArrayList temp = new System.Collections.ArrayList();
for (int i=0;i<ar.Count;i++)
{
for (int j=0;j<ar.Count;j++)
{
if (i==j)
continue;
if(ar!=ar[j] && !temp.Contains(ar[j]))
{
temp.Add(ar);
}
}
}
for(int n=0;n<temp.Count;n++)
{
Console.WriteLine(temp[n]);
}
Console.Read();
}
}
}
执行结果如下:
语文
英语
数学
政治
3. 使用goto语句
在结构化编程中,由于goto语句破坏了结构化思想,因此一直以来遭到大多数人的非议。认为只要是关于goto语句的所有方面都是不好的。其实不是,在C#中同样支持goto语句。在某些情况下反而使用goto语句更为简单和容易理解。
C#支持如下三种格式得goto语句:
goto identifier
goto case constant-expression
goto default
在goto语句的第一种用法中:identifier的目标是一条标签语句。标签语句采用如下格式:
identifier:
如果当前方法中标签不存在,就会产生编译器错误。另外要记住的一条很重要规则就是:goto语句可以被用来跳出一个嵌套循环。然而,如果goto语句不处于标签的作用域范围以内,那么就会发生一个编译时的错误。因此,您不可能跳转进入一个嵌套循环。关于goto语句的更多文章,在这里就不多说了,在使用时请读者自行决定。
4. return语句
return语句有两个功能。这条语句指定一个返回值,这个值被返回给调用当前代码的函数。如果没有定义返回值,则默认返回null值。return语句将导致程序直接返回到函数调用处。
语法格式如下:
return [return-expression]
如果return语句后被指定一个表达式,在编译器执行时将先对编译器进行求值,然后隐式地将值得类型转换成该方法所定义的返回值类型。转换后的结果被传递回调用当前代码的函数。
如果使用return语句时还使用了异常处理,需要理解其执行规则。如果return语句位于一个try块,该try块又含有相应的finally块,return语句执行时将传递到finally块的第一行代码执行。执行完毕后,把结果返回到调用函数。如果try块被嵌套在另一个try块中,控制将按这种方式进行执行。直到执行到最后的finally块为止。
2.5 本章小结
公共类型系统是。NET框架的一个重要特征。其定义了应用程序想要在CLR中正常运行所必须遵循的类型系统规则。公共类型分为两类:引用类型和数值类型。公共类型系统的好处是语言互用性、单根的对象层次结构以及类型安全。在C#中的类型可以使用装箱和开箱来进行转换,以便兼容的类型共享某些特性和功能。
对任何一门语言都很关键的一部分就是处理赋值、数学、关系和逻辑操作的方式。这些操作是处理现实中的应用所必须的。在代码中通过操作符来控制这些操作。影响代码中操作符的作用的就是优先级。C#除了提供一个强大的预定义的操作符集外,还可以通过用户的实现来进行扩展。即所谓的操作符重载,将在以后进行讨论。
C#条件语句使您能够控制程序的流程。流程语句的三个不同分类包括选择语句(如if和switch)、迭代语句(如while、for和foreach),以及各种不同的跳转语句(break、continue、goto和return),在使用时灵活运用,可以构造出结构更为清晰,并且更易于维护的应用程序。
2.5 实战演练
1、编写一个控制台应用程序测试所学的装箱和开箱操作,以及常用操作符的作用。
2、分别编写不同的程序来练习程序流程控制一节中的if/else、switch、for、while、foreach、break、continue、goto、return语句。
第三章 面向对象编程的基础知识
本章重点:
对象和类
实例化
封装
继承
多态性
本章目的:
通过本章的学习,带领您熟悉面向对象编程(Object-Oriented Programming,OOP)的各种术语,并使您认识到面向对象的概念对于编程的重要性。
许多编程语言,诸如C++和Microsoft Visual Basic,都声称自己是“支持对象”的,但实际上只有少数几种语言真正完全支持构成面向对象编程的所有原则。C#就是其中一种,它从最底层开始设计,是一种真正面向对象。基于组件的编程语言。因此,为了最大程度地从本书获益。您需要牢固掌握本章提出的概念。
读者通常会跳过类似这样概念性的章节而一头扎入代码之中,但是,除非您认为自己已经是“对象通”,否则,建议您还是先认真阅读本章,对于那些对面问对象编程似是而非的读者来说,肯定能从本章中学有所获。另外,别忘了本章后面的章节也会引用本章讨论的术语和概念。
前面已经说过,许多语言都被称为是面向对象或基于对象的,但实际上只有少数几种才真正是。C++不是,因为它的根深植于C语言言之中。这是不可否认、无法回避的事实。在C++中为了支持传统的C代码。已经牺牲了太多的OOP理想。即便是Java语言,尽管它已经够好了,但作为一种面向对象的语言,它仍然是有限的。比如,作者可以指出这样一个事实:在Java中存在简单类型和对象类型,它们的处理方式和行为模式都非常不同。但本章的重点并不是比较不同的语言对OOP原则的忠诚度。而是提供一个关于OOP原则本身客观的、与语言无关的指南。
在开始之前,需要指出的是:面向对象编程并非一种营销的招牌(尽管对于某些入来说是),也不是一种新的语法或一种新的应用编程接口(Application Programming Interface API)。面向对象编程是一套全新的概念和想法。它是一种用计算机程序来描述实际问题的新思路,也是一种更直观、效率更高的解决问题的方法。
回顾一下我们学过的各种语言,只要我们学懂了一种语言,后续的每种语言不论其复杂程度。学习过程都是越来越短。这是因为直到开始用C++编程为止,我们用到的所有语言都是一种结构化语言,主要的区别仅仅在于语法的不同而已。
不过,如果您是初次按触面向对象编程的话,您应当注意:您以前学过的非面向对象语言在这里不会有所帮助。面向对象编程在如何设计和实现问题的解决方案时,采取的是一种完全不同的思路。事实上有研究表明,在学习面向对象语言时,编程新手比学过诸如BASIC、COBOL和C这些结构化语言的人要快得多。因为他们不需要克服任何有碍于理解OOP的结构化学习,他们就像一张白纸,是从头学起。如果您已经具有使用结构化语言编程的多年经验,而C#又是您接触的第一种面向对象语言的话,您能得到的最好的建议就是保持开放的心胸,并且努力实现作者在这里提出的想法,而不是在心中暗想:“哼,我也能做到(当然是使用结构化语言)!”任何一个从结构化背景转向面向对象编程的人都会经历这样一个过程,而这也是值得的。使用面向对象语言编程的好处是无法估量的,无论是更有效地书写代码方面。还是构造一个更易修改和扩展的系统方面。这样的好处一开始可能看不到。那么只要正确运用,OOP的概念的确能够实现它们的承诺。
3.1一切都是“对象”
在真正的面向对象语言中,所有的问题域(problem domain)实体都是通过对象(叫Object)的概念表达出来的。您也许已经猜到。对象就是面向对象编程的核心所在。在面向对象编程中,我们不会拐着弯儿地去想什么结构、数据包、函数调用和指针,通常只考虑对象这个概念。先来看一个例子。
假设您在编写一个开发票的应用程序,您需要对发票上的明细栏进行计算。从用户的角度出发,以下哪一种思路是更直观的呢?
● 非面向对象思路
用一个数据结构来表示发票的抬头,这个发票的抬头结构包括一个指向发票明细栏结构的双向链表(linked list),每个发票明细栏结构包含该明细栏的小计。这样,要计算出发票的总计,需要再声明一个变量,就把它叫做totalInvoiceAmount吧,并将它初始化为0;使用一个指针指向发票的抬头结构,得到明细栏链表的头指针,然后遍历这个链表;访问每个明细栏结构时,将包含该明细栏小计的成员变量值加入到totalInvoiceAmount变量之中。
● 面向对象思路
使用一个发票对象,发送一条消息给该对象,要求得到发票总计。我们并不需要考虑在对象内部信息是怎样存储的。而在非面向对象数据结构中却必须考虑这一点。我们只需以一种自然的方式来对待这个对象。也就是用发送消息的方式来向它做出请求(对象能处理的消息综合起来叫做该对象的接口,下面将解释在面向对象思路中为什么只考虑接口而不是具体实现是合理的)。
显然,面向对象思路更直观,也更接近于大多数人解决问题的方式。在面向对象思路中,发票对象可能会包含多个发票明细栏对象,并对每个明细栏对象发送消息要求得到其小计。但是,尽管您需要得到总计,但却不必关心它到底是怎样得到的。这是因为面向对象编程的一个主要原则就是封装——对象能够将其内部的数据和方法隐藏起来并且提供一个接口。这个接口就是能够访问该对象的重要组成部分。只要对象能够完成任务。在其内部究竟怎样完成任务就不重要了。您只需了解对象的接口部分,然后利用接口使对象执行给定的任务即可(本章的后续部分还将进一步解释封装和接口的概念。上述的例子表明了这样一道理:编程时如果能够对问题域的现实世界对象加以模拟,程序编写起来将会十分容易,因为我们的思路更加贴近正常的思维方式。
注意在第二种思路中要求对象执行给定的任务——也就是计算出明细栏的总计。一个对象并不只包含数据,而结构却只包含数据。从定义上来看,对象由数据和加工数据的方法组成。这意味着对于—个问题域。我们能做的就不只是设计必要的数据结构了。我们还可以看看哪些方法可以附加在给定的对象上面,以便该对象成为功能齐全的封装结构。下面的示例以及后面章节中的示例都有助于说明这个概念。
注意:本章中的代码片断用于展示面向对象编程的概念。要注意书中出现C#代码片断时,这些概念本身对于OOP来说都是通用的,并不只特定于某一种编程语言。在本章中为了便于比较,也列出了C语言代码。而它并不是面向对象的。
比如说您正在编写一个应用程序,用来计算您的新公司中唯一雇员Amy的薪酬。如果使用C语言,您可能这样书写代码以将相关的数据与雇员联系起来:
下面是使用EMPLOYEE结构来计算Amy薪酬的代码;
struct EMPLOYEE{
char szFirstName[25];
char szLastName[25];
int iAge;
double dPayRate;
}
void main()
{
double dTotalPay;
struct EMPLOYEE* pEmp;
pEmp = (struct EMPLOYEE*)malloc(sizeof(struct EMPLOYEE));
if (pEmp)
{
pEmp->dPayRate = 100;
strcpy(pEmp->szFirstName, "Amy");
strcpy(pEmp->szLastName, "Anderson");
pEmp->iAge = 28;
dTotalPay = pEmp->dPayRate * 40;
pzintf("Total Payment for %s %s is %0.2f",
pEmp->szFirstName, pEmp->szLastName, dTotalPay);
}
free(pEmp);
}
在这个示例中,代码包含了结构中的数据和一些使用该结构的外部代码(相对于该结构而言)。这样会出现什么问题呢?主要问题之一就在于抽象:EMPLOYEE结构的使用者必须了解很多有关于雇员的数据:为什么?比如说以后如果您想要修改计算Amy薪酬的税率的话,假设您想将美国联邦社会保险捐款FICA和其他各种税赋考虑进来以便决定税后工资。这时您不仅必须修改所有使用EMPLOYEE结构的代码,而且需要做出文档记载——为您公司将来任何一位程序员——说明该程序在使用中已经发生过改变。
下面我们来看看C#是怎样解决这个问题的:/Example/Chapter3/3-1-1.cs
using System;
namespace ConsoleApplication1
{
public class Employee
{
protected string firstName;
protected string lastName;
protected int age; //年龄
protected double payRate; //费用
public Employee(string firstName,string lastName,int age,double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate;
}
public double CaculatePay(int hoursWorked)
{
//计算工资
return payRate*(double)hoursWorked;
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Employee emp = new Employee("Amy","Anderson",38,100);
Console.WriteLine("Amy's pay is ${0}",emp.CaculatePay(40));
}
}
执行结果如下:
Amy's pay is $4000
在上面这个C#版本的示例中。使用对象的程序员只需简单地调用该对象的CaculatePay方法就能计算出该对象的薪酬。这种思路的优点就是使用者不再需要担心具体薪酬是怎样计算出来的。如果将来您决定修改计算薪酬的方法。那么做出的修改对于已存在的代码不会有任何影响。这种程度的抽象就是使用对象的一个基本好处。
可能有人会说,创建一个访问EMPLOYEE结构的函数也可以实现C语言代码的抽象。然而问题就在于创建的这个函数与该函数要处理的数据结构是完全分离的。而使用C#这样的面向对象语言时,对象的数据和加工数据的方法(即对象的接口)总是在一起。
另外要记住只有对象的方法才能修改对象的变量。如前面的示例中所见,每个Employee成员变量声明时都带有“protected”访问限定符,除了定义为“public”的方法CaculatePay以外。访问限定符用来指定派生类和客户代码可以访问给定类成员的级别。如果指定“protected”限定符,则派生类可以访问该成员,而客户代码则不能访问。限定符“public”表示派生类和客产代码都可以访问该成员。在后面的章节中我们还将详细讨论访问限定符,现在要记住的关键问题就是限定符可以保护关键的类成员不被误用。
3.1.1 对象和类
类和对象之间的区别常常是刚接触面向对象编程的程序员们容易混淆的地方。为了说明这两个术语之间的不同。我们使用上面的示例更现实一些,假设不是处理一个雇员的数据,而是处理整个公司所有雇员的数据。
使用C语言,我们可以定义一个基于EMPLOYEE结构的雇员数组;因为我们并不清楚将来公司到底会有多少雇员,只好创建一个包含固定个数元素的数组,假设其大小是10000,但是,如果目前公司只有Amy这一个雇员的话,这样使用资源就显得很浪费。因此,我们通常会创建一个EMPLOYEE结构的链表。以便在应用程序中动态分配内存。
但是,我们现在正在做的可能恰恰是不应该由我们做的。我们在语言和机器——即分配多大的内存和何时分配内存——上绞尽脑汁,而不是专注于问题域本身。使用对象则有助于我们将精力集中在解决问题必需的逻辑思考上。
要定义一个类并将它与对象区别开来有多种方法。您可以将类简单地看作是一个拥有相关方法的类型(就像char、int或long一样),而对象就是类型或类的一个实例。但通常的定义却是:类是对象的一个蓝图。作为开发者的您描绘出这幅蓝图,就像工程师描绘出房子的蓝图一样。一旦完成蓝图。您就可以按照蓝图建造多幢房子。同时。别人也可以购买您的这幅蓝图从而建造相同的房子。同样的道理。类就是给定功能集的蓝图,基于特定类创建的对象就拥有该类集成的所有功能。
3.1.2 实例化
实例化(instantiation)是面向对象编程的专用术语,表示创建类的一个实例。这个实例就是一个对象。在下面的例子中,我们所做的一切就是为对象创建一个类。换句话说,这个阶段不涉及内存分配。因为我们只拥有对象的一个蓝图,而没有实际的对象。
public class Employee
{
protected string firstName;
protected string lastName;
protected int age; //年龄
protected double payRate; //费用
public Employee(string firstName,string lastName,int age,double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate;
}
public double CaculatePay(int hoursWorked)
{
//计算工资
return payRate*(double)hoursWorked;
}
}
为了实例化这个类并使用它,我们还必须在方法中声明一个该类的实例
static void Main(string[] args)
{
Employee emp = new Employee("Amy","Anderson",38,100);
}
在这个例子中,emp被声明为Employee类型并且使用操作符“new”将它实例化。变量emp就表示Employee类的一个实例并被看作是一个Employee对象。实例化之后,我们就可以通过其公共成员来与这个对象进行通信了。比如。我们可以调用emp对象的CaculatePay方法,而在没有实际对象之前我们不能这样做(有一个例外,那就是处理静态成员时,在后面章节我们将讨论静态成员)。
下面我们来看看以下c#代码:
static void Main(string[] args)
{
Employee emp = new Employee();
Employee emp2 = new Employee();
}
这里我们有同一个Employee类的两个实例——emp和emp2。从表面上看。每个对象的功能相同。每个实例都可以包含它自己的实例数据并被单独处理。同样的道理。我们可以创建一个包含这些Employ既对象的数组或集合。这里是指大多数面向对象的语言都能够定义对象数组。这使得您能够很轻松地组织对象。调用对象数组的方法或者使用数组的下标。将这与链表比较一下吧,在链表中您必须自己将链表中的每个条目与它前后的条目一一连接起来。
3.2 面向对象编程语言的三大原则
C++编程语言的作者认为。一种语言必须支持对象、类和继承这三个概念,才可以称得上是面向对象的。但实际上,面向对象语言已经被更普遍地认为是建立在封装、继承和多态性这三者之上的。之所以发生这种观念上的转变,是因为这么多年以来,我们逐渐认识到封装和多态性与类和继承一样。是构成面向对象系统不可缺少的组成邰分。
3.2.1 封装
在前面提到过“封装”。有时也叫做“信息隐藏(information hiding)”,能够将对象的内部隐藏起来,只提供一个接口给对象的使用者,使得只有那些能被直接操作的成员才能被使用。但同时也提到了“抽象”这个概念,在这一节里将澄清有关这两个相似概念之间容易混淆的地方。封装提供的是一道屏障,将类的外部接口(也就是类的使用者能看到的公共成员)与它的内部实现细节分隔开来。封装的优点在于类的开发者可以将静态的、不变的成员公开,而将那些动态的、容易改变的类成员隐藏起来。如本章先前所示。在C#中通过赋予每个类成员访问限定符public、private或者protected就可以实现封装。
1. 抽象设计
“抽象”指的是在程序空间中一个给定的问题是如何表示出来的。编程语言本身就提供抽象。试想:您上一次考虑如何使用CPU的寄存器和堆栈是什么时候?即使您最初学的是用汇编语言编程。您也一定有很长时间没有考虑过这样底层而且是针对特定机器的细节了。这是因为大多数编程语言都己将这些细节为您抽象出来了,使得您可以集中精力于要解决的问题域本身。
面向对象语言使得声明的类所具有的名称和接口都类似于现实世界中的问题域实体,因此使用对象时就有了更加自然的“感觉”。去掉那些与当前解决问题没有直接关联的因素之后,您就可以专注于问题本身,工作效率自然也就更高。但实际上,解决大多数问题的能力通常会由所采用的抽象质量决定。
当然,那是另外一个层次上的抽象,更进一步,作为一个类开发人员,您必须考虑到如何没计出最好的抽象,以便用户在使用类时能够集中精力于当前要解决的问题。而不陷入您的类是怎样工作这样的细节之中。从这一点上说。“类的接口怎样与抽象关联”是个很好的提问,类的接口实际上就是抽象的实现。
下面将讲一个大家熟悉的编程课上的类比。以便明确这些概念。这个类比就是自动售货机的内部工作原理。自动售货机的内部实际上非常复杂,为了完成它的职责。自动售货机必须能够接受现金和货币,找零钱,找到并送出顾客想要的东西。然而,自动售货机需要展示给顾客的却只是有限的几个功能。通过一个硬币榴、选择货品的按钮、要求找零的拉杆、放零钱的槽和发送选定物品的出口,即可表达出自动售货机展现给顾客的接口。以上每一项都表示了该机器接口的一部分。自动售货机自发明以来,大体上都保留了这样相同的功能。尽管随着技术的进步,自动售货机的内部也发生了很大的变化,但是其基本的接口部分却并不需要改变太多。设计类的接口时很重要的一步就是要深入彻底地理解问题域本身。这种理解将有助于您创建的接口既能让用户访问他们需要的信息和方法。同时也能将他们隔离在类的内部工作之外。设计出来的类不仅要能解决今天的问题,同样也要从类的内部中充分地抽象出来,以便私有类成员能够在不影响己存在代码的前提下自由修改。
设计类的抽象概念时另一个同等重要的方面是始终考虑客户程序员的方便。假设您设计了一个通用的数据库引擎,如果您是一个数据库方面的专家,您可能对游标、委托控制和元组这样的术语会很熟悉。但是,大多数不熟悉数据库编程的开发人员可能对这些术语就不甚了解。使用这些对类的使用者而言很陌生的术语的行为,其实完全违背了抽象的目的——即通过用自然术语代表问题域以便提高程序员的工作效率。
另外一个需要考虑到用户的地方是决定哪些类成员应该被公共访问。同样,这时应该多一点对问题域的理解和多为类的用户想想。仍以数据库引擎为例。您可能不会希望用户直接访问表示内部数据缓冲器的成员,这样将来就能轻易地更改这些数据缓冲器的定义,另外。由于这些缓冲器对于引擎整个的操作至关重要。您可能就会确保只能通过您的方法来修改它们,这样就能保证采取必要的预防措施。
注意: 您可能会想到面向对象系统主要是为方便创建类而设计的。尽管这个特点的 确能够带来生产效率的短期提高,但是只有认识到引入OOP的目的在于为使用类的程序员提供方便以后,才能带来长期效益。因此在设计类的时候。始终不要忘记考虑那些将要实例化您所创建的类或从您创建的类中派生类的那些程序员。
2. 良好抽象的益处
开发可复用(reusable)的软件过程中,将类抽象成一种对将使用这些类的程序员最有用的方式是极其重要的。如果开发的接口在实现方式改变之后仍能保持稳定和不变的话,那么随着时间的推移,您的应用程序也就很少需要修改。还是以先前的计算薪酬的示例代码为例,在Employee对象和计算功能中,只有少数几个方法是相关的,如:CaculatePay、GetAddress和GetEmployeeType。如果您熟悉实际的计算薪酬方法,在很大程度上就能轻松地确定使用这个类的用户将需要哪些方法。既然如此。如果在设计这个类的时候,您把对问题域的理解与远见融合在设计之中的话。当然就有理由保证在这个类的实际实现中。不管将来如何变化,类的大部分接口仍然可以保持不变。总之,从用户的角度看来,它就是一个Employee类而己。而站在用户的立场上,当然也是希望在版本更迭过程中类最好不要有什么变化。
将用户和实现细节分离还能使得整个系统易于理解和维护。在结构化语言,如C中。每个模块都需要一个明确的名称并且要访问一些给定结构的成员,这样,每当这些结构的成员发生变化时,引用这些结构的每一行代码都必须随之改变。
3.2.2 继承
“继承”指的是程序员指定的一个类与另一个类之间的某种关系。通过继承,您可以创建(或派生)一个新的类,而它以已存在的类为基础。然后您可以随心所欲地修改这个新类并且创建这个派生类型的新的对象。这个功能就是构建类层次结构的精华所在。除了抽象,在系统整体设计中继承就是最重要的部分了。派生类是被创建的新类,基类就是新类从其派生的那个类。新创建的派生类继承了基类的所有成员,因此您可以重新利用以前的工作成果。注意:在C#中,哪些基类成员将被继承取决于成员定义时所用的访问限定符。在后面我们将详细讨论。这里为了便于讨论,您可以暂时认为派生类将继承其基类的所有成员。
还是以先前的Employee为例。我们来看看何时且如何使用继承。在那个示例中,我们可能会雇用不同类型的雇员。如正式工、合同工和小时工。虽然所有这些Employee对象的接口一样,但在内部它们的功能却会有很大的不同。比如,Cal01atePay方法对正式工和合同工的计酬方法就不一样,但我们还是希望不管雇员是何种类型。他们的CaculatePay接口仍然一样。
如果您是刚接触面向对象编程,您可能就会想到:“这里我为什么要用对象?用一个带有雇员类型成员的EMPLOYEE结构和一个类似的函数?”
如下所示:
Double CaculatePay(Employee* pEmployee,int iHoursWorded)
{
if(pEmployee->type==SALARIED)
{
//正式工薪酬计算方法
}
else if(pEmployee->type==CONTRACTOR)
{
//合同工薪酬计算方法
}
else if(pEmployee->type==HOURLY)
{
//小时工薪酬计算方法
}
//return one of the salary
}
这段代码存在几个问题。首先,CalculatePay函数是否能成功调用与EMPLOYE结构关系紧密。前面已经说过,这样紧密的关系会导致EMPLOYEE结构的任何变化都会影响到这段代码。作为面向对象的程序员,最不想做的事情就是给类的用户增加深入了解设计类的细节这样的负担。这就好比是自动售货机生产厂家要求您在购买汽水之前了解自动售货机的内部工作原理一样。
其次,这段代码不便于复用。一旦您开始了解继承是如何为复用提供方便之后,您就会意识到类和对象确实不错。在这里,您只需简单地定义基类的所有成员。而不用考虑雇员的类型。派生类会继承这些成员然后做出必要的修正。
下面来看看C#是怎样解决这个问题的:/Example/Chapter3/3-1-2.cs
public class Employee{
protected string firstName;
protected string lastName;
protected int age; //年龄
protected double payRate; //费用
public Employee(string firstName,string lastName,int age,double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate;
}
public double CaculatePay(int hoursWorked)
{
//计算薪酬
return payRate*(double)hoursWorked;
}
}
class SalariedEmployee:Employee
{
public string SocialSecurityNumber; //社会保险
public double CaculatePay(int hoursWorked)
{
//正式工薪酬计算
}
}
class ContractEmployee:Employee
{
public string FederalTaxId; //税率
public double CaculatePay(int hoursWorked)
{
//合同工薪酬计算
}
}
这个例子中的三个特点值得关注:
基类Employee中定义了一个名为Employee的相关信息,它被SalariedEmployee和ContractEmployee两个类所继承,但这两个派生类与这个成员一点关系也没有——它们只是从Employee基类中派生时顺带地自动继承了它。
两个派生类都实现了它们自己的CaculatePay方法。但是,您会注意到它们都继承了同样的接口。因此,尽管它们根据自己的需要修改了内部的代码,但是用户的代码却无需改变。
两个派生类都向从基类继承的成员中添加了新的成员。SalariedEmployee类定义了一个SocialSecurityNumber字符串,而ContractEmployee类则包含了一个对FederalTaxId成员的定义。
从这个小小的示例中您已经看到:继承使得您可以通过从基类中继承功能而实现代码复用:更进一步。它使您可以通过添加自己的变量和方法来扩展类。
定义适当的继承
为了说明适当继承的重要性。我们将引用Marshall Cline和Greg Lomow合著的《C++FAQs》(Addison-Wesley,1998)中的一个术语:可替代性(substitutabiolity)。可替代性表示派生类的已通告行为对于基类来说是可替代的:仔细想想这句话——这就是您在学习创建有效的类层次结构时最重要的—个规则(“有效”表示经受得住时间的考验并且实现OOP可以复用和扩展代码的承诺)。
在创建类层次结构时另一个需要牢记的重要规则是,派生类在对待继承的接口时应该不比它的基类要求多而承诺少。不遵守这条规则将破坏已存在的代码。类的接口就是类和使用这个类的程序员之间的一个捆绑合同。当程序员引用派生类时,总是可以将该派生类当作其基类一样看待。这就叫做“向上转换”(upcasting)。在我们先前的例子中,如果用户引用了一个ContractEmployee对象,那么它实际上也隐性引用了该对象的基类一一Employee对象。因此,按照定义,ContractEmployee总是应该完成其基类能够完成的功能。请注意这条规则只应用于基类的功能。派生类可以选择添加功能,而随着它的要求和承诺增加,这些功能可能会越来越有限。因此,这条规则只应用于继承的成员。因为己存在代码只与这些成员之间有合同限制。
3.2.3 多态性
本人认为关于多态性(polymorphism)最好和最简洁的定义是:多态性就是允许老代码调用新代码的能力。这当然是面向对象编程最大的好处了,因为这样就可以在不修改或破坏已存在代码的前提下扩展或加强您的系统。
现在假设您编写了一个方法需要遍历一组Employee对象,调用每个对象的CaculatePay方法。在您的公司只有一种雇员类型的时候,这个方法很有效,因为每当增加雇员时您只需将确切的对象类型加入到集合中即可。但是。如果您开始雇佣别的类型的雇员时情况将会怎样呢?比如,如果您使用了Employee类并且它实现了正式工的计酬方法,那么在您开始雇佣合同工时又该怎么办呢?毕竟合同工的工资计算方法是不同的。在结构化语言中,您可能就需要修改函数以便处理新的雇员类型。因为旧代码不可能知道如何处理新代码。而面向对象的解决方案就可以通过多态性来处理类似这样的问题。
还是以前面的例子为例,您定义了一个名为Employee的基类,然后为每一种雇员类型定义了一个派生类(如前所见),每一个派生的雇员类都有其自己对CaculatePay方法的实现。这时奇迹发生了。利用多态性,当您使用一个指向对象的己向上转换的指针调用该对象的方法时,程序运行时会自动保证调用正确版本的方法。
看如下代码所示:/Example/Chapter3/3-2-1.cs
using System;
namespace ConsoleApplication1
{
// 本示例中的计算工资并没有引入具体数据,我们只是通过他们输出的不同内容来演示 多态性 的作用。
public class Employee
{
protected string firstName;
protected string lastName;
protected int age; //年龄
protected double payRate; //费用
public string EmployeeID; //员工编号
public Employee(string firstName,string lastName,int age,double payRate)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
this.payRate = payRate;
}
public virtual double CaculatePay(int hoursWorked)
{
//计算工资
Console.WriteLine("Employee.CaculatePay");
return 0;
}
}
class SalariedEmployee:Employee
{
//正式工
public SalariedEmployee(string firstname,string lastname,int age,double payrate)
:base(firstname,lastname,age,payrate)
{
}
public override double CaculatePay(int hoursWorked)
{
Console.WriteLine("SalariedEmployee.CaculatePay");
return 0;
//return payRate * (double)hoursWorked;
}
}
class ContractEmployee:Employee
{
//合同工
public ContractEmployee(string firstname,string lastname,int age,double payrate)
:base(firstname,lastname,age,payrate)
{
}
public override double CaculatePay(int hoursWorked)
{
Console.WriteLine("ContractEmployee.CaculatePay");
return 0;
}
}
class HourlyEmployee:Employee
{
//小时工
public HourlyEmployee(string firstname,string lastname,int age,double payrate):base(firstname,lastname,age,payrate)
{
}
public override double CaculatePay(int hoursWorked)
{
Console.WriteLine("HourlyEmployee.CaculatePay");
return 0;
}
}
}
主程序如下:
static void Main(string[] args)
{
Employee[] emp; //声明一个Employee类型的数组
Console.WriteLine("Load Employees......");
emp = new Employee[3];
emp[0] = new SalariedEmployee("Amy","Anderson",28,100); //实例化一个正式工对象
emp[1] = new ContractEmployee("John","Mafei",35,110); //实例化一个合同工对象
emp[2] = new HourlyEmployee("Lan","Ma",32,10); //实例化一个小时工对象
//遍历数组,计算薪酬
foreach(Employee tempemp in emp)
{
tempemp.CaculatePay(5);
}
Console.Read();
}
编译并运行这个程序将产生以下输出结果;
Load Employees......
SalariedEmployee.CaculatePay
ContractEmployee.CaculatePay
HourlyEmployee.CaculatePay
多态性至少提供了两个好处,第一,它让您可以将具有共同基类的对象组成一组并对它们进行一致处理,在上面的例子中,尽管从技术上我们已经拥有三个不同的对象类型——SalariedEmployee、ContractEmployee和HourlyEmployee——但我们仍可将它们都作为Employee对象对待。这是因为它们都是从Employee基类中派生出来的。这样就可以把它们都放入定义为Employee对象数组的数组之中。由于多态性的存在,当调用这些对象的方法时,程序运行时就会保证调用正确的派生对象的方法。
第二个好处是曾在本节开始时提到的:老代码可以调用新代码。注意主程序中的foreach循环反复调用了由Employee对象组成的成员对象数组。由于这个方法调用对象时实际上隐性调用了己向上转换的Employee对象,而在运行时程序的多态性又保证会调用正确的派生类的方法,因此。我们可以向系统添加别的雇员类型,将它们插入到Employee对象数组之中即可,而应用程序却可以照常运行,无需更改任何源代码。
3.4 本章小结
本章带领您快速浏览了面向对象编程中的术语和概念,关于这个主题我们后面还有篇幅会加以深入研究。因此,对于本书的重点而言,它可能有点喧宾夺主了。但是,牢固掌握面向对象编程的基础知识之后,您才可以更轻松地学习C#语言。
在本章中我们接触到了很多新观念。理解面向对象系统的关键就是要弄明白类、对象和接口之间的区别,以及这些概念与有效的解决方案之间的联系。好的面向对象解决方案同样有赖于深刻理解并实现面向对象编程的三大原则:封装、继承和多态性。本章提出的概念为下一章打下了一个基础。
3.5 实战演练
1.想想生活中都有哪些类和对象的具体实例。又有哪些实现了很好的封装、继承以及多态性呢?
第四章 类
本章重点:
定义类
类的成员
访问限定符
构造函数
常量和只读字段
继承和封装
本章目的:
通过本章的学习,我们可以定义类。通过访问限定符来实现类的封装以及构造函数的概念及作用。了解类的继承、封装特性。
类是每一种面向对象编程语言的灵魂。我们在第三章中已经提到。类就是数据和加工数据的方法的一个封装。这是在任何面向对象语言中都“颠扑不破的真理”。而一种面向对象语言与其他面向对象语言的不同之处就在于作为成员存储的数据的类型和每一种类的功能。C#在有关类和其他许多功能上都从C++和Java处借用了一些概念.但同时又以它的独创性为一些旧的问题提出了新颖的解决方案。
在本章,我们将首先介绍在C#中定义类的一些基本知识。包括实例成员、访问限定符。构造函数以及初始化列表,然后将介绍静态成员和常量与只读字段之间的区别。
4.1 定义类
在C#中定义类的语法很简单。特别是对于经常在C++或Java中编程的人来讲。您只需在类的名称前加上关键字“class”,然后在大括号之间插入该类的成员即可,如下所示:
class Employee
{
private long employeeId;
}
如您所见,这个类的确很简单。它的名称叫做“Employee”,包含了单个成员“employeeId”。注意在该成员之前有关键字“private”,这叫做“访问限定符”。C#中定义了四种合法的访问限定符,稍后我们就会涉及到它们。
4.2 类的成员
在第2章中,我们讨论了由公共类型系统(Comon Type System,CTS)定义的不同的类型。这些类型就是C#所支持的类的成员,包括以下内容:
字段
字段就是用于保持一个值的成员变量。用OOP的用语来说,宇段有时也称为对象的数据。根据您希望怎样使用一个字段,您可以对该字段应用多个限定符。这些限定符包括“static”、“readonly”、“const”,稍后我们将了解这些限定符的含义以及如何使用它们。
方法
方法就是加工对象的数据(或字段)的真正的代码。在本章我们只着重介绍定义类的数据,在后面的章节将详细地介绍方法。
属性
属性有时也叫做“智能字段”,因为对于使用类的客户而言,它们就是看起来像字段的真正的方法。这使得客户可以拥有更高程度的抽象.因为客户不需要知道它访问时是直接访问了字段还是访问了被调用的存取器方法。在后面的章节将详细介绍属性。
常量
如其名所示,常量就是不能改变其值的字段,本章后面将对常量和只读字段进行比较。
索引器
与属性是智能字段一样,索引器就是智能数组,也就是说,索引踞可以让对象通过“get”和“set”存驭器方法来被索引。索引器允许您在对象中轻松实现索引。以便设置或浏览当前值。索引器和属性都将在后面章节中讨论。
事件
事件就是导致一些代码运行的东西。事件是Microsoft Windows编程中不可缺少的组成部分。比如.当鼠标移动或窗口被选中或调整大小时,就会触发事件。C#事件采用了标准的“发布/预订”设计模型(请参见Microsoft Message Queuing,MSMQ)和COM+异步事件模型(这使得应用程序具备了异步事件处理功能)。但是在C#中,这种设计模型是语言中内置的“头等”(first-class)概念.在后面将讨论如何使用事件。
操作符
C#通过操作符重载允许您可以将标准数学运算操作符添加到类中,以便使用这些操作符编写出更直接的代码.在后面将介绍操作符重载。
4.3 访问限定符
既然我们已经提到可以被定义为C#类成员的不同类型。下面就来看看一些重要的限定符。它们用于指定一个给定成员对于它所在的类之外的代码而言是可见的.还是不可见的这些限定符叫做“访问限定符”。如表5.1所示:
表4-1 C#访问限定符
访问限定符 说明
Public 表示该成员可以从它所在的类定义和派生类的层次结构之外访问
Protected 表示该成员在它所在的类之外是不可见的,只允许派生类访问
Private 表示该成员在类定义的范围以外不可访问,即使派生类也不能访问
Internal 表示该成员只能在当前编译单元内可见,根据代码所在的位置,访问限定符“internal”代表了“public”和“protected”的访问性组合
在C#中,如果我们在定义类时,不写任何限定符,则编译器会把它当作“private”对待。因为C#默认都是“private”。
4.4 构造函数
像C#这样的面向对象编程语言最大的好处之一就是:您可以定义一些特殊的方法,无论何时创建类的实例时,总可以调用这些方法。这样的方法就叫做“构造函数”(constructor)。C#还引入了一种新的构造函数,叫做“静态构造函数”,在下一节中我们将讨论这种构造函数。
使用构造函数的关键好处在于:它能保证对象被使用之前经过合适的初始化过程。当用户实例化一个对象时。该对象的构造函数首先被凋用,并在用户对该对象执行进一步操作之前必须返回。这种保证有助于确保对象的完整性,也有助于使用面向对象语言编写的应用程序更加可靠。
不过,我们应该怎样为构造函数命名才能使编译器在实现对象时能够知道如何调用它呢?C#的设计者们效仿了Stronstrup,规定在C#中类的构造函数必须与类同名。下面这个简单的类包含了一个同样简单的构造函数:
using System;
class TestApp
{
public TestApp()
{
Console.WriteLine("I am Constructor");
}
[STAThread]
static void Main(string[] args)
{
TestApp temp = new TestApp();
}
}
构造函数不返回任何值。如果您试图为构造函数加上一个某种类型的前缀,编译器就会发出错误提示,告诉您不能定义与成员所在类型同名的成员。
您还应该在C#中指定对象被实例化的方式。使用关键字“new”时参照下面的语法就可以了:
<class><object> = new <class>(constructor arguments)
如果您具有C++背景,在这一点上要特别注意。在C++中,实例化对象有两种方式。您可以在堆栈上声明它。如:
//C++ code This create an instance of Cmyclass on the stack
Cmyclass myClass;
也可以在自由存储区(或堆)上使用C++关键字“new”来实例化一个对象:
//C++ code This create an instance of Cmyclass on the heap
Cmyclass myClass = new Cmyclass();
在C#中实例化对象有所不同,这也是C#开发新手容易发生馄淆的地方之一。发生这种混淆的原因在于这两种浯言创建对象时使用的关键字相同,尽管在C++中使用关键字“new”可以让您指定对象在何处创建,但是在C#中创建一个对象却取决于被实例化的类型。在第2章中我们已经知道,引用类型是在堆上创建的,而数值类型是在堆栈上创建的。因此,在C#中关键字“new”可以让您创建对象的新实例,但却不会决定该对象在何处创建。
既然如此,下面的代码就是合法的C#代码,但如果您是一位C++开发人员的话。这段代码的含义可能跟您想的不一样:
MyClass myClass;
在C++中,运行代码将在堆栈上创建一个MyClass的实例。在前面我们已经提到,在C#中只能使用关键字“new”来创建对象。因此,在C#中运行代码只是声明myClass是MyClass类型的一个变量,但它并没有实例化这个对象。
作为这样的一个例子,如果您编译下面的程序.C#编译器就会警告您在这个应用程序中声明过的变量从没有使用过。
using System;
class TestApp
{
public TestApp()
{
Console.WriteLine("I am Constructor");
}
[STAThread]
static void Main(string[] args)
{
TestApp temp;
}
}
因此,如果声明了一个对象类型,就应该在代码中某个地方使用关键字“new”来将它实例化:
TestApp temp;
Temp = new TestApp();
为什么有时候会声明一个对象而不将它实例化呢?如果是在一个类中声明另一个类时,就应该在使用对象之前声明这些对象。类的这种嵌套就叫做“包容”(containment),或者“聚集”(aggregation)。
4.4.1 静态成员和实例成员
在C++中,您可以将类的成员定义为静态成员或实例成员。默认情况下,每一个成员都被定义为实例成员,表示类的每一个实例都会复制这些成员。如果成员被声明为静态成员,该成员就只被复制一次。静态成员是在载入包含类的应用程序时创建的,在应用程序的整个生命期内它都存在。因此,您甚至可以在类被实例化之前就访问这样的成员。不过,为什么会这样做呢?
有一点与Main方法有关。公共语言运行环境(CLR)需要一个通向应用程序的公共的入口。这样CLR就不需实例化任何一个对象,只要在某个类中定义了一个名为Main的静态方法即可。另外,从面向对象的角度看来,如果某个方法只在语义上属于某个类而并不需要实际的对象时。也需要使用静态成员——比如,如果您希望在应用程序的生命期内跟踪一个给定的对象有多少个实例,因为静态成员也存在于对象实例中。
因此可以编写出下面的代码:/Example/Chapter4/4-5-1.cs
class TestApp{
public static int instanceCount=0;
public TestApp(){
instanceCount++;
}
static void Main(string[] args){
Console.WriteLine(TestApp.instanceCount);
TestApp temp1 = new TestApp();
Console.WriteLine(TestApp.instanceCount);
TestApp temp2 = new TestApp();
Console.WriteLine(TestApp.instanceCount);
}
}
执行结果如下:
0
1
2
关于静态成员的最后一个提示就是:静态成员必须有一个合法的值。在定义成员时就可以指定这个值.如:
public static int instanceCount=10;
如果您没有初始化这个变量,CLR就会在应用程序启动时使用默认值0对它进行初始化。因此,下面两行代码实际上是等价的:
public static int instanceCount;
public static int instanceCount=0;
4.4.2构造函数的初始化函数
所有的C#对象构造函数——除了System.Object构造函数以外——在执行构造函数的第一行代码之前都会调用其基类的构造函数。这些构造函数初始化函数允许您指定希望调用哪个类和哪个构造函数,它们具有两种形式:
形如“base(…)”的初始化函数允许调用当前类的基类的构造函数。也就是由发出调用指令的构造函数的名称所指定的特定的构造函数。
形如“this(…)”的初始化函数允许当前类调用它内部定义的另一个构造函数。当您重载了多个构造函数,而希望总是调用一个默认的构造函数时,就应该使用这种初始化函数。重载方法将在后面讨论.这里给出一个粗略的定义:重载方法就是两个以上具有相同名称但参数值列表不同的方法。
为了看看实际操作中的顺序,注意下面的代码将首先执行类A的构造函数,然后执行
类B的构造函数:/Example/Chapter4/4-5-2.cs
class A{
public A(){
Console.WriteLine("A");
}
}
class B:A {
public B()
{
Console.WriteLine("B");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
B b = new B();
}
}
下面的代码功能与上面的代码相同,下面的代码中显示调用了基类的构造函数。
class A
{
public A()
{
Console.WriteLine("A");
}
}
class B:A
{
public B():base()
{
Console.WriteLine("B");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
B b = new B();
}
}
下面我们来看一个展示构造函数初始化函数作用的更好的例子。同样,我们有两个类:A和B。这次,类A有两个构造函数:一个没有参数值,一个带有一个int类型的参数值。类B有一个带有一个int类型参数值的构造函数。在类B的构造函数中就可能会发生问题。如果运行下面的代码,就会凋用类A不带参数值的那个构造函数:
/Example/Chapter4/4-5-3.cs
class A
{
public A()
{
Console.WriteLine("A");
}
public A(int foo)
{
Console.WriteLine("A={0}",foo);
}
}
class B:A
{
public B(int foo)
{
Console.WriteLine("B={0}",foo);
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
B b = new B(88);
}
}
那么,怎样才能保证调用我们希望调用的类A的构造函数呢?只需告诉编译器我们希望调用初始化函数列表中的哪个构造函数即可。
如下所示:/Example/Chapter4/4-5-4.cs
class A
{
public A()
{
Console.WriteLine("A");
}
public A(int foo)
{
Console.WriteLine("A={0}",foo);
}
}
class B:A
{
public B(int foo):base(foo)
{
Console.WriteLine("B={0}",foo);
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
B b = new B(88);
}
}
注意: 与Visual C++不同,在C#中您不能使用构造函数初始化函数去访问当前类中除构造函数以外的其他实例成员。
4.5 常量和只读字段
您可能常常会将在应用程序执行过程中不希望改变的元素作为字段使用。比如,应用程序所依赖的数据文件、某个数学类中的∏值、或者应用程序中任何您所知的永远不会改变的值。为了解决这些问题,C#允许定义两种极其相似的成员类型:常量和只读字段。
4.5.1 常量
从这个名字中就可以看出,由关键字“const”表示的常量就是在应用程序的生命期内保持不变的字段。在定义常量时只需记住两点:第一,常量的值是在编译时设定的,可以由程序员设定,默认状态是编泽器设定。第二,常量成员的值必须是数值文字。
要将字段定义成常量,只需在被定义的成员前指定关键字“const”即可。
如:/Example/Chapter4/4-6-1.cs
class MagicNumbers
{
public const double PI = 3.1415;
public const int answerToAllLifesQuestions = 42;
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Console.WriteLine("PI = {0},everything else = {1}",MagicNumbers.PI,MagicNumbers.answerToAllLifesQuestions);
Console.Read();
}
}
注意这段代码中有一个关键的地方:客户代码不需要实例化MagicNumbers类,因为默认状态下常量成员是静态的。为了更清楚的认识这一点,不防看看下面这两个成员产生的MSIL代码:
answerToAllLifesQuestions:public static literal int32 =iht32
(0x0000002A)
pi :public static literal float64 =float64(3.1415000000000002)
4.5.2 只读字段
定义为常量的字段是很有用的,因为它清楚地体现了程序员的意图:即该字段包含的是一个不变的值。不过,这只有在编译时您就知道常量值的情况下才起作用。那么,如果字段的值要到运行的时候才知道。而一旦被初始化后就不再改变。这时程序员该怎么办呢?这个问题在别的编程语言中通常都不会考虑到,但C#语言的设计者们通过所谓的“只读字段”解决了这个问题。
当您使用关键字“readonly”定义一个字段时,您可以在这个地方设置该字段的值:构造函数,除此之外,类本身或使用类的客户代码就都不能改变该字段的值了。比如,您希望在一个图形应用程序中追踪屏幕的分辨率,您不能使用常量来解决这个问题,因为直到运行的时候,应用程序才能决定终端用户的屏幕分辨率。
因此,您可以使用类似下面这样的代码:/Example/Chapter4/4-6-2.cs
class GraphicsPackage
{
public readonly int ScreenWidth;
public readonly int ScreenHeight;
public GraphicsPackage()
{
this.ScreenWidth = 1024;
this.ScreenHeight = 768;
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
GraphicsPackage graphics = new GraphicsPackage();
Console.WriteLine("Width = {0},Height = {1}",graphics.ScreenWidth,graphics.ScreenHeight);
}
}
初看起来,这段代码好像就是您所需要的。不过,这里还存在一个小问题:我们定义的只读字段都是实例字段,也就是说,用户必须实例化类之后才能使用这些字段。如果在实例化类的方式将决定只读字段值的情形下,这也许正是您所希望的。但是.如果您想要的是一个常量——按定义是静态的。在运行的时候才被初始化,又该怎么办呢?这时,您就应该给字段加上限定符“static”和“readonly”。然后再创建一个特殊类型的构造函数。叫做“静态构造函数”。静态构造函数就是用来初始化静态字段(不管只读与否)的构造函数,下面我们将前面那个例子中的屏幕分辨率字段修改为静态和只读,并添加了一个静态构造函数。注意在构造函数定义时添加的关键字“static”:
class GraphicsPackage{
public static readonly int ScreenWidth;
public static readonly int ScreenHeight;
static GraphicsPackage()
{
ScreenWidth = 1024;
ScreenHeight = 768;
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Console.WriteLine("Width = {0},Height = {1}",GraphicsPackage.ScreenWidth,GraphicsPackage.ScreenHeight);
}
}
4.6 对象的清除和资源管理
Microsoft提出了基于Dispose设计模式的解决方案。这种设计模式推荐对象公开一个公共方法,通常可以叫做“Cleanup”或“Dispose”,然后在用户结束使用该对象时来凋用这个方法。这样,就由类的设计者负责在该方法中进行必要的清除操作。实际上,在.NET框架类库中有很多类都为此实现了Dispose方法。关于Dispose设计模式相关内容,我们在本书中不重点讨论。
4.7 继承
在第3章中我们已经提到。当一个类构建于另一个类之上(数据或者行为)时就要用到继承,并且遵循可替代性规则。也就是说,派生类可以替代基类。比如说您想构造一个数据库类的层次结构,假设您想编写一个类来处理Microsoft SQL Server数据库和Oracle数据库,由于这两个数据库在某些方面有区别,您想为每个数据库分别编写一个类。但是,这两个数据库又共享很多功能,您希望将这些公共的功能放进一个基类,然后从基类中派生出另外两个类,在需要的时候再覆盖或修改所继承的基类行为。
要从一个类中继承另一个类,您可以使用如下的语法:
class <derivedClass> : <baseClass>
下面是该数据库示例程序的代码:/Example/Chapter4/4-6-1.cs
class Database
{
public int ComonField;
public Database()
{
ComonField = 58;
}
public void ComonMethod()
{
Console.WriteLine("Database.Comon Method");
}
}
class SQLServer : Database
{
public void SomeMethodSpecificToSQLServer()
{
Console.WriteLine("SQLServer.SomeMethodSpecificToSQLServer");
}
}
class Oracle : Database
{
public void SomeMethodSpecificToOracle()
{
Console.WriteLine("Oracle.SomeMethodSpecificToOracle");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
SQLServer sqlserver = new SQLServer();
sqlserver.SomeMethodSpecificToSQLServer();
sqlserver.ComonMethod();
Console.WriteLine("Inherited common field = {0}",sqlserver.ComonField);
}
}
程序运行结果如下:
SQLServer.SomeMethodSpecificToSQLServer
Database.Comon Method
Inherited common field = 58
注意Database.ComonMethod方法和Database.ComonField方法现在都是SQLServer类定义的一部分。因为SQLServer类和Oracle类都是从Database基类中派生而来的。因此它们都继承了基类中定义为“public”、“protected”或“internal”的几乎所有成员。惟一的例外是构造函数,因为它不能被继承。每个类都必须实现它自己的与其基类无关的构造函数。
第5章中将介绍方法覆盖。不过,为了本节的完整性,此处先简单介绍一下。方注覆盖就是允许您从基类中继承一个方法,然后改变这个方法的具体实现。抽象类与方法覆盖联系很紧密——这也将在第5章中介绍。
4.7.1 多接口
因为在很多新闻组和邮件列表中,对有关多继承的主题(比如:C#不支持通过派生的多继承)很有争议,因此我们来澄清一下。其实,通过实现多接口就可以将多个程序实体的行为特征聚集在一起。接口以及如问使用接口将在第7章中介绍。现在可以先将C#接口看作COM接口。
因此,下面这个程序就是非法的:
class foo
{
}
class bar
{
}
class MITest : foo,bar
{
Public static void main()
{
}
}
在这个示例中将出现的错误与您如何实现接口有关。您选定要实现的接口要列在该类的基类后面。因此,在这个例子中,C#编译器就认为bar应当是一个接口类型,这也是C#编译器带给您下面这条错误信息的原因:
‘bar’:type in interface list is not an interface
下面这个更实际的例子完全合法,因为MyFancyGrid是从Control中派生出来的,并且实现了ISerializable和真IDataBind接口:
public class Control
{
}
interface ISerializable
{
}
interface IDataBound
{
}
public class MyFanceGrid : Control,ISerializable,IDataBound
{
}
这里的关键在于:在C#中实现所谓的多继承的唯一方式就是使用接口。
4.7.2 封装类
如果您希望确保某个类永远也不用作基类,在定义该类时就应该使用限定符“seded”。定义封装类的惟一限制就是抽象类不能被定义为封装类。因为抽象类“天生”就是要被当作基类使用的。另外,尽管使用封装类的目的是阻止派生意图,但其实将类定义为封装类之后还会允许在运行的时候进行一定的优化。特别地,由于编译器保证了封装类永远也不会有任何派生类存在,因此就可以将封装类实例中的虚函数成员调用转变为非虚调用。下面是一个封装类的示例:
sealed class SaledClass
{
private int x,y;
public SaledClass(int x,int y)
{
this.x = x;
this.y = y;
}
public int X
{
get
{return x;}
set
{x = value;}
}
public int Y
{
get
{return y;}
set
{y = value;}
}
}
注意内部类成员x和y使用了访问限定符“private”。如果使用限定符“protected”就会使编译器发出警告信息,因为受保护的成员对于派生类而言是可见的,但现在我们已经知道,封装类不会有任何派生类。
4.8 本章小结
类的概念和类与对象的关系是基于对象编程思路的基础。构建于从C++传递下来的继承之上的C#的面向对象特征在。NET框架中得到了进一步的加强。在类似公共语言运行环境(CLR)这样的受控系统中,资源管理是开发人员需要特别关注的地方。CLR通过基于确定性结束的垃圾收集器来努力将程序员们从引用计算这样的“苦差事”中解放出来。C#中的继承处理也与C++中不同。尽管C#只支持单一继承,但开发人员可以通过实现多接口来获得一些多继承的好处。
4.9 实战演练
1. 列举出现实生活中都有哪些可以用类来描述,并且实例化对象出来。
2. 编写一个显示器(Displayer)类,该类包括型号、大小、厂商、价格和显示图像功能这些信息,然后实例化对象,用来描述您所使用的显示器。
3. 利用类模板实现一个通用的栈模板。要求这个栈模板能够完成一般栈的基本操作(栈元素为基本数据类型,不包括指针、数组以及对象)。向栈中压入一个元素(push)、取栈顶元素的值(top)、弹出栈顶元素(pop)、清空栈(empty)、判断栈是否为空(isEmpty)。编写一段主程序,要求主程序中生成整型、浮点型、字符型的栈实例各一个并分别测试其功能。
第五章 方法
本章重点:
方法的参数“ref”和“out”
方法重载
虚拟方法
多态性
静态方法
本章目的:
学习“ref”和“out”两种方法参数关键字,以及如何使用他们来定义方法,以便能返回多个值给应用程序。还应掌握如何定义方法重载。以便多个同名的方法根据传递给它们的不同类型和(或)参数值的个数而具有不同的功能。还有虚拟方法和静态方法的定义。
我们在第3章中已经学到,类就是封装的数据和加工数据的方法。换句话况,方法为类赋予了行为特征。而我们根据自己的意图使用希望类执行的操作来为方法命名。到目前为止。我们还没有涉及到在C#中有关定义和调用方法的更具体的问题。这也正是本章的目的——您将会学习“ref”和“out”这两个方法参数关键字,以及您如何使用它们来定义方法.以便能返回多个值给调用程序。您还会学习如何定义重载方法,以便多个同名的方法根据传递给它们的不同类型和(或)参数值的个数而具有不同的功能。然后您将学到如何处理直到运行的时候才能知道方法参数值的确切个数的情形。最后,我们将以对虚拟方法的讨论(虚拟方法构建于第3章所讨论的继承之上)以及如何定义静态方法作为本章的结束。
5.1方法参数“ref”和“out”
当您在C#中通过使用方法来试图获取一些信息时,通常只能得到一个返回值。因此,初看起来就好像每个方法调用只能返回一个值。显然,在很多时候如果为每一个必要的数据都调用一个方法会相当麻烦,比如,假设您有一个Color类,用来代表一种给定的颜色,该颜色由三个值确定,这些值使用描述颜色的标准RGB(red-green-blue,红—绿—蓝)模式来给出。如果只使用一个返回值,您就不得不书写如下的代码来得到这三个值:
// Assuming color is an instance of a Color class.
int red =color. GetRed();
int green =color. GetGreen();
int blue =color. GetBlue();
但是我们希望的却是类似于这样的代码:
int red;
int green;
int blue;
color.GetRGB(red,green,blue);
不过,这里有个问题.当调用color.GetRGB方法时,参数值red、green和blue的值会被复制到该方法的局部堆栈中,该方法对这些变量的任何修改都不会传递给调用程序的变量。
在C++中,这个问题是这样解决的。调用程序将指针或引用传递给变量,这样,被调用的方法就能加工调用程序的数据了。在C#中的解决方案与此类似。事实上,C#提供了两种相似的解决方案。第一种包含关键字“ref”,这个关键字告诉C#编译器被传递的参数值指向与调用代码中变量相同的内存。这样,如果被调用的方洼修改了这些值然后返回的话,调用代码的变量也就被修改了。
下面的程序说明了如何在color类中使用关键字“ref”:
public class Color
{
protected int red;
protected int green;
protected int blue;
public Color()
{
red = 255;
green = 0;
blue = 125;
}
public void GetRGB(ref int red,ref int green,ref int blue)
{
red = this.red;
green = this.green;
blue = this.blue;
}
}
//主程序
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Color color = new Color();
int red;
int green;
int blue;
color.GetRGB(ref red,ref green,ref blue);
Console.WriteLine("red = {0},green = {1},blue = {2}",red,green,blue);
}
}
使用关键字“ref”有一个缺点,实际上正是由于这个限制,以上的代码不会通过编泽,当您使用关键字“ref”时,您必须在调用方法之前初始化被传递的参数值。因此,要想让上面的代码能够正常运行.必须做出如下修改。
如下所示:/Example/Chapter5/5-1-1.cs
public class Color
{
protected int red;
protected int green;
protected int blue;
public Color()
{
red = 255;
green = 0;
blue = 125;
}
public void GetRGB(ref int red,ref int green,ref int blue)
{
red = this.red;
green = this.green;
blue = this.blue;
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Color color = new Color();
int red=0;
int green=0;
int blue=0;
color.GetRGB(ref red,ref green,ref blue);
Console.WriteLine("red = {0},green = {1},blue = {2}",red,green,blue);
}
}
程序执行结果如下:
red = 255,green = 0,blue = 125
在这个例子中,初始化这些就要被重写的变量看起来好像是白费功夫。因此,C#提供了另外一种途径来传递这种参数值——调用代码需要看到它们的值的变化:关键字“out”下面是同样的Color类示例程序,只不过使用的是关键字“out”:
public class Color
{
protected int red;
protected int green;
protected int blue;
public Color()
{
red = 255;
green = 0;
blue = 125;
}
public void GetRGB(out int red,out int green,out int blue)
{
red = this.red;
green = this.green;
blue = this.blue;
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Color color = new Color();
int red;
int green;
int blue;
color.GetRGB(out red,out green,out blue);
Console.WriteLine("red = {0},green = {1},blue = {2}",red,green,blue);
}
}
关键字“ref”和“out”之间的惟一区别就是关键字“out”不要求调用代码忉始化要传递的参数值。那么,关键字“ref”什么时候使用呢?当您需要确保调用方法已经初始化参数值的时候。您就应该使用关键字“ref”。
注意:如果被调用的方法需要使用参数的值时,这时必须使用关键字“ref”,因为“ref”可以强制变量进行初始化。这样才能保证被调用方法能够正常工作。
5.2 方法重载
方法重载允许C#程序员可以多次使用同名的方法而每次传递的参数值不同。这一点在两种情形下特别有用;第一种情形是您希望公开一个方法名,该方法的外部表现根据传递的参数值的类型不同而稍微有些不同。比如,假设您有一个日志类,允许您的应用程序将诊断信息写入磁盘。要想把这个类变得更灵活一些.您可能会有几个不同形式的write方法用于指定要写入的信息。除了接受要写入的字符串之外,该方法可能还需要接受一个字符串源的标识符。如果没有重载方法,您就不得不为每种情形分别实现一个方法。如WriteString和WriteFromResourceId。不过,通过方法重载,您就可以实现以下的方法——都叫做“WriteEntry”——每个方法只是参数类型不同而已。
如下所示:/Example/Chapter5/5-2-1.cs
public class Log
{
protected string filename;
public Log(string fileName)
{
this.filename = fileName;
}
public void WriteEntry(string entry)
{
Console.WriteLine("{0} was Writed into LogFile {1}",entry,filename);
}
public void WriteEntry(int resourceId)
{
Console.WriteLine("{0} was Writed into LogFile {1}",resourceId,filename);
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Log log = new Log("mylog.log");
log.WriteEntry("Entry One");
log.WriteEntry(88);
Console.Read();
}
}
执行结果如下:
Entry One was Writed into LogFile mylog.log
88 was Writed into LogFile mylog.log
第二种方法重载有用的情形是当您使用构造函数的时候,构造函数是实例化对象时要调用的基本方法。假设您希望创建一个类,这个类可以用多种方式来构造——比如,采用文件句柄(int)或者文件名(string)来打开一个文件,因为C#规则规定了类的构造函数必须与类同名,因此您不能简单地为每一种不同的变量类型创建不同名称的方法。此时,您应该重载构造函数:如下所示:/Example/Chapter5/5-2-2.cs
public class File
{
}
public class CommonFile
{
public CommonFile(string fileName)
{
Console.WriteLine("Construct with a filename");
}
public CommonFile(File file)
{
Console.WriteLine("Construct with a File Object");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
File file = new File();
CommonFile file2 = new CommonFile(file);
CommonFile file3 = new CommonFile("Some file name");
Console.Read();
}
}
关于方法重载要记住的重要一点是:每个方法的参数值列表必须有所不同,即参数个数或者类型不同。
5.3虚拟方法
在第4章中我们已经看到,可以从一个类中派生出另外—个类。这样类就可以继承并在已存在的类的功能之上构建新的功能,因为那时我们还没有讨论到方法,因此只仅仅接触到了字段和方法的继承。也就是说,我们并没有研究在派生类中修改基类行为的方法。这是通过使用虚拟方法来实现的,这也正是本节的主题所在。
5.3.1方法覆盖
首先我们来看看如何在—个继承的方法中覆盖基类的功能。还是以代表雇员的基类开始,为了使这个示例程序尽可能简单,我们只给这个基类一个方法CalculatePay。而方法内部除了让我们知道被调用的方法的名称外。别的什么也不做,这将有助干我们在稍后确定继承树中的哪个方法被调用:
class Employee
{
public void CalculatePay()
{
Console.WriteLine("Employee.CalculatePay()");
}
}
现在,假设您想要从Employee类中派生一个类,并希望覆盖掉CalculatePay方法,以便针对派生类进行一些具体的操作。要想达到这样的目的,您需要在派生类的方法定义中使用关键字“new”。下面的代码显示了其简单性:
public class Employee
{
public void CalculatePay()
{
Console.WriteLine("Employee.CalculatePay()");
}
}
public class SalariedEmployee:Employee
{
public new void CalculatePay()
{
Console.WriteLine("SalariedEmployee.CalculatePay()");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Employee emp = new Employee();
emp.CalculatePay();
SalariedEmployee emp2 = new SalariedEmployee();
emp2.CalculatePay();
Console.Read();
}
}
执行结果如下:
Employee.CalculatePay()
SalariedEmployee.CalculatePay()
5.3.2多态性
引用派生对象的时候,利用关键字“new”便可以实现方法的覆盖。那么,如果引用的是基类,如何让编译器调用派生类的方法呢?这就是多态性要解决的问题。多态性允许在类的层次结构中多次定义同一个方法,并使运行环境根据要使用的对象来调用该方法的适当版本。仍以前面的雇员程序为例来说明。应用程序TestApp能够正常运行,因为我们有两个对象:一个Employee对象和一个SalariedEmployee对象。在更接近实际的应用程序中,我们可能会从一个数据库中读出所有的雇员记录并形成一个数组。尽管这些雇员中有些是合同工。有些是正式工,但我们仍然需要将它们都以相同的类型——基类类型Employee——存放在数组中。这样,在遍历这个数组、获取对象并凋用每个对象的CalculatePay方法时。我们希望编译器能够正确调用每个对象对CalculatePay方法的实现。
在下面的例子中添加了一个新类一ContractEmployee。主要的应用程序类现在包含一个Employee类型的对象数组和两个方法——LoadEmployees将Employee对象装载到数组中.DoPayroll遍历这个数组.调用每个对象的CalculatePay方法。
如下所示:/Example/Chapter5/5-3-1.cs
public class Employee
{
protected string Name;
public Employee(string name)
{
this.Name = name;
}
public string name
{
get{return Name;}
set{Name = value;}
}
public void CalculatePay()
{
Console.WriteLine("Employee.CalculatePay called for {0}",Name);
}
}
public class SalariedEmployee:Employee
{
public SalariedEmployee(string name):base(name)
{
}
public new void CalculatePay()
{
Console.WriteLine("SalariedEmployee.CalculatePay called for {0}",Name);
}
}
public class ContractEmployee:Employee
{
public ContractEmployee(string name):base(name)
{
}
public new void CalculatePay()
{
Console.WriteLine("ContractEmployee.CalculatePay called for {0}",Name);
}
}
public class PolyApp
{
protected Employee[] employees;
public void LoadEmployees()
{
employees = new Employee[2];
employees[0] = new SalariedEmployee("Green");
employees[1] = new ContractEmployee("Jakson");
}
public void DoPayroll()
{
foreach(Employee emp in employees)
{
emp.CalculatePay();
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
PolyApp poly = new PolyApp();
poly.LoadEmployees();
poly.DoPayroll();
}
}
程序运行结果如下:
Employee.CalculatePay called for Green
Employee.CalculatePay called for Jakson
显然,这并不是我们想要的结果——每个对象都调用了基类的CaculatePay方法的实 现。这里所发生的就是所谓“早期捆绑”(early binding)现象的一个例子。当编译代码的时候,C#编译器一看到对emp.CalculatePay的调用,就确定了调用发生时在内存中它应跳转的地址。这种情况下,采用的当然就是Employee.CalculatePay方法的内存地址了。对Employee.Calculate方法的调用就是问题所在。我们希望的是发生“后期捆绑”(late binding),也就是说编译器直到运行的时候才选择要调用的方法。要强制编译器调用向上转换了的对象的方法的正确版本,可以使用两个新的关键字:“virtual”和“override”。关键字“virtual”必须用于基类方法。关键字“override”用于派生类对该方法的实现。下面更改过的代码这次就能正常工作了:/Example/Chapter5/5-3-2.cs
public class Employee
{
protected string Name;
public Employee(string name)
{
this.Name = name;
}
public string name
{
get{return Name;}
set{Name = value;}
}
public virtual void CalculatePay()
{
Console.WriteLine("Employee.CalculatePay called for {0}",Name);
}
}
public class SalariedEmployee:Employee
{
public SalariedEmployee(string name):base(name)
{
}
public override void CalculatePay()
{
Console.WriteLine("SalariedEmployee.CalculatePay called for {0}",Name);
}
}
public class ContractEmployee:Employee
{
public ContractEmployee(string name):base(name)
{
}
public override void CalculatePay()
{
Console.WriteLine("ContractEmployee.CalculatePay called for {0}",Name);
}
}
public class PolyApp
{
protected Employee[] employees;
public void LoadEmployees()
{
employees = new Employee[2];
employees[0] = new SalariedEmployee("Green");
employees[1] = new ContractEmployee("Jakson");
}
public void DoPayroll()
{
foreach(Employee emp in employees)
{
emp.CalculatePay();
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
PolyApp poly = new PolyApp();
poly.LoadEmployees();
poly.DoPayroll();
Console.Read();
}
}
程序执行结果如下:
SalariedEmployee.CalculatePay called for Green
ContractEmployee.CalculatePay called for Jakson
注意: 虚拟方法不能被声明为私有(private),因为私有成员对于派生类来说是不可见的。
5.4 静态方法
静态方法就是作为一个整体存在于类中的方法,而不是存在于类的一个特定实例中。与别的静态成员相比,静态方法的关键好处就在于它们虽然与类的特定实例分离,但却并没有影响应用程序的全局空间,也不因为没有与类实例相连而有损面向对象的风格。作者曾编写过一个C#的数据厍应用编程接口,在这个类层次结构中,有一个SQLServerDB类带有一些基本的NURD(new、update、read and delete)功能,它也公开了一个修复数据库的方法Repair。在这个Repair方法中,并不需要打开数据库。实际上,作者使用的ODBC函数SQLConfigDataSource要求在对该数据库进行操作时它必须被关闭。不过,SQLServerDB的构造函数还是打开了由传递给它的数据库名称所指定的数据库。这种情况下最好使用静态方法,这样就可以将一个方法放在它所属的SQLServerDB类中,而不用借助于类的构造函数了。显然.对于客户来说,好处就是他们也无需对SQLServerDB类进行实例化,在下一个例子中,您可以看到一个静态方法RepairDataBase被从Main方法中调用。注意我们并没有创建一个SQLServerDB的实例:
class SQLServerDB
{
public static void RepairDataBase()
{
Console.WriteLine("Repairing database.........");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
SQLServerDB.RepairDataBase();
Console.Read();
}
}
使用关键字“static”就可以将方法定义为静态方法了。用户使用class.Method这样的语法来调用静态方法。即使用户引用了类的实例,仍然需要使用这种语法格式。
关于静态方法的最后一点就是它能访问哪些类成员。您也许已经想到,静态方法可以访问类的任何静态成员,但是不能访问实例成员。
5.5 本章小结
方法赋予类以行为恃征并且按照我们的意愿执行操作。C#中的方法很有弹性,允许返回多个值以及重载等。关键字“ref”和“out”允许方法返回一个以上的值给调用程序。重载允许多个同名的方法按照传递给它们的参数值类型和个数的不同而具有不同的功能。虚拟方法允许在继承的类中控制如何修改方法。最后,关键字“static”允许方法作为类的一部分存在,而不是作为对象的一部分存在。
5.6 实战演练
1. 编写实例测试方法的“ref”和“out”参数的作用。
2. 编写类的静态方法和实例方法,注意它们的用法。
3. 编写一个加法程序,应考虑到加数的类型,如整型、浮点型等采用方法重载实现。
第六章 属性、数组和索引器
本章重点:
属性的定义及使用
数组的定义及使用
索引器
本章目的:
通过本章的学习,我们应该熟练掌握属性和数组的定义及使用,了解索引器的概念及其实现的功能。
到目前为止,我们已经了解了C#支持的基本类型以及在类和应用程序中如何声明和使用它们。本章将打破每章只介绍一种语言要素的模式,而同时介绍属性、数组和索引器这三种语言要素,因为它们之间存在某些共同的地方,它们允许C#类开发人员扩展基本的类/字段/方法结构,以便展示出更加自然的类成员接口。
6.1 属性——智能字段
设计类时的—个好的目标总是不仅仅隐藏类成员的实现,而且禁止任何对类字段成员的直接访问。通过“存取器方法”——其职责就是获取和设置字段的值,您就可以确保字段可以被正确处理,也就是说,根据您特定的问题域规则而执行必要的操作处理。
比如,假设您有—个地址类Address包含一个邮政编码字段ZipCode和一个城市字段City,当客户修改字段Address.ZipCode时,您希望通过一个数据库来验证邮政编码是否有效,并且根据邮政编码自动设置Address.City字段的值。如果客户可以直接访问一个公共的Address.ZipCode成员的话,上面这两个任务就有点难了。因为直接更改成员变量并不需要方法。因此,除了可以直接访问Address.ZipCode字段之外,更好的解决方案是将Address.ZipCode和Address.Cipy这两个字段定义为“protected”,然后提供存取器方法来获取和设置Address.ZipCode字段的值。这样,您就可以附加一些代码来执行需要的操作了。 这个邮政编码的程序在C#中的示例代码如下。注意真正的ZipCode字段被定义为“protected”,因此客户不能直接访问它。而存取器方法GetZipCode和SetZipCode被定义为“public”:
class Address
{
protected string ZipCode;
protected string City;
public string GetZipCode()
{
return ZipCode;
}
public string SetZipCode(string zipcode)
{
//Validate value against some datasource
ZipCode = zipcode;
//Update city based on validated zipCode
}
}
客户可以这样来访问 ZipCode 的值。
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Address addr = new Address();
addr.SetZipCode("710041");
string zip = addr.GetZipCode();
}
}
6.1.1定义和使用属性
使用存取器方法固然不错,这种技术也被不少面向对象语言(包括C++和Java的程序员们所采用。不过,C#提供了一种更加理想的机制——属性,它具有与存取器方法相同的功能,在客户端却表现更佳。通过属性,程序员们不必借助于存取器方法就可以让客户能够像访问公共字段一样来访问类的字段。
C#属性由一个字段声明和用于修改该字段值的存取器方法组成。这些存取器方法叫做“getter”和“setter”方法。Getter方法用于获取字段的值,而setter方法用于修改字段的值。 下面是使用C#字段重写的前面的代码:
class Address
{
protected string ZipCode;
protected string City;
public string zipCode
{
get
{
return ZipCode;
}
set
{
//Validate value against some datasource
ZipCode = zipcode;
//Update city based on validated zipCode
}
}
}
注意此处创建了一个叫做“Address。ZipCode”的字段和一个叫做“Address.City”的属性。有些人可能一开始会混淆。以为Address.ZipCode也是字段,不明白为什么一个字段要定义两次。但Address.ZipCode不是字段,而是属性。它其实就是一种定义类成员存取器的通用方式,以便可以使用更加自然的“Object.field”语法格式。如果在这个例子中省略掉Address.ZipCode字段,在setter语句中将ZipCode=value改为zipCode=value的话,就会导致setter方法被无限调用。同样要注意setter并没有任何参数值,被传递的值被自动放在一个叫做“value”的变量中,在setter方法内可以访问value变量。(很快您就会在MSIL中看到这个“奇迹”是怎样发生的)
既然我们已经写出了Address.ZipCode属性,就来看一看客户代码需要做出的改动吧:
Address addr = new Address();
addr.zipCode = "710041";
string zip = addr.zipCode;
这样,客户访问字段就很自然了,无需猜想或到文档(即源代码)中查找以便决定这个字段是否足公共的,如果不是,存取器方法的名称又是什么。
6.1.2编译器的工作原理
那么,编译器是如何允许我们以标准的Object.field语法格式来调用一个方法的呢?同样,value变量又是从哪里来的呢?要回答这些问题,我们需要看一看编译器产生的MSIL代码,首先看一看属性的getter方法。
在目前的示例程序中,我们定义了这个getter方法。如下是其编译后产生的中间代码:
.method public hidebysig specialname
instance string get_zipCode() cil managed
{
// 代码大小 11 (0xb)
.maxstack 1
.locals init ([0] string CS$00000003$00000000)
IL_0000: ldarg.0
IL_0001: ldfld string ConsoleApplication1.Address::ZipCode
IL_0006: stloc.0
IL_0007: br.s IL_0009
IL_0009: ldloc.0
IL_000a: ret
} // end of method Address::get_zipCode
从这个方法的MSIL代码中可以看到,编译器创建了一个叫做“get_ZipCode”的存取器方法。我们之所以能够识别出存取器方法的名称,是因为编译器在属性名前加上了“get”前缀(为getter方法)和“set”前缀(为settr方法)。这样,下面的代码就表示调用get_ZipCode:
string str = addr.ZipCode; //this calls Address::get_ZipCode
我们的问题是:“编译器是怎样允许我们使用标准语法格式Object。Field并调用方法的呢?答案就是编泽器在解析C#属性语法的时候,实际上为我们产生了适当的getter和setter方法。这样,出现Address。ZipCode属性的时候,编译器就产生了包含get_ZipCode和set_ZipCode方法的MSIL代码。
下面看看产生的setter方法。在Addess类中,您会看到如下代码:
set
{
//Validate value against some datasource
ZipCode = value;
//Update city based on validated zipCode
}
注意在这段代码中根本没有声明一个名为“value”的变量。但我们仍然可以使用这个来存储调用程序传递过来的值,并且设置受保护的成员字段zipcde。当C#编译器为setter方法产生MSIL代码时,它将插入这个变量作为set_ZipCode方法的一个参数值。
在产生的MSIL代码中,set_ZipCode方法将一个字符串变量作为参数值,如下所示:
.method public hidebysig specialname
instance void set_zipCode(string 'value') cil managed
{
// 代码大小 8 (0x8)
.maxstack 2
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld string ConsoleApplication1.Address::ZipCode
IL_0007: ret
} // end of method Address::set_zipCode
尽管在C#源代码中看不到这个方法,但是在设置ZipCode属性的时候,如addr.zipCode=710041时,就会产生一段MSL代码来调用Address::set_zipCode(“710041”)。但如果在C#中直接调用set_zipCode这个方法就会产生错误。
6.1.3 只读属性
在前面的例子中,Address.zipCode属性可以认为是可读写的,因为定义了getter和setter这两个方法。当然,有时候您可能不希望客户代码设置给定字段的值,这时候您可以将这个字段设为只读字段,将setter方法省略掉即可。为了进一步说明只读字段,我们现在限制客户代码设置address.city字段,只留下address.zipCode属性作为更改这个字段值的惟一途径。
6.1.4继承属性
与方法一样,属性也可以使用第4章中介绍过的“virtual”、“override”或“abstract”限定符。这些限定符允许派生类继承并覆盖属性。与它处理来自基类的任何其他成员一样。关键在于您只能在属性这个级别上指定这些限定符。也就是说.如果同时存在getter方法和setter方法的话。如果您覆盖其中一个,您必须也覆盖另外的一个。
6.1.5 属性的高级使用
到目前为止.我们谈论到属性的用途时有以下方面:
● 它们为客户代码提供了一定层次上的抽象。
● 通过Object.field语法格式,它们提供了一种访问类成员的通用方式。
● 它们允许类在修改或访问一个特定的字段时,还可以进行其他的操作。
上面第三点给我们指出了使用属性的另一个“用武之地”。实现所谓“偷懒的初始化。这是一种优化技术,能够在需要类成员的时候才对它们进行初始化。
如果类中包含了很少被引用的成员,而这些成员的初始化又会占用大量的时间和系统资源的话.比如数据必须从数据库或拥挤的网络读入的时候,这时使用“偷懒的初始化”就很有用了。因为您知道这些成员不常被引用而对它们进行初始化的代价又很大,所以可以在调用它们的getter方法时再对它们进行初始化。为了说明这一点,假设您有一个库存应用程序,销售人员会在他们的笔记本电脑中运行,以便存入顾客的订单,但他们很少会使用这个程序来检查库存水平。通过属性的使用,您就可以允许相关的类初始化时不必读入库存记录,如下面的代码所示。这样,如果销售人员的确需要访问某项货物的库存记录时,这时getter方法才访问远程数据库。
class sku
{
protected double onHand=0;
public double OnHand
{
get
{
//read from database server
return onHand;
}
}
}
本章到目前为止,您可能已经看到,属性通过为字段提供存取器方法,从而为客户代码提供了更通用和自然的接口。正因为此,属性有时候也叫做“智能字段”。现在,我们更进一步来看一看在C#中是如何定义和使用数组的,以及属性如何以“索引器”的形式与数组共用。
6.2 数组
到目前为止,本书中的大多数示例程序展示的都是如何定义有限个数、且预先能确定个数的变量。但是在实际的应用程序中,有时候您可能会直到运行的时候才知道所需对象的确切数目。比如,您开发了一个编辑器,希望跟踪添加到一个对话框里的控件,但编辑器将显示出来的控件的确切数目要到运行的时候才知道。这时,您就可以使用一个数组来存储并跟踪一组动态分配的对象——这个例子中即编辑器中的控件。
在C#中,数组对象共同的基类是System.Array。因此,虽然在C#中定义数组的语法格式与C++或Java中类似,但定义数组实际上是实例化了—个.NET类,也就是说,声明的每一个数组都从System.Array中继承了相同的成员。本节我们将介绍如何定义和实例化数组、如何使用不同的数组类型,如何遍历数组的每一个元素以及System.Array类的一些经常使用的属性和方法。
6.2.1 声明数组
在C#中声明一个数组时,只需将空的方括号放置在变量的类型和名称之间。如:
int[] numbers;
注意这种语法格式与C++中稍微有所不同,在C++中括号是在变量名之后。因为数组是基于类的,所以声明类的很多规则也可以应用于声明数组。比如,当您声明一个数组时,实际上并没有创建那个数组。与使用类一样,在分配数组的元素之前.您必须实例化该数组。在下面的例子中,在声明数组的同时实例化了数组:
Int[] numbers = new int[6];
不过,在将数组声明为类的一个成员时,您必须将声明数组与实例化数组分成两个不同的步骤,因为直到运行的时候您才能实例化对象。
6.2.2 一维数组
下面这个例子将一个一维数组声明为一个类成员,在构造函数中实例化并填充该数组,然后使用了一个for循环语句来遍历这个数组,打印出每一个元素:
/Example/Chapter6/6-2-1.cs
class SingleDimArray
{
protected int[] numbers;
public SingleDimArray()
{
numbers = new int[6];
for(int i=0;i<6;i++)
{
numbers = i*i;
}
}
public void PrintArray()
{
for(int n=0;n<numbers.Length;n++)
{
Console.WriteLine("numbers[{0}] = {1}",n,numbers[n]);
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
SingleDimArray single = new SingleDimArray();
single.PrintArray();
}
}
程序执行结果如下:
numbers[0] = 0
numbers[1] = 1
numbers[2] = 4
numbers[3] = 9
numbers[4] = 16
numbers[5] = 25
在这个例子中,SingleDimArray.PrintArray方法使用System.Array.Length属性来确定该数组中的元素个数。由于这里只是一个一维数组,看起来不是很明显,实际上Length属性返回的是数组所有维的所有元素的个数。因此,如果是一个5x4的两维数组,Length属性将返回10。下一节我们将学习多维数组以及如何确定一个特定数组维的上界。
6.2.3 多维数组
C#除了一维数组以外,还支持多维数组,多维数组的每一维由一个逗号分隔。下面的代码声明了一个double类型的三维数组:
double[ , , ] numbers;
要确定一个已声明C#数组的维数,只需将逗号的个数加1即可。
下面的例子中使用了一个两维数组的销售数字来代表今年当月的销售总量与去年同期的销售总量,要特别注意实例化该数组所用的语法格式:
如下所示:/Example/Chapter6/6-2-2.cs
class MultiDimArray
{
protected int currentMounth;
protected double[,] sales;
public MultiDimArray()
{
currentMounth = 10;
sales = new double[2,currentMounth];
for(int i=0;i<sales.GetLength(0);i++)
{
for(int j=0;j<10;j++)
{
sales[i,j] = (i*100)+j;
}
}
}
public void PrintSales()
{
for(int i=0;i<sales.GetLength(0);i++)
{
for(int j=0;j<sales.GetLength(1);j++)
{
Console.WriteLine("sales[{0}][{1}] = {2}",i,j,sales[i,j]);
}
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MultiDimArray temp = new MultiDimArray();
temp.PrintSales();
Console.Read();
}
}
程序执行结果如下:
sales[0][0] = 0
sales[0][1] = 1
sales[0][2] = 2
sales[0][3] = 3
sales[0][4] = 4
sales[0][5] = 5
sales[0][6] = 6
sales[0][7] = 7
sales[0][8] = 8
sales[0][9] = 9
sales[1][0] = 100
sales[1][1] = 101
sales[1][2] = 102
sales[1][3] = 103
sales[1][4] = 104
sales[1][5] = 105
sales[1][6] = 106
sales[1][7] = 107
sales[1][8] = 108
sales[1][9] = 109
在前面的一维数组示例程序中曾说过,Length属性将返回数组中所有元素的个数,因此在这个例子中,Length的返回值就是20。在MultiDimArray.PrintSales方法中使用了Array.GetLength方法来确定数组每一维的长度(或者叫上界),然后在PrintSales方法中就可以使用每一个特定的值了。
6.2.4 查询秩
既然我们已经看到动态遍历一维数组或多维数组是多么简单,您可能就会问:如何从程序中确定数组的确切维数呢?数组的维数就叫做该数组的“秩”(rank),而秩可以通过Array.Rank属性来获得。请看下面的示例程序:/Example/Chapter6/6-2-3.cs
class RankArray
{
int[] singleD;
int[,] doubleD;
int[,,] tripleD;
public RankArray()
{
singleD = new int[6];
doubleD = new int[6,7];
tripleD = new int[6,7,8];
}
public void PrintRanks()
{
Console.WriteLine("singleD Rank = {0}",singleD.Rank);
Console.WriteLine("doubleD Rank = {0}",doubleD.Rank);
Console.WriteLine("tripleD Rank = {0}",tripleD.Rank);
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
RankArray rank = new RankArray();
rank.PrintRanks();
Console.Read();
}
}
程序执行结果如下:
singleD Rank = 1
doubleD Rank = 2
tripleD Rank = 3
6.2.5 锯齿状数组
关于数组的最后一点就是所谓的“锯齿状数组”Gas8ed叫),锯齿状数组其实就是
一个数组的数组。下面的代码定义了一个包含整数故组的数组:
int[][] jaggedarray;
如果您要开发一个编辑器,可能就会用到锯齿状数组。在这个编辑器中,您希望在一个数组中存储代表用户创建的控件的每一个对象。假设您已经有一个按钮和组合框的数组。您可能希望将三个按钮和两个组合框都分别存储到这个数组中。声明一个锯齿状数组就能够让您拥有一个这些数组的“父”数组。这样在程序上您就可以在需要的时候轻松遍历这些控件,如下所示:/Example/Chapter6/6-2-3.cs
class Control
{
virtual public void SayHi()
{
Console.WriteLine("Base control class");
}
}
class Button : Control
{
override public void SayHi()
{
Console.WriteLine("Button control");
}
}
class ComboBox : Control
{
override public void SayHi()
{
Console.WriteLine("ComboBox control");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Control[][] controls;
controls = new Control[2][];
controls[0] = new Control[3];
for(int i=0;i<controls[0].Length;i++)
{
controls[0] = new Button();
}
controls[1] = new Control[2];
for(int i=0;i<controls[1].Length;i++)
{
controls[1] = new ComboBox();
}
for(int i=0;i<controls.Length;i++)
{
for(int j=0;j<controls.Length;j++)
{
Control control = controls[j];
control.SayHi();
}
}
Console.Read();
}
}
上面的示例程序中定义了一个基类(Control)和两个派生类(Button)和(ComboBox),并且声明了一个锯齿状数组作为包含Control对象数组的数组。这样就可以将特定的类型存放在特定的数组中了。通过“神奇”的多态性,在从数组中抽取对象的时候(通过向上转换的对象,就会得到我们希望的结果。
6.3 使用索引器将对象当作数组对待
在“数组”一节里,我们学习了如何声明和实例化数组、如何使用不同的数组类型。以及如何遍历数组中的元素,同时还了解到如何利用System.Array类的一些常用的属性和方法。下面我们继续学习有关数组的知识,C#特有的一个属性——索引器,看看它是如何允许我们在程序上如对待数组一样对待对象。
那么我们为什么会希望把对象当作数组对待呢?与程序语言的大多数属性相似,索引器的好处就在于能够让应用程序的书写更加自然一些。在本章的第一节里,我们已经看到C#属性是如何使我们能够利用标准的class.field语法格式来引用类的字段,最终导致产生了getter和setter方法。这种抽象将书写客户代码的程序员解放了出来,他们无需确定字段的getter/setter方法是否存在,无需知道这些方法的确切格式。同样地,索引器也允许类的客户代码能够在对象中进行索引,就好像对象是一个数组一样。
考虑下面这一个例子。您有了一个列表框类需要公布一些成员,以便使用这个类的用户可以插入字符串。如果您熟悉Win32 SDK的话,您就知道为了将一个字符串插入到列表框窗口,需要发送一个LB_ADDSTRING或LB_INSERTSTRING消息。这种机制在二十世纪八十年代后期出现以后,我们就认为自己真的已经是面向对象的程序员了。总之,我们不是已经给对象发送消息了吗?与那些好笑的、面向对象分析和设计的书籍告诉我们的一样。其实,在诸如C++和Object Pascal这样的面向对象和基于对象的语言扩散的同时,我们已经知道可以使用对象来为这样的任务创建更加自然的程序接口。使用C++和MFC(Microsoft Foundation Classes)就有了整个的一个类的格子。使得我们可以将窗口(比如列表框)当作对象对待,这样的对象公开了一些成员函数,这些成员函数基本上是为对象与潜在的Microsoft Windows控件发送和接收消息提供了一层薄薄的包装。在ClistBox类(也就是Window列表框控件的MFC包装)这种情况下,我们就有了AddString和InsertString这样的成员函数来完成任务,而先前这些任务是由发送LB—ADDSTRING和LB_INSERTSTRING这样的消息来完成的。
不过,为了开发出最好和最自然的语言,C#设计小组看到这些问题并开始思索:“为什么不能将一个其实就是数组的对象当作数组对待呢?”比如列表框不就是一个带有额外的显示和排序功能的字符串数组吗?正是从这个想法诞生了索引器这一概念。
6.3.1 定义索引器
因为属性有时候被叫做“智能字段”,而索引器被称为“智能数组”,因此属性和索引器共享同样的语法格式也是合理的。实际上,定义索引器与定义属性极其相似,只有两点不同:第一,索引器有索引参数值;第二,由于类本身被当作数组使用,就将关键字“this”当作索引器的名称。我们很快会看到一个更完整的示例程序,先看看下面这个索引器例子:
class myClass
{
public object this[int idx]
{
get
{
//return desired data
}
set
{
//set desired data
}
}
}
此处还没有展示一个完整的例子来说明索引器的语法格式,因为定义数据以及如何得到或设置这些数据的内部实现其实与索引器无关。要记住不管在内部如何存储数据(存储为数组、集合等等),索引器部只不过是一种途径,以便实例化类的程序员可以这样来编写代码:
myClass cls = new myClass();
cls[0] = someObject;
Console.WriteLine("{0}",cls[0]);
6.3.2 索引器示例程序
下面看看在哪些场合可以使用索引器,还是以前面用过的列表框为例。从概念上来说,列表框就是一个列表,或者说是要显示的一个字符串数组。在下面的例子中,声明了一个叫做“MyListBox”的类,它包含了一个索引器通过ArrayList对象来设置和获取字符串(ArrayList类就是一个用于存储对象集合的.NET框架类)。
示例如下所示:/Example/Chapter6/6-3-1.cs
class myListBox
{
protected ArrayList data = new ArrayList();
public object this[int idx]
{
get
{
if(idx > -1 && idx < data.Count)
{
return (data[idx]);
}
else
{
// possibly throw an exception here
return null;
}
}
set
{
if(idx > -1 && idx < data.Count)
{
data[idx] = value;
}
else if (idx == data.Count)
{
data.Add(value);
}
else
{
// possibly throw an exception here
}
}
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
myListBox lBox = new myListBox();
lBox[0] = "First";
lBox[1] = "Second";
lBox[2] = "Third";
Console.WriteLine("{0},{1},{2}",lBox[0],lBox[1],lBox[2]);
}
}
注意在这个例子中我们检查了数据索引时可能会发生的越界错误。这种技术并不一定要与索引器一起使用,我们已经说过,索引器只需做到使用类的客户使用这个被当作数组的对象时感觉一切正常即可.而与数据内部如何表示无关。不过,在学习一种新的语言属性的时候。了解这种属性的实际用法总比只看它的语法格式有用。因此,在索引器的getter和setter方法中,我们校验了存储在类的ArrayList成员中的数据传递过来的索引值。作者个人可能会选择在传递过来的索引值无法解析时抛出异常,但这也仅仅是个人习惯——各人的错误处理方式也许会不同。关键在于如果传递的是非法索引时,您需要对客户代码指出这个错误。
6.2.3 设计规则
索引器是C#设计小组提供的另一个很灵巧、也很强大的语言属性,有助于我们在开发过程中提高工作效率。但是,与任何语言的任何属性一样,索引器也要用得恰当。它们只能用在将对象当作数组会更自然的场合。让我们看一看开发票的那个例子。开发票的应用程序有一个Invoice类,它定义了一个InvoiceDetail对象的成员数组。这种情况下,用户要访问这些细节栏,采用如下的浯法会更自然一些:
InvoiceDetail detail = invoice[2];//Retrieves the ID 3 detail line
不过,如果更进一步试图将所有的InvoiceDetail成员都转换为数组以便通过索引器来访问的话,就会适得其反了。这里您可以看到,下面第一行代码比第二行代码更容易理解。
TermCode terms = invoice.Terms; //Property accessor to Terms member
TermCode terms = invoice[3]; // a soloution in search of a problem
这种情况下,您可以做某事但并不意味着您必须做某事,而要具体情况具体分析。或更具体一点地讲,要想想实现的任何新属性会如何影响使用您的类的客户代码,在您决定要实现这些属性时,要保证客户代码能够更轻松地使用您的类。
6.4 本章小结
C#属性由字段声明和存取器方法组成。属性允许对类的字段进行智能访问,这样使用类书写客户代码的程序员就无需确定是否应该、以及如何为该字段创建存取器方法了。C#中的数组声明时只需将空的方括号放置在变量类型和变量名之间即可,这一点与C++中使用的语法格式稍微有所不同。C#数组可以是一维数组、多维数组或者锯齿状数组。通过索引器的使用,C#中的对象可以被当作数组对待。索引器允许程序员可以更轻松地处理和跟踪同样类型的多个对象。
6.5 实战演练
1。实现描述超市的的类Suppermacket类,记录系统中现有商品(用指针实现),定义增加商品的函数Append,删除商品的函数Delete,查询商品的函数Query,并显示查询结果;
2。定义商品类Goods,具有商品名称Name,商品价格Price,商品数量number等属性,操作Sale(销售商品,余额不足时给予提示)、Add(商品上架操作)和ShowMe(显示商品信息)。
3.编写main函数,测试以上所要求的各种功能,即可以根据菜单命令增加、删除和查询商品,以及商品销售和商品上架的操作。
第七章 接口与抽象
本章重点:
接口的声明
接口的实现
接口继承
合并接口
抽象的定义
接口与抽象的区别
本章目的:
通过本章的学习,我们应理解接口实现的功能,接口的定义、接口的实现、接口继承以及抽象类的定义与使用,同时掌握二者之间的区别。
理解接口的关键是把接口与类放在一起比较。类是具有属性和在这些属性上操作的方法的集合,尽管类确实展示了一些行为特征(方法),然而类更是—种与行为相对的结构,而这正是为什么出现接口的原因。接口使您可以定义行为特征或能力,而且可以直接向对象应用这些行为,而无需考虑类的层次。例如,假设您有一个分布式应用程序,其中有些实体可以破串行化。这些实体包括Customer、Supplier和Invoice类。而有些其他类,例如MaintenanceView和Document,无法被定义成可串行化。怎样才能使自己选择的类成为可串行化的呢?一个显而易见的方法就是,创建一个基础类,可以给这个类起一个诸如Serializable之类的名字。然而,这种方法有—个主要的缺陷。单继承路径无法正常工作,因为我们不希望类的所有行为都被共享。C#并不支持多继承,因此无法使一个给定的类可以选择性地从多个类中派生。所以真正的答案就是接口。接口使您能够定义一组语义上相关的方法和属性,所选择的类可以实现这些方法和属性,而无需考虑类的层次关系。
从概念的角度来看,接口是两个不同代码段之间的约定。也就是说,一旦定义了一个接口,而且定义了一个实现这个接口的类,类的客户端就可以得到保证——这个类已经实现了接口中所定义的所有方法。让我们看几个例子,您很快就可以明白这一点了。
在本章中,我们将考虑接口为什么是C#以及一般的基于组件的编程中如此重要的一个部分。然后我们将看一看如何在C#应用程序中声明和实现接口。最后,我们将深入学习更多特定细节,这些细节是关于使用接口以及克服多继承和名字冲突所带来的问题的。
注意:当您定义了一个接口,并在接口的定义中指定了使用这个接口的类时,我们就称这个类“实现了该接口”或是“从接口继承”。这两种说法都可以使用,而且您将会见到这两种说法在其他文章中交替使用。从个人角度来说,相对于“从其他类继承”的说法,作者认为“实现”是一个语义上更为正确的术语——接口被定义了行为,并且类被定义成实现该行为——但是这两种术语都是正确的!
7.1 接口的应用
为了理解接口的用途所在,让我们先看一看Microsoft Windows开发中一个传统的编程问题,其中没有使用接口,但是里面有两段完全不同的代码需要以一种普通的方式进行通信。假如您为Microsoft工作,而且您是控制面板小组(Control Panel Team)的一个出色的程序员。您需要为控制面扳中所包含的所有客户端程序供一种通用的手段,利用这种手段可以在控制面板中显示这些客户端程序的图标,而且最终用户可以执行这些程序。请记住,在引入COM之前就要设计出这种功能。您怎样才能为所有未来的应用程序都提供一种通用手段,使之可以利用这种手段集成到控制面板中呢?多年以来,所考虑的解决方案已经成为Windows开发中的一个标准部分了。
作为控制面板开发小组中的一位出色的程序员,您要设计一个(或多个)需要客户端应用程序来实现的功能,并为这些功能编写文档,此外还要设计一些规则。在控制面板程序的情况中,Microsoft决定编写一个您所需要的控制面板程序,来创建一个动态链接库(DLL),该动态链接库实现并导出了一个名为CPIApplet的函数。您还需要用扩展名.CPL来扩展这个DLL的名字。并把DLL放到Windows/System32文件夹中(在Windows 98中),而在Windows 2000中,应该放在文件夹WINNT/System32中。一旦控制面板加载,就会(利用函数LoadLibrary)把System32文件夹中所有扩展名为.CPL的文件加载进来,然后使用函数GetProcAddress来加载CPIApplet函数,从而检验您是否遵循了这些规则,并且检验是否可以与控制面板进行通信。
正如前面听提别过的,如果要处理这种情况,即有一些代码需要与一些现在未知的代码以一种通用的方法进行通信,这已经成为Windows中一种标准的编程模型。然而,这并不是最好的解决方法,而且这种方法自身也有明显的缺陷。这种技术最大的缺点就是,它强迫客户端——在这个例子中是控制面板代码——中包含大量有效性确认代码。例如,控制面板不能仅仅只假设文件夹中的任何.CPL文件都是Windows DLL文件。此外,控制面板还需要证实在那个DLL中有正确的函数,并且要证实那些功能能够完成文件所指定的工作。我们就是要在这里使用接口。接口使您能够在完全不同的代码之间创建相同的约定安排,但是是以一种更为面向对象和灵活的方式来完成的。此外,因为接口是C#语句的一部分,所以编译器确保了当一个类被定义为实现一个给定接口时,这个类可以完成其应该完成的工作。
在C#中,接口是“头等”概念,其中声明了一个引用类型,这个引用类型中只包含方法声明。“头等概念”到底是什么含义?该术语的意思是,当前所讨论的这个特性是该语言中一种内置的、集成的部分。换句话说,这不是在该语言被设计出来以后才加入进来的特性。现在让我们来深入地了解一些细节——接口是什么以及如何声明接口。
注意:C++开发人员请注意;一个接口基本上就是一个抽象类,这个抽象类中除了声明C#类的其他成员类型——例如属性、事件和索引器以外,只声明了纯虚拟方法。在本章的后面将介绍抽象类以及抽象类和接口的区别。
7.2 声明接口
接口中可以包含方法、属性、索引器和事件——其中任何一种部不是在接口自身中来实现的。让我们看一个例子,这样就可以懂得如何使用这个特性。假设您在为您的公司设计一个编辑器,在该编辑器上面要处理不同的Windows控件。您正在编写编辑器和例程,这些例程被用来对用户放在编辑器表单中的控件进行有效性检查。开发小组的其他人员编写表单上应该存放的控件,您几乎一定需要提供一些表单级的有效性。在适当的时候——例如当用户显式地告诉表单来对所有的控件或者是在表单处理期间进行有效性检查——表单就会迭代其附加控件并且对每个控件进行有功性检查,或者更为适当地,通知控件对自身进行检查。
您如何才能向控件提供这种有效性确认能力?正是在这里接口有看重要的作用。下面有一个简单的接口例子,其中包含了一个简单的名为Validate的方法:
interface IValidate
{
bool Validate();
}
现在您可以证明这样的事实——如果控件实现了IValidate接口,控件就可以生效了。 让我们仔细考虑一下上述代码片断的几个方面。第一,您并不希需要在接口方法上指定一个访问限定符,例如public。事实上,预先考虑访问限定符的方法声明会导致产生一个编译时的错误。这是因为按照定义,所有的接口方法部是public类型的(C++程序员可能也注意到,因为按照定义,接口是抽象类,所以不需要显式地利用appending=0把方法声明为pure virtual)。
除了方法以外.接口还可以定义属性、索引器和事件,如下所示:
Interface IExampleInterface
{
// Example property declaration
int testProperty { get; }
// Example event declaration
event testEvent Changed;
// Example indexer declaration
string this[int index] { get; set; }
}
7.3 实现接口
因为接口定义了一个约定,任何实现一个接口的类都必须定义那个接口中的每一项条目,否则就无法编译代码。要想使用前面部分中的IValidate例子,一个客户端类只需要实现接口的方法就可以了。在下面的例子中,有一个名为FancyControl的基础类和一个名为IValidate的接口。还有一个类MyControl,这个类从FancyControl类派生出来,并实现了IValidate。请注意语法以及MyControl对象是如何被转换(cast)到IValidate接口来引用其成员的。 /Example/Chapter7/7-3-1.cs
class FancyControl
{
protected string data;
public string Data
{
get{return data;}
set{data = value;}
}
}
interface IValidate
{
bool Validate();
}
class MyControl:FancyControl,IValidate
{
public MyControl()
{
data = "my Grid Data";
}
#region IValidate 成员
public bool Validate()
{
Console.WriteLine("Validating.....{0}",data);
return true;
}
#endregion
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyControl myControl = new MyControl();
IValidate val = (IValidate)myControl;
bool success = val.Validate();
Console.WriteLine("The validation of {0} was {1} successful",myControl.Data,(true==success?"":"not"));
}
}
使用上述类和接口,编辑器可以向控件查询其是否实现了IValidate接口(在下一节中,您将会了解如何实现这一步骤)。如果控件实现了IValidate接口,编辑器可以确认并调用所实现的接口方法。那么您可能会问,“为什么不能仅仅定义一个基础类来使用这个编辑器(这个基础类具有名为Validate的纯虚拟函数?)编辑器就会只接受从该基础类派生出来的控件,不是吗?”
这个方案也是可以工常工作的,但是它有很强的局限性。让我们假设您创建了自己的控件,并且这些控件都是从这个假想的基础类派生的。从而,这些控件都实现了这个Validate虚方法。这种方案一直都可以正常工作,直到有一天您发现有一个很强大的控件,您希望可以用来使用编辑器。假设您发现一个由其他人编写的网格(grid),这样的话,该网格就无法从编辑器的强制控件基础类派生了。在C++中,解决的方法是使用多继承,并且让您的网格同时继承第三方网格和编辑器的基础类。然而,C#并不支持多继承。
使用接口,您可以在单个类中实现多种行为特征。在C#中,您可以从单个类中派生,并且,除了拥有那些通过继承得到的功能以外,还可以按照类的需要来实现任意多的接口。例如,如果您要编辑器应用程序对控件的内容进行有效性检查。把控件绑定到数据库上、以及把控件的内容串行地输出到磁盘上,您就应该按照如下形式声明自己的类:
public class MyGrid : ThridPartyGrid,IValidate,ISerializable,IDataBound
{
……
}
正如前面所说的,下一节将回答这样的问题。“一段代码如何才能知道一个类何时实现一个给定的接口?”
7.3.1 使用is来查询实现
在/Example/Chapter7/7-3-1例子中,您己经看到了下面的代码,这些代码被用来将一个对象(MyControl)转换到这个类的一个已实现的接口(IValidate),然后调用这些接口成员的其中一个(Validate):
MyControl myControl = new MyControl();
IValidate val = (IValidate)myControl;
bool success = val.Validate();
如果一个客户端试图使用一个类,仿佛这个类已经实现了一个方法,但是实际上这个类并没有实现这个方法.这时会发生什么?下面的例子将可以编译,因为ISerializable是一个有效的接口。虽然如此,在运行的时候还是会抛出一个System。InvalidCaseException异常,因为MyGdd并没有实现接口ISerializable。这个应用程序然后就会终止,除非这个异常可以被显式地截获。
class FancyControl
{
protected string data;
public string Data
{
get{return data;}
set{data = value;}
}
}
interface ISerializable
{
bool Save();
}
interface IValidate
{
bool Validate();
}
class MyControl:FancyControl,IValidate,ISerializable
{
public MyControl()
{
data = "my Grid Data";
}
#region IValidate 成员
public bool Validate()
{
Console.WriteLine("Validating.....{0}",data);
return true;
}
#endregion
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyControl myControl = new MyControl();
ISerializable val = (ISerializable)myControl;
bool success = val.Save();
Console.WriteLine("The Saving of {0} was {1} successful",myControl.Data,(true==success?"":"not"));
}
}
当然,截获这个异常并不能改变这样的事实,即您所希望执行的代码在这种情况下实际上无法执行,您所需要的是一种在试图转换对象之前先向其进行查询的方法。一种做法是,使用is操作符。is操作符使您能够在运行时检查一种类型是否与另外一种类型相兼容。这个操作符采用以下格式,其中expression是一种引用类型:
expression is type
这个操作符产生一个布尔型的值,并且也因此可以在条件语句中使用。在下面的例子中,已经修改了代码,以便于在试图使用方法Serializable之前先检验类MyControl和接口ISerializable之间的兼容性: /Example/Chapter7/7-3-2.cs
class FancyControl
{
protected string data;
public string Data
{
get{return data;}
set{data = value;}
}
}
interface ISerializable
{
bool Save();
}
interface IValidate
{
bool Validate();
}
class MyControl:FancyControl,IValidate
{
public MyControl()
{
data = "my Grid Data";
}
#region IValidate 成员
public bool Validate()
{
Console.WriteLine("Validating.....{0}",data);
return true;
}
#endregion
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyControl myControl = new MyControl();
if (myControl is ISerializable)
{
ISerializable val = (ISerializable)myControl;
bool success = val.Save();
Console.WriteLine("The Saving of {0} was {1} successful",myControl.Data,(true==success?"":"not"));
}
else
{
Console.WriteLine("The ISerializable interface is not implemented");
}
}
}
既然您已经看到is操作符是如何使您能够检验两种类型之间的兼容性,以确保用法正确的,那么让我们再来看一看门is操作符的“近亲”——as操作符——并且将这两个操作符加以比较。
7.3.2 使用as来查询实现
使用as操作符可以便检验处理更加有效率。as操作符在可兼容的类型之间进行转换,并且采用如下格式,其中那个expression是任何一种引用类型:
Object = expression as type
如果所讨论的两种类型是相兼容的话,您可以把as看作是is操作符和转换的组合。as操作符和is操作符之间的一个重要不同就是,如果expression和type之间不兼容,as操作符把object设定为等于null。而不是返回一个布尔值,现在我们可以以一种更有效率的方式来重新编写这个例子的主程序:/Example/Chapter7/7-3-3.cs
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyControl myControl = new MyControl();
ISerializable val = myControl as ISerializable;
if (val!=null)
{
bool success = val.Save();
Console.WriteLine("The Saving of {0} was {1} successful",myControl.Data,(true==success?"":"not"));
}
else
{
Console.WriteLine("The ISerializable interface is not implemented");
}
}
}
可以看到,同样可以实现转换操作,通常情况下,as操作符的执行效率优于is操作符。
7.4 显示的接口成员名字限定
到目前为止,您已经见到,类在实现接口时指定一个访问限定符public,紧接着后面的是接口方法名字。然而,有时候您希望(甚至是需要)明确地用接口的名字来限定成员的名字。在这—节里,我们将考虑这么做的两个理由。
7.4.1 接口的名字隐藏
调用一个从接口实现的方法的最常见做法是把那个类的一个实例转换为该接口类型,然后调用所需要的方法。尽管这种做法是有效的,而且许多人都使用这种技术,但是从技术角度来说,您并不一定要把对象转换到它所实现的接口上来调用这个接口的方法。这是因为当一个类实现了一个接口的方法,这些方法同时也就是该类的public方法。
如下例所示:
interface IDataBound
{
void Bind();
}
public class EditBox:IDataBound
{
public void Bind()
{
Console.WriteLine("Binding to data store....");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
EditBox edit = new EditBox();
Console.WriteLine("Calling EditBox.Bind()...");
edit.Bind();
IDataBound bound = (IDataBound)edit;
Console.WriteLine("Calling (IDataBound)EditBox.Bind()...");
bound.Bind();
Console.Read();
}
}
程序执行结果如下:
Calling EditBox.Bind()...
Binding to data store....
Calling (IDataBound)EditBox.Bind()...
Binding to data store....
请注意,虽然这个应用程序以两种不同的方式调用了已经实现的Bind方法。一种是利用了转换,而另一种则没有使用转换——这两种方式都正确地调用了函数.因为Bind方法被处理了。虽然乍一看,能够直接调用所实现的方法而无需把对象转换到一个接口上,这种功能似乎是种优点,但是有时候这并不是我们所需要的,最为一种明显的原因是,几个不同接口的实现——每一个接口都可能含有大量的成员——可能很快就会使您的类的public名称空间遭到那些在实现类的作用域以外没有意义的成员的污染。您可以防止接口中被实现的成员成为该类的public成员,这要用到被称为名字隐藏(name hiding)的技术。
最为简单的名字隐藏就是,能够对那些除被派生类或实现类以外的任何代码(这些代码通常破称为外合世界(outside world),隐藏其所继承的成员名字。让我们假设有一个与我们前面所使用的例子相同的例子,其中一个EditBox类需要实现接口IDataBound——然而,这次EditBox类并不想要把IDataBound方法暴露给外部世界。它只是为了实现自己的目的才需要使用该接口,或者仅仅可能是程序员不希望那些一般客户端不会用到的大量方法把类的名称空间搞混乱。为了隐藏一个被实现接口的成员,您只需要把成员的public访问限定符去掉,并且用接口的名字来限制成员的名字,如下面所示:
interface IDataBound
{
void Bind();
}
public class EditBox:IDataBound
{
void IDataBound.Bind()
{
Console.WriteLine("Binding to data store....");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
EditBox edit = new EditBox();
Console.WriteLine("Calling EditBox.Bind()...");
//error:the Bind method no longer exists in the EditBox class’s namespace
edit.Bind();
IDataBound bound = (IDataBound)edit;
Console.WriteLine("Calling (IDataBound)EditBox.Bind()...");
bound.Bind();
Console.Read();
}
}
上述代码将无法通过编译,因为成员名字Bind下再是EditBox类的一部分。因此,这种技术使您能够把成员从类的名称空间中去掉,同时仍然允许使用转换操作来进行显式的访问。
在这里需要重申的一点是,当您隐藏一个成员时,您不能使用访问限定符,如果您试图在一个被实现的接口成员上使用访问限定符,就会产生一个编译错误。可能您发现这有点奇怪,但是考虑到隐藏的原因就是为了防止对当前类的外部可见,也就不以为怪了。既然访问限定符的存在只是为了定义对于基础类外部的可见级别,您就可以明白在使用名字隐藏时,使用访问限定符是没有意义的了。
7.4.2 避免名字模糊性
C#不支持多继承的一个主要原因就在于名字冲突的问题,这是由名字模糊性造成的。虽然C#并不在(从一个类的派生)对象一级支持多继承,但是它支持从一个类的继承。此外还支持多接口的实现。然而,这样的功能会带来一些代价:名字冲突。
在下面的例子中,我们有两个接口,ISerializable和IdataStore,这两个接口支持以两种不同格式进行读取和存储,一种是以二进制格式从对象到磁盘进行读取和存储,另一种是向一个数据库读取和存储。问题是这两个接口都含有名为SaveData的方法:
interface ISerializable
{
void SaveData();
}
interface IDataStore
{
void SaveData();
}
class Test : ISerializable,IDataStore
{
public void SaveData()
{
Console.WriteLine("Test.SaveData called");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Test test = new Test();
Console.WriteLine("Calling Test.SaveData");
test.SaveData();
}
}
现在写出的这些代码确实可以通过编译。但是,我们已经得到通知,在以后所构造出来的C#编译器中,这样的代码会导致一个编译时的错误产生,因为所实现的方法SaveData是不明确的。无论这些代码是否可以编译,在运行时都会有问题产生,因为调用SaveData方法的行为结果对于使用这个类的程序员来说是不清楚的——您用的SaveData方法究竟是要串行化地把数据从对象保存到磁盘.还是要把数据保存到数据库呢?
此外,请看下面的代码:
interface ISerializable
{
void SaveData();
}
interface IDataStore
{
void SaveData();
}
class Test : ISerializable,IDataStore
{
public void SaveData()
{
Console.WriteLine("Test.SaveData called");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Test test = new Test();
if (test is ISerializable)
{
Console.WriteLine("ISerializable is implemented");
}
if (test is IDataStore)
{
Console.WriteLine("IDataStore is implemented");
}
}
}
在这段代码中,is操作符对于这两个接口都是成功的,这就表示这两个接口都被实现了,但是我们知道事实并非如此。甚至连编译器在编译这个例子的代码时也给出了下面的警告:
给定表达式始终为所提供的(“ConsoleApplication1.IDataStore”)类型
给定表达式始终为所提供的(“ConsoleApplication1.ISerializable”)类型
问题在于这个类所实现的要么是Bind方法的串行化版本,要么是数据库版本(而不是同时实现两个版本)。然而,如果客户端检查其中一个接口的实现——两个接口的实现都是成功的——而同时又碰巧试图使用那个并没有被真正实现的那个接口,这时就会发生意外的结果。
您可以使用显式的成员名字限定,这样就可以解决这个问题:去掉访问限定符,并且预先在考虑使用成员名的时候——在这个例子里的成员名是SaveData——要附带上接口名字。/Example/Chapter7/7-4-1.cs
interface ISerializable
{
void SaveData();
}
interface IDataStore
{
void SaveData();
}
class Test : ISerializable,IDataStore
{
void ISerializable.SaveData()
{
Console.WriteLine("Test.ISerializable.SaveData called");
}
void IDataStore.SaveData()
{
Console.WriteLine("Test.IDataStore.SaveData called");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
Test test = new Test();
if (test is ISerializable)
{
Console.WriteLine("ISerializable is implemented");
((ISerializable)test).SaveData();
}
if (test is IDataStore)
{
Console.WriteLine("IDataStore is implemented");
((IDataStore)test).SaveData();
}
}
}
现在这里没有关于哪个方法被调用的不明确问题了。这两个方法都用完整的限定名实现了,而且程序输出结果也正是您所预期的。
ISerializable is implemented
Test.ISerializable.SaveData called
IDataStore is implemented
Test.IDataStore.SaveData called
7.5 接口和继承
有两个常见问题与接口和继承有关。第一个问题涉及的是从一个基础类派生的事项,这个类中含有的方法名与该类所需要实现的接口方法名完全相同,我们用一个例子来演示这一点:
class Control
{
public void Serializable()
{
Console.WriteLine("Control.Serializable called");
}
}
interface IDataBound
{
void Serializable();
}
class EditBox : Control,IDataBound
{
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
EditBox edit = new EditBox();
edit.Serializable();
}
}
正如您已经知道的,要想实现一个接口,您必须在该接口的声明中为每个成员提供定义。然而,在上述例子中,我们并没有这么做,而这段代码居然也通过编译了!能够通过编译的原因在于C#要在类EditBox中查找一个已经实现的Serializable方法,而且也找到了。然而,编译器所作出的判断是不正确的,它所找到的并不是一个已经实现的方法。编译器所找到的Serializable方法是从类Control类继承来的Serializable方法。并不是所需要实现的IDataBound. Serializable方法。因此,虽然代码通过了编泽,但是并不能按照预期的那样工作,正如我们接下来将看到的。
现在我们更进一步看一下该问题。请注意,下面的代码首先通过as操作符来检查接口是否被实现,然后试着调用被实现Serializable的方法。代码可以通过编译并运行。然而,正如我们所知道的,类EditBox并没有真正地实现一个Serializable方法作为IDataBound继承的结果。EditBox已经具有一个从Control类继承的Serializable方法。这就意味着客户端可能会得到一些意外的结果。
class Control
{
public void Serializable()
{
Console.WriteLine("Control.Serializable called");
}
}
interface IDataBound
{
void Serializable();
}
class EditBox : Control,IDataBound
{
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
EditBox edit = new EditBox();
IDataBound bound = edit as IDataBound;
if(bound!=null)
{
Console.WriteLine("IDataBound is supported...");
bound.Serializable();
}
else
{
Console.WriteLine("IDataBound is not supported...");
}
}
}
当某个被派生的类中有一个方法,这个方法的名字与基础类实现的一个接口方法名字相同时,可能会发生另外一个问题。让我们看一看下面的代码:
interface ITest
{
void Foo();
}
class Base : ITest
{
public void Foo()
{
Console.WriteLine("Base.Foo(ITest implementation)");
}
}
class MyDerived : Base
{
public new void Foo()
{
Console.WriteLine("MyDerived.Foo");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyDerived myDerived = new MyDerived();
myDerived.Foo();
ITest test = (ITest)myDerived;
test.Foo();
}
}
程序执行结果如下:
MyDerived.Foo
Base.Foo(ITest implementation)
在这个例子中,Base类实现了ITest接口和这个接口的Foo方法。然而,MyDerived类从Base类派生出一个新类,并且也为新的类实现了一个新的Foo方法。那么哪一个Foo方法才会被调用呢?这取决于您有哪一个引用,如果您有一个指向MyDerived对象的引用,那么就会调用MyDerived对象的Foo方法,这是因为即使MyDerived对象实现了一个继承来的ITest.Foo方法,运行时也会执行MyDerived.Foo方法,因为关键字new指定了一个被继承方法的覆盖。
然而,当您显式地把对象myDerived转换到接口ITest上时,编译器要判断接口是否已经实现。MyDerived类有一个同名方法,但是这个同名方法并不是编译器所要找的那个方法。当您把一个对象转换到一个接口时,编译器遍历继承树,直到找到一个类,这个类在其基本列中含有该接口。这就是为什么Main方法的最后两行代码会调用ITest所实现的Foo方法的原因。
现在,这些潜在问题中的一些,包括名字冲突和接口继承,已经支持了以下建议的正确性:永远都要把对象转换到您所要使用的成员变量所在的那个接口。
7.6 合并接口
C#中的另一个强大的功能就是,能够把两个或以上的接口合并到一起,这样一个类只要实现合并后的接口就可以了。例如,让我们假设您想创建一个新的TreeView类,这个类既要实现IDragDrop接口,也要实现ISrotable接口。我们可以假设其他控件,例如ListView和ListBox,可能也想把这些特征合并起来。因为这样的假想是合理的,所以您可能希望把IDragDrop接口和ISortable接口合并成一个接口。
public class Control
{
}
public interface IDragDrop
{
void Drag();
void Drop();
}
public interface ISerializable
{
void Serialize();
}
public interface ICombo : ISerializable,IDragDrop
{
}
public class MyTreeView : Control,ICombo
{
public void Serialize()
{
Console.WriteLine("MyTreeView.Serialize called");
}
public void Drag()
{
Console.WriteLine("MyTreeView.Drag called");
}
public void Drop()
{
Console.WriteLine("MyTreeView.Drop called");
}
}
class TestApp
{
[STAThread]
static void Main(string[] args)
{
MyTreeView tree = new MyTreeView();
tree.Drag();
tree.Drop();
tree.Serialize();
}
}
利用合并接口的功能,您就不仅可以简化把语义上相关的接口聚集在一个接口中的能力,还可以在需要的时候,向新的“复合”接口添加方法。
7.7 抽象的定义及使用
抽象类声明了一个或多个没有实现的方法。如果把一个方法声明为抽象的,也要把类声明为抽象的。抽象类不能进行实例化,实现了抽象类中抽象方法的类才能进行实例化。抽象类不能继承。
有时候我们需要表达一种抽象的东西,它是一些东西的概括,但我们又不能真正的看到它成为一个实体在我们眼前出现,为此面向对象的编程语言便有了抽象类的概念。在实现抽象方法是需要采用“override”关键字来实现方法覆盖。
Class abstract A //定义一个含有抽象方法的抽象类
{
Public abstract void Say();
Public void PrintString(string a);
{
System.Console.WriteLine(“You input string is {0}”,a);
}
}
Class B:A
{
Public override Say()
{
System.Console.WriteLine(“实现了抽象类的抽象方法。”);
}
}
Static void main()
{
B b = new B(); //实现了抽象方法的类B才可以使用
b.Say(); //已经实现的抽象方法
b.PrintString(“test”); //抽象类A提供的方法
}
7.8 接口与抽象类的比较
创建一个接口就是创建了一个或多个方法的定义,在每个实现该接口的类中必须实现这些方法。系统不会生成任何默认的方法代码,必须自己完成实现过程。接口的优点是它提供了一种让一个类成为两个类的子类的方式:一个是继承,一个来自于接口,如果实现该接口的类漏掉了一个接口方法,编译器会产生错误。
创建一个抽象类就是创建了这样一个基类,它可能有一个或多个完整的、可以工作的方法,但至少有一个方法未实现并声明为抽象的。不能实例化一个抽象类,而必须从它派生出类,这些类包含了抽象方法的实现过程。如果一个抽象类的所有方法在基类中都未实现,它在本质上就等同于一个接口,但限制条件是,一个类不能从它继承,也不能继承其他类层次结构,而使用接口则可以这样做。抽象类的作用是对派生类如何工作提供一个基类定义,允许程序员在不同的派生类中填充这些实现过程。
7.9 本章小结
C#中的接口允许开发出这样的一些类,这些类之间可以共享特性,但是这些类并不是同一个类层次关系中的一部分。接口在C#的开发中起着特殊的作用。因为C#真并不支持多继承。为了共享语义上相关的方法和属性,类可以实现多个接口。同样,is操作符和as操作符可以被用来判断一个特定的接口是否已经被对象所实现了,这可以有助于防止发生与使用接口成员有关的错误;最后,显式的成员命名和名字隐藏可以用来控制接口的实现.并可以防止错误的发生。最后介绍了抽象类的定义以及抽象类和接口的比较。其实接口是抽象类的一个特例。
7.1实战演练
关于宠物进笼
Anne的宠物小屋有12个笼子,每个笼子可以放不同的动物,但只能放1只或0只,包括
猫Cat,狗Dog,蛇Snake.
1。实现一个简单的管理系统,可增加、删除笼子中的宠物,查询每个笼子中存放的
宠物类型,(包括笼子为空的情况),统计笼中宠物的种类和数量.
2。定义描述宠物小屋的类shelves,其中有12笼子用于存放各种宠物.
3。定义虚拟基类Animal,包括纯虚函数ShowMe,显示每个宠物的情况,包括类型、颜色、
体重和喜爱的食物。
4。定义派生类Cat,Dog,Snake,具体实现上述纯虚函数。
5。重载输入‘>>'*作符,使得可能通过Console.read()方法直接读入宠物颜色、体重和喜爱的
食物。
6。编写具有main函数的类,测试上述要求和各种功能。
第八章 代表和事件处理器
本章重点:
代表的作用
代表的定义及使用
通过代表定义事件
本章目的:
通过本章的学习,我们应掌握代表的定义及使用,通过代表实现事件响应、实现方法回调等功能。
C#中另外一种很有用的创新就是代表,代表的目的基本上与C++中的函数指针相同。但是,代表是一种具有类型保护和安全机制的对象。这意味着在运行时刻能保证一个代表总是指向一个有效的方法,这又进一步表明,我们可以得到函数指针的所有优点,却不会带来与之相关的危险,例如一个无效的地址或是一个代表破坏其他对象的存储空间。在这一章里,我们将会仔细探讨代表,它们是如何与接口相比较的,定义代表的语法,以及它们被设计用来处理的不同问题。我们还会看几个使用代表的例子,这些例子中都有回调方法和异步事件处理.
在第7章中,我们已经看到,在C#中是如问定义接口和实现的,从概念的角度来说,接口只是两段完全不同代码部分之间的简单约定。然而,接口更像是类,因为它们是在编译时被定义的,而且可以包含方法、属性、索引器以及事件。换句话说,在C#编程中,代表有两个主要的用处:回调和事件处理。下面,我们将从对回调方法的讨论作为开始。
8.1 将代表用作回调方法
回凋方法广泛应用于Microsoft Windows编程,当需要把一个函数指针传递给另一个接下来进行回调的函数(通过被传递过来的指针)时,就要用到回调方法。这里有一个Win32 API函数EnumWindows的例子。该函数列举所有屏幕上的顶层窗口,调用为每个窗口提供的函数。回调可以用于许多目的,下面几个目的是最为常见的。
1. 异步调用
当被调用的代码将占用大量时间来处理请求时,就要在异步处理中用到回调方法了。一种典型的工作方式是这样:客户端代码调用一个方法,把一个回调方法传递给它。被调用的方法启动一个线程,并且立即返回。然后,线程完成大部分工作,调用所需的回调函数。这样做的好处很明显:可以允许客户端继续处理,无需在一个可能的长同步调用上被阻塞。
2. 把自定义代码加入类的代码路径中
回调方法的另一个常见用法就是,一个类允许客户端指定一个方法,这个方法将被调用,来进行自定义处理。让找们用一个Windows中的例子来对此加以说明。通过在Windows使用类Listbox,可以指定各项按照升序或降序排列。除了一些其他的排序选择以外,类Listbox并不能给出任何真正的范围并保持一个一般类。因此,类Listbox也可以使您能够指定一个回调函数,用来排序。这样的话,当Listbox对各项进行排序时,它调用回调函数,这样就可以用自己的代码来进行自定义的排序。
现在让我们来看一个定义和使用代表的例子。在这个例子中,我们有一个数据库管理器的类,对所有数据库上的活跃连接保持追踪,并提供一个方法来列举这些连接。假设数据库管理器位于一个远程服务器上,这样也许是一个良好的设汁决策,可以使得方法异步,并且允许客户端提供一个回调方法,注意,对于一个实际的应用程序来说,我们将其创建为典型的多线程应用程序,使其成为真正的异步方式。但是,为了使例子保持简单,而且也因为目前我们还没有介绍到多线程——我们先省略多线程。
首先,让我们来定义两个主要的类:DBManager和DBConnection
class DBConnection
{
}
class DBManager
{
static DBConnection[] activeConnections;
public delegate void EnumConnectionsCallback(DBConnection connection);
public static void EnumConnections(EnumConnectionsCallback callback)
{
foreach(DBConnection connection in activeConnections)
{
callback(connection);
}
}
}
方法EnumConnectionsCallback就是代表,并且是通过把关键字代表放在方法名前面来定义的,我们可以发现,这个代表被定义为返回void,并且只有一个参数值:一个DBConnection对象。然后,EnumConnections方法被定义为把一个EnumConnectionsCallback方法作为其惟一的参数值,为了调用DBManager.EnumConnections方法,我们需要给它传递一个实例化的DBManager.EnumConnectionsCallback代表。
为了这样做,需要用new来产生一个代表,把具有相同代表符号的方法名字传递给它。下面是一个这样的例子:
DBManager.EnumConnectionsCallback myCallback = new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback);
DBManager.EnumConnections(myCallback);
还要注意,可以把上面的语句在单个调用语句中联合使用,就像这样:
DBManager.EnumConnections(new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback));
这里是所有的代表的基本语法.现在让我们来看一看完整的应用程序例子。
/Example/Chapter8/8-1-1.cs
class DBConnection
{
protected string Name;
public DBConnection(string name)
{
this.Name = name;
}
public string name
{
get {return Name;}
set {Name = value;}
}
}
class DBManager
{
static DBConnection[] activeConnections;
public delegate void EnumConnectionsCallback(DBConnection connection);
public void AddConnections()
{
activeConnections = new DBConnection[5];
for(int i=0;i<5;i++)
{
activeConnections = new DBConnection("DBConnections"+(i+1));
}
}
public static void EnumConnections(EnumConnectionsCallback callback)
{
foreach(DBConnection connection in activeConnections)
{
callback(connection);
}
}
}
class TestApp
{
[STAThread]
public static void Main(string[] args)
{
DBManager dbMgr = new DBManager();
dbMgr.AddConnections();
DBManager.EnumConnectionsCallback myCallback = new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback);
DBManager.EnumConnections(myCallback);
Console.Read();
}
public static void ActiveConnectionsCallback(DBConnection connection)
{
Console.WriteLine("Callback method called for "+connection.name);
}
}
程序执行结果如下:
Callback method called for DBConnections1
Callback method called for DBConnections2
Callback method called for DBConnections3
Callback method called for DBConnections4
Callback method called for DBConnections5
8.2 把代表定义为静态成员
因为每次要用到代表时,客户端都不得不实例化一个代表,这显得太繁琐。C#允许把用于创建代表的方法定义为类的静态成员。下面的这个例子是上一节中用到过的,但是被修改成使用现在这种格式。注意,代表现在被定义为名为myCallback类的静态成员。还要注意,这个成员可以在main方法中使用,无需客户端对代表进行实例化。
/Example/Chapter8/8-2-1.cs
class DBConnection
{
protected string Name;
public DBConnection(string name)
{
this.Name = name;
}
public string name
{
get {return Name;}
set {Name = value;}
}
}
class DBManager
{
static DBConnection[] activeConnections;
public delegate void EnumConnectionsCallback(DBConnection connection);
public void AddConnections()
{
activeConnections = new DBConnection[5];
for(int i=0;i<5;i++)
{
activeConnections = new DBConnection("DBConnections"+(i+1));
}
}
public static void EnumConnections(EnumConnectionsCallback callback)
{
foreach(DBConnection connection in activeConnections)
{
callback(connection);
}
}
}
class TestApp
{
public static DBManager.EnumConnectionsCallback myCallback = new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback);
[STAThread]
public static void Main(string[] args)
{
DBManager dbMgr = new DBManager();
dbMgr.AddConnections();
DBManager.EnumConnections(myCallback);
Console.Read();
}
public static void ActiveConnectionsCallback(DBConnection connection)
{
Console.WriteLine("Callback method called for "+connection.name);
}
}
注意:因为代表的标准命名习惯是把以代表为参数的方法加上扩展词callback,这样就容易在使用代表时,误把方法的名字当作代表的名字。在这种情况下,就会在编译时产生误导性的错误,告诉您刚才指定了一个方法,但是缺少这个方法的类。如果产生这样的错误,要记住实际上的问题是,在程序中指定的是一个方法,而不是代表。
8.3 仅在需要时创建代表
到目前为止,无论用到与否,在我们所见到的两个例子中都创建了代表。在这两个例子中没有什么问题,因为它总是会被调用的。但是,当定义您自己的代表时,很重要的一点就是要考虑何时才创建代表。让我们考虑一下,例如,创建一个特定的代表是需要耗费时间的,并且这样做并非毫无代价。在有些情况下,我们知道客户端通常并不会调用一个给定的回调方法,这样就可以推迟代表的创建,直到实际需要在属性中包装(wrap)其实例的时候,为了演示怎样做,下面对DBManager类进行修改,使用一个只读属性——因为里面只有一个方法getter——来实例化代表。直到这个属性被引用时才创建该代表。
/Example/Chapter8/8-3-1.cs
class DBConnection
{
protected string Name;
public DBConnection(string name)
{
this.Name = name;
}
public string name
{
get {return Name;}
set {Name = value;}
}
}
class DBManager
{
static DBConnection[] activeConnections;
public delegate void EnumConnectionsCallback(DBConnection connection);
public void AddConnections()
{
activeConnections = new DBConnection[5];
for(int i=0;i<5;i++)
{
activeConnections = new DBConnection("DBConnections"+(i+1));
}
}
public static void EnumConnections(EnumConnectionsCallback callback)
{
foreach(DBConnection connection in activeConnections)
{
callback(connection);
}
}
}
class TestApp
{
[STAThread]
public static void Main(string[] args)
{
TestApp app = new TestApp();
DBManager dbMgr = new DBManager();
dbMgr.AddConnections();
DBManager.EnumConnections(app.myCallback);
Console.Read();
}
public DBManager.EnumConnectionsCallback myCallback
{
get
{
return new DBManager.EnumConnectionsCallback(ActiveConnectionsCallback);
}
}
public static void ActiveConnectionsCallback(DBConnection connection)
{
Console.WriteLine("Callback method called for "+connection.name);
}
}
8.4 代表构成
构成代表的能力——从多个代表创建出单个代表——在一开始的时候并不能让人感到方便,但是如果您需要的话,那么一定会感到高兴。C#设计组已经考虑到了这一点。让我们来看几个例子,在这些例子中,代表构成是很有用的。在第一个例子中.有一个分布式系统,并且有一个类迭代给定地点的每一个部打,为每一个具有“。卜hMd”值小于50的部分凋用一个回调方法。在一个更为现实的分布式例子中,公式中不仅要考虑到“on土Md”值,还要考虑到与订货至交货时间有关的“甽order”和“讥咖s旷值,还要减去安全库存级别等等。但是,我们先尽量将随问题简单化:如果某个部分的on个Md值小于50,就会发生一个异常。
问题在于如果某个给定部分库存不够,那么我们要调用两个小同的方法:我们要记录
事件,然后辽要向采购主管发电子邮件。因此,让我们来看一下怎样程序化地从多个代表
创建出单个复合代表:
/Example/Chapter8/8-4-1.cs
class Part
{
protected string Sku;
protected int OnHand;
public Part(string sku)
{
this.Sku = sku;
Random r = new Random(DateTime.Now.Millisecond);
double d = r.NextDouble() * 100;
this.OnHand = (int)d;
}
public string sku
{
get { return this.Sku; }
set { this.Sku = value; }
}
public int onhand
{
get { return this.OnHand; }
set { this.OnHand = value; }
}
}
class InventoryManager
{
protected const int MinOnHand = 50;
public Part[] parts;
public InventoryManager()
{
parts = new Part[5];
for(int i=0;i<5;i++)
{
Part part = new Part("Part "+(i+1));
Thread.Sleep(10); //Randomizer is seeded by time
parts = part;
Console.WriteLine("Adding part '{0}' on-hand = {1}",part.sku,part.onhand);
}
}
public delegate void OutOfStockExceptionMethod(Part part);
public void ProcessInventory(OutOfStockExceptionMethod exception)
{
Console.WriteLine("Processing inventory...");
foreach(Part part in parts)
{
if(part.onhand < MinOnHand)
{
Console.WriteLine("{0} {1} is below minimum on-hand {2}",part.sku,part.onhand,MinOnHand);
exception(part);
}
}
}
}
class CompositeDelegateApp1
{
public static void LogEvent(Part part)
{
Console.WriteLine("\tlogging event...");
}
public static void EmailPurchasingMgr(Part part)
{
Console.WriteLine("\temailing purchasing manager...");
}
[STAThread]
public static void Main(string[] args)
{
InventoryManager mgr = new InventoryManager();
InventoryManager.OutOfStockExceptionMethod LogEventCallBack = new InventoryManager.OutOfStockExceptionMethod(LogEvent);
InventoryManager.OutOfStockExceptionMethod EmailPurchasingMgrCallBack = new InventoryManager.OutOfStockExceptionMethod(EmailPurchasingMgr);
InventoryManager.OutOfStockExceptionMethod OnHandExceptionEventsCallBack = LogEventCallBack + EmailPurchasingMgrCallBack;
mgr.ProcessInventory(OnHandExceptionEventsCallBack);
}
}
程序执行结果如下:
Adding part 'Part 1' on-hand = 34
Adding part 'Part 2' on-hand = 24
Adding part 'Part 3' on-hand = 46
Adding part 'Part 4' on-hand = 69
Adding part 'Part 5' on-hand = 91
Processing inventory...
Part 1 34 is below minimum on-hand 50
logging event...
emailing purchasing manager...
Part 2 24 is below minimum on-hand 50
logging event...
emailing purchasing manager...
Part 3 46 is below minimum on-hand 50
logging event...
emailing purchasing manager...
因而,使用语言的这种功能,我们可以动态地分辨出哪些方法中包含了回凋方法。把这些方法集中到单个代表中,并且把这个复合代表当作单个代表一样地传递。运行环境将自动确保所有的方法都按顺序调用。此外,可以使用减号操作符把所需的代表从复合代表中移去。
然而,这些方法按照顺序方式被调用的事实回避了一个重要的问题:为什么不能通过让每个方法连续不断地调用下一个方法,来简单地把这些方法链接到一起呢?在这一节的例子中,我们就可以这样做。该例子中只有两个方法,而且这两个方法总是成对调用的。但是我们还是把这个例子稍微复杂化些。假设我们有几个商店位置,每个位置都指示了应该调用的方法。例如,Location1可能是仓库,因此我们要记录事件,并向采购主管发送电子邮件。而所有其他地方低于最小OnHand值的部分会导致事件被记录下来,并且商店的主管会接收到电子邮件。
根据被处理对象的位置,通过动态地创建复合代表,我们可以很轻易地满足这些需求。如果没有这些代表的话,我们就不得不写一个方法,不仅要判断应该调用哪个方法,还要在调用过程中进行追踪,看哪个方法已经被调用过了,哪个方法还未被调用。正如您在下面的代码中所见到的一样,代表把原本可能非常复杂的操作简化了。
/Example/Chapter8/8-4-2.cs
class Part
{
protected string Sku;
protected int OnHand;
public Part(string sku)
{
this.Sku = sku;
Random r = new Random(DateTime.Now.Millisecond);
double d = r.NextDouble() * 100;
this.OnHand = (int)d;
}
public string sku
{
get { return this.Sku; }
set { this.Sku = value; }
}
public int onhand
{
get { return this.OnHand; }
set { this.OnHand = value; }
}
}
class InventoryManager
{
protected const int MinOnHand = 50;
public Part[] parts;
public InventoryManager()
{
parts = new Part[5];
for(int i=0;i<5;i++)
{
Part part = new Part("Part "+(i+1));
Thread.Sleep(10); //Randomizer is seeded by time
parts = part;
Console.WriteLine("Adding part '{0}' on-hand = {1}",part.sku,part.onhand);
}
}
public delegate void OutOfStockExceptionMethod(Part part);
public void ProcessInventory(OutOfStockExceptionMethod exception)
{
Console.WriteLine("Processing inventory...");
foreach(Part part in parts)
{
if(part.onhand < MinOnHand)
{
Console.WriteLine("{0} {1} is below minimum on-hand {2}",part.sku,part.onhand,MinOnHand);
exception(part);
}
}
}
}
class CompositeDelegateApp2
{
public static void LogEvent(Part part)
{
Console.WriteLine("\tlogging event...");
}
public static void EmailPurchasingMgr(Part part)
{
Console.WriteLine("\temailing purchasing manager...");
}
public static void EmailStoreMgr(Part part)
{
Console.WriteLine("\temailing store manager...");
}
[STAThread]
public static void Main(string[] args)
{
InventoryManager mgr = new InventoryManager();
InventoryManager.OutOfStockExceptionMethod[] exceptionMethods = new InventoryManager.OutOfStockExceptionMethod[3];
exceptionMethods[0] = new InventoryManager.OutOfStockExceptionMethod(LogEvent);
exceptionMethods[1] = new InventoryManager.OutOfStockExceptionMethod(EmailPurchasingMgr);
exceptionMethods[2] = new InventoryManager.OutOfStockExceptionMethod(EmailStoreMgr);
int Location = 1;
InventoryManager.OutOfStockExceptionMethod compositeDelegate;
if (Location == 2)
{
compositeDelegate = exceptionMethods[0] + exceptionMethods[1];
}
else
{
compositeDelegate = exceptionMethods[0] + exceptionMethods[2];
}
mgr.ProcessInventory(compositeDelegate);
}
}
编译并执行这个程序,根据对变量Location赋值的不同,会得到不同的结果。
8.5 定义具有代表的事件
几乎所有的Windows应用程序都需要处理一些异步事件。这类事件中,有些事件是通用的。例如,当用户以某种方式与应用程序进行交互时,Windows向应用程序消息队列发送消息。还有一些属于更加特殊的问题域,例如为一个更新过的订单打印发票。
C#中的事件遵循“发布——预订”的设计模式。在这种模式中,一个类公布能够出现的事件,然后任意数量的类都可以预订这个事件。一旦事件产生,运行环境就负责通知每个订户,告诉它们事件已经发生了。
方法作为所产生事件的结果,由代表来定义。但是,要时时记住在这种方式下使用代表时的严格规定。首先,代表必须被定义为采用两个参数值。第二,这些参数值总是代表两个对象,引发事件的对象(发布者)和一个事件信息对象。另外,第二个对象必须是从.NET框架的EventArgs类中派生出来的。
现在假设我们要监视库存级的改变。我们可以创建一个名为InventoryManager的类,这个类将总是用于更新库存。无沦何时.当通过类似于库存接收、销售以及实际上的库存更新等动作对库存进行改变时,InventoryManager类将发布一个事件,然后,任何需要保持同步更新的类将预订这个事件。下面的例子表明了如何在C#中使用代表和事件进行编码。
/Example/Chapter8/8-5-1.cs
class InventoryChangeEventArgs : EventArgs
{
string sku;
int change;
public InventoryChangeEventArgs(string sku,int change)
{
this.sku = sku;
this.change = change;
}
public string Sku
{
get { return this.sku; }
}
public int Change
{
get { return this.change; }
}
}
class InventoryManager //publisher
{
public delegate void InventoryChangeEventHandler(object source,InventoryChangeEventArgs e);
public event InventoryChangeEventHandler OnInventoryChangeHandler;
public void UpdateInventory(string sku,int change)
{
if(change==0)
return; // no update on null change
InventoryChangeEventArgs e = new InventoryChangeEventArgs(sku,change);
if(OnInventoryChangeHandler!=null)
{
OnInventoryChangeHandler(this,e);
}
}
}
class InventoryWatcher //subscriber
{
InventoryManager inventoryManager;
public InventoryWatcher(InventoryManager inventoryManager)
{
this.inventoryManager = inventoryManager;
inventoryManager.OnInventoryChangeHandler +=new InventoryManager.InventoryChangeEventHandler(OnInventoryChange);
}
void OnInventoryChange(object source,InventoryChangeEventArgs e)
{
int change = e.Change;
Console.WriteLine("Part {0} was {1} by {2} units",e.Sku,change>0 ? "increased":"decreased",Math.Abs(e.Change));
}
}
class EventApp1
{
[STAThread]
public static void Main(string[] args)
{
InventoryManager inventoryManager = new InventoryManager();
InventoryWatcher inventoryWatcher = new InventoryWatcher(inventoryManager);
inventoryManager.UpdateInventory("111 006 116",-2);
inventoryManager.UpdateInventory("111 005 383",5);
Console.Read();
}
}
让我们来看一看InventoryManager类的前两个成员:
public delegate void InventoryChangeEventHandler
(object source,InventoryChangeEventArgs e);
public event InventoryChangeEventHandler OnInventoryChangeHandler;
代码的第一行是一个代表.现在我们知道这个代表是为一个方法符号定义的。正如前面所提到的,所有在事件中用到的代表都必须被定义为采用两个参数:一个发布者对象(在这种情况下,是事件源)和一个事件信息对象(一个从EventArgs类派生的对象)。第二行代码中用到了关键字event。通过这个成员类型,可以指定代表和一个(或多个)当事件引发时调用的方法。
在类InventoryManager中的最后一个方法是方法UpdateInventory,无论何时库存被改变,这个方法都会被调用。正如您所见到的那样,这个方法创建了一个InventoryChangeEventArgs类型的对象。这个对象被传递到所有的订户那里,用于描述所发生的事件。
现在来看一看接下来的两行代码:
if(OnInventoryChangeHandler!=null)
OnInventoryChangeHandler(this,e);
条件语句if检查事件是否有什么与方法OnInventoryChangeHandler方法相关的订户。如果有的话——换句话说,就是OnInventoryChangeHandler不等于null—实际事件就被引发了。这就是在发布方要做的全部事情。现在让我们来看一看订户代码。
在这个例子中的订户是名为InventoryWatcher的类。这个类所要做的就是执行两个简单的任务。第一、实例化一个新的InventoryManager.InventoryChangeEventHandler并把这个代表加入到事件InventoryManager.OnInventoryChangeHandler上去,通过这种作法,可以把自己加为一个订户,要特别注意所用的语法——它用到了复合赋值操作符+=,把自己加入到订户列表中,这样做不会取消以前的订户。
inventoryManager.OnInventoryChangeHandler
+=new InventoryManager.InventoryChangeEventHandler(OnInventoryChange);
这里惟一需要提供的参数值就是当事件引发时被调用的方法名字。
订户需要完成的另外一个任务就是实现其事件处理器。在这个例子中,事件处理器是InventoryWatcher.OnInventoryChange,这个事件处理器打印一条消息,其中有该部分的号码和库存的变化。
最后,运行这个程序的代码把类InventoryManager和InventoryWatcher实例化,并在每次调用InventoryManager.UpdateInventory方法时,会自动引发一个事件,该事件导致了方法InventoryWatcher.OnInventoryChanged被调用。
8.6 本章小结
代表是一种具有类型保护和实全机制的对象,与C++中函数指针所要达到的目的相同。代表不同于类和接口,因为它不是在编译时被定义的,它指的是单个的方法,且在运行时被定义。代表通常情况下被用于执行异步处理,并把自定义的代码加入到类的代码路径中。代表有许多常规用途,包括作为回调方法、定义静态方法,以及定义事件等。
8.7 实战演练
1. 打开一个C#的项目查看Page_load事件是怎么响应的?实现了系统中的哪个代表?其代表原型是什么?
第一章 MICROSOFT.NET 概述 1
1.1 MICROSOFT.NET平台 1
1.2 .NET框架 2
1.2.1公共语言运行环境 2
1.2.2 .NET框架类库 3
1.3 Microsoft中间语言和JITters 5
1.4 编写第一个C#应用程序 6
1.5 本章小结 8
1.6 实战演练 8
第二章 C#基本语法 9
2.1 类型系统 9
2.1.1 数值类型 9
2.1.2 引用类型 9
2.2 装箱与开箱 10
2.3类型转换 11
2.4 表达式和操作符 11
2.4.1初级表达式操作符 11
2.4.2 关系操作符 12
2.4.3 简单赋值操作符 13
2.5 程序流程控制语句 14
2.5.1 选择语句 14
2.5.2 迭代语句 16
2.5.3 跳转语句 18
2.5 本章小结 20
2.5 实战演练 20
第三章 面向对象编程的基础知识 21
3.1一切都是“对象” 22
3.1.1 对象和类 24
3.1.2 实例化 25
3.2 面向对象编程语言的三大原则 26
3.2.1 封装 26
3.2.2 继承 28
3.2.3 多态性 30
3.4 本章小结 33
3.5 实战演练 33
第四章 类 34
4.1 定义类 34
4.2 类的成员 34
4.3 访问限定符 35
4.4 构造函数 35
4.4.1 静态成员和实例成员 37
4.4.2构造函数的初始化函数 38
4.5 常量和只读字段 41
4.5.1 常量 41
4.5.2 只读字段 42
4.6 对象的清除和资源管理 43
4.7 继承 43
4.7.1 多接口 45
4.7.2 封装类 45
4.8 本章小结 46
4.9 实战演练 47
第五章 方法 48
5.1方法参数“REF”和“OUT” 48
5.2 方法重载 51
5.3虚拟方法 53
5.3.1方法覆盖 53
5.3.2多态性 54
5.4 静态方法 58
5.5 本章小结 59
5.6 实战演练 59
第六章 属性、数组和索引器 60
6.1 属性——智能字段 60
6.1.1定义和使用属性 61
6.1.2编译器的工作原理 62
6.1.3 只读属性 63
6.1.4继承属性 63
6.1.5 属性的高级使用 63
6.2 数组 64
6.2.1 声明数组 65
6.2.2 一维数组 65
6.2.3 多维数组 66
6.2.4 查询秩 68
6.2.5 锯齿状数组 68
6.3 使用索引器将对象当作数组对待 70
6.3.1 定义索引器 71
6.3.2 索引器示例程序 71
6.2.3 设计规则 73
6.4 本章小结 73
6.5 实战演练 73
第七章 接口与抽象 74
7.1 接口的应用 74
7.2 声明接口 75
7.3 实现接口 76
7.3.1 使用is来查询实现 78
7.3.2 使用as来查询实现 80
7.4 显示的接口成员名字限定 81
7.4.1 接口的名字隐藏 81
7.4.2 避免名字模糊性 83
7.5 接口和继承 86
7.6 合并接口 89
7.7 抽象的定义及使用 90
7.8 接口与抽象类的比较 91
7.9 本章小结 91
7.1实战演练 92
第八章 代表和事件处理器 93
8.1 将代表用作回调方法 93
8.2 把代表定义为静态成员 96
8.3 仅在需要时创建代表 97
8.4 代表构成 99
8.5 定义具有代表的事件 103
8.6 本章小结 106
8.7 实战演练 106
版权所有归"布衣软件工作者".未经容许不得转载.