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ż 1Tile
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 razyTiles::find(callable $callback) : Tiles
- czy zwrócone Tiles zawierają spodziewane elementyTiles::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
- kolekcjaTile
'ówTile
- 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óweach
- przeiteruj po elementach kolekcjifind
- zwróć ten sam typ kolekcji ograniczony poprzez konkretne kryteriummap
- przekształć jedną kolekcję w drugą o takim samym rozmiarzereduce
- 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.