65.9K
CodeProject 正在变化。 阅读更多。
Home

实践类型系统 (PTS) 中的错误处理

starIconstarIconstarIconemptyStarIconemptyStarIcon

3.00/5 (2投票s)

2024年3月1日

CPOL

35分钟阅读

viewsIcon

3340

错误处理很重要,但没人愿意做!

目录

这是系列文章《如何设计一个实用的类型系统以最大限度地提高软件开发项目的可靠性、可维护性和生产力》的第六部分。

建议(但对有经验的程序员不是必需的)按出版顺序阅读文章,从 第一部分:是什么?为什么?如何做? 开始。

欲快速回顾前几篇文章,您可以阅读 实践类型系统 (PTS) 文章系列摘要

注意

请注意,PTS 是一种新的范式,仍处于开发过程中。正如《实践类型系统 (PTS) 的本质与基础》一文的 历史 部分所述,我创建了一个概念验证实现,现在已有些过时——因此,您将无法尝试本文中显示的 PTS 代码示例。

错误处理代码中的错误

引言

您喜欢编写处理错误的 Our 吗?

大多数程序员(包括我自己)都不喜欢。我们更喜欢编写“快乐路径”——处理错误“没意思”。我们希望我们的应用程序能在这样一个世界中运行:所需文件始终存在、数据库永不失败、网络连接始终可用,并且恶意用户闻所未闻。

实践表明,错误处理通常被忽视(尤其是在项目早期),因为它需要相当多的奉献、纪律、经验和专业知识。一个常见的陷阱是认为错误处理“以后可以完成”,因为这通常意味着它永远不会完成,因为“有截止日期,我们需要并想要添加功能。”

与此同时,我们也意识到了错误处理的重要性,因为它有助于快速识别和解决问题。良好的错误处理对于创建可靠、健壮、安全、容错、可维护和用户友好的软件至关重要。

简而言之:错误处理很重要,但没人愿意做!

因此,一个为可靠性而设计的类型系统应该

  • 保护我们免于意外忘记处理错误

  • 尽可能地促进所有错误处理的变体(包括显式忽略错误),并支持简洁易读易写的语法

本文将展示 PTS 如何实现这一目标。

常见的错误处理方法

在展示 PTS 中的错误处理工作原理之前,先考虑一些常见的错误处理方法可能会有用。

注意

只对 PTS 方法感兴趣的读者可以 跳过本节

这里是简要概述

  • 专用返回值

    在没有原生错误处理支持的语言中,可以使用 *专用返回值* 来信号化错误。

    例如

    • 返回大于或等于 0 的整数的函数,在出错时返回 -1

    • 将 XML 文档转换为 JSON 字符串的函数,在 XML 文档无法解析时返回 "XML ERROR",或在 JSON 文档无法创建时返回 "JSON ERROR"

    这种方法的一个特例是返回一个布尔值来指示成功或失败。

  • 可变输入参数

    在没有内置错误处理支持的语言中返回错误的另一种方法是使用一个可变输入参数,该参数可以在函数内部设置为错误值,然后由调用函数的代码进行检查。

  • 全局错误对象

    一些语言提供了一个全局读/写错误对象,函数通过该对象更新以信号化错误,然后由调用堆栈中的父函数读取以检查是否发生错误。

    一个例子是 C 中的 errno

  • 多返回值

    一些编程语言支持函数的多返回值。第一个输出参数用于返回结果,而最后一个输出参数用于返回错误。Golang 使用这种方法。

    通过函数返回的元组可以实现类似的效果。

  • 异常

    许多流行的编程语言支持 *异常* 作为处理错误的专用机制——例如 C++、C#、Java、JavaScript、Kotlin、Python 和 Ruby。

    据我所知,Java 是唯一区分 *检查型* 和 *非检查型* 异常的流行语言。非检查型异常可以被忽略,但检查型异常不能。上面列出的所有其他语言仅使用非检查型异常。

  • Result/Either 类型

    一些语言提供 Result(又名 Either)类型,它可以作为可能失败的函数的返回类型。该类型有两种可能的实例:表示成功返回值的实例,或表示错误的实例。

    例如

    • Rust 提供了一个 Result 类型用于返回和传播错误。

    • Haskell 提供了一个 Either monad 来表示一个值,该值要么是正确的,要么是错误的。

注意

有关上述大多数错误处理方法的详尽讨论,我推荐阅读 Joe Duffy 的文章 The Error Model

上述方法的示例也显示在 Nicolas Fränkel 的文章 Error handling across different languages 中。

PTS 方法

PTS 不采用上述任何方法。

前三种方法(专用返回值、可变输入参数和全局错误对象)是不好的方法——它们会导致易出错的错误处理,因此不适合旨在编写可靠代码的高级语言。

使用多返回值或元组进行错误处理对开发者不友好。错误很容易被忽略,代码很快就会被错误处理弄得混乱不堪。

不使用异常或 Result/Either 类型的原因在文章 实践类型系统 (PTS) 中的联合类型为什么我们需要联合类型? 部分进行了详细解释。

注意

不采用非检查型异常来处理 *所有* 错误的其他原因可以在 Joe Duffy 的(冗长但非常有见地的)文章 The Error Model非检查型异常 部分找到。

它是如何工作的?

本节通过简单的源代码示例概述了 PTS 中的错误处理,但不深入探讨实现细节。

基本原则

PTS 中的一个关键原则是将两种类型的错误区分开

  • *可预期* 错误,例如 file_not_found_errornetwork_connection_error 等。

    这些错误预计可能在运行时发生。

  • *不可预期* 错误,例如 out_of_memory_errorstack_overflow_error 等。

    这些错误预计不会在运行时发生。它们仅在发生通常无法在运行时解决的严重问题时才会发生。

这两种类型的错误处理方式不同。

