C# - 值类型和引用类型

时间:2020-12-10 19:50:05

值类型和引用类型(Value Type & Reference Type)

.NET使用两种不同的物理内存来存储数据,数据类型可简单分为值类型和引用类型。当声明一个值类型的变量后,系统会在栈(stack)中分配适当的内存来存储值类型的数据。而引用类型的变量虽然也利用栈,但栈上的地址是对堆(heap)地址的引用,引用类型的数据都存储在托管堆上,栈存储的是引用类型在托管堆上的地址的一个引用。

 

赋值

值类型的赋值

int x = 100;
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。

string a = "sam";
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 a = new Animal();
Animal b = a;//将a赋值给b时,a是将栈上存储的对自身在堆上地址的引用赋值给了b,这样,b对自身在堆上地址的引用被擦除,重新填充了一个指向a指向的地址。但b原来指向的那个地址上的对象并未被擦除。

方法中的参数赋值

将值类型变量传递给方法,方法会建立值类型变量的拷贝,将引用类型变量传递给方法,方法会建立引用类型变量的地址的拷贝。也即值类型参数在方法中是独立的,与外部的那个变量没有关系,在方法内部改变这个变量不会影响外部那个变量,而引用类型因为传递的是引用地址,所以在方法中改变该变量会同时改变外部的那个变量。

数组的赋值 

将一个数组赋值给另一个数组时,是将前一个数组的引用传递给另一个数组,但传递一个数组给某个方法时,发生的是值拷贝。

int[] a = { 1, 2, 3 };
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.整数 

byte整数(System.Byte)(无符号)
sbyte整数(System.SByte )(带符号)
以上两个最大存储8位二进制整数
byte称为字节,一个byte[]数组每个元素只存储1B(1个字节),所以byte[]的length就是字节总数,1024B=1KB(千字节),1024KB=1MB,单位转换时小转大用除法,大转小用乘法即可

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位二进制整数
 
decimal(System.Decimal)128位十进制数

2-1-2.浮点数 

float小数(System.Single)(最大存储32位)单精度类型,精确到小数点后6-7位。
double小数(System.Double)(最大存储64位)双精度类型,精确到小数点后15-16位。

2-1-3.字符数 

char(System.Char)(最大存储16个位)

字符将被自动转换为其对应的UTF-16编码,一个英文字符对应的UTF-16编码占一个字节,一个中文字符对应的UTF-16编码占两个字节。char类型的字符不能使用双引号,只能使用单引号括起来。

char.IsWhiteSpace(str, index)
//指定索引处是否为空字符串

char.IsPunctuation(charStr)
//参数是否是标点符号

2-3.自定义结构 

所有值类型或自定义的结构类型都派生自System.ValueType类。 

2-2.布尔型 

bool(System.Boolean)(存储8个位),实际上只需要一个位就可以存储布尔值,但它实际占用8个位 

注意

C#编译器默认整数类型的字面量是int类型,如果int存储不了该值则默认是long类型,浮点数则被默认是double类型。即使你把1赋值给一个byte类型的变量,该值也是Int32类型。

C# - 值类型和引用类型

当你用一个byte类型的变量存储整数值时,该值被默认为是int,但实际存储的只有8个位。如果该变量参与数学运算,则它的值又会被当做int,在C#中整数类型都是以int或long进行计算,C#并没有为byte等类型重写任何数学运算符。另外,整数类型相除如果预期结果有小数,小数不会保留,计算这样的结果应使用浮点数类型。

byte x = 3//字面量3默认是int类型,但x存储它只用8个位
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。

decimal x = 100m//默认int被转为了decimal
uint y = 100u;
float z = 0.1f;
show ( 1.234566666654333 ); //1.234566666654333默认是double类型,它会丢失一个精度,最后一个3不会输出。
show ( 1.234566666654333M ); //将其当做decimal输出

值类型的方法 

int c = int.MaxValue;
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个字节。内存编号是存储数据的地址,变量标识符(变量名)指向了数据存储的物理地址(内存编号)。

C# - 值类型和引用类型

数值的存储规则

1.该数的二进制数的位占不满内存划分的位时,系统会把0放置在该二进制数之前进行填充直到占满为止。

根据你声明的值类型的可存储最大位数,cpu自动为其划分对应位数的栈,下图涂色区域是可存储8位的栈,其它以此类推。

C# - 值类型和引用类型

现在假设我们要声明一个int类型的变量来存储3。

数字3的二进制数是11,该数只占2个bit位,2个位占不满int所声明的32个位,所以11前面会被填充30个0:

0000 0000 0000 0000 0000 0000 0000 0011(看下图)

补0时从内存编号的高位开始补起,下图中可以看到是从10000003的区域开始补0:

C# - 值类型和引用类型

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:

C# - 值类型和引用类型

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个位。如下图:

C# - 值类型和引用类型

写个程序检测一下:

static void Main( string [ ] args ) 
{
    short i = 1000;
    string s = Convert.ToString ( i, 2); //将i转换为二进制的字符表示
    Console.WriteLine ( s );
}

C# - 值类型和引用类型

  

引用类型(Reference Type)

引用类型分为三种

1.类(Class)

2.接口(Interface)

3.委托(Delegate)

引用类型变量在运行时的内存分配

计算机以数字的二进制形式进行存储。当声明了一个引用类型的变量时,系统会在栈上默认为其划分32个位用于存储该标识符对该对象地址的引用。这个分配内存的流程如下:

public class Animal { public int ID; public short NameCode  }
class Program
{
    static void Main(string[] args)
    {
        Animal animal;
    }
}

在Main中声明了一个Animal类型的变量时,系统在栈上分内存并把每个位全部都刷成0,如图:

C# - 值类型和引用类型

接着你new一个对象

