UpPetto

This is a real course generated by UpPetto — unedited.

AI-generated. Two models wrote it, a third checked every risky claim against sources, a fourth re-checked.

Create my own course

Свои исключения и проектирование 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
sentinel-значение нужно проверять на каждом уровне вызова, исключение долетает до нужного уровня само

Вывод

Магическое возвращаемое значение — это перегруженный канал, который вызывающий обязан расшифровывать вручную на каждом уровне. Собственное исключение с иерархией (ConfigError → MissingKeyError) делает сигнатуру честной и позволяет обрабатывать ошибку там, где это осмысленно, а не на каждом промежуточном шаге.

AI-generated · source-grounded review

🛡 Fact-checked: 1 risky claim verified · 2 removed · confidence: high · figures: 1
[verified] Питонические API сигнализируют об ошибке исключением, а не sentinel-значением (-1, NULL, errno)
Прямо утверждается в базе знаний (раздел «Исключения как механизм ошибок»)
[softened] Аннотация -> int гарантирует, что вернётся int или будет исключение
База знаний: аннотации типов не влияют на выполнение в CPython; переформулировано как контракт, проверяемый статически
[unconfirmed by second model] Собственные исключения наследуют от Exception, а не от BaseException; группируются в иерархию с базовым классом модуля
Соответствует стандартной практике и иерархии исключений
Second model: KB не содержит информации о создании собственных исключений или их наследовании от Exception.
[unconfirmed by second model] Фигура: sentinel требует проверки на каждом уровне, исключение долетает до нужного уровня само
Согласуется с базой знаний об исключениях как основном механизме ошибок
Second model: KB не сравнивает распространение исключений с ручной проверкой sentinel на каждом уровне вызова.
[removed] Утверждение «сигнатура означает: либо int, либо исключение» уточнено как аннотация/контракт: в CPython аннотации типов не проверяются во время выполнения (база знаний, PEP 484)
[removed] Фраза «только по счастливой случайности не ловится как баг» заменена нейтральной формулировкой: в CPython возврат разнотипных значений сам по себе не вызывает ошибки

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.

Key concepts: custom exceptions API design sentinel values
Tell me more 🔒 Didn't understand — explain simply 🔒 Show examples 🔒 Sources 🔒

On your own course these buttons answer instantly, quizzes track what you've mastered, and lessons adapt to your gaps. Write my course

Check yourself

1. В чём главное преимущество собственного исключения перед возвратом sentinel-значения (-1/None)?
2. От какого класса обычно стоит наследовать собственное исключение общего назначения?
3. Зачем группировать свои исключения в иерархию с общим базовым классом (например, ConfigError → MissingKeyError)?
On your own course, these are marked as you answer