Memento (Pamiątka)

Memento UML

Opis

Memento (Pamiątka) należy do grupy wzorców behawioralnych. Pozwala tworzyć zrzut stanu (snapshot) danego obiektu. Co ważne, dzieje się to bez ujawniania szczegółów implementacji. Wzorzec projektowy Memento może być implementowany na różne sposoby. Najprostsza wersja zakłada istnienie dwóch obiektów: pierwszy, którego stan ma być zapamiętany (originator) oraz drugi, czyli snapshot (memento).

Problem i rozwiązanie

Istnieją sytuacje, w których potrzebny jest dostęp nie tylko do aktualnego stanu obiektu, ale również do poprzednich. Tworząc taką historię zmian, trzeba byłoby za każdym razem odczytywać ten stan za pomocą getterów. Nie ma więc tutaj mowy o właściwej hermetyzacji.

Istnieje też mechanizm klonowania, ale on nie rozwiązuje tego problemu. Faktycznie, łatwiej będzie zrzucić stan obiektu przed jego zmianą, ale jego odczytanie nadal będzie miało miejsce przez masę getterów. Dodatkowo dla skomplikowanych obiektów, klonowanie jest uciążliwe z uwagi na to, że obiekty przechowywane są za pomocą referencji.

Ile razy tworzyliście gettery tylko na potrzeby testów albo persystencji? Ta sama sytuacja ma miejsce w tym przypadku. Jako rozwiązanie przychodzi tutaj wzorzec Memento. Obiekt sam potrafi zrzucić swój stan, ale nie wystawia nic na zewnątrz. Można spotkać implementację, w której sam obiekt snapshot również nie powinien ujawniać swojego stanu. Zależy od zastosowania. I tak lepiej mieć dowiązanie do snapshota, niż realnego obiektu, który chcemy chronić. Taki snapshot staje się bardziej używalny, kiedy można z niego czytać.

Plusy i minusy

Zacznę od minusów, których nie ma dużo. Po pierwsze, wzorzec Pamiątka może być skomplikowany, wtedy gdy istnieje potrzeba przechowywania wielu wcześniejszych stanów i trzeba nimi zarządzać. W podstawowej wersji, gdzie istnieje tylko obiekt i jego pamiątka, wydaje się stosunkowo prosty w implementacji.

Jeśli wzorzec zostanie użyty jako konwencja w całej aplikacji, na przykład do testowania lub do persystencji, istnieje ryzykowo powstania wielu dodatkowych klas reprezentujących snapshoty. Implementacja Memento dla jednego przypadku, nie powoduje tego problemu.

Wzorzec projektowy Snapshot rozluźnia warstwy. Gwarantuje enkapsulację na najwyższym poziomie. Odczytywanie stanu obiektu odbywa się przy użyciu jednorazowego zrzutu. Nie trzeba więc obarczać getterami i setterami obiektów, które powinny być sterowane zachowaniami. Tego rodzaju elastyczność daje dużo możliwości.

Przykładowa implementacja w PHP

Wzorzec Memento w PHP nie jest zbyt popularny z uwagi na to, że jest to język interpretowalny. W najbardziej podstawowej wersji może jednak okazać się bardzo użyteczny. W jaki sposób? Obiekty domenowe powinny być jak najbardziej czyste i muszą posiadać odpowiednią enkapsulację.

Aby utrwalić ich stan w bazie danych trzeba go odczytać. Mapowanie obiektów dziedzinowych bezpośrednio na obiekty persystentne najczęśćiej wymusza pewne kompromisy i powoduje, że trzeba dostosować się do narzutu ORM. Inną opcją jest posiadanie osobnych obiektów domenowych i persystentnych. Tylko, że gdzieś trzeba będzie przetransformować jedne w drugie i na odwrót. W tym celu potrzebne będą gettery dla wszystkich pól. Nie jest to najlepsze rozwiązanie, ale pewnie do przeżycia. Innym sposobem jest właśnie użycie wzorca Pamiątki. Wówczas obiekty domenowe są czyste. Co więcej, snapshoty można wówczas wykorzystać w innych miejscach, chociażby w testach jednostkowych.

