Конференции Олега Бунина (Онтико) corporate blog
Website development
Python
Programming
Perfect code
March 26

Исключения в Python теперь считаются анти-паттерном

Что такое исключения? Из названия понятно — они возникают, когда в программе происходит исключительная ситуация. Вы спросите, почему исключения — анти-паттерн, и как они вообще относятся к типизации? Я попробовал разобраться, и теперь хочу обсудить это с вами, хабражители.

Проблемы исключений


Трудно найти недостатки в том, с чем сталкиваешься каждый день. Привычка и зашоренность превращает баги в фичи, но давайте попробуем взглянуть на исключения непредвзято.

Исключения трудно заметить


Существует два типа исключений: «явные» создаются при помощи вызова raise прямо в коде, который вы читаете; «скрытые» запрятаны в используемых функциях, классах, методах.

Проблема в том, что «скрытые» исключения и правда трудно заметить. Покажу на примере чистой функции:

def divide(first: float, second: float) -> float:
    return first / second

Функция просто делит одно число на другое, возвращая float. Типы проверены и можно запустить что-то такое:  

result = divide(1, 0)
print('x / y = ', result)

Заметили? На самом деле до print исполнение программы никогда не дойдет, потому что деление 1 на 0 – невозможная операция, она вызовет ZeroDivisionError. Да, такой код безопасен с точки зрения типов, но его все равно нельзя использовать.

Чтобы заметить потенциальную проблему даже в таком максимально простом и читаемом коде, нужен опыт. Все что угодно в Python может перестать работать с разными типами исключений: деление, вызовы функций, int, str, генераторы, итераторы в циклах, доступ к атрибутам или ключам. Даже сам raise something() может привести к сбою. Причем, я даже не упоминаю операции ввода и вывода. А проверенные исключения перестанут поддерживаться в ближайшем будущем.

Восстановление нормального поведения на месте невозможно


Но именно на такой случай у нас же есть исключения. Давайте просто обработаем ZeroDivisionError, и код станет безопасным с точки зрения типов.

def divide(first: float, second: float) -> float:
    try:
        return first / second
    except ZeroDivisionError:
        return 0.0

Теперь всё в порядке. Но почему мы возвращаем 0? Почему не 1 или None?  Конечно, в большинстве случаев, получить None почти так же плохо (если даже не хуже), как исключение, но все же нужно опираться на бизнес-логику и варианты использования функции.

Что именно мы делим? Произвольные числа, какие-то конкретные единицы или деньги? Не каждый вариант легко предусмотреть и восстановить. Может получиться, что при последующем использовании одной функции обнаружится, что потребуется другая логика восстановления.

Печальный вывод: решение каждой проблемы — индивидуальное, в зависимости от конкретного контекста использования.

Нет серебряной пули, которая бы справилась с ZeroDivisionError раз и навсегда. И это мы ещё не говорим о возможности сложного ввода-вывода с  повторными запросами и таймаутами.

Может быть, вообще не обрабатывать исключения именно там, где они возникают? Может быть, просто кинуть его в процесс исполнения кода — кто-нибудь потом разберется. И тогда мы вынуждены вернуться к сегодняшнему положению дел.

Процесс выполнения неясен


Хорошо, давайте понадеемся, что кто-то другой поймает исключение и, возможно, справится с ним. Например, система может запросить у пользователя изменить введенное значение, потому что нельзя делить на 0. И функция divide явно не должна отвечать за восстановление после ошибки.

В таком случае нужно проверить, где мы поймали исключение. Кстати, как определить, где именно оно обработается? Возможно ли перейти в нужное место кода? Оказывается, что нет, невозможно.

Невозможно определить, какая строка кода выполнится после создания исключения. Разные типы исключений могут обрабатываться с помощью разных вариантов except, а некоторые исключения могут игнорироваться. А еще вы можете выкинуть дополнительные исключения в других модулях, которые выполнятся раньше, и вообще сломают всю логику.

