Синтаксис Python
July 27, 2022

Полный гайд по List Comprehensions в Python

Привет! В Python существует множество способов решить одну и ту же задачу, но не все подходы можно считать Pythonic. Это такой код ("питоничный"), который не просто работает, а решает элегантно, кратко и максимально выразительно, используя возможности языка по максимуму.

В этой статье мы досконально разберем один из таких инструментов — List Comprehensions (их еще называют списковыми включениями). Если ты все еще создаешь списки через for и .append(), приготовься к серьезному апгрейду своего инструментария. И рассмотрим мы не только основы, но и продвинутые техники, которые могут быть полезны и опытным специалистам.

Зачем нужны List Comprehensions? Проблема классического цикла

Прежде чем изучать новый инструмент, давай поймем, какую проблему он решает. Представим простую задачу: нам нужен список, содержащий квадраты чисел от 0 до 9.

Если мыслить в рамках базовых конструкций языка, решение "в лоб" будет выглядеть так:

# Классический подход: цикл for и метод .append()
squares = []  # 1. Инициализируем пустой список

for i in range(10):
    squares.append(i * i)  # 2. Наполняем его в цикле

print(squares)
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Этот код абсолютно корректен и работает. Но давай посмотрим на него критически:

  1. Многословность. Нам потребовалось три строки для операции, суть которой можно описать в нескольких словах: "создай список квадратов".
  2. Логический разрыв. Мы сначала объявляем пустой список squares в одном месте, а наполняем его — в другом. В нашем простом примере это не кажется проблемой, но в реальном коде между этими двумя действиями могут оказаться десятки других строк, что ухудшает читаемость. Мы видим список и не сразу понимаем, откуда в нем появятся данные.

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

И здесь на сцену выходят list comprehensions.

Разбираем синтаксис List Comprehension

Давай возьмем наш предыдущий пример с квадратами чисел и перепишем его с использованием list comprehension. Сравни два подхода:

# Классический цикл
squares_loop = []
for i in range(10):
    squares_loop.append(i * i)

# List comprehension
squares_comp = [i * i for i in range(10)]

print(squares_loop == squares_comp)
# True

Результат идентичен, но решение с comprehension уместилось в одну строку. Оно объединяет идею создания списка и логику его наполнения в единую, неделимую конструкцию.

Базовый синтаксис выглядит так:

[expression for item in iterable]

Чтобы его было проще читать и запомнить, можно разбить эту конструкцию на три семантических блока:

  1. expression — Что делаем? Это выражение, которое будет вычислено для каждого элемента и станет частью нового списка. В нашем примере это i * i.
  2. for item in iterable — Откуда берем элементы? Это обычный цикл for, который перебирает исходную последовательность (например, range(10)).
  3. [] — Квадратные скобки говорят Python, что результатом всей этой операции должен быть новый список.

Но это еще не все. Часто нам нужно не просто преобразовать элементы, а отфильтровать их. В list comprehensions для этого есть необязательный блок if:

[expression for item in iterable if condition]

  • if condition — При каком условии? Этот блок позволяет включить в результирующий список только те элементы, для которых condition истинно.

Например, создадим список квадратов только для нечетных чисел:

# Нам нужны квадраты только для нечетных чисел от 0 до 9
odd_squares = [i * i for i in range(10) if i % 2 != 0]

print(odd_squares)
# [1, 9, 25, 49, 81]

Здесь выражение i * i будет вычислено только для тех i, которые прошли проверку if i % 2 != 0.

Эта полная форма — «Что, Откуда, Когда» — и есть вся суть list comprehensions.

Базовые сценарии использования

Теория — это хорошо, но давай рассмотрим несколько типовых задач, где list comprehensions показывают себя во всей красе. Я разделил их на категории, чтобы ты мог распознавать эти паттерны в своем коде.

Фильтрация: отбор элементов по условию

Это классика. У нас есть список, и мы хотим получить новый, содержащий только элементы, которые удовлетворяют определенному критерию.

Задача: Из списка чисел оставить только положительные.

numbers = [10, -5, 0, 8, -1, 3]
positive_numbers = [num for num in numbers if num > 0]

print(positive_numbers)
# [10, 8, 3]

Задача: Из списка строк убрать пустые строки-заглушки.

data = ["user1", "", "user2", " ", "user3"]
# Пустая строка и строка с пробелом при булевом преобразовании дадут False
# Метод .strip() уберет пробелы по краям
non_empty_data = [item.strip() for item in data if item.strip()]

print(non_empty_data)
# ['user1', 'user2', 'user3']

Обрати внимание на небольшой нюанс в последнем примере. Мы используем item.strip() и в выражении, и в условии. Чуть позже мы разберем, как сделать такой код еще эффективнее.

Трансформация: применение операции к каждому элементу

Здесь мы берем каждый элемент исходной последовательности и как-то его изменяем, создавая новый список.

Задача: Список цен в долларах нужно перевести в другую валюту по заданному курсу.

prices = [12.5, 20, 8.75]
exchange_rate = 100

new_prices = [price * exchange_rate for price in prices_usd]

print(new_prices )
# [1193.75, 1910.0, 835.625]

Задача: Получить список длин всех слов в предложении.

sentence = "The quick brown fox jumps over the lazy dog"
word_lengths = [len(word) for word in sentence.split()]

print(word_lengths)
# [3, 5, 5, 3, 5, 4, 3, 4, 3]

Создание сложных структур

Comprehensions позволяют удобно работать с вложенными циклами.

Задача: Создать "плоский" список (flatten) из списка списков.

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

# Классический подход
flat_list_loop = []
for row in matrix:
    for item in row:
        flat_list_loop.append(item)

# Подход с list comprehension
flat_list_comp = [item for row in matrix for item in row]

print(flat_list_comp)
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

Обрати внимание: порядок for во вложенном comprehension точно такой же, как и в классическом варианте с циклами. Это упрощает переход от одного синтаксиса к другому.

А почему не sum(matrix, [])? Хотя этот код выглядит короче, он крайне неэффективен. На каждом шаге sum создает новый временный список, что приводит к очень медленной работе на больших данных. List comprehension — это идиоматичный способ для этой задачи.

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

Задача: Транспонировать матрицу (поменять строки и столбцы местами).

matrix = [
    [1, 2, 3], 
    [4, 5, 6], 
    [7, 8, 9]
]

transposed = [[row[i] for row in matrix] for i in range(len(matrix[0]))]

# Внешний цикл `for i in range(3)` перебирает индексы столбцов.
# Внутренний цикл `[row[i] for row in matrix]` строит новый столбец,
# собирая i-й элемент из каждой строки.

print(transposed)
# [[1, 4, 7], [2, 5, 8], [3, 6, 9]]

Как видишь, эти три паттерна — фильтрация, трансформация и генерация — покрывают огромное число ежедневных задач.

Вопросы производительности: действительно ли comprehensions быстрее?

Споры о производительности — вечная тема в программировании. Принято считать, что list comprehensions работают быстрее, чем эквивалентные циклы for. Давай разберемся, так ли это и почему.

Причина превосходства comprehensions кроется в том, как Python их выполняет. Когда интерпретатор видит list comprehension, он исполняет итерацию в более оптимизированном слое, близком к C-реализации. В обычном цикле for на каждой итерации происходит больше шагов на уровне интерпретатора (например, поиск метода .append у объекта списка и его вызов), что вносит небольшие, но накапливающиеся издержки.

Но лучший способ что-то проверить — провести замер. Для этого мы используем стандартный модуль timeit. Он многократно запускает код, чтобы получить усредненный и более точный результат, исключая случайные флуктуации системы.

Сравним три подхода к созданию списка квадратов миллиона чисел:

  1. Классический цикл for.
  2. List comprehension.
  3. Функция map с lambda-функцией (часто рассматривается как альтернатива).
# setup.py - код, который будет выполнен один раз перед замерами
# Здесь мы определяем функции для тестирования
SETUP_CODE = """
def classic_loop(n):
    result = []
    for i in range(n):
        result.append(i*i)
    return result

def list_comp(n):
    return [i*i for i in range(n)]

def map_lambda(n):
    return list(map(lambda i: i*i, range(n)))
"""

# test_cases.py - код, который мы будем замерять
# Мы вызываем функции, определенные в setup, с одним и тем же аргументом
N = 5_000_000
TEST_CODE_LOOP = f"classic_loop({N})"
TEST_CODE_COMP = f"list_comp({N})"
TEST_CODE_MAP = f"map_lambda({N})"

import timeit

# Запускаем замеры. number=10 означает, что каждый тест выполнится 10 раз
# timeit вернет общее время за все запуски
time_loop = timeit.timeit(stmt=TEST_CODE_LOOP, setup=SETUP_CODE, number=10)
time_comp = timeit.timeit(stmt=TEST_CODE_COMP, setup=SETUP_CODE, number=10)
time_map = timeit.timeit(stmt=TEST_CODE_MAP, setup=SETUP_CODE, number=10)

