《CLR via C#》 Class成员

类型成员基础

  • 常量:数据恒定不变的符号。
  • 字段:只读或可读/可写的数据值。
  • 实例构造器:将新对象的实例字段初始化的特殊方法
  • 类型构造器:将类型的静态字段初始化的特殊方法。
  • 方法:更改或查询类型或对象状态的函数。作用于类型成为静态方法,作用于对象为实例方法
  • 操作符重载:实际是方法,定义了当操作符作用于对象时,应该如何操作对象。
  • 转换操作符:定义如何隐式或显式将对象从一种类型转型为另一种类型的方法。
  • 属性:设置或查询类型或对象的逻辑状态。作用于类型为静态属性,作用域对象为实例属性
  • 事件:静态事件允许类型向一个或多个静态或实例方法发送通知。实例事件允许对象向一个或多个静态或实例方法发送通知。
  • 类型:可以定义其他嵌套类型。

常量

定义常量符号时,值必须能在编译时确定。编译器将常量值保存到程序集元数据中。意味着只能定义编译器可识别的基元类型常量。非基元类型的常量变量只能为null。

编译器会在定义常量的程序集元数据中查找该符号,提取常量值并嵌入IL代码中。所以运行时没有任何额外内存分配,但是不能取常量地址或引用传递常量

C#不允许常量设置为static,因为常量总隐式设置为static

提示

如果希望在运行时从一个程序集中提取另一个程序集中的值,那么不应该使用常量,而应该使用readonly字段。

字段

字段是一种数据成员,容纳了一个值类型的实例或对一个引用类型的引用。

CLR支持类型(静态)字段实例(非静态)字段。类型字段所需的动态内存是在类型对象中分配的,而类型对象是在类型加载到一个AppDomain时创建的类型加载到AppDmain的时机又是在引用该类型的任何方法首次进行JIT编译的时候。实例字段容纳字段数据所需的动态内存是在构造类型的实例时分配的。

字段修饰符

CLR术语 C#术语 说明
Static static 类型(静态)字段
Instance (默认) 实例(非静态)字段
InitOnly readonly 只能有一个构造方法中的代码写入(但可利用反射修改readonly字段)
Volatile volatile 编译器、CLR和硬件不会对访问这种字段的代码执行“线程不安全”的优化措施

提示

当某个字段时引用类型,并且该字段呗标记为readonly时,不可改变的是引用,而非字段引用的对象。

方法

实例构造器(引用类型)

实例构造器是初始化类型的实例的特殊方法。构造器方法在“方法定义元数据表”中始终叫做.ctor。创建引用类型的实例时,首先为实例的数据字段分配内存,然后把内存部分归零(保证没有被构造器显式赋值的所有字段都为0或null值),接着初始化对象的附加字段(类型对象指针和同步索引块),最后调用类型的实例构造器来设置对象的初始状态

注意事项
  • 实例构造器永远不能被继承。所以不能使用:virtual,new,override,sealed,abstract修饰符。
  • 如果没有显式定义构造器,C#编译器则会默认定义一个(无参)构造器。这个构造器只是简单的调用了基类的构造器
    1public class A {}
    2//等价于:
    3public class A {
    4    public A() : base() {}
    5}
    
  • 如果类的修饰符为abstract,则编译器生成的默认构造器的可访问性就为protected。否则为public。
  • 如果类的修饰符为static,则编译器不会生成默认构造器。
  • 为了使代码“可验证”,类的实例构造器在访问从基类继承的任何字段之前,必须先调动基类的构造器。如果派生类的构造器没有显式调用一个基类构造器,C#编译器会自动生成对默认的基类构造器的调用。
  • 极少数可以在不调用实例构造器的前提下创建类型的实例。比如Object的MemberwiseClone方法、或反序列化时。
  • 不要在构造函数中调用虚方法

C#编译器以“内联”(嵌入)方式初始化实例字段。在每个构造器方法开始的位置,包含类字段中直接赋值的代码。在其后会插入对基类构造器的调用。然后再插入构造函数的代码

 1class B { }
 2class A {
 3    public int a = 1;
 4    public int b = 3;
 5    public A() {
 6        b = 2;
 7    }
 8}
 9
10// C#编译器编译后的代码为
11class A : B {
12    public int a;
13    public int b;
14    public A() {
15        a = 1;
16        b = 3;
17        // 调用父类构造器
18        b = 2; // b会被重复赋值
19    }
20}

提示

优化:在声明字段是就赋值会导致再每个构造器中都会加入初始化代码。可以不在声明字段时赋值。用一个构造函数初始化,并在其他构造函数中调用那个构造函数。

 1class A {
 2    private int a;
 3    private int b;
 4    public A() {
 5        a = 1;
 6    }
 7    public A(int b) : this() { // 调用空参构造函数
 8        this.b = b;
 9    }
10}

实例构造器(值类型)

CLR总允许创建值类型的实例,并且没有办法阻止值类型的实例化。所以值类型其实并不需要定义构造器,C#编译器根本不会为值类型内联(嵌入)默认的构造器。

CLR不会为包含在引用类型中的每个值类型字段都主动调用构造器。但是,值类型的字段会初始化为0或null。

CLR允许为值类型定义构造器,但必须显式调用才会执行。

C#编译器不允许值类型定义无参构造函数。但CLR允许。

值类型的任何构造器都必须初始化值类型的全部字段

提示

注意:当值类型嵌套到引用类型中时,才保证初始化为0或null。基于栈的值类型字段则无此保证。但任何基于栈的值类型字段都必须在读取之前赋值。

提示

注意:在值类型的构造器中,this代码值类型本身的一个实例,用new创建的值类型的一个实例可以赋给this。在new的过程中,会将所有字段置为0或null。而在引用类型的构造器中,this被认为是只读的,不可赋值。

1struct A {
2    int x;
3    public A(int x) {
4        this = new A();
5        this.x = x;
6    }
7}

类型构造器

