.NET学习笔记(三) ——系统类型和通用操作 (下)

四 装箱与拆箱

 

前面主要都讨论的是同类型直接的转换,引用到引用,值类型到值类型。还有一种用的非常多的就是引用类型和值类型之间的转换,比如传递参数时,值类型存储到一个引用类型时。也就是经常可以听到的装箱和拆箱操作。这确实是个非常复杂的地方。因为引用类型和值类型的内存空间是不同的,也就导致了许多性能问题和拷贝使用等问题。对于每一种值类型,运行库都提供一种相应的已装箱类型,这是与值类型有着相同状态和行为的类。当需要已装箱的类型时,某些语言要求使用特殊的语法(如C++要用关键字);而另外一些语言会自动使用已装箱的类型(C#是自动的)。在定义值类型时,需要同时定义已装箱和未装箱的类型。

  1. 由前面我们可以知道,值类型是分配在线程的堆栈上,而引用类型是分配在托管堆上的。所谓的‘装箱’也就是把值类型转化为一个引用类型的过程。实际上也就是把分配在堆栈上的对象,装箱(可能说打包比较形象,前面说过分配在托管堆的对象回又个附加成员),然后重新分配到托管堆上的。装箱操作通常由以下几步组成:
    在托管堆上为新生成的引用类型分配内存空间。这个空间大小为值类型本身大小和附加成员(方法表指针和SyncBlockIndex)
  2. 将值类型实例的字段拷贝到托管堆上新分配对象的内存中。
  3. 返回托管堆中新分配对象的地址。这个地址就是一个指向对象的引用。值类型实例也就变成了一个引用类型对象。
//声明一个值类型
struct Point
{
  public Int32 x,y;
}


class app
{
 static void Main()
 {
   ArrayList a = new ArraryList();
   Point p ;            //值类型分配到线程堆栈上
   for(Int32 i = 0; i < 10 ;i++)
   {
      p.x = p.y = 1;    //初始化值类型成员
      a.Add(p);         //Add(Object obj)方法要接受一个引用类型,所以会对p进行装箱操作。
   }
 }
}

上面的代码中,会先在托管堆上为point分配一个内存空间,然后把p中字段传到新的内存空间中去。然后返回这个新内存空间的地址,也就是指向这个新分配的对象的地址给add方法。这时传个add方法的就是托管堆上新对象了。这个装箱过程对于C#来说是自动完成的。这个新的对象会最后会被垃圾回收器回收掉。而这个时候,之前的分配在线程堆栈上的对象p则可以被重用或者释放。经过装箱后的值类型的生存期超过了未装箱的值类型。

但我们想从ArrayList中取得这个新的Point对象时,因为他是值类型,我们就必须把他从托管堆上拷贝到线程堆栈上进行使用。这就涉及到拆箱的操作。拆箱操作是指获取已装箱值类型的对象的地址的过程。这也就是说拆箱和装箱不是想对的。往往在拆箱后还要把此对象的字段拷贝到线程堆栈上的值类型实例中。可见拆箱的代价很下,他只获得对象的地址。要注意的是,在拆箱后进行转型时,必须先转为他原来未装箱时的类型。

Int32 x = 5;
Object o = x ;
Int16 y =(Int16)o;    //错误
Int16 y =(Int32)(Int16)o; //正确

在装箱和拆箱的过程中,会从速度和内存两方面损伤应用程序的性能。所以我们应该清楚编译器何时产生这些操作的指令,并在程序中尽可能的减少这种情况。看下面的代码,为了修改P,两次装箱代价非常大。

Point p;
p.x = p.y = 1;
Object o = p;    //p第一次装箱

p = (Point) o;   //拆箱并拷贝
p.x = 2;     
o = p;           //第二次装箱,又会分配一个新的内存空间。

下面看一些列子:

using System;

class ex1
{
    public static void Main()
    {
        Int32 v = 5 ;
        Object o = v;   
        v = 123;
        Console.WriteLine (v + "," + (Int32)o);
    }
}

这样的程序进行了几次装箱操作呢?答案是3次。可以通过看IL代码来了解:

.method public hidebysig static void  Main() cil managed
{
  // 代码大小       46 (0x2e)
  .maxstack  3
  .locals init (int32 V_0,
           object V_1)
  //把5入栈
  IL_0000:  ldc.i4.5
  IL_0001:  stloc.0
  //装箱
  IL_0002:  ldloc.0
  IL_0003:  box        [mscorlib]System.Int32    
  IL_0008:  stloc.1

  //把123存到v中
  IL_0009:  ldc.i4.s   123
  IL_000b:  stloc.0
  //对V装箱
  IL_000c:  ldloc.0
  IL_000d:  box        [mscorlib]System.Int32  

  //把,入栈 
  IL_0012:  ldstr      ","
  //对o拆箱
  IL_0017:  ldloc.1
  IL_0018:  unbox      [mscorlib]System.Int32
  IL_001d:  ldind.i4

  //对o装箱,可以看到调用了String::Concat方法都接受Object型
  IL_001e:  box        [mscorlib]System.Int32    
  IL_0023:  call       string [mscorlib]System.String::Concat(object,
                                                              object,
                                                              object)
  IL_0028:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_002d:  ret
} // end of method ex1::Main

第一次装箱和你好看出,而后两次是发生在Console.WriteLine方法中的,对于连接多个字符串时,他接受的类型是Object型,看到上面的方法中第一个参数v和最后一个(Int32)o,这两个地方发生了装箱。对于这个Console.WriteLine方法可以改为Console.WriteLine (v + “,” + o),这样的话,最后一个参数o已经是引用参数了,就会减少一次装箱。重新编译后查看IL代码会发现 代码大小变小了。

查阅MSDN可以发现Console.WriteLine方法有多种重载的方法,比如为Int32,Char,Single等基元类型提供了重载,这样在单独输出这些类型的时候就避免的装箱的操作。其他一些类型也有类似重载方法,目的就是为了减少值类型装箱操作,提高效率。我们自己在写程序时,如果一个类型可能导致系统对他反复装箱,我们就应该自己为他装箱

Int32 v = 5 ;
Console.WriteLine ("{0},{1},{2}",v,v,v); //3次装箱

Int32 v = 5 ;
Object o = v;    //1次装箱    
Console.WriteLine ("{0},{1},{2}",o,o,o); 

 

五 继承和重写通用操作

 

前面提到过Object类型有一些公有的方法,而所有的类型都继承与Object类型,所以他们都能继承这些通用操作方法。当然Object中实现的这些方法可能不能满足我们的类型的需求,这个时候我们可以对他进行重写,来实现自己的方法。比如Equals方法,在Object中实现是用来判断2个引用类型是否指向同一个对象。这对于我们要判断2个对象的值相等时就显得不够了,所以我们需要对他进行重写。对于重写了Equals方法的类型,一般也要求重写他的GetHashCode方法。这里Equlas方法的重写比较麻烦,要分为引用类型和值类型的重写。而引用类型中又分为基类重写了此方法和没有重写此方法两钟。对Equals方法重新写主要是比较:

  1. 对象是否为空
  2. 调用基类此方法判断(对于引用类型的基类重写了此方法的需要判断,如果调用基类会导致调用Object.Equlas是就不调用)
  3. 2个对象类型是否相同
  4. 2个对象中的所有字段是否相等
  5. 最后需要对 ==,!=,这些操作符进行重载

对于值类型来说,因为所有值类型是继承ValueType,而他又是继承Object的。ValueType重写了Equlas方法。他在内部使用了反射,然后比较每个字段。这样效率比较低,但所有的值类型都能继承。另外一个常用的方法就是MemberwiseClone。他是创建一个新的类型实例,并将去字段设置为和this对象的字段相同。最后返回创建实例引用。在系统中拷贝一个对象会经常用到,其中拷贝有两种,浅拷贝和深拷贝。利用MemberwiseClone来进行的是深拷贝,因为他创建一个新的对象,然后把他的字段和被拷贝的对象置成相同的。而浅拷贝,是指当对象的字段值被拷贝时,字段引用的对象不会拷贝。也就是说只拷贝堆栈上的数据,而不拷贝所指向托管堆栈的对象。

而一个对象是否允许被Clone就要看他是否实现了ICloneable接口。

Public interface ICloneable
{ 
  Object Clone();
}

//定义一个类型
Class MyClass
{
  ArrayList set;
  MyClass();
}

//浅拷贝
MyClass o1 = new MyClass();
MyClass o2 = o1; 

//要想实现深拷贝,在Clone方法中调用MemberwiseClone就行了
Class MyClass : ICloneable
{
 public Object Clone()
 {
   return MemberwiseClone();
 }
}
MyClass o1 = new MyClass();
MyClass o2 = o1.Clone();
//另一种方法就是自己分配一个新对象,实现深拷贝,不调用MemberwiseClone。
Class MyClass : ICloneable
{
 ArrayList set;

 //定义Clone方法的私有构造器
 private MyClass(ArrayList set)
 {
   this.set = (ArrayList)set.Clone()
 }

 public Object Clone()
 {
   return new MyClass(set);  //构造一个新对象,构造器参数为原来对象中使用的ArrayList。
 }
}

MyClass o1 = new MyClass();
MyClass o2 = o1.Clone();

上面讲的都是引用类型,而对于值类型他本身就支持浅拷贝。如果我们自己定义的一个值类型,也希望实现深拷贝,就需要我们自己来实现ICloneable接口,最好不要使用MemberwiseClone方法,而是分配一个对象,自己实现深拷贝。

这里是关于MemberwiseClone方法:

using System;
using System.Collections;

class ex :ICloneable
{
    internal int x;
    internal ex1 y;
    public ex()
    {
        x = 0;
        y = new ex1();
    }

    public Object Clone()
    {
        return MemberwiseClone();
    }
}

class ex1
{
    internal int n;
    public ex1()
    {
        n = 0;
    }

    public ex1(int n)
    {
        this.n = n;
    }
}

class examlpe
{
    static void Main()
    {
        ex e1 = new ex();
        ex e2 = (ex)e1.Clone();
        e1.x = 1;
        e1.y.n = 5;
        Console.WriteLine(e1.x);
        Console.WriteLine(e1.y.n);
        Console.WriteLine(e2.x);
        Console.WriteLine(e2.y.n);
    }
}

输出结果入下:

1
5
0
5

从结果可以看出,实例的int字段指向不同地址,修改了e1.x不会修改e2.x。而对于引用字段y,在Clone时,只复制了y的引用,ex1.y和ex2.y指向同一个地址。所以修改了一个会影响另一个。

MemberwiseClone是浅拷贝。对于浅拷贝是把值拷到新空间中,而深拷贝把引用拷到新的空间中。而和是否创建新的实例没有关系。因为以前一直受下面这个影响,所以对浅拷贝理解的比较浅。

ex e1 = new ex();
ex e2 = e1;

一直以为只有这一种浅拷贝方式,2个对象指向同一个实例就是签拷贝。但MemberwiseClone方法可以看出。创建了新的实例,2个对象指向不同的实例,也可能是浅拷贝。因为实例中如果是引用字段,复制的时候只是复制的引用地址。所以看到底是深拷贝还是浅拷贝,还是要看实现的时候对引用对象是复制地址还是值。而MemberwiseClone是复制引用地址,所以是浅拷贝。

 

六 命名空间和程序集

 

这个在书中出现了就顺便提下。很多人可能弄不清楚命名空间和程序集的关系。用一句比较通俗的说法就是,程序集是类型的物理存储形式,他表示某个类型在哪个文件中。而命名空间则是类型的逻辑存储形式。就好比学校里的学生就是类型,他们住的寝室好比程序集,而他们的班级是命名空间。不同程序集的类型可能属于同一个命名空间。一个程序集中的类型也可能包含属于多个命名空间。


如果本文对您有帮助,可以扫描下方二维码打赏!您的支持是我的动力!
微信打赏 支付宝打赏

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注