Sygnały i sloty.

Sygnały i sloty to pomysł na komunikację pomiędzy różnymi obiektami w bibliotece Qt. To pomysł odmienny od tzw. wywołań zwrotnych, stosowanych w większości konkurencyjnych do Qt framework’ów. Jak zobaczymy w dalszej części artykułu, sam system jest dość intuicyjny i wygodny – jest to jedna z funkcjonalności, którymi biblioteka Qt na pewno się wyróżnia.

Wprowadzenie.

Dobrym przykładem omawianego tematu jest taka sytuacja: Użytkownik klika przycisk Zamknij, a my chcemy, żeby po kliknięciu została wywołana funkcja close, która zamknie okno. Ale skąd program ma wiedzieć, że Użytkownik coś kliknął i co w takiej sytuacji zrobić ? Właśnie do tego rodzaju komunikacji będziemy używać sygnałów i slotów. Mechanizm obecny w Qt jest bardzo prosty w użyciu i wymaga niewielkiej ilości nieskomplikowanego kodu.

Sygnał w bibliotece Qt to po prostu zdefiniowana funkcja, która wywoływana jest podczas wystąpienia jakiegoś określonego zdarzenia. Natomiast slot (inaczej gniazdo) jest funkcją, która jest wywoływana w odpowiedzi na wyemitowany sygnał. Oczywiście, żeby to zadziałało w ten sposób, sygnał musi być połączony z odpowiadającym mu slotem za pomocą funkcji QObject::connect, której używaliśmy już wcześniej. Generalnie, wiele sygnałów można podłączyć do jednego slota, jak również jeden sygnał można połączyć z wieloma slotami.

Test mechanizmu na przykładzie aplikacji konsolowej.

Biblioteka Qt posiada wiele zdefiniowanych już sygnałów i slotów, ale oczywiście można też definiować własne, wedle potrzeb. Tym razem utworzymy prosty program działający w konsoli.

W QtCreatorze wybieramy File->New File or Project->Application->Qt Console Application. Reszta wygląda podobnie, jak wcześniej (nazwa, lokalizacja, itd.). Tym razem QtCreator automatycznie wypełnił plik *.pro oraz dodał plik main.cpp do projektu.

Plik projektu będzie zawierał mniej więcej taki kod:

Nie musimy już nic ręcznie dodawać – IDE zajmie się tym plikiem niejako z automatu.

Kod źródłowy.

Dodamy sobie teraz nową klasę do projektu. W tym celu klikamy prawym przyciskiem myszy na nazwę projektu i wybieramy z listy Add New…->C++->C++ Class i klikamy Choose…. Następnie podajemy nazwę klasy – w naszym przypadku będzie to Counter. QtCreator automatycznie nadaje nazwy dla dwóch plików – nagłówkowego i źródłowego – ale możemy to poniżej w oknie zmienić. Następnie klikamy na Next > oraz Finish.

Plik nagłówkowy wypełniamy następująco:

Powyżej widzimy kod pliku nagłówkowego klasy Counter, w której definiujemy jeden sygnał (ValueChanged) i jeden slot (SetValue). Na początku widzimy, że klasa Counter dziedziczy po klasie QObject – jest to warunek konieczny, jeśli chcemy definiować własne sygnały i sloty. Kolejnym niezbędnym elementem jest makro Q_OBJECT na początku pliku nagłówkowego klasy, w przestrzeni prywatnej klasy.

Sloty są tak naprawdę zwykłymi funkcjami i możemy je definiować ze wszystkimi kwalifikatorami dostępu, czyli public, protected bądź private. Za kwalifikatorem umieszczamy słowo slots, które jest makrem niezbędnym do utworzenia slotu przez Qt, natomiast dla kompilatora C++ jest ono obojętne (jest zdefiniowane jako pusty ciąg znaków). Dalej następuje definicja sygnału, poprzedzona słowem signals – jest to kolejne makro, niezbędne do utworzenia sygnału przez Qt, natomiast przez kompilator C++ interpretowane jako kwalifikator protected. Oznacza to, że każdy sygnał jest chronioną funkcją składową klasy.

Sygnały i sloty, poza swoimi ciekawymi właściwościami, są najzwyklejszymi funkcjami składowymi klasy. Oznacza to, że funkcję, która jest slotem, możemy też wywołać ręcznie. Sygnały i sloty mogą też zawierać dowolną ilość argumentów dowolnego typu, z jednym wyjątkiem – sygnały i sloty, które ze sobą łączymy, muszą mieć taką samą listę argumentów. W naszym przykładzie zarówno sygnał, jak i slot, posiadają jeden argument typu int. W momencie wyemitowania sygnału, połączony z nim slot jest wywoływany z argumentami przekazanymi przez sygnał – to jest bardzo przydatna cecha tego mechanizmu. Oczywiście łączone ze sobą sygnały i sloty nie muszą mieć żadnych argumentów. W powyższym pliku zadeklarowaliśmy również funkcję składową GetValue, zwracającą liczbę całkowitą, którą przechowujemy w obiekcie (m_Number).

