一、类型的各种成员
类型中可定义0个或多个以下种类的成员。
● 常量 常量是指出数据值恒定不变的符号。这种符号使代码更易阅读和维护。常量总与类型关联,不与类型的实例关联。常量逻辑上总是静态成员。
● 字段 字段表示只读或可读/可写的数据值。字段可以是静态的;这种字段被认为是类型状态的一部分。字段也可以是实例(非静态);这种字段被认为是对象状态的一部分。强烈建议将字段声明为私有,防止类型或对象的状态被类型外部的代码破坏。
● 实例构造器 实例构造器是将新对象的实例字段初始化为良好初始状态的特殊方法。
● 类型构造器 类型构造器是将类型的静态字段初始化为良好初始状态的特殊方法。
● 方法 方法是更改或查询类型或对象状态的函数。作用于类型称为静态方法,作用于对象称为实例方法。方法通常要读写类型或对象的字段。
● 操作符重载 操作符重载实际是方法,定义了当操作符作用于对象时,应该如何操作该对象。由于不是所有编程语言都支持操作符重载,所以操作符重载方法不是“公共语言规范”(Common Language Specification、 CLS)的一部分。
● 转换操作符 转换操作符是定义如何隐式或显式将对象从一种类型转型为另一种类型的方法。和操作符重载方法一样,并不是所有编程语言都支持转换操作符,所以不是CLS 的一部分。
● 属性 属性允许用简单的、字段风格的语法设置或查询类型或对象的逻辑状态,同时保证状态不被破坏。作用于类型称为静态属性,作用于对象称为实例属性。属性可以无参(非常普遍),也可以有多个参数(相当少见,但集合类用得多)。
● 事件 静态事件允许类型向一个或多个静态或实例方法发送通知。实例(非静态)事件允许对象向一个或多个静态或实例方法发送通知。引发事件通常是为了响应提供事件的类型或对象的状态的改变。事件包含两个方法,允许静态或实例方法登记或注销对该事件的关注。除了这两个方法,事件通常还用一个委托字段来维护已登记的方法集。
● 类型 类型可定义其他嵌套类型。通常用这个办法将大的、复杂的类型分解成更小的构建单元(building block)以简化实现。
二、类型的可见性
public类型对所有程序集的代码可见,internal只对本程序集的代码可见。
三、友元程序集
CLR和C#通过友元程序集(friend assembly)使其他程序集可访问本程序集中的internal类型。用一个程序集中的代码对另一个程序集中的内部类型进行单元测试时,友元程序集功能也能派上用场。
生成程序集时,可用 System.Runtime.CompilerServices 命名空间中的 InternalsVisibleTo 特性标明它认为是“友元”的其他程序集。该特性获取标识友元程序集名称和公钥的字符串参数(传给该特性的字符串绝不能包含版本、语言文化和处理器架构)。注意当程序集认了“友元”之后,友元程序集就能访问该程序集中的所有 internal 类型,以及这些类型的 internal 成员。
using System;为了使用InternalsVisibleTo特性 using System.Runtime.CompilerServices;//当前程序集中的internal类型可由以下两个程序集中的任何代码访问 //(不管什么版本或语言文化) [assembly:InternalsVisibleTo("Wintellect, PublicKey=12345678...90abcdef")] [assembly:InternalsVisibleTo("Microsoft, PublicKey=12345678...90abcdef")]//允许Wintellect和Microsoft两个程序集中的代码访问下面的internal类型 internal sealed class SomeInternalType {} internal sealed class AnotherInternalType {}
四、成员的可访问性
成员的可访问性 | ||
CLR术语 | C#术语 | 描述 |
Private | private | 成员只能由定义类型或任何嵌套类型中的方法访问 |
Family |
protected |
成员只能由定义类型、任何嵌套类型或者不管在什么程序集中的派生类型中的方法访问 |
Family and Assembly |
(不支持) |
成员只能由定义类型、任何嵌套类型或者同一程序集中定义的任何派生类型中的方法访问 |
Assembly |
internal |
成员只能由定义程序集中的方法访问 |
Family or Assembly |
protected internal |
成员可由任何嵌套类型、任何派生类型(不管在什么程序集)或者定义程序集中的任何方法访问 |
Public | public | 成员可由任何程序集的任何方法访问 |
五、静态类
在C#中,static关键字只能用于类,不能用于结构(值类型)。因为CLR总是允许值类型实例化,这是没办法阻止的。
C#编译器对静态类型进行了如下限制。
● 静态类型必须直接从基类 System.Object 派生。
● 静态类不能实现任何接口,这是因为只有使用类的实例时,才可调用类的接口方法。
● 静态类只能定义静态成员(字段、方法、属性和事件),任何实例成员都会导致编译器报错。
● 静态类不能作为字段、方法参数或局部变量使用,因为它们都代表引用了实例的变量,而这是不允许的。
编译器不会为静态类生成实例构造器(.ctor)方法。
可以使用 ILDasm.exe 打开dll或exe查看编译器生成的 IL 代码。
六、分部类、结构和接口
partial 关键字告诉C#编译器:类、结构或接口的定义源 代码可能要分散到一个或多个源代码文件中。将类型源代码分散到多个文件的原因有三。
● 源代码控制
假定类型定义包含大量源代码,一个程序员把它从源代码控制系统中签出(check out)以进行修改。没有其他程序员能同时修改这个类型,除非之后执行合并(merge)。使用 partial 关键字可将类型的代码分散到多个源代码文件中,每个文件都可单独签出,多个程序员能同时编辑类型。
● 在同一个文件中将类或结构分解成不同的逻辑单元
我有时会创建一个类型来提供多个功能,使类型能提供完整解决方案。为简化实现,有时会在一个源代码文件中重复声明同一个分部类型。然后,分部类型的每个部分都实现一个功能,并配以它的全部字段、方法、属性、事件等。这样就可方便地看到组合以提供一个功能的全体成员,从而简化编码。与此同时,可以方便地将分部类型的一部分注释掉,以便从类中删除一个完整的功能,代之以另一个实现(通过分部类型的一个新的部分)。
● 代码拆分
在 Microsoft Visual Studio 中创建新项目时,一些源代码文件会作为项目一部分自动创建。这些源代码文件包含模板,能为项目开个好头。使用 Visual Studio 在设计图面上拖放控件时,Visual Studio 自动生成源代码,并将代码拆分到不同的源代码文件中。这提高了开发效率。很久以前,生成的代码是直接放到当前正在处理的那个源代码文件中的。这样做的问题在于,如果不小心编辑了一下生成的代码,设计器行为就可能
失常。从 Visual Studio 2005 开始,新建窗体、控件等的时候,Visual Studio 自动创建两个源代码文件;一个用于你的代码,另一个用于设计器生成的代码。由于设计器的代码在单独的文件中,所以基本上杜绝了不小心编辑到它的可能。
要将 partial 关键字应用于所有文件中的类型。这些文件编译到一起时,编译器会合并代码,在最后的.exe 或all程序集文件(或.netmodule模块文件)中生成单个类型。“分部类型”功能完全由C#编译器实现,CLR 对该功能一无所知,这解释了一个类型的所有源代码文件为 什么必须使用相同编程语言,而且必须作为一个编译单元编译到一起。
七、组件、多态和版本控制
面向对象编程(Object-Oriented Programming,OOP)已问世多年。它在上个世纪 70 年代末、80年代初首次投入应用时,应用程序规模还非常小,而且使应用程序运行起来所需的全部代码都由同一家公司编写。当然,那时确实有操作系统,应用程序也确实使用了操作系统的一些功能,但和今天的操作系统相比,那时的操作系统所提供的功能实在是太少了。如今软件变得相当复杂,而且用户希望应用程序提供更丰富的功能,如 GUI、菜单、鼠标输入、手写板输入、打印输出、网络功能等。正是由于这个原因,操作系统和开发平台在这几年中取得了迅猛发展。另外,应用程序的开发也必须分工。不能再像以前那样,一个或几个开发人员就能写出一个应用程序需要的全部代码。这样做要么不可能,要么效率太低。现在的应用程序一般都包含了由许多不同的公司生成的代码。这些代码通过面向对象编程机制契合到一起。
组件软件编程(Component Software Programming,CSP)正是 OOP 发展到极致的成果。下面列举组件的一些特点。
● 组件(.NET Framework 称为程序集)有“已经发布”的意思。
● 组件有自己的标识(名称、版本、语言文化和公钥)。
● 组件永远维持自己的标识(程序集中的代码永远不会静态链接到另一个程序集中:.NET总是使用动态链接)。
● 组件清楚指明它所依赖的组件(引用元数据表)。
● 组件应编档它的类和成员。C#语言通过源代码内的 XML 文档和编译器的/doc 命令行开关提供这个功能。
● 组件必须指定它需要的安全权限。CLR 的代码访问安全性(Code Access Securiy, CAS)机制提供这个功能。
● 组件要发布在任何“维护版本”中都不会改变的接口(对象模型)。“维护版本"(servicing version)代表组件的新版本,它向后兼容组件的原始版本。通常,“维护版本”包含 bug 修复、安全补丁或者一些小的功能增强。但在“维护版本”中,不能要求任何新的依赖关系,也不能要求任何附加的安全权限。
如最后一点所述,CSP 有很大一部分涉及版本控制。组件随着时间而改变,并根据不同的时间表来发布。版本控制使 CSP 的复杂性上升到了 OOP 无法企及的高度。(在OOP中,全部代码都由一家公司编写和测试,并作为一个整体发布。本节将重点放在组件的版本控制上。
.NET Framework 中的版本号包含 4 个部分:主版本号(major version)、次版本号(minor version)、内部版本号(build number)和修订号(revision)。例如,版本号为 1.2.3.4 的程序集,其主版本号为1,次版本号为2,内部版本号为3,修订号为4。major/minor部分通常代表程序 集的一个连续的、稳定的功能集,而 build/revision 部分通常代表对这个功能集的一次维护。
假定某公司发布了版本号为 2.7.0.0 的程序集。之后,为了修复该组件的 bug,他们可以生成一个新的程序集,并只改动版本号的build/revision 部分,比如 2.7.1.34。这表明该程序集是维护版本,向后兼容原始版本(2.7.0.0)。
另一方面,假定该公司想生成程序集的新版本,而且由于发生了重大变化,所以不准备向后兼容程序集的原始版本。在这种情况下,公司实际是要创建全新组件,major/minor 版本号(比如 3.0.0.0)应该和原来的组件不同。
前面讨论了如何使用版本号更新组件的标识,从而反映出组件的新版本。下面要讨论如何利用 CLR 和编程语言(比如 C#)提供的功能来自动适应组件可能发生的变化。
将一个组件(程序集)中定义的类型作为另一个组件(程序集)中的一个类型的基类使用时,便会发生版本控制问题。显然,如果基类的版本(被修改得)低于派生类,派生类的行为也会改变,这可能造成类的行为失常。在多态情形中,由于派生类型会重写基类型定义的虚方法,所以这个问题显得尤其突出。
C#提供了 5 个能影响组件版本控制的关键字,可将它们应用于类型以及/或者类型成员。这些关键字直接对应 CLR 用于支持组件版本控制的功能。
C#关键字及其对组件版本控制的影响 | |||
C#关键字 | 类型 | 方法/属性/事件 | 常量/字段 |
abstract | 表示不能构造该类型的实例 | 表示为了构造派生类型的实例,派生类型必须重写并实现这个成员 | (不允许) |
virtual |
(不允许) |
表示这个成员可由派生类型重写 | (不允许) |
override |
(不允许) |
表示派生类型正在重写基类型的成员 | (不允许) |
sealed |
表示该类型不能用作基类型 |
表示这个成员不能被派生类型重写,只能将该关键字应用于重写虚方法的方法 | (不允许) |
new | 应用于嵌套类型、方法、属性、事件、常量或字段时,表示该成员与基类中相似的成员无任何关系 |
八、CLR如何调用虚方法、属性和事件
术节重点是方法,但我们的讨论也与虚属性和虚事件密切相关。属性和事件实际作为方法实现,本书后面会用专门的章来讨论它们。
方法代表在类型或类型的实例上执行某些操作的代码。在类型上执行操作,称为静态方法;在类型的实例上执行操作,称为非静态方法。所有方法都有名称、签名和返回类型(可为void)。CLR 允许类型定义多个同名方法,只要每个方法都有一组不同的参数或者一个不同的返回类型。所以,完全能定义两个同名、同参数的方法,只要两者返回类型不同。但除了IL汇编语言,我没有发现任何利用了这一“特点”的语言。大多数语言(包括C#)在判断方法的唯一性时,除了方法名之外,都只以参数为准,方法返回类型会被忽略。(C#在定义转换操作符方法时实际上放宽了此限制)
CLR提供两个方法调用指令。
● call
该IL指令可调用静态方法、实例方法和虚方法。用 call 指令调用静态方法,必须指定方法的定义类型。用 call 指令调用实例方法或虚方法,必须指定引用了对象的变量。call 指令假定该变量不为 null。换言之,变量本身的类型指明了方法的定义类型。如果变量的类型没有定义该方法,就检查基类型来查找匹配方法。call 指令经常用于以非虚方式调用虚方法。
● callvirt
该IL指令可调用实例方法和虚方法,不能调用静态方法。用 callvirt 指令调用实例方法或虚方法,必须指定引用了对象的变量。用 callvirt 指令调用非虚实例方法,变量的类型指明了方法的定义类型。用 callvirt 指令调用虚实例方法,CLR 调查发出调用的对象的实际类型,然后以多态方式调用方法。为了确定类型,发出调用的变量绝不能是 null。换言之,编译这个调用时,JIT编译器会生成代码来验证变量的值是不是 null。如果是,callvirt 指令造成 CLR 抛出 NullReferenceException 异常。正是由于要进行这种额外的检查,所以 callvirt 指令的执行速度比 call 指令稍慢。注意,即使 callvirt指令调用的是非虚实例方法,也要执行这种 null 检查。
设计类型时应尽量减少虚方法的数量。首先,调用虚方法的速度比调用非虚方法慢。其次,JIT编译器不能内嵌(inline)虚方法,这进一步影响性能。第三,虚方法使组件版本控制变得更脆弱。第四,定义基类型时,经常要提供一组重载的简便方法(convenience method)。如果希望这些方法是多态的,最好的办法就是使最复杂的方法成为虚方法,使所有重载的简便方法成为非虚方法。顺便说一句,遵循这个原则,还可以改善组件版本控制的同时,不至于对派生类型产生负面影响。