В 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]