BAZA WIEDZY
KURSY
Bazy danych w PHP
Kurs AdvancedAJAX
Kurs ASP
Kurs ASP.NET
Kurs C++
Kurs CSS
Kurs HTML
Kurs HTML drugi
Kurs JavaScript
Kurs MySQL
Kurs PHP
Kurs RSS
Kurs XHTML
Obiekty DOM
MANUALE
CSS1 - W3C
DOM - w budowie
PHP 2005
PHP 2006
Wyrażenia regularne
SHOUTBOX
STAT
Online: 8 | UU: 211

Projektowanie zorientowane obiektowo

A miało być tak pięknie. Programowanie obiektowe miało być przecież wyjątkowo naturalnym sposobem kodowania, a poprzednie paragrafy raczej nie bardzo o tym przekonywały, prawda? Jeżeli rzeczywiście odnosisz takie wrażenie, to być może zwyczajnie utonąłeś w powodzi szczegółów, dodajmy - niezbędnych szczegółów, koniecznych do stosowania OOPu w praktyce. Czas jednak wypłynąć na powierzchnię i ponownie spojrzeć na zagadnienie bardziej całościowo. Temu celowi będzie służyć niniejszy podrozdział.

Wiele podręczników opisujących programowanie obiektowe (czy nawet programowanie jako takie) wspomina skąpo, jeżeli w ogóle, o praktycznym stosowaniu prezentowanych mechanizmów, czyli po prostu o projektowaniu aplikacji z użyciem omawianych technik. Możnaby to wybaczyć tym publikacjom, których głównym celem jest "jedynie" kompletny opis danego języka. Jeżeli jednak mówimy o materiałach dla całkiem początkujących, będących w założeniu wprowadzeniem w świat programowania, wtedy zdecydowanie niewskazane jest pomijanie praktycznych stron projektowania i kodowania aplikacji. Na co bowiem przyda się znajomość budowy młotka, jeśli nie ułatwi to zadania, jakim jest wbicie gwoździa? :)

Staram się więc uniknąć tego błędu i przedstawiam programowanie obiektowe także od strony programisty-praktyka. Mam jednocześnie nadzieję, że w ten sposób przynajmniej częściowo uchronię cię przed wyważaniem otwartych drzwi w poszukiwaniu informacji w gruncie rzeczy oczywistych - które jednak wcale takie nie są, gdy się ich nie posiada. Naturalnie, nic nie zastąpi doświadczenia zdobytego samodzielnie podczas prawdziwego kodowania. Prezentowana tutaj wiedza teoretyczno-praktyczna może być jednak bardzo pomocnym punktem startowym, ułatwiającym koderskie życie przynajmniej na jego początku.

Cóż więc znajdziemy w aktualnym podrozdziale? Żałuję, ale nie będzie to przegląd kolejnych kroków, jakie należy czynić programując konkretną aplikację. Zamiast na mniej lub bardziej trywialnym programiku skoncentrujemy się raczej na ogólnym procesie budowania wewnętrznej, obiektowej struktury programu - czyli na tak zwanym modelowaniu klas i ich związków. Najpierw poznamy zatem trzy podstawowe rodzaje obiektów albo, jak kto woli, ról, w których one występują. Dalej zajmiemy się kwestią definiowania odpowiednich klas - ich interfejsu i implementacji, a wreszcie związkami pomiędzy nimi, dzięki którym programy stworzone według zasad OOPu mogą poprawnie funkcjonować.

Znajomość powyższego zestawu zagadnień powinna znacznie poprawić twoje szanse w starciu z problemami projektowymi, związanymi z programowaniem obiektowym. Być może ich rozwiązywanie nie będzie już wówczas wiedzą tajemną, ale normalną i, co ważniejsze, satysfakcjonującą częścią pracy kodera.

Nie przedłużając już więcej zacznijmy zatem właściwą treść tego podrozdziału.

Rodzaje obiektów

Każdy program zawiera w mniejszej lub większej części nowatorskie rozwiązania, stanowiące główne wyzwanie stojące przed jego twórcą. Niemniej jednak pewne cechy prawie zawsze pozostają stałe - a do nich należy także podział obiektów składowych aplikacji na trzy fundamentalne grupy.

Podział ten jest bardzo ogólny i niezbyt sztywny, ale przez to stosuje się w zasadzie do każdego projektu. Będzie on zresztą punktem wyjścia dla nieco bardziej szczegółowych kwestii, opisanych później.

Pomówmy więc kolejno o każdym rodzaju z owej podstawowej trójki.

Singletony

Większość obiektów jest przeznaczonych do istnienia w wielu egzemplarzach, różniących się przechowywanymi danymi, lecz wykonujących te same działania poprzez metody. Istnieją jednakże wyjątki od tej reguły, a należą do nich właśnie singletony.

Singleton ('jedynak') to klasa, której jedyna instancja (obiekt) spełnia kluczową rolę w całym programie.

W danym momencie działania aplikacji istnieje więc co najwyżej jeden egzemplarz klasy, będącej singletonem.

Obiekty takie są dosłownie jedyne w swoim rodzaju i dlatego zwykle przechowują one najważniejsze dane programu oraz wykonają większość newralgicznych czynności. Najczęściej są też "rodzicami" i właścicielami pozostałych obiektów.

W jakich sytuacjach przydają się takie twory? Otóż jeżeli podzielilibyśmy nasz projekt na jakieś składowe (sposób podziału jest zwykle sprawą mocno subiektywną), to dobrymi kandydatami na singletony byłyby przede wszystkim te składniki, które obejmowałyby najszerszy zakres funkcji. Może to być obiekt aplikacji jako takiej albo też reprezentacje poszczególnych podsystemów - w grach byłyby to: grafika, dźwięk, sieć, AI, itd., w edytorach: moduły obsługi plików, dokumentów, formatowania itp.

Niekiedy zastosowanie singletonów wymuszają warunki zewnętrzne, np. jakieś dodatkowe biblioteki, używane przez program. Tak jest chociażby w przypadku funkcji Windows API odpowiedzialnych za zarządzanie oknami.

Siłą rzeczy singletony stanowią też "punkty zaczepienia" dla całego modelu klas, gdyż ich pola są w większości odwołaniami do innych obiektów: niekiedy do wielu drobnych, ale częściej do kilku kolejnych zarządców, czyli następnego, niższego poziomu hierarchii zawierania się obiektów.

O relacji zawierania się (agregacji) będziemy jeszcze szerzej mówić.

Przykłady wykorzystania

Najbardziej oczywistym przykładem singletonu może być całościowy obiekt programu , a więc klasa w rodzaju CApplication czy CGame . Będzie ona nadrzędnym obiektem wobec wszystkich innych, a także przechowywała będzie globalne dane dotyczące aplikacji jako całości. To może być chociażby ścieżka do jej katalogu, ale także kluczowe informacje otrzymane od bibliotek Windows API, DirectX czy jakichkolwiek innych.

