Свои исключения и проектирование API без sentinel-значений
optional спроектировать функцию, сигнализирующую об ошибке через собственное исключение, вместо магического возвращаемого значения
В C функция сигнализирует об ошибке через возвращаемое значение — NULL, -1, специальный код, — а полезный результат приходится передавать через выходной параметр (int parse(const char *s, int *out)). Это заставляет вызывающего каждый раз помнить: проверить возврат, только потом читать *out. Ошибка и результат живут в одном канале и постоянно рискуют перепутаться — классический баг «забыли проверить -1, прочитали мусор из *out».
Python-функция может просто возвращать полезное значение и никогда не возвращать «код ошибки» — потому что для ошибки есть отдельный канал: raise. Питонические API сигнализируют о неудаче исключением, а не sentinel-значением. Это меняет сам контракт функции: аннотация def parse(s: str) -> int означает, что либо вы получаете int, либо получаете исключение — третьего не бывает, и не нужно проверять, «а не является ли этот int замаскированным -1».
Собственные исключения — это то, чем в C были бы именованные коды ошибок (enum ParseError { ERR_EMPTY, ERR_OVERFLOW, ... }), но с двумя преимуществами: они несут произвольные данные (не только код, а хоть весь контекст сбоя) и распространяются по стеку вызовов автоматически, без ручной проверки на каждом промежуточном уровне.
class ConfigError(Exception):
"""Базовая ошибка конфигурации."""
class MissingKeyError(ConfigError):
def __init__(self, key):
super().__init__(f"missing required key: {key}")
self.key = key
def get_required(config, key):
if key not in config:
raise MissingKeyError(key)
return config[key]
Вызывающему на трёх уровнях выше не нужно ничего проверять — если MissingKeyError не перехвачена явно, она сама долетит до верхнего уровня со всем traceback'ом. В C та же задача требует, чтобы каждая промежуточная функция проверяла код возврата и явно перепробрасывала его вверх — то есть повторяла логику обработки на каждом уровне, даже если сама эта функция ничего не может с ошибкой сделать.
Важный нюанс: собственные исключения стоит наследовать от Exception (или от более специфичного встроенного класса, если ошибка реально того же рода — например, ValueError для некорректного значения), а не от BaseException напрямую, и группировать их в иерархию с общим базовым классом модуля/библиотеки (как выше ConfigError). Это позволяет вызывающему коду перехватывать либо конкретный случай, либо весь класс ошибок одной строкой except ConfigError.
Разобранный пример
В C-стиле функция чтения записи из бинарного протокола могла бы выглядеть так (псевдо-Python, но с C-мышлением):
def read_record(buf):
if len(buf) < 4:
return None # sentinel: "не хватает данных"
length = int.from_bytes(buf[:4], "big")
if length > len(buf) - 4:
return -1 # sentinel: "повреждённый заголовок"
return buf[4:4+length]
Проблема: None и -1 — это не «результат», а перегруженный канал. Вызывающий обязан проверять оба магических значения, и легко забыть один из случаев — компилятора, который напомнит, здесь нет. Плюс тип возврата функции реально «bytes | None | int», и никакой проверки на этапе выполнения это само по себе не вызовет.
Питонический вариант:
class ProtocolError(Exception):
pass
class IncompleteData(ProtocolError):
pass
class CorruptedHeader(ProtocolError):
def __init__(self, declared, available):
super().__init__(f"declared length {declared} exceeds available {available}")
def read_record(buf: bytes) -> bytes:
if len(buf) < 4:
raise IncompleteData()
length = int.from_bytes(buf[:4], "big")
if length > len(buf) - 4:
raise CorruptedHeader(length, len(buf) - 4)
return buf[4:4+length]
Сигнатура честна: либо bytes, либо исключение из семьи ProtocolError. Вызывающий, которому не важна разница между «неполные данные» и «битый заголовок», ловит except ProtocolError; тому, кому важна — except IncompleteData отдельно.
Попробуй сейчас
У вас есть функция на C-манер:
def find_user(db, user_id):
row = db.query(user_id)
if row is None:
return None # sentinel: пользователь не найден
if row.get("deleted"):
return -1 # sentinel: пользователь удалён
return row
Спроектируйте замену: два своих исключения (UserNotFound, UserDeleted), общий базовый класс UserLookupError, и функцию find_user, которая либо возвращает валидную запись, либо поднимает одно из исключений. Проверьте на трёх случаях: обычный пользователь, отсутствующий user_id, удалённый пользователь.
Получилось, если…
Признак, что получилось: тип возврата функции — один конкретный тип (запись пользователя), а не «запись или None или -1»; вызывающий код может написать except UserLookupError и не думать о конкретной причине, либо except UserDeleted — если причина важна.
flowchart LR
subgraph C["C-стиль: sentinel"]
C1[find_user] -->|return None / -1 / row| C2[Вызывающий проверяет значение]
C2 --> C3[Каждый уровень выше повторяет проверку]
end
subgraph PY["Python: исключение"]
P1[find_user] -->|return row или raise| P2[except UserLookupError на нужном уровне]
P2 --> P3[Промежуточные уровни ничего не проверяют]
endВывод
Магическое возвращаемое значение — это перегруженный канал, который вызывающий обязан расшифровывать вручную на каждом уровне. Собственное исключение с иерархией (ConfigError → MissingKeyError) делает сигнатуру честной и позволяет обрабатывать ошибку там, где это осмысленно, а не на каждом промежуточном шаге.
AI-generated · source-grounded review
🛡 Fact-checked: 1 risky claim verified · 2 removed · confidence: high · figures: 1
A second model re-checked this lesson's claims and corroborated 1 of 4 , and could not confirm 3 either way. This is incomplete corroboration, not a disagreement.
On your own course these buttons answer instantly, quizzes track what you've mastered, and lessons adapt to your gaps. Write my course