一看就懂:图解C#中的值类型、引用类型、栈、堆、ref、out

C# 的类型系统可分为两种类型,一是值类型,一是引用类型,这个每个C#程序员都了解。还有托管堆,栈,ref,out等等概念也是每个C#程序员都会接触到的概念,也是C#程序员面试经常考到的知识,随便搜搜也有无数的文章讲解相关的概念,貌似没写一篇值类型,引用类型相关博客的不是好的C#程序员。我也凑个热闹,试图彻底讲明白相关的概念。

程序执行的原理

要彻底搞明白那一堆概念及其它们之间的关系似乎并不是一件容易的事,这是因为大部分C#程序员并不了解托管堆(简称“堆”)和线程栈(简称“栈”),或者知道它们,但了解得并不深入,只知道:引用类型保存在托管堆里,而值类型“通常”保存在栈里。要搞明白那一堆概念的关系,我认为先要明白程序执行的基本原理,从而理解栈和托管堆的作用,才能理清它们的关系。考虑下面代码,Main调用Method1,Method1调用Method2:

代码如下:

class Program
{
    static void Main(string[] args)
    {
        var num = 120;
        Method1(num);
    }
 
    static void Method1(int num)
    {
        var num2 = num + 250;
        Method2(num2);
        Console.WriteLine(num);
    }
 
    static void Method2(int i)
    {
        Console.WriteLine(i);
    }
}

大家都知道Windows程序通常是多个线程的,这里不考虑多线程的问题。程序由Main方法进入开始执行,这时这个(主)线程会分配得到一个1M大小的只属于它自己的线程栈。这1M的的栈空间用于向方法传递参数,定义局部变量。所以在Main方法进入Method1前,大家心理面要有一个”内存图“:把num压入线程栈,如下图:

接着把num作为参数传入Method1方法,同样在Method1内定义一个局部变量num2,调用加方法得到最后的值,所以在进入Method2前,“内存图”如下,num是参数,num2是局部变量

接着调用Method2的过程雷同,然后退出Method2方法,回到上图的样子,再退出Method1方法,再回到第一副图的样子,然后退出程序,整个过程如下图:

所以去除那些if,for,多线程等等概念,只保留对象内存分配相关概念的话,程序的执行可以简单总结为如下:

程序由Main方法进入执行,并不断重复着“定义局部变量,调用方法(可能会传参),从方法返回”,最后从Main方法退出。在程序执行过程中,不断压入参数和局部变量到线程栈里,也不断的出栈。

注意,其实压入栈的还有方法的返回地址等,这里忽略了。

引用类型和堆

上面的例子我只用了一种简单的int值类型,目的是为了只关注线程栈的压栈(生长)和出栈(消亡)。很明显C#还有种引用类型,引入引用类型,再考虑上面的问题,看下面代码:

代码如下:

static void Main(string[] args)
{
    var user = new User { Age = 15 };
    var num = 23;
    Console.WriteLine(user.Age);
    Console.WriteLine(num);
}
 
class User
{
    public int Age;
}

我想很多人都应该知道,这时应该引入托管堆的概念了,但这里我想跟上面一样,先从栈的角度去考虑问题,所以在调用WriteLine前,“内存图”应该是这样的(地址是乱写的):

这也就是人们常说的:对于引用类型,栈里保存的是指向在堆里的实例对象的地址(指针,引用)。既然只是个地址,那么要获取一个对象的实例应该有一个根据地址或寻找对象的步骤,而事实正是这样,如果Console.WriteLine(num),这样获取栈里的num的值给WriteLine方法算一步的话,要获取上面user的实例对象,在运行时是要分两步的,也就是多了根据地址去寻找托管堆里实例对象的字段或方法的步骤。IL反编译上面的Main方法,删去一些无关代码后:

代码如下:

//load local 0=>获取局部变量0(是一个地址)
IL_0012:  ldloc.0
// load field => 将指定对象中字段的值推送到堆栈上。
IL_0013:  ldfld      int32 CILDemo.Program/User::Age
IL_0018:  call       void [mscorlib]System.Console::WriteLine(int32)

代码如下:

//load local 1=>获取局部变量1(是一个值)
IL_001e:  ldloc.1
IL_001f:  call       void [mscorlib]System.Console::WriteLine(int32)

第二个WriteLine方法前,只需要一个ldloc.1(load local 1)读取局部变量1指令即可获取值给WriteLine,而第一个WriteLine前需要两条指令完成这个任务,就是上面说的分两步。

