学会异常处理,让你的代码更稳定

当面对复杂问题时,我们常常无从下手,难以找到明确的方向。此时,我通常使用 WWH 方法:What——搞清问题所在;Why——分析问题根本原因、How——如何解决问题。

所以这个系列叫做 WWH ,我会把平时遇到的技术问题和思考整理到这里。

今天,我们来看看系列的第五篇文章:

How - 如何处理异常?


正常流程 vs 异常流程

607e49deb5f117a7a5ac8b56866eb4fc.png

正常流程 vs 异常流程

一个程序的运行是由许多函数的调用组成。函数 A 调用函数 B,函数 B 再调用函数 C,依次类推。

在正常情况下,每个被调用的函数都能返回调用者所期望的结果,使得程序可以顺畅地运行。我们可以将这种状态称为“正常流程”或“常规路径”。

25b92bc962a50e2299a0c74ab22d259a.png

正常流程

然而,有时候,函数在执行过程中可能会遇到一些它无法解决的问题,这些问题会打破程序的正常流程,从而引发我们所称的“异常流程”。

例如,你可能试图访问一个已经被删除的文件(触发FileNotFoundException),或者由于递归层次过深导致栈溢出(触发StackOverflowException)。这些问题既可能由程序本身产生,也可能由外部环境导致。

异常流程的影响

异常流程往往给程序员带来极大的困扰。如果不对它们进行处理,我们就会收到一大堆的错误信息。

大家都熟知的 Windows 蓝屏现象,正是由于操作系统内核模块中出现了未经处理的异常所引起的。

eb9010a3ad0febafdb1eec71f21e8453.png

Windows 蓝屏

在控制台应用程序中,未经处理的异常信息会直接在命令行界面中显示。

b794092508856df4e02f4042ac107218.png

控制台抛出异常

在图形界面的应用程序中,未经处理的异常则会通过弹窗的形式进行显示。

d2895a0d794c2a62a0340713966f5ca4.png

图形界面抛出异常

而在网页应用程序中,未经处理的异常信息会直接在页面上呈现。

7944d52b043a2154b41c5634e53e6aab.png

网页抛出异常

这些未经处理的异常都有可能导致当前运行的程序崩溃。因此,如何正确处理异常是程序设计中必须要考虑的问题。

面向对象语言处理异常的原理

大部分的面向对象编程语言,都通过 exception 对象来处理异常流程。

异常的设计目的是用于表示程序中的错误条件,并在出现这种错误条件时通过抛出异常来改变程序的正常流程。

在这里,你可能会问:“为什么面向对象语言要分离异常流程和正常流程的控制流?”

其实,这主要是因为异常情况在本质上与常规程序执行流程不同。在常规流程中,函数返回的结果是预期的,而在异常流程中,函数无法正常执行,需要通过其他方式表达这种错误情况。

通过将异常流程和正常流程的控制流分离,我们可以使代码结构更清晰,易于理解和维护。同时,这也有助于提高代码的健壮性,因为在出现异常时,程序可以直接跳转到处理这个异常的代码块,而不是继续执行可能会导致程序崩溃的代码。

然而,异常并不是方法的返回值,那么异常是如何传播的呢?

实际上,在 C 语言中,异常流程仍然通过函数返回错误代码的方式来处理,也就是说,正常流程与异常流程是通过同一种控制流来处理的。

然而,对于 Java 和 C# 这类语言来说,异常流程和正常流程的控制流是分离的。运行环境会直接负责处理异常流程的控制流,使得异常对象可以直接回溯到处理它的函数中,而非仅能返回到调用它的函数。

具体流程如下:

  1. 每当执行一个方法,如果该方法抛出了异常,则该异常对象将被压入调用栈的顶部。

  2. 程序可以通过在方法内使用 try/catch 块来捕获该异常。当捕获到异常时,该异常对象将从调用栈的顶部弹出,并被传递到 catch 块中处理。

  3. 如果没有任何 catch 块能够处理该异常,则该异常将继续在调用栈中传递,直到被某个 catch 块处理。

  4. 如果未能捕获该异常,则该程序将终止并生成一个错误报告。

总的来说,异常是通过抛出异常对象,然后在调用栈中进行回溯传递,以此来表示程序中出现的错误或异常情况。

5b18846d9247d083f569f85c0d63c971.png

异常流程

