异常处理(Exception Handling)
异常处理是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)异常的类型是什么?
如何应对此类情况呢?我们来看一个例子。
新的一天早上,渔夫将渔网撒进家门口的池塘,准备捕鱼。
但是,他的捕鱼计划不一定能顺利实现。
·如果下起了暴风雨,那么不能够捕鱼;
·如果池塘已经被污染了,那么不能够捕鱼;
·如果池塘里没有鱼,那么不能够捕鱼;
·如果池塘里的鱼数量太少,出于不应竭泽而渔的考虑,渔夫也不想捕鱼。
渔夫捕鱼结束回到家时,他会向妻子汇报今天的捕鱼情况:
·如果没有捕到鱼,为什么没有捕到;
·如果捕到了鱼,捕到了多少条。
如何实现上面的逻辑呢?
在传统的程序逻辑下,程序应该是类似这样的:
捕鱼()
{
......//执行捕鱼工作
//在适当的时机检测
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这段方法的目的是“捕鱼”,但出于处理异常的需要,整个方法内充斥着大量与捕鱼无关的异常处理指令。
这会使得程序的可读性非常差——一眼看上去,此方法被铺天盖地的异常处理分支所覆盖,很难看出这段代码的目的仅仅是捕鱼。
(——如果在复杂的程序内这样做,一个代码块内的异常处理指令往往有正常流程指令的数倍乃至数十倍长)
针对这些问题,我们来认识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;
}
}
}
运行结果没有变化,但是程序显然变得更加健壮,可维护性更好了。
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和实际游戏开发中,还涉及到更多、更具体的异常处理需求、方法和技巧,这就有待更多的详细介绍。我们下期再会~