基元类型、引用类型、值类型、装箱和拆箱

1.基元类型

有些数据类型我们平常写代码经常会用到,例如:int,string等,例如下面我们定义一个整数:

int a =0

我们也可以用下面的写法定义:

System.Int32  a = new System.Int32();

以上两种写法的结果都是一样的,为什么会这样呢?,大家肯定知道是编译器做的优化;本人比较喜欢第一种写法,因为这种语法不仅增强了代码的可读性,生成的IL代码跟System.Int32的IL代码是完全一样的。那什么是基元类型呢?就是编译器能直接支持的数据类型称为基元类型(primitive type).基元类型直接映射到.NET框架类库(FCL)中存在的类型。会不会很难理解?例如:c#中的int直接映射到了System.Int32类型。再看下面的代码,虽然写的方式不一样,但是它们生成的IL代码是完全一致的。只要是符合公共语言规范(CLS)的类型,不管那种语言都有提供了类似的基元类型。但是,不符合CLS的类型语言就不一定支持。

int a = 0;
System.Int32 a = 0;
int a = new int();
System.Int32 a =new System.Int32();

但是CLR via C#的作者是不推荐这种简洁的写法,下面就把他不推荐的理由照抄过来,大家可以作为参考:

1.许多开发人员纠结于是用String还是string。由于c#的string(一个关键字)直接映射到System.String(一个FCL类型),所以两者没有区别,都可以使用。类似,一些人开发人员说应用程序在32位操作系统上运行,int代表32位整数;在64位系统上运行,int代表64位整数。这个说法完全错误。c#的int始终映射到System.Int32,所以不管在什么操作系统上运行,代表的都是32位整数。如果程序员习惯在代码中使用Int32,像这样的误解就没有了。

2.c#的long映射到Sytem.Int64,但在其他编程语言中,Long可能映射到Int16或Int32.例如,c++/CLI就将long视为Int32.习惯于用一种语言写程序的人在看用另一种语言写的源代码时,很容易错误理解代码意图。事实上,大多数语言甚至不将long当作关键字,根本不编译使用了它的代码。

3.FCL的许多方法都将类型名作为方法名的一部分。例如,BinaryReader类型的方法包括ReadBoolean,ReadInt32,ReadSingle等;而System.Convert类型的方法包括ToBoolean,ToInt32,ToSingle等。以下代码虽然语法没问题,但包含float的那一行显得很别扭,无法一下子判断该行的正确性:

BinaryReader br = new BinaryReader(...);
float val = br.ReadSingle();    //正确,但感觉别扭
Singleval = br.ReadSingle();    //正确,感觉自然

4.平时只用c#的许多程序员逐渐忘了还可以用其他语言写面向CLR的代码,“c#主义”逐渐入侵类库代码。例如,Microsoft的FCL几乎是完全用c#写的,FCL团队向库中引入了像Array的GetLongLength这样的方法。该方法返回Int64值。这种值在c#确实是long,但在其他语言中不是。另一个例子是System.Linq.Enumerable的LongCount方法。

以上就是作者不推荐基元类型的原因,以至于CLR via c#里面所描述的类型都是FCL的类型名称。不过我自己倒觉得使用基元类型看起来比用FCL类型顺眼,所以还是自己的习惯吧,至少两种类型生成的IL代码都是一样的,也没有说用这种性能好,用那种性能不好的说法。在这里引用了博友的图片,图代表了C#的基元类型与对应的FCL类型

基元类型、引用类型、值类型、装箱和拆箱_第1张图片

 

最后说下类型转换,首先,编译器能执行基元类型之前的隐式或显示转换,只有在转换安全的时候,c#就允许隐式转型。反之,就要求显示转型。

 

2.引用类型和值类型

CLR提供了2种类型,引用类型和值类型。虽然FCL大多都是引用类型,但是开发人员用的最多还是值类型。引用类型总是从托管堆分配,使用new关键字返回指向对象数据的内存地址。设想下如果所有类型都是引用类型,那么性能肯定会明显下降。当你要使用int值时,都需要进行一次内存的分配,性能是会受多么大的影响。所以CLR提供了“值类型”的轻量级类型。值类型的实例一般在栈上分配。值类型的实例变量不包含指向实例的指针,而是变量本身包含了实例本身的字段。由于本身包含了实例的字段,所以不需要提领指针,一定程度上缓解了托管堆的压力,也减少了程序生存期内垃圾回收的次数。

