在新的平台上编程 译文/赵湘宁
一年多来,我将注意力一直放在微软的.NET CLR(公共语言运行时:Common Language Runtime)平台。在我看来,今后大多数新的开发都将面向这个平台,因为它使应用程序的开发变得更容易、更简单。同时,我还期望现有的应用开发能迅速移到.NET平台上来。 为了帮助开发人员掌握这个新的平台,本文以及以后的系列文章将专门针对.NET讨论各种编程问题。我将假设你已经熟悉面向对象的编程概念。每一篇文章的内容都聚焦在非选定的特定公共语言运行时编程主题上。所有.NET开发人员必须知道这些主题。 当展示代码例子时,我必须在支持.NET CLR 的多种语言中选择一种。我决定使用C#。它是微软设计的一种新语言。 我的目的是介绍不同的编程主题并就如何实现它们为你提供一些想法。所以,我不想完整的描述每一个主题以及所有与之相关的细微差别。有关主题完整详细的介绍请参考公共语言运行时或者语言文档。
真正的面向对象设计 对于使用Win32 SDK的编程人员来说,对大多数操作系统特性的访问时通过一组从动态链接库输出的独立函数实现的。这些独立的函数从诸如C这样的非面向对象语言中非常容易调用。但对于一个新的开发人员来说,要面对上千个表面上看来毫无关系的独立的函数是相当让人畏惧的。更为困难的是许多函数名是以单词“Get”开始的(如GetCurrentProcess和GetStockObject)。此外,Win32 API已经历数年并且微软添加了新的函数,这些新函数依旧的函数相比。有相似的语义,但提供的特性有些差异。你常常能认出较新的函数,因为它们的名字原来的函数名相似(象CreateWindow/CreateWindowEx,CreateTypeLib/CreateTypeLib2以及我最喜欢的CreatePen/CreatePenIndirect/ExtCreatePen 所有这些问题都使程序员觉得Windows开发很难。随着.NET平台的出现,微软终于为叫苦不迭的开发人员提供了一个完全面向对象的开发平台。平台服务现在被分成为单独的名字空间(如:System.Collections,System.Data,System.IO,SystemSecurity,System.Web等等)并且每一个名字空间包含一组允许访问平台服务的相关类。 因为类方法可以重载,行为差别不大的方法具有相同的名字,并且只有从原型中才能看出差别来。例如,一个类可能提供三个不同版本的CreatePen方法。所有方法都做相同的事情:即创建一支笔。但是,每一个方法都有不同的参数集并且行为不太一样。将来微软还要创建第四个CreatePen方法并且与前面的类方法配合默契。 因为所有的平台服务都通过这种面向对象的方式来实现,所以软件开发者应该对面向对象的编程有所理解。面向对象的方法还带来了其它的一些特点,如使用继承和多态性很容易创建专门版本的基类库类型。我再次强烈建议要熟练掌握这些概念,这对于使用微软的.NET框架很重要。
System.Object 在.NET中,每一个对象都是从System.Object派生而来。也就是说下面的两种类型定义(使用C#)是相同的:
class Jeff { ... }
和
class Jeff : System.Object { ... }
因为所有对象都是从System.Object派生出来的,从而可以保证每一个对象具有最小的功能集。表一是System.Object中的公共方法。 公共语言运行时需要所有的对象都要用new操作符创建(调用newobj IL指令)。下列代码示范了如何创建Jeff类型(已在前面声明)的对象实例: Jeff j = new Jeff("ConstructorParam1");
new操作符根据指定的类型需要从堆中分配字节数来创建对象。它初始化对象的开销成员。每一个对象都会有一些公共语言运行时用来管理对象的附加字节,如对象的许表指针以及对同步快的引用。 调用类的构造函数时,传递的参数在new语句中指定(例子中是串"ConstructorParam1")。注意大多数语言会编译构造函数以便它们调用基类构造函数,但这在公共语言运行时中是不需要的。 在new实现了所有我所提到的操作后,它返回新创建对象的引用。在例子代码中,这个引用被存储在变量j中,它的类型是Jeff。 另外,new操作符没有配对操作(delete)。即没有方法显式地释放或销毁对象。公共语言运行时提供自动地探测的垃圾回收环境,当对象不再被使用或不再被访问时自动地释放和销毁对象,有关这个主题将在下次的讨论中提出。
数据类型的强制转换 在编程过程当中,对象从一个数据类型到另一个数据类型的强制类型转换是十分常见的。在这一部分,我将讨论对象的强制数据类型转换规则。为此,先看下列代码:
System.Object o = new Jeff("ConstructorParam1");
先前的代码编译通过并正确执行是因为有一个隐含的强制类型转换。new操作符返回Jeff的一个引用类型,但o是一个System.Object的引用类型。因为所有的类型(包括Jeff类型)都能被强制转换为System.Object,隐含的强制类型转换是成功的。但是,如果执行下面的代码,就会有编译器错误,因为编译器不提供基类型到派生类型的强制类型转换。
Jeff j = o;
为了能通过编译,必须插入如下的显式强制类型转换: Jeff j = (Jeff) o; 现在就可以编译通过并成功执行。 再来看另外一个例子:
System.Object o = new System.Object(); Jeff j = (Jeff) o;
第一行创建了一个System.Object类型对象。第二行代码试图将System.Object引用类型转换为Jeff引用类型。两行代码都能编译通过。但是在执行的时候,第二行代码产生一个InvalidCastException异常,如果捕获不到这个异常,将强制应用程序终止。
当第二行代码执行时,公共语言运行时查证o所指的对象就是Jeff类型对象(或任何Jeff派生类型)。如果是,则公共语言运行时允许强制类型转换。否则,如果o所指的对象与Jeff类型无关,或是一个Jeff的基类,则公共语言运行时会预防这种不安全的强制类型转换并产生InvalidCastException异常。
C# 使用as操作符提供另一种方法来实现强制类型转换:
Jeff j = new Jeff(); // 创建一个新的Jeff 对象 System.Object o = j as System.Object; // 强制转换 j 为一个System.Object对象 // 现在o 指Jeff 对象
as操作符试图强制转换一个对象为指定的类型。但与通常的强制转换不一样,如果对象的类型强制转换不成功,结果会是null,as操作符决不会掷出异常。当引用有毛病的强制类型转换发生时,将产生NullReferenceException异常。下列代码示范了这种情况。
System.Object o = new System.Object(); //创建一个新的Object 对象 Jeff j = o as Jeff; //强制转换 o 为一个Jeff对象 // 上面的强制转换失败:不会有异常掷出,而j会被置为null
j.ToString(); // 访问j时产生一个NullReferenceException 异常
除了as操作符以外,C#还提供一个is操作符。它检查是否一个对象实例与给定的类型兼容并判断结果是True或是False。Is操作符不会产生异常。
System.Object o = new System.Object(); System.Boolean b1 = (o is System.Object); // b1 是 True System.Boolean b2 = (o is Jeff); // b2 是 False
注意,如果对象引用是null,is操作符总是返回False,因为得不到对象来检查其类型。 为了肯定你理解了刚才所说的内容,假设下列两各类定义存在。
class B { int x; }
class D : B { int x; }
现在,参见图二看看哪一行代码通过编译并执行成功(ES),哪一行代码导致编译器错误(CE),哪一行代码导致公共语言运行时错误(RE)。
集合与名字空间 类型集可以被分组成集合(一个或多个文件集)并且被展开。在一个集合中可以只存在单独的名字空间。对应用程序开发人员来说,名字空间就像有关联的类型的逻辑分组。例如,基本类库集合包含许多名字空间。System名字空间包括Object基类型、Byte、Int32、Exception、Math和Delegate之类的核心低级类型,而System.Collections名字空间包括的类型如AarryList、BitAarry、Queue和Stack。
对于编译器来说,名字空间只不过是名字较长的的类型名,以及其唯一性是用句点分隔某些符号名来保证的。对于编译器而言,System名字空间中的Object类型只不过是用一个叫做System.Object的类型来表示。同样,System.Collections名字空间中的Queue类型简单地用标示符System.Collections.Queue来表示。 运行时引擎不知道关于名字空间的任何信息。当你访问一个类型时,公共语言运行时只需要知道完整的类型名字以及哪一个集合包含这个类型的定义,以便公共语言运行时能正确加载集合,从而找到要访问的类型并处理之。 编程人员通常都想用最简练的方法来表达算法,但用完全限定名引用每一个类类型的话极其麻烦。因此,许多编程语言提供一条语句来指示编译器添加各种前缀到类型名,直到实现一个匹配。当用C#编程时,我经常在源代码的最前面是用下面的语句:
using System;
当我在代码中引用一个类型时,编译器需要保证这个类型被定义过并且我的代码要以正确的方式访问这个类型。如果编译器不能找到指定的类型,它试图将“System.”添加到类型名并检查产生的类型名字是否与现存的类型名匹配。前面的代码行允许我在代码中使用Object,并且编译器将自动将名字展开为System.Object。我肯定你能轻松想象这样省去了多少键盘输入。 当进行类型定义的检查时,编译器必须知道哪一个集合包含了这个类型,以便这个集合的信息和类型信息能被送到结果文件中。为了获得集合信息,你必须将定义了任何引用类型的集合传给编译器。 正如你所设想的一样,这种设计存在一些潜在的问题。为了编程方便,你应该避免创建名字冲突的类型。但是在某些情况中,它完全不可能。.NET鼓励组件重用。你的应用程序可以利用Microsoft所创建的组件,同时,你也可以用Richter创建的另一个组件。这些公司的组件可能都提供了一个叫做FooBar的类型,-Microsoft的FooBar所做的事情与Richter的FooBar所做的事情完全不同。在这种情况下,你无法控制类类型的命名。为了引用Microsoft的FooBar,你使用Microsoft.FooBar,为了引用Richter的FooBar,你使用Richter.FooBar。 在下列的代码中,对FooBar的引用是不明确的。编译器报告一个错误也就罢了,但是实际上C#编译器挑选FooBar类型的一种可能的情况;直到运行时你才能发现问题:
using Microsoft; using Richter;
class MyApp { method void Hi() { FooBar f = new FooBar(); // Ambiguous, compiler picks } }
为了排除这种不明确的引用,你必须显式地告诉编译器,你想创建哪一个FooBar。
using Microsoft; using Richter;
class MyApp { method void Hi() { Richter.FooBar f = new Richter.FooBar(); // 明确引用 } }
另一种语句形式是允许你为单独类型创建别名,如果你只使用名字空间中的几种类型并且不想用所有的名字空间类型污染整个名字空间的话,这种方法很方便。下列代码示范了另一种解决类型不明确问题的方法。
// 定义RichterFooBar 为Richter.FooBar的别名 using RichterFooBar = Richter.FooBar;
class MyApp { method void Hi() { RichterFooBar f = new RichterFooBar(); // 不会出错 } }
这种方法对于消除类型歧义有用,但不尽人意的地方仍然存在。假设澳大利亚的飞镖(Boomerang)公司(简称ABC)和阿拉斯加的船舶(Boat)公司(也简称ABC)两家公司各自创建了一个类型。可能两家公司都创建了叫做ABC的名字空间,在名字空间中包含一个叫做BuyProduct的类型。任何试图开发需购买飞镖和船舶应用程序的人将会陷入麻烦,除非编程语言提供编程方法来区分两家公司的集合-而不仅仅是两家公司的名字空间。 不幸的是C#语言只支持名字空间,并不提供任何方式来详细说明集合。但实际上碰到这个问题的时候并不多,属于罕见问题。如果是设计希望第三方使用的组件类型。推荐在一个名字空间中定义类型,以便编译器轻松排除类型问题。事实上,应该使用公司全名(不是只取首字母)作为最高级名字空间名来降低冲突的可能性。你能看到Microsoft使用“Microsoft”作为名字空间。 在代码中写一个名字空间声明来创建名字空间是一件很简单的事情。就像下面这样:
namespace CompanyName { // CompanyName class A { // CompanyName.A class B { ... } // CompanyName.A.B }
namespace X { // CompanyName.X class C { ... } // CompanyName.X.C } }
注意名字空间是隐含的公共类型(public)。不能通过任何访问修饰符改变这一点。但可以在内部的名字空间中定义类型(不能在集合外面使用)或者在公共的名字空间中定义类型(能被任何集合访问)。名字空间只表示逻辑上的限制策略,可访问性和包装是通过将名字空间放入一个集合来完成的。 下一次的讨论中,我将阐述所有.NET编程人员必须掌握的简单数据类型、引用类型和数值类型。对于每一个.NET程序员来说,透彻理解数值类型是非常重要的。