Расскажу, что считаю важным держать в голове, чтобы написать годный код.

Ну, и почему именно так.

Какая-нибудь синтетическая задача

У нас есть какая-то потребность - например, нам дали контракт с суммой в Gross и надо понять, сколько же монеток упадёт нам на счёт после уплаты подоходного налога.

Эта простая задача, в моём случае её можно свести к применению элементарной формулы:

Net = Gross - 13%

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

Поэтому всегда важно иметь в голове большую картину, чтобы не сделать в первых же подходах нерасширяемой херни 🤓

Как решать эту задачу?

Ну, подход “в лоб” - это выписать действия по порядку:

  • Берем сумму Gross в контракте
  • Вычитаем подоходный налог
  • Выводим сумму Net

Так я делал очень долгое время.

Попробуем записать

Сделаем простую функцию, которая будет выполнять поставленную задачу:

def gross_to_net_calculator(gross):
    return (1.00 - 0.13) * gross

В данном случае код предельно простой, но даже в нём есть пространство для ошибки.

Проблема - Динамическая типизация

Python динамически типизирован.

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

Так что, мы не можем быть уверены, что на вход этой функции будет подано именно значение типа float. Может приехать string, или вообще какой-нибудь datetime.

С этим надо что-то делать.

Проверки и LBYL

Сначала все просто обкладывались проверками:

if type(param) != str:
    raise MyError

А что, если не тот тип? А если не то значение? А если в списке не столько элементов?

Этот подход известен под акронимом LBYL: “Look Before You Leap”, дословно “смотри, куда прыгаешь”.

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

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

Исключения и EAFP

Выше мы поняли, что быть параноиком - весьма затратно по ресурсам.

Давайте сменим подход на другой, будем делать всё, что нам нужно, а вот уже случаи ошибок как-то дополнительно обрабатывать.

Например:

try:
    proces(param)
except ValueError:
    logging.warning("ой")

Эта парадигма известна как EAFP: “It’ Easier to Ask for Forgiveness than Permission”, дословно “проще попросить прощения, чем разрешения”.

Согласно этому пути, в нашу функцию мы должны бы добавить что-то такое:

def gross_to_net_calculator(gross):
    try:
        # что-то делаем
        pass
    except Exception:
        # как-то выходим из ситуации, если "не получилось"
        pass

Вообще, ловить базовый класс Exception - это всегда стрельба из пушки по воробьям, мы как бы обработаем любую ошибку, какой бы она ни была.

Хорошим тоном будет уточнять наши намерения:

try:
    # пробуем
    pass
except TypeError:
    # если не тот тип значения
    pass
except ValueError:
    # если не то значение
    pass

Типов исключений есть много и на любой вкус.

И, конечно, можно писать собственные, просто наследуясь от объекта Exception:

class MySpecialError(Exception):
    pass

Аннотации типов

Кроме того, для обхода проблемы с типами существует модуль typing, который позволяет вводить в код аннотации, например:

def gross_to_net_calculator(gross: float):
    return (1.00 - 0.13) * gross

Эти аннотации всё также не будут проверяться интерпретатором, но они могут быть проверены в вашей IDE при написании кода.

Например, это умеет делать проект mypy.

Буквально, ты пишешь код, а у тебя кусочек подсвечивается:

– Эй, здесь должно быть другое значение, это не подходит!

Круто? Круто.

Проблема - Логически некорректные данные

Мы улучшили нашу функцию, она стала как-то мягонько сообщать о проблемах:

def gross_to_net_calculator(gross):
    try:
        net = (1.00 - 0.13) * gross

    except Exception:
        logging.warning(f'что-то пошло не так со значением {type({gross})} "{gross}"')
        net = 0.0

    return net

Теперь она, вроде как, надежна.

Ан нет.

Кто-то может передать абсурдную сумму в -25_000.0, и мы опять будем получать что-то странное:

>>> gross_to_net_calculator(-25_000.0)
-21750.0

Надо проверять и подобные вещи тоже.

Для этого существует понятие валидации данных, оно находится на стороне LBYL-подхода.

Да, ровные пацаны не выбирают один подход на всю жизнь, а балансируют между ними в собственных интересах 😎

В нашем случае валидация будет заключаться в, например, такой проверке:

def gross_to_net_calculator(gross):
    if gross < 0.0:
        raise ValueError

  # ...

Это, в целом, топорный подход, его будет сложно расширять.

Лучше бы иметь отдельный объект gross и у него метод validate(), который всё провалидирует. Но про это поговорим ниже.

Проблема - Человечья ошибка

Мы стали проверять пару мест, где проблемы наиболее вероятны.

Но люди не роботы, люди ошибаются.

