Null Object (Pusty obiekt)

null object uml

Opis

Wzorzec projektowy Null Object (Pusty Obiekt) należy do grupy wzorców behawioralnych. Gwarantuje alternatywę dla wartości null obsługując zachowanie domyślne albo jeszcze częściej puste. Taki mechanizm pozwala uniknąć ciągłego sprawdzania, czy obiekt istnieje, a przy okazji jest w pełni spójny z zasadami OOP.

Problem i rozwiązanie

Wartość null jest niesamowicie pomocna, ale może przysporzyć wiele problemów. Po pierwsze trzeba ją obsłużyć. Jeśli obiekt posiada referencję do innego, ale jest ona opcjonalna to łatwo popełnić błąd.

Wszędzie, gdzie pobierany jest obiekt i wykonywane są na nim operacje trzeba sprawdzić, czy nie jest nullem. W przeciwnym razie pojawi się błąd: Call to a member function on null lub ewentualnie: Typed property must not be accessed before initialization. Raczej nic trudnego, chociaż można zapomnieć. Odpowiednie testy jednostkowe pomogą ustrzec się takich wpadek.

Kolejna rzecz to sama obsługa. Sprawdzić, czy nie jest nullem to mniejszy problem, ale za każdym razem powielać logikę, co ma się zadziać w takim przypadku… A Null Object poradzi sobie z tym bardzo dobrze. W przypadku zachowań najczęściej dostarczy pustą metodę (albo jakieś inne domyślne zachowanie), a w przypadku metod lub własności zwracających wartość zwróci domyślną. Naturalnie nie chodzi o to, żeby wszędzie, gdzie jest możliwy null pakować wzorzec projektowy Pusty Obiekt. Jak każdy mechanizm ma on też pewne wady, o czym przeczytacie w dalszej części.

Plusy i minusy

Jak wspomniałem, wzorzec projektowy Null Object redukuje powtarzające się instrukcje warunkowe sprawdzające w wielu miejscach, czy pole nie jest nullem. Tym samym realizuje regułę DRY, ale przede wszystkim gwarantuje większą niezawodność i elastyczność. Wszystkie te zalety oczywiście przy wsparciu takich terminów jak polimorfizm i enkapsulacja.

Pattern ma stosunkowo dużo wad. Warto je znać, bo nie są one widoczne na samym początku, ale mogą pojawić się dopiero przy implementacji kolejnych funkcjonalności. Mając pusty obiekt definiuje się mu pewne puste lub domyślne zachowania. Kłopot jeśli w kilku miejscach powinny one być nieco inne.

W takich miejscach wzorzec się nie sprawdzi, ewentualnie skończy się na łataniu metodą instanceof(), czyli de facto to samo co sprawdzanie (albo jeszcze gorzej), czy zmienna jest nullem. Chociaż jeśli wyjątków są jeden czy dwa to da się jeszcze przeżyć, bo w pozostałych miejscach zalety wzorca nadal będą widoczne.

Mechanizm ten ma pewną wadę charakterystyczną dla wielu wzorców projektowych. Wydaje się, że pasuje wszędzie. I tutaj rzeczywiście tak jest, że prawie każdego takiego nulla w miejscu referencji dałoby się w ten sposób ograć, ale w często nie ma to sensu (zgodnie z regułą KISS). Tym bardziej, że jeden minus wyrasta ponad inne, a mianowicie złożoność klas. Jeśli obiekt dla którego stworzony ma być Null Object jest skomplikowany, czyli ma jako własności instancje innych klas to trzeba będzie je jakoś wypełnić. Pewnie wewnątrz klasy wykonywane są na nich pewne operacje, a to niestety ciągnie za sobą stworzenie kolejnych pustych obiektów (ewentualnie w specyficznych przypadkach nadadzą się reprezentacje oryginalnych klas).

Przykładowa implementacja w PHP

