#error #type-level #error-handling #anonymous #sum

terrors

基于类型级集合运算的优雅且精确的错误处理

13 个版本

0.3.0 2024 年 4 月 6 日
0.2.6 2024 年 4 月 2 日
0.2.3 2024 年 3 月 31 日
0.1.6 2024 年 3 月 30 日

#173Rust 模式

Download history 35/week @ 2024-04-24 29/week @ 2024-05-01 55/week @ 2024-05-08 96/week @ 2024-05-15 3/week @ 2024-06-12 4/week @ 2024-06-19 9/week @ 2024-06-26 13/week @ 2024-07-03 50/week @ 2024-07-10 147/week @ 2024-07-17 235/week @ 2024-07-24 127/week @ 2024-07-31 57/week @ 2024-08-07

每月 580 次下载
2 个crate中使用(通过 aeronet_proto

MIT/Apache

44KB
988

terrors - Rust 错误 处理

处理错误意味着从可能的错误类型集中移除本地可解决的部分,然后如果剩余的错误集不在本地关注范围内,将其传播给调用者。调用者不应该接收到被调用者的本地错误。

原则

  • 错误类型应该是精确的。
    • terrors::OneOf 通过创建可能的错误集的精确集合来解决此问题
      • 指定低摩擦
      • 通过特定的错误处理程序缩小范围低摩擦
      • 扩展以向上传递到调用堆栈低摩擦
  • 错误处理应遵循单一职责原则
    • 如果系统中的每个错误都分散到其他地方,那么就没有明确的职责来确定它应该在哪里被处理。
  • 没有宏。
    • 用户不需要学习一些新的 DSL 来处理错误,因为这每个宏都包含。

示例

use terrors::OneOf;

let one_of_3: OneOf<(String, u32, Vec<u8>)> = OneOf::new(5);

let narrowed_res: Result<u32, OneOf<(String, Vec<u8>)>> =
    one_of_3.narrow();

assert_eq!(5, narrowed_res.unwrap());

OneOf 也可以扩展到一个超集,并在编译时进行检查。

use terrors::OneOf;

struct Timeout;
struct AllocationFailure;
struct RetriesExhausted;

fn allocate_box() -> Result<Box<u8>, OneOf<(AllocationFailure,)>> {
    Err(AllocationFailure.into())
}

fn send() -> Result<(), OneOf<(Timeout,)>> {
    Err(Timeout.into())
}

fn allocate_and_send() -> Result<(), OneOf<(AllocationFailure, Timeout)>> {
    let boxed_byte: Box<u8> = allocate_box().map_err(OneOf::broaden)?;
    send().map_err(OneOf::broaden)?;

    Ok(())
}

fn retry() -> Result<(), OneOf<(AllocationFailure, RetriesExhausted)>> {
    for _ in 0..3 {
        let Err(err) = allocate_and_send() else {
            return Ok(());
        };

        // keep retrying if we have a Timeout,
        // but punt allocation issues to caller.
        match err.narrow::<Timeout, _>() {
            Ok(_timeout) => {},
            Err(one_of_others) => return Err(one_of_others.broaden()),
        }
    }

    Err(OneOf::new(RetriesExhausted))
}

OneOf 还实现了 CloneDebugDisplay 和/或 std::error::Error,如果类型集中的所有类型也都这样做

use std::error::Error;
use std::io;
use terrors::OneOf;

let o_1: OneOf<(u32, String)> = OneOf::new(5_u32);

// Debug is implemented if all types in the type set implement Debug
dbg!(&o_1);

// Display is implemented if all types in the type set implement Display
println!("{}", o_1);

let cloned = o_1.clone();

type E = io::Error;
let e = io::Error::new(io::ErrorKind::Other, "wuaaaaahhhzzaaaaaaaa");

let o_2: OneOf<(E,)> = OneOf::new(e);

// std::error::Error is implemented if all types in the type set implement it
dbg!(o_2.description());

OneOf 还可以转换为所有权的或引用的枚举形式

use terrors::{OneOf, E2};

let o_1: OneOf<(u32, String)> = OneOf::new(5_u32);

match o_1.as_enum() {
    E2::A(u) => {
        println!("handling reference {u}: u32")
    }
    E2::B(s) => {
        println!("handling reference {s}: String")
    }
}

match o_1.to_enum() {
    E2::A(u) => {
        println!("handling owned {u}: u32")
    }
    E2::B(s) => {
        println!("handling owned {s}: String")
    }
}

动机

论文 Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-intensive Systems 是一份关于系统失败的软件模式的统计资料,它揭示了令人着迷的统计数据。这是我最喜欢的之一

almost all (92%) of the catastrophic system failures
are the result of incorrect handling of non-fatal errors
explicitly signaled in software.

我们的系统正在崩溃,因为我们没有处理我们的错误。在表示它们的存在方面,我们做得很好,但我们需要真正处理它们。

当我们编写 Rust 时,我们倾向于遇到各种不同的错误类型。有时我们需要将多个可能的错误放入一个容器中,然后从函数中返回,调用者或递归调用者是期望处理出现的特定问题的。

随着代码库的扩展,类似情况越来越多。虽然在一两个地方编写自定义枚举来表示可能出现的错误集合不是那么费力,但大多数人会采用以下两种策略之一来最小化传播错误类型所需的努力

  • 一个包含代码库中所有错误变体的顶级枚举,随着时间的推移会越来越大,削弱了使用穷举模式匹配来自信地确保局部问题不会向上冒泡的能力。
  • 一个易于转换为错误的封装特质,但它隐藏了可能实际包含的信息。你不知道它来自哪里,要去哪里。

随着这些错误容器持有的不同源错误类型数量的增加,它们传达给遇到它们的人的信息量减少。实际上容器包含的内容变得越来越不清楚。随着类型精度的下降,人类对在哪里处理容器内的特定关注点的推理能力也下降。

我们必须增加我们的错误类型的精度。

人们不会为每个可能只返回一些错误子集的函数编写精确的枚举,因为这会导致大量的小型枚举类型,这些类型只在少数地方使用。这是驱使人们使用过于宽泛的错误枚举或过于平滑的封装动态错误特质的痛苦,这降低了他们处理错误的能力。

有趣的东西

这个crate是以OneOf为基础构建的,它充当一种匿名枚举,可以通过类似于TypeScript等用户熟悉的方式进行缩小。我们的错误容器需要变小,因为当单个错误被剥离和处理时,如果本地问题不存在,就会留下可能错误类型的减少剩余部分。

有趣的是,它建立在可能的错误类型级别的异构集合之上,其中只有不同可能性中的一个实际值。

而不是有一个巨大的泥球枚举或封装特质对象,你永远不清楚它实际上包含什么,这会导致你永远不会从它那里处理个别关注点,这个想法是你可以有一个最小化的实际错误类型集合,这些类型可能贯穿整个堆栈。

这个类型级别集合的好处是,在缩小其他类型时,如果缩小失败,可以剥离任何特定类型。缩小和扩展都基于编译时错误类型集检查。

权衡

由于编译错误导致的混乱的错误信息,我在职业生涯的大部分时间里都努力避免使用类型级编程。这些复杂的类型检查失败会产生难以推理的错误,通常需要几分钟才能理解。

我努力避免让terrors的用户接触到底层类型机制中的太多锋利边缘,但如果源和目标类型集不满足SupersetOf特质,那么错误就不会特别愉快地阅读。只需知道错误几乎总是意味着超集关系没有按照要求保持。

展望未来,我相信大多数所需特性可以通过暴露用户于类似 (A, B) does not implement SupersetOf<(C, D), _> 的错误来实现,而不是通过 Cons<A, Cons<B, End>> does not implement SupersetOf<Cons<C, Cons<D, End>>> 的方式,通过利用存在于异构类型集 Cons 链和更人性化的类型元组之间的双向类型映射。

特别感谢

关于错误类型集合的许多高级类型逻辑直接受到 frunk 的启发。多年来我一直想知道数据结构 OneOf 的可行性,并常常认为这是不可能的,直到我终于有了一个周末可以深入研究。经过许多尝试和失败,我终于找到了 一篇由 lloydmeta(frunk 的作者)撰写的文章,该文章讲述了 frunk 在异构列表结构上下文中如何处理几个相关问题。尽管我已经使用 Rust 超过 10 年,但那篇文章让我对如何以有趣的方式使用该语言类型系统来满足实际需求有了巨大的了解。特别是,那篇博客文章中关于如何以其他函数式语言中熟悉的递归方式实现特质的总体观点,是我用 Rust 语言前十年没有意识到可能实现的缺失原语。非常感谢你创建 frunk 并向世界展示你是如何做到的!

无运行时依赖

功能