当然,大家都知道对我们来说,这是透明的,所以很多人喜欢画这样的图去帮助理解,毕竟,我们是感觉不到那个0x0612ecb4地址存在的。

也有一种说法就是,引用类型分两段存储,一是在托管堆里的值(实例对象),二是持有它的引用的变量。对于局部变量(参数)来说,这个引用就在栈里,而作为类型的字段变量的话,引用会跟随这个对象。

字段和局部变量(参数)

上面图的托管堆,大家应该看到,作为值类型的Age的值是保存在托管堆里的,并不是保存在栈里,这也是很多C#新手所犯的错误:值类型的值都是保存在栈里。

很明显他们不知道这个结论是在我们上面讨论程序运行原理时,局部变量(参数)压栈和出栈时这个特定的场景下的结论。我们要搞清楚,就像上面代码一样,除了可以定义int类型的num这个局部变量存储23这个值外,我们还可以在一个类型里定义一个int类型Age字段成员来存储一个整形数字,这时这个Age很明显不是储存在栈,所以结论应该是:值类型的值是在它声明的位置存储的。即局部变量(参数)的值会在栈里,作为类型成员的话,会跟随对象。

当然,引用类型的值(实例对象)总是在托管堆里,这个结论是正确的。

ref和out

C#有值类型和引用类型的区别,再有传参时有ref和out这两个关键字使得人们对相关概念的理解更加模糊。要理解这个问题,还是要从栈的角度去理解。我们分四种情况讨论:正常传递值类型,正常传递引用类型,ref(out)传递值类型,ref(out)传递引用类型。

注意,对于运行时来说,ref和out是一样,它们的区别是C#编译器对它们的区别,ref要求初始化好,out没有要求。因为out没有要求初始化,所以被调用的方法不能读取out参数,且方法返回前必须赋值。

正常传递值类型

代码如下:

static void Main(string[] args)
{
    var num = 120;
    Method1(num);
    Console.WriteLine(num);//输出=>120
}
 
static void Method1(int num)
{
    Console.WriteLine(num);
    num = 180;
}

这种场景大家都熟悉,Method1的那句赋值是不起作用的,如果要画图的话,也跟上面第二幅图类似:

也就是说传参是把栈里的值复制到Method1的num参数,Method1操作的是自己的参数,对Main的局部变量完全没有影响,即影响不到属于Main方法的栈里的数据。

正常传递引用类型

代码如下:

static void Main(string[] args)
{
    var user = new User();
    user.Age = 15;
    Method2(user);
    Debug.Assert(user != null);
    Console.WriteLine(user.Age);//输出=> 18
}
 
static void Method2(User user)
{
    user.Age = 18;
    user = null;
}

留意这里的Method2的代码,把Age设为18,影响到了Main方法的user,而把user设为null却没有影响。要分析这个问题,还是要先从栈的角度去看,栈图如下(地址乱写):

看到第二幅图,大家应该大概明白了这个事实:无论值类型也好,引用类型也好,正常传参都是把栈里的值复制给参数,从栈的角度看的话,C#默认是按值传参的。

既然都是“按值传参”,那么引用类型为什么表现出可以影响到调用方法的局部变量这个跟值类型不同的表现呢?仔细想想也不难发现,这个不同的表现不是由传参方式不同引起的,而是值类型和引用类型的局部变量(参数)在内存的存储不同引起的。对于Main方法的局部变量user和Method2的参数user在栈里是各自储存的,栈里的数据(地址,指针,引用)互不影响,但它们都指向同一个在托管堆里的实例对象,而user.Age = 18这一句操作的正是对托管堆里的实例对象的操作,而不是栈里的数据(地址,指针,引用)。num = 180操作的是栈里的数据,而user.Age = 18却是托管堆,就是这样造成了不同的表现。

对于user = null这一句不会响应Main的局部变量,看了第三幅图应该也很容易明白,user = null跟user.Age = 18不一样,user = null是把栈里的数据(地址,指针,引用)设空,所以并不会影响Main的user。

这里再补充一下,对引用类型来说,var user = null,var user = new User(),user1 = user2都会影响栈里的数据(地址,指针,引用),第一个会设null,第二个会得到一个新的数据(地址,指针,引用),第三个跟上面传参一样,都是栈数据复制。

ref(out)传递值类型

代码如下:

