值类型和引用类型(Value Type & Reference Type)
.NET使用两种不同的物理内存来存储数据,数据类型可简单分为值类型和引用类型。当声明一个值类型的变量后,系统会在栈(stack)中分配适当的内存来存储值类型的数据。而引用类型的变量虽然也利用栈,但栈上的地址是对堆(heap)地址的引用,引用类型的数据都存储在托管堆上,栈存储的是引用类型在托管堆上的地址的一个引用。
赋值
值类型的赋值
x = 200;//x指向的数据在栈上会被擦除同时在这个位置上重新填充200的二进制数。
int x = 100;
int y = x;//将x的数据拷贝给x,两个变量互不相干
值类型之间的相互赋值会产生副本,如果将100赋值给x,那么当你把x赋值给y时就会发生一次值拷贝。这样,x和y都有一个相同的值,但两个变量指向栈上的地址是不一样的。也即y是拷贝了x的副本,它们各自有自己的版本。即使你把x传递给一个方法,在方法体内部改变这个x,原来的x也并未改变,方法接收的这个x将是原来那个x的一份拷贝。
string类型的赋值
string比较特殊,本身string一旦创建就不能被改变,当为它赋予一个新字符时,原来那个字符还驻留在内存中,只不过不再有变量指向原来那个字符在堆上的地址而已。即使更改一个string的大小写,也不会真的改变这个字符,这只会在堆上创建一个新字符。为了降低内存占用,C#的字符串还有留用机制,假设某个字符串在内存中已经有了地址,那么假如两个变量的值都是该字符串,则两个字符串的引用地址是相等的。但字符串留用机制只针对常量,所以下面第二个测试引用相等性的输出是false。
a = "korn"; //a指向了新的地址,但sam还在内存中,未被擦除,如果频繁使用string类型就会造成内存浪费,建议使用StringBuilder创建字符串
string key = "a";
string key1 = "aaa";
string key2 = "aaa";
Console.WriteLine(object.ReferenceEquals(key1, key2)); //true
Console.WriteLine(object.ReferenceEquals($"{key}{key1}", $"{key}{key2}")); //false
引用类型的赋值
Animal b = a;//将a赋值给b时,a是将栈上存储的对自身在堆上地址的引用赋值给了b,这样,b对自身在堆上地址的引用被擦除,重新填充了一个指向a指向的地址。但b原来指向的那个地址上的对象并未被擦除。
方法中的参数赋值
将值类型变量传递给方法,方法会建立值类型变量的拷贝,将引用类型变量传递给方法,方法会建立引用类型变量的地址的拷贝。也即值类型参数在方法中是独立的,与外部的那个变量没有关系,在方法内部改变这个变量不会影响外部那个变量,而引用类型因为传递的是引用地址,所以在方法中改变该变量会同时改变外部的那个变量。
数组的赋值
将一个数组赋值给另一个数组时,是将前一个数组的引用传递给另一个数组,但传递一个数组给某个方法时,发生的是值拷贝。
int[] b = a;
b[0] = 10;
b[1] = 20;
b[2] = 30;
Console.WriteLine($"{a[0]}{a[1]}{a[2]}"); //print 10、20、30
从占用内存空间上考虑,值类型的释放明显快于引用类型,因为存储在栈中的数据一旦离开作用域(块)就会被立刻销毁而不用等待垃圾收集器来完成销毁的工作,试考虑在一个方法中定义了一个值类型的数据,一旦方法执行结束,该值会被立刻清除,又假设在方法中定义一个引用类型x,方法中调用了另一个方法,假设另一个方法也调用了其它方法并且每一个方法都引用了x,那么x是不可能马上被销毁的,因为引用类型不建立拷贝,堆上的数据被改变,那么引用这个地址的变量都会被改变。从执行效率上考虑,当拷贝发生时,引用类型比值类型更高效,执行效率更快,因为它不需要副本,只需要拷贝一个堆地址的引用。值类型却需要在栈上分配内存空间存储副本数据。针对不同的情况应采取不同的方式处理这个问题。
与类型相关的null、Nullable<T>和void
null可以赋值给引用类型的变量,它代表的含义是未指向堆上的任何地址。如果x="",则x指向了""在堆上存储的地址,所以null!=""。
值类型不可以被赋值为null,但数据库表的值类型却可以为null,从表里查询的数据如果是null则没办法赋值给C#的值类型,为了解决这个问题,从2.0开始可以使用Nullable<T>来表示一个可以为空的值类型。可以使用可空修饰符?来表明任意类型是可以被赋值为null的。
void是在声明方法时使用,表明该方法没有任何返回类型。
字面量
直接写出的一个值就是字面量(值)。如123456、"aa"。但string x="aa"则不是。
值类型(Value Type)
值类型分类
值类型分为枚举(Enum)和结构(Struct)。
2-1.值类型
内置的值类型就是struct类型,从小到大依次为:sbyte<short<int<long<float<double<decimal
2-1-1.整数
sbyte整数(System.SByte )(带符号)
以上两个最大存储8位二进制整数
ushort整数(System.UInt16)(无符号)
short整数(System.Int16)(带符号)
以上两个最大存储16位二进制整数
uint整数(System.UInt32)(无符号)
int整数(System.Int32)(带符号)
以上两个最大存储32位二进制整数
9999999999的二进制数是1001 0101 0000 0010 1111 1000 1111 1111 11,该二进制数有34个bit位,int就不能存储该数。
ulong整数(System.UInt64)(无符号)
long整数(System.Int64)(带符号)
以上两个最大存储64位二进制整数
2-1-2.浮点数
double小数(System.Double)(最大存储64位)双精度类型,精确到小数点后15-16位。
2-1-3.字符数
字符将被自动转换为其对应的UTF-16编码,一个英文字符对应的UTF-16编码占一个字节,一个中文字符对应的UTF-16编码占两个字节。char类型的字符不能使用双引号,只能使用单引号括起来。
//指定索引处是否为空字符串
char.IsPunctuation(charStr)
//参数是否是标点符号
2-3.自定义结构
所有值类型或自定义的结构类型都派生自System.ValueType类。
2-2.布尔型
注意
C#编译器默认整数类型的字面量是int类型,如果int存储不了该值则默认是long类型,浮点数则被默认是double类型。即使你把1赋值给一个byte类型的变量,该值也是Int32类型。
当你用一个byte类型的变量存储整数值时,该值被默认为是int,但实际存储的只有8个位。如果该变量参与数学运算,则它的值又会被当做int,在C#中整数类型都是以int或long进行计算,C#并没有为byte等类型重写任何数学运算符。另外,整数类型相除如果预期结果有小数,小数不会保留,计算这样的结果应使用浮点数类型。
byte y = 2; //字面量2默认是int类型,但y存储它只用8个位
byte z = ( byte ) ( x + y ); //整数计算时如果值在栈上的存储不满32个位则以0填充,待满32个位后才会进行计算。所以此处x + y是两个int相加,结果是int,int转byte属于大转小,所以显示转换一下才行。
字面量后缀
可以为值类型的字面量指定后缀以转换该值被C#默认为的类型,可用的后缀(不区分大小写)有:m、d、f、u、l、ul,分别表示:decimal、double、float、uint、long、ulong。
uint y = 100u;
float z = 0.1f;
show ( 1.234566666654333 ); //1.234566666654333默认是double类型,它会丢失一个精度,最后一个3不会输出。
show ( 1.234566666654333M ); //将其当做decimal输出
值类型的方法
int z = int.MinValue;
int h = int.Parse("1");
int result=0;
bool k = int.TryParse("123", out result);
值类型的转换
隐式转换
小转大就是隐式转换。也即隐式转换总是发生于位数小的类型转位数大的类型。
强制转换
大转小就是强制转换,也即位数大的类型转位数小的类型可能会丢失精度(sbyte转int没问题,int转flota没问题,倒过来则是错误的),编译器会及时提示错误。需考虑强转。
值类型变量在运行时的内存分配
计算机以数字的二进制形式进行存储。当声明了一个值类型的变量时,系统会根据它可存储的bit位数来进行内存分配(划分)。下图是cpu的栈位,0-7是8个位,8个位=1个字节。内存编号是存储数据的地址,变量标识符(变量名)指向了数据存储的物理地址(内存编号)。
数值的存储规则
1.该数的二进制数的位占不满内存划分的位时,系统会把0放置在该二进制数之前进行填充直到占满为止。
根据你声明的值类型的可存储最大位数,cpu自动为其划分对应位数的栈,下图涂色区域是可存储8位的栈,其它以此类推。
现在假设我们要声明一个int类型的变量来存储3。
数字3的二进制数是11,该数只占2个bit位,2个位占不满int所声明的32个位,所以11前面会被填充30个0:
0000 0000 0000 0000 0000 0000 0000 0011(看下图)
补0时从内存编号的高位开始补起,下图中可以看到是从10000003的区域开始补0:
999999999的二进制数是1110 1110 0110 1011 0010 0111 1111 11,该数只占30个位,30个位占不满int所声明的32个位,所以该数前面会被填充2个0:
0011 1011 1001 1010 1100 1001 1111 1111(看下图,3的二进制数已占满从内存编号10000000开始到10000003的区域,所以999999999的二进制数将划分在后面,灰色部分)
补0(补码)时从内存编号的高位开始补起,下图中可以看到是从10000007的区域开始补0:
2.负数的存储是把当前数字的绝对值的满位后的二进制数按位取反再+1的形式来表示,流程是:1.取绝对值。2.转化为二进制数。3.不满位数则以0补位。4.按位取反:1变0,0变1。5.用结果数+1。6.用结果数逢二进一。按位取反称为反码,+1称为补码。
假设现在要用short存储数字-1000,绝对值1000的二进制数是1111 1010 00,该数只有10位,前面要补6个0得到0000 0011 1110 1000,每个位取相反数(1的相反数是0)得到:1111 1100 0001 0111,1111 1100 0001 0111+1=1111 1100 0001 0112,逢二进一得到:1111 1100 0001 1000,首位的1会被计算机识别为负号,负号占了1个位。如下图:
写个程序检测一下:
{
short i = 1000;
string s = Convert.ToString ( i, 2); //将i转换为二进制的字符表示
Console.WriteLine ( s );
}
引用类型(Reference Type)
引用类型分为三种
1.类(Class)
2.接口(Interface)
3.委托(Delegate)
引用类型变量在运行时的内存分配
计算机以数字的二进制形式进行存储。当声明了一个引用类型的变量时,系统会在栈上默认为其划分32个位用于存储该标识符对该对象地址的引用。这个分配内存的流程如下:
class Program
{
static void Main(string[] args)
{
Animal animal;
}
}
在Main中声明了一个Animal类型的变量时,系统在栈上分内存并把每个位全部都刷成0,如图:
接着你new一个对象
{
Animal animal;
animal = new Animal();
}
此时系统会扫描该对象的成员,上面我们在该对象的类里定义了一个32位的ID和一个16位的NameCode ,计算后得到48个位,系统就在堆上面为其划分48个位用来存储该对象。
从30000001开始先分配32个位,接着分配16个位。完成后,需要把对象在堆上的起始地址(内存编号)30000001转换为二进制数,这个二进制数会被填充到栈上,栈就完成了对堆的引用。30000001转换为二进制数得到:
1110 0100 1110 0001 1100 0000 1 (4*6=24位,不够32位,所以在其高位补足7个0)得到:
0000 0001 1100 1001 1100 0011 1000 0001 (刚好32位)
这个数字会被填充到刚才被刷成0的栈上,这样,栈就完成了对实例对象的引用,30000001的二进制数就成为了指向Animal对象的真正地址。30000001的二进制数填充到栈后如图:
这样animal这个变量在栈上的数据就被刷成了一个内存上的物理地址,这个地址指向了该变量所对应的对象的真正数据。
现在假设你要把animal赋值给另一个变量,如图:
此时,系统会把animal在栈上的内存编号所引用的地址(30000001的二进制表示)copy、填充到animal2在栈上的内存编号所占用的位,假设此时10000004到10000007已经被其它数据占满,那么这个copy会在10000008处开始填充,如图:
如果类型里有引用类型的成员,比如一个string类型的成员,那么系统同样会在堆上(而非栈上)为该成员变量分配32个位用来存储能指向它数据的地址,然后在堆上另辟一块区域去存储它的值。
{
uint ID;
string Name;
}
方法运行时的内存分配
方法在执行时,系统会在栈的高(内存编号从高到低,从下往上为函数划分内存)位上为该方法分配一个stack frame的空间。分配完成后stack frame如下图,栈帧用于存储函数作用域并执行。作用域中为方法的变量分配栈空间,一条规则是这样的:在哪个方法中声明哪些变量,那么那些变量就由那个方法负责为它们划分内存。所以在以下在A方法的作用域中声明了两个byte类型的变量x和y,所以在栈帧包含的栈上为x和y划分了两个字节,A方法还接收了两个参数,因为参数是byte类型,所以还会发生值拷贝,这样,A方法还需要为i和z划分内存,Main方法中声明了i和z,所以在Main方法的栈帧上会为i和z划分内存,Main方法还接收一个string类型的args参数,所以还需要为args在堆上划分内存,再把堆地址填充到Main的栈帧所包含的栈上。
{
byte i = 1;
byte z = 3;
A(i,z);
}
static public byte A(byte i, byte z)
{
byte x = 4;
byte y = 5;
return (byte)(i + z);
}
以上的Main方法是caller,所以调用的A方法的两个参数i和z的内存分配就划归给Main管理。看图:
栈溢出(stack overflow)
栈溢出(stack overflow)就是因为运行时,方法的stack frame空间是由高位向低位划分内存,如果方法有返回值,return后函数终止,它区块内的所有变量就会立即被销毁,但如果一直没有rentun,比如无限递归,这会造成一直向上划分内存,直到低位的区块被彻底占满,最终就会导致栈溢出。
结语
最后我们需要知道,程序运行时,无论是值类型抑或引用类型,当程序执行到声明这些变量的代码的时候,就会为其划分对应的内存,然后将每一个位都刷成0,直到赋值后才会有数据。这就是为什么当声明一个变量却不使用它时,编译器会提示你还未使用过该变量,因为当程序运行起来后未使用的变量会浪费内存资源。
装箱与拆箱(Boxing&UnBoxing)
我们可以把栈看成小盒子,把堆看成大箱子。装箱拆箱是指不同数据类型之间的转换。
装箱(小盒子装进大箱子,值类型变引用类型)
int x = 100;
//声明了引用类型的变量obj,立即在栈上划分32个位
//计算定义在object类型中的成员需要占多少空间,再在堆上划分对应大小的空间
//将x赋值给obj,将x指向的值拷贝一份往堆上存储,再把这个值在堆上的地址存储到栈上
//转换的过程就是这么麻烦,所以大量的装箱操作就会发生性能损耗
object obj = x;
拆箱(大箱子拆成小盒子,引用类型变值类型)
object obj = x;
//声明了int类型的变量y,立即在栈上划分32个位
//将obj指向的堆上的数据拷贝一份往栈上存储
int y =(int)obj;
因为值类型较小而引用类型较大,把小数据装进大箱子是装得下的,所以装箱是属于隐式进行。把大数据装进小盒子不一定装得下,对象装进小盒子就有可能拆掉大箱子后都装不下,所以拆箱是属于显示或强制进行,系统不会自动为你转换,这需要你自己手动显示或强制转换,装箱拆箱需要一个装或拆的过程,所以大量装和拆就会造成性能损耗。
对象的浅拷贝与深拷贝(Shallow Copy & Deep Copy)
假设有一个Animal类型的变量,如果直接把这个变量赋值给另一个Animal变量,这个行为不叫拷贝,应叫做赋值,这会使两个Animal变量指向同一个堆上的地址。现在假设Animal有一个int类型的ID字段和一个Person类型的person字段,当拷贝Animal对象时,你有两个选择:
1.只拷贝Animal对象的ID,只拷贝Animal对象的person指向的堆地址,此为浅拷贝。
2.拷贝Animal对象的ID,拷贝Animal对象的person的数据,此为深拷贝。
实现浅拷贝
你可以使用Object的MemberwiseClone方法创建对某对象的浅拷贝。MemberwiseClone是一个受保护的方法,只能在Object的派生类的类代码块中使用,该方法返回一个浅拷贝的对象,该对象只拷贝了源对象的值类型的成员,而引用类型的成员则只有一个堆引用地址。
public class Department
{
public string Name;
public Department()
{
}
}
//人员
public class Person
{
public Department Department { get; set; }
public Person() { }
public Person GetCopy()
{
return MemberwiseClone() as Person;
}
}
class Program
{
static void Main(string[] args)
{
Person p = new Person();
p.Department = new Department();
p.Department.Name = "科技部";
Person p2 = p.GetCopy(); //将p浅拷贝赋给p2
p2.Department.Name = "开发部"; //p2.Department拷贝的是p.Department在堆上的地址
Console.WriteLine(p.Department.Name); //print 开发部
}
}
实现深拷贝
从上面代码的结果可知,改变p2的成员department,则p的department也会跟着被改变。因为p2的department修改了堆上的值,而深度拷贝可以解决这个问题。
public static T DeepCopyWithBinarySerialize<T>(T obj)
{
object retval;
using (MemoryStream ms = new MemoryStream())
{
BinaryFormatter bf = new BinaryFormatter();
// 序列化成流
bf.Serialize(ms, obj);
ms.Seek(0, SeekOrigin.Begin);
// 反序列化成对象
retval = bf.Deserialize(ms);
ms.Close();
}
return (T)retval;
}
转换
对于值类型来说,小转大,大能存储小,所以隐式转换就可以完成。 大转小,小不能存储大,不被允许,所以必须显示甚至强制转换。对于引用类型来说,子类转父类/基类,子派生自父类/基类,所以隐式转换就可以完成。父类/基类转小,父类/基类并不从子类派生,所以不存在转换问题。
隐式转换
隐式转换:直接赋值
相同类型之间,小转大,可隐式转换。隐式转换就是编译器自动进行转换,不需要你亲自动手。
short y = x;//8位存入16位,小转大,可隐式转换
{
public class Animal
{
public void Eat( ) { }
}
public class Person : Animal
{
public void Job( ) { }
}
class Program
{
static void Main( string [ ] args )
{
Person p = new Person ( );
p.Job ( );//具有job方法
Animal a = p;//子类转换为父类/基类对象后(a),a会丢失子类的Job()方法
}
}
}
显示转换
显示转换:(类型)变量
相同类型之间,大转小,精度丢失,编译器会提示错误,此时需要你亲自动手显示转换。
int z = 10;
h = z; //提示错误,32位存入16位,精度丢失,不可隐式转换,需显示转换
h = (short)z; //显示转换
强制转换
强制转换:Convert.Toxxx( )方法 | Parse()方法
不同类型之间进行转换时才需要强转,编译器无法推测转换结果,这种转换如果出错只能在运行时抛出异常。也即强制转换就是告诉编译器不要插手我的逻辑,我对我的行为负责。通常情况都是需要将一个object类型转换为其它类型时使用强转。
安全强制转换
安全强制转换:TryParse()方法 | as操作符
这是最保险的方法,TryPrase方法是值类型的方法,它接受两个参数,一个是被转换的操作数,另一个是out类型的操作数。该方法测试操作数是否可被转换,并返回一个bool值,如果结果为真,就把转换结果给out变量,为假则不。as操作符是引用类型的操作符,它测试当前操作数的类型是否可以转换为目标类型,如果不能则返回null,该操作符不会因为转换失败抛出异常。
非转换
非转换:toString()
任何类型都继承了Object类,它提供了toString()方法,既然是任何类型,则结构类型同样可以使用toString(),但null因为没有指向堆上的地址,所以为null的变量使用该方法会抛错。
{
int x = 100;
x.ToString();//并未发生装箱,不存在转换操作,因为此方法是从Object继承
}
自定义转换
自定义显示转换:关键字explicit
{
public class A
{
public static explicit operator A(B obj)
{
A a = new A();
return a;//创建对象返回给需要转换的变量obj。
}
public void Show()
{
Console.WriteLine("转换成功");
}
}
public class B { }
class Program
{
static void Main(string[] args)
{
B b = new B();
A newObj = (A)b;
newObj.Show();
}
}
}
自定义隐式转换
自定义隐式转换:关键implicit
//……
B b = new B();
A newObj = b;//隐式转换