C# - 值類型和引用類型

值類型和引用類型(Value Type & Reference Type)html

.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原來指向的那個地址上的對象並未被擦除。

方法中的參數賦值

將值類型變量傳遞給方法,方法會創建值類型變量的拷貝,將引用類型變量傳遞給方法,方法會創建引用類型變量的地址的拷貝。也即值類型參數在方法中是獨立的,與外部的那個變量沒有關係,在方法內部改變這個變量不會影響外部那個變量,而引用類型由於傳遞的是引用地址,因此在方法中改變該變量會同時改變外部的那個變量。ide

數組的賦值 

將一個數組賦值給另外一個數組時,是將前一個數組的引用傳遞給另外一個數組,但傳遞一個數組給某個方法時,發生的是值拷貝。函數

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 十、20、30

從佔用內存空間上考慮,值類型的釋放明顯快於引用類型,由於存儲在棧中的數據一旦離開做用域(塊)就會被馬上銷燬而不用等待垃圾收集器來完成銷燬的工做,試考慮在一個方法中定義了一個值類型的數據,一旦方法執行結束,該值會被馬上清除,又假設在方法中定義一個引用類型x,方法中調用了另外一個方法,假設另外一個方法也調用了其它方法而且每個方法都引用了x,那麼x是不可能立刻被銷燬的,由於引用類型不創建拷貝,堆上的數據被改變,那麼引用這個地址的變量都會被改變。從執行效率上考慮,當拷貝發生時,引用類型比值類型更高效,執行效率更快,由於它不須要副本,只須要拷貝一個堆地址的引用。值類型卻須要在棧上分配內存空間存儲副本數據。針對不一樣的狀況應採起不一樣的方式處理這個問題。性能

 

與類型相關的null、Nullable<T>和void

null能夠賦值給引用類型的變量,它表明的含義是未指向堆上的任何地址。若是x="",則x指向了""在堆上存儲的地址,因此null!=""。學習

值類型不能夠被賦值爲null,但數據庫表的值類型卻能夠爲null,從表裏查詢的數據若是是null則沒辦法賦值給C#的值類型,爲了解決這個問題,從2.0開始可使用Nullable<T>來表示一個能夠爲空的值類型。可使用可空修飾符?來代表任意類型是能夠被賦值爲null的。測試

void是在聲明方法時使用,代表該方法沒有任何返回類型。大數據

 

字面量

直接寫出的一個值就是字面量(值)。如12345六、"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# - 學習總目錄

相關文章
相關標籤/搜索