在面向对象出现之前,程序是由一系列相互关联的模块和子程序组成,编程采用过程的方式,代码中有一条主线,决定需要完成哪些步骤。后来,面向对象出现了,它是对软件领域的杰出贡献,是软件设计中的里程碑。在软件发展速度远远落后硬件发展速度的时代,它的出现无疑是一种激励。它把程序想象成一系列的相互交互的对象,每个对象都要自己的数据和行为。它如此地令人兴奋与着迷。它的出现,极大地降低了软件构建的首要问题——复杂度。并使我们开发的程序,富有印象派画风的美感。但是,我个人认为,现今很多学校都是从 C/C++ 学起,让人养成了面向过程的开发习惯,而又没有教会学生面向对象编程的精髓,造成了很多初学者的代码不伦不类。在如今这个浮躁的社会,代码质量越来越不被重视,大批大批的程序员更愿意研究 API 的使用,而不是如何提高代码质量,重功能而轻质量的做法已经成风。这里,我不再赘述 OOP 的基础知识,我仅复习下 OOP 的基本规则:
- 万物皆对象——为问题领域找到一个干净灵活的对象。
- 高内聚低耦合——降低耦合度并提高内聚性。
- 代码重用——考虑代码的可重用性。(XP 编程将其应用到了极致)
- 单一职责(SRP)——就一个类而言,应该仅有一个引起它变化的原因。
- 开放/封闭原则(OCP)——对扩展开放,对修改封闭。
- 里氏替换原则(LSP)——子类应该可以在任何地方替代其基类使用。
- 依赖倒置原则(DIP)——依赖于抽象而不依赖于实现。
面向组件是在面向对象基础上发展而来的,两种技术存在一些共性。“一个组件是一个.NET类,一个对象是一个组件的实例。”这类定义会模糊我们对面向组件概念的认识。面向对象与面向组件的区别在于,面向对象编程着眼于程序集或模块中类之间的关系,而面向组件着眼于独立工作的可替换的程序集或模块,并且无需了解其内部工作原理。面向组件的开发人员大部分时间花在设计接口上,而不是花力气设计复杂的类层次结构。因为面向组件技术是对面向对象技术的进一步发展,所以面向对象的设计原则也适用于面向组件。以下原则是面向组件编程最重要的原则:
- 接口和实现分离——重用的最小单元是接口。
- 二进制兼容——组件的内部改变不会影响外部的其它程序。
- 语言独立性——开发组件时对编程语言的选择是无关的。
- 位置透明——使用者不在乎使用对象的具体位置,对象可以在同一进程中,在不同一进程中,在同一本地网络的不同机器乃至跨越互联网的不同机器中。
- 并发管理——组件必须被构建为可以在并发情景中使用。
- 版本控制——组件需要版本化并向下兼容。
- 基于组件的安全——一个高效的组件技术应该允许组件有尽可能少的与安全相关的代码。它也应该允许系统管理员在不改变代码的情况下自定义和管理应用程序的安全策略。
编译器直接支持的数据类型称为基元类型。基元类型直接映射到 FCL 中存在的类型,比如 C# 中的 int 直接映射到 System.Int32 类型,int 就是基元类型。基元类型(int )实际上只是,系统类型(System.Int32)的简化符号。基元类型支持默认构造函数,它将自动将变量设置为其默认值:
- bool 类型设置为 false。
- 数值型设置为0(0.0)。
- char 型设置为单个空字符
- DateTime 类型设置为1/1/0001 12:00:00 AM。
- 引用类型设置为 null。
CLR 要求所有对象都用 new 操作符来创建。以下是 new 所做的事情:
(1) 它计算类型及其基类型(一直到 Object)中定义的所有实例字段需要的字节数。堆上的每个对象都需要一些额外的成员(类型句柄、同步块)。这些成员的字节数会计入对象大小。
(2) 它从托管堆中分配指定类型要求的字节数,从而分配对象的内存,分配的所有字节都设为零。
(3) 它初始化对象的类型句柄和同步块成员。
(4) 调用类型的构造器,向其传人在对 new 的调用中指定的任何参数。多数构造器中自动生成代码来调用一个基类构造器。每个类型的构造器在调用时,都要负责初始化这个类型定义是实例字段。最终调用的是 Object 的构造器,该构造器只是简单的返回,不会做其它任何事情。
(5)new 执行了所有这些操作之后,会返回指向新建对象一个引用。
CLR 支持两种类型——值类型与引用类型。在 C# 中,值类型包括:结构(数值类型、bool类型、用户定义的结构)、枚举和可空类型。其它类型,均为引用类型。
值类型与引用类型的区别如下:
值类型 | 引用类型 | |
分配位置 | 栈 | 堆 |
变量表示 | 值类型变量是局部复制的 | 引用类型变量指向被分配的对象的内存地址 |
基类 | ValueType | Object |
是否可以继承 | 不能被继承(隐式密封) | 可以被继承 |
变量间的相互赋值 | 传值(生成副本,两个变量的值类型字段不会相 互影响) | 传址(不生成副本,两个变量之间相互影响) 存在内部引用被公开的风险 |
是否需要终结器 | 不需要(允许定义终结器,但是值类型的一个已装箱 实例被 GC 回收时,CLR 不会调用该方法) | 需要 |
是否需要构造函数 | 需要(默认的构造函数被保留,作用是设置默认值, 所以自定义构造函数必须是带参数的) | 需要 |
变量何时消亡 | 离开作用域时 | 被 GC 回收时 |
生命周期是否可预测 | 可预测 | 不可预测 |
默认值 | 0 | null |
多线程同步 | 没有同步块,不能使用 Threading.Monitor 类型的各种方法(或者lock语句)让多线程同步实例 | 支持 |
如果所有类型都是引用类型,程序性能会难以接受,所以 CLR 提供了“轻量级“的值类型。值类型的使用缓解了托管堆的压力,减少了垃圾回收次数。值类型(未装箱)比引用类型更轻量级的原因是:
- 它们不分配在托管堆上。
- 它们没有堆上对象都有的额外成员——类型句柄和同步块。
(一)Object 与 ValueType
CLR 要求每个类型最终都从 System.Object 类型派生。Object 类定义了一组框架中所有类型公共的成员,但没有定义实例字段,编译器会自动从 Object 派生我们的类型。Oblect 定义如下:
public class Object { // 虚成员 public virtual bool Equals(object obj);//比较相等性 public virtual int GetHashCode();//获取散列码 public virtual string ToString();//返回对象的字符串表示 protected virtual void Finalize();//终结器 // 实例级别,非虚成员 public Type GetType();//返回对象的类型句柄 protected object MemberwiseClone();//克隆对象 // 静态成员 public static bool Equals(object objA, object objB);//比较相等性 public static bool ReferenceEquals(object objA, object objB);//比较相等性 }
值类型隐式派生于 System.ValueType,System.ValueType 派生于 System.Object。System.ValueType 的作用是确保所有派生类型都分配在栈上而不是垃圾回收堆上,该类型的唯一目的是“override“Object 定义的虚方法,使其服务于值类型。
(二) 装箱与拆箱
装箱——通过把变量保存在 Object 中,将值类型显示转换为相应的引用类型。装箱过程会造成性能损耗,其步骤如下:
(1) 在托管堆中分配好内存。
(2) 值类型的字段复制到新分配的堆内存。
(3) 返回对象的地址。
拆箱——把保存在对象引用中的值转换回栈上的相应值类型。拆箱的代价比装箱低的多,拆箱是取得指向包含在一个对象中的原始值类型的指针的过程。拆箱不需要在内存中复制任何字节。一个已装箱实例在拆箱时可能会抛出下列异常:
- NullReferenceException,要拆箱的对象为 null 时抛出。
- InvalidCastException,要拆箱的对象不是所期待的值类型的已装箱实例。
装箱和拆箱的关键之处是:装箱时存放的是值类型的副本,拆箱返回的是值类型的另一个副本。
1 装箱的性能损耗
装箱的性能损耗是难以接受的:
using System; using System.Diagnostics; namespace CLRTest { class Program { static void Main() { // JIT编译 test1(); test2(); // 测试 Stopwatch stop = new Stopwatch(); stop.Start(); test1(); stop.Stop(); Console.WriteLine(stop.ElapsedTicks);//发生装箱操作的 stop.Reset(); stop.Start(); test2(); stop.Stop(); Console.WriteLine(stop.ElapsedTicks); Console.ReadKey(); } static void test1() { for (int i = 0; i < 99999; i++) { object num1 = i;//box object num2 = i;//box object num3 = i;//box } } static void test2() { for (int i = 0; i < 99999; i++) { int num1 = i; int num2 = i; int num3 = i; } } } }
输出为:
可见大量装箱操作会严重影响性能。
2 避免不必要的装箱
何时会发生装箱:
- 显式的类型转换:显式地将值类型转换为引用类型。
- 隐式的类型转换:常发生在调用参数为 Object 类型的方法或使用非泛型的集合时,如 Console.WriteLine 和 ArrayList 等。
- 值类型重写基类型中的方法并调用基类的实现时:如果重写的虚方法要调用基类中的实现,那么调用基类的实现时,值类型实例就会装箱,以便通过 this 指针将对一个堆对象的引用传给基方法。
- 调用非虚的、继承的方法时:在调用调用非虚的、继承的方法时,如 GetType(),则会对值类型进行装箱。这是因为这些方法期望 this 实参是指向堆上一个对象的指针。
- 转换接口时:将值类型的一个未装箱实例转型为接口时,会进行装箱。
查看如下代码:
using System; using System.Collections; namespace CLRTest { struct Number : IComparable { public double Num { get; set; } public override string ToString() { return Num.ToString();//不装箱 //return base.ToString();//box,因为调用了基类方法 } public int CompareTo(Number p) { if (this.Num > p.Num) { return 1; } else if (this.Num < p.Num) { return -1; } else { return 0; } } public int CompareTo(object o) { if (GetType() != o.GetType()) { throw new ArgumentException("Object is not a Number"); } else { return CompareTo((Number)o); } } } class Program { static void Main() { //局部变量 Number n1 = new Number(); Number n2 = new Number(); Object o = n1;//box,显式类型转换 Console.WriteLine("{0}", n2);//box,隐式类型转换 ArrayList arr = new ArrayList(); arr.Add(o); arr.Add(n2);//box,n2被隐式转换为Object Console.WriteLine(n1.ToString()); Console.WriteLine(n1.GetType());//box,调用了基类的方法 Console.WriteLine(n1.CompareTo(n2));//不装箱,因为Number实现了CompareTo(Number)方法 IComparable n3 = n1;//box,接口被定义为引用类型所以必须装箱 Console.WriteLine(n1.CompareTo(n3));//n1不装箱,会调用CompareTo(Object) Console.WriteLine(n3.CompareTo(n2));//box,n2会被装箱,因为调用的是CompareTo(Object) Console.ReadKey(); } } }
所以,避免装箱的规则如下:
- 小心到 Object 的隐式转换。
- 使用支持值类型的重载方法。
- 使用泛型。
- 小心地将值类型转换为接口。
- 如果知道装箱已无法避免,请手动进行装箱,以减少装箱次数。
3 使用接口更改已装箱值类型中的字段
下例再次显示了装箱和拆箱的关键——装箱时存放的是值类型的副本,拆箱返回的是值类型的另一个副本。
using System; namespace CLRTest { struct Number { public double Num { get; set; } public override string ToString() { return Num.ToString(); } public void Add(double d) { this.Num += d; } } class Program { static void Main() { Number n1 = new Number(); n1.Add(1); Console.WriteLine(n1);//输出1 object o = n1; //对o进行拆箱,将以装箱的Number中的字段复制到线程栈上的一个临时Number中,这个Number的Num值会变为2,但是已装箱的Number不受这个Add的影响。 ((Number)o).Add(1); Console.WriteLine(o);//输出1 Console.ReadKey(); } } }
使用接口可以更改已装箱值类型中的字段,如下:
using System; namespace CLRTest { interface IAdd { void Add(double d); } struct Number:IAdd { public double Num { get; set; } public override string ToString() { return Num.ToString(); } public void Add(double d) { this.Num += d; } } class Program { static void Main() { Number n1 = new Number(); n1.Add(1); Console.WriteLine(n1);//输出1 //对n1装箱,更改已装箱的对象,在Add返回之后,已装箱的对象立即准备好进行垃圾回收。 ((IAdd)n1).Add(1); Console.WriteLine(n1);//输出1 object o = n1; //为装箱,因为o已经是一个装箱的Number,Add修改了已装箱的对象。 ((IAdd)o).Add(1); Console.WriteLine(o);//输出2 Console.ReadKey(); } } }
这种做法将无情地破坏值类型的“不可变”初衷,将会产生不可预期的行为。
(三) 值类型与引用类型的嵌套
1 包含值类型的引用类型
值类型作为引用类型的字段时,与引用类型的实例一起存储在堆上。
2 包含引用类型的值类型
当引用类型作为值类型的字段时,值类型实例本身存储在栈上,而值类型中的引用类型则存储在堆上,并被值类型的实例所持有。所以,当把一个值类型变量赋值给另一个值类型变量时,执行的是“浅复制”,在栈上对值类型实例产生一个副本,每个副本都包含指向内存中同一对象的引用。这样,当我们修改其中一个值类型实例的引用字段时,另外一个值类型实例也会受到影响。
(四) 使用值类型和引用类型要注意的一些问题
1 何时使用值类型,何时使用引用类型
多数情况下,我们偏向使用引用类型,使用值类型是为了得到更好的性能,除非以下所有条件都满足,否则应该使用引用类型:
- 类型实例较小,或者不作为方法的实参传递也不从方法返回。
- 类型的主要职责在于数据存储,而不是抽象出行为。
- 类型具有常量性。
- 类型绝对不会有派生类。
- 类型不需要从其它类型继承。
2 按值传递引用类型与按引用传递引用类型的区别
- 按值传递引用类型(参数不带 ref ),被调用者可以改变对象的状态数据的值,但不能改变所引用的对象。
- 按引用传递引用类型(参数带 ref ),被调用者可以改变对象的状态数据的值和所引用的对象(直接指向新的对象)。
3 类型实例的初始化问题
对于引用类型我们可以使用初始器来把对象初始化为想要的状态。
对于值类型来说,我们无法阻止 CLR 调用无参数的构造函数对值类型进行初始化。所以,如果不想显式地进行初始化,就必须保证 new 出的值类型各个字段均有效。要注意以下两点:
(1) 保证值类型中的值类型字段的 0 值有效。
(2) 保证值类型中的引用类型为 null 时,将其转换为相应值(eg. public string Name{get{return(name!=null)?name:"Not named";}set {name=value;}}})。
4 保证值类型的常量性和原子性
“常量性”是指:创建后其值就保持不见。而“原子性”是指:单一的实体,多数时候实体某个字段的变化会导致直接替换整个内容。
因为值类型事例分配在栈上,没有同步块,所以要保证值类型原子性最简单方法就是保证值类型的常量性。以下两点有助于保证值类型的常量性:
(1) 使用 readonly 修饰字段。
(2) 小心处理常量中的可变引用类型字段。在为这样的类型设计构造函数以及返回一个可变的引用类型时,需要对其中的可变类型进行防御性的复制。例如:
错误示范:
using System; using System.Collections.Generic; namespace CLRTest { struct LetterList { private readonly char[] letters; public char[] Letters { get { return letters; } } public LetterList(char[] letterList) { letters = letterList; } } class Program { static void Main() { char[] letters = new char[26]; letters[0] = 'a'; LetterList ll = new LetterList(letters); Console.WriteLine(ll.Letters[0]);//输出a //在外部修改letters,同样会影响到ll letters[0] = '0'; Console.WriteLine(ll.Letters[0]);//输出0,破坏了常量性 Console.ReadKey(); } } }
在构造函数中进行防御性复制:
public LetterList(char[] letterList) { letters = new char[letterList.Length]; letterList.CopyTo(letters,0); }
但这还不够,我们还要防止外部通过属性来破坏常量性:
public char[] Letters { get { char[] results = new char[letters.Length]; letters.CopyTo(results, 0); return results; } }
CLR 最重要的特性之一就是类型安全性。在运行时中,CLR 总是知道一个对象是什么类型。调用 GetType 方法,总是知道一个对象的确切类型是什么。CLR 允许将一个对象转换为它的实际类型或者它的基类型。
(一) 窄化与宽化数据类型转换
宽化类型转换是指把小值保存到大变量里,不会损失数据精度,所以宽化类型转换可以使用隐式类型转换。窄化运算是指把大值保存到小变量里,造成数据丢失(溢出),必须使用显式类型转换。C# 允许程序员决定如何处理溢出。溢出检查默认是关闭的,这时我们的代码运行速度更快。C# 同时提供了checked 和 unchecked 关键字用于检查数据丢失。checked 的作用是决定生成哪一个版本的运算和数据转换 IL 指令,所以在其中调用其它类型成员不会受到影响。
static void Main() { byte byteMax = byte.MaxValue; Console.WriteLine((byte)(byteMax + 1));//未做溢出检查,输出0 Console.ReadKey(); }
使用 checked 检查溢出:
using System; namespace CLRTest { class Program { static void Main() { try { checked//对一段进行溢出检查 { byte byteMax = byte.MaxValue; int intMax = int.MaxValue; Console.WriteLine(unchecked((byte)(intMax + 1)));//输出0,因为unchecked阻止异常抛出 Console.WriteLine((byte)(byteMax + 1));//也可以仅使用checked((byte)(byteMax + 1))检查类型转换 } } catch (OverflowException ex) { Console.WriteLine(ex.Message); } finally { Console.ReadKey(); } } } }
在 VS 中启用项目级溢出检查:
注意:溢出检查无法对 System.Decimal 类型生效。CLR 没有相应的 IL 指令还处理该类型的值。编译使用了 Decimal 值的程序时,编译器会生成代码调用 Decimal 的成员,并通过它们来执行实际的运算。所有,Decimal 值的处理速度慢于其它基元类型的处理速度。如果 Decimal 值执行的运算是不安全的,则会抛出 OverflowException 异常。如果值太大,没有足够内存,则会抛出 OutOfMemoryException 异常。
软件构建时的推荐做法:
(1) 尽量使用有符号数值类型,而不用使用无符号数值类型。原因如下:
- 有符号数值类型允许编译器监测更多的上下溢出错误。
- FCL 中的很多类成员放回的是有符号数值类型,在移动这些值时,不用进行强制类型转换。
- 无符号数值类型不符合 CLS。
(2) 如果要构建的应用程序不能接受数据丢失,那么必须使用溢出检查。
(3) 最好在开发阶段打开溢出检查,然后在发布阶段关闭,如果性能允许,也可以保留。
(二) 用户自定义的类型转换
任何简单数据的类型都可以自定义,定义不同类型之间的转换有两个限制:
- 如果某个类型直接或间接继承了另一个类,就不能定义这两个类之间的类型转换,因为,类型转换已经存在。
- 数据类型转换必须在源或目标数据类型的内部定义。
定义数据类型转换的例子如下:
public static implicit operator double(Number num)//自定义隐式类型转换 { return num.Num; } public static explicit operator Number(double d)//自定义显式类型转换 { Number number = new Number(); number.Num = d; return number; }
在一个类型在定义了数据类型转换,就不能在另一个类型中定义相同的数据类型转换。
(三) 基类与派生类之间的类型转换
基类实例转换为派生类实例,必须使用显式类型转换。(但实际上,可以定义派生类的带参数构造函数,让这个构造函数接受基类对象作为参数,执行相关的初始化。)
派生类型向基类型的转换是安全的隐式转换,不需要使用任何语法。
推荐使用 is 和 as 操作符而不是强制类型转换,因为它们更清晰地表达意图。(不同的转型方式有不同的规则,而 is 和 as 操作符在绝大多数情况下都能表达正确的语义,只有当被检查的对象是正确的类型时才会成功。)is 检查一个对象是否兼容于指定的类型,返回 Boolean 值;as 会检查一个对象是否兼容于指定的类型,如果是 as 会返回对同一对象的一个非空引用,如果不兼容,则会返回 null。需要注意的是,is 语法性能不如 as 语法:
if ( o in Custom )//CLR 第一次检查对象的类型 { Custom c = ( Custom ) o ;//类型转换时,CLR 再次检查对象的类型 ... }
is 语法的编程模式要检查 2 次类型,对性能有所浪费,而 as 只进行一次类型检查:
Custom c = o as Custom //CLR 检查对象的类型 if ( c != null )//检查 c 是否为 null 的速度比检查对象类型的速度快的多 { ... }
(四) 小心类型转换路径
在定义类型转换时必须考虑的一个问题是,如果在进行要求的类型转换时,编译器没有可用的直接转换方式,就会寻找一种方式把几种转换合并起来。C# 有一些规则告诉编译器如何确定哪条是最佳路径。但最好自己设计转换(显式指定转换路径),让所有类型转换都能得到相同的结果。(我的建议是在编写单元测试用例时,也要验证从源类型到目标类型、从目标类型到源类型的转换,保证相同的结果且没有失真。)
相等性是指两个实例的内容相等,而同一性是指两个引用指向类型的同一个实例。System.Object 定义了 3 个比较方法,在加上运算符 == ,就有 4 种比较方式。这还不是唯一选择,重写 Equals 方法的函数还应该实现IEquatable<T> ,这样就意味着有 5 种方法来比较相等。(.NET 4.0 中新增了 IStructuralEquatable 接口,但我还没有实践过,所以在本文没有提及。)当我们改变其中一个时,有可能影响其它几个函数的行为。
(一)static bool ReferenceEquals(object objA, object objB)
用于比较同一性,并认为 null 等于 null。无论比较的是值类型还是引用类型,该方法判断的依据都是对象标识,而不是对象内容。所以,如果使用 ReferenceEquals() 来比较两个值类型,其结果永远返回 false。永远不要去重新定义该方法,因为它已经完美地完成了所需要完成的工作——判断两个不同变量的对象标识符是否相等。
(二)static bool Equals(object objA, object objB)
static bool Equals(object objA, object objB) 首先检查同一性,再检查实参是否为 null,然后会调用 Equals 的虚拟版本。所以,重写虚拟的 Equals 时,相当于也重写了静态版本。静态 Equals() 方法的实现如下:
public static bool Equals(object objA, object objB) { //检查同一性 if ( Object.ReferenceEquals ( objA , objB ) ) { return true; } //实参为 null 时 if( Object.ReferenceEquals ( objA , null ) || Object.ReferenceEquals ( null , objB ) ) { return false; } //调用虚拟的 Equals() 方法 return objA.Equals ( objB ); }
永远不要去重新定义该方法,因为实际完成判断工作的是 Equals() 方法的虚拟版本。
(三)virtual bool Equals(object obj)
该方法的默认实现是比较同一性,但可以根据需要重写。System.ValueType 就重写了该方法,用于比较相等性。实际上,ValueType 的 Equals 方法是像这样实现的:
(1) 如果 obj 实参为 null,就返回 false。
(2) 如果 this 和 obj 实参引用不同类型的对象,就返回 false。
(3) 针对类型定义的每个实例字段,都将 this 对象中的值与 obj 对象中的值进行比较(通过调用字段的 Equals 方法)。任何字段不相等,就返回 false。
(4) 返回 true。
ValueType 的 Equals 方法不会调用 Object 的 Equals 方法,并使用反射完成步骤(3)。因为反射机制比较慢,所以在定义自己的值类型时,应该重写 Equals 方法,以便提供较高的性能。但是对于引用类型,只有我们希望改变预定义的语言时,才需要重写该方法。重写Equals 时,必须满足以下条件:
- Equals 必须是自反的,x.Equals(x) 必须返回 true。
- Equals 必须是对称的,x.Equals(y) 等于 y.Equals(x)。
- Equals 必须是可传递的,如果 x.Equals(y) 返回 true,y.Equals(z) 返回 true,则 x.Equals(z) 返回 true。
- Equals 必须是一致的,如果 x 与 y 没有变化,则 x.Equals(y) 的返回值也不能变化。
此外,还需要做以下几件事情:
- 让类型实现 System.IEquatable<T> 接口的 Equals 方法,实现类型安全的 Equals 方法。
- 重载 == 和 != 操作符,通常在内部调用类型安全的 Equals 方法。
- 重写 GetHashCode() 方法,因为 在 System.Collections.Hashtable 类型、System.Collections.Generic.Dictionary 类型以及其它一些集合的实例中,要求两个对象为了相等,必须具有相同的哈希码。
有以下实践需要遵循:
- Equals 绝不要抛出异常,两个变量要么相等要么不相等,不存在失败的情况,对于所有错误情况都应该返回 false。
- 小心类型转换,代码检查的是所比较对象的精确类型,仅仅判断实参能否可以转换为当前类型的不够的,因为类型转换也许是不可逆的,如果不检查对象的精确类型,易造成比较顺序决定比较结果的情况。
- 如果基类的 Equals() 方法不是由 System.Object 或 System.ValueType 提供的话,我们也应该同时调用基类的 Equals() 方法。
下例显示了,由类型转换引起的破坏比较对称性的问题:
using System; namespace CLRTest { class A : IEquatable<A> { public override bool Equals(object obj) { if (object.ReferenceEquals(obj, null)) { return false; } if (object.ReferenceEquals(this, obj)) { return true; } A a = obj as A; if (a == null) { return false; } return this.Equals(a); } public bool Equals(A a) { //... return true; } } class B : A, IEquatable<B> { public override bool Equals(object obj) { if (object.ReferenceEquals(obj, null)) { return false; } if (object.ReferenceEquals(this, obj)) { return true; } B b = obj as B; if (b == null) { return false; } if (base.Equals(b) == false) { return false; } return this.Equals(b); } public bool Equals(B b) { //... return true; } } class Program { static void Main() { A a = new A(); B b = new B(); Console.WriteLine(A.Equals(b, a));//输出 true Console.WriteLine(A.Equals(a, b));//输出 false Console.ReadKey(); } } }
(四) 比较运算符 ==
最好把比较运算符看做是严格的值比较和严格的引用比较之间的选项,可以根据需要重写该运算符,System.String 类就重写了这个运算符,以比较字符串的内容,而不是它们的引用。只要创建的是值类型,都必须重新定义 ==。理由也是因为默认版本使用了反射,导致效率较低。创建引用类型时,应该尽量避免重写 ==,引用类型的 == 描述的正是比较同一性,默认版本已经做到了。
Hash,也翻译为“散列”,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列码。哈希表(散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做哈希表(散列表)。
FCL 设计者认为,如果将任何实例放到一个哈希表集合中,会带来很多好处。因此,System.Object 提供了虚方法 GetHashCode,它能获取任意对象的 Int32 哈希码。该方法仅会在一个地方用到,即为基于哈希(散列)的集合定义键的散列码时,此类集合包括 HashSet<T> 和 Dictionary<K,V> 容器等。简单地说,在一个集合中添加一个键/值对时,首先会获取键对象的一个哈希码。这个哈希码指出键/值对应该存储到哪一个哈希桶(bucket)中。集合要查找一个键时,会获取指定的键对象的哈希码。这个哈希码标识了现在要搜索的目标哈希桶,要在其中查找与指定指定键对象相等的一个键对象。采用这个算法来存储和查找键,意味着一旦修改了集合中的一个键对象,集合就再也找不到对象。所以,需要修改一个哈希表中的键对象时,正确的做法是移除原来的键/值对,修改键对象,再将新的键/值对添加回哈希表中。
选择一种算法来计算类型实例的哈希码时,请遵守以下规则:
- 这个算法要提供良好的随机分布,使哈希表获得最佳性能。
- 可以在这个算法中调用基类的 GetHashCode 方法,并包含它的返回值。然而,一般不要调用 Object 或者 ValueType 的 GetHashCode 方法,因为这两个方法性能不佳。
- 这个算法应该使用至少一个实例字段。
- 理想情况下,这个算法中使用的字段应该是不可变的,即字段应在对象构造时初始化,在对象生存期内永不改变。
- 这个算法应该尽可能快的执行。
- 包含相同值的不同对象应返回相同的哈希码,如果两个对象相等(==),那么,它们必须生成相同的散列码。否则,这样的散列码将无法用来查找容器中的对象。
- 任何一个对象的哈希码必须保持不变,不管在该对象上调用什么方法,该对象的哈希码必须总返回同一个值,这可以确保放在“桶”中的对象总是位于正确的“桶”中。
要编写一个正确高效的散列函数,我们需要对类型有充分的认识。
Object 实现的 GetHashCode 方法对其派生类型以及类型中的字段一无所知,该方法使用 Object 中的一个内部字段来产生散列值。系统创建的每一个对象在创建时都会被指派给一个唯一的对象键(一个整数值)。这些键从 1 开始,每创建一个对象在创建时都会随之增长。对象标识字段会在 Object 构造函数中设置,并且之后不能更改。对于一个给定的对象,GetHashCode 方法会返回该值作为哈希码。因此,利用 Object 的 GetHashCode 方法返回的哈希码,可以在 AppDomain 中唯一地标识对象。这个编码保证在对象生存期内不会改变。但在对象被垃圾回收后,它的唯一性的编号可能被重新用作一个新对象的哈希码。Object 的 GetHashCode 的主要缺陷是其递增算法,其在整数范围内显然不是一个随机分布,这些散列码都分布在低端了。这意味着,Object.GetHashCode() 的实现虽然正确,但是效率不高。如果一个类型重写了该方法,就不能调用它来获取对象的一个唯一性的 ID。要想在一个 AppDomain 中获取对象的唯一性 ID,可以使用 RuntimeHelpers 类提供的一个公共静态方法 GetHashCode,它获取一个 Object 的引用作为实参。RuntimeHelpers 的 GetHashCode 方法能保证返回一个对象的唯一性ID。
System.ValueType 重写了 GetHashCode,其采用了反射机制,并对类型的某些实例字段执行了 XOR 运算。默认实现会返回类型中定义的第一个字段的散列码。只有在 struct 的第一个字段是只读的情况下,ValueType.GetHashCode() 才能正常工作。
我在前面提到“需要修改一个哈希表中的键对象时,正确的做法是移除原来的键/值对,修改键对象,再将新的键/值对添加回哈希表中”,对于值类型和引用类型,如果直接修改键对象可能导致以下行为:
(1) 对于值类型,将有一个键对象的副本保存在集合中,修改键对象后,不会对存储在集合中的对象副本产生任何影响。装箱和拆箱都会导致复制,因此,在一个值类型对象被添加到一个集合之后,再改变其内容几乎是不可能的。
(2) 对于引用类型,当键对象放入集合后,散列码会根据该对象的某个(或多个)字段产生。当改变键对象的这个字段的值后,散列码也会随之改变,散列码由新的值产生。这时候,键对象仍然存储在由原始值定义的“桶”中,而没有存储在新值定义的“桶”中,所以,集合会找不到这个对象。丢失的原因在于散列码不再是一个固定不变的值,因为在存储对象之后,我们更改了它所在的“桶”。
对于哈希码,最后要说明的两点是:
(1) 一个常用且成功的算法——对一个类型中的所有字段调用 GetHashCode() 返回的值进行 XOR 运算,如果类型中包含可变字段,那么应该在计算时排除它们。
(2) 绝对不要对哈希码进行持久化,因为哈希码很容易改变。