static void Main(string[] args)
{
    var num = 10;
    Method1(num);
    Console.WriteLine(num);//输出=> 10
    Method3(ref num);
    Console.WriteLine(num);//输出=> 28
}
 
static void Method1(int num)
{
    Console.WriteLine(num);
    num = 18;
}
 
static void Method3(ref int num)
{
    Console.WriteLine(num);
    num = 28;
}

代码很简单,而且输出应该都很清楚,没有难度。ref的使用看似简单平常,背后其实是C#为我们做了大部分工作。画图的话,“栈图”如下(地址乱写):

看到这图,不少人应该迷惑了,Method3的参数明明写的是int类型的num,怎么在栈里却是一个指针(地址,引用)呢?这其实C#“欺骗”了我们,IL反编译看看:

可以看到,加了ref(out)的Method3编译出来的方法参数是不一样,再来看看方法里对参数取值的IL代码:

代码如下:

//这是Method1的代码
//load arg 0=>读取索引0的参数,直接就是一个值
IL_0001:  ldarg.0
 
//这是Method3的代码
//load arg 0=>读取索引0的参数,这是一个地址
IL_0001:  ldarg.0
//将位于上面地址处的 int32 值作为 int32 加载到堆栈上。
IL_0002:  ldind.i4

可以看到,同样是获取参数值给WriteLine,Method1只需一个指令,而Method3则需要2个,即多了一个根据地址去寻值的步骤。不难想到,赋值也有同样的区别:

代码如下:

//Method1
//把18放入栈中
IL_0008:  ldc.i4.s   18
//store arg=> 把值赋给参数变量num
IL_000a:  starg.s    num
 
//Method3
//load arg 0=>读取索引0的参数,这是一个地址
IL_0009:  ldarg.0
//把28放入栈中
IL_000a:  ldc.i4.s   28
//在给定的地址存储 int32 值。
IL_000c:  stind.i4

没错,虽然同样是num = 5这样一个对参数的赋值语句,有没有ref(out)关键字,实际上运行时发生的事情是不一样的。有ref(out)的方法跟上面取值一样有给定地址然后去操作(这里是赋值)的指令。

看到这里大家应该明白,给参数加了ref(out)后,参数才是引用传递,这时传递的是栈地址(指针,引用),否则就是正常的值传递--栈数据复制。

ref(out)传递引用类型

加了ref(out)的引用类型的参数有什么奥秘,这个留给大家去思考。可以肯定的是,还是从栈的角度去考虑的话,跟值类型是没有区别的,都是传递栈地址。

我个人认为,貌似给引用类型加ref(out)没什么用处。恶魔

总结

在考虑这一大堆概念问题时,我们首先要搞明白程序执行的基本原理,只不过是栈的生长和消亡的过程。明白这个过程后,要学会“从栈的角度”去思考问题,那么很多事情将会迎刃而解。为什么叫“值”类型和“引用”类型呢?其实这个“值”和“引用”是从栈的角度去考虑的,在栈里,值类型的数据就是值,引用类型在栈里只是一个地址(指针,引用)。还要注意到,变量除了可以是一个局部变量(参数)外,还可以作为一个类型的字段成员存在。知道这些后,“值类型的对象是存储在那里?”这些问题应该就一清二楚了。最后就是明白C#默认是按值传参的,也就是把栈里的数据赋值给参数,这跟在同一个方法内把一个变量赋值给同一类型的另一个变量是一样的,而加了ref(out)为什么这个神奇,其实是C#背后做了更多的事情,编译成不同的IL代码了。

(0)

