程序中会出现错误——它们是不可避免的!了解错误可能发生的位置以及如何有效处理错误非常重要。
在这篇文章中,我们将:
- 比较处理错误的两种主要方法:抛出错误和作为值的错误。
- 演示如何在 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 中效果不好,要么过于冗长,要么大量使用了一种尚未习得的模式。