- Генераторы списков
- Когда использовать генераторы списков
- Как проявляется декларативность генераторов списков
В повседневной жизни разработчика часто встречается код, работающий с последовательностями. Это связано с тем, что итераторы встроены в Python и тесно интегрированы в стандартную библиотеку.
Итераторы и операции над ними обычно собираются в конвейеры для данных. Лишь в конце каждого конвейера стоит reduce()
или другой потребитель элементов, не передающий элементы дальше.
Большинство таких конвейеров состоит из двух видов операций:
- Преобразование отдельных элементов. Эту задачу выполняет функция
map()
. Она преобразует весь поток с помощью другой функции, обрабатывающей отдельные элементы - Изменение состава элементов, то есть фильтрация или размножение. Фильтровать данные умеет
filter()
. А ужеmap()
в паре сchain()
из модуляitertools
превращают каждый элемент в несколько, не меняя при этом уровень вложенности
Для примера представим, что мы хотим получить список чисел вида [0, 0, 2, 2, 4, 4...]
— то есть по две копии возрастающих четных чисел. Напишем подходящий конвейер:
# Получаем поток четных чисел
def is_even(x):
return x % 2 == 0
list(filter(is_even, range(20)))
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
# Удваиваем каждое
def dup(x):
return [x, x]
list(map(dup, filter(is_even, range(20))))
# [[0, 0], [2, 2], [4, 4], [6, 6], [8, 8], [10, 10], [12, 12], [14, 14], [16, 16], [18, 18]]
# Делаем конвейер опять плоским
from itertools import chain
list(chain(*map(dup, filter(is_even, range(20)))))
# [0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 16, 16, 18, 18]
# Вариант в виде однострочника
list(chain(*map(lambda x: [x, x], filter(lambda x: x % 2 == 0, range(20)))))
# [0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 16, 16, 18, 18]
Как видите, задача решается соединением готовых элементов, а не написанием всего кода вручную в виде цикла for
. Уже здесь виден минус нашего конструктора: если готовых функций над элементами или предикатов нет, то их либо приходится заранее объявлять, либо использовать lambda
.
Оба варианта неудобны. Когда другой человек читает наш код с отдельными функциями, ему приходится постоянно прыгать по коду туда-сюда. А lambda
просто смотрятся громоздко. Но отчаиваться не нужно: у Python есть синтаксис, который может упростить работу с конвейерами.
Генераторы списков
Попробуем решить ту же задачу другим способом:
[x for num in range(20) for x in [num, num] if num % 2 == 0]
# [0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 16, 16, 18, 18]
Это тоже однострочник. Выглядит он не очень удобно, но к такому синтаксису можно привыкнуть. Попробуем отформатировать все выражение:
[x
for num in range(20)
for x in [num, num]
if num % 2 == 0
]
# [0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 16, 16, 18, 18]
Теперь код стал похож на два вложенных цикла. Похожий код можно написать и на обычных циклах:
res = []
for y in range(20):
for x in [y, y]:
if y % 2 == 0:
res.append(x)
res
# [0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 16, 16, 18, 18]
Код выглядит очень похоже, но есть два различия:
- В первом варианте мы создаем новый список, а во втором — изменяем заранее созданный
- Первый вариант — это выражение, а второй — набор инструкций. Следовательно, первый вариант можно использовать как часть любых других выражений. При этом нам не пришлось объявлять вспомогательные функции, лямбды тоже не понадобились
Выражения вида [… for … in …]
называются генераторами списков. Рассмотрим составляющие нового синтаксиса.
Генератор списков описывается так:
[ВЫРАЖЕНИЕ for ПЕРЕМЕННАЯ in ИСТОЧНИК if УСЛОВИЕ]
Рассмотрим этот шаблон подробнее:
ВЫРАЖЕНИЕ
может использоватьПЕРЕМЕННУЮ
и вычисляется в элемент будущего спискаПЕРЕМЕННАЯ
— имя, с которым поочередно связываются элементыИСТОЧНИКА
ИСТОЧНИК
— любой итератор или итерируемый объектУСЛОВИЕ
— выражение, которое используетПЕРЕМЕННУЮ
, вычисляемую на каждой итерации
Если условие оказывается ложным, то вычисление выражения для текущей итерации пропускается — в итоговый список новый элемент не добавится. Если условие вместе с ключевым словом if
будет пропущено, то это будет эквивалентно условию if True
.
В общем случае переменных может быть несколько. Здесь тоже работает распаковка кортежей и списков, в том числе и вложенных.
Вот несколько примеров:
# квадраты чисел
[x*x for x in [1, 2, 3]]
# [1, 4, 9]
# Коды прописных букв из заданной строки
[ord(c) for c in "Hello!!" if c.isalpha() and c.islower()]
# [101, 108, 108, 111]
# Индексы пар, элементы которых равны друг другу
[i for i, (x, y) in enumerate([(1, 2), (4, 4), (5, 7), (0, 0)]) if x == y]
# [1, 3]
# Пример посложнее: отфильтруем во вложенных списках четные элементы, затем оставим списки длиннее трех элементов
list_of_lists = [[1, 2, 3, 5], [7, 11, 8, 0], [21, 12, 2, 7, 1], [1, 3]]
# Генерируем внутренний список списков и оставляем только нечетные элементы
# Отфильтруем список списков и оставим только списки длиннее 3
[ x for x in [[elem for elem in l if elem % 2 == 1] for l in list_of_lists] if len(x) >= 3]
# [[1, 3, 5], [21, 7, 1]]
Когда использовать генераторы списков
Выше мы увидели, что генераторы списков не отменяют все встроенные функции для работы с итераторами. Одно с другим отлично сочетается.
С другой стороны, лучше не смешивать генераторы списков с функциями map()
и filter()
— это как раз взаимозаменяемые сущности. Еще не стоит смешивать генераторы списков с какими-либо побочными эффектами. Дело в том, что генераторы позволяют писать довольно лаконичный и компактный код. Не нужно заставлять программиста думать, где и что поменяется при создании списка.
Это касается не только кода с функциями map()
и filter()
, но и вообще любых декларативных конвейеров. Стоит разделять код, написанный в разных парадигмах, на отдельные однотонные участки. Например, ввод-вывод — это один из основных видов побочных эффектов. Он может находиться в начале конвейера или в его конце, но не в середине.
Как проявляется декларативность генераторов списков
Разберем, чем генератор списка отличается от явно императивного двойного цикла. В цикле можно не только строить список, но и производить другие побочные эффекты — например, изменять объекты списка.
Побочные эффекты в циклах не считаются плохим тоном, ведь циклы и предназначены для выполнения повторяющихся действий. В свою очередь генераторы списков описывают, что из себя представляет каждый элемент, а не как его получить из внешнего мира или вывести в консоль.
Можно взглянуть на отличающиеся части и увидеть, что:
- Генератор списка описывает результат. Он говорит: «Результирующий список — это список чисел в диапазоне от 1 до 20
- Процедурное решение показывает, как получить результат. Оно говорит: «Для каждого числа в диапазоне до 20 добавляем в список число»
Сами циклы for
в обоих случаях выглядят одинаково, потому что в Python циклы более декларативные, чем в некоторых других языках. Таким образом, цикл for
в Python считается императивным из-за его тела, а не из-за заголовка.
Дополнительные материалы
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.