程式設計之基礎:資料型別(二)

周見智發表於2015-03-24

相關文章連線:

程式設計之基礎:資料型別(一)

高屋建瓴:梳理程式設計約定

動力之源:程式碼中的“泵”

完整目錄與前言

程式設計之基礎:資料型別(二)   

3.3 賦值與複製

3.3.1 引用型別賦值

通過賦值運算子"="操作後,運算子兩頭的變數應該是相等的,這個是永遠不會變的,不管在什麼計算機語言中,也不管是值型別還是引用型別甚至其它型別。將.NET中一個引用型別變數賦值給另外一個引用型別變數後,兩個變數相等,既然相等,那麼兩個變數(棧中引用)肯定是指向堆中同一個例項,見下程式碼Code 3-7:

 1 //Code 3-7
 2 
 3 class RefType //NO.1
 4 {
 5     int _a;
 6     bool _b;
 7     public RefType(int a,bool b)
 8     {
 9         _a = a;
10         _b = b;
11     }
12 }
13 class Program
14 {
15     static void Main()
16     {
17         RefType r = new RefType(1,true); //NO.2
18         RefType r2 = r; //NO.3
19     }
20 }

如上程式碼Code 3-7所示,程式碼先建立了一個引用型別RefType(NO.1處),然後建立該型別的一個物件(NO.2處),變數r指向該物件在堆中的例項,最後將變數r賦值給變數r2(NO.3處),該操作執行後,r和r2相等,都指向堆中同一個例項。見下圖3-10:

圖3-10 引用型別賦值

如上圖3-10所示,賦值後,棧中兩個變數指向堆中的同一例項。

可以看出,引用型別變數賦值後,對兩個變數中任何一個操作,都會影響另外一個,這種情況會發生在引用型別"傳參"過程中,詳見3.3.3小節。

3.3.2 值型別賦值

既然賦值後,兩個變數是相等的,既然相等,那麼兩個變數中包含的內容也應該一一相等。這個對於簡單值型別來講,很好理解,整型變數a(值為1)賦值給整型變數b後,a和b的值相等(值都等於1);對於複合值型別來講,賦值的過程就是成員的一一賦值,最後對應的成員一一相等,下面程式碼Code 3-8顯示了複合值型別賦值情況:

 1 //Code 3-8
 2 
 3 struct MultipleValType1 //NO.1
 4 {
 5     int _a;
 6     bool _b;
 7     public ValType(int a,bool b)
 8     {
 9         _a = a;
10         _b = b;
11     }
12 }
13 struct MultipleValType2 //NO.2
14 {
15     int _a;
16     int[] _ref;
17     public MultipleValType(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 MultipleValType(1,true); //NO.3
28         MultipleValType1 mvt2 = mvt1; //NO.4
29         MultipleValType2 mvt3 = new MultipleValType2(1,new int[]{1,2,3}); //NO.5
30         MultipleValType2 mvt4 = mvt3; //NO.6
31     }
32 }

如上程式碼Code 3-8所示,程式碼中建立了兩種複合值型別MultipleValType1和MultipleValType2,一種只由簡單值型別組成(NO.1處),另外一種由簡單值型別和引用型別組成(NO.2處),然後定義一個MultipleValType1型別的變數mvt1(NO.3處),將它賦值給變數mvt2(NO.4處),之後還定義了一個MultipleValType2型別的變數mvt3(NO.5處),將它賦值給變數mvt4(NO.6處)。賦值操作後,mvt1和mvt2相等,mvt3和mvt4相等,見下圖3-11:

圖3-11 值型別賦值

如上圖3-11所示(為了方便繪圖,注意圖中棧裡的資料並沒按正確順序儲存),值型別變數賦值時,逐個成員依次賦值,當成員中不包含引用型別時,賦值後兩個物件完全獨立(如mvt1和mvt2),操作其中一個不會影響另外一個;當成員中包含引用型別時,由於引用型別成員指向堆中同一例項,賦值後兩個物件不完全獨立(如mvt3和mvt4),操作其中一個有可能影響另外一個。

