C#值类型、引用类型、指针类型

计算机内存的分区如下:

一个内存条

我们的内存就像一个仓库,这个仓库由成千上万个大小相等的房间构成,一个房间就是一个字节(byte),每个房间都有一个唯一的“房间号”,就是该字节的“地址”,地址一般以16进制无符号整数表示,比如电脑蓝屏时出现的“0x0000007b”就是一个内存地址

64位操作系统的内存地址转化为二进制后是一个8byte的无符号整数,32位则是4byte(这也是32位操作系统的内存上限是4G的原因),下文默认讨论32位操作系统

我们将这些房间分为五个区,每个区的功能,顾名思义:代码区就是放代码的地方,常量区放常量,静态区放静态变量,至于其他货物,都存放在栈区和堆区。

栈区比堆区要小一些,用来储存小型货物、以及堆区各个“套间”(n个连续的房间组成一个套间)的钥匙;堆区则大很多,用来存储大型货物

介绍完了内存结构,接下来让我们看看内存是如何存储变量的,C#的变量有三种类型,我们依次介绍:

【CASE1 值类型】

我们看语句:

int a=10;

系统会先处理赋值号左边的定义语句:

首先在栈上开辟4byte的连续空间,比如说300,301,302,303这四个房间,组成一个套间。(为了方便,不再使用“0x……”这种十六进制格式来表示地址,就用300来表示地址,下同)然后,系统会在一个叫“符号表”的小本本里记下“a——300号4连房间里的那个货物

!注意:“变量a”不是指300号房间,而是里面的货物。300是个常数,而a在不断变化

系统再处理赋值语句:

首先判断赋值号右边的货物是什么类型,10是整型,并且4连房里放得下,ok没问题,那就把他塞进刚开的套间a里

值类型a与b

现在我们传递一下值:

int b=a;

和上面类似,系统会先看赋值号左边的定义语句:

首先在内存的栈上开辟4byte的连续空间,比如说400,401,402,403这四个房间,组成了一个套间。然后在“符号表”里记下“b——400号4连房间里的那个int型货物

系统再看赋值语句:

首先判断赋值号右边是什么类型,a是个什么东西?去符号表里查一下,哦~是个int类型,与左边一致,ok没问题,那就把货物a复制一份,塞到400号4连房间里

在这个case里,b和a指代的都是货物,也就是数据

结论:

因为b是a的一个副本,所以对变量b进行的任何修改,都不会影响到变量a

【CASE2 引用类型】

假设我们有这样一个类:

class Person{int age;bool sex;}

我们把他实例化:

Person c=new Person();

系统会先看赋值号左边的定义语句:

首先在栈上开辟4byte连续空间,比如说500,501,502,503这4个房间,用来存放实例的地址,然后在“符号表”里记下“c——500号4连房里的那把Person型钥匙

系统再看赋值号右边的实例化语句:

因为int age占4byte,bool sex占1byte,所以在堆上开辟5byte连续空间,比如说2000,2001,2002,2003,2004这五个房间,作为一个套间,用来存放这个实例的各个字段,(严格来讲不止5byte,因为还要存一些其他东西,比如引用计数;方法不放在这里,方法在代码区),并且按照构造方法赋初值。因为我们调用的是无参构造函数,所以每个字段初始值就是默认值,age=0,sex=false

系统再处理赋值号:

等号两边类型匹配,ok,将“2000号5连房”的大门,配一把钥匙,把钥匙放入500号4连房

引用类型c与d

现在我们传递一下值:

Person d=c;

系统会先看赋值号左边的定义语句:

首先在栈上开辟4byte连续空间,比如说首地址叫600,用来存放Person类实例的地址,然后在“符号表”里记下“d——600号4连房里的那把Person型钥匙

系统再看赋值号右边:

c是何物?查一下符号表,得到一把Person类的钥匙,与等号左边匹配,所以该赋值过程合法

系统再处理赋值号:

把房间c里的货物——“那把钥匙”,又配了一把,塞到了房间d里

在这个case里,c和d指代的不是货物(数据),而是堆上那个实例的套间的钥匙 (引用);

对钥匙d打开的房间里的货物进行的任何修改,都会影响到钥匙c打开的房间的货物,因为他们指向同一堆货物

