Warstwa Abstrakcji - stanowi izolację pomiędzy kodem programisty a zewnętrznymi modułami, pozwala odseparować system od jego zależności przez co czyni go podatnym na zmiany i aktualizacje. Przywiązując system do innej usługi czynimy go słabym.

FP i OOP w PHP post

2018-05-07

Tablice asocjacyjne i nie tylko to chyba jedna z pierwszych struktur danych z którą spotykają się programiści zaczynający przygodę z językiem. Pozwalają one na budowanie dowolnych struktur nie ograniczanych przez żaden schemat, można dokładać elementy, usuwać, zagnieżdżać jedną tablicę w drugiej. Przy okazji programista poznaje wszelkiego rodzaju pętle, instrukcje warunkowe, zmienne oraz stałe i to daje mu w zasadzie wszystko czego potrzebuje żeby napisać swój pierwszy program. Kolejnym krokiem w ewolucji programisty PHP jest podejście obiektowe, wszystko staje się obiektem (nawet kiedy tym obiektem być nie powinno). Niektórzy jednak przesiąknięci złymi nawykami będącymi często efektami samokształcenia próbują usilnie ukryć podejście strukturalne pod maską obiektów. Zamiast tego moim zdaniem lepiej jest zainteresować się programowaniem funkcyjnym, które w połączeniu z obiektowym daje naprawdę dobre efekty i pozwala bardzo skomplikowane problemy przedstawiać za pomocą nieskomplikowanego kodu.

Podejście Strukturalne vs Obiektowe

Załóżmy, że piszemy prostą gre planszową. Plansza jest dwuwymiarowa, skłąda się z pól które identyfikowane są poprzez swoją pozycję na planszy. Przyjmijmy, że początek planszy znajduje się w lewym górnym rogu a pierwsze pole ma pozycję row = 0, column = 0. Przyjmijmy, że celem gry jest odkrycie jak największej ilości pól na planszy w jak najkrótszym czasie. W dalszej części tego wpisu trochę urozmaicimy reguły gry, narazie jednak zacznijmy od czegoś bardzo prostego.

Popatrzmy, jak można by więc zamodelować planszę korzystając z tablic.

$board = [];

$board[] = ['row' => 0, 'column' => 0, 'clicked' => false];
$board[] = ['row' => 0, 'column' => 1, 'clicked' => false];
$board[] = ['row' => 0, 'column' => 2, 'clicked' => false];

Ten kod, jak najbardziej zadziała. Jest nawet względnie prosty, wszystko co dopiero zaczynamy pisać jest względnie proste. Komplikacje przychodzą z czasem.

Podejdźmy do problemu bardziej obiektowo, zacznijmy od zamodelowania podstawowych pojęć jak pozycja czy pole.

<?php

declare (strict_types=1);

namespace Example\Game;

final class Position
{
    private $row;
    private $column;

    public function __construct(int $row, int $column)
    {
        Assertion::greaterOrEqualThan($row, 0);
        Assertion::greaterOrEqualThan($column, 0);

        $this->row = $row;
        $this->column = $column;
    }

    public function is(Position $position) : bool
    {
        return $this->row === $position->row && $this->column === $position->column;       
    }
}

Dla ułatwienia do asercji używam statycznej klasy Assertion, można ją zaimplementowac w oparciu np o http://github.com/beberlei/assert

Pozycja stanie się identyfikatorem Tile'a

<?php

declare (strict_types=1);

namespace Example\Game;

final class Tile
{
    private $position;
    private $clicked;

    public function __construct(Position $position)
    {
        $this->position = $position;
        $this->clicked = false;
    }
}

Tile natomiast staje się elementem planszy


<?php declare (strict_types=1); namespace Example\Game; final class Board { /** * @var Tile[] */ private $tiles; public function __construct(Tile ...$tiles) { Assertion::greaterThan(\count($tiles), 0); $this->tiles = $tiles; } }

Na pierwszy rzut oka widać, że narzut na ilość kodu potrzebnego do napisania w przypadku podejścia obiektowego jest o wiele większy. Czy są tu więc jakieś zalety obiektów? Tak, są i widać je już teraz. Przekazując tak napisane klasy programiście, który 5 minut temu dołączył do zespołu, mówiąc mu "zrób plansze, masz jednak całkowity zakaz zmieniania już napisanego kodu" poradzi sobie z tym bez większego problemu. Wystarczy, że popatrzy na Board, następnie będzie musiał rzucić okiem na Tile co finalnie zaprowadzi go do Position.