*可预期* 错误遵循以下原则

  • 可能在运行时失败的函数必须在其签名中声明可能会返回可预期错误。

  • 函数返回的错误不能被静默忽略:它们必须在调用代码中处理,或 *显式* 忽略。

  • 只有一个惯用的方法可以从函数 *返回* 错误,但有几种(良好支持的)方法可以 *处理* 函数返回的错误。

  • 一组专用的运算符和语句可以简化错误处理。

*不可预期* 错误遵循以下原则

  • 不可预期错误可能在程序执行期间的任何时间、源代码中的任何位置发生。但是,它们 *不被预期* 发生——它们是 *不可* 预期的,因此不在函数签名中声明。

  • 默认情况下,不可预期错误由全局错误处理器处理。此处理器的内置默认实现会将错误信息(包括堆栈跟踪)写入标准操作系统 err 设备,然后以退出代码 1 中止程序执行。

    应用程序可以注册不同的错误处理器来定制不可预期错误的全局处理。

  • 不可预期错误可以在源代码中的任何位置显式捕获,以便在特定情况下提供定制的错误处理。

  • 不可预期错误通常是隐式抛出的,但也可以显式抛出,例如在运行时检测到不可恢复的问题时。

这些是基本的设计原则。

现在让我们看看细节。

Types

类型层级结构

在上篇文章《实践类型系统 (PTS) 中的 Null 安全》的 类型层级结构 部分,我们已经看到了 PTS 类型层级结构中的以下顶级类型

现在我们将细化此类型层级结构,首先定义 non_null 的子类型

正如你所见,代表错误(error 的子类型)的类型与不代表错误的类型(non_error 的子类型)之间存在清晰的分离。

现在让我们看看 error 的子类型

此图显示了 *可预期* 和 *不可预期* 错误之间的区别。

考虑到上述附加类型,PTS 顶级类型层级结构变为

所有以斜体显示的类型都是 *不可实例化* 类型(又称 *抽象* 类型)。它们的基数是零。

现在让我们仔细看看这些类型。

错误类型

类型 error 是所有表示错误类型的根。

error 是一个 *不可实例化*(又称 *抽象*)类型——它的基数是零。因此,不可能创建 error 类型的对象——只能创建 error 的具体后代,例如 file_not_found_errorinvalid_user_input_error

类型 error 也是 *密封* 的。它有一组固定的两个直接子类型:anticipated_errorunanticipated_error。任何人不得定义 error 的其他直接子类型。

表示错误的类型都提供关于错误的特定、结构化数据——对于处理错误和适当报告错误很有用。类型 error 定义了四个通用属性,所有其后代都继承这些属性:messageidtimecause

考虑到上述规范,类型 error 定义如下(使用 PTS 语法)

type error
    inherit: non_null                                                         (1)
    child_types: anticipated_error, unanticipated_error                       (2)
    factories: none                                                           (3)

    atts
        message string                                                        (4)
        id string (pattern = "[a-zA-Z0-9_-\.]{1,100}") or null default:null   (5)
        time date_time or null default:date_time.now                          (6)
        cause error or null default:null                                      (7)
    .
.

(1) 类型 errornon_null 的子类型。

(2) child_types 属性定义了 error 的固定直接子类型集:anticipated_errorunanticipated_error。因此,类型 error 是密封的。定义其他直接子类型是无效的。此外,编译器会检查 anticipated_errorunanticipated_error 是否确实在库中定义。

(3) factories: none 声明类型 error 为不可实例化(抽象)类型。

(4) message 属性是必需的。它包含错误的描述,通常在运行时发生错误时显示。

(5) id 是错误的选定字符串标识符(例如 "FILE_NOT_FOUND")。

(6) time 表示错误发生的发生时间。默认情况下,使用当前日期和时间。在安全敏感环境中,此属性可以设置为 null,因为在此类环境中不允许存储时间(例如,攻击者不应知道错误发生的时间)。

(7) cause 是一个选定属性,表示导致此错误的低级错误。例如,config_data_not_available_error 的原因可能是 file_not_found_error

非错误类型

类型 non_error 是所有不代表错误类型的根。后代包括

  • 内置 PTS 类型,例如 stringnumberbooleanlistsetmap

  • 用户定义的类型,例如 customersupplierproduct

error 类似,non_error 也是不可实例化且密封的。固定的子类型集取决于 PTS 实现,但典型的子类型包括:scalarcollectionrecordobjectfunction

可预期错误类型

一个 *可预期错误* 是一个 *预计可能在运行时发生* 的错误。

例如,考虑一个依赖存储在文件中的配置数据的应用程序。无法保证文件在运行时实际存在——它可能已被意外移动、重命名或删除。我们必须 *预期* 这个问题,并进行相应处理(如果我们想编写高质量的代码)。例如,我们可以向用户显示一个有用的错误消息,并询问他们是否要继续使用硬编码的默认配置。

anticipated_error 的子树取决于 PTS 实现。后代的例子可能包括

  • 各种资源输入/输出错误(例如,file_errordirectory_errordatabase_connection_error——它们都是 IO_error 的子类型)

  • invalid_data_erroruser_input_errorcompiler_diagnostic_errorunit_test_error 等。

不可预期错误类型

一个 *不可预期错误* 是一个 *不被预期在运行时发生* 的错误——如果发生,则意味着发生了非常糟糕的事情。

一些例子包括:硬件故障、操作系统问题、配置问题(例如,缺少库)、代码错误。

内置错误子类型

如前所述,PTS 实现提供了大量常用的 anticipated_errorunanticipated_error 后代。

后代类型可以定义除 error 类型中定义的其他属性,以便提供与错误相关的特定、结构化数据。例如,类型 unanticipated_error 具有 call_trace 属性——一个表示函数调用堆栈的源代码位置列表(对于调试非常有用)。

