作者
Fred Hebert <mononcqc(at)ferd(dot)ca>
状态
最终/25.0 在 OTP 25 版本中实现
类型
标准跟踪
创建
2018年8月31日
Erlang版本
OTP-25.0
发布历史
2020年12月5日, 2021年11月2日, 2021年11月17日

EEP 49: 基于值的错误处理机制 #

摘要 #

此 EEP 引入了 maybe ... end 表达式,作为一种基于模式匹配的控制流和基于值的错误处理的构造。通过使用这种构造,可以避免或简化深度嵌套的 case ... end 表达式,并且可以避免使用异常进行流程控制。

版权 #

本文档已置于公共领域。

规范 #

我们提出了 maybe ... end 构造,它类似于 begin ... end,用于将多个不同的表达式分组为单个块。但有一个重要的区别,即 maybe 块不导出其变量,而 begin 导出其变量。

我们提出了一种新的表达式类型(表示为 MatchOrReturnExprs),它仅在 maybe ... end 表达式中有效

maybe
    Exprs | MatchOrReturnExprs
end

MatchOrReturnExprs 定义为具有以下形式

Pattern ?= Expr

此定义意味着 MatchOrReturnExprs 仅允许在 maybe ... end 表达式的顶层。

?= 运算符获取 Expr 返回的值,并根据 Pattern 对其进行模式匹配。

如果模式匹配成功,则 Pattern 中的所有变量都绑定到局部环境中,并且表达式等效于成功的 Pattern = Expr 调用。如果该值不匹配,则 maybe ... end 表达式直接返回失败的表达式。

存在一个特殊情况,我们将 maybe ... end 扩展为以下形式

maybe
    Exprs | MatchOrReturnExprs
else
    Pattern -> Exprs;
    ...
    Pattern -> Exprs
end

这种形式的存在是为了捕获 MatchOrReturnExprs 中不匹配的表达式,以处理失败的匹配,而不是返回它们的值。在这种情况下,未处理的失败匹配将引发 else_clause 错误,否则与 case_clause 错误相同。

这种扩展形式有助于在同一构造中正确识别和处理成功和不成功的匹配,而不会混淆成功和失败的路径。

给定此处描述的结构,最终表达式可能如下所示

maybe
    Foo = bar(),            % normal exprs still allowed
    {ok, X} ?= f(Foo),
    [H|T] ?= g([1,2,3]),
    ...
else
    {error, Y} ->
        {ok, "default"};
    {ok, _Term} ->
        {error, "unexpected wrapper"}
end

请注意,为了允许更简单的模式匹配和更直观的用法,?= 运算符的结合性规则应低于 =,例如

maybe
    X = [H|T] ?= exp()
end

是有效的 MatchOrReturnExprs,等效于非中缀形式 '?='('='(X, [H|T]), exp()),因为反转优先级会得到 '='('?='(X, [H|T]), exp()),这将在上下文中创建一个 MatchOrReturnExp,并且无效。

动机 #

Erlang 在许多编程语言中都拥有一些最灵活的错误处理方式。该语言支持

  1. 三种类型的异常 (throwerrorexit)
    • catch Exp 处理
    • try ... [of ...] catch ... [after ...] end 处理
  2. 链接、exit/2trap_exit
  3. 监视器
  4. 返回值,例如 {ok, Val} | {error, Term}, {ok, Val} | falseok | {error, Val}
  5. 以上一个或多个的组合

那么,我们为什么要添加更多?原因有很多,包括尝试减少深度嵌套的条件表达式、清理在野外发现的一些混乱模式,以及在实现函数时提供更好的关注点分离。

减少嵌套 #

在 Erlang 中可以看到的一种常见模式是 case ... end 表达式的深度嵌套,用于检查复杂的条件。

例如,采用以下从 Mnesia 中获取的代码

commit_write(OpaqueData) ->
    B = OpaqueData,
    case disk_log:sync(B#backup.file_desc) of
        ok ->
            case disk_log:close(B#backup.file_desc) of
                ok ->
                    case file:rename(B#backup.tmp_file, B#backup.file) of
                       ok ->
                            {ok, B#backup.file};
                       {error, Reason} ->
                            {error, Reason}
                    end;
                {error, Reason} ->
                    {error, Reason}
            end;
        {error, Reason} ->
            {error, Reason}
    end.

代码嵌套到必须为变量引入较短的别名(OpaqueData 重命名为 B),并且一半的代码只是透明地返回每个函数给出的确切值。

相比之下,可以使用新的构造将相同的代码编写如下

commit_write(OpaqueData) ->
    maybe
        ok ?= disk_log:sync(OpaqueData#backup.file_desc),
        ok ?= disk_log:close(OpaqueData#backup.file_desc),
        ok ?= file:rename(OpaqueData#backup.tmp_file, OpaqueData#backup.file),
        {ok, OpaqueData#backup.file}
    end.

或者,为了防止 disk_log 调用返回 ok | {error, Reason} 以外的内容,可以使用以下形式

commit_write(OpaqueData) ->
    maybe
        ok ?= disk_log:sync(OpaqueData#backup.file_desc),
        ok ?= disk_log:close(OpaqueData#backup.file_desc),
        ok ?= file:rename(OpaqueData#backup.tmp_file, OpaqueData#backup.file),
        {ok, OpaqueData#backup.file}
    else
        {error, Reason} -> {error, Reason}
    end.

这些调用的语义是相同的,除了现在更容易关注单个操作的流程以及成功或错误路径。

淘汰混乱的模式 #

人们处理一系列可失败操作的常用方法包括在函数列表上进行折叠,以及滥用列表推导。这两种模式都有很大的缺点,使其并非理想选择。

在函数列表上进行折叠会使用诸如 邮件列表中的帖子 中定义的模式

pre_check(Action, User, Context, ExternalThingy) ->
    Checks =
        [fun check_request/1,
         fun check_permission/1,
         fun check_dispatch_target/1,
         fun check_condition/1],
    Args = {Action, User, Context, ExternalThingy},
    Harness =
        fun
            (Check, ok)    -> Check(Args);
            (_,     Error) -> Error
        end,
    case lists:foldl(Harness, ok, Checks) of
        ok    -> dispatch(Action, User, Context);
        Error -> Error
    end.

此代码需要逐个声明函数,确保整个上下文从一个函数传递到另一个函数。由于函数之间没有共享作用域,因此所有函数都必须对所有参数进行操作。

相比之下,可以使用新的构造将相同的代码实现为

pre_check(Action, User, Context, ExternalThingy) ->
    maybe
        ok ?= check_request(Context, User),
        ok ?= check_permissions(Action, User),
        ok ?= check_dispatch_target(ExternalThingy),
        ok ?= check_condition(Action, Context),
        dispatch(Action, User, Context)
    end.

并且,如果需要在任意两个步骤之间导出状态,则很容易将其编织进去

pre_check(Action, User, Context, ExternalThingy) ->
    maybe
        ok ?= check_request(Context, User),
        ok ?= check_permissions(Action, User),
        ok ?= check_dispatch_target(ExternalThingy),
        DispatchData = dispatch_target(ExternalThingy),
        ok ?= check_condition(Action, Context),
        dispatch(Action, User, Context, DispatchData)
    end.

相比之下,列表推导式*黑客*有点罕见。事实上,它主要是理论上的。可以在 Diameter 测试用例Rebar3 的 PropEr 插件中找到一些关于它如何工作的提示。

它的整体形式在列表推导式中使用生成器来隧道化一条成功的路径

[Res] =
    [f(Z) || {ok, W} <- [b()],
             {ok, X} <- [c(W)],
             {ok, Y} <- [d(X)],
             Z <- [e(Y)]],
Res.

这种形式的使用并不多,因为它相当晦涩,我怀疑大多数人要么足够理智不使用它,要么没有考虑过它。显然,新形式会更简洁

maybe
    {ok, W} ?= b(),
    {ok, X} ?= c(W),
    {ok, Y} ?= d(X),
    Z = e(Y),
    f(Z)
end

最重要的是,如果找到错误值,它还可以返回错误值。

更好地分离关注点 #

这种形式乍一看并不明显。为了更好地展示它,让我们看一下在 OTP 中的 release_handler 模块中定义的一些函数

write_releases_m(Dir, NewReleases, Masters) ->
    RelFile = filename:join(Dir, "RELEASES"),
    Backup = filename:join(Dir, "RELEASES.backup"),
    Change = filename:join(Dir, "RELEASES.change"),
    ensure_RELEASES_exists(Masters, RelFile),
    case at_all_masters(Masters, ?MODULE, do_copy_files,
                        [RelFile, [Backup, Change]]) of
        ok ->
            case at_all_masters(Masters, ?MODULE, do_write_release,
                                [Dir, "RELEASES.change", NewReleases]) of
                ok ->
                    case at_all_masters(Masters, file, rename,
                                        [Change, RelFile]) of
                        ok ->
                            remove_files(all, [Backup, Change], Masters),
                            ok;
                        {error, {Master, R}} ->
                            takewhile(Master, Masters, file, rename,
                                      [Backup, RelFile]),
                            remove_files(all, [Backup, Change], Masters),
                            throw({error, {Master, R, move_releases}})
                    end;
                {error, {Master, R}} ->
                    remove_files(all, [Backup, Change], Masters),
                    throw({error, {Master, R, update_releases}})
            end;
        {error, {Master, R}} ->
            remove_files(Master, [Backup, Change], Masters),
            throw({error, {Master, R, backup_releases}})
    end.

乍一看,很难清理这段代码:有 3 个多节点操作(备份、更新和移动发布数据),每个操作都依赖于前一个操作成功。

您还会注意到,每个错误都需要特殊处理,在成功或失败时恢复或删除特定操作。这不是一个简单的问题,即将值进出狭窄的范围。

另一点需要注意的是,此模块作为一个整体(而不仅仅是此处提供的代码片段)使用 throw 表达式来操作非局部返回。处理这些返回的实际点分布在文件中的各个位置:create_RELEASES/4,以及write_releases_1/3,例如。

case catch Exp of 形式在整个文件中使用,因为基于值的错误流在嵌套结构中很痛苦。

因此,让我们看一下如何使用新构造重构它

write_releases_m(Dir, NewReleases, Masters) ->
    RelFile = filename:join(Dir, "RELEASES"),
    Backup = filename:join(Dir, "RELEASES.backup"),
    Change = filename:join(Dir, "RELEASES.change"),
    maybe
        ok ?= backup_releases(Dir, NewReleases, Masters, Backup, Change,
                              RelFile),
        ok ?= update_releases(Dir, NewReleases, Masters, Backup, Change),
        ok ?= move_releases(Dir, NewReleases, Masters, Backup, Change, RelFile)
    end.

backup_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
    case at_all_masters(Masters, ?MODULE, do_copy_files,
                        [RelFile, [Backup, Change]]) of
        ok ->
            ok;
        {error, {Master, R}} ->
            remove_files(Master, [Backup, Change], Masters)
            {error, {Master, R, backup_releases}}
    end.

update_releases(Dir, NewReleases, Masters, Backup, Change) ->
    case at_all_masters(Masters, ?MODULE, do_write_release,
                        [Dir, "RELEASES.change", NewReleases]) of
        ok ->
            ok;
        {error, {Master, R}} ->
            remove_files(all, [Backup, Change], Masters),
            {error, {Master, R, update_releases}}
    end.

move_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
    case at_all_masters(Masters, file, rename, [Change, RelFile]) of
        ok ->
            remove_files(all, [Backup, Change], Masters),
            ok;
        {error, {Master, R}} ->
            takewhile(Master, Masters, file, rename, [Backup, RelFile]),
            remove_files(all, [Backup, Change], Masters),
            {error, {Master, R, move_releases}}
    end.

重写代码的唯一合理方法是将所有三个主要多节点操作提取到不同的函数中。

改进之处在于

  • 操作失败的后果位于操作发生的位置附近
  • 函数具有 Dialyzer 可以更轻松地进行类型检查的返回值
  • 这些函数本质上更易于独立测试
  • 上下文仍然可以在父级别的通用工作流中添加和传递
  • 成功操作的链条非常明显且易于阅读
  • 不再需要异常来使代码工作,但是如果我们需要它,则只需要在 write_release_m 中使用一个 throw(),从而将流程控制细节与特定函数实现分离。

作为对照实验,让我们尝试将我们的较短函数与以前的流程一起重用

%% Here is the same done through exceptions:
write_releases_m(Dir, NewReleases, Masters) ->
    RelFile = filename:join(Dir, "RELEASES"),
    Backup = filename:join(Dir, "RELEASES.backup"),
    Change = filename:join(Dir, "RELEASES.change"),
    try
        ok = backup_releases(Dir, NewReleases, Masters, Backup, Change,
                             RelFile),
        ok = update_releases(Dir, NewReleases, Masters, Backup, Change),
        ok = move_releases(Dir, NewReleases, Masters, Backup, Change, RelFile)
    catch
        {error, Reason} -> {error, Reason}
    end.

backup_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
    case at_all_masters(Masters, ?MODULE, do_copy_files,
                        [RelFile, [Backup, Change]]) of
        ok ->
            ok;
        {error, {Master, R}} ->
            remove_files(Master, [Backup, Change], Masters)
            throw({error, {Master, R, backup_releases}})
    end.

update_releases(Dir, NewReleases, Masters, Backup, Change) ->
    case at_all_masters(Masters, ?MODULE, do_write_release,
                        [Dir, "RELEASES.change", NewReleases]) of
        ok ->
            ok;
        {error, {Master, R}} ->
            remove_files(all, [Backup, Change], Masters),
            throw({error, {Master, R, update_releases}})
    end.

move_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
    case at_all_masters(Masters, file, rename, [Change, RelFile]) of
        ok ->
            remove_files(all, [Backup, Change], Masters),
            ok;
        {error, {Master, R}} ->
            takewhile(Master, Masters, file, rename, [Backup, RelFile]),
            remove_files(all, [Backup, Change], Masters),
            throw({error, {Master, R, move_releases}})
    end.

三个分布式函数几乎没有变化。但是,这种方法的缺点是,我们将小函数的实现细节与其父级的上下文紧密地联系在一起。这使得很难单独推理这些函数或在不同的上下文中使用它们。此外,父函数可能会捕获并非旨在用于它的 throws

我认为,通过类似的重构使用基于值的流程控制可以产生更安全、更简洁的代码,并且嵌套级别也大大降低。因此,应该可以表达更复杂的操作序列,而不会使它们更难以阅读或在孤立的情况下进行推理。

这部分是由于嵌套,还因为我们采用了一种更具组合性的方法,其中无需将本地函数的实现细节与它们的整体管道和执行上下文的复杂性联系起来。

这也是构建代码以处理所有异常并尽可能接近其源,并尽可能远离集成流程的最佳方式。

基本原理 #

本节将详细介绍此 EEP 背后的决策过程,包括

  • 其他语言中的先例
  • 是否规范化包装器
  • 添加 else
  • 关于 maybe ... end 结构及其作用域的选择
  • 关于匹配运算符 ?= 的选择
  • 其他被否决的方法
  • 关于引发的异常的选择

这里有很多内容需要讨论。

其他语言的先例 #

多种语言都具有基于值的异常处理,其中许多语言具有很强的函数式倾向。

Haskell #

最著名的例子可能是 Haskell 的 Maybe monad,它使用 Nothing(表示计算未返回任何内容)或 Just x(它们基于类型的等价物,类似于 {ok, X})。这两种类型的并集表示为 Maybe x。以下示例摘自 Haskell/Understanding monads/Maybe

此类错误的返回值在函数中标记如下

safeLog :: (Floating a, Ord a) => a -> Maybe a
safeLog x
    | x > 0     = Just (log x)
    | otherwise = Nothing

直接使用类型注释,可以通过模式匹配提取值(如果有)

zeroAsDefault :: Maybe Int -> Int
zeroAsDefault mx = case mx of
    Nothing -> 0
    Just x -> x

这里需要注意的一点是,只要您无法找到值来替换 Nothing 或者您无法采取不同的分支,您就必须在系统的所有类型中携带这种不确定性。

这通常是 Erlang 的停止之处。您具有相同的可能性(尽管是动态检查的),以及将无效值转换为异常的可能性。

相比之下,Haskell 提供了单子操作及其 *do notation* 来抽象事物

getTaxOwed name = do
  number       <- lookup name phonebook
  registration <- lookup number governmentDatabase
  lookup registration taxDatabase

在此代码段中,即使 lookup 函数返回 Maybe x 类型,do notation 也会抽象掉 Nothing 值,让程序员专注于 Just xx 部分。即使代码编写得好像我们可以对离散值进行操作,该函数也会自动将其结果重新包装到 Just x 中,任何 Nothing 值都会直接跳过操作。

因此,开发人员被迫承认整个函数的流程取决于值是否到位,但他们仍然可以像一切都是离散的一样编写它。

OCaml #

OCaml 支持异常,具有 raise (Type "value") 等结构来引发异常,以及 try ... with ... 来处理异常。但是,由于异常不会被类型系统跟踪,因此维护者引入了 Result 类型。

该类型定义为

type ('a, 'b) result =
  | Ok of 'a
  | Error of 'b

这让人想起 Erlang 的 {ok, A}{error, B}。OCaml 用户似乎主要使用模式匹配、组合器库和单子绑定来处理基于值的错误处理,这类似于 Haskell 的用法。

Rust #

Rust 定义了两种类型的错误:不可恢复的错误(使用 panic!)和可恢复的错误,使用 Result<T, E> 值。后者是我们感兴趣的,定义为

enum Result<T, E> {
    Ok(T),
    Err(E),
}

这可以直观地转换为 Erlang 术语 {ok, T}{error, E}。在 Rust 中处理这些的简单方法是通过模式匹配

let f = File::open("eep.txt");
match f {
    Ok(file) => do_something(file),
    Err(error) => {
        panic!("Error in file: {:?}", error)
    },
};

特定的错误值必须是类型良好的,并且似乎 Rust 社区仍在讨论关于如何在泛型类型中最好地获得可组合性和注释的实现细节。

但是,他们处理这些问题的工作流程已经明确定义。这种模式匹配形式被认为过于繁琐。为了在错误值上自动 panic,添加了 .unwrap() 方法

let f = File::open("eep.txt").unwrap();

在 Erlang 中,我们可以用以下方式来近似这个

unwrap({ok, X}) -> X;
unwrap({error, T}) -> exit(T).

F = unwrap(file:open("eep.txt", Opts)).

还存在另一种构造,可以使用 ? 运算符更直接地将错误返回给调用者代码,而不会出现 panic

fn read_eep() -> Result<String, io::Error> {
    let mut h = File::open("eep.txt")?;
    let mut s = String::new();
    h.read_to_string(&mut s)?;
    Ok(s)
}

任何遇到 ? 的值 Ok(T) 都会被解包。任何遇到 ? 的值 Err(E) 都会按原样返回给调用者,就像使用了带有 returnmatch 一样。但是,此运算符要求函数的类型签名使用 Result<T, E> 类型作为返回值。

在 1.13 版本之前,Rust 使用 try!(Exp) 宏来实现相同的效果,但发现它过于繁琐。比较一下

try!(try!(try!(foo()).bar()).baz())
foo()?.bar()?.baz()?

Swift #

Swift 支持异常,以及声明函数可能引发异常的类型注释和 do ... catch 块。

有一个特殊的运算符 try?,它会捕获任何抛出的异常并将其转换为 nil

func someThrowingFunction() throws -> Int {
    // ...
}
let x = try? someThrowingFunction()

在这里,x 的值可以是 Intnil。通过在条件表达式中使用 let 赋值,数据流通常会得到简化

func fetchEep() -> Eep? {
    if let x = try? fetchEepFromDisk() { return x }
    if let x = try? fetchEepFromServer() { return x }
    return nil
}

Go #

Go 的错误处理相当薄弱。它有 panics 和错误值。必须分配(或显式忽略)错误值,但它们可能会保持未检查状态并导致各种问题。

尽管如此,Go 还是在未来版本中暴露了 新的错误处理计划,这可能很有趣。

Go 设计人员主要考虑的是语法上的更改,以减少其错误的繁琐性,而不是改变其错误处理的语义。

Go 程序通常按如下方式处理错误

func main() {
        hex, err := ioutil.ReadAll(os.Stdin)
        if err != nil {
                log.Fatal(err)
        }

        data, err := parseHexdump(string(hex))
        if err != nil {
                log.Fatal(err)
        }

        os.Stdout.Write(data)
}

新的拟议机制如下所示

func main() {
    handle err {
        log.Fatal(err)
    }

    hex := check ioutil.ReadAll(os.Stdin)
    data := check parseHexdump(string(hex))
    os.Stdout.Write(data)
}

check 关键字要求隐式检查第二个返回值 err 是否等于 nil。如果它不等于 nil,则调用最新定义的 handle 块。它可以将结果返回以退出函数、修复一些值,或者简单地 panic,仅举几例。

Elixir #

与 Erlang 相比,Elixir 在错误处理方面采用了一种略有不同的语义方法。不鼓励将异常用于控制流(而 Erlang 特别使用 throw),并且引入了 with

with {:ok, var} <- some_call(),
     {:error, _} <- fail(),
     {:ok, x, y} <- parse_name(var)
do
    success(x, y, var)
else
    {:error, err} -> handle(err)
    nil -> {:error, nil}
end

该宏允许一系列模式匹配,之后会调用 ˋdo …ˋ 块。如果任何模式匹配失败,则失败的值会在可选的 ˋelse … end` 部分中重新匹配。

这是本文档中最通用的控制流,在它可以处理的值方面具有完全的灵活性。这样做部分原因是,至少与此处其他语言相比,Erlang 和 Elixir API 中都没有关于错误或有效值的严格规范。

这种高度的灵活性在某些情况下受到批评,认为它有点令人困惑:用户可以创建仅错误的流程、仅成功的流程、混合流程,因此 ˋelseˋ 子句可能会变得复杂。

发布了 OK 库,以明确将工作流程缩小到定义明确的错误。它支持三种形式,第一种是 for

OK.for do
  user <- fetch_user(1)
  cart <- fetch_cart(1)
  order = checkout(cart, user)
  saved_order <- save_order(order)
after
  saved_order
end

它通过 *仅* 匹配 {:ok, val} 来在使用 <- 运算符时保持前进:上面的 fetch_user/1 函数必须返回 {:ok, user},代码才能继续执行。对于模式匹配,允许使用 = 运算符,就像它通常在 Elixir 中一样。

任何与 {:error, t} 匹配的返回值最终都会直接从表达式中返回。after ... end 部分获取返回的最后一个值,如果它尚未以 {:ok val} 形式的元组存在,则会将其包装成这种形式。

第二个变体是 try

OK.try do
  user <- fetch_user(1)
  cart <- fetch_cart(1)
  order = checkout(cart, user)
  saved_order <- save_order(order)
after
  saved_order
rescue
  :user_not_found -> {:error, missing_user}
end

此变体还将捕获异常(在 rescue 块中),并且不会在 after 部分中重新包装最终返回值。

该库的最后一个变体是管道

def get_employee_data(file, name) do
  {:ok, file}
  ~>> File.read
  ~> String.upcase
end

此变体的目标是简单地将可能导致成功或错误的操作串在一起。~>> 运算符匹配并返回 {:ok, term} 元组,~> 运算符将值包装到 {:ok, term} 元组中。

是否在包装器上进行规范化 #

在 Erlang 中,truefalse 是常规原子,它们仅通过在布尔表达式中的使用而获得特殊状态。如果不是因为控制流结构,很容易想到更多的函数会返回 yesno

同样,经过多年的使用,undefined 已成为一种默认的“未找到”值。诸如 nilnullunknownundeffalse 等值也得到了一些使用,但格式的强一致性最终使社区对一个值达成了一致。

对于各种函数的返回值,{ok, Term} 是最常用的,用于需要传达值的肯定结果,ok 用于除了自身成功之外没有其他值的肯定结果,{error, Term} 最常用于错误。模式匹配和断言确保通过其自身的结构可以轻松知道调用是否工作。

但是,许多成功值仍然是更大的元组:{ok, Val, Warnings}{ok, Code, Status, Headers, Body} 等。这种变化本身不是问题,但使用 {ok, {Val, Warnings}}{ok, {Code, Status, Headers, Body}} 也可能不会有太大损害。

虽然使用更标准的形式可能会导致更容易的概括和抽象,这些可以应用于社区范围的代码。通过选择基于值的错误处理的控制流的特定格式,我们将明确鼓励这种形式的标准化。

话虽如此,现有格式的多样性和使用的严格值数量较少意味着强制规范化可能会导致未来语言决策中潜在的灵活性丧失。例如,EEP-54——在本 EEP 的最终修订之前完成——试图为错误报告添加新的上下文形式,并且各种库已经依赖于这些更丰富的模式。

因此,OTP 技术委员会的意见是我们应该规范化错误返回值。因此,已经提出了一种更接近 Elixir 的 with 的方法,尽管此 EEP 的方法在可接受表达式的序列及其组合方面更通用。

添加 else 块 #

避免对错误和好值进行规范化引入了对 else ... end 子块的需求,以防止极端情况。

让我们以以下类型的表达式为例,解释为什么

maybe
    {ok, {X,Y}} ?= id({ok, {X,Y}})
    ...
end

虽然这种机制可以很好地处理跳过模式,但在错误处理的上下文中,它有一些有问题的弱点。

一个例子可以从 OTP pull request 中提取,该 pull request 基于 inet 选项向数据包读取添加了新的返回值:#1950

此 PR 为数据包接收在先前形式中添加了可能的值

{ok, {PeerIP, PeerPort, Data}}

为了使其可以替代地获得

{ok, {PeerIP, PeerPort, AncData, Data}}

基于先前设置的套接字选项。因此,让我们将其放在当前提案的上下文中

maybe
    {ok, {X,Y}} ?= id({ok, {X,Y}}),
    {ok, {PeerIP, PeerPort, Data}} ?= gen_udp:recv(...),
    ...
end

由于我们强制返回任何不匹配的值,如果套接字配置错误以返回 AncData,则整个表达式在匹配失败时会返回 {ok, {PeerIP, PeerPort, AncData, Data}}

基本上,使用 maybe ... end 结构,一个函数可能会返回一个意想不到但却看似不错的结果,而实际上,这完全是由于未能匹配和处理所提供的信息而导致的失败。当数据具有正确的形状和类型,但一组绑定变量最终决定匹配成功还是失败时(例如,在 UDP 套接字的情况下,返回来自错误对等方的值),这种情况会变得更加模糊。

在最坏的情况下,它可能会让原始的、未格式化的数据在没有办法事后检测到的情况下退出条件管道,特别是如果 maybe ... end 中后面的函数对文本应用转换,例如匿名化或清理数据。这可能会非常不安全,并且几乎不可能进行良好的调试。

例如,考虑一下

-spec fetch() -> {ok, iodata()} | {error, _}.
fetch() ->
    maybe
        {ok, B = <<_/binary>>} ?= f(),
        true ?= validate(B),
        {ok, sanitize(B)}
    end.

如果从 f() 返回的值最终是一个列表(假设它是一个使用 list 而不是 binary 作为选项的配置错误的套接字),表达式将提前返回,fetch() 函数仍然会返回 {ok, iodata()},但作为调用者,您无法知道它是转换后的数据还是不匹配的内容。对于大多数开发人员来说,这可能代表一个重大的安全风险,因为它允许意外数据被视为干净数据,这一点也不明显。

事实上,这种特定类型的错误在 Elixir 中是可能发生的,但到目前为止,其社区内似乎没有流传这种警告。这个问题需要使用 else 代码块来处理,本提案重新利用该代码块来限制意外值。

-spec fetch() -> {ok, iodata()} | {error, _}.
fetch() ->
    maybe
        {ok, B = <<_/binary>>} ?= f(),
        true ?= validate(B),
        {ok, sanitize(B)}
    else
        false -> {error, invalid_data};
        {error, R} -> {error, R}
    end.

在这里,配置错误的套接字不会导致未检查的数据通过您的应用程序;任何无效的用例都会被捕获,如果 B 的值最终是一个列表,则会引发一个带有错误值的 else_clause 错误。

除非该子句是强制性的(在 Elixir 中不是),否则这种额外的匹配级别是纯粹可选的;开发人员没有明显的动机去处理这些错误,如果他们这样做了,引发的异常将是通过 else 部分中缺少的子句,这将掩盖其来源和行号。

因此,我们将不得不依靠教育和文档(以及类型分析)来防止将来出现此类问题。

这些问题在静态类型语言中使用的规范化错误和返回值中不会存在,但由于我们不打算规范化值,因此 else 代码块是必要的解决方法。

选择 maybe ... end 表达式 #

错误流程的抽象需要定义一个范围,以限制控制流程的方式。在选择 maybe ... end 表达式之前,需要考虑以下几点

  1. 我们需要覆盖的范围是什么
  2. 要使用的结构的格式是什么
  3. 为什么最终选择 maybe ... end
  4. 为什么选择 else 关键字

范围限制 #

在前面提到的语言中,似乎出现了两大类错误处理。

第一组语言似乎在函数级别跟踪它们的错误处理。例如,Go 使用 return 从当前函数提前返回。Swift 和 Rust 也将其错误处理抽象的范围限定为当前函数,但它们也利用它们的类型签名来保留有关正在发生的控制流转换的信息。Rust 使用 Result<T, E> 类型签名来定义哪些操作有效,而 Swift 要求开发人员要么在本地处理错误,要么使用 throws 注解函数以使其显式化。

另一方面,Haskell 的 do 表示法被限制为特定的表达式,Elixir 的所有机制也是如此。

Erlang、Haskell 和 Elixir 主要使用递归作为迭代机制,并且(在 Haskell 的单子结构之外)不支持 return 控制流;当迭代需要递归时,return(或 break)在概念上更难发挥作用:通过退出当前流程“返回”可能无法将您从程序员可能认为是循环的内容中解脱出来,例如。

相反,Erlang 会使用 throw() 异常作为非本地返回的控制流机制,以及 catchtry ... catch。选择一个在函数级别起作用的基于值的错误处理结构不一定很有趣,因为几乎任何递归过程仍然需要使用异常。

因此,使用一个专门构建的自包含结构来专注于包含基于值的错误的运算序列,感觉更简单。

结构格式 #

之前在 Erlang 中抽象基于值的错误处理的尝试使用解析转换重载特殊结构,以便提供特定的工作流程。

例如,fancyflow 库尝试抽象以下代码

sans_maybe() ->
    case file:get_cwd() of
        {ok, Dir} ->
            case
                file:read_file(
                  filename:join([Dir, "demo", "data.txt"]))
            of
                {ok, Bin} ->
                    {ok, {byte_size(Bin), Bin}};
                {error, Reason} ->
                    {error, Reason}
            end;
        {error, Reason} ->
            {error, Reason}
    end.

-spec maybe() -> {ok, non_neg_integer()} | {error, term()}.
maybe() ->
    [maybe](undefined,
            file:get_cwd(),
            file:read_file(filename:join([_, "demo", "data.txt"])),
            {ok, {byte_size(_), _}}).

并且 Erlando 将替换

write_file(Path, Data, Modes) ->
    Modes1 = [binary, write | (Modes -- [binary, write])],
    case make_binary(Data) of
        Bin when is_binary(Bin) ->
            case file:open(Path, Modes1) of
                {ok, Hdl} ->
                    case file:write(Hdl, Bin) of
                        ok ->
                            case file:sync(Hdl) of
                                ok ->
                                    file:close(Hdl);
                                {error, _} = E ->
                                    file:close(Hdl),
                                    E
                            end;
                        {error, _} = E ->
                            file:close(Hdl),
                            E
                    end;
                {error, _} = E -> E
            end;
        {error, _} = E -> E
    end.

使用列表推导中的单子结构

write_file(Path, Data, Modes) ->
    Modes1 = [binary, write | (Modes -- [binary, write])],
    do([error_m ||
        Bin <- make_binary(Data),
        Hdl <- file:open(Path, Modes1),
        Result <- return(do([error_m ||
                             file:write(Hdl, Bin),
                             file:sync(Hdl)])),
        file:close(Hdl),
        Result]).

这些情况专门旨在找到一种编写操作序列的方法,其中预定义的语义由特殊上下文绑定,但仅限于重载结构,而不是引入新的结构。

相比之下,Erlang 的大多数控制流表达式都遵循类似的结构。请参阅以下最常见的结构

case ... of
    Pattern [when Guard] -> Expressions
end

if
   Guard -> Expressions
end

begin
    Expressions
end

receive
    Pattern [when Guard] -> Expressions
after                                               % optional
    IntegerExp -> Expressions
end

try
    Expressions
of                                                  % optional
    Pattern [when Guard] -> Expressions
catch                                               % optional
    ExceptionPattern [when Guard] -> Expressions
after                                               % optional
    Expressions
end

因此,如果我们要添加一个新的结构,它应该采用以下形式,这是合乎逻辑的

<keyword>
    ...
end

剩下的问题是:选择哪个关键字,以及支持哪些子句。

选择 maybe ... end #

最初,考虑了类似于 Elixir 的 with 表达式的格式

<keyword>
    Expressions | UnwrapExpressions
of                                              % optional
    Pattern [when Guard] -> Expressions
end

使用这种结构,基本的 <关键字> ... end 形式将遵循当前提议的语义,但 of ... 部分将允许对表达式的任何返回值进行模式匹配,无论是 {error, Reason} 还是主部分中最后一个表达式返回的任何非异常值。

这种形式将与 try ... of ... catch ... end 允许的形式一致:一旦主部分被覆盖,就可以在同一结构中完成更多工作。

然而,try ... of ... catch ... end 引入模式和保护是有特定原因的:受保护的代码会影响尾递归。

在诸如

map_nocrash(_, []) -> [];
map_nocrash(F, [H|T]) ->
    try
        F(H)
    of
        Val -> [Val | map_nocrash(F, T)]
    catch
        _:_ -> map_nocrash(F, T)
    end.

之类的循环中,of 部分允许在没有发生异常的情况下继续工作,而无需保护超出当前函数范围的范围,也不会通过强制每次迭代都出现在堆栈上而阻止尾递归。

基于值的错误处理不存在此类问题,虽然 of ... end 部分有时可能很方便,但对于结构发挥作用而言,它严格来说不是必需的。

剩下要做的是选择一个名称。最初,选择的 <keyword> 值是 maybe,基于 Maybe 单子。问题是引入任何新的关键字都会给向后兼容性带来严重风险。

由于 OTP 团队现在计划为每个模块引入一种新的语言功能激活机制,因此引入新关键字带来的不兼容风险会降低。只有显式使用新语言功能的模块才会受到影响。

例如,考虑了以下所有单词

======= ================= =========================================
Keyword Times used in OTP Rationale
         as a function
======= ================= =========================================
maybe   0                 can clash with existing used words,
                           otherwise respects the spirit
option  88                definitely clashes with existing code
opt     68                definitely clashes with existing code
check   49                definitely clashes with existing code
let     0                 word is already reserved and free, but
                           makes no sense in context
cond    0                 word is already reserved and free, may
                           make sense, but would prevent the
                           addition of a conditional expression
given   0                 could work, kind of respects the context
when    0                 reserved for guards, could hijack in new
                          context but may be confusing
begin   0                 carries no conditional meaning, mostly
                          free for overrides

最初,本提案希望使用 maybe 关键字

maybe
    Pattern <op> Exp,
    ...
of
    Pattern -> Exp  % optional
end

但由于前一节中提到的原因,of ... 部分变得不重要了。

为什么选择 else 关键字 #

这里的第一步是查看所有现有的备用保留关键字:ofwhencondcatchafter

这些关键字都不能实际表达需要该结构备用子句的含义,因此我们需要添加一个新的关键字。如果仅仅是因为它为以后在 if 表达式中将其作为保留字引入打开了大门,那么 else 关键字是诱人的。

快速查看 OTP 代码库以确保安全似乎没有返回 else() 函数,因此在一般情况下应该相对安全地使用。

选择中缀运算符 #

为了形成 MatchOrReturnExprs,需要一种机制来引入与常规模式匹配具有不同语义的模式匹配。

使用虚假函数调用的简单解析转换方法将是最基本的方法

begin
    match_or_return(Pattern, Exp),
    %% variables bound in Pattern are available in scope
    ...
end

但是,这会在非左侧位置引入模式匹配,并且在不暴露解析转换详细信息和了解代码如何转换的情况下,会使嵌套处理起来非常奇怪。

也可以使用诸如 let <Pattern> = <Exp> 之类的单词前缀。但是,let 的这种用法与其他语言中的用法不同,并且会令人困惑。

中缀运算符似乎很合适,因为模式匹配已经以多种形式使用它们

  • = 用于模式匹配。在错误流程中重载它会阻止使用常规匹配

  • := 用于映射;使用它可能会起作用,但在处理模式中的嵌套映射时肯定会令人困惑

  • <- 可能有意义。它已经在列表和二进制推导的范围内受到限制,因此不会发生冲突或混淆。运算符的现有语义暗示了像过滤器一样工作的文字模式匹配,这正是我们正在寻找的。

  • <=<- 相同,但用于二进制生成器

  • ?= 可能有意义。它是一个新的运算符,不会与任何现有的运算符冲突,并且可以被认为是条件匹配。(预处理器使用 ? 来表示宏调用的开始。尽管 = 是一个有效的宏名称,但这不会造成任何歧义,因为不是原子或变量的宏名称必须用单引号引起来。因此,?= 不是 = 宏的有效调用;= 宏的调用必须写成 ?'='。)

<-?= 运算符最有意义。我们选择 ?=,因为 <- 在其当前用法中表示“子集”或“成员”,这并不是它在这里的用途。

为了完整起见,我还检查了本 EEP 早期版本中的替代运算符,该版本为 {ok, T} | {error, R} 引入了规范值,这些值具有不同的语义

=======  ===========================================================
Operator Description
=======  ===========================================================
#=       No clash with other syntax (maps, records, integers), no
         clash with abstract patterns EEP either.
!=       No clash with message passing, but is sure to anyone used
         to C-style inequality checks
<~       Works with no known conflict; shouldn't clash with ROK's
         frame proposals (uses infix ~ and < > as delimiters).
         Has the disadvantage to being visually similar to `<-`.
<|       Reverse pipe operator. If Erlang were to implement Elixir's
         pipe operator, it would probably make sense to implement both
         `<|` and `|>` since the "interesting" argument often comes
         last.
=~       Regular expression operator in Elixir and Perl.

运算符优先级 #

在解包表达式的预期用法中,?= 运算符需要具有一个优先级规则,例如

X = {Y,X} ?= <Exp>

被视为有效的模式匹配操作,其中 X = {Y,X} 是整个左侧模式,因此运算优先级为

lhs ?= rhs

而不是

lhs = rhs ?= <...>

在所有其他方面,优先级规则应与 = 相同,以便提供尽可能不令人惊讶的体验。

其他被忽略的方法和变体 #

在制定本提案时,考虑了其他方法,但最终被忽略了。

使用规范性错误值的 Begin ... end #

本文档的早期版本只是简单地使用了

begin
    Foo = bar(),
    X ?= id({ok, 5}),
    [H|T] ?= id({ok, [1,2,3]}),
    ...
end

它通过调用 T ?= f() 隐式解包 {ok, T} = f(),并强制所有可接受的非匹配值都采用 {error, T}. 的形式。

为了使该形式对大多数现有代码有用,它还需要一些所有人(包括我自己)都不太喜欢的魔法,其中如果 f() 的返回值是 ok,则 _ ?= f() 将隐式成功。

这被认为太神奇了,而且不一定有很多现有的 Erlang 代码会从此形式中受益,因为 ok 通常在成功的函数中返回,而没有额外的值。为了避免这种魔法,需要对形式 {ok, undefined} 进行更强的规范性(以复制 Rust 的 Ok(())),而且会感觉非常不符合习惯。

with 中类似 Elixir 的模式 #

Elixir 的方法相当全面,而且非常强大。它不是处理成功或错误,而是像我们这里所做的那样,将模式匹配作为一个整体进行泛化。

唯一的区别是 Elixir 的 with 表达式强制所有条件判断先发生,然后是一个 do 代码块,用于处理后续的自由形式的表达式。

with dob <- parse_dob(params["dob"]),
    name <- parse_name(params["name"])
do
  %User{dob: dob, name: name}
else
  err -> err
end

本文档中介绍的 Erlang 形式更通用,因为它允许在整个过程中混合使用 MatchOrReturnExprs 和常规表达式,而无需通用的 do 代码块。

Erlang 形式确实意味着当从 AST 形式转换为 Core Erlang 时,可能需要一组更复杂的重写规则。 虽然最终结果可能看起来与原始代码完全不同,但应该可以在现有的 Core Erlang 术语中纯粹地重写它

condcond let #

Anthony Ramine 建议研究一下重用已经保留的 condlet 关键字。他提到 Rust 正在计划基于这些关键字做一些事情,以及如何根据他之前在语言中支持 cond 结构的工作将其移植到 Erlang。

提议的机制看起来像这样

cond
    X > 5 -> % regular guard
        Exp;
    f() < 18 -> % function used in guard, as originally planned
        Exp;
    let {ok, Y} = exp(), Y < 5 ->
        Exp
end

只有当 Y 匹配并且所有保护条件成功时,最后一个子句才允许在它自己的分支中使用 Y;如果绑定失败,则会自动切换到下一个分支。

因此,可以涵盖更复杂的操作序列,如下所示

cond
    let {ok, _} = call1(),
    let {ok, _} = call2(),
    let Res = call3() ->
        Res;
    true ->
        AlternativeBranch
end

在我看来,这种机制值得探索并可能添加到语言中,但它本身并不能充分解决错误处理流程问题,因为无法轻易地从失败的操作中提取错误。

自动包装返回值 #

自动包装返回值是 Elixir 的 OK 库以及 Haskell 的 do notation 所做的事情,但 Rust 和 Swift 都没有这样做。

似乎对于可以做什么并没有非常明确的共识。因此,为了实现简单,直接按原样返回值而不进行自动包装似乎是明智的,特别是因为我们没有规定已处理值的元组格式。

因此,开发者可以返回最符合其函数类型签名的任何值,从而更容易地将返回值与其系统集成。

它还允许操作序列在成功时可能返回 ok,即使它们的单个函数返回了诸如 true 之类的值,而不是 {ok, true}

选择引发的异常 #

这里提出的异常格式是 {else_clause, Value}。选择此格式是为了遵循 Erlang/OTP 标准

  • if_clause
  • {case_clause, Val}
  • function_clause(该值在堆栈跟踪中提供)
  • {badmatch, Val}
  • catch 代码块和 receive 表达式中不匹配的值不会显式引发任何异常

由于 case_clause 在功能上是最接近的异常,并且它携带一个值,我们选择在这里复制相同的形式。

选择 else_clause 而不是 maybe_clause 的原因是,else 代码块在未来可能会在其他结构中使用,将异常限制在代码块本身的名字上可能更具有前瞻性。

向后兼容性 #

引入了新关键字 maybe,它可能会与现有未加引号的 maybe 作为原子(实际上也包括用作模块或函数名)的使用发生冲突。

计划将此新功能与一种机制一起引入,该机制允许用户按模块单独激活它和其他新功能,这样新关键字 maybe 只会在用户激活此功能的模块中成为潜在的不兼容项。

参考实现 #

有几个参考实现