pyqt: A correct way to connect multiple signals to the same function in pyqt (QSignalMapper not applicable)
Asked Answered
B

2

5
  1. I've ready many posts on how to connect multiple signals to the same event handler in python and pyqt. For example, connecting several buttons or comboboxes to the same function.

  2. Many examples show how to do this with QSignalMapper, but it is not applicable when the signal carries a parameter, as with combobox.currentIndexChanged

  3. Many people suggest it can be made with lambda. It is a clean and pretty solution, I agree, but nobody mentions that lambda creates a closure, which holds a reference - thus the referenced object can not be deleted. Hello memory leak!

Proof:

from PyQt4 import QtGui, QtCore

class Widget(QtGui.QWidget):
    def __init__(self):
        super(Widget, self).__init__()

        # create and set the layout
        lay_main = QtGui.QHBoxLayout()
        self.setLayout(lay_main)

        # create two comboboxes and connect them to a single handler with lambda

        combobox = QtGui.QComboBox()
        combobox.addItems('Nol Adyn Dwa Tri'.split())
        combobox.currentIndexChanged.connect(lambda ind: self.on_selected('1', ind))
        lay_main.addWidget(combobox)

        combobox = QtGui.QComboBox()
        combobox.addItems('Nol Adyn Dwa Tri'.split())
        combobox.currentIndexChanged.connect(lambda ind: self.on_selected('2', ind))
        lay_main.addWidget(combobox)

    # let the handler show which combobox was selected with which value
    def on_selected(self, cb, index):
        print '! combobox ', cb, ' index ', index

    def __del__(self):
        print 'deleted'

if __name__ == '__main__':

    import sys
    app = QtGui.QApplication(sys.argv)

    wdg = Widget()
    wdg.show()

    wdg = None

    sys.exit(app.exec_())

The widget is NOT deleted though we clear the reference. Remove the connection to lambda - it gets deleted properly.

So, the question is: which is the proper way to connect several signals with parameters to a single handler without leaking memory?

Brobdingnagian answered 23/10, 2015 at 13:59 Comment(0)
D
5

It is simply untrue that an object cannot be deleted because a signal connection holds a reference in a closure. Qt will automatically remove all signal connections when it deletes an object, which will in turn remove the reference to the lambda on the python side.

But this implies that you cannot always rely on Python alone to delete objects. There are two parts to every PyQt object: the Qt C++ part, and the Python wrapper part. Both parts must be deleted - and sometimes in a specific order (depending on whether Qt or Python currently has ownership of the object). In addition to that, there's also the vagaries of the Python garbage-collector to factor in (especially during the short period when the interpreter is shutting down).

Anyway, in your specific example, the easy fix is to simply do:

    # wdg = None
    wdg.deleteLater()

This schedules the object for deletion, so a running event-loop is required for it have any effect. In your example, this will also automatically quit the application (because the object is the last window closed).

To more clearly see what's happening, you can also try this:

    #wdg = None
    wdg.deleteLater()

    app.exec_()

    # Python part is still alive here...
    print(wdg)
    # but the Qt part has already gone
    print(wdg.objectName())

Output:

<__main__.Widget object at 0x7fa953688510>
Traceback (most recent call last):
  File "test.py", line 45, in <module>
    print(wdg.objectName())
RuntimeError: wrapped C/C++ object of type Widget has been deleted
deleted

EDIT:

Here's another debugging example that hopefully makes it even clearer:

    wdg = Widget()
    wdg.show()

    wdg.deleteLater()
    print 'wdg.deleteLater called'

    del wdg
    print 'del widget executed'

    wd2 = Widget()
    wd2.show()

    print 'starting event-loop'
    app.exec_()

Output:

$ python2 test.py
wdg.deleteLater called
del widget executed
starting event-loop
deleted
Doretha answered 23/10, 2015 at 17:8 Comment(4)
deleteLater() seems to hide the widget, but the destructor is not called none the less. Adding a second widget after the scheduling the deleting of the first one shows the second, but no sign of deleting the first. As before, removing connects solves the problem. wdg.deleteLater() wdg2 = Widget() wdg2.move(300,100) wdg2.show()Brobdingnagian
@GrigoryMakeev. No, that is not what happens at all. Obviously, the Python wrapper doesn't get deleted immediately, since you are still holding a global reference to it. But all you need to do is del wdg, and __del__ will be called once Qt has deleted the C++ part. I've added another debugging example to my answer which should show even more clearly what is really happening.Doretha
Indeed it is working now, thank you! Only one thing remains unclear to me: if i add gc.collect() right after del wdg, the destructor is not called still. Any idea why?Brobdingnagian
@GrigoryMakeev. See the note in the python docs for __del__. A reference is still held by Qt, so the python garbage-collector must wait until the deleteLater event is processed. I put the print statement before the event-loop in my debugging example to make it clear that __del__ will only be called once event-processing has started. So Qt deletes the C++ part first, which then allows the garbage-collector to delete the python part.Doretha
D
2

in many cases the parameter carried by signal can be catched in another way, e.g. if an objectName is set for the sending object, so QSignalMapper can be used:

    self.signalMapper = QtCore.QSignalMapper(self)
    self.signalMapper.mapped[str].connect(myFunction)  

    self.combo.currentIndexChanged.connect(self.signalMapper.map)
    self.signalMapper.setMapping(self.combo, self.combo.objectName())

   def myFunction(self, identifier):
         combo = self.findChild(QtGui.QComboBox,identifier)
         index = combo.currentIndex()
         text = combo.currentText()
         data = combo.currentData()
Dann answered 23/10, 2015 at 19:33 Comment(1)
Yes, thank you, that is a workaround we currently use. Basically it only highlights the fact we can NOT capture the parameter signal in this case and should therefore try to infer it some other way, in this case with combo.currentIndex(). Only we use self.signalMapper.mapped[QtCore.QWidget] form, so we do not have to use findChild.Brobdingnagian

© 2022 - 2024 — McMap. All rights reserved.