一 面向对象与面向组件
在面向对象出现之前,程序是由一系列相互关联的模块和子程序组成,编程采用过程的方式,代码中有一条主线,决定需要完成哪些步骤。后来,面向对象出现了,它是对软件领域的杰出贡献,是软件设计中的里程碑。在软件发展速度远远落后硬件发展速度的时代,它的出现无疑是一种激励。它把程序想象成一系列的相互交互的对象,每个对象都要自己的数据和行为。它如此地令人兴奋与着迷。它的出现,极大地降低了软件构建的首要问题——复杂度。并使我们开发的程序,富有印象派画风的美感。但是,我个人认为,现今很多学校都是从 C/C++ 学起,让人养成了面向过程的开发习惯,而又没有教会学生面向对象编程的精髓,造成了很多初学者的代码不伦不类。在如今这个浮躁的社会,代码质量越来越不被重视,大批大批的程序员更愿意研究 API 的使用,而不是如何提高代码质量,重功能而轻质量的做法已经成风。这里,我不再赘述 OOP 的基础知识,我仅复习下 OOP 的基本规则:
面向组件是在面向对象基础上发展而来的,两种技术存在一些共性。“一个组件是一个.NET类,一个对象是一个组件的实例。”这类定义会模糊我们对面向组件概念的认识。面向对象与面向组件的区别在于,面向对象编程着眼于程序集或模块中类之间的关系,而面向组件着眼于独立工作的可替换的程序集或模块,并且无需了解其内部工作原理。面向组件的开发人员大部分时间花在设计接口上,而不是花力气设计复杂的类层次结构。因为面向组件技术是对面向对象技术的进一步发展,所以面向对象的设计原则也适用于面向组件。以下原则是面向组件编程最重要的原则:
二 基元类型与 new 操作
编译器直接支持的数据类型称为基元类型。基元类型直接映射到 FCL 中存在的类型,比如 C# 中的 int 直接映射到 System.Int32 类型,int 就是基元类型。基元类型(int )实际上只是,系统类型(System.Int32)的简化符号。基元类型支持默认构造函数,它将自动将变量设置为其默认值:
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) 返回对象的地址。
拆箱——把保存在对象引用中的值转换回栈上的相应值类型。拆箱的代价比装箱低的多,拆箱是取得指向包含在一个对象中的原始值类型的指针的过程。拆箱不需要在内存中复制任何字节。一个已装箱实例在拆箱时可能会抛出下列异常:
装箱和拆箱的关键之处是:装箱时存放的是值类型的副本,拆箱返回的是值类型的另一个副本。
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 避免不必要的装箱
何时会发生装箱:
查看如下代码:
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(); } } }
所以,避免装箱的规则如下:
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 按值传递引用类型与按引用传递引用类型的区别
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) 尽量使用有符号数值类型,而不用使用无符号数值类型。原因如下:
(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
(一)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 时,必须满足以下条件:
此外,还需要做以下几件事情:
有以下实践需要遵循:
下例显示了,由类型转换引起的破坏比较对称性的问题:
(四) 比较运算符 ==
最好把比较运算符看做是严格的值比较和严格的引用比较之间的选项,可以根据需要重写该运算符,System.String 类就重写了这个运算符,以比较字符串的内容,而不是它们的引用。只要创建的是值类型,都必须重新定义 ==。理由也是因为默认版本使用了反射,导致效率较低。创建引用类型时,应该尽量避免重写 ==,引用类型的 == 描述的正是比较同一性,默认版本已经做到了。
六 对象哈希码
Hash,也翻译为“散列”,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列码。哈希表(散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做哈希表(散列表)。
FCL 设计者认为,如果将任何实例放到一个哈希表集合中,会带来很多好处。因此,System.Object 提供了虚方法 GetHashCode,它能获取任意对象的 Int32 哈希码。该方法仅会在一个地方用到,即为基于哈希(散列)的集合定义键的散列码时,此类集合包括 HashSet
选择一种算法来计算类型实例的哈希码时,请遵守以下规则:
要编写一个正确高效的散列函数,我们需要对类型有充分的认识。
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) 绝对不要对哈希码进行持久化,因为哈希码很容易改变。