編程之基礎:數據類型(一)


相關文章連接:

編程之基礎:數據類型(二)

高屋建瓴:梳理編程約定

動力之源:代碼中的“泵”

完整目錄與前言

編程之基礎:數據類型(一)   

數據類型是編程的基礎,每個程序員在使用一種平台開發程序時,首先得知道平台中有哪些數據類型,每種數據類型有哪些特點、又有着怎樣的內存分配等。熟練掌握每種類型不僅有利於提高我們的開發效率,還能使我們開發出來的程序更加穩定、健全。.NET中的數據類型共分為兩種:引用類型和值類型,它們無論在內存分配還是行為表現上,均有着非常大的差別。

3.1 引用類型與值類型

關於對引用類型和值類型的定義,聽得最多的是:值類型分配在線程棧中,而引用類型分配在堆中。這個定義並不准確(因為值類型也可以分配在堆中,而引用類型在某種場合也可以分配在棧中),或者說太抽象,它只是從內存分配的角度來區分值類型和引用類型,而對於內存分配,我們開發者是很難直觀地去辨別。如果從代碼角度來講,.NET中的值類型是指"派生自System.ValueType的類型",而引用類型則指.NET中排除值類型在外的所有其它類型。下圖3-1顯示了.NET中的類型布局:

圖3-1 類型布局

如上圖3-1所示,派生自System.ValueType的類型屬於值類型(圖中虛線部分,不包括System.ValueType),所有其它類型均為引用類型(包括System.Object、System.ValueType)。在以System.Object為根的龐大"繼承樹"中圈出一部分(圖中虛線框),那么該小部分就屬於"值類型"。

    注:以上對值類型和引用類型的解釋似乎有些難以理解,為什么"根"是引用類型,而某些"枝葉"卻是值類型?這是因為.NET內部對派生自System.ValueType的類型做了些"手腳"(這些對我們來講是不可見的),使其跟其它類型(引用類型)具備不一樣的特性。另外,.NET中還有一些引用類型並不繼承自System.Object類,比如使用interface關鍵字定義的接口,它根本不在"繼承樹"的范圍之類,這樣看來,像我們平時聽見的"所有類型均派生自System.Object類型"的話似乎也不太准確,這些隱藏的不可告人的秘密都是.NET內部做的一些處理,大部分並沒有遵守主流規律。

通常值類型又分為兩部分:

1)簡單值類型:包括類似int、bool、long等.NET內置類型,它們本質上也是一種結構體;

2)復合值類型:使用Struct關鍵字定義的結構體,如System.Drawing.Point等。復合值類型可以由簡單值類型和引用類型組成,下面定義一個復合值類型:

1 //Code 3-1
2 
3 struct MultipleValType
4 {
5     int a; //NO.1
6     object c; //NO.2
7 }

如上代碼Code 3-1所示,MultipleValType類型包含兩個成員,一個簡單值類型(NO.1處),一個引用類型(NO.2處)。

值類型均默認派生自System.ValueType,又由於.NET不允許多繼承,因此我們既不可以在代碼中顯示定義一個派生自System.ValueType的結構體,同時也不可以讓某個結構體繼承自其它結構體。

引用類型和值類型各有自己的特性,這具體表現在內存分配、類型賦值(復制)、類型判等幾個方面。

3.1.1 內存分配

本節開頭就談到,引用類型對象與值類型對象在內存中的存儲方式不相同,使用new關鍵字創建的引用類型對象存儲在(托管)堆中,而使用new關鍵字創建的值類型對象則分配在當前線程棧中。

    注:堆和棧的具體概念請參見本書后面講"對象生命期"的第四章。另外,使用類似"int a = 0;"這種方式定義的簡單值類型變量,跟使用new關鍵字"Int32 a = new Int32();"效果一樣。

