Почему в параметр функции идет только последний индекс?

Используется библиотека Pystray. Я пытаюсь собрать массив из кнопок, каждая из которых должна передавать индекс своего устройства в функцию, чтобы установить это устройством по умолчанию, но по неведомым причинам при нажатии на любую кнопку приходит только последний индекс. В моем случае индексы микрофонов - 4 5 6, и независимо от того, какую кнопку я жму - выбирается индекс 6.

menu_items = []
for device in only_recording_devices:
    index = device[0][-1]
    is_default = device[1].split('Default              : ')[1]
    name = device[4].split('Name                 : ')[1]
    menu_items.append(MenuItem(f'{index} - {name}',
        lambda _: set_default_microphone(index), 
        radio=True
    ))

Вид меню, название устройств и их индексы слева


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

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

Это называется "замыкание переменной". В тот момент, когда у вас вызывается любая lambda, значение index в ней оказывается последним из присвоенных в эту переменную. Потому что в lambda передаётся адрес переменной, а не её значение. Чтобы значение правильно "запомнилось", используйте форму записи лямбды с параметром. Выглядит она странно, но должна сработать. В этом случае значение переменной index "запомнится" в параметр лямбды и при вызове этой лямбды будет использовано правильное значение переменной (потому что это будет уже внутренняя переменная лямбды index, а не та внешняя переменная index, на которую ссылалась ваша лямбда, хотя название переменной казалось бы одно и то же).

lambda _,index=index: set_default_microphone(index)

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

Update2: Добавил код, иллюстрирующий проблему. Переменная на каждой итерации разная, как и пишет в комментарии Grundy, но лямбда запоминает только последнюю. До конца объяснить почему так не могу.

Код, демонстрирующий проблему и проверяющий id переменной:

lst = []
for i in range(5):
    index = i + 1
    print(index, id(index))
    lst.append(lambda _: print(index, id(index)))

for l in lst:
    l(None)

Вывод:

1 1605469667568
2 1605469667600
3 1605469667632
4 1605469667664
5 1605469667696
5 1605469667696
5 1605469667696
5 1605469667696
5 1605469667696
5 1605469667696

Исправленный код:

lst = []
for i in range(5):
    index = i + 1
    print(index, id(index))
    lst.append(lambda _, index=index: print(index, id(index)))

for l in lst:
    l(None)

Вывод:

1 1605469667568
2 1605469667600
3 1605469667632
4 1605469667664
5 1605469667696
1 1605469667568
2 1605469667600
3 1605469667632
4 1605469667664
5 1605469667696

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

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

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

class MenuItem:
    def __init__(self, name, callback):
        self.name = name
        self._callback = callback

    def click(self):
        # print(self._callback.__closure__)
        self._callback(self)


def set_default_microphone(index):
    print('click', index)


def create_menu():
    menu_items = []
    for device in only_recording_devices:
        index, name = device
        menu_items.append(MenuItem(
            f'{index} - {name}',
            lambda _: set_default_microphone(index)
        ))
    # index = 42
    return menu_items


only_recording_devices = (4, 'NVIDIA'), (5, 'PANTEON'), (6, 'Ritmix')

for item in create_menu():
    item.click()

Все вызовы выдали шестёрку:

$ python callbacks.py
click 6
click 6
click 6

Разбираемся что делает выражение lambda _: set_default_microphone(index). Это функция с одним параметром, который игнорируется (_). В теле два имени, которые в теле же не определены. Откуда они берутся? Лямбда определена внутри функции create_menu, компилятор ищет имена там и находит index. Чтобы найти set_default_microphone надо сделать ещё шаг. Функция create_menu определена в модуле (то есть, она не вложена ни в какую другую), компилятор просматривает все имена определённые в модуле и находит set_default_microphone. В итоге у нас лямбда с двумя нелокальными именами.

Чего хотел программист? Лямбда определяется три раза, программист хочет получить три различные анонимные функции, каждую с своим значением index. То есть:

... lambda _: set_default_microphone(4) ...
... lambda _: set_default_microphone(5) ...
... lambda _: set_default_microphone(6) ...

Но компилятор действует по другому. В всех лямбдах сохраняется ссылка на переменную index и код вызывает set_default_microphone со значением index на момент вызова. Так как вызов делается после завершения цикла, то все видят последнее значение – шестёрку.

Попробуйте раскомментировать index = 42 и везде будет напечатано 42.

Ещё раз: в вызов подставляется не текущее значение index, а ссылка на переменную. Если поменять эту переменную позже, изменения будут видны в вызовах лямбд. Обратите внимание что к тому времени как лямбды будут вызваны, функция create_menu завершила свою работу и её локальные переменные перестали существовать. Мы разговариваем с духом покойной локальной переменной! Чтобы это стало возможно, компилятор помещает переменную index в замыкание. И это замыкание хранит index до тех пор пока есть лямбды, которые компилировались пока переменная была жива.

Раскомментируйте # print(self._callback.__closure__). Если функция ссылается на локальные переменные другой функции, то есть, если у функции есть замыкание, атрибут __closure__ хранит его. Вот что будет на печати:

python callbacks.py
(<cell at 0x7f044430ffd0: int object at 0x7f0445650610>,)
click 42
(<cell at 0x7f044430ffd0: int object at 0x7f0445650610>,)
click 42
(<cell at 0x7f044430ffd0: int object at 0x7f0445650610>,)
click 42

Замыкание – кортеж. В нашем случае в нём одна ячейка. Все лямбды используют одну и ту же ячейку и, конечно, они будут всегда видеть одно и то же значение одной и той же переменной.

Всё! Конец истории! Нельзя в цикле создать несколько лямбд с разными значениями. Одна переменная index, одно значение, одно поведение.

Всё упирается в единственную переменную index, а нам надо несколько разных. А когда в Питоне рождаются переменные? Когда вызывается функция! Сделаем функцию make_callback, внутри неё создадим лямбду и вернём её наружу:

...
def make_callback(index):
    return lambda _: set_default_microphone(index)


def create_menu():
    menu_items = []
    for device in only_recording_devices:
        index, name = device
        menu_items.append(MenuItem(
            f'{index} - {name}',
            make_callback(index)
        ))
    index = 42
    return menu_items
...

Разные лямбды получили в пользование разные ячейки в замыканиях, ссылающиеся на разные переменные, и значения этих переменных какие надо:

python callbacks.py 
(<cell at 0x7f01d0f77c40: int object at 0x7f01d22c0150>,)
click 4
(<cell at 0x7f01d0f77820: int object at 0x7f01d22c0170>,)
click 5
(<cell at 0x7f01d0f77790: int object at 0x7f01d22c0190>,)
click 6

Это конечно громоздко, но функция make_... позволяет точно указывать что лямбда будет видеть в замыкании, какие выражения должны быть вычислены до создания лямбды, какие вычислятся при вызове лямбды.

Почему в замыканиях запоминаются переменные, а не их значения? Это воля авторов языка. Можно было бы запоминать значения и тогда не нужно городить промежуточную функцию, что сделало бы вашу жизнь проще. Но в большинстве случаев переменные в замыканиях полезнее. Например, вы хотите часть кода большой функции поместить в локальную функцию. Замыкания с переменными позволяют делать такой рефакторинг почти (но не совсем) автоматически. Замыкания со значениями привели бы к серьёзному переписыванию кода.

Я отвлёкся. Как сделать код не таким громоздким?. Во-первых, make_... можно поместить внутрь create_menu:

def create_menu():

    def make_callback(index):
        return lambda _: set_default_microphone(index)

    menu_items = []
...

Во-вторых, make_... можно сделать лямбдой. Хотя правила хорошего тона запрещают именованные лямбды:

def create_menu():
    make_callback = lambda index: lambda _: set_default_microphone(index)
    menu_items = []
...

Привыкайте к lambda-lambda. Это часто встречается.

В-третьих, лямбду, которая make_..., можно определить и вызвать прямо в выражении. Я такое не люблю, но если вы понимаете что, как и зачем, вы – прожжённый функциональщик:

def create_menu():
    menu_items = []
    for device in only_recording_devices:
        index, name = device
        menu_items.append(MenuItem(
            f'{index} - {name}',
            (lambda index: lambda _: set_default_microphone(index))(index)
        ))
    index = 42
    return menu_items

В-четвёртых, есть приём с значением аргументов по-умолчанию. Я его не люблю, но он очень популярен. Идея в том, что значение аргумента по-умолчанию вычисляется в момент определения функции. Выражение index=index упоминает две разные переменные. Слева параметр нашей лямбды, справа локальная переменная из функции create_menu. Значение параметра по-умолчанию создаётся на каждом вызове и мы получаем много разных переменныx index, что нам и нужно для разного поведения разных лямбд. Более того, наша лямбда не имеет замыкания вовсе! В теле лямбды index - параметр, локальная переменная самой лямбды.

def create_menu():
    menu_items = []
    for device in only_recording_devices:
        index, name = device
        menu_items.append(MenuItem(
            f'{index} - {name}',
            lambda _, index=index: set_default_microphone(index)
        ))
    index = 42
    return menu_items

Почему я тогда не люблю этот приём?

  1. Потому что это сложно объяснить: выражение одно, но вы должны хорошо понимать какие его части когда выполняются. Это же относится и к предыдущему примеру.
  2. Такую лямбду можно нечаянно вызвать с двумя параметрами и интерпретатор вам ничего не скажет, молча подставит лишнее значение в параметр index и будет вам отладка.

В общем, я не люблю, когда ради краткости кода тут, вы подкладываете мину под код там.

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

Для передачи параметров в таких замыканиях есть в питоне специальная функция.

import functools

def set_default_microphone(index, event):
    print('click', index)

...
menu_items.append(MenuItem(f"{index} - {name}", functools.partial( set_default_microphone, index),
                                       radio=True))

Эта функция часто используется именно для целей сохранения аргумента в колбэке.

Работает почти также как make_callback в соседнем ответе, но не использует лямбды.

Добавил event чтоб поймать в него что пришлет обработчик клика. В вопросе это _ (как по мне лучше переменную назвать, чем потом вспоминать что за мусор там.)

→ Ссылка