Jeżeli chodzi o inne możliwe singletony, to z pewnością będą to zarządcy poszczególnych modułów; w grach są to obiekty klas o tak wiele mówiących nazwach jak CGraphicsSystem , CSoundSystem , CNetworkSystem itp., podobne twory można też wyróżnić w programach użytkowych.

Wszystkie te klasy występują w pojedynczych instancjach, gdyż unikatowa jest ich rola. Kwestią otwartą jest natomiast ich ewentualna podległość najbardziej nadrzędnemu obiektowi aplikacji - na przykład w ten sposób:

class CGame
{
private :
CGraphicsSystem* m_pGFX;
CSoundSystem* m_pSFX;
CNetworkSystem* m_pNet;
// itd.
// (resztę składowych pominiemy)
};
// jedna jedyna instancja powyższej klasy
extern CGame* g_pGra;

Równie dobrze mogą być bowiem samodzielnymi obiektami, dostępnymi poprzez swoje własne zmienne globalne - bez pośrednictwa obiektu głównego. Obydwa podejścia są w zasadzie równie dobre (może z lekkim wskazaniem na pierwsze, jako że nie zapewnia takiej swobody w dostępie do podsystemów z zewnątrz).

Dlaczego jednak w ogóle stosować singletony, jeżeli i tak będą one tylko pojedynczymi kopiami swoich pól? Przecież podobne efekty można uzyskać stosując zmienne globalne oraz zwyczajne funkcje w miejsce pól i metod takiego obiektu-jedynaka.

To jednak tylko część prawdy. Namnożenie zmiennych i funkcji poza zasadniczą, obiektową strukturą programu narusza zasady OOPu, i to aż podwójnie. Po pierwsze, nie unikniemy w ten sposób wyraźnego oddzielenia danych od kodu, a po drugie nie zapewnimy im ochrony przed niepowołanym dostępem, co zwiększa ryzyko błędów. Wreszcie, mieszamy wtedy dwa style programowania, a to nieuchronnie prowadzi do bałaganu w kodzie, jego niespójności, trudności w rozbudowie i konserwacji oraz całej rzeszy innych plag, przy których te egipskie mogą zdawać się dziecinną igraszką ;D

Używanie singletonów jest zatem nieodzowne. Przydałoby się więc znaleźć jakiś dobry sposób ich implementacji, bo chyba domyślasz się, że zwykłe zmienne globalne nie są tutaj szczytem marzeń. No, a jeśli nawet nie zastanowiłeś się nad tym, to właśnie masz precedens porównawczy - przedstawię bowiem nieco lepszą drogę na realizację pomysłu pojedynczych obiektów w C++.

Praktyczna implementacja z użyciem składowych statycznych

Nawet najlepszy pomysł nie jest zbyt wiele wart, jeżeli nie można jego skutków zobaczyć w działaniu. Singletony można na szczęście zaimplementować aż na kilka sposobów, różniących się wygodą i bezpieczeństwem.

Najprostszy, z wykorzystaniem globalnego wskaźnika na obiekt lub globalnej zmiennej obiektowej, posiada kilka wad, związanych przede wszystkim z kontrolą nad tworzeniem oraz niszczeniem obiektu. Dlatego lepiej zastosować tutaj inne rozwiązanie, oparte na składowych statycznych klas.

Statyczne składowe są przypisane do klasy jako całości, a nie do jej poszczególnych instancji (obiektów).

Deklarujemy je przy pomocy słowa kluczowego static . Wówczas pełni więc ono inną funkcję niż ta, którą znaliśmy dotychczas.

Podstawową cechą składowych statycznych jest to, że do skorzystania z nich nie jest potrzebny żaden obiekt macierzystej klasy. Odwołujemy się do nich, podając po prostu nazwę klasy oraz oznaczenie składowej, w ten oto sposób:

nazwa_klasy :: składowa_statyczna

Możliwe jest także tradycyjne użycie obiektu danej klasy lub wskaźnika na niego oraz operatorów wyłuskania . lub -> . We wszystkich przypadkach efekt będzie ten sam. Musimy jakkolwiek pamiętać, że nadal obowiązują tutaj specyfikatory praw dostępu, więc jeśli powyższy kod umieścimy poza metodami klasy, to będzie on poprawny tylko dla składowych zadeklarowanych jako public .

Bliższe poznanie statycznych elementów klas wymaga rozróżnienia spośród nich pól i metod. Działanie modyfikatora static jest bowiem nieco inne dla danych oraz dla kodu.

I tak statyczne pola są czymś w rodzaju zmiennych globalnych dla klasy . Można się do nich odwoływać z każdej metody, a także z klas pochodnych i/lub z zewnątrz - zgodnie ze specyfikatorami praw dostępu. Każde odniesienie do statycznego pola będzie jednak dostępem do tej samej zmiennej , rezydującej w tym samym miejscu pamięci . W szczególności poszczególne obiekty danej klasy nie będą posiadały własnej kopii takiego pola, bo będzie ono istniało tylko w jednym egzemplarzu .

Podobieństwo do zmiennych globalnych przejawia się w jeszcze jednym aspekcie: mianowicie statyczne pola muszą zostać w podobny sposób przydzielone do któregoś z modułów kodu w programie. Ich deklaracja w klasie jest bowiem odpowiednikiem deklaracji extern dla zwykłych zmiennych. Odpowiednia definicja w module wygląda zaś następująco:

typ nazwa_klasy :: nazwa_pola [ = wartość_początkowa ] ;

Kwalifikatora nazwa_klasy :: możemy tutaj wyjątkowo użyć nawet wtedy, kiedy nasze pole nie jest publiczne. Spostrzeżmy też, iż nie korzystamy już ze słowa static , jako że poza definicją klasy ma ono odmienne znaczenie.

Statyczność metod polega natomiast na ich niezależności od jakiegokolwiek obiektu danej klasy. Metody opatrzone kwalifikatorem static możemy bowiem wywoływać bez konieczności posiadania instancji klasy. W zamian za to musimy jednak zaakceptować fakt, iż nie posiadamy dostępu do wszelkich niestatycznych składników (zarówno pól, jak i metod) naszej klasy. To aczkolwiek dość naturalne: jeśli wywołanie funkcji statycznej może obejść się bez obiektu, to skąd moglibyśmy go wziąć, aby skorzystać z niestatycznej składowej, która przecież takiego obiektu wymaga? Otóż właśnie nie mamy skąd, gdyż w metodach statycznych nie jest dostępny wskaźnik this , reprezentujący aktualny obiekt klasy.

No dobrze, ale w jaki sposób statyczne składowe klas mogą nam pomóc w implementacji singletonów?. Cóż, to dosyć proste. Zauważ, że takie składowe są unikalne w skali całej klasy - tak samo, jak unikalny jest pojedynczy obiekt singletonu. Możemy zatem użyć ich, by sprawować kontrolę nad naszym jedynym i wyjątkowym obiektem.