如果 PTS 在面向对象的语言中实现,也可以添加有用的方法。

例如,让我们看看类型 file_error,它是 anticipated_error 的一个原生后代,它添加了 file_path 属性和 file_name 方法,以便我们能够报告哪个文件导致了错误

type file_error

    inherit: IO_error

    att file_path file_path

    // Get the name of the file, without it's directory
    fn file_name -> file_name
        return file_path.name
    .
.

file_not_found_errorfile_read_errorfile_write_error 类型仅继承自 file_error

type file_not_found_error
    inherit file_error
.

type file_read_error
    inherit file_error
.

type file_write_error
    inherit file_error
.

用户定义的错误子类型

大多数应用程序、库和框架将定义 anticipated_error 的自定义后代——与项目领域相关的特定错误类型。

例如,股票交易应用程序的 API 可能会为交易系统中不支持的货币返回专用的 unsupported_currency_error

PTS 也允许定义 unanticipated_error 的自定义后代。但是,这些很少需要——只在特殊的错误处理情况下才需要,如本文后面所示。

可预期错误

如果一个函数被宣传为在遇到困难时返回错误代码,那么你必须检查该代码,是的,即使检查会使你的代码大小加倍并使你的打字手指疼痛,因为如果你认为“这不会发生在我身上”,那么神灵一定会惩罚你的傲慢。

—— Henry Spencer,《C 程序员十诫

在本节中,我们将探讨 *可预期* 错误——即,*可能* 在运行时发生的错误(例如,file_not_found_erroruser_input_error 等)。

基本原则》部分提到

  • 可能在运行时失败的函数必须在其签名中声明可能会返回可预期错误。

  • 函数返回的错误不能被静默忽略:它们必须在调用代码中处理,或 *显式* 忽略。

接下来的几节将解释 PTS 如何确保这些条件。

函数签名

考虑一个读取文件中文本的函数。在一个理想的世界里,这样的函数永远不会失败,代码看起来是这样的

fn read_text_file ( file_path ) -> string
    // body
.

此函数接受 file_path 作为输入,并返回一个 string,表示文件中存储的文本。

但在现实世界中,资源输入/输出操作从不能保证成功——必须预期故障。因此,如果由于任何原因无法读取文件(例如,文件不存在、文件被锁定、文件被拒绝读取访问等),此函数必须返回错误。如文章 实践类型系统 (PTS) 中的联合类型 所解释的,可能失败的函数返回一个联合类型,其中包含一个 anticipated_error 类型的成员。在我们的例子中,失败时返回 file_read_error。因此,函数签名必须更改为

fn read_text_file ( file_path ) -> string or file_read_error
    // body
.

现在,函数在成功时返回 string,在失败时返回 file_read_error

如果文件为空,函数应该返回什么?出于后面文章中解释的原因,函数不返回空字符串——它返回 null。最终的函数签名变为

fn read_text_file ( file_path ) -> string or null or file_read_error
    // body
.

从函数返回错误

错误像任何其他值一样从函数返回:通过 return 语句。

下面是一个询问用户输入标题的函数示例。如果用户未能这样做,则返回 user_input_error

fn ask_title -> string or user_input_error

    case type of GUI_dialogs.ask_string ( prompt = "Please enter a title" )
        is string as title
            return title
        is null
            return user_input_error.create (
                message = "A title is required."
                id = "INVALID_TITLE" )
    .
.

有用的运算符、语句和编译器功能

在前几篇文章 实践类型系统 (PTS) 中的联合类型实践类型系统 (PTS) 中的 Null 安全 中,引入了以下运算符和语句

我们还简要提到了几个有用的编译器功能,例如 流敏感类型,它减少了代码中所需的类型检查次数。

在 PTS 中,null 处理和错误处理有许多相似之处,两者都严重依赖于联合类型。因此,上述运算符和语句也对错误处理有用——它们简化了我们的任务,使我们能够编写简洁的代码。它们也可用于其他类型(不仅仅是 nullerror),但本文重点介绍如何在错误处理的上下文中使用的它们。

以下是前几篇文章中已解释的一些示例的重述

case type of 语句:

case type of read_text_file ( file_path.create ( "example.txt" ) )
    is string as text // the string is stored in constant 'text'
        write_line ( "Content of file:" )
        write_line ( text ) // the previously defined constant 'text' is now used
    is null
        write_line ( "The file is empty." )
    is file_error as error
        write_line ( """The following error occurred: {{error.message}}""" )
.

case type of 表达式:

const message = case type of read_text_file ( file_path.create ( "example.txt" ) )
    is string: "a string"
    is null: "null"
    is error: "an error"
write_line ( "The result is " + message )        

运算符 ?(安全导航运算符)

const phone_number = get_employee()?null?error \
    .get_department()?null?error \
    .get_manager()?null?error \
    .get_phone_number()

assert 语句 配合 is 运算符

assert result is not error

on 子句:

const value = get_value_or_null_or_error() \
    on null : return null \
    on error as e : return e

上面的代码可以缩短为

const value = get_value_or_null_or_error() ^null ^error

错误处理方法

如前所述,函数返回的可预期错误不能被静默忽略——我们不能意外地忘记处理它们。这个(编译器强制的)规则有助于创建更可靠的软件。

现在我们将探讨以下常见的错误处理方法

  • 处理错误

  • 返回(传播)错误

  • 返回包装错误

  • 抛出不可预期错误

  • 中止程序执行

  • 显式忽略错误

注意

如果您愿意,可以 跳过 本(相当长的)部分,稍后再阅读。

为了说明每种方法,我们将调用一个接受客户标识符作为输入并返回客户姓名的函数。如果客户不存在,则返回错误。函数签名如下

fn customer_name_by_id ( id string ) -> string or inexistant_customer_error

类型 inexistant_customer_error 是一个应用程序特定的错误,定义如下

type inexistant_customer_error
    
    inherit invalid_data_error

    att customer_id string
.

为测试目的,函数 customer_name_by_id 的一个虚拟实现可能如下所示

fn customer_name_by_id ( id string ) -> string or inexistant_customer_error

    // dummy implementation
    if id =v "100"
        return "Foo"
    else
        return inexistant_customer_error.create (
            message = """Customer '{{id}}' doesn't exist."""
            customer_id = id )
    .
.

在接下来的几节中,您将看到在调用 customer_name_by_id 后应用的错误处理方法的示例。为了说明每种方法,我们将编写一个非常简单的函数的主体,该函数接受客户标识符作为输入,并将客户姓名写入标准操作系统 out 设备。函数签名如下

fn write_customer_name ( customer_id string )

在查看错误处理方法之前,让我们先设想一下 write_customer_name 的以下实现

fn write_customer_name ( customer_id string )
    
    const name = customer_name_by_id ( customer_id )
    write_line ( name )
.

这段代码会编译吗?

不,不会。

常量 name 的类型被推断为联合类型 string or inexistant_customer_error(函数 customer_name_by_id 的返回类型)。函数 write_line 需要 string 作为输入。但是 string or inexistant_customer_errorstring 不兼容——因此代码无法编译。

上述代码违反了 基本原则 部分介绍的一个基本规则

函数返回的错误不能被静默忽略。

我们不能忽略调用 customer_name_by_id 可能失败的事实。编译器要求我们以某种方式处理 inexistant_customer_error。让我们看看如何做到这一点。

处理错误

假设您只想在发生 inexistant_customer_error 时写入“未知客户”。这可以用 case type of 语句完成

fn write_customer_name ( customer_id string )

    case type of customer_name_by_id ( customer_id )
        is string as name
            write_line ( name )
        is inexistant_customer_error
            write_line ( "Unknown customer" )
    .
.

或者您可以这样写

fn write_customer_name ( customer_id string )

    const result = customer_name_by_id ( customer_id )
    if result is string then
        write_line ( result )
    else
        write_line ( "Unknown customer" )
    .
.

您可以使用 if_is 运算符来缩短代码

fn write_customer_name ( customer_id string )

    const name = customer_name_by_id ( customer_id ) if_is error: "Unknown customer"
    write_line ( name )
.

或者您只需这样写

fn write_customer_name ( customer_id string )

    write_line ( customer_name_by_id ( customer_id ) if_is error: "Unknown customer" )
.
返回错误

很多时候,一个函数应该直接将错误传播给调用堆栈中的父函数。在这种情况下,错误必须在其签名中声明。

代码如下所示

fn write_customer_name ( customer_id string ) -> inexistant_customer_error or null

    case type of customer_name_by_id ( customer_id )
        is string as name
            write_line ( name )
            return null
        is inexistant_customer_error as error
            return error
    .
.

这段代码的工作原理如下

  • 如果 customer_name_by_id 返回一个字符串,那么这个字符串将被写入 STDOUT,并返回 null

  • 如果 customer_name_by_id 返回一个错误,那么这个错误将由 write_customer_name 返回。换句话说,错误会沿着调用堆栈向上传播。

on 子句使我们能够用更少的代码实现相同的效果

fn write_customer_name ( customer_id string ) -> inexistant_customer_error or null

    const name = customer_name_by_id ( customer_id ) on error as e: return e
    write_line ( name )
.

请注意,上述代码中常量 name 的类型被推断为 string

由于 on error as e: return e 被频繁使用,PTS 提供了 ^error 简写

fn write_customer_name ( customer_id string ) -> inexistant_customer_error or null

    const name = customer_name_by_id ( customer_id ) ^error
    write_line ( name )
.
返回包装错误

有时,一个函数应该返回一个与它在其主体中调用的函数返回的错误不同的错误。这很有用,例如,如果您想返回一个不同的错误消息,添加对调用者有用的额外上下文,或者如果您不想出于安全原因暴露问题的原因(因为详细的错误消息可能为攻击者提供他们可能利用的信息)。

以下代码说明了如何返回一个更高级别的错误

fn write_customer_name ( customer_id string ) -> runtime_error or null

    case type of customer_name_by_id ( customer_id )
        is string as name
            write_line ( name )
            return null
        is inexistant_customer_error as error
            return runtime_error.create (
                message = "An error occurred."
                cause = error // can be left off for security reasons
            )
    .
.

注意

这里,我们假设 runtime_error 是标准库中定义的一个高级错误——所有可以在运行时发生的类型的可预期错误的常见父类型。

同样,on 子句可以用来缩短代码

fn write_customer_name ( customer_id string ) -> runtime_error or null

    const name = customer_name_by_id ( customer_id ) \
        on error as e: return runtime_error.create (
            "An error occurred."
            cause = e )

    write_line ( name )
.
抛出不可预期错误

与其缓慢地长期损坏数据,不如尽早崩溃。

—— Fred Hebert,《Erlang 的禅意》( 视频)

有时,调用堆栈中的任何父函数都无法有意义地处理可预期错误。因此,让错误传播下去没有意义。在这种情况下,最好快速失败。这可以通过抛出不可预期错误来完成,这类似于在其他语言中抛出(非检查型)异常。

这是一个例子

fn write_customer_name_or_throw ( customer_id string )

    case type of customer_name_by_id ( customer_id )
        is string as name
            write_line ( name )
        is inexistant_customer_error as e
            throw program_error.create (
                message = e.message
                cause = e )
    .
.

请注意函数名中的 _or_throw 后缀。根据约定,此后缀用于可能显式抛出不可预期错误的函数。

注意

throw 将在 throw 语句 部分进一步解释。

我们可以抛出一个继承自 unanticipated_error 的自定义错误,而不是抛出 program_error

