内存分配:
CLR 管理内存的区域,主要有三块,分别为:
线程的堆栈,用于分配值类型实例。堆栈主要由操作系统管理,而不受垃圾收集器的控制,当值类型实例所在方法结束时,其存储单位自动释放。栈的执行效率高,但存储容量有限。
GC 堆,用于分配小对象实例。如果引用类型对象的实例大小小于 85000 字节,实例将被分配在 GC 堆上,当有内存分配或者回收时,垃圾收集器可能会对 GC 堆进行压缩,详情见后文讲述。
LOH(Large Object Heap)堆,用于分配大对象实例。如果引用类型对象的实例大小不小于 850
00 字节时,该实例将被分配到 LOH 堆上,而 LOH 堆不会被压缩,而且只在完全 GC 回收时被回收。
堆栈的内存分配机制:
对于值类型来说,一般创建在线程的堆栈上。但并非所有的值类型都创建在线程的堆栈上,例如作为类的字段时,值类型作为实例成员的一部分也被创建在托管堆上;装箱发生时,值类型字段也会拷贝在托管堆上。
引用类型的实例分配于托管堆上,而线程栈却是对象生命周期开始的地方。对 32 位处理器来说,应用程序完成进程初始化后,CLR 将在进程的可用地址空间上分配一块保留的地址空间,它是进程(每个进程可使用 4GB)中可用地址空间上的一块内存区域,但并不对应于任何物理内存,这块地址空间即是托管堆。
托管堆又根据存储信息的不同划分为多个区域,其中最重要的是垃圾回收堆(GC Heap)和加载堆(Loader Heap),GC Heap 用于存储对象实例,受 GC 管理;Loader Heap 又分为 High-Frequency Heap、Low-Frequency Heap 和 Stub Heap,不同的堆上又存储不同的信息。Loader Heap 最重要的信息就是元数据相关的信息,也就是 Type 对象,每个 Type 在 Loader Heap 上体现为一个 Method Table(方法表),而 Method Table 中则记录了存储的元数据信息,例如基类型、静态字段、实现的接口、所有的方法等等。Loader Heap 不受 GC 控制,其生命周期为从创建到 AppDomain 卸载。
在进入实际的内存分配分析之前,有必要对几个基本概念做以交代,以便更好的在接下来的分析中展开讨论。
TypeHandle,类型句柄,指向对应实例的方法表,每个对象创建时都包含该附加成员,并且占用 4 个字节的内存空间。我们知道,每个类型都对应于一个方法表,方法表创建于编译时,主要包含了类型的特征信息、实现的接口数目、方法表的 slot 数目等。
SyncBlockIndex,用于线程同步,每个对象创建时也包含该附加成员,它指向一块被称为 Synchronization Block 的内存块,用于管理对象同步,同样占用 4 个字节的内存空间。
NextObjPtr,由托管堆维护的一个指针,用于标识下一个新建对象分配时在托管堆中所处的位置。CLR 初始化时,NextObjPtr 位于托管堆的基地址。
在托管堆中增加新的实例对象,只是将 NextObjPtr 指针增加一定的数值,再次新增的对象将分配在当前 NextObjPtr 指向的内存空间,因此在托管堆栈中,连续分配的对象在内存中一定是连续的,这种分配机制非常高效。
静态字段的内存分配和释放,又有何不同?
静态字段也保存在方法表中,位于方法表的槽数组后,其生命周期为从创建到 AppDomain卸载。因此一个类型无论创建多少个对象,其静态字段在内存中也只有一份。静态字段只能由静态构造函数进行初始化,静态构造函数确保在类型任何对象创建前,或者在任何静态字段或方法被引用前执行,其详细的执行顺序请参考相关讨论。
继承,就是面向对象中类与类之间的一种关系。继承的类称为子类、派生类,而被继承类称为父类、基类或超类。通过继承,使得子类具有父类的属性和方法,同时子类也可以通过加入新的属性和方法或者修改父类的属性和方法建立新的类层次。
继承机制体现了面向对象技术中的复用性、扩展性和安全性。为面向对象软件开发与模块化软件架构提供了最基本的技术基础。
Adapter 模式主要用于
将一个类的接口转换为另外一个接口,通常情况下在改变原有体系的条件下应对新的需求变化,通过引入新的适配器类来完成对既存体系的扩展和改造。Adapter 模式就其实现方式主要包括:类的 Adapter 模式。通过引入新的类型来继承原有类型,同时实现新加入的接口方法。其缺点是耦合度高,需要引入过多的新类型。对象的 Adapter 模式。通过聚合而非继承的方式来实现对原有系统的扩展,松散耦合,较少的新类型。
继承:
密封类不可以被继承。
继承关系中,我们更多的是关注其共性而不是特性,因为共性是层次复用的基础,而特性是系统扩展的基点。
实现单继承,接口多继承。
从宏观来看,继承多关注于共通性;而多态多着眼于差异性。
继承的层次应该有所控制,否则类型之间的关系维护会消耗更多的精力。
面向对象原则:多组合,少继承;低耦合,高内聚。
.NET 中对象的继承两个原则:
1.关注对象原则:调用子类还是父类的方法,取决于创建的对象是子类对象还是父类对象,而不是它的引用类型。例如 Bird bird2 = new Chicken()时,我们关注的是其创建对象为 Chicken 类型,因此子类将继承父类的字段和方法,或者覆写父类的虚方法,而不用关注 bird2 的引用类型是否为Bird。引用类型不同的区别决定了不同的对象在方法表中不同的访问权限。
根据关注对象原则,那么下面的两种情况又该如何区别呢?
Bird bird2 = new Chicken();
Chicken chicken = new Chicken();
根据我们上文的分析,bird2 对象和 chicken 对象在内存布局上是一样的,差别就在于其引用指针的类型不同:bird2 为 Bird 类型指针,而 chicken 为 Chicken 类型指针。以方法调用为例,不同的类型指针在虚拟方法表中有不同的附加信息作为标志来区别其访问的地址区域,称为 offset。不同类型的指针只能在其特定地址区域内进行执行,子类覆盖父类时会保证其访问地址区域的一致性,从而解决了不同的类型访问具有不同的访问权限问题。
2. 执行就近原则:对于同名字段或者方法,编译器是按照其顺序查找来引用的,也就是首先访问离它创建最近的字段或者方法,例如上例中的 bird2,是 Bird 类型,因此会首先访问 Bird_type(注意编译器是不会重新命名的,在此是为区分起见),如果 type 类型设为 public,则在此将返回 Bird 值。这也就是为什么在对象创建时必须将字段按顺序排列,而父类要先于子类编译的原因了。
封装:
在面向对象三要素中,封装特性为程序设计提供了系统与系统、模块与模块、类与类之间交互的实现手段。
封装隐藏了类内部的具体实现细节,对外则提供统一访问接口,来操作内部数据成员。这样实现的好处是实现了 UI 分离,程序员不需要知道类内部的具体实现,只需按照接口协议进行控制即可。同时对类内部来说,封装保证了类内部成员的安全性和可靠性。
字段,属性,方法
通常系统需求描述的核心名词,可以抽象为类,而对这些名词驱动的动作,可以对应地抽象为方法。当然,具体的设计思路要根据具体的需求情况,在整体架构目标的基础上进行有效的筛选、剥离和抽象。取舍之间,彰显 OO 智慧与设计模式的魅力。
字段(field)
通常定义为 private,表示类的状态信息。
封装的第一个原则就是:将字段定义为 private。
封装原则告诉我们:类的字段信息最好以私有方式提供给类的外部,而不是以公有方式来实现,否则不适当的操作将造成不必要的错误方式,破坏对象的状态信息,数据安全性和可靠性无法保证。
属性(property)
通常定义为 public,表示类的对外成员。
字段设置为了private,则需要通过属性对外进行暴露。
编译器的执行逻辑是:如果发现一个属性,并且查看该属性中实现了 get 还是 set,就对应地生成 get_属性名、set_属性名两个方法。因此,我们可以说,属性的实质其实就是在编译时分别将 get 和 set 访问器实现为对外方法,从而达到控制属性的目的,而对属性的读写行为伴随的实际是一个相应方法的调用,它以一种简单的形式实现了方法。
通过对公共属性的访问来实现对类状态信息的读写控制,主要有两点好处:一是避免了对数据安全的访问限制,包含内部数据的可靠性;二是避免了类扩展或者修改带来的变量连锁反应。
还有一种含参属性,在 C#中称为索引器(indexer),对 CLR 来说并没有含不含参数的区别,它只是负责将相应的访问器实现为对应的方法,不同的是含参属性中加入了对参数的处理过程罢了。
方法(method)
封装了类的行为,提供了类的对外表现。用于将封装的内部细节以公有方法提供对外接口,从而实现与外部的交互与响应。
从上面属性的分析我们可知,实际上对属性的读写就是通过方法来实现的。因此,对外交互的方法,通常实现为 public。
多态
分类:根据其实现的方式我们可以进一步分为基类继承式多态和接口实现式多态。
运行机制:从技术实现角度来看,是.NET 的动态绑定机制成就了面向对象的多态特性。
动态绑定,又叫晚期绑定,是区别与静态绑定而言的。静态绑定在编译期就可以确定关联,一般是以方法重载来实现的;而动态绑定则在运行期通过检查虚拟方法表来确定动态关联覆写的方法,一般以继承和虚方法来实现。严格来讲,.NET 中并不存在静态绑定。所有的.NET 源文件都首先被编译为 IL 代码和元数据,在方法执行时,IL 代码才被 JIT 编译器即时转换为本地 CPU 指令。JIT 编译发生于运行时,因此也就不存在完全在编译期建立的关联关系,静态绑定的概念也就无从谈起。
意义:多态提供了对同一类对象的差异化处理方式,实现了对变化和共性的有效封装和继承,体现了“一个接口,多种方法”的思想,使方法抽象机制成为可能。在.NET 中,默认情况下方法是非虚的,以 C#为例必须显式地通过 virtual 或者 abstract 标记为虚方法或者抽象方法,以便在子类中覆写父类方法。在面向对象的基本要素中,多态和继承、多态和重载存在紧密的联系,正如前文所述多态的基础就是建立有效的继承体系,因此继承和重载是多态的实现基础。
接口
所谓接口,就是契约,用于规定一种规则由大家遵守。所以,.NET 中很多的接口都以 able 为命名后缀,例如 INullable、ICloneable、IEnumerable、IComparable 等,意指能够为空、能够克隆、能够枚举、能够对比,其实正是对契约的一种遵守寓意,只有实现了 ICloneable 接口的类型,才允许其实例对象被拷贝。
面向接口编程就意味着,在自定义类中想要有某种特性,就必须遵守这种契约。
interface IDriveable
{
void Drive();
}
public class BusDriver : IDriveable
{
public void Drive()
{
Console.WriteLine("有经验的司机可以驾驶公共汽车。");
}
}
同样例如要使用 foreach 语句迭代,其前提是操作类型必须实现 IEnumerable 接口,这也是一种契约。
实现接口还意味着,同样的方法对不同的对象表现为不同的行为。
本质:因此接口其实本质上可以看作是一个定义了抽象方法的类,该类仅提供了方法的定义,而没有方法的实现,其功能由接口的实现类来完成。
另外,按照接口隔离原则,接口应该被实现为具有单一功能的多个小接口,而不是具有多个功能的大接口。通过多个接口的不同组合,客户端按需实现不同的接口,从而避免出现接口污染的问题。
关于接口的规则,可以有以下的归纳:
接口隔离原则强调接口应该被实现为具有单一功能的小接口,而不要实现为具有多个功能的胖接口,类对于类的依赖应建立在最小的接口之上。
接口支持多继承,既可以作用于值类型,也可以作用于引用类型。
禁止为已经发布的接口,添加新的成员,这意味着你必须重新修改所有实现了该接口的类型,在实际的应用中,这往往是不可能完成的事情。
接口不能被实例化,没有构造函数,接口成员被隐式声明为 public。
接口可以作用于值类型和引用类型,并且支持多继承。
.NET开发性能优化条款:
1.资源的释放:推荐以 Dispose 模式来代替 Finalize 方式。
2.选择合适的垃圾收集器:工作站 GC 和服务期 GC。
3.在适当的情况下对对象实现弱引用。
弱引用是对象引用的一种 中间态 ,实现了对象既可以通过 GC 回收其内存,又可被应用程序访问的机制。在.NET中,WeakReference 类用于表示弱引用,通过其 Target 属性来表示要追踪的对象,通过其值赋给变量来创建目标对象的强引用.
4.尽可能以 using 来执行资源清理。
5.推荐使用泛型集合来代替非泛型集合。
6.初始化时最好为集合对象指定大小。
7.特定类型的 Array 性能优于 ArrayList。
8.字符串驻留机制,是 CLR 为 String 类型实现的特殊设计。
String 类型无疑是程序设计中使用最频繁、应用最广泛的基元类型,因此 CLR 在设计上为了提升 String类型性能考虑,实现了一种称为 字符串驻留 的机制,从而实现了相同
字符串可能共享内存空间。同时,字符串驻留是进程级的,垃圾回收不能释放 CLR 内部哈希表维护的字符串对象,只有进程结束时才释放。这些机制均为 String类型的性能提升
和内存优化提供了良好的基础。
9.合理使用 System.String 和 System.Text.StringBuilder。
10.尽量在子类中重写 ToString 方法。
ToString 方法是 System.Object 提供的一个公有的虚方法,.NET 中任何类型都可继承 System.Object 类型提供的实现方法,默认为返回类型全路径名称。在自定义类或结构中重写 ToString 方法,除了可以有效控制输出结果,还能在一定程度上减少装箱操作的发生。
11.for 和 foreach 的选择。
推荐选择 foreach 来处理可枚举集合的循环结构
12.以多线程处理应对系统设计。
13.尽可能少地抛出异常,禁止将异常处理放在循环内。
14.捕获异常时,catch 块中尽量指定具体的异常筛选器,多个 catch 块应该保证异常由特殊到一般的排列顺序。
15.以 is/as 模式进行类型兼容性检查。
以 is 来实现类型判断,以 as 实现安全的类型转换,是值得推荐的方法。
16.const 和 static readonly 的权衡。
const 是编译时常量,readonly 是运行时常量,所以 const 高效,readonly 灵活。在实际的应用中,推荐以 static readonly来代替 const,以解决 const 可能引起的程序集引用不一致问题,还有带来的较多灵活性控制。
17.尽量避免不当的装箱和拆箱,选择合适的代替方案。
18.尽量使用一维零基数组。
19.CLR 对一维零基数组使用了特殊的 IL 操作指令 newarr,在访问数组时不需要通过索引减去偏移量来完成,而且 JIT也只需执行一次范围检查,可以大大提升访问性能
20.以 FxCop 工具,检查你的代码。
FxCop 是微软开发的一个针对.NET 托管环境的代码分析工具
new 关键字深入浅出
作为运算符, 用于创建对象和调用构造函数。作为修饰符,用于向基类成员隐藏继承成员。作为约束,用于在泛型声明中约束可能用作类型参数的参数的类型。
MSDN 中的定义是:new 约束指定泛型类声明中的任何类型参数都必须有公共的无参数构造函数。当泛型类创建类型的新实例时,将此约束应用于类型参数。
base 关键字
其用于在派生类中实现对基类公有或者受保护成员的访问,但是只局限在构造函数、实例方法和实例
属性访问器中,MSDN 中小结的具体功能包括:
调用基类上已被其他方法重写的方法。
指定创建派生类实例时应调用的基类构造函数。
this 关键字
其用于引用类的当前实例,也包括继承而来的方法,通常可以隐藏 this,MSDN 中的小结功能主要包
括:
限定被相似的名称隐藏的成员
将对象作为参数传递到其他方法
声明索引器
class和struct相关
1.class 是引用类型,继承自 System.Object 类;struct 是值类型,继承自 System.ValueType类,因此不具多态性。但是注意,System.ValueType 是个引用类型。
2.从职能观点来看,class 表现为行为;而 struct 常用于存储数据。
3.class 支持继承,可以继承自类和接口;而 struct 没有继承性,struct 不能从 class 继承,也不能作为 class 的基类,但 struct 支持接口继承
4.class 可以声明无参构造函数,可以声明析构函数;而 struct 只能声明带参数构造函数,且不能声明析构函数。因此,struct 没有自定义的默认无参构造函数,默认无参构造器只是简单地把所有值初始化为它们的 0 等价值
5.实例化时,class 要使用 new 关键字;而 struct 可以不使用 new 关键字,如果不以 new 来实例化 struct,则其所有的字段将处于未分配状态,直到所有字段完成初始化,否则引用未赋值的字段会导致编译错误。
6.class 可以实抽象类(abstract),可以声明抽象函数;而 struct 为抽象,也不能声明抽象函数。
7.class 可以声明 protected 成员、virtual 成员、sealed 成员和 override 成员;而 struct 不可以,但是值得注意的是,struct 可以重载 System.Object 的 3 个虚方法,Equals()、ToString()和GetHashTable()。
8.class 的对象复制分为浅拷贝和深拷贝(该主题我们在本系列以后的主题中将重点讲述,本文不作详述),必须经过特别的方法来完成复制;而 struct 创建的对象复制简单,可以直接以等号连接即可。
9.class 实例由垃圾回收机制来保证内存的回收处理;而 struct 变量使用完后立即自动解除内存分配。
10.作为参数传递时,class 变量是以按址方式传递;而 struct 变量是以按值方式传递的。
接口和抽象类的区别:
1接口支持多继承;抽象类不能实现多继承。
2接口只能定义抽象规则;抽象类既可以定义规则,还可能提供已实现的成员。
3接口是一组行为规范;抽象类是一个不完全的类,着重族的概念。
4接口可以用于支持回调;抽象类不能实现回调,因为继承不支持。
5接口只包含方法、属性、索引器、事件的签名,但不能定义字段和包含实现的方法;抽象类可以定义字段、属性、包含有实现的方法。
6接口可以作用于值类型和引用类型;抽象类只能作用于引用类型。例如,Struct 就可以继承接口,而不能继承类。
接口和抽象类适用的场合:
1抽象类应主要用于关系密切的对象,而接口最适合为不相关的类提供通用功能。
2接口着重于 CAN-DO 关系类型,而抽象类则偏重于 IS-A 式的关系;
3接口多定义对象的行为;抽象类多定义对象的属性;
4接口定义可以使用 public、protected、internal 和 private 修饰符,但是几乎所有的接口都定义为 public,原因就不必多说了。
5接口不变 ,是应该考虑的重要因素。所以,在由接口增加扩展时,应该增加新的接口,而不能更改现有接口。
6尽量将接口设计成功能单一的功能块,以.NET Framework 为例,IDisposable、IDisposable、IComparable、IEquatable、IEnumerable 等都只包含一个公共方法。
7接口名称前面的大写字母 I 是一个约定,正如字段名以下划线开头一样,请坚持这些原则。
8在接口中,所有的方法都默认为 public。
9如果预计会出现版本问题,可以创建 抽象类 。例如,创建了狗(Dog)、鸡(Chicken)和鸭(Duck),那么应该考虑抽象出动物(Animal)来应对以后可能出现风马牛的事情。而向接口中添加新成员则会强制要求修改所有派生类,并重新编译,所以版本式的问题最好以抽象类来实现。
10从抽象类派生的非抽象类必须包括继承的所有抽象方法和抽象访问器的实实现。
11对抽象类不能使用 new 关键字,也不能被密封,原因是抽象类不能被实例化。
12在抽象方法声明中不能使用 static 或 virtual 修饰符。
System.Object
System.Object 是所有类型的基类,任何类型都直接或间接继承自 System.Object 类。没有指定基类的类型都默认继承于 System.Object,从而具有 Object 的基本特性,这些特性
主要包括:
通过 GetType 方法,获取对象类型信息。
通过 Equals、ReferenceEquals 和==,实现对象判等。
通过 ToString 方法,获取对象字符串信息 ,默认返回对象类型全名。
通过 MemberwiseClone 方法,实现对象实例的浅拷贝。
通过 GetHashCode 方法,获取对象的值的散列码。
通过 Finalize 方法,在垃圾回收时进行资源清理。
委托,事件,匿名方法,Lambda表达式
委托本质上仍旧是一个类,该类继承自System.MulticastDelegate类,该类维护一个带有链接的委托列表,在调用多播委托时,将按照委托列表的委托顺序而调用的。还包括一个接受两个参数的构造函数和3个重要方法:BeginInvoke、EndInvoke和Invoke。
委托的构造函数中包括了两个参数:第一个参数表示一个对象引用,它指向了当前委托调用回调函数的实例,第二个参数标识了回调方法。创建完实例化对象都后,通过Invoke执行回调方法调用,可见真正执行调用的是Invoke方法。
.NET的事件模型建立在委托机制之上,透彻的了解了委托才能明白的分析事件。可以说,事件是对委托的封装,从委托的示例中可知,在客户端可以随意对委托进行操作,一定程度上破坏了面向的对象的封装机制,因此事件实现了对委托的封装。。
完整定义过程:
定义一个内部事件参数类型,用于存放事件引发时向事件处理程序传递的状态信息,EventArgs是事件数据类的基类。
声明事件委托,主要包括两个参数:一个表示事件发送者对象,一个表示事件参数类对象。定义事件成员。
定义负责通知事件引发的方法,它被实现为protected virtual方法,目的是可以在派生类中覆写该方法来拒绝监视事件。
定义一个触发事件的方法,例如Calculate被调用时,表示有新的计算发生。
匿名方法以内联方式放入委托对象的使用位置,而避免创建一个委托来关联回调方法,也就是由委托调用了匿名的方法,将方法代码和委托实例直接关联,在语法上有简洁和直观的好处。
Lambda表达式是Functional Programming的核心概念,现在C# 3.0中也引入了Lambda表达式来实现更加简洁的语法,并且为LINQ提供了语法基础。
小结:
委托实现了面向对象的,类型安全的方法回调机制。
以Delegate作为委托类型的后缀,以EventHandle作为事件委托的后缀,是规范的命名规则。
多播委托返回值一般为void,不推荐在多播委托中返回非void的类型。
匿名方法和Lambda表达式提供了更为简洁的语法表现,而这些新的特性主要是基于编译器而实现的,在IL上并没有本质的变化。
.NET的事件是Observer模式在委托中的应用,并且基于.NET规范而实现,体现了更好的耦合性和灵活性。
.NET中的异常
而对异常的理解往往存在或多或少的误解:
例如:异常就是程序错误,以错误代码返回错误信息就足够了。在系统中异常越多越能保证容错性,尽可能多的使用try/catch块来处理程序执行。使用.NET自定义Exception就能捕获所有的异常信息,不需要特定异常的处理块。将异常类作为方法参数或者返回值。在自定义异常中通过覆写ToString方法报告异常信息,对这种操作不能掉以轻心,因为某些安全敏感信息有泄漏的可能。
异常是对程序接口隐含假设的一种违反。
从try/catch/finally说起:解析异常机制:try子句中通常包含可能导致异常的执行代码,而try块通常执行到引发异常或成功执行完成为止。catch子句包含了异常出现时的响应代码,其执行规则是:一个try子句可以关联零个或多个catch子句,CLR按照自上而下的顺序搜索catch块。catch子句包含的表达式,该表达式称为异常筛选器,用于识别try块引发的异常。如果筛选器识别该异常,则会执行该catch子句内的响应代码;如果筛选器不接受该异常,则CLR将沿着调用堆栈向更高一层搜索,直到找到识别的筛选器为止,如果找不到则将导致一个未处理异常。异常筛选器,用于表示用户可预料、可恢复的异常类,所有的异常类必须是System.Exception类型或其派生类,System.Excetpion类型是一切异常类型的基类。