Najpierw zadeklarujemy więc statyczne pole, którego zadaniem będzie przechowywanie wskaźnika na ów kluczowy obiekt:

// *** plik nagłówkowy ***
// klasa singletonu

class CSingleton
{
private :
// statyczne pole, przechowujące wskaźnik na nasz jedyny obiekt
static CSingleton* ms_pObiekt; // 2
// (tutaj będą dalsze składowe klasy)
};
// *** moduł kodu ***
// trzeba rzecz jasna dołączyć tutaj nagłówek z definicją klasy
// inicjujemy pole wartością zerową (NULL)

CSingleton* CSingleton::ms_pObiekt = NULL;

Deklarację pola umieściliśmy w sekcji private , aby chronić je przed niepowołaną zmianą. W takiej sytuacji potrzebujemy jednak metody dostępowej do niego, która zresztą także będzie statyczna:

// *** wewnątrz klasy CSingleton ***
public :
static CSingleton* Obiekt()
{
// tworzymy obiekt, jeżeli jeszcze nie istnieje
// (tzn. jeśli wskaźnik ms_pObiekt ma początkową wartość NULL)

if (ms_pObiekt == NULL) CSingleton();
// zwracamy wskaźnik na nasz obiekt
return ms_pObiekt;
}

Oprócz samego zwracania wskaźnika metoda ta sprawdza, czy żądany przez nasz obiekt faktycznie istnieje; jeżeli nie, jest tworzony. Jego kreacja następuje więc przy pierwszym użyciu.

Odbywa się ona poprzez bezpośrednie wywołanie konstruktora. którego na razie nie mamy (jest domyślny)! Czym prędzej naprawmy zatem to niedopatrzenie, przy okazji definiując także destruktor:

// *** wewnątrz klasy CSingleton ***
private :
CSingleton() { ms_pObiekt = this ; }
public :
~CSingleton() { ms_pObiekt = NULL; }

Spore zdziwienie może budzić niepubliczność konstruktora. W ten sposób jednak zabezpieczamy się przed utworzeniem więcej niż jednej kopii naszego singletonu. Uprawniona do wywołania prywatnego konstruktora jest bowiem tylko składowa klasy, czyli metoda CSingleton::Obiekt() . Wszelkie zewnętrzne próby stworzenia obiektu klasy CSingleton zakończą się więc błędem kompilacji, zaś jedyny jego egzemplarz będzie dostępny wyłącznie poprzez wspomnianą metodę.

Powyższy sposób jest zatem odpowiedni dla obiektu stojącego na samym szczycie hierarchii w aplikacji, a więc dla klas w rodzaju CApplication , CApp czy CGame . Jeżeli zaś chcemy mieć wygodny dostęp do obiektów leżących niżej, zawartych wewnątrz innych, wtedy nie możemy oczywiście uczynić konstruktora prywatnym. Wówczas warto więc skorzystać z innych rozwiązań, których jednak nie chciałem tutaj przedstawiać ze względu konieczność znacznie większej znajomości języka C++ do ich poprawnego zastosowania 3 .

Musimy jeszcze pamiętać, aby usunąć obiekt, gdy już nie będzie nam potrzebny - robimy to w zwyczajny sposób, poprzez operator delete :

delete CSingleton::Obiekt();

To konieczne - skoro chcemy zachować kontrolę nad tworzeniem obiektu, to musimy także wziąć na siebie odpowiedzialność za jego zniszczenie.

Na koniec wypadałoby zastanowić się, czy stosowanie powyższego rozwiązania (albo podobnych, gdyż istnieje ich więcej) jest na pewno konieczne. Być może sądzisz, że można się spokojnie bez nich obyć - i chwilowo masz rzeczywiście rację! Kiedy nasze programy są zdeterminowane od początku do końca, zawarte w całości w funkcji main() , łatwo jest zapanować nad życiem singletonu. Gdy jednak rozpoczniemy programować aplikacje okienkowe dla Windows, sterowane zewnętrznymi zdarzeniami, wtedy przebieg programu nie będzie już taki oczywisty. Powyższy sposób na implementację singletonu będzie wówczas znacznie użyteczniejszy.


1 Pamiętajmy, że zmienne zadeklarowane w pliku nagłówkowym z użyciem extern wymagają jeszcze przydzielenia do odpowiedniego modułu kodu poprzez deklarację bez wspomnianego słówka. Powyższy sposób nie jest zresztą najlepszą metodą na zaimplementowanie singletonu - bardziej odpowiednią poznamy za chwilę.

2 Przedrostek s_ wskazuje, że dana zmienna jest statyczna. Tutaj został on połączony ze zwyczajowym m_ , dodawanym do nazw prywatnych pól.

3 Jeden z najlepszych sposobów został opisany w rozdziale 1.3, Automatyczne singletony , książki Perełki programowania gier, tom 1 .

Obiekty zasadnicze

Drugi rodzaj obiektów skupia te, które stanowią największy oraz najważniejszy fragment modelu w każdym programie. Obiekty zasadnicze są jego żywotną tkanką, wykonującą wszelkie zadania przewidziane w aplikacji.

Obiekty zasadnicze to główny budulec programu stworzonego według zasad OOP. Wchodząc w zależności między sobą oraz przekazując dane, realizują one wszystkie funkcje aplikacji.

Budowanie sieci takich obiektów jest więc lwią częścią procesu tworzenia obiektowej struktury programu. Definiowanie odpowiednich klas, związków między nimi, korzystanie z dziedziczenia, metod wirtualnych i polimorfizmu - wszystko to dotyczy właśnie obiektów zasadniczych. Zagadnienie ich właściwego stosowania jest zatem niezwykle szerokie - zajmiemy się nim dokładniej w kolejnych paragrafach tego podrozdziału.

Obiekty narzędziowe

Ostatnia grupa obiektów jest oczkiem w głowie programistów, zajmujących się jedynie "klepaniem kodu" wedle projektu ustalonego przez kogoś innego. Z kolei owi projektanci w ogóle nie zajmują się nimi, koncentrując się wyłącznie na obiektach zasadniczych.

W swojej karierze jako twórcy oprogramowania będziesz jednak często wcielał się w obie role, dlatego znajomość wszystkich rodzajów obiektów z pewnością okaże się pomocna.

Czym więc są obiekty należące do opisywanego rodzaju? Naturalnie, najlepiej wyjaśni to odpowiednia definicja :D

Obiekty narzędziowe , zwane też pomocniczymi lub konkretnymi 1 , reprezentują pewien nieskomplikowany typ danych. Zawierają pola służące przechowywaniu jego danych oraz metody do wykonywania nań prostych operacji.

Nazwa tej grupy obiektów dobrze oddaje ich rolę: są one tylko pomocniczym konstrukcjami, ułatwiającymi realizację niektórych algorytmów. Często zresztą traktuje się je podobnie jak typy podstawowe - zwłaszcza w C++.

Obiekty narzędziowe posiadają wszakże kilka znaczacych cech:

  • istnieją same dla siebie i nie wchodzą w interakcje z innymi, równolegle istniejącymi obiektami. Mogą je wprawdzie zawierać w sobie, ale nie komunikują się samodzielnie z otoczeniem

  • ich czas życia jest ograniczony do zakresu, w którym zostały zadeklarowane. Zazwyczaj tworzy się je poprzez zmienne obiektowe, w takiej też postaci (a nie poprzez wskaźniki) zwracają je funkcje

  • nierzadko zawierają publiczne pola, jeżeli możliwe jest ich bezpieczne ustawianie na dowolne wartości. W takim wypadku typy narzędziowe definiuje się zwykle przy użyciu słowa struct , gdyż uwalnia to od stosowania specyfikatora public , który w typach strukturalnych jest domyślnym (w klasach, definiowanych poprzez class , domyślne prawa to private ; poza tym oba słowa kluczowe niczym się od siebie nie różnią)

  • posiadają najczęściej kilka konstruktorów, ale ich przeznaczenie ogranicza się zazwyczaj do wstępnego ustawienia pól na wartości podane w parametrach. Destruktory są natomiast rzadko używane - zwykle wtedy, gdy obiekt sam alokuje dodatkową pamięć i musi ją zwolnić

  • metody obiektów narzedziowych są zwykle proste obliczeniowo i krótkie w zapisie. Ich implementacja jest więc umieszczana bezpośrednio w definicji klasy. Bezwzględnie stosuje się też metody stałe, jeżeli jest to możliwe

  • obiekty należące do opisywanego rodzaju prawie nigdy nie wymagają użycia dziedziczenia, a więc także metod wirtualnych i polimorfizmu

  • jeżeli ma to sens, na rzecz tego rodzaju obiektów dokonywane jest przeładowywanie operatorów, aby mogły być użyte w stosunku do nich. O tej technice programistycznej będziemy mówić w jednym z dalszych rozdziałów

  • nazewnictwo klas narzędziowych jest zwykle takie samo, jak normalnych typów sklarnych. Nie stosuje się więc zwyczajowego przedrostka C , a całą nazwę zapisuje tą samą wielkością liter - małymi (jak w Bibliotece Standardowej C++) lub wielkimi (według konwencji Microsoftu)

Bardzo wiele typów danych może być reprezentowanych przy pomocy odpowiednich obiektów narzędziowych. Z jednym z takich obiektów masz zresztą stale do czynienia: jest nim typ std::string , będący niczym innym jak właśnie klasą, której rolą jest odpowiednie "opakowanie" łańcucha znaków w przyjazny dla programisty interfejs.

Takie obudowywanie nazywamy enkapsulacją .

Klasa ta jest także częścią Standardowej Biblioteki Typów C++, którą poznamy szczegółowo po zakończeniu nauki samego języka. Należą do niej także inne typy, które z pewnością możemy uznać za narzędziowe, jak na przykład std::complex , reprezentujący liczbę zespoloną czy std::bitset , będący ciągiem bitów.

Matematyka dostarcza zresztą największej liczby kandydatów na potencjalne obiekty narzędziowe. Wystarczy pomyśleć o wektorach, macierzach, punktach, prostokątach, prostych, powierzchniach i jeszcze wielu innych pojęciach. Nie są one przy tym jedynie obrazowym przykładem, lecz niedzownym elementem programowania - gier w szczególności. Większość bibliotek zawiera je więc gotowe do użycia; sporo programistów definiuje dlań jednak własne klasy.

Zobaczmy zatem, jak może wyglądać taki typ w przypadku trójwymiarowego wektora:

#include <cmath>
struct VECTOR3
{
// współrzędne wektora
float x, y, z;
//
// konstruktory

VECTOR3() { x = y = z = 0.0 ; }
VECTOR3( float fX, float fY, float fZ) { x = fX; y = fY; z = fZ; }
//
// metody

float Dlugosc() const { return sqrt(x * x + y * y + z * z); }
void Normalizuj()
{
float fDlugosc = Dlugosc();
// dzielimy każdą współrzędną przez długość
x /= fDlugosc; y /= fDlugosc; z /= fDlugosc;
}
//
// tutaj można by się pokusić o przeładowanie operatorów +, -, *, /,
// =, +=, -=, *=, /=, == i != tak, żeby przy ich pomocy wykonywać
// działania na wektorach. Ponieważ na razie tego nie umiemy, więc
// musimy z tym poczekać :)

};

Najwięcej kontrowersji wzbudza pewnie to, że pola x , y , z są publicznie dostępne. Ma to jednak solidne uzasadnienie: ich zmiana jest rzeczą naturalną dla wektora, zaś zakres dopuszczalnych wartości nie jest niczym ograniczony (mogą nimi być dowolne liczby rzeczywiste). Ochrona, którą zwykle zapewniamy przy pomocy metod dostępowych, byłaby zatem niepotrzebnym pośrednikiem.

Użycie powyższej klasy/struktury (jak kto woli.) wymaga oczywiście utworzenia jej instancji. Przy prostym zestawie danych, jaki ona reprezentuje, nie potrzeba jednak poświęcać pieczołowitej uwagi na tworzenie i niszczenie obiektów, zatem wystarczą nam zwykłe zmienne obiektowe zamiast wskaźników. Nawet więcej - możemy potraktować VECTOR3 identycznie jak typy wbudowane i napisać na przykład funkcję obliczającą oba rodzaje iloczynów wektorów:

float IloczynSkalarny(VECTOR3 vWektor1, VECTOR3 vWektor2)
{
// iloczyn skalarany jest sumą iloczynów odpowiednich współrzędnych
// obu wektorów

return (vWektor1.x * vWektor2.x
+ vWektor1.y * vWektor2.y
+ vWektor1.z * vWektor2.z);
}
VECTOR3 IloczynWektorowy(VECTOR3 vWektor1, VECTOR3 vWektor2)
{
VECTOR3 vWynik;
// iloczyn wektorowy ma za to bardziej skomplikowaną formułkę :)
vWynik.x = vWektor1.y * vWektor2.z - vWektor2.y * vWektor1.z;
vWynik.y = vWektor2.x * vWektor1.z - vWektor1.x * vWektor1.z;
vWynik.z = vWektor1.x * vWektor2.y - vWektor2.x * vWektor1.y;
return vWynik;
}

 

Te operacje mają zresztą niezliczone zastosowania w programowaniu trójwymiarowych gier, zatem ich implementacja ma głęboki sens :)

Spokojnie możemy w tych funkcjach pobierać i zwracać obiekty typu VECTOR3 . Koszt obliczeniowy tych działań będzie bowiem niemal taki sam, jak dla pojedynczych liczb.

W przypadku parametrów funkcji stosujemy jednak referencje, które optymalizują kod, uwalniając od przekazania nawet tych skromnych kilkunastu bajtów. Zapoznamy się z nimi w następnym rozdziale.

Łańcuchy znaków czy wymysły matematyków to nie są naturalnie wszystkie koncepcje, które można i trzeba realizować jako obiekty narzędziowe. Do innych należą chociażby wszelkie reprezentacje daty i czasu, kolorów, numerów o określonym formacie oraz wszystkie pozostałe, nieelementarne typy danych.

Szczególnym przypadkiem obiektów pomocniczych są tak zwane inteligentne wskaźniki (ang. smart pointers ). Ich zadaniem jest zapewnienie dodatkowej funkcjonalności zwykłym wskaźnikom - obejmuje to na przykład zwolnienie wskazywanej przez nie pamięci w sytuacjach wyjątkowych czy też zliczanie odwołań do "opakowanych" nimi obiektów.

1 Autorem tej ostatniej, dziwnej nazwy jest Bjarne Stroustrup i tylko dlatego ją tutaj podaję :)

Definiowanie odpowiednich klas

Tworzenie obiektowego modelu programu przebiega zwykle dwuetapowo. Jednym z zadań jest identyfikacja klas, które będą się nań składały, oraz pól i metod, które zostaną zawarte w ich definicjach. Drugim jest określenie związków pomiędzy tymi klasami, dzięki którym aplikacja mogłaby realizować zaplanowane czynności.

Przestrzeganie powyższej kolejności nie jest ściśle konieczne. Oczywiście, mając już kilka zdefiniowanych klas, można pewnie prościej połączyć je właściwymi relacjami. Równie dobre jest jednak wyjście od tychże relacji i korzystanie z nich przy definiowaniu klas. Obydwa wspomniane procesy często więc odbywają się jednocześnie.

Ponieważ jednak lepiej jest opisać każdy z nich osobno, zatem od któregoś należy zacząć :) Zdecydowałem tedy, że najpierw poszukamy właściwych klas oraz ich składowych, a dopiero potem zajmiemy się łączeniem ich w odpowiedni model.

Zaprojektowanie kompletnego zbioru klas oznacza konieczność dopracowywania dwóch aspektów każdej z nich:

  • abstrakcji , czyli opisu tego, co dana klasa ma robić

  • implementacji , to znaczy określenia, jak ma to robić

Teraz naturalnie zajmiemy się kolejno obydowami kwestiami.

Abstrakcja

Jeżeli masz pomysł na grę, aplikację użytkową czy też jakikolwiek inny produkt programisty, to chyba najgorszą rzeczą, jaką możesz zrobić, jest natychmiastowe rozpoczęcie jego kodowania. Słusznie mówi się, że co nagle, to po diable; niezbędne jest więc stworzenie model abstrakcyjnego zanim przystąpi się do właściwego programowania.

Model abstrakcyjny powinien opisywać założone działanie programu bez precyzowania szczegółów implementacyjnych.

Sama nazwa wskazuje zresztą, że taki model powinien abstrahować od kodu. Jego zadaniem jest bowiem odpowiedź na pytanie "Co program ma robić?", a w przypadku technik obiektowych, "Jakich klas będzie do tego potrzebował i jakie czynności będą przez nie wykonywane?".

Tym kluczowym sprawom poświęcimy rzecz jasna nieco miejsca.

Identyfikacja klas

Klasy i obiekty stanowią składniki, z których budujemy program. Aby więc rozpocząć tę budowę, należałoby mieć przynajmniej kilka takich cegiełek. Trzeba zatem zidentyfikować możliwe klasy w projekcie.

Muszę cię niestety zmartwić, gdyż w zasadzie nie ma uniwersalnego i zawsze skutecznego przepisu, który pozwałby na wykrycie wszelkich klas, potrzebnych do realizacji programu. Nie powinno to zresztą dziwić: dzisiejsze programy dotykają przecież prawie wszystkich nauk i dziedzin życia, więc podanie niezawodnego sposobu na skonstruowanie każdej aplikacji jest zadaniem porównywalnym z opracowaniem metody pisania książek, które zawsze będą bestsellerami, lub też kręcenia filmów, które na pewno otrzymają Oscara. To oczywiście nie jest możliwe, niemniej dziedzina informatyka poświęcona projektowaniu aplikacji (zwana inżynierią oprogramowania ) poczyniła w ostatnich latach duże postępy.

Chociaż nadal najlepszą gwarancją sukcesu jest posiadane doświadczenie, intuicja oraz odrobina szczęścia, to jednak początkujący adept sztuki tworzenia programów (taki jak ty :)) nie pozostanie bez pomocy. Programowanie obiektowe zostało przecież wymyślone właśnie po to, aby ułatwić nie tylko kodowanie programów, ale także ich projektowanie - a na to składa się również wynajdywanie klas odpowiednich dla realizowanej aplikacji.

Otóż sama idea OOPu jest tutaj sporym usprawnieniem. Postęp, który ona przynosi, jest bowiem związany z oparciem budowy programu o rzeczowniki , zamiast czasowników, właściwym programowaniu strukturalnemu. Myślenie kategoriami tworów, bytów, przedmiotów, urządzeń - ogólnie obiektów , jest naturalne dla ludzkiego umysłu. Na rzeczownikach opiera się także język naturalny, i to w każdej części świata.

Również w programowaniu bardziej intuicyjne jest podejście skoncentrowane na wykonawcach czynności, a nie na czynnościach jako takich. Przykładowo, porównaj dwa poniższe, abstrakcyjne kody:

// 1. kod strukturalny
hPrinter = GetPrinter();
PrintText (hPrinter, "Hello world!" );
// 2. kod obiektowy
pPrinter = GetPrinter();
pPrinter->PrintText ( "Hello world!" );

Mimo że oba wyglądają podobnie, to wyraźnie widać, że w kodzie strukturalnym ważniejsza jest sama czynność drukowania, zaś jej wykonawca (drukarka) jest kwestią drugorzedną. Natomiast kod obiektowy wyraźnie ją wyróżnia, a wywołanie metody PrintText() można przyrównać do wciśnięcia przycisku zamiast wykonywania jakiejś mało trafiającej do wyobraźni operacji.

Jeżeli masz wątpliwość, które podejście jest właściwsze, to pomyśl, co zobaczysz, patrząc na to urządzenie obok monitora - czynność (drukowanie) czy przedmiot (drukarkę) 1 ?.

No, ale dosyć już tych luźnych dygresji. Mieliśmy przecież zająć się poszukiwaniem właściwych klas dla naszych programów obiektowych. Odejście od tematu w poprzednim akapicie było jednak tylko pozorne, gdyż "niechcący" znaleźliśmy całkiem prosty i logiczny sposób, wspomagający identyfikację klas.

Mianowicie, powiedzieliśmy sobie, że OOP przesuwa środek ciężkości programowania z czasowników na rzeczowniki. Te z kolei są także podstawą języka naturalnego, używanego przez ludzi. Prowadzi to do prostego wniosku i jednocześnie drogi do całkiem dobrego rozwiązania dręczacego nas problemu:

Skuteczną pomocą w poszukiwaniu klas odpowiednich dla tworzonego programu może być opis jego funkcjonowania w języku naturalnym.

Taki opis stosunkowo łatwo jest sporządzić, pomaga on też w uporządkowaniu pomysłu na program, czyli klarowanym wyrażeniu, o co nam właściwie chodzi :) Przykład takiego raportu może wyglądać choćby w ten sposób:

Program Graph jest aplikacją przeznaczoną do rysowania wszelkiego rodzaju schematów i diagramów graficznych. Powinien on udostępniać szeroką paletę przykładowych kształtów, używanych w takich rysunkach: bloków, strzałek, drzew, etykiet tekstowych, figur geometrycznych itp. Edytowany przez użytkownika dokument powinien być ponadto zapisywalny do pliku oraz eksportowalny do kilku formatów plików graficznych.

Nie jest to z pewnością zbyt szczegółowa dokumentacja, ale na jej podstawie możemy łatwo wyróżnić sporą ilość klas. Należą do nich przede wszystkim:

  • dokument

  • schemat

  • różne rodzaje obiektów umieszczanych na schematach

Warto też zauważyć, że powyższy opis ukrywa też nieco informacji o związkach między klasami, np. to, że schemat zawiera w sobie umieszczone przez użytkownika kształty.

Zbiór ten z pewnością nie jest kompletny, ale stanowi całkiem dobre osiągnięcie na początek. Daje też pewne dalsze wskazówki co do możliwych kolejnych klas, jakimi mogą być poszczególne typy kształtów składających się na schemat.

Tak więc analiza opisu w języku naturalnym jest dosyć efektywnym sposobem na wyszukiwanie potencjalnych klas, składających się na program. Skuteczność tej metody zależy rzecz jasna w pewnym stopniu od umiejętności twórcy aplikacji, lecz jej stosowanie szybko przyczynia się także do podniesienia poziomu biegłości w projektowaniu programów.

Analizowanie opisu funkcjonalnego programu nie jest oczywiście jedynym sposobem poszukiwania klas. Do pozostałych należy chociażby sprawdzanie klasycznej "listy kontrolnej", zawierającej często występujące klasy lub też próba określenia działania jakiejś konkretnej funkcji i wykrycia związanych z nią klas.

Abstrakcja klasy

Kiedy już w przybliżeniu znamy kilka klas z naszej aplikacji, możemy spróbować określić je bliżej. Pamiętajmy przy tym, że definicja klasy składa się z dwóch koncepcyjnych części:

  • publicznego interfejsu , dostępnego dla użytkowników klasy

  • prywatnej implementacji , określającej sposób realizacji zachowań określonych w interfejsie

Całą sztuką w modelowaniu pojedynczej klasy jest skoncentrowanie się na pierwszym z tych składników, będącym jej abstrakcją . Oznacza to zdefiniowanie roli, spełnianej przez klasę, bez dokładnego wgłębiania się w to, jak będzie ona tę rolę odgrywała.

Taka abstrakcja może być również przedstawiona w postaci krótkiego, najczęściej jednozdaniowego opisu w języku naturalnym, np.:

Klasa Dokument reprezentuje pojedynczy schemat, który może być edytowany przez użytkownika przy użyciu naszego programu.

Zauważmy, że powyższe streszczenie nic nie mówi choćby o formie, w jakiej nasz dokument-schemat będzie przechowywany w pamięci. Czy to będzie bitmapa, rysunek wektorowy, zbiór innych obiektów albo może jeszcze coś innego?. Wszystkie te odpowiedzi mogą być poprawne, jednak na etapie określania abstrakcji klasy są one poza obszarem naszego zainteresowania.

Abstrakcja klasy jest określeniem roli, jaką ta klasa pełni w programie.

Jawne formułowanie opisu podobnego do powyższego może wydawać się niepotrzebne, skoro i tak przecież będzie on wymagał uszczegółowienia. Posiadanie go daje jednak możliwość prostej kontroli poprawności definicji klasy. Jeżeli nie spełnia ona założonych ról, to najprawdopodobniej zawiera błędy.

Składowe interfejsu klasy

Publiczny interfejs klasy to zbiór metod, które mogą wywoływać jej użytkownicy. Jego określenie jest drugim etapem definiowania klasy i wyznacza zadania, jakie należy wykonać podczas jej implementacji.

Nasza klasa Dokument będzie naturalnie zawierała kilka publicznych metod. Co ciekawe, sporo informacji o nich możemy "wyciągnąć" i wydedukować z już raz analizowanego opisu całego programu. Na jego podstawie dają się sprecyzować takie funkcje jak:

  • Otwórz - otwierającą dokument zapisany w pliku
  • Zapisz - zachowująca dokument w postaci pliku
  • Eksportuj - metoda eksportująca dokument do pliku graficznego z możliwością wyboru docelowego formatu

Z pewnościa w toku dalszego projektowania aplikacji (być może w trakcie definicji kolejnych klas albo ich związków?) można by znaleźć także inne metody, których umieszczenie w klasie będzie słusznym posunięciem. W każdej sytuacji musimy jednak pamiętać, aby postać klasy zgadzała się z jej abstrakcją.

Mówię o tym, gdyż nie powinieneś zapominać, że projektowanie jest procesem cyklicznym, w którym może występować wiele iteracji oraz kilka podejść do tego samego problemu.

Implementacja

Implementacja klasy wyznacza drogę, po jakiej przebiega realizacja zadań klasy, określonych w abstrakcji oraz przybliżonych poprzez jej interfejs. Składają się na nią wszystkie wewnętrzne składniki klasy, niedostępne jej użytkowników - a więc prywatne pola , a także kod poszczególnych metod .

Dogmaty ścisłej inżynierii oprogramowania mówią, aby dokładne implementacje poszczególnych metod (zwane specyfikacjami algorytmów ) były dokonywane jeszcze podczas projektowania programu. Do tego celu najczęściej używa się pseudokodu, o którym już kiedyś wspominałem. W nim zwykle zapisuje się wstępne wersje algorytmów metod.

Jednak według mnie ma to sens chyba tylko wtedy, kiedy nad projektem pracuje wiele osób albo gdy nie jesteśmy zdecydowani, w jakim języku programowania będziemy go ostatecznie realizować. Wydaje się, że obie sytuacje na razie nas nie dotyczą :)

W praktyce więc implementacja klasy jest dokonywana podczas programowania, czyli po prostu pisania jej kodu. Można by zatem spierać się, czy faktycznie należy ona jeszcze do procesu projektowania. Osobiście uważam, że to po prostu jego przedłużenie, praktyczna kontynuacja, realizacja - różnie można to nazywać, ale generalnie chodzi po prostu o zaoszczędzenie sobie pracy. Łączenie projektowania z programowaniem jest w tym wypadku uzasadnione.


Schemat 28. Proces tworzenia klasy

Odkładanie implementacji na koniec projektowania, w zasadzie "na styk" z kodowaniem programu, jest zwykle konieczne. Zaimplementowanie klasy oznacza przecież zadeklarowanie i zdefiniowanie wszystkich jej składowych - pól i metod, publicznych i prywatnych. Do tego wymagana jest już pełna wiedza o klasie - nie tylko o tym, co ma robić, jak ma to robić, ale także o jej związkach z innymi klasami.

1 Oczywiście nie dotyczy to tych, którzy drukarki nie mają, bo oni nic nie zobaczą :D

Związki między klasami

Potęgą programowania obiektowego nie są autonomiczne obiekty, ale współpracujące ze sobą klasy. Każda musi więc wchodzić z innymi przynajmniej w jedną relację , czyli związek.

Obecnie zapoznamy się z trzema rodzajami takich związków. Spajają one obiekty poszczególnych klas i umożliwiają realizację założonych funkcji programu.

Dziedziczenie i zawieranie się

Pierwsze dwa typy relacji będziemy rozpatrywać razem z tego względu, iż przy ich okazji często występują pewne nieporzumienia. Nie zawsze jest bowiem oczywiste, którego z nich należy użyć w niektórych sytuacjach. Postaram się więc rozwiać te wątpliwości, zanim jeszcze zdążysz o nich pomyśleć ;)

Związek generalizacji-specjalizacji

Relacja ta jest niczym innym, jak tylko znanym ci już dobrze dziedziczeniem. Generalizacja-specjalizacja (ang. is-a relationship ) to po prostu bardziej uczona nazwa dla tego związku.

W dziedziczeniu występują dwie klasy, z których jedna jest nadrzędna, zaś druga podrzędna. Ta pierwsza to klasa bazowa, czyli generalizacja ; reprezentuje ona szeroki zbiór jakichś obiektów. Wśród nich można jednak wyróżnić takie, które zasługują na odrębny typ, czyli klasę pochodną - specjalizację .


Schemat 29. Ilustracja związku generalizacji-specjalizacji

Klasa bazowa jest często nazywana nadtypem , zaś pochodna - podtypem . Na schemacie bardzo dobrze widać, dlaczego :D

Najistotniejszą konsekwencją użycia tego rodzaju relacji jest przejęcie przez klasę pochodną całej funkcjonalności, zawartej w klasie bazowej. Jako że jest ona jej bardziej szczegółowym wariantem, możliwe jest też rozszerzenie odziedziczonych możliwości, lecz nigdy - ich ograniczenie.

Klasa pochodna jest więc po prostu pewnym rodzajem klasy bazowej.

Związek agregacji

Agregacja (ang. has-a relationship ) sugeruje zawieranie się jednego obiektu w innym. Mówiąc inaczej, obiekt będący całością składa się z określonej liczby obiektów-składników.


Schemat 30. Ilustracja związku agregacji

Przykładów na podobne zachowanie nie trzeba daleko szukać. Wystarczy chociażby rozejrzeć się po dysku twardym we własnym komputerze: nie dość, że zawiera on foldery i pliki, to jeszcze same foldery mogą zawierać inne foldery i pliki. Podobne zjawisko występuje też na przykład dla kluczy i wartości w Rejestrze Windows.

Implementacja tej relacji w C++ oznacza umieszczenie w deklaracji obiektu agregatu pola, które będzie reprezentowało jego składnik, np.:

// składnik
class CIngredient { /* ... */ };
// obiekt nadrzędny
class CAggregate
{
private :
// pole ze składowym składnikiem
CIngredient* m_pSkladnik;
public :
// konstruktor i destruktor
CAggregate() { m_pSkladnik = new CIngredient; }
~CAggregate() { delete m_pSkladnik; }
};

Można by tu także zastosować zmienną obiektową, ale wtedy związek stałby się obligatoryjny , czyli musiał zawsze występować. Natomiast w przypadku wskaźnika istnienie obiektu nie jest konieczne przez cały czas, więc może być on tworzony i niszczony w razie potrzeby.

Trzeba jednak uważać, aby po każdym zniszczeniu obiektu ustawiać jego wskaźnik na wartość NULL . W ten sposób będziemy mogli łatwo sprawdzać, czy nasz składnik istnieje, czy też nie. Unikniemy więc błędów ochrony pamięci.

Odwieczny problem: być czy mieć?

Rozróżnienie pomiędzy dziedziczeniem a zawieraniem może czasami nastręczać pewnych trudności. W takich sytuacjach istnieje na szczęście jedno proste rozwiązanie.

Otóż jeżeli relację pomiędzy dwoma obiektami lepiej opisuje określenie "ma" ("zawiera", "składa się" itp.), to należy zastosować agregację. Kiedy natomiast klasy są naturalnie połączone poprzez stwierdzenie "jest", wtedy odpowiedniejszym rozwiązaniem jest dziedziczenie.

Co to znaczy? Dokładnie to, co widzisz i o czym myślisz. Należy po prostu sprawdzić, które ze sformułowań:

Klasa1 jest rodzajem Klasa2 .

Klasa1 zawiera obiekt typu Klasa2 .

jest poprawne, wstawiając oczywiście nazwy swoich klas w oznaczonych miejscach, np.:

Kwadrat jest rodzajem Figury .

Samochód zawiera obiekt typu Koło .

Mamy więc kolejny przykład na to, że programowanie obiektowe jest bliskie ludzkiemu sposobowi myślenia, co może nas tylko cieszyć :)

Związek asocjacji

Najbardziej ogólnym związkiem między klasami jest przyporządkowanie , czyli właśnie asocjacja (ang. uses-a relationship ). Obiekty, których klasy są połączone taką relacją, posiadają po prostu możliwość wymiany informacji między sobą podczas działania programu.

Praktyczna realizacja takiego związku to zwykle użycie przynajmniej jednego wskaźnika, a najprostszy wariant wygląda w ten sposób:

class CFoo { /* ... */ };
class CBar
{
private :
// wskaźnik do połączonego obiektu klasy CFoo
CFoo* m_pFoo;
public :
void UstanowRelacje(CFoo* pFoo) { m_pFoo = pFoo; }
};

 

Łatwo tutaj zauważyć, że zawieranie się jest szczególnym przypadkiem asocjacji dwóch obiektów.

Połączenie klas może oczywiście przybierać znacznie bardziej pogmatwane formy, my zaś powinniśmy je wszystkie dokładnie poznać :D Pomówmy więc o dwóch aspektach tego rodzaju związków: krotności oraz kierunkowości.

Krotność związku

Pod dziwną nazwą krotności kryje się po prostu liczba obiektów , biorących udział w relacji. Trzeba bowiem wiedzieć, że przy asocjacji dwóch klas możliwe są różne ilości obiektów, występujących z każdej strony. Klasy są przecież tylko typami, z nich są dopiero tworzone właściwe obiekty, które w czasie działania aplikacji będą się ze sobą komunikowały i wykonywały zadania programu.

Możemy więc wyróżnić cztery ogólne rodzaje krotności związku:

  • jeden do jednego . W takim przypadku pojedynczemu obiektowi jednej z klas odpowiada również pojedynczy obiekt drugiej klasy. Przyporządkowanie jest zatem jednoznaczne .
    Z takimi relacjami mamy do czynienia bardzo często. Weźmy na przykład dowolną listę osób - uczniów, pracowników itp. Każdemu numerowi odpowiada tam jedno nazwisko oraz każde nazwisko ma swój unikalny numer. Podobnie "działa" też choćby tablica znaków ANSI.

  • jeden do wielu . Tutaj pojedynczy obiekt jednej z klas jest przyporządkowany kilku obiektom drugiej klasy. Wygląda to podobnie, jak włożenie skarpety do kilku szuflad naraz - być może w prawdziwym świecie byłoby to trudne, ale w programowaniu wszystko jest możliwe ;)

  • wiele do jednego . Ten rodzaj związku oznacza, że kilka obiektów jednej z klas jest połączonych z pojedynczym obiektem drugiej klasy.
    Dobrym przykładem są tu rozdziały w książce, których może być wiele w jednej publikacji. Każdy z nich jest jednak przynależny tylko jednemu tomowi.

  • wiele do wielu . Najbardziej rozbudowany rodzaj relacji to złączenie wielu obiektów od jednej z klas oraz wielu obiektów drugiej klasy.
    Wracając do przykładu z książkami możemy stwierdzić, że związek między autorem a jego dziełem jest właśnie takim typem relacji. Dany twórca może przecież napisać kilka książek, a jednocześnie jedno wydawnictwo może być redagowane przez wielu autorów.

Implementacja wielokrotnych związków polega zwykle na tablicy lub innej tego typu strukturze, przechowującej wskaźniki do obiektów danej klasy. Dokładny sposób zakodowania relacji zależy rzecz jasna także od tego, jaką ilość obiektów rozumiemy pod pojęciem "wiele".

Pojedyncze związki są natomiast z powodzeniem programowane za pomocą pól, będących wskaźnikami na obiekty.

Widzimy więc, że poznanie obsługi obiektów poprzez wskaźniki w poprzednim rozdziale było zdecydowanie dobrym pomysłem :)

Tam i (być może) z powrotem

Gdy do obiektu jakiejś klasy dodamy pole - wskaźnik na obiekt innej klasy, wtedy utworzymy między nimi relację asocjacji. Związek ten będzie jednokierunkowy , gdyż jedynie obiekt posiadający wskaźnik stanie się jego aktywną częścią i będzie inicjował komunikację z drugim obiektem. Ten drugi obiekt może w zasadzie "nie wiedzieć", że jest częścią relacji!

W związku jednokierunkowym z pierwszego obiektu możemy otrzymać drugi, lecz odwrotna sytuacja nie jest możliwa.

Naturalnie, niekiedy będziemy potrzebowali obustronnego, wzajemnego dostępu do obiektów relacji. W takim przypadku należy zastosować związek dwukierunkowy .

W związku dwukierunkowym oba obiekty mają do siebie wzajemny dostęp.

Taka sytuacja często ułatwia pisanie bardziej skomplikowanego kodu oraz organizację przeplywu danych. Jej implementacja napotyka jednak ma pewną, zdawałoby się nieprzekraczalną przeszkodę. Popatrzmy bowiem na taki oto kod:

class CFoo
{
private :
// wskaźnik do połączonego obiektu CBar
CBar* m_pBar;
};
class CBar
{
private :
// wskaźnik do połączonego obiektu CFoo
CFoo* m_pFoo;
};

Zdawałoby się, że poprawnie realizuje on związek dwukierunkowy klas CFoo i CBar . Próba jego kompilacji skończy się jednak niepowodzeniem, a to z powodu wskaźnika na obiekt klasy CBar , zadeklarowanego wewnątrz CFoo . Kompilator analizuje bowiem kod sekwencyjnie, wiersz po wierszu, zatem na etapie definicji CFoo nie ma jeszcze bladego pojęcia o klasie CBar , więc nie pozwala na zadeklarowanie wskaźnika do niej.

Łatwo przewidzieć, że zamiana obu definicji miejscami w niczym tu nie pomoże. Dochodzimy do paradoksu: aby zdefiniować pierwszą klasę, potrzebujemy drugiej klasy, zaś by zdefniować drugą klasą, potrzebujemy definicji pierwszej klasy! Sytuacja wydaje się być zupełnie bez wyjścia.

A jednak rozwiązanie istnieje, i jest do tego bardzo proste. Skoro kompilator nie wie, że CBar jest klasą, trzeba mu o tym zawczasu powiedzieć. Aby jednak znowu nie wpaść w błędne koło, nie udzielimy o CBar żadnych bliższych informacji; zamiast definicji zastosujemy deklarację zapowiadającą :

class CBar; // rzeczona deklaracja
// (dalej definicje obu klas, jak w kodzie wyżej)

Po tym zabiegu kompilator będzie już wiedział, że CBar jest typem (dokładnie klasą) i pozwoli na zadeklarowanie odpowiedniego wskaźnika jako pola klasy CFoo .

Niektórzy, by uniknąć takich sytuacji, od razu deklarują deklarują wszystkie klasy przed ich zdefiniowaniem.

Widzimy więc, że związki dwukierunkowe, jakkolwiek wygodniejsze niż jednokierunkowe, wymagają nieco więcej uwagi. Są też zwykle mniej wydajne przy łączeniu nim dużej liczby obiektów. Prowadzi to do prostego wniosku:

Nie należy stosować związków dwukierunkowych, jeżeli w konkretnym przypadku wystarczą relacje jednokierunkowe.

***

Projektowanie aplikacji nawet z użyciem technik obiektowych nie zawsze jest prostym zadaniem. Ten podrozdział powinien jednak stanowić jakąś pomoc w tym zakresie. Nie da się jednak ukryć, że praktyka jest zawsze najlepszym nauczycielem, dlatego zdecydowanie nie powinieneś jej unikać :) Samodzielne zaprojektowanie i wykonanie choćby prostego programu obiektowego będzie bardziej pouczające niż lektura najobszerniejszych podręczników.

Kończacy się podrozdział w wielu miejscach dotykał zagadnień inżynierii oprogramowania. Jeżeli chciałbyś poszerzyć swoją wiedzę na ten temat (a warto), to zapraszam do Materiału Pomocniczego C, Podstawy inżynierii oprogramowania .

Kurs C++
| | | |
Copyright © 2006-2013 egrafik.pl | Kontakt | Reklama | Projekty domów
jocker