Для кого статья
Вы уже написали свои первые 1000 строк кода и сейчас хотите сделать их понятнее, потому что внесение изменений занимает столько-же времени, сколько написать заново, но советы из ООП, SOLID, clean architecture и т.д. непонятны вам.
О чем статья
Эта статья - не объяснение принципов ООП, SOLID своими словами, а попытка создать промежуточный уровень между никакой и чистой архитектурами. 100% советы будут накладываться друг на друга и перефразировать SOLID, но так даже лучше.
От кого статья
Я обычный разработчик. Конечно, не гуру разработки, но кому, как не мне, помнить о проблемах, с которыми сталкивался когда только начинал свой путь.
Отказ от ответственности
Уверен, каждый пункт из статьи может быть предметом спора, но на то это и вольный пересказ. Вся статья идет под эмблемой "Лучше применить такую архитектуру, чем не применять вообще никакой".
Формат статьи - наводящие советы / вопросы.
Содержание:
К чему относится функция.
Как вы будете модернизировать одну функцию, не затрагивая другую.
На сколько логических частей я могу раздробить мою функцию?
Повторяющиеся слова в названиях функций / переменных.
Что является центральными объектами вашего кода.
На какие аналогичные функции может быть заменена ваша функция?
Как выглядит идеальный псевдокод вашей функции?
Обращайте внимание на формат данных.
Отдавайте предпочтение пространству имен, а не ветвлениям.
Скрывайте постоянные аргументы функции внутри отдельной функции.
Совет номер 1
Когда пишете код и не знаете как его организовать - задайте себе вопрос следующего типа:
“К чему относится моя функция?” / “К чему относится этот функционал?” / “За что отвечает этот функционал?”
Попробуйте мысленно проставить хэштеги вашей функции:
#обработка, #валидация, #проверка, #БД, #отображение.
Безусловно, запрос к БД может являться частью обработки, но он же в будущем может использоваться и для другой функции,
даже если пока написан только для этой.
Ремарка: Вообще в разработке уже есть устоявщийся набор таких тегов, некоторые из них: validate, check, get, set, show, load, send. Сюда же входит CRUD и HTTP заголовки.
Совет номер 2
Подумайте, что может быть причиной модернизации вашей функции, что заставит ее измениться.
Небольшие изменения не должны существенно затрагивать другие функции.
Например, вам нужно поменять у функции чтения контактов запрос к базе данных,
это не должна быть та-же функция, что и отправка или отображение контактов.
Стремитесь к ситуации, когда добавление нового функционала сводится к созданию нового метода у класса, и возможно, появлению пары новых аргументов у функции / цепочки вызова функций.
Бывают задачи, которые требуют значительных изменений, но это происходит редко.
Совет номер 3
“На какие части я бы разделил этот функционал?”, “На какие еще подфункции можно разделить код этой функции?”.
Рекурсивно задавая себе этот вопрос, вы придете к моменту, когда функция становится "атомарной", ее функционал логически больше не имеет смысла дробить (не путать с атомарной операцией).def get_product_price():
… # Здесь код
Даже ничего не зная об этой функции и требованиях к задаче, я предполагаю,
что процесс вычисления цены можно разбить на N этапов, к примеру:
Применить общую формулу процентов. - Та самая атомарная операция.
Раздробить это действие уже не получится.Применить ограничения к цене.
Товар не может стоить меньше, чем похожий товар из прошлогодней коллекции и т.п.Применить скидку. Скидка не может быть отрицательной, больше 100%, и т.п.
Две функции ниже могут быть общими для всего проекта и находиться в модуле "util.py".
Классы могут использовать эти функции под разными и аргументами, делая обертку вокруг них.
Примечание: не все программисты одобряют такой модуль в проекте, я встречал статью с критикой такого подхода, но на этом этапе это вполне оправдано.
# (Не очень удачное название)
def calculate_percentage_number(number: int, percentage: int) -> int:
return number * (percentage / 100)
def limit_number(number: int, min_: int, max_: int, ) -> int:
"""Вернет число или ограничение числа."""
return min(max(min_, number), max_)
def get_product_price(price: int, discount: int, ) -> int:
min_discount = 10 # Лучше поместить внутрь класса
max_discount = 20 # Лучше поместить внутрь класса
discount = calculate_percentage_number(number=price, percentage=discount, )
discount = limit_number(
number=discount,
min_=min_discount,
max_=max_discount,
)
discounted_price = price - discount
if 0 < discounted_price < price:
return discounted_price
# Игнорируем скидку в случае ошибки.
logger.log(DiscountError)
return price # Более разумным будет применить базовую скидку.Обратите внимание как меняются имена переменных в зависимости от контекста, price -> number, discount -> percentage.
Подсказка: Если функцию без труда можно записать в функциональном стиле
(когда наша функция в качества аргумента вызывает другую функцию) - то к ней применимо правило дробления.
Разумеется, не нужно сразу дробить ваш функционал на 1000 частей, далеко не все ва�� понадобится (принцип YAGNI), но вы должно быть к этому готовым.
Подсказка: Для процентов можно создать отдельный тип, что бы не путать с обычными числами.
Совет номер 4
Обратите внимание на повторяющиеся "user" в названии функций.
def get_user_data():
...
def notify_user_friends():
...
def create_user_post():
...Такие повторения явный признак, что пора создавать класс для повторяющегося слова:
В пайтоне класс это не только объект, который должен быть создан много раз, но и пространство имен (удобная организация функций).
Ремарка: Лично я считаю, что инструкция "class" в пайтоне перегружена,
это и пространство имен, и структура данных, и сами классы собственно.
Лучше будет:
class User():
def get_data():
...
def notify_friends():
...Совет номер 5
ООП вращается вокруг объектов / сущностей / моделей, которые определяет бизнес / работодатель.
В проекте условного мессенджера класс "сообщение" будет большим,
а в проекте про такси класс "сообщение" будет куда меньше, зато будет большой класс "автомобиль".
Определите для себя, какие классы в вашем проекте центральные и наполняйте их методами.
Ремарка: возможно в ближайшем будущем какой-нибудь ИИ создаст универсальную структуру для каждого объекта на земле и в каждом проекте будут одинаковые объекты, но скорее всего ИИ просто научится программировать лучше нас, без всякой организации кода :)
На моей практике начало любого проекта это небольшой набор стандартных функций и классов, например:View, DB, User, Mail. Они используются для общих целей.
Очень быстро в сервисе такси класс Taxi перерастет остальные классы и будет иметь собственный метод приветствия.
def some_func(user: User):
...
View.say_hello(name=user.name, ) # Общее приветствие.
taxi.say_hello(name=user.name, ) # Приветствие от конкретного такси.
...Это может выглядеть подозрительно, но преимуществ у такого подхода больше чем недостатков.
Общий метод say_hello помещается в общий класс View,
а вот taxi_say_hello в класс Taxi.
Это не самое гибкое решение, но на практике такого подхода должно хватать надолго для не крупных проектов.
Ремарка: насколько я знаю, подход MVC (Model-View-Controller) имеет как сторонников, так и противников.
Поэтому в первую очередь все должно зависеть от требований к проекту.
Совет номер 6
На что я МОГУ заменить свою функцию / класс?
Допустим, у вас есть класс user и у него есть метод отправки данных по почте.
Для этого вы используете какой-либо фреймворк.
В какой-то момент вы решили сменить этот фреймворк.
Старый фреймворк:
recipient = BarMailAgent.serialize_recipient(recipient=...)
FooMailAgent.send(text=self.get_txt_data(), recipient=..., retry=3, delay=10)Новый фреймворк:
# recipient serialization already inside the method
BarMailAgent.send(message=self.get_txt_data(), email=..., attempts=3, interval=10)FooMailAgent1 и BarMailAgent делают примерно одно и тоже, но быстро заменить в коде одно на другое не получится, разные аргументы, разный набор действий.
Лучше создать универсальный класс / метод именно для вашего кода (учитывая специфику), который будет принимать заранее известные аргументы, а дальше уже передавать их какому-либо методу.
class User:
def send_email(self, version: int = 1, arguments=...):
if version == 1:
recipient = BarMailAgent.serialize_recipient(recipient=...)
FooMailAgent.send(text=self.get_txt_data(), recipient=..., retry=3, delay=10)
else:
# recipient serialization already inside the method
BarMailAgent.send(message=self.get_txt_data(), email=..., attempts=3, interval=10)Совсем безболезненно заменить фреймворк трудно, но это явно облегчит задачу.
Совет номер 7
Напишите сперва идеальный псевдокод, как в идеальном случае должен выглядеть ваш код, пример:
def register(user: User):
user.validate()
user.save()
logger.log(event=events.registration, entity=user, )
mail.send(event=events.registration, recipient=user.email, )
notifier.notify(event=events.registration, recipients=user.possible_friends, )
statistics.add_record(event=events.registration, recipient=user.email,)Ремарка: Я пользуюсь правилом: 1 строчка - 1 действие.
Это сделано для того, что бы можно было быстро пробежаться глазами.
Когда кода много - основная причина ошибок это невнимательность, и хорошо, если тесты покроют этот случай.
Можно запросто забыть оправить почту, уведомить друзей и т.п., особенно, когда набор действий постоянно меняется от руководства и команды аналитики.
Где-то снаружи код может выглядеть так:
def register_handler(update, context):
try:
events.register(user=context.user)
except Exceptions.Registration.ValidationError:
# Где-то внутри будет: "400. Увы, вы ввели некорректные данные, мы не можем сохранить такого пользователя."
events.fails.registration(user=user)
except Exceptions.Registration.DbError:
# Где-то внутри будет: "503. Внутренняя ошибка, приносим свои извинения."
events.fails.registration(user=user)
Должен отметить, что этот код вызывает у меня самого несколько некритич��ых сомнений:
Должен ли блок
try/exceptбыть снаружи метода "register"?Можно ли упаковать "
user" в "events.registration"?Нужно ли передавать целиком пользователя или только необходимые атрибуты?
С 1-ой стороны это делает код очевиднее, с другой - при изменении необходимого набора - придется больше писать.
Я для себя пришел к такому компромиссу:
Если атрибут неотъемлемая часть объекта (почта, телефон, айди) - передаем объект целиком, иначе - только атрибут.
В любом случае, это неплохой вариант для архитектуры.
Совет номер 8
Обращайте внимание на формат данных.
Какой-нибудь фреймворк может передавать на вход вашим обработчикам объект под названием event / update.
Функции проверки из этого объекта нужен только атрибут "user",
а базе данных из этого объекта нужен только атрибут "ID" или "role".
Т.е. условная проверка прав доступа может выглядеть так:update / event - передано в обработчик.update.user - передано в функцию проверки.user.id - передано в запрос к базе данных.
Не нужно в функцию проверки передавать update целиком, таким образом ваша функция обработки сможет быть использована сразу для нескольких фреймворков. Именно это позволяет мне легко сменить мой фреймворк при желании.
Мои функции валидации / проверки не зависят от формата данных предоставленных фреймворком.
Совет номер 9
Отдавайте предпочтение пространству имен, а не ветвлениям.
Каждая ветка if/else усложняет код, создает потенциальную возможность ошибки и усложняет тестирование.
Ремарка: в архитектуре существуют метрики сложности кода, чрезмерное ветвление ухудшает показатели.
Теоретически, все API можно написать на ветвлениях, но не нужно:
def gloabal_handler(request):
if request.url == 'settings':
...
elif request.url == 'photos':
...Отдавайте ветвления на откуп ЯП, ведь в конечном счете пространство имен можно представить как:
for key in namespace:
if key == dot_value:
return keyРемарка: Лично я, кроме обычного кейсов, использую компромиссный подход, применяю ветвление если от него зависят аргументы функции, но не сама функция (и не будет зависеть в будущем).
(функцию тоже можно представить как аргумент, но это часто может усложнить код, оставьте это для декораторов).
Совет номер 10
Скрывайте постоянные аргументы функции вну��ри отдельной функции.
Здесь аргумент 'hello' всегда одинаковый, он не несет никакой полезной нагрузки при анализе кода, в 9 / 10 случаях при чтении кода мы НЕ хотим концентрировать свое внимание на том, какой текст отправляется, но код ниже заставляет нас это делать.
Легко читаемая функция, но может быть еще проще.
# Используйте переменную-константу вместо 'hello'
bot.send_message(text='hello', recipient=user.id, )Краткость - сестра таланта.
View.say_hello(recipient=recipient, ) # bot.send_message внутриБлагодарю за внимание.
