Когда использовать List Comprehension в Python

Перевод статьи «When to Use a List Comprehension» in Python, опубликованный сайтом webdevblog.ru.

Прим. переводчика: В русской терминологии нет общепризнанного перевода list comprehension. Гугл его переводит как списковое включение или абстракция списков. Хотя наиболее часто можно встретить фразу генератор списков, но мне кажется, это не совсем правильно, так как в Python есть отдельное понятие генератора. По-моему, наиболее подходящий перевод — представление списков. Поэтому в этой статье эта фраза будет использоваться без перевода, либо будет переводится следующим образом: list comprehension — представление списковset comprehension — представление множества и dictionary comprehension — представление словаря).

Содержание

Python известен тем, что позволяет нам писать элегантный, понятный и простой код. Одной из отличительных особенностей языка является list comprehension (представление списков), которое можно использовать для создания мощных функций в одной строке кода. Однако многие разработчики постоянно пытаются полностью использовать более продвинутые функции list comprehension. Некоторые программисты даже используют их слишком часто, что может привести к меньшей эффективности кода и сложности для чтения.

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

В этом уроке вы узнаете, как:

  • Переписать циклы и вызовы map() с использованием list comprehension
  • Выбрать между comprehensions, циклами и вызовами map()
  • Использовать comprehensions с условной логикой
  • Использовать comprehensions, для замены filter()
  • Профилировать свой код для решения вопросов производительности

Как создаются списки в Python

Чтобы лучше понять компромиссы, связанные с использованием list comprehension, давайте сначала рассмотрим способы создания списков.

Использование цикла for

Наиболее распространенным типом цикла является цикл for. Использование цикла for можно разбить на три этапа:

  1. Создание пустого списка.
  2. Цикл по итерируемому объекту или диапазону элементов range.
  3. Добавление каждого элемента в конец списка.

Допустим, нам надо создать список squares. В коде эти шаги будут выглядеть следующим образом:

>>> squares = []
>>> for i in range(10):
...     squares.append(i * i)
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Здесь мы создаем пустой список squares. Затем используем цикл for для перебора range(10). Наконец, умножаем каждое число отдельно и добавляем результат в конец списка.

Использование объектов map()

map() предоставляет альтернативный подход, основанный на функциональном программировании. Мы передаем функцию и итерируемый объект (iterable), а map() создает объект. Этот объект содержит выходные данные, которые мы получаем при запуске каждого итерируемого элемента через предоставленную функцию.

Немного запутано, поэтому давайте рассмотрим пример. Допустим, нам надо рассчитать цену после вычета налога для списка транзакций:

>>> txns = [1.09, 23.56, 57.84, 4.56, 6.78]
>>> TAX_RATE = .08

>>> def get_price_with_tax(txn):
...     return txn * (1 + TAX_RATE)

>>> final_prices = map(get_price_with_tax, txns)
>>> list(final_prices)

[1.1772000000000002, 25.4448, 62.467200000000005, 4.9248, 7.322400000000001]

Здесь у вас есть итерируемый объект txns (в нашем случае простой список) и функция get_price_with_tax(). Мы передаем оба эти аргумента в map() и сохраняем полученный объект в final_prices. Мы можем легко преобразовать этот объект map в список, используя list().

Использование List Comprehensions

List comprehensions — это третий способ составления списков. При таком элегантном подходе мы можем переписать цикл for из первого примера всего в одну строку кода:

>>> squares = [i * i for i in range(10)]
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Вместо того чтобы создавать пустой список и добавлять каждый элемент в конец, мы просто определяем список и его содержимое одновременно, следуя этому формату:

 new_list = [expression for member in iterable]

Каждое представление списков в Python включает три элемента:

  1. expression — какое-либо вычисление, вызов метода или любое другое допустимое выражение, возвращающее значение. В приведенном выше примере выражение i * i является квадратом значения члена.
  2. member является объектом или значением в списке или итерируемом объекте (iterable). В приведенном выше примере значением элемента является i.
  3. iterable — список, множество, последовательность, генератор или любой другой объект, который может возвращать свои элементы по одному. В приведенном выше примере iterable — это range(10).

