异常的使用

异常的使用

不管是网络还是出版读物,关于 C# 异常系统性的资料都比较少,我所在的工控领域也很少有开发者使用异常。异常实际上是一种非常好的机制,很值得推广。为此我根据过往的学习积累,结合一些项目经验,撰写本文。

1. 为什么应该使用异常

在开始本文之前,我们先看一下常用的几种"报告错误"的方式:

  • 方式一:返回错误码

缺点:

  1. 使用者不得不对返回值进行判断,导致"圈复杂度"增加;
  2. 如果方法需要返回值,返回内容不得不通过"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;
        }
    }
}
  • 方式二:全局属性记录错误信息

缺点:

  1. 需要对全局属性进行判断,使用者很容易遗漏;
  2. 同样会造成"圈复杂度"增加。
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 个:

  • ArgumentExceptionArgumentNullExceptionArgumentOutOfRangeException 的基类,用于表示传入的参数错误
  • 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 异常");
    }
}

通过上述问题我们可以得出如下结论:

  1. 要捕捉的异常存在父子关系时,需要子类异常在前,父类异常在后,否则无法编译;

  2. 子类异常可以通过捕捉父类异常完成捕捉;

    Exception​ 作为所有异常的基类,捕捉它可以捕获全部异常。

  3. 未捕获的异常会进一步向上抛出;

对应的,捕捉异常有这些惯例(准则):

  1. 处理方式相同的异常,可以捕获它们共同的父类;

如"使用错误"异常(ArgumentException​ 三兄弟),它们的处理方式相同(应由调用者修改代码),可以直接捕获 ArgumentException​ 异常进行处理。

类似的还有 OperationCanceledException​ 和 TaskCanceledException

  1. 只捕获知道如何处理的异常;

对于未知的异常,应该进一步向上抛出,由上一级进行处理。

如下代码演示了处理已知异常 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.MessageException.StrackTrace 等诸多成员值都发生了变化。

我们可以根据需要采用相应的二次抛出方式,不过一般会遵循如下准则:

  1. 若不需要二次包装异常,应直接使用 throw 二次抛出;
  2. 若需要二次包装异常,应使用 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:基类异常,它是所有异常的基类。我们自定义异常时需要派生自该类或其子类。
  • ApplicationExceptionSystemException:设计之初,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 抛出,开发者不应该使用这些异常。

  • NullReferenceExceptionIndexOutOfRangeExceptionAccessViolationException:表示代码存在缺陷,需要开发者调整代码。
  • 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​。


参考文献:

  1. 《框架设计指南:构建可复用.NET库的约定、惯例与模式》第三版
  2. 《C#7.0 核心技术指南》

Info

上述两本书的部分内容,可参阅我的阅读笔记阅读笔记目录汇总 - hihaojie - 博客园

相关推荐
数据的世界012 小时前
使用Avalonia UI实现DataGrid
c#
powershell 与 api5 小时前
C#,shell32 + 调用控制面板项(.Cpl)实现“新建快捷方式对话框”(全网首发)
开发语言·windows·c#·.net
Kelvin_Ngan7 小时前
C#从XmlDocument提取完整字符串
c#
iqay9 小时前
【C语言】填空题/程序填空题1
c语言·开发语言·数据结构·c++·算法·c#
中游鱼16 小时前
C# 数组和列表的基本知识及 LINQ 查询
c#·linq·数组·数据处理·list数列
32码奴17 小时前
C#基础知识
开发语言·c#
来恩10031 天前
C# 类与对象详解
开发语言·c#
Dr.勿忘1 天前
C#面试常考随笔8:using关键字有哪些用法?
开发语言·unity·面试·c#·游戏引擎
xcLeigh1 天前
WPF进阶 | WPF 数据绑定进阶:绑定模式、转换器与验证
c#·wpf