引用型別賦值和值型別賦值有很大的區別,前者傳遞的是物件在堆中的"地址"(索引、引用),其它都不變;後者會發生一次完整的"成員賦值",逐個成員依次賦值。正因為這種賦值差異的存在,導致引用型別和值型別在"傳參"過程中有明顯差別,詳見下一小節。

3.3.3 傳參

所謂"傳參",其實就是賦值,將實參賦給形參。由於.NET中的引用型別與值型別的賦值存在差異,所以在傳遞引數時,我們需要分清引數到底是什麼型別,這個非常重要,因為引用型別傳參後,實參和形參指向的是堆中同一個例項,使用形參操作堆中的例項,直接能影響到實參指向的例項;而值型別傳參後,形參是實參的一個副本,形參和實參包含有相同的內容,一般對形參進行的操作不會影響到實參,但也有例外,比如值型別中包含有引用型別的成員,不管在形參還是實參中,這個引用型別成員指向堆中同一個例項,通過該引用型別成員,形參照樣可以影響到實參。

    注:"形參"指方法執行時,用於接收(儲存)外部傳值的臨時變數,它在方法體內部可見;"實參"則是呼叫方在呼叫方法時,用來給方法傳遞引數的變數,它在方法體內部不可見。

void Func(int a)

{

        //這裡的a是形參

        //…

}

呼叫方法程式碼:

int a = 1;

Func(a); //這裡的a是實參,實參a將值賦給形參a

下面程式碼演示值型別傳參和引用型別傳參:

 1 //Code 3-9
 2 
 3 class RefType //NO.1
 4 {
 5     public int a;
 6     public bool b;
 7     public RefType(int a,bool b)
 8     {
 9         this.a = a;
10         this.b = b;
11     }
12 }
13 struct ValType //NO.2
14 {
15     public int a;
16     public int[] ref;
17     public ValType(int a,int[] ref)
18     {
19         this.a = a;
20         this.ref = ref;
21     }
22 }
23 class Program
24 {
25     static void Main()
26     {
27         RefType rt = new RefType(1,true); //NO.3
28         UpdateRef(rt); //NO.4
29         // rt.a == 2
30         ValType vt = new ValType(1,new int[]{1,2,3}); //NO.5
31         UpdateVal(vt); //NO.6
32         // vt.a == 1 and vt.ref contains {2,2,3}
33     }
34     static void UpdateRef(RefType tmp)
35     {
36         tmp.a += 1; //NO.7
37     }
38     static void UpdateVal(ValType tmp)
39     {
40         tmp.a += 1; //NO.8
41         tmp.ref[0] += 1; //NO.9
42     }
43 }

如上程式碼Code 3-9所示,程式碼中定義了一個引用型別RefType(NO.1處)和一個值型別ValType(NO.2處),然後分別建立一個變數rt(NO.3處)和vt(NO.5處),將這兩個變數作為實參,分別傳遞給UpdateRef()方法和UpdateVal()方法,UpdateRef方法改變形參tmp中成員a的值(加1,NO.7處),這時候,實參rt的成員a也會受到影響(也會加1);UpdateVal方法中改變形參tmp中成員a的值(加1,NO.8處),實參vt的成員a不會受到影響,但是,UpdateVal方法中使用形參tmp中引用型別成員ref去改變堆中陣列中的值時(首元素加1,NO.9處),實參vt中的陣列也會受到影響。詳細過程參見下圖3-12:

圖3-12 引用型別傳參與值型別傳參

如上圖3-12所示(為了方便繪圖,注意圖中棧裡的資料並沒按正確順序儲存),左邊為傳參之前棧和堆中的情況,右邊為傳參之後棧和堆中的情況。可以看出,引用型別傳參時,形參tmp可以完全操控實參rt指向的例項,而值型別傳參時,形參tmp不能操控vt中值型別成員,但是仍然可以通過引用型別成員ref操控vt中ref指向的堆中例項。

引用型別傳參和值型別傳參各有優點,可以分場合使用不同的型別進行傳參,有些時候我們在呼叫方法時,希望方法在操作形參的時候,同時影響到實參,也就是說希望方法的呼叫能夠對方法體外部變數起到效果,那麼我們可以使用引用型別傳參;如果僅僅是為了給方法傳遞一些必需的數值,讓方法能夠正常執行,不需要方法的執行能夠影響到外部變數,那麼我們可以使用值型別(不包含引用型別成員)傳參。

