Skillversitas

Signals and Slots

The signal/slot is the behavior model of Qt and the mechanism every PyQt app is built on: the signal is the named event the object emits (the clicked, the textChanged, the valueChanged), and the slot is the connected callable - a function, a bound method, a lambda, or a functor. The connect binds them with a type check at connect time (the mismatch is a warning before it runs), the disconnect removes the binding, and the connection types (the AutoConnection, the DirectConnection, the QueuedConnection) decide how the signal reaches the slot across threads. This chapter covers the connect forms and the type check, the signal arguments and the slot signature, the custom signals (the pyqtSignal), the disconnect discipline, and the connection types that make the threads chapter possible.

The connect Forms and the Type Check

from PyQt5.QtWidgets import QApplication, QPushButton
import sys
app = QApplication(sys.argv)
button = QPushButton("Go")

# the slot is a callable; the connect binds it to the signal
def on_clicked():
    print("clicked")
button.clicked.connect(on_clicked)

# the bound method (the slot on the object):
class Controller:
    def handle(self):
        print("the controller handled it")
ctrl = Controller()
button.clicked.connect(ctrl.handle)

# the lambda (the one-line slot):
button.clicked.connect(lambda: print("the lambda slot"))

# the TYPE CHECK: the signal's arguments must match the
# slot's (the slot may take FEWER - the extras are dropped);
# the mismatch is a WARNING at connect, not a crash at emit:
#   button.clicked.connect(lambda checked: ...)  # the
#   clicked carries no arg - the warning names the mismatch

# the disconnect is the inverse (by the signal + the slot):
button.clicked.disconnect(on_clicked)

# a signal can have SEVERAL slots; the emit calls them all
# (in the connect order) - the widget does not know who
# listens, which is the decoupling the model is built on

