List comprehension в Python: списковые включения с примерами

Python Автор: Среда и версия: CPython 3.14.5

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 варианты, потом граница, где включение уже не нужно.

Источники