Python: неочевидное в очевидном
Введение
Изучение любого языка - очень долгий процесс, в ходе которого могут возникать ситуации, когда очевидные с виду вещи ведут себя странно. Даже спустя много лет изучения языка не все и не всегда могут с уверенностью сказать “да, я знаю этот на 100%, несите следующий”.
Python - один из самых популярных языков программирования на сегодняшний день, но и он имеет ряд своих нюансов, которые на протяжении многих лет изменялись, оптимизировались и теперь ведут себя немного не так, как это может показаться, глядя на строчки незамысловатого кода.
Немного строк
Метод is для сравнения строк
Рассмотрим несколько примеров, в которых работа со строками может быть не такой гладкой. Для начала необходимо создать файл (пусть будет test.py), и в нем реализовать функцию для тестирования работы строк:
def test_str():
# пример 1
a = "hello"
b = "hello"
print("Пример 1:", a is b)
# пример 2
c = "hell"
print("Пример 2:", c+"o" is a)
# пример 3
a = "hello"
b = "hello"
print("Пример 3:", a + “!” is b + “!”)
# пример 4
a, b = "hello!", "hello!"
print("Пример 4:" ,a is b)
# пример 5
a = "привет"
b = "привет"
print("Пример 5:", a is b)
# пример 6
a = "!"
b = "!"
print("Пример 6:", a is b)
test_str()
После запуска кода вывод будет таким:
Пример 1: True
Пример 2: False
Пример 3: False
Пример 4: True
Пример 5: True
Пример 6: True
В примере 1 вроде всё логично, есть 2 строки, мы проверяем, является ли одна строка другой при помощи метода is (а именно, ссылаются ли две переменные на одну строку). Так как значения обеих строки равны, то и они (по идее) должны быть равны. Но в примере 2 видно, что сравниваются 2 одинаковые строки (ведь “hell” + “o” даст в итоге “hello”), но по итогу is вернул False.
Python оптимизирует работу со строками, используя метод интернирования, то есть для некоторых неизменяемых объектов python хранит только 1 экземпляр в памяти, как следствие, если в 2х переменных хранятся одинаковые строки, то они будут ссылаться на одну ячейку в памяти. Но это верно лишь отчасти.
При запуске программы интернирование происходит до момента её выполнения, именно поэтому в случае примера 2 строка, полученная при помощи конкатенации “hell” и “o” не была интернирована. Как следствие, во время выполнения программы строка “hello” (переменной a) и строка “hello” (полученная при помощи c + “o”) не будут ссылаться на один объект в памяти. Работу python можно проверить при помощи:
import dis
def test_dis():
a = "hello"
b = "hello"
print("Пример 1:", a is b)
c = "hello"
d = "hell"
print("Пример 2:", d+"o" is c)
dis.dis(test_dis)
Вывод:
38 0 LOAD_CONST 1 ('hello')
2 STORE_FAST 0 (a)
39 4 LOAD_CONST 1 ('hello')
6 STORE_FAST 1 (b)
40 8 LOAD_GLOBAL 0 (print)
10 LOAD_CONST 2 ('Пример 1:')
12 LOAD_FAST 0 (a)
14 LOAD_FAST 1 (b)
16 IS_OP 0
18 CALL_FUNCTION 2
20 POP_TOP
42 22 LOAD_CONST 1 ('hello')
24 STORE_FAST 2 (c)
43 26 LOAD_CONST 3 ('hell')
28 STORE_FAST 3 (d)
44 30 LOAD_GLOBAL 0 (print)
32 LOAD_CONST 4 ('Пример 2:')
34 LOAD_FAST 3 (d)
36 LOAD_CONST 5 ('o')
38 BINARY_ADD
40 LOAD_FAST 2 (c)
42 IS_OP 0
44 CALL_FUNCTION 2
46 POP_TOP
48 LOAD_CONST 0 (None)
50 RETURN_VALUE
Как видно, в строке 0 и 4 в переменные a и b ссылаются на константные значения “hello”, а переменная d (в строке 26) ссылается на значение hell, после чего в 38 строке идет сложение значений из переменной d и строки “o”, и данное значение не берется из памяти (как в случае с переменными a и b), а получается новая строка с новым id.
Если же рассмотреть пример с вводом данных с клавиатуры (или из любого другого источника), то можно заметить следующий результат:
while True:
a = input("Введите a: ")
b = input("Введите b: ")
print(a, b, a is b, id(a), id(b))
Вывод:
Введите a: hello
Введите b: hello
hello hello False 2437519007408 2437519445104
Введите a: !
Введите b: !
! ! True 2437486790320 2437486790320
Введите a: W
Введите b: W
W W True 2437484664176 2437484664176
Введите a: д
Введите b: д
д д False 2713563632704 2713563632624
Все ASCII символы (ASCII строки длины 1 и 0) содержатся в python в единственном экземпляре изначально, поэтому вводя строку длиной 1 (или 0) можно быть уверенным, что это один тот же элемент в памяти, но для каждой не ASCII строки (либо строки с длиной больше 1) выделяется новое место в памяти в ходе выполнения программы.
Такая особенность хранения строк в Python позволяет экономить память, однако надо быть аккуратными при работе со строками, а именно, при сравнении строк при помощи метода is.
Конкатенация строк
Еще один пример. Конкатенирование строк в Python:
def test_str2():
import time
s1 = "Привет"
s2 = "Всем"
s3 = ","
s4 = "Кто"
s5 = "Это"
s6 = "Читает"
t = time.time()
for _ in range(1000000):
s = s1 + " " + s2 + " " + s3 + " " + s4 + " " + s5 + " " + s6
r = time.time() - t
print("Время на +: ", r)
t = time.time()
for _ in range(1000000):
s = " ".join([s1, s2, s3, s4, s5, s6])
r = time.time() - t
print("Время на join: ", r)
t = time.time()
for _ in range(1000000):
s = "{} {} {} {} {} {}".format(s1, s2, s3, s4, s5, s6)
r = time.time() - t
print("Время на format: ", r)
t = time.time()
for _ in range(1000000):
s = "%s %s %s %s %s %s" % (s1, s2, s3, s4, s5, s6)
r = time.time() - t
print("Время на %: ", r)
t = time.time()
for _ in range(1000000):
s = f"{s1} {s2} {s3} {s4} {s5} {s6}"
r = time.time() - t
print("Время на f-string: ", r)
test_str2()
Вывод:
Время на +: 0.601959228515625
Время на join: 0.3228156566619873
Время на format: 0.6226434707641602
Время на %: 0.49173593521118164
Время на f-string: 0.28386688232421875
F-string было введено в Python 3.6. Хотя существует несколько способов объединять строки, многие не задаются вопросов “зачем столько всякого?”, считая, что это всего лишь украшения языка для удобства пользования (ведь запись f”{s1} {s2}...” более понятна, чем s1 + “ ” + s2+ … + sn). Но разные методы работы со строкам расходуют разный объем памяти и времени, f-string был введен с целью ускорить работу со строками.
Проведя dis.dis() для данного метода, можно заметить, что разные методы конкатенации вызывают разные состояния, которые в свою очередь различаются по реализации.
Для “+” выполняются следующие действия:
140 52 LOAD_FAST 1 (s1)
54 LOAD_CONST 9 (' ')
56 BINARY_ADD
58 LOAD_FAST 2 (s2)
60 BINARY_ADD
62 LOAD_CONST 9 (' ')
64 BINARY_ADD
66 LOAD_FAST 3 (s3)
68 BINARY_ADD
70 LOAD_CONST 9 (' ')
72 BINARY_ADD
74 LOAD_FAST 4 (s4)
76 BINARY_ADD
78 LOAD_CONST 9 (' ')
80 BINARY_ADD
82 LOAD_FAST 5 (s5)
84 BINARY_ADD
86 LOAD_CONST 9 (' ')
88 BINARY_ADD
90 LOAD_FAST 6 (s6)
92 BINARY_ADD
94 STORE_FAST 9 (s)
Как видно, каждый раз вызывается состояние BINARY_ADD (см. подробнее оф. гитхаб cpython: строка case TARGET(BINARY_ADD)). Для метода “”.join():
140 LOAD_CONST 9 (' ')
142 LOAD_METHOD 3 (join)
144 LOAD_FAST 1 (s1)
146 LOAD_FAST 2 (s2)
148 LOAD_FAST 3 (s3)
150 LOAD_FAST 4 (s4)
152 LOAD_FAST 5 (s5)
154 LOAD_FAST 6 (s6)
156 BUILD_LIST 6
158 CALL_METHOD 1
160 STORE_FAST 9 (s)
Для format:
206 LOAD_CONST 12 ('{} {} {} {} {} {}')
208 LOAD_METHOD 4 (format)
210 LOAD_FAST 1 (s1)
212 LOAD_FAST 2 (s2)
214 LOAD_FAST 3 (s3)
216 LOAD_FAST 4 (s4)
218 LOAD_FAST 5 (s5)
220 LOAD_FAST 6 (s6)
222 CALL_METHOD 6
224 STORE_FAST 9 (s)
Для %s:
270 LOAD_CONST 14 ('%s %s %s %s %s %s')
272 LOAD_FAST 1 (s1)
274 LOAD_FAST 2 (s2)
276 LOAD_FAST 3 (s3)
278 LOAD_FAST 4 (s4)
280 LOAD_FAST 5 (s5)
282 LOAD_FAST 6 (s6)
284 BUILD_TUPLE 6
286 BINARY_MODULO
288 STORE_FAST 9 (s)
290 EXTENDED_ARG 1
Для f-string:
336 LOAD_FAST 1 (s1)
338 FORMAT_VALUE 0
340 LOAD_CONST 9 (' ')
342 LOAD_FAST 2 (s2)
344 FORMAT_VALUE 0
346 LOAD_CONST 9 (' ')
348 LOAD_FAST 3 (s3)
350 FORMAT_VALUE 0
352 LOAD_CONST 9 (' ')
354 LOAD_FAST 4 (s4)
356 FORMAT_VALUE 0
358 LOAD_CONST 9 (' ')
360 LOAD_FAST 5 (s5)
362 FORMAT_VALUE 0
364 LOAD_CONST 9 (' ')
366 LOAD_FAST 6 (s6)
368 FORMAT_VALUE 0
370 BUILD_STRING 11
372 STORE_FAST 9 (s)
374 EXTENDED_ARG 1
Сравнение объектов
Рассмотрим небольшой пример сравнения двух экземпляров классов:
def test_class():
class my_class():
pass
a, b = my_class(), my_class()
print(a == b)
print(a is b)
print(id(a) == id(b))
print(hash(a) == hash(b))
test_class()
Вывод:
False
False
False
False
Всё замечательно. Теперь посмотрим на другой пример:
def test_class2():
class my_class():
pass
print(my_class() == my_class())
print(my_class() is my_class())
print(hash(my_class()) == hash(my_class()))
print(id(my_class()) == id(my_class()))
test_class()
Вывод:
False
False
True
True
Что-то пошло не так. Вроде так же создаются 2 экземпляра класса, но при этом вывод показывает, что hash и id этих экземпляров одинаковы, но is показывает, что это получаются разные объекты. Рассмотрим еще пример:
def test_class3():
class my_class():
pass
print(my_class() == my_class())
print(my_class() is my_class())
print(hash(a:=my_class()) == hash(b:=my_class()))
print(id(c:=my_class()) == id(d:=my_class()))
test_class3()
Вывод:
False
False
False
False
Всё опять встало на свои места. Дополним немного my_class во всех примерах:
class my_class():
def __init__(self):
print("__init__")
def __del__(self):
print("__del__")
Как это работает? Когда создается экземпляр класса, он получает свой уникальный id, но как только у него вызывается метод __del__, тот самый id уже перестает принадлежать этому объекту, и точно такой же id может быть присвоен другому объекту, например:
class my_class():
pass
a = my_class()
print(id(a))
>>> 2082631974720
del a
b = my_class()
print(id(b))
>>> 2082631974720
Когда python создает объект класса, как в случае 1 (в методе test_class), то на этот объект ссылается переменная, следовательно, этот объект хранится в памяти до тех пор, пока на него что-то ссылается. В примере 3 аналогично, a := my_class() и b := my_class() - “моржовый” оператор := создает переменную a, которая во время выполнения функции print живёт и ссылается на объект, а переменная b ссылается уже на другой объект, так как у объекта в a:=my_class() не был вызван метод __del__.
Пример 2 самый интересный. Когда вызывается id(my_class()), то происходит следующее: создается экземпляр класса my_class, этот только что созданный объект передается функции id, которая берёт id объекта, затем объект уничтожается и вызывается часть id(my_class()), стоящая справа от ==. Опять создается my_class (который, как мы определили чуть выше, будет иметь такой же id, как был у предыдущего my_class(), который уже не существует), и правая функция id получается значение id этого объекта. Так получается, что id слева и справа от == получают идентификаторы своих объектов в то время, когда физически существует только один из этих объектов, следовательно id, полученное слева будет таким же, как id полученное справа.
Назревает вопрос: почему метод is в примере 2 выдает False, id объектов должны же быть одинаковы, значит, они ссылаются на один объект? Чуть выше был изменён класс my_class. Для наглядности можно запустить пример 2 еще раз. Получим такой результат:
__init__
__init__
__del__
__del__
False
__init__
__init__
__del__
__del__
False
__init__
__del__
__init__
__del__
True
__init__
__del__
__init__
__del__
True
Когда выполнялся print с hash или id, видно, что по отдельности сначала вызывается конструктор, потом деструктор одного объекта, а потом конструктор и деструктор другого объекта, именно после того, как функции hash или id завершали свои действия, вызывался метод __del__ и создавался новый объект. В примере с is видно, что сначала создались оба экземпляра класса, а только потом вызывался метод __del__ у каждого из них. Это дает понять, что метод is выполнялся таким образом, что сначала создавались оба объекта my_class(), потом они сравнивались, а только лишь после выполнения is они оба удалялись (т.е. два экземпляра my_class жили в одно время и физически не могли иметь 2 одинаковых id).
И еще немного
Изменение данных внутри кортежа
Создадим кортеж, который содержит в себе список, и попробуем расширить этот список, добавив новые элементы:
def test_list():
a = ([], 0)
a[0].extend([1])
print("Применили extend: ", a)
a[0].append(2)
print("Применили append: ", a)
a[0].insert(0,0)
print("Применили insert: ", a)
try:
a[0] += [4]
print("Применяем +=: ", a)
except Exception as e:
print(e)
print("Итоговый кортеж:", a)
test_list()
Вывод:
Применили extend: ([1], 0)
Применили append: ([1, 2], 0)
Применили insert: ([0, 1, 2], 0)
'tuple' object does not support item assignment
Итоговый кортеж: ([0, 1, 2, 4], 0)
Очень забавная ситуация, кортеж выкинул ошибку, однако список все равно был расширен. Аналогично будет себя вести пример со словарями ( оператор |= - был добавлен в версии 3.9).
def test_dicts():
a = ({}, 0)
a[0]["cat"] = 100
print("Изменяем словарь: ", a)
a[0].update([("dog", 200)])
print("Применили update: ", a)
a[0].update({"bird": 50})
print("Применили update: ", a)
try:
a[0] |= {"lion": 200}
print("Применили |=: ", a)
except Exception as e:
print(e)
print("Итоговый словарь: ", a)
test_dicts()
Вывод:
Изменяем словарь: ({'cat': 100}, 0)
Применили update: ({'cat': 100, 'dog': 200}, 0)
Применили update: ({'cat': 100, 'dog': 200, 'bird': 50}, 0)
'tuple' object does not support item assignment
Итоговый словарь: ({'cat': 100, 'dog': 200, 'bird': 50, 'lion': 200}, 0)
Создание таблицы
Допустим, необходимо создать таблицу (матрицу, двумерный массив) с данными и поменять в нём первый элемент:
def test_list_2():
row = ["_"] * 3
table = [row] * 3
print("Создали таблицу:\n",table )
table[0][0] = "X"
print("Изменили первый элемент:\n",table )
test_list_2()
Вывод:
Создали таблицу:
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
Изменили первый элемент:
[['X', '_', '_'], ['X', '_', '_'], ['X', '_', '_']]
Поменялся не только самый первый элемент таблицы, но и первые элементы в каждой строке. Если использовать инициализацию таблицы путем умножения одного списка, то каждая строка таблицы будет содержать один и тот же список (ссылаться на один список row).
Убедиться в этом можно, выполнив следующий код:
for table_row in table:
print(id(table_row))
Вывод:
1707508645824
1707508645824
1707508645824
Метод all
Немного о методе all:
def test_all():
print(all([]))
print(all([[]]))
print(all([[[]]]))
print(all([[[[]]]]))
test_all()
Вывод:
True
False
True
True
Почему так? all([]) - по определению выдаст True. Ситуация с all([[]]) объясняется тем, что для каждый элемент во внешнем списке приводится к булевскому значению (как известно, к False приводятся: ноль, пустая строка, пустой список и др.), поэтому внешний список содержит [], который интерпретируется как False. В all([[[]]]), внешний список содержит список, в котором есть список, а список с 1 элементов уже интерпретируется, как True, то есть all не проверяет вложенность списков.
Вывод
Все эти примеры довольно очевидны для опытного программиста, но иногда могут вводить в ступор, особенно, если приходится читать чужой код и пытаться найти в нём ошибку. Такого рода ошибки порождают массу проблем, если плохо разбираться в нюансах работы языка. Поэтому иногда стоит читать и изучать чужой код, изучать статьи по языку и читать документацию к работе методов, чтобы видеть и понимать, какие сюрпризы может преподнести язык. И не стоит забывать, используя два разных языка и описав в них одинаковые конструкции, они могут вести себя совершенно по-разному, потому что внутренняя реализация у каждого языка своя.
P.s. всё тестировал на Python 3.9 в стандартной IDLE. Картинка - кликбейт.