Implementacja jest uproszczona po to by lepiej zrozumieć koncept. Zakłada, że klient może mieć typ, w którym na ten moment określany jest poziom współpracy. Własność ta mogłaby być na przykład potrzebna do wyliczania zniżki do oferty, gdzie 10% zniżki mnożone jest przez wysokość poziomu, czyli standardowa zniżka to 10%, maksymalna 30%.

Przy dodatkowym założeniu, że brak zniżki dla klientów, których typu nie da się określić. Da się to załatwić jednym ifem, ale metoda musiałaby pozwolić żeby argument clientType mógł przyjmować wartość null, a następnie sprawdzić, czy aby nim nie jest. Zamiast tego można ten sam efekt uzyskać dzięki Null Object Pattern. Kiedy do typu klienta dojdą kolejne zachowania to w ten sam sposób rozszerzony zostanie interferjs, a co za tym idzie pusty obiekt.

Zwróćcie uwagę na metodę getType(), bo to ona kontroluje wartość null. W mojej implementacji pustego obiektu akurat jest metoda, która coś zwraca, ale częściej będzie to funkcja realizująca jakieś zadanie, a w przypadku pustego obiektu nie robiąca nic, czyli zawierająca puste ciało.

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\NullObject;

interface ClientTypeInterface
{
    public const LEVEL_0 = 0;
    public const LEVEL_1 = 1;
    public const LEVEL_2 = 2;
    public const LEVEL_3 = 3;

    public function getLevel(): int;
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\NullObject;

final class ClientType implements ClientTypeInterface
{
    public function __construct(
        private int $level
    ) {
        $this->level = in_array($level, [self::LEVEL_0, self::LEVEL_1, self::LEVEL_2, self::LEVEL_3]) ? $level : 1;
    }

    public function getLevel(): int
    {
        return $this->level;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\NullObject;

final class NullClientType implements ClientTypeInterface
{
    public function getLevel(): int
    {
        return self::LEVEL_0;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\NullObject;

final class Client
{
    private ?ClientTypeInterface $type;

    public function setType(?ClientTypeInterface $type): void
    {
        $this->type = $type;
    }

    public function getType(): ClientTypeInterface
    {
        return $this->type ?? new NullClientType();
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\NullObject\Test;

use DesignPatterns\Behavioral\NullObject\Client;
use DesignPatterns\Behavioral\NullObject\ClientType;
use DesignPatterns\Behavioral\NullObject\ClientTypeInterface;
use DesignPatterns\Behavioral\NullObject\NullClientType;
use PHPUnit\Framework\TestCase;

final class ClientTest extends TestCase
{
    public function testCanSetType(): void
    {
        $clientType = new ClientType(2);
        $client = new Client();

        $client->setType($clientType);

        self::assertEquals($clientType, $client->getType());
        self::assertInstanceOf(ClientTypeInterface::class, $client->getType());
    }

    public function testCanGetTypeWhenIsNull(): void
    {
        $client = new Client();

        self::assertInstanceOf(NullClientType::class, $client->getType());
        self::assertInstanceOf(ClientTypeInterface::class, $client->getType());
    }
}

Null object – podsumowanie

Sensowny i w większości przypadków ułatwiający życie wzorzec projektowy. To oczywiste, że dużo prostsze jest sięgnięcie po instrukcję warunkową sprawdzającą czy istnieje referencja. Na dłuższą metę niejednokrotnie okaże się, że zaimplementowany Null Object przyniesie więcej korzyści.

Tak jak wspominałem, nie należy z nim przesadzać. Już chyba w większości wpisów o tym wspominam, a to może dlatego, że korzystanie z wzorców jest bardzo kuszące, ale nie zawsze pomocne. Co jest bardziej czytelne: jeden if, czy zupełnie nowa klasa i interfejs? No właśnie… Tyle, że powielanie tej samej instrukcji warunkowej i jej ciągła obsługa to nic przyjemnego. Jak zawsze – z głową!

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.