以下是异常的几种常见处理方式。我尽量让语言更流畅,逻辑更清晰,并检查了准确性。希望对你有所帮助。

异常处理:预防胜于治疗

异常处理涉及创建异常对象、填充栈跟踪信息,以及可能的上下文切换等步骤, 因此,与常规的控制流语句相比,异常处理机制的性能开销往往更大。频繁使用异常可能会对程序的性能产生负面影响。

然而,即使在高性能需求的场景下,我们也可以通过一些策略保持良好的性能。关键在于预防性地检查错误条件,避免在常规执行路径中抛出异常。

接下来,我将介绍两种预防性处理异常的策略:Tester-Doer 模式和 Try 模式。

d7d145c67c9b0a1e0a8aaa361d669820.png

预防处理异常的策略模式

Tester-Doer 模式

Tester-Doer 模式主要是为了防止在可能抛出异常的操作中造成不必要的性能消耗。

"Tester-Doer"模式由两个部分组成:

  • "Tester"(检查者):负责检查某个操作是否能够成功执行。

  • "Doer"(执行者):进行可能抛出异常的操作。

下面是这种模式的一个代码示例:

string filePath = "..."; // 文件路径

if (File.Exists(filePath)) // "Tester"
{
    string content = File.ReadAllText(filePath); // "Doer"
    Console.WriteLine(content);
}
else
{
    Console.WriteLine("文件不存在");
}

在上述示例中,我们首先使用 File.Exists 方法检查文件是否存在。如果文件存在,我们接着使用 File.ReadAllText 方法读取文件内容。这样,我们就避免了在试图读取不存在的文件时引发异常,提高了代码的性能。

Try 模式

对于对性能特别敏感的 API,我们可以使用 Try 模式。此模式通过使用 out 参数返回函数的内容,并使用 bool 类型返回是否出现异常,以避免直接抛出异常。

举例来说,C# 中有 DateTime.ParseDateTime.TryParse 两种方法。Parse 方法尝试将字符串解析为 DateTime 对象,如果解析失败,就会抛出异常。TryParse 方法也尝试解析字符串,但如果解析失败,它返回 false 并且不抛出异常。如果解析成功,它返回 true 并通过 out 参数返回解析结果。

string dateStr = "2023-07-11";
DateTime date;

if (DateTime.TryParse(dateStr, out date)) // "Try-Method"
{
    Console.WriteLine("解析成功,日期是 " + date.ToString("yyyy-MM-dd"));
}
else
{
    Console.WriteLine("解析失败,无法转换 '" + dateStr + "' 为日期");
}

然而,"Try" 模式并不适用于所有场景。例如,如果一个操作需要大量的输入参数,使用 "out" 参数返回结果可能会使代码变得难以理解和维护。因此,我们必须仔细考虑具体的应用场景,才能决定是否使用 "Try" 模式。

Tester-Doer 模式与 Try 模式的对比

"Try" 模式是对 "Tester-Doer" 模式的进一步优化,它将 "Tester" 和 "Doer" 整合到一个方法中。这个方法尝试执行一个操作,并返回该操作是否成功。如果操作成功,它还会返回操作的结果。这种模式的优点是避免了两次方法调用的开销(一次 "Tester",一次 "Doer"),并且可以预防 "race condition" 的问题。

让我们以一个在线银行应用为例。用户可以通过此应用进行转账操作。现假设我们按照

"Tester-Doer" 模式实现了如下代码:

// "Tester"
if (account.Balance >= amount)
{
    // "Doer"
    account.Balance -= amount;
    otherAccount.Balance += amount;
}

在这个例子中,我们首先检查账户余额是否足够("Tester")。如果足够,我们就进行转账操作("Doer")。然而,如果在 "Tester" 和 "Doer" 之间有其他线程也改变了账户余额,就可能出现 "race condition"。比如,当余额检查完后,如果有其他线程从账户取走了一部分钱,此时账户余额可能就不足以完成转账操作,但转账操作还会被执行,可能导致账户余额变为负数。

如果我们使用 "Try-Method" 模式,我们可以将余额检查和转账操作作为一个原子操作,如下:

public bool TryTransfer(decimal amount, Account otherAccount)
{
    lock (balanceLock)
    {
        if (Balance >= amount)
        {
            Balance -= amount;
            otherAccount.Balance += amount;
            return true;
        }
        else
        {
            return false;
        }
    }
}

