3.3. Списочные выражения. Модель памяти для типов языка

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

Ключевые вопросы статьи

  • Что такое списочные выражения и зачем они нужны?
  • Чем генераторы отличаются от списков?
  • Как добавить условия и вложенные циклы в списочные выражения?
  • Что такое изменяемые и неизменяемые типы в Python?
  • Как Python хранит переменные в памяти и почему важно понимать модель хранения?

Что такое списочные выражения и зачем они нужны

В Python, помимо стандартных конструкций — цикла for и встроенных функций вроде append() или range(), — есть более лаконичный способ создавать списки. Это списочные выражения (англ. list comprehensions).

Примечание

Напомним: в предыдущих статьях вы уже научились перебирать элементы в цикле for, добавлять значения в список с помощью метода.append(), использовать range() для генерации чисел и преобразовывать строки в числа с помощью int(). Теперь всё это можно объединить в одну строку.

Пример: ввод чисел и создание списка

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

numbers = []
for i in range(5):
    numbers.append(int(input()))
print(numbers)

Теперь та же программа, но с использованием списочного выражения:

numbers = [int(input()) for i in range(5)]
print(numbers)

Здесь цикл for встроен внутрь выражения — и на каждой итерации значение добавляется прямо в список. Такой подход делает код более читаемым и лаконичным, особенно когда нужно получить список преобразованных значений.

Чем генераторы отличаются от списков и в каких задачах полезнее

Списочные выражения позволяют быстро и лаконично создавать списки. Это удобно, если вам сразу нужен весь результат — например, список чисел, строк или объектов.

Но в Python есть ещё один способ перебора значений — генераторы. Внешне они похожи на списочные выражения, но работают иначе: список создаёт все элементы сразу и хранит их в памяти, а генератор формирует значения по одному, «по запросу». Это экономит ресурсы и полезно при работе с большими объёмами данных.

Рассмотрим пример. Вы получаете от пользователя пять чисел и хотите оставить только те, которые больше среднего арифметического:

numbers = [int(input()) for i in range(5)]
avg = sum(numbers) // len(numbers)
numbers = [element for element in numbers if element > avg]
print(numbers)

Здесь работает та же логика цикла: мы проходим по всем элементам numbers, но добавляем в новый список только те, которые больше среднего. Такое выражение можно прочитать как: «добавь элемент в новый список, если он больше среднего».

Такой способ удобен, когда нужно получить итоговый список. Но, если вам нужно просто перебирать значения — например, передавать их по одному в цикл, — можно использовать генератор:

numbers = (int(input()) for i in range(5))

Это выражение возвращает генератор-объект, который выдаёт значения по одному — не загружая в память весь список сразу.

Важно: не жертвуйте производительностью

На первый взгляд, можно упростить код, сразу подставив sum(numbers) // len(numbers) в условие:

numbers = [int(input()) for i in range(5)]
numbers = [element for element in numbers if element > sum(numbers) // len(numbers)]
print(numbers)

Но здесь кроется ошибка производительности: sum(numbers) будет пересчитываться заново при каждой итерации. Это значит, что для списка из 5 элементов функция sum() вызовется 5 раз, а при 1000 элементах — 1000 раз.

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

Как добавить условия и вложенные циклы в списочные выражения

Списочные выражения позволяют не только фильтровать значения, но и включать вложенные циклы. Это особенно полезно при работе с матрицами, строками, словарями и другими составными структурами данных.

Ввод матрицы с клавиатуры

Допустим, нужно считать пять строк чисел, где каждое число в строке разделено пробелом:

matrix = [[int(x) for x in input().split()] for i in range(5)]
print(matrix)

Пример ввода:

1 2 3 4 5
2 3 4 5 6
3 4 5 6 7
4 5 6 7 8
5 6 7 8 9

Вывод программы:

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

Такое выражение можно прочитать как:

«Сделай 5 итераций. На каждой итерации считай строку, разбей её на элементы, преврати каждый в число и добавь полученный список в матрицу».

Ошибка: копирование одной и той же строки

Попробуем создать двумерный список из нулей:

zeros = [[0] * 5] * 5
print(zeros)

# Вывод программы:
# [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]

На первый взгляд, всё работает. Попробуем изменить первый элемент первого списка в списке.

zeros[0][0] = 1
print(zeros)

# Вывод программы:
# [[1, 0, 0, 0, 0], [1, 0, 0, 0, 0], [1, 0, 0, 0, 0], [1, 0, 0, 0, 0], [1, 0, 0, 0, 0]]

Результат удивляет — изменилось не только одно значение, а весь первый столбец.

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

Правильный способ: новый список на каждой итерации

Решение — использовать списочное выражение:

zeros = [[0] * 5 for i in range(5)]
print(zeros)

# Вывод программы:
# [[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]] 

Теперь каждый внутренний список создаётся заново, и изменения не распространяются:

zeros[0][0] = 1
print(zeros)

# Вывод программы:
# [[1, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]

Преобразование строк в коды символов

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

text = "Строка символов"
codes = [ord(symbol) for symbol in text]
print(codes)

Результат — список кодов каждого символа.

# Вывод программы:
# [1057, 1090, 1088, 1086, 1082, 1072, 32, 1089, 1080, 1084, 1074, 1086, 1083, 1086, 1074]

Фильтрация словаря

Списочные выражения работают и с парами ключ — значение:

countries = {
    "Россия": ["русский"],
    "Беларусь": ["белорусский", "русский"],
    "Бельгия": ["немецкий", "французский", "нидерландский"],
    "Вьетнам": ["вьетнамский"]
}

multiple_lang = [country for (country, lang) in countries.items() if len(lang) > 1]

print(multiple_lang)

# Вывод программы:
# ['Беларусь', 'Бельгия']

Создание словаря из списка пар

С помощью словарного включения можно собрать dict из списка:

countries = {country: capital for country, capital in [
    ("Россия", "Москва"),
    ("Беларусь", "Минск"),
    ("Сербия", "Белград")
]}
print(countries)

# Вывод программы:
# {'Россия': 'Москва', 'Беларусь': 'Минск', 'Сербия': 'Белград'}

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

В Python генераторы позволяют создавать последовательности значений «на лету» — без хранения всех элементов в памяти. Они похожи на списочные выражения, но вместо квадратных скобок используют круглые:

numbers = (int(input()) for i in range(5))
print(numbers)

Этот код не выведет числа, а покажет объект генератора:

# Вывод программы:
# <generator object <genexpr> at 0x00000266CEA0CAC0>

Чтобы получить значения, нужно проитерироваться по генератору — например, с помощью for или передав в list().

Генераторы и память

Главное преимущество генераторов — экономия памяти. Они не создают всю коллекцию сразу, а вычисляют значения по одному:

numbers_iter = (i for i in range(10 ** 6))  # генератор
print(f"Итератор занимает {getsizeof(numbers_iter)} байт.")
numbers_list = list(range(10 ** 6))         # список
print(f"Список занимает {getsizeof(numbers_list)} байт.")

Важно

Переменная numbers_iter — это генератор, который реализует протокол итератора. Он сам по себе занимает очень мало места — в данном случае всего 112 байт, независимо от потенциального количества элементов.

# Вывод программы:
# Итератор занимает 112 байт.
# Список занимает 8000056 байт

Генераторы и производительность

Генератор может быть быстрее, особенно при работе с большим объёмом данных:

from timeit import timeit

print(round(timeit("s = '; '.join(str(x) for x in range(10 ** 7))", number=10), 3))

print(round(timeit("s = '; '.join([str(x) for x in list(range(10 ** 7))])", number=10), 3))

Первая строка использует генератор, вторая — список. Разница ощутима:

# Вывод программы (зависит от ресурсов системы):
# 22.914
# 25.576

Из результата видно, что итератор работает быстрее, чем список, при условии обработки большого количества элементов.

Что такое изменяемые и неизменяемые типы в Python

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

Чтобы понять, чем отличаются эти типы, рассмотрим, как Python работает с переменными на уровне хранения данных.

Как работает присваивание

Когда вы создаёте переменную, Python сохраняет объект в памяти и присваивает переменной ссылку на этот объект. У каждого объекта есть идентификатор — уникальный номер, который можно узнать с помощью функции id().

Посмотрим, что произойдёт при изменении значения переменной:

x = 5
print(id(x))
x = 10
print(id(x))

# Вывод программы (значения будут меняться от запуска к запуску программы):
# 2198530255152
# 2198530255184

Идентификатор изменился. Это значит, что Python создал новый объект в памяти, а переменная x теперь ссылается на него.

Как Python хранит переменные в памяти и почему важно понимать модель хранения

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

Переменные — это ссылки

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

x = 1
y = x
print(id(x))
print(id(y))
print(x is y)

# Примерный вывод:
# 2109716588848
# 2109716588848 
# True

Оператор is показывает, что x и y — один и тот же объект в памяти. Они не просто равны по значению (==), но и указывают на один идентификатор объекта (is).

Равны, но не одинаковы

Однако равенство значений ещё не означает, что переменные ссылаются на один и тот же объект:

x = [el ** 2 for el in range(5)]
y = [el ** 2 for el in range(5)]
print(x == y)
print(x is y)

# Результат:
# True
# False

Значения одинаковые, но x и y — разные списки в памяти, у них разные идентификаторы.

Изменяемые и неизменяемые типы

  • Неизменяемые типы: int, float, str, tuple, frozenset.
  • Изменяемые типы: list, set, dict.

У неизменяемых объектов при попытке изменить значение создаётся новый объект с новым идентификатором:

x = 5
print(f'{x}, id = {id(x)}')
x = 10
print(f'{x}, id = {id(x)}')

# Результат:
# 5, id = 4311950120
# 10, id = 4311950280

У изменяемых объектов — наоборот, можно изменить значение, не меняя идентификатор:

numbers = [1, 2, 3]
print(f"{numbers}, id = {id(numbers)}")
numbers += [4]
print(f"{numbers}, id = {id(numbers)}")

# Результат:
# [1, 2, 3], id = 2095932585856
# [1, 2, 3, 4], id = 2095932585856

Но если используется операция присваивания, даже изменяемый объект будет заменён на новый:

numbers = [1, 2, 3]
print(f"{numbers}, id = {id(numbers)}")
numbers = numbers + [4]
print(f"{numbers}, id = {id(numbers)}")

# Результат:
# [1, 2, 3], id = 1438332303232  
# [1, 2, 3, 4], id = 1438341470336

Осторожно: ссылки на один и тот же объект

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

x = [1, 2, 3]
y = x
x[0] = 0
print(x)
print(y)

# Результат:
# [0, 2, 3]  
# [0, 2, 3]

Здесь x и y — один и тот же список в памяти. Мы изменили x, и это автоматически изменило y.

Поверхностная и глубокая копия

Чтобы создать отдельный объект, можно использовать срез:

x = [1, 2, 3]
y = x[:]
x[0] = 0
print(x)
print(y)

# Результат:
# [0, 2, 3]  
# [1, 2, 3]

x и y теперь разные объекты — изменение одного не затрагивает другой.

Но если список вложенный, срез копирует только внешний список:

numbers = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
numbers_copy = numbers[:]
print([numbers_copy[i] is numbers[i] for i in range(len(numbers))])

# Результат:
# [True, True, True]

Внутренние списки остались одними и теми же объектами.

Как создать настоящую копию вложенного списка

Чтобы скопировать всё содержимое, можно использовать списочное выражение:

numbers_copy = [elem[:] for elem in numbers]

Проверим:

print([numbers_copy[i] is numbers[i] for i in range(len(numbers))])

# Результат:
# [False, False, False]

Теперь это полноценная независимая копия.
Альтернатива — использовать функцию deepcopy из модуля copy:

from copy import deepcopy
numbers_copy = deepcopy(numbers)

Важно

  • При работе с коллекциями и функциями вы должны понимать, где копия, а где ссылка.
  • Ошибки с мутацией общего объекта — одни из самых частых в коде на Python.
  • Модель ссылок помогает писать предсказуемый и устойчивый код, особенно при передаче параметров и работе с вложенными структурами.

Что дальше

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

В следующей статье мы сделаем ещё один шаг вперёд: познакомимся со встроенными возможностями Python для работы с коллекциями. Вы увидите, как с помощью функций из стандартной библиотеки itertools, а также enumerate() и zip() можно перебирать, комбинировать, накапливать и повторять элементы — причём без лишнего кода и с минимальной нагрузкой на память.

Ключевые выводы статьи

  • Списочные выражения позволяют компактно создавать списки, объединяя цикл for и условие в одной строке.
  • Внутри списочных выражений можно использовать условия и вложенные циклы, включая преобразование вложенных структур (например, матриц).
  • Генераторы создаются с помощью круглых скобок и возвращают значения по одному — это экономит память при работе с большими объёмами данных.
  • В Python переменные хранят ссылку на объект в памяти, а не само значение; идентификатор объекта можно получить с помощью id(). Изменяемые типы (например, list, dict, set) можно модифицировать без создания нового объекта. Неизменяемые типы (int, str, tuple) при изменении создаются заново.
  • При копировании коллекций важно различать поверхностную копию (x[:]) и глубокую копию (deepcopy()), особенно при работе со вложенными структурами.