Nawet bez otwierania tych plików a jedynie stosując IDE z podpowiadaniem argumentów, będzie w stanie stworzyć board.


$board = new Board( new Tile(new Position(0, 0)), new Tile(new Position(0, 1)), new Tile(new Position(0, 2)) )

Co więcej programista ten, nawet nie znając reguł gry nie będzie w stanie zrobić czegoś takiego:


$board = new Board( new Tile(new Position(-1, -2)) )

Ponieważ Value Object Position dba o to aby nie mógł zostać stworzony z ujemnych wartości. Co powstrzymałoby tego samego programistę przed zrobieniem:

$board = [];

$board[] = ['row' => 1, 'column' => -2, 'clicked' => false];

A no nic, jeżeli jednak reguła "pozycje nie mogą być ujmene" jest istotna biznesowo, to wypadałoby to jakoś sprawdzić? Oczywiście można napisać kod, który to sprawdzi i będą nam do tego potrzebna pętla.


$board = []; $board[] = ['row' => 1, 'column' => -2, 'clicked' => false]; for ($t = 0; $t < \count($board); $t++) { if ($board[$t]['row'] < 0 || $board[$t]['column'] < 0) { throw new \RuntimeException('Board positions can\'t be negative'); } }

Dobrze? A no nie dobrze bo co daje nam w ogóle gwarancję, że ten programista w elementach tablicy board zachowa pierwotnie przyjętą strukturę row, column, clicked? A no nic, trzeba to oczywiście sprawdzić.


$board = []; $board[] = ['row' => 1, 'column' => -2, 'clicked' => false]; for ($t = 0; $t < \count($board); $t++) { if (!\array_key_exists($board, 'column')) { throw new \RuntimeException('Board tile must have column'); } if (!\array_key_exists($board, 'row')) { throw new \RuntimeException('Board tile must have column'); } if (!\array_key_exists($board, 'clicked')) { throw new \RuntimeException('Board tile must have column'); } if ($board[$t]['row'] < 0 || $board[$t]['column'] < 0) { throw new \RuntimeException('Board positions can\'t be negative'); } }

A czy w ogóle możemy być pewni, że gdzieś w trakcie tworzenia boardu programista nie nadpisał przypadkiem zmiennej $board czymś co nie jest tablicą? A no nie, wypadałoby to sprawdzić.


$board = []; $board[] = ['row' => 1, 'column' => -2, 'clicked' => false]; if (\if_array($board) === false && \count($board)) { throw new \RuntimeException('Board must be array with more than one element.'); } for ($t = 0; $t < \count($board); $t++) { if (!\array_key_exists($board, 'column')) { throw new \RuntimeException('Board tile must have column'); } if (!\array_key_exists($board, 'row')) { throw new \RuntimeException('Board tile must have column'); } if (!\array_key_exists($board, 'clicked')) { throw new \RuntimeException('Board tile must have column'); } if ($board[$t]['row'] < 0 || $board[$t]['column'] < 0) { throw new \RuntimeException('Board positions can\'t be negative'); } }

Chyba wszyscy programiści wiedzą, że powielanie stringów, których używamy jako identyfikatorów jest bardzo problematyczne w przypadku kiedy chcielibyśmy zamienić row powiedzmy na x z jakiegoś powodu. Wyciągnijmy więc je do stałych.

<?php

declare (strict_types=1);

const CLICKED = 'clicked';
const ROW = 'row';
const COLUMN = 'column';

$board = [];

$board[] = [ROW => 1, COLUMN => -2, CLICKED => false];

if (\if_array($board) === false && \count($board)) {
    throw new \RuntimeException('Board must be array with more than one element.'); 
}

for ($t = 0; $t < \count($board); $t++) {
    if (!\array_key_exists($board, COLUMN)) {
        throw new \RuntimeException('Board tile must have column');
    }

    if (!\array_key_exists($board, ROW)) {
        throw new \RuntimeException('Board tile must have column');
    }

    if (!\array_key_exists($board, CLICKED)) {
        throw new \RuntimeException('Board tile must have column');
    }

    if ($board[$t][ROW] < 0 || $board[$t][COLUMN] < 0) {
        throw new \RuntimeException('Board positions can\'t be negative');
    }
}