Поскольку требования к expression (выражению) настолько гибки, представление списков хорошо работает во многих местах, где вы будете использовать map(). Мы также можем переписать пример с ценообразованием:

>>> txns = [1.09, 23.56, 57.84, 4.56, 6.78]
>>> TAX_RATE = .08

>>> def get_price_with_tax(txn):
...     return txn * (1 + TAX_RATE)

>>> final_prices = [get_price_with_tax(i) for i in txns]
>>> final_prices

[1.1772000000000002, 25.4448, 62.467200000000005, 4.9248, 7.322400000000001]

Единственное различие между этой реализацией и map() состоит в том, что list comprehension возвращает список, а не объект map.

Преимущества использования представления списков

Представление списков часто описываются как более Pythonic, чем циклы или map(). Но вместо того чтобы слепо принимать эту оценку, стоит понять преимущества использования list comprehension по сравнению с альтернативными вариантами. Позже вы узнаете о нескольких сценариях, в которых эти варианты являются лучшим выбором.

Одним из основных преимуществ list comprehension является то, что это единственный инструмент, который вы можете использовать в самых разных ситуациях. В дополнение к созданию стандартного списка, списки могут также использоваться для отображения и фильтрации. Вам не нужно использовать разные подходы для каждого сценария.

Python включает в себя простые и мощные инструменты, которые вы можете использовать в самых разных ситуациях. И именно поэтому  list comprehension считаются Pythonic.

Дополнительным преимуществом является то, что при использовании представления списков вам не нужно запоминать правильный порядок аргументов, как при вызове map().

List comprehensions также более декларативны, чем циклы. Это означает, что их легче читать и понимать. Циклы требуют, чтобы вы фокусировались на создании списка. Вам нужно вручную создать пустой список, зациклить элементы и добавить каждый из них в конец списка. Используя представления списков, вы можете вместо этого сосредоточиться на том, что хотите добавить в список, и положиться на то, что Python сам позаботится о его построении.

Мужчина ныряет в воду

Погружаемся в Comprehensions

Чтобы понять всю ценность list comprehensions, полезно понять диапазон их функционала. Мы также коснемся изменений, которые были внесены в Python 3.8.

Использование условной логики

Вы уже видели этот шаблон для создания представлений списка:

 new_list = [expression for member in iterable]

Хотя этот шаблон точен, он также немного неполон. Более полное описание шаблона добавляет поддержку необязательных условных выражений. Наиболее распространенный способ добавить условную логику к list comprehension — добавить в конец условное выражение:

 new_list = [expression for member in iterable (if conditional)]

Здесь наше условное утверждение предшествует закрывающей скобке.

Условные выражения позволяют отфильтровывать нежелательные значения без вызова filter():

>>> sentence = 'the rocket came back from mars'
>>> vowels = [i for i in sentence if i in 'aeiou']
>>> vowels

['e', 'o', 'e', 'a', 'e', 'a', 'o', 'a']

В этом блоке кода условный оператор отфильтровывает в sentence любые символы, не являющиеся гласными.

Условие может содержать любое допустимое выражение. Если вам нужен более сложный фильтр, вы можете даже переместить условную логику в отдельную функцию:

>>> sentence = 'The rocket, who was named Ted, came back \
... from Mars because he missed his friends.'

>>> def is_consonant(letter):
...     vowels = 'aeiou'
...     return letter.isalpha() and letter.lower() not in vowels

>>> consonants = [i for i in sentence if is_consonant(i)]

['T', 'h', 'r', 'c', 'k', 't', 'w', 'h', 'w', 's', 'n', 'm', 'd', \
'T', 'd', 'c', 'm', 'b', 'c', 'k', 'f', 'r', 'm', 'M', 'r', 's', 'b', \
'c', 's', 'h', 'm', 's', 's', 'd', 'h', 's', 'f', 'r', 'n', 'd', 's']

Здесь мы создаем сложный фильтр is_consonant() и передаем эту функцию как условный оператор для нашего представления списка. Обратите внимание, что значение элемента i также передается в качестве аргумента нашей функции.

