Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером

Генераторы списков Python: Декларативное программирование

В повседневной жизни разработчика часто встречается код, работающий с последовательностями. Это связано с тем, что итераторы встроены в Python и тесно интегрированы в стандартную библиотеку.

Итераторы и операции над ними обычно собираются в конвейеры для данных. Лишь в конце каждого конвейера стоит reduce() или другой потребитель элементов, не передающий элементы дальше.

Большинство таких конвейеров состоит из двух видов операций:

  1. Преобразование отдельных элементов. Эту задачу выполняет функция map(). Она преобразует весь поток с помощью другой функции, обрабатывающей отдельные элементы
  2. Изменение состава элементов, то есть фильтрация или размножение. Фильтровать данные умеет 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 считается императивным из-за его тела, а не из-за заголовка.


Дополнительные материалы

  1. Генераторы списков

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff
Рекомендуемые программы
профессия
от 25 000 ₸ в месяц
Разработка веб-приложений на Django
10 месяцев
с нуля
Старт 21 ноября

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»