当前位置: 澳门新濠3559 > 编程 > 正文

1.1 什么是值类型与引用类型,程序员都会接触到

时间:2019-11-21 05:33来源:编程
在网上收集。。。 在C#中,有两种大类型——值类型和引用类型。   1、值类型与引用类型 C#的值类型,引用类型,栈,堆,ref,out  C# 的类型系统可分为两种类型,一是值类型,一是

在网上收集。。。

在C#中,有两种大类型——值类型和引用类型。

 

1、值类型与引用类型

C#的值类型,引用类型,栈,堆,ref,out 

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

深入的理解这两种类型是非常重要的,面试官会考验面试者对这两个知识的了解来判断基础是否扎实,并且有没有深入的去思考。

程序执行的原理

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

?

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

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方法退出。在程序执行过程中,不断压入参数和局部变量到线程栈里,也不断的出栈。

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

1.1 什么是值类型与引用类型

引用类型和堆

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

?

1

2

3

4

5

6

7

8

9

10

11

12

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方法,删去一些无关代码后:

?

1

2

3

4

5

//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)

?

1

2

3

//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#1.1 什么是值类型与引用类型,程序员都会接触到的概念。编译器对它们的区别,ref要求初始化好,out没有要求。因为out没有要求初始化,所以被调用的方法不能读取out参数,且方法返回前必须赋值。

对于堆栈和托管堆,在前面将IL知识点的时候就已经有提及过的。可以把它们理解为内存中存储数据的两种结构。

正常传递值类型

?

1

2

3

4

5

6

7

8

9

10

11

12

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方法的栈里的数据。

下面的表格列出了C#中的基本类型

正常传递引用类型

?

1

2

3

4

5

6

7

8

9

10

11

12

13

14

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#澳门新濠3559,默认是按值传参的。

既然都是“按值传参”,那么引用类型为什么表现出可以影响到调用方法的局部变量这个跟值类型不同的表现呢?仔细想想也不难发现,这个不同的表现不是由传参方式不同引起的,而是值类型和引用类型的局部变量(参数)在内存的存储不同引起的。对于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)传递值类型

?

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

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代码:

?

1

2

3

4

5

6

7

8

9

//这是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个,即多了一个根据地址去寻值的步骤。不难想到,赋值也有同样的区别:

?

1

2

3

4

5

6

7

8

9

10

11

12

13

//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代码了。

 

简单类型

有符号整数:int、long、short、sbyte

无符号整数:uint、ulong、ushort、byte

字符类型:char

浮点型:float、double和高精度小数类型decimal

布尔类型:bool

枚举类型

枚举类型:enum

结构体类型

结构体类型:struct

引用类型

类类型

字符串类型:string

类类型:Console类和自己自定义的类类型

数组类型

一维数组和多维数据,如int[]与int[,](二维数组)

接口类型

由interface关键字定义的类型

委托类型

由delegate关键字定义的类型

 

1.2 值类型与引用类型的区别

这是面试中的重点,面试官经常问到的问题。

它们之间最大的区别在于——不同的内存分布。

值类型通常是被分配到线程的堆栈上的(并不是绝对的),而引用类型则被分配到托管堆上。不同的分配位置导致了不同的管理机制,值类型的管理由操作系统负责,而引用类型的管理则由垃圾回收器(GC)负责。

多说无益,先上个简单的代码说明一下:

class Program

{

  static void Main(string[] args)

  {

    //valuetype是值类型

    int valuetype=3;

    //reftype是引用类型

    string reftype="abc";

  }

}

内存分布如下图所示:

澳门新濠3559 1

值类型与引用类型的区别就在于实际数据的存储位置:值类型的变量和实际数据都存储在堆栈中;而引用类型则只有变量存储在堆栈中,变量存储着实际数据的地址,实际数据存储在与地址相对应的托管堆中。

Warning:

前面讲过了,值类型通常是放在堆栈上,但这并不是绝对的。值类型实例不一定总会被分配到线程栈上。在引用类型中嵌套值类型时,或者在值类型装箱的情况下,值类型的实例就会被分配到托管堆中。

嵌套结果包括值类型中嵌套定义了引用类型和引用类型中定义了值类型两种情况。

1.2.1 引用类型中嵌套定义值类型

如果类的字段类型是值类型,它将作为引用类型实例的一部分,被分配到托管堆中。但那些作为局部变量的值类型,则仍然会被分配到线程堆栈中。

//引用类型嵌套定义值类型的情况

public class NestedValueTypeInRef

{

  //valuetype作为引用类型的一部分被分配到托管堆上

  private int valuetype=3;

  public void method()

  {

    char c='c';//因为是方法中的局部变量,所以还是会存放在堆栈上。

  }

}

class Program

{

  static void Main(string[] args)

  {

    NestedValueTypeInRef reftype=new NestedValueTypeInRef();

  }

}

还是以图来解释:

澳门新濠3559 2

1.2.2 值类型中嵌套定义引用类型

值类型嵌套定义引用类型时,堆栈上将保存该引用类型的引用,而实际的数据则依然保存在托管堆中。

public class TestClass

{

  public int x;

  public int y;

}

//值类型嵌套定义引用类型的情况

public struct NestedRefTypeValue

{

  //结构体字段,注意,结构体的字段不能被初始化

  private TestClass classinValuetype;

  //结构图中的构造函数,注意,结构体中不能定义无参的构造函数

  public NestedRefTypeInValue(TestClass t)

  {

    classinValuetype.x=3;

    classinValuetype.y=5;

    classinValuetype=t;

  }

}

class Program

{

  static void Main(string[] args)

  {

    NestedRefTypeInValue valuetype=new NestedRefTypeInValue(new TestClass());  

  }

}

以上代码中的内存分配情况如下图所示:

澳门新濠3559 3

总结:

从以上两个例子可以总结出:值类型实例总会被分配到它声明的地方,声明的是局部变量,将被分配到栈上,而声明为引用类型成员时,则被分配到托管堆上;而引用类型实例总是分配到托管堆上。

上面值分析了值类型与引用类型在内存分布方面的区别,除此之外,还有以下几个区别:

(1)值类型继承自ValueType,ValueType又继承自System.Object;而引用类型则直接继承于System.Object.

(2)值类型的内存不受GC(垃圾回收器)控制,作用域结束时,值类型会被操作系统自行释放,从而减轻了托管堆的压力;而引用类型的内存管理则由GC来完成。所以与引用类型相比,值类型在性能方面更具优势。

(3)值类型是密封的(sealed),你将不能把值类型作为其他任何类型的基类;而引用类型则一般具有继承性,这里指的是类和接口。

(4)值类型不能为null值,它会被默认初始化为数值0;而引用类型在默认的情况下会被初始化为null值,表示不指向托管堆中的任何地址。对值为null的引用类型的任何操作,都会引发NullReferenceException异常。

(5)由于值类型变量包含其实际数据,因此在默认情况下,值类型之间的参数传递不会影响变量本身;而引用类型变量保存的是数据的引用地址,它们作为参数被传递时,参数会发生改变,从而影响类型变量的值。

 

2、参数传递问题

在默认的情况下,C#方法中的参数传递都是按值进行的,但实际上参数传递的方式共有4种不同的情况,它们分别为:

(1)值类型参数的按值传递

(2)引用类型参数的按值传递

(3)值类型参数的按引用传递

(4)引用类型参数的按引用传递

2.1 值类型参数的按值传递

值类型的按值传递,传递的是该值类型实例的一个副本,也就是说形参接收到的是实参的一个副本,被调用方法操作的是实参的一个副本罢了。

class Program

{

  static void Main(string[] args)

  {

    //1.值类型按值传递

    Console.WriteLine("值类型按值传递的情况");

    int addNum=1;

    Add(addNum);

    Console.WriteLine("调用方法后,实参addNum的值:"+addNum);

    Console.ReadKey();

  }

 

  //1.值类型按值传递的情况

  private static void Add(int addnum)

  {

    addnum=addnum+1;

    Console.WriteLine("方法中addnum的值:"+addnum);  

  }

}

运行结果如下:

澳门新濠3559 4

澳门新濠3559 5

上图从内存的角度说明了值类型参数按值传递的情况。

