第 31 講:面向?qū)ο缶幊蹋ㄈ簩傩院头庋b
Part 1 為什么需要封裝
面向?qū)ο髶碛幸恍┗镜奶匦浴_@些特性讓我們理解和明白面向?qū)ο蟮囊饬x。面向?qū)ο蟮娜筇匦裕?/span>封裝(Encapsulation)、繼承(Inheritance)和多態(tài)(Polymorphism)。我們?yōu)榱藢W(xué)習(xí)面向?qū)ο?,我們不能一口氣都介紹了,因此我們一個(gè)一個(gè)學(xué)習(xí)。今天先介紹的特性是封裝。
1-1 封裝的意義
考慮前面的代碼。我們寫了一個(gè) Person
類。不過,我們寫的代碼提供給別人使用的時(shí)候,就好像我們無法查看系統(tǒng)提供的類,只能使用一樣。
如果我們不將數(shù)據(jù)做好良好的包裝、數(shù)據(jù)處理好,別人就可能會(huì)亂使用數(shù)據(jù):比如說,給字段亂賦值。顯然,人的年齡按道理來說是必須處于 0 到大概 150 之間。如果別人拿到數(shù)據(jù)的時(shí)候,故意或者無意賦值 200,或者是 -15,按照數(shù)據(jù)類型來說,數(shù)據(jù)沒有超過這個(gè)數(shù)據(jù)類型的范圍;但是這些數(shù)據(jù)對于“人的年齡”這個(gè)數(shù)據(jù)來說,是無效的數(shù)值。于是,我們就得考慮這種預(yù)防錯(cuò)誤發(fā)生的情況。
可是,字段本身是用來賦值的,這我們無法防止;于是我們需要在某處給予數(shù)據(jù)的處理。舉個(gè)例子。我們書寫構(gòu)造器的時(shí)候,可以防止此點(diǎn)。
name
為 null
的時(shí)候,顯然名字是不合適的,于是我們就需要使用異常這個(gè)手段,終止程序來告知用戶輸入錯(cuò)誤;同理,age
如果超出合適的范圍的話,就通過另外的異常來產(chǎn)生錯(cuò)誤信息,告知用戶無法這么使用。
1-2 相關(guān)問題
1-2-1 ArgumentNullException
和 ArgumentOutOfRangeException
異常
這兩個(gè)異常是我們?nèi)率褂玫漠惓?。異常是我們通過積累來使用的,這一點(diǎn)我們之前講到異常結(jié)構(gòu)的時(shí)候,說過這一個(gè)部分的內(nèi)容。
這兩個(gè)異常專門表示參數(shù)數(shù)值不正確的時(shí)候的異常。前者的異常類型名稱帶有 null 這個(gè)單詞,這表示這個(gè)異常用在參數(shù)為 null
的時(shí)候;而后者寫的是 out of range,這表示參數(shù)的數(shù)值超出了合理的范圍。當(dāng)一個(gè)數(shù)據(jù)在賦值的時(shí)候超出了實(shí)際的范圍,我們可以使用這兩個(gè)異常來處理。另外需要注意的是,這兩個(gè)異常在 new
的時(shí)候,寫這個(gè)錯(cuò)誤參數(shù)的名字,比如說這里的 name
,我們就寫上這個(gè)名字:"name"
。因?yàn)槲覀冃枰獋魅氲氖清e(cuò)誤參數(shù)的參數(shù)名而不是它的數(shù)值,因此寫 "name"
而非 name
。
另外,這兩個(gè)異常類型要想使用,需要你使用 using System;
指令。這一點(diǎn)我們不必多說,因?yàn)榇蠖鄶?shù)異常類型都是在 System
這個(gè)命名空間下的。
1-2-2 異??梢越鉀Q問題,但為什么非得異常來報(bào)錯(cuò)呢?
問題不是很清晰。我們舉個(gè)例子來讓你明白這個(gè)問題想問什么。
我們剛才的代碼里,第 4 和第 7 行用了 throw
-new
語句來產(chǎn)生異常個(gè)體來報(bào)錯(cuò),但是之前我們說過一個(gè)方案:如果 name
參數(shù)為 null
的話,我們干脆給 Name
字段賦值 "<unknown name>"
來表達(dá)名稱不知道賦的是什么。那么問題來了,為什么我們不采用這個(gè)方案?
這個(gè)說起來不是很容易。實(shí)際上,我們完全可以采用這個(gè)方案。但我們使用了異常的方案來處理這種情況。假如我們針對 age
參數(shù)來理解的話:如果用戶賦值 -20 的話,顯然是不正常的賦值形式,因此我們得處理這種非法數(shù)值的情況。假設(shè)我們不使用異常來告知用戶,而是采用賦默認(rèn)值的形式來的話:
當(dāng)然你也可以寫成一句話:
Person
這會(huì)輸出什么?光看這段代碼的話,我們無從知道 Person
內(nèi)部的賦值和處理邏輯,而最終,明明輸入的是 -20,而輸出的字符串卻是 "Sunnie is 0 years old."
,用戶一看這代碼的執(zhí)行結(jié)果:欸,很奇怪??!輸入的 -20 怎么在輸出的時(shí)候改成 0 了?從另一方面來說,如果你在寫內(nèi)部的代碼的話,如果真這么處理的話,那我怎么知道我是真輸入了 0 這個(gè)合法數(shù)字,還是因?yàn)檩斎肓隋e(cuò)的數(shù)字而導(dǎo)致賦了默認(rèn)數(shù)值 0 呢?
因此,我們認(rèn)為,在這種情況下,在內(nèi)部將 -20 改成 0 的這種方案會(huì)導(dǎo)致很隱蔽的問題,因此我們不建議這么指定代碼的執(zhí)行方案,故我們采用了前者:拋出異常。
Part 2 屬性成員
前面我們介紹了一些完全基礎(chǔ)的成員類型:方法、字段和構(gòu)造器。接下來我們單獨(dú)拿一節(jié)內(nèi)容介紹另外一種新的成員類型:屬性(Property)。
2-1 引例
先來考慮前面的例子。我們在構(gòu)造器里可以處理屬性的賦值,可問題在于,如果我們想要重載構(gòu)造器的話,參數(shù)可能就不一致了:我們沒有必要非得把全部的參數(shù)都賦上數(shù)值,比如這里的 Name
字段。于是我們就可以考慮寫成兩個(gè)構(gòu)造器:
因?yàn)闃?gòu)造器有兩個(gè),因此我們就得針對于 age
參數(shù)使用同樣的數(shù)據(jù)校驗(yàn)的代碼(就那個(gè) if
)。顯然,寫兩遍是沒有必要的,因此,屬性這個(gè)成員就誕生了。
我們將前面的 Name
字段、Age
字段全部改成 private
的修飾符,并改成“下劃線+駝峰命名法”,而單獨(dú)添加一個(gè)。
public 類型 屬性名稱 { get { 操作 } set { 操作 } }
來書寫一個(gè)屬性。
2-2 語法描述
由于我們對每一個(gè)字段都寫了一個(gè)屬性,因此有三個(gè)屬性。三個(gè)屬性的寫法是類似的,因此我們選一個(gè)介紹即可。我們拿第二個(gè) Age
屬性作介紹。我們單獨(dú)抽取出 Age
屬性的代碼:
我們使用一對大括號,包裝 get
方法和 set
方法。我們將 get
方法稱為屬性的取值器(Getter),而把 set
方法稱為屬性的賦值器(Setter)。取值器是取出對應(yīng)字段的信息,因此我們直接寫 return _age;
、return _isBoy;
這樣的語句就好;而賦值器里則是給 _age
、_isBoy
這些字段賦值的過程,以及數(shù)據(jù)校驗(yàn)的過程。
另請注意,正是因?yàn)槿绱耍x值器的返回值類型是根據(jù)屬性本身動(dòng)態(tài)變化的,因此我們不在 get
上寫返回值類型;另外,get
set
方法里,我們會(huì)帶有一個(gè)從外部傳入的參數(shù),而不返回任何數(shù)據(jù)。這個(gè)外部傳入的參數(shù),它的數(shù)據(jù)類型也是根據(jù)屬性本身的類型來確定的。
正是因?yàn)橘x值器和取值器的參數(shù)類型和返回值類型動(dòng)態(tài)變化,且它們的簽名模式是固定的,因此我們只需要寫 get
和 set
關(guān)鍵字即可。而 set
方法里固定會(huì)從外部帶入一個(gè)數(shù)值進(jìn)來,因此這個(gè)數(shù)值是固定存在的,我們使用 value
關(guān)鍵字代表這個(gè)參數(shù)。比如說在代碼里,我們直接將參數(shù) value
拿來使用即可,而無需在意它為什么之前從沒聲明出來。
2-3 相關(guān)問題
2-3-1 為什么非要下劃線+駝峰?
可以看到,此處的字段從原始的帕斯卡命名法改成了下劃線+駝峰命名法。我們之前簡單說過,駝峰命名法和帕斯卡命名法僅僅是代碼的規(guī)范,并不屬于語法約定和要求。你完全可以不遵照這個(gè)規(guī)則來命名,因此這個(gè)寫法只是一個(gè)寫法而已。那么,為什么我們會(huì)采用這個(gè)寫法呢?下面我來說一下下劃線和駝峰的組合到底是為什么。
這個(gè)問題,我們需要將下劃線和駝峰命名法拆開,分成兩個(gè)部分來解釋。我們先來說駝峰。因?yàn)閷傩杂么髮憗肀WC我們前面賦值的過程和原始字段用的大寫作匹配,而大寫開頭這個(gè)是 C# 建議的一種規(guī)范,因此,我們將屬性用大寫字母開頭的帕斯卡命名法;而此時(shí),字段變成了后臺(tái)的數(shù)據(jù)存儲(chǔ)了,因此字段此時(shí)被改成了 private
,我們無法從外部訪問和使用到它。顯然,屬性和字段是配對書寫的,因此一套屬性和字段,它們用的單詞是完全一樣的;但是,如果字段和屬性用的單詞一樣,連大小寫都一樣的話,C# 肯定是不允許的,因?yàn)橹孛寺?。重名了,我們使用成員訪問運(yùn)算符的時(shí)候,就沒辦法區(qū)分到底誰是誰了。因此肯定是不允許的。那么,既然是后臺(tái)的字段,那么這個(gè)字段還是用小寫吧。這就是為什么屬性用大寫開頭的帕斯卡命名法、而字段是小寫開頭的駝峰命名法。
再來說一下下劃線的問題。這個(gè)之所以放在后面講而先說帕斯卡,是因?yàn)檫@個(gè)說起來要困難一點(diǎn)。VS 在寫 C# 代碼的時(shí)候相當(dāng)方便,因?yàn)樗幸粋€(gè)叫做智能提示(Intellisense)的東西。智能提示會(huì)盡量智能地告知我們,我們輸入的代碼現(xiàn)在是這樣的話,下一步輸入的東西應(yīng)該是什么。比如說我們輸入了 person.
,既然小數(shù)點(diǎn)已經(jīng)出來了,那么自然而然地,我們就知道這里的小數(shù)點(diǎn)是成員訪問運(yùn)算符,智能提示就會(huì)給出所有這個(gè)類型里的實(shí)例成員,我們選擇到合適的提示項(xiàng)目后,按 Tab 按鍵或 Enter 按鍵,就可以自動(dòng)補(bǔ)全這個(gè)實(shí)例成員到代碼里。就像這樣:


這個(gè)列表就是智能提示了。我們按方向鍵 ↑ 和 ↓ 來切換選項(xiàng);也可以多輸入一些字母,來精確篩選包含這些字符的實(shí)例成員名。
不過,我們從按鍵是無法區(qū)別和區(qū)分我們到底需要大寫的還是小寫的單詞。有時(shí)候,我們在錄入代碼的時(shí)候,一旦我們輸入了比如 Name
的話,智能提示因?yàn)闊o法確定開頭字符,而會(huì)同時(shí)提示字段和屬性。當(dāng)你在字段最前面追加了下劃線后,我們就能夠完全避免開頭字符是字母而使得智能提示無法知道我們到底需要屬性還是字段:

2-3-2 為什么配套的那個(gè)字段不再只讀了呢?
可能你已經(jīng)注意到了這個(gè)細(xì)節(jié)。我們在代碼里,字段的 readonly
修飾符被我去掉了。實(shí)際上,追加 readonly
會(huì)保證代碼出問題。這是為什么呢?set
方法是屬性的賦值器,這表示這個(gè)字段可能會(huì)在使用的過程之中更改和修改值。既然數(shù)值會(huì)被修改,那么字段就不能標(biāo)記只讀。
Part 3 使用屬性的賦值器和取值器
我們介紹了如何寫屬性的代碼;但是我們還沒有說這個(gè)屬性怎么使用呢?還記得字段嗎?字段最開始我們使用賦值和取值的時(shí)候,就直接用的是成員訪問運(yùn)算符就可以搞定。
是的,C# 的屬性為了避免語法復(fù)雜,就采用了相同的語法。比如說:
就是這樣的。很簡單,對吧。