值类型与引用类型

值类型与引用类型

 

前言

  最近看了很多关于值类型与引用类型的文章,涵盖了很多零零散散以及不容易让人理解的知识,因此,将这些知识整理归纳一下,便于日后复习。

  文章的目录结构:

  1. 概念
  2. 复制方式
  3. 参数传递

概念

  C# 中定义了许多数据类型,它们被分为两大类,一类是:值类型,另一类是:引用类型。

  下面这张图具体的值类型与引用类型的划分:

Category   Description
    Value types         Simple types      Signed integral:sbyte,short,int ,long
Unsigned integral:byte,ushort,uint,ulong
Unicode characters:char
IEEE floating point:float,double
High-precision decimal:decimal
Boolean:bool
Enum types User-defined types of the form enum E {...}
Struct types User-defined types of the form struct S {...}
Nullable types Extensions of all other value types with a null value
    Reference types      Class types   Ultimate base class of all other types:object
Unicode strings:string
User-defined types of the form class C {...}
Interface types User-defined typs of the form interface I {...}
Array types Single-and multi-dimensional,for example,int[] and int[,]
Delegate types Uesr-defined types of the form  e.g. delegate int D {...}

  值类型继承自 System.ValueType,System.ValueType 又隐式继承自 System.Object; 引用类型直接继承自 System.Object; 具体地信息可以使用 Reflector 查看,这里以 ValueType 和 String 为例:

 

  

区别

  值类型与引用类型的区别来源于它们的复制方式:值类型的数据总是按值复制;而引用类型的数据总是按引用复制。注意,这里是复制方式,后面会进行解释。

  另一个差异是:值类型不能被继承。因此,值类型不需要额外的信息来描述值实际是社么类型;对于引用类型来说,每个对象的开头都包含一个数据块,它标识了对象的实际类型,还可能提供了一些别的信息。

复制方式

  值类型直接包含值,或者说是,变量引用的位置就是值在内存中实际存储的位置。引用类型并不直接存储值,变量中存储的是对内存中对真实值的引用(即真实值所在的地址),根据这个引用(或地址)才能访问到真实值。

比如,我们在Main()中定义这样一段的代码:

            int number = 12;
            int anotherNumber = number;

            Console.WriteLine("number:{0}, anotherNumber:{1}",number,anotherNumber);

            // 将number的值赋为 13,
            // 看anotherNumber的值是否发生更改?
            number = 13;
            Console.WriteLine("number:{0}, anotherNumber:{1}", number, anotherNumber);

            // 将anotherNumber的值赋为 14,
            // 看number的值是否发生更改?
            anotherNumber = 14;
            Console.WriteLine("number:{0}, anotherNumber:{1}", number, anotherNumber);

运行这段代码,你会看到更改number的值后,并不会对anotherNumber的值产生影响,同理,反之亦是如此。
这就验证了上面说的那句话,值类型的数据总是按值复制。

下面我们用图来演示一下前两行代码在内存中的存储的形式:

从图中可以看出:将number赋值给anotherNumber,其实会anotherNumber的位置上创建一个number值的副本,因此当你对anotherNmber的值进行改变时,不会影响到原值(即number的值),因为你改变的是一个副本。同理,反之亦是如此。

这次,我们先看用图来演示引用类型在内存中是怎么复制的(注:以下图中内存地址并不代表真实的地址,只是为了演示):

如上图所示,引用类型的变量中保存的是指向堆上真实值存储的地址,而真正的值存储在堆上。当把str赋值给anotherStr时,复制的是引用,anotherStr与str事实上是指向同一个地址。

 现在,我们再来看对应这张图的演示代码:

            string str = "One";
            string anotherStr = str;
            Console.WriteLine(object.ReferenceEquals(str,anotherStr));  // True
            Console.WriteLine("str:{0}, anotherStr:{1}",str,anotherStr);

            // 将anotherStr的值赋为 Two,
            // 看str的值是否发生更改?
            anotherStr = "Two";
            Console.WriteLine(object.ReferenceEquals(str,anotherStr));  // False
            Console.WriteLine("str:{0}, anotherStr:{1}", str, anotherStr);

先别急着去看运行结果,先思考一下代码的运行结果是什么?当我们给anotherStr重新赋值时,理论上str的值也会改变,因为它们指向了同一个引用,但事实并非如此,str的值仍然为 One,很奇怪是不是。。。其实导致这样的结果是由于string类型是一个特殊的类型,它是不可变的,意思就是当你定义好了一个string变量时,并对它赋值之后,如果你再去修改这个变量的值(即对它重新赋值),此时会在堆上创建一个新的值,并把这个新值的引用返回给这个变量,这样,anotherStr变量就指向了这个新值所在的地址。这里就不会导致str的引用发生改变。

改变anotherStr值后的图应该是这样的:

对比上面的那张图,string类型的不可变性就很容易理解了。

由于 string 类型的特殊性,并不适合直观的表现出我们此处要说明的问题。因此,我们采用另一个类型Class来更直观的阐述按引用类型引用复制的概念。

看下面这段代码,我们先定义个Point类,如下:

    class Point
    {
        public int XPos { get; set; }
        public int YPos { get; set; }

        public Point(int xPos,int yPos)
        {
            XPos = xPos;
            YPos = yPos;
        }

        public override string ToString()
        {
            return string.Format("XPos: {0}, YPos: {1}",XPos,YPos);
        }
    }

接着,将更改Main()中的代码:

         Point p1 = new Point(12, 23);
         Point p2 = p1;
         Console.WriteLine(p1);
         Console.WriteLine(p2);

         // 更改 p2.XPos 的值
         p2.XPos = 20;
         Console.WriteLine(p1);
         Console.WriteLine(p2);

运行以上代码,你就会发现p2.XPos的值被重新赋为20后,p1.XPos的值也发生了改变,因为它们指向托管堆上同一块内存地址。同时,证明了引用类型按引用复制这句话。这里,如果把 XPos 和 YPos 的类型更改为 string ,得到的结果仍然是同 int 相同,可见,在类中 string ,被作为局部对象来操作时,并不具有不变性,想了解更多,点击此处。为了更高效的操作字符串,通常我们会使用 StringBuilder。
其实这里还有一个值得思考的问题是,如果我们把这个Person 类定义到一个结构体中(struct),接着对结构体进行类似上面的赋值操作,会复制值还是会复制引用呢?这个问题有点偏离了主题,如果你感兴趣的话,可以点这里--深、浅复制

