[C#] 类型学习笔记一:CLR中的类型,装箱和拆箱

在学习.NET的时候,因为一些疑问,让我打算把.NET的类型篇做一个总结。总结以三篇博文的形式呈现。

这篇博文,作为三篇博文的第一篇,主要探讨了.NET Framework中的基本类型,以及这些类型一些重要的特性。

第二篇中,我会探讨.NET 是如何实现两个对象的比较的,其中会用到第一篇中的基础和结论。

第三篇,我从CLR中的常用容器出发,来探讨泛型以及它们背后的数据结构。

 

下面,我们从类型说起。

Primitive, Reference and Value

首先将这三种类型放在一起是不科学的。

Primitive Type(基元类型)只是针对一种语言的,C#的基元类型有int,string等等,VB的基元类型有Integer等等。我们知道C#只是基于FCL的一种语言,基于C#写出的程序会被支持IL的编译器编译为CIL语言。

Reference Type(引用类型)和 Value Type(值类型)才是.NET Framework Class Library(FCL)中的两种类型,FCL中的任何实例,值,都是这两种类型的一种。

 

下面具体介绍下这些类型:

基元类型 Primitive Type:

C#中的基元类型有十几个:int, string, short, byte 等等。每一个基元类型,必然对应着FCL中的一种类型,比如int对应System.Int32,string对应System.String。因此,基元类型可以看作FCL中对应类型的一个别名。

int  a = 0;  // Most convenient syntax 
System.Int32 a = 0;  // Convenient syntax 
int  a = new int();  // Inconvenient syntax 
System.Int32 a = new System.Int32(); // Most inconvenient syntax

以上四句代码都正确,而且本质是一样的。

下面列举了C#中基元类型,被编译成CIL后的类型,以及对应的.NET Framework type类型。

C# type    CIL type   .NET Framework type
============================================
short      int16     System.Int16
int        int32      System.Int32
long       int64               System.Int64

float       float32      System.Single
double      float64     System.Double

因此,那些由编译器直接支持,将语言本身的关键字类型转换为.NET Framework类型的,就叫做基元类型。

http://www.cnblogs.com/JimmyZhang/archive/2012/11/27/2790759.html#!comments

因此基元类型本质上就是特定语言中的一些别名,它们在.NET Framework中都有对应的类型。

如果查阅VB的资料的话,可以发现Int32在VB中的别名是Integer。Integer是VB的基元类型之一。

因为只有System.Int32, System.String等这些比较基本的类型才在各个语言中有别名,因此这些别名被称为基元类型。

 

基元类型的作用,在我看来,应该是为了程序书写的方便。C#中我们可以使用int 而不是冗长的 System.Int32来定义一个整数,虽然编译后的IL代码都一样。

但是Jeffrey Richter在他的《CL via C#》中,推荐我们使用FCL类型,最主要的原因在于避免混淆,比如:
在C#中,int映射到System.32Int,表示一个32位整数。但是在64位机器上,它表示多少位的整数呢?如果我们知道int实际上是Int32的别名,我们就会知道int在64位机器上依然被编译起认作32位整数。但如果没有了解这一点的人会误认为C# 的int 在64位机器上会表示64位整型。

为了避免引起误会,Jeffrey Richter建议我们取消使用别名的习惯。

引用类型Reference Type 和 值类型 Value Type

在FCL中,所有类型都派生自System.Object,包括抽象类System.ValueType。

这些类型虽然都派生自System.Object,但是被分为两类:引用类型和值类型。

 

Value和Reference这两种类型的区别:

1. 最显著的区别:Value类型的实例分配在栈空间上,Reference类型的实例被分配在堆空间上。

这个区别后果就是:值类型的实例不受垃圾回收机制的控制,而垃圾回收是需要消耗时间的,从而节省了时间;而且由于直接申明在栈区,比起在堆区的申明要节省空间和时间(Reference类型的实例话需要先在栈区定义指针,然后再堆区分配内存,费时费空间,想想如果一个程序中所有的Int32都需要到堆区分配空间,所带来的时间消耗将显著提高)。

2. 值类型不能被继承,也不能继承自其他类型。比如说,我们定义一个struct Car,这个Car类型在定义时就不能包含virtual方法,并且所有方法都被隐式地指明为sealed。

3. 值类型的赋值,本质上是逐个field的拷贝,而引用类型的赋值,则仅仅是栈上指针地址的拷贝,结果是两个指针指向同一块托管堆内存。

对于这一点,常常会被放到面试题或笔试题中,比如如下代码,常常会问r1.x, v1.x, r2.x, v2.x的输出结果是什么。

// Reference type (because of 'class')
      private class SomeRef { public Int32 x; }

      // Value type (because of 'struct')
      private struct SomeVal { public Int32 x; }

      public static void Go() {
         SomeRef r1 = new SomeRef();   // Allocated in heap
         SomeVal v1 = new SomeVal();   // Allocated on stack
         r1.x = 5;                     // Pointer dereference
         v1.x = 5;                     // Changed on stack
         Console.WriteLine(r1.x);      // Displays "5"
         Console.WriteLine(v1.x);      // Also displays "5"
         // The left side of Figure 5-2 reflects the situation
         // after the lines above have executed.

         SomeRef r2 = r1;              // Copies reference (pointer) only
         SomeVal v2 = v1;              // Allocate on stack & copies members
         r1.x = 8;                     // Changes r1.x and r2.x
         v1.x = 9;                     // Changes v1.x, not v2.x
         Console.WriteLine(r1.x);      // Displays "8"
         Console.WriteLine(r2.x);      // Displays "8"
         Console.WriteLine(v1.x);      // Displays "9"
         Console.WriteLine(v2.x);      // Displays "5"
         // The right side of Figure 5-2 reflects the situation 
         // after ALL the lines above have executed. 

 

除此以外,还有一些比较细节的区别:

4. 值类型重写了Equal 方法和GetHashCode 方法

具体来讲,继承自System.ValueType,而System.ValueType继承自System.Object并重写了System.Object的Equal()函数。

5. 值类型在申明时就带有初值,即使不赋值也不会报异常,而引用类型的变量申明后如果不用new来实例化的话,使用这个变量会报NullReferenceException

 

FCL中,值类型有:Boolean, CharDate, 各种数字类型, Enumerations, 和struct 申明的类型。

我们如果查看下关于Int32, Boolean这些值类型的定义:

namespace System
{
    // Summary:
    //     Represents a 32-bit signed integer.
    [Serializable]
    [ComVisible(true)]
    public struct Int32 : IComparable, IFormattable, IConvertible, IComparable<int>, IEquatable<int>
    {
        ......
    }
}
namespace System
{
    // Summary:
    //     Represents a Boolean value.
    [Serializable]
    [ComVisible(true)]
    public struct Boolean : IComparable, IConvertible, IComparable<bool>, IEquatable<bool>
    {
     .....
    }
}

通过上面两段代码,我们可以发现:

(1) System.Int32, System.Boolean这些值类型其实也是通过struct申明的

(2) 值类型可以继承实现借口,但是不能从别的类继承

 

值类型的优势在于:

减少了托管堆的压力,并且由于垃圾回收次数减少,提升系统性能。

值类型使用条件:

1. 类型不需要被继承,也不需要从其他类型继承。

2. 这个类型所包含的任何Field不会被修改。

 

等等,大家有没有发现矛盾之处?

值类型既然不该被继承,也不能继承自其他类型。但是我们在开篇就说,.NET中不论是值类型还是引用类型,都继承自System.Object类,这个类是引用类型。具体点,实际上是 Int32,Enum等这些值类型,都是继承自System.ValueType,而System.ValueType继承自System.Object。

 

矛盾吗?不矛盾,因为我们即将介绍下一节:类型的装箱状态和不装箱状态。

Boxing and Unboxing(装箱和拆箱)

值类型对象有两种形态:unboxed 和 boxed,而引用类型只有boxed的形态。因此, 装箱和拆箱是针对值类型特有的操作。

我们接着上一节的疑问讨论,值类型不能继承或被继承,怎么可以说所有值类型也继承自System.Object?

因为当值类型的数据不得不被当作引用类型对待 (比如说一个函数只接受System.Object作为参数,又比如容器ArrayList只能存放System.Object类型),这个时候,我们的值类型会被“装箱”。装箱后的值类型,和引用类型没有区别,也是在托管堆中有自己的内存,栈中存放的是引用地址。这时候的值类型,继承自万物之源System.Object。

装箱的过程可以被显示的触发,当我们使用如下代码:

Int32 i = 5;
Object o = i;

就会触发Int32类型的 i 被装箱。而下面的代码

Int32 i2 = (Int32)o;

则触发了拆箱。

 

public static void Main() {
         Int32 v = 5;            // Create an unboxed value type variable.
         Object o = v;            // o refers to a boxed Int32 containing 5.
         v = 123;                 // Changes the unboxed value to 123

         Console.WriteLine(v + ", " + (Int32)o);    // Displays "123, 5"
      }

上面的代码中会触发三次装箱:

(1) o = v 一次装箱

(2)Console.WriteLine的输入要求是String,如果不是String,C#编译器会自动调用Concat来创建String,而Concat方法的输入参数是Object类型,因此本身为Int32的v会被装箱。

(3) 而o虽然本身已经被装箱,但是它又被转化成了Int32再被作为Console.WriteLine的参数,也就是说,先被拆箱再被装箱。

 

再将代码做一些变动,如下代码会触发v的装箱吗?

Int32 v = 123; 
Console.WriteLine(
v.ToString());

对于这个问题,我们再次查看Int32的定义:

我们发现Int32提供了ToString()方法,v.ToString()不会造成装箱。CLA via C#中关于这个问题有详细的解答,当时的场景是定义了Point struct,里面重写了ToString()方法,然后定义Point p1,调用p1.ToString(),询问会不会装箱。

Calling ToString In the call to ToString, p1 doesn’t have to be boxed. At first, you’d think that p1 would have to be boxed because ToStringis a virtual method that is inherited from the base type, System.ValueType. Normally, to call a virtual method, the CLR needs to determine the object’s type in order to locate the type’s method table. Since p1 is an unboxed value type, there’s no type object pointer. However, the just-in-time (JIT) compiler sees that Point overrides the ToString method, and it emits code that calls ToStringdirectly (nonvirtually) without having to do any boxing .The compiler knows that polymorphism can’t come into play here since Pointis a value type, and no type can derive from it to provide another implementation of this virtual method .Note that if Point's ToString method internally calls base.ToString(), then the value type instance would be boxed when calling System.ValueType's ToString method.

ToString()是一个virtual方法,可能我我们的第一反应是:v必须被装箱来调用其父类System.ValueType的ToString()。

然而,根据书中的说法,因为Int32显示地通过override关键字重写了ToString()方法(注意,虽然Int32没有继承自任何类),编译器识别出了Override,因此直接调用了ToString()方法而没有去装箱。

 

CLR via C#(3rd version)还给出了别的实例,比如stuct Point实现了接口IComparable,如果把Point 类型的变量转换为Icomparable,会不会引起装箱,结果是会的,因为IComparable接口也是Reference Type。

 

下面还有一段代码,最后一行的结果是关键:

private struct Point : IChangeBoxedPoint {
         private Int32 m_x, m_y;

         public Point(Int32 x, Int32 y) {
            m_x = x;
            m_y = y;
         }

         public void Change(Int32 x, Int32 y) {
            m_x = x; m_y = y;
         }

         public override String ToString() {
            return String.Format("({0}, {1})", m_x, m_y);
         }
      }

      public static void Go() {
         Point p = new Point(1, 1);

         Console.WriteLine(p); //注意点1,p会装箱

         p.Change(2, 2);
         Console.WriteLine(p);

         Object o = p;
         Console.WriteLine(o);

         ((Point)o).Change(3, 3);
         Console.WriteLine(o); //注意点2,输出结果依然为2, 2
      }

 

注释中的第一个注意点,p定义了ToString(),Console.WriteLine(p)依然会导致装箱的原因,我们上面已经提到了,Console.WriteLine()不会自动调用ToString(),而是自动调用Concat生成String,Concat接受Object作为参数。

第二个注意点,((Point)o).Change(3, 3)所造成的效果是: o被拆箱后,被赋值到了栈上的另一个临时Point类型的变量上。因此,是这个临时变量调用了Change(3, 3), o的内容没有改变。

 

相关阅读:

[C#] 类型学习笔记二:两个对象之间的比较

[C#] 类型学习笔记三:自定义值类型

 

posted on 2014-03-17 06:54  Felix Fang  阅读(2915)  评论(0编辑  收藏  举报

导航