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#引用类型包括且仅包括:类、接口、数组、委托