Robi się sieczka, nie?
Patrząc z tej perspektywy na board zapisany za pomocą tablicy lub 3 obiektów widać, że mniej kodu nie zawsze oznacza łatwiej. Oczywiście tutaj rodzi się odwieczny dylemat, czy w ogóle powinniśmy sprawdzać te wszystkie "wymagania"?

Ja osobiście uważam, że jeżeli projekt ma pisać więcej niż jeden programista i ma on trwać więcej niż miesiąc to należy wszystkie te wymagania bezwzględnie sprawdzać.

Dlaczego miesiąc? Tyle średnio pamiętam dlaczego coś napisałem biorąc pod uwagę, że pracuje 5 dni w tygodniu i codziennie tworzę nowy kod. Dla każdego ta wartość będzie inna, jedni pamiętają przez tydzień, inni przez kwartał. Jakikolwiek by to jednak nie był okres czasu, prędzej czy później każdy zapomina dlaczego napisał coś w taki a nie inny sposób.

Bez tych wszystkich sprawdzeń, wracając do projektu po miesiącu mógłbym przeoczyć jakieś założenie, o którym wiedziałem wcześniej i wprowadzić zmianę, która spowoduje regresję. Mając te wszystkie sprawdzenia i testy, które udowadniają, że te sprawdzenia działają o wiele trudniej będzie mi przypadkowo zepsuć to co napisałem wcześniej. Tego o czym mówię nie widać na tym przykładzie, jest on banalnie prosty. Kod który tu napisałem da się ogarnąć w przeciągu minuty nawet po miesiącu. Nie znam jednak żadnego programisty, który zarabiałby na życie pisząc takie proste rzeczy.

Myślę, że do tego momentu większość programistów zgodzi się ze mną, że ta forma strukturalna jest o wiele bardziej skomplikowana i uciążliwa w utrzymaniu niż ta obiektowa. Ta mniejsza złożoność z nawiązką rekompensuje wymóg napisania kilku linii kodu więcej.

Obiekty wspomagane przez funkcje

Przejdźmy więc dalej, nie trudno zauważyć, że w obydwu przypadkach, obiektowym i strukturalnym możliwe jest przekazanie 2 razy tilea o tej samej pozycji. Tak naturalnie nie może być i należy jakoś ten problem rozwiązać. zacznijmy od podejścia obiektowego.

Tutaj bardzo wielu programistów zaczyna łączyć podejście strukturalne z obiektowym i walidację przeprowadzają wewnątrz konstruktora klasy Board wspomagając się tablicami. Wygląda to mniej więcej tak:

<?php

declare (strict_types=1);

namespace Example\Game;

final class Board
{
    /**
     * @var Tiles
     */
    private $tiles;

    public function __construct(Tile ...$tiles)
    {
        $duplicates = [];

        foreach ($tiles as $tile) {
            if (!\array_key_exists((string) $tile->position(), $duplicates)) {
                $duplicates[(string) $tile->position()] = [];
            }

            $duplicates[(string) $tile->position()][] = $tile;
        }

        foreach ($duplicates as $duplicate) {
            if (\count($duplicate) > 1) {
                throw new \RuntimeException('Board can have only tile at each position');
            }
        }

        $this->tiles = $tiles;
    }

}

Teoretycznie nie ma w tym podejściu nic złego, ten kod działa. Wymagał jedynie dołożenia metody Position::__toString(). Da się na to napisać test, działa. Jednak nie wygląda to zbyt elegancko. Mamy te 2 foreache w konstruktorze. Złożoność tego konstruktora bardzo mocno na tym ucierpiała. Czy jest więc jakiś inny sposób?

Można do tematu podejść oczywiście w sposób strukturalny i w ogóle nie pisać klasy Board tylko walidować tą nieszczęsną tablicę, jednak nie mam siły ani nerwów na pisanie tego kodu więc przykładu nie będzie.

Można też zaczerpnąć z podejścia funkcyjnego, które idealnie współgra z obiektowym i pozbyć się tych 2 foreachów za pomocą dodatkowej klasy Tiles na której można wykonać odpowiednie do przeprowadzenia walidacji operacje.


<?php declare (strict_types=1); namespace Example\Game; final class Tiles { /** * @var Tile[] */ private $tiles; public function __construct(Tile ...$tiles) { $this->tiles = $tiles; } public function count() : int { return \count($this->tiles); } public function each(callable $callback) : void { array_map($callback, $this->tiles); } public function find(callable $callback) : Tiles { return new Tiles(...array_filter( $this->tiles, $callback )); } public function hasMoreThan(Position $position, int $expectedLimit) : bool { return $this->find( function(Tile $tile) use ($position) { return $tile->position()->is($position); } )->count() > $expectedLimit; } }

Przeanalizujmy tą klasę krok po kroku. Zacznijmy od konstruktora. W tej klasie nie sprawdzamy czy ilość tilesów jest większa niż 0. Dlaczego? Ponieważ ta klasa reprezentuję tylko kolekcję Tile'ów, kolekcja może równie dobrze być pusta. Kiedy może być pusta? Np. kiedy metoda find() nie zwróci nam tego czego szukamy. Zamiast walidacji w konstruktorze wystawiamy metodę Tiles::count(), która będzie używana przez Board.

Idąc dalej napotykamy metodę Tiles::each(callable $callback): void. Jedynym celem tej metody jest przeiterowanie po wszystkich Tile'sach i wykonanie dla każdego callbacka, który obiekt Tile dostanie jako argument. Callback wygląda tak function(Tile $tile) {}, nie powinien niczego zwracać.

Następnie mamy funkcję Tiles::find(callable $callback) : Tiles. Ta funkcja jest bardzo podobna do each jednak jej celem jest znalezienie w kolekcji elementów pasujących do pewnego wzorca. Jakiego? To definiuje callback, w naszym wypadku będzie to "każdy tile którego pozycja jest równa innej pozycji", czyli:

function(Tile $tile) use ($position) {
    return $tile->position()->is($position);
}

Rezultatem funkcji find jest natomiast obiekt Tiles, zawężony jedynie do tych Tile które nas interesują. To tłumaczy brak walidacji ilości przekazanych Tiles'ów, find może przecież nie zwrócić niczego co pasowałoby do wzorca.

No i na końcu jest jeszcze funkcja pomocnicza Tiles::hasMoreThan(Position $position, int $expectedLimit) : bool, jej zadaniem jest sprawdzenie czy w kolekcji Tiles występuje więcej niż X Tile z określoną pozycjią. Tej funkcji mogłoby równie dobrze nie być, ona wykorzystuje tylko funkcję Find jednak dla czytelności klasy Board (o niej zaraz) uznałem, że nie zaszkodzi ją mieć.

Popatrzmy teraz na Board ale wcześniej przypomnijmy sobie co chcemy osiągnąć.

Wewnątrz Board'u nie może występować więcej niż 1 Tile o tej samej pozycji. Board nie może być pusty, musi mieć co najmniej 1 Tile.

<?php

declare (strict_types=1);

namespace Example\Game;

final class Board
{
    /**
     * @var Tiles
     */
    private $tiles;

    public function __construct(Tiles $tiles)
    {
        Assertion::greaterThan($tiles->count(), 0); 

        $tiles->each(function(Tile $tile) use ($tiles) {
            Assertion::false($tiles->hasMoreThan($tile->position(), 1), 'Board can have only tile at each position');
        });

        $this->tiles = $tiles;
    }

}

Dlaczego to podejście jest moim zdaniem lepsze od tego pierwszego, które pokazywałem? Cała logika została rozbita na drobne elementy, które przede wszystkim można testować jednostkowo w następujący sposób:

  • Tiles::each(callable $callback) : void - czy callback wykonał się spodziewaną ilośc razy
  • Tiles::find(callable $callback) : Tiles - czy zwrócone Tiles zawierają spodziewane elementy
  • Tiles::hasMoreThan(Position $position, int $expectedLimit) : bool - czy Tiles faktycznie posiadają więcej niż X elementów

Do tego oczywiście powinniśmy przetestować czy da się utworzyć Board z dwoma elementami posiadającymi tą samą pozycję, oczywiście spodziewanym rezultatem powinno być tutaj Exception.

