Learning Platform
Урок 04.04 · 20 мин
Начальный
ComprehensionsGenerator expressionsFunctional styleIdiomatic Python
Comprehensions: продвинутые паттерны

Главное синтаксическое отличие Python

TIP

comprehension — это короткая запись цикла. Если ты ещё не видел циклы, сначала загляни в следующий урок (Управляющие конструкции: if, циклы, match), а потом вернись сюда — так всё будет понятнее.

В прошлом уроке мы строили lookup-словари и фильтровали данные. Делали это циклами. Это работает, но идиоматичный Python пишет такой код одной строкой — через

comprehensions
. Этот синтаксис вы будете видеть в каждом DE-скрипте, и писать его — самостоятельно.

Простейший пример. У вас список чисел, нужно получить их квадраты:

# через цикл
nums = [1, 2, 3, 4, 5]
squares = []
for n in nums:
    squares.append(n ** 2)

# через list comprehension
squares = [n ** 2 for n in nums]

Обе версии делают то же самое. Вторая — короче, выразительнее, чуть быстрее (на 10-30% в среднем — пустяки, но приятный бонус). И главное — это идиома, по которой узнают Python-разработчика. На код-ревью прислать первый вариант — попросят переписать.

Синтаксис list comprehension

Общий вид:

[expr for item in iterable if condition]

Читается слева направо как фраза: «выражение expr, для каждого item из iterable, при условии condition». Условие опционально:

# простой случай — без фильтра
squares = [n ** 2 for n in range(10)]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# с фильтром
evens = [n for n in range(10) if n % 2 == 0]
# [0, 2, 4, 6, 8]

# с преобразованием и фильтром
upper_long = [name.upper() for name in names if len(name) > 3]
Структура list comprehension

Три части: что положить, откуда взять, чем отфильтровать.

[Скобка списка — результат будет list
exprn ** 2Что положить в результат. Может быть любым выражением.
forfor n in numsОткуда брать значения. Любой итерируемый объект.
ifif n > 0Опциональный фильтр. Если False — элемент пропускается.
]Закрывающая скобка

Эквивалент циклу

Любая list comprehension легко разворачивается обратно в цикл — это полезно для понимания, что она делает:

result = [expr for item in iterable if condition]

# эквивалентно:
result = []
for item in iterable:
    if condition:
        result.append(expr)

Если вы запутались в чтении длинной comprehension — мысленно разверните её в цикл.

Dict comprehension

Похожий синтаксис, но с двоеточием между ключом и значением, в фигурных скобках:

{key_expr: value_expr for item in iterable if condition}

Главные DE-кейсы:

# построить lookup-словарь из списка
products = [
    {"id": 101, "name": "Кофе", "price": 250.0},
    {"id": 102, "name": "Чай", "price": 180.0},
]

price_by_id = {p["id"]: p["price"] for p in products}
# {101: 250.0, 102: 180.0}
# обратный словарь
forward = {"a": 1, "b": 2, "c": 3}
backward = {v: k for k, v in forward.items()}
# {1: "a", 2: "b", 3: "c"}
# фильтр словаря
data = {"name": "Анна", "age": 30, "email": None, "phone": None}
non_null = {k: v for k, v in data.items() if v is not None}
# {"name": "Анна", "age": 30}

Последний пример — чисто DE-задача: «убрать поля со значением None перед записью в JSON». Одна строка, ясная цель.

Set comprehension

Та же логика, но фигурные скобки без двоеточия:

{expr for item in iterable if condition}
# уникальные домены из email'ов
emails = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"]

domains = {e.split("@")[1] for e in emails}
# {"example.com", "gmail.com"}

В этом примере мы дедуплицируем за счёт того, что результат — set. Если бы писали list comprehension, получили бы ["example.com", "gmail.com", "example.com", "gmail.com"] и пришлось бы потом ещё set(...). Здесь — одна операция.

WARNING

{} без двоеточия — это set (или comprehension над ним). {} пустой — это dict (исторически так сложилось, set появился позже). Пустой set создаётся через set().

Nested comprehensions — когда стоит

Comprehensions можно вкладывать. Иногда это красиво, иногда — кошмар для читателя.

Хорошо — обработка списка списков (например, матрица или вложенный JSON):

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

# плоский список
flat = [x for row in matrix for x in row]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