在此例中,我们使用了 lock 关键字来确保在检查余额和进行转账操作之间,没有其他线程能改变账户余额。这样可以避免 "race condition",确保账户余额的正确性。

总的来说,'Tester-Doer' 模式和 'Try' 模式都是为了预防性地处理异常,而不是在异常发生后进行处理。这两种模式各有优势,'Tester-Doer' 模式易于理解和维护,适用于大部分情况,而 'Try' 模式对性能的影响更小,特别是在需要频繁调用的情况下。

何时抛出异常

决定何时抛出异常时,我们需要界定异常是否代表的是使用错误(usage error)还是执行错误(execution error)。

使用错误(Usage Errors)这类错误指出程序员没有正确使用API或方法。例如,传递了无效的参数或在错误的上下文中调用了方法。这类错误应该在开发和测试阶段被检测并修复。

执行错误(Execution Errors)这类错误表示在尝试执行某些操作时发生了一个意外的状况。这种状况并非由于程序员的错误,而是由于某些无法预测的运行时状况导致,如网络中断、磁盘空间不足,或数据库连接失败等等。这类错误需要在运行时捕获并适当处理。

309429ec0a6408df6a8f9b2f2cbffe0f.png

抛出异常的情况

使用错误(Usage Errors)

“使用错误”通常指的是程序员因为编程错误或误解导致的问题。例如,可能传递了错误的参数,或在错误的上下文中调用了方法。这些错误通常在编译阶段就能被检测和修复,因此没有必要在运行时捕获和处理这些错误。

以下例子可以说明:

public class MyClass
{
    public void MyMethod(string arg)
    {
        if (arg == null)
        {
            throw new ArgumentNullException(nameof(arg));
        }

        // 使用 arg 执行某些操作...
    }
}

public class MyApp
{
    public void Run()
    {
        var myClass = new MyClass();
        
        myClass.MyMethod(null); // 这将引发一个使用错误(ArgumentNullException)。
    }
}

在这个例子中,MyMethod 期望 arg 参数不为null。但是,在Run 方法中,我们错误地传递了一个null值给 MyMethod。当 MyMethod 被调用时,它会抛出一个 ArgumentNullException,表示这是一个使用错误。

对于这种错误,我们不应在运行时捕获并处理这个异常。相反,我们应修复 Run 方法,以确保我们永远不会传递一个 null 值给 MyMethod。这是一个编程错误,应通过修改代码来修复,而非通过异常处理。

下面是修改后的代码:

public class MyApp
{
    public void Run()
    {
        var myClass = new MyClass();
        
        var arg = "一些有效的值";

        // 现在我们保证我们永远不会传递一个null给MyMethod。
        // 这修复了编译时的使用错误,因此在运行时永远不会发生。
        myClass.MyMethod(arg);
    }
}

在这个修改后的版本中,我们确保了永远不会向 MyMethod 传递 null 值。这样就避免了在运行时抛出 ArgumentNullException 的使用错误。

执行错误(Execution Errors)

执行错误可以进一步划分为程序错误和系统失败。

程序错误(Program Error): 这种类型的错误是由程序的内部错误或逻辑错误引起的。例如,算法错误、数据结构问题、资源泄露等。这类错误通常可以通过更好的设计和编程技术来避免。

系统失败(System Failure): 这种类型的错误是由外部系统引起的,比如硬件故障、网络中断、数据库连接失败、外部服务不可用等。对于这类错误,你的代码无法直接控制或预防,只能通过异常处理来应对。

e03c262684b904a4549c1dfd16465bbb.png

执行错误的分类

1.程序错误(Program Error)

程序错误是一种可通过编程方式处理的执行错误。即便错误在运行时发生并非由编程错误引起,我们仍然可以通过编写适当的错误处理代码来将其返回正常流程。

以下是一个例子,说明了如何处理 File.Open 抛出的 FileNotFoundException,也就是一个典型的程序错误:

public FileStream OpenOrCreateFile(string filePath)
{
    try
    {
        return File.Open(filePath, FileMode.Open);
    }
    catch (FileNotFoundException)
    {
        // 如果文件不存在,我们可以在这里创建一个新文件。
        return File.Create(filePath);
    }
}

在这个例子中,我们首先尝试打开一个文件。如果文件不存在,File.Open 方法会抛出一个 FileNotFoundException。我们捕获这个异常,并在异常处理代码中创建一个新文件,这样处理这种情况,使其返回正常流程。