下面代碼顯示創建一個引用類型對象和一個值類型對象:

 1 //Code 3-2
 2 
 3 class Ref //NO.1
 4 {
 5     int a;
 6     Ref ref;
 7     public Ref(int a,Ref ref)
 8     {
 9         this.a = a;
10         this.ref = ref;
11     }
12 }
13 struct Val1 //NO.2
14 {
15     int a;
16     bool b;
17     public Val1(int a,bool b)
18     {
19         this.a = a;
20         this.b =b;
21     }
22 }
23 struct Val2 //NO.3
24 {
25     int a;
26     Ref ref;
27     public Val2(int a,Ref ref)
28     {
29         this.a = a;
30         this.ref = ref;
31     }
32 }
33 class Program
34 {
35     static void Main()
36     {
37         Ref r = new Ref(0,new Ref(1,null)); //NO.4
38         Val1 v1 = new Val1(2,true); //NO.5
39         Val2 v2 = new Val2(3,r); //NO.6
40     }
41 }

如上代碼Code 3-2所示,先定義了一個引用類型Ref(NO.1處),它包含一個值類型和一個引用類型成員;然后定義了兩個值類型(NO.2和NO.3處),前者只包含兩個簡單值類型成員(int和bool類型),后者包含一個簡單值類型和一個引用類型成員;最后分別各自創建一個對象(NO.4、NO.5以及NO.6處)。創建的三個對象在堆和棧中存儲情況見下圖3-2:

 

圖3-2 堆和棧中數據存儲情況

如上圖3-2所示,值類型對象v1和v2均存放在棧中,而引用類型對象均存放在堆中。

通常程序運行過程中,線程會讀寫各自對應的棧(因此有時候我們稱"線程棧"),也就是說,"棧"才是程序進行讀寫數據的地方,那么程序怎么訪問存放在堆中的數據(對象)呢?這就需要在棧中保存一個對堆中對象的引用(索引),程序就可以通過該引用訪問到存放在堆中的對象。

    注:引用類型對象一般分為兩部分:對象引用和對象實例,對象引用存放在棧中,程序使用該引用訪問堆中的對象實例;對象實例存放在堆中,里面包含對象的數據內容,有關它們更詳細介紹,請參見本書后面有關"對象生命期"的第四章。

3.1.2 字節序

我們知道,內存可以看作是一塊具有連續編號的存儲空間,編號有大有小,所以有高地址和低地址之分。如果以字節為單元進行編號,那么一塊內存可以用下圖3-3表示:

圖3-3 內存結構

如上圖3-3所示,從左往右,地址編號依次增大,左側稱為"低地址",右側稱為"高地址"。編號為0x01字節中存儲數值為0x01,編號為0x02字節中存儲數值為0x09,編號為0x03字節中存儲數值為0x00,編號為0x04字節中存儲數值為0x1a,每個字節中均可存放一個0~255之間的數值。那么這時候,如果我問你,圖3-3中最左側四個字節表示的一個int型整數為多少?你可能會這樣去計算:0x01*2的24次方+0x09*2的16次方+0x00*2的8次方+0x1a*2的0次方,然后這樣解釋:高位字節在左邊,低位字節在右邊,將這樣的一個二進制數轉換成十進制數當然是這樣計算。事實上,這種計算方法不一定正確,因為沒有人告訴你高位字節一定在左邊(低地址),而低位字節一定在右邊(高地址)。

當占用超過一個字節的數值存放在內存中時,字節之間必然會有一個排列順序,我們稱之為"字節序",這種順序會因不同的硬件平台而不同。高位字節存放在低地址,而低位字節存放在高地址(如剛才那樣),我們稱之為"Big-Endian";相反,高位字節存放在高地址,而低位字節存放在低地址,我們稱之為"Little-Endian"。在使用高級語言編程的今天,我們大部分時間不用去在意"字節序"的差別,因為這些都有系統底層支撐模塊幫我們判斷完成。

.NET中的值類型對象和引用類型對象在內存中同樣遵循"字節序"的規律,如下面一段代碼:

 1 //Code 3-3
 2 
 3 class Program
 4 {
 5     static void Main()
 6     {
 7         int a = 0x1a09;
 8         int b = 0x2e22;
 9         int c = b;
10     }
11 }

如上代碼Code 3-3所示,變量a、b、c在棧中存儲結構如下圖3-4:

圖3-4 整型變量在棧中的存儲結構

如上圖3-4所示,圖中右邊為棧底(注意這里,通常情況下,棧底位於高地址,棧頂位於低地址)。依次將c、b和a壓入棧,圖中上部分為按"Big-Endian"的字節序存放數據,而圖中下部分為按"Little-Endian"字節序存放數據。

3.1.3 裝箱與拆箱

