Kilka uwag o projektowaniu obiektowym
Transkrypt
Kilka uwag o projektowaniu obiektowym
Kilka uwag o projektowaniu obiektowym Mateusz Kobos 17.03.2011 System komputerowy a rzeczywistość ● Czasami można się spotkać ze stwierdzeniem, że „system komputerowy modeluje rzeczywistość”. Takie stwierdzenie nie jest do końca poprawne, bo – „rzeczywistość” zależy od punktu widzenia, – pojęcie rzeczywistości traci sens w przypadku programów, które operują na programach (np. kompilatory, edytory), – w dzisiejszych czasach komputery i programy są częścią modelowanej "rzeczywistości" (np. w systemach wspomagających zarządzanie firmą), – system nie jest modelem rzeczywistości, w najlepszym przypadku jest modelem modelu jakiejś części rzeczywistości. 2/21 System komputerowy a rzeczywistość cd. ● ● ● Przy tworzeniu oprogramowania mamy do czynienia z kilkoma „poziomami”: – rzeczywistość; – część rzeczywistości, który nas interesuje; – abstrakcyjny model tej części rzeczywistości; – system jest modelem tego abstrakcyjnego modelu. Szpitalny system monitorowania pacjentów nie jest modelem szpitala, ale praktycznym modelem czyjegoś punktu widzenia na to, jak powinny wyglądać pewne aspekty zarządzania szpitalem. Modelować znaczy odrzucać - zadaniem projektanta jest modelowanie tej części rzeczywistości, która jest ważna dla budowanego systemu. 3/21 Cechy oprogramowania dobrej jakości Poniżej przedstawiono podstawowe cechy jakimi charakteryzuje się oprogramowanie dobrej jakości. ● ● ● ● Niezawodność na którą składają się: – poprawność (ang. correctness) - najważniejsza cecha - jeśli program nie robi tego, czego od niego oczekujemy to reszta jest nieważna; – odporność (ang. robustness) – odporność na nieprzewidziane (na etapie specyfikacji/projektowania) sytuacje – program powinien wypisać błąd i łagodnie zakończyć działanie. Modułowość,na którą składają się: – rozszerzalność (ang. extendibility) - łatwość przystosowania programu do zmian w specyfikacji; – przystosowanie do wielokrotnego użycia (ang. reusability) – kod można wykorzystać w innych częściach programu lub w innych programach. Modułowość prowadzi do tworzenia elastycznej architektury systemu złożonej z autonomicznych komponentów software-owych. Wykorzystuje się moduły – samodzielne części kodu zorganizowane w stabilne struktury. W przypadku programowania obiektowego to klasy są modułami. 4/21 Kryteria modułowości By realizować pożądane cechy oprogramowania rozszerzalności i przystosowania do ponownego użycia, metoda konstrukcji oprogramowania powinna spełniać następujące kryteria. ● ● ● ● ● Wspomaganie podejścia top-down – wspomaganie podejścia, w którym rozbijamy problem software-owey na parę mniej skomplikowanych podproblemów, które można analizować w miarę niezależnie. Wspomaganie podejścia bottom-up – wspomaganie wytwarzania komponentów software-owych, które mogą być ze sobą łatwo łączone tworząc w ten sposób nowy system. Zrozumiałość – by zrozumieć dowolny moduł, powinna wystarczyć analiza tylko tego modułu, a w najgorszym przypadku również paru (niewielu) innych. Stabilność (alternatywna nazwa: ciągłość) - mała zmiana w specyfikacji pociąga za sobą zmianę w jednym module albo w małej liczbie modułów. Ochrona - w przypadku wystąpienia anormalnej sytuacji w module, jej skutki ograniczają się tylko do tego modułu, a w najgorszym przypadku do 5/21 jeszcze paru (niewielu) innych. Reguły modułowości Uwaga: poniżej będziemy używać pojęcia intefejsu ale w znaczeniu bardziej ogólnym niż np. interfejs w języku C#. Przez interfejs będziemy tutaj rozumieć „kanał komunikacyjny” między modułami. Z kryteriów modułowości wynikają następujące reguły. ● ● Mapowanie bezpośrednie (ang. direct mapping) - struktura modułowa programu powinna odpowiadać strukturze modułowej modelowanego problemu opisanego w specyfikacji wymagań. Niewiele interfejsów - każdy moduł powinien komunikować się z jak najmniejszą liczbą innych modułów. – ● ● Nieduże interfejsy - jeśli 2 moduły komunikują się ze sobą, powinny wymieniać tylko tyle informacji, ile jest niezbędne. Jawne interfejsy – jeśli 2 moduły komunikują się ze sobą, musi to być wyraźnie uwidocznione w kodzie źródłowym tych modułów. – ● umożliwia to tworzenie odpornych, rozszerzalnych architektur. Tu taką ukrytą komunikacją może być korzystanie ze wspólnej struktury danych (zamiast standardowego wywoływania nawzajem swoich procedur przez moduły). Ukrywanie informacji - tylko część modułu powinna być publiczna (bo wtedy można łatwo zmieniać część prywatną). 6/21 Zasady modułowości Z kryteriów i reguł modułowości wynikają następujące zasady. ● ● Samodokumentowanie się kodu (ang. self-documentation) - trzeba dążyć do tego, by wszystkie informacje o module były zawarte w module (a nie w oddzielnych dokumentach). Jednolity dostęp (ang. uniform access) – dostęp do właściwości/elementów klasy powinien odbywać się przez odpowiednik właściwości (ang. properties) z języka C# (by klient klasy nie wiedział, czy uzyskuje bezpośredni dostęp do pola klasy, czy do pewnej funkcji obliczającej wartość). – Zaleta: można łatwo zmienić implementację takiego dostępu do klasy (wyliczać wartość dynamicznie lub przechowywać w polu klasy) 7/21 Zasady modułowości cd. ● ● Zasada otwarty zamknięty (ang. open-closed) - moduł powinien być otwarty na rozszerzanie i zamknięty na modyfikacje. – „Otwartosć” oznacza tutaj, że można np. dodawać do modułu/klasy nowe operacje/metody i nowe pola (co może być konieczne przy zmianie specyfikacji wymagań). – „Zamkniętość” oznacza tutaj, że moduł ma stabilny, dobrze zdefiniowany opis (intefejs) i dzięki temu może być używany przez inne moduły. – Rozwiązaniem tego paradoksu jest np. zastosowanie dziedziczenia, gdzie nie modyfikuje się napisanego już kodu, a można rozszerzać jego funkcjonalność. Pojedynczy wybór – gdy program obsługuje zbiór alternatyw, ich pełna lista powinna być znana dokładnie jednemu modułowi. – Zaleta: dodanie nowej alternatywy (co może być konieczne gdy zmieni się specyfikacja wymagań) powoduje zmiany tylko w jednym module 8/21 Znajdowanie klas ● ● ● ● ● ● Znajdowanie klas jest najważniejszym zadaniem w budowaniu systemu obiektowo zorientowanego. Zadanie to wymaga talentu, doświadczenia i szczęścia (jak w każdej dyscyplinie związanej z kreatywnością). System projektujemy na podstawie specyfikacji wymagań. Przykład: fragment specyfikacji wymagań: "Winda zamyka drzwi zanim przemieści się na inne piętro". Pytanie: czy klasa drzwi jest potrzebna? Odpowiedź: to zależy, od tego, co ma robić program. Trzeba zadać sobie pytanie czy – „drzwi” to oddzielny typ danych z jasno zdefiniowanymi operacjami (klasa jest potrzebna) lub – czy może wszystkie operacje wykonywane na "drzwi" są już zawarte w operacjach wykonywanych na innych typach danych takich jak winda (klasa jest niepotrzebna). Analogicznie, piętra możemy reprezentować po prostu za pomocą liczb. Jeśli jednak piętro ma cechy inne niż liczba, czyli istotne operacje nie są określone poprzez liczbę - np. niektóre piętra mogą mogą mieć określone 9/21 specjalne prawa dostępu - to należy utworzyć klasę piętro. Znajdowanie klas cd. Poniżej podano źródła, które mogą być przydatne przy znajdowaniu klas. ● Istniejące programy/biblioteki. ● Dokument wymagań – często pojawiające się terminy – terminy zdefiniowane explicite w tekście – terminy nie zdefiniowane precyzyjnie w tekście ze względu na swoją oczywistość ● Dyskusja z klientami i przyszłymi użytkownikami. ● Rozmowa z doświadczonym projektantem. ● Dokumentacja (np. manual) do innych systemów z tej dziedziny. ● Literatura dotycząca systemów obiektowych oraz algorytmów i struktur danych. 10/21 Dziedziczenie - własności ● Na dziedziczenie można patrzeć z 2 perspektyw. ● Perspektywa modułu – perspektywa bardziej implementacyjna. – ● Łatwo można tworzyć nowy moduł wskazując tylko to co jest dodane i zmienione w nowym module (podklasa) względem starego (nadklasa). W paru modułach (podklasy) można wydzielić wspólną część (nadklasa) i dzięki temu nie duplikować kodu. Jest to zgodne z zasadą otwarty-zamknięty. Perspektywa typu – perspektywa bardziej abstrakcyjna. Dziedziczenie reprezentuje relację „A jest B”/”każde A jest B”. – Co oznacza ta relacja? ● ● Zawieranie się zbiorów obiektów: obiekty typu prostokąt są podzbiorem obiektów typu wielokąt. Podstawialność: każda operacja, która może być zastosowana do klasy wielokąt może też być zastosowana do klasy prostokąt. 11/21 Jak tworzyć hierarchię klas ● ● ● Hierarchia klas tworzy strukturę klasyfikacji obiektów w systemie. Zazwyczaj jest możliwych parę klasyfikacji dla danego problemu - należy wybierać tą najbardziej odpowiadającą dziedzinie zastosowania. Tworzenie hierarchii to kombinacja 2 procesów: specjalizacji i generalizacji. – Czasami najpierw zauważamy abstrakcję i wnioskujemy o przypadkach szczególnych jej użycia, – a czasami najpierw budujemy użyteczne klasy i zdajemy sobie, że istnieje bardziej abstrakcyjny koncept, który je „spina”. – Jest to proces typu yo-yo: projektujemy abstrakcje, potem implementacje i z powrotem abstrakcje. 12/21 Problem: sprawdzanie typu obiektu ● ● W programach obiektowych b. rzadko zachodzi potrzeba sprawdzania typu obiektu (tzn. sprawdzania instancją jakiej klasy jest obiekt). Obowiązuje raczej zasada egoizmu: "Nie mów mi czym jesteś, powiedz mi co masz – jak mogę cię wykorzystać” lub inaczej: „programuj biorąc pod uwagę interfejs a nie implementację”. W programie wykorzystujemy interfejs klasy (publiczne metody i właściwości), nie interesuje nas to z jaką dokładnie klasą mamy do czynienia void voidmakeSound(Animal makeSound(Animala){ a){ if(a if(aisisCow) Cow)((Cow)a).moo(); ((Cow)a).moo(); else if(a is Dog) else if(a is Dog)((Dog)a).bark(); ((Dog)a).bark(); else if(a is Cat) ((Cat)a).meow(); else if(a is Cat) ((Cat)a).meow(); else elsethrow thrownew newUnknownAnimal(); UnknownAnimal(); }} void voidmakeSound(Animal makeSound(Animala){ a){ a.makeSound(); a.makeSound(); }} 13/21 Problem: sprawdzanie typu obiektu cd. ● ● Komentarz do rysunku po lewej. Jeśli sprawdzamy typ obiektu i w zależności od typu wykonujemy pewne działania, to podobny kod sprawdzający ma tendencję do występowania w różnych miejscach programu. Gdybyśmy dodali nową klasę zwierzęcia np. kurę, kod prawdopodobnie należałoby zmienić w tych wszystkich miejscach, by obsłużyć ten nowy typ. Byłoby to czasochłonne i podatne na popełnienie błędu przez programistę. Jest to niezgodne z kryterium stabilności, i niezgodne z zasadą pojedynczego wyboru (bo wiele różnych modułów musiałoby mieć wiedzę o alternatywnych typach zwierzęcia). Komentarz do rysunku po prawej. Jeśli natomiast postępujemy zgodnie z „zasadą samolubności”, jest to zgodne z kryterium stabilności z z zasadą pojedynczego wyboru – informacja o alternatywnych rodzajach zwierzęcia jest wykorzystywana tylko w miejscu, w którym tworzone są obiekty typu zwierzę. Dodanie nowego typu zwierzęcia jest tu o wiele łatwiejsze. 14/21 Problem: sprawdzanie typu obiektu na liście ● Problem (analogiczny do poprzedniego): przy iterowaniu po elementach listy, dla każdego elementu wykonujemy czynność zależną od jego typu (klasy). var varl l==new newArrayList<Animal>(); ArrayList<Animal>(); l.add(new Dog(„Pluto”)); l.add(new Dog(„Pluto”)); l.add(new l.add(newDog(„Butch”)); Dog(„Butch”)); l.add(new Cow(„Megan”)); l.add(new Cow(„Megan”)); … … foreach(Animal foreach(Animalaain inl){ l){ if(a is Cow) ((Cow)a).moo(); if(a is Cow) ((Cow)a).moo(); else elseif(a if(aisisDog) Dog)((Dog)a).bark(); ((Dog)a).bark(); else if(a is Cat) ((Cat)a).meow(); else if(a is Cat) ((Cat)a).meow(); else elsethrow thrownew newUnknownAnimal(); UnknownAnimal(); }} 15/21 Problem: sprawdzanie typu obiektu na liście cd. ● Nie ma dobrego i ogólnego rozwiązania tego problemu. ● Rozwiązanie 1 - analogiczne jak poprzednio – wykorzystanie polimorfizmu var varl l==new newArrayList<Animal>(); ArrayList<Animal>(); l.add(new Dog(„Pluto”)); l.add(new Dog(„Pluto”)); l.add(new l.add(newDog(„Butch”)); Dog(„Butch”)); l.add(new Cow(„Megan”)); l.add(new Cow(„Megan”)); … … foreach(Animal foreach(Animalaain inl){ l){ a.makeSound(); a.makeSound(); }} ● Wada tego rozwiązania: gdy chcemy np. zliczać obiekty każdego z typów 16/21 występujące na liście i tak musimy sprawdzić typ każdego obiektu. Problem: sprawdzanie typu obiektu na liście cd. ● ● ● Rozwiązanie 2 – nie trzymać na tej samej liście obiektów, które bardzo się różnią – wykorzystać różne listy dla różnych typów obiektów. Wada tego rozwiązania: to podejście może być nienaturalne i niezgodne z filozofią reszty systemu. W szczególności możemy mieć do czynienia z sytuacją, gdy dla każdego nowego typu obiektu należy tworzyć nową listę. Czasami jedynym wyjściem jest stosowanie rozwiązania 1 połączonego ze sporadycznym sprawdzaniem typu obiektów na liście (gdy np. chcemy policzyć ile jest obiektów każdego z typów). 17/21 Problem: dziedziczenie czy typ wyliczeniowy? ● ● Przykład: w systemie będzie występować parę różnych typów zwierząt: pies, krowa, kot. Pytanie: czy stosować dziedziczenie czy typ wyliczeniowy do reprezentowania tych typów? kontra ● Odpowiedź: – Jeśli w systemie rodzaje zwierząt mają różne cechy/funkcje/zachowanie (np. jest to system do zarządzania zwierzętami farmerskimi, gdzie każde ze zwierząt wymaga innego pokarmu, jest powiązane z innym lokum, wytwarza inne produkty), to wprowadzenie hierarchii dziedziczenia jest uzasadnione. – W przeciwnym przypadku (np. jest to system raportowy, zliczający tylko liczbę różnych rodzajów zwierząt) lepiej użyć typu 18/21 wyliczeniowego. Problem: dziedziczenie czy zawieranie ● Dziedziczenie i zawieranie to dwie podstawowe relacje występujące w językach obiektowych. Kiedy należy stosować jedną a kiedy drugą? kontra ● Jeśli klasa A dziedziczy po klasie B, to powinna być zachowana relacja „A jest B”, a bardziej szczegółowo: „każde A jest B” (np. każdy Samochód jest Pojazdem). – Dzięki temu uszczegółowieniu możemy uniknąć trywialnego błędu: ● ● Klasa SanFrancisco dziedziczy po klasie City – źle. Można powiedzieć „San Francisco jest miastem”, ale już nie „Każde San Francisco jest miastem”. Jeśli obiekt A zawiera/posiada obiekt B, to powinna być zachowana relacja „A zawiera/posiada B” (np. „samochód zawiera kierownicę”). 19/21 ● ● ● Uwaga: gdy zachodzi relacja "jest", można zamiast niej równie dobrze zastosować relację "zawiera". Przykład klasy "inżynier oprogramowania" i dwóch opisów świata. – "Każdy inżynier oprogramowania jest inżynierem.". Z tego opisu wynika, że należy zastosować relację "jest", czyli dziedziczenie. – "W każdym inżynierze oprogramowania jest inżynier". Z tego opisu wynika, że należy zastosować relację "zawiera/posiada", czyli zawieranie. Następująca reguła pomaga w wyborze odpowiedniej relacji (ale nie zawsze wskazuje ją jednoznacznie). Pytamy czy klasa B powinna zawierać obiekt klasy A, czy dziedziczyć po klasie A? – Stosować relację zawierania, jeśli w czasie wykonania programu może zajść potrzeba zastąpienia obiektu klasy A obiektem innej klasy. ● – Inżynier oprogramowania po paru latach może zmienić swój komponent inżynier np. na poetę lub hydraulika. Stosować relację dziedziczenia, jeśli może zajść potrzeba, by obiekty klasy B były czasem traktowane jakby były obiektami klasy A. ● Należy używać dziedziczenia, jeśli relacja między obiektami klas 20/21 A i B jest niezmienna. Literatura ● ● Bertrand Meyer, „Object Oriented Software Construction”, drugie wydanie, 2000 Bertrand Meyer, „Programowanie zorientowane obiektowo”, Helion. 2005 – Nie polecane ze względu na słabe tłumaczenie 21/21