PyQt/PySide 中连接点击信号时 lambda 和 partial 的区别

Differences between lambda and partial when connecting clicked signal in PyQt/PySide

我在将来自一组按钮的多个点击信号连接到带参数的单个槽函数时遇到了信号槽问题。

lambdafunctools.partial 可以按如下方式使用:

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

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

虽然在某些情况下,它们的表现不同。 下面的代码显示了一个示例,它希望在单击每个按钮时打印每个按钮的文本。 但在使用 lambda 方法时,输出始终是“按钮 3”。 partial 方法符合我们的预期。

如何找到它们的不同之处?

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.

就我个人而言,我同意接受的答案中的 ButtonGroup 方法是解决此类问题的正确且优雅的解决方案。

以下是关于这个问题的一些参考资料:

lambda in a loop

Transmitting extra data with Qt Signals

您的答案在这里: Connecting slots and signals in PyQt4 in a loop

这会起作用:

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_())

根据我的理解,原因是每次“部分”都会根据带有预定义参数的“printtext”函数创建一个新函数,因此您要为每个按钮传递一个不同的函数。 而在 lambda 函数中,您的参数是对变量的引用,因此当您单击按钮时,循环已经 运行 并且变量等于循环中的最后一个数字并打印 3。 原来你可以做同样的事情而不像这样(为我工作):

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_())

在讨论 lambda/partial 和其他一些问题之间的差异之前,我将简要介绍您的代码示例的一些实际修复。

主要有两个问题。第一个是 for 循环中名称绑定的常见问题,如以下问题所述:Lambda in a loop. The second is a PyQt-specific problem with default signal paramters, as discussed in this question: 。鉴于此,您的示例可以通过这样连接信号来修复:

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

所以这会在默认参数中缓存 n 的当前值,并且还会添加一个 checked 参数来阻止 n 被信号发出的参数覆盖。

该示例还可以受益于以更惯用的方式重写,从而消除讨厌的 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)

或使用 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)

(请注意,也可以在 Qt Designer 中通过选择按钮然后在上下文菜单中选择“分配给按钮组”来创建按钮组)。


关于 lambda 和 partial 的区别问题:主要是前者在局部变量上隐式创建了一个 closure,而后者在内部显式存储传递给它的参数。

python 中闭包的常见“陷阱”是循环内定义的函数不捕获封闭变量的当前值。所以当稍后调用该函数时,它只能访问最后看到的值。有一个 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 将传递给它的参数存储为只读属性。因此,在 lambda 中使用默认参数来显式存储变量的变通方法实际上是使用相同的方法。

这里还有一点值得一提:PyQt 和 PySide 在使用默认参数连接信号时的区别。看起来 PySide 将所有插槽都视为装饰有 slot decorator;而 PyQt 以不同的方式对待未修饰的插槽。这是差异的说明:

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}')

依次点击每个按钮后,产生以下输出:

PyQt5 输出:

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

PySide2 输出:

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

如果插槽用 @QtCore.pyqtSlot() 装饰,PyQt5 的输出与上面显示的 PySide2 输出匹配。因此,如果您需要一个适用于 PyQt 和 PySide 的 lambda(或任何其他未修饰槽)解决方案,您应该使用这个:

bh.clicked.connect(lambda *args, n=n: self.printtext(n))