Как работает под капотом распаковка в Python (*)?

Как работает под капотом распаковка в Python (*)?


Подскажите, пожалуйста, как работает распаковка (*) под капотом, на примере обычного генераторного выражения.

gen = (num for num in range(10))


print(*gen)



Слева обычный вызов f с тремя аргументами, справа вызов с помощью *. Оба варианта делают одно и тоже:

import dis                             import dis


def f(a, b, c):                        def f(a, b, c):
    return a + b + c                       return a + b + c


def driver():                          def driver():
    g = (v for v in range(3))              g = (v for v in range(3))
    a, b, c = g
    return f(a, b, c)                      return f(*g)


dis.dis(driver)                        dis.dis(driver)

Кусок исполнимого кода. Слева прямолинейный код: функция и аргументы кладутся на стек, CALL 3 выполняет функцию с тремя аргументами. Справа на стек помещается функция f и генератор g и вызывается CALL_FUNCTION_EX 0. Что происходит внутри пока не ясно:

...                                    ...
LOAD_FAST         0 (g)
UNPACK_SEQUENCE   3
STORE_FAST        1 (a)
STORE_FAST        2 (b)
STORE_FAST        3 (c)

LOAD_GLOBAL       3 (NULL + f)         LOAD_GLOBAL       1 (f)
LOAD_FAST         1 (a)                LOAD_FAST         0 (g)
LOAD_FAST         2 (b)
LOAD_FAST         3 (c)                CALL_FUNCTION_EX  0
CALL              3
RETURN_VALUE                           RETURN_VALUE

Инструкция CALL_FUNCTION_EX реализованна в 3.12/Python/bytecodes.c#L3194-L3260.

Если переданный объект не кортеж (!PyTuple_CheckExact(callargs)), из него строят кортеж (PyObject *tuple = PySequence_Tuple(callargs);): 3.12/Python/bytecodes.c#L3198-L3207.

Затем этот кортеж передаётся в вызов функции (result = PyObject_Call(func, callargs, kwargs);): 3.12/Python/bytecodes.c#L3219 и 3.12/Python/bytecodes.c#L3254.

Если вы передаёте генератор в вызов функции со звёздочкой, все значения из генератора извлекаются и помещаются в кортеж. Затем вызывается функция с кортежем вместо аргументов. Вызов f(*g) равносилен чему-то вроде:

if isinstance(g, tuple):
    f(*g)         # не приводит к накладным расходам
else:
    t = tuple(g)  # все накладные расходы тут
    f(*t)         # не приводит к накладным расходам

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

Но нотация * - жадная. На практике это означает, что для вызова требуется память, пропорциональная числу элементов в генераторе. Также надо подождать, пока генератор отработает. То есть, если генератор медленный, вызов функции будет отложен до конца его работы. А если генератор бесконечный, то функция никогда не будет вызвана, а дело закончится исчерпанием памяти.

Вот так работает всегда и быстро:

for v in g:
    print(v)

Вот так работает не всегда и не сразу видны результаты:

print(*g, sep='\n')

Хотя и кажется, что это один и тот же код (почти один и тот же).

P.S. Ещё немного ассемблера. Что будет, если в функции совмещены позиционные параметры и *?

import dis


def f(a, b, c):
    return a + b + c


def driver():
    g = (v for v in range(3))
    a = next(g)
    return f(a, *g)


dis.dis(driver)

Всё ещё усложнилось: аргументы складываются в список, результаты из генератора добавляются в него же, список копируется в кортеж, кортеж передаётся в вызов функции:

 LOAD_GLOBAL       3 (NULL + next)
 LOAD_FAST         0 (g)
 CALL              1
 STORE_FAST        1 (a)

 LOAD_GLOBAL       5 (NULL + f)
 LOAD_FAST         1 (a)
 BUILD_LIST        1
 LOAD_FAST         0 (g)
 LIST_EXTEND       1
 CALL_INTRINSIC_1  6 (INTRINSIC_LIST_TO_TUPLE)
 CALL_FUNCTION_EX  0
 RETURN_VALUE

Всякое удобство имеет свою цену. Если вы знаете что генератор может быть велик (очень велик) избегайте *.



Report Page