前面講到,new出來的值類型對象存放在棧中,new出來的引用類型對象存放在堆中(棧中有引用指向堆中的實例)。如果我們把棧中的值類型轉存到堆中,然后通過一個引用訪問它,那么這種操作叫"裝箱";相反,如果我們把裝箱后在堆中的值類型轉存到棧中,那么就叫"拆箱"。下面代碼Code 3-4表示裝箱和拆箱操作:

 1 //Code 3-4
 2 
 3 class Program
 4 {
 5     static void Main()
 6     {
 7         int a = 1; //NO.1
 8         object b = a; //NO.2
 9         int c = (int)b; //NO.3
10     }
11 }

如上代碼Code 3-4所示,NO.1定義一個整型變量a,它存放在棧中,NO.2處進行裝箱操作,將棧中的a的值復制一份到堆中,並且使用b引用指向它,NO.3處將裝箱后堆中的值復制一份到棧中,整個過程棧和堆中的變化情況見下圖3-5:

 

圖3-5 裝/拆箱棧和堆中變化過程

如上圖3-5所示,裝箱時將棧中值復制到堆中,拆箱時再將堆中的值復制到棧中。

使用時間短、主要是為了存儲數據的類型應該定義為值類型,存放在棧中,隨着線程中方法的調用完成,棧中的數據會不停地自動清理出棧,再加上棧一般情況下容量都比較有限,因此,建議類型設計的時候,值類型不要過大,而把那種體積大、程序需要長時間使用的類型定義為引用類型,存放在堆中,交給GC統一管理。同時,拆裝箱涉及到頻繁的數據移動,影響程序性能,應盡量避免頻繁的拆裝箱操作發生。

    注:圖3-5中棧的存儲是連續的,而堆中存儲可以是隨機的,具體原因參見本書后續有關"對象生命期"的第四章。

3.2 對象相等判斷

在面向對象的世界里,隨處充滿着"對象"的影子,那么怎么去判斷對象的相等性呢?所謂相等,指具有相同的組成、屬性、表現行為等,兩個對象相等並不一定要求相同。.NET對象的相等性判斷主要包括以下三個方面:

3.2.1 引用類型判等

 引用類型分配在堆中,棧中只存放對堆中實例的一個引用,程序只能通過該引用才能訪問到堆中的對象實例。對引用類型來講,只有棧中的兩個引用指向堆中的同一個實例時,才能說這兩個對象相等(其實是同一個對象),其余任何時候,對象都不相等,就算兩個對象中包含的數據一模一樣。用圖3-6表示為:

圖3-6 引用類型判等

如上圖3-6所示,左邊的a和b分別指向堆中不同的對象實例,雖然實例中包含相同的內容,但是它兩不相等;右邊的a和b指向堆中同一個實例,因此它們相等。

可以看出,對於引用類型來講,判斷兩個對象是否相等很簡單,直接判斷兩個對象引用是否指向堆中同一個實例,若是,則相等;其余任何情況都不相等。

    注:熟悉C/C++中指針的讀者應該很清楚,兩個不同的整型變量ab,雖然a的值和b的值相等(比如都為1),但是它們兩的地址肯定不相等(參見前面講到的"字節序")。.NET中引用類型判等其實就是比較對象在堆中的地址,不同的對象地址肯定不相等(就算內容相等)。另外,.NET中的String類型是一種特殊的引用類型,它不遵守引用類型的判等標准,只要兩個String包含相同的字符串,那么就相等,String類型判等更符合值類型的判等標准。

3.2.2 簡單值類型判等

簡單值類型包括.NET內置類型,比如int、bool、long等,這一類的比較准則跟現實中所說到的"相等"概念相似,只要兩者的值相等,那么兩者就相等,見如下代碼:

 1 //Code 3-5
 2 
 3 class Program
 4 {
 5     static void Main()
 6     {
 7         int a = 10;
 8         int b = 11;
 9         int c = 10;
10     }
11 }

如上代碼Code 3-5所示,a和c相等,與b不相等。為了與引用類型判等進行區分,見下圖3-7:

圖3-7 簡單值類型在棧中的存儲情況

