niedziela, 5 kwietnia 2020

Alternatywa od tablic

    Jedną z najczęstszych czynności w implementowaniu rozwiązań biznesowych jest iterowanie przez kolejne elementy tablicy za pomocą konstruktu foreach.  Tablice te często są składane tuż przed samym ich wertowaniem przez co metody w których się znajdują są długie, nieczytelne i trudne w utrzymaniu/rozwoju. Rozwiązanie to nie jest zbytnio modułowe bo nawet jeżeli metoda zwraca poskładaną tablice jako jej Type Hint możemy zadeklarować tylko array, co sprawia, że tak naprawdę nie mamy pewność czy w po kilku modyfikacjach metody tworzącej tablicę, wartość zwracana będzie kompatybilna z wcześniej napisanym kodem klienta. 

    Jednym z największy problemów, który dotyka kod oparty o tablicę jest fakt, że nie mogą one zawierać w sobie żadnych zachowań - bo jest to tylko struktura danych. W konsekwencji, cała logika obróbki elementów tablicy (formatowanie poszczególnych wartości pojedynczych elementów czy filtrowanie elementów) spada do odpowiedzialności klasy, która je przetwarza. Sprawia to trudności w tworzeniu uniwersalnych rozwiązań, gotowych do wykorzystania w wielu miejscach. Gdy taki kod nie jest tworzonych z myślą o modułowości i przestrzeganiu zasady DRY, dochodzi do duplikacji (jawnych lub ukrytych), które mogą nieść ze sobą poważne konsekwencje w przyszłości, związane ze zmianą jednego globalnego zachowanie ich dotyczącego.  

    W codziennej pracy, możemy zauważyć ograniczenia klucza tablicy jakie narzuca sama struktura danych zaimplementowana w PHP. Wartość jaką może przyjąć może być jedynie typu String i Integer. Programiści dostosowując się do tej zasady umieszczają tam (jeżeli w ogóle) zwykle ID znajdującej się w wartości klucza encji/modelu bądź np datę w formacie 'Y-m-d'. W tym drugim przypadku implikuje to zwykle powtarzalną pracę, polegającą na tworzeniu wewnątrz pętli instancji DateTime na podstawie obecnie iterowanego elementu. Nie uważam, że jest to uciążliwość najwyższego priorytetu, ale w przypadkach gdy widzę tego typu niewielkie nieudogodnienia, staram się znaleźć i stosować lepsze rozwiązania.

    Programiści PHP często decydują się na stosowanie tablic, ze względu na szybkość wdrożenia rozwiązania pewnego problemu. Wystarczy jedynie poskładać array'a tuż przed jego wykorzystaniem, w metodzie realizującą pewne zachowanie biznesowe. Jak się okazuje, jest to tylko pozorna szybkość bo wraz ze stosowaniem tablic, przemycamy do kodu inne problemy-spowalniacze, które są jedynie odroczone w czasie, ale prędzej cz później będziemy musieli się zmierzyć z ich refaktoryzacją. O ile z tej sytuacji można byłoby jeszcze wyjść obronną ręką i oddelegować implementację do klas wielokrotnego użytku, często w dalszym ciągu podejmowane są złe decyzje. Deweloperzy brną w stosowanie licznych instrukcji warunkowych komplikujących sprawę coraz bardziej, bo uważają, że sprawy zaszły za daleko i naprawą będzie zbyt czasochłonna (opcja pesymistyczna: gdy brak im wiedzy/doświadczenia i nie potrafią inaczej). Wszystko to mogłoby się nigdy nie wydarzyć, gdyby od razu stosowane były dobre wzorce - niekoniecznie idealne - sprawiające, że kod napisany dzisiaj jest przygotowany na to co może nadejść jutro.  

Stosowanie tablic, które utrudnia pracę w projekcie mogę podzielić na dwa typy:

  1. tablice tworzące struktury danych,
  2. tablice w kodzie klienta, przechowujące elementy będące tablicową-strukturą danych (z podpunktu pierwszego) tworzone w celu przetwarzania ich w pętli
W tym wpisie odniosę się do podpunktu drugiego.

Oddelegowanie array'a do oddzielnej klasy


use App\Model\Product;

final class Products 
{
    /** @var array|Product[] */ private $list = [];
}

klasa wielokrotnego użytku, możliwa do zarejestrowania w Kontenerze Zależności, 
 komentarz @docBlock wskazujący IDE i inny programistą jakiego typu obiekty przechowywane są w tablicy,