print(f"Классический цикл: {time_loop:.4f} сек")
print(f"List comprehension: {time_comp:.4f} сек")
print(f"map() + lambda:    {time_map:.4f} сек")

# Вывод на моей машине (у тебя он может отличаться):
# Классический цикл: 6.1010 сек
# List comprehension: 5.5822 сек
# map() + lambda:    9.1955 сек

Выводы из бенчмарка:

  1. List comprehension — явный победитель. Наш тест убедительно показывает, что list comprehension является самым быстрым способом для решения этой задачи, опережая и классический цикл, и map.
  2. map с lambda — не всегда хорошая идея. Вопреки распространенному мнению, map в паре с lambda-функцией часто оказывается медленнее. Это связано с высокими издержками на вызов Python-функции (lambda) на каждой итерации из C-кода map.
  3. Читаемость и скорость здесь идут рука об руку. List comprehension в данном случае — это решение, которое одновременно и самое производительное, и одно из самых читаемых.

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

Расширяем горизонты: продвинутые техники

Освоив базу, можно рассмотреть и более сложные конструкции. Но здесь важно помнить золотое правило: краткость не должна вредить читаемости.

Тернарный оператор для условного выражения

Что если нам нужно не просто отфильтровать элементы, а применить к ним разную логику в зависимости от условия? Например, для списка чисел мы хотим получить строку "even" для четных и "odd" для нечетных.

Здесь на помощь приходит тернарный условный оператор value_if_true if condition else value_if_false.

# Классический if-else в цикле
result = []
for i in range(5):
    if i % 2 == 0:
        result.append("even")
    else:
        result.append("odd")

# Элегантное решение с тернарным оператором
result_comp = ["even" if i % 2 == 0 else "odd" for i in range(5)]

print(result_comp)
# ['even', 'odd', 'even', 'odd', 'even']

Важный синтаксический момент: Обрати внимание, что конструкция if-else пишется до цикла for. Это потому, что она является частью expression (блока "Что делаем?"). Простой фильтрующий if без else пишется после цикла, так как он относится к блоку "При каком условии?". Не путай их.

Каскадные фильтры if

Ты можешь использовать несколько if подряд. Это не аналог if ... and ..., а скорее вложенные if.

Задача: Найти все числа от 0 до 99, которые делятся и на 7, и на 11.

# Несколько if подряд
multiples = [i for i in range(100) if i % 7 == 0 if i % 11 == 0]

# Это полностью эквивалентно вложенным if:
# result = []
# for i in range(100):
#     if i % 7 == 0:
#         if i % 11 == 0:
#             result.append(i)

print(multiples)
# [0, 77]

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

Оптимизация вычислений с помощью оператора :=

Вспомни наш пример с очисткой списка строк. Там мы дважды вызывали .strip(). На такой простой операции это не страшно, но если бы вместо .strip() была "тяжелая" функция, мы бы делали двойную работу.

# Неэффективно: "тяжелая" функция func() вызывается дважды
# cleaned_data = [func(item) for item in data if func(item)]

В Python 3.8 появился моржовый оператор (:=), который решает эту проблему элегантнее всего. Он позволяет присвоить значение переменной прямо внутри выражения.

Задача: Очистить список строк от пустых значений, вызвав .strip() только один раз.

data = ["user1", "", "user2", " ", "user3"]

# С помощью моржового оператора
cleaned_data = [
    cleaned_item
    for item in data
    if (cleaned_item := item.strip())  # Вызываем .strip() и присваиваем результат
]

print(cleaned_data)
# ['user1', 'user2', 'user3']

Здесь item.strip() выполняется только один раз. Его результат присваивается переменной cleaned_item, которая тут же используется в if для проверки на истинность (непустая строка) и затем, если проверка пройдена, попадает в итоговый список. Это идиоматичный, читаемый и производительный способ избежать повторных вычислений.

Неочевидные возможности и приемы

Этот раздел — для самых любознательных. Здесь мы рассмотрим техники, которые расширяют возможности list comprehensions, но использовать их стоит с осторожностью. Главный вопрос, который нужно себе задавать: "Поймет ли мой коллега этот код через полгода?".

Обработка исключений внутри comprehension

Напрямую в синтаксисе list comprehension нет конструкции try-except. Что если мы работаем с "грязными" данными и хотим обработать возможные ошибки, не прерывая цикл?

Решение: вынести логику с try-except в отдельную функцию-обертку.

