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

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

В Python, кроме уже известных нам функций и методов, существует удобный способ обработки и создания списков — списочные выражения (list comprehensions). Рассмотрим их применение на примере.

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

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

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

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

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

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

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 и запиши очередной элемент в результирующий список, если для элемента выполняется условие".

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

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

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

В списочных выражениях можно использовать вложенные циклы. Считаем с клавиатуры матрицу целых чисел размерностью 5 на 5 (5 строк, 5 столбцов). Числа в строке разделены пробелом, количество строк равно 5. При этом программа не следит за количеством чисел в строке, так как используется метод split(); поэтому в общем случае приведённая программа может считать не матрицу в математическом смысле, а вложенный список.

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 на 5 элементов, состоящего из нулей. Неправильный подход к решению приведёт нас к следующему коду:

zeros = [[0] * 5] * 5

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

zeros = [[0] * 5] * 5
print(zeros)
zeros[0][0] = 1
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]]
[[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]]

Изменение элемента внутреннего списка с индексом 0 привело к изменению всех аналогичных элементов в других внутренних списках.

Для правильного решения задачи используем следующее списочное выражение:

zeros = [[0] * 5 for i in range(5)]
print(zeros)
zeros[0][0] = 1
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]]
[[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)

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

['Беларусь', 'Бельгия']

Списочные выражения можно применять для создания словарей. Напишем программу, которая из списка пар "(ключ, значение)" сгенерирует соответствующий словарь:

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

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

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

Если говорить более подробно, то списочное выражение может возвращать не только список, но и генератор. То есть можно не сохранять сразу все значения списочного выражения, а получать их, например, в процессе прохода в цикле for. Для этого вместо квадратных скобок укажем круглые. Вернём в первом примере не список, а генератор:

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

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

<generator object <genexpr> at 0x00000266CEA0CAC0>

Программа не ожидала ввода данных, а вывела сразу информацию о переменной numbers. Так произошло, потому что мы нигде в программе не использовали значения итератора, а следовательно и не понадобилось их вводить. Для получения значений итератора мы можем брать их по одному в цикле или преобразовать итератор в коллекцию (например, список) и записать в эту коллекцию сразу все элементы.

Давайте проверим, сколько памяти будет занимать итератор и соответствующий ему список. Для этого воспользуемся функцией getsizeof() стандартного модуля sys. Эта функция возвращает размер занимаемой объектом памяти в байтах. Итератор создадим с помощью известной нам стандартной функции range().

from sys import getsizeof

# Создаём итератор из одного миллиона целых чисел
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)} байт.")

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

Итератор занимает 112 байт.
Список занимает 8000056 байт.

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

Давайте также сравним скорость работы итератора и списка. В первой программе объединим строки итератора с помощью метода join(). Во второй — сделаем то же самое, но для списка. Сами значения сгенерируем с помощью стандартной функции range(). Выведем для обоих вариантов суммарное затраченное на 10 циклов время в секундах с точностью 1 мс.

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 хранит в памяти значения переменных. Когда мы создаём в программе переменную, интерпретатор назначает ей уникальное число — идентификатор. Узнать идентификатор переменной можно с помощью встроенной функции id(). Каждый раз, когда в уже существующую переменную в программе записывается новое значение, ей назначается новый идентификатор. Проверим это на примере:

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

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

2198530255152
2198530255184

Мы видим, что идентификатор переменной x поменялся при записи нового значения.

На самом деле, в самих переменных хранится вовсе не значение, а ссылка на это значение в некоторой области оперативной памяти компьютера. Это означает, что если в программе присвоить значение одной переменной в другую, то обе переменные будут иметь одинаковые ссылки на это значение, и переменные будут одним объектом с одинаковым идентификатором, а встроенный оператор is, проверяющий, что объекты являются одним и тем же (имеют одинаковые идентификаторы), для этих переменных вернёт значение True:

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

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

2109716588848
2109716588848
True

Обратите внимание, что равенство переменных не означает, что они являются одним и тем же объектом в программе (имеют одинаковые ссылки на значения):

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

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

Переменные неизменяемых типов данных могут изменить своё значение только путём создания новой переменной с тем же именем и с новым идентификатором. К неизменяемым типам относят int, float, str, tuple, frozenset (подробнее о типе данных frozenset можно почитать в документации). Изменение идентификатора целочисленной переменной при операции присваивания было показано в одном из примеров выше.

К изменяемым типам данных относят set, list, dict. Переменные изменяемых типов данных могут изменить своё значение без создания новой переменной с тем же именем. При этом у переменной сохранится тот же идентификатор. Для этого можно воспользоваться операциями и методами, которые меняют значения в коллекции. Покажем, что метод append() и операция += для списков меняет исходный объект, не создавая новый:

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
print(x is y)
x[0] = 0
print(x)
print(y)
print(x is y)

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

True
[0, 2, 3]
[0, 2, 3]
True

В примере мы видим, что до и после изменения списка x, список y имеет ту же ссылку на значение в памяти. То есть x и y являются одним и тем же объектом в программе. Поэтому изменение значение в списке x вызвало и изменение списка y. Хотя теперь мы знаем, что никакого изменения списка y не было, просто изменилось общее значение, на которое ссылаются переменные x и y.

Если создать копию списка x с помощью среза, включающего все его элементы, то будет создан новый, независимый от исходного список с новым идентификатором:

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

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

False
[0, 2, 3]
[1, 2, 3]
False

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

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 = [[1, 2, 3],
           [4, 5, 6],
           [7, 8, 9]]
numbers_copy = [elem[:] for elem in numbers]
print([numbers_copy[i] is numbers[i] for i in range(len(numbers))])

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

[False, False, False]

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

Полный срез для копирования списка можно заменить стандартным методом copy(). А для копирования вложенных коллекций можно использовать функцию deepcopy() из модуля copy. Пример копирования вложенного списка с использованием deepcopy:

from copy import deepcopy

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

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

[False, False, False]

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

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

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

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

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

Здесь мы рассмотрим функции стандартной библиотеки itertools для обработки коллекций.