Czas na implementację:

Na liście inicjalizacyjnej konstruktora przypisujemy do zmiennej m_Number wartość zero. Definicja funkcji GetValue to po prostu zwrócenie zmiennej prywatnej m_Number. Definicja slotu, czyli funkcji SetValue sprowadza się do prostego warunku, w którym sprawdzamy, czy przekazana przez argument wartość jest różna od zmiennej m_Number. Jeśli tak, zmiennej m_Number przypisujemy nową wartość i (uwaga!) emitujemy sygnał ValueChanged za pomocą słowa emit.

W tym miejscu od razu chciałbym wyjaśnić małe kłamstewko – emit tak naprawdę nie robi nic. Równie dobrze moglibyśmy po prostu wywołać funkcję sygnału. Jednak użycie słowa emit daje dużo innych korzyści – na czele z czytelnością kodu. W ten sposób jawnie pokazujemy, że samodzielnie emitujemy sygnał – dzięki temu wiemy, że wywoływana funkcja jest sygnałem (co nie jest oczywiste, patrząc np. na nazwę).

Zastanawiacie się pewnie, dlaczego nie ma tutaj definicji sygnału (funkcji) ValueChanged… Otóż implementacją sygnałów zajmuje się biblioteka Qt – my musimy tylko wcześniej taki sygnał zadeklarować.

MOC.

W tym miejscu wypada jeszcze powiedzieć coś na temat narzędzia o nazwie MOC (Meta-Object Compiler). Jest to tzw. kompilator metaobiektów. To właśnie on analizuje nasz kod pod względem wystąpienia w nim deklaracji sygnałów i slotów i generuje dodatkowe pliki źródłowe C++, zawierające implementacje sygnałów i slotów, zrozumiałe dla kompilatora C++. Wszystko dzieje się jednak automatycznie i nie musimy się tym zbytnio zajmować – działanie kompilatora MOC jest jednym z etapów kompilacji naszych projektów w QtCreatorze.

main.cpp

Ostatni plik, main.cpp, wypełniamy tak:

Na początku tworzymy dwa obiekty klasy Counter, o nazwach first i second. Następnie definiujemy połączenie sygnału ValueChanged ze slotem SetValue. Wykorzystujemy w tym celu statyczną funkcję składową QObject::connect, której argumentami są, w tym najpopularniejszym przypadku: wskaźnik na obiekt wysyłający sygnał, wysyłany sygnał, wskaźnik na obiekt odbierający sygnał, wybrany slot. Nie będę tutaj dokładnie omawiał, jak wygląda prototyp funkcji connect, ponieważ jest to w tej chwili nieistotne. Ważne, żeby zapamiętać, jak najprościej połączyć sygnał jednego obiektu ze slotem innego obiektu.

Trzeba też zwócić uwagę na jeszcze jedną, bardzo istotną rzecz: właśnie połączyliśmy sygnał z pierwszego obiektu, ze slotem z drugiego obiektu. Nie ma natomiast połączenia w drugą stronę (!) – bardzo łatwo o taką pomyłkę, szczególnie w tym przypadku, kiedy mamy dwa obiekty tej samej klasy.

Teraz ustawiamy wartość zmiennej m_Number obiektu first, za pomocą funkcji składowej SetValue. Następnie sprawdzamy, jakie wartości mają składowe m_Number w poszczególnych obiektach. Okazuje się, że obie wartości są identyczne (na starcie obie zmienne były równe zero). Stało się tak, ponieważ obiekt second odebrał sygnał ValueChanged(4) od obiektu first i uruchomił połączony z tym sygnałem swój slot SetValue(4). Liczba 4 została przekazana jako argumet z sygnału do slota.

Jeśli jednak wywołamy w kodzie funkcję SetValue(7) obiektu second, nie spowoduje to zmiany wartości w obiekcie first, ponieważ nie zdefiniowaliśmy takiego połączenia.

Dalej widzimy w kodzie wywołanie funkcji QObject::disconnect, która jest funkcją bliźniaczą do connect i anuluje połaczenie utworzone wcześniej. Lista argumentów dla obu funkcji jest identyczna. Po rozłączeniu sygnału pierwszego obiektu ze slotem drugiego obiektu, wywołanie funkcji SetValue(13) obiektu first nie powoduje zmiany wartości m_Number w obiekcie second.

Mam nadzieję, że ten prosty przykład pokazał, na czym polegają i jak działają sygnały i sloty. Wiemy już, jak definiować sygnały, sloty i połączenia pomiędzy nimi oraz jak rozłączyć utworzone wcześniej połączenie. Podczas tworzenia aplikacji z graficznym interfejsem Użytkownika częściej korzysta się z już zdefiniowanych sygnałów, emitowanych przez graficzne elementy interfejsu, jednak tworzenie własnych slotów jest na porządku dziennym.

Poniżej obrazek, pokazujący wynik działania naszego kodu, uruchomionego w systemie Linux (Ubuntu):