Предположим, в приложении есть два независимых потока: обычный, выполняющийся сверху вниз, и поток исключений, который выполняется как ему вздумается. Как прочитать и осознать такой код?

Только с включенным отладчиком в режиме «ловить все исключения».


Исключения, как пресловутое goto, рвут структуру программы.

Исключения не исключительны


Посмотрим на другой пример: обычный код доступа к удаленному HTTP API:

import requests

def fetch_user_profile(user_id: int) -> 'UserProfile':
    """Fetches UserProfile dict from foreign API."""
    response = requests.get('/api/users/{0}'.format(user_id))
    response.raise_for_status()
    return response.json()

В этом примере буквально все может пойти не так. Вот неполный список возможных ошибок:

  • Сеть может быть недоступна, и запрос вообще не будет выполняться.
  • Может не работать сервер.
  • Сервер может быть слишком занят, наступит таймаут.
  • Сервер может потребовать аутентификацию.
  • У API может не быть такого URL.
  • Может быть передан несуществующий пользователь.
  • Может быть недостаточно прав.
  • Сервер может упасть из-за внутренней ошибки при обработке вашего запроса
  • Сервер может вернуть невалидный или поврежденный ответ.
  • Сервер может вернуть невалидный JSON, который не удастся распарсить.

Список можно продолжать бесконечно, столько потенциальных проблем кроется в коде из несчастных трех строчек. Можно сказать, что он вообще работает только по счастливой случайности, и гораздо вероятнее упадет при исключении.

Как себя обезопасить?


Теперь, когды мы убедились, что исключения могут быть вредны для кода, давайте разберемся, как от них избавиться. Чтобы писать код без исключений, есть разные паттерны.

  • Везде написать except Exception: pass. Тупиковый путь. Не делайте так.
  • Возвращать None. Тоже зло. В итоге либо придется почти каждую строку начинать с if something is not None: и вся логика потеряется за мусором очищающих проверок, либо все время страдать от TypeError. Не самый приятный выбор.
  • Писать классы для особых случаев использования. Например, базовый класс User с подклассами для ошибок типа UserNotFound и MissingUser. Такой подход вполне можно использовать в некоторых конкретных ситуациях, таких как AnonymousUser в Django, но обернуть все возможные ошибки в классы нереально. Потребуется слишком много работы, и доменная модель станет невообразимо сложной.
  • Использовать контейнеры, чтобы обернуть полученное значение переменной или ошибки в обертку и дальше работать уже со значением  контейнера. Вот почему мы создали проект @dry-python/return. Чтобы функции возвращали что-то осмысленное, типизированное и безопасное.

Вернемся к примеру с делением, который при возникновении ошибки возвращает 0. Можем ли мы явно указать, что выполнение функции не прошло успешно, не возвращая конкретное числовое значение?

from returns.result import Result, Success, Failure

def divide(first: float, second: float) -> Result[float, ZeroDivisionError]:
    try:
        return Success(first / second)
    except ZeroDivisionError as exc:
        return Failure(exc)

Заключим значения в одну из двух оберток: Success или Failure. Данные классы наследуются от базового класса Result. Типы упакованных значений можно указать в аннотации возвращаемой функцией, например, Result[float, ZeroDivisionError] возвращает либо Success[float], либо Failure[ZeroDivisionError].

Что это нам дает? Больше исключения не исключительные, а представляют собой ожидаемые проблемы. Также оборачивание исключения в Failure решает вторую проблему: сложность определения потенциальных исключений.

1 + divide(1, 0)
# => mypy error: Unsupported operand types for + ("int" and "Result[float, ZeroDivisionError]")

Теперь их легко заметить. Если видите в коде Result, значит функция может выдать исключение. И вы даже заранее знаете его тип.

Более того, библиотека полностью типизирована и совместима с PEP561. То есть mypy предупредит вас, если вы попытаетесь вернуть что-то, что не соответствует объявленному типу.