Для простой фильтрации условие можно поместить в конец оператора. Но что, если вы хотите не отфильтровать элемент, изменить его значение? В этом случае полезно поместить условное выражение в начало выражения:

 new_list = [expression (if conditional) for member in iterable]

С помощью этого шаблона вы можете использовать условную логику для выбора из нескольких возможных вариантов вывода. Например, если у вас есть список цен, вы можете заменить отрицательные цены на 0 и оставить положительные значения без изменений:

>>> original_prices = [1.25, -9.45, 10.22, 3.78, -5.92, 1.16]
>>> prices = [i if i > 0 else 0 for i in original_prices]
>>> prices
[1.25, 0, 10.22, 3.78, 0, 1.16]

Здесь наше выражение i содержит условный оператор, if i> 0 else 0. Это указывает Python выводить значение i, если число положительное, но менять i на 0, если число отрицательное. Если этого окажется недостаточно, может быть полезно рассматривать условную логику как отдельную функцию:

>>> def get_price(price):
...     return price if price > 0 else 0

>>> prices = [get_price(i) for i in original_prices]
>>> prices

[1.25, 0, 10.22, 3.78, 0, 1.16]

Теперь условный оператор содержится в get_price(), и вы можете использовать его как часть выражения вашего списка.

Использование Set и Dictionary Comprehensions

Представления списков в Python используются очень часто. Но это не единственный тип представлений. Вы также можете создавать представления множеств и словарей (set comprehension и dictionary comprehension). set comprehension почти ничем не отличается от представления списка. Разница лишь в том, что заданные значения обеспечивают, чтобы выходные данные не содержали дубликатов. Вы можете создать set comprehension, используя фигурные скобки вместо квадратных:

>>> quote = "life, uh, finds a way"
>>> unique_vowels = {i for i in quote if i in 'aeiou'}
>>> unique_vowels
{'a', 'e', 'u', 'i'}

В нашем примере set comprehension выводит все уникальные гласные, которые он нашел в quote. В отличие от списков, наборы не гарантируют, что элементы будут сохранены в определенном порядке. Вот почему первым членом набора является a, хотя первый гласный в quote — i.

Dictionary comprehension похоже на set comprehension, но с дополнительным требованием определения ключа:

>>> squares = {i: i * i for i in range(10)}
>>> squares
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}

Чтобы создать словарь квадратов, воспользуемся фигурными скобками {}, а также парой ключ-значение i: i * i в выражении.

Использование оператора Walrus

Python 3.8 представил выражение присваивания (assignment expression), также известное как оператор walrus (оператор моржа). Чтобы понять, как он используется, рассмотрим следующий пример.

Скажем, нам нужно сделать десять запросов к API, который будет возвращать данные о температуре. Мы хотим вернуть только результаты, превышающие 100 градусов по Фаренгейту. Предположим, что каждый запрос будет возвращать разные данные. В этом случае мы не сможем использовать list comprehension для решения проблемы. Формула expression for member in iterable (if conditional) не позволяет условию назначить данные переменной, к которой может обращаться это выражение.

Оператор walrus решает эту проблему. Он позволяет нам запускать выражение, одновременно назначая выходное значение переменной. Следующий пример показывает, как это возможно, путем использования get_weather_data() для генерации поддельных данных о погоде:

>>> import random

>>> def get_weather_data():
...     return random.randrange(90, 110)

>>> hot_temps = [temp for _ in range(20) if (temp := get_weather_data()) >= 100]

>>> hot_temps
[107, 102, 109, 104, 107, 109, 108, 101, 104]

Не так часто возникает необходимость использовать выражение присваивания внутри list comprehension, но это полезный инструмент, который может быть в вашем распоряжении, когда это необходимо.

Знак "Не входить"

Когда не использовать List Comprehension в Python

Представления списков полезны и могут помочь нам написать элегантный код, который легко читать и отлаживать. Но это не всегда является правильным выбором. Представления могут замедлить работу вашего кода или привести к большему использованию памяти. Если из-за list comprehensions код станет менее производительным или более сложным для понимания, то, вероятно, лучше выбрать альтернативный вариант.

Остерегайтесь вложенных Comprehensions

