Obiekty, klasy i metody w Objective-C
Programowanie zorientowane obiektowo w czystym C, mimo że możliwe, rzadko kiedy jest proste i przyjemne, a powstały kod jest zwykle bardzo zagmatwany. Dlatego też na bazie C powstały kompatybilne z nim języki dodające wygodne w użyciu wsparcie dla kodu zorientowanego obiektowo. Pierwszy z nich to niezwykle popularny C++, w którym większość tych rozszerzeń to różnego rodzaju dodatkowe operacje wykonywane podczas kompilacji. Drugi to niszowy Objective-C, który (oprócz składni) od C++ różni się tym, że dodatkowe elementy jakie zostały do niego wprowadzone są odpowiednimi operacjami wykonywanymi podczas działania programu. Dzięki temu udaje się uzyskać w języku kompilowanym do kodu natywnego część udogodnień znanych z technologii takich jak .NET czy Java.
Klasy
W języku Objective-C każda klasa jest także obiektem zawierające podstawowe informacje wymagane przez pozostałe funkcje wsparcia runtime. Najważniejsze z danych przechowywanych przez obiekty opisujące klasy to:
- nazwa klasy
- rozmiar instancji klasy (z uwzględnieniem klas bazowych)
- lista zmiennych składowych klasy
- lista funkcji składowych klasy
- wskaźnik na klasę bazową
- lista klas pochodnych
- realizowane protokoły
Traktowanie klasy jako obiektu (inspirowane Smalltalkiem) pozwala znacząco poszerzyć możliwości języka szczególnie jeżeli chodzi o refleksyjność. Struktury opisujące klasy najczęściej są przechowywane w wewnętrznych danych runtime. Odpowiednie informacje są wydobywane przy okazji tworzenia instancji danej klasy, a następnie wiązane nowym obiektem. Dlatego też poniższy kod:
array = [NSArray new]
zostanie w większości implementacji, już przez kompilator zamieniony na konstrukcję podobną do tej:
array = [(objc_get_class("NSArray")) new]
Funkcja objc_get_class przegląda listę wszystkich klas w poszukiwaniu żądanej i w przypadku jej odnalezienia zwraca wskaźnik do niej. Cała operacja ma miejsce już podczas działania programu, dlatego też istotna jest szybkość działania takich podstawowych operacji. Jednym ze sposobów na przyśpieszenie kodu jest cachowanie wskaźników na obiekty opisujące klasy. W rezultacie funkcja objc_get_class jest wykonywana jeden raz dla każdej klasy, a w każdym kolejnym przypadku używany jest zwrócony przez nią wskaźnik.
Obiekty
Wszystkie klasy w Objective-C dziedziczą po NSObject. Jest to klasa która realizuje większość operacji pozwalających na korzystanie przez programistę z informacji jakie są przechowywane o każdej klasie. Oferowana funkcjonalność to między innymi:
- sprawdzenie czy klasa odpowiada na selektor
- sprawdzenie czy klasa realizuje protokół
- podanie funkcji odpowiadającej na selektor
- wykonanie podanego selektora
- podanie nazwy klasy
Selektory
Selektory identyfikują wysyłaną do obiektu wiadomość, czyli innymi słowy, nazywają funkcję składową która powinna zostać wywołana. W większości jest to po prostu ciąg znaków odpowiadających nazwie funkcji, chociaż w niektórych implementacjach zawiera także unikalny numer identyfikacyjny. W celu usprawnienia operacji tłumaczenia ciągu znaków na selektor i odwrotnie istnieją odpowiednie tablice przechowujące listy wszystkich zarejestrowanych danych. Jako że mimo tego, może to być kosztowna operacja, umożliwiane jest (wręcz zalecane) tłumaczenie nazw do selektorów podczas kompilacji.
Wywołanie funkcji składowych
Wywołanie funkcji składowych, a właściwie zgodnie z nomenklaturą Objective C: wysyłanie wiadomości obiektom jest przeprowadzany w całkowicie dynamiczny sposób. Warto także zaznaczyć, że ponieważ klasy także są traktowane jako obiekty, statyczne funkcje składowe wywoływane są w taki sam sposób. Oto przykładowy kod wysyłania komunikatu do obiektu:
[obiekt metoda]
zostaje zamieniony na:
objc_msgSend(obiekt, @selector(metoda))
Funkcja objc_msgSend, na podstawie informacji zawartych w opisie klasy której instancją jest object, wywołuje odpowiednią metodę. Każda klasa zawiera listę selektorów oraz przypisanych im adresów funkcji. Procedura przekazująca wiadomość przeszukuje tę listę (z uwzględnieniem klas bazowych) w poszukiwaniu adresu odpowiedniej metody, która następnie zostaje wywołana.
Często, w celu przyśpieszenia tej bardzo częstej operacji, wykorzystywane są tak zwane dispatch tables. Właściwie pod każdym względem przypominają one tablice funkcji wirtualnych wykorzystywane np. w C++. Każda taka tablica zawiera jedynie adresy kolejnych funkcji składowych klasy. W tej implementacji unikalny numer identyfikacyjny selektora służy także jako indeks wskazujący na odpowiedni adres w tej tablicy. Najważniejszą różnicą między dispatch table a tablicami funkcji wirtualnych jest fakt, że dispatch tables najczęściej są tworzone już podczas działania programu na podstawie listy funkcji składowych zawartej w obiekcie opisującym klasę.
Pozostałe dane dotyczące klasy
Często zdarza się, że wpisy z adresami funkcji składowych i odpowiadającymi im selektorami zawierają także informację o przyjmowanych przez metodę argumentach. Jest to przydatne przy debuggingu. Podobnie rzecz się ma z wpisami dotyczącymi zmiennych składowych. Obiekt opisujący klasę przechowuję listę struktur zawierających nazwę każdej takiej zmiennej, jej typ (dla ułatwienie debuggingu) i przesunięcie w stosunku do bazowego adresu instancji.
Podsumowanie
Objective-C prezentuje inne podejście co do sposobu implementacji rozszerzeń związanych z OOP w C niż C++. Opiera się bardziej na wykonywaniu operacji podczas działania programu, co zmniejsza jego wydajność, ale z drugiej strony oferuje szereg dodatkowych możliwości. Niech za przykład posłużą tutaj choćby systemy rozproszone. W Objective-C przy odrobinie wysiłku ze strony osoby implementującej taki system, można tworzyć obiekty rozproszone w banalnie prosty sposób. Możliwe jest to właśnie dzięki dużej ilości informacji które zostają w kodzie po kompilacji, oraz wielu możliwościach wstawienia dodatkowego kodu podczas działania programu. Oczywiście takie rozwiązania są też możliwe w C++ czy innych językach, ale nie zawsze jest to aż takie proste.
Główną wadą Objective-C jest jego niewielka popularność. Przed śmiercią chroni go właściwie jedynie OpenStep. Warto jednak zwrócić uwagę na jego możliwości, ponieważ jest on gdzieś pomiędzy innymi językami kompilowanymi do kodu natywnego a kompilowanymi do kodu pośredniego. Z jednej strony wsparcie runtime jest bardzo rozbudowane, ale wciąż istnieje możliwość uzyskania maksymalnej możliwej wydajności w krytycznych miejscach, przez ograniczenie używania niektórych z jego funkcji.
Programowanie oparte na komponentach
Programowanie zorientowane obiektowo jest niewątpliwie jednym z najpopularniejszych obecnie paradygmatów. Powstało bardzo wiele opracowań na jego temat wprowadzających chociażby techniki znane jako wzorce projektowe. Nie jest to jednak rozwiązanie idealne i w pewnych zastosowaniach wiążą się z nim pewne trudności. Chodzi tutaj o tworzenie gier komputerowych gdzie coraz częściej można się spotkać z programowaniem opartym na komponentach.
Uzasadnienie
Praktycznie w każdej grze występuje główna baza obiektów w niej występujących. Zwykle są to instancje klas ściśle ze sobą powiązanych odpowiednią hierarchią. Wspólna funkcjonalność, zgodnie z duchem OOP, jest w miarę możliwości przenoszona do klas znajdujących się wyżej w hierarchii. Wszelkie odstępstwa od domyślnego zachowania są implementowane przy użyciu polimorfizmu.
Wraz ze wzrostem stopnia skomplikowania projektu coraz trudniej jest unikać dziedziczenia wielobazowego i wszystkich związanych z nim niedogodności, w trakcie pozbywania się duplikowanego kodu. Innym problemem który się pojawia jest powstanie bardzo dużych klas bazowych, które są zwykle wysoce niezalecane. Dochodzi do sytuacji gdzie programowanie zorientowane obiektowo najwyraźniej nie jest najlepszy sposobem na rozwiązanie danego problemu.
Podstawowe założenia
W powyższych sytuacjach bardziej istotnym od zarządzania obiektami jest wygodne zarządzanie logiką kodu. Realizowane jest to za pomocą dwóch podstawowych elementów programowania opartego na komponentach:
- jednostka (entity) – identyfikuje to co w OOP zostałoby nazwane obiektem. Nie posiada pól ani metod, jest jedynie czymś w rodzaju nazwy. Swoim przeznaczeniem przypomina klasę, ponieważ definiuje konkretne zachowanie, lecz ilościowo odpowiada obiektom.
- komponent (component) – definiują logikę operacji. Każdy komponent opisuje inne fragmenty zachowania jednostki. Najważniejszą różnicą między klasami a komponentami jest to, że klasy opisują pełną funkcjonalność, podczas gdy pojedynczy komponent tylko jej część. W większości implementacji komponenty są obiektami globalnymi realizowanymi przy pomocy wzorca singleton, jednakże nie jest to sztywną regułą.
Niezwykle istotnym elementem całego systemu jednostek jest przyporządkowywanie im konkretnych komponentów. Może to odbywać się na dwa sposoby, zależnie od implementacji. Także umieszczenie kodu i danych jest sprawą zależną od samej implementacji. Warto jednak pamiętać, że podstawowym założeniem programowania opartego na komponentach jest utrzymanie minimalnego rozmiaru jednostki. W rezultacie jest to najczęściej liczba całkowita.
Dane
W systemach opartych na tym paradygmacie dane są umieszczane w komponentach. W zależności od możliwych zastosowań jednostki, komponenty przechowują dane potrzebne do realizowania przez nie odpowiednich funkcji. Wymaga to dodatkowego mechanizmu komunikowania się pomiędzy komponentami różnych typów w sytuacji gdy potrzebna jest modyfikacja danych innego komponentu. Powinno to być jednak unikane. Główną zaletą jest uproszczenie mechanizmów związanych z synchronizacją w przypadku systemów wielowątkowych. Każdy komponent zajmuje się tylko swoimi danymi dzięki czemu można ograniczyć ilość blokad.
Dodatkowo najczęściej to komponenty przechowują informację o przyporządkowanym im jednostką. Dzięki temu zlokalizowanie wszystkich jednostek jest proste i nie wymaga istnienia dodatkowych struktur danych. W tej kwestii wiele jednak zależy od implementacji, bo nie da się ukryć, że zdarza się także sytuacja w której każda jednostka przechowuje listę przypisanych komponentów.
Kod
W przypadku kodu najpopularniejszym rozwiązaniem jest umieszczenie go także w komponentach. Wtedy co prawda jest on powiązany z danymi, co może przypominać OOP, jednak uzyskuje się duże rozdrobnienie funkcjonalności co ułatwia wiele kwestii. Oprócz wspomnianego ominięcia problemu synchronizacji, możliwa jest dynamiczna zmiana właściwości i metod obiektu (reprezentowanego przez jednostkę) co pozwala na ograniczenie ilości zajmowanej pamięci. Istotny jest także inny sposób zapisu kodu co jest główną cechą odróżniającą od siebie paradygmaty programowania. Znacząco ułatwia to dodanie dodatkowej funkcjonalności poprzez wprowadzenie kolejnego komponentu.
Komponenty ułatwiają także masowe wykonywanie operacji. Jeżeli dana funkcja powinna być wykonana na wszystkich obiektach danego typu, komponent potrafi to zrobić korzystając z posiadanej listy przyporządkowanych mu jednostek. Kluczowe jest tutaj ustalenie odpowiedniej kolejności wykonywania komponentów w odpowiedzi na zdarzenia.
Przykład realizacji
Przykładowe zastosowanie komponentów w grze polega na stworzeniu wystarczającej ich ilości do pokrycia każdego możliwego zachowania obiektów. Oczywiście, z reguły nie stosuje się tutaj żadnej hierarchii komponentów, ponieważ zwykle nie przynosi to żadnych korzyści. Każdy obiekt obecny w grze jest zarejestrowany w komponencie Render który udostępnia procedury związane z jego wyświetlaniem. Oprócz tego łódki mogą być zarejestrowane w komponencie Boat. Po ich zniszczeniu zostają usunięte z komponentu Boat a zarejestrowane w Wreck.
Główna pętla programu w odpowiedniej kolejności, często w odpowiedzi na konkretne zdarzenia wykonuje żądane komponenty. Dla przykładu, jeżeli samochód jest sterowany przez gracza, to należy do komponentu PlayerControl, którego procedury są wywoływane w odpowiedzi na zdarzenia z urządzeń zewnętrznych.
Podsumowanie
Programowanie oparte na komponentach zwane także bardziej szczegółowo Entity System jest bez wątpienia bardzo ciekawym spojrzeniem na sposób rozwiązania problemów związanych z dużą ilością podobnych obiektów, których implementacja zawiera jednak pewne różnice. Pozwalają one w takiej sytuacji na uporządkowanie kodu i co za tym idzie ułatwienia jego dalszej rozbudowy.
Jest to technika stosowana najczęściej w przypadku produkcji gier komputerowych co nie oznacza, że w innych dziedzinach nie może znaleźć ona zastosowania. Istnieje wiele różnych podejść do takich kwestii jak umieszczenie kodu czy sposób kojarzenia komponentów z jednostkami. Dzieje się tak, ponieważ nie ma tutaj uniwersalnego rozwiązania i każdy dostosowuje to do swoich potrzeb. Podobnie rzecz się ma gdy porównać komponenty do OOP. Trudno powiedzieć żeby jeden paradygmat był lepszy od drugiego. Ich zastosowanie jest odmienne i należy tak je używać, aby wykorzystać jak najwięcej ich zalet.