Как стать автором
Поиск
Написать публикацию
Обновить
16
0.8

Пользователь

Отправить сообщение

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

Не очень было понятно, как можно извлечь эту информацию из самого Питона, не заглядывая в готовые таблицы соответствия (это было бы скучно). Тем более, что на самом деле Питон много чего оптимизирует, и на самом деле при сложении двух сущностей обычно не будет вызван метод __add__, а в байткоде сгенерируется вызов BINARY_ADD, в чём можно легко убедиться с помощью модуля dis (если речь не о самописном классе с перегруженным методом __add__, а о каких-то стандартных объектах Питона). Например, сложим два списка через + и через __add__ и посмотрим, какой байткод будет сгенерирован.

import dis

dis.dis("first_list = [1, 2]; second_list = [3, 4]; final_list = first_list + second_list")
# Только самое существенное в выводе:
#             16 LOAD_NAME                0 (first_list)
#             18 LOAD_NAME                1 (second_list)
#             20 BINARY_ADD

dis.dis("first_list = [1, 2]; second_list = [3, 4]; final_list = first_list.__add__(second_list)")
# Опять же самая суть:
#             16 LOAD_NAME                0 (first_list)
#             18 LOAD_METHOD              2 (__add__)
#             20 LOAD_NAME                1 (second_list)
#             22 CALL_METHOD              1

И тут я вдруг вспомнил про стандартный модуль operator, в который зашиты все возможные операторы Питона именно в виде именованных методов как с двойным подчёркиванием, так и без оного. После небольшого исследования оказалось, что в нём каждый такой метод имеет строку документации, в которой таки написано что-то типа "Same as a + b." ("Тоже самое, что a+b.")

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

# Модуль operator содержит методы, аналогичные встроенным методам Питона и методам классов Питона
import operator
import re

# Будем искать в докстрингах методов фразу "Same as" ("То же, что и")
rx = re.compile('Same as (.*)')

# Перебираем имена модуля operator
for name in dir(operator):
    
    # Нас интересуют только имеющие двойное подчёркивание в названии
    if '__' in name:
        
        # Берём аттрибут модуля operator с таким именем
        attr = getattr(operator, name)
        
        # Читаем его docstring и ищем там фразу (см. выше)
        descr = rx.findall(attr.__doc__)
        
        # Если фраза нашлась, то она там одна и заканчивается она точкой, которая нам не нужна
        if descr:
            print(f'{descr[0][:-1]} -> {name}')

Получилось такое соответствие.

