异常的使用
不管是网络还是出版读物,关于 C# 异常系统性的资料都比较少,我所在的工控领域也很少有开发者使用异常。异常实际上是一种非常好的机制,很值得推广。为此我根据过往的学习积累,结合一些项目经验,撰写本文。
1. 为什么应该使用异常
在开始本文之前,我们先看一下常用的几种"报告错误"的方式:
- 方式一:返回错误码
缺点:
- 使用者不得不对返回值进行判断,导致"圈复杂度"增加;
- 如果方法需要返回值,返回内容不得不通过"out"参数传出。
下面是一段伪代码,我们可以看到使用错误码有诸多不便:
c#
Client client = new Client();
if (client.Connect() == 1)
{
if (client.Receive(out string content) == 1)
{
// 执行其他动作
}
}
class Client
{
public int Connect()
{
// 执行其他动作
if (successeded)
{
// 执行成功,返回 1
return 1;
}
else
{
// 执行失败,返回错误码
return errorCode;
}
}
public int Receive(out string result)
{
if (successeded)
{
result = result;
return 1;
}
else
{
result = null;
return errorCode;
}
}
}
- 方式二:全局属性记录错误信息
缺点:
- 需要对全局属性进行判断,使用者很容易遗漏;
- 同样会造成"圈复杂度"增加。
c#
Client value = new Client();
value.Connect();
if (value.ErrorCode == 1)
{
string content = value.Receive();
if (value.ErrorCode == 1)
{
// 执行其他动作
}
}
class Client
{
public int ErrorCode { get; private set; }
public void Connect()
{
// 执行其他动作
if (successeded)
{
// 执行成功,全局属性置为 1
ErrorCode = 1;
return;
}
else
{
// 执行失败,设置错误码
ErrorCode = errorCode;
return;
}
}
public string Receive()
{
if (successeded)
{
ErrorCode = 1;
return result;
}
else
{
ErrorCode = errorCode;
return null;
}
}
}
上述两种机制不光使用繁琐,返回错误码在部分没有返回值的场景下还无法使用:
- 构造函数执行失败
- 设置属性值执行失败
而异常刚好可以弥补这些缺陷。
Info
《框架设计指南》这本书对异常的好处进行了详细的阐释,我在这里进行引述(有删改):
异常与面向对象语言结合精密。就构造函数、运算符重载和属性而言,开发者无法选择返回值。出于这个原因,对于面向对象的框架来说,基于返回值的错误报告是不可能标准化的。
异常促进了 API 的一致性,因为它们只被设计用于错误报告。相比之下,返回值有很多用途,错误报告只是其中一个子集。出于这个原因,尽管异常可以被限制在特定的模式中,然而通过返回值报告错误的 API 很可能会利用大量的模式。Win32 API 就是这种不一致的一个典型例子:它使用了 BOOL、HRESULTS 和 GetLastError 等。
在基于返回值的错误报告中,错误处理代码总是被放置在靠近故障点的地方。然而,对于异常处理,应用程序的开发者可以有自己的选择,他们既可以在故障点附近捕获异常,也可以将错误处理代码集中在调用栈的更上方。
错误处理代码更容易被本地化。如果通过返回值报告错误的代码非常健壮,则往往意味着几乎每一行功能代码中都有一个 if 语句。这些 if 语句用于处理失败的情况。有了基于异常的错误报告,通常可以这样书写健壮的代码:顺序执行多个方法或操作,然后在 try 语法块后面按组去处理错误,甚至可以在调用栈更高层级位置处理错误。
错误码很容易被忽略掉,并且在大多数情况下都是如此。
异常携带的丰富信息可描述导致错误的原因。
异常允许面向未经处理异常的处理器。
这里的"未经处理异常的处理器"指诸如
Application.DispatcherUnhandledException
、Application.ThreadException
支持订阅全局的异常处理事件。异常促进工具的发展。异常是一种定义明确的方法失败模型。正因为如此,调试器、分析器、性能计数器等工具才有可能密切关注异常。
2. 异常的使用示例
上一节的伪代码改为使用异常后如下:
c#
try
{
Client value = new Client();
value.Connect();
string content = value.Receive();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
class Client
{
public void Connect()
{
// 执行其他动作
if (successeded)
{
return;
}
else
{
// 执行失败,抛出异常
throw new InvalidOperationException("因...,连接失败。");
}
}
public string Receive()
{
if (successeded)
{
return result;
}
else
{
throw new InvalidOperationException("因...,接受数据失败。");
}
}
}
前面我们还提到返回错误码无法在没有返回值的场景下使用,下面是在构造函数和属性 setter 中使用异常的一个简单示例:
c#
class Client
{
public bool IsConnected { get; private set; }
private string _hostname;
private int _port;
// 通过异常限制 Hostname 和 Port 只能在未连接状态下修改。
public string Hostname
{
get => _hostname;
set
{
if (IsConnected)
{
throw new InvalidOperationException("客户端处于连接状态,无法修改主机名称。");
}
_hostname = value;
}
}
public int Port
{
get => _port;
set
{
if (IsConnected)
{
throw new InvalidOperationException("客户端处于连接状态,无法修改端口。");
}
_port = value;
}
}
public Client(string hostname, int port)
{
if (string.IsNullOrWhiteSpace(hostname))
{
throw new ArgumentException("参数无效。", nameof(hostname));
}
Hostname = hostname;
Port = port;
}
public void Connect()
{
IsConnected = true;
}
}
可以看到,通过异常反馈执行错误代码更为整洁,并且能通过异常的 Message
属性报告错误信息。
3. 应该抛出哪个异常?
对于异常,简单使用并不复杂。但是异常的种类繁多,开发者常常困惑应该抛出哪个异常。异常和错误相关,错误一般分为两类:
-
使用错误
-
执行错误
又分为两类:
- 程序错误
- 系统失败
使用错误:错误调用导致的错误,例如传入了 null 参数。此类错误不应该由框架处理,而应该修改调用方代码。
此类错误对应的常用异常有 3 个:
ArgumentException
:ArgumentNullException
和ArgumentOutOfRangeException
的基类,用于表示传入的参数错误ArgumentNullException
:参数为空异常,当传入方法的参数为 null,应该抛出该异常ArgumentOutOfRangeException
:参数值超出范围异常,当传入方法的参数超过限定范围,应该抛出该异常
下面是一个简单的示例:
c#
class Client
{
public void Connect(string hostname, int port)
{
if (string.IsNullOrWhiteSpace(hostname))
{
throw new ArgumentException("参数无效。", nameof(hostname));
}
// 执行其他操作
}
}
执行错误-程序错误 :可以在程序中处理的错误。如 File.Open
未找到相应文件抛出 FileNotFoundException
异常,我们可以创建一个新文件并继续运行。
此类错误对应的异常有很多,最常用的异常是:
InvalidOperationException
:对象处于不正确的状态时,抛出该异常
下面是一个简单的示例:
c#
class Client
{
public bool IsConnected { get; private set; }
public void Connect()
{
if (IsConnected)
{
throw new InvalidOperationException("客户端已连接。");
}
// 其他操作
IsConnected = true;
}
}
执行错误-系统失败 :无法在程序中进行处理的执行错误。如即时编译器(Just-In-Time compiler)用尽了内存而引发的 OutOfMemoryException
。
此类错误对应的异常也有很多,但是这些异常都不应该由开发者抛出,而是由 CLR 负责。例如:
OutOfMemoryException
:内层分配失败时抛出该异常,只有 CLR 才能抛出该异常
Info
关于更多的异常分类,见第7章 异常 - hihaojie - 博客园 7.3 标准异常类型的使用
4. 异常的捕捉
有抛出,自然有捕捉。异常的捕捉并不复杂,不过仍然有诸多细节需要注意。这里我们通过几个问题厘清异常的捕捉。我们假设有如下 Connect()
方法:
c#
void Connect(string hostname)
{
if (hostname is null)
{
throw new ArgumentNullException(nameof(hostname));
}
// 执行其他操作
}
这里先提一点:ArgumentException
是 ArgumentNullException
和 ArgumentOutOfRangeException
的父类。
- 问题一:如下三段代码,哪段可以通过编译?
c#
// 代码1
try
{
Connect(null!);
}
catch (ArgumentException ex)
{
Console.WriteLine("捕捉到 ArgumentException 异常");
}
catch (ArgumentNullException ex)
{
Console.WriteLine("捕捉到 ArgumentNullException 异常");
}
c#
// 代码2
try
{
Connect(null!);
}
catch (ArgumentNullException ex)
{
Console.WriteLine("捕捉到 Exception 异常");
}
catch (ArgumentException ex)
{
Console.WriteLine("捕捉到 ArgumentException 异常");
}
c#
// 代码3
try
{
Connect(null!);
}
catch (ArgumentNullException ex)
{
Console.WriteLine("捕捉到 Exception 异常");
}
catch (ArgumentException ex)
{
Console.WriteLine("捕捉到 ArgumentException 异常");
}
catch (InvalidOperationException ex)
{
Console.WriteLine("捕捉到 InvalidOperationException 异常");
}
- 问题二:如下代码会输出"捕捉到 ArgumentException 异常"吗?
c#
try
{
Connect(null!);
}
catch (ArgumentException ex)
{
Console.WriteLine("捕捉到 ArgumentException 异常");
}
- 问题三:如下代码会输出哪条信息?
c#
try
{
SentMessage("异常测试。");
}
catch (ArgumentException ex)
{
Console.WriteLine("捕捉到 ArgumentException 异常");
}
void SentMessage(string message)
{
try
{
Connect(null!);
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine("捕捉到 ArgumentOutOfRangeException 异常");
}
}
通过上述问题我们可以得出如下结论:
-
要捕捉的异常存在父子关系时,需要子类异常在前,父类异常在后,否则无法编译;
-
子类异常可以通过捕捉父类异常完成捕捉;
Exception
作为所有异常的基类,捕捉它可以捕获全部异常。 -
未捕获的异常会进一步向上抛出;
对应的,捕捉异常有这些惯例(准则):
- 处理方式相同的异常,可以捕获它们共同的父类;
如"使用错误"异常(ArgumentException
三兄弟),它们的处理方式相同(应由调用者修改代码),可以直接捕获 ArgumentException
异常进行处理。
类似的还有 OperationCanceledException
和 TaskCanceledException
- 只捕获知道如何处理的异常;
对于未知的异常,应该进一步向上抛出,由上一级进行处理。
如下代码演示了处理已知异常 TimeoutException
,忽略未知异常 ArgumentNullException
c#
try
{
Connect(hostname);
}
catch (TimeoutException ex)
{
Console.WriteLine("连接超时,尝试二次连接");
Connect(hostname);
}
void Connect(string hostname)
{
if (hostname is null)
{
throw new ArgumentNullException(nameof(hostname));
}
// 执行其他操作
if (usedTime > TimeSpan.FromSeconds(100))
{
throw new TimeoutException("连接用时超过 100s。");
}
}
5. 异常的捕捉、再抛出
有时我们捕捉异常后并不想处理,而是想进行记录并二次抛出,或者将转为其他异常再抛出。我们先比较如下三段代码,看看它们的二次抛出有何不同:
c#
Exception holder = null;
try
{
try
{
throw new Exception("原始异常");
}
catch (Exception ex)
{
Console.WriteLine(ex.StackTrace);
holder = ex;
throw;
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
Console.WriteLine(holder == ex);
}
c#
Exception holder = null;
try
{
try
{
throw new Exception("原始异常");
}
catch (Exception ex)
{
Console.WriteLine(ex.StackTrace);
holder = ex;
throw ex;
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
Console.WriteLine(ex == holder);
}
c#
Exception holder = null;
try
{
try
{
throw new Exception("原始异常");
}
catch (Exception ex)
{
Console.WriteLine(ex.StackTrace);
holder = ex;
throw new Exception("二次抛出异常", ex);
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
Console.WriteLine(holder == ex);
}
执行上述代码我们可以发现:
- 直接使用
throw
二次抛出:Exception.StackTrace
属性会保留原始栈信息; - 使用
throw ex
二次抛出:二次抛出的异常与原实例相同,但Exception.StackTrace
存储的栈信息更新为二次抛出的位置; - 使用
throw new Exception()
二次抛出:将异常进行了二次包装,Exception.Message
、Exception.StrackTrace
等诸多成员值都发生了变化。
我们可以根据需要采用相应的二次抛出方式,不过一般会遵循如下准则:
- 若不需要二次包装异常,应直接使用
throw
二次抛出; - 若需要二次包装异常,应使用
throw new SomeException
二次抛出异常,并将原异常传入新异常。
你可能对"并将原异常传入新异常"这句话感到困惑。这里我们看一下基类异常 Exception
的四个构造函数:
c#
public Exception();
public Exception(string message)
public Exception(string message, Exception innerException)
protected Exception(SerializationInfo info, StreamingContext context)
其中第三个构造函数 Exception(string message, Exception innerException)
需要一个 Exception
实例,是的,该参数是专门用于二次包装异常。当我们需要将原异常包装为其他异常时,需要将原异常实例通过该参数传入。二次包装时不传入该参数也是可以的,不过传入是标准做法,有利于开发者追溯原异常。下面是一个简单示例:
c#
try
{
throw new TimeoutException("执行超时。");
}
catch (Exception ex)
{
throw new InvalidOperationException("执行失败,请检查硬件状态。", ex);
}
6. 常见异常
下面我们介绍一下常见的异常,以及它们的使用场景。
6.1 基类异常
如下异常因表达的异常分类不明确,开发者不应抛出下列异常:
Exception
:基类异常,它是所有异常的基类。我们自定义异常时需要派生自该类或其子类。ApplicationException
和SystemException
:设计之初,SystemException
的派生类用于表示 CLR(或系统)自身抛出的异常,ApplicationException
的派生类用于表示非 CLR 异常(应用程序异常)。但是很多异常类没有遵循这一模式,如TargetInvocationException
派生自ApplicationException
,却由 CLR 抛出。因此ApplicationException
已失去原有意义。
我们在自定义异常时,不再推荐以 ApplicationException
、SystemException
为基类。
6.2 常用异常
-
InvalidOperationException
:如果对象处于不正确的状态,抛出该异常。例如:往只读的
FileStream
写入数据。 -
ArgumentException
、ArgumentNullException
、ArgumentOutOfRangeException
:用户传入错误参数时,要抛出ArgumentException
或其派生类,并设置ParamName
属性。如果可以,尽量选择位于继承层次末尾的异常类型。 -
OperationCanceledException
、TaskCanceledException
:表示操作被取消。TaskCanceledException
用于异步编程,OperationCanceledException
可用于任意场景。需要注意的是,在异步方法中手动抛出
TaskCanceledException
时,需要通过其构造函数传入CancellationToken
,否则相应的Task
的Status
属性不会标记为Canceled
-
FormatException
:表明文本解析方法中的输入字符串不符合要求或指定格式。 -
FileNotFoundException
:表示文件未找到。 -
InvalidCastException
:表示无效的类型转换,常见于强制转换、拆箱。如下代码便会抛出该异常:
c#object content = string.Empty; int value = (int)content;
-
NotSupportedException
:表示当前成员功能不支持。以
ReadOnlyCollection<T>
为例,它不支持Add()
方法,但又实现了IList<T>
接口,因此它的Add()
方法便抛出了该异常。 -
NotImplementedException
:表示当前成员功能尚未实现。 -
TimeoutException
:表示执行超时。因历史遗留原因,Web 通信超时并未抛出该异常。以WebRequest
为例,它通信超时会抛出WebException
异常,并令异常实例的Status
属性值为WebExceptionStatus.Timeout
枚举值。
6.3 CLR 异常
这类异常通常由 CLR 抛出,开发者不应该使用这些异常。
NullReferenceException
、IndexOutOfRangeException
、AccessViolationException
:表示代码存在缺陷,需要开发者调整代码。StackOverflowException
:栈溢出时会抛出该异常,常见于无限递归。栈溢出时,几乎不可能让托管代码保持状态一致。发生该异常 CLR2.0 默认会让程序立即终止。开发者也不应该捕获该异常(是的,此时应该纵容程序崩溃)。OutOfMemoryException
:内存分配失败时会抛出该异常。
7. 如何自定义异常
上一节我们讲了诸多预定义异常,当预定义异常不能满足我们的需要时,就需要自定义异常了。
自定义异常通常遵循如下准则:
-
自定义异常应该派生自
System.Exception
或其他常用的基类异常; -
继承层次不应该过深;
-
命名使用"Exception"后缀;
-
如果多种错误可以通过一种方式来处理,则它们应该属于同一类型的异常;
-
自定义异常应该至少有如下 4 个构造函数:
c#public class SomeException : Exception, ISerializable { public SomeException(); public SomeExcepiton(string message); public SomeExcepiton(string message, Exception inner); // 序列化所需构造函数 protected SomeException(SerializationInfo info, StreamingContext context); }
Tips
关于自定义异常必须实现二进制序列化(即实现
SomeException(SerializationInfo info, StreamingContext context)
构造函数)在新版 .NET 中已不再要求,且Exception(SerializationInfo info, StreamingContext context)
也被标记为了弃用([Obsolete]
)。因此下面的例子忽略了二进制序列化的实现。
现在,我们假设有这么一个硬件设备:
- 它是一个测距仪,计算机通过 TCP/IP 的方式与它通信(它是服务端);
- 当它正在测量中,重复发送测量指令它不会进行响应(即会发生通信超时);
- 如果测量的距离超出返回,它会返回"OutOfRange"字符串;
- 测量成功,则会返回数值,表示测得的距离。
这里我们要自定义一个 DeviceErrorException
表示操作该硬件时的一切异常。请思考,该测距仪的类应该怎样定义?该异常又怎样定义?
下面是我编写的一个测距仪类和对应的 DeviceErrorException
异常,大家可以作为参考:
c#
class Measurer
{
private TcpClient _client;
public bool IsConnected => _client != null && _client.Connected;
private const string Command = "Measure";
private const string OutOfRangeResponse = "OutOfRange";
public void Connect(string hostname, int port)
{
try
{
_client = new TcpClient(hostname, port);
}
catch (TimeoutException ex)
{
throw new DeviceErrorException("连接超时。", ex, DeviceState.Timeout);
}
}
public double Measure()
{
try
{
if (!IsConnected)
{
throw new DeviceErrorException("测距仪尚未连接。请连接后再进行操作。", DeviceState.NotConnected);
}
byte[] writeBuffer = Encoding.ASCII.GetBytes(Command);
Stream stream = _client.GetStream();
_client.GetStream().Write(writeBuffer, 0, writeBuffer.Length);
byte[] readBuffer = new byte[100];
int count = stream.Read(readBuffer, 0, readBuffer.Length);
string content = Encoding.ASCII.GetString(readBuffer, 0, count);
if (content == OutOfRangeResponse)
{
throw new DeviceErrorException("数据读取失败,超出测量范围。", DeviceState.OutOfRange);
}
if (double.TryParse(content, out double result))
{
return result;
}
throw new DeviceErrorException($"转换数据失败。获取的内容为:[{content}]", DeviceState.DataParseError);
}
catch (TimeoutException ex)
{
throw new DeviceErrorException("通信超时。", ex, DeviceState.Timeout);
}
catch (DeviceErrorException)
{
throw;
}
catch (Exception ex)
{
throw new DeviceErrorException("未知异常。", ex);
}
}
}
class DeviceErrorException : InvalidOperationException
{
public DeviceState State { get; }
public DeviceErrorException() : this(DeviceState.Unknown)
{ }
public DeviceErrorException(DeviceState state)
{
State = state;
}
public DeviceErrorException(string message) : this(message, DeviceState.Unknown)
{ }
public DeviceErrorException(string message, DeviceState state) : base(message)
{
State = state;
}
public DeviceErrorException(string message, Exception innerException) : this(message, innerException, DeviceState.Unknown)
{ }
public DeviceErrorException(string message, Exception innerException, DeviceState state) : base(message, innerException)
{
State = state;
}
}
enum DeviceState
{
Unknown,
DataParseError,
NotConnected,
Timeout,
OutOfRange,
}
8. 异常和性能
一些开发者不愿意使用异常的一个重要原因便是:影响性能(个人决定有点无稽之谈,至少在工控领域我觉得这点性能损失完全不必要担心)。
《框架设计指南》中提到:当抛出异常的频率高于每秒 100 个时,极有可能会带来显著的性能影响。此时我们可以使用"测试者-执行者模式",或"Try 模式",这两种模式在 .NET 中十分常见。
8.1 测试者-执行者模式:
我们以集合为例。以下代码我们并不知道 numbers
是否是只读集合,因此它有可能抛出 NotSupportException
:
c#
ICollection<int> numbers = ...
numbers.Add(1);
ICollection<T>
刚好定义了 IsReadOnly
属性,我们可以先通过它判断集合是否是只读的,再进行 Add 操作:
c#
ICollection<int> numbers = ...
...
if (!numbers.IsReadOnly)
{
numbers.Add(1);
}
其中,IsReadOnly
是"测试者",Add()
方法是"执行者"。
在"7. 如何自定义异常"中,我定义的 Measurer
类也添加了 IsConnected
属性,用于告知硬件是否已连接,这也是一种"测试者-执行者模式"。
Notice
在多线程中使用该模式可能出现"竞态条件",使用时要多加注意。
8.2 Try 模式
Try 模式的使用更加常见,如 int.TryParse()
、Dictionary<TKey, TValue>.TryGetValu()
等方法。
以 DateTime
的 Try 模式为例,大致形式如下:
c#
public struct DateTime
{
public static DateTime Parse(string dateTime) { ... }
public static DateTime TryParse(string dateTime, out DateTime Result) { ... }
}
Try 模式有诸多细节需要注意:
-
如果成员在常用代码中都可能抛出异常,应使用 Try-Parse 模式避免因异常引起的性能问题。
-
Try-Parse 模式要使用"Try"前缀,用 bool 作为返回类型。
-
Try 方法返回
false
的原因只有一种,其余类的失败则要抛出异常。 -
要为 Try 方法提供等价抛出异常的方法。
如
DateTime.TryParse()
的等价方法DateTime.Parse()
-
要通过 out 参数,返回 Try 方法的值。
-
要在 Try 方法返回 false 时,将 default(T) 赋值给 out 参数。
-
避免在抛出异常时向 Try 方法的 out 参数写入数据。
9. 怎么处理"不知道如何处理"的异常
当你调用的接口抛出了未知异常,应该怎么处理?答案可能出乎大多数人的意料:不要捕捉它,让程序崩溃是最好的解决方案。《框架设计指南》中有这么一段话:
你的应用程序应该只处理它理解的那些异常。一般来说,在"某物"出问题之后,几乎不可能把应用程序从可能已被破坏的状态恢复到正常状态,此时只需处理那些你的应用程序可以合理响应的异常。对于其他所有的异常,无需处理,操作系统可中止你的应用程序。
如果你认为当前应用程序已处于带病状态、它不应该继续运行,此时建议通过调用 System.Enviroment.FailFast()
方法来中止进程,而不是抛出异常。该方法接受一个 string 参数,我们可以通过该参数传递错误信息。这个错误信息最终会被操作系统记录在"计算机管理→系统工具→事件查看器→Windows 日志→应用程序"中。因未捕获异常导致的程序崩溃,其崩溃信息也会记录在此处。
最后,根据 Windows 日志信息,进一步排查程序崩溃原因,进行针对性修复。
Enviroment.FailFast()
的用法如下:
C#
Enviroment.FailFast("发生了一个无法挽回的异常", ex)
10. 转移异常
有时我们捕捉到异常并不想直接处理,又不想抛出,而是想转移至其他线程。使用一个字段/属性记录该异常,再在主线程抛出?不是很合适,这会破坏原有的调用栈信息。
.NET 早有预备,它提供了 ExceptionDispatchInfo
类,专门用于转移异常:
当从其他线程转移异常,或者 catch 后未使用空的 throw 语句再次抛出异常,要使用 ExceptionDispatchInfo
类,它会在重新抛出的过程中持续保存调用栈。下面是一个简单的用例:
C#
private ExceptionDispatchInfo _savedExceptionInfo;
private void BackgroundWorker() {
try{
...
} catch (Exception e){
_savedExceptionInfo = ExceptionDispatchInfo.Capture(e);
}
}
public object GetResult() {
if(_done) {
if(_savedExceptionInfo != null){
_savedExceptionInfo.Throw();
// 编译器无法理解该方法是抛出了一个异常,因此需要额外的return语句。
return null;
}
}
}
11. 过滤异常
异常过滤器(exception filter)于 C#6 引入。通过异常过滤器我们可以重复捕获同类型异常:
csharp
catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{ ... }
catch (WebException ex) when (ex.Status == WebExceptionStatus.NameResolutionFailure)
{ ... }
when 子句中的布尔表达式可以包含副作用,例如调用一个方法记录诊断所需的异常的日志。
它的使用也有陷阱。试分析如下代码会发生什么:
c#
try
{
throw new ArgumentException();
}
catch (ArgumentException ex) when (ex.ParamName == string.Empty)
{
Console.WriteLine("命中第一个 when 子句");
}
catch (ArgumentException ex) when (ex.ParamName is null)
{
Console.WriteLine("命中第二个 when 子句");
}
答案是:会输出"命中第二个 when 子句"。
你可能会疑惑:它不应该在第一个 when 字句那里抛出异常吗?毕竟 ParamName 属性未赋值,进行相等判断时应该抛出 NullReferenceException
才对。这是因为在过滤器中引发异常时,该异常由 CLR 捕获,并且该过滤器返回 false。该行为无法和过滤器执行并返回 false 区分开,因此很难进行调试。使用时要多加注意
12. 其他事项
12.1 切勿随意修改异常名称及其父类
我们前面提到:
处理方式相同的异常,可以捕获它们共同的父类
这意味着我们不应该随意修改自定义异常的基类,否则原有的异常处理代码无法正常工作。以如下代码为例,因基类异常由 InvalidOperationException
改为 Exception
,原有的异常处理代码不再起作用:
c#
// 原异常
// class MyException : InvalidOperationException { }
// 修改基类后的异常
class MyException : Exception { }
// 原本可以正常运作的异常处理代码失效
try
{
throw new MyException();
}
catch (InvalidOperationException ex)
{
Console.WriteLine("捕捉到 InvalidOperationException 或其子类异常");
}
12.2 捕捉后仍会向上抛出的异常:ThreadAbortException
提到多线程显然离不开 Thread
。Thread
的 Abort()
方法用于终止相应线程,被终止的线程会抛出 ThreadAbortException
异常。不过该异常较为特别,捕捉后会继续向上抛出。试分析如下代码,会发生什么:
c#
Thread thread = new Thread(DoSomething);
thread.Start();
Thread.Sleep(100);
thread.Abort();
void DoSomething()
{
try
{
try
{
while (true)
{
Thread.Sleep(20);
}
}
catch (ThreadAbortException ex)
{
Console.WriteLine("第一次捕获到 ThreadAbortException 异常");
}
}
catch (ThreadAbortException ex)
{
Console.WriteLine("第二次捕获到 ThreadAbortException 异常");
}
}
答案是:它会依次输出"第一次捕获到 ThreadAbortException 异常"、"第二次捕获到 ThreadAbortException 异常"。若想终止该异常进一步向外抛出,需调用 Thread.ResetAbort()
方法,它会将线程状态(ThreadState
)从 AbortRequested
恢复至 Running
。
参考文献:
- 《框架设计指南:构建可复用.NET库的约定、惯例与模式》第三版
- 《C#7.0 核心技术指南》
Info
上述两本书的部分内容,可参阅我的阅读笔记阅读笔记目录汇总 - hihaojie - 博客园