List comprehension — способ построить список одной строкой вместо цикла с append. По-русски это называют списковым включением, реже генератором списка, хотя генератор списка и генератор в смысле yield — разные вещи, о них ниже. Здесь только цикл, условия и вложенность, с реально выполненным кодом на Python 3.14.5.
От цикла к comprehension
Одна и та же задача: квадраты чётных чисел от 0 до 9.
result = []
for x in range(10):
if x % 2 == 0:
result.append(x * x)
print(result)
[0, 4, 16, 36, 64]
Теперь то же самое включением:
result2 = [x * x for x in range(10) if x % 2 == 0]
print(result2)
print(result == result2)
[0, 4, 16, 36, 64]
True
Части соответствуют друг другу построчно. x * x это то, что раньше стояло аргументом у append. for x in range(10) это заголовок цикла, переписанный без двоеточия. if x % 2 == 0 это условие, которое раньше отбирало x для добавления. Порядок в comprehension повторяет порядок вложенности цикла: сначала выражение-результат, потом откуда брать x, потом что отфильтровать.
Фильтр, тернарник и разница между ними
if в конце и if в начале не совпадают: это разные if.
nums = range(6)
print([x for x in nums if x % 2 == 0])
print([x if x % 2 == 0 else -x for x in nums])
[0, 2, 4]
[0, -1, 2, -3, 4, -5]
Первая строка фильтрует: нечётные просто выброшены, в результате три элемента. Во второй строке тернарный оператор перед for, он не отбрасывает ничего, а выбирает значение для каждого x, поэтому длина результата всегда равна длине источника.
print(len([x for x in nums if x % 2 == 0]))
print(len([x if x % 2 == 0 else -x for x in nums]))
3
6
Несколько if после for работают через «и»:
print([x for x in range(30) if x % 2 == 0 if x % 3 == 0])
print([x for x in range(30) if x % 2 == 0 and x % 3 == 0])
[0, 6, 12, 18, 24]
[0, 6, 12, 18, 24]
Оба варианта дают один результат. Разница чисто стилистическая: два if читаются как последовательные фильтры, один if с and как единое условие. При трёх и более проверках and обычно компактнее.
Вложенность: декартово произведение и разворачивание
Два for подряд образуют вложенный цикл: порядок такой же, как при обычной вложенности.
sizes = ["S", "M", "L"]
colors = ["red", "blue"]
print([(s, c) for s in sizes for c in colors])
[('S', 'red'), ('S', 'blue'), ('M', 'red'), ('M', 'blue'), ('L', 'red'), ('L', 'blue')]
Первый for образует внешний цикл, второй образует внутренний. Тот же приём разворачивает список списков в плоский:
matrix = [[1, 2, 3], [4, 5], [6]]
print([x for row in matrix for x in row])
[1, 2, 3, 4, 5, 6]
Границу читаемости отмечает третий уровень. Разворачивание трёхмерного списка ещё влезает в одну строку:
matrix_3d = [[[1, 2], [3, 4]], [[5, 6], [7, 8]]]
flat = [x for plane in matrix_3d for row in plane for x in row]
print(flat)
[1, 2, 3, 4, 5, 6, 7, 8]
Работает, но порядок чтения уже не совпадает с порядком мышления: чтобы понять flat, приходится читать выражение слева направо, а собирать смысл приходится справа налево, от внутреннего цикла к внешнему. На практике два for образуют предел одной строки. На третьем уровне обычная вложенная запись с for и промежуточными именами читается быстрее, чем эта строка расшифровывается.
Отдельно стоит [[row[i] for row in grid] for i in range(3)]. Это не тройная вложенность, а comprehension внутри comprehension, транспонирование матрицы:
grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
transposed = [[row[i] for row in grid] for i in range(3)]
print(transposed)
[[1, 4, 7], [2, 5, 8], [3, 6, 9]]
Внешнее включение строит список строк, внутреннее каждую строку. Читается тяжелее одиночного, но для матриц фиксированного вида это устоявшийся идиоматичный код, а не перебор.
dict и set comprehension
Синтаксис общий, меняются только скобки и то, что стоит перед for.
prices = {"apple": 120, "banana": 80, "cherry": 300}
inv = {v: k for k, v in prices.items()}
print(inv)
{120: 'apple', 80: 'banana', 300: 'cherry'}
Инверсия работает чисто, пока значения уникальны. Как только два ключа делят одно значение, побеждает тот, что встретился последним при обходе .items():
scores = {"anna": 5, "boris": 4, "gleb": 5, "vera": 3}
by_score = {v: k for k, v in scores.items()}
print(by_score)
{5: 'gleb', 4: 'boris', 3: 'vera'}
Anna пропала. У неё и у Глеба одна оценка, 5, а словарь допускает только один ключ 5 — выжил тот, кто встретился последним по порядку обхода scores.items(). Никакой ошибки при этом не будет, пропажа тихая. Перед инверсией словаря проверяй уникальность значений, иначе получишь на выходе словарь короче исходного и не заметишь этого без явной проверки длины.
Set comprehension убирает дубликаты тем же способом, каким это делает set():
grades = [5, 4, 5, 3, 4, 5, 2]
print({g for g in grades})
print(sorted({g for g in grades}))
{2, 3, 4, 5}
[2, 3, 4, 5]
Порядок множества не гарантирован, поэтому для стабильного вывода нужен sorted.
Замер: comprehension против append, память списка против генератора
Одна и та же работа, миллион элементов, timeit с усреднением по 20 прогонам:
import timeit
def with_append(n):
result = []
for x in range(n):
result.append(x * x)
return result
def with_comprehension(n):
return [x * x for x in range(n)]
n = 1_000_000
t_append = timeit.timeit(lambda: with_append(n), number=20)
t_comp = timeit.timeit(lambda: with_comprehension(n), number=20)
print(f"append: {t_append / 20:.4f} c")
print(f"comprehension: {t_comp / 20:.4f} c")
append: 0.0321 c
comprehension: 0.0300 c
Comprehension быстрее примерно на 7%. Разница не в алгоритме (оба варианта делают одно и то же количество умножений), а в том, что append каждый раз ищет атрибут result.append и вызывает его как метод, а comprehension собирает список байткодом LIST_APPEND без поиска атрибута и без вызова функции. На CPython 3.14.5 экономия заметна, но не драматична: ради 7% не стоит переписывать читаемый цикл, если comprehension получается менее понятным.
Отдельный вопрос: списковое включение против генераторного выражения, то есть [x for x in ...] против (x for x in ...). Разница памяти на том же миллионе:
import sys
listcomp = [x * x for x in range(1_000_000)]
genexpr = (x * x for x in range(1_000_000))
print(sys.getsizeof(listcomp))
print(sys.getsizeof(genexpr))
8448728
208
Список весит 8 МБ, генератор 208 байт, потому что ничего не посчитал. Это устройство генераторов целиком разобрано в статье про генераторы и yield: там те же замеры через tracemalloc, конвейеры без промежуточных списков и объяснение, почему генератор одноразовый.
Когда comprehension не подходит
Ради побочного эффекта. Comprehension строит список и тут же может его выбросить: само наличие результата, который никто не читает, уже сигнал, что выбран не тот инструмент.
log = []
def notify(name):
log.append(f"уведомили {name}")
return name
users = ["Аня", "Борис", "Вера"]
[notify(u) for u in users]
print(log)
['уведомили Аня', 'уведомили Борис', 'уведомили Вера']
Код работает, но построенный список из имён нигде не используется и сразу уходит в мусор. Обычный for без comprehension говорит то же самое честнее: он не притворяется, что строит данные.
Ради сложной логики. Один уровень if/else внутри comprehension ещё нормален, второй уже стоит выносить в отдельную функцию и вызывать её из for x in items. Comprehension с тремя ветвлениями и вложенными тернарниками читается медленнее, чем именованная функция с обычными if.
Морж внутри comprehension. := легален и иногда экономит повторный вызов:
data = [1, 5, 3, 9, 2, 8, 4]
result = [y for x in data if (y := x * 2) > 6]
print(result)
[10, 18, 16, 8]
Без моржа пришлось бы писать x * 2 дважды: один раз в условии, второй в выражении-результате. С одним := это ещё читается. Как только внутри одного включения появляется второй := или морж соседствует с вложенным for, перебор уже случился — код превращается в квест по восстановлению порядка вычислений, и обычный цикл с промежуточными переменными снова выигрывает.
Переменная цикла не утекает наружу
Здесь comprehension ведёт себя не так, как for.
for i in range(3):
pass
print(i)
2
Обычный for не создаёт новую область видимости, и i остаётся доступна после цикла. Comprehension создаёт:
squares = [j * j for j in range(3)]
print(squares)
try:
print(j)
except NameError as exc:
print(repr(exc))
[0, 1, 4]
NameError("name 'j' is not defined")
j существует только внутри включения, снаружи её никогда не было. Это касается всех форм: списковой, словарной, множественной и генераторного выражения. Практическое следствие — comprehension безопаснее использовать в цикле с уже занятым именем переменной: x внутри [x * x for x in range(3)] не подменит внешний x, а for x in range(3) подменит.
На чём ловят на собеседовании
«List comprehension всегда быстрее цикла». На 7% быстрее в замере выше, не больше. Выигрыш от отсутствия поиска атрибута append, а не от другого алгоритма. На операции, которая сама по себе дорогая (сетевой вызов, чтение файла), разница в способе сборки списка тонет в фоне.
«Генератор списка — то же самое, что генератор с yield». Термин путают из-за перевода. Comprehension в квадратных скобках считает всё сразу и хранит в памяти, yield в функции выдаёт по одному и ничего не хранит. Разница в 8 МБ против 208 байт из замера выше та же самая.
«Вложенность в comprehension не имеет предела». Формально не имеет, практически два for в одной строке. Третий уровень все ещё выполняется, но перестаёт быть тем, ради чего comprehension вообще выбирают: краткостью, которую видно с одного взгляда.
«Comprehension с условием и без него — одно и то же по цене». По цене вычисления да, оба проходят весь диапазон. По результату нет: фильтрующий if в конце может вернуть список короче источника, тернарник в начале всегда той же длины.
Частые вопросы
Чем списковое включение отличается от генератора списка
Ничем, это два русских названия одного и того же — [x for x in ...]. Путаницу вносит третий термин, генератор в смысле функции с yield или выражения в круглых скобках: он не строит список, а выдаёт значения по требованию.
Можно ли использовать list comprehension без цикла for
Нет, for — обязательная часть синтаксиса. Без него это не comprehension, а обычное выражение в скобках.
Что быстрее — list comprehension или map с filter
Замер на том же миллионе, [x * x for x in range(n) if x % 2 == 0] против list(map(lambda x: x * x, filter(lambda x: x % 2 == 0, range(n)))): 0.0282 c у comprehension против 0.0468 c у связки map/filter с лямбдами. Разница в самих лямбдах: каждый вызов lambda x: ... — это вызов функции Python, а comprehension обходится без него. С встроенной функцией вместо лямбды разрыв исчезает: [str(x) for x in range(n)] и list(map(str, range(n))) дали 0.0516 c и 0.0507 c, разница в пределах шума. Comprehension почти всегда читается лучше, особенно с условием, и на практике выбор чаще делают по читаемости.
Меняет ли comprehension исходный список
Нет, [x for x in items] строит новый список и не трогает items. Если нужно поменять элементы на месте, это обычный for с индексами или срез items[:] = [...].
Что учить дальше
После comprehension логично взять генераторы — они снимают главное ограничение включений, память на больших данных. Рядом стоят декораторы: третья тема из того же набора вопросов про устройство языка. В пути «Python: функции и идиомы» на Koddo comprehension разбирается отдельным паком задач с автопроверкой: сначала перевод цикла во включение, потом dict/set варианты, потом граница, где включение уже не нужно.