異常處理(Exception Handling)
1.難以預料的異常
異常處理是C#提供的一整套功能,同時也是遊戲邏輯的編程中非常重要的概念和技能;
在初學編程時,無論學習哪種語言,老師都會教我們識別異常,並且修改代碼來消除異常。
例如:
int a = 8 / 0;
這是一個非常顯而易見的bug——程序中出現了"除以0"的問題。事實上,編譯器根本就不會容許這種低級錯誤的出現;
在你按下Play鍵開始編譯之前,編譯器就會用紅色下劃線標註這個出錯的語句,並提示你修改。
那麼,來看下一個例子:
string[] texts = new string[2];
texts[0] = "A";
texts[1] = "B";
texts[2] = "C";
稍微觀察即可看出,這段代碼有錯誤。程序中首先聲明瞭一個長度爲2的數組texts, texts的下標最大爲1;試圖調用text[2]是錯誤的。(順便說一句,這個就是PC早期年代經典的“燙燙燙燙燙”錯誤,即數組越界)
不過,這個錯誤就比上一種要“隱晦”了一些,或者說,它不會被編輯器一眼看出,而是會在編譯時報告異常。
那麼,如果程序是下面這個樣子呢?
int c = MyFunc(255);
int[] data = new int[c];
for (int i = 0; i < 999; i++)
{
data[i] = i / MyFunc(i);
}
這下,事情就變得複雜了。
在這個例子中,MyFunc(int)是一個未知方法;此時我們不難發現,這段代碼的執行過程將面臨衆多風險:
1.數組data的長度c是多少?萬一數組的長度被定義成負值怎麼辦?
2.data[i]這個表述中,i是否超出了數組data的下標範圍?
3. i / MyFunc(i)這個算式中,除數有沒有可能是零?
......
如果你有一定的編程經驗,你一定會認同以下的事實:當一個項目規模更大,代碼更復雜,那麼代碼中出現的異常也會越來越難以預料。
在一個複雜、具有很多不確定性的大型系統中,我們往往無法準確地預料到:
(1)是否會有異常?
(2)異常會在何時何處出現?
(3)異常的類型是什麼?
如何應對此類情況呢?我們來看一個例子。
2.焦頭爛額的漁夫
新的一天早上,漁夫將漁網撒進家門口的池塘,準備捕魚。
但是,他的捕魚計劃不一定能順利實現。
·如果下起了暴風雨,那麼不能夠捕魚;
·如果池塘已經被污染了,那麼不能夠捕魚;
·如果池塘裏沒有魚,那麼不能夠捕魚;
·如果池塘裏的魚數量太少,出於不應竭澤而漁的考慮,漁夫也不想捕魚。
漁夫捕魚結束回到家時,他會向妻子彙報今天的捕魚情況:
·如果沒有捕到魚,爲什麼沒有捕到;
·如果捕到了魚,捕到了多少條。
如何實現上面的邏輯呢?
在傳統的程序邏輯下,程序應該是類似這樣的:
捕魚()
{
......//執行捕魚工作
//在適當的時機檢測
if(發生異常A)
{
記錄異常信息;
向妻子彙報;
return;
}
......//繼續執行捕魚工作
if(發生異常B)
{
記錄異常信息;
向妻子彙報;
return;
}
......//繼續執行捕魚工作
if(發生異常C)
{
記錄異常信息;
向妻子彙報;
return;
}
......//繼續執行捕魚工作
......
}
按照這個邏輯,編寫正式的程序如下:
using System;
namespace FishingSample
{
public enum Weather //定義:天氣
{ Sunny, Rainy, RainStorm }
public class Day //定義:一天(當天的天氣)
{
public Weather weather;
public Day(Weather _weather)
{
weather = _weather;
}
}
public class Pool //定義:池塘(包含魚的數量,是否污染)
{
public int fish;
public bool IsPolluted = false;
public Pool(int num)
{
fish = num >= 0 ? num : 0;
}
public Pool(int num, bool polluted)
{
fish = num >= 0 ? num : 0;
IsPolluted = polluted;
}
}
public class Fisher //定義:漁夫
{
string[] FishingReport = new string[2];//捕魚記錄
//記錄0——是否正常捕魚,記錄1——詳情報告
//包含傳統異常處理的捕魚指令
//如果池塘未被污染,且魚的數量達到10條或以上,則捕魚成功,並捕獲一半的魚
public void TryFishing(Day day, Pool pool)
{
Console.WriteLine("\n開始捕魚 天氣:" + day.weather.ToString() + " 池塘中的魚數量:" + pool.fish.ToString());
if (day.weather == Weather.RainStorm)
{
WriteReport("【異常】", "遇到暴風雨,無法捕魚。");
Console.WriteLine("漁夫對妻子說:“" + FishingReport[1] + "”");
return;
}
if (pool.IsPolluted)
{
WriteReport("【異常】", "池塘被污染了。");
Console.WriteLine("漁夫對妻子說:“" + FishingReport[1] + "”");
return;
}
if (pool.fish == 0)
{
WriteReport("【異常】", "池塘裏沒有魚,無法捕魚。");
Console.WriteLine("漁夫對妻子說:“" + FishingReport[1] + "”");
return;
}
if (pool.fish < 10)
{
WriteReport("【異常】", "魚太少了,不適合捕魚。");
Console.WriteLine("漁夫對妻子說:“" + FishingReport[1] + "”");
return;
}
int CatchedFish = (int)Math.Floor((double)pool.fish / 2);
pool.fish -= CatchedFish;
WriteReport("【正常】", "捕到的魚數量:" + CatchedFish.ToString() + "\n池塘中剩餘魚的數量:" + pool.fish.ToString());
Console.WriteLine("漁夫對妻子說:“" + FishingReport[1] + "”");
}
public void WriteReport(string result, string description)
{
FishingReport[0] = result;
FishingReport[1] = description;
Console.WriteLine(FishingReport[0] + FishingReport[1]);
}
}
public class Fishing
{
static void Main()
{
Console.WriteLine("------捕魚測試------\n");
Fisher fisher = new Fisher();
//嘗試捕魚:暴風雨天氣,池塘中有15條魚
fisher.TryFishing(new Day(Weather.RainStorm), new Pool(15));
//嘗試捕魚:晴天,池塘中有0條魚
fisher.TryFishing(new Day(Weather.Sunny), new Pool(0));
//嘗試捕魚:雨天,池塘中有5條魚
fisher.TryFishing(new Day(Weather.Rainy), new Pool(5));
//嘗試捕魚:晴天,池塘中有13條魚,池塘被污染了
fisher.TryFishing(new Day(Weather.Sunny), new Pool(13, true));
//嘗試捕魚:暴風雨天氣,池塘中有15條魚
fisher.TryFishing(new Day(Weather.Sunny), new Pool(11));
Console.ReadLine();
}
}
}
在上述程序中,漁夫進行了5次捕魚嘗試,如下:
//嘗試捕魚:暴風雨天氣,池塘中有15條魚
fisher.TryFishing(new Day(Weather.RainStorm), new Pool(15));
//嘗試捕魚:晴天,池塘中有0條魚
fisher.TryFishing(new Day(Weather.Sunny), new Pool(0));
//嘗試捕魚:雨天,池塘中有5條魚
fisher.TryFishing(new Day(Weather.Rainy), new Pool(5));
//嘗試捕魚:晴天,池塘中有13條魚,池塘被污染了
fisher.TryFishing(new Day(Weather.Sunny), new Pool(13, true));
//嘗試捕魚:暴風雨天氣,池塘中有15條魚
fisher.TryFishing(new Day(Weather.Sunny), new Pool(11));
運行程序,獲得以下結果:
可見,程序運行結果正常,上述結構確實起到了異常處理的作用;但是我們也不難發現,這種異常處理邏輯存在很多缺陷。
(1)每個異常判定代碼塊都要單獨寫一遍異常處理邏輯(記錄異常信息、向妻子彙報),代碼顯得十分囉嗦,且缺乏可擴展性;
(2)必須頻繁地進行return跳出操作,中斷執行過程,以此來規避異常。如果漁夫有“無論是否出現異常都要執行”的事務需要處理(例如向妻子彙報捕魚結果),就必須在每次return之前加以完成。這樣的運行邏輯是繁瑣而不安全的——return指令過於生硬,編碼者需要下很大的功夫,來阻止原本有用的代碼塊被return意外丟棄;
(2)TryFishing這段方法的目的是“捕魚”,但出於處理異常的需要,整個方法內充斥着大量與捕魚無關的異常處理指令。
這會使得程序的可讀性非常差——一眼看上去,此方法被鋪天蓋地的異常處理分支所覆蓋,很難看出這段代碼的目的僅僅是捕魚。
(——如果在複雜的程序內這樣做,一個代碼塊內的異常處理指令往往有正常流程指令的數倍乃至數十倍長)
3.異常處理結構
針對這些問題,我們來認識C#的異常處理系統。
(其實,大多數編程語言都有異常處理系統,比C#的性能更出色的不在少數;這裏只講C#,當然是因爲我們後面的目標在於Unity)
C#支持異常處理功能,異常處理功能涉及以下四個關鍵字; try, catch,finally 和 throw. 每一個異常處理結構由try catch finally三個關鍵字標記的代碼塊所組成,其中finally是可選的。異常處理結構的基本格式如下:
try
{
}
catch (Exception ex)
{
}
finally
{
}
當程序在執行中遇到try關鍵字時,就會開始執行try代碼塊裏面的指令,並等待執行過程中出現異常。
一旦一個異常被拋出,try代碼塊會立即停止執行,將程序控制權轉到catch代碼塊;先前在try代碼塊中被拋出的異常會作爲參數被交付到catch代碼塊中,從而使得catch代碼塊有權對異常作出進一步的處理,例如記錄異常詳情,或者打印異常信息。
在上述過程執行完畢後,無論try有沒有拋出過異常,都執行finally代碼塊中的內容。
說到這裏,什麼是異常呢?
異常(Exception) 是一個類,所有的異常都繼承自System.Exception。在編譯器中隨便找個地方打出Exception,可以看出System已經爲我們定義了許許多多種異常類型,從初學編程時就認識的數組下標異常,到複雜的網絡通信異常,可謂應有盡有。
當然,我們也可以自定義自己的異常。習慣上,我們在自定義異常時,會建立ApplicationException的子類。
是不是有一點聽不懂啦?不要着急,現在我們用僞代碼,將漁夫的捕魚流程用異常處理結構重新設計一遍。
出發捕魚前,漁夫準備好一篇捕魚日誌(FishingReport);
try
{
捕魚;
將捕魚成功的信息和捕獲魚的數量記錄到日誌上;
}
catch (Exception ex) //如果捕魚時發現了異常,則立即記住此異常並中斷捕魚,轉爲以下動作:
{
將捕魚時發生的異常(ex)詳情記錄到日誌上;
}
finally
{
向妻子彙報捕魚日誌(FishingReport)的內容;
}
現在,我們建立自定義的異常類型,將捕魚過程中可能會出現的異常進行概括,稱爲FishingException.
(在下面示例中,我針對FishingException又建立了三個細分子類,來區分不同的異常;如果你自行練習,可以不用這樣花裏胡哨)
public class FishingException: ApplicationException
{
public string Description;
}
public class WeatherException : FishingException
{
public WeatherException(string msg)
{
Description = msg;
}
}
public class NotEnoughFishException : FishingException
{
public NotEnoughFishException(string msg)
{
Description = msg;
}
}
public class PollutionException : FishingException
{
public PollutionException(string msg)
{
Description = msg;
}
}
然後,我們就可以修改一下Fisher類,使用try_catch_finally的異常處理結構來重寫TryFishing方法啦!
修改後的Fisher類如下:
public class Fisher
{
string[] FishingReport = new string[2];
public void TryFishing(Day day, Pool pool)
{
Console.WriteLine("\n開始捕魚 天氣:" + day.weather.ToString() + " 池塘中的魚數量:" + pool.fish.ToString());
try
{
if (day.weather == Weather.RainStorm)
{
throw new WeatherException("遇到暴風雨,無法捕魚。");
}
if (pool.IsPolluted)
{
throw new PollutionException("池塘被污染了。");
}
if (pool.fish == 0)
{
throw new NotEnoughFishException("池塘裏沒有魚,無法捕魚。");
}
if (pool.fish < 10)
{
throw new NotEnoughFishException("魚太少了,不適合捕魚。");
}
//如果沒有遇到過異常,則捕獲一半的魚,並將捕魚成果寫入日誌
int CatchedFish = (int)Math.Floor((double)pool.fish / 2);
pool.fish -= CatchedFish;
WriteReport("【正常】", "捕到的魚數量:" + CatchedFish.ToString() + "\n池塘中剩餘魚的數量:" + pool.fish.ToString());
}
catch (FishingException ex)
{
//如果遇到了異常,則將異常詳情寫入日誌
WriteReport("【異常】", ex.Description);
}
finally
{
//無論是否遇到過異常,漁夫都將向妻子彙報捕魚日誌的內容
Console.WriteLine("漁夫對妻子說:“" + FishingReport[1] + "”");
}
}
public void WriteReport(string result, string description)
{
FishingReport[0] = result;
FishingReport[1] = description;
Console.WriteLine(FishingReport[0] + FishingReport[1]);
}
}
在try代碼塊中,每當滿足異常的條件,我們就建立一個FishingException異常,並用throw關鍵字將其拋出;此時throw後面的內容將被丟棄,所以漁夫不會將“捕魚正常”和“捕魚數量”之類的消息寫入捕魚日誌。
如果捕魚過程沒有遇到異常,則漁夫執行try代碼塊直到結束,捕魚任務完成。
在catch塊中——一旦程序進入此塊,說明我們已經成功捕獲了一個名爲ex的異常——我們讓漁夫將異常的詳情寫入捕魚日誌。
在Finally塊中,漁夫向妻子彙報捕魚日誌上記錄的內容。根據先前的不同情況,捕魚日誌已經被try或catch代碼塊所編輯過;所以漁夫彙報的內容總是能正確體現捕魚正常或異常的情況。
重構之後的新程序如下:
using System;
namespace FishingSample
{
public enum Weather
{ Sunny, Rainy, RainStorm }
public class Day
{
public Weather weather;
public Day(Weather _weather)
{
weather = _weather;
}
}
public class Pool
{
public int fish;
public bool IsPolluted = false;
public Pool(int num)
{
fish = num >= 0 ? num : 0;
}
public Pool(int num,bool polluted)
{
fish = num >= 0 ? num : 0;
IsPolluted = polluted;
}
}
public class Fisher
{
string[] FishingReport = new string[2];
public void TryFishing(Day day, Pool pool)
{
Console.WriteLine("\n開始捕魚 天氣:" + day.weather.ToString() + " 池塘中的魚數量:" + pool.fish.ToString());
try
{
if (day.weather == Weather.RainStorm)
{
throw new WeatherException("遇到暴風雨,無法捕魚。");
}
if (pool.IsPolluted)
{
throw new PollutionException("池塘被污染了。");
}
if (pool.fish == 0)
{
throw new NotEnoughFishException("池塘裏沒有魚,無法捕魚。");
}
if (pool.fish < 10)
{
throw new NotEnoughFishException("魚太少了,不適合捕魚。");
}
int CatchedFish = (int)Math.Floor((double)pool.fish / 2);
pool.fish -= CatchedFish;
WriteReport("【正常】", "捕到的魚數量:" + CatchedFish.ToString() + "\n池塘中剩餘魚的數量:" + pool.fish.ToString());
}
catch (FishingException ex)
{
WriteReport("【異常】", ex.Description);
}
finally
{
Console.WriteLine("漁夫對妻子說:“" + FishingReport[1] + "”");
}
}
public void WriteReport(string result, string description)
{
FishingReport[0] = result;
FishingReport[1] = description;
Console.WriteLine(FishingReport[0] + FishingReport[1]);
}
}
public class Fishing
{
static void Main()
{
Console.WriteLine("------捕魚測試------\n");
Fisher fisher = new Fisher();
fisher.TryFishing(new Day(Weather.RainStorm), new Pool(15));
fisher.TryFishing(new Day(Weather.Sunny), new Pool(0));
fisher.TryFishing(new Day(Weather.Rainy), new Pool(5));
fisher.TryFishing(new Day(Weather.Sunny), new Pool(13, true));
fisher.TryFishing(new Day(Weather.Sunny), new Pool(11));
Console.ReadLine();
}
}
public class FishingException: ApplicationException
{
public string Description;
}
public class WeatherException : FishingException
{
public WeatherException(string msg)
{
Description = msg;
}
}
public class NotEnoughFishException : FishingException
{
public NotEnoughFishException(string msg)
{
Description = msg;
}
}
public class PollutionException : FishingException
{
public PollutionException(string msg)
{
Description = msg;
}
}
}
運行結果沒有變化,但是程序顯然變得更加健壯,可維護性更好了。
4.技巧與備註
1.捕獲多種異常
在try_catch結構中,catch代碼塊只會捕獲指定類型的異常,而會忽略與本身參數中的異常類型不同的異常。如果你想要捕獲try代碼塊中可能拋出的多種異常,可以採用以下方案:
(1)在try後連續寫入多個catch代碼塊,並讓不同的catch代碼塊捕獲不同的異常;try { } catch(Exception0 ex) { } catch(Exception1 ex) { } catch(Exception2 ex) { } finally //optional { } //這裏的Exception0/1/2表示不同的異常類型
(2)在catch代碼塊中寫入多種異常的父類,從而使得此父類的各種子類異常都能被catch代碼塊所捕獲。
例如
不過,這樣做對捕獲到的異常的類型概括較爲籠統(異常被看成父類而非子類),可能會導致對Exception的可用處理操作變少。不過,你可以之後採用System.Type相關方法來區分父類Exception實例的具體所屬子類。這就請各位自己探究啦。
2.異常的分級處理
2.1 異常處理的嵌套
多個異常處理結構之間可以進行嵌套;通過嵌套機制,我們可以實現對異常的分級處理。
如果一個try代碼塊拋出了異常,但是該異常的類型不在其下屬catch代碼塊的捕獲範圍之內,那麼編譯器會判斷整個結構是否位於上級try代碼塊之內,並將這個未處理的異常交給上級catch代碼塊處理,以此類推。
(這個比較簡單,示例代碼略)
2.2 異常的上報
利用嵌套機制,我們還可以實現更優雅的功能。
舉例子,基層法院可以獨立審理一起疑難案件並作出判決;
但如果基層法院認爲案情重大,可以同時進行上報,提醒上級法院注意:下級有疑難案件產生。
如果上級法院認爲有必要,那麼可以再介入案件,執行進一步的處理。
在程序中也是如此。有時,我們儘管進行了異常處理,但仍有必要向更高層的模塊發出警告,提示程序運行中曾經有異常發生。
要實現這樣的邏輯,我們只需要在下級異常處理結構的catch代碼塊中拋出異常,即可實現異常上報。
例如
在Main方法中執行結果如下:
可以看到,針對同一個異常,下級進行處理後,又由上級進行了一次處理。
至此,異常處理的基礎內容介紹完畢!大家應該也對C#中的異常處理功能有了充分的瞭解。而在Unity和實際遊戲開發中,還涉及到更多、更具體的異常處理需求、方法和技巧,這就有待更多的詳細介紹。我們下期再會~