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

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

Ключевые вопросы параграфа

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1matrix = [[int(x) for x in input().split()] for i in range(5)]
2print(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 итераций. На каждой итерации считай строку, разбей её на элементы, преврати каждый в число и добавь полученный список в матрицу».

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

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

1zeros = [[0] * 5] * 5
2print(zeros)
3
4# Вывод программы:
5# [[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]]

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

1zeros[0][0] = 1
2print(zeros)
3
4# Вывод программы:
5# [[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]]

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

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

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

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

1zeros = [[0] * 5 for i in range(5)]
2print(zeros)
3
4# Вывод программы:
5# [[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]] 

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

1zeros[0][0] = 1
2print(zeros)
3
4# Вывод программы:
5# [[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]]

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

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

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

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

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

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

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

1countries = {
2    "Россия": ["русский"],
3    "Беларусь": ["белорусский", "русский"],
4    "Бельгия": ["немецкий", "французский", "нидерландский"],
5    "Вьетнам": ["вьетнамский"]
6}
7
8multiple_lang = [country for (country, lang) in countries.items() if len(lang) > 1]
9
10print(multiple_lang)
11
12# Вывод программы:
13# ['Беларусь', 'Бельгия']

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

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

1countries = {country: capital for country, capital in [
2    ("Россия", "Москва"),
3    ("Беларусь", "Минск"),
4    ("Сербия", "Белград")
5]}
6print(countries)
7
8# Вывод программы:
9# {'Россия': 'Москва', 'Беларусь': 'Минск', 'Сербия': 'Белград'}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1x = 5
2print(id(x))
3x = 10
4print(id(x))
5
6# Вывод программы (значения будут меняться от запуска к запуску программы):
7# 2198530255152
8# 2198530255184

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

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

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

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

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

1x = 1
2y = x
3print(id(x))
4print(id(y))
5print(x is y)
6
7# Примерный вывод:
8# 2109716588848
9# 2109716588848 
10# True

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

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

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

1x = [el ** 2 for el in range(5)]
2y = [el ** 2 for el in range(5)]
3print(x == y)
4print(x is y)
5
6# Результат:
7# True
8# False

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

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

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

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

1x = 5
2print(id(x))
3x = 10
4print(id(x))

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

1numbers = [1, 2, 3]
2print(f"{numbers}, id = {id(numbers)}")
3numbers += [4]
4print(f"{numbers}, id = {id(numbers)}")
5
6# Результат:
7# [1, 2, 3], id = 2095932585856
8# [1, 2, 3, 4], id = 2095932585856

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

1numbers = [1, 2, 3]
2print(f"{numbers}, id = {id(numbers)}")
3numbers = numbers + [4]
4print(f"{numbers}, id = {id(numbers)}")
5
6# Результат:
7# [1, 2, 3], id = 1438332303232  
8# [1, 2, 3, 4], id = 1438341470336

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

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

1x = [1, 2, 3]
2y = x
3x[0] = 0
4print(x)
5print(y)
6
7# Результат:
8# [0, 2, 3]  
9# [0, 2, 3]

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

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

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

1x = [1, 2, 3]
2y = x[:]
3x[0] = 0
4print(x)
5print(y)
6
7# Результат:
8# [0, 2, 3]  
9# [1, 2, 3]

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

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

1numbers = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
2numbers_copy = numbers[:]
3print([numbers_copy[i] is numbers[i] for i in range(len(numbers))])
4
5# Результат:
6# [True, True, True]

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

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

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

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

Проверим:

1print([numbers_copy[i] is numbers[i] for i in range(len(numbers))])
2
3# Результат:
4# [False, False, False]

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

1from copy import deepcopy
2numbers_copy = deepcopy(numbers)

Почему это важно

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

✅ У вас получилось разобраться со списочными выражениями?

👉 Оценить этот параграф

Что дальше

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

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

А пока вы не ушли дальше — закрепите материал на практике:

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

Хотите обсудить, задать вопрос или не понимаете, почему код не работает? Мы всё предусмотрели — вступайте в сообщество Хендбука! Там студенты помогают друг другу разобраться.

Ключевые выводы параграфа

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

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.2. Множества, словари

В этом параграфе мы продолжим изучать коллекции — но теперь познакомимся с неупорядоченными типами данных. Вы узнаете, как работают множества (set) и словари (dict), чем они отличаются от списков и в каких задачах используются. Научитесь проверять, содержится ли элемент в множестве, находить пересечения и разности, а также хранить и быстро получать значения по ключам. Мы разберёмся, как создавать множества и словари, выполнять над ними операции, использовать их методы и применять в реальных примера.

Следующий параграф3.4. Встроенные возможности по работе с коллекциями

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