.NET本质论 实例

对象和值的比较

CLR的类型系统(其实就是通用类型系统(CTS),它定义了如何在运行库中声明,使用和管理类型,同时也是运行库支持跨语言集成的一个重要组成部分)将对应简单值的类型同对应传统"对象"的类型区分开来.前者被称为值类型(value type)(值类型直接包含它们的数据,值类型的实例要么在堆栈上,要么内联在结构中.值类型可以是内联的(由运行库实现),用户定义的或枚举的);后者被称为引用类型(reference type)(引用类型存储对值的内存地址的引用,位于堆上.引用类型可以是自描述类型,指针类型或接口类型.自描述类型进一步细分成数组和类类型.类类型是用户定义的类,装箱的值类型和委托).值类型支持引用类型所支持的一个有限子集.尤其是值类型的实例不会占有像对象那么多的存储开销.在限制对象的内存开销的情形下,值类型是很有用的.特别需要注意的是,引用类型和值类型都可以有诸如字段和方法之类的成员.这意味着下面的语句是合法的:

在这里,53是类型(System.Int32)的一个实例,该类型有一个ToString方法.

术语object(对象)在文献和CLR文档中被多次使用,考虑到一致性,我们在这里将对象定义为:被垃圾回收的(garbage-collected,GC)堆上的CLR类型的实例.对象支持所有被其类型声明的方法和接口.为了实现多态,对象总是以两个字段的对象头开始.值类型(例如,System.Int32或System.Boolean)也是CLR类型,但值类型的实例不是对象,因为它们既不以对象头开始,也不会在GC堆上分配明显的实体.这使得值类型的实例比引用类型的实例在开销上要小一些.像引用类型一样,值类型可以有字段和方法.这既适用于基本数据类型,也适用于用户定义的值类型.

引用类型和值类型的基类型是不同的.所有值类型都是以System.ValueType作为基类型.System.ValueType对于CLR来说充当了一个信号,表明该类型的实例必须被区别对待.图5.1展示了CLR类型系统.注意基本数据类型(例如,System.Int32)是System.ValueType类型的派生类型,所有用户自定义的结构和枚举也是它的派生类型.除此之外,其他类型都是引用类型.

编程语言通常有一套内置(built-in)类型或基本数据类型.将这些内置类型映射到CLR类型是编译器的工作.CLR提供了一套相当丰富的标准数值类型,布尔类型,以及字符串类型的集合.注意,所有数值类型和布尔类型都是值类型.此外,还要注意System.String是引用类型.在CLR中,System.String对象是不变的(immutable),并且在被创建之后就不能被改变.这使得System.String的行为更像是一个值类型而不是引用类型.