Задача: Преобразовать список строк в числа, игнорируя некорректные значения.

def to_int_safe(value):
    """Безопасно преобразует значение в int, возвращая None в случае ошибки."""
    try:
        return int(value)
    except (ValueError, TypeError):
        return None

raw_data = ["10", "25", "не число", "42", None, "100"]

# Используем нашу безопасную функцию в comprehension
# и сразу же отфильтровываем None
numbers = [num for item in raw_data if (num := to_int_safe(item)) is not None]

print(numbers)
# [10, 25, 42, 100]

Мы инкапсулировали логику обработки ошибок в переиспользуемую функцию, а сам comprehension остался ясным и декларативным. Это не идеальное решение, учитывая, что нам нужна вспомогательная функция, но это лучшее, что мы можем сделать, поскольку предложение (PEP 463), в котором пытались ввести соответствующий синтаксис, было отклонено.

Синергия с itertools

List comprehensions бывает удобно использовать в паре с модулем itertools. Эта библиотека предоставляет высокопроизводительные строительные блоки для сложных итераций, а comprehensions позволяют собирать из них результат.

Прием 1: Имитация break для прерывания итерации

В list comprehension нет аналога оператора break. Но мы можем добиться того же эффекта с помощью itertools.takewhile.

Задача: Взять все числа из списка до первого отрицательного.

from itertools import takewhile

data = [2, 8, 10, 4, -5, 12, 7]

# takewhile создает итератор, который возвращает элементы
# до тех пор, пока предикат (lambda-функция) истинен.
result = [x for x in takewhile(lambda n: n > 0, data)]

print(result)
# [2, 8, 10, 4]

Использование takewhile — это самый "питоничный" способ решить такую задачу. Он явно говорит о нашем намерении: "брать, пока условие выполняется".

Прием 2: Работа с соседними элементами

Задача: "Отменить" накопительную сумму, то есть найти разницу между соседними элементами в списке.

from itertools import pairwise  # Доступен в Python 3.10+

cumulative_sums = [100, 104, 109, 121, 129, 130, 140]

# pairwise(iterable) возвращает итератор по парам соседних элементов:
# (100, 104), (104, 109), (109, 121) и т.д.
original_values = [y - x for x, y in pairwise(cumulative_sums)]

print(original_values)
# [4, 5, 12, 8, 1, 10]

Эти примеры показывают, что list comprehension — это не просто синтаксический сахар, а гибкий инструмент, который в правильных руках способен на многое.

За гранью списков: краткий взгляд на dict и set comprehensions

Мы досконально разобрали всё, что касается list comprehensions. Однако этот подход не ограничивается одними лишь списками.

В Python существуют абсолютно аналогичные конструкции для создания словарей (dict) и множеств (set).

  • Set Comprehensions используют фигурные скобки {} и позволяют создавать множества — коллекции уникальных элементов. Это удобно, когда нужно получить уникальные значения из другой коллекции.
  • Dict Comprehensions также используют фигурные скобки {}, но с парой ключ: значение. Они позволяют конструировать словари из других данных, например, "переворачивать" существующий словарь или создавать его из списка кортежей.

Мы не будем углубляться в них здесь, так как каждая из этих тем заслуживает собственного исчерпывающего гайда. Просто запомни: освоив list comprehensions, ты уже на 90% освоил и их "собратьев" для словарей и множеств.

Хочешь копнуть глубже? Этим темам посвящены наши отдельные подробные руководства:

Заключение: принцип разумной достаточности

Мы прошли путь: от проблемы классического цикла for до продвинутых техник с itertools и моржовым оператором. Теперь ты знаешь, что list comprehensions — это не просто "синтаксический сахар", а полезный производительный инструмент в арсенале Python-разработчика. Они позволяют писать код, который ближе к описанию сути задачи, а не к пошаговой инструкции для машины.

Но, овладев этой силой, важно научиться применять ее с умом. И если бы из всей этой статьи я попросил тебя запомнить только одну вещь, это была бы она:

Код пишется один раз, а читается — десятки.

Прежде чем написать сложный list comprehension с тройной вложенностью и каскадом if, остановись на секунду и задай себе вопрос: "Будет ли этот код понятен мне самому через три месяца? А моему коллеге, который увидит его впервые?".

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


P.S. Если статья сэкономила тебе время или научила чему-то новому, лучшей благодарностью будет поддержка проекта. Это позволяет мне и дальше создавать качественный и бесплатный контент для всех. Спасибо, что дочитал до конца!