# транспонировать
transposed = [[row[i] for row in matrix] for i in range(3)]
# [[1, 4, 7], [2, 5, 8], [3, 6, 9]]

Плохо — три уровня вложенности с несколькими условиями:

# не читается, не пишите так
result = [
    x
    for sublist in data
    for item in sublist
    for x in item.values()
    if isinstance(x, int)
    if x > 0
]

Правило: если comprehension не помещается в строку или содержит больше двух for/if — переписывайте в обычный цикл с понятными промежуточными переменными. Цель — читаемость, а не «однострочник любой ценой».

Generator expression — ленивая версия

Если заменить [] на (), получится не list, а

generator expression
:

# list — вычислит все 10М квадратов сразу, займёт ~80МБ
squares_list = [n ** 2 for n in range(10_000_000)]

# generator — не вычислит ничего, пока не начнут итерировать
squares_gen = (n ** 2 for n in range(10_000_000))
print(squares_gen)   # <generator object <genexpr> at 0x...>

Generator — это «инструкция, как получать значения», а не «сами значения». Он отдаёт их по одному при итерации:

total = sum(n ** 2 for n in range(10_000_000))
# Если бы мы написали `sum([... for ...])`, Python бы создал список из 10М элементов,
# потом суммировал. С generator — суммирует «на ходу», память O(1).

Generator expression обычно используется как аргумент функции, потребляющей итерируемое:

# среднее значение поля price без создания промежуточного списка
avg = sum(p["price"] for p in products) / len(products)

# проверить, что все элементы валидны
all(p["price"] > 0 for p in products)   # True/False — короткозамыкающий

# найти первый подходящий
first = next((p for p in products if p["price"] > 200), None)

sum, all, any, min, max, next, join — все принимают итерируемое и работают с generator’ом без создания промежуточного списка. Это важная привычка: если результат — единое число или строка, не нужен промежуточный список.

В этом уроке мы не углубляемся в генераторы — только касаемся generator expressions. Подробнее про yield, ленивую обработку гигабайтных файлов, итераторы как объекты — в модуле 4 «Идиомы Python».

Comprehension vs map/filter

В функциональных языках (Haskell, Lisp) принят другой синтаксис — map() и filter(). В Python они тоже есть:

# через map/filter
squares = list(map(lambda n: n ** 2, range(10)))
evens = list(filter(lambda n: n % 2 == 0, range(10)))

# через comprehension
squares = [n ** 2 for n in range(10)]
evens = [n for n in range(10) if n % 2 == 0]

В современном Python предпочтительнее comprehension — он читается естественнее, не требует lambda, проверяется типизатором лучше. map/filter имеют смысл, если уже есть готовая именованная функция:

# хорошо
parsed = list(map(int, ["1", "2", "3"]))

# плохо — лишний lambda
parsed = list(map(lambda s: int(s), ["1", "2", "3"]))

# идиоматично
parsed = [int(s) for s in ["1", "2", "3"]]

Когда НЕ стоит comprehension

Несколько случаев, когда возвращаемся к обычному циклу:

1. Сложная логика с побочными эффектами.

# плохо — comprehension с print'ом
[print(x) for x in items]   # создаёт список из None, ужасно

# хорошо — обычный цикл
for x in items:
    print(x)

2. Накопление в несколько коллекций сразу.

# плохо — два прохода через comprehension
errors = [r for r in records if not r["valid"]]
valid = [r for r in records if r["valid"]]

# можно так — но если потом ещё что-то...
# лучше один цикл
errors = []
valid = []
for r in records:
    (valid if r["valid"] else errors).append(r)

3. Многошаговая обработка с понятными промежуточными именами.

# плохо — нечитаемо
result = [
    {**p, "tax": p["price"] * 0.2}
    for p in products
    if p["price"] > 0 and not p.get("deleted") and p["category"] != "test"
]

# хорошо — пошагово
active = [p for p in products if p["price"] > 0 and not p.get("deleted")]
non_test = [p for p in active if p["category"] != "test"]
with_tax = [{**p, "tax": p["price"] * 0.2} for p in non_test]

Здесь промежуточные имена active, non_test, with_tax объясняют, что происходит на каждом шаге. Это код, который не нужно расшифровывать через неделю.

DE-кейсы