如上圖3-7所示,假設按照"Big-Endian"的字節序排列,右邊是棧底,程序依次將c、b以及a壓入棧。我們可以看到,如果比較a和c的內容,"a==c"成立;但是如果比較a和c的地址,很明顯,a的(起始)地址為0x01,而c的(起始)地址為0x09,它兩的地址不相等。

簡單值類型的比較只關注兩者包含的內容,而不去關心兩者的地址,只要它們的內容相等,那么它們就相等。復合值類型也是比較兩者包含的內容,只是復合值類型可能包含多個成員,需要挨個成員進行一一比較,詳見下一小節。

    注:雖然筆者很不想在.NET的書籍中提到有關指針(地址)的話題,但是為了說明"引用類型判等"的標准與"值類型判等"的標准有何區別,還是稍微提到了指針。我們可以很容易對比發現,引用類型判等其實就是比較對象在堆中的地址,而對象在堆中的地址就是由棧中的引用來表示的,地址不同,棧中引用的值肯定不相等,把棧中引用想象成一個存儲堆中地址的變量,完全可以用簡單值類型的判等標准去判斷引用是否相等。

3.2.3 復合值類型判等

前面講過,復合值類型由簡單值類型、引用類型組成。既然也是值類型的一種,那么它的判等標准和簡單值類型一樣,只要兩個對象包含的內容依次相等,那么它們就相等。下面代碼Code 3-6定義了兩種復合值類型,一種只由簡單值類型組成,一種由簡單值類型和引用類型組成:

 1 //Code 3-6
 2 
 3 struct MultipleValType1 //NO.1
 4 {
 5     int _a;
 6     int _b;
 7     public MultipleValType1(int a,int b)
 8     {
 9         _a = a;
10         _b = b;
11     }
12 }
13 struct MultipleValType2 //NO.2
14 {
15     int _a;
16     int[] _ref;
17     public MultipleValType2(int a,int[] ref)
18     {
19         _a = a;
20         _ref = ref;
21     }
22 }
23 class Program
24 {
25     static void Main()
26     {
27         MultipleValType1 mvt1 = new MultipleValType1(1,2); //NO.3
28 
29         MultipleValType1 mvt2 = new MultipleValType1(1,2); //NO.4
30         // mvt1 equals mvt2 return true;
31         MultipleValType2 mvt3 = new MultipleValType2(2,new int[]{1,2,3}); //NO.5
32         MultipleValType2 mvt4 = new MultipleValType2(2,new int[]{1,2,3}); //NO.6
33         //mvt3 equals mvt4 retturn false;
34     }
35 }

如上代碼Code 3-6所示,創建兩個復合值類型,一個只包含簡單值類型成員(NO.1處),另一個包含簡單值類型成員和引用類型成員(NO.2處),最后創建了兩對對象mvt1和mvt2(NO.3和NO.4處)、mvt3和mvt4(NO.5和NO.6處),它們都存放在棧中。mvt1和mvt2相等,因為它兩包含相等的成員(_a都等於1,_b都等於2),相反,mvt3和mvt4卻不相等,雖然看起來它兩初始化是一樣的(_a都等於1,_ref都指向堆中一個int[]數組,並且數組中的值也相等),原因很簡單,按照前面關於"引用類型判等"的標准,mvt3中的_ref和mvt4中的_ref根本就不是指向堆中同一個對象實例(即mvt3._ref!=mvt4._ref)。為了更好地理解這其中的區別,請見下圖3-8:

圖3-8 復合值類型內存分配

如上圖3-8所示,創建的4個對象均存放在棧中,mvt1和mvt2包含相等的成員,因此它兩相等,但是mvt3和mvt4包含的引用類型成員_ref並不相等,它們指向堆中不同的對象實例,因此mvt3和mvt4不相等。

對於值類型而言,判斷對象是否相等需要按以下幾個步驟:

(1)若是簡單值類型,則直接比較兩者內容,如int、bool等;

(2)若是復合值類型,則遍歷對應成員:

    1)若成員是簡單值類型,則按照"簡單值類型判等"的標准進行比較;

    2)若成員是引用類型,則按照"引用類型判等"的標准進行比較;

    3)若成員是復合值類型,則遞歸判斷。

值類型判等是一個"遞歸"的過程,只要遞歸過程中有一次比較不相等,那么整個對象就不相等。詳見下圖3-9:

圖3-9 值類型判等流程

(本章未完)

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM