C++ bez cholesterolu: Zaawansowane programowanie w C++: Teoria stanów cząstkowych
3.6 Teoria stanów cząstkowych
Właściwości wartości
Program, poza kodem, składa się ze struktur danych. W
szczególności zaś, elementami wykonującego się programu są
również obiekty o określonych definicjach. Obiekty te składają
się z różnych części, które przechowują określone
wartości przez określony czas. Z tych wartości się potem korzysta.
Wartość przechowywana w takiej zmiennej charakteryzuje stan owej
cząstki. Inaczej, stan cząstkowy programu.
Zanim skupię się na tym, jakie konsekwencje ma to wszystko dla
struktur, zajmę się zwykłymi, prostymi wartościami typów
ścisłych. Taki int na przykład. Wydawałoby się np., że co za różnica,
czy tworzymy zmienną, czy stałą tego typu. Owszem, w C nie ma to
znaczenia (dlatego, że w C niczego nie zmieniono w praktyce po
wprowadzeniu słowa const, że nie wspomnę o tym, że zostało ono w tym
języku zapożyczone z... C++ ;). W C++ różnica jest taka, że --
jak powiedziałem -- stałe nie muszą posiadać tożsamości. No bo w
sumie po co im ona? Po co w ogóle istnieje coś takiego jak
tożsamość?
Wyobraźmy sobie dwa obiekty o identycznych wartościach. Jeśli
teraz dokona się zmiany w jednym obiekcie, to drugi pozostanie przy
starej wartości. Gdyby te dwa obiekty miały jedną tożsamość (jak np.
na początku poprzedniego rozdziału w przykładzie ze zmienną
referencyjną), to zmiana w jednym obiekcie pociągnęłaby zmianę w
drugim obiekcie (może powiedzmy to inaczej: zmiana będzie widoczna po
odczytaniu wartości przez takoż pierwszą jak i drugą referencję). Jak
widać zatem, tożsamość obiektów jest określana przez równość
wartości referencji (tzn. ich wskaźników): jeśli dwa obiekty
mają te same wartości referencji, to jest to jeden i ten sam obiekt.
Zatem zapis pod jakąś referencję modyfikuje obiekt pod tą referencją.
I to wszystko. Czy zatem gdy nie modyfikujemy obiektu, to tożsamość
ma jakieś znaczenie? Od razu widać, że nie. Można co najwyżej
stwierdzić, że dwa obiekty kto inny tworzył, tworzone były w innym
czasie itd. Zatem jeśli nie ma obserwowalnych efektów czasu
tworzenia obiektów, to i żadna informacja ntt. do niczego nam
nie posłuży (konkretnie: jeśli kompilator to zignoruje, to nie zmieni
to finalnego efektu).
No dobrze, ustaliliśmy zatem jedną rzecz (jest ona co nieco
umowna, ale chyba nie jest to nic niebezpiecznego): tylko obiekty
zmienne posiadają tożsamość. Jest to jedna z konsekwencji istnienia
możliwości ZMIAN w obiektach zmiennych. Jeśli zaś chodzi o obiekty
stałe, to skoro i tak nie można im niczego zmienić, to istotna jest
tylko ich wartość (nie mówię oczywiście o referencjach z
volatile!). W obiektach zmiennych zaś owe wartości są potem przez coś
używane, a więc pewne konkretne wartości są w odpowiednim czasie
przez odpowiednie procedury oczekiwane (również po to, żeby
nadać nową wartość, być może też zależną od aktualnej wartości).
Zatem jedną z pierwszych rzeczy, jaką należy na takiej zmiennej
wykonać, to nadać jej wartość początkową. Jeśli tego nie zrobimy,
zmienna będzie posiadała wartość osobliwą. Możliwość zrobienia tego
mamy oczywiście tylko w przypadku zmiennych typów ścisłych i
to też nie zawsze (tylko gdy tworzy się obiekty zmienne). Natomiast z
obiektami typów strukturalnych jest sprawa trochę bardziej
skomplikowana.
Wyjaśnijmy więc sobie może dokładnie,
co to jest wartość osobliwa. Konkretnie, jak rozumieć fakt, że
zmienna ma wartość osobliwą. Nie jest to takie proste do zrozumiemia,
dlatego jest takie ważne, aby to dokładnie określić. Wielu myli
wartość osobliwą z wartością niewłaściwą. Podajmy więc oba
twierdzenia, które określą owe zachowania:
Niech dany będzie typ T który określa zbiór X
wartości, które są dla tego typu prawidłowe. Ponieważ zmienna
typu T jest zapisana w pamięci, która stanowi jej wewnętrzną
reprezentację, dla niektórych typów może istnieć taka
kombinacja bitów, która nie odpowiada żadnej wartości
ze zbioru X. Weźmy więc zmienną 'a' typu T i porównajmy ją z
dowolną wartością typu T.
Jeżeli zmienna `a' ma wartość niewłaściwą, to porównanie
jej z dowolną wartością typu T da wynik false.
Jeżeli zmienna `a' ma wartość osobliwą,
to porównanie jej z dowolną wartością typu T da wynik o
nieokreślonej wartości.
Brzmi to trochę dziwacznie, ale dość dobrze oddaje naturę wartości
osobliwej. Spróbujmy podać drobny przykład. Wyobraźmy sobie,
że w którymś miejscu w programie mamy zmienną typu int i
sprawdzamy jej wartość. Wiemy, że jeśli ta wartość jest równa
5, to znaczy, że coś tam konkretnego się w programie stało, w wyniku
czego ta zmienna ma właśnie taką wartość. Załóżmy jednak, że
zmienna taka ma wartość osobliwą. Nie przeszkadza to oczywiście, żeby
ona miała wartość 5. Nie przeszkadza jednakoż, by miała dowolną inną
wartość i to niezależnie od tego, co się naprawdę w programie stało,
jak też w tej samej sytuacji może ta wartość być różna za
każdym razem, kiedy uruchamia się program (czy nawet po prostu
przechodzi miejsce pobierania jej wartości). Ważne jest jednak to, że
nawet jeśli ta zmienna ma wartość 5, to i tak nic z tego nie wynika -
to 5 nie wzięło się stąd, że ktoś rzeczywiście do tej zmiennej jakieś
5 przypisał, ani że powstała w wyniku jakiejś operacji. No dobrze,
więc z czego? Czas zatem przedstawić definicję samej wartości
osobliwej (zrezygnowałem tutaj z "wersji naukowej" - w
końcu strona ta jest przeznaczona dla praktyków, a nie dla
naukowców):
Obiekt dowolnego typu ścisłego ("POD")`T' ma wartość
osobliwą, jeśli jego wewnętrzna reprezentacja została zmieniona poza
kontrolą referencji, tzn.:
nikt tam jeszcze nic nie zapisał
od czasu utworzenia (i wewnętrzna reprezentacja zawiera śmieci po
poprzednim "użytkowniku" pamięci)
"ktoś" dostał się do wewnętrznej reprezentacji
metodą naruszającą system typów i dokonał w niej modyfikacji
(np.:
int i; *(char*)&i = 9;
)
zapisano obiekt inną wartością osobliwą
Pierwsze dwa są oczywiście oczekiwane, ale skąd trzecie? Otóż
jeśli przypiszemy obiektowi wartość innego obiektu, który miał
wartość osobliwą, to ten pierwszy obiekt również będzie miał
wartość osobliwą. Zatem, jak widać, osobliwość jest chorobą zakaźną
:).
Jak zatem widać, w C++ wartość osobliwa "powstaje"
(czyli pomijam przypadek, gdy jest kopiowana) albo w przypadku
niezainicjalizowania typu POD, albo gdy narusza się system typów.
W C++ jest dokładnie określone, co narusza statyczny system typów
- patrz dodatek o rzutowaniu. Z tym tylko że w tym mamy też parę
wyjątków, a są nimi wszystkie operatory rzutowania z wyjątkiem
reinterpret_cast. Polega to na tym, że opierając się na dynamicznym
systemie typów naruszenie statycznego systemu typów
uważamy za... no powiedzmy za niebyłe. W przypadku dynamic_cast jest
to naruszenie, ale z zapewnieniem poprawności przez dynamiczny system
typów (czyli naruszenia nie ma, bo kod, który naruszył
również dynamiczny system typów po prostu się nie
wykona). W przypadku static_cast i const_cast z kolei konieczne jest
"zapewnienie użytkownika" co do tego, że rzeczywisty typ
obiektu podlegającego rzutowaniu (w tym również referencji)
jest taki jak podano w operatorze lub niejawnie-konwertowalny - czyli
to użytkownik musi zatwierdzić, że nie nastąpiło naruszenie
DYNAMICZNEJ typizacji.
Dla typów POD wartość osobliwa ma prostą definicję. Dla
typów strukturalnych zaś musimy się podeprzeć definicją
nie-wprost, tzn. kiedy taki obiekt nie ma wartości osobliwej:
Wartość typu strukturalnego T (czyli deklarowanego słowem class
lub struct) jest nieosobliwa, jeśli został wykonany jej konstruktor
lub wykonano zmienialną operację, która jest zdefiniowana dla
typu T (tu uwaga: właśnie tu zakłada się, że jeśli użytkownik
zdefiniował operację zmienialną dla konkretnego typu, to "ufa"
się mu, że tak to zorganizował, aby wartość obiektu po takiej
operacji była nieosobliwa, nawet zakładając wszelkie naruszania typów
ścisłych -- polecam w tej kwestii np. deklarację basic_string w gcc).
Jeśli zmodyfikowano obiekt po jawnym naruszeniu systemu
typów (w sytuacji, gdy nie można zapewnić poprawności
dynamicznego systemu typów, oczywiście bierze się wtedy pod
uwagę już tylko naruszenie statycznego systemu typów), to jego
wartość uważa się za osobliwą.
Tu ważna uwaga! Weźmy np. strukturę: { int a, b; }. Jeśli teraz
zadeklarujemy jej konstruktor, który nie zrobi niczego, to
mimo że pola a i b będą miały wartość osobliwą (osobliwą jako obiekty
typu int), to wartość obiektu całej struktury uważa się za
nieosobliwą. To bardzo niebezpieczne założenie. Dlaczego tak jest?
Ano dlatego, że - jak pokazałem w definicji - obiekt, któremu
wywołano konstruktor uważa się za nieosobliwy. Dlatego też właśnie
obiekty typów klasowych (tzn. takich, którym
zadeklarowano konstruktor) są teoretycznie bezpieczniejsze, gdyż nie
istnieje dla nich możliwość stania się osobliwym przez brak
inicjalizacji. Jednocześnie jednak nikt nie mówi, jak należy
zrobić konstruktor - zatem w praktyce to jest dużo większe
potencjalne źródło błędów. Należy więc pamiętać, że
jeśli wewnątrz obiektu typu klasowego jakiś pod-obiekt ma wartość
osobliwą, to jest to osobliwość tylko na terenie tego obiektu -
kwestia ta nie wpływa na właściwość wartości całego obiektu!
Typ unijny nie posiada wartości i dla
takiego typu nieosobliwą wartość może mieć tylko jedno z pól w
jednym czasie, natomiast pozostałe posiadają wartość osobliwą.
Oczywiście typ unijny ma swoją wyjątkowość, w wyniku której
można powiedzieć tylko, że odczyt tylko ostatnio zapisanego pola (bo
konstruktorów unie nie mają) da w wyniku wartość nieosobliwą.
Jeśli nie zapisano żadnego pola, to oczywiście każde ma wartość
osobliwą - normalna sytuacja niezainicjalizowanych obiektów.
Jak wynika z powyższej definicji, gwarantuje się pewne rzeczy dla
typów ścisłych, natomiast dla typów złożonych pewne
rzeczy należy zapewnić samemu. Bazując oczywiście na typach ścisłych,
bo w końcu każda, najbardziej złożona nawet struktura bazuje na
typach ścisłych. Jak inicjalizować struktury, to już wiemy, jak
również, że ten sposób inicjalizacji jest przestarzały.
Struktury można też nie inicjalizować. Co się stanie? Nic. Niestety
owo "nic" nie oznacza, że jest to bez znaczenia; weźmy sobie np. strukturę:
struct S { int a, b; string s; };
Możemy sobie teraz zadeklarować obiekt takiego typu i to utworzy
obiekt o wartości osobliwej:
S s;
Ale w tych przypadkach:
S s = S();
S s = { a, b };
obiekt `s' jest nieosobliwy. Tak, jak to opisałem w rozdziale
"Złożone typy danych", jeśli S nie ma zdefiniowanego
konstruktora to jest parę skomplikowanych reguł, w każdym razie w
bieżącym przypadku (w tej pierwszej deklaracji) a i b mają wartości
osobliwe. Sam obiekt jednak uważa się za nieosobliwy (w tej drugiej
pola są inicjalizowane wartościami domyślnymi, czyli int(), czyli 0).
Jednak oczywiście, jak też można się domyślić, tak konstruować
obiekty można tylko podczas tworzenia obiektów zmiennych!
Gdybyśmy chcieli utworzyć stały obiekt takiego typu
strukturalnego, to wtedy niestety taka deklaracja zostanie odrzucona
przez kompilator. Dlaczego? Ano dlatego, że -- jak wspomniałem -- nie
można utworzyć obiektu stałego typu ścisłego (lub -- jak w tym
przypadku -- agregatu) nie inicjalizując go. Tutaj nastąpiłaby
właśnie taka próba, gdyż aby utworzyć cały obiekt typu
strukturalnego, należy utworzyć każdą z jego części (a dokładnie to
konstruktor domyślny wywołuje po kolei konstruktory domyślne każdego
z pól). Jeśli chcemy utworzyć obiekt stały, a żadnemu polu nie
określono wariancji, więc będą miały wariancję stałą. W związku z
czym można je utworzyć tylko jako obiekty stałe. Jak wiemy, obiekty
stałe typów ścisłych musimy czymś zainicjalizować. I ta
sytuacja ma miejsce również tutaj. Zatem... zresztą spójrzmy
na przykład:
struct X { int a, b, c; };
X x1; // dobrze, ale pola x1 są osobliwe
X x2 = { 1, 2, 3 }; // dobrze, pola x2 są nieosobliwe
const X cx1; // źle! nie ma konstruktora domyślnego dla const int
const X cx2 = { 1, 2, 3 }; // dobrze
Jak wiec widać, to że możemy napisać "X x1;" wynika
tylko stąd, że dla obiektów zmiennych typów ścisłych,
czyli tutaj pod-obiektow typu X, są dostępne konstruktory puste - i
takoż dla X, gdyż żadnego konstruktora nie zdefiniowaliśmy. Owe
konstruktory nie są dostępne, jeśli tworzy się obiekt stały. Dzięki
temu istnieje zapewnienie, że nie zostaną utworzone obiekty stałe o
wartości osobliwej.
Niestety użytkownik może łatwo złamać to zapewnienie. Oczywiście
jeśli wewnątrz struktury zamieści pole stałe, to kompilator mu nie
popuści. Ale jeśli stały będzie tylko nad-obiekt, a użytkownik zrobi
sobie konstruktor domyślny, który nie robi niczego, to w
efekcie dopuszcza możliwość zrobienia sobie wartości stałej
osobliwej. Jest to właśnie jedna z sytuacji, kiedy użytkownikowi
pozostawia się inicjatywę co do niektórych gwarancji i on może
je obejść, jeśli tak zechce.
Atrybuty i stany
No dobrze, ustaliliśmy zatem następujące rzeczy:
wartość charakteryzuje stan
obiektu
obiekty stałe można utożsamiać z
wartościami
stan obiektu może być osobliwy (czyli pobrana wartość może
być wartością osobliwą)
Ale z obiektami złożonymi (tzn. typów strukturalnych)
należy uważać. O ich właściwościach bowiem decydują po kolei, ale też
wszystkie razem (!) elementy tego obiektu. Każdy z elementów
może być również typu strukturalnego, zatem nasze drzewko może
się zacząć niebezpiecznie rozrastać. Faktycznie, zbytnie rozbudowanie
struktur i rozproszenie oraz rozdrobnienie projektu, to proszenie się
o kłopoty. Zanim więc zaczniemy się prosić o kłopoty, określmy jak
właściwości wartości wyglądają w typach strukturalnych.
Ponieważ w obiektach stałych zmienić niczego nie można, zatem
elementy owego obiektu są to ATRYBUTY. Może istnieć kilka obiektów
tego typu, niektóre mogą mieć te same fragmenty, w innych mogą
się różnić, ale w żadnym nie mogą się zmieniać. Łączy je zatem
wspólna konstrukcja i definicja, a każdy może mieć też własny
atrybut. Operowanie obiektami stałymi jest zatem o niebo
bezpieczniejsze; nic nas tam nie może zaskoczyć i nad wszystkim ma
się kontrole, a na dodatek operacje na obiektach stałych dużo łatwiej
się optymalizuje. W tym sensie nie dziwi chyba fakt, że w językach
funkcyjnych używanie obiektów zmiennych się minimalizuje i
stosuje do tego specjalne konstrukcje językowe (np. w OCamlu w ogóle
nie istnieje słowo const, za to żeby określić, że pole struktury jest
zmienne, należy jawnie napisać "mutable"). Niestety nie do
wszystkiego da się stosować obiekty stałe, a już na pewno nie w C++.
Oczywiście pewnie znasz określenie "stan" w trochę inny
sposób, mianowicie stan modelowany za pomocą tzw. maszyny
stanu i grafów. Zatem stan, który jest trzymany przez
konkretny indykator to tzw. stan cząstkowy. Stan ogólny
programu zaś jest złożeniem wszystkich stanów cząstkowych (a
żeby lepiej sobie wyobrazić, co to oznacza -- ilość stopni swobody
stanu programu jest iloczynem stopni swobody wszystkich stanów
cząstkowych). Z tym tylko, że operowanie stanem globalnym dla
programu jest o tyle bezsensem, że stany mają również swoją
trwałość, większość z nich dużo krótszą, niż sam program (choć
bywają też stany o dłuższej trwałości :), nie mówiąc już o
tym, że ilość stopni swobody takiego stanu może doprowadzić człowieka
do nieodwołalnego pobytu w zakładzie psychiatrycznym :). Właśnie
dlatego będziemy operować tutaj wyłącznie stanem cząstkowym,
nazywając go po prostu "stanem".
Obiekty zmienne oczywiście mogą się składać ze stałych pól
i każde takie pole jest również atrybutem. Jednak mogą
zawierać również pod-obiekty zmienne, czyli w efekcie stany.
Każdy obiekt posiada zatem tyle stanów, ile zmiennych pól.
Ponieważ każde pole może też zawierać swoje pola, również pola
zmienne, zatem dostarcza na dodatek swoje pod-stany. W ogólności
zatem robi się z tego wielki sajgon - załóżmy np. taką zmienną
typu bool jako stan. Ma on trzy stopnie swobody: true, false i stan
osobliwy. Jeśli mamy strukturę z dwoma boolami, to jest ich już 9 --
ilość stopni swobody obiektu typu złożonego jest iloczynem stopni
swobody jego pod-obiektów... :) Tu oczywiście zlekka
przesadzam z truciem. Ilością stopni swobody tak naprawdę nie ma się
co przejmować. Tym stwierdzeniem chciałem jedynie wskazać związek
tego, o czym mówię, z klasyczną "maszyną stanu". Ta
właśnie klasyczna maszyna stanu będzie tutaj oparta na stanie ogólnym
- jednak to pojęcie jest tutaj niewygodne i bezużyteczne, dlatego
operujemy tylko stanem cząstkowym.
To może jeden akapit małej dygresji. Czemu wcześniej
ludzie się nie przejmowali takimi rzeczami? Ano np. dlatego, że
programy były mniejsze, mniej złożone, no a przede wszystkim nie
miały zbytnio rozbudowanych tych właśnie elementów, które
są wrażliwe na stan... Tak naprawdę bowiem gdyby człowiek myślał w
ten sam sposób, w jaki pracuje maszyna, to nawet przy
dzisiejszych komputerach, z oprogramowaniem bylibyśmy na etapie
spectruma. To nie wypasione komputery, ani nie postęp w elektronice
doprowadziły do takich postępów w dziedzinie oprogramowania.
To mamy tylko dzięki bujnej wyobraźni ludzi, którzy to
oprogramowanie tworzyli. Cały bajer właśnie w tym, że dopiero wtedy
człowiek jest w stanie zmusić komputer do robienia tego, co chce,
jeśli umie to sobie wyobrazić i przetłumaczyć głupiemu komputerowi;
tzn. jeśli umie sobie wyobrazić problem w sposób logiczny i
użyć narzędzia do jego zrealizowania. Różnie ten problem można
rozwiązać. Można przesiąść się na języki funkcjonalne, dzięki czemu
będzie się rozwiązywać problemy w maszynie funkcjonalnej (podobno
bliższej logice myślenia człowieka), w której operuje się
tylko wyrażeniami. Maszyna taka jest modelowana oczywiście w maszynie
natywnej, która sama niestety pracuje jako maszyna stanów,
więc całkowicie odmiennie. Jest zatem drugi sposób. Pozostać
przy C++ i skorzystać z teorii stanów cząstkowych. Co prawda
planowanie stanu to nie jest taka prosta rzecz, jak planowanie
zależności pomiędzy wartością funkcji a argumentem, to jednak po
pierwsze, sposób rozumowania jest tak samo bliski logice, a
jednocześnie jest bliższy każdej maszynie natywnej, a po drugie,
wbrew temu co wmawiają wszyscy entuzjaści języków
funkcjonalnych, metodami funkcjonalnymi nie da się rozwiązać
wszystkich problemów (wedle mojej praktyki, nie da się bez
naginania logiki do narzędzia nawet do 50% problemów - ale
oczywiście nie chcę nikogo przekonywać, każdy ma własną praktykę :).
Podpowiem tylko (żeby być bardziej złośliwym), że nie istnieje taki
język funkcjonalny, który nie ma w sobie odrobiny
imperatywizmu.
Chciałbym jednak mimo wszystko gorąco zachęcić do programowania
raczej logicznego, niż niskopoziomowego. Umówmy się więc, że w
C++ nie operuje się kawałkami pamięci, lecz obiektami. Zatem
NIENAPISANIE const jest o wiele groźniejsze, niż jego napisanie.
Nienapisanie const to przystanie na wprowadzenie stanu. A stan, w
odróżnieniu od atrybutu, to bardzo skomplikowana rzecz. Przede
wszystkim dlatego, że jeśli jest, to przeważnie oznacza, że coś ma
odeń zależeć. Dany fragment programu może więc robić co innego w
zależności od różnych rzeczy. Warto o tym wiedzieć w C++, bo
logiczne myślenie podczas pisania programu w tym języku najczęściej
wychodzi na dobre.
Prześledźmy sobie zatem taki przykładowy program, w którym
użyjemy sobie jednej zmiennej typu int. Program ten wygląda dość
niegroźnie, a programiści C stwierdziliby, że nie ma tu niczego
skomplikowanego. Zatem popatrzmy:
#include <iostream>
int z;
int main( int argc, char** argv )
{
bool bill;
int x;
int y;
cin >> x;
if ( x != 0 )
z = x;
cin >> y;
if ( x == y ) {
bill = true;
z = x + y;
} else {
x = y;
bill = false;
}
return 0;
}
Tworzymy tutaj cztery zmienne: x, y, z i bill. Program jest
oczywiście bez sensu, ale chce za jego pomocą objaśnić zjawiska,
jakie tu zachodzą. Zaczynamy. Ponieważ żadna ze zmiennych nie jest
inicjalizowana, zatem wszystkie posiadają stan osobliwy. Zmienna 'z'
jest globalna i chociaż funkcja main jest zawsze wołana tylko raz (a
jej lokalne obiekty maja takie trwanie, jak obiekty globalne,
przynajmniej w przybliżeniu), to jednak gdyby była to inna funkcja,
wołana już kilka razy, to na początku jej wywołania ten obiekt będzie
miał stan taki, w jakim zostawiło go poprzednie wywołanie (funkcje
zazwyczaj nie mają zwyczaju "zostawiać zmiennych w takim stanie,
w jakim je zastały", ale fakt, że w przypadku niektórych
funkcji jest to również warta stosowania zasada). Zaś obiekt
globalny posiada go tylko na początku (no i w razie naruszenia
któregoś z warunków nieosobliwości). Zatem pierwsza,
najważniejsza sprawa: trwanie obiektu oznacza trwanie stanu. Obiekt
zatem może przechodzić przez rożne stany od jego utworzenia do
zniszczenia. Jeśli zatem w którymś momencie następuje odczyt
wartości tego stanu, należy być świadomym, kto ostatnio ten stan
ustawił. Zatem czynniki które mają związek z jednym stanem są
to:
NOSICIELE stanu, czyli obiekty,
które zawierają dany stan. W tym sensie -- należy na to
zwrócić uwagę -- takimi obiektami są również niejako
konteksty, a dokładnie "obiektem" jest tu zbiór
zmiennych lokalnych danego kontekstu (kontekstu, NIE funkcji!)
INDYKATORY stanu, czyli obiekty
reprezentujące konkretny stan
REGULATORZY stanu, czyli operacje,
które dokonują zmiany stanu
SUSCEPTORZY stanu, czyli operacje, których wykonanie
jest uzależnione od wartości stanu
Zajmijmy się najpierw najprostszym z tych stanów. Oto mniej
więcej tak przedstawia się najogólniej stan zmiennej 'bill'
(czyli każdego stanu typu bool):
Widzimy wyraźnie, jak diametralnie rożni się stan i wartość.
Wartości typu bool istnieją tylko dwie: true i false. Zaś stan typu
bool to może być true, false oraz stan osobliwy. Dlatego właśnie
istnieje dobra zasada, aby zmienne od razu inicjalizować, dzięki
czemu pozbywamy się jednej z możliwości powstania stanu osobliwego.
Wbrew pozorom, właśnie to jest najczęstsza przyczyna istnienia stanów
osobliwych, a jak widać metoda na pozbycie się tego jest banalnie
prosta (choć oczywiście nie zamierzam twierdzić, że nie jest również
banalnie prosta do przeoczenia :).
Niestety na tym się problemy wcale nie kończą. Poza konkretną
wartością stanu (nazwijmy to stanem wartościowym) oraz stanem
osobliwym istnieje jeszcze stan nieoczekiwany. Niestety jest to
jeszcze większa abstrakcja, niż stan osobliwy. Sposób na
zaistnienie tego stanu nie jest właściwie możliwy do sprecyzowania,
gdyż zależy on od tego, jak się ma kwestia "oczekiwań działania
programu" do ich implementacji. Ponieważ jedno może być
określone tylko logicznie, zatem stan taki może powstać w wyniku albo
niedoprecyzowania warunków zaistnienia danego stanu, albo
przeoczenia w implementacji. Niestety należy o tym wspomnieć, gdyż
jest to druga najczęstsza przyczyna błędów w programie
spowodowana źle określonym stanem. Pamiętajmy zatem, że jeśli
wprowadza się do programu stan, to dobrze jest albo -- jak w
przypadku prostych obiektów -- minimalizować zakres ich
działania, aby ich zachowanie było widoczne, albo dokładnie
rozplanować zachowanie się danego stanu uwzględniając jego nosicieli,
indykatory, regulatorów, susceptorów, no i oczywiście
trwałość.
Przyjrzyjmy się zatem pływom stanów (state flow). Użyłem
tutaj odpowiednich oznaczeń w komentarzach, które nie
oznaczają wykonania konkretnej czynności, tylko co się dzieje ze
stanami. Wyrażenie "stan =" oznacza REGULACJE stanu, zaś
"?( stan1, stan2 )" oznacza SUSCEPTANCJĘ stanu (zależność,
czy też dosłownie tłumacząc, wrażliwość na stan), w tym wypadku
"stan1" i "stan2" (susceptancja może być od wielu
stanów). Nosicieli stanu jest, jak widać dwóch:
kontekst globalny i kontekst funkcji main.
#include <iostream>
int z; // z = (singular)
int main( int argc, char** argv )
{
bool bill; // bill = (singular)
int x; // x = (singular)
int y; // y = (singular)
cin >> x; // x = (value)
if ( x != 0 ) // ?( x )
z = x; // z = ?( x )
// inaczej: z = ?( x ) => ?( x ) | ()
cin >> y; // y = (value)
if ( x == y ) { // ?( x, y )
bill = true; // bill = (value)
z = x + y; // z = ?( x, y )
} else {
x = y; // x = ?( y )
bill = false; // bill = (value)
}
// inaczej: bill = ?( x ), ?( y ) => true | false
// z = ?( x, y ) => ?( x, y ) | ()
// x = ?( x, y ) => ?( y ) | ()
return 0;
}
Np. stan 'bill' w czasie działania funkcji jest poddany tylko
jednej zmianie. Na początku posiada on stan osobliwy i stan ten
utrzymuje się aż do instrukcji "bill = true" lub "bill
= false", czyli na pewno aż do tego warunku "if ( x == y
)". Nie ma susceptorów tego stanu, gdyż -- jak widać,
nigdzie nie pobiera się zawartości zmiennej 'bill'. Aż do instrukcji
"return 0;" ów stan trwa. Tu można by go przedstawić
w ten sposób:
Teraz cos nt. x. Te stany są nastawiane w odpowiednich
instrukcjach pobierania ze strumienia. Po instrukcji "cin >>
x;" następuje sprawdzenie warunku, a wiec susceptancja stanu
'x'. Ponieważ w wyniku spełnienia tego warunku następuje przypisanie
do 'z', zatem ta instrukcja jest REGULATOREM stanu z. Zaraz ktoś
pewnie zarzuci, że z jest ustawiane tutaj tylko pod warunkiem, wiec
jest tylko częściowym regulatorem. Otóż niestety nie.
Regulatorem albo się jest, albo się nie jest. Proszę zwrócić
uwagę na komentarz, który znajduje się w linijce pod ta
instrukcja. Oznacza to, że "w zależności od stanu x następuje
wykonanie jednej z dwóch czynności (to te dwie rozdzielone
znakiem `|'): regulacja stanu z będąca susceptancją stanu x albo
nic". Oczywiście, że ta druga czynność jest "wirtualna",
a nawet w ogóle nie istnieje. Jednak skoro mamy wybór,
to musimy określić, czym jest to "nic", żeby stwierdzić, że
to coś się wykona albo nie. Jednak sumarycznie, ponieważ wewnątrz tej
operacji istnieje co najmniej jeden (tu: dokładnie jeden) regulator
stanu z, to przyjmujemy że cala ta instrukcja (if z jej podległą)
jest regulatorem stanu z.
Następnie ten stan x jest sprawdzany w warunku ( x == y ).
Sprawdzenie tego warunku jest susceptorem stanów x i y. Niżej
w komentarzu są ujęte wszystkie stany regulowane przez tą instrukcję
i czego susceptorem jest w tej instrukcji każdy z tych stanów.
Na podstawie takiej analizy łatwo stwierdzić, gdzie znajdują się
susceptory i regulatory stanu. Relacje złożone, opisane pod if-ami z
kolei pokazują, jak potrafi się komplikować sprawa, jeśli operacje są
zależne i zagnieżdżone. Np. ten ostatni if jest regulatorem stanów
bill, z i x oraz susceptorem x, y i z. Tak można by to ująć w
ogólności. Widać zresztą, że niepoprawność działania
któregokolwiek ze stanów może spowodować całkiem niezły
bigos.
Skoro wiec tak skomplikowane potrafią być pływy stanów przy
tak banalnym programie, to jak skomplikowane mogą być przy bardziej
skomplikowanych!? Oczywiście nie ma się co przerażać. Dopóki
owe stany nie są zaprojektowane w jakiś pokrętny i nieczytelny
sposób, nie ma się czego obawiać. Jednak rzecz, której
projektant/programista powinien unikać to jest tzw. leniwa
synchronizacja stanu. Często się to przydaje, ale nadużycie prowadzi
do bardzo poważnych problemów. Polega to na tym, że jak się
podczas śledzenia programu napotka miejsce susceptancji pewnego
stanu, który to stan w tym miejscu nie został odpowiednio
zsynchronizowany (zaistniał stan nieoczekiwany), to się go
synchronizuje. Efekty bywają różne, np. nieścisła
synchronizacja, przez co wprowadza się po sprawdzeniu pewnych
warunków (czyli zazwyczaj kolejna susceptancja!!!) dodatkową
inną synchronizację. To jest mniej więcej jak z naprawianiem dróg
w naszym kraju: jak jest dziura to się sypie trochę asfaltu i ubija,
zamiast zerwać całą dużą powierzchnię asfaltu i położyć drogę na
nowo. Efekty są identyczne: w łatach powstają kolejne dziury (bugi),
na nie nakłada się następne łaty. Kod w efekcie się rozrasta, staje
się bardziej skomplikowany, a jego możliwości pielęgnacji, jak
również jego wydajność maleją w tempie geometrycznym. Ktoś
powie, że trzeba przeprojektować zawsze nie licząc się z kosztami?
Niestety nie każdy się z tym zgodzi; w większości amerykańskich firm
np. bardzo mocny nacisk kładzie się na marketing i szybkość produkcji
oprogramowania, a jakość jest sprawą drugorzędną (co dobitnie widać
po firmie Microsoft). Ostatnio miałem do czynienia z takim kawałkiem
kodu pewnego edytora, którego wydruk stosu w pewnym momencie
wykonywania może dać nieco do myślenia. W tym edytorze czasem
istnieje potrzeba konwersji pozycji karetki w oknie edytora na
logiczną pozycję kursora w edytorze (tzw. linia i kolumna). Wykonuje
ją funkcja o niewinnej nazwie "CaretToCursor". Oto, co ona
m.in. robi:
EditText::DrawText(HDC__ * 0x4f010d6c, char * 0x194a68e0, unsigned int 30) line 889
EditText::DrawRange(HDC__ * 0x4f010d6c ...
EditText::GetTextWidth(EditLine* 0x0429facc, int 30, HDC__ * 0x4f010d6c) ...
EditClient::CaretToCursor(CARET * const 0x0429fb48, tagPOINT * 0x0429fb40 {x=412405449 y=286}, int 3) ...
I żeby nie było wątpliwości, DrawText następnie wywołuje
TabbedTextOut, czyli pewną wersję funkcji, która rysuje tekst
na ekranie. Oczywiście każdy, kto miał zlekka do czynienia z
windowsami, czy nawet jakimkolwiek systemem okienkowym, wie o tym, że
synchronizację stanu (którym jest tutaj to, co widać na
ekranie) należy wykonać przez unieważnienie pewnego obszaru
(InvalidateRect), a to z kolei automatycznie każe programowi
odrysować daną część (WM_PAINT). Ale widocznie nie autor tego kodu.
W przedstawionym wcześniej programie i tak istnieje jeszcze wiele
innych ciekawych rzeczy, np. jakie możliwe zawartości mogą mieć owe
cztery stany na końcu funkcji main (funkcja kończy się w jednym
miejscu). No to spójrzmy:
bill: true lub false; narzuca
kwestia przypisań w obu podkontekstach if
x: wartość konkretna, może zostać
zmieniona w tym drugim warunku
y: wartość konkretna
z: wartość konkretna (zależna od x i y) lub wartość osobliwa
Warte uwagi są następujące rzeczy: istnieje możliwość, że zmienna
`z' pozostanie osobliwa przez cały czas, zmienna bill nie będzie
osobliwa (ale to tylko dlatego, że następują dwa przypisania, gdzie
jeden warunek jest dopełnieniem drugiego), a x i y nie będą osobliwe,
gdyż następuje bezwarunkowe ich zapisanie.
Z kwestią obsługi stanu proponowałbym bardzo uważać, gdyż jest to
naprawdę jedna z najczęstszych przyczyn wprowadzania komplikacji do
projektu i wiele potencjalnych bugów. Stan powinien mieć jak
najmiejszy zasięg, a zasięg jego indykatora powinien być w idealnym
przypadku taki sam, jak zasięg stanu. No i oczywiście nie należy też
tworzyć w programie stanów o zbyt dużym zasięgu -- proszę
pamiętać o ilościach stopni swobody stanu ogólnego programu.
Maksymalna ilość owych stopni swobody (ilość stanów
cząstkowych może być różna w różnych momentach
programu) jest jednym z najbardziej znaczących wyznaczników
skomplikowania programu.
Technologia sygnałów i
slotów
Programowanie GUI jest aspektem, w którym jednym z
najbardziej znaczących elementów są stany cząstkowe. Nie jest
ich wiele mniej w innych rodzajach programów, ale: po
pierwsze, wiele programów ma jednak jakiś interfejs
użytkownika, a po drugie nawet programy, które go nie mają,
mają też jakąś strukturę złożoną z odpowiedniej ilości stanów
cząstkowych.
Ponieważ jednak synchronizacja stanu i w ogóle stany
cząstkowe są chyba najpoważniejszym problemem w programowaniu GUI,
zatem porządniejsze biblioteki graficzne, takie jak GTK i Qt, zostały
wyposażone w najlepszą technologię do synchronizacji stanów
cząstkowych, mianowicie technologię sygnałów i slotów.
Na czym ten bajer polega? Otóż jeden obiekt definiuje sobie
(w sposób zależny od implementacji sygnałów-slotów)
jakieś sygnały. Następnie na rzecz obiektu wywołuje się jakieś
metody, które dokonują zmiany jego stanu. Lub też -- jeszcze
bardziej widoczne w GUI -- np. system przekazał mu informację o
zdarzeniu, które go interesuje, co jest jakimś tam ważnym
zdarzeniem czy też również spowodowało zmianę jego stanu.
Metoda obsługująca zdarzenie (czy jakakolwiek inna) na znak, że coś
takiego się stało, wysyła sygnał.
Co się wtedy dzieje? Z punktu widzenia obiektu nic. No i dopóki
ktoś się nie podłączy pod taki sygnał to też nic sensownego się nie
zdarzy. Weźmy sobie teraz drugi obiekt, który definiuje sobie
slot. Co to jest slot? Slot to nic innego jak po prostu funkcja, czy
też metoda, której przeznaczeniem jest wykonać się na
odpowiednie zawołanie. Slot służy do tego, żeby podłączyć do niego
sygnał. Załóżmy więc, że ten drugi obiekt ma slot i podłączył
sobie do niego sygnał. W takim przypadku gdy następuje "wysłanie"
(emisja) sygnału, to w odróżnieniu od poprzedniej sytuacji,
coś konkretnego się już stanie. Mianowicie wykona się "slot".
Spróbowałbym podać przykład, ale takowy powinien być
dostosowany do jakiejś konkretnej implementacji. Dlatego podam
przykład w Qt, gdyż tam implementacja s-s jest chyba najprostsza do
wyjaśnienia. Tam zarówno sygnał jak i slot są po prostu
metodami. Implementację do sygnału dostarcza kompilator i
makrogenerator moc. Sygnał jest normalnie wołany jak zwykła funkcja
(tylko należy go poprzedzić słowem "emit"), a slot zostanie
wywołany, jeśli zostanie wywołany sygnał, do którego dany slot
jest podłączony. Spójrzmy zatem na przykład:
class Int: public QObject
{
Q_OBJECT;
int x;
signals:
valueChanged( int );
public:
Int() { x = 0; }
operator int() { return x; }
Int& operator=( int z )
{
if ( x != z ) {
x = z;
emit valueChanged( z );
}
}
public slots:
void setValue( int z )
{ *this = z; }
};
Pewną zawiłością jest kilka dziwnych słów kluczowych
wprowadzonych przez Qt: signals, slots, emit i Q_OBJECT. Niestety
Q_OBJECT jest wymagane w każdej klasie dziedziczącej z QObject, a już
na pewno, gdy ten obiekt chce mieć sygnały. Poza tym nie ma tam nic
specjalnego: "signals" jest aliasem do "private"
(w związku z czym -- zauważ -- sygnały są zawsze prywatne), a slots i
emit mają definicje puste. Słowa te potrzebne są tylko i wyłącznie
makrogeneratorowi "moc", którego zadaniem jest
wygenerować implementacje sygnałów -- no i te całe metaobiekty
do każdej klasy pochodnej od QObject.
Wróćmy więc do poważniejszych rzeczy. Mamy sobie taką
klasę. Co możemy z nią zrobić? Spójrzmy na kawałek programu:
int main()
{
Int i1, i2;
QObject::connect( i1, SIGNAL( valueChanged(int) ),
i2, SLOT( setValue(int) ) );
i1 = 10;
cout << i2 << endl;
}
Parę kwestii technicznych. Makra SIGNAL i SLOT nie są bynajmniej
magiczne. Zamieniają one podany argument na... tekst, poprzedzony
odpowiednio cyframi 1 lub 2. Jak więc widać, moc musi rozpoznać owe
"slots", żeby wygenerować z jego deklaracji tekst, który
będzie potem rozpoznany przez connect. Ta technika jest jednak dość
niezawodna; znajdowanie definicji jest nawet niewrażliwe na spacje w
deklaracji. Ma ona jednak poważną wadę - błąd popełniony w tej
instrukcji zostanie wykryty dopiero na etapie wykonania.
Jak widzimy, klasa w konstruktorze ma inicjalizację zerem. Po
wypisaniu okaże się jednak, że i2 zostało zapisane wartością 10, tak
samo jak i1. Skąd się to wzięło? Ano właśnie stąd, że "i1 = 10"
spowodowało wywołanie sygnału (tak stanowi operator przypisania dla
klasy Int). Ponieważ więc i1 zostało zapisane 10 i wysłało sygnał, a
sygnał ten został podłączony do slotu w i2, to wywołanie sygnału
spowodowało wywołanie slotu.
Proszę też zwrócić uwagę na to, że valueChanged oznacza, że
wartość uległa zmianie, a nie zostało po prostu dokonane przypisanie.
Dlaczego? No np. dlatego, żeby zgodnie z nazwą sygnał był emitowany
tylko w przypadku, gdy wartość się zmieniła. Spójrzmy na taki
przykład:
int main()
{
Int i1, i2;
QObject::connect( i1, SIGNAL( valueChanged(int) ),
i2, SLOT( setValue(int) ) );
QObject::connect( i2, SIGNAL( valueChanged(int) ),
i1, SLOT( setValue(int) ) );
i1 = 10;
cout << i2 << endl;
}
Teraz jest jeszcze fajniejsza sprawa. Co prawda reakcja programu jest
identyczna, ale spróbujmy coś przypisać do i2. Okaże się, że
ta sama wartość pojawiła się w i1. Ale to nie wszystko. Sprobujmy
utworzyć trzecią zmienną klasy Int i podłączyć jej sygnał
valueChanged do dowolnej z pozostałych dwóch slotu setValue.
Proszę przyjrzeć się definicji slotu setValue - jest tam używany
operator przypisania, zatem jest on w stanie jeszcze na dodatek
wywołać od siebie sygnał valueChanged! Co się zatem stanie gdy
utworzymy sobie i3 i połączymy z np. i2? Ano tyle, że dowolna zmiana
wartości i3 operatorem przypisania ustawi na taką wartość również
i1 i i2.
Opisałem to dość pobieżnie. W Qt mechanizm sygnałów-slotów
jest używany bardzo często, począwszy już od przykładowego (qApp jest
to obiekt aplikacji):
QButton quit;
QObject::connect( quit, SIGNAL( clicked() ), qApp, SLOT( quit() ) );
Ponieważ w dokumentacji do Qt jest to wystarczająco dobrze opisane,
więc teraz przejdę do kwestii ewentualnych implementacji. Istnieją
ogólnie do użytku trzy możliwe implementacje mechanizmu
sygnałów-slotów:
Qt - rozpoznawanie po tekstach
nagłówków funkcji
libsigc++ i boost::signals -
definicje oparte na wzorcach
deklaracje bezpośrednie
To znaczy, tylko o takich wiem :). Z ogólnych cech
charakterystycznych, jakie mógłbym tu wymienić są następujące:
QtJest bardzo prosta w obsludze. Składnia funkcji connect
jest jedna z następujących:
connect( source, SIGNAL(signal), target, SLOT(slot) );
connect( source, SIGNAL(signal), SLOT(slot) ); // target == source
connect( SIGNAL(signal), target, SLOT(slot) ); // source == this
To oczywiście nie są wszystkie, pokazałem tylko kombinacje
charakterystyczne. Jak widać, brak target oznacza, że jest on równy
source, a brak source oznacza, że jest on równy this. Zamiast
SLOT może być też oczywiście również SIGNAL. Reguły są zawsze
takie, że zarówno sygnał jak i slot muszą być metodami klasy
wyprowadzonej z QObject (metody będące sygnałami się oczywiście
tylko zapowiada - implementacje dostarcza MOC). Implementacja jest
bazowana na łańcuchach tekstowych i one są wykorzystywane do
porównania przy znajdowaniu sygnałów i slotów
mających podlegać połączeniu. Sygnał i slot zatem jest
identyfikowany przez makra SIGNAL i SLOT, a "stanowi" go
zawsze metoda. Emisja sygnału to po prostu wywołanie metody
obsługującej sygnał. Musi ono być poprzedzona słowem "emit",
które w sensie C++ nie ma żadnego znaczenia - stanowi tylko
informację dla moc, że w danym miejscu nastąpiło wywołanie sygnału.
Typem zwracanym sygnału i slotu musi być zawsze void, jak również
nie wolno im rzucić wyjątkiem. Bibliotece tej wytyka się rozliczne
wady, jak np. już samo używanie moc'a (moim zdaniem przesada), ale
również zbyt duża wrażliwość na błędy i wprowadzanie
konfliktu nazw - nazwy "signals", "slots" i
"emit" są tworzone przez #define, a ich użycie w kodzie
wcale nie jest takie znów mało prawdopodobne (i tutaj
niestety muszę przyznać pełną rację, bo spotkałem się z przypadkiem
użycia "signals" jako zmiennej lokalnej - ale też
przyznam, że ludzie z TrollTech'a mają cokolwiek osobliwe sposoby
traktowania C++).
libsigc++ (GTKMM, Inti) oraz
boost::signalsJest bardziej skomplikowana, ale też ma większe
możliwości i jest wydajniejsza. Jest oparta -- odmiennie od Qt - na
wzorcach. Definiuje się tam obiekty, zwane sygnałami i slotami. Mogą
one być opakowaniem do funkcji, metod, funkcjonałów i
właściwie wszystkiego, co da się wywołać. W przypadku metod należy
również podać obiekt podstawowy. Składnia podłączenia jest w
ogólności następująca:
signal.connect( slot );
gdzie zarówno sygnał jak i slot
są obiektami, które muszą być w jakiś sposób
utworzone. Sygnał wywołuje się przez operator() lub metodę emit().
Sygnał do sygnału również można podłączyć - w tym celu należy
utworzyć slot z sygnału wywołując mu metodę slot(). Metoda connect
zwraca nam obiekt typu SigC::Connection (boost::signals::connection
w przypadku boosta), który może być użyty np. do rozłączenia
danego połączenia. Implementacja ta jest bezwzględnie
bezpieczniejsza i pozbawiona właściwie wszystkich wad Qt. Również
nie ma konieczności, by sygnały i sloty zwracały void. Można tutaj
wykorzystać możliwość integrowania wartości (Marshal - nie wiem, jak
to dokładnie określić, ale też i nie widziałem jeszcze sensownie
oddanego po polsku określenia - próbuję to nazwać zgodnie z
tym co to robi) - dla sygnału definiuje się wartość domyślną i
sposób zbierania wartości z wywołań slotów, wtedy
sygnał zwróci tak zintegrowaną wartość. Np. można określić,
że każdy slot zwraca ilość mikrosekund, jaką zabrało mu wykonanie, a
sygnał te wartości sumje (lub zwraca wartość "domyślną" -
zero) - ale mówię to tylko tak hipotetycznie, wcale nie
twierdzę, że ma to jakikolwiek sens. Można również za pomocą
metafunkcji bind (i podobnych, ale w boost akurat tylko bind)
tłumaczyć sygnatury funkcji na inne, żeby dostosować się do slotu.
deklaracje bezpośrednie To jest - można powiedzieć -
lekka kpina z mechanizmu sygnałów i slotów, ale -
wbrew pozorom - można tego używać na poważnie. Na starcie należy
oczywiście wspomnieć o największych ograniczeniach
(dyskwalifikujących z większości zastosowań). Mianowicie: nie można
manipulować połączeniami podczas działania programu, zatem wszelkie
połączenia są zahardkodowane. Oczywiście nie do końca jest to prawda
- jeśli obiekt nie istnieje, to kwestia jego połączeń również
nie istnieje. Druga wada jest taka, że połączenia są zdefiniowane
dla klasy, a nie dla obiektu, zatem ma to sens tylko w przypadku
klas tworzącej tylko jeden obiekt (w obrębie całej hierarchii, w
której zdefiniowane są połączenia). Wbrew pozorom jednak,
wcale nie jest to taki mało prawdopodobny przypadek, zwłaszcza w
przypadku GUI. Tam bowiem najczęściej tworzy się jedną klasę,
dostarcza jej definicje sposobu tworzenia swojego obiektu, ale
zazwyczaj nie ma więcej, niż jednego takiego obiektu. Zresztą nawet
jeśli jest, to nie w obrębie tej samej hierarchii. Zalety mechanizmu
sygnałów i slotów nie kończą się bowiem na
"uogólnieniu" mechanizmu reakcji na zachowanie, ale
również oddzieleniu definicji zdarzenia od definicji reakcji
- co ma dość istotne znaczenie dla zawężenia logiki działania
konkretnego fragmentu projektu. Tutaj można także oczywiście
wykorzystać możliwości obiektowe; na zasadzie że dodaje się do
pewnego zbiornika obiekty i wywołuje im metodę o określonej nazwie -
wtedy można zlekka złagodzić zastrzeżenie, że klasa jest na jeden
obiekt (typ będzie nadal na jeden obiekt, ale można je powiązać
jedną klasą bazową). Połączenie sygnału i slotu realizuje się przez
definicję funkcji pełniącej rolę sygnału - definicja taka może
zawierać TYLKO wywołania odpowiednich slotów (plus
ewentualnie wyrażenia adaptacyjne użyte w celu dostosowania
argumentów sygnału do argumentów slotu - jest to
ewidentna zaleta tej implementacji, acz zaleca się umiar w
stosowaniu, jak też każde takowe musi być deterministyczne). W
każdym razie, jedynym wywołaniem proceduralnym (czyli bez ograniczeń
co do wykonywanych czynności) wewnątrz procedury sygnału jest
wywołanie slotu. Oczywiście nie tylko slotu, może być to również
sygnał (lub przelot, ale o przelotach będzie dokładniej w opisie).
Nie zaleca się również łamać zastrzeżenia, że sygnały i sloty
zawsze zwracają void i nie rzucają wyjątkiem.
Tak z ogólnych uwag nadmienię jeszcze o pewnej dość
istotnej rzeczy, o której w przypadku Qt jest to wyraźnie
powiedziane, ale niespecjalnie przy reszcie (ale Qt jest biblioteką
typowo obiektową, a libsigc++ typowo C++-ową :). Mianowicie w Qt
istnieje wyraźnie zaznaczona zasada, że sygnały to metody PRYWATNE
klasy. Oznacza to w ogólności, że nikt, poza samym obiektem,
nie ma prawa wywoływać sygnałów (nawet makro signals jest
aliasem do private). W przypadku libsigc++ o niczym takim się nie
mówi; tam sygnał można sobie zainstalować w dowolnie chcianym
miejscu. Nie ma takoż jak narzucić tego w przypadku deklaracji
bezpośrednich (jak i żadnej rzeczy oczywiście). Jest to bardzo
istotne zapewnienie, a złamanie go (w deklaracjach bezpośrednich
oczywiście) swego czasu sporo mnie kosztowało. Dlatego też zaznaczam
od razu, że w przypadku deklaracji bezpośrednich, jak też libsigc++
należy bezwzględnie pamiętać o tym, żeby nikt poza klasą
dostarczającą sygnały nie miał do nich dostępu (czy to ma być sekcja
prywatna, czy chroniona, to już zależy od projektu; prywatna tylko w
przypadku programowania ściśle obiektowego; na pewno jednak nie
publiczna). Istnieje też dodatkowy "trik" teoretyczny,
który można stosować w przypadku wszystkich bibliotek, który
pozwoliłem sobie nazwać "internal button". Służy on do
wywołania przez obiekt klasy danego sygnału. Jest on rodzajem
udostępnienia funkcji wywołującej sygnał dla zewnętrza klasy. Jednak
taka definicja ani nie musi istnieć, ani też nie musi tylko biernie
"wywoływać funkcje sygnału".
Ogólnie zaś mechanizm ten definiuje się w następujący
sposób: każdy sygnał (jako obiekt) jest zbiornikiem, którego
elementami są wskaźniki do funkcji (lub funktory). Podłączanie
sygnału do slotu jest to w istocie dodanie funktora będącego slotem
do owego zbiornika. Natomiast implementacja wywołania sygnału polega
na tym, że wszystkie sloty ze zbiornika są po kolei wywoływane.
Jak też widać, aby cały ten mechanizm dał się skompilować w C++,
to sygnał i slot muszą przynajmniej być funkcjami o takich samych
nagłówkach. Można jednak zrobić tak prostą implementację s-s
żeby można było na styku połączeń dokonywać również konwersji
argumentów. Jak? Jest to możliwe w GTK (libsigc++) i własnej
implementacji (libsigc++ jest to biblioteka s-s wyrwana z GTKMM;
biblioteka GTK posiada również implementację sygnałów-slotów
w stylu C).
Tak naprawde jednak, aby wykorzystac mechanizm sygnałów i
slotów, spokojnie można ... obejść się bez biblioteki. Tak
właśnie funkcjonuje implementacja sygnałów i slotów
oparta na deklaracjach bezpośrednich. Przykład definicji:
class Win
{
public:
int x;
Scrollbar s;
// konstruktor Scrollbar przyjmuje Win i zapisuje to
// w polu m_window
Win(): s( this ), x( 0 ) {}
// slots
void slot_updateX( int h ) { x = h; }
void slot_updateSB( int h ) { s.SetPos( h ); }
// signals -- connection definitions
void emit_xChanged( int n ) { slot_updateSB( n ); }
void emit_sbChanged( int n ) { slot_updateX( n ); }
// functions
SetPosition( int pos )
{
bool changed = (pos != x);
x = pos;
if ( changed )
emit_xChanged( pos );
}
}
void Scrollbar::SetPos( int pos )
{
bool changed = (pos != m_x);
m_x = pos;
if ( changed )
m_window->emit_sbChanged( pos );
}
Oczywiście ktoś powie, że to tylko parę takich powielonych definicji
funkcji. Owszem, tak jest. Ale dzięki zastosowaniu samej teorii
sygnałów i slotów mamy to wszystko odpowiednio
uporządkowane, tzn. każde zachowanie ma ograniczoną ilość informacji,
którą niesie. Np. Scrollbar::SetPos wywołuje tylko
emit_sbChanged, które informuje jedynie okno, że scrollbar
zmienił swoją pozycję. O tym, jak na to zareaguje okno, to już jego
sprawa. A zareaguje zgodnie z własnymi definicjami sygnałów,
czyli regułami połączeń sygnałów i slotów.
Wbrew pozorom, jest to bardzo użyteczne mimo, że taka
implementacja sygnałów-slotów ma wiele ograniczeń.
Posiada jednak następujące zalety:
nie wymaga żadnej dodatkowej
biblioteki
nie powoduje najdrobniejszych
narzutów (wszystkie definicje są inline)
kontrola typów przy
przekazywaniu argumentów z sygnałów do slotów
jest na poziomie C++
można przekazywać argumenty
dowolnego typu (w Qt np. nie jest już tak dobrze)
można dokonywać konwersji argumentów pomiędzy sygnałem
a slotem (tzn. w definicji sygnału)
Oczywiście posiada również wady:
definicje połączeń są
zahardkodowane; nie ma możliwości manipulowania połączeniami w
czasie działania programu (co jest możliwe w Qt i libsig)
bardzo słaby zasięg sygnałów
i slotów; korzystanie z nich pomiędzy różnymi klasami
może powodować zamieszanie. Raczej należałoby utworzyć jakąś klasę,
która byłaby kontrolerem sygnałów (a przy takiej
architekturze trudno jest zaprojektować sloty w sensowny sposób).
definicja połączeń znajduje się w definicji klasy, podczas
gdy w libsig i Qt połączenia wykonywane są w kodzie; niemożliwe jest
zatem tworzenie połączeń osobno dla każdego obiektu - jest to zatem
możliwe do zastosowania tylko w klasach tworzących tylko jeden
obiekt
Z powodu tych wad owo rozwiązanie ma bardzo ograniczone
możliwości. Jednak bardzo dobrze się sprawuje w pojedynczych klasach
okienkowych, np. w oknach dialogowych. Funkcje sygnałów
(emit_*) można zwyczajnie wywoływać spod funkcji obsługujących
komunikaty. Przy używaniu tego mechanizmu jednak należy trzymać się
następujących zasad (i to własnie dlatego, że cała sztuka opiera się
na nazewnictwie):
definiujemy dwa rodzaje funkcji:
emit_*, które wywołuje się w reakcji na jakieś zdarzenie
mogące powodować zmianę stanu, oraz slot_*, które powodują
aktualizację stanu. Definicja funkcji slot_ jest dowolna. Nazwy
funkcji emit_ powinny sugerować zdarzenie, a nazwy funkcji slot_
sposób synchronizacji stanu
sygnały, sloty i przeloty mają
zawsze typ zwracany void; slot nigdy nie ma prawa do żadnego
zwracania stanu operacji, ani rzucenia wyjątkiem
definicje funkcji slot_* są de
facto definicjami połączeń sygnałów ze slotami. Sygnały można
zostawić nie połączone z niczym, można też je łączyć z wieloma
slotami. Wewnątrz definicji funkcji emit_* dopuszczalne jest
WYŁĄCZNIE wywoływanie funkcji slot_* oraz nieskomplikowanych wyrażeń
deterministycznych i bezstanowych, jeśli są potrzebne do konwersji
argumentów sygnału do argumentów slotu
funkcje emit_* można wywoływać z
dowolnych miejsc, jednak należy trzymać się jednego kierunku
przesyłania sygnałów (wyjątek stanowią sygnalizacje zmian,
jak w powyższym przykładzie)
sygnały, które sygnalizują
zmianę winny mieć wyraźnie zawarte w nazwie słowo oznaczające zmianę
(np. emit_changedSB, czy emit_widthChanged) i NIGDY nie mogą być
wywoływane bezwarunkowo (w powyższym przykładzie mamy takie właśnie
sygnały, które są wywoływane tylko, gdy nastąpiła zmiana,
gdyby tego warunku nie było, nastąpiłoby zapętlenie). Podobne reguły
dotyczą wszystkich sygnałów wywoływanych w obu kierunkach
(wykluczone jest np. w takich wypadkach, żeby wywoływać funkcję,
która wywołuje bezwarunkowo jakiś sygnał)
definicje sygnałów (czyli
definicje połączeń) powinny być zgrupowane w jednym miejscu
(przynajmniej w jednej klasie); jeśli jakiś sygnał nie może być
podłączony bezpośrednio w tym miejcu (bo brakuje definicji) zaleca
się utworzyć dodatkową funkcję z przedrostkiem thru_ (tzw. przelot),
która będzie kierować do odpowiedniego slotu (jej definicja
znajdzie sie w innym miejscu, ale będzie zawierać tylko wywołanie
slotu). Nazwa przelotu jest obowiązana takimi samymi regulami, jak
nazwa slotu i powinna wyłącznie wywoływac odpowiedni slot.
metody sygnałów muszą być prywatne; jeśli istnieje
konieczność udostępnienia jej z jakichś powodów na zewnątrz,
należy wykorzystać w tym celu "internal button", czyli
zrobić metodę o nazwie push_*, która będzie wywoływać
odpowiednią metodę emit_*.
Tak przy okazji napomknę jeszcze o tym, jak co niektórzy do
tych wszystkich rzeczy podchodzą. Jakiś czas temu natknąłem się na
bibliotekę wxWindows. Była to biblioteka okienkowa, przenośna, będąca
konkurentem dla Qt i GTKMM. W dokumentacji oczywiście od razu
wytykali wady Qt: że zbyt wysokopoziomowa, skomplikowana no i --
najczęściej używany argument przeciwko Qt -- że używa moc-a. Co zaś
wxWindows miało do zaoferowania? Message-mapy na wzór MFC...
Owszem, message-mapy są proste do zrozumienia i działają podobnie
do mechanizmu s-s. Jednak mają sporo ograniczeń, mianowicie:
nazwa funkcji obsługującej
wiadomość dostaje nazwę korespondującą do tego, na co reaguje (np.
OnButtonClick), podczas gdy w mechaniźmie sygnałów i slotów
nazwa slotu koresponduje najczęściej do tego, co w danej funkcji się
wykonuje (qapp.quit()).
message-mapa definiuje pojedynczą
relację komunikat-funkcja, podczas gdy mechanizm sygnałów i
slotów pozwala podłączać jeden sygnał do kilku slotów
jednocześnie, czy kilka sygnałów do jednego slotu (przy
message-mapach też tak można, tylko to potem głupio brzmi, np.
HANDLE_MESSAGE( WM_CLICK, OnKeyPressed ) ), czy nawet podłączać
sygnał do sygnału
pomijając już to, że jeśli nie stosujemy metody deklaracji
bezpośrednich, odpada nam również kilka innych jej wad,
wspólnych z message-map'ami
Jedną dość istotną również rzeczą w mechaniźmie sygnałów
i slotów dostarczonych przez Qt i GTKMM jest również
to, że unikamy konieczności programowania ściśle obiektowego, tzn.
konieczności stworzenia sobie własnej klasy tylko po to, żeby
zdefiniować jakieś konkretne zachowania. Możemy sobie np. utworzyć
okno z kilkoma przyciskami, gdzie każdy będzie zachowywał się inaczej
i wszystko może mieć dowolną definicję. W przypadku MFC i w ogólności
w WinAPI mamy możliwość jedynie albo wyprowadzić własną klasę na
bazie określonej klasy okienkowej, albo łapać "notyfikacje"
w oknie rodzicielskim (przy czym oczywiście nie każde istotne
zdarzenie powoduje notyfikacje; np. okienko klasy EDIT w windows nie
przesyła notyfikacji o naciśniętym klawiszu ENTER, a jedynie o
naciskanych klawiszach ze znakami - WM_CHAR).
Ogólnie mechanizm sygnałów
i slotów, choć najczęściej jest stosowany w programowaniu GUI,
jest tak naprawde ogólnym mechanizmem wykonywania
synchronizacji stanu. Jeśli się zatem chce uniknąć niespodzianek lub
też nagle stracić kontrolę nad projektem, dobrze jest ściśle
zaprojektować sobie wszystkie linie synchronizacyjne. Projekt taki
powinien zawierać listę indykatorów i susceptorów stanu
oraz ich połączenia. Oczywiście mechanizm sygnałów-slotów
nie jest jedynym mechanizmem, który można do tego wykorzystać.
Jednak w praktyce zawsze się okazuje, że każdy inny mechanizm jest
gorszy, a każdy prostszy powoduje większe komplikacje projektu.
Wyszukiwarka
Podobne podstrony:
statescapitalsabbreviationsStateSample csproj FileListAbsoluteBush Altered statesUnited States Presidents Paul Joseph Herbert Hoover (2001)states?minstatesMaps Of The World United StatesUnited States Presidents Anne Welsbacher Theodore Roosevelt (1998)Altered States (Odmienne stany świadomości)RES ,Out of hospital airway management in the United Statesebook Wine Trails Discovering Great Wines In All 50 StatesSHSpec 80 6609C08 States of IdentityUnited States Presidents Paul Joseph Chester Arthur (2000)Heinlein, Robert A The Last Days of the United StatesOdmienne stany świadomości (Altered States) napisywięcej podobnych podstron