Представления могут быть вложенными. Это используется при создании комбинаций из списков, словарей и множеств внутри коллекции. Допустим, климатическая лаборатория отслеживает высокую температуру в пяти разных городах в течение первой недели июня. Идеальной структурой для хранения этих данных может быть представление списка, вложенное в представление словаря:

>>> cities = ['Austin', 'Tacoma', 'Topeka', 'Sacramento', 'Charlotte']

>>> temps = {city: [0 for _ in range(7)] for city in cities}

>>> temps
{
    'Austin': [0, 0, 0, 0, 0, 0, 0],
    'Tacoma': [0, 0, 0, 0, 0, 0, 0],
    'Topeka': [0, 0, 0, 0, 0, 0, 0],
    'Sacramento': [0, 0, 0, 0, 0, 0, 0],
    'Charlotte': [0, 0, 0, 0, 0, 0, 0]
}

Мы создали внешнюю коллекцию temps как представление словаря. Выражение представляет собой пару ключ-значение, которая содержит еще одно comprehension. Этот код быстро сгенерирует список данных для каждого города в cities.

Вложенные списки являются распространенным способом создания матриц, которые часто используются в математических целях. Посмотрите на блок кода ниже:

>>> matrix = [[i for i in range(5)] for _ in range(6)]
>>> matrix
[
    [0, 1, 2, 3, 4],
    [0, 1, 2, 3, 4],
    [0, 1, 2, 3, 4],
    [0, 1, 2, 3, 4],
    [0, 1, 2, 3, 4],
    [0, 1, 2, 3, 4]
]

Внешнее представление списка [… for _ in range(6)] создает шесть строк, в то время как внутреннее представление списка [i for i in range(5)] заполняет каждую из этих строк значениями.

До сих пор цель каждого вложенного представления была довольно интуитивна. Однако существуют другие ситуации, такие как выравнивание вложенных списков, когда логика, возможно, делает ваш код более запутанным. Давайте рассмотрим пример, где представление вложенного списка используется для выравнивания матрицы:

matrix = [
...     [0, 0, 0],
...     [1, 1, 1],
...     [2, 2, 2],
... ]
>>> flat = [num for row in matrix for num in row]
>>> flat
[0, 0, 0, 1, 1, 1, 2, 2, 2]

Код для выравнивания матрицы является лаконичным, оно, возможно, не очень интуитивно понятно. С другой стороны, если бы вы использовали цикл for для выравнивания одной и той же матрицы, ваш код был бы намного проще:

>>> matrix = [
...     [0, 0, 0],
...     [1, 1, 1],
...     [2, 2, 2],
... ]
>>> flat = []
>>> for row in matrix:
...     for num in row:
...         flat.append(num)
...
>>> flat
[0, 0, 0, 1, 1, 1, 2, 2, 2]

Теперь вы можете видеть, что код пересекает одну строку матрицы за раз, вытягивая все элементы этой строки перед тем, как перейти к следующей.

Хотя вложенные однострочные представления списков могут показаться более Pythonic, самое важное — это написать код, который ваша команда сможет легко понять и изменить. Выбирая свой подход, нужно ориентироваться на собственное суждение о том, не ухудшает ли представление списка читабельность вашего кода.

Используйте Генераторы для больших наборов данных

Представление списков в Python работает путем загрузки всего списка в память. Для небольших или даже средних списков это обычно хорошо. Например, если вы хотите сложить квадраты первой тысячи целых чисел, то list comprehension решит эту проблему превосходно:

>>> sum([i * i for i in range(1000)])
332833500

Но что, если вы хотите сложить квадраты первых миллиардов целых чисел? Если вы попытаетесь это выполнить на своем компьютере, вы увидите, что ваш компьютер перестал отвечать на запросы. Это потому, что Python пытается создать список с одним миллиардом целых чисел, а это потребляет больше памяти, чем хотелось бы вашему компьютеру. Ваш компьютер может не иметь ресурсов, необходимых для создания огромного списка и сохранения его в памяти.

