前言 0.1 提要 欢迎阅读《展现 C#》(Presenting C#)。这本书是你提高企业编程语言的一条捷径。这种企业编程语言带有下一代编程语言服务运行时(NGWS Runtime):C#(发音"C sharp")。 NGWS Runtime 是一个不仅管理执行代码、同时也提供使编程更加容易的动态环境。编译器产生受管代码以指向这种受管理执行环境。你获得跨平台语言集成、跨平台语言异常处理、增强安全性、版本控制、安排支持和查错服务。 支持NGWS Runtime 的主要语言是C#。支持NGWS框架的很多程序是用C#写的,因此,在一些带有NGWS Runtime的编译器中,它的编译器可以被认为是非常经得起测试且是经过优化的。C#语言借鉴了C++,但是具备现代化和新增的类型安全——使C#成为企业解决方案的首选语言。 0.2 谁应该读这本书 如果你对编程很陌生,这本书不适合你。这本书企图让程序员解脱并使用C#,基于他们已经拥有的知识。《展现 C#》的目标瞄准了已经具有如C/C++、VB、Java或其它编程经验的程序员。 如果你具有C++的背景,转向C#会很容易,但是,如果你精通于其它不同的语言,这本书也会耗掉你的一些日子。如果你有一点COM编程的知识,这本会更有趣,但会COM编程不再是必要的。 0.3 这本书如何组织 第一章 C#简介——这一章把你带到C#逛一回,同时回答了有关你应该考虑学习C#的问题。 0.4 使用这本书你需要什么? 从这本书的观点看,你所需要的就是下一代windows服务软件开发包(NGWS SDK)。尽管至少只要有NGWS Runtime 和C#编译器就可以,但当探索这些激动人心的新技术功能 时,在一台机器上装有说明文件档和所有的SDK工具(包括debugger),将是一个极好的主意。 0.5 关于作者 Christoph Wille,微软系统工程师(MCSE)、微软系统开发师(MCSD)、Netware网络管理员(CNA)和MCP-IT,作为一个网络咨询人员和程序员,特别精通Windows DNA。微软认为他是ASP方面最有价值的人(MVP),他是和微软在一起使用早期的C#版本的少数开发者之一。 第一章 C# 简介 欢迎您加入C#的世界! 这一章将把您引进C#的天地,并回答一些相关的问题,如:您为什么要使用C#,C++和C#的主要有什么不同点,以及为什么C#使开发更容易而且还使您感到很有趣。 为什么是另外一种编程语言? C#语言自C/C++演变而来。但是,它现代、简单、完全面向对象和类型安全。如果您是C/C++程序员,学习曲线将会很平坦。许多C#语句直接借用您所喜爱的语言,包括表达式和操作符。假如不仔细看,简直会把它当成C++。 关于C#最重要的一点:它是现代的编程语言。它简化和现代化了C++在类、名字空间、方法重载和异常处理等领域。屏弃了C++的复杂性,使它更易用、更少出错。 对C#的易用有贡献的是减少了C++的一些特性,不再有宏、模板和多重继承。特别对企业开发者来说,上述功能只会产生更多的麻烦而不是效益。使编程更方便的新功能是严格的类型安全、版本控制、垃圾收集(garbage collect)等等。所有的这些功能的目标都是瞄准了开发面向组件的软件。 在继续呈现出更多的功能之前,我想停下来并在下面说明C#至关重要的各种要素。 简单 简单 C#具有C++所没有的一个优势就是学习简单。该语言首要的目标就是简单。很多功能(还不如说是缺少了C++的一些功能)有助于C#全方位的简单。 在C#中,没有C++中流行的指针。默认地,您工作在受管理的代码中,在那里不允许如直接存取内存等不安全的操作。我想没有C++程序员可以声称,从没有使用指针访问过不属于他们的内存。 与指针"戏剧性"密切相关的是"愚蠢的"操作。在C++中,有::、.、和->操作符,它们用于名字空间、成员和引用。对于新手来说,操作符至今仍是学习的一道难关。C#弃用其它操作符,仅使用单个操作符 "."。现在一个程序员所需要理解的就是嵌套名字的注解了。 您不必记住基于不同处理器架构的隐含的类型,甚至各种整型的变化范围。C#使用统一的类型系统,屏弃了C++多变的类型系统。这种系统充许您把各种类型作为一个对象查看,它是一个原始类型还是一个full-blown 类。和其它编程语言相比,由于加框(boxing)和消框(unboxing)的机制,把简单类型当作对象处理并不能获得性能的改善。稍后将详细解释加框和消框,但基本上仅当需要时才使用对象访问简单类型这种技术。 首先,老练的程序员可能不喜欢它,但是整型和布尔型如今终归是两种完全不同的数据类型。这就意味着原来if语句中错误的赋值现在会被编译出错,因为if语句只接受布尔类型的值。再也不会出现误用赋值符为比较符这样的错误!(首次学C#,cnbruce就感受到了,“=”and “==”:) C#同时也解决了存在于C++中已经有些年头的多余东西(redundancies)。这种多余包括常数预定义,不同字符类型等。鉴于多余表单已经从该语言中消失,故一般在C#中都可以使用表单了。 |
现代 您投入学习C#的努力是一笔大投资,因为C#是为编写NGWS 应用程序的主要语言而设计。您将会发现很多自己用C++可以实现或者很费力实现的功能,在C#中不过是一部分基本的功能而已。 对于企业级的编程语言来说,新增的金融数据类型很受欢迎。您用到了一种新的十进制数据类型,它专用于金融计算方面。如果不喜欢这种现成简单的类型,根据您应用程序的特殊需求,可以很容易地创建出新的一种数据类型。 我已经提到,指针不再是您编程武器的一部分。不要太惊讶,全面的内存管理已经不是您的任务。运行时NGWS提供了一个垃圾收集器,负责C#程序中的内存管理。因内存和应用程序都受到管理,所以很必要增强类型安全,以确保应用的稳定性。 对于C++程序员,异常处理的切不是新的东西,但它是C#的主要功能。C#的异常处理与C++的不同点在于它是交叉语言的(运行时的另一个功能)。在没有C#之前,您必须处理怪异的HRESULTs,但现在由于使用了基于异常的健壮的出错处理, 这一切都 结束了 对于现代的应用程序,安全是首要的,C#也不会例外。它提供了元数据语法,用于声明下述NGWS安全模式的能力和许可。元数据是NGWS运行时的一个关键的概念,下一章将涉及到它更深的含义。 面向对象 您不会预料一种新语言不支持面向对象的功能吧? C#当然支持所有关键的面向对象的概念,如封装、继承和多态性。完整的C#类模式构建在NGWS运行时的虚拟对象系统(VOS,Virtual Object System)的上层,VOS将在下章描述。对象模式只是基础的一部分,不再是编程语言的一部分。 您一开始必须关注的事,就是不再有全局函数、变量或者是常量。所有的东西都封装在类中,包括事例成员(通过类的事例--对象可以访问)或都静态成员(通过数据类型)。这些使C#代码更加易读且有助于减少潜在的命名冲突。 定义类中的方法默认是非虚拟的(它们不能被派生类改写)。主要论点是,这样会消除由于偶尔改写方法而导致另外一些原码出错。要改写方法,必须具有显式的虚拟标志。 这种行为不但缩减速了虚拟函数表,而且还确保正确版本的控制。 使用C++编写类,您可以使用访问权限(access modifiers) 给类成员设置不同的访问等级。C#同样支持private、protected 和public 三种访问权限 ,而且还增加了第四种:internal。有关访问权限 的详细情况将在第五章 "类" 中说明。 您曾经创建了多少个类是从多基类派生出来的(ATL 程序员,您的投票不计在内!) ? 大多数情况,仅需从一个类派生出。多基类惹出的麻烦通常比它们解决的问题还多。那就是为什么C#仅允许一个基类。如果您觉得需要多重继承,可以运用接口。 一个可能出现的问题:在C#中不存在指针,如何模仿它? 这个问题的答案很有代表性,它提供了对NGWS运行时事件模式的支持。再次,我将把对它的全面解释放到第五章。 类型安全 我再次选指针作为一个例子。在C++中拥有一个指针,您能自由地把它强制转换成为任何类型,包括干出诸如把一个int*(整型指针)强制转换成一个double *(双精度指针)这样的傻事。只要内存支持这种操作,它就"干过"。这并不是您所想象的企业级编程语言的类型安全。 纲要性的问题,C#实施最严格的类型安全,以保护自己及垃圾收集器(garbage collector)。所以必须遵守C#中一些相关变量的规则: 您不能使用没有初始化的变量。对于对象的成员变量,编译器负责清零。而局部变量,则由您负责清零。当您使用一个没有初始化的变量时,编译器会教您怎么做。优点是能够避免由于使用不经初始化的变量计算结果而导致的错误,而您还不知道这些奇怪的结果是如何产生的。 C#取消了不安全的类型转换。不能把一个整型强制转换成一个引用类型(如对象),而当向下转换时,C#验证这种转换是正确的。(也就是说,派生类真的是从向下转换的那个类派生出来的。) 边界检查是C#的一部分。再也不会出现这种情况:当数组实际只定义了n-1个元素,却超额地使用了n个元素。 算术运算有可能溢出终值数据类型的范围。C#允许在语句级或应用程序级检测这些运算。在允许检测溢出的情况下,当溢出发生时将会抛出一个异常。 在C#中,被传递的引用参数是类型安全的。 版本可控(Versionable) 在过去的几年中,几乎所有的程序员都至少有一次不得不涉及到众所周知的"DLL地狱"。该问题起因于多个应用程序都安装了相同DLL名字的不同版本。有时,老版本的应用程序可以很好地和新版本的DLL一起工作,但是更多的时候它们会中断运行。现在的版本问题真是令人头痛。 就象您将在第八章"用C#写组件"所看到的,NGWS runtime 将对您所写的应用程序提供版本支持。C#可以最好地支持版本控制。尽管C#不能确保正确的版本控制,但是它可以为程序员保证版本控制成为可能。有这种支持,一个开发人员就可以确保当他的类库升级时,仍保留着对已存在的客户应用程序的二进制兼容。 兼容 C#并没有存在于一个封闭的世界中。它允许使用最先进的NGWS的通用语言规定(Common Language Specification,简写为CLS)访问不同的API。CLS规定了一个标准,用于符合这种标准的语言的内部之间的操作。为了加强CLS的编译,C#编译器检测所有的公共出口编译,并在通不过时列出错误。 当然,您也想能够访问旧一点的COM对象。NGWS运行时提供对COM透明的访问。如何集成原来的代码将在第10章"非管理代码的内部操作"有介绍。 OLE 自动化是一种特殊的动物。任一个使用C++创建OLE自动化项目的人已经喜欢上各种各样的自动化数据类型。有个好消息就是C#支持它们,而没有烦锁的细节。 最后,C#允许您 用C 原型的API进持内部操作。可以从您的应用程序访问任何DLL中的入口点(有C的原型)。用于访问原始API的功能称作平台调用服务(Plaform Invocation Services ,缩写PInovke),第10章将展示使用C API进行内部操作的一些例子。 灵活 上一部分的最后一段有可能提醒了程序员。您可能会问:"难道就没有我要传递指针的API吗?" 您是正确的。不是仅有少数的这种API,而是很多(有点保守的估计)。这种对原始WIN32代码的访问有时导致对非安全类指定指针的使用(尽管它们中的一些由于受COM和PInvoke的支持可以解决)。 尽管C#代码的缺省状态是类型安全的,但是您可以声明一些类或者仅声明类的的方法是非安全类型的。这样的声明允许您使用指针、结构,静态地分配数组。安全码和非安全码都运行在同一个管理空间,这样暗示着当从安全码调用非安全码时不会陷入列集(marshaling)。 小结 C#语言从C和C++演变而来,它是给那些愿意牺牲C++一点底层功能,以获得更方便和更产品化的企业开发人员而创造的。C#现代、简单、面向对象和类型安全。尽管它借鉴了C和C++的许多东西,但是在一些诸如名字空间、类、方法和异常处理等特定领域,它们之间还存在着巨大的差异。 C#为您提供了方便的功能,如垃圾收集、类型安全、版本控制,等等。仅有的"代价"就是,代码操作默认是类型安全的,不允许指针。光是类型安全就可以搞定了。但是,如果您需要指针,仍可以通过非安全码使用它们,而且当调用非安全码时,不能含有列集 |
第二章 理论基础-公用语言 运行环境 既然你已经具有了C#全面的印象,我也想让你了解NGWS runtime的全貌。C#依靠由NGWS提供的运行时;因此,有必要知道运行时如何工作,以及它背后所蕴含的概念。 2.1 NGWS Runtime NGWS和NGWS Runtime为你提供了一种运行时环境。该运行时管理执行代码,并提供了使编程更容易的服务。只要你的编译器支持这种运行时,你就会从这种受管理的执行环境中得益。 你猜测C#编译器支持NGWS runtime很正确,但是不仅它支持NGWS runtime,VB和C++也支持。这些为支持运行时所创建的代码称作"受管代码"(managed code)。以下是你的应用程序从NGWS runtime那里所得到的利益: 交叉语言集成(通过通用语言规范) 因NGWS runtime 要提供了所有的这些好处,编译器必须把元文件和受管代码一起发出。元文件描述代码中的类型,它和你的代码存在一起(与PE类似---PE为可变位执行文件) 正如你从很多种交叉语言功能所看到的,NGWS runtime主要是关于高度集成交叉多异编程语言(tight integration across multiple different programming languages)。这种支持可达到允许你从一个VB对象派生出一个C#类的程度(我后面会给出要讨论的文章)。 C#程序员将会喜欢的一个功能是,他们不必担心内存管理—也就是说不必担心臭名昭著的内存泄漏。NGWS runtime提供了内存管理,当对象和变量的生命期结束(不再被引用)时,垃圾收集器释放它们。我真的喜欢这个功能,因为在COM中的内存管理一直是我的一块心病。 应该鼓励配置一个管理应用程序或者组件。因为管理应用程序含有元数据文件,NGWS runtime可以利用这些信息,以确保你的应用程序具有它所需的各种规定版本。所产生的明显效果为,由于你的代码没有相互之间的依赖,很少可能出现中断。 这章余下来的将分为两部分,每一部分讨论NGWS runtime的各个方面,直到你的C#应用程序能执行为止。 2.1.1 中间语言和元数据 由C#编译器生成的受管代码并不是原始代码,但它是中间语言(IL)代码。这种IL代码自身变成了NGWS runtime的受管执行进程的入口。IL代码明显的优势在于它是CPU无关的,这也意味着,你要用目标机器上的一个编译器才能把IL代码转换成原始代码。 尽管IL代码由编译器产生,但它并不是编译器提供给运行时仅有的东西。编译器同样产生有关你代码的元数据,它告诉运行时有关你代码的更多的东西,例如各种类型的定义、各种类型成员的签名以及其它数据。基本上,元数据是类型库、注册表内容和其它用于COM的信息。尽管如此,元数据还是直接和执行代码合并在一起,并不处在隔离的位置。 在进一步说明之前,我想给你已有的IL指令的简短目录。尽管它不是一个完整的清单,也不需要你熟记和理解,但是它列出了你所必需的、C#程序所基于的知识基础。 算术和逻辑操作符 |
2.1.2 即时编译器(JITters) 由C#或其它能产生受管代码的编译器所生成的受管代码就是IL码。虽然IL代码被包装在一个有效的PE文件中,但是你还是不能执行它,除非它被转换成为受管原始代码。这就是NGWS runtime 即时编译器(也称作JITters)大显身手的时候。 从技术上说,全部的处理过程如下:当一个类型被装载时,装载器创建一个存根(stub),并使它连接每一个类型的方法。当一个方法第一次被调用时,存根把控制交给JIT。JIT把IL编译为原始代码,且把存根指针指向缓冲了的原始代码。接着的调用将执行原始码。在某些位置上(At some point),所有的IL都被转换成为原始代码,而JITter处于空闲状态。 正如我在前面提到的,JIT编译器有很多,不止一个。在Windows平台上,NGWS runtime装有3个不同的JIT编译器。 JIT——这是NGWS runtime默认使用的JIT编译器。它是一个后台(back end)优化的编译器 ,在前台(up front)实行数据流分析,并创建了高度优化的受管原始代码做为输出结果。JIT可以使用不严格的IL指令集编码,但是所需资源将十分可观。主要的限制在于内存足迹(footprint)、结果工作集,以及实行优化所消耗的时间。 EconoJIT—— 和主JIT相比,EconJIT的目标是把IL高速地转换成受管原始代码。它允许缓冲所产生的原始代码,但是输出码并不象主JIT生成的代码那样优化(代码小)。当内存紧张时,快速代码生成方案的优势将荡然无存。通过永久地抛弃无用的已JIT过的代码,你可以把更大的IL程序装入代码缓冲区。因为JIT编译快,执行速度也仍然很快。 PreJIT—尽管它是基于主JIT的,但操作起来更象是一个传 统的编译器。你安装了NGWS组件,它才能运行,才可以把IL代码编译成受管原始代码。当然最终的结果为,更快的装载时间和更快的应用程序启动时间(不需要更多的JIT编译)。 在所列出的JITters中,有两个是运行时的JITters。可是你怎么决定要使用哪一个JIT,它如何使用内存? 有一个称做"JIT编译管理器"的小应用程序(jitman.exe),它存放于NGWS SDK安装目录下的bin目录中。 尽管它是一个小小的对话框,可是你所选择的选项功能是相当强大的。每一个选项将在以下描述。 Use EconoJIT only 选项——当该复选框没有选上时,NGWS runtime使用默认的正常的JIT编译器。前面就曾经解释过两种JITter的区别。 Max Code Pitch Overhead(%)选项——该设置仅保留给EconoJIT。它控制了JIT编译时间和执行代码时间的百分比。如果超过了设定的域值,代码缓冲区得到扩充,以缩短JIT编译所消耗的时间。 Limit Size of Code Cache选项——该项默认为非选。没有选择该项意味着缓冲区将使用它所能得到的内存。如果你想限制缓冲区大小,复选该选项,这将允许你使用Max Size of Cache(bytes)选项。 Max Size of Cache(bytes)选项—控制容纳JIT代码的缓冲区的最大值。虽然你可以非常严格地限制这个值,但你还是应该小心,不能超过这个缓冲区所适合的最大值。否则该方法的JIT编译将会失败。 Optimize For Size选项——告诉JIT 编译器,优化的目的是为了使代码更小而不是能执行得更快。这个设置默认是关掉的。 Enable Concurrent GC[garbage collection]选 项——垃圾收集(GC)默认地运行在用户代码的线程中。意味GC发生时,可能会注意到回应有轻微的延迟。为防止出现该现象,打开当前GC。注意,当前GC比标准GC更慢,它仅在windows 2000上写时(the time of writing)有效。 当用C#创建项目时,你可能使用不同的设置试验过。当创建 UI-intensive应用程序时,你将会看到允许当前GC的最大差别。 2.2 虚拟对象系统(VOS) 到目前为止,你仅看到了NGWS runtime如何工作,但是并不了解它工作的技术背景以及为什么它要这样工作。这节都是关于 NGWS 虚拟对象系统的(VOS)。 以下为在VOS中形成声明、使用和管理类型模型时,NGWS runtime的规则。在VOS背后的思想是建立一个框架,在执行代码时不能牺牲性能,允许交叉语言集成和类型安全。 我提到的框架是运行时架构的基础。为了帮助你更好地了解它,我将它勾出四个区域。当开发C#应用程序和组件时,理解它们很重要。 VOS类型系统——提供丰富的类型系统,它打算支持全面编程语言的完全实施。 |
通用语言规范(CLS)——CLS定义了VOS中类型的子集,也定义了常规的用法。如果一个类库遵守CLS的规则,它确保类库可以在其它所有能实现CLS的编程语言上使用。 虚拟执行系统(VES)——这是VOS实时的实现。VES负责装入和执行为NGWS运得时编写的程序。 这四个部分一起组成了NGWS runtime架构。每一部分在下面小节中描述。 2.2.1 VOS类型系统 VOS类型系统提供丰富的类型系统,它打算支持多种编程语言的完全实施。所以,VOS必须都支持面向对象的语言和过程编程语言。 现在,存在着很多种近似但有点不兼容的类型。就拿整型当例子,在VB中,它是16位长,而在C++中,它是32位。还有更多的例子,特别是用在日期和时间以及数据库方面的数据类型。这种不兼容使应用程序的创建和维护不必要地复杂化,尤其当程序使用了多种编程语言时。 另一个问题是,因为编程语言之间存在着一些差别,你不能在一种语言中重用另一种语言创建的类型。(COM用二进制标准接口部分地解决了这个问题)。 当今代码重用肯定是有限的。 发布应用程序的最大障碍是各种编程语言的对象模型不统一。几乎每一方面都存在着差异:事件、属性、永久保存(persistence)等等。 VOS这里将改变 这种现象 。VOS定义了描述值的类型,并规定了类型的所有值所必须支持的一条合约。由于前面提到的支持面向对象和过程编程语言,就存在着两种值和对象。 对于值,类型存储于表述(representation)中,同样操作也在其中实行。对象更强大因为它显式地存于表述中。每一个对象都有一个区别于其它对象的识别号。支持不同的VOS类型在第四章 "C#类型"中提出。 2.2.2元数据 尽管元数据用于描述和引用由VOS类型系统定义的类型,但它还不能锁定到这个单个目标。当你写一个程序时,通过利用类型声明,你所声明的类型(假定它们是数值类型或引用类型)被介绍给NGWS runtime类型系统。类型声明在存于PE可执行文件内部的元数据中得到描述。 基本上,元数据用于各项任务:用于表示NGWS runtime用途的信息,如定位和装载类、 内存中这些类的事例、解决调用 、翻译IL为原始码、加强安全并设置运行时上下文边界。 你不必关心元数据的生成。元数据是由C#的"代码转IL编译器"(code-to-IL compiler,不是JIT编译器)生成的。代码转IL编译器发送二进制元数据信息给PE文件,是以标准的方式发送的,不象C++编译器那样,为出口函数创建它们自己的修饰名字。 你从元数据和可执行代码并存所获得的主要优势为,有关类型的信息同类型自身固定在一起,不会遍布很多地方。同样有助于解决存在于COM中的版本问题。进一步地,你可以在相同的上下文中使用不同的版本库,因为库不仅被注册表引用,也被包含在可执行代码中的元数据引用。 |
2.2.3通用语言规范 通用语言规范(CLS)并不是虚拟对象系统(VOS)真正的一部分,它是特殊的。CLS定义了VOS中的一个类型子集,也定义了必须符合CLS的常规用法。 那么,对此有什么迷惑呢?如果一个类库遵守CLS规则,其它编程语言同样也遵守CLS规则,那么其它编程语言的客户也可以使用类库。CLS是关于语言的交互可操作性(interoperability)。因此,常规用法必须仅遵循外部可访问项目 (externally visible items)如方法、属性和事件等等。 我所描述的优点是你可以做以下工作。用C#写一个组件,在VB中派生它,因加在VB中的功能是如此之强大,在C#中再次从VB类派生它。只要所有的外部可访问项遵守CLS规则,这样是可行的。 这个清单不完整。它仅包含一些很重要的项目。我不指出出现在本书中每一种类型的CLS协定,所以有个好主意:当你寻找CLS协定时,至少应该用浏览该表,以了解哪种功能有效。不要担心你不熟悉这章表中的每一个含义,在这本书中你会学到它们。 表2.1 通能语言规范中的类型和功能 bool
类型(Types) 可以被抽象或隐藏。 类型成员 类型成员允许隐藏或者覆盖另一种类型中的其它成员。 方法 一种方法可以是静态、虚拟或者实例。 字段(Fields) 可以是静态或者是非静态。 属性 当获取和设置方法而不是使用属性语法时,属性可以公开。 枚举(Enumerations) 强调类型必须是byte、short、int 或long。 异常 可以被引发和被捕获。 接口 可需要实现其它接口。 事件 增加和取消方法必须是都提供或者都没有 ,每一种方法采用一个参数,它是一个从系统代表元(System Delegate)派生下来的类。 自定义属性 可以仅使用下更类型:Type(类型),char, char, bool, byte, short, int, long, float, double, enum (一种CLS 类型), and object. 代表元(Delegates) 可以被创建和被激活 标识符(Identifiers) 一个标识符的第一个字母必须来自一限制集。 |
2.2.4虚拟执行系统(VES) 虚拟执行系统实现了虚拟对象系统。通过实现一个负责NGWS runtime的执行引擎(execution engine,缩写EE)创建VES。这个执行引擎执行你用C#编写和编译的应用程序。 下列组件为VES的一部分。
这一章,我带你逛了一回运行时的世界。我描述了当创建、编译和配置C#程序时它是如何工作的。你学会了中间语言(IL),还有元数据是如何用于描述被编译为IL的类型。元数据和IL都用于JITter检测和执行你的代码。你甚至可以选择用哪一种JITter来执行应用程序。 |
第三章 第一个C#应用程序 尽管我是一个顽固的Notepad狂,但这次我不建议用它编辑源码。原因是你正在与真正的编程语言打交道,使用Notepad编辑源码编译时可能产生大量的错误信息行(C++程序员知道我在说什么。) 你有几种选择。可以重新配置你信任的老式Visual C++ 6.0,使它能够和C#源文件一起工作。第二种选择是使用新的Visual Studio 7。第三,你可以用任何第三方程序编辑器,最好要支持行数、色彩编码、工具集成和良好的搜索功能。CodeWright就是其中一个例子,CodeWright 是你可以用于创建C#代码文件众多可能编辑器中的一个。 当然,在所提到的编辑器中,没有一个对创建C#程序来说是必要的。用Notepad肯定可以编辑。但是,如果你考虑到要编写更大的项目,最好还是忍痛割爱吧。(cnbruce建议采用Visual Studio .Net) 3.1 "Hello World" 代码 讨论编辑器有点离题 ,让我们把话题转回到一个非常出名的小应用程序。这个最短的C#版本应用程序见清单3.1。把它存起来,文件名为 helloworld.cs,以便使你能按照说明,完成诸如编译应用程序等其它余下来的步骤。 清单 3.1 最简单的 "Hello World "程序 (1,2,3,……7为行号,非程序:)
1: class HelloWorld
2: { 3: public static void Main() 4: { 5: System.Console.WriteLine("Hello World"); 6: } 7: } 在C#中,代码块(语句组)由大括弧({和})所括住。所以,甚至你以前没有C++的经验,你也可以说出Main()方法就是HelloWorld 类语句的一部分,因为类被括在所定义的大括弧中。 C#,它必须包含在一个类中。仅有一个类能使用该标志定义,除非你告诉编译器它应使用哪一个 Main 方法(否侧,会产生一个编译错误)。应用程序(可执行)的入口点就是 static Main 方法 和C++相比,Main的第一个字母是大写的M,而不是你曾经使用过的小写字母。在这个方法中,你的程序开始并结束。方法中可以调用其它方法——如这个例子中,用于输出文本——或者创建对象并激活该方法。 正如你所看到的,Main方法返回一个void类型。 尽管看到这些语句时,C++程序员肯定会觉得似曾相识,但是其他程序员并不如此。首先,public 的访问标志告诉我们这个方法可以被任何程序访问,这是它被调用的必要条件。其次,static 意味着没有先创建类的实例也可以调用方法——你所要做的就是用类名调用方法。 但是,我不赞成在Main方法中执行这行代码,递归会导致堆栈溢出。 另一重要的方面是返回类型。对于方法Main,可选择void (意味着根本就没有返回值),或用int 为整型结果(应用程序返回的错误级别)。因此,两种可能的Main方法为: C++程序员会同样知道后面我要提到的——可以传给应用程序的命令行参数数组。如: 我现在并不想详细地说明如何访问参数,但我想事先给C++程序员一个警告:和C++相比,应用程序路径不是这个数组的一部分。仅仅那些参数包含在这个数组中。 在对Main方法并不简短的介绍之后,让我们把注意力集中到唯一真正的代码行——这行代码在屏幕上显示"Hello Wold"。 假如不是由于有了System,大家会马上猜到WriteLine是Console 对象的一个静态方法。那么System代表什么呢? 它是包含Console对象的名字空间(范围),实际上并不是每次都在Console对象前加上名字空间的前缀,你可以象清单3.2所示范的那样,在应用程序中引入名字空间。 清单3.2 在应用程序中引入名字空间
1: using System;
2: 3: class HelloWorld 4: { 5: public static void Main() 6: { 7: Console.WriteLine("Hello World"); 8: } 9: } 所有你要做的就是给System名字空间加一个using指令。在这之后,不再需要规定名字空间,就可以使用它们的方法和属性了。NGWS 框架体系中有很多的名字空间,我只对巨大的名字空间池中的少数几个对象进行探讨。但在第八章 "用C#写组件"将介绍为你的对象创建自己的名字空间。 |
3.2 编译应用程序 由于NGWS Runtime支持所有的编译器(VB、C++和C#),你不必买一个单独的开发工具用来把应用程序编译成IL(中间语言)。但是,如果你从没有用过命令行编译器编译过应用程序(仅懂得编译名,而没有熟记), 它还是你的首要选择。
csc helloworld.cs
helloworld.cs 被编译并链接成hellworld.exe。因为源码没有错误(那当然!),C#编译器没有出错提示,在整个编译过程没有丝毫停顿。 现在你已经准备好运行第一个真正用C#编写的应用程序。简单地在命令行上敲入helloworld,输出结果为 "Hello World"。 在继续往下介绍之前, 我想稍为想象一下第一个应用程序和一个编译器开关的使用: 这个开关告诉编译器输出文件命名为hello.exe。虽然这不是什么绝招,但它是这本书中用到的未来编译器的基本功。 3.3 输入和输出 到目前为止,我仅仅演示了把简单的常量字符串输出到屏幕。尽管这本书只介绍了C#编程的概念而不介绍用户接口编程,但我需要让你迅速学会简单的屏幕输入和输出方法——相应于C的scanf 和 printf,或者C++的cin 和cout。我不能提供VB相应的函数,因为屏幕访问不是该核心语言的一部分。 Listing 3.3 从控制台读输入信息
1: using System;
2: 3: class InputOutput 4: { 5: public static void Main() 6: { 7: Console.Write("Please enter your name: "); 8: string strName = Console.ReadLine(); 9: Console.WriteLine("Hello " + strName); 10: } 11: } 第7行使用Console对象的一个新方法用于提示文本信息给用户,它就是Write方法。它与WriteLine不同的地方在于它输出时不换行。我使用这种方法以便用户可以在信息提示的同一行输入名字。 你几乎已学完了NGWS框架必要的输入和输出功能。但是,你还需要为用户显示多个值。为用户写一个格式串。清单3.4展示一个例子。 清单 3.4 使用不同的输出方法
1: using System;
2: 3: class InputOutput 4: { 5: public static void Main() 6: { 7: Console.Write("Please enter your name: "); 8: string strName = Console.ReadLine(); 9: Console.WriteLine("Hello {0}",strName); 10: } 11: } 第9行包含了使用格式串的Console.WriteLine语句。格式串例子如下: 当然,并不仅限于只使用字符串变量。你可以使用任何类型,这些类型在后面的第四章 "C#类型"中有讨论。 3.4 添加注释 当写代码时,你应为代码写注释条文,解释实现的内容、变更史等。尽管你注释中提供的信息(如果有的话)是给你写的,但是你还是必须遵守写C#注释的方法。清单3.5 显示采用的两种不同的方式。 清单3.5 给你的代码添加注释
1: using System;
2: 3: class HelloWorld 4: { 5: public static void Main() 6: { 7: // 这是单行注释 8: /* 这种注释 9: 跨越多行 */ 10: Console.WriteLine(/*"Hello World"*/); 11: } 12: } "//" 符号用于单行注释。你可以用"//"注释当前所在行,或是跟在一个代码语句的后面: 如果你的注释跨越多行,必须使用"/* */"的字符组合。这种方式在C中有效。除了单行注释外,这种方式在C++和C#中还同样有效。因C/C++和C#都使用这种多行注释方式,所以它们也使用相同的终结符。请看下列代码行: 我使用"/* */"简单地注释一整行。现在我假定这一行是很长代码的一部分,而且我决定要暂时禁用一个程序块: 这个结构所存在的问题为: "Hello World"那一行后面的"*/"终止了始于第一行的"/*"的注释,余下的代码对编译器有效,你将看到一些有趣的出错信息。至少 最后的"*/"被标志为归属错误。我只不过想提醒一下,让你了解这种错误。 3.5小结 在这一章中,你创建、编译并执行了第一个C#应用程序:著名的"Hello World"程序。我用这个短短的应用程序给你介绍有关Main方法,它是一个应用程序的入口点,也是出口点。这个方法可以没有返回值或返回一个整数错误级别。如果你的应用程序用参数调用,你可以(但不必要)读出并使用它们。 在编译和测试应用程序后,你学到了更多的由Console对象提供的有关输入和输出的方法。对于学习C#而言,它们足以创建出有意义的控制台例子,但用户接口的大部分将是WFC、WinForms或者ASP+(ASP.NET)。 |
第四章 C#类型 既然你知道了怎样创建一个简单的C#程序,我将会给你介绍C#的类型系统。在这一章中,你学到如何使用不同的值和引用类型,加框和消框机制能为你作些什么。尽管这一章的不侧重于例子,但你可以学到很多重要的信息,关于如何创建现成类型的程序。 4.1 值类型 各种值类型总是含有相应该类型的一个值。C#迫使你初始化变量才能使用它们进行计算-变量没有初始化不会出问题,因为当你企图使用它们时,编译器会告诉你。 每当把一个值赋给一个值类型时,该值实际上被拷贝了。相比,对于引用类型,仅是引用被拷贝了,而实际的值仍然保留在相同的内存位置,但现在有两个对象指向了它(引用它)。C#的值类型可以归类如下: 4.1.1 简单类型 在C#中出现的简单类型共享一些特性。第一,它们都是.NET系统类型的别名。第二,由简单类型组成的常量表达式仅在编译时而不是运行时受检测。最后,简单类型可以按字面被初始化。以下为C#简单类型归类: 4.1.1.1 整型 ·sbyte型为有符号8位整数,取值范围在128~127之间。 VB和C程序员都可能会对int和long数据类型所代表的新范围感到惊讶。和其它的编程语言相比,在C#中,int不再取决于一个机器的字(word)的大小,而long被设成64位。 4.1.1.2 布尔型 布尔数据类型有true和false两个布尔值。可以赋于true或false值给一个布尔变量,或可以赋于一个表达式,其所求出的值等于两者之一: 4.1.1.3 字符型 字符型为一个单Unicode 字符。一个Unicode字符16位长,它可以用来表示世界上多种语言。可以按以下方法给一个字符变量赋值: Table 4.1 转义符( Escape Sequences) 转义符 字符名 |
4.1.1.4 浮点型 两种数据类型被当作浮点型:float和double。它们的差别在于取值范围和精度: 4.1.1.5 小数型(The decimal Type) 小数型是一种高精度、128位数据类型,它打算用于金融和货币的计算。它所表示的范围从大约1.0x10^-28 到 7.9x10^28,具有28至29位有效数字。要注意,精度是以位数 (digits)而不是以小数位(decimal places)表示。运算准确到28个小数位的最大值。 正如你所看到的,它的取值范围比double的还窄,但它更精确。因此,没有decimal和double之间的隐式转换——往一个方向转换可能会溢出,往另外一个方向可能会丢失精度。你不得不运用显式转换。 当定义一个变量并赋值给它时,使用 m 后缀以表明它是一个小数型: 4.1.2 结构类型 一个结构类型可以声明构造函数、常数、字段、方法、属性、索引、操作符和嵌套类型。尽管列出来的功能看起来象一个成熟的类,但在C#中,结构和类的区别在于结构是一个值类型,而类是一个引用类型。与C++相比,这里可以用结构关键字定义一个类。 使用结构的主要思想是用于创建小型的对象,如Point和FileInfo等等。你可以节省内存,因为没有如类对象所需的那样有额外的引用产生。例如,当声明含有成千上万个对象的数组时,这会引起极大的差异。 清单4.1 包含一个命名为IP的简单结构,它表示一个使用byte类型的4个字段的IP地址。我不包括方法等,因为这些工作正如使用类一样,将在下一章有详细的描述。 清单4.1 定义一个简单的结构
1: using System;
2: 3: struct IP 4: { 5: public byte b1,b2,b3,b4; 6: } 7: 8: class Test 9: { 10: public static void Main() 11: { 12: IP myIP; 13: myIP.b1 = 192; 14: myIP.b2 = 168; 15: myIP.b3 = 1; 16: myIP.b4 = 101; 17: Console.Write("{0}.{1}.",myIP.b1,myIP.b2); 18: Console.Write("{0}.{1}",myIP.b3,myIP.b4); 19: } 20: } 4.1.3 枚举类型 当你想声明一个由一指定常量集合组成的独特类型时,枚举类型正是你要寻觅的。最简单的形式,它看起来可能象这样: 因我惯用缺省设置,故枚举元素是int型,且第一个元素为0值。每一个连续的元素按1递增。如果你想给第一个元素直接赋值,可以如下把它设成1: 如果你想赋任意值给每个元素——甚至相同的值——这也没有问题: 最后的选择是不同于int的数据类型。可以在一条语句中按如此赋值: |
4.2 引用类型 和值类型相比,引用类型不存储它们所代表的实际数据,但它们存储实际数据的引用。在C#中提供以下引用类型给你使用: 4.2.1 对象类型 对象类型是所有类型之母——它是其它类型最根本的基类。因为它是所有对象的基类,所以可把任何类型的值赋给它。例如,一个整型: 4.2.2 类类型 一个类类型可以包含数据成员、函数成员和嵌套类型。数据成员是常量、字段和事件。函数成员包括方法、属性、索引、操作符、构造函数和析构函数。类和结构的功能是非常相似的,但正如前面所述,结构是值类型而类是引用类型。 4.2.3 接口 一个接口声明一个只有抽象成员的引用类型。跟C++中相似的概念为:一个结构的成员,且方法等于0。如果你不知道那些概念的任何东西,这里就是在C#中一个接口实际所做的。仅仅只存在着方法标志,但根本就没有执行代码。这就暗示了不能实例化一个接口,只能实例化一个派生自该接口的对象。 可以在一个接口中定义方法、属性和索引。所以,对比一个类,接口有什么特殊性呢?当定义一个类时,可以派生自多重接口,而你只能可以从仅有的一个类派生。 你可能会问:"OK,但我必须实现所有的接口成员,那么我能从这个途径得到什么呢?" 我想举一个来自.NET的例子:很多类实现了IDictionary 接口。你可以使用简单的类型转换访问接口: 现在你的代码可以访问字典了。可等等,我说很多类可以实现这个接口——所以,你可以在多个地方重用代码来访问IDictionary 接口!一旦学会,任何地方都可使用。 当你决定在类设计中使用接口时,学习更多关于面向对象的设计是个好主意。这本书不能教你这些概念,但你可以学习如何创建接口。以下的代码段定义接口IFace,它只有一个方法:
interface IFace
{ void ShowMyFace(); } 正如我所提到的,不能从这个定义实例化一个对象,但可以从它派生一个类。因此,该类必须实现ShowMyFace抽象方法:
class CFace:IFace
{ public void ShowMyFace() { Console.WriteLine("implementation"); } } 接口成员和类成员的区别在于,接口成员不能被实现。因此,我不想在下一章中再次提到这一点。 4.2.4 代表元 一个代表元封装了具有一些标志的一个方法。基本上,代表元是类型安全和函数指针的安全版本(回调功能)。可以同时在一个代表元实例中同时封装静态和实例方法。 4.2.5 字符串类型 C程序员可能会诧异,但当然,C#有一个用于操作字符串数据的基本字符串类型。字符串类直接派生自对象,且它是被密封的,这意味着再不能从它派生类。就象其它类型,字符串是预定义类System String的一个别名。 它的用法十分简单: 我只不过想提到,尽管字符串是一个引用类型,比较时是比较值,而不是比较引用(内存地址)。 |
4.2.6 数组 一个数组包含有通过计算下标访问的变量。所有包含于数组中且被当作元素的变量必须是同一类型。这种类型自然被称为"数组类型"。数组可以存储整数对象、字符串对象或者 你提出的任何对象。 数组的维数就是所谓的排(rank),它决定了相关数组元素的下标数。最常用的数组是一维数组(第一排)。一个多维数组具有的排数大于1 。每个维的下标始于0,终于维的长度减1 。 应有足够的理论支持。让我们看一下用一个数组初始化器( array initializer)初始化的数组: 4.3 加框和消框 这一章的课程中,我已经给出了各式各样的值类型和引用类型。由于速度的原因,你会使用值类型——它除了占据一定空间的内存块外,就没有什么了。但是,有时对象的方便性就象值类型一样好用。 这就是加框和消框登上了舞台的地方,加框和消框是C#类型系统的核心概念。通过允许一个值类型转换成类型对象或从类型对象转换成值类型,这种机制形成了值类型和引用类型之间的捆绑连接。任何东西终究是一个对象——但是,仅当需要它们是对象时。 4.3.1 加框转换 给一个值加框指隐式地把任何值类型转换成类型对象。当一个值类型被加框时,一个对象实例就被分配,且值类型的值被拷贝给新的对象。
int nFunny = 2000;
object oFunny = nFunny; 第二行的赋值暗示调用一个加框操作。nFunny整型变量的值被拷贝给oFunny对象。现在整型变量和对象变量都同时存在于栈中,但对象的值居留在堆中。 那么,它暗示着什么呢?它们的值互相独立——在它们之间没有连接。(oFunny没有引用nFunny的值。) 以下代码说明了结果:
int nFunny = 2000;
object oFunny = nFunny; oFunny = 2001; Console.WriteLine("{0} {1}", nFunny, oFunny); 当代码改变oFunny的值时,nFunny的值并没有改变。只要你脑袋中有这个copy动作,就能够使用值类型的对象功能,发挥出你的巨大优势! 4.3.2 消框转换 和加框相比,消框是显式操作——必须告诉编译器,你想从对象中抽取出哪一种值类型。当执行消框操作时,C#检测所请求的值类型实际上存储在对象实例中。经过成功的校验,该值被消框。 这就是消框如何执行:
int nFunny = 2000;
object oFunny = nFunny; int nNotSoFunny = (int)oFunny; 如果错误地请求一个double值 4.4 小结 在这一章中,你学到了C#中用到的各种类型。简单的值类型包括整型、布尔型、浮点型和小数型。你会非常经常地用到一些类型,进行数学和金融的计算,还有逻辑表达。 在介绍引用类型之前,我显示了一个看起来象类的结构类型。它几乎如一个类般地运作,但它只是一个值类型,这使它更加适合需要有大量的小对象的场合。 引用类型起始于所有对象之母的objedt本身。object是C#中所有对象的基类,且它同样用于值类型的加框和消框。除此之外,我还让你领略了代表元、字符串和数组。 令C#程序员十分神气的类型就是类。它是C#面向对象编程的心脏,下一章整章专门让你迅速理解这个激动人心且功能强大的类型。 |
第五章 类 前一章讨论了数据类型和它们的用法。现在我们转移到C#中至关重要的结构——类。没有了类,就连简单的C#程序都不能编译。这一章假定你知道了一个类的基本组成部分:方法、属性、构造函数和析构函数。C#在其中增加了索引和事件。 在这一章中,你学到下列有关类的话题。 5.1 构造函数和析构函数 在你可以访问一个类的方法、属性或任何其它东西之前, 第一条执行的语句是包含有相应类的构造函数。甚至你自己不写一个构造函数,也会有一个缺省的构造函数提供给你。
class TestClass
{ public TestClass(): base() {} // 由编译器提供 } 一个构造函数总是和它的类名相同,但是,它没有声明返回类型。总之,构造函数总是public的,你可以用它们来初始化变量。
public TestClass()
{ // 在这给变量 // 初始化代码等等。 } 如果类仅包含静态成员(能以类型调用,而不是以实例调用的成员),你可以创建一个private的构造函数。
private TestClass()
{ } 尽管存取修饰符在这一章的后面将要大篇幅地讨论,但是private意味着从类的外面不可能访问该构造函数。所以,它不能被调用,且没有对象可以自该类定义被实例化。 并不仅限于无参数构造函数——你可以传递初始参数来初始化成员。
public TestClass(string strName, int nAge) { ... }
作为一个C/C++程序员,你可能习惯于给初始化写一个附加的方法,因为在构造函数中没有返回值。当然,尽管在C#中也没有返回值,但你可以引发一个自制的异常,以从构造函数获得返回值。更多有关异常处理的知识在第七章 "异常处理"中有讨论。但是,当你保留引用给宝贵的资源,应该想到写一个方法来解决:一个可以被显式地调用来释放这些资源。问题是当你可以在析构函数(以类名的前面加"~"的方式命名)中做同样的事情时,为何还要写一个附加的方法.
public ~TestClass()
{ // 清除 } 你应该写一个附加方法的原因是垃圾收集器,它在变量超出范围后并不会立即被调用,而仅当间歇期间或内存条件满足时才被触发。当你锁住资源的时间长于你所计划的时间时,它就会发生。 因此,提供一个显式的释放方式是一个好主意,它同样能从析构函数中调用。
public void Release()
{ // 释放所有宝贵的资源 } public ~TestClass() { Release(); } 调用析构函数中的释放方法并不是必要的——总之,垃圾收集会留意释放对象。但没有忘记清除是一种良好的习惯。 |
5.2 方法 既然对象能正确地初始化和结束,所剩下来的就是往类中增加功能。在大多数情况下,功能的主要部分在方法中能得到实现。你早已见过静态方法的使用,但是,这些是类型(类)的部分,不是实例(对象)。 为了让你迅速入门,我把这些方法的烦琐问题安排为三节: 5.2.1 方法参数 因方法要处理更改数值,你多多少少要传递值给方法,并从方法获得返回值。以下三个部分涉及到由传递值和为调用者获取返回结果所引起的问题。 。输入参数 5.2.1.1 输入参数 你早已在例子中见过的一个参数就是输入参数。你用一个输入参数通过值传递一个变量给一个方法——方法的变量被调用者传递进来的值的一个拷贝初始化。清单5.1 示范输入参数的使用。 清单 5.1 通过值传递参数
1: using System;
2: 3: public class SquareSample 4: { 5: public int CalcSquare(int nSideLength) 6: { 7: return nSideLength*nSideLength; 8: } 9: } 10: 11: class SquareApp 12: { 13: public static void Main() 14: { 15: SquareSample sq = new SquareSample(); 16: Console.WriteLine(sq.CalcSquare(25).ToString()); 17: } 18: } 因为我传递值而不是引用给一个变量,所以当调用方法时(见第16行),可以使用一个常量表达式(25)。整型结果被传回给调用者作为返回值,它没有存到中间变量就被立即显示到屏幕上 。 输入参数按C/C++程序员早已习惯的工作方式工作。如果你来自VB,请注意没有能被编译器处理的隐式ByVal或ByRef——如果没有设定,参数总是用值传递。 这点似乎与我前面所陈述的有冲突:对于一些变量类型,用值传递实际上意味着用引用传递。 迷惑吗? 一点背景知识也不需要:COM中的东西就是接口,每一个类可以拥有一个或多个接口。一个接口只不过是一组函数指针,它不包含数据。重复该数组会浪费很多内存资源;所以,仅开始地址被拷贝给方法,它作为调用者,仍然指向接口的相同指针。那就是为什么对象用值传递一个引用。 5.2.1.2 引用参数 尽管可以利用输入参数和返回值建立很多方法,但你一想到要传递值并原地修改它(也就是在相同的内存位置),就没有那么好运了。这里用引用参数就很方便。
void myMethod(
ref int nInOut)
因为你传递了一个变量给该方法(不仅仅是它的值),变量必须被初始化。否则,编译器会报警。 清单 5.2 显示如何用一个引用参数建立一个方法。 清单 5.2 通过引用传递参数
1: // class SquareSample
2: using System; 3: 4: public class SquareSample 5: { 6: public void CalcSquare( ref int nOne4All) 7: { 8: nOne4All *= nOne4All; 9: } 10: } 11: 12: class SquareApp 13: { 14: public static void Main() 15: { 16: SquareSample sq = new SquareSample(); 17: 18: int nSquaredRef = 20; // 一定要初始化 19: sq.CalcSquare( ref nSquaredRef); 20: Console.WriteLine(nSquaredRef.ToString()); 21: } 22: } 正如所看到的,所有你要做的就是给定义和调用都加上ref限定符。因为变量通过引用传递,你可以用它来计算出结果并传回该结果。但是,在现实的应用程序中,我强烈建议要用两个变量,一个输入参数和一个引用参数。 5.2.1.3 输出参数 传递参数的第三种选择就是把它设作一个输出参数。正如该名字所暗示,一个输出参数仅用于从方法传递回一个结果。它和引用参数的另一个区别在于:调用者不必先初始化变量才调用方法。这显示在清单5.3中。 清单 5.3 定义一个输出参数
1: using System;
2: 3: public class SquareSample 4: { 5: public void CalcSquare(int nSideLength, out int nSquared) 6: { 7: nSquared = nSideLength * nSideLength; 8: } 9: } 10: 11: class SquareApp 12: { 13: public static void Main() 14: { 15: SquareSample sq = new SquareSample(); 16: 17: int nSquared; // 不必初始化 18: sq.CalcSquare(15, out nSquared); 19: Console.WriteLine(nSquared.ToString()); 20: } 21: } |
5.2.2 改写方法 面向对象设计的重要原则就是多态性。不要理会高深的理论,多态性意味着:当基类程序员已设计好用于改写的方法时,在派生类中,你就可以重定义(改写)基类的方法。基类程序员可以用virtual 关键字设计方法:
virtual void CanBOverridden()
当从基类派生时,所有你要做的就是在新方法中加入override关键字:
override void CanBOverridden()
当改写一个基类的方法时,你必须明白,不能改变方法的访问属性——在这章的后面,你会学到更多关于访问修饰符的知识。 除了改写基类方法的事实外,还有另一个甚至更重要的改写特性。当把派生类强制转换成基类类型并接着调用虚拟方法时,被调用的是派生类的方法而不是基类的方法。
((BaseClass)DerivedClassInstance).CanBOverridden();
为了演示虚拟方法的概念,清单 5.4 显示如何创建一个三角形基类,它拥有一个可以被改写的成员方法(ComputeArea)。 清单 5.4 改写一个基类的方法
1: using System;
2: 3: class Triangle 4: { 5: public virtual double ComputeArea(int a, int b, int c) 6: { 7: // Heronian formula 8: double s = (a + b + c) / 2.0; 9: double dArea = Math.Sqrt(s*(s-a)*(s-b)*(s-c)); 10: return dArea; 11: } 12: } 13: 14: class RightAngledTriangle:Triangle 15: { 16: public override double ComputeArea(int a, int b, int c) 17: { 18: double dArea = a*b/2.0; 19: return dArea; 20: } 21: } 22: 23: class TriangleTestApp 24: { 25: public static void Main() 26: { 27: Triangle tri = new Triangle(); 28: Console.WriteLine(tri.ComputeArea(2, 5, 6)); 29: 30: RightAngledTriangle rat = new RightAngledTriangle(); 31: Console.WriteLine(rat.ComputeArea(3, 4, 5)); 32: } 33: } 基类Triangle定义了方法ComputeArea。它采用三个参数,返回一个double结果,且具有公共访问性。从Triangle类派生出的是RightAngledTriangle,它改写了ComputeArea 方法,并实现了自己的面积计算公式。两个类都被实例化,且在命名为TriangleTestApp的应用类的Main() 方法中得到验证。 我漏了解释第14行: 清单 5.5 调用基类实现
1: class RightAngledTriangle:Triangle
2: { 3: public override double ComputeArea(int a, int b, int c) 4: { 5: const double dEpsilon = 0.0001; 6: double dArea = 0; 7: if ( Math.Abs((a*a + b*b - c*c)) > dEpsilon) 8: { 9: dArea = base.ComputeArea(a,b,c); 10: } 11: else 12: { 13: dArea = a*b/2.0; 14: } 15: 16: return dArea; 17: } 18: } 该检测简单地利用了毕达哥拉斯公式,对于直角三角形,检测结果必须为0。如果结果不为0,类就调用它基类的 ComputeArea来实现。
dArea = base.ComputeArea(a,b,c);
例子的要点为:通过显式地利用基类的资格检查,你就能轻而易举地调用基类实现改写方法。当你需要实现其在基类中的功能,而不愿意在改写方法中重复它时,这就非常有帮助。 |
5.2.3 方法屏蔽 重定义方法的一个不同手段就是要屏蔽基类的方法。当从别人提供的类派生类时,这个功能特别有价值。看清单 5.6,假设BaseClass由其他人所写,而你从它派生出 DerivedClass 。 清单 5.6 Derived Class 实现一个没有包含于 Base Class中的方法
1: using System;
2: 3: class BaseClass 4: { 5: } 6: 7: class DerivedClass:BaseClass 8: { 9: public void TestMethod() 10: { 11: Console.WriteLine("DerivedClass::TestMethod"); 12: } 13: } 14: 15: class TestApp 16: { 17: public static void Main() 18: { 19: DerivedClass test = new DerivedClass(); 20: test.TestMethod(); 21: } 22: } 在这个例子中, DerivedClass 通过TestMethod()实现了一个额外的功能。但是,如果基类的开发者认为把TestMethod()放在基类中是个好主意,并使用相同的名字实现它时,会出现什么问题呢?(见清单5.7) 清单 5.7 Base Class 实现和 Derived Class相同的方法
1: class BaseClass
2: { 3: public void TestMethod() 4: { 5: Console.WriteLine("BaseClass::TestMethod"); 6: } 7: } 8: 9: class DerivedClass:BaseClass 10: { 11: public void TestMethod() 12: { 13: Console.WriteLine("DerivedClass::TestMethod"); 14: } 15: } 在优秀的编程语言中,你现在会遇到一个真正的大麻烦。但是,C#会给你提出警告: 'BaseClass.TestMethod()'. To make the current method override that implementation, add the override keyword. Otherwise add the new keyword. 'BaseClass.TestMethod()'。要想使当前方法改写原来的实现,加上 override关键字。否则加上新的关键字。具有了修饰符new,你就可以告诉编译器,不必重写派生类或改变使用到派生类的代码,你的方法就能屏蔽新加入的基类方法。清单5.8 显示如何在例子中运用new修饰符。 清单 5.8 屏蔽基类方法
1: class BaseClass
2: { 3: public void TestMethod() 4: { 5: Console.WriteLine("BaseClass::TestMethod"); 6: } 7: } 8: 9: class DerivedClass:BaseClass 10: { 11: new public void TestMethod() 12: { 13: Console.WriteLine("DerivedClass::TestMethod"); 14: } 15: } |
该类仅有一个方法,它允许我们触发事件。请注意,你必须进行事件域成员不为null的检测,因为可能会出现没有客户对事件感兴趣这种情况。 TestApp类包含了Main 方法,也包含了另外两个方法,它们都具备事件所必需的信号。其中一个方法是静态的,而另一个是实例方法。 EventSource 被实例化,而静态方法CatchEvent被预关联上了 TextOut事件: 从现在起,当事件被触发时,该方法被调用。如果你对事件不再感兴趣,简单地取消关联:evsrc.TextOut -= new EventHandler(CatchEvent); 注意,你不能随意取消关联的处理函数——在类代码中仅创建了这些处理函数。为了证明事件处理函数也和实例方法一起工作,余下的代码建立了TestApp 的实例,并钩住事件处理方法。 5.5 应用修饰符 在这一章的学习过程中,你已经见过了象public、virtual等修饰符。欲以一种易于理解的方法概括它们,我把它们划分为三节: 。类修饰符 5.5.1 类修饰符 到目前为止,我还没有涉及到类修饰符,而只涉及到了应用于类的存取修饰符。但是,有两个修饰符你可以用于类: abstract——关于抽象类的重要一点就是它不能被实例化。只有不是抽象的派生类才能被实例化。派生类必须实现抽象基类的所有抽象成员。你不能给抽象类使用sealed 修饰符。 sealed——密封 类不能被继承。使用该修饰符防止意外的继承,在.NET框架中的类用到这个修饰符。 清单 5.12 抽象类和密封类
1: using System;
2: 3: abstract class AbstractClass 4: { 5: abstract public void MyMethod(); 6: } 7: 8: sealed class DerivedClass:AbstractClass 9: { 10: public override void MyMethod() 11: { 12: Console.WriteLine("sealed class"); 13: } 14: } 15: 16: public class TestApp 17: { 18: public static void Main() 19: { 20: DerivedClass dc = new DerivedClass(); 21: dc.MyMethod(); 22: } 23: } 5.5.2 成员修饰符 与有用的成员修饰符的数量相比,类修饰符的数量很少。我已经提到了一些,这本书即将出现的例子描述了其它的成员修饰符。 以下是有用的成员修饰符: abstract——说明一个方法或存取标志不能含有一个实现。它们都是隐式虚拟,且在继承类中,你必须提供 override关键字。 |
5.5.3 存取修饰符 存取修饰符定义了某些代码对类成员(如方法和属性)的存取等级。你必须给每个成员加上所希望的存取修饰符,否则,默认的存取类型是隐含的。 你可以应用4个 存取修饰符之一: 为了演示存取修饰符的用法,我稍微修改了Triangle例子,使它包含了新增的域成员和一个新的派生类(见清单 5.13)。 清单 5.13 在类中使用存取修饰符
1: using System;
2: 3: internal class Triangle 4: { 5: protected int m_a, m_b, m_c; 6: public Triangle(int a, int b, int c) 7: { 8: m_a = a; 9: m_b = b; 10: m_c = c; 11: } 12: 13: public virtual double Area() 14: { 15: // Heronian formula 16: double s = (m_a + m_b + m_c) / 2.0; 17: double dArea = Math.Sqrt(s*(s-m_a)*(s-m_b)*(s-m_c)); 18: return dArea; 19: } 20: } 21: 22: internal class Prism:Triangle 23: { 24: private int m_h; 25: public Prism(int a, int b, int c, int h):base(a,b,c) 26: { 27: m_h = h; 28: } 29: 30: public override double Area() 31: { 32: double dArea = base.Area() * 2.0; 33: dArea += m_a*m_h + m_b*m_h + m_c*m_h; 34: return dArea; 35: } 36: } 37: 38: class PrismApp 39: { 40: public static void Main() 41: { 42: Prism prism = new Prism(2,5,6,1); 43: Console.WriteLine(prism.Area()); 44: } 45: } Triangle 类和 Prism 类现在被标为 internal。这意味着它们只能在当前组件中被访问。 请记住“.NET组件”这个术语指的是包装( packaging,),而不是你可能在COM+中用到的组件。 Triangle 类有三个 protected成员,它们在构造函数中被初始化,并用于面积计算的方法中。由于这些成员是protected 成员,所以我可以在派生类Prism中访问它们,在那里执行不同的面积计算。 Prism自己新增了一个成员m_h,它是私有的——甚至派生类也不能访问它。 5.6 小结 这章显示了类的各种要素,它是运行实例(对象)的模板。在一个对象的生命期,首先被执行的代码是个构造函数。构造函数用来初始化变量,这些变量后来在方法中用于计算结果。 方法允许你传递值、引用给变量,或者只传送一个输出值。方法可以被改写以实现新的功能,或者你可以屏蔽基类成员,如果它实现了一个具有和派生类成员相同名字的方法。 命名属性可以被当作域成员(成员变量)或属性存取标志实现。后者是get和set存取标志,忽略一个或另外一个,你可以创建仅写或仅读属性。存取标志非常适合于确认赋给属性的值。 C#类的另外一个功能是索引,它使象数组语法一样访问类中值成为可能。还有,如果当类中的某些事情发生时,你想客户得到通知,要让它们与事件关联。 当垃圾收集器调用析构函数时,对象的生命就结束了。由于你不能准确地预测这种情况什么时候会发生,所以应该创建一个方法以释放这些宝贵的资源,当你停止使用它们时。 |
第六章 控制语句 有一种语句,你在每种编程语言控制流程语句中都可以找到。在这一章中,我介绍了C#的控制语句,它们分为两个主要部分: 6.1 选择语句 当运用选择语句时,你定义了一个控制语句,它的值控制了哪个语句被执行。在C#中用到两个选择语句: 6.1.1 if 语句 最先且最常用到的语句是 if 语句。内含语句是否被执行取决于布尔表达式: if (布尔表达式) 内含语句 当然,也可以有else 分枝,当布尔表达式的值为假时,该分支就被执行: if (布尔表达式) 内含语句 else 内含语句 在执行某些语句之前就检查一个非零长字符串的例子:
if (0 != strTest.Length)
{ } 这是一个布尔表达式。(!=表示不等于。) 但是,如果你来自C或者C++,可能会习惯于编写象这样的代码:
if (strTest.Length)
{ } 这在C#中不再工作,因为 if 语句仅允许布尔( bool) 数据类型的结果,而字符串的Length属性对象返回一个整形(integer)。编译器将出现以下错误信息:
上边是你必须改变的习惯,而下边将不会再在 if 语句中出现赋值错误: if (nMyValue = 5) ... 正确的代码应为 if (nMyValue == 5) ... 因为相等比较由==实行,就象在C和C++中一样。看以下有用的对比操作符(但并不是所有的数据类型都有效): 每个操作符是通过重载操作符被执行的,而且这种执行对数据类型有规定。如果你比较两个不同的类型,对于编译器,必须存在着一个隐式的转换,以便自动地创建必要的代码。但是,你可以执行一个显式的类型转换。 清单 6.1 中的代码演示了 if 语句的一些不同的使用场合,同时也演示了如何使用字符串数据类型。这个程序的主要思想是,确定传递给应用程序的第一个参数是否以大写字母、小写字母或者数字开始。 清单 6.1 确定字符的形态
1: using System;
2: 3: class NestedIfApp 4: { 5: public static int Main( string[] args) 6: { 7: if (args.Length != 1) 8: { 9: Console.WriteLine("Usage: one argument"); 10: return 1; // error level 11: } 12: 13: char chLetter = args[0][0]; 14: 15: if (chLetter >= 'A') 16: if (chLetter <= 'Z') 17: { 18: Console.WriteLine("{0} is uppercase",chLetter); 19: return 0; 20: } 21: 22: chLetter = Char.FromString(args[0]); 23: if (chLetter >= 'a' && chLetter <= 'z') 24: Console.WriteLine("{0} is lowercase",chLetter); 25: 26: if (Char.IsDigit((chLetter = args[0][0]))) 27: Console.WriteLine("{0} is a digit",chLetter); 28: 29: return 0; 30: } 31: } 始于第7行的第一个 if 语段检测参数数组是否只有一个字符串。如果不满足条件,程序就在屏幕上显示用法信息,并终止运行。 可以采取多种方法从一个字符串中提取出单个字符——既可象第13行那样利用字符索引,也可以使用Char类的静态 FromString 方法,它返回字符串的第一个字符。 第16~20行的 if 语句块使用一个嵌套 的if 语句块检查大写字母。用逻辑“与”操作符(&&)可以胜任小写字母的检测,而最后通过使用Char类的静态函数IsDigit,就可以完成对数字的检测。 除了“&&”操作符之外,还有另一个条件逻辑操作符,它就是代表“或”的“¦¦”。两个逻辑操作符都 是“短路”式的。对于“&&”操作符,意味着如果条件“与”表达式的第一个结果返回一个假值,余下的条件“与”表达式就不会再被求值了。相对应,“¦¦”操作符当第一个真条件满足时,它就“短路”了。 我想让大家理解的是,要减少计算时间,你应该把最有可能使求值“短路”的表达式放在前面。同样你应该清楚,计算 if 语句中的某些值会存在着替在的危险。
if (1 == 1 ¦¦ (5 == (strLength=str.Length)))
{ Console.WriteLine(strLength); } 当然,这是一个极其夸张的例子,但它说明了这样的观点:第一条语句求值为真,那么第二条语句就不会被执行,它使变量strLength维持原值。给大家一个忠告:决不要在具有条件逻辑操作符的 if 语句中赋值。 |
6.1.2 switch 语句 和 if 语句相比,switch语句有一个控制表达式,而且内含语句按它们所关联的控制表达式的常量运行。 switch (控制表达式) 控制表达式所允许的数据类型为: sbyte, byte, short, ushort, uint, long, ulong, char, string, 或者枚举类型。只要使其它不同数据类型能隐式转换成上述的任何类型,用它作为控制表达式也很不错。 switch 语句接以下顺序执行: 在继续更详细地探讨switch语句之前,请看清单 6.2 ,它演示用 switch语句来显示一个月的天数(忽略跨年度)
1: using System;
2: 3: class FallThrough 4: { 5: public static void Main(string[] args) 6: { 7: if (args.Length != 1) return; 8: 9: int nMonth = Int32.Parse(args[0]); 10: if (nMonth < 1 ¦¦ nMonth > 12) return; 11: int nDays = 0; 12: 13: switch (nMonth) 14: { 15: case 2: nDays = 28; break; 16: case 4: 17: case 6: 18: case 9: 19: case 11: nDays = 30; break; 20: default: nDays = 31; 21: } 22: Console.WriteLine("{0} days in this month",nDays); 23: } 24: } switch 语段包含于第13~21行。对于C程序员,这看起来非常相似,因为它不使用break语句。因此,存在着一个更具生命力的重要差别。你必须加上一个break语句(或一个不同的跳转语句),因为编译器会提醒,不允许直达下一部分。 |
何谓直达?在C(和C++)中,忽略break并且按以下编写代码是完全合法的:
nVar = 1
switch (nVar) { case 1: DoSomething(); case 2: DoMore(); } 在这个例子中,在执行了第一个case语句的代码后,将直接执行到其它case标签的代码,直到一个break语句退出switch语段为止。尽管有时这是一个强大的功能,但它更经常地产生难于发现的缺陷。 可如果你想执行其它case标签的代码,那怎么办? 有一种办法,它显示于清单6.3中。 清单 6.3 在swtich语句中使用 goto 标签 和 goto default
1: using System;
2: 3: class SwitchApp 4: { 5: public static void Main() 6: { 7: Random objRandom = new Random(); 8: double dRndNumber = objRandom.NextDouble(); 9: int nRndNumber = (int)(dRndNumber * 10.0); 10: 11: switch (nRndNumber) 12: { 13: case 1: 14: //什么也不做 15: break; 16: case 2: 17: goto case 3; 18: case 3: 19: Console.WriteLine("Handler for 2 and 3"); 20: break; 21: case 4: 22: goto default; 23: // everything beyond a goto will be warned as 24: // unreachable code 25: default: 26: Console.WriteLine("Random number {0}", nRndNumber); 27: } 28: } 29: } 在这个例子中,通过Random类产生用于控制表达式的值(第7~9行)。switch语段包含两个对switch语句有效的跳转语句。 goto case 标签:跳转到所说明的标签 goto default: 跳转到 default 标签 有了这两个跳转语句,你可以创建同C一样的功能,但是,直达不再是自动的。你必须明确地请求它。
switch (nSomething)
{ default: case 5: goto default; } 我已经保留了其中一个swich 语句功能的讨论直至结束——事实上你可以使用字符串作为常量表达式。这对于VB程序员,可能听起来不象是什么大的新闻,但来自C或C++的程序员将会喜欢这个新功能。 现在,一个 switch 语句可以如以下所示检查字符串常量了。
string strTest = "Chris";
switch (strTest) { case "Chris": Console.WriteLine("Hello Chris!"); break; } |
6.2 循环语句 当你想重复执行某些语句或语段时,依据当前不同的任务,C#提供4个不同的循环语句选择给你使用: 6.2.1 for 语句 当你预先知道一个内含语句应要执行多少次时,for 语句特别有用。当条件为真时,常规语法允许重复地执行内含语句(和循环表达式): for (初始化;条件;循环) 内含语句 请注意,初始化、条件和循环都是可选的。如果忽略了条件,你就可以产生一个死循环,要用到跳转语句(break 或goto)才能退出。
for (;;)
{ break; // 由于某些原因 } 另外一个重点是,你可以同时加入多条由逗号隔开的语句到for循环的所有三个参数。例如,你可以初始化两个变量、拥有三个条件语句,并重复4个变量。 清单6.4 包含使用 for 语句的一个例子。它显示了如何计算一个阶乘,比使用递归函数调用还要快。 清单 6.4 在for 循环里计算一个阶乘
1: using System;
2: 3: class Factorial 4: { 5: public static void Main(string[] args) 6: { 7: long nFactorial = 1; 8: long nComputeTo = Int64.Parse(args[0]); 9: 10: long nCurDig = 1; 11: for (nCurDig=1;nCurDig <= nComputeTo; nCurDig++) 12: nFactorial *= nCurDig; 13: 14: Console.WriteLine("{0}! is {1}",nComputeTo, nFactorial); 15: } 16: } 尽管该例子过于拖沓,但它作为如何使用for 语句的一个开端。 另一种忽略初始化的选择如下行,因为第10行在for 语句的外部初始化了变量。(记住C#需要初始化变量): 另一种改变是把++操作符移到内含语句中: 如果我也想摆脱条件语句,全部要做的是增加一条if 语句,用break 语句中止循环:
for (;;)
{ if (nCurDig > nComputeTo) break; nFactorial *= nCurDig++; } 除了用于退出for语句的break语句外,你还可以用continue 跳过当前循环,并继续下一次循环。
for (;nCurDig <= nComputeTo;)
{ if (5 == nCurDig) continue; // 这行跳过了余下的代码 nFactorial *= nCurDig++; } 6.2.2 foreach 语句 已经在Visual Basic 语言中存在了很久的一个功能是,通过使用For Each 语句收集枚举。C#通过foreach 语句,也有一个用来收集枚举的命令: foreach(表达式中的类型标识符) 内含语句 循环变量由类型和标识符声明,且表达式与收集相对应。循环变量代表循环正在为之运行的收集元素。 你应该知道不能赋一个新值给循环变量,也不能把它当作ref 或out 参数。这样引用在内含语句中被执行的代码。 你如何说出某些类支持foreach 语句? 简而言之,类必须支持具有 GetEnumerator()名字的方法,而且由其所返回的结构、类或者接口必须具有public 方法MoveNext() 和public 属性Current。如果你想知道更多,请阅读语言参考手册,它有很多关于这个话题的详细内容。 对于清单 6.5 中的例子,我恰好偶然选了一个类,实现了所有这些需要。我用它来列举被定义过的所有的环境变量。 清单 6.5 读所有的环境变量
1: using System;
2: using System.Collections; 3: 4: class EnvironmentDumpApp 5: { 6: public static void Main() 7: { 8: IDictionary envvars = Environment.GetEnvironmentVariables(); 9: Console.WriteLine("There are {0} environment variables declared", envvars.Keys.Count); 10: foreach ( String strKey in envvars.Keys) 11: { 12: Console.WriteLine("{0} = {1}",strKey, envvars[strKey].ToString()); 13: } 14: } 15: } 对GetEnvironmentVariables的调用返回一个IDictionary类型接口,它是由.NET框架中的许多类实现了的字典接口。通过 IDictionary 接口,可以访问两个收集:Keys 和 Values。在这个例子里,我在foreach语句中使用Keys,接着查找基于当前key值的值(第12行 |
当使用foreach时,只要注意一个问题:当确定循环变量的类型时,应该格外小心。选择错误的类型并没有受到编译器的检测,但它会在运行时受检测,且会引发一个异常。 6.2.3 while 语句 当你想执行一个内含语句0次或更多次时,while语句正是你所盼望的: while (条件) 内含语句 条件语句——它也是一个布尔表达式 ——控制内含语句被执行的次数。你可以使用 break 和continue语句来控制while语句中的执行语句,它的运行方式同在for语句中的完全相同。 为了举例while的用法,清单 6.6 说明如何使用一个 StreamReader类输出C#源文件到屏幕。 清单 6.6 显示一个文件的内容
1: using System;
2: using System.IO; 3: 4: class WhileDemoApp 5: { 6: public static void Main() 7: { 8: StreamReader sr = File.OpenText ("whilesample.cs"); 9: String strLine = null; 10: 11: while (null != (strLine = sr.ReadLine())) 12: { 13: Console.WriteLine(strLine); 14: } 15: 16: sr.Close(); 17: } 18: } 代码打开文件 whilesample.cs, 接着当ReadLine 方法返回一个不等于null的值时,就在屏幕上显示所读取的值。注意,我在while条件语句中用到一个赋值。如果有更多的用&&和¦¦连接起来的条件语句,我不能保证它们是否会被执行,因为存在着“短路”的可能。 6.2.4 do 语句 C#最后可利用的循环语句是do语句。它与while语句十分相似,仅当经过最初的循环之后,条件才被验证。
do
{ 内含语句 } while (条件); do语句保证内含语句至少被执行过一次,而且只要条件求值等于真,它们继续被执行。通过使用break语句,你可以迫使运行退出 do 语块。如果你想跳过这一次循环,使用continue语句。 一个如何使用do语句的例子显示在清单 6.7中。它向用户请求一个或多个数字,并且当执行程序退出do循环后计算平均值。 清单 6.7 在do 循环中计算平均值
1: using System;
2: 3: class ComputeAverageApp 4: { 5: public static void Main() 6: { 7: ComputeAverageApp theApp = new ComputeAverageApp(); 8: theApp.Run(); 9: } 10: 11: public void Run() 12: { 13: double dValue = 0; 14: double dSum = 0; 15: int nNoOfValues = 0; 16: char chContinue = 'y'; 17: string strInput; 18: 19: do 20: { 21: Console.Write("Enter a value: "); 22: strInput = Console.ReadLine(); 23: dValue = Double.Parse(strInput); 24: dSum += dValue; 25: nNoOfValues++; 26: Console.Write("Read another value?"); 27: 28: strInput = Console.ReadLine(); 29: chContinue = Char.FromString(strInput); 30: } 31: while ('y' == chContinue); 32: 33: Console.WriteLine("The average is {0}",dSum / nNoOfValues); 34: } 35: } 在这个例子里,我在静态 Main函数中实例化 ComputeAverageApp类型的一个对象。它同样接着调用实例的Run方法,该方法包含了计算平均值所有必要的功能。 do 循环跨越第19~31行。条件是这样设定的:分别回答各个问题 “y”,以决定是否要增加另一个值。输入任何其它字符会引起程序退出 do语块,且平均值被计算。 正如你可以从提到的例子看出,do语句和while语句差别不太大——仅有的差别就是条件在什么时候被求值。 6.3 小结 这章解释了如何使用C#中用到的各种选择和循环语句。 if 语句在应用程序中可能是最为常用的语句。当在布尔表达式中使用计算时,编译器会为你留意。但是,你一定要确保条件语句的短路不会阻止必要代码的运行。 在这一章的最后部分,我说明如何使用for、foreach、while和do语句。语句完成各种需要,包括执行固定次数的循环、列举收集元素和执行基于某些条件的任意次数的语句。 |
第七章 异常处理
|
7.2 异常处理语句 既然你知道了如何产生一个异常(你会发现更多的方法,相信我),仍然存在如何处理它的问题。如果你是一个 C++ WIN32 程序员,肯定熟悉SEH(结构异常处理)。你将从中找到安慰,C#中的命令几乎是相同的,而且它们也以相似的方式运作。 The following three sections introduce C#'s exception-handling statements: 以下三节介绍了C#的异常处理语句: 。用 try-catch 捕获异常 7.2.1 使用 try 和 catch捕获异常 你肯定会对一件事非常感兴趣——不要提示给用户那令人讨厌的异常消息,以便你的应用程序继续执行。要这样,你必须捕获(处理)该异常。 清单7.3 捕获由Factorial Calculation引发的OverflowException 异常
1: using System;
2: 3: class Factorial 4: { 5: public static void Main(string[] args) 6: { 7: long nFactorial = 1, nCurDig=1; 8: long nComputeTo = Int64.Parse(args[0]); 9: 10: try 11: { 12: checked 13: { 14: for (;nCurDig <= nComputeTo; nCurDig++) 15: nFactorial *= nCurDig; 16: } 17: } 18: catch ( OverflowException oe) 19: { 20: Console.WriteLine("Computing {0} caused an overflow exception", nComputeTo); 21: return; 22: } 23: 24: Console.WriteLine("{0}! is {1}",nComputeTo, nFactorial); 25: } 26: } 为了说明清楚,我扩展了某些代码段,而且我也保证异常是由checked 语句产生的,甚至当你忘记了编译器设置时。 正如你所见,异常处理并不麻烦。你所有要做的是:在try语句中包含容易产生异常的代码,接着捕获异常,该异常在这个例子中是OverflowException类型。无论一个异常什么时候被引发,在catch段里的代码会注意进行适当的处理。 如果你不事先知道哪一种异常会被预期,而仍然想处于安全状态,简单地忽略异常的类型。
try
{ ... } catch { ... } 但是,通过这个途径,你不能获得对异常对象的访问,而该对象含有重要的出错信息。一般化异常处理代码象这样:
try
{ ... } catch(System.Exception e) { ... } 注意,你不能用ref或out 修饰符传递 e 对象给一个方法,也不能赋给它一个不同的值。 |
7.2.2 使用 try 和 finally 清除异常 如果你更关心清除而不是错误处理, try 和 finally 会获得你的喜欢。它不仅抑制了出错消息,而且所有包含在 finally 块中的代码在异常被引发后仍然会被执行。尽管程序不正常终止,但你还可以为用户获取一条消息,如清单 7.4 所示。 清单 7.4 在finally 语句中处理异常
1: using System;
2: 3: class Factorial 4: { 5: public static void Main(string[] args) 6: { 7: long nFactorial = 1, nCurDig=1; 8: long nComputeTo = Int64.Parse(args[0]); 9: bool bAllFine = false; 10: 11: try 12: { 13: checked 14: { 15: for (;nCurDig <= nComputeTo; nCurDig++) 16: nFactorial *= nCurDig; 17: } 18: bAllFine = true; 19: } 20: finally 21: { 22: if (!bAllFine) 23: Console.WriteLine("Computing {0} caused an overflow exception", nComputeTo); 24: else 25: Console.WriteLine("{0}! is {1}",nComputeTo, nFactorial); 26: } 27: } 28: } 通过检测该代码,你可能会猜到,即使没有引发异常处理,finally也会被执行。这是真的——在finally中的代码总是会被执行的,不管是否具有异常条件。为了举例说明如何在两种情况下提供一些有意义的信息给用户, 我引进了新变量bAllFine。bAllFine告诉finally 语段,它是否是因为一个异常或者仅是因为计算的顺利完成而被调用。 作为一个习惯了SEH程序员,你可能会想,是否有一个与__leave 语句等价的语句,该语句在C++中很管用。如果你还不了解,在C++中的__leave 语句是用来提前终止 try 语段中的执行代码,并立即跳转到finally 语段 。坏消息, C# 中没有__leave 语句。但是,在清单 7.5 中的代码演示了一个你可以实现的方案。 清单 7.5 从 try语句 跳转到finally 语句
1: using System;
2: 3: class JumpTest 4: { 5: public static void Main() 6: { 7: try 8: { 9: Console.WriteLine("try"); 10: goto __leave; 11: } 12: finally 13: { 14: Console.WriteLine("finally"); 15: } 16: 17: __leave: 18: Console.WriteLine("__leave"); 19: } 20: } 当这个应用程序运行时,输出结果为 try 一个 goto 语句不能退出 一个finally 语段。甚至把 goto 语句放在 try 语句 段中,还是会立即返回控制到 finally 语段。因此,goto 只是离开了 try 语段并跳转到finally 语段。直到 finally 中的代码完成运行后,才能到达__leave 标签。按这种方式,你可以模仿在SEH中使用的的__leave 语句。 顺便地,你可能怀疑goto 语句被忽略了,因为它是try 语句中的最后一条语句,并且控制自动地转移到了 finally 。为了证明不是这样,试把goto 语句放到Console.WriteLine 方法调用之前。尽管由于不可到达代码你得到了编译器的警告,但是你将看到goto语句实际上被执行了,且没有为 try 字符串产生的输出。
1: using System;
2: 3: class CatchIT 4: { 5: public static void Main() 6: { 7: try 8: { 9: int nTheZero = 0; 10: int nResult = 10 / nTheZero; 11: } 12: catch(DivideByZeroException divEx) 13: { 14: Console.WriteLine("divide by zero occurred!"); 15: } 16: catch(Exception Ex) 17: { 18: Console.WriteLine("some other exception"); 19: } 20: finally 21: { 22: } 23: } 24: } 这个例子的技巧为,它包含了多个catch 语句。第一个捕获了更可能出现的DivideByZeroException异常,而第二个catch语句通过捕获普通异常处理了所有剩下来的异常。 你肯定总是首先捕获特定的异常,接着是普通的异常。如果你不按这个顺序捕获异常,会发生什么事呢?清单7.7中的代码有说明。 清单7.7 顺序不适当的 catch 语句
1: try
2: { 3: int nTheZero = 0; 4: int nResult = 10 / nTheZero; 5: } 6: catch(Exception Ex) 7: { 8: Console.WriteLine("exception " + Ex.ToString()); 9: } 10: catch(DivideByZeroException divEx) 11: { 12: Console.WriteLine("never going to see that"); 13: } 编译器将捕获到一个小错误,并类似这样报告该错误: 最后,我必须告发CLR异常与SEH相比时的一个缺点(或差别):没有 EXCEPTION_CONTINUE_EXECUTION标识符的等价物,它在SEH异常过滤器中很有用。基本上,EXCEPTION_CONTINUE_EXECUTION 允许你重新执行负责异常的代码片段。在重新执行之前,你有机会更改变量等。我个人特别喜欢的技术为,使用访问违例异常,按需要实施内存分配。 |
7.3 引发异常 当你必须捕获异常时,其他人首先必须首先能够引发异常。而且,不仅其他人能够引发,你也可以负责引发。其相当简单: throw new ArgumentException("Argument can't be 5"); 你所需要的是throw 语句和一个适当的异常类。我已经从表7.1提供的清单中选出一个异常给这个例子。 表 7.1 Runtime提供的标准异常 异常类型 描述 Exception 所有异常对象的基类 然而,在catch语句的内部,你已经有了随意处置的异常,就不必创建一个新异常。可能在表7.1 中的异常没有一个符合你特殊的要求——为什么不创建一个新的异常?在即将要学到小节中,都涉及到这两个话题。 7.3.1 重新引发异常 当处于一个catch 语句的内部时,你可能决定引发一个目前正在再度处理的异常,留下进一步的处理给一些外部的try-catch 语句。该方法的例子如 清单7.8所示。 清单 7.8 重新引发一个异常
1: try
2: { 3: checked 4: { 5: for (;nCurDig <= nComputeTo; nCurDig++) 6: nFactorial *= nCurDig; 7: } 8: } 9: catch (OverflowException oe) 10: { 11: Console.WriteLine("Computing {0} caused an overflow exception", nComputeTo); 12: throw; 13: } 注意,我不必规定所声明的异常变量。尽管它是可选的,但你也可以这样写: |
7.3.2 创建自己的异常类 尽管建议使用预定义的异常类,但对于实际场合,创建自己的异常类可能会方便。创建自己的异常类,允许你的异常类的使用者根据该异常类采取不同的手段。 在清单 7.9 中出现的异常类 MyImportantException遵循两个规则:第一,它用Exception结束类名。第二,它实现了所有三个被推荐的通用结构。你也应该遵守这些规则。 清单 7.9 实现自己的异常类 MyImportantException
1: using System;
2: 3: public class MyImportantException:Exception 4: { 5: public MyImportantException() 6: :base() {} 7: 8: public MyImportantException(string message) 9: :base(message) {} 10: 11: public MyImportantException(string message, Exception inner) 12: :base(message,inner) {} 13: } 14: 15: public class ExceptionTestApp 16: { 17: public static void TestThrow() 18: { 19: throw new MyImportantException("something bad has happened."); 20: } 21: 22: public static void Main() 23: { 24: try 25: { 26: ExceptionTestApp.TestThrow(); 27: } 28: catch (Exception e) 29: { 30: Console.WriteLine(e); 31: } 32: } 33: } 正如你所看到的,MyImportantException 异常类不能实现任何特殊的功能,但它完全基于System.Exception类。程序的剩余部分测试新的异常类,给System.Exception 类使用一个catch 语句。 如果没有特殊的实现而只是给MyImportantException定义了三个构造函数,创建它又有什么意义呢?它是一个重要的类型——你可以在catch语句中使用它,代替更为普通的异常类。可能引发你的新异常的客户代码可以按规定的catch代码发挥作用。 当使用自己的名字空间编写一个类库时,也要把异常放到该名字空间。尽管它并没有出现在这个例子中,你还是应该使用适当的属性,为扩展了的错误信息扩充你的异常类。 7.4 异常处理的“要”和“不要 作为最后的忠告之语,这里是对异常引发和处理所要做和不要做的清单:
7.5 小结 这一章由介绍溢出校验开始。你可以使用编译器开关(默认是关),使整个应用程序允许或禁止溢出校验。如果需要微调控制,你可以使用校验和非校验语句,它允许你使用或不使用溢出校验来执行一段代码,尽管没有给应用程序设置开关。 当发生溢出时,一个异常就被引发了。如何处理异常取决于你。我提出了各种途径,包括你最有可能贯穿整个应用程序使用的:try、catch 和finally 语句。在伴随的多个例子中,你学到了它与WIN32结构异常处理(SEH)的差别。 最后,你需要阅读引发和处理异常的各种“要”和“不要”。 |