static void Main(string[] args)
{
    Animal animal;
    animal = new Animal();
}

此时系统会扫描该对象的成员,上面我们在该对象的类里定义了一个32位的ID和一个16位的NameCode ,计算后得到48个位,系统就在堆上面为其划分48个位用来存储该对象。

C# - 值类型和引用类型

从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的二进制数填充到栈后如图:

C# - 值类型和引用类型

这样animal这个变量在栈上的数据就被刷成了一个内存上的物理地址,这个地址指向了该变量所对应的对象的真正数据。

现在假设你要把animal赋值给另一个变量,如图:

Animal animal2 = animal;

此时,系统会把animal在栈上的内存编号所引用的地址(30000001的二进制表示)copy、填充到animal2在栈上的内存编号所占用的位,假设此时10000004到10000007已经被其它数据占满,那么这个copy会在10000008处开始填充,如图:

C# - 值类型和引用类型

如果类型里有引用类型的成员,比如一个string类型的成员,那么系统同样会在堆上(而非栈上)为该成员变量分配32个位用来存储能指向它数据的地址,然后在堆上另辟一块区域去存储它的值。

public class Student
{
    uint ID;
    string Name;
}

C# - 值类型和引用类型C# - 值类型和引用类型

方法运行时的内存分配

方法在执行时,系统会在栈的高(内存编号从高到低,从下往上为函数划分内存)位上为该方法分配一个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的栈帧所包含的栈上。

static void Main(string[] args)
{
    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管理。看图:

C# - 值类型和引用类型

栈溢出(stack overflow)

栈溢出(stack overflow)就是因为运行时,方法的stack frame空间是由高位向低位划分内存,如果方法有返回值,return后函数终止,它区块内的所有变量就会立即被销毁,但如果一直没有rentun,比如无限递归,这会造成一直向上划分内存,直到低位的区块被彻底占满,最终就会导致栈溢出。 

结语

最后我们需要知道,程序运行时,无论是值类型抑或引用类型,当程序执行到声明这些变量的代码的时候,就会为其划分对应的内存,然后将每一个位都刷成0,直到赋值后才会有数据。这就是为什么当声明一个变量却不使用它时,编译器会提示你还未使用过该变量,因为当程序运行起来后未使用的变量会浪费内存资源。

 

装箱与拆箱(Boxing&UnBoxing)

我们可以把栈看成小盒子,把堆看成大箱子。装箱拆箱是指不同数据类型之间的转换

装箱(小盒子装进大箱子,值类型变引用类型)

//声明了int类型的变量x,立即在栈上划分32个位,填充100的二进制数
int x = 100;
//声明了引用类型的变量obj,立即在栈上划分32个位
//计算定义在object类型中的成员需要占多少空间,再在堆上划分对应大小的空间
//将x赋值给obj,将x指向的值拷贝一份往堆上存储,再把这个值在堆上的地址存储到栈上
//转换的过程就是这么麻烦,所以大量的装箱操作就会发生性能损耗
object obj = x;

拆箱(大箱子拆成小盒子,引用类型变值类型)

int x = 100;
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;
}

转换

对于值类型来说,小转大,大能存储小,所以隐式转换就可以完成。 大转小,小不能存储大,不被允许,所以必须显示甚至强制转换。对于引用类型来说,子类转父类/基类,子派生自父类/基类,所以隐式转换就可以完成。父类/基类转小,父类/基类并不从子类派生,所以不存在转换问题。 

隐式转换

隐式转换:直接赋值 

 相同类型之间,小转大,可隐式转换。隐式转换就是编译器自动进行转换,不需要你亲自动手。

sbyte x = 10;
short y = x;//8位存入16位,小转大,可隐式转换
namespace Test
{
    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()方法
        }
    }
}

显示转换

显示转换:(类型)变量 

相同类型之间,大转小,精度丢失,编译器会提示错误,此时需要你亲自动手显示转换。

short h;
int z = 10;
= z; //提示错误,32位存入16位,精度丢失,不可隐式转换,需显示转换
= (short)z; //显示转换

强制转换

强制转换:Convert.Toxxx( )方法 | Parse()方法

不同类型之间进行转换时才需要强转,编译器无法推测转换结果,这种转换如果出错只能在运行时抛出异常。也即强制转换就是告诉编译器不要插手我的逻辑,我对我的行为负责。通常情况都是需要将一个object类型转换为其它类型时使用强转。

安全强制转换

安全强制转换:TryParse()方法 | as操作符

这是最保险的方法,TryPrase方法是值类型的方法,它接受两个参数,一个是被转换的操作数,另一个是out类型的操作数。该方法测试操作数是否可被转换,并返回一个bool值,如果结果为真,就把转换结果给out变量,为假则不。as操作符是引用类型的操作符,它测试当前操作数的类型是否可以转换为目标类型,如果不能则返回null,该操作符不会因为转换失败抛出异常。

非转换

非转换:toString()

任何类型都继承了Object类,它提供了toString()方法,既然是任何类型,则结构类型同样可以使用toString(),但null因为没有指向堆上的地址,所以为null的变量使用该方法会抛错。

C# - 值类型和引用类型C# - 值类型和引用类型
static void Main(string[] args)
{
    int x = 100;
    x.ToString();//并未发生装箱,不存在转换操作,因为此方法是从Object继承
}
View Code

自定义转换

自定义显示转换:关键字explicit

C# - 值类型和引用类型C# - 值类型和引用类型
namespace Test
{
    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();
        }
    }
}
View Code

自定义隐式转换

自定义隐式转换:关键implicit 

C# - 值类型和引用类型C# - 值类型和引用类型
public static implicit operator A(B obj)
//……
B b = new B();
A newObj = b;//隐式转换
View Code

 

 

内存栈堆.下载!

C# - 学习总目录