Jak programować obiektowo? cz. 9 – klasy abstrakcyjne

Po kilku artykułach przyszła w końcu pora na bardzo kontrowersyjną strukturę, a mianowicie na klasy abstrakcyjne. Dlaczego kontrowersyjna? Tak jak interfejsy, abstrakcje również dają programiście spore możliwości. Jednak w przeciwieństwie do interfejsów pozwalają one na wymieszanie implementacji oraz deklaracji kontraktu. Jest to powodem dziwnego zachowania u niektórych programistów, a mianowicie całkowicie unikają oni klas abstrakcyjnych. Bo „kompozycja ponad dziedziczenie”, bo dziedziczenie to najmocniejsza ze wszystkich zależność, a kod ma być otwarty na nowe, itd., itp.

Osobiście dawno wyleczyłem się ze stwierdzeń, które składają się m.in. ze słów „zawsze” bądź „nigdy”. Dlatego też nie dołączam do grona programistów, którzy zawsze unikają klas abstrakcyjnych i nigdy z nich nie korzystają. Wychodzę z założenia, że jak z większością rzeczy wykorzystanie abstraktów ma sens wtedy, gdy ma to sens. A kiedy taka sytuacja ma miejsce? O tym już za chwilę, a nim do tego dojdziemy popatrzmy na to, jak klasa abstrakcyjna wygląda z punktu widzenia struktury.

Pokaż kotku co masz w środku…

Klasa abstrakcyjna pozwala nam na wszystko na co pozwalają interfejsy oraz zwykłe klasy. Możemy tworzyć stałe, można definiować atrybuty klasy, można tworzyć metody o wszelkiej widoczności, ale można również te metody jedynie deklarować.

Niemniej jednak jest rzecz, która jest specyficzna jedynie dla klas abstrakcyjnych. Mam na myśli typ widoczności protected. Co prawda PHP umożliwia nam stworzenie zarówno atrybutów jak i metod chronionych również w normalnych klasach, jednak dopiero wykorzystanie go (typu widoczności) w klasach abstrakcyjnych odkrywa przed nami ich prawdziwą potęgę i zasadność.

Powody jakie stoją za umieszczaniem w klasach abstrakcyjnych publicznych czy też prywatnych metod są takie same jak dla normalnej klasy. Analogicznie rzecz ma się do metod publicznych abstrakcyjnych z tym, że w tym wypadku powody są takie jak dla interfejsu. Tutaj na marginesie namawiam do dowiedzenia się dlaczego istnienie metod publicznych w klasie abstrakcyjnej wskazuje na istnienie interfejsu.

My spójrzmy zatem na to, czego jeszcze nie mieliśmy okazji omówić, a więc na powody użycia metod i atrybutów chronionych.

Enkapsulacja vs. protected

Są trzy sposoby wykorzystania protected: atrybut chroniony, metoda chroniona albo abstrakcyjna metoda chroniona. Ich wspólną cechą jest fakt, że są dostępne zarówno dla klasy abstrakcyjnej oraz dla wszystkich klas, które ją rozszerzają. Nie są jednak dostępne na zewnątrz.
Omówmy je po kolei:

  • Atrybuty chronione – są to wszystkie te atrybuty, które uważamy, że będą potrzebne wszystkim klasom rozszerzających naszą abstrakcję. I choć jest możliwe tworzenie atrybutów chronionych to osobiście jestem zwolennikiem pary: prywatny atrybut + chroniony getter, bo daje to programiście większą kontrolę nad danym atrybutem oraz jest jakąś namiastką enkapsulacji:
  • Metody chronione – są pewnego rodzaju metodami pomocniczymi tzn. zakładamy (bądź wiemy), że do przeprowadzenia pewnych operacji wszystkie klasy będą potrzebowały wykonać konkretne instrukcje. Aby nie powielać kodu w każdej z tych klas możemy zdecydować się na zamknięcie tych instrukcji w metodzie chronionej:
  • Abstrakcyjne metody chronione – deklarują pewien krok większej całości, który musi zostać zaimplementowany przez konkretną klasę rozszerzającą. Jednym z przykładów zastosowania takiej konstrukcji jest wzorzec Template Method:

Abstrakcja, czyli rzecz o implementacji

Jak już zapewne zdążyliście zauważyć po przeczytaniu wcześniejszych paragrafów, klasy abstrakcyjne są ściśle związane z jakąś konkretną implementacją, z jakimś konkretnie wybranym rozwiązaniem. W końcu są niczym więcej jak wyniesieniem wspólnego dla grupy klas kodu do jednego miejsca. Co prawda, nie są one pełnym rozwiązaniem (metody abstrakcyjne), niemniej jednak zawsze posiadają pewną ilość kodu, która bezpośrednio wpływa na sposób rozwiązania problemu w całości w klasie dziedziczącej.

Można wyróżnić trzy powtarzające się powody tworzenia klas abstrakcyjnych:

  • Baza + Interfejs – czasami posiadamy interfejs, który dostarcza szeregu metod, ale implementacja części z nich jest na tyle oczywista, że wraz z interfejsem dostarczamy również bazową klasę abstrakcyjną, która zawiera kod odpowiedzialny za tą część funkcjonalności:
  • Klasa dostarczająca wspólną funkcjonalność – jest to klasa, która jest zbiorem metod wykorzystywanych przez klasy rozszerzającej do przeprowadzenia stosownych operacji. Dobrym przykładem jest klasa AbstractRepository z poprzedniego paragrafu, gdzie opisywałem zasadność tworzenia metod chronionych.
  • Podstawa algorytmu – jest to dokładnie to, o czym pisałem wyżej, gdy omawialiśmy wykorzystanie abstrakcyjnych metod chronionych. Klasa abstrakcyjna w tym wypadku deklaruje kroki, które należy przeprowadzić, ale ze względu na to, że implementacja niektórych/wszystkich jest zależna od warunków zewnętrznych oraz naszych oczekiwań, kroki te deklarujemy w postaci metod wymaganych do zaimplementowania. Takie podejście daje nam możliwość wyboru różnych rozwiązań.
    Analogicznie jak w poprzednim punkcie, przykład ze wcześniejszego paragrafu (klasa ArraysMerger) doskonale obrazuje ten typ.

Co za dużo to niezdrowo

Jeszcze ostatnia rzecz warta zapamiętania nim przejdziemy do przykładu. Zdarzało mi się natknąć na klasy abstrakcyjne, które łączyły grupy klas, które były logicznie ze sobą powiązane i zawierały w sobie szereg metod wykorzystywanych przez wszystkie klasy (czyli tak jak być powinno) oraz takie metody, które były wykorzystywane jedynie przez część klas dziedziczących.

Ważna wskazówka. Jeżeli już koniecznie chcecie opierać swoją funkcjonalność na dziedziczeniu to pamiętajcie, że klasa abstrakcyjna powinna zawierać tylko część wspólną dla wszystkich klas ją rozszerzających. Jeżeli metoda pojawia się w części klas to nie wynoście jej do klasy bazowej, ponieważ nie jest wykorzystywana przez całą pulę klas. Jednym z rozwiązań może być stworzenie kolejnej klasy abstrakcyjnej pomiędzy rodzicem, a klasami potomnymi, ale drzewo dziedziczenia samo w sobie może urosnąć do poważnego problemu tak więc decydujcie się na takie rozwiązanie w ostateczności.

Może trochę kodu?

Wróćmy do naszego przykładu z firmą transportową. Wiemy, że nasza aplikacji musi posiadać możliwość wystawiania faktur. Za co nasi klienci mogą nam płacić? Za transport lądem, morzem i w powietrzu, może również być transport, który będzie połączeniem wcześniejszych. Nasz klient, podczas dalszych rozmów, poinformował nas, że czasami kontrahenci przechowują swój towar w jego magazynach, za co również płacą. Do tego dochodzą również inne koszty np. konieczność przewożenia towaru w określonych warunkach.

Mamy więc kilka możliwych przedmiotów transakcji, kilka możliwych ich typów, czyli: Przewóz, Magazynowanie, Inne. Każde z nich musi posiadać nazwę, cenę jednostkową, jednostkę miary oraz cenę całkowitą. Poza ceną całkowitą, wartości pozostałych atrybutów powinny być ustawiane przy tworzeniu każdej instancji odpowiedniej klasy. Jedyną rzeczą, która się zmienia to tak naprawdę sposób obliczania ceny całkowitej.

Z powyższego można łatwo wywnioskować, że każda klasa konkretnego typu powinna posiadać dwie metody:

  • Konstruktor, który przyjmuje wartości dla atrybutów: nazwa, cena jednostkowa oraz jednostka miary.
  • Metodę, która zwróci nam niezbędne dane.

O ile logika konstruktora będzie taka sama w każdej klasie, to metoda zwracająca dane musi również w jakiś sposób określić cenę dla danego przedmiotu transakcji.

To na jakie rozwiązanie tego problemu się zdecydować? Wiemy, że każdy przedmiot musi być możliwy do dodania do faktury. Dane dotyczące faktury, a co za tym idzie również przedmiotów transakcji, muszą zostać wyświetlone, więc potrzebujemy metody, która będzie miała taką samą definicję w każdej klasie, a co za tym idzie – tworzymy interfejs:

Ok, idziemy dalej. Wiemy, że każdy przedmiot powinien posiadać konstruktor, który przyjmuje określone atrybuty. Powinny być one przypisane do atrybutów klasy. I jest to podobieństwo budowy klas, więc mamy:

Pozostała nam implementacja metody do pobierania danych nt. przedmiotu. Czym różnią się te metody pomiędzy poszczególnymi klasami? Z wymagań wiemy, że jedyną różnicą jest sposób obliczania ceny całkowitej, więc na podstawie tego możemy pokusić się o poniższą implementację metody getInformationItem() w klasie abstrakcyjnej:

I koniec. Teraz kolejne klasy odpowiedzialne za przedmioty sprzedaży muszą jedynie zaimplementować metodę getTotalPrice() i po wszystkim:)

Część wspólna, zarówno budowy jak i dotycząca realizacji logiki została wyniesiona do klasy abstrakcyjnej. Klasy pochodne muszą jedynie dostarczać funkcjonalność odpowiedzialną za obsługę różnic pomiędzy nimi.

Co dalej?

I takim sposobem dotarliśmy do końca kursu. Jestem wdzięczny za wszelkie Wasze komentarze, uwagi, sugestie oraz za ciekawe dyskusje. Jeżeli uważacie, że któryś z tematów warto zgłębić bardziej, to zachęcam do umieszczania sugestii w komentarzach 🙂

Mam nadzieję, że kurs uporządkował Waszą wiedzę, a może nawet czegoś nauczył 🙂

Na koniec pozostaje mi życzyć Wam powodzenia w dalszym zgłębianiu tajników programowania obiektowego, a ja ze swojej strony mogę Wam obiecać, że postaram się Wam w tym od czasu do czasu pomóc.

Pozostałe artykuły z cyklu

Jestem fanatykiem obiektowego programowania i nieustannie pogłębiam swoją wiedzę we wszelkich tematach z nim związanych. Wszystko czego się dowiem konfrontuję z rzeczywistością, ponieważ teoria, która nie ma odzwierciedlenia w praktyce, traci swój sens tam, gdzie zaczyna się praca programisty :)

  • Jacek Jackowiak

    Warto dodać że template metod jest przeważnie kiepskim wyborem gdy potrzebujemy zmodyfikować zachowanie. Znacznie lepiej zastosować strategie która jest dużo prostsza w testowaniu od template method

    • Sebastian Malaca

      To juz zupelnie inna dyskusja i swiadomie z tego zrezygnowalem.
      Ponadto, template method rzeczywiscie popadl ostatnio w nielaske, ale to nie oznacza, ze jest konstrukcja, ktorej nalezy unikac za wszelka cene. Z tym ze, tak jak pisalem, to juz watek na osobna dyskusje 🙂

  • gulgul

    Nie wymieniłeś podstawowej cechy klas abstrakcyjnych – nie można utworzyć ich instancji. W przykładach „abstract class AbstractRepository implements Repository” jest nieścisłość. Zakładam że interfejs Reposiotry posiada metody getDbConnection() oraz
    saveEntity() (bo chyba taka jest jego intencja). Tymczasem te metody są zdefiniowane w klasie abstrakcyjnej jako metody protected.

    • Sebastian Malaca

      Co do braku mozliwosci stworzenia instacji to masz racje 🙂 Dzieki za czujnosc. W najblizszym czasie to poprawie.
      Jezeli chodzi o AbstractRepository to metody maja byc protected. Caly paragraf traktuje o wykorzystaniu tego typu widocznosci.
      getDbConnection() to metoda, ktora powstala po to, aby dbConnection nie bylo protected tylko private (dlaczego? to napisalem w punkcie dotyczacym atrybutow chronionych).
      Zasadnosc saveEntity() jest opisana w punkcie dotyczacym metod chronionych. To jest metoda pomocnicza (wykorzystywana w kazdej klasie, ktore rozszerza abstrakcje)

Send this to a friend

webmastah.weekly
Cotygodniowa porcja linków ze świata WEBDEV BEZ spamu, TYLKO samo mięcho!
Zobacz poprzednie wydania. Dołącz do 2 tysięcy webdeveloperów!
HTML5, CSS3, JS (React, Angular, Ember, Vue), PHP, SQL