CLR Via C#个人笔记4 - 类型和成员基础
大章6:类型和成员基础
类型的各种成员
- 常量(大章7) 数据值恒定不变的符号。
public const bool isBoy = true;
- 字段(大章7)
- 实例构造器(大章8)
- 类型构造器(大章8) 是将类型的静态字段初始化为良好初始状态的特殊方法。
static ClassName() {}
- 方法(大章8) 更改或查询类型或对象状态的函数。分静态方法和实例方法。
- 操作符重载(大章8) 实际是方法,由于不是所有编程语言都支持操作符重载,所以它不是CLS(公共语言规范) 的一部分。
- 转换操作符(大章8) 是定义如何隐式或显式将对象从一种类型转型为另一种类型的方法。同操作符重载,由于不是所有编程语言都支持,所以它不是CLS(公共语言规范) 的一部分。
- 属性(大章10) 可以无参,也可以多参。
public Int32 this[String s]{get;set;} // 实例有参属性(索引器)
- 事件(大章11)
public event EventHandler SomeEvent; // 实例事件
- 类型 可以定义其他嵌套类型。通常用这个办法将大的类型分解成更小的构建单元(building block)以实现简化。
元数据
无论什么编程语言,编译器都必须能处理源代码,为上述成员生成元数据和IL代码,所有编程语言的生成的元数据格式完全一致。元数据是所有语言都生成和使用的公共信息,是它使不同基于CLR的语言的代码无缝访问。
下面看一下元数据到底是怎么定义的:
1 | public sealed class SomeType{ // 1 |
希望能回头看看这个例子,体会成员是如何定义的,他们对编译器生成的元数据有何影响。
类型的可见性
可见性,只是针对类说的,而不是字段。
public class 全部可见
internal class 程序集内可见,如果不定义可见性,默认就是internal
友元程序集(friend assembly)
是为了让两个不同程序集之间能相互公开,而对外不公开的一种技术支持。利用InternalsVisibleTo
特性。
1 | using System.Runtime.CompilerServices; // 为了InternalsVisibleTo特性 |
成员的可访问性
可访问性,是针对字段说的,但是如果类不可见,哪怕是可访问的也访问不到。
- private => CLR:Private
- protected => CLR:Family
- 不支持 => CLR:Family Ana Assembly
- internal => CLR:Assembly
- protected internal => CLR:Family or Assembly
- public => CLR:Public
派生类重写基类型定义的成员时,C#要求必须是相同的可访问性。比如父类protected,子类也必须是protected。但是CLR并没有这么严格,它要求重写方法可以放宽限制,比如从protected到public,但不可以缩紧比如从protected到private。之所以这样因为CLR承诺派生类总能转型为基类,并获取对基类方法的访问权。
静态类 static
- 是指永远不需要实例化的类,比如Console、Math。
- 静态类只有static成员,且static关键字只能修饰类、不能修饰值类型,因为CLR总是允许值类型实例化。
- 静态类必须从基类System.Object派生。
- 静态类不能实现任何接口。这是因为只有使用类的实例时,才可调用类的接口方法。
在IL代码中,会把static类标记为abstract和sealed,且不会为其生成实例构造器方法。
分布类 partial
partial关键字
partial关键字告诉C#编译器,类、结构或接口的定义源代码可能要分散到一个或多个源代码文件中。
- 多人协作。可以将类型的代码分散到多个源代码文件中,每个文件都可单独check out 或 merge。
- 结构清晰。在同一个文件中将类或结构分解成不同的逻辑单元,比如一部分写需求A一部分写需求B,这样注释修改会更容易。
- 代码生成。通过工具新生成的代码,不需要去原有类里写,新起一个文件类名相同新增即可。更符合开放-封闭原则。
组件化开发和版本控制(虚实调用、virtual、new)
组件化开发
组件软件编程(Component Software Programming)正是面向对象编程发展到极致的成果。
- 组件(程序集)是独立的,且有自己的标识,比如名称和版本号。
- 组件必须指定它需要的安全权限。
- 组件要对外公布接口,这些接口不应随着版本迭代而导致外部的用法改变。
如果无法想象,就直接想象第三方dll。
CLR调用方法虚方法
IL提供2种指令去调用方法
call
- 可用于调用实例方法、虚方法和静态方法。
- 假定该变量不为null,如果变量的类型没有定义该方法,就检查基类型来查找匹配方法。
- call指令常用于以非虚方式调用虚方法。
- 对于代码来说,
Object o = new Point()
会根据申明类型Object来确定。
callvirt
- 可用于调用实例方法和虚方法。
- 一定会进行null检查,因为会check null,所以callvirt的执行速度会比call慢一些。
- callvirt调用虚实例方法,CLR调查发出调用的对象的实际类型,然后以多态方式调用方法。
- 对于代码来说,
Object o = new Point()
会根据实际类型Point来确定。
看一个调用例子
1 | ... |
IL_1 是call,很好理解,静态方法调用。
IL_2 是callvirt,不难想,因为GetHashCode是虚方法。
IL_3 是callvirt,这个比较难,因为定义是public Type GetType()
根本不是虚方法。
但这是可行的,因为对代码进行JIT编译时,CLR知道GetType不是虚方法,所以JIT编译好的代码中,会直接以非虚方式(就是直接找申明类型,不找对象指针了)调用GetType。
那么,为什么C#不干脆直接生成call指令呢?
C#团队认为,JIT编译器应生成代码来验证发出调用的对象不为null。
// TODO 为什么必须用callvirt调用 p147
综上所属,设计类的时候,最好减少虚方法的数量:
- 上面说过了,调用虚方法的速度比非虚方法慢
- JIT编译器不能内嵌(inline)虚方法,这进一步影响性能
- 虚方法使组件版本控制变得更脆弱
正确的做法是:
将最复杂的方法作为虚方法,使所有重载的简便方法成为非虚方法。
具体如下:
1 | public class Set{ |
利用可见性和可访问性
一般实际应用的情况来说,开发人员使用第三方程序集的时候是看不到源码的,所以做程序集的时候就需要好好利用 可见性和可访问性 ,避免使用者开发人员出错。
- 对于不需要特化或无法确定的新类型,要定义成密封类。1.因为更安全:不存在派生类修改其内部逻辑;2.更易于版本控制:密封类可以保持密封,非密封类无法改为密封,因为用户可能已经继承过这个类了。
- 对于确定需要特化、需要派生的类型,定义成public类对外暴露。
- 类的内部的数据字段,尽可能定义成private,不让外界中途修改。
- 类的内部的方法、属性、事件,定义成private和非虚。
- 类的内部的嵌套类型,要用private,不要对外公布而应该只在该类里使用。
new与override
如果基类型和其派生类型有同名同参方法,但是想要指明新方法与基类型的同名方法无关,那么需要用到new关键字。new关键字可以告诉编译器生成元数据,这同样CLR就知道到底调用的是哪个了。
比如:
1 | public class A:B |
但如果想要替换、影响基类方法,那么就该使用override关键字:
1 | public class A:B |
大章7:常量和字段
常量
常量,是指值从不变化的符号,它的值必须能在编译时确定。
确定后,编译器将常量值保存到程序集元数据中,这意味着常量只能定义成编译器识别的基元类型。
我们把const关键字拿来写常量,且它不能和static关键字连用,因为常量总是隐式为static。
常量符号会直接嵌入应用程序的IL代码,如下。
1 | public const Int32 MyNumMaxLength = 50; |
字段
字段,是一种数据成员,其中容纳了一个值类型的实例或者对一个引用类型的引用。
字段存储于动态内存中,所以他们的值在运行时才能获取。
字段的内联(inline)是指
public string x = "A" ;
这种声明后紧跟赋值的用法。内联语法简化的只是语法,仍然是在构造器中赋值的。
C#术语 | CLR术语 | 说明 |
---|---|---|
默认 | Instance | 该字段只与对象的一个实例关联,而不是与类型本身关联 |
static | Static | 该字段是类型状态的一部分,而不是对象状态的一部分 |
readonly | InitOnly | 该字段只能由一个构造器方法中的代码写入(但是可以通过反射修改) |
volatile | Volatile | 编译器、CLR和硬件不会对该字段执行“线程不安全”的优化措施 |
readonly标记,限制的是让字段的引用不可改变,而其引用的对象是可以修改的。
1 | public class Test |
大章8:方法
实例构造器(引用类型)
- 构造器是将类型的实例初始化为良好状态的特殊方法。
- 构造器在“方法定义元数据表”种始终叫做
.ctor
。 - 未被构造器构造的引用类型对象,其实例字段都是0或null。
- 构造器不可被继承,所以不能使用以下修饰符:virtual、new、override、sealed、abstract。
类自动实现的默认构造器
- 如果不写构造器的话,会默认生成:
1 | public class SomeType{ } |
如果派生类没有显式调用基类的构造器,C#编译器会在自动生成对默认的基类构造器的调用。
自动生成的基类构造器,会在派生类构造器内部执行完所有字段初始化(如果你不给它们赋值,就会赋值default)后,再调用。下面贴的代码进一步理解。
有两种情况不会调用构造器而创建类型的实例,1是MemberwiseClone方法,2是序列化器反序列化对象。
以下纪录构造器的特殊写法,这样写可以让IL只自动生成一次代码:
1 | class SomeType(){ |
实例构造器(值类型)
上面说了:
自动生成的基类构造器,会在派生类构造器内部执行完所有字段初始化(如果你不给它们赋值,就会赋值default)后,再调用。
意思比如你有个int,就会赋值0。
那么如果像这么改写呢?
1 | public struct Point { |
虽然上述代码通过不了C#编译,但是如果执行,Rectangle也不会隐式调用Point的构造器,而是会赋默认值0给Point内的x、y。
类型构造器
1 | public class Test |
上面就是类型构造器。
- 它不允许有入参。
- 它本身静态,也只能操作静态字段。
- 它有一个线程互斥同步锁,C#希望AppDomain中每个类型只执行一次类型构造器,所以线程1拿到该锁后开始构造类型,期间其他线程想调用会被阻塞,知道线程1构造结束放开锁后,其他线程恢复正常发现类已经造好了。
此外,如果你想在类型销毁时做点什么,虽然CLR不支持静态Finalize方法,但是可以在AppDomain卸载时的DomainUnload事件登记一个回调。
操作符重载
对于CLR而言,对操作符一无所知,所谓操作符重载只是方法而已。
转换操作符方法
说的是自定义类型之间的 显式转换 和 隐式转换 。
1 | public class Rational |
扩展方法
1 | public static class SomeExtensions{ |
作用
简化代码用,添加后会在智能感知中提示。
this关键字
其核心是this
关键字,需要在静态类中声明。
调用
编译器遇到这个会先找类内的实例方法,找不到才继续会找静态方法,最终生成IL。
因为本质是对静态方法的调用,所以CLR不会生成代码对调用方法的值进行null-check。即不保证非空。
技巧
诸如 public static void ShowItems<T> (this IEnumerable<T> collection)
,通过泛型+接口实现泛用。
可被接作委托
拓展方法可以被当作委托接住,并在后续执行。
1 | Action a = "Jeff".ShowItems; |
分部方法
partial关键字
分部方法用起来和委托很像,书里没提,我猜是委托+注入委托实现的。
1 | partial class SomeType |
用法
- 分部方法只能在 分部类 或者 分部结构 中声明。
- 分部方法可以是 静态 或者 泛型方法 。
- 分部方法返回类型必须是 void ,且不能用 out 。
它更有优势
比起把基类某方法设置为virtual调用,然后子类继承去重写的方法,它更有以下2点优势:
- 类的要求更低,不需要继承且可以密封。
- 编译器会对其进行优化,如果没有提供分部方法的实现,IL就不会生成调用这个方法的代码。
大章9:参数
可选参数和命名参数
可选参数
就是给参数设定默认值,这样可以不输。
命名参数
就是调用的时候给参数显式赋值。
1 | DoSth(name:"Sim",thing:"talk"); |
ref and out
- ref和out不能作为默认参数,因为它们无法传递有意义的默认值。
- ref和out在IL以及CLR看来是一码事,它们都是传参数实例的指针。
- ref和out在方法元数据中的签名是一摸一样的,以为着无法把一个方法的out参数改为ref就作为新方法,编译器会报错。
IL代码
C#中分配了默认参数,编译器会在内部向该参数应用定制特性 System.Runtime.InteropServices.OptionalAttribute 和 System.Runtime.InteropServices.DefaultParameterValueAttribute。
生成的代码会存在文件的元数据中。然后,会向DefaultParameterValueAttribute的构造器传递在源代码中指定的常量值。
向参数传引用
引用和值参数
- 如果是引用类型,会传引用,意味着值可以被改变。
- 如果是值类型,会传实例的一个副本。
ref vs out
C#编译器会区分 out和ref ,但CLR不区分,他们的IL代码相同,元数据几乎相同只有一个bit不同用来区分。
- out假设调用者在调用方法之前可能没初始化对象,所以方法体内不能读取参数的值,而且必须在返回前写入这个值。
- ref与之相反,方法体内能读写参数的值,但是必须在调用方法前初始化参数。
优化点
为大的参数值类型使用out,可以避免在调用方法时复制值类型实例的字段。
签名一致
如果方法使用ref和out,要求参数必须完全吻合签名的类型:
1 | public static void SomeMethod(){ |
=> 因此,签名处建议改成泛型:
1 | public static void SomeMethod(){ |
让参数的数量可变
params关键字 怎么用
使用params关键字可以让方法接受可变数量的参数:
1 | public static void SomeMethod(params Int32 values){ |
params关键字 实现
params关键字告诉编译器向参数应用定制特性System.ParamArrayAttribute的一个实例。
编译器在方法调用时,会先检查所有参数没有应用ParamArray特性的方法;如果没有找到匹配的方法,再去寻找参数应用了ParamArray特性的方法。
params关键字 少用
因为params肯定跟着数组型参数,数组型参数必须在堆上分配且参与垃圾回收,比帧栈上丢参数的方式耗费性能的多。关于这一块改善,可以参考String.Concat
方法,对方法进行尽可能地重载而不是用params,params只用在极端情况下。
参数和返回类型的设计规范 ⭐
参数类型尽可能弱
声明方法的参数,类型越弱越好,宁愿要接口也不要基类。这样适用范围更大。
1 | void SomeMethod<T> (IEnumerable<T> collection) {...} |
返回类型尽可能强
相反,声明方法的返回值类型越强越好,代码就不演示了。这样做返回值能做的事更多,也可以转换成基类。
适当调整提高灵活性
上面2点虽然是原则,但是最后要根据实际情况来。比如说返回类型更强,会导致方法内部的变量要求更为严格,导致灵活性下降更难改动方法。
常量性
有的语言(比如C++)允许将方法或参数声明为常量,但是CLR并不提供这个功能。
大章10:属性
属性
CLR支持两种属性:无参与有参。它们之间的区分就是get、set访问器接不接受参数。
- 无参属性,就是平时说的属性
- 有参属性,C#又称它为索引器。
无参属性
是什么
无参属性,就是平时说的属性。
数据封装
建议将字段设置为private,为其设置访问器(accessor)方法。属性进行读/写操作的本质是通过访问器,也就是方法。
数据封装的目的
- 为了实现一些side effect,就是在
get;set;
里写逻辑。比如数据检查。 - 可以以线程安全的方式访问字段。
编译器处理
- 如果对属性写了
get;set;
访问器方法,那么在编译器会生成对应的方法,类似于get_Name();set_Name();
。如果没写就不生成。 - 托管程序集元数据中的属性定义。这一项必然生成。
用于反射
上面说了,编译器会为属性生成一个属性定义项,里面有一些flag、属性类型、属性对应的get、set访问器方法的引用。这种元数据可以被System.Reflection.PropertyInfo类
获取,但是CLR不用这些元数据,只需要访问器方法。
属性自动实现
C#提供的语法糖:
1 | public String Name {get; set;} |
像上述这么实现,C#编译器会自动声明一个私有字段,并自动实现get_Name、set_Name方法。
属性vs字段
- 属性可以只读只写,而字段总是可读可写(除了readonly)。
- 属性不能作为out或ref参数传给方法,而字段可以。
- 属性方法可能造成副作用,而字段不会。
- 属性方法可能抛出异常,而字段访问永远不会。
对象初始化器
C#提供的语法糖:
1 | var man = new Man() { Name = "Sam", Age = 25}; |
集合初始化器
如果属性类型实现了IEnumerable
或者IEnumerable<T>
接口,属性就被认为是集合。
而编译器面对集合的初始化,会假设类实现了Add方法并为其执行Add方法;如果集合没有实现Add方法却使用集合初始化器,会报错。
比如,C#提供的语法糖:
1 | // 假设有一个类 |
匿名类型
如下方式定义类型,编译器的操作:
1 | var o1 = new {Name = "Jeff", Year = 1964}; |
- 如代码段描述,生成的匿名类型继承自Object,所有字段readonly且不提供set所以不可修改。
- 如代码段描述,会重写Equals、GetHashCode、ToString三个方法。
- 如代码段描述,完全同样结构的匿名类型,C#编译器会复用同一个类,所以可以
o1 = o2
甚至o1.Equals(o2)
。 - 匿名类型经常与LINQ配合使用,比如
var query = from ... select new{Name = ...,Year = ...};
System.Tuple类型
Tuple,组元。一组微软定义的泛型组成的类型,目的是简化类的定义,有需求时可以使用。
- 这个类型提供了CompareTo、Equals、GetHashCode、ToString方法 以及 Size属性。
- 这一系列类型都和下面一样结构,只是入参数量不同,最复杂的是8个入参!
1 | [ ] |
有参属性
是什么
get、set访问器接受一个或多个参数的属性,C#称为索引器。
数组风格
C#使用数组风格的语法来公开有参属性(索引器),其实也就是对[]
操作符的重载。
必须在this里写重载,如下:
1 | // 定义 |
编译器处理
- 如果对属性写了
get;set;
访问器方法,那么在编译器会生成对应的方法,类似于get_Item();set_Item();
。如果没写就不生成。 - 托管程序集元数据中的属性定义。这一项必然生成。
IndexerName特性
上面提到了编译器会默认给有参属性生成get_Item();set_Item();
名字默认方法,但是也可以使用 IndexerName特性 修改默认的名字:
1 | using System.Runtime.CompilerServices; |
使用上述代码后,编译器将生成get_Bit();set_Bit();
的方法,而不是默认方法名。
因为C#编译器的索引器实现,本质是重写[]
,所以不支持 命名不同、签名相同的 索引器,但是支持 命名相同、签名不同的 索引器。
另外,关于属性访问器
调用属性访问器时的性能
- 对于简单的get、set访问器,JIT编译器会将代码内联,这样使用属性就不会有性能上的缺失。
- 此处内联(inline)是指将方法的代码直接编译到调用它的方法中。
- 这么做,坏处是会使编译好的方法变得更大,好处是避免了在运行时发出调用产生的开销。
属性访问器的可访问性
就是set、get访问器分别设置不同的保护级别,实现不同目的。
大章11:事件
事件是什么
概念
事件,用于通知其他对象发生了特定的事情。
CLR事件以 委托 为基础。
功能
- 方法能登记它对事件的关注。
- 方法能注销它对事件的关注。
- 事件发生时,登记了的方法将收到通知。
例子
- 一个
Phone实例
和一个Computer实例
,两者各有一个方法,在MailManager实例
里登记了对NewMail事件
的关注。 - 一封新邮件到达
MailManager实例
。 MailManager实例
将事件通知发送给所有已登记的方法,他们各自的方法以自己的方式处理邮件。
设计公开事件
0.序言
进行上述模型的完整设计。
1.定义传递信息:EventArgs
定义一个类型,来容纳所有应该发送给事件通知接收者的附加信息。
根据约定,这种类,这种类应该从System.EventArgs
派生,类名由EventArgs结束。
1 | public class NewMailEventArgs : EventArgs { |
2.定义事件成员:EventHandler
事件成员使用关键字event定义。
根据约定,这种类,这种类应该从System.EventArgs
派生,类名由EventArgs结束。
1 | public class MailManager { |
3.定义引发事件的方法:OnEvent
根据约定,类要定义一个受保护的虚方法。引发事件时,方法会被调用,获取到NewMailEventArgs
对象并传递对象信息给接收者们。
1 | class MailManager { |
4.定义引发事件的方法:Publish
相当于对外暴露的接口,按照指定格式输入入参,再将入参转化为配置好的事件进行调用。
1 | class MailManager { |
5.简单使用:Subscribe & Use
书里没有,我补一下最简单的使用方法。
1 | class Main() { |
编译器如何实现事件
事件成员
前面代码中,事件成员,用一行代码就完成了定义:
1 | public event EventHandler<NewMailEventArgs> NewMail; |
编译
C#编译器编译时把它转换为以下3个构造:
1.具有恰当委托类型的字段
1 | private EventHandler<NewMailEventArgs> NewMail = null; |
第一个构造是一个具有恰当委托类型(EventHandler<NewMailEventArgs>
)的字段。
把NewMail
理解成一个委托实例列表,事件发生时会通知这个列表中的所有委托实例,目前为null
意味着没有监听者(listener)。
登记监听该事件,就相当于对委托列表添加一个实例;反之,注销意味着从列表中移除委托。
注意了,上面代码也提到过,EventHandler就是委托!委托类似于签名定义,下面说的委托实例是说遵照签名定义的方法。
~.插播一下
下面用到很多Interlocked.CompareExchange
方法,先了解一下,摘自微软官方文档。
System.Threading.Interlocked
类:为多个线程共享的变量提供原子操作。Interlocked.CompareExchange(ref A, B, C)
静态方法:比较BC两个值是否相等,如果相等,则替换第一个值A。
2.为事件构造add方法
1 | // 允许方法登记对事件的关注 |
C#编译器在事件名(NewMail)前加add_前缀,并自动生成代码。
添加委托是通过调用Delegate.Combine
方法,它将委托实例添加到委托实例列表中,返回新的列表头(地址),并将这个地址存回字段。
3.为事件构造remove方法
1 | // 允许方法注销对事件的关注 |
C#编译器在事件名(NewMail)前加remove_前缀,并自动生成代码。
添加委托是通过调用Delegate.Remove
方法,它将委托实例从委托实例列表中移除,返回新的列表头(地址),并将这个地址存回字段。
ps.关于add和remove
上述由编译器自动生成的add和remove方法的可访问性,都是根据event申明的可访问性来的,也就是都是public。
编译器除了上述3块,还会在元数据中生成一个事件定义记录项,它引用了add和remove访问其方法。它们用来建立“事件”和访问其方法之间的联系,可以通过反射System.Reflection.EventInfo
获取调用。
设计监听者的类型
我分割为3要素
1 | public class Phone { |
+=操作符
C#编译器内部对+=的操作符由特殊处理,会翻译成以下代码:
1 | manager.NewMail += PhoneMsg; |
-=操作符
代码和上面一样,就不多写了。
值得一提的是,-=是扫描委托列表,找到匹配再删除的,没有找到也不会报错。
登记方法属于引用
实例的方法被登记到了某事件,那么该实例就无法被垃圾回收了。
显式实现事件
下面自己使用Dictionary来实现一个维护委托实例列表的事件类。
调用
先看做完后怎么调用的。使用标准语法即可:
1 | public static void Main(){ |
实现委托列表
映射EventKey -> Delegate,且对外提供Add、Remove、Raise方法。书中用了很多线程安全方法诸如Monitor.Enter
,由于在将事件,这块简化掉:
1 | public sealed class EventKey { } |
调用委托列表的类
接着定义一个类来使用EventSet类。在这个类中,一个字段引用了一个EventSet对象,且显式实现了事件的add/remove。
1 | // 事件类型,可以在里面加想要的附加信息 |
疑问与思考
其实到这里,我还是不理解为什么要用一个字典型来实现,明明按照示例一个使用类单独维护一个委托队列,那么key就是一个,压根用不到key啊。
我怀疑可能是为了让eventSet
实例复用,像我可以在TypeWithLotsOfEvents
类里加一套新的委托,但还是加到原来的eventSet
实例中。可以这么写:
1 | public class MyEventArgs2 : EventArgs { } |
最后,本节核心思想是维护一个基于key-value的集合,引发事件时会在集合中查找事件标识符,如果找到就调用委托列表,如果没找到意味着没人登记关注所以就不做回调。
大章12:泛型
FCL中的泛型
泛型是什么
泛型(generic)是CLR和编程语言提供的一个特殊机制,目的是为了“算法重用”。
泛型提供更佳的性能
泛型可以替换很多需要Object装箱拆箱实现的场景,还可以避免强制类型转换,从而提高代码运行速度、减少资源使用。
比如,一个使用泛型的List<T>
比非泛型的ArraryList算法,在面对频繁拆装箱的情况下,能有非常大的性能差距!
泛型基础结构
实现泛型
为了在CLR2.0加入泛型,微软至少做了以下工作:
- 创建新的IL指令,使之能够识别类型实参。
- 修改编译器和JIT编译器,使之识别处理泛型生成IL代码。
- 修改现有元数据表格式,以便表示具有泛型参数的类型名称和方法。
- 修改C#、.NET库来支持新语法。
开放类型、封闭类型
- 开放类型:具有泛型类型参数的类型。
- 封闭类型:为所有类型参数都传递了实际的数据类型的类型。
- 两者使用区别:开放类型无法创建实例,和接口一样;封闭类型可以。
1 | // 一个部分指定的开放类型 |
关于静态字段,开放类型转换成封闭类型后会其分配各自的静态字段,相同的T共用一个静态字段。
由此可知,对于同一个开放类型,相同的T入参会共用一个封闭类型:
1 | static void Main(string[] args) |
最后,利用开放类型转换封闭类型时会为static重新分配的技巧,可以如此约束封闭类型:
1 | class MyList<T> { |
泛型类型的继承
泛型类型也是类型,他是一样可以继承的,可以继承也意味着可以利用各种类型转换。
下面实现一个节点为泛型的链表:
1 | public class Node{ |
泛型类型的同一性、相等性
如果对泛型类型进行派生:
1 | public class DateTimeList : List<DateTime> { |
这么派生,虽然程序员的意思是想简写List<DateTime>
为DateTimeList
,但是这样是不可行的,因为:
1 | // x = false |
这样即使两者数据完全一致,但是仍然在类型上不同。你就无法直接用=给DateTimeList实例
赋值了。
有一种比较好的解决方案是用using重命名List<DateTime>
。当然,我的建议不命名,保持List<DateTime>
。
代码爆炸
这块说的就是因为泛型给程序员带来的书写简便,是由编译器承担更多处理、生成更多IL代码来实现的,所以每次用类型都会生成一个新类型的IL代码,这样内存里的代码量会爆炸。
CLR为解决这个问题做了2个优化:
- 上面提过的,对于同类型同入参T的泛型类型,只进行一次编译,后面复用这个类型。
- CLR会认为所有是引用类型的T实参都完全相同,所以能够共享引用类型的IL代码。意思就是说,因为引用类型的实参或者变量都是在堆上的指针,不同的T类型也只是指针指向的对象不同而已。
泛型接口
目的
除了上面提的泛型(值、引用)类型,泛型接口也非常常用来避免频繁拆装箱:
1 | public interface IEnumerator<T> : IDisposable,IEnumerator { |
具体看13章。
泛型委托
目的
泛型委托是为了保证未知类型对象能以安全的方式传给回调方法,且不必装箱。
本质
委托实际只是提供了4个方法的一个类,17章会细说,这里展示一下泛型委托:
1 | // 如此定义泛型委托, |
逆变协变
协变和逆变是指对返回值和参数的类型进行转换,使得T更灵活。
- 不变量(invariant),意味着泛型类型参数不能更改。目前为止提过的都是不变量形式的泛型类型参数。
- 逆变量(contravariant),意味着泛型类型参数可以从一个类更改为它的某个派生类。C#中用
in
来标记,只能出现在输入位置,比如入参。 - 协变量(covariant),意味着泛型类型参数可以从一个类更改为它的某个基类。C#中用
out
来标记,只能出现在输出位置,比如返回值。
好了,看完上面的概念,反正我是一头雾水,但其实就是下面这玩意:
1 | // 可以这么定义,没错就是Func<> |
泛型方法
示例
可以很好的让ref
、out
这两个要求入参必须与签名保持一致的关键字,很好的得到运用。
具体的可以看之前的9.2节:
1 | public static void Swap<T>(ref T o1, ref T o2) { |
类型推断
因为上述这么些泛型方法,<>
实在是太多了,所以C#编译器支持泛型方法在调用时,自动推断T类型。
比如:
1 | string n1 = "testA"; |
此外,可以重名定义明确的参数,编译器会选择先调用明确的,比如:
1 | public static void Display(string s){ |
约束
约束
向C#编译器承诺,入参T会使用后续类型的实现、派生类。
就是where:
1 | // 普通使用 |
主要约束
参数T可以指定0~1个主要约束,主要约束代表类,注意了必须要非密封类:
1 | // 主要约束:要求为引用类型 |
次要约束
参数T可以指定0~n个次要约束,次要约束代表接口。代码就不show了。
类型参数约束
参数T可以指定0~n个次要约束,类型参数约束,就是用参数来约束参数。
1 | // 类型参数约束 T : TBase |
构造器约束
参数T可以指定0~1个构造器约束,就是new:
1 | public class SomeType<T> where T : new() { |
可验证性
可验证性
为了确保安全,约束代码有所验证。
1.泛型类型变量的转换
如果不提供约束并按照约束规则转型,直接将泛型类型的变量强转型为其他类型会报错:
1 | public class Test { } |
2.将泛型类型变量设为默认值
将泛型类型变量设置为null是编译不过的,除非将泛型类型约束成引用类型。
原因是编译器确定不了T的类型,而值类型不能为null,引用类型可以。
添加约束为引用类型就可以合法了。
但是我们也可以这么做:
1 | public void Method<T>() { |
3.将泛型类型变量与null进行比较
可以比较。
如果T是值类型,永远不会为null、永远返回false,编译器知道,所以在生成代码中就会直接理解为false。
4.两个泛型类型变量相互比较
如果泛型类型参数不能肯定是引用类型,对同一个泛型类型的两个变量比较也是非法的。
5.泛型类型变量作为操作数使用
+、-、*、/ 是非法的。
大章13:接口
实现接口和继承的关系
关于接口继承
因为C#不支持多继承,所以推出了“缩水版”的多继承,也就是接口。
聊下继承
System.Object类是终极基类,所有类都继承了Object的4个实例方法,这个之前讲过不提了。
这里要聊一下的是方法签名,就是C#编译器会认为自己在操作Object类的实例(因为继承了Object),可以有各种智能感知等等,但实际操作的可能是其他类的实例。
接口初识
聊下接口
接口,是用来被实现的。
它实际只是对一组方法签名进行了统一命名。这些方法不提供任何实现,继承了某接口的类需要显式实现该接口定义的所有方法。
除了实现了多继承,它的另一个好处和类一样,就是“里氏替换原则”:
派生类对象可以在程式中代替其基类对象。
CLR怎么看接口
其实CLR看来,接口定义就是类型定义。也就是CLR会为接口类型对象定义内部数据结构,同时可通过反射机制来查询接口类型的功能。
接口支持泛型方法。
接口“继承”接口
接口“继承”接口就有点不一样了,这和传统的类继承类不一样,它更接近于将其他接口的协定(contract)包括到新接口中。
接口幕后
显式实现接口
1 | // 定义 |
输出不同,说明后者调用到的是显式实现接口的方法。下面再解释为什么。
隐式实现:编译器是怎么看待接口实现的?
先再来看隐式实现,并给出编译器执行流程:
1 | // 定义 |
可以看到,与显式实现比对,输出又相同了。CLR对这段代码的处理流程如下:
- 首先CLR加载类型时,会为该类型创建并初始化一个方法表,每个方法都有对应的记录项(第一章)。
对于上述SomeType
类型的方法表,会生成以下3个记录项:
- Object(隐式继承的基类)定义的所有虚实例方法。
- IDisposable(继承的接口)定义的所有接口方法。这里指Dispose。
- SomeType引入的新方法Dispose。
为简化编程,C#编译器假定SomeType引入的新方法Dispose是对IDisposable的Dispose方法的实现。因为两者签名和返回值完全一致。
C#编译器接下来,会将这新方法和接口方法进行匹配,生成元数据,指明
SomeType
类型的放发表中的两个记录项应引用同一个实现。
综上,
隐式实现接口方法的时候,2个方法在元数据里指向同一个实现,所以完全一致;
显式实现接口方法的时候,2个方法在元数据里指向不同的实现,只不过接口的同签名方法是private的,想要调用,需要用接口申明的变量去接这个实例,才能调用到。
泛型接口
使用泛型接口有一些好处:
- 类型确定,提高编译时安全性。
- 避免装箱拆箱。
- 类可以实现同一个接口若干次,只要T不同即可,接口代码复用率提高。
- 泛型接口也可以协变逆变,具体看12.4泛型委托里。
泛型接口约束
一样是where,书中只举了个方法例子,避免了拆装箱 并 验证了类型:
1 | // 值类型实现了IComparable,IConvertible |
再聊显式实现方法
优点1:可实现多个具有同签名方法的接口
就是说可以靠显式实现,来实现多个同名、同签名方法的接口。
基本没用过,不同接口接同一个对象,能有不同的方法实现,这算是另一种意义上的多态吗?
总之是能有效解决多个接口有同签名方法,一个类无法同时实现多个这样的接口的问题。
缺点1:无智能感知
缺点2:无法被派生类调用
1 | // 比如有一个基类显式实现了IComparable的CompareTo |
缺点3:值类型调用时需要装箱
代码不贴了,和2一样,需要转换成对应接口才能调用显式实现方法,而值类型转换接口时需要装箱。
设计:基类还是接口
1.IS-A对比CAN-DO关系
CAN-DO关系,就是很多类型对象都“能”做某事,这需要用接口。
2.易用性
基类提供各个方面的功能,接口需要一个个实现。
3.一致性实现
基类型可以提供好的默认实现,接口不能提供默认实现,容易让开发人员出错。