✔ kod kliencki może Type Hint'ować na tą klasę, dzięki czemu mamy pewność, że operujemy w nim na pożądanej strukturze,
✔ testowalny,


Metoda dodająca element do tablicy


use OverflowException;
use App\Model\Product;

[...]

public function add(Product $product): void 
{
    if ($this->has($product)) {
        throw new OverflowException;
    }
    $this->list[$product->id()] = $product;
}

private function has(Product $product): bool
{
    return array_key_exists($product->id(), $this->list);
}

✔ daje pewność, że obiekty znajdujące się w tablicy będą tylko jednego typu,
✔ nie pozwala na dodanie takiego samego elementu do tablic drugi raz, a więc zawsze będziemy mogli operować na walidowalnej strukturze,

Umożliwienie klientom iterowania instancji w pętli foreach 


    Aby możliwe byłby iterowanie się w pętli foreach po obiekcie w ustalony przez nas sposób, klasa musi implementować wewnętrzny interfejs silnika - Traversable.  Nie można go jednak implementować bezpośrednio do klasy, musimy zatem skorzystać z jednego z dwóch interfejsów zapewnionych przez PHP:

  1. Iterator,
  2. IteratorAggregate,

           ╔═══════════════════╗
           ║      Iterable     ║
           ╚═════════╤═════════╝
          ┌──────────┴───────────┐
╔═════════╧═════════╗  ╔═════════╧═════════╗
║       Array       ║  ║    Traversable    ║
╚═══════════════════╝  ╚═════════╤═════════╝
                      ┌──────────┴───────────┐
            ╔═════════╧═════════╗  ╔═════════╧═════════╗
            ║      Iterator     ║  ║ IteratorAggregate ║
            ╚═══════════════════╝  ╚═══════════════════╝


W przypadku klasy Products wykorzystam IteratorAggregate by sam proces iteracji był odseparowanym od operacji uzupełniania listy.


use IteratorAggregate;
use Iterator;
use ArrayIterator;
use EmptyIterator;

final class Products implements IteratorAggregate
{
    [...]

    /**
     * @inheritDoc
     * @return Iterator<int, Product>
     */
    public function getIterator(): Iterator
    {
        return $this->list ? new ArrayIterator($this->list)
                           : new EmptyIterator;
    }
}


Schemat wykorzystanych klas:


                ╔═══════════════════╗
                ║   <<Interface>>   ║
                ╟───────────────────╢
                ║      Iterator     ║
                ╚═════════╤═════════╝
                          │
╔══════════════════════╗  │
║Biblioteka Standardowa║  │
╠══════════════════════╩══╪═══════════════════════╗
║           ┌─────────────┴────────────┐          ║
║           │                          │          ║
║ ╔═════════╧═════════╗                │          ║
║ ║   <<Interface>>   ║                │          ║
║ ╟───────────────────╢                │          ║
║ ║ SeekableIterator  ║                │          ║
║ ╚═════════╤═════════╝                │          ║
║           │                          │          ║
║ ╔═════════╧═════════╗     ╔══════════╧════════╗ ║
║ ║   ArrayIterator   ║     ║   EmptyIterator   ║ ║
║ ╚═══════════════════╝     ╚═══════════════════╝ ║
║                                                 ║
╚═════════════════════════════════════════════════╝

    
    Kiedy lista posiada jakieś elementy, zwracana jest instancja SPL'owej klasy ArrayIterator. Co prawda klasa Biblioteki Standardowej posiada inne zachowania poza metodami iteracyjnymi jak sortowania czy serializacja, ale metoda (return Type HintgetIterator(): Iterator zapewnia nam płaszcz ochronny dzięki któremu klasy klienckie będą wiedzieć tylko o tym, że korzystają z podstawowego iteratora.  Jeżeli lista jest pusta zwrócony jest obiekt SPL'owej klasy EmptyIterator będący implementacją wzorca projektowego Null Object.

    To w zasadzie tylko jedyne zachowanie, które wychodzi na zewnątrz i udostępnia dane klasie klienta. Nie dodaję tutaj żadnych innych metod w rodzaju getById(int $id): Product ponieważ nie takie są intencje tej klasy. Jeżeli naprawdę potrzebujesz jakiegoś konkretnego elementu z listy, próba pozyskiwania go w taki sposób jest nadużyciem - w tym przypadku powinno się przemyśleć inne rozwiązanie niż na siłę implementować je tutaj. 