on 子句缩短了代码

fn write_customer_name_or_throw ( customer_id string )

    const name = customer_name_by_id ( customer_id ) \
        on error as e: throw program_error.create (
            message = e.message
            cause = e )

    write_line ( name )
.

可以通过以下方式实现相同效果

fn write_customer_name_or_throw ( customer_id string )

    const name = customer_name_by_id ( customer_id ) \
        on error: throw

    write_line ( name )
.

使用 assert 语句可以实现类似效果

fn write_customer_name ( customer_id string )

    const result = customer_name_by_id ( customer_id )
    assert result is not error
    write_line ( result ) // result is guaranteed to be a string
.
中止程序执行

如果您只想在发生可预期错误时快速中止程序执行,可以这样做

fn write_customer_name ( customer_id string )

    case type of customer_name_by_id ( customer_id )
        is string as name
            write_line ( name )
        is inexistant_customer_error
            write_line ( "Error: invalid customer id." )
            OS_process.exit ( 1 )
    .
.

注意

在发生可预期错误后中止程序执行(如上所示)通常在容错应用程序中是不可接受的——想象一下操作系统在遇到每个错误时都会关闭,或者浏览器在页面中发生错误时会中止。

在容错系统中,只有应用程序的一部分(例如,当前操作、进程或线程)应该在发生不可恢复错误时被中止,然后重新启动。要深入了解容错系统,请阅读 Erlang 的禅意(或观看 视频)。

显式忽略错误

如果您有充分的理由忽略错误,您可以这样做,但您需要明确,如下所示

fn write_customer_name ( customer_id string )

    case type of customer_name_by_id ( customer_id )
        is string as name
            write_line ( name )
        is inexistant_customer_error
            do nothing
    .
.

请注意,上述代码中的 do nothing 语句是必需的,因为 is 分支不能为空。do nothing 的作用正如其名:*什么都不做*。此语句清楚地表达了程序员忽略错误的意图。

try 语句

到目前为止,我们已经看到了处理 *单个* 错误的各种方法。如果一个函数需要处理 *多个* 错误,那么单独处理每个错误可能会导致样板代码,如下所示

fn do_stuff -> stuff_error or null

    task_1() on task_error as e: return stuff_error.create (
        message = "An error occurred",
        cause = e )
    task_2()
    task_3() on task_error as e: return stuff_error.create (
        message = "An error occurred",
        cause = e )
    task_4() on task_error as e: return stuff_error.create (
        message = "An error occurred",
        cause = e )
.

上述函数存在代码重复,因为为了处理 task_1task_3task_4 返回的错误,重复了相同的代码。

try 语句(借用自其他语言)使我们能够缩短代码,使其更易于维护,并将正常执行代码(又称“快乐路径”)与处理错误的 Our 分开

fn do_stuff -> stuff_error or null

    try
        try! task_1()
        task_2()
        try! task_3()
        try! task_4()
    
    on task_error as e
        return stuff_error.create (
            message = "An error occurred",
            cause = e )
    .
.

注意 try! 关键字放在可能失败的语句之前。没有这个关键字,您将无法快速跟踪错误可能发生在哪里(并且 IDE 将不再能够通过语法高亮来识别这些语句)。

不可预期错误

一个 *不可预期错误* 是一个 *不被预期在运行时发生* 的错误。当程序执行期间发生严重问题时,会发生不可预期错误——一个在正常情况下不应发生的问题:例如,硬件故障、操作系统问题、配置问题(例如,缺少库)、代码错误。这些错误可能在程序执行期间的任何时间、源代码中的任何位置发生。

它们通常由于软件中的错误而发生。例如

  • 过深嵌套的递归函数调用(或编译器未检测到的无限递归)导致 stack_overflow_error

  • 一个创建新内存对象的无限循环导致 out_of_memory_error

  • 违反 assert 语句中指定的条件会导致 assert_violation_errorprogram_error 的子类型)

  • 违反函数输入参数的前置条件会导致 pre_condition_violation_error(也是 program_error 的子类型)

  • 函数中的错误可能导致 post_condition_violation_error(同样,是 program_error 的子类型)

处理不可预期错误

在 PTS 中,不可预期错误的处理方式类似于 Java 中的非检查型异常(即 RuntimeException 的子类型),或者 C#、Kotlin 等语言中的异常。

每当发生不可预期错误时,该错误就会在函数调用堆栈中向上传播,直到某个函数显式 *捕获* 该错误。如果没有函数捕获它,则会向 STDERR 写入一条消息,并中止应用程序。

全局错误处理器