不管哪種型別傳參,我們會發現,當有引用型別出現的時候(值型別中可以包含引用型別成員),方法體內總能通過形參去影響到實參,尤其是在呼叫一些別人開發好的類庫,由於我們根本不知道類庫中一些方法的具體實現,所以很難確定呼叫的方法會不會影響到我們傳進去的實參,如果此時我們恰恰不希望方法能夠影響到我們傳遞進去的實參,那該怎麼做才能確保方法執行時(後),我們的實參不會受到影響呢?我們可以以實參為基礎,複製出一個一模一樣的副本,然後將該副本傳遞給方法,這樣一來,方法體內部就不會影響到原來的物件。複製分兩種,一種叫"淺複製",另一種叫"深複製",詳見下一小節。

3.3.4 淺複製

所謂"淺複製",類似值型別賦值,將源物件的成員一一進行拷貝,生成一個全新的物件。新物件與源物件包含相同的組成,值型別賦值就是一種"淺複製"。對於引用型別,怎麼實現淺複製呢?下面程式碼Code 3-10演示引用型別怎樣實現淺複製:

 1 //Code 3-10
 2 
 3 class RefType:IClonable //NO.1
 4 {
 5     int _a;
 6     int[] _ref;
 7     public RefType(int a,int[] ref)
 8     {
 9         _a = a;
10         _ref = ref;
11     }
12     public Object Clone()
13     {
14         return new RefType(_a,_ref); //NO.2
15     }
16 }
17 class Program
18 {
19     static void Main()
20     {
21         RefType rt = new RefType(1,new int[]{1,2,3}); //NO.3
22         RefType rt2 = rt; //NO.4
23         RefType rt3 = rt.Clone(); //NO.5
24     }
25 }

如上程式碼Code 3-10所示,程式碼定義了一個引用型別RefType(NO.1處),它包含一個值型別成員和一個引用型別成員。RefType實現了IConable介面,該介面包含一個Clone()方法,意為克隆出一個新物件,在實現Clone()方法時,方法中呼叫了RefType的構造方法建立一個全新的RefType物件(NO.2處),並將其返回。顯而易見,新建立的副本與源物件包含相同的組成。接著,在客戶端程式碼中,我們例項化一個RefType物件(NO.3處),我們首先執行"賦值"操作,將引用rt賦給rt2(NO.4處),這時候rt和rt2指向了堆中同一個例項,緊接著,我們呼叫rt的Clone方法,將返回值賦給rt3(NO.5處),這時候rt3指向了堆中另外一個例項,見下圖3-13:

圖3-13 引用型別的淺複製

如上圖3-13所示,很容易看到,引用型別的賦值和複製之間的區別,"rt2=rt;"這樣的賦值語句,導致rt和rt2指向堆中的同一例項;而"rt3=rt.Clone();"這樣的複製語句,不會導致rt和rt3指向堆中的同一例項,rt3會指向一個副本,而該副本的內容與原物件中的內容一致,成員之間進行了一一拷貝。我們將圖3-13與圖3-11進行比較會發現,引用型別的淺複製與值型別賦值(前面說過,值型別賦值就是一種淺複製)非常相似,都是將成員進行逐一拷貝,產生了一個全新的物件,唯一的區別是:值型別的淺複製發生在棧中,而引用型別的淺複製發生在堆中。物件淺複製的過程見下圖3-14:

圖3-14 物件淺複製過程

如上圖3-14所示,任何一個物件包含三種成員:簡單值型別、複合值型別以及引用型別。淺複製發生時,簡單值型別成員直接一一賦值,引用型別成員直接一一賦值,複合值型別成員由於本身可以再包含其它的成員,所以需要遞迴賦值。

    注:intbool本質上也是struct型別,本書中只是將這些.NET內建型別歸納為"簡單值型別",簡單值型別與複合值型別的定義並不是官方的。另外淺複製也稱為"淺拷貝"。

