fb pixel

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:
abstract class AbstractRepository implements Repository {
    private $dbConnection;
    protected function getDbConnection() {
        return $this->dbConnection;
    }
    // additional code
}
  • 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:
abstract class AbstractRepository implements Repository {
    protected function saveEntity(ColumnValueMap $values, $tableName) {
        //try to insert new record into table
    }
    // additional code
}
  • 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:
abstract class ArraysMerger {
    public function mergeAsc(array $array1, array $array2) {
        $sortedArray1 = $this->sortAsc($array1); 
        $sortedArray2 = $this->sortAsc($array2);
        //merging sorter arrays
    }
    abstract protected function sortAsc(array $arrayToSort);
    // additional code
}

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:
interface Observable {
    public function add(Observer $observer); 
    public function remove(Observer $observer);
    public function notifyAll();
}

abstract class BaseObservable implements Observable {
    private $observers = array();
    public function add(Observer $observer) {
        //adding particular observer
    }
    public function remove(Observer $observer) {
        //removing particular observer
    }
}
  • 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:

interface InvoiceItem
{
    /**
     * @return ItemInformation
     */
    public function getItemInformation();
}

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:

abstract class ItemAbstract implements ItemInformation
{
    private $name;
    private $price;
    private $unit;

    public function __construct($name, $price, $unit);
    {
        $this->name = $name;
        $this->price = $price;
        $this->unit = $unit;
    }
}

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:

public function getItemInformation()
{
    $itemInformation = new ItemInformation();
    $itemInformation->name = $this->_name;
    $itemInformation->price = $this->_price;
    $itemInformation->unit = $this->_unit;
    $itemInformation->totalPrice= $this->getTotalPrice($this->_price, $this->_unit);

    return $itemInformation;
}

abstract protected function getTotalPrice($price, $unit);

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

Dodaj na LinkedIn
Sebastian Malaca
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 :)