原文:
[CLR via C#]6. 类型和成员基础
6.1 类型的各种成员
在一个类型中,可以定义0个或多个以下种类的成员:
1)
常量 常量就是指出数据值恒定不变的符号。这些符号通常用于使代码更容易阅读和维护。常量通常与类型关联,而不与类型的实例关联。从逻辑上讲,常量始终是静态成员。
2)
字段 字段表示一个只读或可读/写的数据值。字段可以是静态的,这时是类型状态的一部分;字段也可以是实例(非静态)的,这时字段是对象状态的一部分。强烈建议将字段声明成为私有字段,防止类型或对象状态被外部代码破坏。
3)
实例构造器 实例构造器是将新对象的实例字段初始化为良好初始化状态(即完成预期初始化)的一种特殊方法。
4)
类型构造器 类型构造器是将类型的静态字段初始化为良好初始化状态(即完成预期初始化)的一种特殊方法。
5)
方法 方法是一个特殊的函数,作用是更改或查询一个类型或状态的状态。作用于类型时,成为静态方法;作用于对象是,称为实例方法。方法一般会对类型或对象的字段执行读写操作。
6)
操作符重载 操作符重载实际是一个方法,它定义了将一个特定的操作符作用于对象时,应该如何操作这个对象。由于不是所有编程语言都支持操作符重载,所以操作符重载方法不是"公共语言规范"(CLS)的一部分。
7)
转换操作符 转换操作符是定义如何显式或隐式地将对象从一种类型转型为另一种类的方法。不是所有编程语言都支持操作符重载,所以转换操作符方法不是"公共语言规范"(CLS)的一部分。
8)
属性 利用属性(property),可以使用一种简单的、字段风格的语法来设置或查询类型或对象的部分逻辑状态,同时保证状态不被遭到破坏。作用于类型的称为静态属性,作用于对象的称为实例属性。属性可以是没有参数的(比较普遍),但也可以有参数的(相当少见,但对集合类来说很常见)。
9)
事件 利用静态事件,一个类型可以向一个或多个静态或实例方法发出通知。而利用实例事件,一个对象可以向一个或多个静态或实例方法发出通知。提供事件的类型或对象的状态发生改变,通常就会引发事件。事件包含两个方法,允许静态或实例方法登记或注销对该事件的关注。除了这两个方法,事件通常还使用一个委托字段来维护已登记的方法集。
10)
类型 类型可定义嵌套于其中的其他类型。通常用这个方法将大的、复杂的类型分解成更小的构建单元,以简化实现。
无论使用什么编程语言,它的编译器都必须能处理你的源代码,为上述的每一种成员生成元数据和IL代码。无论使用哪种编程语言,元数据的格式都是完全一致的。所以才使得CLR能成为"公共语言运行时"。元数据是所有语言都生成和使用的公共信息。
CLR使用公共元数据格式决定常量、字段、构造器等成员在运行时的行为。简单地说,元数据是整个.NET Framework开发平台的关系,它实现了编程语言、类型和对象的无缝集成。
6.2 类型的可见性
在文件范围中定义类型时,可以将类型的可见性指定为public或internal。
public类型不仅对它定义的程序集中的所有代码可见,还对其它程序集中的代码可见。
internal类型仅对定义程序集中的所有代码可见,对其它程序集中的代码不可见。定义类型时,如果不显式定义类型的可见性,C#编译器默认将类型的可见性设为internal。
CLR和C#可以通过友元程序集(friend assembly)来使得一个程序集对另一个可见性为internal的程序集进行public操作。
构建程序集时,可以使用System.Runtime.CompilerServices命名空间中定义一个名为InternalsVisibleTo的attribute来标明它认为的"友元"的其它程序集。这个attribute要获取一个字符串参数,这个字符串参数要标识友元程序集的名称和公钥。
using Systeml using System.Runtime.CompilerServices [assembly:InternalsVisibleTo("Winterllect,publickKey=12345678....asdf")] .....
"友元程序集"功能只适合发布时间相同的程序集,最好是打包一起发布。因为友元程序集相互依赖程度很好,错开太久可能发生版本不兼容的问题。
6.3 成员的可访问性
定义类型的成员(包括嵌套类型)时,可指定成员的可访问性(accessibility)。
CLR和C#中的成员可访问性对比:
CLR术语 | C#术语 | 描述 |
Private | private | 成员只能由定义类型或任何嵌套类型中的方法访问 |
Family | protected | 访问仅限于包含类或从包含类派生的类型 |
Family and Assembly | 不支持 | 成员只能由定义类型、任何嵌套类型或者同一程序集中定义的任何派生类型中的方法访问 |
Assembly | internal | 访问仅限于当前程序集 |
Family or Assembly | protected internal | 访问仅限于从包含类派生的当前程序集或类型 |
Public | public | 访问不受限制 |
在C#中如果没有显示声明成员的可访问性,编译器通常默认选private。
CLR要求接口类型的所哦成员都具有public访问性。所以C#就禁止开发人员显示指定接口的可访问性。
一个派生类重写它的基类中定义的一个成员时,C#编译器要求原始成员和重写成员具有相同的访问性。但CLR不然,从一个基类派生时,CLR允许放宽成员的可访问性的限制,但不可收紧。因为CLR承诺派生类总是可以转换成为基类,并获取对基类方法的访问权控制。
6.4 静态类
在C#中,要用static关键字定义不可实例化的类。这个关键字只能应用于类,不能引用于结构 (值类型)。这是因为CLR总是允许值类型实例化。
C#编译器对静态类做了如下限制:
1)静态类必须直接从基类System.Object派生,从其他任何基类派生都是无意义的。继承只适用于对象,而你不能创建静态类的实例。
2)静态类不能实现任何接口,因为只有使用类的实例,才能调用接口的方法。
3)静态类只能定义静态成员(字段、方法、属性和事件),任何实例成员都将导致编译器报错。
4)静态类不能作为字段、方法参数或局部变量使用,因为它们都代表引用了一个实例的变量。
使用关键字static定义一个类,将导致C#编译器将该类同时标记为abstract(不可实例化)和sealed(不可派生)。编译器不会再类型中生成一个实例构造方法(.ctor)。
6.5 分部类、结构和接口
partial这个关键字告诉C#编译器,一个类、结构或者接口的定义源代码可能分散到一个或者多个源代码文件中。
主要有三方面原因促使我们将某个类型的源代码分散到多个文件中:
1)源代码控制 使用partinal关键字,可以将类型的代码分散到多个源代码文件中,每个文件都可以单独签出使多个开发人员同时编辑该类型。
2)在同一个文件中,将一个类或结构分解成不同的逻辑单元
3)代码拆分
6.6 组件、多态和版本控制
组件软件编程(Component Software programming,CSP)正是OOP发展到极致的一个成功。下面列举了组件的一些特点。
1)组件(.NET中成为程序集)有"已经发布"的意思
2)组件有自己的标识(名称、版本、语言文化和公钥)
3)组件永远维持自己的标识(程序集中的代码永远不会静态链接到另一个程序集中:.NET总是使用动态链接)
4)组件清楚指明它所依赖的组件(引用元数据表)。
5)组件要文档化它的类和成员。
6)组件必须指明它要求的安全权限。
7)组件要发布一个在任何"维护版本"中都不会改变的接口(对象)。"维护版本"(servicing version)代表组件的新版本,它向后兼容于组件的原始版本。通常,"维护版本"包含bug修复、安全补丁或者一些小的功能增强。但在"维护版本"中,不能要求任何新的依赖关系,也不能要求任何附加的安全权限。
CSP的很大一部分都涉及到版本控制。组件随时间不断变化,并根据不同的时间表来发布。
.NET中,版本号包括4个部分:主版本号(mahor version)、次版本号(minor version)、内部版本(build number)和修订号(recision)。
major/minor部分通常代表程序集的一个连续的、稳定的功能集,而build/revision部分通常代表对这个功能的一次维护。
将某个组件(程序集)中定义的一个类型作为另一个组件(程序集)中的一个类型的基类使用时,便会发生版本控制问题。
C#提供了5个能影响组件版本控制的关键字,可将它们应用类型以及/或者类型成员。
C#关键字 | 类型 | 方法/属性/事件 | 常量/字段 |
abstract | 表示不能构造该类型的实例 | 表示为了构造派生类型的实例,派生类型必须重写并实现这个成员 | 不允许 |
virtual | 不允许 | 表示这个成员可由派生类重写 | 不允许 |
override | 不允许 | 表示派生类型重写了基类型的成员 | 不允许 |
sealed | 表示该类型不能用作基类 | 表示这个成员不能被派生类型重写,只能将该关键字应用于准备重写一个虚方法的方法 | 不允许 |
new | 引用于嵌套类型、方法、属性、时间、常量或字段时,表示该成员与基类型中相似的成员无任何关系 |
CLR是如何调用虚方法、属性和事件?
以下Employee类定义了三种不同的方法:
internal class Employee { //非虚实例方法 public int32 GetYearsEmployed() { ... } //虚方法 public virtual String GenProgressReport() { ... } //静态方法 public static Employee Lookup(String name) { ... } }
编译器编译上述代码,会生成的程序集的方法定义表中写入三个记录项,每个记录项都用一组标识(flag)来指明该方法是实例犯法、虚方法还是静态方法。
CLR提供了两个方法调用指令:
1)call 这个调用指令可调用静态方法、实例方法和虚方法。用call指令调用静态方法时,必须指定是哪个类型定义了要由CLR调用的方法。用call指令调用实例方法或虚方法时,必须指定引用了一个对象的变量。call指令假定该变量不为null。换言之,该变量本身的类型指明了要由CLR调用的方法是在哪个类型中定义的。如果变量的类型没有定义该方法,就检查基类型来查找匹配的方法。call指令经常用于以非虚的方式调用一个虚方法。
2)callvirt 这个调用指令可调用实例方法和虚方法,但不能调用静态方法。用callvirt指令调用实例方法或虚方法时,必须指定引用了一个对象的变量。用callvirt指定调用非虚实例方法时,变量的类型指明了最终由CLR调用的方法是在哪个类型中定义的。用callvirt指令调用虚实例方法时,CLR会调查发出调用的哪个对象的实际类型,然后以多态方式调用。为了确定类型,用来发出调用的变量决不能为null。换言之,编译这个调用时,JIT编译器会生成代码来验证变量的值是不是为null。正是由于要进行这种额外的检查,所有callvirt指令执行速度比call稍慢。注意,即使callvirt指令调用的是一个非虚的实例方法,也会执行这种null检查。
C#使用callvirt指令调用所有的实例方法。
编译器调用由值类型定义的方法时,会倾向使用call指令,因为之类的密封的。这就意味着即使你非要在值类型中写一些虚方法也不用考虑多态的问题。
无论是call还是callvirt来调用实例方法或虚方法,这些方法通常接受一个隐藏的this实参作为方法的第一个参数。this实参引用的是要操作的对象。
设计一个类型时,应尽量减少所定义的虚方法的数量。首先,调用虚方法的速度比调用非虚方法慢。其次,JIT编译器不能内嵌虚方法,这进一步影响性能。第三,虚方法使组件的版本控制变得更脆弱。第四,定义一个基类型时,经常需要提供一组重载的简便方法。如果希望这些方法是多态的,最好就是是最复杂的方法称为虚方法,使所有重载的简便方法成为非虚方法。
例如:
public class Set { private Int32 m_length = 0; //这个重载的简便方法是非虚的 public Int32 Find(Object value) { return Find(value,0,m_length); } //这个重载的简便方法是非虚的 public Int32 Find(Object value, Int32 startIndex) { return Find(value, startIndexx, m_length - startIndex); } //功能最丰富的方法是虚方法,可以被重写 public virtual Int32 Find(Object value, Int32 startIndex, Int32 endIndex){ //可被重写的实现放在这里.... } //其他方法 }
合理使用类型的可见性和成员的可访问性
首先,在定义一个新的类型时,编译器应默认是密封类,使它不能作为基类使用。C#编译器默认是非密封类,但允许开发人员使用关键字sealed显示地将类标记为密封。
使用密封类比非密封类更好,理由有三:
1)版本控制 可以容易的将密封类改成非密封类,但非密封类改成密封类就麻烦许多。
2)性能 如上所说,调用虚方法的性能过比不上调用非虚方法,因为CLR为了判断是哪个类型定义了要调用的方法,必须在运行时查找对象的类型。但是,如果JIT编译器看到的是使用密封类型对虚方法调用,就可以采用非虚的方式调用虚方法。
3)安全性和可预测性 类必须保护它自己的状态,不允许自己被破坏。当类处于非密封非私有的,派生类就能访问和更改基类的状态。
定义类是遵循的一些原则:
1)定义类时,除非确定要将一个类作为基类使用,并允许派生类对它进行特化处理,否则总是显式地把它指定为sealed类。另外,默认将类指定为internal类,除非希望在程序集外部公开这个类。
2)在类的内部,将数据字段定义为private。
3)在类的内部,将自己的方法、属性和事件定义为private和非虚。当然,会将某个方法、属性和事件定义为public,以便公开类的某些功能。virtual是最后会考虑的,因为虚成员会放弃许多控制,丧失独立性,变得彻底依赖于派生类的正确行为。