基于一系列的原因,值类型不能用作基类型.为此,所有的值类型在其类型的元数据中都被标记为sealed,并且不能在类型中声明抽象方法.此外,由于值类型的实例不能作为确定的实体在堆上分配,因此,它不能有终结程序(finalizer)(允许对象在"垃圾回收"回收对象之前尝试释放资源并执行其他清理操作.在C#中,使用析构函数语法表示终结程序).这是由CLR强加的限制.C#编程语言还有另外一个限制,那就是值类型不能有默认构造函数.由于缺乏默认构造函数,因而当构造一个值类型的实例时,CLR简单地将值类型的所有字段设置为其默认值.最后,因为值类型的实例没有对象头,所以在值类型上的方法调用不使用虚方法分发.这提高了性能但损失了一些灵活性. 

定义新的值类型有两种方式:一种方式是定义以System.ValueType为基类型的类型:另一种方式是定义以System.Enum为基类型的类型.C#的struct定义与class定义极为相似.只是关键字不同而已.但还是有一些差别;你不能给C#的struct指定显式的基类型,即隐含的基类型总是System.ValueType.你也不能显式地将C#的struct声明为abstract或者sealed,更确切说地说,编译器将隐式地添加sealed修饰符.考虑下面简单的C# struct定义:

注意,像C# class的定义一样,C# struct可以有方法和字段.C# struct还能支持任何接口.说到底,C# struct的定义等价于定义了一个派生于System.ValueType的新的C# class.例如,前面的struct等价于下面的类定义:

然而,C#编译器不允许ValueType用作基类型.更确切地说,你必须使用struct构件才能实现这个目的

对于充当用户自定义的基本数据类型,却又包含任意合成字段的类型定义,C#的struct是很有用的.此外,你还可以定义特殊的整形,它不能添加任何新的变量,参数和字段,而是限制特定整型的值空间.这些受限制的整型被称为枚举类型(enumeration type)

枚举类型是直接基类型为System.Enum而不是System.ValueType的CLR值类型.枚举类型必须指定一个辅助类型用于枚举的数据表示.这个辅助类型必须是CLR内置整型之一(不包括System.Char)(枚举类型是具有命名常数的独特的类型.每个枚举类型都具有一个基础类型,这必须是byte,sbyte,short,ushort,int,unit,long或ulong.枚举类型的值集和它的基础类型的值集相同).枚举类型可以包含成员;然而,被支持的成员只是字面字段.枚举类型的字面字段必须匹配枚举的基础表示类型,并且充当能被用于枚举实例的合法值的集合.

你可以使用C#的enum定义创建枚举类型.C#的enum与C或C++的enum很相似.C#的enum定义包含一个用逗号分隔的唯一名字的列表:

编译器为每个名字赋予一个数值.如果没有提供显式值,那么,编译器将按声明顺序赋予0,1,2等等.在概念上,这个enum等价于下面的C#类定义:

然而,如同ValueType一样,C#编译器禁止显式地将System.Enum作为基类型,只能够采用enum构件.此外,不同于C,C#并不认为数值类型与枚举类型是兼容的.如果要像处理int一样处理enum(反之亦然),则必须首先显式地将其强制转换为想要的类型.相反,C++允许从枚举类型到数值类型的隐式转换.

如果enum定义中没有显式地指定基础类型,C#编译器就将假定基础类型是System.Int32.你可以使用下面的语法重写这个默认设置

尽管枚举的成员名必须唯一,但对于每个成员的整型值却没有这种唯一性要求.事实上,使用枚举类型表示位掩码(bitmask)是通用的方式.为了使这种用法更为明确,枚举可以应用[System.Flags]特性.这个特性向开发人员表明了其预期的用法.它还将影响底层的ToString实现,这样枚举值的字符串化版本将会是逗号分隔的成员名的列表,而不是一个数字

变量,参数和字段

引用类型总是会产生分配在堆上的实例.相比之下,对于值类型产生的实例,它的分配与变量声明的上下文相关.如果一个局部变量是值类型,CLR将在堆栈上分配该实例的内存:如果一个类中的某个字段是值类型的成员,CLR将为实例分配内存,并将其作为声明该字段的对象或类型的布局的一部分.对于变量,字段和参数而言,用于处理值类型和引用类型的规则是一致的.为此,本章将使用术语变量(variable)代表这三个概念,当讨论变量本身的时候将使用术语局部变量(local variable)

引用类型变量(reference type variable)如同其名字的含义:它包含对象引用而不是它们声明的类型实例.引用类型只是包含它引用的对象地址.这意味着两个引用类型变量可能引用同一个对象.此外,对于一个对象引用来说,根本不指向任何对象也是可能的.在使用一个引用变量之前,必须首先将它初始化,使其指向一个有效对象.如果你试图通过一个没有指向有效对象的引用来访问其成员,就会导致运行时错误.引用类型字段的默认值为null(空),这是一个不指向任何对象的地址.任何使用空引用的尝试都将导致System.NullReferenceException异常.有趣的是,你可以大胆的假设你所使用的任何对象引用都会指向一个有效对象或者null,因为使用一个未经初始化的引用,要么会被编译器捕获,要么就被CLR验证器(在实时(JIT)编译期间,可选的验证过程检查要实时编译为本机代码的方法的元数据和Microsoft中间语言(MSIL),以验证它们是否为类型安全)捕获.此外,如果你有一个存活的(在模块级声明的变量通常在应用程序的整个运行期间都存在.在类或结构中声明的非共享变量作为声明它的类或结构的每个实例的单独副本存在:每个这样的变量都具有与它的实例相同的生存期.但是,static变量仅有一个生存期,即应用程序运行所持续的全部时间)变量或字段在引用对象,那么CLR将不会释放整个对象

引用类型变量总是需要一个对象.相反,值类型变量(value type variable)自身就是实例,而不是引用.这意味着,值类型变量一旦声明就立即有效

示例5.1是这两个类型的例子:一个是引用类型,另一个是值类型,除此之外,其他都是一样的.这里,变量v能够被立即使用,因为其实例已经被作为变量声明的一部分而分配.相比之下,变量v能够被立即使用,因为其实例已经被作为变量声明的一部分而分配.相比之下,变量r不能被立即使用,需要等它指向堆上的有效对象后才可用.图5.3展示了两个变量在内存中是如何分配的.

有趣的是,不管是引用类型还是值类型,C#语言允许你使用同样的new操作符.当在引用类型上使用时,C#的new操作符被转换成一条CIL newobj指令.它在调用类型的构造函数之后,将引发在堆上的分配行为.当你使用值类型时,CLR把C#的new操作符转换成一条CIL initobj指令.它只是初始化实例,给每个字段赋予默认值.由此可见,对值类型使用new操作符,与在C++中通过operator new调用构造函数而不分配内存是一样的

值类型和引用类型的赋值操作是不同的.对于引用类型,赋值只是简单地复制对原实例的一个引用,这导致在内存中两个变量将引用同一个实例.对于值类型,赋值是将一个实例的内容改写为其他实例的内容.于是,在赋值完成后,两个实例完全没有关系.比较示例5.2的代码(如图5.4所示)和示例5.3的代码(如图5.5所示).注意引用类型的情形,赋值仅仅是复制引用,并且对一个变量的改变对于另一个变量而言是可见的(如果两个引用变量指向同一个对象,那么当改变一个引用的值时,也将同时改变另一个引用的值).相比之下,值类型的赋值产生了另外一个独立的实例.在值类型的例子中,v1和v2代表size类型的两个不同实例.而在引用类型的例子中,r1和r2只是CSize类型的一个实例的两个名字而已

特别要注意的是:向方法传递参数是赋值的变体.当向方法传递参数时,方法的声明决定参数是按引用传递还是按值传递.按值传递参数时,方法的声明决定参数是按引用传递还是按值传递.按值传递参数(默认情形)将导致该方法或者被调用方(caller)具有自己的该参数值的私有拷贝.如图5.6所示,如果参数是值类型,方法将得到它自己的该实例的私有拷贝.

如果参数是引用类型,那么,按值传递的是引用(而不是实例).该引用所指向的对象是不会被拷贝的.更进一步说,调用方(caller)和被调用方(callee)各自引用的是同一个共享对象.

按引用传递参数(在C#中用ref或out修饰符表标识)导致方法或被调用方得到一个托管指针,它指向调用方的变量.如图5.7所示,方法对值类型或引用类型所做的任何修改对于调用方都是可见的.并且,假如这个方法对一个对象引用的参数进行修改,使之重定向到内存中的另外一个对象,那么,这个改变也将影响调用方的变量.

在图5.7所示的例子中,被调用方的方法体在a或b参数上执行的任何赋值操作,都将影响调用方

特别是假设将b参数设为null,调用方的y变量也会相应地被设为null.相比之下,在图5.6所示的示例中,被调用方的方法体可以随意地给a或b参数赋值,而不会对调用方有任何影响

相等和同一

CLR(像许多其他技术一样)区别对象的相等与同一.这对于引用类型(例如类)尤其重要.一般来说,如果两个对象是相同类型的实例,并且其中一个对象的各个字段匹配另一个对象的字段值,那么这两个对象就是相等的(equivalent).但这并不意味着它们是"同一“个对象,只是这两个对象有相同的值而已.相比之下,如果两个对象在内存中共享一个地址,则它们便是同一的(identical).在实践中,如果两个引用都指向同一个对象,那么,它们就是同一的

比较对象引用的同一性相对简单一些,只需要对内存地址进行比较,而与类型无关.你可以通过System.Object.ReferenceEquals静态方法执行这种测试.该方法简单地包含在两个对象引用中的地址进行比较,与相关对象的类型没有关系

不像同一性比较那样,相等性的比较则是类型相关的.正是这个原因,System.Object提供了一个Equals虚方法,用于比较任意两个对象的相等性(示例5.4).如图5.8和示例5.5所示,只有两个对象具有相等的值时,Equals方法才返回真.而对于System.Object.ReferenceEquals方法而言,只有当引用指向同一个对象时,它才返回真

Object.Equals的实现需要保证这个运算具有自反性,对成性和传递性.也就是说在给定任何类型的一个实例时,下面的断言总是为真:

同样,Equals的实现必须是对称的

最后,Equals的实现在相等性上必须具有传递性:

在多数情况下,Equals的一般实现都需要遵守这三个要求

每个类型都能对System.Object.Equals方法实现自己的版本,如示例5.6所示.对于引用类型而言,Object.Equals的默认实现只是简单地测试同一性,这意味着只有当两个对象实际是同一对象时该方法才会返回真.

由于同一性对于值类型通常没有意义,因此,值类型的Object.Equals的默认实现是对所有实例字段调用Object.Equals方法进行按成员比较(值类型的Equals方法(即VauleType.Equals)的默认实现,是通过反射对要比较的对象的对应字段和此实例进行比较).在许多情况下,CLR将灵活地优化这些调用,例如,当类型的所有字段都是基本数据类型时,CLR将作一个类型无关的内存比较

重写Object.Equals方法的类型必须同时重写Object.GetHashCode方法.程序可以使用Object.GetHashCode方法决定两个对象是否相等.如果两个对象返回不同的散列码,那么就可以保证它们是不相等.如果两个对象返回相同的散列码,那么,它们可能相等也可能不相等.确认的唯一途径就是调用Object.Equals方法.Object.GetHashCode的实现通常比Object.Equals的开销要小得多,原因就是它不需要一个确定的答案

如果不考虑编程语言的特殊性,则很难说清楚同一性和相等性.在C++和C#中,标准的比较操作符是==和!=.当这些操作符被应用在基本数据类型上时,它们只是简单地发射直接比较这两个值CIL指令.当被应用在对象引用上时,这些操作符发射的CIL指令原则上等价于调用System.Object.ReferenceEquals方法.然而,C++和C#都支持操作符重载,这意味着一个特定的类型可能将==(和!=)操作符映射到任意代码.典型的例子就是System.String类型.System.String类型重载了这些操作符,使之调用Equals方法.于是,对于字符串比较,就能采用更为直观的相等比较.一般来说,重写Equals方法的类型应该考虑重载==和!=操作符,尤其当类型是(或者其行为像)值类型时

GetHashCode和Equals实际上是为那些类似值类型的对象而设计的.它们的设计主要考虑那些底层值不变的形象(例如,System.String).然而,当被应用到其相等性随时间而改变的对象上时.用于GetHashCode和Equals的约定存在着一些不一致性.一般来说,当没有不变的字段时(例如只读字段),实现GetHashCode是极其困难的.

对于类似值的类型,在该类型的实例上实施一个排序关系经常是很有用的.为了以统一的方式支持这种观点,CLR提供了一个标准的接口:System.IComparable.实现System.IComparable接口的类型,标明实例可被排序.如下所示,IComparable只有一个方法CompareTo:

CompareTo方法返回一个int值,可能有三种结果.如果该对象的值小于指定参数值,CompareTo就返回一个负数;如果该对象的值大于指定参数值,CompareTo就返回一个正数;如果对象的值与指定的参数值相等,CompareTo就返回零

IComparable接口与System.Object.Equals方法是相关联的.实现IComparable接口的类型必须提供Object.Equals的实现,这样才能与IComparable.CompareTo的实现保持一致.特别是下面例举的这些约束,总会应用到:

同样,重写System.Object.Equals方法的类型也应该考虑实现IComparable接口.所有基本数据类型和System.String都是有序的,并且都实现了IComparable接口.你自己编写的类可以通过实现IComparable接口,使之能够产生排序.示例5.7是一个实现了IComparable接口的类型,用于支持其实例的排序.

你可以在CompareTo方法中,对复合的if-else语句进行修改,如下所示:

如果this.age大于other.age,该方法将返回一个正数:如果两个值相等,该方法返回零;否则,该方法返回一个负数.

克隆

当将一个引用变量赋值给另外一个引用变量时,只是简单地创建了指向同一个对象的第二个引用.如果要制造一个对象的副本,你就需要某种机制来创建同一个类的新实例,并且基于原来对象的状态初始化该实例.Object.MemberwiseClone方法就是做这件事情的;然而,它不是一个公有方法.更进一步说,如果对象要想支持克隆(cloning),往往需要实现System.ICloneable接口,该接口有一个方法Clone:

MemberwiseClone方法执行的是浅表副本(shallow copy),这意味着它只是将原对象的每个字段的值拷贝到克隆体中.如果字段是一个对象引用,那么,拷贝的仅仅是这个引用,而不是引用的对象.下面的类使用浅表副本实现了ICloneable接口:

图5.9是浅表副本的结果

深层副本(deep copy)是指递归拷贝其字段所引用的所有对象,如图5.10所示.深层副本经常是人们所期望的;但它不是默认的行为,并且在一般情形下实现深层副本也不是好主意.深层副本除了会引起额外的内存活动和资源消耗之外,当对象层次结构(a graph of objects)出现环路时,深层副本还会出现问题,其原因是递归有可能陷入无限循环.不过,对于简单的对象层次结构,它至少是可以实现的,如示例5.8所示

在示例5.8中,Clone的实现居然可以不调用MemberwiseClone方法.一个可供选择的实现就是简单地使用new操作符实例化第二个对象,并且手动产生它的字段.此外,你还可以定义一个私有构造函数,使这两个部分(实例化和初始化)合并成一步.示例5.9就是这样一种实现

装箱

如图5.1所示,所有的类型都与System.Object兼容.然而,因为System.Object是一个多态类型,实例在内存中需要一个对象头,以支持动态方法分发.值类型既没有这个头,也不需要被分配在堆上.CLR允许你在使用对象引用的上下文中,使用值类型(最终是内存).例如,集合或接收System.Object作为方法参数的通用函数.为了支持这一点,CLR允许你通过与System.Object兼容的格式,将值类型的实例”克隆"到堆上.这个过程被称为装箱(boxing)(装箱和取消装箱使我们能够统一地来考察类型系统,其中任何类型的值最终都可以按对象处理.类型系统统一化为值类型提供了对象性的优点,并且不会带来不必要的系统开销.例如,对于不需要int值的行为与对象一样的程序,int值只是32位值.对于需要int值的行为与对象一样的程序.就可以使用装箱),并且只要当一个值类型的实例被赋给一个对象引用变量,参数或字段时,这个过程就会发生

例如,考虑示例5.10的代码.注意,当Size的实例被赋给一个对象引用变量时(本示例中是itf),CLR分配一个基于堆的对象,它实现了被底层值类型(Size)声明兼容的所有接口(IAdjustor).这个装箱的对象是一个独立的拷贝,并且对它所做的任何改变都不会传递到最初的值类型的实例,然而,只要通过向下强制类型转换操作符,就可能将装箱的对象拷到值类型的实例中,如示例5.10所示.图5.11是装箱和取消装箱的过程.

数组

CLR支持两种复合类型:一种是其成员可以通过局部的唯一名称访问;另一种是其成员没有被命名,而是通过位置被访问.类和结构体就是前者的例子,数组是后者的例子

数组是引用类型的实例.这个引用类型是CLR基于数组的元素类型和数组的维数(也称为秩(rank)]合成的(synthesized).所有的数组类型都继承了内置类型System.Array(System.Array是所有数组的抽象基类型.存在从任何数组类型到System.Array的隐式引用转换,并且存在从System.Array到任何数组类型的显式引用转换),如示例5.11所示.这意味着System.Array的所有方法对于任何类型的数组都是隐式可用的.这还意味着只要通过声明一个System.Array类型的参数,就可以让一个方法接受任何类型的数组.从根本上讲,System.Array所标识的对象子集才是实际的数组(System.Arrray本身不是数组类型,相反,它是一个从中派生所有数组类型的类类型)

数组类型有其自身的类型兼容规则,这些规则基于元素类型数组的形式(shape).数组的形式由维及每一维的容量组成.对于如何确定数组类型的兼容性,我们说,元素类型和维数相同的数组是类型兼容的(type-compatible).如果两个数组的元素类型都是引用类型,则需要考虑额外的兼容性.

假设一个元素类型是引用类型(T)的数组,它要与相同维数的元素类型为V的数组兼容,则要求类型T与类型V是兼容的.这意味着所有一维数组(其元素类型是引用类型)与System.Object[]是类型兼容的,因为所有可能元素类型本身与System.Object都是类型兼容的.图5.12说明了这个概念

大多数编程语言都有几种数组类型.将语言级别的数组语法映射到CLR数组类型是编译器的工作.在CLR中,数组是引用类型的实例,并且有方法,属性和接口.因为数组是引用类型,所以,数组可以在任何需要System.Object类型的地方被高效地传递.在使用时独立于编程语言的是,你总是可以通过使用Length属性得到数组元素的总数

每种编程语言都提供了其自身的语法,用于声明数组变量,初始化数组和访问数组元素.下面的C#程序片段创建并使用了一维整型数组:

因为数组是引用类型,所以这个例子中的rgn变量是一个引用.用于数组元素的内存将被分配在堆上

C#编程语言支持多种初始化数组的语法.下面的三种方式产生相同的结果:

上面所列出的紧凑版的好处是:右边的初始化语句是一个有效的C#表达式,并且可在任何期望int[]的地方使用

一个数组由零个或多个元素组成.这些元素可以通过位置访问,并且它们必须是统一的类型.对于值类型的数组,每个元素恰好是同一类型(例如,System.Int32)的实例.对于引用类型的数组,每个元素可能引用至少支持元素类型的实例,而实际上,该元素引用的是派生类型的实例

在一维数组中,数组元素前面被加上一个长度字段,用以标明该数组的容量.当创建一个数组时,你就设置了这个字段,并且该字段在数组的生存期内是不能改变的.当你最初实例化数组时,CLR将它的元素设为默认值.一旦实例化了一个数组,你就可以像处理一个类型的其他任何字段那样处理数组的元素,只是访问它们时需要通过索引而不是名字.图5.13是一个各个元素已被赋值的值类型的数组.对于引用类型,每个元素都被初始化为null.必须用有效对象引用重写这些元素之后,才能使用它们.图5.14所示的是一个各个元素均已被赋值的引用类型的数组.

尽管数组的内容在它被创建后可以改变,但数组的实际结构和容量是不变的,并且只在创建时被设置.CLR提供了更高级的集合类(例如,System.Collections.ArrayList)用于动态地调整集合的大小.有趣的是,数组的容量不是其类型的一部分.例如,考虑下面的C#变量声明:

注意,该变量的类型并没有标明数组的容量;这个决定被推迟,直到new操作符被使用的时候.这是因为数组的类型只取决于其元素类型和维数,而不是它的实际大小.

在CLR中,数组可以是多维的.对于多维数组,推荐的格式是矩形的(rectangular)或者C风格的数组.矩形数组的元素存储在连续的块中,如图5.15所示.多维数组不仅包含每一维的容量,还包含每一维的下限(lower bound)的索引.尽管在数组中存在下限,但是CLR并不支持非零下限的数组.

矩形数组的每一行的容量都必须相同,这也是我们使用术语矩形(rectangular)的原因所示.示例5.12是一个简单的矩形数组程序.这里使用逗号划分每一维的索引,并通过GetLength方法确定每一维的长度.对于矩形数组,Length属性返回所有维的元素总数(例如,对于一个MxN二维数组,Length返回M*N).此外,在C#中矩形数组有几种初始化的语法,其最简洁的形式如下:

另一种多维数组的形式是交错形(jagged)数组,或者Java风格的数组.交错形数组实际是一个"数组的数组",它的元素很少存储在连续的块中,如图5.16所示.交错形数组每一行可以有不同的容量,因此,我们使用术语交错形(jagged).示例5.13是一个简单的交错形数组程序.这里需要注意用于索引每一维的语法,以及Length属性是按预期的方式工作的,其原因是"根"数组实际是一个一维数组,它的元素本身就是引用数组.尽管交错形数组相当灵活,但在性能优化上就比不上矩形数组了,并且,VB.NET对交错形数组的处理也比较麻烦(但并不是不可能)

数组支持一套运算的通用集合.在基本访问器方法(如示例5.11所示)之外,数组还支持块拷贝操作(如示例5.14所示).尤其是Copy方法支持将一个数组中的某个范围的元素拷贝到另一个数组中.示例5.15展示了这些方法的用法.

只有当数组的元素支持IComparable接口时,System.Array类型的几种方法才可以应用.示例5.16列出了这些方法.从技术上讲,Array.IndexOf和Array.LastIndexOf需要元素以一种有意义的方法实现Equals方法.示例5.17列举了IndexOf和BinarySearch.尽管BinarySearch方法要求数组已经被排好序,但它执行的时间只是O(long(n)),明显优于IndexOf所需的时间O(n)

对象生存期

CLR知道在系统中的所有对象引用.正是根据这种全局的知识,运行时能够侦察到不再被引用的对象.运行时区别根引用和非根引用.根引用(root reference)往往是一个存活的局部变量或者一个类的静态字段.非根引用(nonroot reference)往往是一个对象的实例字段.根引用的存在足以使引用对象保持在内存中.没有根引用的对象潜在地不再使用.准确的说,一个对象被保证驻留在内存中,只有通过遍历由根引用开始的对象层次结构时能否到达(reach)该对象.不能直接或间接由根引用到达的对象将受到自动内存回收的影响,也就是所谓的垃圾回收器(garbage collection,GC)

图5.17显式了一个简单的对象层次结构,以及根引用与非根引用.注意根的集合是基于程序执行而变化的.在这个例子中,所显示的可到达(reachability)图反映的是ReadLine(加亮显示的)调用期间的情形.注意,词法的作用域(变量temp1和temp2都在f()方法中进行声明,但只有前者被认为是存活的)并不重要的.准确地说,CLR通过由JIT编译器创建的存活(liveness)信息,决定哪个局部变量是存活的,从而适合任何给定指令的指针值(这里的原文为"the CLR uses liveness information created by the JIT complier to determine which local variables are live for any given instruction pointer value."其中,"given instruction pointer value"被译为"给定指令的指针值",就图5.17而言,Console.WriteLine(temp1)语句将对应两条IL指令:ldloc.0和call.因此,局部变量temp1(编号为0,本身是一个字符串对象的指针)被推送到堆栈中,作为Console.WriteLine方法的参数).这就是为什么在图5.17中temp2并不被认为是一个存活的根

有时候保持一个对象的引用,可以防止对象被垃圾回收.例如,在一个静态集合中保持一个命名对象的查找表,通常用来防止命名对象被垃圾回收:

这个类确保一个给定的命名对象同时至多有一个实例驻留在内存中.然而,由于CLR不能从该集合中删除由Hashtabel对象持有的引用,因而这些对象不会被垃圾回收,原因就是Hashtable在其生存期中本身保证了这种可到达性.从理论上讲,由Hashtable表示的缓存本身只希望持有"建议性的(advisory)"引用(这里FancyObjects类中的缓存可以充当弱引用的角色,主要是在"对象引用和目标对象之间添加了一个间接层",目的主要是在引用对象的同时仍然允许该对象进行垃圾回收.然而事实上,本例中Hashtable表示的缓存实际上持有的是强引用(可到达的对象的引用,不允许垃圾回收器回收它)),不足以保持目标对象的存活.这实际上是System.WeakReference类型的任务

System.WeakReference类型在对象引用和目标对象之间添加了一个间接层.当垃圾回收器在查找根以决定哪个对象是以到达的时候,中间的WeakReference阻止垃圾回收器的进一步遍历.如果目标对象通过其他途径是不可达到的,那么,CLR将会回收该对象的内存.同样重要的是,CLR会设置WeakReference对象内的引用为null,以保证该对象被回收后不能再被访问.CLR通过WeakReference.Target属性使得这个内部引用有效,如果目标对象被回收了,该引用只是简单地返回null.

为了掌握弱引用(weak reference)的用法,考虑前面Get方法的修改版本:

注意,散列表持有的只是弱引用.这意味着缓存中的引用不能防止目标对象被垃圾回收.还要注意的是,当你在这个缓存上执行查找时,你必须确保目标对象在它被加入缓存后没有被回收.可以通过检查Target属性是否为null做到这一点.

只有当特定资源不够用时,CLR才会执行垃圾回收器.当这种情形发生时,CLR接管CPU,通过一个根引用跟踪可到达的对象.在识别所有这些对象(可到达的对象)后,垃圾回收器将收集堆上的剩余内存,用于后续的内存分配.作为内存回收的一部分,垃圾回收器将在内存中重新定位存活的对象,以避免产生堆碎片,并且,通过将存活的对象保存在更少的虚拟内存页来调节进行的工作集(下面这段话引自MSDN2003,能够帮助读者理解垃圾回收的过程:"在回收中,垃圾回收器检查托管堆,查找无法访问对象所占据的地址空间块.当发现无法访问的对象时,它就使用内存复制功能来压缩内存中可以访问的对象,释放分配给不可访问对象的地址空间块.在压缩了可访问对象的内存后.垃圾回收器就会做出必要的指针更正,以便应用程序的根指向新地址中的对象.它还将托管堆指针定位至最后一个可访问对象之后"这样一来就压缩了内存,从而减小程序的工作集)

CLR通过System.GC类以可编程的方式公开垃圾回收器.最有意思的方法是Collect,它指示CLR立刻执行垃圾回收.示例5.18展示了该方法的使用.注意,在这个示例中,当第一次调用System.GC.Collect时,你就可以回收r2引用的对象,尽管该引用仍在C#的词法作用域内,但CLR能侦查到该引用对象不再被需要.在第二次调用System.GC.Collect执行时,你就可以回收r1和r3所引用的对象,因为r1已经被显式地设置为null,而r3不再是一个存活的变量.你可以通过插入一个对System.GC.KeepAlive的调用.将一个对象引用保持为"存活"状态,以"欺骗"垃圾回收器.这个静态方法除了欺骗CLR.让它认为作为参数传递的引用是实际需要的,其他什么也不做.这样一来就使得引用的对象不被回收了

Collect方法接收一个可选的参数,该参数可用于控制对未被引用对象的搜索范围.CLR使用一个被称为生成代(generation)的算法,如果一个对象被引用得越久,它被垃圾回收得可能性就越小.Collect方法允许你指定一个对象有多"老".不过,频繁地调用GC.Collect会对性能产生负面影响

终结

对于触发清理代码的执行,推荐机制是使用一个终结处理程序.终结处理程序(termination handler)保护方法内一定范围的指令,它保证在离开受保护范围指令之前,执行这个"处理程序(handler)".该机制是通过在第6章中讨论的try-finally构件向c#程序员公开的

尽管存在着终结程序的机制,但习惯于使用C++的程序员还是旧习难改,总是想在对象的生存期中清理代码.为了降低他们的学习难度.CLR支持一种称为对象终结(object finalization)的机制.然而,对象终结是异步发生的,它与许多C++背景的程序员所使用的C++风格的析构函数(destructor)根本不同.当然,针对CLR的新设计应该避免过多地使用终结程序,因为它既复杂,又会产生性能损失

对于那些在被返回到堆时想要得到通知的对象,可以重写Object.Finalize方法.当GC试图回收一个带有终结程序的对象时,回收行为将会被推迟,直到终结程序被调用.GC将需要终结的对象排列在终结队列中,而不是回收它的内存.一个专门的GC线程最后会调用对象终结程序,并且,在终结程序完成执行后.对象的内存对于回收就是有效的.这意味着带终结程序的对象在被回收前,占用了至少两次单独的垃圾回收过程.

你的对象能够执行任何特定应用的逻辑来响应这个通知.不过,CLR可能会在确定你的对象是不可到达之后的很长一段时间,才调用Object.Finalize方法,并且这个方法将在CLR的某个内部线程上执行.从垃圾回收器确定你的对象是不可到达的,到对象终结程序被调用,可能会间隔很长的一段时间.因此,假如你想通过终结程序释放稀有资源,那么在很多情况下都无法容忍这个间隔时间,这也限制了终结程序的实用性

重写默认Finalize方法的类需要调用该方法的基类型版本,以确保没有忽略基类型的功能性.在C#中,你不能直接实现Finalize方法.准确地说,你必须实现一个析构函数(destructor),这将导致编译器在Finalize方法内发射你的析构函数代码,接着调用你的基类型的Finalize方法.示例5.19显示了一个包含析构函数的简单的C#类.注释部分说明了编译器生成的(compiler-generated)Finalize方法

因为GC是异步执行的,所以,依靠终结程序清理稀有资源并不是个好主意.为此,在CLR编程中有一个标准的习惯用法,就是提供一个显式的Dispose方法,以便客户端在使用完你的对象后进行调用(当托管对象不再使用时,垃圾回收器会自动释放分配给该对象的内存.不过,进行垃圾回收的时间不可预知.另外,垃圾回收器对窗口句柄,打开的文件和流等非托管资源一无所知.将Dispose方法与垃圾回收器一起使用从而显式释放非托管资源.当不再需要对象时,对象的使用者可以调用此方法).实际上,System.IDisposable接口标准化了该惯用语法.下面是System.IDisposable的定义:

实现该接口的类标明他们需要显式的清理.一旦引用的对象不再需要,就要调用IDisposable.Dispose方法,这最终是客户端程序员的工作.因为你的Dispose方法很可能与Finalize方法执行同样的工作,在你的Dispose方法内可以通过调用System.GC.SuppressFinalize方法,以消除这种冗余终结行为 ,如示例5.20所示

示例5.21是一个客户端在使用完对象后,显式地在该对象上调用Dispose方法.为了确保即使是在遇到的异常的时候,对象的使用者总是会调用Dispose方法,C#编程语言提供了一个构件,它封装了带有终结处理程序(隐式地称为你调用Dispose)的IDisposable兼容的(IDisposable-compatible)变量声明.这个构件就是C#的using语句

图5.18展示了using语句的语法.using语句允许程序员声明一个或多个变量,它们的IDisposable,Dispose方法将会被自动调用.资源获得从句的语法与局部变量声明语句的语法很相似.你可以声明多个变量,但每个变量的类型都必须相同.示例5.22显示了using语句的一个简单用法.注意在该示例中,因为using语句与IDisposable兼容的对象一起被使用,所以,编译器会发射代码,确保即使是在遇到没有被处理的异常或者其他方法终结(例如,一个return语句)时,Dispose方法也会被调用

 

转载于:https://www.cnblogs.com/revoid/p/6671506.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值