The most elegant way to communicate between parent and child using Signal - PyQt5 & PySide2

I would like to explain my question with an example.

There is more than one way to communicate between parent and children, if I know right.

if parent and child communicate directly... for example like this (1):

class Main(QWidget):
  def __init__(self):
    super().__init__()
    self.button = QPushButton("click me")

I don't have any problem here. But when there is child's child and when I want to communicate child's child, this is getting more complicated for me. for example (2):

class Button(QPushButton):
  def __init__(self, parent=None):
    super().__init__(parent)
    self.setText("click me")

class AnotherFrame(QFrame):
  def __init__(self, parent=None):
    super().__init__(parent)
    self.button = Button(self)

class Frame(QFrame):
  def __init__(self, parent=None):
    super().__init__(parent)
    self.another_frame = AnotherFrame(self)

class Main(QWidget):
  def __init__(self):
    super().__init__()
    self.frame = Frame(self)

for first example (1) I can connect signal in main class directly.

class Main(QWidget):
  def __init__(self):
    super().__init__()
    self.button = QPushButton("click me")
    self.button.clicked.connect(self.print_clicked)
  
  @staticmethod
  def print_clicked():
    print("clicked")

but for example (2), what should I do? is this the solution for that? :

class Main(QWidget):
  def __init__(self):
    super().__init__()
    self.frame = Frame()
    self.frame.another_frame.button.clicked(self.print_clicked)

But I heard about we shouldn't access objects directly in OOP. am I need to use getter and setter here? something like this:

  self.frame.get_another_frame().get_button().clicked(self.print_clicked)

or otherwise I can do this as well:

class Button(QPushButton):
  def __init__(self, parent=None):
    super().__init__(parent)
    self.setText("click me")
    self.connect(self.parent().parent().print_clicked)

so for (2) what is the best way to communicate between main class and button class? I want to listen click signal and run print_clicked function in main class.

(I am new here and I am still studying English. please let me know if I am not good at explaining my problem)


While encapsulation and information hiding are often considered part of the same aspects, indirect access to members is not "forbidden" (or discouraged) per se. Remember that design patterns and practices are not absolute rules, they exist to provide guidelines for good programming.

While, formally, it is better to allow access to child members only through functions, this doesn't have to cause unnecessary complication.

Python provides composition (but I believe that the proper term should be "aggregation" in this case), and while "good practice" suggest you should avoid access beyond a certain level, making functions and subclasses just to gain access once to a single component because "that's how it should be done" isn't very useful.[1]

Clearly, this is not very elegant:

self.frame.another_frame.button.clicked(self.print_clicked)

But, for very simple cases, there's really no shame in that, as long as you are completely sure that the object hierarchy will always be the same.

A possible alternative could be to make the child attribute a member of the current instance, recursively:

class Frame(QFrame):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.another_frame = AnotherFrame(self)
        self.button = self.another_frame.button


class Main(QWidget):
    def __init__(self):
        super().__init__()
        self.frame = Frame(self)
        self.button = self.frame.button
        self.button.clicked(self.print_clicked)

Better, especially from the point of view of readability; but still not particularly good. We must still be sure that both Frame and AnotherFrame have a reference for the button. We should also avoid doing that for lots of attributes, as it would make the main instance overcrowded with members that are actually used just once.

Qt luckily provides signals and signal chaining, a system that offers a better and more modular way to connect objects without going too deep in the structure while preserving modularity and some level of encapsulation: as long as the signatures are compatible[2], you can directly connect a signal to another, and the connected signal will be emitted when the original one is.

In the following examples I'm creating signals with the same signature as QAbstractButton.clicked (which is a bool indicating the checked state), but if you're not interested in that you can obviously omit that in the "upper levels".

class AnotherFrame(QFrame):
    buttonClicked = pyqtSignal(bool) # or Signal(bool) for PySide
    def __init__(self, parent=None):
        super().__init__(parent)
        self.button = Button(self)
        self.button.clicked.connect(self.buttonClicked)


class Frame(QFrame):
    buttonClicked = pyqtSignal(bool)
    def __init__(self, parent=None):
        super().__init__(parent)
        self.another_frame = AnotherFrame(self)
        self.another_frame.buttonClicked.connect(self.buttonClicked)


class Main(QWidget):
    def __init__(self):
        super().__init__()
        self.frame = Frame(self)
        self.frame.buttonClicked.connect(self.print_clicked)

This approach is much better for many reasons: we don't need to access the button in any way, so we can even remove the button from the class without having any AttributeError exception, as long as the signal exists.
This is useful when creating prototype classes, so you can have a base Frame class that only provides the signal, and then use subclasses that change their behavior or aspect, while keeping the same interface:

class BaseFrame(QFrame):
    buttonClicked = pyqtSignal(bool)


class FrameA(BaseFrame):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.another_frame = AnotherFrame(self)
        self.another_frame.buttonClicked.connect(self.buttonClicked)


class FrameB(BaseFrame):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.something_else = SomethingElse(self)


class Main(QWidget):
    def __init__(self):
        super().__init__()
        self.frame = FrameB(self)
        self.frame.buttonClicked.connect(self.print_clicked)

In this case, even if FrameB doesn't have an inner frame containing the button, we can still connect the signal to the function: it obviously won't do anything, but encapsulation will be properly respected without trying to access the object tree and risking exceptions due to the missing attribute.

The only (partial) drawback is that the result of self.sender()[3] will not be the object that emitted the original signal, but the "last" one that is actually connected to the slot. In the case on top of this answer, it will return the button instance (since we connected to a reference to the original sender), while in the case above it will return the frame (the "owner" of the signal we connected to).
If you need to know the source of the signal (at any level), you can then create a signal that also sends the instance.
For example, supposing you're not interested in the checked argument, but in the frame that emitted the signal:

class AnotherFrame(QFrame):
    buttonClicked = pyqtSignal(bool)
    def __init__(self, parent=None):
        super().__init__(parent)
        self.button = Button(self)
        self.button.clicked.connect(self.buttonClicked)


class Frame(QFrame):
    buttonClicked = pyqtSignal(object)
    def __init__(self, parent=None):
        super().__init__(parent)
        self.another_frame = AnotherFrame(self)
        self.another_frame.buttonClicked.connect(self.emitButtonClicked)

    def emitButtonClicked(self):
        self.buttonClicked.emit(self)


class Main(QWidget):
    def __init__(self):
        super().__init__()
        self.frame = Frame(self)
        self.frame.buttonClicked.connect(self.print_clicked)

    def print_clicked(self, frame):
        print(frame)

[1] remember some important points of import this: "Special cases aren't special enough to break the rules. - Although practicality beats purity."
[2] in order to connect two signals, the target signal must have arguments of the same types, and an argument number equal to or less than the source: Signal(int, bool) cannot be connected to Signal(int, str), but it can be connected to Signal(int) or Signal().
[3] as also reported in the documentation, sender() "violates the object-oriented principle of modularity. However, getting access to the sender might be useful when many signals are connected to a single slot."