The connect is the binding, and its forms are the working vocabulary. The slot is any callable - the plain function, the bound method (the controller's handle), the lambda (the one-line slot), or the functor (the object with a __call__) - and the connect binds it to the signal, with the disconnect as the inverse (by the signal and the slot). The type check is the property that distinguishes the signal/slot from the plain callback: the signal's arguments must match the slot's signature (the slot may take fewer, the extras are dropped), and a mismatch is a warning at connect time, which is the check that catches the wiring error before it runs instead of the TypeError at the emit. A signal can have several slots, and the emit calls them all in the connect order - the widget does not know who listens, which is the decoupling the whole model is built on. The sender() (the object that emitted, available in the slot through the QObject.sender) is the reverse reference, the one that lets a shared slot tell which widget fired.

The Signal Arguments and the Slot Signature

from PyQt5.QtWidgets import (QApplication, QSlider, QLineEdit,
                             QSpinBox)
import sys
app = QApplication(sys.argv)

# the signals carry their arguments; the slot receives them
slider = QSlider()
slider.setRange(0, 100)
# the valueChanged(int): the slot takes the value
def on_value(value):
    print("slider:", value)
slider.valueChanged.connect(on_value)

# the textChanged(str): the line edit's text on every change
edit = QLineEdit()
edit.textChanged.connect(lambda t: print("text:", t))

# the slot with FEWER arguments: the extra is dropped
def on_value_min():
    print("the slider changed (the value not read here)")
slider.valueChanged.connect(on_value_min)
# the valueChanged carries the int; the slot takes none -
# the valid connect (the drop is the rule, not the error)

# the sender: the shared slot that must know the widget
def on_any_change():
    sender = sender_of()   # the QObject that emitted
    print("changed by:", sender)
# the QObject.sender() in the slot (the static form):
#   from PyQt5.QtCore import QObject
#   sender = QObject.sender()   # the emitting object

# the argument TYPES are the pyqt types: the int, the str,
# the bool, the QtCore types (the QPoint, the QByteArray) -
# the C++ type of the signal argument is the Python type the
# slot receives (the mapping is the binding's job)

The signals carry their arguments, and the slot receives them: the valueChanged carries the int value, the textChanged carries the str, the toggled carries the bool, and the C++ type of the signal argument is the Python type the slot receives (the mapping is the binding's job). The slot may take fewer arguments than the signal carries - the extras are dropped, which is the valid connect (the slot that does not need the value connects to the valueChanged with no argument) - and the mismatch the other way (the slot taking an argument the signal does not carry) is the warning at connect. The QObject.sender() is the reverse reference: in a slot, it is the object that emitted the signal, which is what a shared slot (the one connected to several widgets' signals) uses to tell which widget fired. The argument types are the QtCore types for the rich signals (the QPoint from the mouse signal, the QByteArray from the network) - the types the binding maps from the C++ side, and the slot's signature is the contract the type check enforces.

The Custom Signals: pyqtSignal

from PyQt5.QtCore import QObject, pyqtSignal, QTimer
from PyQt5.QtWidgets import QApplication
import sys
app = QApplication(sys.argv)

# the custom signal: the pyqtSignal on the QObject subclass
# (the type arguments are the signal's argument types)
class Worker(QObject):
    # the signal: the progress (the int), the finished (the str)
    progress = pyqtSignal(int)
    finished = pyqtSignal(str)
    failed = pyqtSignal(str)

    def run(self, total):
        for i in range(total):
            # ... do one unit ...
            self.progress.emit(i + 1)     # the emit
        self.finished.emit("done")

worker = Worker()
worker.progress.connect(lambda v: print("progress:", v))
worker.finished.connect(lambda m: print("result:", m))
worker.run(5)

# the emit is the method the signal generates: the
# progress.emit(value) is the call that fires the slots
# (the DirectConnection, the same thread, by default)

# the signal without arguments: the pyqtSignal() (the
# no-arg, the "the event happened" notification)
class Model(QObject):
    changed = pyqtSignal()
    def touch(self):
        self.changed.emit()

The Disconnect Discipline

from PyQt5.QtWidgets import QApplication, QPushButton
import sys
app = QApplication(sys.argv)
button = QPushButton("Go")

def on_clicked():
    print("clicked")

# the connect returns NOTHING in PyQt5 (the binding is the
# state); the disconnect takes the signal + the slot:
button.clicked.connect(on_clicked)
button.clicked.disconnect(on_clicked)
# the slot no longer fires on the next emit

# the disconnect ALL: the disconnect without the slot
# disconnects every slot on that signal:
def a(): pass
def b(): pass
button.clicked.connect(a)
button.clicked.connect(b)
button.clicked.disconnect()   # both a and b are gone

# the rule for the long-running app: the component that can
# be created and destroyed keeps its connections and
# disconnects them on its teardown - the handler on the
# destroyed object is the "the widget is gone but the slot
# still fires" bug (the dangling reference, the crash on
# the emit to the dead object)

# the parent / the destroy: the QObject's deleteLater (the
# deferred delete, the Qt's safe destroy) is the teardown;
# the connections to a deleted object are dropped by the
# QObject tree when the parent is destroyed

The disconnect is the inverse of the connect: the disconnect(signal, slot) removes that one binding, and the disconnect() without the slot removes every slot on that signal. The discipline is the one the long-running app needs: the component that can be created and destroyed keeps its connections and disconnects them on its teardown, because a slot connected to a destroyed object is the dangling-reference bug - the emit to the dead object, the crash or the silent no-op. The QObject parent tree handles part of this automatically (the connections are dropped when the parent is destroyed, and the deleteLater is the deferred delete the Qt's safe destroy), but the explicit disconnect is the deliberate form for the component whose lifetime the app manages - the same rule the other toolkits' signal chapters state, with the QObject tree as the automatic layer underneath.

The Connection Types: Across the Threads

from PyQt5.QtCore import (QObject, pyqtSignal, QThread,
                          Qt, QTimer)
from PyQt5.QtWidgets import QApplication, QLabel
import sys
app = QApplication(sys.argv)

# the connection type: the how of the signal-to-slot delivery
#   AutoConnection (the default): the Direct if the slot is
#     in the EMITTING thread, the Queued if it is in ANOTHER
#   DirectConnection: the slot runs IMMEDIATELY, in the
#     emitting thread (the synchronous, the same stack)
#   QueuedConnection: the signal is POSTED to the slot's
#     thread's event loop (the asynchronous, the cross-thread)
#   BlockingQueuedConnection: the Queued + the emit BLOCKS
#     until the slot runs (the synchronous cross-thread)

# the cross-thread: the worker in its QThread, the UI in the
# main thread - the AutoConnection QUEUES the worker's
# signals to the main thread's loop (the safe marshal):
class Worker(QObject):
    result = pyqtSignal(str)
    def work(self):
        import time
        time.sleep(0.5)
        self.result.emit("the worker's result")

label = QLabel("waiting")
worker = Worker()
thread = QThread()
worker.moveToThread(thread)
# the result is emitted from the worker thread; the slot is
# on the main thread's label - the AutoConnection QUEUES it:
worker.result.connect(label.setText)
thread.started.connect(worker.work)
thread.start()
# the label.setText runs on the MAIN thread (the loop's),
# marshaled by the queued connection - the widget is touched
# by its owner, the thread-safety the threads chapter builds
# on is THIS mechanism

The connection type is the how of the signal-to-slot delivery, and it is the mechanism the threading chapter builds on. The AutoConnection (the default) is the DirectConnection when the slot is in the emitting thread (the synchronous, the same stack) and the QueuedConnection when it is in another (the asynchronous, the posted to the slot's thread's event loop). The DirectConnection is the explicit synchronous (the slot runs immediately, in the emitting thread), the QueuedConnection is the explicit cross-thread (the signal is posted to the slot's thread's loop), and the BlockingQueuedConnection is the queued plus the emit blocks until the slot runs (the synchronous cross-thread). The example is the worker in its QThread (the moveToThread), emitting its result from the worker thread to the main thread's label.setText - the AutoConnection sees the slot is in another thread and queues it, so the setText runs on the main thread (the loop's), which is the thread-safety: the widget is touched by its owner, and the queued connection is the marshal. The signal/slot is not just the behavior model - it is the thread bridge, and the connection type is the control of how the signal crosses.

The Working Rules

  • The slot is a callable; the connect is typed. The function, the bound method, the lambda, the functor - the connect checks the arguments against the slot's signature, and the mismatch is the warning at connect, the check that catches the wiring before it runs.
  • The slot may take fewer arguments; the extra is dropped. The valueChanged carries the int, the no-argument slot is the valid connect - and the QObject.sender() is the reverse reference the shared slot uses to tell the emitter.
  • The custom signal is the pyqtSignal with its type arguments. The progress = pyqtSignal(int) on the QObject subclass, the emit the method that fires the slots - the app's own events, the same system the widgets use.
  • Disconnect on the component's teardown. The disconnect(signal, slot) or the disconnect() for all - the slot on the destroyed object is the dangling reference, and the QObject tree drops the connections on the parent's destroy as the automatic layer.
  • The connection type is the thread control. The AutoConnection (the default) is the direct in-thread, the queued cross-thread; the QueuedConnection is the explicit cross-thread marshal - the widget touched by its owner, the thread-safety the threads chapter relies on.

Summary

The signal/slot is the behavior model: the signal is the named event the object emits, the slot is the connected callable (the function, the bound method, the lambda, the functor), and the connect binds them with the type check (the arguments against the slot's signature, the mismatch the warning at connect, the slot taking fewer the valid drop). The signals carry their arguments (the int, the str, the bool, the QtCore types), the QObject.sender() is the reverse reference, and the custom signal is the pyqtSignal with its type arguments on the QObject subclass, the emit the method that fires the slots. The disconnect is the inverse (the one slot, or the disconnect() for all), and the discipline is the disconnect on the component's teardown (the dangling reference the bug). The connection type is the thread control: the AutoConnection (the default) is the direct in-thread and the queued cross-thread, the QueuedConnection is the explicit cross-thread marshal that posts the signal to the slot's thread's loop - the widget touched by its owner, the thread-safety the threading chapter builds on. The signal/slot is the behavior model and the thread bridge, and the connect/disconnect and the connection type are the controls of both.