不管是值型別還是引用型別,它們的淺複製都只是將物件內部成員進行一一賦值,然後產生一個新物件。如果成員是引用型別,那麼新物件與源物件中該引用型別成員會指向堆中同一例項,換句話說,複製產生的副本與源物件仍有關聯(見圖3-13中,rt與rt3中的_ref指向同一個整型陣列),操作其中一個很有可能影響到另外一個。要想複製出來的副本與源物件徹底斷絕關聯,那麼需要將源物件成員(包括所有直接成員和間接成員)中所有的引用型別成員全部進行淺複製,如果引用型別成員本身還包括自己的引用型別成員,那麼必須依次遞迴進行淺複製,由上向下遞迴進行淺複製的過程叫"深複製",詳細過程請參見下一小節。

    注:對於值型別來講,淺複製出來的副本與源物件是相等的,原因很簡單,副本與源物件中包含的成員一一相等;但是對於引用型別來講,淺複製出來的副本與源物件是不相等的,原因也很簡單,副本和源物件在堆中佔有不同的記憶體地址。

3.3.5 深複製

"淺複製"僅僅是將物件成員進行一一賦值,而無論成員是簡單值型別、複合值型別還是引用型別,這就造成了一個問題:當一個物件包含有引用型別成員(包括直接成員和間接成員)時,淺複製出來的副本內部與源物件內部都包含一個指向堆中同一例項的引用。要想避免此問題,物件在進行淺複製時,如果存在引用型別成員,不能直接賦值,必須對該引用型別成員再進行淺複製,如果該引用型別成員本身還包含引用型別成員,必須依次遞迴進行淺複製。

    注:直接成員指物件包含的第一級成員,間接成員指物件包含的一級成員(比如引用型別成員和複合值型別成員)本身包含的成員。

下圖3-15顯示了引用型別賦值、淺複製、深複製的區別:

圖3-15 引用型別的深複製

如上圖3-15所示,淺複製只是將物件的直接成員一一賦值,包括引用型別成員;而深複製指將物件的所有(直接或間接的)引用型別成員依次進行淺複製。值型別的賦值、淺複製、深複製的區別見下圖3-16:

圖3-16 值型別的深複製

如上圖3-16所示,值型別的賦值與淺複製的效果是一樣的,值型別的深複製指將物件的所有(直接或間接的)引用型別成員依次進行淺複製。

顯而易見,不管是值型別還是引用型別的深複製,均是一個遞迴的過程,它要求物件的所有引用型別成員均能夠進行淺複製。下圖3-17顯示了物件深複製的過程:

圖3-17 物件深複製過程

如上圖3-17所示,物件深複製發生時,簡單值型別成員直接一一賦值;複合值型別成員由於本身可以包含其它引用型別成員,所以它需要遞迴淺複製;引用型別成員不再直接一一賦值,而是需要進行淺複製,如果引用型別成員中又包含其它引用型別成員,那麼依次遞迴淺複製。下面程式碼Code 3-11顯示了一個值型別的深複製:

 1 //Code 3-11
 2 
 3 struct ValType //NO.1
 4 {
 5     int _a;
 6     RefType _ref;
 7     public ValType(int a,RefType ref)
 8     {
 9         _a = a;
10         _ref = ref;
11     }
12     public ValType Clone()
13     {
14         return new ValType(_a,_ref.Clone()); //NO.2
15     }
16 }
17 class RefType //NO.3
18 {
19     int _b;
20     bool _c;
21     public RefType(int b,bool c)
22     {
23         _b = b;
24         _c = c;
25     }
26     public RefType Clone()
27     {
28         return new RefType(_b,_c); //NO.4
29     }
30 }
31 class Program
32 {
33     static void Main()
34     {
35         ValType vt = new ValType(1,new RefType(2,true)); //NO.5
36         ValType vt2 = vt; //NO.6
37         ValType vt3 = vt.Clone(); //NO.7
38     }
39 }

