Spis treści
- Orientacja obiektowa a orientacja funkcyjna
- Znaczniki przesłaniania
- Programowanie bez wywołań dyspozycyjnych
- Interfejsy i wielokrotne dziedziczenie
Programowanie zorientowane obiektowo zdobyło szturmem świat programistów około 20 lat temu. Za jego największą wartość uznano elastyczność. Lecz z elastycznością jest podobnie jak z wolnością omawianą we wprowadzeniu: niewłaściwy rodzaj elastyczności może być okazją do zezwolenia na wprowadzenie niebezpiecznych błędów.
Kluczową ideą programowania obiektowego jest, że w programowaniu dominują obiekty, a podprogramy (metody) manipulujące obiektami są własnościami tych obiektów. Inne, starsze podejście czasami nazywane jest jako programowanie zorientowane na funkcję (Function-Oriented), czy też strukturalne. W tym wypadku programowanie jest skupione na funkcyjnej dekompozycji: w organizacji programu dominują podprogramy, a obiekty są elementami pasywnymi, którymi manipulują wcześniej wspomniane podprogramy.
Oba podejścia zajmują swoje miejsce w programowaniu oraz mają fanatycznych zwolenników, więc mówienie o surowym, czystym programowaniu obiektowym jest często niewłaściwe.
Ada zapewnia doskonałą równowagę i umożliwia zastosowanie albo jednego, albo drugiego podejścia w zależności od wymagań aplikacji. Tak naprawdę Ada uwzględnia ideę obiektów właściwie od momentu swego powstania w 1980 roku poprzez koncepcję pakietów enkapsulujących typy i działania na nich, oraz zadania, które enkapsulują niezależne działania.
5.1 Orientacja obiektowa a orientacja funkcyjna
Przeanalizujemy dwa przykłady, które pomogą zilustrować różne punkty widzenia. Zostały one wybrane z powodu znajomości zagadnienia, dzięki czemu unikniemy potrzeby wyjaśniania zastosowania aplikacji. Przykłady skupiają się na obiektach geometrycznych (których mamy wiele rodzajów) oraz ludziach (których mamy tylko dwa rodzaje: mężczyzna i kobieta).
Rozważmy wpierw obiekty geometryczne. Dla uproszczenia weźmiemy pod uwagę jedynie figury płaskie. Każdy obiekt ma pozycję. W Adzie może zadeklarować obiekt-korzeń, którego własności są wspólne dla wszystkich rodzajów obiektów:
type Object is tagged record X_Coord: Float; Y_Coord: Float; end record;
Słowo kluczowe tagged odróżnia ten typ od normalnego typu rekordowego (takiego jak Date w rozdziale 3) i oznacza, że dany typ może być rozszerzany. Dodatkowo, obiekty tego typu posiadają w czasie działania programu znacznik (tag) identyfikujący typ obiektu. Możemy zadeklarować różne określone typy obiektowe jak Circle, Triangle, Square itd., a każdy z nich będzie posiadał unikalną wartość znacznika.
Możemy zadeklarować różne własności obiektów geometrycznych: pole, moment bezwładności względem środka. Każdy obiekt ma takie własności, lecz różnią się one w zależności od kształtu. Własności te mogą być określone funkcjami, zadeklarowanymi w tym samym pakiecie co dany typ. Zaczniemy od tego:
package Geometry is type Object is abstract tagged record X_Coord, Y_Coord: Float; end record; function Area(Obj: Object) return Float is abstract; function Moment(Obj: Object) return Float is abstract; end Geometry;
Zadeklarowaliśmy typ i operacje jako abstrakcyjne. Obecnie nie potrzebujemy żadnego obiektu typu Object. Uczynienie typu jako abstrakcyjnego chroni przed przypadkowym zadeklarowaniem obiektów tego typu. Potrzebne nam są obiekty rzeczywiste jak okrąg (Circle), którego własnością jest pole (Area). Jeśli zamierzamy używać punktu jako takiego (brak pola), powinniśmy zadeklarować określony typ Point. Funkcje Area i Moment zostały zadeklarowane również jako abstrakcyjne. To zapewnia, że w momencie deklarowania prawdziwego typu, np. Circle, jesteśmy zmuszeni do zadeklarowania konkretnych funkcji Area i Moment z odpowiednim kodem.
Możemy teraz zadeklarować typ Circle. Najlepiej wykorzystać do tego pakiet potomny:
package Geometry.Circles is type Circle is new Object with record Radius: Float; end record; function Area(C: Circle) return Float; function Moment(C: Circle) return Float; end; with Ada.Numerics; use Ada.Numerics; -- dostęp do π package body Geometry.Circles is function Area(C: Circle) return Float is begin return π * C.Radius**2; -- wykorzystujemy grecką literę π end Area; function Moment(C: Circle) return Float is begin return 0.5 * C.Area * C.Radius**2; end Moment; end Geometry.Circles;
Zwróćmy uwagę na kod definiujący Area i Moment w treści pakietu. Z rozdziału Bezpieczna architektura pamiętamy, że jeśli zajdzie taka potrzeba, kod może być zmieniony i ponownie skompilowany bez konieczności ponownej kompilacji samego opisu typu, a w konsekwencji - bez potrzeby ponownej kompilacji programów korzystających z danego pakietu.
Moglibyśmy teraz zadeklarować następne pakiety takie, jak Square (mający dodatkową składową - długość boku), Triangle (trzy składowe na trzy boki) i tak dalej, bez konieczności wprowadzania zmian w istniejącym abstrakcyjnym typie Object czy typie Circle.
Różne typy tworzą hierarchię zakorzenioną w typie Object. Taki zbiór typów (w terminologii Ady - klasa) jest oznaczony jako Object'Class. Ada wyraźnie odróżnia dany typ Circle od typu takiego jak Object'Class. Dzięki temu unikamy zamieszania, które może wystąpić w innych językach. Jeśli kolejno będziemy definiować inne typy jako rozszerzenia typu Circle, możemy mówić o klasie Circle'Class.
Funkcja Moment zadeklarowana powyżej ilustruje wykorzystanie notacji prefiksowej. Możemy zapisać w jeden z poniższych sposobów:
C.Area -- notacja prefiksowa Area(C) -- notacja funkcyjna
Notacja prefiksowa uwypukla model obiektowy i oznacza, że winniśmy rozważać C jako byt bardziej dominujący niż funkcja Area.
Przypuśćmy, że mamy już zadeklarowane różne obiekty, na przykład:
A_Circle: Circle := (1.0, 2.0, Radius => 4.5); My_Square: Square := (0.0, 0.0, Side => 3.7); The_Triangle: Triangle := (1.0, 0.5, A => 3.0, B => 4.0, C => 5.0);
Dla zilustrowania wykorzystaliśmy notację nazywaną dla składowych innych niż współrzędne x i y, które to współrzędne są wspólne dla wszystkich typów.
Możemy mieć również procedurę drukującą własności obiektu uogólnionego. Piszmy:
procedure Print(Obj: Object'Class) is begin Put("Area is "); Put(Obj.Area); -- dyspozycyjne wywołanie Area ... -- i tak dalej end Print;
a następnie:
Print(A_Circle); Print(My_Square);
Procedura Print może otrzymać dowolny element klasy Object'Class. Wewnątrz procedury wywołanie do Area jest dynamicznie wiązane, co skutkuje wywołaniem funkcji Area odpowiedniej dla określonego typu parametru Obj. To zawsze działa bezpiecznie ponieważ reguły języka są takie, że każdy możliwy obiekt w klasie Object'Class ma określony typ ostatecznie dziedziczony z Object i posiada funkcję Area. Zauważmy, że typ Object jest sam w sobie abstrakcyjny, więc nie mogą być deklarowane obiekty tego typu. Tak samo nie jest problemem, że funkcja Area dla typu Object jest abstrakcyjna i nie ma kodu. I tak nigdy nie będzie ona mogła być wywołana.
W poobny sposób możemy mieć typy dotyczące osób. Rozważmy:
package People is type Person is abstract tagged record Birthday: Date; Height: Inches; Weight: Pounds; end record; type Man is new Person with record Bearded: Boolean; -- czy on ma brodę? end record; type Woman is new Person with record Births: Integer; -- jak wiele dzieci ona urodziła? end record; ... -- różne operacje end People;
Ponieważ nie innych rodzajów osób moglibyśmy opisać je wykorzystując rekord z wariantami, który bardziej należy do świata programowania zorientowanego funkcyjnie:
type Gender is (Male, Female); type Person (Sex: Gender) is record Birthday: Date; Height: Inches; Weight: Pounds; case Sex is when Male => Bearded: Boolean; when Female => Births: Integer; end case; end record;
Możemy następnie zadeklarować różne operacje na tej wersji typu Person. Każda taka operacja mogłaby mieć instrukcję wyboru, aby zróżnicować swe działanie w zależności od płci.
Sposób ten należy zakwalifikować do raczej starych i mało eleganckich. Jednakże ma on swe niewątpliwe zalety.
W przypadku programowania obiektowego, jeśli musimy dodać inną operację, to cała struktura musi być ponownie skompilowana - każdy typ musi być rozszerzony o nową operację. Jeśli musimy dodać inny typ (np. Pentagon), to istniejąca struktura pozostaje bez zmian.
W przypadku orientacji funkcyjnej sytuacja jest całkowicie odwrotna (w powyższym akapicie zamieniamy po prostu miejscami słowa typ i operacja).
Jeśli zachodzi potrzeba dodania innego typu w przypadku orientacji funkcyjnej, to cała struktura musi być ponownie skompilowana - każda operacja musi być zmieniona pod kątem uwzględnienia nowego typu (poprzez dodanie nowej gałęzi do instrukcji wyboru). Jeśli zaś musimy dodać nową operację, to istniejąca struktura może pozostać niezmienioną.
Podjeście zorientowane obiektowo często jest postrzegane jako bardziej bezpieczne od podejścia funkcyjnego, ponieważ brak w tym pierwszym instrukcji wyboru. Jest to prawdą, lecz czasami zarządzanie jest o wiele bardziej kosztowne jeśli dodawane są nowe operacje, gdyż wymaga to oddzielnego dodania odpowiedniego kodu w przypadku każdego typu.
Ada udostępnia oba podejścia i oba są w Adzie bezpieczne.
5.2 Znaczniki przesłaniania
Jedno z niebezpieczeństw w programowaniu zorientowanym obiektowo związane jest z przesłanianiem dziedziczonych operacji. Gdy dodajemy nowy typ do klasy, możemy również dodać nowe wersje wszystkich odpowiednich operacji. Jeśli tego nie uczynimy, odziedziczone zostaną operacje z klasy nadrzędnej.
Niebezpieczeństwo tkwi w próbie dodania nowej wersji podając jednocześnie błędną nazwę:
function Aera (C : Circle) return Float;
lub też myląc typ parametru czy wartości zwracanej:
function Area (C : Circle) return Integer;
W obu przypadkach istniejąca funkcja Area nie zostanie przesłonięta. Dodana zostanie za to zupełnie nowa funkcja. W tej sytuacji, w momencie gdy wywołanie operacji typu klasowego odwoła się do funkcji Area, to zostanie wywołana wersja odziedziczona, a nie jedna z tych błędnie przesłoniętych. Błędy tego rodzaju są bardzo trudne do "wyłapania" - program kompiluje się poprawnie, działa, lecz dostarcza dziwnych odpowiedzi.
Właściwie Ada chroni nas w tym przypadku, ponieważ deklarujemy funkcję Area dla typu Object jako abstrakcyjną, co jest dalszym działaniem ochronnym. Lecz, jeśli mamy kolejne pokolenie lub niemądrze zrezygnujemy z abstrakcyjności funkcji Area, będziemy mieli problemy.
Aby uchronić się przed takimi pomyłkami możemy na przykład zapisać deklarację w taki sposób:
overriding function Area (C : Circle) return Float;
Dzięki temu, jeśli popełnimy omyłkę nie otrzymamy nowej funkcji, lecz kompilator zasygnalizuje błąd. Jeśli natomiast faktycznie chcemy dodać nową operację, możemy to uzyskać pisząc:
not overriding function Area (C : Circle) return Float;
Znaczniki przesłaniania ze względu na zapewnienie zgodności w tył są zawsze opcjonalne.
Języki takie jak C++ czy Java nie wspierają w tym zakresie tak jak Ada, co powoduje, że subtelne błędy są niewykrywalne przez dłuższy czas.
5.3 Programowanie bez wywoływań dyspozycyjnych
W oprogramowaniu krytycznym pod kątem bezpieczeństwa często jest zabronione dynamiczne wywoływanie kodu. Bezpieczeństwo jest zapewnione, gdy możemy udowodnić, że przepływ sterowania przebiega zgodnie ze ściśle określonym wzorcem z - na przykład - brakiem "martwego" kodu. Tradycyjnie rzecz ujmując, oznacza to, że musimy wykorzystać bardziej podejście funkcyjne z widocznymi instrukcjami warunkowymi i instrukcjami wyboru określającymi właściwą ścieżkę przepływu.
Mimo, iż dynamiczne wywoływanie dyspozycyjne jest źródłem mocy programowania obiektowego, są dostępne (również wartościowe) mechanizmy programowania obiektowego (choćby wykorzystanie odziedziczonego kodu). Moglibyśmy wykorzystać możliwość rozszerzania typów i współdzielić kod, deklarując jednak nazwane operacje, które nie wykazują zachowania dynamicznego. Moglibyśmy również skorzystać z notacji prefiksowej, która ma wiele zalet.
Ada posiada mechanizm znany jako pragma Restrictions, który daje programiście pewność, że określone cechy Ady nie będą wykorzystane w danym programie. W naszym przypadku piszemy:
pragma Restrictions (No_Dispatch);
co uniemożliwia stosowanie konstrukcji X'Class, co z kolei oznacza, że nie są możliwe wywoływania dyspozycyjne.
Do dokładnie odpowiada wymaganiom języka Spark (wpomnieliśmy o nim we wprowadzeniu). Spark umożliwia rozszerzanie typów, lecz zabrania wykorzystywania typów i operacji klasowych.
Jeśli włączymy ograniczenie No_Dispatch, to implementacja jest zdolna do redukcji nadmiarowego kodu zazwyczaj związanego z mechanizmami obiektowymi. Nie ma na przykład potrzeby generowania tablicy wywołań dyspozycyjnych dla każdego typu. Tablica ta zawiera adresy różnych określonych operacji związanych z danym typem. Dodatkowo nie ma potrzeby przechowywania znacznika w każdej strukturze rekordu.
Są również mniej widoczne korzyści. W przypadku pełnego mechanizmu obiektowego pewne pierwotne operacje (na przykład równość) posiadają cechy dyspozycyjności tak więc kod z tym związany również może być pominięty. W wyniku zastosowania pragmy minimalizuje się potrzebę justyfikacji nieaktywnego kodu (kod, który jest obecny w programie i który może być śledzony, ale nigdy nie będzie wykonany) jak tego wymaga poziom A certyfikacji.
3.4 Interfejsy i wielokrotne dziedziczenie
Wielu postrzega wielokrotne dziedziczenie jako Świętego Graala - cecha, na podstawie której winny być oceniane języki. Nie tutaj miejsce na dyskusje o historii różnych wykorzystywanych technik. Podsumujemy raczej kluczowe problemy związane z tą tematyką.
Przypuśćmy, że mamy zdolność dowolnego dziedziczenia cech dwóch typów bazowych. Jest taka ciekawa książka Flatlandia czyli Kraina Płaszczaków autorstwa Edwina Abbotta (drugie, oryginalne wydanie było opublikowane w 1884 roku). To satyra na system klasowy (w sensie socjologicznym, nie programistycznym). Dotyczy świata, w którym ludzie to płaskie obiekty geometryczne. Klasa robotnicza to trójkąty, klasy średnie to inne wieloboki. Arystokracja to okręgi. Osobliwe jest to, iż wszystkie kobiety są prostymi odcinkami.
Tak więc, wykorzystując dwie klasy wprowadzone wyżej (Object i Persons) moglibyśmy wyobrażać sobie reprezentację mieszkańców Flatlandii jako typ dziedziczący z obydwu klas:
type Flatlander is new Geometry.Object and People.Person;
Pytanie jakie się nasuwa to: jakie własności są dziedziczone z dwóch typów bazowych? Moglibyśmy oczekiwać, że Flatlander ma składowe X_Coord i Y_Coord odziedziczone z typu Object oraz Birthday odziedziczoną z typu Person. Jednak ze względu na to, że mamy do czynienia z osobami dwuwymiarowymi składowe Height i Weight mają zastosowanie raczej wątpliwe. Naturalnie skorzystamy z dziedziczenia operacji takiej jak Area, jako że Płaszczak ma pole i moment bezwładności.
Jednak już da się zauważyć potencjalne problemy. Przypuśćmy, że oba typy bazowe mają operację o takim samym identyfikatorze. Dzieje się tak zazwyczaj w przypadku operacji natury ogólnej: Print, Make, Copy etc. Która z nich będzie dziedziczona? Przypuśćmy dodatkowo, że oba typy bazowe mają skłądowe o takich samych identyfikatorach. Który będzie brany pod uwagę? Problemy te szczególnie pojawiają się, gdy oba typy bazowe mają wspólnego przodka.
Niektóre języki dostarczają mechanizmu wielokrotnego dziedziczenia i opracowują nieco dłuższe reguły celem pokonania tych trudności (C++, Eiffel). Możliwości zawierają się w przemianowywaniu, przywoływaniu nazwy typu bazowego celem uniknięcia wieloznaczności, czy też dając pierwszeństwo typowi będącemu na pierwszym miejscu listy. Czasami rozwiązania mają posmak unifikacji dla własnego dobra: unifikacje jednej osoby często wprowadzają zamieszanie u innych. Reguły dostępne w C++ dają mnóstwo okazji do popełniania błędów.
Trudności są zazwyczaj dwojakiego rodzaju: dziedziczenie składowych i dzedziczenie implementacji operacji od więcej niż jednego typu bazowego. Z tym, że nie jest problemem dziedziczenie specyfikacji operacji. Rozwiązanie to zostało zaadoptoowane w Javie i okazało się, że z powodzeniem. Podobnie podeszli do tego problemu twórcy standardu Ada 2005.
Tak więc, regułą Ady jest, że możemy dziedziczyć od więcej niż jednego typu:
type T is new A and B and C with record ... -- dodatkowe składowe end record;
ale tylko pierwszy typ listy (A) może mieć składowe i konkretne operacje. Pozostałe typy muszą być czymś, co jest znane jako interfejs, które w gruncie rzeczy są typami abstrakcyjnymi bez składowych oraz których wszystkie operacje są abstrakcyjnymi lub też procedurami null. Aha, pierwszy typ listy również może być interfejsem.
Możemy przeformułować typ Object jako interfejs:
package Geometry is type Object is interface; procedure Move(Obj: in out Object; New_X, New_Y: in Float) is abstract; function X_Coord(Obj: Object) return Float is abstract; function Y_Coord(Obj: Object) return Float is abstract; function Area(Obj: Object) return Float is abstract; function Moment(Obj: Object) return Float is abstract; end Geometry;
Zwróćmy uwagę na to, że składowe zostały usunięte i zastąpione przez operacje. Procedura Move umożliwia przesuwanie obiektu, tzn. ustala nowe wartości współrzędnych x i y, zaś funkcje X_Coord i Y_Coord zwracają bieżące wartości tych współrzędnych.
Widzimy, że notacja prefiksowa oznacza, że ciągle mamy dostęp do współrzędnych przy pomocy np. A_Circle.X_Coord, czy The_Triangle.Y_Coord tak, jak wtedy, gdy były widoczne składowe.
Tak więc gdy zadeklarujemy konkretny typ Circle musimy zaimplementować wszystkie operacje. Być może tak:
package Geometry.Circles is type Circle is new Object with private; -- częściowy widok procedure Move(C: in out Circle; New_X, New_Y: in Float); function X_Coord(C: Circle) return Float; function Y_Coord(C: Circle) return Float; function Area(C: Circle) return Float; function Moment(C: Circle) return Float; function Radius(C: Circle) return Float; function Make_Circle(X, Y, R: Float) return Circle; private type Circle is new Object with -- pełny widok record X_Coord, Y_Coord: Float; Radius: Float; end record; end Geometry.Circles; package body Geometry.Circles is procedure Move(C: in out Circle; New_X, New_Y: in Float) is begin C.X_Coord := New_X; C.Y_Coord := New_Y; end Move; function X_Coord(C: Circle) return Float is begin return C.X_Coord; end X_Coord; -- i podobnie: Y_Coord, Area, Moment -- także funkcje Radius i Make_Circle end Geometry.Circles;
Utworzyliśmy typ Circle jako prywatny, co czyni wszystkie składowe ukrytymi. Pomimo tego częściowy widok ujawnia, że typ ten dziedziczy z typu Object, więc Circle musi posiadać wszystkie własności typu bazowego. Dodaliśmy również funkcję tworzącą okrąg oraz funkcję dostępu do składowej promienia.
Tak więc, istota programowania z wykorzystaniem interfejsów jest taka, że musimy zaimplementować obiecane własności. Nie ma za dużo wielokrotnego dziedziczenia istniejących własności, lecz jest zapewnione wielokrotne dziedziczenie kontraktów.
Wracając do Flatlandii, możemy zadeklarować:
package Flatland is type Flatlander is abstract new Person and Object with private; procedure Move(F: in out Flatlander; New_X, New_Y: in Float); function X_Coord(F: Flatlander) return Float; function Y_Coord(F: Flatlander) return Float; private type Flatlander is abstract new Person and Object with record X_Coord, Y_Coord: Float := 0.0; -- domyślnie w punkcie początkowym ... -- dowolne składowe, których potrzebujemy end record; end;
Typ Flatlander będzie dziedziczył od typu Person wszystkie składowe, operacje (nie pokazaliśmy żadnej) oraz operacje abstrakcyjne typu Object. Jednak wygodnie jest zadeklarować jako składowe, jako że i tak musimy to uczynić oraz możemy przesłonić odziedziczone operacje abstrakcyjne Move, X_Coord, Y_Coord przy pomocy funkcji konkretnych. Podaliśmy również domyślną wartość współrzędnych, co powoduje, że każdy płaszczak "rodzi" się w początku układu współrzędnych.
A oto treść pakietu:
package body Flatland is procedure Move(F: in out Flatlander; New_X, New_Y: Float) is begin F.X_Coord := New_X; F.Y_Coord := New_Y; end Move; function X_Coord(F: Flatlander) return Float is begin return F.X_Coord; end X_Coord; -- i podobnie dla Y_Coord end Flatland;
Uczynienie typu Flatlander abstrakcyjnym oznacza, że nie musimy jeszcze implementować wszystkich operacji takich jak Area. W końcu możemy zadeklarować typ Square właśćiwy dla Flatlandii (oryginał książki został opublikowany anonimowo, a autor podpisał się jako A Square):
package Flatland.Squares is type Square is new Flatlander with record Side: Float; end record; function Area(S: Square) return Float; function Moment(S: Square) return Float; end Flatland.Squares; package body Flatland.Squares is function Area(S: Square) is begin return S.Side**2; end Area; function Moment(S: Square) is begin return S.Area * S.Side**2 / 6.0; end Moment; end Flatland.Squares;
I w ten sposób zaimplementowaliśmy wszystkie operacje. Dla zilustrowania utworzyliśmy dodatkową składową typu Square - Side bezpośrednio widoczną, ale nic nie stoi na przeszkodzie, aby skorzystać z typu prywatnego. Teraz możemy zadeklarować dra Abbotta jako:
A_Square : Square := (Flatlander with Side => 3.00);
W taki oto sposób uzyskujemy wszystkie składowe typów Square i Person. Zwróćmy jeszcze uwagę na agregat rozszerzający, który pobiera wartości domyślne składowych prywatnych, a wartość dodatkowej widzialnej skłądowej podaje wprost.
Jest jeszcze jedna ważna właściwość interfejsów. Interfejs może mieć jako operację procedurę null. Procedura taka zachowuje się jak zwykła procedura z jedną tylko instrukcją null w treści - nie robi nic. Jeśli dwóch przodków ma taką samą operację, procedura null przesłania operację abstrakcyjną mającą te same parametry i zwracany wynik. Z kolei, jeśli dwóch przodków ma tą samą operację abstrakcyjną z równoważnymi parametrami i wynikiem, to jest ona traktowana jako pojedyncza operacja do zaimplementowania. Jeśli parametry i wynik różnią się, to skutkuje to przeciążęniem, a tym samym obie operacje muszą być zaimplementowane. Podsumowując: reguły są zaprojektowane tak, aby zminimalizować niespodzianki i zmaksymalizować korzyści wynikające z wielokrotnego dziedziczenia.
| Rozdział 4: Bezpieczna architektura | Rozdział 6: Bezpieczne tworzenie obiektów |
| Tekst oryginalny w języku angielskim - |
|



