Главное синтаксическое отличие Python
comprehension — это короткая запись цикла. Если ты ещё не видел циклы, сначала загляни в следующий урок (Управляющие конструкции: if, циклы, match), а потом вернись сюда — так всё будет понятнее.
В прошлом уроке мы строили lookup-словари и фильтровали данные. Делали это циклами. Это работает, но идиоматичный Python пишет такой код одной строкой — через
Простейший пример. У вас список чисел, нужно получить их квадраты:
# через цикл
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 легко разворачивается обратно в цикл — это полезно для понимания, что она делает:
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(...). Здесь — одна операция.
{} без двоеточия — это 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, а
# 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 оператор :=.