Опирается на правила: PY-4.1PY-4.6, PY-4.X1PY-4.X3 из Python Style Guide → раздел 4. Выражения и код.

Важно знать

  • Guard clause (PY-4.1) — ранний raise/return вместо вложенных if/else; счастливый путь идёт прямо.
  • Comprehension (PY-4.2) — вместо ручного append-цикла; но вложенные и многоусловные — обычным for ради читаемости.
  • EAFP vs LBYL (PY-4.3) — try/except для ожидаемо-редких сбоев; if-проверка — когда она дешевле исключения.
  • f-string (PY-4.4) — единственный способ форматирования строк; %/.format() и конкатенация запрещены.
  • pathlib.Path (PY-4.5) — вместо os.path-конкатенации; файловые ресурсы — через with.
  • Булево выражение (PY-4.6) — не более трёх and/or; сложнее — выделить именованный предикат.
  • Мутабельный default (PY-4.X1) — def f(x=[]) делит список между вызовами; всегда None + инициализация в теле.
  • Голый except (PY-4.X2) — без re-raise/обработки проглатывает ошибки молча.

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

Guard clause — ранний return/raise

PY-4.1: ранний выход вместо роста вложенности.

Код с вложенными if/else требует удерживать стек условий в уме:

def charge(order: Order) -> None:
    if not order.is_paid:
        if order.amount > 0:
            provider.charge(order)

Guard clause переворачивает логику — сначала проверяем нарушение и выходим, затем идёт основной путь:

def charge(order: Order) -> None:
    if order.is_paid:
        return
    provider.charge(order)

Для ошибочных состояний guard clause использует raise:

def submit_order(order: Order, customer: Customer) -> None:
    if order.is_empty():
        raise OrderValidationError("order has no items")
    if not customer.is_active:
        raise CustomerBlockedError(customer.id)

    payment_gateway.charge(customer, order.total)
    order_repository.save(order)

Логика оплаты и сохранения читается без ментального отступа — guard clause убрал оба условия-нарушения вверх.

Три уровня вложенности внутри функции — сигнал, что функция делает слишком много. Guard clause помогает, но если вложенность возвращается — функцию стоит разбить.

Comprehension вместо append-цикла

PY-4.2: список или dict строится через comprehension, не через пустую коллекцию + append.

active_order_ids = [order.id for order in orders if order.is_active]

amounts_by_id = {order.id: order.amount for order in orders}

Вложенные comprehension быстро теряют читаемость. Если в comprehension больше одного if или двух for — обычный цикл предпочтительнее:

cancelled_items = []
for order in orders:
    for item in order.items:
        if item.status == ItemStatus.CANCELLED:
            if item.price > Decimal("0"):
                cancelled_items.append(item)

Граница — читаемость, а не формальное число уровней. Вложенный comprehension в четыре строки стоит превратить в цикл раньше.

Generator expression (без квадратных скобок) уместен там, где коллекция в памяти не нужна:

total = sum(item.price for item in order.items if item.is_billable)

EAFP и LBYL — когда что выбирать

PY-4.3: два стиля работы с потенциально-отсутствующими данными.

EAFP (Easier to Ask Forgiveness than Permission) — сначала пробуем, исключение ловим если что-то пошло не так. Подходит, когда успех — норма, а исключение — редкость:

def get_order_total(order_id: UUID) -> Decimal:
    try:
        order = order_repository.find_by_id(order_id)
        return order.total
    except OrderNotFoundError:
        return Decimal("0")

LBYL (Look Before You Leap) — сначала проверяем, потом делаем. Подходит, когда проверка дешевле исключения или когда семантически важно различать «не существует» и «ошибка»:

def reserve_product(product_id: UUID, quantity: int) -> None:
    product = product_repository.find_by_id(product_id)
    if product is None:
        raise ProductNotFoundError(product_id)
    if product.stock < quantity:
        raise InsufficientStockError(product_id, quantity, product.stock)
    product_repository.reserve(product_id, quantity)

Выбор между EAFP и LBYL определяется доменом: если «не найдено» — часть нормального потока, LBYL с явной проверкой читается лучше. Если исключение действительно исключительно — EAFP.

Голый except: или except Exception: без обработки (pass) — нарушение PY-4.X2. Исключение должно либо перевыброситься с контекстом, либо быть явно задокументировано как ожидаемое.

f-string для форматирования строк

PY-4.4: f-string — единственный способ форматирования строк в Python 3.12+.

message = f"order {order.id} declined by {provider.name}: {reason}"

Старые стили запрещены:

message = "order %s declined by %s: %s" % (order.id, provider.name, reason)
message = "order {} declined by {}: {}".format(order.id, provider.name, reason)
message = "order " + str(order.id) + " declined by " + provider.name + ": " + reason

f-string поддерживает выражения, форматирование чисел и вызов методов:

summary = f"order {order.id}: {order.total:.2f} RUB, {len(order.items)} items"
status_line = f"customer {customer.full_name.upper()!r} status={customer.status}"

Для длинных строк f-string можно перенести без конкатенации:

log_message = (
    f"payment failed for order {order.id} "
    f"customer={customer.id} "
    f"amount={order.total:.2f}"
)

pathlib.Path и контекст-менеджеры

PY-4.5: файловая система — через pathlib.Path, ресурсы — через with.

os.path-конкатенация непортируема, многословна и теряет тип пути:

import os
config_path = os.path.join(base_dir, "config", "settings.yaml")
with open(config_path) as f:
    data = f.read()

pathlib.Path — объектная модель, оператор / безопасен на всех платформах:

from pathlib import Path

config_path = Path(base_dir) / "config" / "settings.yaml"
with config_path.open() as f:
    data = f.read()

Любой ресурс — файл, сетевое соединение, транзакция — открывается через with. Это гарантирует освобождение ресурса даже при исключении:

async def export_orders(customer_id: UUID, output: Path) -> None:
    orders = await order_repository.find_by_customer(customer_id)
    with output.open("w", encoding="utf-8") as f:
        for order in orders:
            f.write(f"{order.id},{order.total}\n")

Сложность булева выражения

PY-4.6: не более трёх and/or в одном выражении.

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

def _is_eligible_for_discount(order: Order, customer: Customer) -> bool:
    return (
        customer.is_premium
        and order.total >= Decimal("5000")
        and not order.has_promotional_items
    )


def apply_discount(order: Order, customer: Customer) -> Order:
    if not _is_eligible_for_discount(order, customer):
        return order
    return order.with_discount(PREMIUM_DISCOUNT_RATE)

Имя _is_eligible_for_discount само является документацией — комментарий не нужен.

Оператор not с and/or повышает когнитивную нагрузку. Де Морган (not (A and B) = not A or not B) помогает упростить, но лучший выход — именованный предикат с позитивной формулировкой.

Мутабельный default — главная ловушка

PY-4.X1: мутабельный аргумент по умолчанию создаётся один раз при определении функции и разделяется между всеми вызовами.

def collect(items: list[str] = []) -> None:
    items.append("x")
    print(items)

collect()
collect()

Оба вызова печатают ["x"] и ["x", "x"] — список один и тот же. Правильно:

def collect(items: list[str] | None = None) -> None:
    items = items if items is not None else []
    items.append("x")
    print(items)

То же для dict, set и любого другого мутабельного объекта. Иммутабельные значения (None, 0, "", False, кортежи) безопасны как default.

Изменение коллекции при итерации

PY-4.X3: добавление или удаление элементов из коллекции во время итерации по ней — неопределённое поведение.

for order in orders:
    if order.is_expired:
        orders.remove(order)

Итерация по копии или формирование нового списка:

active_orders = [order for order in orders if not order.is_expired]

Для словарей:

for key in list(product_cache.keys()):
    if product_cache[key].is_stale():
        del product_cache[key]

Что запрещено

АнтипаттернПравилоЧто взамен
if not x: if y: do_thing() — вложенные условияPY-4.1Guard clause: ранний return/raise
result = []; for x in xs: result.append(f(x))PY-4.2result = [f(x) for x in xs]
except Exception: pass без обработкиPY-4.X2Конкретное исключение + re-raise или явная обработка
"order %s" % order_id или "order {}".format(order_id)PY-4.4f"order {order_id}"
os.path.join(base, "config", "file.yaml")PY-4.5Path(base) / "config" / "file.yaml"
def f(tags: list[str] = []) — мутабельный defaultPY-4.X1def f(tags: list[str] \| None = None) + инициализация в теле
a and b and c and d and e — 4+ операторовPY-4.6Именованный предикат
for x in col: col.remove(x) — мутация при итерацииPY-4.X3Итерация по копии или новый список через comprehension

Куда дальше

  • Именование — snake_case, PascalCase, константы, приватность через _.
  • Тайп-хинты — X | None вместо Optional, Protocol, Decimal для денег.
  • ruff + mypy — конфиг в pyproject.toml, mypy --strict, # noqa с обоснованием.
  • /standards/backend/error-handling/python/exception-hierarchy/ — иерархия исключений и где что ловить.