środa, 23 maja 2012

Jak programować obiektowo cz. 9 - klasy abstrakcyjne

klasa abstrakcyjna

Czym jest klasa abstrakcyjna? Nie będę się rozpisywał na temat konstrukcji (jak zwykle:), ponieważ w internecie można znaleźć sporo definicji.
We wstępie chciałbym się natomiast skupić na podziale klas abstrakcyjnych. Co prawda, z tego co się orientuję, żadnego oficjalnego podziału nie ma, ale po jakimś czasie programowania można wyróżnić trzy powtarzające się wzorce (nie w znaczeniu wzorców projektowych) klas abstrakcyjnych:
  • Klasa będąca wynikiem zastosowania interfejsu.
  • Klasa dostarczająca wspólną funkcjonalność.
  • Klasa będąca podstawą algorytmu.

klasa stworzona na podstawie interfejsu

Nie chodzi mi w tym wypadku o klasę, która powstaje, ponieważ instnieje część wspólna w realizacji logiki lub w działaniu, którą warto wynieść wyżej. Mam na myśli coś dużo prostszego. Załóżmy, że mamy interfejs:
<?php
interface ControllerInterface
{
  public function __construct(Request $request, View $view);
  public function setModel(Model $model);
  public function execute();
}
Oczywiście implementacja metody execute() odpowiedzialnej za całą logikę może być dowolna, ale na pierwszy rzut oka widać, że częścią wspólną (nie logiki, ale budowy) klas wynikowych będą trzy atrybuty: Request, View i Model oraz, że w konstruktorze i metodzie setModel() musi być przypisanie przekazanych parametrów do odpowiednich atrybutów, czyli:
<?php
abstract class ControllerAbstract implements ControllerInterface
{
  /**
   * @param Request
   */
  private $_request;

  /**
   * @param View
   */
  private $_view;

  /**
   * @param Model
   */
  private $_model;

  public function __construct(Request $request, View $view);
  {
    $this->_request = request;
    $this->_view = $view;
  }

  public function setModel(Model $model)
  {
    $this->_model = $model;
  }

  protected function _getRequest() {return $this->_request;}
  protected function _getModel() {return $this->_model;}
  protected function _getView() {return $this->_view;}
}
Jak widać, decyzja o utworzeniu klasy abstrakcyjne nie wynika z faktu, że realizowanie logiki odbywa się w pewien podobny sposób. Nie, realizacja może nie mieć części wspólnej jednak, aby uniknąć zbędnego kopiowania kodu, podobieństwa w budowie klas, ich struktur, wyciągamy wyżej.

klasa - funkcjonalność

Załóżmy, że mamy kilka klas odpowiedzialnych za widok. Nie mają wspólnego interfejsu, ponieważ są wykorzystywane w innych sytuacjach. Jednak pomimo braku tego interfesju wiemy, że są w pewien sposób logicznie powiązane (wszystkie są widokami). Po jakimś czasie zauważamy (czy przy pisaniu kodu czy jeszcze w trakcie projektowania), że w połowie z nich wykorzystujemy mechanizmy tłumaczące. Aby uniknąć duplikacji oraz aby uwspólnić tą część aplikacji (a co za tym idzie ułatwić wprowadzanie ewentualnych zmian) tworzymy klasę abstrakcyjną, do której przenosimy wszystkie metody odpowiedzialne za proces tłumaczenia.

klasa - algorytm

Ostatnim wyróżnionym przeze mnie typem jest klasa abstrakcyjna, która jest odpowiedzialna za pewną logikę i sposób jej realizacji. Realizacja ta jest jednak zależna od kilku czynników, na tylu istotnych/skomplikowanych/wielu, że tworzenie ewentualnych bloków warunkowych byłoby co najmniej nieodpowiednie. Z drugiej strony każdy taki blok warunkowy wymagałby ingerencji w kod źródłowy za każdym razem, gdy dodawalibyśmy kolejne czynniki.

W takim wypadku decydujemy się na wyciągnięcie części wspólnej dla tej logiki (niezależnej od czynników zewnętrznych) wyżej, do klasy abstrakcyjnej. Obsługę warunków rozdzielamy pomiędzy klasy pochodne tzn. tworzymy metody abstrakcyjne, których implementacja w klasach pochodnych będzie obsługiwała poszczególne przypadki.

Dzięki temu w przyszłości, jeżeli wymagania się rozrosną lub odkryjemy kolejny przypadek, dalsza rozbudowa aplikacji będzie prostsza. Ten typ klasy ma nawet swoją nazwę i jest to template method.

to jeszcze nie koniec

Powyższe nie wyczerpują wszystkich możliwych sposobów użycia klas abstrakcyjnych, ale opierając się na doświadczeniu wydaje mi się, że są one najczęściej spotykanymi przypadkami.

Bardzo ważną rzeczą, o której należy pamiętać decydując się na tworzenie klas abstrakcyjnych jest to, aby nie tworzyć abstrakcji dla klas, które nie są ze sobą w żaden logiczny sposób powiązane. Czasami unikanie duplikowania kodów za wszelką cenę nie jest dobrym pomysłem i może przysporzyć więcej problemów niż udogodnień.

może trochę kodu?

Wróćmy do naszego przykładu z firmą transportową. Stworzyliśmy już całkiem przyzwoitą implementację klasy Contractor. 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 koszta np. konieczność przewożenia towaru w określonych warunkach.

Jak widać w powyższym mamy 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ż posiadać informacje o sposobie wyliczania ceny całkowitej.

To na jakie rozwiązanie tego problemu się zdecydować?
Wiemy, że każdy przedmiot musi być możliwy do dodania do faktury (dla uproszczenia zakładam, że tworzenie faktury i sprzedaż są równoznaczne). Po dodaniu, faktura musi jakoś pobrać dane (punkt drugi) z każdego obiektu, więc potrzebujemy metody, która będzie miała taką samą definicję w każdej klasie, a co za tym idzie - tworzymy interfejs:
<?php
interface InvoiceItem
{
  /**
   * @return ItemInformation
   */
  public function getItemInformation();
}
W interfejsie, jako typ zwracany użyłem obiektu ItemInformation. Dlaczego obiekt, a nie tablica z danymi? Ponieważ obiektem łatwiej zarządzać, bez sprawdzania mamy pewność, jakie atrybuty posiada. W przypadku tablicy albo wierzymy "na słowo honoru", że tablica posiada niezbędne klucze albo za każdym razem sprawdzamy czy istnieją.

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:
<?php
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;
  }
}
Świetnie, mamy wyciągniętą część wspólna. 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:
<?php
// abstract class ItemAbstract
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 protecte 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.

offtopic

W powyższych przykładach nie sprawdzałem typów parametrów, co w rzeczywistym kodzie, dla typów prostych należy oczywiście zrobić i w jakiś sposób obsłużyć:)