А если мы ещё и работаем в команде - придётся буквально расставить капканов, которые поймают изменения, ведущие нас в пропасть.

Например, коллега случайно заменил в каких-то своих целях * на /, а потом случайно закоммитил. Shit happens.

Это реальная практика тестирования, она называется Mutation testing 🧪

В коде программно меняются значения и операторы. И если с этими мутантами тесты всё ещё проходят - поздравляем, у вас слабые тесты 💩

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

Для этого я сейчас воспользуюсь модулем doctest, который позволяет писать тесты прямо в коде.

Точнее, в комментариях к коду.

def gross_to_net_calculator(gross):
    """Function to get Net amount from Gross one.

    >>> gross_to_net_calculator(100.0)
    87.0

    >>> gross_to_net_calculator(-500.0)
    Traceback (most recent call last):
        ...
    ValueError

    >>> gross_to_net_calculator(0.0)
    0.0

    >>> gross_to_net_calculator("")
    Traceback (most recent call last):
        ...
    TypeError

    >>> gross_to_net_calculator(None)
    Traceback (most recent call last):
        ...
    TypeError
    """

    pass

Дальше мы просто вызовем модуль тестирования на этот файл:

$ python -m doctest main.py
<No errors>

Задумайтесь, насколько это круто, и как проще будет оставаться в состоянии потока!

Даже в другой файл не надо переключаться, хех.

Кстати, моя мечта - это вообще всю работу делать в IDE, без чатиков и таск-трекеров 🧑‍💻

Думаем о разработчике

Функция перестала падать в неожиданных местах, а если уж падает, то сообщает, почему именно.

Считаем, что наша жизнь стала лучше.

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

В питонячьем мире, например, есть модуль pydantic, который как бы фреймворк для статической типизации.

Он имеет из коробки базовую сущность BaseModel, от которой удобно наследовать ваши dataclass-ы, получать из коробки поддержку всяких там Validator-ов, ну и прочие ништяки.

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

В этом смысле классно сразу писать на строго типизированных языках типа Golang, они как бы не оставляют иного выбора. Придётся сразу не лениться и нормально описывать объекты 🧑‍💻

Если попробовать создать тот же процесс вычисления Net-суммы в свете всех замечаний и ссылок выше, то теперь мы скорее опишем его так:

  • Есть сущность Contract, из которой можно получать сумму gross
  • Есть сущность Salary, выражающая собой эту самую зарплату
  • Есть сущность Tax, выражающая налоги
  • Есть сущность GrossToNetCalculator, которая может применять Tax-поправку к Salary, переводя сумму gross в net

Это драматически меняет дело.

Минуточку, это что, ООП?

Ну, да.

Код, ориентированный на объекты, будет выглядеть сильно иначе:

from typing import Optional
from pydantic import BaseModel, validator

class Contract(BaseModel):
    details: str

    def __get_gross_from_details(self) -> float:
        # somehow processing self.details ...
        return gross

    def provide_gross(self) -> float:
        return self.__get_gross_from_details()

class Salary(BaseModel):
    # Amount of money before taxes
    gross: float
    net: Optional[float]

    @validator("gross")
    def gross_must_be_positive(cls, v):
        if v < 0:
            raise ValueError(f'value "{v}" must be positive')
        return v

    def load_from_contract(c: Contract):
        gross = c.provide_gross()

        return Salary(gross)

class Tax(BaseModel):
    portion: float = 0.13

    @validator("portion")
    def portion_must_represent_percentage(cls, v):
        if v < 0.0 or v > 1.0:
            raise ValueError(f'value "{v}" must be in (0.0, 1.0) range')
        return v

class GrossToNetCalculator:
    def process(s: Salary, t: Tax) -> float:
        """Function to get Net amount from Gross one.

        >>> s = Salary(gross=100.0)
        >>> t = Tax()

        >>> GrossToNetCalculator.process(s, t)
        87.0
        """

        if s.net is None:
            s.net = s.gross * (1.00 - t.portion)

      return s.net

И вызывать это всё мы будем иначе:

tax = Tax()

salary = Salary.load_from_contract(contract)

calc = GrossToNetCalculator(salary, tax)

Мой опыт подсказывает, что работать с таким кодом будет приятней.

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

И, наверное, это основной посыл, который я хотел донести.

Когда пишете что-то - пишите для себя будущего, для коллег, не торопясь и не слишком срезая углы ☝

А в заключение скажу, что я не настоящий разработчик, а только им притворяюсь. Поэтому не претендую на истину в последней инстанции.

Можно лучше, можно иначе, можно вообще не.

Выбирать, как и сталкиваться с последствиями, всегда тебе, а не кому-то другому.