from returns.result import Result, Success, Failure

def divide(first: float, second: float) -> Result[float, ZeroDivisionError]:
    try:
        return Success('Done')
        # => error: incompatible type "str"; expected "float"
    except ZeroDivisionError as exc:
        return Failure(0)
        # => error: incompatible type "int"; expected "ZeroDivisionError"

Как работать с контейнерами?


Есть два метода:

  • map для функций, которые возвращают обычные значения;
  • bind для функций, которые возвращают другие контейнеры.

Success(4).bind(lambda number: Success(number / 2))
# => Success(2)

Success(4).map(lambda number: number + 1)
# => Success(5)

Прелесть в том, что такой код защитит вас от неудачных сценариев, поскольку .bind и .map не выполнятся для контейнеров c Failure:

Failure(4).bind(lambda number: Success(number / 2))
# => Failure(4)

Failure(4).map(lambda number: number / 2)
# => Failure(4)

Теперь можно просто сконцентрироваться на правильном процессе выполнения и быть уверенным, что неверное состояние не сломает программу в неожиданном месте. И всегда есть возможность определить неверное состояние, исправить его, и вернуться обратно к задуманному пути процесса.

Failure(4).rescue(lambda number: Success(number + 1))
# => Success(5)

Failure(4).fix(lambda number: number / 2)
# => Success(2)

В нашем подходе «все проблемы решаются индивидуально», и «процесс выполнения теперь прозрачен». Наслаждайтесь программированием, которое едет как по рельсам!

Но как развернуть значения из контейнеров?


Действительно, если вы работаете с функциями, которые ничего не знают про контейнеры, вам нужны именно сами значения. Тогда можно использовать методы .unwrap() или .value_or():

Success(1).unwrap()
# => 1

Success(0).value_or(None)
# => 0

Failure(0).value_or(None)
# => None

Failure(1).unwrap()
# => Raises UnwrapFailedError()

Подождите, мы должны были избавиться от исключений, а теперь выясняется, что все вызовы .unwrap() могут привести к еще одному исключению?

Как не думать об UnwrapFailedErrors?


Хорошо, давайте посмотрим, как жить с новыми исключениями. Рассмотрим такой пример: нужно проверить пользовательский ввод и создать две модели в базе данных. Каждый шаг может завершиться исключением, вот почему все методы обернуты в Result:

from returns.result import Result, Success, Failure

class CreateAccountAndUser(object):
    """Creates new Account-User pair."""

    # TODO: we need to create a pipeline of these methods somehow...

    def _validate_user(
        self, username: str, email: str,
    ) -> Result['UserSchema', str]:
        """Returns an UserSchema for valid input, otherwise a Failure."""

    def _create_account(
        self, user_schema: 'UserSchema',
    ) -> Result['Account', str]:
        """Creates an Account for valid UserSchema's. Or returns a Failure."""

    def _create_user(
        self, account: 'Account',
    ) -> Result['User', str]:
        """Create an User instance. If user already exists returns Failure."""

Во-первых, можно вообще не разворачивать значения в собственной бизнес-логике:

class CreateAccountAndUser(object):
    """Creates new Account-User pair."""

    def __call__(self, username: str, email: str) -> Result['User', str]:
        """Can return a Success(user) or Failure(str_reason)."""
        return self._validate_user(
            username, email,
        ).bind(
            self._create_account,
        ).bind(
            self._create_user,
        )

   # ...

Все сработает без каких-либо проблем, не вызовутся никакие исключения, потому что не используется .unwrap(). Но легко ли читать такой код? Нет. А какая есть альтернатива? @pipeline:

from result.functions import pipeline

class CreateAccountAndUser(object):
    """Creates new Account-User pair."""

    @pipeline
    def __call__(self, username: str, email: str) -> Result['User', str]:
        """Can return a Success(user) or Failure(str_reason)."""
        user_schema = self._validate_user(username, email).unwrap()
        account = self._create_account(user_schema).unwrap()
        return self._create_user(account)

   # ...

