Как работает под капотом распаковка в 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
Всякое удобство имеет свою цену. Если вы знаете что генератор может быть велик (очень велик) избегайте *.