Enkapsulacja kolekcji – wszystko pod kontrolą

enkapsulacja kolekcji wszystko pod kontrola

Chyba każdy programista na co dzień spotyka się z kolekcjami obiektów. Czym w ogóle jest taka kolekcja? To swego rodzaju zbiór obiektów albo inaczej zgrupowane obiekty. Występować może w różnej formie, ale taka najbardziej podstawowa to zwykła tablica. W moim kodzie zdecydowanie częściej gości jednak ArrayCollection z paczki doctrinowej. Własna implementacja takiego mechanizmu to też prosta sprawa.

Spieszę z wyjaśnieniem, dlaczego nie tablica. Przede wszystkim w tym kontekście jest toporniejsza. Jasne, istnieje dużo więcej wbudowanych funkcji do jej obsługi i w wielu miejscach faktycznie zdaje egzamin – w końcu to jeden z podstawowych typów. Niestety, szczególnie w językach takich jak PHP, do arrayki można wrzucić wszystko, czego tylko dusza zapragnie. Zdecydowanie bardziej do gustu przypada mi interfejs obiektowy do zarządzania zgrupowanymi obiektami. Zresztą chyba nie tylko mi, bo takie kolekcje to już standard.

Zły przykład kolekcji

Miejsce wykorzystania, które od razu się nasuwa to dwie encje połączone relacją jeden-do-wielu. Ten klasyczny przypadek posłuży mi dzisiaj do wyjaśnienia, co mam na myśli. Poznajcie dwóch bohaterów: Meal i Ingredient. Jeden posiłek może mieć wiele składników. Za to jeden składnik może należeć dokładnie do jednego posiłku (nie mam na myśli produktu tylko składnik, czyli chociażby 25g masła).

Poniższy fragment kodu prezentuje stan wyjściowy. Proszę, nie kończcie czytania w tym miejscu, gdyż jest to błędne rozwiązanie. Oczywiście działające i niestety często spotykane, ale generujące kilka problemów o których kolejne akapity.

<?php

declare(strict_types=1);

namespace App\Diet\Domain\Model;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

class Meal
{
    private Collection $ingredients;

    public function __construct()
    {
        $this->ingredients = new ArrayCollection();
    }

    public function getIngredients(): Collection
    {
        return $this->ingredients;
    }

    public function setIngredients(Collection $ingredients): void
    {
        $this->ingredients = $ingredients;
    }
}

Co mi się nie podoba? Jednym z problemów jest uboga funkcjonalność. Tylko dwie metody. Jedna do pobierania kolekcji, druga do jej ustawienia. Jak zatem zmodyfikować aktualnie istniejącą? Oczywiście z wykorzystaniem getIngredients() i już w samym kodzie klienckim użycie metod, które są dostarczane wraz z mechanizmem interfejsu Collection. I to jest jedna z rzeczy, których proponuję unikać. Zwracanie oryginalnej kolekcji pozwala na łatwiejsze popełnienie błędu. Lepsza okaże się enkapsulacja kolekcji.

Druga sprawa to setter. Rzadko kiedy widzę sytuację, gdzie istnieje potrzeba podmiany całej kolekcji. Standardowo jest tworzona pusta i później obsługiwana. Gdyby jednak istniała taka potrzeba, taki setter można zostawić, ale ja standardowo go nie implementuję. Zresztą żadnej z metod, które poniżej prezentuję, nie dorzucam na zaś (YAGNI). Prezentuję jednak te najczęściej przydatne.

Ktoś mógłby w takim razie powiedzieć, że w związku z tym kod z pierwszego boxa jest w porządku, dla sytuacji gdzie istnieje potrzeba tylko pobrania kolekcji i jej podmiany. I w pewnym sensie racja, ale taka implementacja zostawia pole do manipulacji kolekcją. Getter sam w sobie nie jest zły, ale da się go zrobić rozsądniej. Wrócę do tego na sam koniec i pokażę jak zabezpieczyć go przed przypadkowymi błędami.

Kolekcja właściwie enkapsulowana

Teraz zaprezentuję kilka metod osobno, a na końcu całą encję posiłku. Na pierwszy ogień proste i przydatne zachowanie weryfikujące, czy posiłek ma dodany jakikolwiek składnik.

public function hasIngredients(): bool
{
    return !$this->ingredients->isEmpty();
}

Kolejna odpowiada za sprawdzenie, czy konkretny składnik zawiera się w zbiorze obiektów.

public function containsIngredient(Ingredient $ingredient): bool
{
    return $this->ingredients->contains($ingredient);
}

Chyba najbardziej potrzebna to ta poniższa, która pozwala uzupełnić kolekcję o kolejne obiekty. W celu zapobiegnięcia duplikacji elementów, jako że sam mechanizm kolekcji tego nie pilnuje, trzeba wcześniej sprawdzić, czy element nie był już dodany. Akurat w tym przykładzie występuje relacja bidirectional, więc jeszcze obiekt Meal jest ustawiany dla Ingredient.

public function addIngredient(Ingredient $ingredient): void
{
    if (!$this->ingredients->contains($ingredient)) {
        $this->ingredients->add($ingredient);
        $ingredient->setMeal($this);
    }
}

Mechanizm usuwania z kolekcji konkretnego obiektu przypomina powyższy, więc chyba nie trzeba tłumaczyć.

public function removeIngredient(Ingredient $ingredient): void
{
    if ($this->ingredients->contains($ingredient)) {
        $this->ingredients->removeElement($ingredient);
        $ingredient->setMeal(null);
    }
}

Pomocna może okazać się też funkcja do policzenia wszystkich składników danego posiłku.

public function countIngredients(): int
{
    return $this->ingredients->count();
}

Ostatnia, która raz za czas jest mi potrzeba to wyczyszczenie wszystkich składników. Równie dobrze można też stworzyć nowy obiekt ArrayCollection, ale ja wykorzystuję mechanizm czyszczenia, który pod spodem i tak robi to o czym wspomniałem.

public function resetIngredients(): void
{
    $this->ingredients->clear();
}

Teraz na chwilę wrócę do problemu gettera. W momencie, kiedy zwrócona zostaje oryginalna kolekcja bardzo łatwo w niej coś poprzestawiać. Każda operacja zmiany obiektu przeważnie kończy się zapisem, więc bez kontroli może okazać się, że getter potrzebny był tylko do pobrania kolekcji, a umożliwił jej poprzestawianie.

Po co w ogóle jest taki getter? Najczęściej żeby pobrać wszystkie obiekty, przelecieć je pętlą i wykonać jakąś operację lub po prostu pobrać/wyświetlić dane. Pierwsza sytuacja wymaga modyfikowania oryginalnych obiektów natomiast jest to zachowanie encji, więc powinno tak samo zostać zanekapulowane. Druga operacja nie powinna mieć możliwości modyfikacji kolekcji. Jako, że w PHP nie ma czegoś takiego jak niemodyfikowalny zbiór (jak w na przykład w Javie), trzeba ratować się innym mechanizmem.

Metoda do pobrania kolekcji może zwrócić po prostu tablicę. Skoro i tak potrzebna będzie tylko do iterowania po niej. Taki zabieg prezentuje poniższy kod.

public function getIngredients(): array
{
    return $this->ingredients->toArray();
}

Jeżeli istnieje potrzeba doctrinowej kolekcji można stworzyć kopię istniejącej. Wówczas możliwy jest dostęp do interfejsu obiektowego. Opcja ciekawa, ale trochę myląca, gdyż można pomyśleć, że działa się na oryginalnej kolekcji i wykonywać operacje z myślą o późniejszym zapisie rezultatu.

public function getIngredients(): Collection
{
    return new ArrayCollection($this->ingredients->toArray());
}

Poniżej prezentuję pełną klasę Meal. Tak jak jednak wspomniałem, nie ma sensu dla każdej kolekcji implementować pełnego arsenału metod jeżeli nie będą potrzebne.

<?php

declare(strict_types=1);

namespace App\Diet\Domain\Model;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

class Meal
{
    private Collection $ingredients;

    public function __construct()
    {
        $this->ingredients = new ArrayCollection();
    }

    public function getIngredients(): Collection
    {
        return new ArrayCollection($this->ingredients->toArray());
    }

    public function hasIngredients(): bool
    {
        return !$this->ingredients->isEmpty();
    }

    public function containsIngredient(Ingredient $ingredient): bool
    {
        return $this->ingredients->contains($ingredient);
    }

    public function addIngredient(Ingredient $ingredient): void
    {
        if (!$this->containsIngredient($ingredient)) {
            $this->ingredients->add($ingredient);
            $ingredient->setMeal($this);
        }
    }
    
    public function removeIngredient(Ingredient $ingredient): void
    {
        if (!$this->containsIngredient($ingredient)) {
            $this->ingredients->removeElement($ingredient);
            $ingredient->setMeal(null);
        }
    }

    public function countIngredients(): int
    {
        return $this->ingredients->count();
    }

    public function resetIngredients(): void
    {
        $this->ingredients->clear();
    }
}

Czy to wszystko to jakieś niesamowite usprawnienie? Pewnie, że nie. Jak większość zasad, które są w programowaniu. Małymi krokami jednak można nieustannie pisać lepszy kod. Składając te wszystkie zasady do kupy. To o czym piszę to tylko sugerowane przeze mnie rozwiązanie, na które gdzieś kiedyś w literaturze trafiłem i dostosowałem pod PHP i własne potrzeby. Pewnie wielu z Was robi coś na wzór – co mnie cieszy.

Nie wiem tylko czy jasno określiłem problemy, które generuje „klasyczne podejście”. Podobny przypadek to obiekt DateTime. Może ten przykład jest bardziej znany albo czytelny. Klasa User ma pewny atrybut przechowujący datę utworzenia obiektu. Istnieje logika mówiąca, że użytkownik po trzech miesiącach od stworzenia automatycznie przechodzi z triala na płatną wersję. Jeżeli na dacie utworzenia wykonana zostanie metoda modify() w celu porównania, a dalej operacja zakończy się zapisem to okaże się, że zmodyfikowana została oryginalna data. W tym celu również używa się funkcji clone albo zwraca DateTimeImmutable. Tak samo może być z kolekcjami…

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.