现在对以上知识做个回顾,你会发现上面的示例中值类型的值存储在栈上,而引用类型的值存储在托管堆上,栈上存储的是指向托管堆的地址。那么是否就可以认为是:值类型与引用类型的区别就是值类型的值存储在栈上,引用类型的值存储在托管堆上?事实上,这种说法并不严谨,或者说并不完全正确。注意,在区别这一节中我们所用来区别值类型与引用类型是根据复制方式,为什么?其实是因为,变量的值是在它声明的地方存储的; 如果我们把一个值类型声明在一个引用类型中(如:类中),那么这个值类型也许就存储在托管堆上跟这个类的某个对象存储在一起。因为,只有对象中的局部变量和方法参数才会存储在栈上,而成员变量依然会跟对象存储在一起。[Note:局部变量是指定义在方法中的变量,成员变量是指定义在如类或结构中的变量。][事实上,局部变量也并非全部存储在栈上,在更高版本的C#中可能会存储在堆上,因为语言规范中,并没有对什么东西应该存放在什么地方做出硬性的规定。]这也解释了为什么我们用复制方式来区别值类型与引用类型,而不是 存储的位置。

参数传递

 参数默认是以按“值”传递的,注意,这里的“值”是指变量中存储的值,分以下两种情况,当所要传递的类型为值类型时,此时变量中存储的值为真实值,那么参数传递的就是值本身;当所要传递的类型为引用类型时,此时变量中存储的值为地址(及对托管堆上的引用),那么参数传递的就是这个地址(即引用)。除了默认的按值传递,另一种就是按引用传递(使用ref或out修饰符)。下面我们会分别讲解这两种传递方式的具体不同之处:

再讨论具体的传递参数之前,我们还是先来了解一下ref和out,顺便附带一下params修饰符。

参数修饰符 作用
(无参数修饰符)

如果一个参数没有用参数修饰符标记,则认为它将按值传递(pass by value),

这意味着被调用的方法收到原始数据的一份副本

out

输出参数由被调用的方法赋值,因此它按引用传递(pass by reference)。

如果被调用的方法没有给输出参数赋值,就会出现编译器错误

params

这个参数修饰符允许将一组可变数量的参数作为单独的逻辑参数进行传递。

方法只能有一个params修饰符,而且必须是方法的最后一个参数

ref

调用者赋初值,并且可以由被调用的方法可选地重新赋值(因为数据是按引用传递的)。

如果被调用的方法未能给ref参数赋值,也不会出现编译错误

   

  值类型的传递方式

我们先定义一个值类型的结构体:

    struct Person
    {
        public string personName { get; set; }
        public int personAge { get; set; }

        public Person(string name, int age)
            : this()
        {
            personName = name;
            personAge = age;
        }

        public override string ToString()
        {
            return string.Format("Name: {0}, Age: {1}", personName, personAge);
        }
    }

然后定义按值传递和按引用传递的方法:

        static void SendByValue(Person p)
        {
            p.personAge = 22;
            p.personName = "Red";

            p = new Person("green", 23);  //注意这句代码
        }

        static void SendByReference(ref Person p)
        {
            p = new Person("green", 23);  //注意这句代码
            p.personAge = 22;
        }

最后修改Main()方法:

        static void Main(string[] args)
        {
            Person blue = new Person("Blue", 21);
            Console.WriteLine(blue);

            SendByValue(blue);  // 按值传递
//SendByReference(ref blue); // 按引用传递 Console.WriteLine(blue); }

首先运行程序,查看结果,接着,注释SendByValue(red);这行代码,并取消注释SendByReference(ref red)这行代码,对比两次运行结果的差异。【自行测试】

    1、按值传递时:SendByValue()方法中对对象p所做的更改,不会对blue对象产生任何影响,因为按值传递默认会产生值的一个副本,这里会产生一个blue的副本并将其赋值给p对象,因此在方法内对p做的任何修改都是对这个副本的修改,不会导致blue的变化。

    2、按引用传递时:SendByReference()方法中所做的任何更改都会导致blue对象的更改,因为此时是将指向blue对象的引用传递给p对象,因此p对象无论是对对象本身(代码:p = new Person("green", 23);)还是对对象中成员变量的更改都会导致blue对象的更改。

  引用类型的传递方式

只需将上面的结构体类型更改为类类型,如下:

    class Person
    {
        public string personName { get; set; }
        public int personAge { get; set; }

        public Person() { }
        public Person(string name, int age)
        {
            personName = name;
            personAge = age;
        }

        public override string ToString()
        {
            return string.Format("Name: {0}, Age: {1}", personName, personAge);
        }
    }

其余代码同上,首先运行程序,查看结果,接着,注释SendByValue(red);这行代码,并取消注释SendByReference(ref red)这行代码,对比两次运行结果的差异。【自行测试】

    1、按值传递时:即 p.personAge = 22;和 p.personName = "Red";这两行代码导致了blue对象的更改,而p = new Person("green", 23);这行代码未起到任何作用,这是为什么呢?其实原因并不复杂,SendByValue(Person p)方法在调用时在栈上创建了一个新的变量p,而这个p的指向与blue的指向相同,因为引用类型按值传递其实传递的是变量中值(本质上还是引用,因为变量中存放的是引用(即地址)),因此,p与blue指向相同的对象,所以无论是p或blue对象对对象中的任何值更改都会导致另一个对象的引用发生改变。而p = new Person("green", 23);这行代码改变的是其引用本身,即p指向一个新的对象,当然不会对blue有任何影响。

    2、按引用传递,了解上面的按值传递的原则,那么按引用传递就变得很容易了,此时传递的是指向blue对象的引用,这样就会导致p实际上指向的是对象blue所在的地址,因此无论你对p本身还是p中的成员变量做更改,都会在blue对象上体现,因为p指向blue对象所在的内存地址。

posted @ 2012-11-25 00:02  KANLEI  阅读(432)  评论(0编辑  收藏  举报