Poza łatwiejszym i szybszym testowaniem redukujemy złożoność konstruktora Board, który możecie mi wierzyć w prawdziwym projekcie nie będzie wyglądał tak jak ten z naszego przykładu. Dojdą do niego specyfikacje, dodatkowe parametry gry, pewnie prędzej czy później będzie trzeba dołożyć graczy, czas i inne ciekawe rzeczy. Gdyby każdą podobną walidację przeprowadzać w konstruktorze w sposób z pierwszego przykładu bardzo szybko dojdziemy do momentu, w którym strach będzie ruszyć ten konstruktor żeby czegoś nie popsuć.

Object Immutability

Zanim przejdziemy głębiej do funkcji chciałbym tylko wspomnieć jeszcze o jednym bardzo istotnym określeniu stosowanym zarówno w podejściu funkcyjnym jak i obiektowym. Opisze je na przykładzie programowania obiektowego.

Object immutability - obiekt raz utworzony nie może zostać zmieniony, możemy jedynie wykorzystać obiekt do utworzenia, nowego, zmienionego obiektu. Przykładem w PHP może być \DateTimeImmutable i metoda modify, nazwa metody jest bardzo niefortunna ale dobrze obrazuje o co chodzi. Ta metoda tak naprawdę nie zmienia instancji obiektu \DateTimeImmutable, zamiast tego tworzony jest nowy obiekt.

Spotkałem się z twierdzeniem, że w zasadzie każdy obiekt, który tworzymy powinien być immutable. Czy się z tym zgadzam? Nie do końca, uważam że jest ono zbyt dobitne i nie sprawdzi się w każdym możliwym kontekście aczkolwiek gdyby się do tej reguły stosować na pewno uzyskamy bardzo ładny, czytelny i mało podatny na błędy kod. Warto dążyć do immutability.

W naszej grze również istnieją 2 obiekty, które moim zdaniem powinny być immutable.

  • Tiles - kolekcja Tile'ów
  • Tile - Pojedyncze pole, na jego przykładzie będzie bardzo dobrze widać o co chodzi.

W tym wypadku mamy więc kolekcję elementów immutable, która sama jest immutable. Czy możemy mieć immutable kolekcję, elementów, które nie są immutable? Tak, nie ma reguły, która mówiłaby że nie powinno tak być (albo jej po prostu nie znam). Lepiej jednak aby obydwa byty były immutable.

Ponieważ nasza gra polega na klikaniu w Tile a samo Tile zawiera flagę clicked jak osiągnąć immutability?

<?php

declare (strict_types=1);

namespace Example\Game;

final class Tile
{
    private $position;
    private $clicked;

    public function __construct(Position $position)
    {
        $this->position = $position;
        $this->clicked = false;
    }

    public static function clicked(Position $position) : self
    {
        $tile = new self($position);
        $tile->clicked = true;

        return $tile;
    }

    public function position() : Position
    {
        return $this->position;
    }

    public function click() : self
    {
        if ($this->clicked) {
            throw new \RuntimeException('Tile can\'t be clicked twice');
        }

        return self::clicked($this->position);
    }
}

Tym sposobem robiąc Tile::click() tak naprawdę nie modyfikujemy obiektu Tile, tworzymy całkiem nowy obiekt więc klasa Tile jest immutable.

Spróbujmy teraz połączyć wszystko razem i stworzyć grę w której można kliknąć w daną pozycję na Board'zie.

Map

Naturalnym wydaje się dodanie do boardu metody Board::click(Position $position) : void.

Tutaj taka mała dygresja, bardzo brakuje mi w PHPie (poza generykami oczywiście) określenia, że dana metoda może wyrzucić exception, po to aby przygotować na nie miejsca, które tej metody używają. Uważam, że Board::click(Position $position) : void throws Exception dałoby wiele dobrego, ale to temat na osobny wpis.

