Расскажу, что считаю важным держать в голове, чтобы написать годный код.
Ну, и почему именно так.
Какая-нибудь синтетическая задача
У нас есть какая-то потребность - например, нам дали контракт с суммой в 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)
Мой опыт подсказывает, что работать с таким кодом будет приятней.
Однажды приняв эту модель в своей голове, расширять и модифицировать проекты - даже старые! - будет многократно легче.
И, наверное, это основной посыл, который я хотел донести.
Когда пишете что-то - пишите для себя будущего, для коллег, не торопясь и не слишком срезая углы ☝
А в заключение скажу, что я не настоящий разработчик, а только им притворяюсь. Поэтому не претендую на истину в последней инстанции.
Можно лучше, можно иначе, можно вообще не.
Выбирать, как и сталкиваться с последствиями, всегда тебе, а не кому-то другому.