所以定义的类型储存在栈中还是托管堆中取决于所属的类型。比如:String和Object属于引用类型,其他的基元类型则分配到栈中,下图详细地展示了在.NET预置类型中,哪些是值类型,哪些又是引用类型。

基元类型、引用类型、值类型、装箱和拆箱_第2张图片

以下代码说明引用类型和值类型的区别:

//引用类型
    class Ref
    {
        public int x;
    }

   //值类型
    struct Val
    {
        public int x;
    }

    static void Demo()
    {
          Ref r1 = new Ref();            //托管堆分配
            Val v1 = new Val();            //栈上分配    
            r1.x = 5;                //提领指针
            v1.x = 5;                //在栈上修改
            Console.WriteLine(r1.x);        //显示5
          Console.WriteLine(v1.x);        //同样显示5

          Ref r2 = r1;                //只复制指针
            Val v2 = v1;                //在栈上分配并复制成员
            r1.x = 8;                //r1.x和r2.x都会改变
            v1.x = 9;                //v1.x会改变,v2.x不会

            Console.WriteLine(r1.x);        //显示8
          Console.WriteLine(r2.x);        //显示8
          Console.WriteLine(v1.x);        //显示9
          Console.WriteLine(v2.x);        //显示5
        }

值类型的主要优势是不作为在托管堆上分配。当然,与引用类型相比,值类型也存在自身的一些局限。下面列出了值类型和引用类型的一些区别。

1.值类型有已装箱和未装箱2种表示形式,而引用类型总是处于已装箱形式。

2.值类型总是从System.ValueType中派生。跟System.Object具有相同的方法。但是ValueType重写了Equals方法,能在两个对象的字段值完全匹配前提下返回true,此外也重写了GetHashCode方法。

3.值类型的所有方法都是不能为抽象的,都是隐式密封的(不可以重写)。

4.将值类型变量赋给另一个值类型变量,会执行逐字段复制。将引用类型的变量赋给另一个引用类型变量只复制内存地址,所以两个引用类型的变量都是指向堆同一个对象,所以对一个变量执行操作可能影响到另一个变量引用的对象。

5.由于未装箱的值类型不在堆中分配,一旦定义了该类型的一个实例方法不在活动,为它们分配的存储就会被释放,而不是等着进行垃圾回收。

6.引用类型变量包含堆中对象的引用地址。引用类型的变量创建时默认初始化为null,代表当前不指向任何对象,试图使用null引用类型变量都会抛出NullReferenceException异常。相反,值类型的所有成员都被初始化为0,访问值类型不可能抛出NullReferenceException异常。CLR已经为值类型添加了可空标识

 

3.装箱和拆箱

讲到引用类型和值类型,必定要讲下装箱和拆箱了,下面开始讲述:

值类型比引用类型“轻”,原因是它们不作为对象在托管堆中分配,不被垃圾回收,也不通过指针进行引用。但许多时候需要获取对值类型实例的引用。例如下面这个栗子,创建一个ArrayList(这里是为了举例子而用ArrayList来装值类型,平常最好不要这样用,因为FCL已经提供了泛型集合类,List<T>在操作值类型不会进行装箱和拆箱)来容纳一组Point结构,上代码:

//声明值类型
struct Point
 {
    public int x, y;
 }

static void Main()
{
    ArrayList arraylist = new ArrayList();
    Point p;            //在栈上分配一个point
    for (int i = 0; i < 10; i++)
    {
        p.x = p.y = i;        //初始化成员
         arraylist.Add(p);        //对值类型装箱,将引用添加到ArrayList中
     }
    Console.ReadLine();
}

上面的代码大家很容易就能看出来,每次循环迭代都初始化一个Point的字段,并将该对象存储在arraylist中。但思考下ArrayList中究竟存储了什么?是point结构,还是地址,还是其他的东西?要知道答案,我们来看下ArrayList的Add方法,了解它的参数被定义成什么。代码如下

 //
 // 摘要: 
 //     将对象添加到 System.Collections.ArrayList 的结尾处。
 //
 // 参数: 
 //   value:
 //     要添加到 System.Collections.ArrayList 的末尾处的 System.Object。 该值可以为 null。
 //
// 返回结果: 
 //     System.Collections.ArrayList 索引,已在此处添加了 value。
 //
 // 异常: 
 //   System.NotSupportedException:
 //     System.Collections.ArrayList 为只读。 - 或 – System.Collections.ArrayList 具有固定大小。
 public virtual int Add(object value);