如上程式碼Code 3-11所示,先定義了一個複合值型別ValType(NO.1處),它包含一個簡單值型別成員和一個引用型別成員,然後給該型別定義了一個Clone()方法,注意該方法中,並不是像淺複製那樣逐個成員一一賦值,而是當遇到引用型別成員時,對引用型別成員同樣呼叫了Clone方法進行淺複製(_ref.Clone())。在定義的引用型別RefType中(NO.3處),同樣給出了一個Clone方法,實現該型別的淺複製(NO.4處)。最後在客戶端程式碼中,先定義了一個值型別vt(NO.5處),然後將vt的賦值給vt2(相當於淺複製,NO.6處),將vt深複製出來的返回值賦給vt3(NO.7處),最後淺複製出來的副本vt2和深複製出來的副本vt3的區別見下圖3-18:

圖3-18 淺複製與深複製區別

如上圖3-18所示,vt2是vt淺複製出來的副本,由於源物件vt中包含有引用型別,很顯然,副本vt2與 源物件vt仍有瓜葛;相反,vt3是vt深複製出來的副本,我們可以看見,vt3與vt毫無關聯,對vt3的任何操作都不會影響到vt。

Code 3-11中的ValType型別中只包含一個引用型別成員,如果它還包含有其它的複合值型別成員,那麼該成員必須也要提供淺複製的方法,另外引用型別RefType中僅僅包含兩個簡單值型別,如果還包含其它的引用型別或者複合值型別成員,那麼這些成員都必須提供能夠淺複製的方法,這樣要求的原因很簡單:物件深複製是一個遞迴的過程,每個引用型別、複合值型別成員都必須能夠淺複製自己。正因為這種限制的存在,所以並不是每種型別都能夠進行深複製操作,一種型別能夠進行深複製操作的前提是,它所有的引用型別成員(包括直接和間接的)都必須提供深複製的方法。

    注:.NET中可以使用"序列化和反序列化"的技術實現物件的深複製,只要一個型別以及該型別中所有的成員型別都標示為"可序列化",那麼我們就可以先序列化該型別物件到位元組流,然後再將位元組流反序列化成源物件的副本,這樣一來,源物件與副本之間沒有任何關聯,達到深複製的效果。

3.4 物件的不可改變性

3.4.1 不可改變性定義

一個型別物件建立後,它的狀態不能再改變,直到它死亡,它的狀態一直維持著跟它建立時一樣。這時候稱該物件具有不可改變性,稱這樣的型別為不可改變型別(Immutable Type)。

    注:有的地方稱這樣的物件具備"常量性"。

不可改變物件在建立的時候,必須完全初始化,因為物件的狀態之後再沒機會發生改變。如果想要在不可改變物件的身上進行操作,試圖想讓它"變成"另一個全新的物件,多數時候,該操作只會返回一個全新的物件,如String型別就是一種不可改變型別,對它的所有操作,String.Replace()、String.Trim()等方法都不會影響原有String物件,取而代之的是,這些方法都會返回一個全新的String物件。另外,.NET中的委託型別也是不可改變的,我們對一個委託進行的所有操作,均會產生一個全新的委託,而不會改變委託本身,詳見本書後面有關委託與事件的章節。下圖3-19顯示在String物件上呼叫ToUpper()方法,不會影響原有物件:

圖3-19 String型別的不可改變特性

    注:String型別是一個特殊的型別,前面提到過,它雖然是引用型別,但是它不遵守引用型別判等的標準;另外,它還是不可改變型別,所有的操作均不會改變原有的String物件。

3.4.2 定義不可改變型別

定義一個不可改變型別時需要注意以下三點:

    1)型別的構造方法一定要設計好,能夠充分的初始化物件,因為物件建立後,無法再修改,構造方法是唯一能夠修改物件狀態的地方;

    2)涉及到改變物件狀態的方法均不能真正地改變物件本身,而都應該返回一個全新的物件,否則操作就沒有實際意義;

    3)型別所有的公開屬性都應該是隻讀的,並且注意一些引用型別雖然是隻讀的,但是在型別外部還是可以通過只讀的引用去改變堆中的例項,從而能夠修改原物件的狀態。

