常量
常量的值必须只能在编译时确定,一般只支持基元类型:Boolean, Char, Byte, SByte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Single, Double, Decimal和string。(注意:基元类型不一定是值类型)。想设置非基元类型也可以,不过只支持设置null:
1 | public sealed class SomeType |
常量由于永远不会变化,所以会被编译器看做是类型的一部分(而不是实例的一部分),所以常量本身就是静态的,不需要使用static修饰。并且,我们可以通过类型直接使用常量,不需要通过实例。
定义常量将导致创建元数据。当编译器要生成IL代码的时候,直接从元数据中提取常量的值,写入IL代码中。所以IL代码访问常量的时候,不需要为常量分配任何内存。由于常量是代码的一部分,所以也不能取常量的地址,不能用传引用的形式传递常量。
上面说到了常量是编译时确定的,会直接决定了IL代码的内容。所以当更改常量的值的时,必须重新编译所有引用到该常量的代码。若非如此,可能出现某些代码中的常量还是旧版本的值。
1 | class Program |
1 | .method private hidebysig static void Main(string[] args) cil managed |
可以看到上面IL代码的第7和第10行,直接使用了常量,而不是他们的引用地址。
如果常量有可能会被其他程序集使用到,那么最好就保证这个常量真的是永远不会变的。否则一旦改变了这个常量,就要重新编译所有引用到这个常量的程序集。
如果希望有一个能够改变值的常量,应该使用static readonly
。
字段
几种字段修饰符:
- static 类型字段,在类型被加载到AppDomain的时候创建
- 实例字段
- readonly 这种字段只能在「初始化器」或者构造器方法中被写入(可以使用反射来强行修改)
- volatile 编译器、CLR和硬件不会对访问这种字段的代码执行县城不安全的优化
现在将上一节提到的C#代码略做修改,把其中一个常量改成static readonly
,我们再来比较他们的IL代码。
1 | class Program |
先看它的静态构造方法.cctor
。
1 | .method private hidebysig specialname rtspecialname static |
可以看到,在静态构造方法中(类型被载入到AppDomain里时被调用),把"StringConst"
这个值传递给了Program::StringConst
。
再看main
方法的IL代码:
1 | .method private hidebysig static void Main(string[] args) cil managed |
第7行那里从原来的直接使用"StringConst"
这个值,改成了对Program::StringConst
的引用。这意味着如果引用的程序集和被引用的程序集是不同一个,只要被引用者改变了readonly的值后重新编译,引用者无需重新编译也可以使用到最新版本的值。
实例构造器
类的构造方法在「方法定义元数据表」中始终叫做.ctor
。
创建一个实例的流程:
- 分配内存
- 初始化对象的附加字段(类型指针对象 + 同步索引块)
- 调用实例构造器
值得注意的是,.ctor
方法不仅包括了你定义的那个和类同名的方法中的逻辑,还包括实例化构造器,就是你给字段的初始值。
show codes!
1 | class Program |
对应的.ctor
方法:
1 | .method private hidebysig specialname rtspecialname |
可以看到对字段的默认初始化是在构造方法前面执行的。
实例构造器不能够被继承,不能够用以下修饰符:virtual, new, override, sealed, abstract。
如果不自定义一个实例构造器,编译器会自动定义一个默认的无参构造器,会自动调用基类的构造器。
1 | public class SomeType{} |
等价于
1 | public class SomeType |
一般来说,构造函数都必须是public的,以便在任何地方生成实例(当然也可以手动设置成private,用静态方法来暴露生成实例的逻辑)。但是如果这个类是个抽象类,由于抽象类是不可以被实例化的,所以构造器会被编译器变成protected,只让派生类访问。
如果派生类的构造器没有显示地调用一个基类的构造器,C#编译器会自动生成对默认的积累构造器的调用(也就是说调用基类的默认构造方法是必然的)。
假设有代码如下:
1 | class ProgramBase { } |
可以看到Program的.ctor
的IL代码会在调用自己的构造方法之前先调用一下基类的构造方法:
1 | .method private hidebysig specialname rtspecialname |
顺序是:
- 初始化字段
- 基类构造方法
- 本类构造方法
由于字段默认初始化和基类实例构造器的调用永远会被加到构造方法前面,所以定义了几个构造方法,前面两个步骤的代码就会被复制多少次(虽然复制这么点代码也没啥)。
值类型的构造器
结构体不被编译器允许自定义一个无参的构造器(C#编译器不允许,但是IL允许)。不能自定义无参构造器,而且编译器也不允许对实例字段初始值设定。
1 | struct SomeValType |
编译器会给结构自动生成一个无参构造器,这个无参构造器可以被非显式调用,把所有字段初始化为0, false或null。
但是编译器允许我们添加有参的构造器,在新建结构体的时候需要显示调用。假若新建了一个自定义的有参构造器,那么我们需要保证在这之内初始化所有的字段(否则有可能生成「不可验证」的代码),否则会有编译错误。
值类型中的this是可以改变引用的
我们可以在结构体的方法中改变this的值。这个操作在引用类型中是无法执行的,因为引用类型中的this是只读的。
1 | struct ValType |
那么最后如果我们输出m_x
,会是什么值呢?如果把this看做是一个简单引用,那么输出的应该是val的值。
很遗憾,这里输出的将会是999。
this = new ValType();
的意思在这里是让new ValType()成为this,而最后构造器返回的总是this引用。
如果你的结构体有多个字段你懒得一一初始化的,就可以在你的带参构造器中调用一个无参构造器,来把所有的参数默认初始化,然后再挑少数几个需要的来做特别的初始化,比如这样:
1 | struct ValType |
静态构造器
也叫类型构造器,类构造器,或者类型初始化器。永远没有参数,永远都是private的,作用是设置类型的初始状态(static字段的初始化之类的)。
1 | class Program |
1 | .method private hidebysig specialname rtspecialname static |
当JIT编译器在编译一个方法的时候,会检查这个方法用到了什么类,以及它们的静态构造器是否被执行过,如果没有被执行过,那么会调用一次。
由于多个线程可能同时想要调用类的静态构造器,所以执行静态构造器之前线程需要先获取一个互斥线程同步锁,其他的线程会被阻塞直至静态构造器被执行完毕,其他线程发现它已经被执行过,直接返回。
值类型的静态构造器
虽然可以定义,但是CLR有时不会调用值类型的静态构造器。所以千万不要使用。
操作符重载方法
CLR对操作符重载一无所知,甚至不知道什么是操作符。编程语言定义了出现这些特殊符号时,应该生成什么样的代码。
操作符重载方法必须要是public
和static
的。
当重载一个运算符的时候,其实是生成了一个特殊的方法。比如下面比较重载了加号的C#代码和IL代码:
1 | public static Program operator+(Program a, Program b) |
1 | .method public hidebysig specialname static |
生成了一个方法,名为op_Addition
,且设置了specialname
标志,表示这是一个特殊方法。C#编译器检测到操作符+时,会查找关联了specialname
元数据标志的op_Addition
方法。如果程序员自己定义了一个op_Addition
,是没有这个元数据标志的,不会被+号调用到。
转换操作符方法
感觉很容易出错,又不是很容易感知,所以平常应该很少人会用这个特性来写业务代码吧。
重载转换操作符的方法,这个方法在类型被强转的时候会被调用。
转型包括了隐式和显式两种:
1 | Int32 i = 2.5f; // 隐式 |
分别对应两个转换操作符方法:
1 | class Int32 |
如果显式转换失败,应该让显式转换操作符方法抛出OverflowException
或InvalidOperationException
。
扩展方法
扩展方法是个很爽的特性。由于鄙人用的已经用的比较熟练了,就先从自己的理解说起吧。
C#只支持单继承,虽然可以实现多个接口,但是接口里面并不允许有任何方法的具体实现。假设我有一个类必定要继承于类BaseA,但是又想方便地使用另一个功能模块的方法,怎么办呢?这个时候我们的思路就是想着,能不能定义一个接口给它实现,然后再给这个接口搞几个有具体实现的方法。
C#虽然不支持直接在interface里面直接定义方法的实现,但是支持一种叫做扩展方法的特性。一句话来说,它可以在类的定义之外,给类加方法(但是仅仅是方法,不支持扩展属性、事件以及操作符)。
废话不多说,先上代码。
1 | public class BaseA |
读者可以复制上面代码运行一下,然后再去掉GetInfo
中参数列表里的this
关键字,再编译试试。
去掉this
关键字后会发现Program的实例p无法调用GetInfo
方法了,得使用FeatureExtension.GetInfo(p)
来调用。
GetInfo也可以不像上面一样使用泛型,如果只需要访问IFeature的属性,那么直接把T改成IFeature也是可以的。
给扩展方法一个自己的命名空间
IDE的智能感知功能可以帮程序员列出所有和本类型实例有关的所有扩展方法。
为了避免扩展方法污染到全局,所以笔者建议把本模块的扩展方法放在自己定义的一个命名空间的中,使用的时候用using
语句来导入。另外,尽量不要给Object等基类扩展方法,以免污染所有派生类的智能感知方法列表。
使用命名空间
1 | // 文件IFeature中 |
1 | // 文件Program中 |
如果编译器发现了同一个扩展方法在不同类被定义了两次,会编译报错。
命名规范
如果类中或接口中定义了和扩展方法同名的方法,会优先调用类或者接口中那个方法。所以扩展方法最好有一定的唯一性,尽量保证不会在以后被覆盖。
分部方法
partial方法。懒得说。
可选参数和命名参数
没啥说的。
隐式类型的局部变量
var类型的变量,没啥说的。
以传医用的方式向方法传递参数
主要是讲ref和out。二者的区别在于out表示在传进来之前参数可能还没有被初始化好,方法内部不能使用参数的值。
ret和out生成的IL代码是完全一样的,元数据也几乎一致,只有一个bit有区别,用来区分是ref还是out。
由于值传递需要复制一整个值,所以如果值的大小过大,使用引用传递可以提高效率。
可以重载两个同名方法,一个值传递,一个引用传递。但是不能重载两个一样是引用传递的方法,即使一个是out一个是ref也不行(毕竟方法签名一样)。
可变参
使用params
关键字可以定义可变参。跟其他语言一样,可变参只可以出现在最后一个参数上。
1 | public void Func(Int32 firstParam, params Int32[] otherPrams) |
参数和返回类型的设计规范
尽量面向接口编程,让参数的类型是基类,或者是接口,以保持灵活性。