相关推荐

  • 详解C#中三个关键字params,Ref,out

    关于这三个关键字之前可以研究一下原本的一些操作 using System; using System.Collections.Generic; using System.Text; namespace ParamsRefOut { class Program { static void ChangeValue(int i) { i=5; Console.WriteLine("The ChangeValue method changed the value "+i.ToString())

  • C#中按引用传递与按值传递的区别,以及ref与out关键字的用法详解

    复制代码 代码如下: /给三个整数从小到大排序并求和及其平均值//其中,三个待求整数及其排序的结果由引用参数传递:其和由输出参数传递:平均值由返回值返回.//在Main()方法中实现三个待求整数的输入及结果的输出//目的:定义方法:调用方法::理解形参和实参的引用传递关系:熟悉引用参数和输出参数的使用.using System;class Class1 {   //x,y,z是形参,按值传递   static void Sort(int x, int y, int z)    {      in

  • C#编程中使用ref和out关键字来传递数组对象的用法

    在 C# 中,数组实际上是对象,而不只是像 C 和 C++ 中那样的可寻址连续内存区域. Array 是所有数组类型的抽象基类型. 可以使用 Array 具有的属性以及其他类成员. 这种用法的一个示例是使用 Length 属性来获取数组的长度. 下面的代码将 numbers 数组的长度(为 5)赋给名为 lengthOfNumbers 的变量: int[] numbers = { 1, 2, 3, 4, 5 }; int lengthOfNumbers = numbers.Length; Arr

  • 解析C#中的ref和out参数

    很多初学者(甚至是工作一定时间的开发人员),在遇到ref或者out参数时,总会有点"晕乎乎"或者疑惑,也不知道到底该在啥时候,啥场景下使用ref或者out参数. 本文将通过实例和说明,给大家详细讲解C#中的ref和out参数. 复制代码 代码如下: using System;using System.Collections.Generic;using System.Linq;using System.Text; namespace RefAndOut{    class Program

  • asp.net(c#)ref,out ,params的区别

    NO.1 params 一个可以让方法(函数)的拥有可变参数的关键字. 原则:在方法声明中的 params 关键字之后不允许任何其他参数,并且在方法声明中只允许一个 params 关键字. 示例(拷贝到vs2005中即可用,下面不再说明) 复制代码 代码如下: public partial class Form1 : Form { public static void UseParams(params int[] list) { string temp = ""; for (int i

  • c#方法中调用参数的值传递方式和引用传递方式以及ref与out的区别深入解析

    复制代码 代码如下: #define Test using System; namespace Wrox.ProCSharp.ParameterTestSample...{ class ParemeterTest ...{    static void TestInt(int[] ints,int i)    ...{        ints[0] = 100;        i = 100;    } static void TestInt(int[] ints, ref int i)    

  • C#中ref和out的区别浅析

    在C#中通过使用方法来获取返回值时,通常只能得到一个返回值.因此,当一个方法需要返回多个值的时候,就需要用到ref和out,那么这两个方法区别在哪儿呢? MSDN:        ref 关键字使参数按引用传递.其效果是,当控制权传递回调用方法时,在方法中对参数所做的任何更改都将反映在该变量中.若要使用 ref 参数,则方法定义和调用方法都必须显式使用 ref 关键字.       out 关键字会导致参数通过引用来传递.这与 ref 关键字类似,不同之处在于 ref 要求变量必须在传递之前进行

  • C#中out与ref的区别实例解析

    本文实例讲述了C#中Out与Ref的区别,可以加深C#程序设计人员对Out和Ref用法的理解,具体分析如下: 一.区别分析: Out和Ref作为参数传递到方法体中,所传递的都是引用地址,两者在操作上本身没有区别. 但Out传递到方法体时,参数会清空,这意味着在方法体内使用Out参数前必须赋值. 而Ref传递到方法体时,其参数也是一起被传递进来,所以作为Ref参数传递,方法体中可以不对其参数赋值. 二.实例代码如下: class Program { /*ref是有进有出,out是只出不进*/ st

  • C#方法中参数ref和out详解

    一.C#方法中参数类型 有4种参数类型,有时候很难记住它们的不同特征,下图对它们做一个总结,使之更容易比较和对照. 二.C#方法中的参数 1.值参数 使用值参数,通过复制实参的值到形参的方式把数据传递到方法.方法调用时,系统做如下操作: · 在栈中为形参分配空间 · 复制实参到形参 注:栈(先进后出)是编译期间就分配好的内存空间,因此你的代码中必须就栈的大小有明确的定义: 堆(队列优先,先进先出)是程序运行期间动态分配的内存空间,你可以根据程序的运行情况确定要分配的堆内存的大小. /// <su

  • 详解C#中的out和ref

    要想充分理解C# out和ref,必须先明确如下两个概念(对值类型与引用类型掌握比较好的,可以跳过"一.明确两个基本概念") 一.明确两个基本概念 值类型: 定义:通过值的方式来传递,即实际参数向形式参数传递(关于形参和实参术语,这里不定义). 存储方式:主要在栈中. 本质:通过值传递,copy副本形式,调用栈的Pop()和Push()方法来实现. 常见类型:int,float,bool,enum,struct,Array等. 值类型例子: //主函数 static void Main

随机推荐