Mechanizm sygnałów i slotów w Qt jest jednym z najbardziej cenionych i rozpoznawalnych funkcjonalności, które zawdzięczamy temu właśnie frameworkowi. Za tym mechanizmem stoi system Meta-Object Compiler będący przedmiotem poprzedniej części tego kursu Qt, ale sygnały i sloty są tematem tak istotnym, że zdecydowałem się na umieszczenie ich w oddzielnym wpisie. Gwarantuję Ci, że zdecydowanie polubisz sposób komunikacji między obiektami oferowany przez Qt.
Uwaga
Zawodowo zajmuję się tematem Qt Development. Od czasu gdy napisałem te wpisy minęło kilka lat a ja zdążyłem założyć jednąz najbardziej znanych firm zajmujących się kodzeniem w Qt. Ten tutorial trochę się zaktualizował, więc zachęcam Cię odwiedzić stronę Scythe Studio, bo tam dzielimy się nowym tutorialem na YouTube, w którym temat jest podejmowany.
Zanim zaczniesz
W ramach tego kursu Qt w pierwszej kolejności skupiamy się na tworzeniu interfejsu graficznego korzystając właśnie z tej technologii. Następnie to czytelnicy będą mogli zdecydować, w którą stronę pójdzie kurs. Dołożę wszelkich starań, aby nowe wpisy ukazywały się co czwartek. Jeżeli ten wpis jest pierwszym z tego kursu na jaki trafiasz to sugeruję, abyś zajrzał również do poprzednich. Miłej lektury!
Mechanizm sygnałów i slotów – główna idea
Zarówno w przypadku pisania aplikacji okienkowych jak i komponentów na jej zapleczu, zdarza się, że zachodzi potrzeba opracowania metody komunikacji pomiędzy obiektami. Przykładowo w sytuacji, gdy użytkownik wciśnie przycisk START w menu gry chcielibyśmy, aby to zdarzenie implikowało jakieś działanie, jak chociażby uruchomienie planszy z grą. Innym przykładem może być chociażby obiekt pingujący dostęp do serwera, gdzie po pomyślnym zapytaniu do serwera rozpoczęlibyśmy pobieranie dalszych danych.
W zwyczajnych warunkach, tj. bez Qt, taka komunikacja odbywa się za pomocą callbacków lub w sposób bardziej „robaczany”, czyli poprzez trzymanie w klasie wskaźnika na obiekt, na rzecz, którego wywołać chcemy metodę. Oba sposoby niosą za sobą szereg możliwych problemów i są nieintuicyjne.
Jak to działa w Qt?
Framework Qt wychodzi naprzeciw dotychczasowym rozwiązaniom dostarczając mechanizm sygnałów i slotów, polegający na tym, że dany obiekt emituje sygnał, będący de facto metodą bez ciała. W tym samym obiekcie lub innym w zależności od naszych zamiarów implementujemy slot – metodę zawierającą instrukcje, które powinny zostać wykonane w przypadku jej wywołania. Zarówno sygnały jak i sloty mogą istnieć niezależnie od siebie. Oznacza to, że na jeden sygnał obiekt może reagować wieloma różnymi slotami oraz każdy slot może być wywołany w reakcji na wiele sygnałów pochodzących z różnych źródeł.
W dodatku mechanizm sygnałów i slotów w Qt niejako wymusza na nas zachowanie tej samej albo bardzo podobnej sygnatury sygnału i slotu. Właściwie to w momencie, gdy sygnał i slot są metodami to chodzi po prostu o to, że parametry sygnału muszą się zgadzać z parametrami slotu. Właściwie to liczba parametrów w slocie może być mniejsza niż liczba parametrów w sygnale, ale te początkowe muszą się zgadzać. Więcej zobaczymy na przykładach.
Deklaracja sygnału
Sygnały opierają się o system Meta-Object Compiler, czyli aby zadeklarować sygnał, klasa musi dziedziczyć po QObject oraz musimy ją dodatkowo udekorować makrem Q_OBJECT. Następnie dodajemy w klasie sekcję signals i w niej jak gdyby nigdy nic tworzymy deklarację metody. Metoda ta nie powinna niczego zwracać, dlatego stosujemy void. Jeżeli chodzi o ilość i typ parametrów to mamy pełną dowolność.
Załóżmy, że stworzymy klasę, która będzie kontrolować stan połączenia z serwerami binarnie.pl. Posiadać będzie metodę check(), która w przypadku, gdy połączenie się zmieni wyemituje sygnał accessChanged(bool access), jeżeli dotychczasowy dostęp się zmienił.
Deklarację sygnału należy zawrzeć w klasie w sekcji signals:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#ifndef CONNECTIVITYCHECKER_H #define CONNECTIVITYCHECKER_H #include <QObject> class ConnectivityChecker : public QObject { Q_OBJECT public: explicit ConnectivityChecker(QObject *parent = nullptr); void checkAccess(); signals: void accessChanged(bool hasAccess); private: bool _hasAccess = false; }; #endif // CONNECTIVITYCHECKER_H |
Następnie jeżeli chcemy sygnału wyemitować to w odpowiednim miejscu należy wywołać instrukcję emit accessChanged() z parametrem określającym zmieniony dostęp do serwera. Sygnał emitujemy w sytuacji, gdy dostęp się zmieni, tj. będzie inny niż dotychczasowy.
1 2 3 4 5 6 7 8 9 |
void ConnectivityChecker::checkAccess() { const bool hasAccess = (qrand() % 2 == 0) ? true : false; if (hasAccess != _hasAccess) { _hasAccess = hasAccess; emit accessChanged(_hasAccess); } } |
Na dobrą sprawę pisanie „emit” przed wywołaniem sygnału można by sobie darować, ale taki zabieg zwiększa czytelność kodu, ponieważ później patrząc w taki kod masz pewność, że wyemitowałeś sygnał a nie wywołałeś zwykłą metodę.
Połączenie sygnału z funkcją lambda
Jednym z pierwszych, mało inwazyjnych sposobów na wykorzystanie sygnału jest połączenie go z funkcją bądź wyrażeniem lambda. Wówczas za każdym razem, gdy sygnał zostanie wyemitowany to taka funkcja zostanie wywołana. Aby połączenie zrealizować należy skorzystać z poniższej metody:
1 |
QObject::connect(const QObject *sender, PointerToMemberFunction signal, Functor functor) |
Gdzie pierwszym parametrem jest wskazanie na obiekt emitujący sygnał, drugim jest funkcja zadeklarowana w definicji klasy tego obiektu jako sygnał, a trzecim parametrem jest właśnie wskazanie na funkcję/wyrażenie lambda.
Może to wyglądać na przykład w ten sposób:
1 2 3 4 5 6 7 8 9 |
ConnectivityChecker checker; QObject::connect(&checker, &ConnectivityChecker::accessChanged, [](bool hasAccess) { qDebug() << "Access changed to " << hasAccess; }); for (int i{0}; i < 10; ++i) { checker.checkAccess(); } |
Jak widzisz sygnał został połączony z wyrażeniem lambda wypisującym „Access changed to ” z wartością zmienionego dostępu w sytuacji, gdy ten faktycznie się zmieni. Dostęp ma szansę zmienić się 10 razy, bo tyle razy wywołujemy metodę checkAccess().
Deklaracja slotu
Załóżmy, że chcielibyśmy, aby nasz ConnectivityChecker sprawdzał dostęp nieustannie a nie tylko w momencie, gdy ręcznie wywołujemy metodę, która ma za zadanie to sprawdzić. W tym celu stworzymy timer, który co 2 sekundy będzie wywoływał funkcję checkAccess().
Skorzystamy z obiektu klasy QTimer, która emituje sygnał timeout() w momencie, gdy timer skończy odliczanie. Skoro mamy do czynienia z sygnałem to stworzymy również slot, który będzie wywoływany po każdej emisji tego sygnału i jego zadanie będzie sprawdzanie dostępności serwera.
Właściwie to mamy nawet taką metodę – checkAccess(). Darujemy sobie zatem pisanie kolejnej robiącej to samo. Wystarczy, że w definicji klasy umieścimy tą metodę w sekcji public slots lub private slots tak jak poniżej.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#ifndef CONNECTIVITYCHECKER_H #define CONNECTIVITYCHECKER_H #include <QObject> #include <QTimer> class ConnectivityChecker : public QObject { Q_OBJECT public: explicit ConnectivityChecker(QObject *parent = nullptr); signals: void accessChanged(bool hasAccess); private slots: void checkAccess(); private: bool _hasAccess = false; QTimer *_timer; // dodaliśmy również Timer }; #endif // CONNECTIVITYCHECKER_H |
Połączenie sygnału ze slotem
Teraz slot checkAccess nie będzie już wywoływany z zewnątrz zatem możemy umieścić go w sekcji private slots. Niemniej w przypadku, gdy slot będzie wykorzystywany również na zewnątrz klasy, umieść go w sekcji public slots.
Zauważ, że w tym samym czasie dodaliśmy nową składową klasy – QTimer *timer. Teraz zmienimy nieco domyślny konstruktor w ten sposób, aby przy utworzeniu nowej instancji obiektu ConnectivityChecker tworzona była również instancja timera domyślnie połączona sygnałem timeout z prywatnym slotem checkAccess().
1 2 3 4 5 6 7 |
ConnectivityChecker::ConnectivityChecker(QObject *parent) : QObject(parent), _timer { new QTimer(this) } { connect(_timer, &QTimer::timeout, this, &ConnectivityChecker::checkAccess); _timer->setInterval(2000); _timer->start(); } |
Ot cała magia. Teraz wystarczy, że usuniesz pętlę używaną wcześniej i twój program będzie sprawdzał dostęp co dwie sekundy bez ustanku. Myślę, że kod jest zrozumiały.
Inna odsłona metody connect()
Parę słów o metodzie:
1 |
QObject::connect(const QObject *sender, PointerToMemberFunction signal, const QObject *receiver, PointerToMemberFunction method) |
Z bardzo podobnej metody korzystaliśmy wcześniej, łącząc sygnał accessChanged() z wyrażeniem lambda. Ogólnie rzecz biorąc metoda connect() jest przeciążona co sprowadza się do tego, że można ją używać na różne sposoby. Ten jest najpopularniejszy. Pierwsze dwa parametry odpowiadają za to samo co w wersji z functorem, natomiast dochodzi nam wskaźnik na odbiorcę sygnału, czyli w naszym przypadku na this -> obiekt ConnectivityChecker. Kolejny parametr to wskazanie na slot co czynimy w podobny sposób jak przy wskazaniu na sygnał.
Mechanizm sygnałów i slotów pomiędzy obiektami różnych klas
Klasę służącą sprawdzaniu połączenia z serwerami binarnie.pl już mamy. Załóżmy, że naszym celem było upewnienie się czy możemy spróbować otworzyć mój blog w przeglądarce 😉
Stwórzmy zatem klasę BrowserController wraz ze slotem za to odpowiadającym. Dodatkowo załóżmy, że klasa ta otworzy przeglądarkę z binarnie.pl tylko raz. Definicja klasy wyglądać może następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#ifndef BROWSERCONTROLLER_H #define BROWSERCONTROLLER_H #include <QObject> class BrowserController : public QObject { Q_OBJECT public: explicit BrowserController(QObject *parent = nullptr); public slots: void openBrowser(bool hasAccess); private: bool _showedAlready = false; }; #endif // BROWSERCONTROLLER_H |
Natomiast definicja metod klasy w ten sposób:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <QDesktopServices> #include <QUrl> #include "BrowserController.h" BrowserController::BrowserController(QObject *parent) : QObject(parent) {} void BrowserController::openBrowser(bool hasAccess) { if (hasAccess && !_showedAlready) { _showedAlready = true; QDesktopServices::openUrl(QUrl("https://binarnie.pl")); } } |
Zatem utworzyliśmy publiczny slot openBrowser() jednorazowo otwierający stronę binarnie.pl w przeglądarce w przypadku, gdy przekażemy jej wartość true. Teraz połączmy ten slot z sygnałem accessChanged() klasy ConnectivityChecker w funkcji main korzystając ze znanej nam już składni metody connect().
1 2 3 4 5 |
ConnectivityChecker checker; BrowserController controller; QObject::connect(&checker, &ConnectivityChecker::accessChanged, [](bool hasAccess) { qDebug() << "Access changed to " << hasAccess; }); QObject::connect(&checker, &ConnectivityChecker::accessChanged, &controller, &BrowserController::openBrowser); |
Teraz w momencie, gdy nawiążemy połączenie z serwerami otworzy się nasza domyślna przeglądarka na stronie binarnie.pl. Przy okazji widzimy, że ten sam sygnał może być podłączony do więcej niż tylko jednego slotu.
Przeciążenia funkcji connect()
Klasa QObject posiada 5 różnych wersji metody connect z uwagi na jej przeciążenia, więc nie zdziw się jeżeli w dokumentacji czy na StackOverflow zobaczysz inne sposoby na jej wywoływanie, niż w tym kursie. Oczywiście o wszystkich przeciążeniach przeczytasz w dokumentacji. Wykorzystywana składnia ze wskazaniami na sygnały i sloty jest obecnie zalecana.
Podsumowanie
Mam nadzieję, że po lekturze tej części kursu Qt wiesz już jak działa mechanizm sygnałów i slotów w tej technologii. W następnym wpisie bierzemy się za okienka, więc mam nadzieję, że zostaniesz ze mną 😊 Koniecznie daj znać co myślisz o kursie i czego jeszcze po nim oczekujesz.
Od razu chciałbym zaznaczyć, że wpis nie wyczerpuje tematu i w zależności od stopnia zaawansowania kursu i moich możliwości temat należy rozszerzyć.