Как работает под капотом распаковка в Python (*)?
Подскажите, пожалуйста, как работает распаковка(*) под капотом, на примере обычного генераторного выражения.
gen = (num for num in range(10))
print(*gen)
Ответы (3 шт):
Всё проще, чем кажется.
*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}
Возьмем для примера функцию 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]
выполняются следующие байткод инструкции:
Создается список, на стек отправляется ссылка на список x
, со стека снимается список x
и происходит "extend"
объектов в список y
Слева обычный вызов 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
Всякое удобство имеет свою цену. Если вы знаете что генератор может быть велик (очень велик) избегайте *
.