默认情况下,不可预期错误由一个或多个 *全局不可预期错误处理器* 处理。PTS 实现通常提供一个默认处理器,该处理器

  • 将错误消息和函数调用跟踪写入标准操作系统 err 设备(STDERR

  • 以退出代码 1 中止程序执行

在 PTS 的面向对象实现中,全局错误处理器可以定义为一个 *函数式类型*(即,只有一个方法的类型)

type unanticipated_error_handler

    fn handle_error ( error unanticipated_error )
.

为了定制全局错误处理,应用程序可以注册/注销全局错误处理器。例如,应用程序可以注销默认错误处理器,注册一个将条目附加到日志文件的处理器,并注册另一个处理器以根据遇到的错误类型向软件开发人员发送电子邮件。

try 语句

有时,需要在源代码中的策略性重要位置显式捕获不可预期错误,而不是让它们由全局错误处理器处理。

例如,考虑一个文本编辑器的开发。假设在用户键入大量未保存的文本后发生了一个不可预期错误。默认错误处理器只会向控制台写入一条无用的错误消息,然后退出应用程序,这意味着用户的作品丢失了。为了避免这种情况,文本编辑器至少应该将当前文本保存到临时文件,向用户显示一条有用的错误消息,然后正常退出。

还有许多其他情况需要对不可预期错误进行特定错误处理,特别是在容错应用程序中,随机的应用程序关闭是不可接受的,因为它们可能造成重大损失。

为了解决这些情况,可以使用 try-catch-finally 语句以定制的方式处理不可预期错误。此语句类似于其他编程语言中的 try-catch-finally 语句,后者用于处理异常。

在本节 try 语句 中,我们已经看到了如何使用此语句处理 *可预期* 错误。以下是该部分代码的重述

fn do_stuff -> stuff_error or null

    try
        try! task_1()
        task_2()
        try! task_3()
        try! task_4()
    
    on task_error as e
        return stuff_error.create (
            message = "An error occurred",
            cause = e )
    .
.

这是我们新的示例,也使用 try 语句,但这次处理的是 *不可* 预期错误

try
    do_this()
    do_that()
catch unanticipated_error as e
    // handle the error stored in constant 'e'
finally
    // clean up (e.g. close resources)
.

如果将后者与前者进行比较,您可以看到 on 分支用于处理可预期错误,而 catch 分支处理不可预期错误。

try-catch-finally 语句的工作原理如下

  • 如果在 try 分支中执行语句时发生不可预期错误,则程序将立即停止执行该分支中剩余的代码,并跳转到 catch 分支内的代码进行错误处理。

  • finally 分支中的代码始终会被执行。如果在 try 分支中没有发生错误,那么 finally 分支将在其之后立即执行;否则,它将在 catch 分支之后执行。finally 分支是可选的——如果合适,可以省略。

可以在单个 try 语句中处理这两种类型的错误(可预期和不可预期),使用 on 分支处理可预期错误,使用 catch 分支处理不可预期错误。

try
    do_this()
    do_that()
    try! task_1()
    task_2()
    try! task_3()
    try! task_4()
on anticipated_error as e
    // handle anticipated errors
catch unanticipated_error as e
    // handle unanticipated errors
finally
    // clean up
.
throw 语句

大多数不可预期错误是 *隐式* 抛出的,每当出现严重问题时。例如,当主机系统内存耗尽时,隐式抛出 out_of_memory_error

有时,*显式* 抛出不可预期错误很有用。这通常是在函数检测到无法处理的严重问题时完成的,无论是函数本身还是调用堆栈中的任何父函数。当前的操作、线程、进程或应用程序必须中止。

例如,考虑一个依赖主机上安装的某些库的应用程序。如果应用程序在运行时发现缺少某个库,它必须中止执行。

可以使用 throw 语句(同样借用自支持异常的其他语言)显式抛出不可预期错误。throw 的语法如下

"throw" <expression>

<expression> 的类型必须是 unanticipated_error

以下是一个说明 throw 语句的代码片段

if third_party_libraries_missing then
    throw program_error.create (
        message = "Third-party libraries must be installed before using this application." )
.

显式抛出的错误与隐式抛出的错误处理方式相同:错误会沿着调用堆栈向上传播,要么在 try-catch 语句中捕获,要么由全局错误处理器处理。

实践考虑

错误处理是一个广泛的话题——在这个主题中无法完全涵盖。

处理错误的最佳策略在很大程度上取决于应用程序域和最坏情况下的潜在损害。

在用于个人用途的印章库存应用程序中,一遇到错误就中止程序执行可能是一种可接受的方法,但在任务关键型的企业软件中采用相同的方法是不负责任的。

PTS 的设计默认始终是安全的,因为它有助于编写可靠、健壮和容错的软件。例如,函数返回的可预期错误不能被忽略。但是,这种严格的方法也意味着对于不需要最高级别可靠性、健壮性和容错能力 O 的应用程序,代码可能会变得过于冗长和复杂。

显然,不可能提供放之四海而皆准的错误处理“规则”。但是,在接下来的几节中,我将提供一些可能很有用的 *通用指南*(而不是一成不变的规则)。

避免返回多种可预期错误类型!

考虑一个名为 do_stuff 的高级函数,它调用低级函数,这些函数在文件和目录上执行各种读/写操作。调用树中的这些低级函数返回可预期的错误,如 file_not_found_errorfile_read_errorfile_write_errordirectory_read_errordirectory_access_error。如果树中的每个函数都将错误传播给其父函数,那么 do_stuff 的签名可能会非常糟糕,如下所示

fn do_stuff -> string or \
    file_not_found_error or file_read_error or file_write_error or \
    directory_read_error or directory_access_error

更糟糕的是,每次调用树中的低级函数签名稍后更改时(例如,添加或删除错误类型),所有父函数的签名(包括 do_stuff)都需要相应地调整。

虽然有不同的解决方案可以避免这种维护噩梦,但对于高级函数来说,一个简单的解决方案是只返回调用树中返回的所有错误的通用父类型。例如,do_stuff 可以简化为如下

fn do_stuff -> string or directory_or_file_error

这里,我们假设 directory_or_file_error 是调用树中返回的所有错误的父类型。

现在假设,稍后在代码中添加了数据库和网络操作。do_stuff 需要调整

fn do_stuff -> string or directory_or_file_error or database_error or network_error

但是,我们同样可以通过使用通用父类型来简化

fn do_stuff -> string or IO_error

实际上,从一开始就使用合适的父类型(例如 IO_error)通常是一个可接受的解决方案,因为

  • 它便于代码维护。

  • 调用函数通常不关心 *哪个* 错误发生了——它们只关心是否发生了错误。

  • 它隐藏了实现细节,这些细节是不相关的,并且可能在未来的版本中更改。

重要的是要注意,如果调用树中的一个高级函数返回一个更高级别的错误类型,错误信息就不会丢失。例如,如果一个低级函数返回 file_not_found_error,那么声明返回 IO_error 的一个更高级别的函数仍然返回 file_not_found_error 的一个实例(即 IO_error 的子类型),该实例可以被父函数检查,或用于调试/诊断目的。

经验法则(如果理由充分,可以忽略)是,函数不应返回多种错误类型。通常,声明一个单一的错误类型,它是调用树中返回的所有错误的通用父类型,这是合适的。这导致更简单、更易于维护的代码。

在合适的情况下使用包装器!

对于前面部分中解释的“返回太多错误类型”的问题,还有另一种解决方案:定义一个专用的错误类型作为所有低级错误的包装器,并在所有低级函数中返回此包装器。

在我们的例子中,我们可以定义类型 stuff_error,它是调用树中所有错误的包装器

type stuff_error
    inherit: runtime_error
.

do_stuff 的签名变为

fn do_stuff -> string or stuff_error

低级函数也返回 stuff_error,它们将源错误(原因)存储在 cause 属性中

fn stuff_child -> null or stuff_error
    ...
    const text = read_text_file ( file_path ) \
        on file_read_error as e: return stuff_error.create (
            message = e.message
            cause = e )
    ...
.

为了缩短代码,我们可以为 stuff_error 定义一个创建者/构造函数 create_from_cause(此处未显示),然后只需编写

const text = read_text_file ( file_path ) \
    on file_read_error as e: return stuff_error.create_from_cause ( e )

同样,低级错误信息也不会丢失,因为它存储在 stuff_errorcause 属性中。

另请参阅:返回包装错误 部分。

在合适的情况下使用不可预期错误!

有时,我们不想处理错误——我们假设代码将在不会发生运行时错误的环境中运行。如果尽管我们的假设还是发生了错误,那么立即终止是合适的:应用程序将错误消息写入 STDERR,然后以退出代码 1 退出。

换句话说,我们选择 *中止* 执行而不是 *处理* 错误。这也称为恐慌——例如,在 Rust 中,panic! 宏可用于优雅地中止应用程序并释放资源。

在各种情况下,在遇到错误时中止程序执行(即恐慌)是合理的:例如,在尝试代码、编写原型、构建个人印章库存应用程序时,或者当我们只想编写快速而粗糙的代码时。即使在设计用于处理错误 O 的应用程序中,也可能存在一些特定情况,其中立即终止是可取的,例如,以避免长期损坏数据。有关此主题的良好建议可在 The Rust Programming Language要恐慌!还是不恐慌! 章中找到。

虽然 PTS 显然不是默认设计用于中止的,但它确实提供了对“尽早崩溃”方法的支持。如果您有充分的理由,您可以*中止*(恐慌)——您只需*明确*这样做。

基本思想是将可预期错误转换为不可预期错误,每当您必须处理可预期错误时。因此,不是返回可预期错误,而是抛出不可预期错误。让我们看看不同的方法。

assert 语句

一种技术是使用 assert 语句来声明可预期错误不应该发生

const result = customer_name_by_id ( customer_id )
assert result is not error
// continue with a customer object stored in 'result'
on error: throw 子句

一种更好、更简洁的技术是使用 on error: throw 子句,该子句已在 抛出不可预期错误 部分介绍

const name = customer_name_by_id ( customer_id ) on error: throw

抛出错误的实用函数

编写大量 on error: throw 子句可能会令人讨厌。因此,更好的解决方案可能是编写抛出不可预期错误而不是返回可预期错误的实用函数。

例如,假设我们的许多代码(在快速、一次性原型中)都读取非空文本文件。在正常情况下(即,当可靠性很重要时),我们将调用一个库函数,如下所示

// returns 'null' if the file is empty
fn read_text_file ( file_path ) -> string or null or file_read_error

使用此函数需要检查 nullfile_read_error。为了避免这些检查,我们可以定义以下实用函数,该函数假定文件读取错误不会发生,并且文本文件永远不会为空

fn read_non_empty_text_file_or_throw ( file_path ) -> string      (1)

    case type of read_text_file ( file_path )
        is string as content
            return content
        is null
            throw program_error.create (                          (2)
                """File {{path.to_string}} is empty.""" )
        is file_read_error as e
            throw program_error.create (
                """Could not read file {{path.to_string}}         (3)
                Reason: {{e.message}}""" )
    .
.

(1) 根据约定,函数名后缀 _or_throw 表示可能会抛出不可预期错误。在正常情况下,函数返回一个 string,其中包含非空文本文件的内容。

(2) 如果文件为空,则抛出不可预期错误。

(3) 如果无法读取文件,则抛出不可预期错误。

可以按如下方式编写上述函数的简化版本

fn read_non_empty_text_file_or_throw ( file_path ) -> string

    const result = read_text_file ( file_path )
    assert result is not null and result is not error
    return result
.

客户端代码现在简单快捷,因为不再需要 null 和错误处理

const text = read_non_empty_text_file_or_throw ( file_path.create ( "example.txt" ) )

在私有代码中使用不可预期错误

有时,在应用程序的未公开(私有)部分使用不可预期错误是有意义的,因为这可以大大简化代码并提高可维护性。

假设我们正在处理一个复杂的解析器,其主函数如下所示

fn parse ( string ) -> AST or syntax_error

语法错误很可能在低级、私有函数中被检测到。在函数 parse 的整个调用树中使用可预期错误很容易导致冗长 O 的代码,因为所有错误都需要处理(例如,传播给父函数)并在函数签名中声明。每当函数签名中的错误类型稍后更改时,都需要进行大量重构。为了避免这种维护负担,最好在 parse(调用树中的根函数)调用的函数中抛出不可预期错误。函数 parse 使用 try 语句来捕获任何不可预期错误,并将其转换为可预期错误,然后返回。以下简化代码说明了此方法

fn parse ( string ) -> AST or syntax_error

    try
        const AST = AST.create_empty
        // parse the string and populate 'AST'
        return AST
    catch unanticipated_error as ue
        return syntax_error.create ( message = ue.message )
    .
.

警告

上述将可预期错误转换为不可预期错误的技术通常*不应*用于*公共* API(例如,库和框架的公共函数)。

公共 API 必须具有表现力并保证可靠性。公共函数应返回可预期的错误,在出现问题时提供有用的错误信息。消费者而不是供应商决定如何处理错误。

但是,也有罕见的例外。例如,即使在公共 API 中,中止而不是用错误/损坏的数据继续执行可能更好。这种尽早崩溃/快速失败的行为应有明确的文档记录,并且如前所述,可能抛出异常的函数应以 _or_throw 作为后缀(例如 do_it_or_throw)。

不要使用 null 返回错误条件!

假设您正在设计标准库中的 map 类型(又称 dictionaryassociated_array)。方法 get 接受一个键作为输入并返回存储在 map 中的相应值。这里有一个有趣的问题:如果键不存在,该方法应该做什么?

很容易就返回 null,就像在几个库(例如 Java Map.get)中那样。使用 PTS 语法,map 可以定义如下

type map<key_type child_of:hashable, value_type>
    
    fn get ( key key_type ) -> value_type or null

    // more methods
.

这种方法有两个缺点

  • 如果 map 中的值允许为 null(例如,map<string, string or null>),则会出现歧义。

    例如,如果一个方法调用像 map.get ( "foo" ) 返回 null,它可以有两种含义:要么没有键为 "foo" 的条目,要么有一个键为 "foo" 且值为 null 的条目。

  • 如果 map 中的值不允许为 null(例如 map<string, string>),则存在误解的风险。

    例如,如果一个方法调用像 map.get ( "foo" ) 返回 null,它可能被客户端代码错误地解释为键为 "foo" 且值为 null 的条目。

    如果一个具有可空值的 map 后来更改为具有非空值的 map(例如,从 map<string, string or null>map<string, string>),这种误解的风险会增加。

为了消除第一个问题(返回 null 时的歧义),我们可以添加方法 contains_key(反正也需要)。

type map<key_type child_of:hashable, value_type>
    
    fn contains_key ( key key_type ) -> boolean
    
    fn get ( key key_type ) -> value_type or null

    // more methods
.

这样做有效,因为我们现在可以调用 contains_key 来消除歧义。但这并不奏效。首先,方法 get 易于出错,因为必须阅读文档、小心操作,并且不能忘记在 get 返回 null 时调用 contains_key。其次,先调用 get,然后调用 contains_key,是冗长的,并且在并发或并行处理环境中共享可变 map 时可能导致非常棘手的 bug(由于竞争条件)。

如果 get 在键不在 map 中时返回错误(而不是 null),则这种易出错性会消失

type map<key_type child_of:hashable, value_type>
    
    fn get ( key key_type ) -> value_type or key_not_contained_in_map_error

    // more methods
.

客户端代码现在被要求检查 key_not_contained_in_map_error,例如。

const value = map.get ( "foo" ) on error as e: return e

我们受到了保护,不会忘记检查键是否实际存在于 map 中。此外,方法 get 可能失败的事实也在客户端代码中得到了清晰的表达。

然而,被强迫检查错误可能会令人厌烦,并导致冗长 O 的代码。如果键不在 map 中,您可能想

  • 抛出一个不可预期错误,因为您假设该键应该包含在 map 中

  • 回退到默认值

  • 获取 null,因为您不需要区分 null 的两个可能含义

这些用例可以很容易地通过添加 get 方法的变体来覆盖

type map<key_type child_of:hashable, value_type>
    
    fn get ( key key_type ) -> value_type or key_not_contained_in_map_error
    fn get_or_throw ( key key_type ) -> value_type
    fn get_or_default ( key key_type, default value_type ) -> value_type
    fn get_or_null ( key key_type ) -> value_type or null

    // more methods
.

提供多种选择有时是适得其反的,但在这种情况下,由于 map 是一个基本数据结构,定义在标准库中,并且以多种方式使用,因此是合理的。

除了提供更通用的 API 外,我们还受益于以下几点

  • 四个 getter 方法的行为由它们的签名清晰地表达——程序员可能不需要阅读文档就知道使用哪个 getter 方法(尽管他/她仍然必须意识到返回 nullget_or_null 的潜在模糊含义)。

  • 在所有情况下,客户端代码都简洁明了,并在键不存在时自动记录行为。以下是一些示例

    const value_1 = map.get ( "foo" ) ^error
    
    const value_2 = map.get_or_throw ( "foo" )
    
    const value_3 = map.get_or_default ( key = "foo", default = "bar" )
    
    const value_4 = map.get_or_null ( "foo" )
    if value_4 is null then
        // handle it
    .

摘要

以下是 PTS 错误处理规则和内置支持的简要摘要

  • 有两种错误

    • 可预期错误(例如,file_not_found_errorinvalid_data_error

    • 不可预期错误(例如,out_of_memory_errorstack_overflow_error

  • 可预期错误

    • 预计可能在运行时发生

    • 必须声明为函数返回类型(使用联合类型)

    • 必须以某种方式处理,或显式忽略

  • 不可预期错误

    • 不预计在运行时发生

    • 可能在任何时间、任何源代码位置发生

    • 由一个或多个全局错误处理器处理,或在源代码中的任何位置显式捕获和处理

    • 可以隐式或显式抛出

  • 标准库中定义了一组常用的可预期和不可预期错误类型。可以在软件开发项目中定义其他特定于域的错误类型,以满足特定需求。

  • PTS 提供了一组专用的运算符和语句来简化错误处理。

致谢

非常感谢Tristano Ajmone提供的宝贵反馈,以改进本文。

历史

  • 2024年2月29日:初始版本
© . All rights reserved.