abs(a) -> __abs__
a + b -> __add__
a & b -> __and__
a + b, for a and b sequences -> __concat__
b in a (note reversed operands) -> __contains__
del a[b] -> __delitem__
a == b -> __eq__
a // b -> __floordiv__
a >= b -> __ge__
a[b] -> __getitem__
a > b -> __gt__
a += b -> __iadd__
a &= b -> __iand__
a += b, for a and b sequences -> __iconcat__
a //= b -> __ifloordiv__
a <<= b -> __ilshift__
a @= b -> __imatmul__
a %= b -> __imod__
a *= b -> __imul__
a.__index__( -> __index__
~a -> __inv__
~a -> __invert__
a |= b -> __ior__
a **= b -> __ipow__
a >>= b -> __irshift__
a -= b -> __isub__
a /= b -> __itruediv__
a ^= b -> __ixor__
a <= b -> __le__
a << b -> __lshift__
a < b -> __lt__
a @ b -> __matmul__
a % b -> __mod__
a * b -> __mul__
a != b -> __ne__
-a -> __neg__
not a -> __not__
a | b -> __or__
+a -> __pos__
a ** b -> __pow__
a >> b -> __rshift__
a[b] = c -> __setitem__
a - b -> __sub__
a / b -> __truediv__
a ^ b -> __xor__

К чему это всё? Да просто люблю исследовать Питон. Благо он позволяет легко извлекать из себя и обрабатывать такие штуки, которые из других языков бывает довольно непросто вытащить даже с применением каких-то специальных библиотек. А в Питоне всё это стандартными методами и встроенными библиотеками делается буквально в несколько строк кода.

Спасибо за чтение.

Теги:
Всего голосов 5: ↑5 и ↓0+5
Комментарии0

Заранее создаваемые объекты - целые числа в Питоне

И снова здравствуйте! Здесь мы проверяем "руками" разные штуки в Питоне.

Наверняка все что-то слышали о том, что часть объектов - целых чисел в Питоне заводится заранее, чтобы сэкономить на создании объектов. В Питоне каждая сущность, даже такая как целые числа - это полноценный объект со всеми положенными объекту прибамбасами. Создавать полноценные объекты - дорого. Поэтому в Питоне, да и в других языках, насколько я помню, кажется в Java, например, часть целых чисел, которые считаются часто используемыми, создаётся заранее и потом используется всё время жизни программы. Т.е. когда вы используете какое-то большое целое число, например, n = 10_000 , то под такое число создаётся новый объект каждый раз, а если используете маленькое, например, n = 10, то новый объект не создаётся, а делается ссылка на один и тот же, заранее созданный объект.

Но давайте сами проверим: действительно ли есть такие числа и каков их диапазон. Будем проделывать с числом простейшие манипуляции - сначала увеличивать на 1, потом уменьшать на 1, чтобы получилось тоже самое число. И потом проверим, поменялся ли id (адрес в памяти) у этого числа. Конечно, тут многое будет зависеть от конкретной версии интерпретатора. Какой-то интерпретатор и код k = n - 1 + 1 не будет оптимизировать, а какой-то и в приведённом ниже коде догадается, что все операции можно сделать как одну операцию, посокращает все добавления-вычитания и мы ничего не сможем определить. И тогда нас спасёт только какой-нибудь eval с вычислениями в виде строки. Но обычно интерпретаторы Питона не настолько хитрые и приведённый ниже код вполне работает в Google Colab.

def check_if_int_cached(n):
    k = n + 1
    k -= 1
    return id(k) == id(n)

checks = [(i, check_if_int_cached(i)) for i in range(-10000, 10000)]
for (x, a), (y, b) in zip(checks, checks[1:]):
    if a != b:
        print((x, y)[b])

В этом коде мы:

  • Проверяем, сохраняется ли idу числа после некоторых математических манипуляций, которые в итоге дают тоже самое число

  • Создаём последовательность из чисел диапазона [-10000, 9999] и результатов нашей проверки [(число, результат_проверки), ...]

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

  • Если результат поменялся - выводим либо текущий элемент, либо следующий, пользуясь тем трюком питона, что True - это 1. а False - это 0, и таким образом можно легко выбрать из двух чисел либо первое либо второе не через тернарный оператор, а через индексацию [num1, num2][условие]

Запустим наш код. Вывод:

-5

256

Итак, мы определили, что, действительно, целые числа в диапазоне [-5, 256] заранее создаются Питоном и какие бы ни были вычисления в программе, если в их результате получается число из этого диапазона, то под него не создаётся новый объект, а переиспользуется старый.

Давайте ещё проверим - а действительно ли эта оптимизация Питона даёт какой-то выигрыш. Попробуем создавать список из чисел диапазонов [0, 200] и [1000, 1200] и проделаем это миллион раз для солидности стабильности результата.

import time

n = 1_000_000
k = (0, 1000)
m = 200
for i in k:
    t1 = time.perf_counter()
    for _ in range(n):
      lst = list(range(i, i+m))
    t2 = time.perf_counter()
    print(t2-t1)

1.732138877000125

3.547805026000333

Выигрыш по времени получился практически ровно в 2 раза! Но это если ничего не делать, а только создавать список из объектов-чисел. Если там будут ещё какие-то действия и вычисления, возможно, выигрыш будет вообще не заметен.

В предыдущем посте я писал о встроенной оптимизации добавления символов в строку в Питоне. Далее будут и другие посты об интересных мелочах в Питоне, которые быстро и просто могут быть проверены своими руками (за что мне и нравится Питон) . Спасибо за чтение.

Теги:
Всего голосов 5: ↑5 и ↓0+5
Комментарии3

Встроенная оптимизация добавления символов в строку.

Люблю делать мини-эксперименты на Питоне. Попробую оформлять их постами, посмотрю, как пойдёт.

Суть проблемы.

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

В общем, давайте проверим, сохраняется ли строка на том же самом месте памяти. Это мы проверим по id объекта. Сохранился id - это тот же объект (хотя и, возможно, изменённый) в том же месте памяти. Поменялся id - это уже другой объект в другом месте, Питон потратил ресурсы на то, чтобы скопировать исходный объект в новое место.

def test_str():
    string = ''
    old_id = id(string)
    print(0, 0, old_id)
    old_i = 0
    for i in range(1, 20000):
        string += '-'
        new_id = id(string)
        if new_id != old_id:
            print(i, i - old_i, new_id)
            old_i = i
            old_id = new_id

test_str()

Запустим (часть строк я сократил для наглядности):

|"№|Шаг|Id|
|:---|:---|:---|
|0|0|2160689512496|
|1|1|2160690869616|
|2|1|2160740037552|
|16|14|2160761306960|
|32|16|2160761158704|
|48|16|2160738952768|
|64|16|2160760928688|
|...|...|...|
|448|16|2160739774000|
|464|16|2160724879344|
|465|1|2160724880928|
|...|...|...|
|1016|1|2160635636480|
|2176|1160|2160726063040|
|3200|1024|2160724362096|
|4128|928|2160688590304|
|4576|448|2160635890208|
|4736|160|2160724769808|
|5056|320|2160744468544|
|8096|3040|2160745279680|
|12064|3968|2160703847904|
|13072|1008|2160724677104|
|14592|1520|2160745337504|
|15600|1008|2160724821296|
|16288|688|2160726148256|

Как интересно. Получается такая картина:

  • Первое прибавление. Питон честно выделяет новую строку.

  • Второе прибавление. Питон кажется что-то подозревает и выделяет сразу место под следующие прибавления - по 16 ячеек (но в первый раз чуть меньше).

  • Прибавление 464. Питон почему-то вдруг обратно переключается на копирование строки каждый раз.

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

В целом картина получается интересная. Кстати, Питон умный и если заменить код на такой, то ничего не изменится, оптимизация сохранится:

string = string + '-'

Оптимизация пропадёт только если сохранять результат в другую переменную.

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

Если бы не было этой оптимизации, то при каждом добавлении символа или строки в нашу строку происходило бы копирование старой строки в новое место, где достаточно памяти под новую строку. Такой процесс имел бы асимптотику O(n2) при добавлении n символов в строку по одному и это было бы очень долго и нерационально. Вместо этого обычно рекомендуют добавлять части строки в список и в самом конце собирать итоговую строку с помощью метода join, что-то типа ''.join(lst). Но, благодаря описываемой тут оптимизации мы видим, что такие добавления можно делать и к строке и производительность при этом должна не сильно страдать. Но конкретика будет зависеть от длины добавляемых фрагментов строки.

"А теперь - слайды!"

Люблю проверять всё "руками", благо Питон это легко и удобно позволяет. Планирую постепенно публиковать и другие эксперименты с Питоном. Спасибо за чтение.

P.S. Таблица в markdown похоже не получилась, подскажите, плиз, как поправить!

P.P.S. Пишут, что начиная с CPython 3.11 эту оптимизацию потеряли. ( Формирование строк через объединение списка вновь актуально.

Теги:
Всего голосов 2: ↑2 и ↓0+2
Комментарии3

Информация

В рейтинге
2 949-й
Зарегистрирован
Активность