结论:

对变量d指向的实例进行的任何修改,都会影响到变量c指向的实例,因为他们指向同一个实例

这就是引用类型和值类型的本质区别。

这时,细心的同学可能会问:“在case1值类型里,你说的是‘对变量b进行的任何修改,都不会影响到变量a’ ,怎么到了case2引用类型这儿,就不提‘变量本身’了呢?却要讨论‘指向的实例’?”

没错!盲生你发现了华点!

别急,先听我讲完第三种类型——指针

【CASE3 指针类型】

在C#里,指针是一种特殊的类型,既非值类型又非引用类型

比如:

int*  e=&a; 

先处理赋值号左边:

系统在栈上开辟4byte连续空间,比如首地址是700,用来存放地址,e的类型是“int*”型,表示它是个指向int的指针型变量。然后在“符号表”里记下“e——700号4连房里那个地址

再处理赋值号右边:

a还是case1的int a,房间号是300,值是10,&是“取址运算符”,&a就是a的地址,就是300

最后处理赋值号:

将“房间号300”这一号码牌,作为一个货物储存在700号房

与值类型和引用类型不同,这次存的货物既不是数据本身,也不是钥匙,而是一个“号码牌”

指针变量e与f

现在我们传递一下值:

int*  f=e;

系统在栈上开辟4byte连续空间,比如首地址是800,那么就在“符号表”里记下“f——800号4连房里那个int型地址”,然后把e,也就是“房间号300”,作为货物,复制一份储存在800号房

如果我们想通过e或f来修改a的值,就必须使用“*”号——“取值运算符”,将地址转化为对应的变量。比如:

*e=20;

这时a的值就是20了

结论:

在这个case里,e和f指代的都是“号码牌”,也就是地址

因为f是e的一个副本,所以对变量f进行的任何修改,都不会影响到变量e

*e和*f指代的都是a,对变量f指向的变量进行的任何修改,都会影响到变量e指向的变量,因为他们指向同一个变量a

【DISCUSSION1】三种类型的区别

为什么唯独在CASE2引用类型里,不提‘变量本身’了呢?却要讨论‘指向的实例’?

原因有二:

其一,多数情况下,我们所关注的那个有价值的数据,是堆上的那个实例,而非他的地址。就好比我们来仓库取快递,我们关心的是货物本身,而非那把钥匙,除非你是仓库管理员

其二,即使我们想关注那个地址,我们也关注不了:

因为,C#限制我们对引用型变量d中所存储的“地址”进行任何显式的读写和计算,这也是引用型和指针型的根本区别

同样是存放地址,引用型变量d代表着一把钥匙,而指针型变量f代表着一个房间号,钥匙和房间号的区别如下

①一把钥匙一旦配好,不可以“再加工”以匹配其他房间,除非重新配一把;一个房间号,只要拿橡皮擦掉重新写,就可以代表另一个房间

比如

d+=1;

是不是d就指向下一个房间了呢?比如从2000号房改为指向2001号房?

事实上,这种操作是非法的。在这个语句中,d代表的是d所指向的Person实例,而非地址,而让一个实例去+1,没有任何意义(除非用户在Person类里对加法重载,即使重载了,d依然指代实例而非地址)。但是

f+=1;

就是合法的。指针类型变量可以对‘地址的值’进行直接读写”,+1表示地址增加一个“单位长度”,f指向int型,单位长度是4(byte),f+1就是加4(byte),指向304号房

指针运算有时可以很方便,比如计算数组某索引的地址,但是如果运用不当,就有可能指向意料之外的位置,如果那个位置已经存储了一些数据,冒然读写可能会导致程序崩溃;引用类型杜绝了地址运算,也就避免了这种错误

必须指出的是,d=null; 、d=c; 和 d=new Person(); 这三种语句没有直接对地址的值进行操作,相当于销毁钥匙或者重新配了一把钥匙,是合法的

②一把钥匙使用的时候,直接就可以打开门锁;而一个房间号在使用的时候,需要先借助取值运算符“*”配一把钥匙,才可以打开指定的房间

*e=30;//使a的值变为30

而引用类型无需加“*”,使用时直接指向对象,也就是说我们在读写引用型变量时,读写的是堆上那个对象而非对象的地址

