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. Картинка — кликбейт.

ссылка на оригинал статьи https://habr.com/ru/post/564804/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *