3

I met a signal-slot problem when connecting several clicked signals from a group of buttons to a single slot function with arguments.

lambda and functools.partial can be used as follow:

user = "user"
button.clicked.connect(lambda: calluser(name))

from functools import partial
user = "user"
button.clicked.connect(partial(calluser, name))

While in some circumstances, they perform differently. The following code show a example, which expects to print each button's text when clicking it. but the output is always "button 3" when using the lambda method. The partial method works we expected.

How can I find their differences?

from PyQt5 import QtWidgets

class Program(QtWidgets.QWidget):
    def __init__(self):
        super(Program, self).__init__()
        self.button_1 = QtWidgets.QPushButton('button 1', self)
        self.button_2 = QtWidgets.QPushButton('button 2', self)
        self.button_3 = QtWidgets.QPushButton('button 3', self)
        from functools import partial
        for n in range(3):
            bh = eval("self.button_{}".format(n+1)) 
            # lambda method : always print `button 3`
            # bh.clicked.connect(lambda: self.printtext(n+1))
            bh.clicked.connect(partial(self.printtext, n+1))
        
        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(self.button_1)
        layout.addWidget(self.button_2)
        layout.addWidget(self.button_3)
         
    def printtext(self, n):
        print("button {}".format(n));

if __name__ == '__main__':

    import sys
    app = QtWidgets.QApplication(sys.argv)
    window = Program()
    window.show()
    sys.exit(app.exec_())

P.S.

Personly, I agree that ButtonGroup method from the accepted answer is the right&elegant solution to this type of issue.

Here are som references about this question:

lambda in a loop

Transmitting extra data with Qt Signals

foool
  • 1,462
  • 1
  • 15
  • 29
  • @musicamante If you are not sure your reference answer this question or not, why initiates a `Close` to this question. Back to your reference, which concludes `lambda` as instance method while `partial` as static method, it does not exaplain outputs of the code. – foool Feb 25 '21 at 09:35
  • That is an automatic comment that StackOverflow creates as soon as a close vote is cast, you may not agree or I may be wrong, just answer "no, it doesn't". – musicamante Feb 25 '21 at 10:12

3 Answers3

4

Before discussing the differences between lambda/partial and some other issues, I will briefly cover some practical fixes for your code example.

There are two main problems. The first is a common issue with the binding of names in a for-loop, as discussed in this question: Lambda in a loop. The second is a PyQt-specific problem with default signal paramters, as discussed in this question: Unable to send signal from Button created in Python loop. Given this, your example can be fixed by connecting the signals like this:

bh.clicked.connect(lambda checked, n=n: self.printtext(n+1))

So this caches the current value of n in a default argument, and also adds a checked argument to stop n being overwritten by the parameter emitted by the signal.

The example could also benefit from being re-written in a more idiomatic way which eliminates the nasty eval hack:

layout = QtWidgets.QVBoxLayout(self)
for n in range(1, 4):
    button = QtWidgets.QPushButton(f'button {n}')
    button.clicked.connect(lambda checked, n=n: self.printtext(n))
    layout.addWidget(button)
    setattr(self, f'button{n}', button)

or using a QButtonGroup:

self.buttonGroup = QtWidgets.QButtonGroup(self)
layout = QtWidgets.QVBoxLayout(self)
for n in range(1, 4):
    button = QtWidgets.QPushButton(f'button {n}')
    layout.addWidget(button)
    self.buttonGroup.addButton(button, n)
    setattr(self, f'button{n}', button)
self.buttonGroup.buttonClicked[int].connect(self.printtext)

(Note that button-groups can also be created in Qt Designer by selecting the buttons and then choosing "Assign to button group" in the context-menu).


As for the question of the differences between lambda and partial: the main one is that the former implicitly creates a closure over the local variables, whereas the latter explicitly stores the arguments passed to it internally.

The common "gotcha" with closures in python is that functions defined inside a loop don't capture the current value of the enclosed variables. So when the function is called later, it can only access the last seen values. There was a discussion about changing this behaviour fairly recently on the python-ideas mailing list, but it didn't seem to reach any firm conclusions. Still, it's possible some future version of python will remove this little wart. The issue is completely avoided by the partial function because it creates a callable object which stores the arguments passed to it as read-only attributes. So the work-around of using default arguments in a lambda to explicitly store the variables is effectively using that same approach.