Теперь данный код отлично читается. Вот как .unwrap() и @pipeline работают вместе: всякий раз, когда какой-либо метод .unwrap() завершается неудачей и Failure[str], декоратор @pipeline ловит её и возвращает Failure[str] в качестве результирующего значения. Вот так я предлагаю удалить все исключения из кода и сделать его действительно безопасным и типизированным.

Оборачиваем все вместе


Хорошо, теперь применим новые инструменты к примеру с запросом к HTTP API. Помните, что каждая строка может вызвать исключение? И нет никакого способа заставить их вернуть контейнер с Result. Но можно использовать декоратор @safe, чтобы обернуть небезопасные функции и сделать их безопасными. Ниже два варианта кода, которые делают одно и то же:

from returns.functions import safe

@safe
def divide(first: float, second: float) -> float:
     return first / second


# is the same as:

def divide(first: float, second: float) -> Result[float, ZeroDivisionError]:
    try:
        return Success(first / second)
    except ZeroDivisionError as exc:
        return Failure(exc)

Первый, с @safe, проще и лучше читается.

Последнее, что нужно сделать в примере с запросом к API – добавить декоратор @safe. В итоге получится такой код:

import requests
from returns.functions import pipeline, safe
from returns.result import Result

class FetchUserProfile(object):
    """Single responsibility callable object that fetches user profile."""

    #: You can later use dependency injection to replace `requests`
    #: with any other http library (or even a custom service).
    _http = requests

    @pipeline
    def __call__(self, user_id: int) -> Result['UserProfile', Exception]:
        """Fetches UserProfile dict from foreign API."""
        response = self._make_request(user_id).unwrap()
        return self._parse_json(response)

    @safe
    def _make_request(self, user_id: int) -> requests.Response:
        response = self._http.get('/api/users/{0}'.format(user_id))
        response.raise_for_status()
        return response

    @safe
    def _parse_json(self, response: requests.Response) -> 'UserProfile':
        return response.json()

Подведем итог, как избавиться от исключений и обезопасить код:

  • Использовать обертку @safe для всех методов, которые могут вызвать исключение. Она изменит тип возвращаемого значения функции на Result[OldReturnType, Exception].
  • Использовать Result как контейнер, чтобы перенести значения и ошибки в простую абстракцию.
  • Использовать .unwrap(), чтобы развернуть значение из контейнера.
  • Использовать @pipeline, чтобы последовательности вызовов .unwrap легче читались.

Соблюдая эти правила мы можем сделать ровно то же самое — только безопасно и хорошо читаемо. Решены все проблемы, которые были с исключениями:

  • «Исключения трудно заметить». Теперь они обернуты в типизированный контейнер Result, что делает их совершенно прозрачными.
  • «Восстановление нормального поведения на месте невозможно». Теперь можно смело делегировать процесс восстановления вызывающей стороне. На такой случай есть .fix() и .rescue().
  • «Последовательность исполнения неясна». Теперь они едины с обычным бизнес-потоком. От начала и до конца.
  • «Исключения не являются исключительными». Мы знаем! И мы ожидаем, что что-то пойдет не так и готовы ко всему.

Варианты использования и ограничения


Очевидно, не получится использовать такой подход во всем вашем коде. Будет слишком безопасно для большинства повседневных ситуаций и несовместимо с другими библиотеками или фреймворками. Но важнейшие части вашей бизнес-логики вы должны писать именно так, как я показал, чтобы обеспечить правильность работы вашей системы и облегчить поддержку в будущем.

Тема заставляет задуматься или даже кажется холиварной? Приходите на Moscow Python Conf++ 5 апреля, обсудим! Кроме меня там будет Артём Малышев – основатель проекта dry-python и core-разработчик Django Channels. Он расскажет еще больше интересного про dry-python и бизнес-логику.
+26
23.6k 128
Comments 148
Similar posts
Top of the day