Ошибки в Python как значения: Сравнение полезных паттернов из Go и Rust
В программах случаются ошибки – они неизбежны! Важно знать, где могут возникать ошибки и как их эффективно устранять. Когда мы разрабатывали наш Python SDK, безопасная и эффективная обработка ошибок имела первостепенное значение.
В этом посте мы:
- Сравните два основных способа обработки ошибок: выброшенные ошибки и ошибки как значения.
- Продемонстрируйте, как обрабатывать ошибки как значения в Python (традиционном языке с выброшенными ошибками).
Выброшенные ошибки
Выброшенная ошибка прерывает поток управления, распространяясь вниз по стеку вызовов, пока не будет поймана. Если она не поймана, то программа завершается. Большой проблемой такого подхода является отсутствие ясности в том, где может произойти ошибка. Например, какие строки в следующем коде приводят к ошибке?
def upsert_thing(thing_id: str) -> Thing:
thing = get_thing(thing_id)
thing.set_name("Doodad")
update_thing(thing)
log_thing(thing)
return thing
Невозможно узнать, в какой строке может возникнуть ошибка, не прочитав сами функции… и функции, которые эти функции вызывают… и функции, которые эти функции вызывают. Некоторые дотошные инженеры могут задокументировать брошенные ошибки, но документация не проверяется и, следовательно, не заслуживает доверия. Java немного лучше, потому что она заставляет вас объявлять о неперехваченных ошибках в подписях методов.
Поэтому, если мы хотим быть действительно безопасными, мы обернем каждый вызов в try/catch:
def upsert_thing(thing_id: str) -> Thing:
try:
thing = get_thing(thing_id)
except Exception as err:
# Swallow error and create a new Thing
thing = Thing(thing_id)
try:
thing.set_name("Doodad")
except Exception as err:
raise Exception(f"failed to set name: {err}") from err
try:
update_thing(thing)
except Exception as err:
raise Exception(f"failed to update: {err}") from err
try:
log_thing(thing)
except Exception as err:
# Swallow error because logging isn't critical
pass
return user
Обдумывая каждую возможную ошибку, мы понимаем, что наша первоначальная логика привела бы к краху программы, когда мы этого не хотели! Но хотя это и безопасно, это также чрезвычайно многословно. Инженеры Python в подавляющем большинстве согласны с этим, поэтому большинство кода Python содержит в лучшем случае 1 большую 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
Так что подход с брошенной ошибкой:
- Не говорит нам, какие функции могут ошибиться.
- Не заставляет нас обрабатывать ошибки там, где они происходят.
- Побуждает инженеров использовать грубую обработку ошибок (т. е. одну большую попытку/захват).
Должен быть лучший способ 🤔.
Ошибки как значения
Некоторые языки (например, 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
Поскольку программа проверки типов не знает, что значения кортежа взаимоисключающие, мы вынуждены сделать лишнее assert user is not None. В противном случае программа проверки типов неверно посчитает, что user является nullable.
Далее попробуем сделать что-то в стиле Rust, используя потрясающую библиотеку result, которая использует сопоставление шаблонов:
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).
Последний подход, который мы попробуем, – это возвращение союза:
# 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
Это выглядит великолепно! Нам не нужны лишние утверждения (как в подходе tuple) и мы не вводим новых паттернов (как в подходе result). Союзы работают, потому что isinstance поддерживает сужение типов:
- В блоке if isinstance(user, Exception) переменная user сужается от User | Exception до Exception.
- Поскольку мы устанавливаем user = User(), когда user является Exception, программы проверки типов поймут, что user не может быть Exception после оператора if.
Заключение
Python SDK Inngest обрабатывает ошибки как значения, поскольку это интегрирует обработку ошибок в обычный поток управления программой. Это делает программы более многословными, но гарантирует, что мы правильно обрабатываем ошибки.
Мы реализовали ошибки как значения с помощью объединений, поскольку это был наиболее идиоматичный и лаконичный подход. Другие языки используют кортежи (например, Go) или типы-обертки (например, Rust), но мы посчитали, что эти паттерны либо не работают в Python, либо слишком многословны, либо сильно используют паттерн, который еще не является идиоматическим.