大章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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public sealed class SomeType{	// 1
// 嵌套类
private class SomeNestedType{} // 2

// 常量、只读和静态可读/可写字段
private const Int32 c_SomeConstant = 1; // 3
private readonly String m_SomeReadOnlyField = "2"; // 4
private static Int32 s_SomeReadWriteField = 3; // 5

// 类型构造器
static SomeType() {} // 6

// 实例构造器
public SomeType(Int32 x) {} // 7
public SomeType() {} // 8

// 实例方法和静态方法
private String InstanceMethod() { return null; } // 9
private static void Main() {} // 10

// 实例属性
public Int32 SomeProp{ // 11
get{ return 0; } // 12
set{} // 13
}

// 实例有参属性(索引器)
public Int32 this[String s]{ // 14
get{ return 0; } // 15
set{} // 16
}

// 实例事件
public event EventHandler SomeEvent; // 17
}

希望能回头看看这个例子,体会成员是如何定义的,他们对编译器生成的元数据有何影响。

类型的可见性

可见性,只是针对类说的,而不是字段。

  • public class 全部可见

  • internal class 程序集内可见,如果不定义可见性,默认就是internal

友元程序集(friend assembly)
是为了让两个不同程序集之间能相互公开,而对外不公开的一种技术支持。利用InternalsVisibleTo特性。

1
2
3
4
5
6
7
using System.Runtime.CompilerServices;	// 为了InternalsVisibleTo特性

// 公钥为“12345678...90abcdef”的友元程序集“AssemblyMySchool”
[assembly:InternalsVisibleTo("AssemblyMySchool,PublicKey=12345678...90abcdef")]

// 友元集访问下类时,会如同访问public一样
internal sealed class Student { ... }

成员的可访问性

可访问性,是针对字段说的,但是如果类不可见,哪怕是可访问的也访问不到。

  • 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
2
3
4
5
6
...

Console.WriteLine(); // IL_1:call void System.Console::WriteLine()
Object o = new Object(); // IL:newobj instance void System.Object::.ctor()
o.GetHashCode(); // IL_2:callvirt instance int32 System.Object::GetHashCode()
o.GetType(); // IL_3:callvirt class System.Object::GetType()

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Set{
private int length = 0;

// 这个简便的重载方法是非虚的
public int Find(Object val){
return Find(val,0,length);
}

// 这个简便的重载方法是非虚的
public int Find(Object val,int startIndex){
return Find(val,0,length - startIndex);
}

// 功能最丰富的方法是虚方法,可被重写
public virtual int Find(Object val,int startIndex,int endIndex){
// 可被重写的实现...
}
}

利用可见性和可访问性

一般实际应用的情况来说,开发人员使用第三方程序集的时候是看不到源码的,所以做程序集的时候就需要好好利用 可见性和可访问性 ,避免使用者开发人员出错。

  • 对于不需要特化或无法确定的新类型,要定义成密封类。1.因为更安全:不存在派生类修改其内部逻辑;2.更易于版本控制:密封类可以保持密封,非密封类无法改为密封,因为用户可能已经继承过这个类了。
  • 对于确定需要特化、需要派生的类型,定义成public类对外暴露。
  • 类的内部的数据字段,尽可能定义成private,不让外界中途修改。
  • 类的内部的方法、属性、事件,定义成private和非虚。
  • 类的内部的嵌套类型,要用private,不要对外公布而应该只在该类里使用。

new与override

如果基类型和其派生类型有同名同参方法,但是想要指明新方法与基类型的同名方法无关,那么需要用到new关键字。new关键字可以告诉编译器生成元数据,这同样CLR就知道到底调用的是哪个了。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class A:B
{
public new void Talk(){
Console.WriteLine("A Talk");
OpenMouth();
base.Talk(); // 用了基类的同名方法!基类的Talk方法里调用了基类自己的OpenMouth方法
}

protected new virtual void OpenMouth(){
Console.WriteLine("A Open the Mouse");
}
}

// 执行A.Talk();
// output line 1: A Talk
// output line 2: A Open the Mouse
// output line 3: B Talk
// output line 4: B Open the Mouse

但如果想要替换、影响基类方法,那么就该使用override关键字

1
2
3
4
5
6
7
8
9
10
public class A:B
{
protected override void OpenMouth(){
Console.WriteLine("A Open the Mouse");
}
}

// 执行A.Talk();
// output line 1: B Talk
// output line 2: A Open the Mouse

大章7:常量和字段

常量

常量,是指值从不变化的符号,它的值必须能在编译时确定。
确定后,编译器将常量值保存到程序集元数据中,这意味着常量只能定义成编译器识别的基元类型。
我们把const关键字拿来写常量,且它不能和static关键字连用,因为常量总是隐式为static。
常量符号会直接嵌入应用程序的IL代码,如下。

1
2
3
public const Int32 MyNumMaxLength = 50;
// 以下为对应调用的IL代码,可以看到直接是50而不是读取变量
IL_0006: ldc.i4.s 50

字段

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

  • 字段存储于动态内存中,所以他们的值在运行时才能获取。

  • 字段的内联(inline)是指 public string x = "A" ; 这种声明后紧跟赋值的用法。内联语法简化的只是语法,仍然是在构造器中赋值的。

C#术语 CLR术语 说明
默认 Instance 该字段只与对象的一个实例关联,而不是与类型本身关联
static Static 该字段是类型状态的一部分,而不是对象状态的一部分
readonly InitOnly 该字段只能由一个构造器方法中的代码写入(但是可以通过反射修改)
volatile Volatile 编译器、CLR和硬件不会对该字段执行“线程不安全”的优化措施

readonly标记,限制的是让字段的引用不可改变,而其引用的对象是可以修改的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Test
{
public static readonly List<int> readonlyList = new List<int>(){1,2,3};
}

static void Main(string[] args)
{
Test.readonlyList.Add(4); // 修改引用的字段
foreach (var line in Test.readonlyList)
{
Console.WriteLine(line);
}
}

// output: 1 2 3 4

大章8:方法

实例构造器(引用类型)

  • 构造器是将类型的实例初始化为良好状态的特殊方法。
  • 构造器在“方法定义元数据表”种始终叫做 .ctor
  • 未被构造器构造的引用类型对象,其实例字段都是0或null。
  • 构造器不可被继承,所以不能使用以下修饰符:virtual、new、override、sealed、abstract。

类自动实现的默认构造器

  • 如果不写构造器的话,会默认生成:
1
2
3
4
5
public class SomeType{ }
/*--------等价于---------*/
public class SomeType{
public SomeType() : base(){ }
}

如果派生类没有显式调用基类的构造器,C#编译器会在自动生成对默认的基类构造器的调用。

自动生成的基类构造器,会在派生类构造器内部执行完所有字段初始化(如果你不给它们赋值,就会赋值default)后,再调用。下面贴的代码进一步理解。

有两种情况不会调用构造器而创建类型的实例,1是MemberwiseClone方法,2是序列化器反序列化对象。

以下纪录构造器的特殊写法,这样写可以让IL只自动生成一次代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SomeType(){
private int m_x;
private double m_d;
// 通用构造器
public SomeType(){
m_x = 5;
m_d = 3.11;
}
// 继承通用构造器
public SomeType(int input) : this(){
m_x = input;
}
public SomeType(double input) : this(){
m_d = input;
}
}

实例构造器(值类型)

上面说了:
自动生成的基类构造器,会在派生类构造器内部执行完所有字段初始化(如果你不给它们赋值,就会赋值default)后,再调用。
意思比如你有个int,就会赋值0。

那么如果像这么改写呢?

1
2
3
4
5
6
7
8
9
10
public struct Point {
public int x,y;
// 注意,实际上不可能写出构造器Point,因为C#规定不允许struct结构体内部定义 无参构造器 或者给内联赋初始值。
public Point() {
x = y = 1;
}
}
public class Rectangle {
public Point topLeft,bottomRight;
}

虽然上述代码通过不了C#编译,但是如果执行,Rectangle也不会隐式调用Point的构造器,而是会赋默认值0给Point内的x、y。

类型构造器

1
2
3
4
5
6
7
8
public class Test
{
static int a = 2;//先执行
static Test()
{
a = 1;//后执行,最后a=1
}
}

上面就是类型构造器。

  • 它不允许有入参。
  • 它本身静态,也只能操作静态字段。
  • 它有一个线程互斥同步锁,C#希望AppDomain中每个类型只执行一次类型构造器,所以线程1拿到该锁后开始构造类型,期间其他线程想调用会被阻塞,知道线程1构造结束放开锁后,其他线程恢复正常发现类已经造好了。

此外,如果你想在类型销毁时做点什么,虽然CLR不支持静态Finalize方法,但是可以在AppDomain卸载时的DomainUnload事件登记一个回调。

操作符重载

对于CLR而言,对操作符一无所知,所谓操作符重载只是方法而已。

转换操作符方法

说的是自定义类型之间的 显式转换 和 隐式转换 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Rational
{
public int num;

public Rational(int num)
{
this.num = num;
}

// 定义转换方法 x.ToInt32();
public Int32 ToInt32() { return num; }

// 隐式转换重写 Rational x = 5;
public static implicit operator Rational(Int32 num)
{
return new Rational(num);
}

// 显式转换重写 (int)x;
public static explicit operator Int32(Rational r)
{
return r.ToInt32();
}

// 在IL中,对应op_Implicit(Int32 num)
// 在IL中,对应op_Explicit(Rational r)
}

扩展方法

1
2
3
4
5
public static class SomeExtensions{
public static Int32 IndexOf(this SomeType st,char val){
return st.Find(val).index;// 假设有这个方法
}
}

作用

简化代码用,添加后会在智能感知中提示。

this关键字

其核心是this关键字,需要在静态类中声明。

调用

编译器遇到这个会先找类内的实例方法,找不到才继续会找静态方法,最终生成IL。

因为本质是对静态方法的调用,所以CLR不会生成代码对调用方法的值进行null-check。即不保证非空。

技巧

诸如 public static void ShowItems<T> (this IEnumerable<T> collection) ,通过泛型+接口实现泛用。

可被接作委托

拓展方法可以被当作委托接住,并在后续执行。

1
2
Action a = "Jeff".ShowItems;
a();// 本质是Invoke委托并传入"Jeff"的引用

分部方法

partial关键字

分部方法用起来和委托很像,书里没提,我猜是委托+注入委托实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
partial class SomeType
{
partial void OnNameChanged(int val);// 分部方法写法
public void Test()
{
OnNameChanged(1);
// 如果OnNameChanged没被提供实现,那么这一段调用在IL代码里不会出现
}
}

partial class SomeType
{
partial void OnNameChanged(int val)
{
// do sth or just not

}

}

用法

  • 分部方法只能在 分部类 或者 分部结构 中声明。
  • 分部方法可以是 静态 或者 泛型方法 。
  • 分部方法返回类型必须是 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
