pythongarbage-collectionpyqtsignals-slotsqsignalmapper

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


  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?


Solution

  • 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