【DISCUSSION2】函数参数传递

上文一直在讨论变量之间值的传递,没有提及函数调用时参数的传递,事实上,两者在原理上是一样的,都是值传递,值类型变量传参时传递的是变量的“值”,引用类型和指针类型传参时传递的也是变量的“值”,只不过“值”是个地址

只有一种情况属于例外:

当函数参数使用关键字“ref”和“out”时,传的是地址而非

例如有如下方法:

public void Shopping(ref int money)//ref值类型变量
    {money-=100;}

在调用此方法时:

int cash=1000;
jim.Shopping(ref cash);

传递的是实参cash在栈上的地址,比如说是900,那么900号房间里的货物又获得一个别名——money,对money的任何修改,都是对cash的修改
即,通过ref/out,值类型的参数传递可以表现地像引用类型一样

例如有如下方法:

public void CelebrateBirthday(ref Person friend)//为他人庆生,ref引用型变量
    {friend.age+=1;}

在调用此方法时:

Person lucy=new Person();
jim.CelebrateBirthday(ref lucy);

传递的是实参lucy在栈上的地址(注意,不是堆上那个实例的地址,而是“引用型变量lucy”的地址),比如说是1000,那么1000这个房间的货物(一把钥匙)又获得一个别名——friend,对friend的任何修改,都是对lucy的修改
虽说传递的是地址,而不再是值,过程和刚才不一样了,但是从结果上讲,引用类型使用ref/out来传参,与不使用ref/out传参,几乎没有区别。除非你在这个方法里做了一些无聊的事:friend=c; 、friend=null; 或 friend=new Person(); 让friend指向了别的实例,相当于新配了一把钥匙,绝大多数情况下,这种操作没有意义

指针型传参我们就不讨论了,感兴趣的同学可以自己琢磨一下,和引用型类似

事实上,在C#里我们基本用不上指针,C#的引用类型已经可以满足绝大多数需求,而且更安全,更方便。那我们为何还要讲指针呢?因为有些其他语言使用指针,比如C和C++,为了避免概念混淆,必须要把他们的区别说一下;而且指针可以更好的帮助我们理解引用

【DISCUSSION3】类型的声明

我们在定义变量时,为何必须要声明变量的类型?

int g=259;

为例,259必须要转化为二进制,即“100000011”,然后把所有空位补0:

00000000 00000000 00000001 00000011

然后把它存到300号四连房里,如下图:

(其实上文中所有栈的开辟空间方式都是错的,栈是从内存条最上面开始使用的,逐渐往下开辟空间,地址越来越小)

内存中的变量g

大小端模式是系统储存数据的两种模式,两者互为倒序,本文只讨论大端

系统会在“符号表”里记下“g——首地址为0xFFFFFFFF的4连房间里的那个货物”

如果我们不告诉系统g是int型,他怎么知道去0xFFFFFFFF这个地址取出四个byte组成一个数呢?他完全可以只取一个byte,取出来的货物是“11”,转化成十进制是3,这与我们存进去的货物“259”毫无关系

指针和引用也是同理,如果我们不声明他们指向的数据的类型,只给他们一个首地址,他们根本就不知道该取多少货

这时有同学可能会问,python从来不需要声明变量类型,是为什么呢?

python里只有两种东西,引用(相当于C#引用类型变量)和对象(相当于C#里类的实例)。在python里一切数据都是对象,“值类型”数据也是对象,所有对象都有一个属性:“类型”,所以取货时系统先瞄一眼货的类型,类型是啥我就取多少

这种模式写起来方便,但也有代价:传值或传参时,如果类型不匹配,编辑器不会报错,甚至运行了也不一定报错,这种bug比较隐蔽;而且,如果写代码的人不写注释,代码的可读性极差

【PS】

在定义指针变量时,必须明确他所指向的变量的类型,就是*前面那个数据类型
这个“数据类型”包括且仅包括:指针类型和值类型(其中struct不能包含引用型字段)

C#值类型包括且仅包括:sbyte、byte、short、ushort、int、uint、long、ulong、float、double、decimal 、bool、char、枚举、自定义struct
C#引用类型包括且仅包括:类、接口、数组、委托

你可能感兴趣的:(C#值类型、引用类型、指针类型)