类型和成员基础
类型的成员
类型中可以定义多种成员,本篇不作深入讲解,后续再逐一介绍。
- 常量:数据值值恒定不变的符号。
- 字段:只读或可读/可写的数据值。
- 实例构造器:将对象的实例字段初始化的特殊方法。
- 类型构造器:将类型的静态字段初始化的特殊方法。
- 方法:更改或查询类型或对象状态的函数。
- 操作符重载:操作符重载实际是方法,定义了当操作符作用于对象时,应该如何操作该对象。
- 转换操作符:定义如何隐式或显式将对象从一种类型转换为另一种类型的方法。
- 属性:属性允许用简单的、字段风格的语法设置或查询类型或对象的逻辑状态,同时保证状态不被破坏。
- 事件:静态事件允许类型向一个或多个静态或实例方法发出通知。实例事件允许对象向一个或多个静态或实例方法发送通知。
- 类型:类型可定义其他嵌套类型。
类型的可见性
类型的可见性可以指定为public或internal。public类型对所有程序集可见,而internal类型仅对定义程序集中的所有代码可见。定义类型时不显示指定可见性,C#编译器会默认指定为internal。
友元程序集
某些情况下,我们希望将可见性为internal的类型开放给另外一个特定的程序集访问,CLR和C#通过友元程序集(friend assembly)提供这方面的支持。生成程序集时,使用System.Runtime.CompilerServices命名空间下的InternalsVisibleTo特性标注友元程序集。该特性获取标识友元程序集名称和公钥的字符串参数。示例如下:
-
创建类库项目FriendA和FriendB,分别将类命名为ClassA(Internal)和ClassB
internal class ClassA { ... }
-
创建密钥文件Answer.snk,并获取其公钥值(方法可见《共享程序集和强命名程序集》)
-
使用密钥文件Answer.snk为FriendB项目生成强命名程序集
-
在FriendA中添加以下特性标记(不要忘记添加System.Runtime.CompilerServices命名空间)
[assembly:InternalsVisibleTo("TeamB,PublicKey=0024...88c4")]
-
在FriendB项目中引用FriendA程序集,成功访问声明为Internal的类ClassA
成员的可访问性
定义类型的成员时,可以指定成员的可访问性。CLR自己定义了一组可访问性修饰符,但每种编程语言在向成员应用可访问性时,都选择了自己的一组术语以及相应的语法。下表总结了应用于成员的可访问性修饰符。
CLR术语 | C#术语 | 说明 |
---|---|---|
Private | private | 成员只能由定义类型或嵌套类型中的方法访问 |
Family | protected | 成员只能由定义类型、嵌套类型或者任意程序集中的派生类型中的方法访问 |
Family and Assembly | 不支持 | 成员只能由定义类型、嵌套类型或同一程序集中定义的派生类型中的方法访问 |
Assembly | internal | 成员只能由定义程序集中的方法访问 |
Family Or Assembly | protected internal | 成员只能由定义程序集中的方法、嵌套类型或任意程序集中的派生类型中的方法访问 |
Public | public | 成员可由任何程序集的任何方法访问 |
在C#中,如果没有显示声明成员的可访问性,编译器通常默认选择private。(接口除外,接口的可访问性自动设置为public)
派生类型重写基类型定义的成员时,C#编译器要求原始成员和重写成员具有相同的可访问性。(但CLR不同,CLR允许放宽但不允许收紧成员的可访问性限制)
静态类
类可以声明为static,以指示它仅包含静态成员。静态类不能实例化,static关键字只能应用于类,不能应用于结构。静态类相当于一个sealed abstract类。
C#编译器对静态类进行了如下限制:
- 静态类必须直接从基类System.Object派生,从其他任何基类派生都没有意义。
- 静态类不能实现任何接口。
- 静态类只能定义静态成员。
- 静态类不能作为字段、方法参数或局部变量使用。
组件、多态和版本控制
本节讨论如何通过CLR和编程语言提供的功能来自动适应程序集可能发生的变化。将一个程序集中定义的类型作为另一个程序集中的类型的基类使用时,如果基类版本低于派生类,就可能造成派生类的行为失常。C#提供了5个能影响程序集版本控制的关键字,可将它们应用于类型以及类型成员。
C#关键字 | 类型 | 方法、属性、事件 | 常量、字段 |
---|---|---|---|
abstract | 不能构造该类型的实例 | 为了构造派生类的实例,派生类型必须重写并实现这个成员 | (不允许) |
virtual | (不允许) | 可由派生类重写 | (不允许) |
override | (不允许) | 派生类型重写基类型的成员 | (不允许) |
sealed | 不能用作基类型 | 不能被派生类型重写,只能讲该类关键字应用于重写虚方法的方法 | (不允许) |
new | 应用于嵌套类型、方法、属性、事件、常量或字段,表示该成员与基类中相似的成员无任何关系 |
CLR如何调用虚方法、属性和事件
编译器在编译代码时,会在程序集方法定义表的记录项中添加一组标志,指明方法是实例方法、虚方法还是静态方法。生成调用代码时,编译器会检查方法定义的标志,判断如何生成IL代码来正确调用方法。CLR提供两个方法调用指令:
-
call
该指令可调用静态方法、实例方法和虚方法。用call指令调用静态方法,必须指定方法的定义类型。用call指令调用实例方法或虚方法,必须指定引用了对象的变量。call指令假定该变量不为null。
-
callvirt
该指令可调用实例方法和虚方法,不能调用静态方法。用callvirt指令调用实例方法或虚方法,必须指定引用了对象的变量。用callvirt指令调用非虚实例方法,变量的类型指明了方法的定义类型。用callvirt指令调用虚实例方法,CLR调查发出调用的对象的实际类型,然后以多态方式调用方法。为了确定类型,发出调用的变量不能为null。JIT编译器会生成代码来验证变量的值是不是null。如果是,callvirt指令造成CLR抛出NullReferenceException异常。这种额外的检查造成callvirt指令的执行速度比call指令稍慢。
为什么C#编译器不直接生成call指令呢?
因为C#团队认为,JIT编译器应生成代码来验证发出调用的对象不为null。
下面来看一段简单的代码
class Program
{
static void Main(string[] args)
{
Console.WriteLine();
Object o = new object();
o.GetHashCode();
o.GetType();
}
}
编译上述代码,查看IL如下:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// Code size 28 (0x1c)
.maxstack 1
.locals init ([0] object o)
IL_0000: nop
IL_0001: call void [mscorlib]System.Console::WriteLine()
IL_0006: nop
IL_0007: newobj instance void [mscorlib]System.Object::.ctor()
IL_000c: stloc.0
IL_000d: ldloc.0
IL_000e: callvirt instance int32 [mscorlib]System.Object::GetHashCode()
IL_0013: pop
IL_0014: ldloc.0
IL_0015: callvirt instance class [mscorlib]System.Type [mscorlib]System.Object::GetType()
IL_001a: pop
IL_001b: ret
} // end of method Program::Main
我们清楚的可以看见,C#编译器用call指令调用Console的WriteLine方法,接着用callvirt调用GetHashCode和GetType方法。