2.系统失败(System Failure)

系统失败指的是无法通过编程手段处理的执行错误。这是因为这类错误通常由程序运行环境或系统资源的问题引起,这些问题超出了程序员的控制范围。

例如,当 Just-in-Time (JIT) 编译器因为内存耗尽而抛出 out-of-memory 异常时,这就是一个典型的系统失败。在这种情况下,尽管程序的代码逻辑可能是完全正确的,但由于环境问题(即内存不足)仍然发生了错误。

通常,对于系统失败,最好的策略是让程序终止,然后依赖外部机制(如操作系统或人工干预)来恢复或处理错误,因为这类错误超出了程序的处理能力。

以下是一个可能会导致 OutOfMemoryException 的示例:

try
{
    // 尝试分配大量内存
    var list = new List<long>();
    for (int i = 0; ; i++)
    {
        list.Add(i);
    }
}
catch (OutOfMemoryException)
{
    // 对于这种严重的系统错误,我们应该立即终止程序。
    Environment.FailFast("Out of memory!");
}

在这个例子中,程序试图无限制地分配内存,这在任何实际的系统中都是无法实现的,所以最终会导致 OutOfMemoryException

尽管你可以在程序中捕获这个异常,但通常无法有效地处理它,因为在内存耗尽的情况下,你的程序可能无法进行任何有效的操作。

因此,对于这类系统失败,最好的处理方式通常是让程序终止,并依赖于外部机制来恢复或处理错误,如人工干预或系统的自动恢复机制。

Catch 块:异常捕获

catch 语句块用于实现错误的恢复代码。只有当你理解了为什么在特定的上下文中抛出了特定的异常,并且你能用编程方式来恢复失败的操作时,你才应该去捕获这个异常。也就是说,如果你无法确定如何从异常中恢复,那你就不应该去捕获这个异常。

按照我们之前的分类,我们应该只在发生程序错误的情况下捕获异常。

通过一个代码示例,我们可以更好地理解这个概念:

try
{
    // 在此处尝试打开一个文件
    var text = File.ReadAllText("myFile.txt");
}
catch (FileNotFoundException e)
{
    // 如果文件不存在,我们就会知道为什么会抛出 FileNotFoundException。
    // 并且,我们知道如何从这个异常中恢复:我们可以创建一个新的文件或使用默认的配置
    File.WriteAllText("myFile.txt", "default content");
}

在这个例子中,我们尝试从 myFile.txt 文件中读取内容。如果文件不存在,File.ReadAllText 方法将会抛出一个 FileNotFoundException 异常。在 catch 块中,我们捕获了这个特定的异常,因为我们清楚导致异常的原因(文件不存在)以及如何从此异常中恢复(创建一个新文件并写入默认的内容)。

避免过度捕获异常

很多时候,我们应该允许异常向调用栈的上层传递,因为这有助于我们发现并修复程序中的错误。过度捕获异常可能会隐藏程序中存在的问题,这对于开发稳定的产品是极其不利的。因此,我们应当尽量减少异常处理的使用,除非我们确实需要它。

让我们通过一个代码示例来理解这个概念:

try
{
    // 这里可能会抛出多种异常,如 FileNotFoundException、OutOfMemoryException 等
    var text = File.ReadAllText("myFile.txt");
}
catch (Exception e)
{
    // 我们捕获了所有的异常,这可能会掩盖一些我们未意识到的错误
    Console.WriteLine("发生错误: " + e.Message);
}

在这个例子中,我们尝试读取一个文件的内容,但我们捕获了所有类型的异常,这可能会掩盖程序中的一些错误。比如,如果文件不存在,File.ReadAllText 方法会抛出 FileNotFoundException,我们可以通过创建一个新文件来处理这个异常。但如果抛出的是 OutOfMemoryException,我们可能无法处理,应让它继续向上抛出,由更高级别的代码或者最终的用户来处理。

因此,更好的做法可能是只捕获我们知道如何处理的特定异常:

try
{
    var text = File.ReadAllText("myFile.txt");
}
catch (FileNotFoundException)
{
    // 处理 FileNotFoundException,例如创建新文件
    File.WriteAllText("myFile.txt", "default content");
}
// 其他异常(如 OutOfMemoryException)会继续向上抛出

