第 18 講:程序結(jié)構(gòu)(四):異常結(jié)構(gòu)
異常體系是 C# 里最重要也是非常必要的控制執(zhí)行邏輯的一種體系。
和一般的教材不同,異常可能會(huì)用到非常多的超綱的東西,所以它們都只能放在以后來(lái)講。但是,由于這里我們把異常結(jié)構(gòu)作為一種控制流程,因此放在這里給大家做介紹。另一方面,為了避免超綱的內(nèi)容,我們可能會(huì)穿插一些超綱內(nèi)容到這里,簡(jiǎn)單做一個(gè)介紹;但具體詳情的話,我們就不得不放在以后介紹。
Part 1 異常的概念
異常(Exception),是將所有不期望程序遇到,但確實(shí)遇到了,但又為了避免程序產(chǎn)生嚴(yán)重崩潰問(wèn)題的一種東西。在一些別的編程語(yǔ)言里,一旦程序出錯(cuò),就會(huì)直接閃退。在 C# 里,我們擁有控制這種防止閃退的機(jī)制,來(lái)避免很多問(wèn)題。
先來(lái)看一個(gè)簡(jiǎn)單的例子。
顯然,數(shù)學(xué)知識(shí)就告訴了我們,我們無(wú)法使用 a / b
,因?yàn)?b
你就會(huì)在運(yùn)行的時(shí)候產(chǎn)生錯(cuò)誤。在控制臺(tái)程序里,我們會(huì)看到類似這樣的信息:

而在 VS 里,我們會(huì)直接看到這個(gè)東西:

這個(gè)所謂的“Unhandled exception”直譯過(guò)來(lái)就是“未處理的異?!?。換句話說(shuō),異常是交給我們靈活處理的,為了避免程序崩潰、閃退。而這里的顯示信息,是程序崩潰的時(shí)候,提示給開(kāi)發(fā)人員看的。這種東西只要開(kāi)發(fā)人員一看,它就知道了問(wèn)題究竟出在具體的哪個(gè)位置上。比如下面的文字“at ...”,就告訴你了錯(cuò)誤是出在這個(gè)地方;而最后的“l(fā)ine 10”就是告訴你,錯(cuò)誤的代碼是在文件的第 10 行上。
這里顯示的 System.DivideByZeroException
是整個(gè)異常錯(cuò)誤而產(chǎn)生的一個(gè)數(shù)據(jù)封裝。這個(gè)東西就被稱為異常。顯示的 DivideByZeroException
是這個(gè)異常的類型。所有的錯(cuò)誤都會(huì)使用異常來(lái)表達(dá),而異常的類型專門表示出現(xiàn)的錯(cuò)誤,到底具體是什么。從這個(gè)異常的類型上直接看,就可以看出 divide by zero 是除以 0 的意思,而 exception 是這里“異?!边@個(gè)單詞的英文。以此可見(jiàn),異常的類型全部以“Exception”單詞結(jié)尾,而前面的單詞拼湊起來(lái),直譯出來(lái)就可以大體知道異常到底是指示錯(cuò)誤的問(wèn)題是什么。
“Exception”這個(gè)單詞原始的意思是“例外”。在程序設(shè)計(jì)里,如果翻譯成例外,有時(shí)候不是很好理解;當(dāng)然,你可以理解成“不屬于正常程序執(zhí)行行為的例外情況”。
這樣一來(lái),所有程序崩潰的具體緣由就使用異常變得更加體系化了。接下來(lái)我們就來(lái)說(shuō)一下,異常的捕獲和處理。
Part 2 try
-catch
2-1 示例 1
思考一下問(wèn)題。假如,我們要做一個(gè)除法計(jì)算器,除法的被除數(shù)和除數(shù)由用戶輸入(Console.ReadLine
讀?。?。
可是,我們輸入的內(nèi)容是程序無(wú)法預(yù)期控制的。比如用戶輸入一個(gè)字母 a、輸入一個(gè)減號(hào)、甚至是別的什么東西, C# 都是不可能在輸入的時(shí)候就知道這里必須是要求“非 0 的整數(shù)數(shù)據(jù)”的。因此,這個(gè)程序就會(huì)在某處產(chǎn)生異常。

事實(shí)上,由于 a
自身就已經(jīng)不是一個(gè)數(shù)字,因此還沒(méi)等到 3 的輸入,程序就報(bào)錯(cuò)了。記住這里的 FormatException
異常類型。這個(gè)類型在稍后我們會(huì)用到。
而另一方面,我們?yōu)榱吮苊廨斎脲e(cuò)誤而產(chǎn)生程序異常的信息提示,我們可以這么改裝一下代碼:
代碼稍微臃腫了一點(diǎn),由于 a
和 b
輸入的過(guò)程是完全一樣的,所以我們就只講一個(gè)。請(qǐng)看到第 3 行到第 15 行代碼。
首先,我們用上了死循環(huán)。死循環(huán)的作用,我們可以嘗試看下里面的語(yǔ)句來(lái)確定。里面嵌套了一個(gè) try
-catch
語(yǔ)句。這個(gè)語(yǔ)句的意思是,我們嘗試去做 try
下面的大括號(hào)里的操作。如果這段代碼一旦出現(xiàn)錯(cuò)誤,必然就會(huì)產(chǎn)生異常。此時(shí),我們需要在 catch
后追加異常的類型,來(lái)表示這里我們到底需要捕獲
接著,我們?cè)?catch
的大括號(hào)里寫(xiě)上“這個(gè)異常類型的異常產(chǎn)生后,我們應(yīng)該怎么做”。從代碼里可以看出,第 11 行就是出錯(cuò)的時(shí)候應(yīng)該做的事情:輸出一行文字給用戶。文字雖然寫(xiě)的是英文,但是實(shí)際上很好翻譯:“現(xiàn)在這個(gè)輸入是不行的。請(qǐng)重新輸出這個(gè)數(shù)字?!?。而從文字可以看出,一旦輸入失敗,程序是不會(huì)退出的,而是執(zhí)行死循環(huán),讓你重新輸入一個(gè)數(shù)據(jù)進(jìn)去。
而我們?cè)?try
下面的大括號(hào)里加上了 break
語(yǔ)句。這句話加在這里很突兀,但是這么去理解就好:假設(shè)我們第 6 行代碼沒(méi)有出現(xiàn)異常,就說(shuō)明我們的輸入是正常無(wú)誤的。那么既然是沒(méi)有問(wèn)題的話,我們就不用在死循環(huán)里反復(fù)重新輸入數(shù)據(jù)了,這就是死循環(huán)和 break
語(yǔ)句一起而發(fā)揮的作用。
此時(shí),我們?cè)俅屋斎?a 字母,程序就會(huì)提示你輸入的數(shù)據(jù)不對(duì),然后要求你重新輸入,直到數(shù)據(jù)是一個(gè)整數(shù)為止。這避免了我們前文提到的輸入數(shù)據(jù)隨便導(dǎo)致的程序閃退崩潰的問(wèn)題。
另外,請(qǐng)一定注意,
break
語(yǔ)句就只給switch
和循環(huán)語(yǔ)句提供流程控制的服務(wù),因此這里的try
-catch
語(yǔ)句里使用了break
語(yǔ)句,但它的跳轉(zhuǎn)依舊是跟外層的while (true)
有關(guān)系,而跟try
-catch
本身沒(méi)有關(guān)系。而且,
try
和catch
的大括號(hào)是不可省略的。它和if
、for
這類語(yǔ)句不同:if
等語(yǔ)句,當(dāng)大括號(hào)里只包含一個(gè)獨(dú)立的語(yǔ)句的時(shí)候,是可以不寫(xiě)大括號(hào)的;但是try
和catch
不可以省略大括號(hào)。
不過(guò),程序還有一處問(wèn)題。如果 b
是 0 怎么辦?我們可以這么改寫(xiě)輸出語(yǔ)句:
DivideByZeroException
,你甚至可以使用 try
-catch
語(yǔ)句來(lái)捕獲這個(gè)異常類型,然后提示錯(cuò)誤信息的文字來(lái)避免程序崩潰:
“The divisor is 0, which isn't allowed in division operation.”這段文字的意思是:“除數(shù)是 0,而這個(gè)數(shù)是不能用在除法操作里的?!薄?span id="s0sssss00s" class="md-plain">
2-2 示例 2
還記得之前提到的溢出嗎?數(shù)據(jù)在溢出的時(shí)候,我們使用 checked
來(lái)控制溢出的時(shí)候產(chǎn)生錯(cuò)誤,以提示溢出錯(cuò)誤。在那篇文章里,展示到了一個(gè)叫做 OverflowException
的異常。這個(gè)異常就是專門指代溢出的。
我們可以改造代碼。比如寫(xiě)一個(gè)加法計(jì)算器,當(dāng)數(shù)據(jù)運(yùn)算超出表示范圍的時(shí)候,提示用戶,輸入數(shù)據(jù)計(jì)算結(jié)果無(wú)效。
因?yàn)樽址恍袑?xiě)不下,我就用了加號(hào)拼接字符串來(lái)將字符串折行。
當(dāng)然,字符串折行還可以使用原義字符串:
注意,折行后,字符串必須頂格書(shū)寫(xiě),因?yàn)樽址锏乃凶址ò崭襁@些字符)也是字符串的一部分,系統(tǒng)是不處理的。
從這個(gè)例子里,我們可以看出,只要算術(shù)出現(xiàn)溢出問(wèn)題,我們就產(chǎn)生異常來(lái)告知用戶數(shù)據(jù)輸入無(wú)效。
顯然,異??梢员苊獬绦虮罎⒑烷W退,但是我們隨時(shí)隨地去查看問(wèn)題和異常的源頭是什么類型,然后都去捕獲,這樣真的好嗎?怕是不見(jiàn)得。C# 里很多異常類型都是可以通過(guò) catch
來(lái)捕獲掉的,這樣確實(shí)防止了程序崩潰,但很多時(shí)候,程序的閃退可以幫助我們程序員更好、更快地找到問(wèn)題所在。試想一下,異常如果一旦被捕獲,程序就不會(huì)產(chǎn)生閃退。全都捕獲掉的話,就算我們遇到了問(wèn)題,程序也不會(huì)閃退,這就會(huì)造成一個(gè)潛在的、我們無(wú)法發(fā)現(xiàn)或很難發(fā)現(xiàn)到的問(wèn)題:畢竟這樣的問(wèn)題都被捕獲掉了。因此,我們不建議隨時(shí)隨地都使用異常捕獲。
“C# 里很多異常類型都是可以通過(guò)
catch
來(lái)捕獲掉的”是想告訴你,C# 里不是所有異常都能捕獲,但這部分的異常類型很少被用到;很有可能 C# 教程把語(yǔ)法全部介紹完畢了之后,這部分無(wú)法捕獲的異常類型也不會(huì)介紹到。因此,你不必?fù)?dān)心遇到它們;但另外一方面,你需要知道的是,確實(shí)存在這種異常類型。
從另外一方面來(lái)看,異常的捕獲是需要一點(diǎn)點(diǎn)性能需求的,這會(huì)耽誤一點(diǎn)點(diǎn)時(shí)間。雖然對(duì)你來(lái)說(shuō),時(shí)間并不夠多,但是對(duì)于程序來(lái)說(shuō),影響是比較大的??赡苣阌脛e的處理過(guò)程和邏輯,執(zhí)行效率會(huì)比異常捕獲好一些,且可以達(dá)到完全一樣的運(yùn)行效果。比如前面我們捕獲除以 0 的異常的問(wèn)題,我們完完全全可以通過(guò)判斷 b == 0
來(lái)過(guò)濾掉除以 0 的情況。異??刂屏鞒淌菑腻e(cuò)誤本身出發(fā)考慮的,而 b == 0
直接是通過(guò)數(shù)據(jù)本身觸發(fā)考慮的。雖然完成的方法不同,但目的是一樣的:提示用戶,0 不能作除數(shù)。但是,后者(b == 0
作為判斷條件)的處理方式就比異常捕獲要好:它直接避免了使用異常機(jī)制。
Part 4 異常實(shí)體的使用
在前文里,我們僅僅是捕獲了異常的類型,然后提供對(duì)應(yīng)的策略。但是有些時(shí)候,我們可能會(huì)需要使用異常的一些具體信息,來(lái)幫助程序員修復(fù)問(wèn)題。這個(gè)時(shí)候,我們需要使用異常的實(shí)體。
我們拿這個(gè)例子來(lái)說(shuō)。一旦拋出了異常后,我們?cè)诋惓n愋秃缶o跟一個(gè)變量:如果從理解的角度來(lái)說(shuō),你可以把這個(gè)變量當(dāng)成是這個(gè)異常類型的一個(gè)實(shí)體。當(dāng)異常拋出后,這個(gè)實(shí)體存儲(chǔ)的東西就是整個(gè)異常在產(chǎn)生的時(shí)候,記錄下來(lái)的具體錯(cuò)誤信息(包括錯(cuò)誤的具體文字信息、錯(cuò)誤的相關(guān)類型、錯(cuò)誤發(fā)生在哪里)。
比如這個(gè)例子,我們用了一個(gè)叫 ex
的變量。在 catch
里,我們使用 Console.WriteLine
ex.Message
這個(gè)寫(xiě)法。緊跟的 .Message
表示獲取這個(gè)實(shí)體里的文本錯(cuò)誤信息的具體內(nèi)容。
當(dāng)然,這個(gè)
ex
異常的實(shí)體還包含了很多其它的東西,它們?nèi)慷际窃诋惓3霈F(xiàn)的時(shí)候,記錄下來(lái)的、對(duì)程序員有幫助的錯(cuò)誤信息。不過(guò)這里就不啰嗦了,因?yàn)槲覀冇貌簧纤鼈?;而且有些?nèi)容是超綱的。
Part 5 throw
語(yǔ)句
5-1 throw
-new
語(yǔ)句
當(dāng)然,除了我們處理系統(tǒng)產(chǎn)生的異常外,我們還可以自己產(chǎn)生一個(gè)異常。這個(gè)行為叫異常的拋出(Throw)。拋出一個(gè)異常需要了解一個(gè)語(yǔ)句:throw
-new
語(yǔ)句。
假設(shè)我們有 5 個(gè)變量 a
、b
、c
、d
和 e
,通過(guò)輸入一個(gè) 1 到 5 之間的數(shù)字來(lái)獲取對(duì)應(yīng)變量的數(shù)值,我們的代碼可以這么寫(xiě):
注意 default
default
部分的內(nèi)容。這里寫(xiě)的是 throw new Exception("The index is invalid.");
這樣一個(gè)語(yǔ)句。
throw
開(kāi)頭的語(yǔ)句就是我們這里說(shuō)的拋出異常的語(yǔ)句。當(dāng)程序員為了調(diào)試程序需要,可以嘗試添加這個(gè)語(yǔ)句來(lái)強(qiáng)制在執(zhí)行到這里的時(shí)候自動(dòng)產(chǎn)生類似前面一些圖片里這樣的嚴(yán)重錯(cuò)誤信息,以幫助程序員了解程序的執(zhí)行流程,找到和解決 bug。
注意寫(xiě)法。throw
后緊跟 new
單詞。這個(gè) new
是一個(gè)關(guān)鍵字,所以不能寫(xiě)成其它的東西。在 new
后跟上你要拋出的異常的類型名稱。比如之前的 FormatException
啊,DivideByZeroException
等等。異常類型名稱是需要你記住一些的;但是我們可以慢慢來(lái),不用一口氣記住很多,因此這里就這兩個(gè)就可以了,再算上這里的 Exception
,一共是三個(gè)。Exception
異常是一種“不屬于任何異常類型的異?!?。這種異常類型是當(dāng)系統(tǒng)拋出的異常類型不夠用的時(shí)候(換句話說(shuō),就是系統(tǒng)提供的那些異常類型都不屬于的時(shí)候,這個(gè) Exception
就可以用)。比如這里,我們就可以使用這個(gè)異常,然后跟一個(gè)小括號(hào),里面寫(xiě)上異常的錯(cuò)誤信息(用一般是字符串字面量)就可以了,比如代碼里的“The index is invalid.”(編號(hào)無(wú)效)。
5-2 throw 實(shí)體
語(yǔ)句
在前文,我們捕獲了異常,并使用了異常信息的實(shí)體的內(nèi)容。當(dāng)我們有時(shí)候不得不再次在 catch
里拋出這個(gè)異常的時(shí)候,我們可以使用 throw 實(shí)體
語(yǔ)句。
重拋出(Re-throw)。
5-3 throw
語(yǔ)句
在 catch
部分里,我們還可以用上一種特殊的異常拋出語(yǔ)句:throw;
。
在 catch
里寫(xiě)了一句 throw;
,這就可以表示原封不動(dòng)地把錯(cuò)誤信息重新拋出來(lái)。你甚至可以不寫(xiě)出 ex
在初學(xué)的時(shí)候,
throw 實(shí)體;
和throw;
確實(shí)沒(méi)有明顯的區(qū)別。但實(shí)際上,它們的調(diào)用堆棧(Calling Stack)是不同的。調(diào)用堆棧這一點(diǎn)對(duì)于 C# 非常重要,但因?yàn)閮?nèi)容極為復(fù)雜,我們將這個(gè)超綱內(nèi)容放在以后講。你可以把調(diào)用堆棧理解成做一件事經(jīng)過(guò)多少人的手。throw;
語(yǔ)句會(huì)重新拋出原封不動(dòng)的異常信息,它可以保證拋出的異常,記錄的東西“高保真”:甚至是連哪些人動(dòng)過(guò)這個(gè)物件都記得很清楚;但是throw 實(shí)體;
語(yǔ)句的其它東西都一樣,就只有調(diào)用堆棧不同:它會(huì)重置堆棧,使得調(diào)用方無(wú)法確認(rèn)(比如說(shuō),如果我要查看誰(shuí)動(dòng)過(guò)我的奶酪,通過(guò)throw 實(shí)體;
就無(wú)法確認(rèn)了)。總之,我們總是建議你使用
throw;
而不是throw 實(shí)體;
。
Part 6 總結(jié)
前文我們學(xué)到了使用 try
-catch
語(yǔ)句來(lái)執(zhí)行程序、捕獲異常,以及重拋出異常。不過(guò)因?yàn)閮?nèi)容講得復(fù)雜,學(xué)得簡(jiǎn)單,所以可能你看一遍也不太明白到底是什么。其實(shí),沒(méi)關(guān)系的:異常的話,按道理說(shuō)是得將了調(diào)用堆棧、講了面向?qū)ο蟮睦^承等等超綱知識(shí)點(diǎn),才可以說(shuō)的東西。但是這么講解有一個(gè)弊端,就是沒(méi)有保持內(nèi)容的系統(tǒng)化。不管怎么說(shuō),教材可能跟我的順序不同,這一點(diǎn)僅作參考。
而且,在 C# 里,異??刂七€有一個(gè)叫做 finally
的控制部分(除了 try
、catch
這兩個(gè)控制部分外),但是因?yàn)檫@個(gè)內(nèi)容是超綱的(這涉及到對(duì)象的內(nèi)存釋放,完全是理論知識(shí)),所以我們不能在這里介紹:它會(huì)用到非常后面的知識(shí)點(diǎn)。到時(shí)候我們?cè)僬f(shuō)。