可以看出Add方法获取的是一个object参数。也就是说,Add获取对托管堆上的一个对象的引用(或指针)来作为参数。但是point又是值类型的,所以必须转换成真正的在堆中托管的对象。将值类型转换成引用类型称为装箱。那么装箱发生了什么呢?

1.在托管堆分配内存。分配的内存量是值类型各字段所需的内存量,同时还有两个额外的成员(类型对象指针和同步块索引)所需的内存量。

2.值类型的字段复制到新分配的堆内存。

3.返回对象地址。这时值类型就成了引用类型了。

知道了装箱之后,我们再来看下拆箱是怎么运行了:

拆箱不是直接将装箱过程倒过来,它其实就是获取指针的过程,该指针指向包含在一个对象中的原始值类型,然后再进行字段的复制。所以拆箱的代价比装箱低得多

那么已装箱值类型实例在拆箱时,内部发生下面这些事情:

1.如果包含“对已装箱值类型实例的引用”的变量为null,抛出NullReferenceException异常

2.如果引用的对象不是所需值类型的已装箱实例,抛出InvalidCastException异常。

用代码来看看装箱和拆箱的例子

static void Main()
 {
            int val = 5;    //创建未装箱值类型变量
            object obj = val;   //val进行装箱
            val = 123;          //将val值改为123
            Console.WriteLine(val + "," + (int)obj);    //显示123,5
 }

大家能从上面代码看出有多少次装箱和拆箱吗?利用ILDasm查看本代码的IL就能很清楚看出来:

.method private hidebysig static void  Main() cil managed
{
  .entrypoint
  // 代码大小       47 (0x2f)
  .maxstack  3
  .locals init ([0] int32 val,
           [1] object obj)
  IL_0000:  nop
  //将5加载到val中
  IL_0001:  ldc.i4.5
  IL_0002:  stloc.0
  //将val进行装箱,将引用指针存储到obj中
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  //将123加载到val中
  IL_000a:  ldc.i4.s   123
  IL_000c:  stloc.0
  //将val进行装箱,将指针保留在栈上以进行Concat操作
  IL_000d:  ldloc.0
  IL_000e:  box        [mscorlib]System.Int32
  //将字符串加载到栈上
  IL_0013:  ldstr      ","
  //对obj进行拆箱,获取一个指针,指向栈上的Int32字段
  IL_0018:  ldloc.1
  IL_0019:  unbox.any  [mscorlib]System.Int32
  IL_001e:  box        [mscorlib]System.Int32
  //调用Concat方法
  IL_0023:  call       string [mscorlib]System.String::Concat(object,
                                                              object,
                                                              object)
  //返回字符串
  IL_0028:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_002d:  nop
  //从main返回,终止引用程序
  IL_002e:  ret
} // end of method Program::Main

上面的IL显示出三个box和一个unbox,第一次装箱很明显就能看出来,但是第二三次装箱可能有些同学会无法理解,在调用writeline这个方法时返回一个String对象,所以c#编译器生成代码来调用String的静态方法Concat。该方法有几个重载版本,而在这里调用了一下版本:

public static string Concat(object arg0, object arg1, object arg2);

所以在代码中val和转成Int32的obj都会装箱然后传给Concat中。大家有兴趣可以把上面的代码改下,输入结构代码变成Console.WriteLine(val + "," + obj); 然后再看看IL代码,你会发现减少了一次装箱和拆箱而且代码的大小减少了10字节左右。所以证明额外的装箱拆箱会在托管堆分配一个额外的对象,然后还要进行垃圾回收,可见过多的装箱操作会影响到程序的性能和内存的消耗。所以我们尽可能地在自己的代码中减少装箱。

如果知道自己的代码在编译器中会反复发生装箱,那最好是用手动方式对其进行装箱,例如:

static void Main()
{
            int val=5;
            //会进行3次装箱
            Console.WriteLine("{0}{1}{2}",val,val,val);

            //手动进行装箱
            object obj=val;
            //不会发生装箱
           Console.WriteLine("{0}{1}{2}",obj,obj,obj);
}

以上罗列出了基元类型、引用类型和值类型的区别,最后加了装箱和拆箱的,文码并茂,希望能给你带来比较深刻的印象,虽然不够深,但愿能够起到抛砖引玉的作用。本文参考了CLR via C#,很推荐大家有空可以阅读此书。以后我还会在这个系列中多写些文章,分享给大家。

你可能感兴趣的:(基元类型、引用类型、值类型、装箱和拆箱)