错误抛出与作为值的两种模式比较

程序中会出现错误——它们是不可避免的!了解错误可能发生的位置以及如何有效处理错误非常重要。
在这篇文章中,我们将:

  • 比较处理错误的两种主要方法:抛出错误和作为值的错误。
  • 演示如何在 Python(一种传统的抛出错误语言)中将错误作为值进行处理。

抛出错误
抛出的错误会中断控制流,并沿着调用堆栈向下传播,直到被捕获。如果没有被捕获,那么程序就会兑现。这种方法的一个大问题是缺乏对可能发生错误的位置的明确性。


当我们思考每一个可能出现的错误时,就会意识到我们原来的逻辑会在我们不希望的情况下让程序崩溃!但是,这样做虽然安全,但也非常冗长。绝大多数 Python 工程师都同意这一点,这就是为什么大多数 Python 代码最多只有一个大的 try/catch:

def upsert_thing(thing_id: str) -> Thing:
    try:
        thing = get_thing(thing_id)
        thing.set_name("Doodad")
        update_thing(thing)
        log_thing(thing)
    except Exception as err:
        raise Exception(f
"something errored ¯\_(ツ)_/¯: {err}")
    return thing

所以就采用了抛出错误的方法:

  • 没有告诉我们哪些函数可能会出错。
  • 不能强制我们处理发生的错误。
  • 鼓励工程师使用粗粒度错误处理(即一个大的 try/catch)。

一定有更好的方法

错误作为值
有些语言(如 Go 和 Rust)采用了不同的方法:它们返回错误而不是抛出错误。通过返回错误,这些语言迫使工程师注意、思考和处理错误。

Go 使用元组(其实不是元组,但看起来像元组!)返回错误,按照惯例,将错误放在最后:

// Define a function that returns a User or error
func getUser(userID string) (*User, error) {
    rows := users.Find(userID)
    if len(rows) == 0 {
        return nil, errors.New(
"user not found")
    }
    return rows[0], nil
}
func renameUser(userID string, name string) (*User, error) {
 
// Consume the function
    user, err := getUser(userID)
    if err != nil {
        return nil, err
    }
    user.Name = name
    return user, nil
}

Rust 使用一种名为 Result 的 "封装 "类型来返回错误。Result 包含非错误值 (Ok) 和错误值 (Err):

// Define a function that returns a Result with a User or an error string
fn get_user(user_id: &str) -> Result<Option<User>, &str> {
    match find_user_by_id(user_id) {
        Some(user) => Ok(Some(user)),
        None => Err(
"user not found"),
    }
}
fn rename_user(user_id: &str, name: String) -> Result<User, &str> {
   
// Consume the function
    match get_user(user_id) {
        Ok(Some(mut user)) => {
            user.name = name;
            Ok(user)
        },
        Ok(None) => Err(
"user not found"),
        Err(e) => Err(e),
    }
}

无论采用哪种具体方法,将错误作为值返回都会让我们考虑到可能发生错误的所有地方。出错的情况变得可以自我记录,处理起来也更加彻底。

在 Python 中将错误视为值
那么我们如何在 Python 中将错误视为值呢?我们可以采用 Go 的方法,返回一个元组:

# Define a function that returns a tuple of a User and an error
def get_user(user_id: str) -> tuple[User | None, Exception | None]:
    rows = users.find(user_id=user_id)
    if len(rows) == 0:
        return None, Exception("user not found")
    return rows[0], None
def rename_user(
    user_id: str, name: str
) -> tuple[User | None, Exception | None]:
    # Consume the function
    user, err = get_user(user_id)
    if err is not None:
        return None, err
    # Unnecessary check but the type checker can't know that
    assert user is not None
    user.name = name
    return user, None

由于类型检查程序不知道元组值是互斥的,我们不得不做一个多余的断言 user is not None。否则,类型检查程序会错误地认为 user 是可空的。

接下来,让我们使用超棒的库 result,利用模式匹配,尝试一些类似 Rust 的方法:

import result
# Define a function that returns a Result
def get_user(user_id: str) -> result.Result[User, Exception]:
    rows = users.find(user_id=user_id)
    if len(rows) == 0:
        return result.Error(Exception("user not found"))
    return result.Ok(rows[0])
def rename_user(user_id: str, name: str) -> result.Result[User, Exception]:
    # Consume the function
    match get_user(user_id):
        case result.Ok(user):
            pass
        case result.Err(err):
            return result.Err(err)
    user.name = name
    return result.Ok(user)

比元组更好!但我们仍然有一些缺点:

  • 冗长。
  • 语言服务器不知道在两种情况下返回总是会结束函数。换句话说,它们不知道同时检查 result.Ok 和 result.Err 是详尽无遗的。
  • 模式匹配是 Python (3.10) 的新特性。很多人使用的是旧版本,还有很多人犹豫是否要在他们的代码库中引入 match 语句。
  • 外部依赖 (result 包)。

我们要尝试的最后一种方法是返回uinion:

# Define a function that returns a union of a User and an error
def get_user(user_id: str) -> User | Exception:
    rows = users.find(user_id=user_id)
    if len(rows) == 0:
        return Exception("user not found")
    return rows[0]
def rename_user(user_id: str, name: str) -> User | Exception:
    # Consume the function
    user = get_user(user_id)
    if isinstance(user, Exception):
        return user
    user.name = name
    return user

这看起来很不错!我们不需要多余的断言(比如元组方法),也没有引入新的模式(比如结果方法)。联合之所以有效,是因为 isinstance 支持类型缩小:

  • 在 if isinstance(user, Exception) 代码块中,user 变量从 User | Exception 变为 Exception。
  • 由于我们在 user 是 Exception 时设置了 user = User(),因此类型检查程序会理解,在 if 语句之后,user 不能是 Exception。

结论
Inngest 的 Python SDK 将错误作为值处理,因为这将错误处理集成到了程序的正常控制流中。这使得程序更加冗长,但却能确保我们正确处理错误。

我们使用uinion联合体将错误作为值来处理,因为这是最简洁明了的方法。其他语言使用 tuples(例如 Go)或包装类型(例如 Rust),但我们认为这些模式要么在 Python 中效果不好,要么过于冗长,要么大量使用了一种尚未习得的模式。