Когда размер списка становится проблематичным, зачастую полезнее использовать генератор вместо list comprehension. Генератор не создает единую большую структуру данных в памяти, а вместо этого возвращает итерацию. Ваш код будет запрашивать следующее значение из итерируемого объекта столько раз, сколько необходимо, или пока вы не достигните конца своей последовательности, сохраняя при этом только одно значение за раз.

Если бы вы суммировали первый миллиард квадратов с генератором, то работа вашей программы, вероятно, заняла бы некоторое время, но она не вызвала бы зависание вашего компьютера. В приведенном ниже примере используется генератор:

>>> sum(i * i for i in range(1000000000))
333333332833333333500000000

То, что это — генератор, видно по тому, что выражение не заключено в квадратные или фигурные скобки. При желании генераторы могут быть заключены в круглые скобки.

Приведенный выше пример все еще требует много времени для выполнения, но программа не зависнет, так как операции выполняются отложено. Из-за отложенных вычислений значения рассчитываются только по явному запросу. После того, как генератор выдаст значение (например, 567 * 567), он может добавить это значение к текущей сумме, затем отбросить это значение и сгенерировать следующее (568 * 568). Когда функция sum запрашивает следующее значение, цикл начинается заново. Для этого процесса необходим небольшой объем памяти.

map() также работает отложено, а значит, если вы решите использовать ее в этом случае, память не будет проблемой:

>>> sum(map(lambda i: i*i, range(1000000000)))
333333332833333333500000000

Вам решать, предпочитаете ли вы выражение генератора или map().

Профилирование для оптимизации производительности

Итак, какой подход быстрее? Стоит ли использовать списки или одну из их альтернатив? Вместо того, чтобы придерживаться единого правила на все случаи жизни, лучше спросить себя, имеет ли значение производительность в ваших конкретных обстоятельствах. Если нет, то обычно лучше выбрать подход, который приведет к чистому коду!

Если вы реализуете сценарии, где важна производительность, то обычно лучше профилировать различные подходы и смотреть на данные. timeit — полезная библиотека для определения времени выполнения кусков кода. Вы можете использовать timeit для сравнения времени выполнения map(), циклов и списков:

>>> import random
>>> import timeit

>>> TAX_RATE = .08
>>> txns = [random.randrange(100) for _ in range(100000)]

>>> def get_price(txn):
...     return txn * (1 + TAX_RATE)
...
>>> def get_prices_with_map():
...     return list(map(get_price, txns))
...
>>> def get_prices_with_comprehension():
...     return [get_price(txn) for txn in txns]
...
>>> def get_prices_with_loop():
...     prices = []
...     for txn in txns:
...         prices.append(get_price(txn))
...     return prices
...
>>> timeit.timeit(get_prices_with_map, number=100)
2.0554370979998566
>>> timeit.timeit(get_prices_with_comprehension, number=100)
2.3982384680002724
>>> timeit.timeit(get_prices_with_loop, number=100)
3.0531821520007725

Здесь мы определяем три метода, каждый из которых использует свой подход для создания списка. Затем мы сообщаем timeit, что каждая из этих функций должна запускаться по 100 раз. timeit возвращает общее время, необходимое для выполнения этих 100 выполнений.

Как показывает код, наибольшее различие заключается в подходе на основе цикла и map(), причем выполнение цикла занимает на 50% больше времени. То, имеет ли это значение, зависит от потребностей вашего приложения.

Заключение

В этом посте вы узнали, как использовать list comprehension в Python для выполнения сложных задач без чрезмерного усложнения кода.

Теперь вы можете:

  • Упрощать циклы и вызовы map() с помощью использования декларативных представлений
  • Использовать условную логику в представлении
  • Создавать представления множества и словаря
  • Определять, когда ясность кода или производительность диктуют альтернативный подход

Всякий раз, когда вам нужно выбрать метод создания списка, попробуйте несколько реализаций и подумайте, что легче всего прочитать и понять в вашем конкретном сценарии. Если важна производительность, можно использовать инструменты профилирования, которые дадут вам действенные данные.

Помните, что хотя list comprehensions привлекает к себе большое внимание, ваша интуиция и способность использовать расчетные данные, помогут вам написать чистый код, который выполняет поставленную задачу. Это, в конечном счете, ключ к созданию кода в духе Python!