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

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

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


print(*gen)

Ответы (3 шт):

Автор решения: txello

Всё проще, чем кажется.

*args и **kwargs - это способы передачи переменного количества аргументов в функцию. Другими словами, они "распаковывают" значения из массива/словаря/множества и т.п. и передают эти значения в функцию.

Пример использования *args:

def test(a,b): # Принимаем параметры a и b
    print(a + b) # Выводим их сложение

array = [1,2] # Создаём массив из двух чисел

test(*array) # Вывод: 3

При записи *array значения для функции были преобразованы в test(1,2)

Пример использования **kwargs:

def test(a,b): # Принимаем параметры a и b
    print(a + b) # Выводим их сложение

dct = {'a':1,'b':2} # Создаём словарь и записываем ключ-значение
test(**dct) # Вывод: 3

При записи **dct значения для функции были преобразованы в test(a=1,b=2)

Таким же образом можно преобразовать данные в обратном направлении:

def test1(*var): # Принимаем параметры в виде множества
    print(var)
 
test1(1,2,3) # Вывод: (1, 2, 3)

def test2(**var): # Принимаем параметры в виде словаря
    print(var)

test2(a=1,b=2) # Вывод: {'a': 1, 'b': 2}
→ Ссылка
Автор решения: Maksim Alekseev

Возьмем для примера функцию test:

import dis

def test():
    x = [1, 2, 3]
    y = [*x]

dis.dis(test)

Output:

  3           0 RESUME                   0

  4           2 BUILD_LIST               0
              4 LOAD_CONST               1 ((1, 2, 3))
              6 LIST_EXTEND              1
              8 STORE_FAST               0 (x)

  5          10 BUILD_LIST               0
             12 LOAD_FAST                0 (x)
             14 LIST_EXTEND              1
             16 STORE_FAST               1 (y)
             18 LOAD_CONST               0 (None)
             20 RETURN_VALUE

на строке:

y = [*x]

выполняются следующие байткод инструкции:

BUILD_LIST

LOAD_FAST

LIST_EXTEND

STORE_FAST

Создается список, на стек отправляется ссылка на список x, со стека снимается список x и происходит "extend" объектов в список y

→ Ссылка
Автор решения: Stanislav Volodarskiy

Слева обычный вызов 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

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

→ Ссылка