这样,我们就可以在知道如何处理异常时进行处理,同时让我们无法处理的异常继续向上抛出。

Finally 块:确保资源的正确释放

异常处理中的 catch 子句用于恢复错误,而 finally 子句则负责执行清理代码。

操作如文件、数据库连接、网络连接等涉及占用系统资源的任务时,任务完成后我们需要进行适当的关闭或释放操作。这是因为这些操作会占用系统资源,如果不进行适当的关闭或释放,可能会导致系统资源的浪费,甚至在某些情况下,系统可能无法打开更多的文件或连接。

在编写异常处理代码时,我们往往更倾向于使用 try-finally 结构,而不是 try-catch 结构。尽管这看起来可能违反直觉,但事实上,在许多情况下,catch 块可能并不是必须的。这是因为 finally 块能够确保即使发生异常,清理代码也会被执行。这样做有助于我们确保即使在抛出异常后,系统也能维持一致的状态。通常来说,这个清理逻辑会涉及到撤销资源分配。

下面的代码示例能更好地说明这个概念:

FileStream stream = null;
try
{
    stream = File.Open("myFile.txt", FileMode.Open);
    // 对文件进行操作
    var text = new StreamReader(stream).ReadToEnd();
    Console.WriteLine(text);
}
catch (FileNotFoundException)
{
    // 处理异常
}
finally
{
    // 不论是否发生异常,总是关闭流
    if (stream != null)
    {
        stream.Close();
    }
}

在此示例中,如果我们没有在 finally 块中关闭 FileStream,那么当 try 块中的代码抛出异常时,stream.Close() 就不会被执行。此时,文件将被程序占用,直到程序结束。然而,通过在 finally 块中关闭 FileStream,我们确保了无论是否发生异常,文件都会被正确地关闭。

Using 语句

C# 和 VB.NET 提供了 using 语句,用于清理实现了 IDisposable 接口的对象。它本质上就是一个 try-finally 语句。

我们通过一个代码示例来理解这个概念:

using (var stream = File.Open("myFile.txt", FileMode.Open))
{
    // 这里可能会抛出异常
    var text = new StreamReader(stream).ReadToEnd();
    Console.WriteLine(text);
} // 在此处,即使抛出了异常,文件流也会自动关闭

在这个例子中,我们使用了 using 语句,它会自动调用 stream 对象的 Dispose 方法来关闭文件流,无论 using 块中的代码是否抛出异常。这样,我们就无需手动编写清理代码,可以更简洁、安全地处理资源。

总结

异常处理在编程中至关重要。它识别和处理程序中的错误条件,保持程序的稳定性和可靠性。异常处理改变程序的正常流程,并采取适当措施处理异常情况。

本文介绍了正常流程和异常流程的区别,以及异常对程序的影响。我们了解了面向对象语言中的异常处理原理,使用异常对象传播异常并通过 try/catch 块处理。

预防性处理异常的技巧包括 Tester-Doer 模式和 Try 模式,提高性能和健壮性。使用 finally 块和 using 语句清理代码和释放资源。

正确处理异常提升程序的可靠性、稳定性和性能。理解异常处理原理和应用技巧,编写健壮、可靠且易于维护的程序。


WWH 系列文章列表:

[1] Why - 为什么 JS 更像一门编译型语言?

[2] What - 什么是依赖注入?

[3] What - 什么是 Big O?

[4] How - 不同的语言都如何处理错误?

ChatGPT 文章列表:

[1] 站在 ChatGPT 的肩上编程

[2] ChatGPT 在做什么?它为什么有效?

[3] 你知道 ChatGPT 每生成一个字需要计算多少次吗?

[4] ChatGPT 启示,UI 的未来是什么?

[5] ChatGPT 启示,如何改变我们的搜索习惯?

[6] ChatGPT 的心智能力

[7] ChatGPT 的费米悖论能力

最近文章列表:

[1] 什么是数据化决策?

[2] 纪念陈虻

[3] 计算机诞生之前,数据如何排序?

[4] 人生有没有意义?人类又有什么意义?

[5] 第一性原理

[6] 什么是好奇心?

[7] 需要多少内存来运行100万个并发任务?

[8] 在 C 语言中实现简单的哈希表

[9] 成就卓越:事业成功的核心要素

b8970c364f88119ff1217324fc39735b.jpeg

喜欢本篇文章,记得动动小手点个在看↓↓

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值