2.2 引用类型参数的按值传递

当传递的参数是引用类型时,传递和操作的目标是指向对象的地址,而传递的实际内容是对地址的复制。由于地址指向的是实参的值,当方法对地址进行操作时,实际上操作了地址所指向的值,所以调用方法后原来实参的值就会被修改。

class Program
{
  static void Main(string[] args)
  {
    Console.WriteLine("引用类型按值传递的情况");
    RefClass refClass = new RefClass();
    refClass.addNum = 1;
    AddRef(refClass);
    Console.WriteLine("调用方法后,实参addNum的值:"+refClass.addNum);
    Console.ReadKey();
  }

  private static void AddRef(RefClass addnumRef)
  {
    addnumRef.addNum += 1;
    Console.WriteLine("方法中addNum的值:"+addnumRef.addNum);
  }
}

public class RefClass
{
  public int addNum;
}

结果为:

澳门新濠3559 6

澳门新濠3559 7澳门新濠3559 8

 2.3 string引用类型参数按值传递的特殊情况

这个是比较特殊的一种方式。string类型也是引用类型,然而在按值传递时,传递的参数却不会因方法中形参的改变而被修改。

class Program
{
  static void Main(string[] args)
  {
    Console.WriteLine("String引用类型按值传递的特殊情况");
    string str = "old string";
    ChangeStr(str);
    Console.WriteLine("调用方法后,实参str的值:"+ str);
    Console.ReadKey();
  }

  private static void ChangeStr(string oldStr)
  {
    oldStr = "New String";
    Console.WriteLine("方法中oldStr的值:"+oldStr);
  }
}

 澳门新濠3559 9

按照前面对“引用类型参数按值传递”过程分析,这里方法对字符串的修改会导致实参的值发生改变,然而实际运行结果并非如此。造成这个特殊性的原因是string具有不变性,一旦string类型被赋值,则它就是不可改变的,即不能通过代码去修改它的值。

方法中oldStr="New String"代码执行时,系统会重新分配一块内存控件来存放New String字符串,然后把分配的内存首地址赋值给oldStr变量。所以,调用完方法后,str变量所指向的仍然是old string字符串,而oldStr变量则指向了New String字符串。

澳门新濠3559 10

澳门新濠3559 11

2.4 值类型和引用类型参数的按引用传递

不管是值类型还是引用类型,你都可以使用ref或out关键字来实现参数的按引用传递。并且在按引用进行传递时,方法的定义和调用都必须显式地使用ref和out关键字,不可以将它们省略,否则会引起编译错误。

还是用具体的代码来说明:

class Program
{
  static void Main(string[] args)
  {
    Console.WriteLine("值类型和引用类型参数的按引用传递情况");
    //num是值类型形参
    int num = 1;
    //refStr是引用类型实参
    string refStr = "Old String";
    ChangeByValue(ref num);
    Console.WriteLine(num);
    ChangeByStr(ref refStr);
    Console.WriteLine(refStr);
    Console.ReadKey();
  }

  private static void ChangeByStr(ref string numRef)
  {
    numRef = "new string";
    Console.WriteLine(numRef);
  }

  private static void ChangeByValue(ref int numValue)
  {
    numValue = 10;
    Console.WriteLine(numValue);
  }
}

结果截图:

澳门新濠3559 12

从结果就可以看出,在值类型参数按引用传递的过程中,传递的是值类型变量的地址,其效果类似于引用类型的按值传递。不同的是,值类型参数按引用传递的地址是栈上值类型变量的地址,而引用类型按值传递的地址是变量所指向的托管堆中实际数据的地址。当方法对值类型变量的地址进行操作时,实现的是对值类型变量的实际数据的操作,所以改变了实参中的值。

而引用类型参数按引用传递的过程中,传递的是引用类型变量的地址,该地址是变量在堆栈上的地址,即传递的是引用的引用而不是引用本身。

澳门新濠3559 13澳门新濠3559 14

 

总结:多动手,多思考。深入理解两种类型的不同,对于面试的问题就会游刃有余。

编辑:编程 本文来源:1.1 什么是值类型与引用类型,程序员都会接触到

关键词: