在10.2节中介绍的大多集合能够处理任何类型的数据,因为这些集合定义的处理对象使用了Object类型。当集合处理的数据是c#中的基本类型时,比如int、float、double类型,这些类型在c#中属于值类型,而这些数据进入集合中时,集合能够处理的是Object这样的引用类型,所以在值类型的数据进入集合的时候,需要将值类型包装成引用类型,这个过程就是装箱的过程,从集合中取出数据时,取出的是引用类型,同样需要把引用类型还原为值类型,这个过程就是拆箱的过程。
10.3.1装箱、拆箱和System.Object的关系
.NET的所有类型都是由基类System.Object继承过来的,包括最常用的基础类型:int, byte, short,bool等,就是说所有的事物都是对象。如果申明这些类型的时候都在堆(HEAP)中分配内存,会造成极低的效率!于是.NET将类型分成值类型(value)和引用类型(regerence type),C#中定义的值类型都从System.ValueType类继承而来,包括原类型(Sbyte、Byte、Short、Ushort、Int、Uint、Long、Ulong、Char、Float、Double、Bool、Decimal)、枚举(enum)、结构(struct),引用类型包括:类、数组、接口、委托、字符串等。与此对应,内存分配被分成了两种方式,一为栈,二为堆。
值类型是在栈中分配内存,在申明的同时就初始化,以确保数据不为null;引用型是在堆中分配内存,初始化为null,引用类型是需要GARBAGE COLLECTION来回收内存的,值型不用,超出了作用范围,系统就会自动释放!值类型只会在栈中分配。引用类型分配内存与托管堆。
装箱和拆箱是一个抽象的概念。装箱就是隐式的将一个数值类型转换为引用类型对象;拆箱是将引用类型转换为值类型。利用装箱和拆箱功能,可允许值类型的任何值与引用类型相互转换。
例如:
int i = 123;
object o = val;
Console.WriteLine ("对象的值 = {0}", o);
这是一个装箱的过程,是将值类型转换为引用类型的过程 。图10-18表示了两个变量 i 和o之间的差异。
图10-18
int val = 100;
object obj = val;
int num = (int)obj;
Console.WriteLine("num={0}", num);
这是一个拆箱的过程,是将值类型转换为引用类型,再由引用类型转换为值类型的过程 因为只有被装过箱的对象才能被拆箱。
将值类型转换为引用类型,需要进行装箱操作(boxing):
1、首先从托管堆中为新生成的引用对象分配内存,大小为值类型实例大小加上一个方法表指针和一个SyncBlockIndex。
2、然后将值类型的数据拷贝到刚刚分配的内存中。
3、返回托管堆中新分配对象的地址。
可以看出,进行一次装箱要进行分配内存和拷贝数据这两项比较影响性能的操作。在装箱的过程中,会新生成一个堆对象,将值类型的值复制到引用类型(堆对象)中,此后,值类型变量的更改,不会影响引用类型。
例子TestBoxing演示了这样的一个过程
namespace TestBoxing
{
class Program
{
static void Main(string[] args)
{
int i = 123;
object o = i;//隐式的装箱操作
i = 456;//改变值类型的值
Console.WriteLine("值类型i={0}", i);
Console.WriteLine("引用类型o={0}", o);
Console.ReadKey();
}
}
}
运行结果是
将引用类型转换为值类型,需要进行拆箱操作(unboxing):
1、首先检查对象实例,确保它是给定值类型的一个装箱值。然后获取托管堆中属于值类型那部分字段的地址,这一步是严格意义上的拆箱。
2、将引用对象中的值拷贝到位于线程堆栈上的值类型实例中。
经过这两步,可以认为同boxing是互反操作。严格意义上的拆箱,并不影响性能,但伴随这之后的拷贝数据的操作就会同boxing操作中一样影响性能。在拆箱时,会生成一个新的栈类型变量,将引用类型的值复制到值类型(堆栈对象)中,此后新的值类型的变化,也不会影响原来的引用类型。
装箱与拆箱会有时间损耗,也就是造成效率降低。所以,应该尽量避免装箱。
装箱/拆箱并不如上面所讲那么简单明了,比如:装箱时,变为引用对象,会多出一个方法表指针,这会有何用处呢?通过示例来进一步探讨。看例子BoxingMethodTable的演示。
namespace BoxingMethodTable
{
struct A : ICloneable
{
public Int32 x;
public override String ToString()
{
return String.Format("值类型重载的{0}", x);
}
public object Clone()
{
Console.WriteLine("值类型的Clone");
return MemberwiseClone();
}
}
class Program
{
static void Main(string[] args)
{
A a;
a.x = 100;
Console.WriteLine(a.ToString());
Console.WriteLine(a.GetType());
A a2 = (A)a.Clone();
ICloneable c = a2;
Object o = c.Clone();
Console.ReadKey();
}
}
}
首先定义了一个结构体A,然后在结构体里面生成了两个方法,一个是Clone(),另外一个是重写基类ValueType的ToString()。Console.WriteLine(a.ToString())中因为a已经具有ToString()方法,所以不需要装箱,而a.GetType()执行时,因为a没有GetType()方法,这个方法是基类的方法,于是需要装箱成为对象,对象能够从父类中继承得到该方法,所以这里就发生了一次装箱过程。装箱后,在对象上就多了一个方法表,该方法表就能调用GetType()。ICloneable c = a2是一个从值类型到接口类型的转化,也需要一次装箱过程。
所以,在对集合的操作中,当把值类型的数据存入集合的时候,首先需要将值类型装箱成为对象,然后把在堆中的对象装入集合;当需要把数据从集合中取出时,需要把这些对象拆箱,然后强制转化为对应的值类型,在栈中开辟值类型的空间,最后将值赋值进去,才能使用。如果集合的数据很大,这些装箱与拆箱的操作将严重影响程序的性能。
10.3.2类型安全和强类型集合
类型安全其实就是有关类型操作的一种规范,在运行时,公共语言运行库(CLR)总是知道一个对象的类型。在开发的过程中,经常会需要将一个对象从一种类型转换为其他的类型,所以CLR允许将一个对象强制转换成它本身所引用的类型或派生其的基类型。一个对象向其父类的转换CLR认为是一种安全的隐式转换,不需要任何特殊的处理。然而需要将一个对象转换为其派生类型时,则需要进行显示的转换,因为这样的转换可能在运行时失败。
类型安全代码就是以完善的、允许的方式访问类型的代码。类型安全代码只访问被授权可以访问的内存位置。例如,给定有效的对象引用,类型安全代码可以按对应于实际字段成员的固定偏移量来访问内存。但是,如果代码以任意偏移量访问内存,该偏移量超出了属于该对象的公开字段的内存范围,则它就不是类型安全的代码。
JIT编译执行称为验证的过程,该过程检查代码并尝试确定该代码是否为类型安全代码。验证过程中被证明属于类型安全的代码称为“可验证为类型安全的代码”。代码可以是类型安全代码,但可能不是类型安全的代码,原因在于验证过程或编译器的限制。不安全代码是指无法验证的代码。C# 中的不安全代码不一定是危险的;只是其安全性无法由 CLR 进行验证的代码。
拿集合ArrayList举例来说。在ArrayList中,允许操作的数据类型是Object类型,在实际的数据加入ArrayList对象时,因为Object是其他类型的基类,所以这时会发生其他类型向基类的转换,这种转换被认为是安全的,于是可以在ArrayList对象中加入任何类型的数据,相当于根本就没有了类型检测。从ArrayList对象中取出数据时,需要从Object转换为具体的类型,这个时候发生了基类到子类的转换,这种转换有可能失败。例子TypeSafe演示了这种情况。
namespace TypeSafe
{
class Program
{
static void Main(string[] args)
{
ArrayList al = new ArrayList();
al.Add(1);
al.Add(2);
al.Add(3.0);
al.Add('8');
al.Add("9");
int sum = 0;
foreach (Object obj in al)
{
Console.WriteLine("{0}的类型是{1}",obj,obj.GetType());
sum +=(int) obj;
}
Console.ReadKey();
}
}
}
点击生成,没有发现任何问题,然后开始运行,就会出现图10-18所示的InvalidCastException异常,也就是说程序在编译阶段没有办法进行类型检查。程序一旦进入这种以基类为操作对象的方法或者类时,编译器就显得无能为力。因为这种类实现的本身就缺乏强类型的限制。
图10-18
以Object这样的通用类型为操作参数的类、方法、或者委托等,在程序设计中的确给程程序员带来了很多的便利,一次实现可以多次使用在不同的场合。但是同时也带来了很多的问题。主要的问题有两个
第一当处理的数据类型为值类型时,需要对数据进行频繁的装箱/拆箱操作,当数据量很大时,将会对程序的性能造成很大的影响。
第二当处理的数据类型为引用类型时,需要在子类到基类,基类到子类中间不停的转换,而且这种转换在程序编译阶段不具备强类型的要求,也就是说,这样的代码不具备类型安全的要求。
为了弥补上面的不足,使用强类型集合会出现什么问题呢?在System.Collections命名空间中提供了3个允许用户派生的强类型集合的基类。如果需要一种类型就派生一个,而且这些派生的功能非常相似甚至是一样的。显然使用强类型集合的办法也不是很合适。而且,在c#中数据类型的数量是没有办法预测的,也是不可能预测的。如果给每种数据类型以及这些数据类型组合的新类都派生一个类,会造成代码的很多冗余。即使在一个项目中使用的数据结构只有一种,可是这种数据结构处理的数据类型有很多种,这个时候如果都从基类派生出来,会显得整个程序很臃肿,而且工作量也是非常巨大的。
好在从c#2.0开始,这种语言开始增加了泛型的支持,泛型的出现使上面的问题迎刃而解。