Opis
Wzorzec projektowy Singleton należy do grupy wzorców konstrukcyjnych. Jego zadaniem jest dostarczenie dokładnie jednej instancji klasy o globalnym dostępie. Celowo użyłem słowa dostarczenie, gdyż dodatkowo kontroluje on istnienie obiektu. Jeśli istnieje to jest zwracany. Jeśli nie to musi wcześniej zostać utworzony.
Problem i rozwiązanie
Skąd pomysł na dokładnie jedną reprezentację danej klasy? Problem wielu instancji ma miejsce w przypadkach, kiedy to obiekt jest współdzielony i dostęp do niego może płynąć z wielu źródeł, ale już sam jego stan powinien być rzeczywisty i kontrolowany. Dlaczego musi być dostępny globalnie? Każdy komponent może w jakimkolwiek momencie odwołać się do niego. Jako, że najlepiej na przykładach to od razu rzucę dwa najpopularniejsze: baza danych i plik.
Ja posłużę się przykładem klasy odpowiedzialnej za konfigurację w aplikacji. Gdyby obiektów klasy Config
było kilka to łatwo wyobrazić sobie konflikt. W jednym miejscu został zmieniony jego stan, a w drugim istnieje potrzeba odczytu tego stanu. Niestety w dwóch miejscach nie są one tożsame.
Błędem jest myślenie, że klasa Config
to dokładnie jeden plik konfiguracyjny. Nie ma problemu, żeby zaimplementować łączenie kilku plików w jeden. Chodzi o to, że klasa Config
jest swego rodzaju jedynym i globalnym punktem dostępu do konfiguracji aplikacji. To w jaki sposób dostarczana jest konfiguracja to już inna kwestia. Większość popularnych frameworków pozwala na umieszczenie konfiguracji w wielu plikach. Przy okazji poprawia się czytelność.
Plusy i minusy
Zacznę może od kwestii, która jest jednym z dwóch głównych założeń tego wzorca. Dla mnie jest zarówno zaletą jak i wadą – globalny dostęp. Zaletą, dlatego że sytuacje w których wzorzec znajduje swoje zastosowanie zawsze tego wymagają. Spełnia więc swoje założenia. Wadą, bo nad globalnym dostępem trudniej zapanować oraz nie jest łatwo go przetestować i debugować. Nad zmiennymi globalnymi ma natomiast jedną przewagę – nie da się go nadpisać.
Plusy Singletona to pewność dokładnie jednej instancji oraz jej tworzenie dopiero w momencie, gdy zajdzie potrzeba (lazy initialization). Duża korzyść optymalizacyjna, dzięki temu że implementacja wykorzystuje metodę statyczną i kreacja obiektu nie zawsze następuje.
Minusów niestety znajdzie się trochę więcej. Już z samej definicji tego wzorca projektowego można wyczytać złamanie zasady pojedynczej odpowiedzialności (single responsibility). Skoro mowa o zasadach SOLID – Singleton nie jest wzorem do naśladowania. Łamie też regułę otwarte-zamknięte (open-closed). Tak jak wspomniałem, jest ciężki do testowania i debugowania, stąd też oznaczenie klasy jako finalna – o standardowym mockowaniu i tak nie ma mowy. To, o czym rzadziej się wspomina to ukrywanie zależności. Kiedy obiekt korzysta z innych, a wstrzyknięcie zależności nie jest możliwe, staje się on nie tylko ciężki w zarządzaniu, ale też w ocenie jak klasy są ze sobą powiązane.
Przykładowa implementacja w PHP
W każdej technologii implementacja tego wzorca może się nieco różnić. Zazwyczaj to co jest wspólne to prywatny konstruktor i metoda statyczna zwracająca instancję. Po to żeby nie dało się utworzyć obiektu przez operator new
i w dowolnym miejscu można było pobrać instancję.
Jeśli chodzi o Singleton w PHP, trzeba również zadbać o przesłonięcie metod magicznych, które umożliwiłby otrzymanie więcej niż jednego obiektu klasy. Kiedyś były to dwie __clone()
i __wakeup()
. W wersji 7.4 doszła nowa możliwość deserializacji poprzez metodę magiczną __unserialize()
. Od tego momentu trzeba również ją przesłonić. Dodam tylko, że jeśli klasa zawiera implementacje obu metod: __wakeup()
i __unserialize()
to realizowana jest ta występująca później. O powodach wprowadzenia nowych funkcjonalności związanych z serializacją można poczytaj tutaj: https://wiki.php.net/rfc/custom_object_serialization.
Rzucenie wyjątku we wszystkich przypadkach nie pozwoli utworzyć kopii Singletona. Można oznaczyć te metody jako prywatne, ale ja wolę je deklarować w ten sposób.
<?php
declare(strict_types=1);
namespace DesignPatterns\Creational\Singleton;
use Exception;
final class Config
{
private static ?Config $instance = null;
private function __construct()
{
}
public static function getInstance(): Config
{
if (null === self::$instance) {
self::$instance = new Config();
}
return self::$instance;
}
/**
* @throws Exception
*/
public function __clone()
{
throw new Exception('Config is singleton - it cannot be cloned');
}
/**
* @throws Exception
*/
public function __wakeup()
{
throw new Exception('Config is singleton - it cannot be unserialized');
}
/**
* @throws Exception
*/
public function __unserialize(array $data)
{
throw new Exception('Config is singleton - it cannot be unserialized');
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Creational\Singleton\Test;
use DesignPatterns\Creational\Singleton\Config;
use Exception;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
final class ConfigTest extends TestCase
{
public function testIsConstructorPrivate(): void
{
$configReflection = new ReflectionClass(Config::class);
self::assertFalse($configReflection->getConstructor()->isPublic());
}
public function testIfOnlyOneInstanceExists(): void
{
$config = Config::getInstance();
$secondConfig = Config::getInstance();
self::assertSame($config, $secondConfig);
}
public function testCannotCloneConfig(): void
{
$config = Config::getInstance();
self::expectException(Exception::class);
clone $config;
}
public function testCannotDeserializeConfig(): void
{
$config = Config::getInstance();
self::expectException(Exception::class);
unserialize(serialize($config));
}
}
Singleton – podsumowanie
Każdy wzorzec można wykorzystać w zły sposób i rozwiązać jeden problem równocześnie tworząc nowe. Często rzutujące na cały cykl rozwoju aplikacji. Przy Singletonie należy jednak szczególnie uważać, bo globalny dostęp jest sporą pokusą. Według mnie stąd właśnie opinia wśród programistów, że jest on antywzorcem. Tak jak zmienne globalne niosą więcej minusów niż plusów, tak samo źle użyty Singleton.
Mi osobiście poza konceptualnymi przykładami nie zdarza się używać tego wzorca projektowego. Nie dlatego, że uważam go za antywzorzec, ale konkretne problemy w których mógłby się przydać najczęściej zostały już rozwiązane na poziomie frameworków. W dodatku, aktualnie istnieją sensowniejsze rozwiązania.
Fajny artykuł, nie wiedziałem o tym, że dochodzi nowa metoda do serializacji. Dobrze, że piszesz przykładowe użycia wzorców. Świetny jest też pomysł z tym ocenianiem na końcu artykułu.
Bardzo ostrożnie wydajesz opinię o tym wzorcu, częściej spotykałem się z opinią, że wzorzec jest kompletnie nieprzydatny, a przy większej aplikacji jego użycia praktycznie podwaja lub nawet zwielokrotnia ilość testów (wypada testować aplikację z każdym wpływającym na nią stanem singletona) co powoduje, że jego użycie prawie uniemożliwia porządne pokrycie aplikacji testami.
P.s. Zastanawiam się tylko dlaczego w klasie finalnej deklarujesz metody finalne? To nie jest nadmiarowe?
Dziękuję za konkretny feedback. Tak jak wspomniałem – w niektórych przypadkach nie musi być najgorszym rozwiązaniem.
Co do metod finalnych, faktycznie nie ma sensu. Nawet nie zwróciłem na to uwagi, obstawiam że najpierw robiłem metody finalne, później oznaczyłem całą klasę i nawet nie zauważyłem. W każdym razie zmienione już w kodzie – w końcu te metody i tak są final.
Przy okazji odświeżania sobie wiedzy o wzorcach pomyślałem nad przykładami gdzie singleton miałby rację bytu i na myśl przychodzi mi sesja aktywnego użytkownika – koniecznie powinna być jedna, dostępna globalnie (a chyba nawet można zawęzić użycie singletona do jednej warstwy czy grupy klas żeby nadal był singletonem?)
To taka luźna myśl
Ciekawy koncept – być może nawet gdzieś użyty. Gdyby właśnie nakładkę obiektową na sesję robić (a tak raczej się robi, przynajmniej w popularnych frameworkach – ma to sens i jest wygodne) to możliwe, że Singleton by się sprawdził. Pewnie na dłuższe przemyślenie plusów i minusów, ale tak na szybko patrząc – pomysł przynajmniej ciekawy.