第 43 講:結(jié)構(gòu)(二):更進(jìn)一步
前文我們簡單對結(jié)構(gòu)的用法作了一個介紹,可以看到結(jié)構(gòu)和類的用法基本上一樣,只是部分細(xì)節(jié)需要注意,比如無參構(gòu)造器什么的。下面我們進(jìn)一步對結(jié)構(gòu)類型進(jìn)行探討,讓你對結(jié)構(gòu)有一個更深層的認(rèn)識。
本節(jié)內(nèi)容或多或少會從 C 語言和 C++ 那邊拿一點(diǎn)東西過來說明。如果你沒有對 C 語言和 C++ 很熟悉的話,可能有一些地方看不太懂。不過,這里主要還是在說 C#,所以即使你看到了不太明白的地方,也沒有啥大問題,暫時先跳過它們就行。
Part 1 可變和不可變結(jié)構(gòu)
和類一樣,結(jié)構(gòu)也具有完全一致的用法和書寫規(guī)則。不過,類里我們可以對字段設(shè)置 readonly
,也可以不設(shè)置。如果不設(shè)置 readonly
,那么這個字段就有可能可提供在以后修改和變動。
如果一個類型的所有數(shù)據(jù)成員都無法在以后修改,只能在聲明的時候(用 new
實(shí)例化出來的對象)才能指定數(shù)據(jù)的話,那么這樣的類型我們稱為不可變類型(Immutable Type);相反地,如果數(shù)據(jù)在實(shí)例化后,仍然可修改的話,那么這個數(shù)據(jù)類型我們稱為可變類型(Mutable Type)。
類和結(jié)構(gòu)一樣,既可以定義為可變類型,也可以定義為不可變類型。舉個例子,我們還是拿 Person
說明:
比如這個例子。我們把 Age
和 IsBoy
都設(shè)置成了可提供后續(xù)修改的、底層字段沒有 readonly
修飾的屬性。此時,Person
Part 2 值類型和引用類型的賦值行為
在寫代碼的時候,我們經(jīng)常碰到這樣的代碼:
這個代碼是想把右側(cè)的 a
賦值給左側(cè)的 b
。那么,如果 a
是引用類型的話,這種賦值過程到底是想表達(dá)什么意思呢?具體行為是什么樣的呢?
引用類型就意味著類型是以引用形式傳遞的。我們之前說過,C# 是從 C 語言和 C++ 拓展起來的,因此我們?yōu)榱舜蠹夷苊靼紫旅娴臇|西,我們從 C 語言和 C++ 層面來介紹。
在 C++ 里,傳參模式分 3 種:
值傳遞
引用傳遞
指針傳遞
C# 把這個傳參機(jī)制沿用了下來。
在 C 語言和 C++ 里,值傳遞指的是把數(shù)據(jù)的具體內(nèi)容全都拷貝一份過來,然后賦值給另外一個變量。這也就意味著,賦值方和接收方兩邊的數(shù)據(jù)都是完全一樣的,而且還是獨(dú)立開來的。
接著。C 語言里是沒有引用傳遞這個概念的,因此我們只能剖析 C++ 里的引用傳遞。在 C++ 里,引用傳遞是想用一個所謂的引用(Reference)來表示一個數(shù)據(jù)。它相當(dāng)類似于指針的地址數(shù)值,但也有不同。引用的好處在于,你可以把兩個類型一致的變量通過引用進(jìn)行賦值;但是,通過指針賦值的話,因為接收方是一個指針變量(即存儲的是地址),那么左右兩側(cè)的變量的書寫格式和規(guī)范就可能“不對稱”。
我們對比一下 C 語言和 C++ 里,指針變量的賦值過程,你就知道了。
在指針賦值的時候,比如 int *p = &a
里,由于 a
是一個普通的變量,而接收方 p
卻是一個指針變量,因此我們不得不對右側(cè)的變量 a
追加 &
,即取地址符號。而此時,左側(cè)變量 p
和右側(cè)變量 a
語法上就不對等了:左側(cè)的 p
是一個單純的 p
,但右邊的 a
必須寫 &a
這一點(diǎn)賦值可能你還感受不出來有多大的問題。在 C 語言里,存在一種叫做數(shù)組指針的概念,即一個指針變量,用于指向一個數(shù)組。那么假設(shè)我們這個數(shù)組是一維數(shù)組的話,那么整個數(shù)組指針就得這么寫:
可以看到代碼的第 7 行,我們聲明了一個 pArr
數(shù)組指針變量,這個變量用來接收一個數(shù)組的地址,表示左邊的變量是指向右邊的這個變量的。此時這個 &arr
的地址符號 &
是不能省略的。
可以看到這種書寫格式完全就“亂套”了。左邊的聲明居然寫成 int (*pArr)[3]
這樣復(fù)雜的東西,而右邊卻又是一個很簡單很簡單的 &arr
的取地址表達(dá)式。在語法上來說,它們寫法完全不同,我把這種行為稱為“不對稱的語法”。
可是,C# 引入了一種新的賦值模式:引用。賦值過程就會變得相當(dāng)簡單了:
因為 C# 里,數(shù)組符號 []
必須靠向聲明類型一側(cè),所以寫成 int[] arr
而沒有寫成 int arr[]
;而另一方面,在引用賦值的時候,refArr
這個變量是直接用 = arr
賦值的。在 C++ 和 C 語言里,我們無法這么賦值,因為左側(cè)的數(shù)組必須要么通過初始化器賦值,要么隨后賦值,反正必須得定義長度,而不能用變量賦值給它:因為 C 語言和 C++ 里數(shù)組是一個指針(首地址)表達(dá)的一個存在,而直接賦值給左邊,就變成了“指針賦值給數(shù)組”的錯誤過程。
即使 C++ 里有引用這個東西,我們?nèi)匀恢荒軐懗深愃茢?shù)組指針一樣復(fù)雜的格式:int (&refArr)[3] = arr
,還是很長也很不好看。但是這點(diǎn)在 C# 里,語法就很輕松了。
回到這里。引用傳遞和指針傳遞,實(shí)際上都只是把地址傳遞了一份過去,因此,這兩個變量(賦值方和接收方)是使用的同一塊內(nèi)存空間的。比如 C# 里,數(shù)組是引用類型,那么必然是引用傳遞:
想必你很快就能知道輸出結(jié)果是多少。沒錯,答案是 0。因為你在更改 refArr
指向的內(nèi)存空間的第二個元素,但 arr
和 refArr
是引用傳遞,因此修改 refArr
里的元素,就等效于在修改 arr
里的元素。因為它們指向的同一塊內(nèi)存空間,修改里面的元素其實(shí)是在更改指向的內(nèi)存空間,這里面的東西。
因此,引用類型是傳遞引用,而引用傳遞和地址傳遞(指針)的區(qū)別是,地址傳遞會導(dǎo)致語法不對稱,而引用傳遞不會,因此引用傳遞是一種非常方便便捷的賦值行為。而值傳遞則直接是把內(nèi)存空間的數(shù)據(jù)全部拷貝一份過去,因此數(shù)據(jù)是獨(dú)立的兩個個體,不會有引用傳遞那樣“更改一邊會影響另一邊”的問題。
總之,值類型是值傳遞,而引用類型是引用傳遞,因此如果遇到最開頭的那句話:
如果 Person
是值類型,那么 b
和 a
就是完全獨(dú)立的兩個個體;而 Person
是引用類型的話,b
和 a
就指向同一塊內(nèi)存空間。
Part 3 復(fù)制構(gòu)造器
C# 里,我們允許對類指定一個帶有一個同類型參數(shù)的構(gòu)造器,這樣的構(gòu)造器稱為復(fù)制構(gòu)造器。
another
是的,C# 的引用類型里,this
引用是無法修改的。因為 GC。我之前就說過,GC 只看堆內(nèi)存。而引用類型剛好就是堆內(nèi)存存儲的數(shù)據(jù)。在 GC 回收一次后,漏出來的部分可能會通過“緊湊”處理把空閑部分拼起來放在一起??蓡栴}在于,此時所有引用類型的對象的地址就有可能產(chǎn)生變化。this
本身就指代了一個對象,因為我們直接在類里賦值,所以這種感覺其實(shí)并不能感受到。我們實(shí)際上在書寫 Age = age
的時候,實(shí)際上是在對我們特有的一個對象(用 this
表示的)來賦值的。
地址的變動與否是 GC 控制的,那么 C# 這個編程語言自然比我們更懂處理過程,所以,C# 不允許我們手動修改 this
的指向。
但是,這一點(diǎn)對于值類型來說就沒有那么重要了。值類型可放在棧內(nèi)存里,這使得對象并不會受到 GC 的“干擾”。那么,這么賦值就沒問題了。
Part 4 盡量不要用可變結(jié)構(gòu)
可變結(jié)構(gòu)和不可變結(jié)構(gòu)到底有什么區(qū)別?而標(biāo)題這個說法,又是為什么?下面我們來對這個說法作一個介紹。
前面我們介紹了值類型的傳遞模式是值傳遞。如果是可變結(jié)構(gòu)的話,某些地方的賦值模式會使得編譯器對代碼分析上出現(xiàn)一些問題。
假如我創(chuàng)建了一個 Person
結(jié)構(gòu)構(gòu)成的數(shù)組。
first
、second
和 third
thirdOne
修改到 persons
里的 third
了嗎?這個問題就好比我有一個 int[]
,然后我取出其中一個元素,然后修改掉元素的數(shù)值,問你是不是修改了原始的數(shù)據(jù)一樣。
實(shí)際上,并沒有。我們這里的等號兩側(cè)是 Person
類型,是一個值類型。按照值類型的執(zhí)行規(guī)則,我們是復(fù)制副本,因此 thirdOne
和 third
應(yīng)該是兩個完全獨(dú)立的個體,只是數(shù)值是一樣的。
另外,照著這個規(guī)則行事的話,我們?nèi)L試修改了 thirdOne
里的 Age
屬性,改成 30,也只是改掉了副本的數(shù)據(jù),原始數(shù)據(jù)并沒有變動。
這就是為什么不要使用可變結(jié)構(gòu)??勺兘Y(jié)構(gòu)在這個代碼體現(xiàn)上會有和我們自身理解沖突的不一樣的地方。C# 世界里有這么一句話:
Mutable structs are evil.
可變結(jié)構(gòu)都是惡魔。
Part 5 類型的默認(rèn)數(shù)值和 default
表達(dá)式
在之前 switch
語句里,我們用到了 default
的一種用法,是表達(dá)默認(rèn)情況?,F(xiàn)在我們介紹一下第二種用法:default(T)
。其中的 T
指的是一個數(shù)據(jù)類型的名字。這表示獲取這個數(shù)據(jù)類型的默認(rèn)值是多少。
在值類型里,我們使用 default(T)
表達(dá)式,得到的結(jié)果一定是和 new T()
一樣的結(jié)果;但是在引用類型里,default(T)
一定是 null
。下面我們來說一下,這是什么意思。
假設(shè) Person
是值類型,那么這三個賦值語句里,第三個是錯誤的賦值:你無法把 null
當(dāng)成 Person
的數(shù)值賦值過去,因為它表示“對象沒有分配內(nèi)存空間”,但值類型是受到方法自動控制的,所以它一定是有內(nèi)存分配的,因此最后一個寫法是錯的。而前面兩個是對的。
實(shí)際上, 是值類型的話,這兩個寫法是等價的,這表示 b
的所有字段都是默認(rèn)數(shù)值(_name
是 null
,_age
是 0,而 _isBoy
是 false
)。
但是,如果 Person
是引用類型的話,第三種寫法就是正確的了。在引用類型里,default
表達(dá)式的默認(rèn)結(jié)果一定是 null
,因此第一種和第三種等價。而第二種,如果 Person
類里擁有無參構(gòu)造器的話,那么就會自動執(zhí)行無參構(gòu)造器的行為;但如果沒有的話,就會產(chǎn)生編譯器錯誤??傊?,值類型和引用類型在這里有不一樣的地方。
Part 6 裝箱和拆箱
要想知道裝箱和拆箱的規(guī)則,我們就得先了解一下值類型和引用類型的繼承關(guān)系。
引用類型是默認(rèn)從 object
類型派生的。如果你定義了繼承關(guān)系,那么這個基類型就得從 object
派生;如果這個基類型也有繼承關(guān)系,那么就繼續(xù)往上倒回去找基類型。總之只要有一個沒有自己寫繼承關(guān)系的地方,就從 object
派生。
但是值類型不同。值類型是不能自己定義繼承關(guān)系的。這一點(diǎn)對于值類型來說相當(dāng)不方便,但也是沒有辦法的事情。值類型本身體現(xiàn)的是一組可以直接復(fù)制副本的輕量級數(shù)據(jù)類型。都輕量級了,那么肯定就沒有辦法從基類型繼承下來一些東西,不然這個類型不就太大了。所以,值類型是無法自定義繼承關(guān)系的。但是,它默認(rèn)是從一個叫做 ValueType
的類派生下來的。換句話說,繼承關(guān)系是這樣的:

總的來說,我們不能對值類型自定義繼承關(guān)系,但可以對引用類型定義繼承關(guān)系。但值類型默認(rèn)從 ValueType
類型派生,而它自動從 object
類型派生;而引用類型可以隨便定義繼承關(guān)系,但最終都是從 object
類型派生下來的。
搞懂這一點(diǎn)之后,我們來說明一下裝箱(Box)和拆箱(Unbox)。
裝箱:值類型對象轉(zhuǎn)換和賦值給引用類型基類型的過程。
拆箱:引用類型對象轉(zhuǎn)換和賦值給值類型子類型的過程。
這么說不太明白的話,我們來舉個例子。首先我們知道 int
按照這個繼承關(guān)系的話,一定是從 object
派生的。那么我們可以這么定義一個多態(tài)的賦值關(guān)系:
這個賦值是沒有問題的。因為 10 是 int
類型的字面量,而 int
本身就是從 object
派生下來的,因此按照繼承關(guān)系和多態(tài)的規(guī)則,這么賦值是沒有問題的。
int
類型(值類型),而 o
是 object
類型(引用類型)的關(guān)系,這么做會產(chǎn)生裝箱操作。所謂的裝箱,你可以理解成“打包”。你要把飯桌上的東西打包帶回去,總得有東西裝起來吧。這里的 o
變量好比是“家”這個容器(object
類型),而飯桌上的東西則當(dāng)成一個值類型。裝箱操作大概就是,用一個塑料袋把它們包裝起來,然后帶回去。
反過來,拆箱就是從家里帶酒水去外面的飯桌。顯然你帶出去還得拆開包裝盒吧,所以拆箱就是這么一個反方向的執(zhí)行行為。
拆箱的書寫代碼是這樣的:
即直接把類型轉(zhuǎn)回去。因為 o
是 object
類型的,而什么類型都可以往 object
這就是裝箱和拆箱。概念其實(shí)很好理解,但很多時候,我們不一定能夠察覺。我們來舉個例子。
C# 里提供了一種數(shù)據(jù)類型叫做 ArrayList
。這個數(shù)據(jù)類型你可以當(dāng)成一個順序表,它比數(shù)組好一點(diǎn)的地方在于,它可以增刪數(shù)據(jù)。用法是這樣的:
Add
這個實(shí)例方法,就可以對前面的這個 al
對象追加元素進(jìn)去了??蓡栴}在于,ArrayList
是為了通用才產(chǎn)生的,顯然我們不可能一定只放 int
數(shù)據(jù)進(jìn)去吧。所以,什么數(shù)據(jù)都可以放進(jìn)去的話,這個數(shù)組整體就必須找一個通用的數(shù)據(jù)類型才可以。
對了,object
類型剛好滿足需求。因此 ArrayList
在 Add
方法調(diào)用的時候,接收的參數(shù)類型是 object
類型的。那么,你傳入的這個 1、2、10,就會產(chǎn)生裝箱行為。
而如果要想取出這個 ArrayList
類型的數(shù)據(jù)元素的話,因為 ArrayList
本身是不知道里面的元素是什么類型的,因此你需要強(qiáng)制轉(zhuǎn)換:
當(dāng)然,C# 甚至還允許我們這么寫:
下面這種和上面這種其實(shí)是等價的。雖然看起來并不等價(畢竟 foreach
后面跟的類型完全不一樣了),但實(shí)際上,foreach
循環(huán)最終會被翻譯成自動強(qiáng)制轉(zhuǎn)換的過程,所以兩者是等價的。
而不管怎么說,這么書寫代碼總會類型轉(zhuǎn)換,所以會導(dǎo)致拆箱操作。
裝箱和拆箱和我們包裝食物和拆食物的包裝一樣,雖然這么做語法上和運(yùn)行上都沒有問題,但耗費(fèi)了不必要的性能,因此我們?yōu)榱损B(yǎng)成好習(xí)慣,要避免裝箱和拆箱行為。