C#锐利体验
南京邮电学院 李建忠(jzli@china.com)
C#语言是一门简单,现代,优雅,面向对象,类型安全,平台独立的一门新型组件编程语言。其语法风格源自C/C++家族,融合了Visual Basic的高效和C/C++强大,是微软为奠定其下一互联网霸主地位而打造的Microsoft.Net平台的主流语言。其一经推出便以其强大的操作能力,优雅的语法风格,创新的语言特性,第一等的面向组件编程的支持而深受世界各地程序员的好评和喜爱。“它就是我多年来梦寐以求的计算机语言!”--很多资深程序员拿到C#都是这样的惊讶。从C#语言的名字(C Sharp)我们也可见微软用其打造其下一代互联网络深度服务的勃勃雄心。C#语言目前已由微软提交欧洲计算机制造商协会ECMA,经过标准化后的C#将可由任何厂商在任何平台上实现其开发工具及其支持软件,这为C#的发展提供了强大的驱动力,我们也可从这里看到微软前所未有的眼光和智慧。
组件编程已经成为当今世界软件业面向下一代程序开发的一致选择,是90年代面向对象编程的深度发展。C#生逢其时,占尽天时地利,“第一等的面向组件编程的支持”也决不是简单说说那么轻松。实际上,组件特性已经深深植入C#语言的各个层面,是为C#锐利(Sharp)之处。在下面的文章中笔者将从C#语言的各个层面来展现C#语言中无处不见的组件特性,深度阐述C#面向组件编程。整个专题共分为十讲:“第一讲 ‘Hello,World!’程序”,“第二讲 C#语言基础介绍”,“第三讲 Microsoft.NET平台基础构造”,“第四讲 类与对象”,“第五讲 构造器与析构器”,“第六讲 方法”,“第七讲 域与属性”,“第八讲 索引器与操作符重载”,“第九讲 数组与字符串”,“第十讲 特征与映射”,“第十一讲 COM互操作 非托管编程与异常处理”,“第十二讲 用C#编织未来--C#编程模型概述”。
本页内容
第一讲 “Hello,World!”程序 | |
第二讲 C#语言基础介绍 | |
第三讲 Microsoft.NET平台基础构造 | |
第四讲 类与对象 | |
第五讲 构造器与析构器 | |
第六讲 方法 | |
第七讲 域与属性 | |
第八讲 索引器与操作符重载 |
第一讲 “Hello,World!”程序
“Hello World!”程序是程序员一直以来的一个浪漫约定,也是一个伟大的梦想--总有一天,出自人类之手的计算机会面对这个美丽的世界说一声“Hello World!”。它是学习一门新语言的一个很好的起点,我们就从这里开始,看下面例子:
//HelloWorld.cs by Cornfield,2001 //csc HelloWorld.cs using System; class HelloWorld { public static void Main() { Console.WriteLine("Hello World !"); } }
我们可以打开Windows自带的简易的"记事本"程序来编写这段代码--笔者推荐刚开始采用这个极其简单却能把程序代码暴露的相当清晰的编辑工具。我们将它的文件名保存为HelloWorld.cs,其中".cs"是C#源代码文件的扩展名。然后在配置好C#编译器的命令行环境里键入"csc HelloWorld.cs"编译文件。可以看到编译输出文件HelloWorld.exe。我们键入HelloWorld执行这个文件可得到下面的输出:
Hello World !
下面我们来仔细分析上面的代码和整个程序的编译输出及执行过程。先看文件开始的两行代码,这是C#语言的单行注释语句。和C++语言类似,C#支持两种注释方法:以"//"开始的单行注释和以"/*","*/"配对使用的多行注释。注释之间不能嵌套。
再来看下面的"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(整数类型)。其他形式的返回值不具有入口点语义。 |
我们再来看"HelloWorld.cs"程序中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 // Code size 11 (0xb) .maxstack 8 IL_0000: ldstr "Hello World !" 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 { // Code size 7 (0x7) .maxstack 8 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!
第二讲 C#语言基础介绍
在体验C#的锐利之前,关乎语言基本知识的掌握是必不可少的一环。由于C#基本语言很多源自C/C++,在这里对那些和C/C++类似的地方仅作简单介绍,我们将体验专注于那些区别于传统C/C++的关键的语言基础知识。
数据类型
C#语言的数据类型主要分为两类:值类型和引用类型。另外一种数据类型"指针"是为unsafe上下文编程专门设定的,其中unsafe上下文指对代码进行unsafe标示以满足利用指针对内存直接进行操作要求的C#非托管代码,这些代码将失去Microsoft.NET平台的垃圾收集等CLR性质,我们放在"COM互操作 非托管编程与异常处理"专题里阐述。值类型的变量本身包含他们的数据,而引用类型的变量包含的是指向包含数据的内存块的引用或者叫句柄。从下面这幅图中可以清晰地看出两者的差别:
引用类型带来的可能的问题便是当多个变量引用同样的内存块时,对任何一个引用变量的修改都会导致该对象的值的改变。null值表示引用类型没有对任何实际地址进行引用。
值类型可分为结构类型和枚举类型。结构类型包括简单类型和用户自定义结构类型。枚举类型和用户自定义结构类型我们将在"第九讲 结构,枚举,数组与字符串"专题里详细阐述。简单类型又可分为布尔类型和数值类型。C#语言中布尔类型严格与数值类型区分,只有true和false两种取值,不存在像C/C++里那样和其他类型之间的转换。数值类型包括整值,浮点和decimal三种类型。整值类型有sbyte,byte,short,ushort,int,uint,long,ulong,char共九种。除了char类型外,其他8种两两一组分别为有符号和无符号两种。浮点值有float和double两种。decimal主要用于金融,货币等对精度要求比较高的计算环境。下表是对这些简单类型的一个详细的描述:
简单类型 | 描 述 | 示 例 |
sbyte |
8-bit 有符号整数 |
sbyte val = 12; |
short |
16-bit 有符号整数 |
short val = 12; |
int |
32-bit有符号整数 |
int val = 12; |
long |
64-bit有符号整数 |
long val1 = 12; long val2 = 34L; |
byte |
8-bit无符号整数 |
byte val1 = 12; byte val2 = 34U; |
ushort |
16-bit 无符号整数 |
ushort val1 = 12; ushort val2 = 34U; |
uint |
32-bit 无符号整数 |
uint val1 = 12; uint val2 = 34U; |
ulong |
64-bit 无符号整数 |
ulong val1 = 12; ulong val2 = 34U; ulong val3 = 56L; ulong val4 = 78UL; |
float |
32-bit单精度浮点数 |
float val = 1.23F; |
double |
64-bit双精度浮点数 |
double val1 = 1.23; double val2 = 4.56D; |
bool |
布尔类型 |
bool val1 = true; bool val2 = false; |
char |
字符类型 ,Unicode 编码 |
char val = 'h'; |
decimal |
28个有效数字的128-bit十进制类型 |
decimal val = 1.23M; |
引用类型共分四种类型:类,接口,数组,委派。类除了我们可以定义自己的类型外,又包括两个比较特殊的类型object和string。object是C#中所有类型(包括所有的值类型和引用类型)的继承的根类。string类型是一个密封类型(不能被继承),其实例表示Unicode字符串,它和数组类型我们将放在"第九讲 结构,枚举,数组与字符串"中详述。接口类型定义一个方法的合同,我们将在"第七讲 接口 继承与多态"中讲述。委派类型是一个指向静态或实例方法的签名,类似于C/C++中的函数指针,将在"第八讲 委派与事件"中讲述。实际上我们将从后面的专题中看到这些类型都是类的某种形式的包装。
每种数据类型都有对应的缺省值。数值类型的缺省值为0或0.0,其中char的缺省为'\x0000'。布尔类型的缺省值为false。枚举类型的缺省值为0。结构类型的缺省值是将它所有的值类型的域设置为对应值类型的缺省值,将其引用类型的域设置为null。所有引用类型的缺省值为null。
不同类型的数据之间可以转换,C#的类型转换有隐含转换,明晰转换,标准转换,自定义转换共四种方式。隐含转换与明晰转换和C++里一样,数据从"小类型"到"大类型"的转换时为隐含转换,从"大类型"到"小类型"的转换为明晰转换,明晰转换需要如"(Type)data"一般的括号转换操作符。标准转换和自定义转换是针对系统内建转换和用户定义的转换而言的,两者都是对类或结构这样的自定义类型而言的。
变量与常量
变量表示存储位置,变量必须有确定的数据类型。C#的类型安全的含义之一就是确保变量的存储位置容纳着合适的类型。可以将C#中的变量分为静态变量,实例变量,传值参数,引用参数,输出参数,数组参数和本地变量共七种。本地变量则是在方法体内的临时变量。
静态变量和实例变量主要是针对类或结构内的数据成员(又叫域)而言的。静态变量在它寄存的类或结构类型被装载后得到存储空间,如果没有对它进行初始化赋值,静态变量的初始值将是它的类型所持有的缺省值。实例变量在它的类实例被创建后获得存储空间,如果没有经过初始化赋值,它的初始值与静态变量的定义相同。两者更详细的说明我们放在"第六讲 域 方法 属性与索引器"专题里。
传值参数,引用参数,输出参数,数组参数主要针对方法的参数类型而言的。简单的讲传值参数是对变量的值的一种传递,方法内对变量的改变在方法体外不起作用。对于传值参数本身是引用型的变量稍有不同,方法内对该引用(句柄)变量指向的数据成员即实际内存块的改变将在方法体外仍然保留改变,但对于引用(句柄)本身的改变不起作用。引用参数是对变量的句柄的一种传递,方法内对该变量的任何改变都将在方法体外保留。输出参数是C#专门为有多个返回值的方法而量身定做的,它类似于引用变量,但可以在进入方法体之前不进行初始化,而其他的参数在进入方法体内C#都要求明确的初始化。数组参数是为传递大量的数组元素而专门设计的,它从本质上讲是一种引用型变量的传值参数。它们更详细的阐述我们也放在"第六讲 域 方法 属性与索引器"专题里。
本地变量严格的讲是在C#的块语句,for语句,switch语句,using语句内声明的变量,它的生命周期严格地被限制在这些语句块内部。
常量在编译时便确定它的值,在整个程序中也不许修改。常量声明的同时必须赋值。由于它的编译时确定值的特性,引用类型可能的值只能为string和null(除string外,引用类型的构建器必须在运行时才能确定引用类型的值)。
操作符与表达式
C#保留了C++所有的操作符,其中指针操作符(*和->)与引用操作符(&)需要有unsafe的上下文。C#摈弃了范围辨析操作符(::),一律改为单点操作符(.)。我们不再阐述那些保留的C++的操作符,这里主要介绍C#引入的具有特殊意义的几个操作符:as,is,new, typeof,sizeof,stackalloc。
as操作符用于执行兼容类型之间的转换,当转换失败时,as 操作符结果为null。is 操作符用于检查对象的运行时类型是否与给定类型兼容,当表达式非null且可以转化为指定类型时,is操作符结果为true,否则为false。as和is操作符是基于同样的类型鉴别和转换而设计的,两者有相似的应用场合。实际上expression as type相当于expression is type ? (type)expression : (type)null。
作为操作符的new用于在堆上创建对象和调用构造函数,值得注意的是值类型对象(例如结构)是在堆栈上创建的,而引用类型对象(例如类)是在堆上创建的。new也用于修饰符,用于隐藏基类成员的继承成员。为隐藏继承的成员,使用相同名称在派生类中声明该成员并用 new 修饰符修改它。typeof 运算符用于获得某一类型的 System.Type 对象,我们将在"第十讲 特征与映射"里结合Microsoft.NET的类型系统对它作详细的阐述。sizeof 运算符用于获得值类型(不适用于引用类型)的大小(以字节为单位)。stackalloc用于在堆栈上分配内存块, 仅在局部变量的初始值设定项中有效,类似于C/C++语言的_alloca。sizeof和statckalloc都由于涉及内存的直接操作而需要unsafe上下文。
C#里的某些操作符可以像C++里那样被重载。操作符重载使得自定义类型(类或结构)可以用简单的操作符来方便的表达某些常用的操作。
为完成一个计算结果的一系列操作符和操作数的组合称为表达式。和C++一样,C#的表达式可以分为赋值表达式和布尔表达式两种,C#没有引入新的表达式形式,我们对此不再赘述。
命名空间与语句
C#采用命名空间(namespace)来组织程序。命名空间可以嵌套。using指示符可以用来简化命名空间类型的引用。using指示符有两种用法。"using System;"语句可以使我们用简短的类型名"Console"来代替类型"System.Console"。"using Output = System.Console;"语句可以使我们用别名"Output"来代替类型"System.Console"。命名空间的引入大大简化了C#程序的组织方式。
C#语句可以分为标号语句,声明语句,块语句,空语句,表达式语句,选择语句,反复语句,跳转语句,try语句,checked/unchecked语句,lock语句,using语句。
标号语句主要为goto跳转设计,C#不允许跨方法的跳转,但允许小规模的方法内的跳转。声明语句可以同时进行初始化赋值,对象的实例化声明需要new关键字。块语句采用"{"和"}"定义语句块,主要是界定局部变量的作用范围。空语句在C#中用分号";"表示,没有执行语义。表达式语句通过表达式构成语句。
选择语句有if语句和switch语句两种,与C++别无二致。反复语句除了while,do,for三种循环结构外引入了foreach语句用于遍历集合中所有的元素,但这需要特定的接口支持,我们在后面的章节里对之作详细阐述。
跳转语句有break,continue,goto,return,throw五种语句,前四种与C++里的语义相同,throw语句与后面的try语句我们将在"第十一讲 COM互操作 非托管编程与异常处理"阐述。
checked/unchecked语句主要用于数值运算中溢出检查的上下文。lock语句主要用于线程信号量的锁控制。using语句主要用于片断资源管理。这些我们在后续章节里都会有具体的涉及。
第三讲 Microsoft.NET平台基础构造
抛开Microsoft.NET平台去谈C#是没有意义的,C#之“Sharp”也正在其后端强大的平台。仅仅拘泥于语法层面是体验不了C#的锐利之处的,C#程序很多诡秘之处必须依靠Microsoft.NET平台才能深度的掌握和运用。简单的讲,Microsoft.NET平台是一个建立在开放互联网络协议和标准之上,采用新的工具和服务来满足人们的计算和通信需求的革命性的新型XML Web智能计算服务平台。它允许应用程序在因特网上方便快捷地互相通信,而不必关心使用何种操作系统和编程语言。
从技术层面具体来说,Microsoft.NET平台主要包括两个内核,即通用语言运行时(Common Language Runtime,简称CLR)和Microsoft.NET框架类库,它们为Microsoft.NET平台的实现提供了底层技术支持。通用语言运行时是建立在操作系统最底层的服务,为Microsoft.NET平台的执行引擎。Microsoft.NET框架包括一套可被用于任何编程语言的类库,其目的是使得程序员更容易地建立基于网络的应用和服务。在此之上是许多应用程序模板,这些模板为开发网络应用和服务提供高级的组件和服务。Microsoft.NET平台之浩瀚绝非这里的几千字能够廓清,我们下面将着重体验那些对我们用C#开发应用程序至关重要的平台基础构造。
通用语言运行时(CLR)
通用语言运行时是整个Microsoft.NET框架赖以建构的基础,它为Microsoft.NET应用程序提供了一个托管的代码执行环境。它实际上是驻留在内存里的一段代理代码,负责应用程序在整个执行期间的代码管理工作,比较典型的有:内存管理,线程管理,安全管理,远程管理,即时编译,代码强制安全类型检查等。这些都可称得上Microsoft.NET框架的生命线。
实际上我们可以看出来,CLR代理了一部分传统操作系统的管理功能。在CLR下的代码称之为托管代码,否则称为非托管代码。我们也可将CLR看作一个技术规范,无论程序使用什么语言编写,只要能编译成微软中间语言 (MSIL),就可以在它的支持下运行,这使得应用程序得以独立于语言。目前支持CLR的编程语言多达二三十种。微软中间语言是我们在Microsoft.NET平台下编译器输出的PE文件的语言。它是Microsoft.NET平台最完整的语言集,非常类似于PC机上的汇编语言。即时编译器在运行时将中间语言编译成本地二进制代码。它为Microsoft.NET平台提供了多语言的底层技术支持。另外根据需要,Microsoft.NET即时编译器提供了特殊情况下的经济型即时编译和安装时编译技术。
CLR的设计目的便是直接在应用程序运行环境中为基于组件的编程提供第一等的支持。正如在Windows中添加了对窗口、控件、图形和菜单的直接支持,为基于消息的编程添加了底层结构,为支持设备无关性添加了抽象内容一样,CLR直接支持组件(包括属性和事件)、对象、继承性、多态性和接口。对属性和事件的直接支持使得基于组件的编程变得更简单,而不需要特殊的接口和适配设计模式。在组件运行时,CLR负责管理内存分配、启动和中止线程和进程、强化安全系数,同时还调整任何该组件涉及到的其他组件的附属配置。序列化支持允许以多种格式操作存储在磁盘上的组件,包括基于业界标准XML的SOAP。CLR提供了处理错误条件的有力、协调的方式。每个模块都具有内置的完整的元数据,这意味着诸如动态创建和方法调用之类的功能更容易,也更安全。映射甚至允许我们灵活地创建和执行代码。我们可以控制应用程序使用的组件的版本,这使应用程序更加可靠。组件代码是与处理器无关的和易于验证的中间语言 ( IL),而不是某一种特定的机器语言,这意味着组件不但可以在多种计算机上运行,而且可以确保组件不会覆盖它们不使用的内存,也不会潜在地导致系统崩溃。CLR根据托管组件的来源(例如来自因特网,企业局域网,本地机)等因素对他们判定以适当的信任度,这样CLR会根据他们的信任度来限定他们执行如读取文件,修改注册表等某些敏感操作的权限。借助通用类型系统(Common Type System,简称CTS)对代码类型进行严格的安全检查避免了不同组件之间可能存在的类型不匹配的问题。CLR下的编程全部是围绕组件进行的。
值得指出的是CLR通常寄宿在其他高性能的服务器应用程序中,比如:因特网信息服务器(IIS),Microsoft SQL Server。这使得我们可以充分利用通用语言运行时诸多的安全,高效的优点来部署自己的商业逻辑。
内存管理
CLR对程序员影响最大的就是它的内存管理功能,以至于我们很有必要单独把它列出来阐述。它为应用程序提供了高性能的垃圾收集环境。垃圾收集器自动追踪应用程序操作的对象,程序员再也用不着和复杂的内存管理打交道。这在某些喜欢张口闭口底层编程的所谓的高手来说,自动内存管理从来都是他们嘲笑的对象。的确,为通用软件环境设计的自动化内存管理器永远都抵不上自己为特定程序量身订制的手工制作。但现代软件业早已不再是几百行代码的作坊作业,动辄成千上万行的代码,大量的商业逻辑凸现的已不再是算法的灵巧,而是可管理性,可维护性的工程代码。.NET/C#不是为那样的作坊高手准备的,C语言才是他们的尤物。在Microsoft.NET托管环境下,CLR负责处理对象的内存布局,管理对象的引用,释放系统不再使用的内存(自动垃圾收集)。这从根本上解决了长期以来困扰软件的内存泄漏和无效内存引用问题,大大减轻了程序员的开发负担,提高了程序的健壮性。实际上我们在托管环境下根本找不到关于内存操作或释放的语言指令。值得指出的是Microsoft.NET应用程序可以使用托管数据,也可以使用非托管数据,但CLR并不能判断托管数据与非托管数据。
垃圾收集器负责管理.NET应用程序内存的分配和释放。当用new操作符创建新的对象时,垃圾收集器在托管堆(Managed Heap)中为对象分配内存资源。只要托管堆内的内存空间可用,垃圾收集器就为每一个新创建的对象分配内存。当应用程序不再持有某个对象的引用,垃圾收集器将会探测到并释放该对象。值得注意的是垃圾收集器并不是在对象引用无效时就立即开始释放工作,而是根据一定算法来决定什么时候进行收集和对什么对象进行收集。任何一个机器的内存资源总是有限的,当托管堆内的内存空间不够用时,垃圾收集器启动收集线程来释放系统内存。垃圾收集器根据对象的存活时间,对象历经的收集次数等来决定对哪些对象的内存进行释放。宏观的看,我们并不知道垃圾收集的确切行为,但Microsoft.NET类库为我们提供了控制垃圾收集行为的部分功能,在某些特殊情况下,我们有必要进行一些受限的操作。
垃圾收集器并不意味着程序员从此可以一劳永逸,如果正在操作一个包装了如文件,网络连接,Windows句柄,位图等底层操作系统资源的对象,我们还是需要明确地释放这些非托管资源的。这在“第五讲 构造器与析构器”里有详细的阐述。
Microsoft.NET框架类库
Microsoft.NET框架类库是一组广泛的,面向对象的可重用类的集合,为应用程序提供各种高级的组件和服务。它将程序员从繁重的编程细节中解放出来专注于程序的商业逻辑,为应用程序提供各种开发支持--不管是传统的命令行程序还是Windows图形界面程序,拟或是面向下一代因特网分布式计算平台的ASP.NET或XML Web服务。下面是对这些组件和服务的一个概括。
• |
系统框架服务 服务框架包括一套开发人员希望在标准语言库中存在的基类库,例如:集合、输入/输出,字符串及数据类。另外,基类库提供访问操作系统服务如图画、网络、线程、全球化和加密的类。服务框架也包括数据访问类库,及开发工具,如调试和剖析服务,能够使用的类。 |
• |
ADO.NET组件 ADO.NET为基于网络的可扩展的应用程序和服务提供数据访问服务。ADO.NET不仅支持传统的基于连接指针风格的数据访问,同时也为更适合于把数据返回到客户端应用程序的无连接的数据模板提供高性能的访问支持。 |
• |
XML数据组件 所有的数据都可被看作XML,开发人员可以通过XML为任何数据使用转换,传输和确认服务。系统框架对XML数据提供第一等的操作支持。系统也支持ADO.NET数据与XML数据之间的通用转换。 |
• |
Windows表单组件 Windows表单组件为开发人员提供了强大的Windows应用程序模型和丰富的Windows用户接口,包括传统的ActiveX控件和Windows XP的新界面,如透明的、分层的、浮动窗口。对设计时的强大支持也是Windows表单组件令人兴奋的地方。 |
• |
ASP.NET应用服务 ASP.NET的核心是高性能的用于处理基于低级结构的HTTP请求的运行语言。编译运行方式大大提高了它的性能。ASP.NET使用基于构件的Microsoft .NET框架配制模板,因此它获得了如XCOPY配制、构件并行配制、基于XML配制等优点。它支持应用程序的实时更新,提供高速缓冲服务改善性能。 |
• |
ASP.NET Web表单 ASP.NET Web表单把基于VB的表单的高生产性的优点带到了网络应用程序的开发中来。ASP.NET Web表单支持传统的将HTML内容与角本代码混合的ASP语法,但是它提出了一种将应用程序代码和用户接口内容分离的更加结构化的方法。ASP.NET提供了一套映射传统的HTML用户接口部件(包括列表框,文本框和按钮)的ASP.NET Web表单控件和一套更加复杂强大的网络应用控件(如日历和广告转板)。 |
• |
XML Web服务 ASP.NET应用服务体系架构为用ASP.NET建立XML Web服务提供了一个高级的可编程模板。虽然建立XML Web服务并不限定使用特定的服务平台,但是它提供许多的优点将简化开发过程。使用这个编程模型,开发人员甚至不需要理解HTTP、SOAP或其它任何网络服务规范。 ASP.NET XML Web服务为在Internet上绑定应用程序提供了一个利用现存体系架构和应用程序的简单的、灵活的、基于产业标准的模型。 |
第四讲 类与对象
组件编程不是对传统面向对象的抛弃,相反组件编程正是面向对象编程的深化和发展。类作为面向对象的灵魂在C#语言里有着相当广泛深入的应用,很多非常“Sharp”的组件特性甚至都是直接由类包装而成。对类的深度掌握自然是我们“Sharp XP”重要的一环。
类
C#的类是一种对包括数据成员,函数成员和嵌套类型进行封装的数据结构。其中数据成员可以是常量,域。函数成员可以是方法,属性,索引器,事件,操作符,实例构建器,静态构建器,析构器。我们将在“第五讲 构造器与析构器”和“第六讲 域 方法 属性与索引器”对这些成员及其特性作详细的剖析。除了某些导入的外部方法,类及其成员在C#中的声明和实现通常要放在一起。
C#用多种修饰符来表达类的不同性质。根据其保护级C#的类有五种不同的限制修饰符:
1. |
public可以被任意存取; |
2. |
protected只可以被本类和其继承子类存取; |
3. |
internal只可以被本组合体(Assembly)内所有的类存取,组合体是C#语言中类被组合后的逻辑单位和物理单位,其编译后的文件扩展名往往是“.DLL”或“.EXE”。 |
4. |
protected internal唯一的一种组合限制修饰符,它只可以被本组合体内所有的类和这些类的继承子类所存取。 |
5. |
private只可以被本类所存取。 |
如果不是嵌套的类,命名空间或编译单元内的类只有public和internal两种修饰。
new修饰符只能用于嵌套的类,表示对继承父类同名类型的隐藏。
abstract用来修饰抽象类,表示该类只能作为父类被用于继承,而不能进行对象实例化。抽象类可以包含抽象的成员,但这并非必须。abstract不能和new同时用。下面是抽象类用法的伪码:
abstract class A { public abstract void F(); } abstract class B: A { public void G() {} } class C: B { public override void F() { //方法F的实现 } }
抽象类A内含一个抽象方法F(),它不能被实例化。类B继承自类A,其内包含了一个实例方法G(),但并没有实现抽象方法F(),所以仍然必须声明为抽象类。类C继承自类B,实现类抽象方法F(),于是可以进行对象实例化。
sealed用来修饰类为密封类,阻止该类被继承。同时对一个类作abstract和sealed的修饰是没有意义的,也是被禁止的。
对象与this关键字
类与对象的区分对我们把握OO编程至关重要。我们说类是对其成员的一种封装,但类的封装设计仅仅是我们编程的第一步,对类进行对象实例化,并在其数据成员上实施操作才是我们完成现实任务的根本。实例化对象采用MyClass myObject=new MyClass()语法,这里的new语义将调用相应的构建器。C#所有的对象都将创建在托管堆上。实例化后的类型我们称之为对象,其核心特征便是拥有了一份自己特有的数据成员拷贝。这些为特有的对象所持有的数据成员我们称之为实例成员。相反那些不为特有的对象所持有的数据成员我们称之为静态成员,在类中用static修饰符声明。仅对静态数据成员实施操作的称为静态函数成员。C#中静态数据成员和函数成员只能通过类名引用获取,看下面的代码:
using System; class A { public int count; public void F() { Console.WriteLine(this.count); } public static string name; public static void G() { Console.WriteLine(name); } } class Test { public static void Main() { A a1=new A(); A a2=new A(); a1.F(); a1.count=1; a2.F(); a2.count=2; A.name="CCW"; A.G(); } }
我们声明了两个A对象a1,a2。对于实例成员count和F(),我们只能通过a1,a2引用。对于静态成员name和G()我们只能通过类型A来引用,而不可以这样a1.name,或a1.G()。
在上面的程序中,我们看到在实例方法F()中我们才用this来引用变量count。这里的this是什么意思呢?this 关键字引用当前对象实例的成员。在实例方法体内我们也可以省略this,直接引用count,实际上两者的语义相同。理所当然的,静态成员函数没有 this 指针。this 关键字一般用于从构造函数、实例方法和实例访问器中访问成员。
在构造函数中this用于限定被相同的名称隐藏的成员,例如:
class Employee { public Employee(string name, string alias) { this.name = name; this.alias = alias; } }
将对象作为参数传递到其他方法时也要用this表达,例如:
CalcTax(this);
声明索引器时this更是不可或缺,例如:
public int this [int param] { get { return array[param]; } set { array[param] = value; } }
System.Object类
C#中所有的类都直接或间接继承自System.Object类,这使得C#中的类得以单根继承。如果我们没有明确指定继承类,编译器缺省认为该类继承自System.Object类。System.Object类也可用小写的object关键字表示,两者完全等同。自然C#中所有的类都继承了System.Object类的公共接口,剖析它们对我们理解并掌握C#中类的行为非常重要。下面是仅用接口形式表示的System.Object类:
namespace System { public class Object { public static bool Equals(object objA,object objB){} public static bool ReferenceEquals(object objA,object objB){} public Object(){} public virtual bool Equals(object obj){} public virtual int GetHashCode(){} public Type GetType(){} public virtual string ToString(){} protected virtual void Finalize(){} protected object MemberwiseClone(){} }
我们先看object的两个静态方法Equals(object objA,object objB),ReferenceEquals(object objA,object objB)和一个实例方法Equals(object obj)。在我们阐述这两个方法之前我们首先要清楚面向对象编程两个重要的相等概念:值相等和引用相等。值相等的意思是它们的数据成员按内存位分别相等。引用相等则是指它们指向同一个内存地址,或者说它们的对象句柄相等。引用相等必然推出值相等。对于值类型关系等号“= =”判断两者是否值相等(结构类型和枚举类型没有定义关系等号“= =”,我们必须自己定义)。对于引用类型关系等号“= =”判断两者是否引用相等。值类型在C#里通常没有引用相等的表示,只有在非托管编程中采用取地址符“&”来间接判断二者的地址是否相等。
静态方法Equals(object objA,object objB)首先检查两个对象objA和objB是否都为null,如果是则返回true,否则进行objA.Equals(objB)调用并返回其值。问题归结到实例方法Equals(object obj)。该方法缺省的实现其实就是{return this= =obj;}也就是判断两个对象是否引用相等。但我们注意到该方法是一个虚方法,C#推荐我们重写此方法来判断两个对象是否值相等。实际上Microsoft.NET框架类库内提供的许多类型都重写了该方法,如:System.String(string),System.Int32(int)等,但也有些类型并没有重写该方法如:System.Array等,我们在使用时一定要注意。对于引用类型,如果没有重写实例方法Equals(object obj),我们对它的调用相当于this= =obj,即引用相等判断。所有的值类型(隐含继承自System.ValueType类)都重写了实例方法Equals(object obj)来判断是否值相等。
注意对于对象x,x.Equals(null)返回false,这里x显然不能为null(否则不能完成Equals()调用,系统抛出空引用错误)。从这里我们也可看出设计静态方法Equals(object objA,object objB)的原因了--如果两个对象objA和objB都可能为null,我们便只能用object. Equals(object objA,object objB)来判断它们是否值相等了--当然如果我们没有改写实例方法Equals(object obj),我们得到的仍是引用相等的结果。我们可以实现接口IComparable(有关接口我们将在“第七讲 接口 继承与多态”里阐述)来强制改写实例方法Equals(object obj)。
对于值类型,实例方法Equals(object obj)应该和关系等号“= =”的返回值一致,也就是说如果我们重写了实例方法Equals(object obj),我们也应该重载或定义关系等号“= =”操作符,反之亦然。虽然值类型(继承自System.ValueType类)都重写了实例方法Equals(object obj),但C#推荐我们重写自己的值类型的实例方法Equals(object obj),因为系统的System.ValueType类重写的很低效。对于引用类型我们应该重写实例方法Equals(object obj)来表达值相等,一般不应该重载关系等号“= =”操作符,因为它的缺省语义是判断引用相等。
静态方法ReferenceEquals(object objA,object objB)判断两个对象是否引用相等。如果两个对象为引用类型,那么它的语义和没有重载的关系等号“= =”操作符相同。如果两个对象为值类型,那么它的返回值一定是false。
实例方法GetHashCode()为相应的类型提供哈希(hash)码值,应用于哈希算法或哈希表中。需要注意的是如果我们重写了某类型的实例方法Equals(object obj),我们也应该重写实例方法GetHashCode()--这理所应当,两个对象的值相等,它们的哈希码也应该相等。下面的代码是对前面几个方法的一个很好的示例:
using System; struct A { public int count; } class B { public int number; } class C { public int integer=0; public override bool Equals(object obj) { C c=obj as C; if (c!=null) return this.integer==c.integer; else return false; } public override int GetHashCode() { return 2^integer; } } class Test { public static void Main() { A a1,a2; a1.count=10; a2=a1; //Console.Write(a1==a2);没有定义“= =”操作符 Console.Write(a1.Equals(a2));//True Console.WriteLine(object.ReferenceEquals(a1,a2));//False B b1=new B(); B b2=new B(); b1.number=10; b2.number=10; Console.Write(b1==b2);//False Console.Write(b1.Equals(b2));//False Console.WriteLine(object.ReferenceEquals(b1,b2));//False b2=b1; Console.Write(b1==b2);//True Console.Write(b1.Equals(b2));//True Console.WriteLine(object.ReferenceEquals(b1,b2));//True C c1=new C(); C c2=new C(); c1.integer=10; c2.integer=10; Console.Write(c1==c2);//False Console.Write(c1.Equals(c2));//True Console.WriteLine(object.ReferenceEquals(c1,c2));//False c2=c1; Console.Write(c1==c2);//True Console.Write(c1.Equals(c2));//True Console.WriteLine(object.ReferenceEquals(c1,c2));//True } }
如我们所期望,编译程序并运行我们会得到以下输出:
True False False False False True True True False True False True True True
实例方法GetType()与typeof的语义相同,它们都通过查询对象的元数据来确定对象的运行时类型,我们在“第十讲 特征与映射”对此作详细的阐述。
实例方法ToString()返回对象的字符串表达形式。如果我们没有重写该方法,系统一般将类型名作为字符串返回。
受保护的Finalize()方法在C#中有特殊的语义,我们将在“第五讲 构造器与析构器”里详细阐述。
受保护的MemberwiseClone()方法返回目前对象的一个“影子拷贝”,该方法不能被子类重写。“影子拷贝”仅仅是对象的一份按位拷贝,其含义是对对象内的值类型变量进行赋值拷贝,对其内的引用类型变量进行句柄拷贝,也就是拷贝后的引用变量将持有对同一块内存的引用。相对于“影子拷贝”的是深度拷贝,它对引用类型的变量进行的是值复制,而非句柄复制。例如X是一个含有对象A,B引用的对象,而对象A又含有对象M的引用。Y是X的一个“影子拷贝”。那么Y将拥有同样的A,B的引用。但对于X的一个“深度拷贝”Z来说,它将拥有对象C和D的引用,以及一个间接的对象N的引用,其中C是A的一份拷贝,D是B的一份拷贝,N是M的一份拷贝。深度拷贝在C#里通过实现ICloneable接口(提供Clone()方法)来完成。
对对象和System.Object的把握为类的学习作了一个很好的铺垫,但这仅仅是我们锐利之行的一小步,关乎对象成员初始化,内存引用的释放,继承与多态,异常处理等等诸多“Sharp”特技堪为浩瀚,让我们继续期待下面的专题!
第五讲 构造器与析构器
构造器
构造器负责类中成员变量(域)的初始化。C#的类有两种构造器:实例构造器和静态构造器。实例构造器负责初始化类中的实例变量,它只有在用户用new关键字为对象分配内存时才被调用。而且作为引用类型的类,其实例化后的对象必然是分配在托管堆(Managed Heap)上。这里的托管的意思是指该内存受.NET的CLR运行时管理。和C++不同的是,C#中的对象不可以分配在栈中,用户只声明对象是不会产生构造器调用的。
实例构造器分为缺省构造器和非缺省构造器。缺省构造器是在一个类没有声明任何构造器的情况下,编译器强制为该类添加的一个无参数的构造器,该构造器仅仅调用父类的无参数构造器。缺省构造器实际上是C#编译器为保证每一个类都有至少一个构造器而采取的附加规则。注意这里的三个要点:
1. |
子类没有声明任何构造器; |
2. |
编译器为子类加的缺省构造器一定为无参数的构造器; |
3. |
父类一定要存在一个无参数的构造器。 |
看下面例子的输出:
using System; public class MyClass1 { public MyClass1() { Console.WriteLine(“MyClass1 Parameterless Contructor!”); } public MyClass1(string param1) { Console.WriteLine(“MyClass1 Constructor Parameters : ”+param1); } } public class MyClass2:MyClass1 { } public class Test { public static void Main() { MyClass2 myobject1=new MyClass2(); } }
编译程序并运行可以得到下面的输出:
MyClass1 Parameterless Contructor!
读者可以去掉MyClass1的无参构造器public MyClass1()看看编译结果。
构造器在继承时需要特别的注意,为了保证父类成员变量的正确初始化,子类的任何构造器默认的都必须调用父类的某一构造器,具体调用哪个构造器要看构造器的初始化参数列表。如果没有初始化参数列表,那么子类的该构造器就调用父类的无参数构造器;如果有初始化参数列表,那么子类的该构造器就调用父类对应的参数构造器。看下面例子的输出:
using System; public class MyClass1 { public MyClass1() { Console.WriteLine("MyClass1 Parameterless Contructor!"); } public MyClass1(string param1) { Console.WriteLine("MyClass1 Constructor Parameters : "+param1); } } public class MyClass2:MyClass1 { public MyClass2(string param1):base(param1) { Console.WriteLine("MyClass2 Constructor Parameters : "+param1); } } public class Test { public static void Main() { MyClass2 myobject1=new MyClass2("Hello"); } }
编译程序并运行可以得到下面的输出:
MyClass1 Constructor Parameters : Hello MyClass2 Constructor Parameters : Hello
C#支持变量的声明初始化。类内的成员变量声明初始化被编译器转换成赋值语句强加在类的每一个构造器的内部。那么初始化语句与调用父类构造器的语句的顺序是什么呢?看下面例子的输出:
using System; public class MyClass1 { public MyClass1() { Print(); } public virtual void Print() {} } public class MyClass2: MyClass1 { int x = 1; int y; public MyClass2() { y = -1; Print(); } public override void Print() { Console.WriteLine("x = {0}, y = {1}", x, y); } } public class Test { static void Main() { MyClass2 MyObject1 = new MyClass2(); } }
编译程序并运行可以得到下面的输出:
x = 1, y = 0 x = 1, y = -1
容易看到初始化语句在父类构造器调用之前,最后执行的才是本构造器内的语句。也就是说变量初始化的优先权是最高的。
我们看到类的构造器的声明中有public修饰符,那么当然也可以有protected/private/ internal修饰符。根据修饰符规则,我们如果将一个类的构造器修饰为private,那么我们在继承该类的时候,我们将不能对这个private的构造器进行调用,我们是否就不能对它进行继承了吗?正是这样。实际上这样的类在我们的类内的成员变量都是静态(static)时,而又不想让类的用户对它进行实例化,这时必须屏蔽编译器为我们暗中添加的构造器(编译器添加的构造器都为public),就很有必要作一个private的实例构造器了。protected/internal也有类似的用法。
类的构造器没有返回值,这一点是不言自明的。
静态构造器初始化类中的静态变量。静态构造器不象实例构造器那样在继承中被隐含调用,也不可以被用户直接调用。掌握静态构造器的要点是掌握它的执行时间。静态构造器的执行并不确定(编译器没有明确定义)。但有四个准则需要掌握:
1. |
在一个程序的执行过程中,静态构造器最多只执行一次。 |
2. |
静态构造器在类的静态成员初始化之后执行。或者讲编译器会将静态成员初始化语句转换成赋值语句放在静态构造器执行的最开始。 |
3. |
静态构造器在任何类的静态成员被引用之前执行。 |
4. |
静态构造器在任何类的实例变量被分配之前执行。 |
看下面例子的输出:
using System; class MyClass1 { static MyClass1() { Console.WriteLine("MyClass1 Static Contructor"); } public static void Method1() { Console.WriteLine("MyClass1.Method1"); } } class MyClass2 { static MyClass2() { Console.WriteLine("MyClass2 Static Contructor"); } public static void Method1() { Console.WriteLine("MyClass2.Method1"); } } class Test { static void Main() { MyClass1.Method1(); MyClass2.Method1(); } }
编译程序并运行可以得到下面的输出:
MyClass1 Static Contructor MyClass1.Method1 MyClass2 Static Contructor MyClass2.Method1
当然也可能输出:
MyClass1 Static Contructor MyClass2 Static Contructor MyClass1.Method1 MyClass2.Method1
值得指出的是实例构造器内可以引用实例变量,也可引用静态变量。而静态构造器内能引用静态变量。这在类与对象的语义下是很容易理解的。
实际上如果我们能够深刻地把握类的构造器的唯一目的就是保证类内的成员变量能够得到正确的初始化,我们对各种C#中形形色色的构造器便有会心的理解--它没有理由不这样!
析构器
由于.NET平台的自动垃圾收集机制,C#语言中类的析构器不再如传统C++那么必要,析构器不再承担对象成员的内存释放--自动垃圾收集机制保证内存的回收。实际上C#中已根本没有delete操作!析构器只负责回收处理那些非系统的资源,比较典型的如:打开的文件,获取的窗口句柄,数据库连接,网络连接等等需要用户自己动手释放的非内存资源。我们看下面例子的输出:
using System; class MyClass1 { ~MyClass1() { Console.WriteLine("MyClass1's destructor"); } } class MyClass2: MyClass1 { ~MyClass2() { Console.WriteLine("MyClass2's destructor"); } } public class Test { public static void Main() { MyClass2 MyObject = new MyClass2(); MyObject = null; GC.Collect(); GC.WaitForPendingFinalizers(); } }
编译程序并运行可以得到下面的输出:
MyClass2's destructor MyClass1's destructor
其中程序中最后两句是保证类的析构器得到调用。GC.Collect()是强迫通用语言运行时进行启动垃圾收集线程进行回收工作。而GC.WaitForPendingFinalizers()是挂起目前的线程等待整个终止化(Finalizaion)操作的完成。终止化(Finalizaion)操作保证类的析构器被执行,这在下面会详细说明。
析构器不会被继承,也就是说类内必须明确的声明析构器,该类才存在析构器。用户实现析构器时,编译器自动添加调用父类的析构器,这在下面的Finalize方法中会详细说明。析构器由于垃圾收集机制会被在合适的的时候自动调用,用户不能自己调用析构器。只有实例析构器,而没有静态析构器。
那么析构器是怎么被自动调用的?这在 .Net垃圾回收机制由一种称作终止化(Finalizaion)的操作来支持。.Net系统缺省的终止化操作不做任何操作,如果用户需要释放非受管资源,用户只要在析构器内实现这样的操作即可--这也是C#推荐的做法。我们看下面这段代码:
using System; class MyClass1 { ~MyClass1() { Console.WritleLine("MyClass1 Destructor"); } }
而实际上,从生成的中间代码来看我们可以发现,这些代码被转化成了下面的代码:
using System; class MyClass1 { protected override void Finalize() { try { Console.WritleLine("My Class1 Destructor"); } finally { base.Finalize(); } } }
实际上C#编译器不允许用户自己重载或调用Finalize方法--编译器彻底屏蔽了父类的Finalize方法(由于C#的单根继承性质,System.Object类是所有类的祖先类,自然每个类都有Finalize方法),好像这样的方法根本不存在似的。我们看下面的代码实际上是错的:
using System; class MyClass { override protected void Finalize() {}// 错误 public void MyMethod() { this.Finalize();// 错误 } }
但下面的代码却是正确的:
using System; class MyClass { public void Finalize() { Console.WriteLine("My Class Destructor"); } } public class Test { public static void Main() { MyClass MyObject=new MyClass(); MyObject.Finalize(); } }
实际上这里的Finalize方法已经彻底脱离了“终止化操作”的语义,而成为C#语言的一个一般方法了。值得注意的是这也屏蔽了父类System.Object的Finalize方法,所以要格外小心!
终止化操作在.Net运行时里有很多限制,往往不被推荐实现。当对一个对象实现了终止器(Finalizer)后,运行时便会将这个对象的引用加入一个称作终止化对象引用集的队列,作为要求终止化的标志。当垃圾收集开始时,若一个对象不再被引用但它被加入了终止化对象引用集的队列,那么运行时并不立即对此对象进行垃圾收集工作,而是将此对象标志为要求终止化操作对象。待垃圾收集完成后,终止化线程便会被运行时唤醒执行终止化操作。显然这之后要从终止化对象引用集的链表中将之删去。而只有到下一次的垃圾收集时,这个对象才开始真正的垃圾收集,该对象的内存资源才被真正回收。容易看出来,终止化操作使垃圾收集进行了两次,这会给系统带来不小的额外开销。终止化是通过启用线程机制来实现的,这有一个线程安全的问题。.Net运行时不能保证终止化执行的顺序,也就是说如果对象A有一个指向对象B的引用,两个对象都有终止化操作,但对象A在终止化操作时并不一定有有效的对象A引用。.Net运行时不允许用户在程序运行中直接调用Finalize()方法。如果用户迫切需要这样的操作,可以实现IDisposable接口来提供公共的Dispose()方法。需要说明的是提供了Dispose()方法后,依然需要提供Finalize方法的操作,即实现假托的析构函数。因为Dispose()方法并不能保证被调用。所以.Net运行时不推荐对对象进行终止化操作即提供析构函数,只是在有非受管资源如数据库的连接,文件的打开等需要严格释放时,才需要这样做。
大多数时候,垃圾收集应该交由.Net运行时来控制,但有些时候,可能需要人为地控制一下垃圾回收操作。例如在操作了一次大规模的对象集合后,我们确信不再在这些对象上进行任何的操作了,那我们可以强制垃圾回收立即执行,这通过调用System.GC.Collect() 方法即可实现,但频繁的收集会显著地降低系统的性能。还有一种情况,已经将一个对象放到了终止化对象引用集的链上了,但如果我们在程序中某些地方已经做了终止化的操作,即明确调用了Dispose()方法,在那之后便可以通过调用System.GC.SupressFinalize()来将对象的引用从终止化对象引用集链上摘掉,以忽略终止化操作。终止化操作的系统负担是很重的。
在深入了解了.NET运行时的自动垃圾收集功能后,我们便会领会C#中的析构器为什么绕了这么大的弯来实现我们的编程需求,才能把内存资源和非内存资源的回收做的游刃有余--这也正是析构的本原!
第六讲 方法
方法又称成员函数(Member Function),集中体现了类或对象的行为。方法同样分为静态方法和实例方法。静态方法只可以操作静态域,而实例方法既可以操作实例域,也可以操作静态域--虽然这不被推荐,但在某些特殊的情况下会显得很有用。方法也有如域一样的5种存取修饰符--public,protected,internal,protected internal,private,它们的意义如前所述。
方法参数
方法的参数是个值得特别注意的地方。方法的参数传递有四种类型:传值(by value),传址(by reference),输出参数(by output),数组参数(by array)。传值参数无需额外的修饰符,传址参数需要修饰符ref,输出参数需要修饰符out,数组参数需要修饰符params。传值参数在方法调用过程中如果改变了参数的值,那么传入方法的参数在方法调用完成以后并不因此而改变,而是保留原来传入时的值。传址参数恰恰相反,如果方法调用过程改变了参数的值,那么传入方法的参数在调用完成以后也随之改变。实际上从名称上我们可以清楚地看出两者的含义--传值参数传递的是调用参数的一份拷贝,而传址参数传递的是调用参数的内存地址,该参数在方法内外指向的是同一个存储位置。看下面的例子及其输出:
using System; class Test { static void Swap(ref int x, ref int y) { int temp = x; x = y; y = temp; } static void Swap(int x,int y) { int temp = x; x = y; y = temp; } static void Main() { int i = 1, j = 2; Swap(ref i, ref j); Console.WriteLine("i = {0}, j = {1}", i, j); Swap(i,j); Console.WriteLine("i = {0}, j = {1}", i, j); } }
程序经编译后执行输出:
i = 2, j = 1 i = 2, j = 1
我们可以清楚地看到两个交换函数Swap()由于参数的差别--传值与传址,而得到不同的调用结果。注意传址参数的方法调用无论在声明时还是调用时都要加上ref修饰符。
笼统地说传值不会改变参数的值在有些情况下是错误的,我们看下面一个例子:
using System; class Element { public int Number=10; } class Test { static void Change(Element s) { s.Number=100; } static void Main() { Element e=new Element(); Console.WriteLine(e.Number); Change(e); Console.WriteLine(e.Number); } }
程序经编译后执行输出:
10 100
我们看到即使传值方式仍然改变了类型为Element类的对象t。但严格意义上讲,我们是改变了对象t的域,而非对象t本身。我们再看下面的例子:
using System; class Element { public int Number=10; } class Test { static void Change(Element s) { Element r=new Element(); r.Number=100; s=r; } static void Main() { Element e=new Element(); Console.WriteLine(e.Number); Change(e); Console.WriteLine(e.Number); } }
程序经编译后执行输出:
10 10
传值方式根本没有改变类型为Element类的对象t!实际上,如果我们能够理解类这一C#中的引用类型(reference type)的特性,我们便能看出上面两个例子差别!在传值过程中,引用类型本身不会改变(t不会改变),但引用类型内含的域却会改变(t.Number改变了)!C#语言的引用类型有:object类型(包括系统内建的class类型和用户自建的class类型--继承自object类型),string类型,interface类型,array类型,delegate类型。它们在传值调用中都有上面两个例子展示的特性。
在传值和传址情况下,C#强制要求参数在传入之前由用户明确初始化,否则编译器报错!但我们如果有一个并不依赖于参数初值的函数,我们只是需要函数返回时得到它的值是该怎么办呢?往往在我们的函数返回值不至一个时我们特别需要这种技巧。答案是用out修饰的输出参数。但需要记住输出参数与通常的函数返回值有一定的区别:函数返回值往往存在堆栈里,在返回时弹出;而输出参数需要用户预先制定存储位置,也就是用户需要提前声明变量--当然也可以初始化。看下面的例子:
using System; class Test { static void ResoluteName(string fullname,out string firstname,out string lastname) { string[] strArray=fullname.Split(new char[]{' '}); firstname=strArray[0]; lastname=strArray[1]; } public static void Main() { string MyName="Cornfield Lee"; string MyFirstName,MyLastName; ResoluteName(MyName,out MyFirstName,out MyLastName); Console.WriteLine("My first name: {0}, My last name: {1}", MyFirstName, MyLastName); } }
程序经编译后执行输出:
My first name: Cornfield, My last name: Lee
在函数体内所有输出参数必须被赋值,否则编译器报错!out修饰符同样应该应用在函数声明和调用两个地方,除了充当返回值这一特殊的功能外,out修饰符ref修饰符有很相似的地方:传址。我们可以看出C#完全摈弃了传统C/C++语言赋予程序员莫大的自由度,毕竟C#是用来开发高效的下一代网络平台,安全性--包括系统安全(系统结构的设计)和工程安全(避免程序员经常犯的错误)是它设计时的重要考虑,当然我们看到C#并没有因为安全性而丧失多少语言的性能,这正是C#的卓越之处,“Sharp”之处!
数组参数也是我们经常用到的一个地方--传递大量的数组集合参数。我们先看下面的例子:
using System; class Test { static int Sum(params int[] args) { int s=0; foreach(int n in args) { s+=n; } return s; } static void Main() { int[] var=new int[]{1,2,3,4,5}; Console.WriteLine("The Sum:"+Sum(var)); Console.WriteLine("The Sum:"+Sum(10,20,30,40,50)); } }
程序经编译后执行输出:
The Sum:15 The Sum:150
可以看出,数组参数可以是数组如:var,也可以是能够隐式转化为数组的参数如:10,20,30,40,50。这为我们的程序提供了很高的扩展性。
同名方法参数的不同会导致方法出现多态现象,这又叫重载(overloading)方法。需要指出的是编译器是在编译时便绑定了方法和方法调用。只能通过参数的不同来重载方法,其他的不同(如返回值)不能为编译器提供有效的重载信息。
方法继承
第一等的面向对象机制为C#的方法引入了virtual,override,sealed,abstract四种修饰符来提供不同的继承需求。类的虚方法是可以在该类的继承自类中改变其实现的方法,当然这种改变仅限于方法体的改变,而非方法头(方法声明)的改变。被子类改变的虚方法必须在方法头加上override来表示。当一个虚方法被调用时,该类的实例--亦即对象的运行时类型(run-time type)来决定哪个方法体被调用。我们看下面的例子:
using System; class Parent { public void F() { Console.WriteLine("Parent.F"); } public virtual void G() { Console.WriteLine("Parent.G"); } } class Child: Parent { new public void F() { Console.WriteLine("Child.F"); } public override void G() { Console.WriteLine("Child.G"); } } class Test { static void Main() { Child b = new Child(); Parent a = b; a.F(); b.F(); a.G(); b.G(); } }
程序经编译后执行输出:
Parent.F Child.F Child.G Child.G
我们可以看到class Child中F()方法的声明采取了重写(new)的办法来屏蔽class Parent中的非虚方法F()的声明。而G()方法就采用了覆盖(override)的办法来提供方法的多态机制。需要注意的是重写(new)方法和覆盖(override)方法的不同,从本质上讲重写方法是编译时绑定,而覆盖方法是运行时绑定。值得指出的是虚方法不可以是静态方法--也就是说不可以用static和virtual同时修饰一个方法,这由它的运行时类型辨析机制所决定。override必须和virtual配合使用,当然也不能和static同时使用。
那么我们如果在一个类的继承体系中不想再使一个虚方法被覆盖,我们该怎样做呢?答案是sealed override (密封覆盖),我们将sealed和override同时修饰一个虚方法便可以达到这种目的:sealed override public void F()。注意这里一定是sealed和override同时使用,也一定是密封覆盖一个虚方法,或者一个被覆盖(而不是密封覆盖)了的虚方法。密封一个非虚方法是没有意义的,也是错误的。看下面的例子:
//sealed.cs // csc /t:library sealed.cs using System; class Parent { public virtual void F() { Console.WriteLine("Parent.F"); } public virtual void G() { Console.WriteLine("Parent.G"); } } class Child: Parent { sealed override public void F() { Console.WriteLine("Child.F"); } override public void G() { Console.WriteLine("Child.G"); } } class Grandson: Child { override public void G() { Console.WriteLine("Grandson.G"); } }
抽象(abstract)方法在逻辑上类似于虚方法,只是不能像虚方法那样被调用,而只是一个接口的声明而非实现。抽象方法没有类似于{…}这样的方法实现,也不允许这样做。抽象方法同样不能是静态的。含有抽象方法的类一定是抽象类,也一定要加abstract类修饰符。但抽象类并不一定要含有抽象方法。继承含有抽象方法的抽象类的子类必须覆盖并实现(直接使用override)该方法,或者组合使用abstract override使之继续抽象,或者不提供任何覆盖和实现。后两者的行为是一样的。看下面的例子:
//abstract1.cs // csc /t:library abstract1.cs using System; abstract class Parent { public abstract void F(); public abstract void G(); } abstract class Child: Parent { public abstract override void F(); } abstract class Grandson: Child { public override void F() { Console.WriteLine("Grandson.F"); } public override void G() { Console.WriteLine("Grandson.G"); } }
抽象方法可以抽象一个继承来的虚方法,我们看下面的例子:
//abstract2.cs // csc /t:library abstract2.cs using System; class Parent { public virtual void Method() { Console.WriteLine("Parent.Method"); } } abstract class Child: Parent { public abstract override void Method(); } abstract class Grandson: Child { public override void Method() { Console.WriteLine("Grandson.Method"); } }
归根结底,我们抓住了运行时绑定和编译时绑定的基本机理,我们便能看透方法呈现出的种种overload,virtual,override,sealed,abstract等形态,我们才能运用好方法这一利器!
外部方法
C#引入了extern修饰符来表示外部方法。外部方法是用C#以外的语言实现的方法如Win32 API函数。如前所是外部方法不能是抽象方法。我们看下面的一个例子:
using System; using System.Runtime.InteropServices; class MyClass { [DllImport("user32.dll")] static extern int MessageBoxA(int hWnd, string msg,string caption, int type); public static void Main() { MessageBoxA(0, "Hello, World!", "This is called from a C# app!", 0); } }
程序经编译后执行输出:
这里我们调用了Win32 API函数int MessageBoxA(int hWnd, string msg,string caption, int type)。
第七讲 域与属性
域
域(Field)又称成员变量(Member Variable),它表示存储位置,是C#中类不可缺少的一部分。域的类型可以是C#中任何数据类型。但对于除去string类型的其他引用类型由于在初始化时涉及到一些类的构造器的操作,我们这里将不提及,我们把这一部分内容作为“类的嵌套”放在“接口 继承与多态”一讲内来阐述。
域分为实例域和静态域。实例域属于具体的对象,为特定的对象所专有。静态域属于类,为所有对象所共用。C#严格规定实例域只能通过对象来获取,静态域只能通过类来获取。例如我们有一个类型为MyClass的对象MyObject,MyClass内的实例域instanceField(存取限制为public)只能这样获取:MyObject. instanceField。而MyClass的静态域staticField(存取限制为public)只能这样获取:MyClass.staticField。注意静态域不能像传统C++那样通过对象获取,也就是说MyObject.staticField的用法是错误的,不能通过编译器编译。
域的存取限制集中体现了面向对象编程的封装原则。如前所述,C#中的存取限制修饰符有5种,这5种对域都适用。C#只是用internal扩展了C++原来的friend修饰符。在有必要使两个类的某些域互相可见时,我们将这些类的域声明为internal,然后将它们放在一个组合体内编译即可。如果需要对它们的继承子类也可见的话,声明为protected internal即可。实际上这也是组合体的本来意思--将逻辑相关的类组合封装在一起。
C#引入了readonly修饰符来表示只读域,const来表示不变常量。顾名思义对只读域不能进行写操作,不变常量不能被修改,这两者到底有什么区别呢?只读域只能在初始化--声明初始化或构造器初始化--的过程中赋值,其他地方不能进行对只读域的赋值操作,否则编译器会报错。只读域可以是实例域也可以是静态域。只读域的类型可以是C#语言的任何类型。但const修饰的常量必须在声明的同时赋值,而且要求编译器能够在编译时期计算出这个确定的值。const修饰的常量为静态变量,不能够为对象所获取。const修饰的值的类型也有限制,它只能为下列类型之一(或能够转换为下列类型的):sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, bool, string, enum类型, 或引用类型。值得注意的是这里的引用类型,由于除去string类型外,所有的类型出去null值以外在编译时期都不能由编译器计算出他们的确切的值,所以我们能够声明为const的引用类型只能为string或值为null的其他引用类型。显然当我们声明一个null的常量时,我们已经失去了声明的意义--这也可以说是C#设计的尴尬之处!
这就是说,当我们需要一个const的常量时,但它的类型又限制了它不能在编译时期被计算出确定的值来,我们可采取将之声明为static readonly来解决。但两者之间还是有一点细微的差别的。看下面的两个不同的文件:
//file1.cs //csc /t:library file1.cs using System; namespace MyNamespace1 { public class MyClass1 { public static readonly int myField = 10; } } //file2.cs //csc /r:file1.dll file2.cs using System; namespace MyNamespace2 { public class MyClass1 { public static void Main() { Console.WriteLine(MyNamespace1.MyClass1.myField); } } }
我们的两个类分属于两个文件file1.cs 和file2.cs,并分开编译。在文件file1.cs内的域myField声明为static readonly时,如果我们由于某种需要改变了myField的值为20,我们只需重新编译文件file1.cs为file1.dll,在执行file2.exe时我们会得到20。但如果我们将static readonly改变为const后,再改变myField的初始化值时,我们必须重新编译所有引用到file1.dll的文件,否则我们引用的MyNamespace1.MyClass1.myField将不会如我们所愿而改变。这在大的系统开发过程中尤其需要注意。实际上,如果我们能够理解const修饰的常量是在编译时便被计算出确定的值,并代换到引用该常量的每一个地方,而readonly时在运行时才确定的量--只是在初始化后我们不希望它的值再改变,我们便能理解C#设计者们的良苦用心,我们才能彻底把握const和readonly的行为!
域的初始化是面向对象编程中一个需要特别注意的问题。C#编译器缺省将每一个域初始化为它的默认值。简单的说,数值类型(枚举类型)的默认值为0或0.0。字符类型的默认值为'\x0000'。布尔类型的默认值为false。引用类型的默认值为null。结构类型的默认值为其内的所有类型都取其相应的默认值。虽然C#编译器为每个类型都设置了默认类型,但作为面向对象的设计原则,我们还是需要对变量进行正确的初始化。实际上这也是C#推荐的做法,没有对域进行初始化会导致编译器发出警告信息。C#中对域进行初始化有两个地方--声明的同时进行初始化和在构造器内进行初始化。如前所述,域的声明初始化实际上被编译器作为赋值语句放在了构造器的内部的最开始处执行。实例变量初始化会被放在实例构造器内,静态变量初始化会被放在静态构造器内。如果我们声明了一个静态的变量并同时对之进行了初始化,那么编译器将为我们构造出一个静态构造器来把这个初始化语句变成赋值语句放在里面。而作为const修饰的常量域,从严格意义上讲不能算作初始化语句,我们可以将它看作类似于C++中的宏代换。
属性
属性可以说是C#语言的一个创新。当然你也可以说不是。不是的原因是它背后的实现实际上还是两个函数--一个赋值函数(get),一个取值函数(set),这从它生成的中间语言代码可以清晰地看到。是的原因是它的的确确在语言层面实现了面向对象编程一直以来对“属性”这一OO风格的类的特殊接口的诉求。理解属性的设计初衷是我们用好属性这一工具的根本。C#不提倡将域的保护级别设为public而使用户在类外任意操作--那样太不OO,或者具体点说太不安全!对所有有必要在类外可见的域,C#推荐采用属性来表达。属性不表示存储位置,这是属性和域的根本性的区别。下面是一个典型的属性设计:
using System; class MyClass { int integer; public int Integer { get {return integer;} set {integer=value;} } } class Test { public static void Main() { MyClass MyObject=new MyClass(); Console.Write(MyObject.Integer); MyObject.Integer++; Console.Write(MyObject.Integer); } }
一如我们期待的那样,程序输出0 1。我们可以看到属性通过对方法的包装向程序员提供了一个友好的域成员的存取界面。这里的value是C#的关键字,是我们进行属性操作时的set的隐含参数,也就是我们在执行属性写操作时的右值。
属性提供了只读(get),只写(set),读写(get和 set)三种接口操作。对域的这三种操作,我们必须在同一个属性名下声明,而不可以将它们分离,看下面的实现:
class MyClass { private string name; public string Name { get { return name; } } public string Name { set { name = value; } } }
上面这种分离Name属性实现的方法是错误的!我们应该像前面的例子一样将他们放在一起。值得注意的是三种属性(只读,只写,读写)被C#认为是同一个属性名,看下面的例子:
class MyClass { protected int num=0; public int Num { set { num=value; } } } class MyClassDerived: MyClass { new public int Num { get { return num; } } } class Test { public static void Main() { MyClassDerived MyObject = new MyClassDerived(); //MyObject.Num= 1; //错误 ! ((MyClass)MyObject).Num = 1; } }
我们可以看到MyClassDerived中的属性Num-get{}屏蔽了MyClass中属性Num-set{}的定义。
当然属性远远不止仅仅限于域的接口操作,属性的本质还是方法,我们可以根据程序逻辑在属性的提取或赋值时进行某些检查,警告等额外操作,看下面的例子:
class MyClass { private string name; public string Name { get { return name; } set { if (value==null) name="Microsoft"; else name=value; } } }
由于属性的方法的本质,属性当然也有方法的种种修饰。属性也有5种存取修饰符,但属性的存取修饰往往为public,否则我们也就失去了属性作为类的公共接口的意义。除了方法的多参数带来的方法重载等特性属性不具备外, virtual, sealed, override, abstract等修饰符对属性与方法同样的行为,但由于属性在本质上被实现为两个方法,它的某些行为需要我们注意。看下面的例子:
abstract class A { int y; public virtual int X { get { return 0; } } public virtual int Y { get { return y; } set { y = value; } } public abstract int Z { get; set; } } class B: A { int z; public override int X { get { return base.X + 1; } } public override int Y { set { base.Y = value < 0? 0: value; } } public override int Z { get { return z; } set { z = value; } } }
这个例子集中地展示了属性在继承上下文中的某些典型行为。这里,类A由于抽象属性Z的存在而必须声明为abstract。子类B中通过base关键字来引用父类A的属性。类B中可以只通过Y-set便覆盖了类A中的虚属性。
静态属性和静态方法一样只能存取类的静态域变量。我们也可以像做外部方法那样,声明外部属性。
第八讲 索引器与操作符重载
索引器
索引器(Indexer)是C#引入的一个新型的类成员,它使得对象可以像数组那样被方便,直观的引用。索引器非常类似于我们前面讲到的属性,但索引器可以有参数列表,且只能作用在实例对象上,而不能在类上直接作用。下面是典型的索引器的设计,我们在这里忽略了具体的实现。
class MyClass { public object this [int index] { get { // 取数据 } set { // 存数据 } } }
索引器没有像属性和方法那样的名字,关键字this清楚地表达了索引器引用对象的特征。和属性一样,value关键字在set后的语句块里有参数传递意义。实际上从编译后的IL中间语言代码来看,上面这个索引器被实现为:
class MyClass { public object get_Item(int index) { // 取数据 } public void set_Item(int index, object value) { //存数据 } }
由于我们的索引器在背后被编译成get_Item(int index)和set_Item(int index, object value)两个方法,我们甚至不能再在声明实现索引器的类里面声明实现这两个方法,编译器会对这样的行为报错。这样隐含实现的方法同样可以被我们进行调用,继承等操作,和我们自己实现的方法别无二致。通晓C#语言底层的编译实现为我们下面理解C#索引器的行为提供了一个很好的基础。
和方法一样,索引器有5种存取保护级别,和4种继承行为修饰,以及外部索引器。这些行为同方法没有任何差别,这里不再赘述。唯一不同的是索引器不能为静态(static),这在对象引用的语义下很容易理解。值得注意的是在覆盖(override)实现索引器时,应该用base[E]来存取父类的索引器。
和属性的实现一样,索引器的数据类型同时为get语句块的返回类型和set语句块中value关键字的类型。
索引器的参数列表也是值得注意的地方。“索引”的特征使得索引器必须具备至少一个参数,该参数位于this关键字之后的中括号内。索引器的参数也只能是传值类型,不可以有ref(引用)和out(输出)修饰。参数的数据类型可以是C#中的任何数据类型。C#根据不同的参数签名来进行索引器的多态辨析。中括号内的所有参数在get和set下都可以引用,而value关键字只能在set下作为传递参数。
下面是一个索引器的具体的应用例子,它对我们理解索引器的设计和应用很有帮助。
using System; class BitArray { int[] bits; int length; public BitArray(int length) { if (length < 0) throw new ArgumentException(); bits = new int[((length - 1) >> 5) + 1]; this.length = length; } public int Length { get { return length; } } public bool this[int index] { get { if (index < 0 || index >= length) throw new IndexOutOfRangeException(); else return (bits[index >> 5] & 1 << index) != 0; } set { if (index < 0 || index >= length) throw new IndexOutOfRangeException(); else if(value) bits[index >> 5] |= 1 << index; else bits[index >> 5] &= ~(1 << index); } } } class Test { static void Main() { BitArray Bits=new BitArray(10); for(int i=0;i<10;i++) Bits[i]=(i%2)==0; Console.Write(Bits[i]+" "); } }
编译并运行程序可以得到下面的输出:
True False True False True False True False True False
上面的程序通过索引器的使用为用户提供了一个界面友好的bool数组,同时又大大降低了程序的存储空间代价。索引器通常用于对象容器中为其内的对象提供友好的存取界面--这也是为什么C#将方法包装成索引器的原因所在。实际上,我们可以看到索引器在.NET Framework类库中有大量的应用。
操作符重载
操作符是C#中用于定义类的实例对象间表达式操作的一种成员。和索引器类似,操作符仍然是对方法实现的一种逻辑界面抽象,也就是说在编译成的IL中间语言代码中,操作符仍然是以方法的形式调用的。在类内定义操作符成员又叫操作符重载。C#中的重载操作符共有三种:一元操作符,二元操作符和转换操作符。并不是所有的操作符都可以重载,三种操作符都有相应的可重载操作符集,列于下表:
一元操作符 + - ! ~ ++ -- true false 二元操作符 + - * / % & | ^ << >> == != > < >= <= 转换操作符 隐式转换()和显式转换()
重载操作符必须是public和static 修饰的,否则会引起编译错误,这在操作符的逻辑语义下是不言而喻的。父类的重载操作符会被子类继承,但这种继承没有覆盖,隐藏,抽象等行为,不能对重载操作符进行virtual sealed override abstract修饰。操作符的参数必须为传值参数。我们下面来看一个具体的例子:
using System; class Complex { double r, v; //r+ v i public Complex(double r, double v) { this.r=r; this.v=v; } public static Complex operator +(Complex a, Complex b) { return new Complex(a.r+b.r, a.v+b.v); } public static Complex operator -(Complex a) { return new Complex(-a.r,-a.v); } public static Complex operator ++(Complex a) { double r=a.r+1; double v=a.v+1; return new Complex(r, v); } public void Print() { Console.Write(r+" + "+v+"i"); } } class Test { public static void Main() { Complex a=new Complex(3,4); Complex b=new Complex(5,6); Complex c=-a; c.Print(); Complex d=a+b; d.Print(); a.Print(); Complex e=a++; a.Print(); e.Print(); Complex f=++a; a.Print(); f.Print(); } }
编译程序并运行可得到下面的输出:
-3 + -4i 8 + 10i 3 + 4i 4 + 5i 3 + 4i 5 + 6i 5 + 6i
我们这里实现了一个“+”号二元操作符,一个“-”号一元操作符(取负值),和一个“++”一元操作符。注意这里,我们都没有对传进来的参数作任何改变--这在参数是引用类型的变量是尤其重要,虽然重载操作符的参数只能是传值方式。而我们在返回值时,往往需要“new”一个新的变量--除了true和false操作符。这在重载“++”和“--” 操作符时尤其显得重要。也就是说我们做在a++时,我们将丢弃原来的a值,而取代的是新的new出来的值给a! 值得注意的是e=a++或f=++a中e的值或f的值根本与我们重载的操作符返回值没有一点联系!它们的值仅仅是在前置和后置的情况下获得a的旧值或新值而已!前置和后置的行为不难理解。
操作符重载对返回值和参数类型有着相当严格的要求。一元操作符中只有一个参数。操作符“++”和“--”返回值类型和参数类型必须和声明该操作符的类型一样。操作符“+ - ! ~”的参数类型必须和声明该操作符的类型一样,返回值类型可以任意。true和false操作符的参数类型必须和声明该操作符的类型一样,而返回值类型必须为bool,而且必须配对出现--也就是说只声明其中一个是不对的,会引起编译错误。参数类型的不同会导致同名的操作符的重载--实际上这是方法重载的表现。
二元操作符参数必须为两个,而且两个必须至少有一个的参数类型为声明该操作符的类型。返回值类型可以任意。有三对操作符也需要必须配对声明出现,它们是“==”和“!=”,“>”和“<”,“>=”和“<=”。需要注意的是两个参数的类型不同,虽然类型相同但顺序不同都会导致同名的操作符的重载。
转换操作符为不同类型之间提供隐式转换和显式转换,主要用于方法调用,转型表达和赋值操作。转换操作符对其参数类型(被转换类型)和返回值类型(转换类型)也有严格的要求。参数类型和返回值类型不能相同,且两者之间必须至少有一个和定义操作符的类型相同。转换操作符必须定义在被转换类型或转换类型任何其中一个里面。不能对系统定义过的转换操作进行重新定义。两个类型也都不能是object或接口类型,两者之间不能有直接或间接的继承关系--这三种情况系统已经默认转换。我们来看一个例子:
using System; public struct Digit { byte value; public Digit(byte value) { if (value < 0 || value > 9) throw new ArgumentException(); this.value = value; } public static implicit operator byte(Digit d) { return d.value; } public static explicit operator Digit(byte b) { return new Digit(b); } }
上面的例子提供了Digit类型和byte类型之间的隐式转换和显式转换。从Digit到byte的转换为隐式转换,转换过程不会因为丢失任何信息而抛出异常。从byte到Digit的转换为显式转换,转换过程有可能因丢失信息而抛出异常。实际上这也为我们揭示了什么时候声明隐式转换,什么时候声明显示转换的设计原则。不能对同一参数类型同时声明隐式转换和显式转换。隐式转换和显式转换无需配对使用--虽然C#推荐这样做。
实际上可以看到,对于属性,索引器和操作符这些C#提供给我们的界面操作,都是方法的某种形式的逻辑抽象包装,它旨在为我们定义的类型的用户提供一个友好易用的界面--我们完全可以通过方法来实现它们实现的功能。理解了这样的设计初衷,我们才会恰当,正确地用好这些操作,而不致导致滥用和错用。