Кейс 1: фильтрация и трансформация ETL-записей

Стандартная DE-задача: пришли записи из API, нужно отобрать валидные, привести к нашему формату, сохранить.

raw = [
    {"id": 1, "name": "Анна", "age": "30", "country": "RU"},
    {"id": 2, "name": "", "age": "25", "country": "RU"},          # битое имя
    {"id": 3, "name": "Pierre", "age": "abc", "country": "FR"},   # битый возраст
    {"id": 4, "name": "Борис", "age": "45", "country": "RU"},
]

def is_valid(r: dict) -> bool:
    if not r.get("name"):
        return False
    try:
        int(r["age"])
    except ValueError:
        return False
    return True

clean = [
    {
        "id": r["id"],
        "name": r["name"].strip(),
        "age": int(r["age"]),
        "country": r["country"],
    }
    for r in raw
    if is_valid(r)
]
# [
#   {"id": 1, "name": "Анна", "age": 30, "country": "RU"},
#   {"id": 4, "name": "Борис", "age": 45, "country": "RU"},
# ]

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

Кейс 2: lookup-таблица из списка

# из API получили список валют
currencies = [
    {"code": "USD", "rate_to_rub": 92.5},
    {"code": "EUR", "rate_to_rub": 100.1},
    {"code": "CNY", "rate_to_rub": 12.8},
]

# построить lookup для быстрого поиска
rate_by_code = {c["code"]: c["rate_to_rub"] for c in currencies}

# теперь можно мгновенно искать
print(rate_by_code["USD"])   # 92.5

Кейс 3: групповая агрегация

# заказы
orders = [
    {"user_id": 1, "amount": 100.0},
    {"user_id": 2, "amount": 50.0},
    {"user_id": 1, "amount": 200.0},
    {"user_id": 3, "amount": 75.0},
]

# уникальные user_id
unique_users = {o["user_id"] for o in orders}
# {1, 2, 3}

# словарь user_id → список их сумм
amounts_by_user: dict[int, list[float]] = {}
for o in orders:
    amounts_by_user.setdefault(o["user_id"], []).append(o["amount"])

# финальные суммы — через comprehension над items()
totals = {uid: sum(amts) for uid, amts in amounts_by_user.items()}
# {1: 300.0, 2: 50.0, 3: 75.0}

Видно, что для группировки comprehension не подойдёт (нужно накапливать), а финальная агрегация и dict-результат — снова comprehension. Комбинируйте, не пытайтесь всё засунуть в один однострочник.

Упражнение

В REPL (uv run python):

# 1. Из списка чисел получите список квадратов чётных:
nums = [1, 2, 3, 4, 5, 6, 7, 8]
# Ожидаемое: [4, 16, 36, 64]

# 2. Из списка email'ов получите set уникальных доменов:
emails = ["[email protected]", "[email protected]", "[email protected]"]
# Ожидаемое: {"x.ru", "y.com"}

# 3. Из словаря {1: "a", 2: "b", 3: "c"} получите словарь {"a": 1, "b": 2, "c": 3}
forward = {1: "a", 2: "b", 3: "c"}

# 4. Из списка словарей оставьте только записи с положительным price,
# и оставьте только поля id и name:
records = [
    {"id": 1, "name": "Кофе", "price": 250.0, "extra": "ignore"},
    {"id": 2, "name": "Чай",  "price": -1.0,  "extra": "ignore"},
    {"id": 3, "name": "Сок",  "price": 100.0, "extra": "ignore"},
]
# Ожидаемое: [{"id": 1, "name": "Кофе"}, {"id": 3, "name": "Сок"}]

# 5. Посчитайте сумму квадратов от 1 до миллиона.
# Не создавайте промежуточный список!
# Ожидаемое: 333_333_833_333_500_000

# 6. Проверьте, есть ли в списке хотя бы один отрицательный.
# Используйте any() с generator expression.
xs = [1, 2, 3, -1, 5]
# Ожидаемое: True

Критерии:

  • Все шесть задач решены comprehensions или generator expressions.
  • В задачах 5 и 6 не создаётся промежуточный список.
  • Вы понимаете, когда [...] неуместен и нужно (...).

В следующем уроке — управляющие конструкции: if/elif, циклы, match/case (тот самый pattern matching из 3.10) и walrus оператор :=.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 8