2
3
4
5
6
7
8
9
10
11
public static void SomeMethod(){
String s1 = "Jeffrey";
String s2 = "Richter";

// Swap(ref s1,ref s2)编译器会直接报错,必须像下面这样写
Swap(ref object(s1),ref object(s2);
}

public static void Swap(ref object a,ref object b){
...
}

=> 因此,签名处建议改成泛型:

1
2
3
4
5
6
7
8
9
10
public static void SomeMethod(){
String s1 = "Jeffrey";
String s2 = "Richter";

Swap(ref s1,ref s2);
}

public static void Swap<T>(ref T a,ref T b){
...
}

让参数的数量可变

params关键字 怎么用

使用params关键字可以让方法接受可变数量的参数:

1
2
3
public static void SomeMethod(params Int32 values){
...
}

params关键字 实现

params关键字告诉编译器向参数应用定制特性System.ParamArrayAttribute的一个实例。

编译器在方法调用时,会先检查所有参数没有应用ParamArray特性的方法;如果没有找到匹配的方法,再去寻找参数应用了ParamArray特性的方法。

params关键字 少用

因为params肯定跟着数组型参数,数组型参数必须在堆上分配且参与垃圾回收,比帧栈上丢参数的方式耗费性能的多。关于这一块改善,可以参考String.Concat方法,对方法进行尽可能地重载而不是用params,params只用在极端情况下。

参数和返回类型的设计规范 ⭐

参数类型尽可能弱

声明方法的参数,类型越弱越好,宁愿要接口也不要基类。这样适用范围更大。

1
2
3
void SomeMethod<T> (IEnumerable<T> collection) {...}
// is better than
void SomeMethod<T> (List<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
2
3
4
5
var man = new Man() { Name = "Sam", Age = 25};
// 上述语法糖等价于
var man = new Man();
man.Name = "Sam";
man.Age = 25;

集合初始化器

如果属性类型实现了IEnumerable或者IEnumerable<T>接口,属性就被认为是集合。

而编译器面对集合的初始化,会假设类实现了Add方法并为其执行Add方法;如果集合没有实现Add方法却使用集合初始化器,会报错。

比如,C#提供的语法糖:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 假设有一个类
public class Classroom {
public List<String> Students { get; set; } = new List<String>();
}

// 语法糖
Classroom class = new Classroom{
Students = {"A","B"}
};
// 上述语法糖等价于
Classroom class = new Classroom();
class.Students.Add("A");
class.Students.Add("B");

匿名类型

如下方式定义类型,编译器的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var o1 = new {Name = "Jeff", Year = 1964};
// equal to , 2种匿名类型定义方式
String Name = "Jeff";Int Year = 1964;
var o2 = new {Name, Year};
// 他们属于一个结构的匿名类,C#就只创造一个类
o2 = o1;
// 匿名类结构,编译器生成代码如下
[CompilerGenerated]
internal sealed class <>f__AnonymousType0<...> : Object {
// 1.属性,会作为只读字段
private readonly String Name;
public String Name {get{return Name;}}
...
// 2.构造器,按照声明处顺序来
public <>f__AnonymousType0<...>(String a1,Int a2){
Name = a1;Year = a2;
}
// 3.重写Object.Equals
public override Boolean Equals(Object val){
// 任何字段不匹配就返回false,否则true
}
// 4.重写Object.GetHashCode
public override Int32 GetHashCode(Object val){
// 返回根据每个字段的哈希码生成的一个哈希码
}
// 5.重写Object.ToString
public override String ToString(Object val){
// 返回 “属性名=值”对 的逗号分隔列表
}
}
  • 如代码段描述,生成的匿名类型继承自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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Serializable]
public class Tuple<T1,T2,TRest>{
private T1 m_Item1;
public T1 Item1 { get{return m_Item1;} }
...other props...
public Tuple(T1 t1,T2 t2,TRest rest){
m_Item1 = t1; m_Item2 = t2; m_Rest = rest;
}
}

// ------使用------
// 常规用法
Tuple<int, int> test = Tuple.Create<int, int>(1, 2);
// 拓展用法,创建多于8个元素的Tuple,可为Rest参数传递另一个Tuple
var t = Tuple.Create(0,1,2,3,4,5,6,Tuple.Create(7,8));

有参属性

是什么

get、set访问器接受一个或多个参数的属性,C#称为索引器。

数组风格

C#使用数组风格的语法来公开有参属性(索引器),其实也就是对[]操作符的重载。

必须在this里写重载,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 定义
public class Students
{
private string[] name = new string[10];

//索引器必须以this关键字定义,其实这个this就是类实例化之后的对象
public string this[int index]
{
get
{
return name[index];
}
set
{
name[index] = value;
}
}
}

// 使用
...
Students sts = new Students();
// “=”号右边对索引器赋值,其实就是调用其set方法
Students[0] = "Tom";
Students[1] = "Sam";
// 输出索引器的值,其实就是调用其get方法
Console.WriteLine(Students[0]);

编译器处理

  • 如果对属性写了get;set;访问器方法,那么在编译器会生成对应的方法,类似于get_Item();set_Item();。如果没写就不生成。
  • 托管程序集元数据中的属性定义。这一项必然生成。

IndexerName特性

上面提到了编译器会默认给有参属性生成get_Item();set_Item();名字默认方法,但是也可以使用 IndexerName特性 修改默认的名字:

1
2
3
4
5
6
7
8
using System.Runtime.CompilerServices;

public sealed class BitArray {
[IndexerName("Bit")]
public Boolean this[Int32 bitPos]{
// 这里至少要定义一个访问器方法
}
}

使用上述代码后,编译器将生成get_Bit();set_Bit();的方法,而不是默认方法名。

因为C#编译器的索引器实现,本质是重写[],所以不支持 命名不同、签名相同的 索引器,但是支持 命名相同、签名不同的 索引器。

另外,关于属性访问器

调用属性访问器时的性能

  1. 对于简单的get、set访问器,JIT编译器会将代码内联,这样使用属性就不会有性能上的缺失。
  2. 此处内联(inline)是指将方法的代码直接编译到调用它的方法中。
  3. 这么做,坏处是会使编译好的方法变得更大,好处是避免了在运行时发出调用产生的开销。

属性访问器的可访问性

就是set、get访问器分别设置不同的保护级别,实现不同目的。

大章11:事件

事件是什么

概念

事件,用于通知其他对象发生了特定的事情。

CLR事件以 委托 为基础。

功能

  • 方法能登记它对事件的关注。
  • 方法能注销它对事件的关注。
  • 事件发生时,登记了的方法将收到通知。

例子

  1. 一个Phone实例和一个Computer实例,两者各有一个方法,在MailManager实例里登记了对NewMail事件的关注。
  2. 一封新邮件到达MailManager实例
  3. MailManager实例将事件通知发送给所有已登记的方法,他们各自的方法以自己的方式处理邮件。

设计公开事件

0.序言

进行上述模型的完整设计。

1.定义传递信息:EventArgs

定义一个类型,来容纳所有应该发送给事件通知接收者的附加信息。

根据约定,这种类,这种类应该从System.EventArgs派生,类名由EventArgs结束。

1
2
3
4
5
6
7
8
9
10
11
public class NewMailEventArgs : EventArgs {
public String From {get;set;} // 发件人
public String To {get;set;} // 收件人
public String Subject {get;set;} // 主题

public NewMailEventArgs(String from,String to,String subject){
From = from;
To = to;
Subject = subject;
}
}

2.定义事件成员:EventHandler

事件成员使用关键字event定义。

根据约定,这种类,这种类应该从System.EventArgs派生,类名由EventArgs结束。

1
2
3
4
5
6
7
8
9
10
public class MailManager {
// 事件成员
public event EventHandler<NewMailEventArgs> NewMail;
...
}

// 常用的泛型委托EventHandler:
public delegate void EventHandler<TEventArgs> (Object sender,TEventArgs e);
// 所有登录的方法原型必须满足以下签名:
void MethodName(Object sender,NewMailEventArgs e);

3.定义引发事件的方法:OnEvent

根据约定,类要定义一个受保护的虚方法。引发事件时,方法会被调用,获取到NewMailEventArgs对象并传递对象信息给接收者们。

1
2
3
4
5
6
class MailManager {
protected virtual void OnNewMail (NewMailEventArgs e){
EventHandler<NewMailEventArgs> tmp = NewMail;
tmp?.Invoke(this,e);
}
}

4.定义引发事件的方法:Publish

相当于对外暴露的接口,按照指定格式输入入参,再将入参转化为配置好的事件进行调用。

1
2
3
4
5
6
7
class MailManager {
public void SimulateNewMail(String from,String to,String subject){
NewMailEventArgs e = new NewMailEventArgs(from,to,subject);
// 调用虚方法通知对象事件已发生
OnNewMail(e);
}
}

5.简单使用:Subscribe & Use

书里没有,我补一下最简单的使用方法。

1
2
3
4
5
6
7
8
9
10
11
class Main() {
var manager = new MailManager();
manager.NewMail += (sender, eventArgs) => {
Console.WriteLine("Phone got it.");
Console.WriteLine(eventArgs.From,eventArgs.To,eventArgs.Subject);
};
manager.NewMail += (sender, eventArgs) => {
Console.WriteLine("Computer got it.");
Console.WriteLine(eventArgs.From,eventArgs.To,eventArgs.Subject);
};
}

编译器如何实现事件

事件成员

前面代码中,事件成员,用一行代码就完成了定义:

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
2
3
4
5
6
7
8
9
10
11
12
// 允许方法登记对事件的关注
public void add_NewMail(EventHandler<NewMailEventArgs> value){
EventHandler<NewMailEventArgs> prevHandler;
EventHandler<NewMailEventArgs> newMail = this.NewMail;
do{
// 通过循环和对CompareExchange的调用,可以以一种线程安全的方式向事件添加委托
prevHandler = newMail;
EventHandler<NewMailEventArgs> newHandler =
(EventHandler<NewMailEventArgs>) Delegate.Combine(prevHandler,value);
newMail = Interlocked.CompareExchange<EventHandler<NewMailEventArgs>>(ref this.NewMial,newHandler,prevHandler)
} while (newMial != prevHandler);
}

C#编译器在事件名(NewMail)前加add_前缀,并自动生成代码。
添加委托是通过调用Delegate.Combine方法,它将委托实例添加到委托实例列表中,返回新的列表头(地址),并将这个地址存回字段。

3.为事件构造remove方法

1
2
3
4
5
6
7
8
9
10
11
12
// 允许方法注销对事件的关注
public void remove_NewMail(EventHandler<NewMailEventArgs> value){
EventHandler<NewMailEventArgs> prevHandler;
EventHandler<NewMailEventArgs> newMail = this.NewMail;
do{
// 通过循环和对CompareExchange的调用,可以以一种线程安全的方式向事件移除委托
prevHandler = newMail;
EventHandler<NewMailEventArgs> newHandler =
(EventHandler<NewMailEventArgs>) Delegate.Remove(prevHandler,value);
newMail = Interlocked.CompareExchange<EventHandler<NewMailEventArgs>>(ref this.NewMial,newHandler,prevHandler)
} while (newMial != prevHandler);
}

C#编译器在事件名(NewMail)前加remove_前缀,并自动生成代码。
添加委托是通过调用Delegate.Remove方法,它将委托实例从委托实例列表中移除,返回新的列表头(地址),并将这个地址存回字段。

ps.关于add和remove

上述由编译器自动生成的add和remove方法的可访问性,都是根据event申明的可访问性来的,也就是都是public。

编译器除了上述3块,还会在元数据中生成一个事件定义记录项,它引用了add和remove访问其方法。它们用来建立“事件”和访问其方法之间的联系,可以通过反射System.Reflection.EventInfo获取调用。

设计监听者的类型

我分割为3要素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Phone {
// 实例化必须给一个MailManager。
public Phone(MailManager manager){
// 1.登记方法到事件
manager.NewMail += PhoneMsg;
}

// 2.登记到事件的方法
// sender:表示MailManager对象,便于将信息传回给它
// e:表示MailManager对象向传给我们的附加事件信息
private void PhoneMsg(Object sender,NewMailEventArgs e){
Console.WriteLine("Phone mail message:");
Console.WriteLine(e.From + e.To + e.Subject);
}

// 3.公开的注销关注方法
public void Unregister(MailManager manager){
manager.NewMail -= PhoneMsg;
}
}

+=操作符

C#编译器内部对+=的操作符由特殊处理,会翻译成以下代码:

1
2
3
manager.NewMail += PhoneMsg;
// C#编译
manager.add_NewMail(new EventHandler<NewMailEventArgs>(PhoneMsg));

-=操作符

代码和上面一样,就不多写了。

值得一提的是,-=是扫描委托列表,找到匹配再删除的,没有找到也不会报错。

登记方法属于引用

实例的方法被登记到了某事件,那么该实例就无法被垃圾回收了。

显式实现事件

下面自己使用Dictionary来实现一个维护委托实例列表的事件类。

调用

先看做完后怎么调用的。使用标准语法即可:

1
2
3
4
5
6
7
8
9
10
11
public static void Main(){
TypeWithLotsOfEvents twle = new TypeWithLotsOfEvents();
// 添加回调
twle.myDelegateList += SomeMyEvent;
// 触发事件
twle.Simulate();
}

private static void SomeMyEvent(object sender,MyEventArgs e){
Console.WriteLine("Nice!");
}

实现委托列表

映射EventKey -> Delegate,且对外提供Add、Remove、Raise方法。书中用了很多线程安全方法诸如Monitor.Enter,由于在将事件,这块简化掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public sealed class EventKey { }

public sealed class EventSet {
// 委托列表,设置为只读是可以add、remove的,但是不可以再被 "= new Dictionary"
private readonly Dictionary<EventKey,Delegate> eventList = new Dictionary<EventKey,Delegate>();

public void Add(EventKey key,Delegate handler){
Delegate d;
eventList.TryGetValue(key,out d);
// Delegate.Combine,像+=操作符一样连接委托
eventList[key] = Delegate.Combine(d,handler);
}

public void Remove(EventKey key,Delegate handler){
Delegate d;
if(eventList.TryGetValue(key,out d)){
d = Delegate.Remove(d,handler);
// 如果还有委托,就设置新的头部(地址),否则删除EventKey
if (d != null)
eventList[key] = d;
else
eventList.Remove(key);
}
}

public void Raise(EventKey key,Object sender,EventArgs e){
Delegate d;
eventList.TryGetValue(key,out d);

// 由于字典包含不同的委托类型,所以无法再编译时构造一个类型安全的委托调用。
// DynamicInvoke会向调用的回调方法查证参数类型安全性,并调用方法。
if (d != null) d.DynamicInvoke(new Object[] { sender,e });
}
}

调用委托列表的类

接着定义一个类来使用EventSet类。在这个类中,一个字段引用了一个EventSet对象,且显式实现了事件的add/remove。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 事件类型,可以在里面加想要的附加信息
public class MyEventArgs : EventArgs { }

public class TypeWithLotsOfEvents {
private readonly EventSet eventSet = new EventSet();

protected EventSet EventSet { get {return eventSet;} }

// 定义本类的key
protected static readonly EventKey eventKey = new EventKey();

public event EventHandler<MyEventArgs> myDelegateList {
add { eventSet.Add(eventKey,value); }
remove { eventSet.Remove(eventKey,value); }
}

protected virtual void OnDelegate(MyEventArgs e){
eventSet.Raise(eventKey,this,e);
}

// 调用事件
public void Simulate() {
OnDelegate(new MyEventArgs());
}
}

疑问与思考

其实到这里,我还是不理解为什么要用一个字典型来实现,明明按照示例一个使用类单独维护一个委托队列,那么key就是一个,压根用不到key啊。

我怀疑可能是为了让eventSet实例复用,像我可以在TypeWithLotsOfEvents类里加一套新的委托,但还是加到原来的eventSet实例中。可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MyEventArgs2 : EventArgs { }

// partical
public class TypeWithLotsOfEvents {
protected static readonly EventKey eventKey2 = new EventKey();

public event EventHandler<MyEventArgs2> myDelegateList2
{
add { eventSet.Add(eventKey2, value); }
remove { eventSet.Remove(eventKey2, value); }
}

protected virtual void OnDelegate(MyEventArgs2 e)
{
eventSet.Raise(eventKey2, this, e);
}

// 调用事件
public void Simulate()
{
OnDelegate(new MyEventArgs());// 这条原来就有
OnDelegate(new MyEventArgs2());
}
}

最后,本节核心思想是维护一个基于key-value的集合,引发事件时会在集合中查找事件标识符,如果找到就调用委托列表,如果没找到意味着没人登记关注所以就不做回调。

大章12:泛型

FCL中的泛型

泛型是什么

泛型(generic)是CLR和编程语言提供的一个特殊机制,目的是为了“算法重用”。

泛型提供更佳的性能

泛型可以替换很多需要Object装箱拆箱实现的场景,还可以避免强制类型转换,从而提高代码运行速度、减少资源使用。

比如,一个使用泛型的List<T>比非泛型的ArraryList算法,在面对频繁拆装箱的情况下,能有非常大的性能差距!

泛型基础结构

实现泛型

为了在CLR2.0加入泛型,微软至少做了以下工作:

  • 创建新的IL指令,使之能够识别类型实参。
  • 修改编译器和JIT编译器,使之识别处理泛型生成IL代码。
  • 修改现有元数据表格式,以便表示具有泛型参数的类型名称和方法。
  • 修改C#、.NET库来支持新语法。

开放类型、封闭类型

  • 开放类型:具有泛型类型参数的类型。
  • 封闭类型:为所有类型参数都传递了实际的数据类型的类型。
  • 两者使用区别:开放类型无法创建实例,和接口一样;封闭类型可以。
1
2
3
4
5
6
7
8
// 一个部分指定的开放类型
class DictionaryStringKey<TValue> : Dctionary<String,TValue> { }
...
// 2个都是开放类型
Type t1 = typeof(Dictionary<,>);
Type t2 = typeof(DictionaryStringKey<>);
// 封闭类型
Type t3 = typeof(DictionaryStringKey<Int>);

关于静态字段,开放类型转换成封闭类型后会其分配各自的静态字段,相同的T共用一个静态字段。
由此可知,对于同一个开放类型,相同的T入参会共用一个封闭类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void Main(string[] args)
{
MyList<int>.Test = "2";
MyList<int>.Test = "3";
MyList<string>.Test = "4";
Console.WriteLine(MyList<int>.Test);// 输出3,而不是1、2
Console.WriteLine(MyList<string>.Test);// 输出4
}

public class MyList<T>
{
public List<T> list = new List<T>();
public static string Test = "1";
}

最后,利用开放类型转换封闭类型时会为static重新分配的技巧,可以如此约束封闭类型:

1
2
3
4
5
6
7
8
9
class MyList<T> {
// 每次构造新的封闭类型,都会执行一次这个,所以可以加入一些对类型的限制
static MyList(){
if(typeof(T).IsClass){
// 限制该类只允许T为值类型
throw new ArgumentException("Type construct failed!");
}
}
}

泛型类型的继承

泛型类型也是类型,他是一样可以继承的,可以继承也意味着可以利用各种类型转换。

下面实现一个节点为泛型的链表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Node{
protected Node next;

public Node(Node next){
next = next;
}
}

// 通过泛型实现了类型各不相同的链表,比Node<Object>更优化
public class TypedNode<T> : Node{
public T data;

public TypedNode(T data) : this(data, null) { }

public TypedNode(T data, Node next) : base(next)
{
this.data = data;
}

public override string ToString()
{
return data.ToString() + ((next == null)? string.Empty : next.ToString());
}
}

泛型类型的同一性、相等性

如果对泛型类型进行派生:

1
2
3
public class DateTimeList : List<DateTime> {

}

这么派生,虽然程序员的意思是想简写List<DateTime>DateTimeList,但是这样是不可行的,因为:

1
2
// x = false
bool x = typeof(DateTimeList) == typeof(List<DateTime>) ;

这样即使两者数据完全一致,但是仍然在类型上不同。你就无法直接用=给DateTimeList实例赋值了。

有一种比较好的解决方案是用using重命名List<DateTime>。当然,我的建议不命名,保持List<DateTime>

代码爆炸

这块说的就是因为泛型给程序员带来的书写简便,是由编译器承担更多处理、生成更多IL代码来实现的,所以每次用类型都会生成一个新类型的IL代码,这样内存里的代码量会爆炸。

CLR为解决这个问题做了2个优化:

  1. 上面提过的,对于同类型同入参T的泛型类型,只进行一次编译,后面复用这个类型。
  2. CLR会认为所有是引用类型的T实参都完全相同,所以能够共享引用类型的IL代码。意思就是说,因为引用类型的实参或者变量都是在堆上的指针,不同的T类型也只是指针指向的对象不同而已。

泛型接口

目的

除了上面提的泛型(值、引用)类型,泛型接口也非常常用来避免频繁拆装箱:

1
2
3
public interface IEnumerator<T> : IDisposable,IEnumerator {
T Current { get; }
}

具体看13章。

泛型委托

目的

泛型委托是为了保证未知类型对象能以安全的方式传给回调方法,且不必装箱。

本质

委托实际只是提供了4个方法的一个类,17章会细说,这里展示一下泛型委托:

1
2
3
4
5
6
7
8
9
10
// 如此定义泛型委托,
public delegate TReturn CallMe<TReturn,TKey,TValue> (Tkey key,TValue value);

// 编译器会转换为这样的类:
public sealed class CallMe<TReturn,TKey,TValue> : MulticastDelegate {
public CallMe (Object object,IntPtr* method);
public virtual TReturn Invoke (TKey key,TValue value);
public virtual IAsyncResult BeginInvoke (TKey key, TValue value, AsyncCallback callback, Object object);
public virtual TReturn EndInvoke (IAsyncResult result);
}

逆变协变

协变和逆变是指对返回值和参数的类型进行转换,使得T更灵活。

  • 不变量(invariant),意味着泛型类型参数不能更改。目前为止提过的都是不变量形式的泛型类型参数。
  • 逆变量(contravariant),意味着泛型类型参数可以从一个类更改为它的某个派生类。C#中用in来标记,只能出现在输入位置,比如入参。
  • 协变量(covariant),意味着泛型类型参数可以从一个类更改为它的某个基类。C#中用out来标记,只能出现在输出位置,比如返回值。

好了,看完上面的概念,反正我是一头雾水,但其实就是下面这玩意:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 可以这么定义,没错就是Func<>
public delegate TResult Func<in T, out TResult> (T arg);

// 可以这么申明
Func<string,int> fn1 = null;

// 可以这么用
Func<string,int> fn2 = fn1;
int i = fn2("test");

// 以及上面提到的,返回值协变(基类),入参逆变(派生类)
Func<object, int> fn3 = null;
Object i = fn3("a string");

泛型方法

示例

可以很好的让refout这两个要求入参必须与签名保持一致的关键字,很好的得到运用。

具体的可以看之前的9.2节:

1
2
3
4
5
public static void Swap<T>(ref T o1, ref T o2) {
T temp = o1;
o1 = o2;
o2 = temp;
}

类型推断

因为上述这么些泛型方法,<>实在是太多了,所以C#编译器支持泛型方法在调用时,自动推断T类型。
比如:

1
2
3
string n1 = "testA";
string n2 = "testB";
Swap(ref n1,ref n2); // 自动推断调用Swap<string>

此外,可以重名定义明确的参数,编译器会选择先调用明确的,比如:

1
2
3
4
5
6
7
8
9
10
public static void Display(string s){
Console.WriteLine(s);
}
public static void Display<T>(T o){
Console.WriteLine(o.ToString());
}
...
Display("Jeff"); // 调用Display(string)
Display(123); // 调用Display<T>(T)
Display<String>("Aidan"); // 调用Display<T>(T)

约束

约束

向C#编译器承诺,入参T会使用后续类型的实现、派生类。

就是where:

1
2
// 普通使用
public static T Min<T>(T o1, T o2) where T : IComparable<T>

主要约束

参数T可以指定0~1个主要约束,主要约束代表类,注意了必须要非密封类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 主要约束:要求为引用类型
class SomeClassType<T> where T : class {
public void SomeMethod(){
// 因为T一定是引用类型所以可以指空
T temp = null;
}
}

// 主要约束:要求为值类型
class SomeValType<T> where T : struct {
public static T Factory(){
// 因为T一定是值类型,值类型隐式有一个公共无参构造器
T temp = null;
}
}

次要约束

参数T可以指定0~n个次要约束,次要约束代表接口。代码就不show了。

类型参数约束

参数T可以指定0~n个次要约束,类型参数约束,就是用参数来约束参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 类型参数约束 T : TBase
class List<TBase> ConvertIList<T, TBase>(IList<T> list) where T : TBase
{
List<TBase> baseList = new List<TBase>(list.Count);
for(int index = 0; index < list.Count ; index++ ){
baseList.Add(list[index]);
}
return baseList;
}

...

// 调用
IList<IComparable> list = ConvertIList<string,IComparable>(ls);

构造器约束

参数T可以指定0~1个构造器约束,就是new:

1
2
3
4
5
6
7
public class SomeType<T> where T : new() {
public static T Factory() {
// 允许值类型,因为所有值类型隐式有一个公共无参构造器
// 也允许实现了公共无参构造器的引用类型
return new T();
}
}

可验证性

可验证性

为了确保安全,约束代码有所验证。

1.泛型类型变量的转换

如果不提供约束并按照约束规则转型,直接将泛型类型的变量强转型为其他类型会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Test { }
public class Test2 : Test { }

// 提供约束并按照约束规则转型,可以编译过。
public static void Method1<T>(T obj) where T : Test2
{
var x = (Test)obj;
}

// 不兼容的转型,下面这段是编译不过的:无法将T转换为int。
public static void Method2<T>(T obj)
{
var x = (int)obj;
}

// 不约束就只能转换为object!可以编译过。但是如果转型失败会报InvalidCastException异常。
public static void Method3<T>(T obj)
{
var x = (object)obj;
var y = (int)(object)obj;
var z = obj as string;
}

2.将泛型类型变量设为默认值

将泛型类型变量设置为null是编译不过的,除非将泛型类型约束成引用类型。

原因是编译器确定不了T的类型,而值类型不能为null,引用类型可以。
添加约束为引用类型就可以合法了。

但是我们也可以这么做:

1
2
3
4
5
6
public void Method<T>() {
// 下面的编译不过
T temp = null;
// 下面的编译得过
T temp = default(T);
}

3.将泛型类型变量与null进行比较

可以比较。

如果T是值类型,永远不会为null、永远返回false,编译器知道,所以在生成代码中就会直接理解为false。

4.两个泛型类型变量相互比较

如果泛型类型参数不能肯定是引用类型,对同一个泛型类型的两个变量比较也是非法的。

5.泛型类型变量作为操作数使用

+、-、*、/ 是非法的。

大章13:接口

实现接口和继承的关系

关于接口继承

因为C#不支持多继承,所以推出了“缩水版”的多继承,也就是接口。

聊下继承

System.Object类是终极基类,所有类都继承了Object的4个实例方法,这个之前讲过不提了。

这里要聊一下的是方法签名,就是C#编译器会认为自己在操作Object类的实例(因为继承了Object),可以有各种智能感知等等,但实际操作的可能是其他类的实例。

接口初识

聊下接口

接口,是用来被实现的。

它实际只是对一组方法签名进行了统一命名。这些方法不提供任何实现,继承了某接口的类需要显式实现该接口定义的所有方法。

除了实现了多继承,它的另一个好处和类一样,就是“里氏替换原则”:

派生类对象可以在程式中代替其基类对象。

CLR怎么看接口

其实CLR看来,接口定义就是类型定义。也就是CLR会为接口类型对象定义内部数据结构,同时可通过反射机制来查询接口类型的功能。

接口支持泛型方法。

接口“继承”接口

接口“继承”接口就有点不一样了,这和传统的类继承类不一样,它更接近于将其他接口的协定(contract)包括到新接口中。

接口幕后

显式实现接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义
internal sealed class SomeType : IDisposable {
public void Dispose() { Console.WriteLine("public Dispose"); }
// 显式实现,无法限制可访问性必须是private
void IDisposable.Dispose() { Console.WriteLine("IDisposable Dispose"); }
}

// 使用
Main(){
SomeType st = new SomeType();
// 调用公共Dispose方法实现
st.Dispose();

IDisposable dp = st;
// 调用IDispose的Dispose方法实现
dp.Dispose();
}
// output: public Dispose , IDisposable Dispose

输出不同,说明后者调用到的是显式实现接口的方法。下面再解释为什么。

隐式实现:编译器是怎么看待接口实现的?

先再来看隐式实现,并给出编译器执行流程:

1
2
3
4
5
6
7
8
// 定义
internal sealed class SomeType : IDisposable {
public void Dispose() { Console.WriteLine("public Dispose"); }
}

// 使用同上

// output: public Dispose , public Dispose

可以看到,与显式实现比对,输出又相同了。CLR对这段代码的处理流程如下:

  1. 首先CLR加载类型时,会为该类型创建并初始化一个方法表,每个方法都有对应的记录项(第一章)。
    对于上述SomeType类型的方法表,会生成以下3个记录项:
  • Object(隐式继承的基类)定义的所有虚实例方法。
  • IDisposable(继承的接口)定义的所有接口方法。这里指Dispose。
  • SomeType引入的新方法Dispose。
  1. 为简化编程,C#编译器假定SomeType引入的新方法Dispose是对IDisposable的Dispose方法的实现。因为两者签名和返回值完全一致。

  2. C#编译器接下来,会将这新方法和接口方法进行匹配,生成元数据,指明SomeType类型的放发表中的两个记录项应引用同一个实现。

综上,
隐式实现接口方法的时候,2个方法在元数据里指向同一个实现,所以完全一致;
显式实现接口方法的时候,2个方法在元数据里指向不同的实现,只不过接口的同签名方法是private的,想要调用,需要用接口申明的变量去接这个实例,才能调用到。

泛型接口

使用泛型接口有一些好处:

  1. 类型确定,提高编译时安全性。
  2. 避免装箱拆箱。
  3. 类可以实现同一个接口若干次,只要T不同即可,接口代码复用率提高。
  4. 泛型接口也可以协变逆变,具体看12.4泛型委托里。

泛型接口约束

一样是where,书中只举了个方法例子,避免了拆装箱 并 验证了类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 值类型实现了IComparable,IConvertible
public static int SomeMethod<T>(T t) where T : IComparable,IConvertible{
...
}

Main(){
int x = 5;
Guid g = new Guid();
// 避免装箱,要知道IComparable接口的Compare方法入参是object类型!
SomeMethod(x);
// 编译错误,Guid未实现IConvertible
SomeMethod(g);
}

再聊显式实现方法

优点1:可实现多个具有同签名方法的接口

就是说可以靠显式实现,来实现多个同名、同签名方法的接口。

基本没用过,不同接口接同一个对象,能有不同的方法实现,这算是另一种意义上的多态吗?

总之是能有效解决多个接口有同签名方法,一个类无法同时实现多个这样的接口的问题。

缺点1:无智能感知

缺点2:无法被派生类调用

1
2
3
4
5
6
7
// 比如有一个基类显式实现了IComparable的CompareTo
...
// 想在派生类里调用基类的CompareTo,下面这样是编译不过的
base.CompareTo(o);
// 要这么写
((IComparable)base).CompareTo(o);
// 或者在基类写一个公开的虚方法,与显式调用同名同实现

缺点3:值类型调用时需要装箱

代码不贴了,和2一样,需要转换成对应接口才能调用显式实现方法,而值类型转换接口时需要装箱。

设计:基类还是接口

1.IS-A对比CAN-DO关系

CAN-DO关系,就是很多类型对象都“能”做某事,这需要用接口。

2.易用性

基类提供各个方面的功能,接口需要一个个实现。

3.一致性实现

基类型可以提供好的默认实现,接口不能提供默认实现,容易让开发人员出错。