Poniższa implementacja to najprostsza forma. Jeden obiekt domenowy i jeden snapshot. Widać, że klasa Package nie posiada getterów do każdego pola, a jedynie zachowania. Potrafi też zrzucić swój stan do snapshota oraz go odwtorzyć. PackageSnapshot to prosty, niemutowalny obiekt. Nie posiada zachowań.

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Memento;

final readonly class PackageSnapshot
{
    public function __construct(
        public float $width,
        public float $height,
        public float $length,
        public float $weight
    ) {}
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Memento;

final class Package
{
    private const float SMALL_WIDTH = 8.00;
    private const float SMALL_HEIGHT = 38.00;
    private const float SMALL_LENGTH = 68.00;
    private const float SMALL_WEIGHT = 25.00;

    public function __construct(
        private float $width,
        private float $height,
        private float $length,
        private float $weight
    ) {}

    public function isSmall(): bool
    {
        return $this->height <= self::SMALL_HEIGHT
            && $this->width <= self::SMALL_WIDTH
            && $this->length <= self::SMALL_LENGTH
            && $this->weight <= self::SMALL_WEIGHT;
    }

    public function increaseWeight(float $weight): void
    {
        $this->weight += $weight;
    }

    public function snapshot(): PackageSnapshot
    {
        return new PackageSnapshot(
            $this->width,
            $this->height,
            $this->length,
            $this->weight
        );
    }

    public static function restore(PackageSnapshot $snapshot): self
    {
        return new self(
            $snapshot->width,
            $snapshot->height,
            $snapshot->length,
            $snapshot->weight
        );
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Memento\Test;

use DesignPatterns\Behavioral\Memento\Package;
use DesignPatterns\Behavioral\Memento\PackageSnapshot;
use PHPUnit\Framework\TestCase;

final class PackageTest extends TestCase
{
    public function testCanIncreaseWeight(): void
    {
        $package = new Package(8.00, 38.00, 68.00, 25.00);

        $package->increaseWeight(25.00);

        self::assertSame(50.00, $package->snapshot()->weight);
    }

    public function testCanMakeSnapshot(): void
    {
        $width = 8.00;
        $height = 38.00;
        $length = 68.00;
        $weight = 25.00;
        $package = new Package($width, $height, $length, $weight);

        $snapshot = $package->snapshot();

        self::assertSame($width, $snapshot->width);
        self::assertSame($height, $snapshot->height);
        self::assertSame($length, $snapshot->length);
        self::assertSame($weight, $snapshot->weight);
    }


    public function testCanRestoreFromSnapshot(): void
    {
        $snapshot = new PackageSnapshot(8.00, 38.00, 68.00, 25.00);

        self::assertInstanceOf(Package::class, Package::restore($snapshot));
    }

    public function testIsSmall(): void
    {
        $package = new Package(8.00, 38.00, 68.00, 25.00);

        self::assertTrue($package->isSmall());
    }

    public function testIsNotSmallWhenWidthIsToBig(): void
    {
        $package = new Package(8.01, 1.00, 1.00, 1.00);

        self::assertFalse($package->isSmall());
    }

    public function testIsNotSmallWhenHeightIsToBig(): void
    {
        $package = new Package(1.00, 38.01, 1.00, 1.00);

        self::assertFalse($package->isSmall());
    }

    public function testIsNotSmallWhenLengthIsToBig(): void
    {
        $package = new Package(1.00, 1.00, 68.01, 1.00);

        self::assertFalse($package->isSmall());
    }

    public function testIsNotSmallWhenWeightIsToBig(): void
    {
        $package = new Package(1.00, 1.00, 1.00, 25.01);

        self::assertFalse($package->isSmall());
    }
}

Memento – podsumowanie

Memento to rozwiązanie, którego wcale nie używa się tak często. Popularniejszą formą implementacji dla potrzeby trzymania całej historii stanów jest event sourcing. Pamiątka operuje jednak tylko w pamięci. Ciekawym rozwiązaniem jest też użycie snapshota własnie do obiektów domenowych, w których stanem obiektu steruje się za pomocą zachowań reprezentujących logikę biznesową. Ostatecznie trzeba jednak jakoś przetestować kod i gdzieś utrwalić dane. To doskonała sytuacja dla tego wzorca, ale niestety może powodować powstawanie wielu dodatkowych klas.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.