Implementacja interfejsu Countable z Biblioteki Standardowej (opcjonalnie)


    Aby zapewnić funkcjonalność tablic PHP'owych i w klasach klienckich używać na instancji klasy Products funkcji count(), musimy zaimplementować SPL'owy interfejs Countable z jedną metodą count(): int.


use IteratorAggregate;
use Countable;


final class Products implements IteratorAggregate, Countable
{
    [...]

    /**
     * @inheritDoc
     */
    public function count(): int
    {
        return count($this->list);
    }
}

    W tym przypadku logika jest bardzo prosta, być może w innych wypadku obliczenia będą naprawdę skomplikowane (listy generowane w locie). Jeżeli będziemy tworzyli bardziej wyspecjalizowaną klasę np. ActiveProducts będącą implementacją wzorca Dekorator,  będzie miała ona dostęp do wszystkich elementów listy (aktywnych/nieaktywnych), wystarczy jedynie zapewnić nową implementacji metody count(): int

 kod klienta opiera się na natywnej funkcji PHP,
 przeniesienie logiki obliczania długości z klienta do klasy listy, 



Modyfikacja zachowań poprzez użyciu klas dekorujących


Obiekt DateTime jako klucz iterowanego elementu.


Schemat klasy IteratorIterator (wzorzec Dekorator)


    ╔═════════════╗
    ║<<Interface>>║
    ╟─────────────╢
    ║  Iterator   ║
    ╚══════╤══════╝

           │            
╔══════════╧═══════════╗
║Biblioteka Standardowa║
╠══════════╤═══════════╣
║          │           ║
║          │           ║
║ ╔════════╧════════╗  ║
║ ║  <<Interface>>  ║  ║
║ ╟─────────────────╢  ║
║ ║  OuterIterator  ║  ║
║ ╚════════╤════════╝  ║
║          │           ║
║ ╔════════╧════════╗  ║
║ ║IteratorIterator ║  ║
║ ╚═════════════════╝  ║
║                      ║
╚══════════════════════╝


use IteratorIterator;
use DateTime;
use App\Products;

/**
 * @interface Iterator<DateTime, Product>
 */
final class DateTimeAsKeyDecorator extends IteratorIterator
{
    public function __construct(Products $products)
    {
        parent::__construct($products);
    }

    public function key(): DateTime 
    {
        /** @var Product */
        $productparent::current();

        return new DateTime(
            $product->rawStringCreationDate()
        );
    }
}

    Zdecydowałem się nadpisać konstruktor klasy by przyjmował ona jako parametr tylko obiekty klasy Products, by mieć pewność że przekazywana instancja Traversable posiada tylko obiekty Product jako iterowane elementy. 

Kod klienta (hipotetyczna akcji kontrolera we framework'u Symfony):

public function someAction(Products $products): Response
{
    /**
     * @var DateTime $date - product creation date time
     * @var Product $pro
     */
    foreach (new DateTimeAsKeyDecorator($products) as $date=>$pro) {
        // implementacja
    }
}


Filtrowanie tylko aktywnych produktów


Schemat klasy FilterIterator (wzorzec Dekorator)


    ╔═════════════╗
    ║<<Interface>>║
    ╟─────────────╢
    ║  Iterator   ║
    ╚══════╤══════╝
           │            
╔══════════╧═══════════╗
║Biblioteka Standardowa║
╠══════════╤═══════════╣
║          │           ║
║          │           ║
║ ╔════════╧════════╗  ║
║ ║  <<Interface>>  ║  ║
║ ╟─────────────────╢  ║
║ ║  OuterIterator  ║  ║
║ ╚════════╤════════╝  ║
║ ╔════════╧════════╗  ║
║ ║ IteratorIterator║  ║
║ ╚════════╤════════╝  ║
║ ╔════════╧════════╗  ║
║ ║  <<Abstract>>   ║  ║
║ ╟─────────────────╢  ║
║ ║  FilterIterator ║  ║
║ ╚═════════════════╝  ║
╚══════════════════════╝


use FilterIterator;
use App\Products;

/**
 * @interface Iterator<int, Product>
 */
final class ActiveProductsDecorator extends FilterIterator 
{
    public function __construct(Products $products)
    {
        parent::__construct($products);
    }

    public function accept(): bool 
    {
        /** @var Product */
        $product = parent::current();

        return $product->isActive();
    }
}




Brak komentarzy:

Prześlij komentarz