I did some additional testing, the the refcount for a Python lambda used as a slot is not automatically decremented when a QObject is deleted. I tried PySide 6.6.2 which includes the correct 4 argument connect function. I could not find a way of passing a QObject that would result in the lambda refcount being decremented to 0 so that it would be deleted. This could potentially be considered a PySide6 bug, but I am not sure passing a lambda as a slot is truly valid, either.
I have found a different approach that both isolates the lambda closure and allows for automatic delete. Instead of directly connecting the lambda to the Signal, the approach uses a separate QObject wrapper:
class CallableSlotAdapter(QtCore.QObject):
"""A QObject that calls a python callable whenever its slot is called.
:param parent: The required parent QObject that manages this
instance lifecycle through the Qt parent-child relationship.
:param fn: The Python callable to call.
Connect the desired signal to self.slot.
"""
def __init__(self, parent, fn):
self._fn = fn
super().__init__(parent)
def slot(self, *args):
code = self._fn.__code__
co_argcount = code.co_argcount
if args and isinstance(self._fn, types.MethodType):
args -= 1
self._fn(*args[:co_argcount])
Here is an example for how to use it:
button = QtWidgets.QPushButton('Push me', parent)
adapter = CallableSlotAdapter(button, lambda checked=False: print(f'pressed {checked}')
button.pressed.connect(adapter.slot)
Since a large percentage of my lambda usage was for QAction in QMenu, I also made this:
class CallableAction(QtGui.QAction):
"""An action that calls a python callable.
:param parent: The parent for this action. If parent is a QMenu instance,
or QActionGroup who has a QMenu instance parent,
then automatically call parent.addAction(self).
:param text: The text to display for the action.
:param fn: The callable that is called with each trigger.
:param checkable: True to allow checkable.
:param checked: Set the checked state, if checkable.
As of Feb 2024, connecting a callable to a signal increments
the callable's reference count. Unfortunately, this reference
count is not decremented when the object is deleted.
This class provides a Python-side wrapper for a QAction that
connects to a Python callable. You can safely provide a lambda
which will be dereferenced and deleted along with the QAction.
For a discussion on PySide6 memory management, see
https://forum.qt.io/topic/154590/pyside6-memory-model-and-qobject-lifetime-management/11
"""
def __init__(self, parent: QtCore.QObject, text: str, fn: callable, checkable=False, checked=False):
self._fn = fn
super().__init__(text, parent=parent)
if bool(checkable):
self.setCheckable(True)
self.setChecked(bool(checked))
self.triggered.connect(self._on_triggered)
while isinstance(parent, QtGui.QActionGroup):
parent = parent.parent()
if isinstance(parent, QtWidgets.QMenu):
parent.addAction(self)
def _on_triggered(self, checked=False):
code = self._fn.__code__
args = code.co_argcount
if args and isinstance(self._fn, types.MethodType):
args -= 1
if args == 0:
self._fn()
elif args == 1:
self._fn(checked)
Since we can rely on QObject parent-child relationship to manage the child object lifetime, both CallableSlotAdapter and CallableAction can correctly decrement the fn refcount and delete the fn that is a lambda, assuming no one else maintains a reference to the lambda.
Here is my example code that shows that this works:
from PySide6 import QtCore, QtGui, QtWidgets
import sys
import gc
import html
import weakref
def _deref(name, obj):
if obj is None:
return f'{name}: no weakref yet'
obj = obj()
if obj is None:
return f'{name}: weakref None'
return f'{name}: refcount={sys.getrefcount(obj) - 2} | {obj}'
class CallableAction(QtGui.QAction):
def __init__(self, parent: QtCore.QObject, text: str, fn: callable):
self._fn = fn
super().__init__(text, parent)
self.triggered.connect(self._on_triggered)
def _on_triggered(self):
self._fn()
class MainWindow(QtWidgets.QMainWindow):
tick_value = QtCore.Signal(str)
def __init__(self, parent=None):
self._menu = None
self._action = None
self._fn = None
super().__init__(parent)
self._widget = QtWidgets.QWidget(self)
self.setCentralWidget(self._widget)
self._layout = QtWidgets.QVBoxLayout(self._widget)
label = QtWidgets.QLabel('Hello world')
label.setToolTip('my tooltip')
self.tick_value.connect(label.setText)
self._layout.addWidget(label)
self._label_wref = weakref.ref(label)
custom_label = QtWidgets.QLabel('Another label')
self._layout.addWidget(custom_label)
button = QtWidgets.QPushButton('Push me')
button.pressed.connect(self._on_button)
self._layout.addWidget(button)
self._status = QtWidgets.QLabel()
self._layout.addWidget(self._status)
self._tick = 0
self._on_timer()
self._timer = QtCore.QTimer(self)
self._timer.timeout.connect(self._on_timer)
self._timer.start(1000)
@QtCore.Slot()
def _on_timer(self):
gc.collect()
gc.collect()
s = [str(self._tick)]
self._tick += 1
s.append(html.escape(_deref('menu', self._menu)))
s.append(html.escape(_deref('action', self._action)))
s.append(html.escape(_deref('lambda', self._fn)))
s.append(html.escape(_deref('label', self._label_wref)))
s = '<html><body>' + '<br/>'.join(s) + '</body></html>'
self.tick_value.emit(s)
@QtCore.Slot()
def _on_button(self):
self._status.setText('button pressed')
@QtCore.Slot()
def _on_section1_1(self):
self._status.setText('section1_1')
@QtCore.Slot()
def _on_section1_2(self):
self._status.setText('section1_2')
def _on_section1_3(self):
self._status.setText('section1_3')
def mousePressEvent(self, event):
event.accept()
if event.button() == QtCore.Qt.RightButton:
menu = QtWidgets.QMenu('My menu', self) # must set parent!
menu.triggered.connect(menu.deleteLater)
s1 = menu.addMenu('Section 1')
# Manually add an action, set action as parent
a1_1 = QtGui.QAction('Item 1', s1) # must set parent!
a1_1.triggered.connect(self._on_section1_1)
s1.addAction(a1_1)
# Simple action add
s1.addAction('Item 2', self._on_section1_2)
# Add an action that uses a lambda function as a slot
# The lambda closure contains self, which outlives the menu
# On delete, slots should be disconnected
fn = lambda: print(f'lambda dir={dir()}') or self._on_section1_3()
self._fn = weakref.ref(fn)
#refcount_before = sys.getrefcount(fn)
#a1_3 = s1.addAction('Item 3', fn)
#refcount_after = sys.getrefcount(fn)
#print(f'fn refcount incr = {refcount_after - refcount_before}')
a1_3 = CallableAction(s1, 'Item 3', fn)
s1.addAction(a1_3)
self._action = weakref.ref(a1_3)
# Display context menu at mouse press location
menu.popup(event.globalPosition().toPoint())
self._menu = weakref.ref(menu)
if __name__ == '__main__':
app = QtWidgets.QApplication([])
w = MainWindow()
w.show()
app.exec()
After launching, right-click to open the context menu, which uses weakrefs to snoop the object lifecycle.
I updated my codebase to use this approach. I am also scouring the code to make sure that it speciifies a parent either indirectly through QLayout.addWidget or directly in the constructor (e.g. QAction, QTimer) for all QObjects. I found a few places keeping Python QAction references without assigning a parent. Combined with the improved Slot lambda handling, it should eliminate memory leaks and method invocations on potentially deleted objects.
Here is my summary of how to correctly manage memory for PySide6 / Qt6:
Ensure all QObjects get a parent, except for the very few top-level QObjects. You only need to maintain a Python reference to the QObjects without a parent. You may opt to keep a reference to QObjects you need to manipulate through code and not just Signal / Slot connections.
When using Signals and Slots, any callable works as a slot in Python. Avoid the temptation and stick to QObjects with Slot methods. Qt is designed for this.
If you do want to use a lambda for a Slot, use a wrapper QObject with a Slot method that invokes the lambda.
Note that Qt automatically deletes all children when the parent is deleted.
Note that Qt automatically disconnects all Signals and Slots for a QObject when that QObject is deleted.
Use QObject.deleteLater() to delete QObjects.
Keep QObject Python references local. Use Signals and Slots to communicate, especially between parent-child Widgets. For larger applications, Signals & Slots communication has scaling challenges. Consider using PubSub.