There is one other point worth covering here: the differences between PyQt and PySide when connecting to signals with default parameters. It appears that PySide treats all slots as if they were decorated with a slot decorator; whereas PyQt treats undecorated slots differently. Here is an illustration of the differences:

def __init__(self):
    ...
    self.button_1.clicked.connect(self.slot1)
    self.button_2.clicked.connect(self.slot2)
    self.button_3.clicked.connect(self.slot3)

def slot1(self, n=1): print(f'slot1: {n=!r}')
def slot2(self, *args, n=1): print(f'slot2: {args=!r}, {n=!r}')
def slot3(self, x, n=1): print(f'slot1: {x=!r}, {n!r}')

After clicking each button in turn, the following output is produced:

PyQt5 output:

slot1: n=False
slot2: args=(False,), n=1
slot3: x=False, n=1

PySide2 output:

slot1: n=False
slot2: args=(), n=1
TypeError: slot3() missing 1 required positional argument: 'x'

If the slots are decorated with @QtCore.pyqtSlot(), the output from PyQt5 matches the PySide2 output shown above. So if you need a solution for lambda (or any other undecorated slot) that works the same for both PyQt and PySide, you should use this:

bh.clicked.connect(lambda *args, n=n: self.printtext(n))
ekhumoro
  • 115,249
  • 20
  • 229
  • 336
1

Well from what I've understood, the reason is that each time "partial" will create a new function based on your "printtext" function with a predefined argument so you're passing a diffrent function for each button. while in the lambda function your argument is a refrence to a varible, so when you click on a button the loop has already ran and the varible is equal to the last number in the loop and it prints 3. turns out you can do the same thing without partial like this(worked for me):

from PyQt5 import QtWidgets


class Program(QtWidgets.QWidget):

    @staticmethod
    def create_func(n):
        return lambda: print('button {}'.format(n+1))

    def __init__(self):
        super(Program, self).__init__()
        layout = QtWidgets.QVBoxLayout(self)
        for n in range(3):
            layout.addWidget(QtWidgets.QPushButton('button {}'.format(n+1), self, clicked=self.create_func(n)))


if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    window = Program()
    window.show()
    sys.exit(app.exec_())
Parsa Hz
  • 61
  • 4
0

Your answer is here: Connecting slots and signals in PyQt4 in a loop

This will work:

from PySide2 import QtWidgets


class Program(QtWidgets.QWidget):

    def __init__(self):
        super(Program, self).__init__()
        layout = QtWidgets.QVBoxLayout(self)
        for n in range(3):
            new_b = QtWidgets.QPushButton('button {}'.format(n+1), self,
                                          clicked=lambda n=n: print('button {}'.format(n+1)))
            layout.addWidget(new_b)


if __name__ == '__main__':
    import sys

    app = QtWidgets.QApplication(sys.argv)
    window = Program()
    window.show()
    sys.exit(app.exec_())
Parsa Hz
  • 61
  • 4
  • 1 Your code does not work in my environment (Window10 64, python 3.7.9), it outputs `button 1` when clicking either button; 2 I already find the work-around method, which is just use `functools.partial`, but I also want to know why `lambda` does not work. – foool Feb 25 '21 at 10:32
  • Check the answer in the link – Parsa Hz Feb 25 '21 at 10:42
  • It is strange it works on PySide2 but not on PyQt5. – Parsa Hz Feb 25 '21 at 10:45
  • 1
    I check the answers of your reference and some others, such as https://stackoverflow.com/questions/47941743/lifetime-of-object-in-lambda-connected-to-pyqtsignal . It seems the namespace of the varible is the reason . I am not familar with Python closure, and need to refer to more materials. – foool Feb 25 '21 at 11:07
  • 2
    @foool The reason why the code in this answer doesn't work properly is caused by a different (but closely related) problem with default signal parameters. See here: https://stackoverflow.com/a/40310832/984421. – ekhumoro Feb 25 '21 at 13:32
  • @ekhumoro sound reasonable. After change the clicked signal to pressed signal or released signal, which have no default parameters and also be triggerred when clicking buttons, the code works as expectation. Maybe the only question for me is to find how the default parameter `state=False` in clicked siganl affects the `connect` process in PyQt5. – foool Feb 25 '21 at 14:16
  • @foool I have reopened your question and added an answer that I hope covers all the issues. – ekhumoro Feb 26 '21 at 03:45