Metoda Board::click powinna oczywiście pobrać tile znajdujący się na danej pozycji i spróbować go kliknąć. Jeżeli Tile nie istnieje lub był już klikniety powinno polecieć exception. Czy exception w tym miejscu to dobre wyjście? Można by też wystawić metody sprawdzające czy można kliknąć. Tak, można by ale uważam, że to jest rola interfejsu użytkownika. Po jego stronie powinna znajdować się odpowiedzialność za gromadzenie informacji o już klikniętych polach i wyświetlenia ich. User interface (czy to w HTML i JS'ie czy w CLI) nie powinien dopuścić do kliknięcia jednego pola dwa razy. Jeżeli jednak użytkownik jakimś cudem oszuka UI, Exception jest dokładnie tym na co zasłużył.

Zobaczmy więc jak mógłby wyglądać nasz kod.


<?php declare (strict_types=1); namespace Example\Game; final class Board { /** * @var Tiles */ private $tiles; public function __construct(Tiles $tiles) { Assertion::greaterThan($tiles->count(), 0); $tiles->each(function(Tile $tile) use ($tiles) { Assertion::false($tiles->hasMoreThan($tile->position(), 1), 'Board can have only tile at each position'); }); $this->tiles = $tiles; } public function click(Position $position) : void { $this->tiles = new Tiles(...$this->tiles->map(function(Tile $tile) use ($position) { return $tile->position()->is($position) ? $tile->click() : $tile; })); } }

Proste prawda? Kluczową rolę odgrywa tutaj metoda map. Jej głównym zadaniem jest zmapowanie kolekcji Tiles na nową kolekcję. Nową kolekcję ale nie kolekcję Tiles, po prostu kolekcję. Ta metoda równie dobrze mogłaby być użyta na zmapowanie Tiles na kolekcję pozycji. Istotne w metodzie map jest to, że powinna zwrócić dokładnie tyle samo elementów ile zawierała pierwotna kolekcja.

Dla formalności pokażę tylko samą metodę map w Tiles

<?php

declare (strict_types=1);

namespace Example\Game;

final class Tiles
{
    /**
     * @var Tile[]
     */
    private $tiles;

    public function __construct(Tile ...$tiles)
    {
        $this->tiles = $tiles;
    }

    public function map(callable $callback) : array
    {
        return array_map($callback, $this->tiles);
    }
}

Nie ma tu żadnej filozofii, oczywiście metodę map również możemy bardzo łatwo przetestować sprawdzając czy callback wykonany jest odpowiednią ilość razy i czy zwracana jest kolekcja o takim samym rozmiarze jak ta którą mapujemy.

Dzięki metodzie map udało się nam zachować immutability, jeżeli jednak Tile o określonym Position, nie będzie istniał nie dostaniemy exception. Jak temu zaradzić?

Do kolekcji Tiles możemy dodać metodę pomocniczą has(Position $position) : bool, która zostanie wykorzystana do asercji w metodzie Board::click. Tak bedzie chyba najłatwiej, oto przykład:

<?php

declare (strict_types=1);

namespace Example\Game;

final class Tiles
{
    /**
     * @var Tile[]
     */
    private $tiles;

    public function __construct(Tile ...$tiles)
    {
        $this->tiles = $tiles;
    }

    public function has(Position $position) : bool
    {
        return (bool) $this->find(
                function(Tile $tile) use ($position) {
                    return $tile->position()->is($position);
                }
            )->count();
    }
}
<?php

declare (strict_types=1);

namespace Example\Game;

final class Board
{
    /**
     * @var Tiles
     */
    private $tiles;

    public function __construct(Tiles $tiles)
    {
        Assertion::greaterThan($tiles->count(), 0);
        $tiles->each(function(Tile $tile) use ($tiles) {
            Assertion::false($tiles->hasMoreThan($tile->position(), 1), 'Board can have only tile at each position');
        });

        $this->tiles = $tiles;
    }

    public function click(Position $position) : void
    {
        Assertion::true($this->tiles->has($position));

        $this->tiles = new Tiles(...$this->tiles->map(function(Tile $tile) use ($position) {
            return $tile->position()->is($position) 
                ? $tile->click()
                : $tile;
        }));     
    }
}

Immutability zostało zachowane. Warto tutaj wspomnieć jeszcze o jednym dodatkowym plusie w kontekście ORM'a Doctrine. Gdybyście chcieli stworzyć mapowanie na klasę Board prawdopodobnie musielibyście utworzyć Custom Type dla pola Tiles, które mapowałoby kolekcję do json'a. Doctrine ma taką przypadłość, że jeżeli mapowany obiekt, jest kolekcją obiektów i zmienia się tylko stan któregokolwiek obiektu w kolekcji, ORM nie wyłapie zmiany ponieważ śledzi on tylko zmiany na głównym obiekcie, w naszym wypadku byłby to Tiles. Immutability zmusza nas przy każdej zmianie do stworzenia nowej kolekcji Tiles, przez co dla Doctrine'a zmiana będzie widoczna po każdym wykonaniu metody click.

Reduce

Każdą grę należy jakoś podsumować. Ponieważ w naszym wypadku gra polegać miała na wyklikaniu jak największej liczby pól logicznym jest iż w podsumowaniu powinna znaleźć się ta liczba.

Operację jak nietrudno się domyślić przeprowadzimy na kolekcji Tiles oraz Tile.


<?php declare (strict_types=1); namespace Example\Game; final class Tile { private $clicked; public function isClicked() : bool { return $this->clicked; } }
<?php

declare (strict_types=1);

namespace Example\Game;

final class Tiles
{
    public function clickedCount() : int
    {
        return (int) array_reduce(
            $this->tiles,
            function(int $clicked, Tile $nextTile) {
                return $nextTile->isClicked() ? $clicked + 1 : $clicked;
            },
            0
        );
    }
}

W zasadzie modyfikacja Board'u to już tylko czysta formalność.

<?php

declare (strict_types=1);

namespace Example\Game;

final class Board
{
    /**
     * @var Tiles
     */
    private $tiles;

    public function __construct(Tiles $tiles)
    {
        Assertion::greaterThan($tiles->count(), 0);
        $tiles->each(function(Tile $tile) use ($tiles) {
            Assertion::false($tiles->hasMoreThan($tile->position(), 1), 'Board can have only tile at each position');
        });

        $this->tiles = $tiles;
    }

    public function click(Position $position) : void
    {
        $this->tiles = $this->tiles->map(function(Tile $tile) use ($position) {
            return $tile->position()->is($position)
                ? $tile->click()
                : $tile;
        });
    }

    public function score() : int
    {
        return $this->tiles->clickedCount();
    }
}

Jak już pewnie zauważyliście do stworzenia metody Tiles::clickedCount() : int wykorzystałem funkcje array_reduce. Reduce to kolejne pojęcie znane z FP, jego zadaniem jest najprościej mówiąc zredukownie kolekcji do konkretnej wartości. W uproszczeniu mówiąc, jeżeli trzeba zliczyć cokolwiek na podstawie kolekcji, najłatwiej jest zrobić to za pomocą reduce.

Podsumowanie

To by było na tyle, Ci którzy spodziewali się, że poruszę tutaj jakieś matematyczne aspekty związane z programowaniem funkcyjnym mogą poczuć się nieco zawiedzeni. Zależało mi najbardziej na wyjaśnieniu czym tak naprawdę są takie pojęcia jak:

  • immutability - raz utworzony obiekt nie powinien zmieniać swojego stanu, może jednak służyć do tworzenia nowych obiektów
  • each - przeiteruj po elementach kolekcji
  • find - zwróć ten sam typ kolekcji ograniczony poprzez konkretne kryterium
  • map - przekształć jedną kolekcję w drugą o takim samym rozmiarze
  • reduce - zredukuj kolekcję do konkretnej wartości (zlicz)

na w miarę prawdziwych i życiowych przykładach. Nie wspomniałem wprawdzie czym jest Functor czy Monada, był to celowy zabieg. Uważam, że zanim pozna się te pojęcia należy najpierw zrozumieć dokładnie ich podstawowe building blocki, które nazbyt często przedstawiane są w sposób zbyt skomplikowany. Moim zdaniem warto zrozumieć jakie intencje stały za funkcjami takimi jak map czy reduce, dalsza wiedza przyjdzie sama.

W tym wpisie nie poruszyłem jednej, bardzo istotne funkcji związanej z FP, mianowicie flatMap, planuję jednak poświęcić temu zagadnieniu kolejny wpis, będący uzupełnieniem tego.

Oczywiście wielkim brakiem PHP jest brak Generyków, który pozwoliłby na stworzenie generycznych kolekcji posiadających metody takie jak map, find czy reduce, które by design byłyby immutable lub mutable, jednak nie zapowiada się żeby w najbliższym czasie miały się one pojawić w języku. Nie jest to jednak jakaś wielka strata, napisanie klasy Tiles nie zajęło mi więcej niż 5 minut, IDE i podpowiadanie/generowanie kodu potrafi bardzo wiele zmienić. Dostępnych jest też kilka bibliotek dostarczających w miarę generyczne kolekcję na który można wykonywać opisane przeze mnie operację, nie jestem jednak ich wielkim fanem. Zysk wydaje się mniejszy niż koszt napisania tych kilku metod samemu.

Kod do zabawy dostępny jest w serwisie https://3v4l.org/

Edit 2018-05-09

W komentarzach na Facebooku pojawiła się dyskusja odnośnie wydajności prezentowanych przykładów. Warto więc dodać, że przykłady które opisałem w tym poście nie należą do najbardziej wydajnych, board o wymiarach 100 x 100 staje się ciężki i walidacja unikalności pozycji zajmie o wiele więcej niż powinna.

Zanim jednak w ogóle zaczniemy myśleć o jakiejkolwiek optymalizacji zastanówmy się, czy warto. Po pierwsze ta gra jest czysto abstrakcyjnym bytem, nie posiadającym żadnego celu, wymyśliłem ją na potrzeby tego wpisu i zwizualizowania tych kilku przykładów. Kto normalny chciałby grać w grę, której jedynym celem jest wyklikanie wszystkich pól na planszy? Załóżmy jednak, że ktoś chciałby napisać taką grę, tutaj logiczne wydaje się wprowadzenie limitu czasu. Celem gier jest często rywalizacja, kto szybciej, kto więcej, kto lepiej. W tego typu grze można więc śmiało dodać limit czasu powiedzmy 10 sekund i ustanowienie celu gry "ile pól odkryjesz w czasie X". Board 100 x 100 przy takim limicie czasu nie ma sensu, można śmiało zredukować go do 20x20 co da nam 400 pozycji, których wyklikanie w 10 sekund i tak jest fizycznie niemożliwe a przy takiej ilości elementów nawet niezoptymalizowana wersja tej gry da sobie spokojnie radę. Nawet podnosząc ten czas do 60 sekund, wyklikanie wszystkich pól uwzględniając scrollowanie, pomyłki itp staje się problemem.

Moim zdaniem przedwczesna optymalizacja jest tak samo zła jak nieprawidłowa abstrakcja.

Faktem jednak jest, że nieszczególnie się przejmowałem wydajnością pisząc ten kod, nie używajcie tej gry produkcyjnie!!

No dobra, ktoś jednak może zapytać "czy ten kod da napisać bardziej wydajnie?". Tak, da się i nie jest to jakoś szczególnie skomplikowane. Załóżmy, że celujemy w posiadanie tego boardu 100 x 100. Najcięższą operacją jest sprawdzanie unikalności pozycji w boardzie.

Dodajmy metodę __toString do klasy Position


<?php declare (strict_types=1); final class Position { private $row; private $column; public function __construct(int $row, int $column) { $this->row = $row; $this->column = $column; } public function __toString() : string { return \sprintf('%d.%d', $this->row, $this->column); } }

Następnie do klasy Board dorzućmy coś takiego.


final class Board { /** * @var Tiles */ private $tiles; public function __construct(Tiles $tiles) { if (\count(\array_unique($tiles->map(function(Tile $tile) { return (string) $tile->position(); }))) !== $tiles->count()) { throw new \RuntimeException('Board can have only tile at each position'); } $this->tiles = $tiles; } }

Co robi ten kod? Mapuje wszystkie elementy Tile kolekcji Tiles do tekstowej reprezentacji każdej pozycji. Da to mniej więcej taki wynik:

[
    0 => '0.0',
    1 => '0.1',
    2 => '0.2',
    ...
    100 => '1.0',
    101 => '1.1',
    ...
]

następnie na tej prostej tablicy wykonujemy array_unique, co zwróci nam tablicę zawierającą tylko unikalne elementy. Jeżeli jakaś pozycja się powtórzy, tablica będzie zawierać ją tylko raz. Finalnie porównujemy rozmiary tablicy i kolekcji, jeżeli nie ma duplikatów będą równe, jeżeli są duplikaty będą różne.

W ten prosty sposób zoptyamlizowaliśmy walidację unikalności planszy i pozwoliliśmy tworzyć i klikać board zawierający 10000 elementów.


Masz pytanie?
Napisz: [email protected]
Akceptuję

Ten serwis używa plików cookies. Więcej o plikach cookies.