zoukankan      html  css  js  c++  java
  • C#

    值类型和引用类型(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类型。

    当你用一个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个字节。内存编号是存储数据的地址,变量标识符(变量名)指向了数据存储的物理地址(内存编号)。

    数值的存储规则

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

    写个程序检测一下:

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

      

    引用类型(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,如图:

    接着你new一个对象

    static void Main(string[] args)
    {
        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 animal2 = animal;

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

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

    public class Student
    {
        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的栈帧所包含的栈上。

    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管理。看图:

    栈溢出(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的变量使用该方法会抛错。

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

    自定义转换

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

    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 

    public static implicit operator A(B obj)
    //……
    B b = new B();
    A newObj = b;//隐式转换
    View Code

     

     

    内存栈堆.下载!

    C# - 学习总目录

  • 相关阅读:
    UGUI血条跟随
    unity组件路径自动生成
    双摄像机使用
    Unity 属性雷达图
    unity UGUI UI跟随
    Unity中实现人物平滑转身
    游戏摇杆
    IIS下载无后缀文件的设置
    convert svn repo to git
    Discovery and auto register
  • 原文地址:https://www.cnblogs.com/myrocknroll/p/3316770.html
Copyright © 2011-2022 走看看