July 26, 2022

Правильная структуризация на Python + 13 практик для написания чистого кода

Привет! Ты открыл совместный материал от Max Wayld и Neko, благодаря которому ты сможешь делать свой код более чистым и понятным, а также поймешь как правильно всё это дело нужно организовывать.

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

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

Навигатор:

  1. 13 практик для написания чистого кода
  2. Правильная структуризация кода на Python
  3. Дополнительные материалы + статьи которые мы разбирали

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 является одним из самых гибких языков, с которыми мне приходилось иметь дело, а всё слишком гибкое означает повышенную вероятность на неправильные решения.

  • Хочешь сохранить все классы проекта в одном файле main.py? Да, запросто, это работает.
  • Надо читать переменную окружения? Ну, просто бери и читай там, где это нужно.
  • Тебе нужно изменить поведение функции? Почему бы просто не использовать декоратор?!

Многие решения которые легко реализовать, могут привести к созданию кода, который чрезвычайно сложно поддерживать. Но если ты знаешь что делаешь, то все последствия гибкости 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. Ниже ты узнаешь как решить эту проблему.


Как называть файлы

Правило 1: Файлов нет

Прежде всего стоит обговорить что вообще-то в 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()

Существует всего несколько исключений из этого правила:

  • Создание функции main(), которая будет вызываться в основной точке входа приложения
  • Использование @property для обращения с методом класса как с атрибутом

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 для программного обеспечения.

Разобрав такой код, можно утверждать следующее:

  • Разработчик может сохранять данные в DEBUG-режиме (они выводятся на экран) или на S3 (он хранит данные в облаке)
  • save_debug использует функцию format_for_debug
  • save_s3 использует функцию create_s3

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

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


Бонусы в виде дополнительной инфы

Материалы которые мы нагло украли для этой статьи, постаравшись улучшить их и адаптировать на ру:

Бонусные материалы по теме этой статьи. Это пригодится тебе для более полноценного погружения:

Информацию укомплектовали с любовью и заботой Max Wayld и Neko. Это наша первая проба пера в виде совместной работы и мы оба будем рады любому твоему фидбеку, если материал оказался полезен for u.