下面程式碼定義了一個不可改變型別:

 1 //Code 3-12
 2 
 3 class ImmutableType
 4 {
 5     private int _val;
 6     private int[] _ref;
 7     public int Val
 8     {
 9         get //NO.1
10         {
11             return _val;
12         }
13     }
14     public int[] Ref
15     {
16         get //NO.2
17         {
18             int[] b = new int[_ref.Length];
19             for(int i=0;i<b.Length;++i)
20             {
21                 b[i] = _ref[i];
22             }
23             return b;
24         }
25     }
26     public ImmutableType(int val,int[] ref) //NO.3
27     {
28         _val = val;
29         _ref = ref;
30     }
31     public ImmutableType UpdateVal(int val)
32     {
33         return new ImmutableType(this.Val + val,this.Ref); //NO.4
34     }
35 }
36 class Program
37 {
38     static void Main()
39     {
40         ImmutableType a = new ImmutableType(1,new int[]{1,2,3}); //NO.5
41         a = a.UpdateVal(2); //NO.6
42     }
43 }

如上程式碼Code 3-12所示,程式碼建立了一個不可改變型別ImmutableType,它包含兩個成員,一個值型別和一個引用型別,值型別屬性是隻讀的(NO.1處),引用型別屬性也是隻讀的(NO.2處),注意該引用型別屬性不是簡單的將引用對外公開,而是深度複製出來了一個副本,對外公開的是這個副本引用,外部不能使用該副本修改類內部的_ref陣列。型別的構造方法也很完善,能夠初始化所有成員(NO.3處),型別還包含一個"更新"_val成員的方法UpdateValue(),它的功能就是將_val增加一個指定數,但是我們可以看見,操作並沒有真正地影響到物件本身,而是新new出了另外一個全新物件(NO.4處),將所有的效果都轉嫁給新建立的物件。NO.5處建立一個不可變物件,NO.6處呼叫物件的UpdateValue()方法,並將返回的新物件賦給a引用,NO.5和NO.6處棧和堆中的變化見下圖3-20:

圖3-20

如上圖3-20所示,左邊為執行"a=a.UpdateValue(2);"之前棧和堆中的情況,右邊為執行之後棧和堆中的情況,可以看到,對a的操作實質上不會改變堆中的物件例項,只會產生一個全新的物件。

3.5 本章回顧

學習"資料型別"是能夠學習好程式設計的前提,熟練掌握各種資料型別的特點有利於提高我們程式設計的效率、提升開發系統效能以及穩定性。本章介紹了.NET中的兩種資料型別:值型別和引用型別,並分別從物件的記憶體分配、物件判等、變數賦值以及物件複製等方面一一做出了介紹。章節最後還提到了不可改變型別,熟悉每種資料型別的不同表現差異是我們能夠編寫出好程式碼、好程式的第一步。

3.6 本章思考

1."值型別物件分配在棧中,引用型別物件分配在堆中"這句話是否準確?為什麼?

A:嚴格上講,不太準確,值型別物件也可以被包含在引用型別物件內部,一起分配在堆中,因此不能一概而論。

2."值型別物件的賦值就等於物件的淺複製"這句話是否正確?為什麼?

A:正確。值型別物件賦值的過程就是淺複製的過程,依次將物件成員一一進行拷貝。

3.下面程式碼Code 3-13執行後會輸出"true",因此可以判斷String是值型別,因為只有值型別判等時才比較兩者所包含的內容是否一一相等,以上陳述是否正確?為什麼?

 1 //Code 3-13
 2 
 3 class Program
 4 {
 5     static void Main()
 6     {
 7         String a = new String("123");
 8         String b = new String("123");
 9         if(a == b)
10         {
11             Console.WriteLine("true");
12         }
13         else
14         {
15             Console.WriteLine("false");
16         }
17     }
18 }

A:錯。String型別是一個特殊的引用型別,它的判等不同於其它引用型別去比較物件引用是否指向堆中同一例項,而是和值型別判等一致,比較物件內容是否一一相等。除此之外,String型別還是不可改變型別,對String物件的任何操作均不能改變該物件。

4.簡要描述深複製與淺複製的區別。

A:物件進行淺複製時,只將物件的直接成員一一進行拷貝,當物件包含有引用型別成員時,源物件與副本之間有關聯;物件進行深複製時,會將物件的所有成員(包括直接成員於間接成員)依次進行拷貝,不管物件是否包含引用型別成員,源物件與副本都無任何關聯。

(本章完)

 

相關文章