Правильная структуризация на Python + 13 практик для написания чистого кода
Привет! Ты открыл совместный материал от Max Wayld и Neko, благодаря которому ты сможешь делать свой код более чистым и понятным, а также поймешь как правильно всё это дело нужно организовывать.
Зачем тебе вообще нужно это знать? Код ведь может работать и без этого? Ну... Просто чтобы у тебя не возникало подобных ситуаций:
Само собой это не обязательные фундаментальные знания для всех и каждого, но всё-таки некоторым кодерам это может облегчить жизнь. Особенно хорошо если ты сразу разберешься в этом, пока ты ещё новичок.
Навигатор:
13 практик для написания чистого кода
1. Catching Exceptions
Это верный способ создать проблемы с твоим кодом:
# очень плохая практика try: do_something() except: pass # Способ немного получше (включающий протоколирование), но все равно плохая практика: try: do_something() except Exception: logging.exception() # Хотя протоколирование само по себе является хорошей практикой # Когда мы не используем Exception, мы будем перехватывать события по типу выхода из системы. # Итак, использование Exception означает, что мы только перехватываем исключения. # Лучший способ: try: do_something() except ValueError: logging.exception() # некоторый код для корректной обработки твоего исключения, если это необходимо
Здесь мы использовали особый тип исключений - ValueError
2. Name Casing
# использование camelCase не является общепринятым в python def isEmpty(sampleArr): ... # вместо этого лучше использовать snake_case def is_empty(sample_arr): ...
3. Chained comparison operators
В Python существует несколько способов сравнения:
# Не делай этого: if 0 < x and x < 10: print('x is greater than 0 but less than 10') # Вместо этого делай следующее: if 0 < x < 10: print('x is greater than 0 but less than 10')
4. Mutable default arguments
# неверный путь! # это верный способ получить непреднамеренные ошибки в твоем коде def add_fruit(fruit, box=[]): box.append(fruit) return box # верный путь! # рекомендуемый способ обработки изменяемых аргументов по умолчанию: def add_fruit(fruit, box=None): if box is None: box = [] box.append(fruit) return box
Подробнее об "изменяемых аргументах по умолчанию" можно узнать здесь.
5. String Formatting
# избегай этого способа: # %-formatting name = "James Bond" profession = "Secret Agent" print("Hello, %s. You are a %s." % (name, profession)) # способ немного лучше: # str.format() print("Hello, {}. You are a {}.".format(name, profession)) # коротко, четко и быстро: # f-strings print(f"Hello, {name}. You are a {profession}.")
Подробнее про строки и в особенности f-strings можно узнать здесь.
6. Top-level script environment
Это выполняется только если он запускается как скрипт, а не как модуль:
# Filename: run.py if __name__ == '__main__': print('Hello from script!')
$ python run.py $ Hello from script!
"Hello from script!" не будет выводиться, если модуль импортирован в любой другой модуль.
Как и зачем нужно использовать if __name__ == '__main__'
ты можешь узнать из этого ру видео-гайда на 10 минут: https://youtu.be/5r6dnmEqqG0
7. Conditional expressions
if x < 10: return 1 else: return 2
return 1 if x < 10 else 2
8. Iterating over an iterator
Не обязательно перебирать индексы элементов в итераторе, если они тебе не нужны. Ты можешь выполнять итерацию непосредственно над элементами.
Это делает твой код более "Python`ичным".
list_of_fruits = ["apple", "pear", "orange"] # плохая практика for i in range(len(list_of_fruits)): fruit = list_of_fruits[i] process_fruit(fruit) # хорошая практика for fruit in list_of_fruits: process_fruit(fruit)
9. Indexing/Counting during iteration
# Не делай этого: index = 0 for value in collection: print(index, value) index += 1 # И вот такое тоже не следует делать: for index in range(len(collection)): value = collection[index] print(index, value) # Категорически не делай этого! index = 0 while index < len(collection): value = collection[index] print(index, value) index += 1 # Вместо этого используй `enumerate()` for index, value in enumerate(collection): print(index, value)
10. Using context managers
Python предоставляет менеджеры контекста, которые управляют накладными расходами на инициализацию и очистку ресурсов и позволяют тебе сосредоточиться на реализации.
Например, в случае чтения файла тебе не нужно беспокоиться о том, чтобы закрывать файл вручную.
d = {"foo": 1} # плохая практика: f = open("./data.csv", "wb") f.write("some data") v = d["bar"] # KeyError # f.close() никогда не выполняется, что приводит к проблемам с памятью f.close() # хорошая практика: with open("./data.csv", "wb") as f: f.write("some data") v = d["bar"] # python все равно выполняет f.close(), даже если возникает исключение KeyError
11. Using set for searching instead of a list
s = set(['s', 'p', 'a', 'm']) l = ['s', 'p', 'a', 'm'] # подходит для небольшого количества элементов def lookup_list(l): return 's' in l # O(n) # подходит для большого количества элементов def lookup_set(s): return 's' in s # O(1)
Наборы реализуются с помощью хэша в python, что делает поиск элемента быстрее (O(1))
по сравнению с поиском в списке (O(n))
12. using * while importing a module
Импорт всегда должен быть конкретным. Импорт из модуля - это очень плохая практика, которая загрязняет пространство имен.
# плохая практика: from math import * x = ceil(x) # хорошая практика: from math import ceil x = ceil(x) # мы знаем откуда взялся ceil
13. using items() for iterating a dictionary
d = { "name": "Aarya", "age": 13 } # не делай это: for key in d: print(f"{key} = {d[key]}") # лучше используй это: for key,val in d.items(): print(f"{key} = {val}")
2. Правильная структуризация кода на Python
Python отличается от таких языков как C# или Java, где тебя обязывают называть классы по имени файла, в котором они находятся.
На данный момент Python является одним из самых гибких языков, с которыми мне приходилось иметь дело, а всё слишком гибкое означает повышенную вероятность на неправильные решения.
Многие решения которые легко реализовать, могут привести к созданию кода, который чрезвычайно сложно поддерживать. Но если ты знаешь что делаешь, то все последствия гибкости Python окажутся не такими уж и плохими. А если не знаешь - эта статья поможет тебе.
Структуризация твоих Python-проектов
Сначала давай сосредоточимся на структуре директорий проекта, именовании файлов и организации модулей. Рекомендуется хранить все файлы модулей в директории src
, а тесты - в поддиректории tests
в этой директории. Пример:
<project> ├── src │ ├── <module>/* │ │ ├── __init__.py │ │ └── many_files.py │ │ │ └── tests/* │ └── many_tests.py │ ├── .gitignore ├── pyproject.toml └── README.md
Где <module>
- это твой основной модуль. Если ты не знаешь какой именно модуль является главным - подумай о том, что пользователи твоего проекта будут устанавливать командой pip install
, а также, как по твоему мнению должна выглядеть команда import
для модуля.
Зачастую главный модуль имеет то же название, что и весь проект, однако это не является правилом.
Обоснование каталога src
Я видел множество проектов которые работали по другому. Например, в проекте может отсутствовать директория src
, а все модули будут просто лежать в его корневой директории:
non_recommended_project ├── <module_a>/* │ ├── __init__.py │ └── many_files.py │ ├── .gitignore │ ├── tests/* │ └── many_tests.py │ ├── pyproject.toml │ ├── <module_b>/* │ ├── __init__.py │ └── many_files.py │ └── README.md
Неудобно когда нет никакого порядка из-за того что папки и файлы просто расположены по алфавиту, в соответствии с правилами сортировки объектов в IDE.
Основная причина, по которой рекомендуется пользоваться папкой src
заключается в том, чтобы активный код проекта был собран в одной директории, в то время как все настройки, параметры CI/CD и метаданные могли находиться за за пределами этой директории.
Единственный минус такого подхода заключается в том, что, без дополнительных усилий не получится воспользоваться в своём коде командой вида import module_a
. Ниже ты узнаешь как решить эту проблему.
Как называть файлы
Прежде всего стоит обговорить что вообще-то в Python нет такого понятия как "файлы". Это является популярным источником путаницы для новичков.
Если ты находишься внутри директории которая содержит файл __init__.py
, то это директория состоящая из модулей, а не из файлов.
Рассматривай каждый модуль как пространство имен.
Я говорю о "пространство имен", так как нельзя точно сказать, имеется ли в модуле множество функций, классов или констант. В нем может присутствовать практически всё что угодно, или лишь несколько сущностей пары видов.
Правило 2: Держите вещи вместе по мере необходимости
Это вполне нормально иметь несколько классов в одном модуле. Так и стоит организовывать код, если конечно эти классы связаны с модулем.
Выделяй классы в отдельные модули только в том случае, если модуль становится слишком большим, или если его разные части направлены на решение различных задач.
Ошибочно многие люди думают что это плохая практика, из-за некоторого опыта работы с другими языками, которые применяют обратное (например Java и C#)
Правило 3: присваивай модулям имена, представляющие собой существительные во множественном числе
Называй свои модули во множественном числе и называй их в деловом контексте.
Однако из этого правила есть исключения: модули могут называться core
, main.py
и т.д., чтобы обозначать что-то одно. Подбирая имена модулей, руководствуйся здравым смыслом, а если сомневаешься - придерживайся вышеприведённого правила.
Наглядный пример именования модулей:
В качестве примера покажу мой проект Google Maps Crawler. Он отвечает за сбор данных с Google Maps с помощью Selenium и их вывод (если тебе это интересно, подробнее про этот проект можно узнать тут)
Вот текущее состояние дерева проекта (тут выделены исключения из правила №3):
gmaps_crawler ├── src │ └── gmaps_crawler │ ├── __init__.py │ ├── config.py 👈 (Singular) │ ├── drivers.py │ ├── entities.py │ ├── exceptions.py │ ├── facades.py │ ├── main.py 👈 (Singular) │ └── storages.py │ ├── .gitignore ├── pyproject.toml └── README.md
Весьма естественным кажется такой импорт классов и функций:
from gmaps_crawler.storages import get_storage from gmaps_crawler.entities import Place from gmaps_crawler.exceptions import CantEmitPlace
Можно понять что в exceptions
может быть как один, так и множество классов исключений.
Именование модулей существительными множественного числа несет в себе следующую пользу:
Именование классов, функций и переменных
Некоторые кодеры утверждают что присваивать имена тем или иным вещам довольно муторно, но это становится не так сложно, если заранее определиться с правилами именования
1. Функции и методы должны быть глаголами
Функции и методы представляют собой действия, или нечто, выполняющее действия. Функция или метод это не просто нечто "существующее", это нечто "действующее".
Всё это можно чётко обозначить глаголами.
def get_orders(): ... def acknowledge_event(): ... def get_delivery_information(): ... def publish(): ...
def email_send(): ... def api_call(): ... def specific_stuff():
Здесь не совсем ясно - возвращают ли функции объект, позволяющий выполнить обращение к API, или они сами выполняют какие-то действия, например отправку письма.
Можно представить такой сценарий использования функции с неудачным именем:
email_send.title = "title" email_send.dispatch()
2. Переменные и константы должны быть существительными
Имена переменных и констант всегда должны быть существительными и никогда не должны быть глаголами (это позволяет чётко отделить их от функций)
plane = Plane() customer_id = 5 KEY_COMPARISON = "abc"
fly = Plane() get_customer_id = 5 COMPARE_KEY = "abc"
Если же переменная/константа является списком или коллекцией, делай ее множественной. Как пример:
planes: list[Plane] = [Plane()] # Даже если содержит всего один элемент customer_ids: set[int] = {5, 12, 22} KEY_MAP: dict[str, str] = {"123": "abc"# Имена словарей остаются существительными в единственном числе
3. Имена классов должны быть понятны сами по себе, но использовать для этого суффиксы - тоже нормально
Старайся отдавать предпочтение именам классов, которые понятны без дополнительных пояснений. При этом можно также использовать суффиксы по типу Service
, Strategy и
Middleware
, но только тогда, когда это крайне необходимо, чтобы прояснять их значения.
Давай классам имена в единственном числе. Имена во множественном числе напоминают имена коллекций элементов. Например, если я вижу имя orders
, то сразу предположу что это список или итерируемый объект.
Поэтому выбирая имя класса, напоминай себе о том, что после создания экземпляра класса в нашем распоряжении оказывается единственный объект.
Классы, представляющие сущности:
Классы, представляющие что-либо из бизнес-среды, должны называться в соответствии с названиями связанных с ними сущностей (и быть существительными!). Например: Order
, Sale
, Store
, Restaurant
и так далее.
Пример использования суффиксов:
Допустим, ты хочешь создать класс, отвечающий за отправку электронных писем. Если назвать его просто Email
, его назначение будет неясно.
Кто-то может подумать, что это может представлять некую сущность, например:
email = Email() # предполагаемый вариант использования email.title = "Title" email.body = create_body() email.send_to = "guilatrova.dev" send_email(email)
Такой класс следует назвать EmailSender
или EmailService
Соглашения об именах
По умолчанию следуй этим соглашениям об именовании:
Отступление о «приватных» методах
Если имя метода выглядит как __method(self)
(любой метод, имя которого начинается с двух символов подчёркивания), то Python не позволит внешним классам/методам вызывать этот метод обычным образом.
Для тех, кто также как и я пришёл в Python из C#, может показаться странным то, что метод класса нельзя защитить, но у Гвидо ван Россума есть достойная причина считать что на это есть веские основания:
"Мы тут все взрослые и ответственные люди"
Это значит что если ты знаешь, что не должен вызывать метод, тогда ты и не будешь этого делать - если только ты не уверен в своих действиях на 200%.
В конце концов, если ты и правда решишь вызвать некий приватный метод, то ты сделаешь для этого что-то неординарное (в C# это называется Reflection)
Поэтому давай своим приватным методам/функциям имена, начинающиеся с одного символа подчёркивания, указывающего на то, что они предназначены лишь для внутреннего использования, и просто смирись с этим...
Когда нужно создавать функцию, а когда класс?
Если ты будешь следовать приведенным выше рекомендациям, у тебя появятся понятные модули, а понятные модули - это эффективный способ организации функций:
from gmaps_crawler import storages storages.get_storage()# Это похоже на класс, но экземпляр не создаётся, а имя - это существительное во множественном числе storages.save_to_storage() # Так может называться функция которая хранится в модуле
Иногда внутри модуля можно выделить подмножества связанных функций. В таких случаях подобные функции имеет смысл выделять в класс.
Пример группировки различных подмножеств функций. Предположим что у нас имеется уже встречавшийся нам модуль storages
с 4-мя функциями:
def format_for_debug(some_data): ... def save_debug(some_data): """Выводит данные на экран""" formatted_data = format_for_debug(some_data) print(formatted_data) def create_s3(bucket): """Создаёт s3 bucket, если он не существует""" ... def save_s3(some_data): s3 = create_s3("bucket_name") ...
S3 - это облачное хранилище для любых данных, предоставляемое Amazon (AWS). Это как Google Drive для программного обеспечения.
Я вижу две группы функций и не вижу причин держать их в разных модулях, так как они кажутся маленькими. Поэтому я бы хотел чтобы они были определены как классы:
class DebugStorage: def format_for_debug(self, some_data): ... def save_debug(self, some_data): """Выводит данные на экран""" formatted_data = self.format_for_debug(some_data) print(formatted_data) class S3Storage: def create_s3(self, bucket): """Создаёт s3 bucket, если он не существует""" ... def save_s3(self, some_data): s3 = self.create_s3("bucket_name") ...
Создание модулей и точек входа
У каждого приложения есть своя точка входа. Это означает что есть один модуль (он же файл), который запускает твоё приложение. Это может быть как отдельный скрипт, так и большой модуль.
Всякий раз, когда ты создаешь точку входа, обязательно добавляй условие, чтобы убедиться, что она выполняется, а не импортируется:
def execute_main(): ... if __name__ == "__main__": # Добавляй это условие execute_main()
Выполняя это, ты гарантируешь что любой импорт не вызовет случайного срабатывания твоего кода.
Определение главного для модулей
Ты мог заметить некоторые Python-пакеты, которые можно вызвать, передав команду -m
. Например:
python -m pytest python -m tryceratops python -m faust python -m flake8 python -m black
Такие пакеты рассматриваются почти как обычные команды, поскольку ты также можешь запускать их как:
pytest tryceratops faust flake8 black
Чтобы это произошло, тебе нужно указать файл __main__.py
внутри твоего основного модуля:
<project> ├── src │ ├── example_module 👈 Main module │ │ ├── __init__.py │ │ ├── __main__.py 👈 Add it here │ │ └── many_files.py │ │ │ └── tests/* │ └── many_tests.py │ ├── .gitignore ├── pyproject.toml └── README.md
Не забывай также, что тебе всё ещё нужно включать проверку __name__ == "__main__"
внутри файла __main__.py
Когда ты установишь модуль, ты можешь запустить свой проект как python -m example_module
Бонусы в виде дополнительной инфы
Материалы которые мы нагло украли для этой статьи, постаравшись улучшить их и адаптировать на ру:
Бонусные материалы по теме этой статьи. Это пригодится тебе для более полноценного погружения:
- 18 распространенных анти-паттернов Python
- Про f-строки + видео-гайд на 5 минут
- Вложение условий в условия
- Ещё немного про правильное написание кода
- Самый дьявольский антипаттерн в Python
- Паттерны проектирования в Python для новичков
- Инструменты качества кода
- Руководство для начинающих по абстрактному базовому классу
- Как и зачем нужно использовать if __name__ == '__main__'
Информацию укомплектовали с любовью и заботой Max Wayld и Neko. Это наша первая проба пера в виде совместной работы и мы оба будем рады любому твоему фидбеку, если материал оказался полезен for u.