Stan globalny i singletony
Ostrzeżenie: poniższe konstrukcje są objawami źle zaprojektowanego kodu:
Foo::getInstance()DB::insert(...)Article::setDb($db)ClassName::$varalbostatic::$var
Czy któraś z tych konstrukcji pojawia się w Twoim kodzie? Jeśli tak, masz okazję do poprawy. Możesz sądzić, że to powszechne konstrukcje, widywane choćby w przykładowych rozwiązaniach rozmaitych bibliotek i frameworków. Jeśli tak, ich kod jest wadliwie zaprojektowany.
Nie mówimy tu o jakiejś akademickiej czystości. Wszystkie te konstrukcje mają jedną wspólną cechę: korzystają ze stanu globalnego. A stan globalny fatalnie wpływa na jakość kodu. Klasy zaczynają kłamać o swoich zależnościach. Kod staje się nieprzewidywalny. Dezorientuje programistów i obniża ich efektywność.
W tym rozdziale wyjaśnimy, dlaczego tak jest i jak unikać stanu globalnego.
Globalne powiązania
W idealnym świecie obiekt powinien komunikować się wyłącznie z obiektami, które zostały mu bezpośrednio przekazane. Jeśli utworzę dwa obiekty A i B i nigdy
nie przekażę między nimi referencji, to ani A, ani B nie może sięgnąć po stan drugiego ani go
zmienić. To wysoce pożądana właściwość kodu. To trochę jak z baterią i żarówką; żarówka nie zaświeci, dopóki nie
połączysz jej z baterią przewodem.
Nie dotyczy to jednak zmiennych globalnych (statycznych) ani singletonów. Obiekt A mógłby bezprzewodowo
sięgnąć po obiekt C i zmienić go bez przekazywania jakiejkolwiek referencji, wywołując
C::changeSomething(). Jeśli obiekt B również podłączy się do globalnego C, to
A i B mogą wpływać na siebie przez C.
Używanie zmiennych globalnych wprowadza nową formę bezprzewodowego powiązania, niewidocznego z zewnątrz. Tworzy
zasłonę dymną, przez którą kod staje się trudniejszy do zrozumienia i użycia. Aby naprawdę pojąć zależności,
programiści muszą przeczytać każdy wiersz kodu źródłowego, zamiast polegać po prostu na interfejsach klas. Co więcej, to
powiązanie jest całkowicie zbędne. Stanu globalnego używa się dlatego, że jest łatwo dostępny zewsząd i pozwala na
przykład zapisywać do bazy danych globalną (statyczną) metodą DB::insert(). Jak jednak pokażemy, ta pozorna
wygoda jest znikoma w porównaniu z poważnymi komplikacjami, które wprowadza.
Pod względem zachowania nie ma różnicy między zmienną globalną a statyczną. Są równie szkodliwe.
Upiorne działanie na odległość
„Upiorne działanie na odległość“ – tak Albert Einstein nazwał słynnie zjawisko fizyki kwantowej, które przyprawiało go o dreszcze. Chodzi o splątanie kwantowe, w którym pomiar właściwości jednej cząstki natychmiast wpływa na inną, splątaną z nią cząstkę, niezależnie od dzielącej je odległości, choćby milionów lat świetlnych, co pozornie łamie fundamentalne prawo wszechświata mówiące, że nic nie może poruszać się szybciej od światła.
W świecie oprogramowania „upiorne działanie na odległość“ opisuje sytuację, w której uruchamiamy proces uznawany za odizolowany (bo żadne zależności nie zostały jawnie przekazane), a mimo to w odległych częściach systemu dochodzi, bez naszej wiedzy, do nieoczekiwanych interakcji i zmian stanu. Może to zajść wyłącznie przez stan globalny.
Wyobraź sobie, że dołączasz do zespołu deweloperskiego przy projekcie z dużą, dojrzałą bazą kodu. Nowy szef prosi Cię o zaimplementowanie nowej funkcji, a Ty, jak dobry programista, zaczynasz od napisania testu. Ponieważ jednak jesteś w projekcie nowy, robisz sporo eksploracyjnych testów typu „co się stanie, gdy wywołam tę metodę“. I próbujesz napisać taki test:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // numer Twojej karty
$cc->charge(100);
}
Uruchamiasz kod, może kilka razy, a po chwili zauważasz w telefonie powiadomienia z banku: przy każdym uruchomieniu z Twojej karty kredytowej pobrano 100 dolarów! 🤦♂️
Jakim cudem test mógł spowodować rzeczywistą płatność? Operowanie kartą kredytową nie jest proste. Trzeba porozumieć się z zewnętrzną usługą webową, znać jej URL, uwierzytelnić się itd. Żadnej z tych informacji nie ma w teście. Co gorsza, nie wiesz, gdzie te informacje się znajdują, przez co nie da się zamockować zewnętrznych zależności, aby zapobiec obciążeniu 100 dolarami przy każdym uruchomieniu testu. A skąd Ty, jako nowy programista, miałeś wiedzieć, że to, co zamierzasz zrobić, sprawi, że będziesz o 100 dolarów biedniejszy?
To właśnie upiorne działanie na odległość!
Jesteś zmuszony przekopywać się przez obszerny kod źródłowy i konsultować ze starszymi kolegami, aby zrozumieć
powiązania w projekcie. Trudność ta bierze się stąd, że interfejs klasy CreditCard nie zdradza koniecznej
inicjalizacji stanu globalnego. Nawet zbadanie kodu źródłowego klasy może nie ujawnić, którą metodę inicjalizującą
wywołać. W najlepszym razie znajdziesz zmienną globalną, po którą sięga, i spróbujesz wydedukować, jak ją
zainicjować.
Klasy w takim projekcie są patologicznymi kłamcami. Klasa CreditCard udaje, że wystarczy utworzyć jej
instancję i wywołać metodę charge(). W tajemnicy współpracuje jednak z inną klasą,
PaymentGateway, reprezentującą bramkę płatniczą. Nawet interfejs PaymentGateway może sugerować
niezależną inicjalizację, ale w rzeczywistości może ciągnąć poświadczenia z pliku konfiguracyjnego itd. Pierwotni
programiści rozumieją, że CreditCard wymaga PaymentGateway. Sami tak ten kod napisali. Ale dla nowych
osób to kompletna zagadka, która utrudnia im naukę i skuteczne włączenie się do pracy.
Jak naprawić tę sytuację? Łatwo. Niech API deklaruje zależności.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Zwróć uwagę, że wzajemne zależności w kodzie stają się od razu widoczne. Ponieważ metoda charge()
deklaruje, że potrzebuje PaymentGateway, nie musisz już zgadywać ani pytać o tę zależność. Wiesz, że musisz
utworzyć instancję, a przy okazji odkryjesz wymagane parametry dostępowe. Bez nich kod w ogóle by się nie uruchomił.
A co najważniejsze, możesz teraz zamockować bramkę płatniczą, więc przy każdym uruchomieniu testu nie stracisz 100 dolarów.
Stan globalny pozwala obiektom skrycie sięgać po zależności niezadeklarowane w ich API, skutecznie zamieniając Twoje API w patologicznych kłamców.
Może nigdy tak o tym nie myślałeś, ale ilekroć używasz stanu globalnego, tworzysz tajne, bezprzewodowe kanały komunikacji. To upiorne działanie na odległość zmusza programistów do czytania każdego wiersza kodu, aby zrozumieć możliwe interakcje, obniża produktywność i dezorientuje nowych członków zespołu. Jeśli to Ty stworzyłeś ten kod, znasz prawdziwe zależności, ale każdy, kto przyjdzie po Tobie, nie ma o nich pojęcia.
Unikaj pisania kodu opierającego się na stanie globalnym; preferuj jawne przekazywanie zależności. Przyjmij wstrzykiwanie zależności.
Kruchość stanu globalnego
W kodzie korzystającym ze stanu globalnego i singletonów nigdy nie możesz mieć pewności, kiedy i przez kogo stan został zmieniony. Ryzyko to ujawnia się już przy inicjalizacji. Poniższy kod ma utworzyć połączenie z bazą danych i zainicjować bramkę płatniczą, ale wielokrotnie zgłasza wyjątki, a debugowanie przyczyny jest wyjątkowo żmudne:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Musisz drobiazgowo prześledzić kod, aby odkryć, że obiekt PaymentGateway bezprzewodowo sięga po inne obiekty,
z których część wymaga połączenia z bazą danych. Bazę danych trzeba więc zainicjować przed
PaymentGateway. Ale zasłona dymna stanu globalnego to przed Tobą ukrywa. Ile czasu by się zaoszczędziło, gdyby
API tych klas było uczciwe i deklarowało swoje zależności?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, /* ... */);
Podobny problem pojawia się przy globalnym dostępie do połączenia z bazą danych:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
Przy wywołaniu metody save() nie wiadomo, czy połączenie z bazą danych zostało nawiązane ani kto odpowiada
za jego nawiązanie. Jeśli potrzebujemy zmieniać połączenie z bazą danych dynamicznie (np. na potrzeby testów), możemy
uciec się do dodania metod w rodzaju DB::reconnect(...) albo DB::reconnectForTest().
Rozważ przykład:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Skąd mamy pewność, że przy wywołaniu $article->save() rzeczywiście używana jest testowa baza danych? Co,
jeśli metoda Foo::doSomething() zmieniła globalne połączenie z bazą danych? Aby to ustalić, musielibyśmy
zbadać kod źródłowy Foo i potencjalnie wielu innych klas. Takie dochodzenie dałoby tylko tymczasową
odpowiedź, bo sytuacja może zmienić się później.
A gdybyśmy przenieśli połączenie z bazą danych do zmiennej statycznej wewnątrz klasy Article?
class Article
{
private static DB $db;
public static function setDb(DB $db): void
{
self::$db = $db;
}
public function save(): void
{
self::$db->insert(/* ... */);
}
}
Nie zmienia to absolutnie nic. Problemem jest sam stan globalny, niezależnie od tego, w której klasie jest ukryty. W tym
scenariuszu, tak jak w poprzednim, przy wywołaniu $article->save() nie mamy pewności, do której bazy danych
trafią dane. Ktokolwiek, gdziekolwiek w aplikacji, mógł w dowolnej chwili zmienić bazę danych przez
Article::setDb(). Bez naszej wiedzy.
Stan globalny czyni naszą aplikację wyjątkowo kruchą.
Istnieje jednak prosty sposób poradzenia sobie z tym problemem. Wystarczy, aby API deklarowało zależności potrzebne do poprawnego działania.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Takie podejście eliminuje obawy o ukryte albo nieoczekiwane zmiany połączenia z bazą danych. Mamy teraz pewność, gdzie zapisywany jest artykuł, a modyfikacje w niepowiązanych klasach nie mogą już na to wpłynąć. Kod nie jest już kruchy, lecz stabilny.
Unikaj pisania kodu opierającego się na stanie globalnym; preferuj jawne przekazywanie zależności. Przyjmij wstrzykiwanie zależności.
Singleton
Singleton to wzorzec projektowy, który zgodnie z definicją ze słynnej publikacji Gang of Four ogranicza klasę do jednej instancji i oferuje globalny dostęp do niej. Implementacja tego wzorca zwykle przypomina poniższy kod:
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// i inne metody pełniące funkcje klasy
}
Niestety singleton wprowadza do aplikacji stan globalny. A jak pokazaliśmy wyżej, stan globalny jest niepożądany. Dlatego singleton uznawany jest za antywzorzec.
Nie używaj singletonów w swoim kodzie i zastąp je innymi mechanizmami. Naprawdę nie potrzebujesz singletonów. Jeśli
jednak potrzebujesz zapewnić, że w całej aplikacji istnieje tylko jedna instancja klasy, przekaż tę odpowiedzialność kontenerowi DI. Powstaje w ten sposób singleton o zasięgu aplikacji, powszechnie nazywany usługą.
Sama klasa zostaje wtedy uwolniona od zarządzania swoją unikalnością (czyli nie będzie miała metody
getInstance() ani statycznej właściwości z instancją) i może skupić się wyłącznie na swoich obowiązkach.
Przestanie więc łamać zasadę pojedynczej odpowiedzialności.
Stan globalny kontra testy
Pisząc testy, zakładamy najlepiej, że każdy test jest odizolowaną jednostką, do której nie wchodzi ani z której nie wychodzi żaden zewnętrzny stan. Po zakończeniu testu jakikolwiek związany z nim stan powinien zostać automatycznie posprzątany przez garbage collector. Dzięki temu testy są odizolowane. Możemy więc uruchamiać je w dowolnej kolejności.
Gdy jednak w grę wchodzi stan globalny albo singletony, te korzystne założenia się sypią. Stan może wyciekać do testów i z testów. Nagle kolejność testów może mieć znaczenie.
Aby w ogóle przetestować kod z singletonami, programiści często muszą naruszyć ich integralność, na przykład
pozwalając podmienić instancję singletona. Takie rozwiązania są w najlepszym razie hackami, prowadzącymi do kodu trudnego w
utrzymaniu i zrozumieniu. Każdy test (albo jego metoda tearDown()), który modyfikuje stan globalny, musi
drobiazgowo cofnąć te zmiany.
Stan globalny to największy ból głowy przy testach jednostkowych!
Jak to naprawić? Prosto. Unikaj pisania kodu używającego singletonów; preferuj jawne przekazywanie zależności. Przyjmij wstrzykiwanie zależności.
Stałe globalne
Stan globalny nie ogranicza się do używania singletonów i zmiennych statycznych, ale może dotyczyć również stałych globalnych.
Stałe, których wartości reprezentują uniwersalne prawdy (M_PI) albo dostarczają samodzielnych informacji
(PREG_BACKTRACK_LIMIT_ERROR), są zwykle akceptowalne. Odwrotnie, stałe używane jako sposób bezprzewodowego
wstrzykiwania informacji do kodu są w istocie ukrytymi zależnościami. Jak LOG_FILE w poniższym przykładzie.
Użycie stałej FILE_APPEND jest całkowicie poprawne.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Zamiast tego powinniśmy zadeklarować ścieżkę do pliku logu jako parametr konstruktora klasy Foo, czyniąc ją
jawną częścią jej API:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Teraz jawnie przekazujemy ścieżkę do pliku logu. Możemy ją łatwo zmienić w razie potrzeby, co upraszcza testowanie i utrzymanie kodu.
Funkcje globalne i metody statyczne
Chcemy podkreślić, że używanie metod statycznych i funkcji globalnych nie jest samo w sobie problematyczne. Wyjaśniliśmy
problemy z metodami w rodzaju DB::insert(), ale sednem problemu był zawsze leżący u ich podstaw stan globalny,
typowo przechowywany w zmiennej statycznej. Metoda DB::insert() opiera się na zmiennej statycznej trzymającej
połączenie z bazą danych. Bez tej zmiennej nie dałoby się zaimplementować tej metody.
Używanie deterministycznych metod statycznych i funkcji, takich jak Closure::fromCallable(),
strlen() i wielu innych, jest w pełni zgodne ze wstrzykiwaniem zależności. Funkcje te są przewidywalne, bo dla
tych samych parametrów wejściowych zawsze zwracają ten sam wynik. Nie używają żadnego stanu globalnego.
W PHP istnieją jednak funkcje, które nie są deterministyczne. Należy do nich na przykład funkcja
htmlspecialchars(). Jej trzeci parametr, $encoding, jeśli zostanie pominięty, przyjmuje domyślnie
wartość opcji konfiguracyjnej default_charset (ini_get('default_charset')). Zaleca się więc zawsze
podawać ten parametr, aby zapobiec potencjalnie nieprzewidywalnemu zachowaniu. Nette konsekwentnie tak robi.
Niektóre funkcje, jak strtolower() i strtoupper(), jeszcze niedawno zachowywały się
niedeterministycznie, zależnie od ustawienia locale (setlocale()). Powodowało to wiele komplikacji, najczęściej
przy pracy z językiem tureckim. Turecki rozróżnia bowiem „I“ z kropką i bez kropki, zarówno w małych, jak i wielkich
literach. W konsekwencji strtolower('I') zwracało ı (małe i bez kropki), a
strtoupper('i') zwracało İ (wielkie I z kropką), co prowadziło do licznych tajemniczych błędów
aplikacji. Problem ten został jednak naprawiony w PHP 8.2 i funkcje nie zależą już od locale.
To dobry przykład tego, jak stan globalny (ustawienie locale) przysporzył kłopotów tysiącom programistów na całym świecie. Ostateczne rozwiązanie polegało na uczynieniu funkcji niezależnymi od locale, czyli skutecznym usunięciu ukrytej zależności.
Kiedy można używać stanu globalnego?
Istnieją konkretne, ograniczone sytuacje, w których używanie stanu globalnego bywa dopuszczalne. Na przykład podczas debugowania, gdy potrzebujesz zrzucić wartość zmiennej albo zmierzyć czas wykonania konkretnego fragmentu kodu. W tych przypadkach, dotyczących działań tymczasowych, które później zostaną z kodu usunięte, użycie globalnie dostępnego dumpera albo timera może być uzasadnione. Narzędzia te nie są częścią rdzennego projektu aplikacji.
Innym przykładem są funkcje wyrażeń regularnych PHP (preg_*), które wewnętrznie cachują skompilowane
wyrażenia regularne w pamięci statycznej. Gdy w swoim kodzie wielokrotnie wywołujesz funkcje z tym samym wyrażeniem
regularnym, wyrażenie kompilowane jest tylko raz. Ten cache poprawia wydajność i jest dla użytkownika całkowicie
niewidoczny, przez co takie użycie wewnętrznego stanu statycznego jest zwykle akceptowalne.
Podsumowanie
Omówiliśmy, dlaczego warto:
- usunąć ze swojego kodu wszystkie zmienne właściwości statyczne (stan globalny)
- jawnie deklarować zależności
- i korzystać ze wstrzykiwania zależności
Projektując swój kod, pamiętaj, że każde zmienne static $foo jest potencjalnym źródłem problemów. Aby
stworzyć środowisko przyjazne DI, kluczowe jest całkowite wyeliminowanie stanu globalnego i zastąpienie go wstrzykiwaniem
zależności.
Podczas tego procesu możesz odkryć potrzebę rozdzielenia klas mających wiele odpowiedzialności. Nie wahaj się tego zrobić; dąż do zasady pojedynczej odpowiedzialności.
Chciałbym podziękować Miško Hevery'emu, którego artykuły, takie jak Flaw: Brittle Global State & Singletons, stanowią podstawę tego rozdziału.