类型构造器可用于接口(C#编译器不允许)、引用类型和值类型。类型构造器是设置类型的初始状态。类型默认没有定义类型构造器只能定义一个不能有参数必须标记为static总是私有

 1class A {
 2    static A() {
 3        // A被首次访问时,执行这里的代码
 4    }
 5}
 6
 7struct B {
 8    static B() {
 9        // B被首次访问时,执行这里的代码
10    }
11}
类型构造器的调用

JIT编译一个方法时,会查看代码中都引用了哪些类型。任何一个类型定义了类型构造器,JIT编译器都会检查针对当前AppDomain,是否已经执行了这个类型构造器。如果未执行,JIT编译器会在它生成的本机代码中添加对类型构造器的调用。如果已执行,JIT编译器就不添加对它的调用。

在调用类型构造器时,为了保证类型构造器只执行一次。调用线程需要获取互斥线程同步锁。如果多个线程试图同时调用某个类型的静态构造器则只有一个线程可以获得锁,其他线程会被阻塞(blocked)。当线程执行完毕后等待的线程被唤起发现类型构造器已经执行过。因此不会在执行,直接返回。

注意

可以在值类型中定义类型构造器,但是不要那么做,因为CLR有时不会调用值类型的类型构造器。

1struct A {
2    static A() {
3        Console.WriteLine("这行代码不会被执行");
4    }
5    public int x;
6}
7A[] a = new A[10];
8a[0].x = 123;
9Console.WriteLine(a[0].x);

注意

因为类型构造器是线程安全级的,所以非常适合在类型构造器中初始化单例对象。

注意

类型构造器中只能访问类型的静态字段

操作符重载方法

CLR规范操作符重载方法必须是publicstatic方法。C#要求操作符重载方法至少有一个参数类型与当前定义这个方法的类型相同。为了更快的找到要使用哪个操作符方法。

编译器看到源码中出现一个操作符时,会检查是否有一个操作数的类型定义了名为操作符对应特殊方法名的方法,而且该方法的参数兼容于操作数的类型。如果存在则调用,不存在则报错。

转换操作符方法

如果源类型或目标类型不是基元类型,编译器会生成强制转换代码,检查源对象的类型和目标类型(或者从目标类型派生的其他类型)是不是相同。转换操作符是将一种类型转换成另一种类型的方法。CLR规范转换操作符重载方法必须是public和static方法。C#要求器参数和返回类型二者必有一个与定义转换方法的类型相同。

implicit关键字告诉编译器为了生成代码来调用方法,不需要再源代码中进行显式转型。explicit关键字告诉编译器只有发现了显式转型时,才调用方法。

扩展方法

扩展方法允许定义一个静态方法,并用实例方法的语法来调用。

扩展方法的调用过程

如下代码

1StringBuilder sb = new StringBuilder();
2int index = sb.IndexOf('X');

首先检查StringBuilder类或者它的任何基类是否提供了获取单个Char参数、名为IndexOf的实例方法。如果有,就生成IL代码来调用,否则继续检查是否有任何静态类定义的名为IndexOf的静态方法,方法的第一个参数类型与当前调用方法的哪个表达式的类型匹配,而且该类型必须用this关键字标识

规则和原则
  • 可以为接口类型、委托类型和枚举类型定义扩展方法。
  • C#只支持扩展方法,不支持扩展属性、事件、操作符等。
  • 扩展方法必须在非泛型的静态类中声明。而且,类名没有限制,但是扩展方法必须要有一个参数,而且只第一个参数使用this关键字。
  • C#编译器在静态类中查找扩展方法时,要求静态类本身必须具有文件作用域(不能嵌套在某个类中)。
  • 由于静态类可以取任何名字,所以C#编译器要花一定时间来寻找扩展方法,它必须检查文件作用域中的所有静态类,并扫描所有静态方法来查找一个匹配。为了增强性能,C#编译器要求“导入”扩展方法。
  • 多个静态类可以定义相同的方法。如果编译器发现会报错。
  • 扩展方法扩展一个类型时,同时也扩展了派生类型
  • 会存在覆盖问题,如果一个类原本没有实例方法,并定义了一个扩展方法,未来加入了同名的实例方法则会直接调用实例方法。并且没有报错
ExtensionAttribute

如果用this关键字标记的某个静态方法的第一个参数,编译器就会在内部想该方法应用一个ExtensionAttribute特性。该特性会生成到文件的元数据中持久的保存下来。相当于一个搜索表。

分部方法

利用partial关键字,可以将类或方法的实现分为两个文件

 1partial class A {
 2    partial void Func();
 3}
 4
 5// 另一个文件中的代码
 6partial class A {
 7    partial void Func() {
 8        // 具体代码
 9    }
10}
规则和原则
  • 如果没有实现分部方法,编译器不会生成任何代表分部方法的元数据。
  • 只能在分部类或结构中声明。
  • 分部方法的返回类型始终为void,任何参数都不能用out标记。因为方法在运行时可能不存在,所以不能将变量初始化为方法也许会返回的东西。但可以用ref,可以是泛型方法,可以时实例或静态方法。也可以标记unsafe。
  • 分部方法的声明和实现必须具有完全一致的签名。如果两者都定制了特性,则会合并特性。应用于参数的任何特性也会合并。
  • 分部方法总为private。

参数

规则与原则

  • 向方法传递实参时,编译器从左到右的顺序对实参进行求值。
  • 有默认值的参数必须放在没有默认值的所有参数之后。但参数数组必须放在所有参数(包括有默认值的参数)之后,而且数组不能有默认值。
  • 默认值必须是编译时能确认的常量。包括基元类型、枚举类型,以及能设为null的任何引用类型。可用default或new关键字来表达默认值。这两种关键字会生成完全一样的IL代码。
  • ref或out关键字的标识不能设置默认值。
  • 实参可以按照任意顺序传递,但命名实参只能出现在实参列表的尾部。
  • 可按名称将实参传给没有默认值的参数,但所有必须的实参都必须传递。
  • C#不允许省略逗号之间的实参。例如Func(1, ,2)

引用传参(out和ref)

CLR不区分out和ref,但C#编译器区分。

参数用out标记,表明不指望调用者在调用方法前就初始化了对象。

参数用ref标记,表示调用者必须在调用方法前初始化对象。

注意

两个重载方法只有out和ref的区别规则不合法,因为对于CLR他们是一样的。

注意

对于以传引用的方式给方法的变量,他的类型必须与方法签名中声明的类型相同(不能向上转型)。

注意

可变参数方法对性能会有所影响(除非显式传递null)。因为数组对象需要在堆上分配,元素必须初始化,而且需要垃圾回收。

参数和返回值的设计规范

  • 声明方法的参数类型时,应尽量指定最弱的类型,宁愿要接口也不要基类型。因为弱类型适合更广泛的情形。
  • 声明方法的返回类型时,应尽量指定最强的类型。
  • 如果想保持一定的灵活性,在将来更改方法返回的东西,可以选择一个较弱的返回类型。

总结:其实就是看情况而定。不确定就偏弱类型。

属性

允许源代码用简化的语法来调用方法。CLR支持两种属性:无参属性和有参属性(C#中的索引器)。属性本质就是调用一个方法

无参属性

CLR支持静态、实例、抽象和虚属性。属性可用任意“可访问性”修饰符来标记。而且可以在接口中定义

每个属性都有名称和类型(类型不能为void)。属性不能重载,即不能定义名称相同、类型不同的两个属性。

get访问器对应的方法仅在定义了get访问器方法的时候生成。set同理。

属性与字段的区别:

  • 属性方法可能抛出异常;字段访问永远不会。
  • 属性不能作为out或ref参数传给方法;字段可以。
  • 属性方法可能花费较长的时间执行;字段总是立即完成。
  • 连续多次调用,属性可能返回不同的值;字段则每次都相同。
  • 属性可造成副作用(会造成对象的改变等);字段不会。
  • 属性可能需要额外的额内存;字段不会。
 1private String m_Name;
 2public String Name {
 3    get { return m_Name; }
 4    set { m_Name = value; } // 关键字value总是代表新值
 5}
 6
 7// C#编译器编译后等同于以下代码
 8private String m_Name;
 9public String get_Name() {
10    return m_Name;
11}
12public void set_Name() {
13    m_Name = value;
14}
自动实现属性(AIP)

C#自动生成对应的get,set方法。

1public String Name { get; set; } // 自动实现属性

不建议使用AIP的原因:

  • 没有办法简单的初始化AIP字段。必须在构造函数中初始化。
  • AIP字段名称由编译器决定,每次重新编译可能会改变这个名称。所以不支持序列化
  • 调试不能在get,set上加断点。

有参属性

get访问器接受一个或多个参数,set访问器接受两个或多个参数。

C#使用数组风格的语法来公开有参属性(索引器)

 1public sealed class BitArray {
 2    private Byte[] _byteArray;
 3    private int _numBits;
 4
 5    public BitArray(int numBits) {
 6        if (numBits <= 0) throw;
 7        _numBits = numBits;
 8        _byteArray = new Byte[(numBits + 7) / 8];
 9    }
10
11    // 有参属性
12    public Boolean this[int pos] {
13        // 索引器的get访问器方法
14        get {
15            if (pos < 0 || pos >= _numBits) throw;
16            return (_byteArray[pos / 8] & (1 << (pos % 8))) != 0;
17        }
18        // 索引器的set访问器方法
19        set {
20            if (pos < 0 || pos >= _numBits) throw;
21            if (value) {
22                // 将索引位置设置为true
23                _byteArray[pos / 8] = (Byte) (_byteArray[pos / 8] | (1 << (pos % 8)));
24            } else {
25                // 将索引位置设置为false
26                _byteArray[pos / 8] = (Byte) (_byteArray[pos / 8] & ~(1 << (pos % 8)));
27            }
28        }
29    }
30}

注意

  • C#不支持定义静态索引器属性。CLR支持。
  • C#允许一个类型定义多个索引器,只要索引器的参数集不同。
  • C#将索引器看成对[]操作符的重载。

属性访问器的性能

对于简单的get和set访问器,JIT编译器会将代码内联(inline)。使之基本没有性能损失。代价就是使编译好的方法变的更大。缺点在上方“属性与字段的区别”中有所列出。

注意

JIT编译器在调试模式不会内联属性方法,因为会变的难以调试。

事件

定义了事件成员的类型允许类型(或类型的实例)通知其他对象发生了特定的事情。

CLR事件模型以委托为基础。委托是调用回调方法的一种类型安全的方式。

事件成员使用C#关键字event定义。每个事件成员都要指定以下内容:

  • 可访问性标识符(几乎是public)。
  • 委托类型,指出要调用的方法的原型。
  • 名称。
 1// 定义一个类型来容纳所有应发送给事件通知者的附加信息
 2class TestEventArgs : EventArgs {
 3    private readonly string _message;
 4    public TestEventArgs(string message) {
 5        _message = message;
 6    }
 7    public string Message { get { return _message; } }
 8}
 9
10class TestEventManager {
11    // 定义事件成员
12    // 泛型System.EventHandler委托类型的定义为:
13    // public delegate void EventHandler<TEventArgs>(Object sender, TEventArgs e);
14    // 则方法的原型必须为以下格式:
15    // void MethodName(Object sender, TestEventArgs e);
16    public event EventHandler<TestEventArgs> TestEvent;
17
18    // 负责引发事件来通知登记的对象。
19    protected virtual void OnTestEvent(TestEvnetARgs e) {
20        // 出于线程安全的考虑,将委托字段的引用复制到一个临时变量中。避免在通过!=null的判断后被置空。
21        // Volatile.Read来保证让编辑器复制值,关闭编辑器的优化操作。
22        EventHandler<TestEventArgs> temp = Volatile.Read(ref TestEvent);
23        if (temp != null) temp(this, e);
24    }
25
26    // 真正外部调用发送消息的方法
27    public void SendMessage(string message) {
28        // 构造一个对象来容纳数据
29        TestEventArgs e = new TestEventArgs(message);
30        OnTestEvent(e);
31    }
32}

event在C#编译器编译时转换的代码为:

 1// 一个被初始化为null的私有委托字段
 2private EventHandler<TestEventArgs> TestEvent = null;
 3
 4// 一个公共add_xxx方法。允许方法登记对事件的关注
 5public void add_TestEvent(EventHandler<TestEventArgs> value) {
 6    // 通过循环和对CompareExchange的调用,可以以一种线程安全的方式向事件添加委托
 7    EventHandler<TestEventArgs> prevHandler;
 8    EventHandler<TestEventArgs> newTestEvent = this.TestEvent;
 9    do {
10        prevHandler = newTestEvent;
11        // 将委托实例添加到委托列表中,返回新的列表头(地址)
12        EventHandler<TestEventArgs> newHandler = (EventHandler<TestEventArgs>) Delegate.Combine(prevHandler, value);
13        // 将这个地址存回字段
14        newTestEvent = Interlocked.CompareExchange<EventHandler<TestEventArgs>>(ref this.TestEvent, newHandler, prevHandler);
15    } while (newTestEvent != prevHandler);
16}
17
18// 一个公共remove_xxx方法。允许方法注销对事件的关注
19public void add_TestEvent(EventHandler<TestEventArgs> value) {
20    // 通过循环和对CompareExchange的调用,可以以一种线程安全的方式向事件移除委托
21    EventHandler<TestEventArgs> prevHandler;
22    EventHandler<TestEventArgs> newTestEvent = this.TestEvent;
23    do {
24        prevHandler = newTestEvent;
25        // 将委托实例从委托列表中删除,返回新的列表头(地址)
26        EventHandler<TestEventArgs> newHandler = (EventHandler<TestEventArgs>) Delegate.Remove(prevHandler, value);
27        // 将这个地址存回字段
28        newTestEvent = Interlocked.CompareExchange<EventHandler<TestEventArgs>>(ref this.TestEvent, newHandler, prevHandler);
29    } while (newTestEvent != prevHandler);
30}

注意

即时事件字段定义为public,委托字段本身也是private。

登记并监听事件

 1class Fax {
 2    // 登记
 3    public Fax(TestEventManager tem) {
 4        tem.TestEvent += FaxMsg;
 5    }
 6
 7    // 注销登记
 8    public void Unregister(TestEventManager tem) {
 9        tem.TestEvent -= FaxMsg;
10    }
11
12    // 消息接收
13    private void FaxMsg(Object sender, TestEventArgs e) {
14        Console.WriteLine("message: " + e.Message);
15    }
16}

注意

对象只要登记了一个方法,就不能被垃圾回收。