类型成员
类型可以定义0个或者多个以下种类的成员。
成员 | 描述 |
---|---|
常量 | 常量是指出数据值恒定不变的符号。这种符号使代码更易阅读和维护。常量总与类型关联,不与类型的实例关联。常量总与类型关联,不与类型的实例关联 |
字段 | 字段表示只读或可读/可写的数据值。字段可以是静态的:这种字段被认为是类型状态的一部分。字段也可以是实例(非静态);这种字段被认为是对象状态的一部分。强烈建议将字段声明为私有,防止类型或对象的状态被类型外部的代码破坏。 |
实例构造器 | 实例构造器是将新对象的实例字段初始化为良好初始状态的特殊方法。 |
类型构造器 | 类型构造器是将类型的静态字段初始化为良好初始状态的特殊方法。 |
方法 | 方法是用来改变或获取对象或类型信息的函数。作用于类型称为静态方法,作用于对象称为实例方法。 |
操作重载符 | 操作符重载实际是方法,定义了当操作符作用于对象时,应该如何操作该对象。操作重载符不属于CLS的一部分(不是所有编程语言都支持) |
转换操作符 | 转换操作符是定义如何隐式或显式将对象从一种类型转型为另一种类型的方法。转换操作符不属于CLS的一部分(不是所有编程语言都支持) |
属性 | 属性让你用简单的语法像访问字段一样读取或修改对象的信息,同时确保数据的完整性。作用于类型称为静态函数,作用于对象称为实例属性。属性可以无参,也可以有多个参数。 |
事件 | 静态事件允许类型向一个或多个静态或实例方法发送通知。实例对象允许对象向一个或多个静态或实例方法发送通知。 |
类型 | 类型可以定义其他嵌套类型 。 |
无论什么编程语言,编译器都必须能处理源代码,为上述每种成员生成元数据和IL代码。所有编程语言生成的元数据格式完全一致。这正式CLR成为“公共语言运行时”的原因。元数据是所有语言都生成和使用的公共信息。CLR还利用公共元数据格式决定常量、字段、构造器、方法、属性和事件在运行时的行为。简单来说,元数据是整个Microsoft .NET Framework开放平台的关键,它实现了编程语言、类型和对象的无缝集合。
public class MainClass
{
private class SubClass { } // 嵌套类
private const int constVal = 1; // 常量
private readonly int readonlyVal = 2; // 只读
private static int staticVal = 3; // 静态字段
static MainClass() { } // 类型构造器
public MainClass() { } // 实例构造器-无参
public MainClass(int val) { } // 实例构造器-带参
private void ObjMethod() { } // 实例方法
private static void StaticMethod() { } // 静态方法
public int prop { get; set; } // 实例属性
public int this[int index] { get => 0;set { } } // 实例有参属性(索引器)
public event EventHandler SomeEvent; // 实例事件
}
编译这个类型,并用ILDasm.exe查看下元数据,看看编译是如何将类型极其成员转为元数据:
类型的可访问性
CLR自己定义了一组可访问性修饰符,但每种编程语言在向成员应用可访问性时,都选择了自己的一组术语以及相应的语法。
CLR术语 | C#术语 | 描述 |
---|---|---|
Private | private | 成员只能由定义类型或任何嵌套类型中的方法访问 |
Family | protected | 成员只能由定义类型、任何嵌套类型或者不管在什么程序集中的派生类型中方法访问 |
Family and Assembly | 不支持 | 成员只能由定义类型、任何嵌套类型或者同一程序集中的派生类型中方法访问 |
Assembly | internal | 成员只能由定义程序集中的方法访问 |
Family or Assembly | protected internal | 成员可由任何嵌套类型,任何派生类型(同一或不同程序集都可)中的任何方法访问 |
Public | public | 成员可由任何程序集的任何方法访问 |
编译代码时,编程语言的编译器检查代码是不是正确引用了类型和成员。如果代码不正确地引用了类型或成员,编译器会生成一条合适的错误信息。
在C#中,如果没有显式声明成员的可访问性,编译器通常(但不总是)默认选择private。CLR要求所有接口类型的所有成员都具有public可访问性。
派生类重写基类定义的成员时,C#编译器要求原始成员和重写成员具有相同的可访问性,即基类成员是protected,子类也要是protected。CLR则允许放宽但不能收紧成员的可访问性,即基类是protected,子类可以是public。这是因为CLR承诺派生类总能转为基类,并获取对基类方法的访问权。
友元程序集
public类型不仅对定义程序集中的所有代码可见,还对其他程序集中的代码可见。internal则仅对定义程序集中的所有代码可见,对其他程序集中的代码不可见。
生成程序集时,可用System.Runtime.CompilerServices命名空间中的InternalsVisibleTo特性标明它认为是“友元”的其他程序集。该特性获取标识友元程序集名称和公钥的字符串参数。注意当程序集认了“友元”之后,友元程序集就能访问该程序集中的所有internal类型,以及这些类型的internal成员。这在需要共享程序集之间的内部实现细节时特别有用,比如单元测试时需要访问类的内部实现。
要定义友元程序集,你需要在被访问的程序集的 AssemblyInfo.cs 文件中使用 InternalsVisibleTo 属性指定友元程序集的名称。假设我们有一个名为 MainAssembly 的主程序集,其中包含一个类 MyClass,该类具有一个 internal 方法。
// MainAssembly - AssemblyInfo.cs
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FriendAssembly")]
namespace MainAssembly
{
public class MyClass
{
internal void InternalMethod()
{
Console.WriteLine("Internal method in MainAssembly.");
}
}
}
接下来,我们创建一个名为 FriendAssembly 的友元程序集,它可以访问 MainAssembly 的内部成员。
// FriendAssembly - Program.cs
using MainAssembly;
namespace FriendAssembly
{
class Program
{
static void Main(string[] args)
{
MyClass myClass = new MyClass();
myClass.InternalMethod(); // 访问 internal 方法
}
}
}
需要注意的是,如果 MainAssembly 使用了强名称签名,那么在使用 InternalsVisibleTo 属性时,你需要指定友元程序集的公共密钥(PublicKey)。示例如下:
[assembly: InternalsVisibleTo("FriendAssembly, PublicKey=0024000004800000940000000602000000240000525341310004000001000100d537e8bda...")]
静态类
有一些永远不需要实例化的类,例如Console,Math等。这些类只有static成员。事实上,这种类唯一的作用就是组合一组相关的成员。例如,Math类就定义了一组执行数学运算的方法。在C#中,要用static关键字定义不可实例化的类。该关键字只能用于类,不能应用于结构(值类型)。因为CLR总是允许值类型实例化,这无法避免。
C#编译器对静态类做了如下限制:
- 静态类必须直接从System.Object派生,从其他任何基类派生都没有意义。继承只适用于对象,而你不能创建静态类的实例。
- 静态类不能实现任何接口,这是因为只有使用类的实例时,才可调用类的接口方法。
- 静态类只能定义静态成员(字段、方法、属性和事件),任何实例成员都会导致编译器报错。
- 静态类不能作为字段、方法参数或局部变量使用,因为它们都代表引用了实例的变量。
编译如下代码:
public static class MainClass
{
public static int val;
}
使用ILDasm.exe查看程序集,能够发现使用关键字static定义类,将导致C#编译器将该类标记为abstract和sealed。另外,编译器不在类型中生成实例构造器方法(.ctor)
分部类型
partial关键字告诉C#编译器:类、结构或接口的定义源代码可能要分散到一个或多个源代码文件中。有如下几点好处:
- 源代码控制:没法多个程序员同时对一个类型进行修改,使用partial关键字可以将类型的代码分散到多个源代码文件中,每个文件都可以单独签出,多个程序员能同时编辑类型。
- 在同一个文件中将类或结构分解成不同的逻辑单元:分部的每个部分都能实现一个功能,并配以它的全部字段、方法、属性、事件等。这样就可以方便地看到组合以提供一个功能的全体成员,从而简化编码。
- 代码拆分:新建一个类型时,可以自动生成一部分代码。这些代码可以我们自己的代码拆分到不同的源代码文件中。避免自动生成和我们自己的代码互相干扰。
“分部类型”功能完全由C#编译器实现,CLR对该功能一无所知。这也解释了一个类型的所有源代码文件为什么必须使用相同编程语言,而且必须作为一个编译单元编译到一起。
CLR调用方法指令
以下Employee类定义了3种不同的方法:
internal class MainClass
{
public void CustomMethod() { }
public void VirtualMethod() { }
public void StaticMethod() { }
}
编译上述代码,编译器会在程序集的方法定义表中写入3个记录项,每个记录项都用一组flag指明方法是实例方法、虚方法还是静态方法。
写代码调用这些方法,生成调用代码的编译器会检查方法定义的flag,判断如何生成IL代码来正确调用方法。
- call
在IL指令中,call指令用于调用方法,可以是静态方法、实例方法或虚方法。
调用静态方法:必须明确指出在哪个类型中定义了该方法。
调用实例方法或虚方法:需要指定一个引用了对象的变量,并且假设这个变量不是null(即,变量必须有一个有效的对象)。变量的类型会确定方法属于哪个类。如果变量的类型中没有定义这个方法,就会在其基类中查找合适的方法。
此外,call指令通常用于以非虚拟方式调用虚方法。 - callvirt
callvirt是IL指令的一种,用于调用实例方法和虚方法,不能用于静态方法。
调用实例方法或虚方法:需要指定一个引用对象的变量。对于非虚实例方法,变量的类型决定了调用哪个类中的方法。
调用虚方法:CLR(公共语言运行时)会检查对象的实际类型,并以多态方式调用正确的方法。
为确保对象存在,变量不能是null。编译时,JIT编译器会生成代码来检查变量是否为null。如果是null,callvirt指令会引发NullReferenceException异常。
由于这种额外的null检查,callvirt指令的执行速度比call指令稍慢。即使callvirt指令调用的是非虚方法,也会进行null检查。