Stan globalny i singletony
Ostrzeżenie: Poniższe konstrukcje są oznaką źle zaprojektowanego kodu:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
lubstatic::$var
Czy niektóre z tych konstrukcji występują w Twoim kodzie? W takim razie masz okazję do jego ulepszenia. Być może myślisz, że są to powszechne konstrukcje, które widzisz nawet w przykładowych rozwiązaniach różnych bibliotek i frameworków. Jeśli tak jest, to projekt ich kodu nie jest dobry.
Teraz zdecydowanie nie mówimy o jakiejś akademickiej czystości. Wszystkie te konstrukcje mają jedną wspólną cechę: wykorzystują stan globalny. A ten ma destrukcyjny wpływ na jakość kodu. Klasy kłamią o swoich zależnościach. Kod staje się nieprzewidywalny. Mylą programistów i obniżają ich efektywność.
W tym rozdziale wyjaśnimy, dlaczego tak jest i jak unikać stanu globalnego.
Globalne powiązanie
W idealnym świecie obiekt powinien móc komunikować się tylko z obiektami, które zostały mu bezpośrednio przekazane. Jeśli utworzę dwa
obiekty A
i B
i nigdy nie przekażę referencji między nimi, to ani A
, ani B
nie mogą dostać się do drugiego obiektu ani zmienić jego stanu. To jest bardzo pożądana właściwość kodu. Jest to podobne
do sytuacji, gdy masz baterię i żarówkę; żarówka nie zaświeci, dopóki nie połączysz jej z baterią drutem.
Ale to nie dotyczy globalnych (statycznych) zmiennych lub singletonów. Obiekt A
mógłby bezprzewodowo
dostać się do obiektu C
i zmodyfikować go bez jakiegokolwiek przekazania referencji, wywołując
C::changeSomething()
. Jeśli obiekt B
również chwyci globalne C
, to A
i
B
mogą wzajemnie na siebie wpływać za pośrednictwem C
.
Użycie globalnych zmiennych wprowadza do systemu nową formę bezprzewodowego powiązania, która nie jest widoczna
z zewnątrz. Tworzy zasłonę dymną utrudniającą zrozumienie i używanie kodu. Aby programiści rzeczywiście zrozumieli
zależności, muszą przeczytać każdą linię kodu źródłowego. Zamiast jedynie zapoznać się z interfejsem klas. Jest to
ponadto powiązanie całkowicie zbędne. Stan globalny jest używany dlatego, że jest łatwo dostępny z dowolnego miejsca
i pozwala na przykład zapisać do bazy danych za pomocą globalnej (statycznej) metody DB::insert()
. Ale jak
pokażemy, korzyść, którą to przynosi, jest znikoma, natomiast komplikacje, które powoduje, są fatalne.
Z punktu widzenia 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 słynnie nazwał w 1935 roku Albert Einstein zjawisko w fizyce kwantowej, które przyprawiało go o gęsią skórkę. Chodzi o splątanie kwantowe, którego osobliwością jest to, że gdy zmierzysz informację o jednej cząstce, natychmiast wpływasz na drugą cząstkę, nawet jeśli są oddalone od siebie o miliony lat świetlnych. Co pozornie narusza podstawowe prawo wszechświata, że nic nie może poruszać się szybciej niż światło.
W świecie oprogramowania możemy nazwać “upiornym działaniem na odległość” sytuację, gdy uruchamiamy jakiś proces, o którym sądzimy, że jest izolowany (ponieważ nie przekazaliśmy mu żadnych referencji), ale w odległych miejscach systemu dochodzi do nieoczekiwanych interakcji i zmian stanu, o których nie mieliśmy pojęcia. Może do tego dojść tylko za pośrednictwem stanu globalnego.
Wyobraź sobie, że dołączasz do zespołu programistów projektu, który ma obszerną, dojrzałą bazę kodu. Twój nowy przełożony prosi Cię o zaimplementowanie nowej funkcji, a Ty jako dobry programista zaczynasz od napisania testu. Ale ponieważ jesteś nowy w projekcie, robisz wiele testów eksploracyjnych typu “co się stanie, jeśli wywołam tę metodę”. I próbujesz napisać następujący test:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // numer Twojej karty
$cc->charge(100);
}
Uruchamiasz kod, może kilka razy, i po jakimś czasie zauważasz na telefonie powiadomienia z banku, że przy każdym uruchomieniu pobrano 100 dolarów z Twojej karty płatniczej 🤦♂️
Jak, do diabła, test mógł spowodować rzeczywiste pobranie pieniędzy? Operowanie kartą płatniczą nie jest łatwe. Musisz komunikować się z usługą internetową strony trzeciej, musisz znać adres URL tej usługi, musisz się zalogować i tak dalej. Żadna z tych informacji nie jest zawarta w teście. Co gorsza, nawet nie wiesz, gdzie te informacje się znajdują, a więc ani jak mockować zewnętrzne zależności, aby każde uruchomienie nie prowadziło do ponownego pobrania 100 dolarów. I skąd jako nowy programista miałeś wiedzieć, że to, co zamierzasz zrobić, doprowadzi do tego, że będziesz o 100 dolarów biedniejszy?
To jest upiorne działanie na odległość!
Nie pozostaje Ci nic innego, jak długo grzebać w mnóstwie kodu źródłowego, pytać starszych i bardziej doświadczonych
kolegów, zanim zrozumiesz, jak działają powiązania w projekcie. Jest to spowodowane tym, że patrząc na interfejs klasy
CreditCard
, nie można zidentyfikować stanu globalnego, który należy zainicjować. Nawet spojrzenie na kod
źródłowy klasy nie powie Ci, którą metodę inicjalizacyjną masz wywołać. W najlepszym przypadku możesz znaleźć
globalną zmienną, do której uzyskuje się dostęp, i na jej podstawie próbować odgadnąć, jak ją zainicjować.
Klasy w takim projekcie są patologicznymi kłamcami. Karta płatnicza udaje, że wystarczy ją utworzyć i wywołać metodę
charge()
. W ukryciu jednak współpracuje z inną klasą PaymentGateway
, która reprezentuje bramkę
płatniczą. Jej interfejs również mówi, że można ją zainicjować samodzielnie, ale w rzeczywistości pobiera dane
uwierzytelniające z jakiegoś pliku konfiguracyjnego i tak dalej. Programistom, którzy napisali ten kod, jest jasne, że
CreditCard
potrzebuje PaymentGateway
. Napisali kod w ten sposób. Ale dla każdego, kto jest nowy w
projekcie, jest to kompletna zagadka i utrudnia naukę.
Jak naprawić sytuację? Łatwo. Niech API deklaruje zależności.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Zauważ, jak nagle powiązania wewnątrz kodu stają się oczywiste. Dzięki temu, że metoda charge()
deklaruje,
że potrzebuje PaymentGateway
, nie musisz nikogo pytać, jak kod jest powiązany. Wiesz, że musisz utworzyć jej
instancję, a gdy spróbujesz to zrobić, natkniesz się na to, że musisz podać parametry dostępu. Bez nich kod nie dałby się
nawet uruchomić.
A co najważniejsze, teraz możesz mockować bramkę płatniczą, dzięki czemu przy każdym uruchomieniu testu nie zostanie Ci naliczone 100 dolarów.
Stan globalny powoduje, że Twoje obiekty mogą potajemnie uzyskiwać dostęp do rzeczy, które nie są zadeklarowane w ich API, a w rezultacie czynią z Twoich API patologicznych kłamców.
Być może wcześniej o tym tak nie myślałeś, ale za każdym razem, gdy używasz stanu globalnego, tworzysz tajne bezprzewodowe kanały komunikacyjne. Upiorne działanie na odległość zmusza programistów do czytania każdej linii kodu, aby zrozumieć potencjalne interakcje, obniża produktywność programistów i myli nowych członków zespołu. Jeśli to Ty stworzyłeś kod, znasz rzeczywiste zależności, ale każdy, kto przyjdzie po Tobie, jest bezradny.
Nie pisz kodu, który wykorzystuje stan globalny, preferuj przekazywanie zależności. Czyli dependency injection.
Kruchość stanu globalnego
W kodzie, który używa stanu globalnego i singletonów, nigdy nie jest pewne, kiedy i kto ten stan zmienił. To ryzyko pojawia się już przy inicjalizacji. Poniższy kod ma utworzyć połączenie z bazą danych i zainicjować bramkę płatniczą, jednak ciągle rzuca wyjątek, a znalezienie przyczyny jest niezwykle czasochłonne:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Musisz szczegółowo przeglądać kod, aby dowiedzieć się, że obiekt PaymentGateway
uzyskuje bezprzewodowy
dostęp do innych obiektów, z których niektóre wymagają połączenia z bazą danych. Czyli konieczne jest zainicjowanie bazy
danych przed PaymentGateway
. Jednak zasłona dymna stanu globalnego ukrywa to przed Tobą. Ile czasu byś
zaoszczędził, gdyby API poszczególnych klas nie kłamało i deklarowało swoje zależności?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);
Podobny problem pojawia się również przy użyciu globalnego dostępu 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 jest pewne, czy połączenie z bazą danych zostało już utworzone i kto jest
odpowiedzialny za jego utworzenie. Jeśli chcemy na przykład zmieniać połączenie z bazą danych w trakcie działania, na
przykład w celu testów, musielibyśmy najprawdopodobniej utworzyć dodatkowe metody, takie jak DB::reconnect(...)
lub DB::reconnectForTest()
.
Rozważmy 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 sprawdzić, musielibyśmy
przeanalizować kod źródłowy klasy Foo
i prawdopodobnie wielu innych klas. To podejście przyniosłoby jednak
tylko krótkoterminową odpowiedź, ponieważ sytuacja może się w przyszłości zmienić.
A co jeśli przeniesiemy 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(/* ... */);
}
}
To w ogóle nic nie zmieniło. Problemem jest stan globalny i jest zupełnie obojętne, w której klasie się ukrywa. W tym
przypadku, podobnie jak w poprzednim, przy wywołaniu metody $article->save()
nie mamy żadnej wskazówki co do
tego, do jakiej bazy danych zostanie zapisany. Ktokolwiek na drugim końcu aplikacji mógł w dowolnym momencie za pomocą
Article::setDb()
zmienić bazę danych. Nam pod nosem.
Stan globalny czyni naszą aplikację niezwykle kruchą.
Istnieje jednak prosty sposób, aby poradzić sobie z tym problemem. Wystarczy pozwolić API deklarować zależności, co zapewni poprawną funkcjonalność.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Dzięki temu podejściu znika obawa o ukryte i nieoczekiwane zmiany połączenia z bazą danych. Teraz mamy pewność, gdzie artykuł jest zapisywany, a żadne modyfikacje kodu wewnątrz innej, niepowiązanej klasy nie mogą już zmienić sytuacji. Kod nie jest już kruchy, ale stabilny.
Nie pisz kodu, który wykorzystuje stan globalny, preferuj przekazywanie zależności. Czyli dependency injection.
Singleton
Singleton to wzorzec projektowy, który według definicji ze znanej publikacji Gang of Four ogranicza klasę do jednej instancji i oferuje do niej globalny dostęp. Implementacja tego wzorca zwykle przypomina następujący 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 danej klasy
}
Niestety, singleton wprowadza do aplikacji stan globalny. A jak pokazaliśmy wyżej, stan globalny jest niepożądany. Dlatego singleton jest uważany za antywzorzec.
Nie używaj w swoim kodzie singletonów i zastąp je innymi mechanizmami. Singletony naprawdę nie są potrzebne. Jeśli
jednak potrzebujesz zagwarantować istnienie jednej instancji klasy dla całej aplikacji, pozostaw to kontenerowi DI. Stwórz w ten sposób singleton aplikacyjny,
czyli usługę. Dzięki temu klasa przestanie zajmować się zapewnieniem swojej własnej unikalności (tj. nie będzie miała
metody getInstance()
i zmiennej statycznej) i będzie pełnić tylko swoje funkcje. W ten sposób przestanie
naruszać zasadę pojedynczej odpowiedzialności.
Stan globalny a testy
Podczas pisania testów zakładamy, że każdy test jest izolowaną jednostką i że nie wchodzi do niego żaden zewnętrzny stan. I żaden stan nie opuszcza testów. Po zakończeniu testu cały powiązany z nim stan powinien zostać automatycznie usunięty przez garbage collector. Dzięki temu testy są izolowane. Dlatego możemy uruchamiać testy w dowolnej kolejności.
Jeśli jednak obecne są stany globalne/singletony, wszystkie te przyjemne założenia się rozpadają. Stan może wchodzić do testu i wychodzić z niego. Nagle kolejność testów może mieć znaczenie.
Aby w ogóle móc testować singletony, programiści często muszą rozluźnić ich właściwości, na przykład pozwalając na
zastąpienie instancji inną. Takie rozwiązania są w najlepszym przypadku hackiem, który tworzy trudny do utrzymania
i zrozumienia kod. Każdy test lub metoda tearDown()
, która wpływa na jakikolwiek stan globalny, musi te zmiany
cofnąć.
Stan globalny to największy ból głowy przy testach jednostkowych!
Jak naprawić sytuację? Łatwo. Nie pisz kodu, który wykorzystuje singletony, preferuj przekazywanie zależności. Czyli dependency injection.
Stałe globalne
Stan globalny nie ogranicza się tylko do używania singletonów i zmiennych statycznych, ale może dotyczyć również stałych globalnych.
Stałe, których wartość nie wnosi nam żadnej nowej (M_PI
) lub użytecznej
(PREG_BACKTRACK_LIMIT_ERROR
) informacji, są jednoznacznie w porządku. Natomiast stałe, które służą jako
sposób na bezprzewodowe przekazanie informacji do wnętrza kodu, są niczym innym jak ukrytą zależnością. Jak na
przykład 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);
// ...
}
}
W tym przypadku powinniśmy zadeklarować parametr w konstruktorze klasy Foo
, aby stał się częścią API:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Teraz możemy przekazać informację o ścieżce do pliku logów i łatwo ją zmieniać w zależności od potrzeb, co ułatwia testowanie i konserwację kodu.
Funkcje globalne i metody statyczne
Chcemy podkreślić, że samo używanie metod statycznych i funkcji globalnych nie jest problematyczne. Wyjaśnialiśmy, na
czym polega nieodpowiedniość użycia DB::insert()
i podobnych metod, ale zawsze chodziło tylko o kwestię stanu
globalnego, który jest przechowywany w jakiejś zmiennej statycznej. Metoda DB::insert()
wymaga istnienia zmiennej
statycznej, ponieważ w niej jest przechowywane połączenie z bazą danych. Bez tej zmiennej implementacja metody byłaby
niemożliwa.
Używanie deterministycznych metod statycznych i funkcji, takich jak DateTime::createFromFormat()
,
Closure::fromCallable
, strlen()
i wielu innych, jest w całkowitej zgodzie z dependency injection.
Funkcje te zawsze zwracają te same wyniki dla tych samych parametrów wejściowych i są zatem przewidywalne. Nie używają
żadnego stanu globalnego.
Istnieją jednak również funkcje w PHP, które nie są deterministyczne. Należy do nich na przykład funkcja
htmlspecialchars()
. Jej trzeci parametr $encoding
, jeśli nie jest podany, domyślnie przyjmuje
wartość opcji konfiguracyjnej ini_get('default_charset')
. Dlatego zaleca się zawsze podawać ten parametr, aby
zapobiec ewentualnemu nieprzewidywalnemu zachowaniu funkcji. Nette konsekwentnie to robi.
Niektóre funkcje, takie jak strtolower()
, strtoupper()
i podobne, w niedawnej przeszłości
zachowywały się niedeterministycznie i były zależne od ustawienia setlocale()
. Powodowało to wiele komplikacji,
najczęściej przy pracy z językiem tureckim. Ten bowiem rozróżnia małą i dużą literę I
z kropką i bez
kropki. Tak więc strtolower('I')
zwracało znak ı
, a strtoupper('i')
znak
İ
, co prowadziło do tego, że aplikacje zaczęły powodować szereg zagadkowych błędów. Ten problem został
jednak usunięty w PHP w wersji 8.2 i funkcje nie są już zależne od locale.
Jest to piękny przykład, jak stan globalny napsuł krwi tysiącom programistów na całym świecie. Rozwiązaniem było zastąpienie go przez dependency injection.
Kiedy można użyć stanu globalnego?
Istnieją pewne specyficzne sytuacje, w których można wykorzystać stan globalny. Na przykład podczas debugowania kodu, gdy potrzebujesz wypisać wartość zmiennej lub zmierzyć czas trwania określonej części programu. W takich przypadkach, które dotyczą tymczasowych działań, które zostaną później usunięte z kodu, uzasadnione jest wykorzystanie globalnie dostępnego dumpera lub stopera. Te narzędzia bowiem nie są częścią projektu kodu.
Innym przykładem są funkcje do pracy z wyrażeniami regularnymi preg_*
, które wewnętrznie przechowują
skompilowane wyrażenia regularne w statycznej pamięci podręcznej w pamięci. Kiedy więc wywołujesz to samo wyrażenie
regularne wielokrotnie w różnych miejscach kodu, kompiluje się ono tylko raz. Pamięć podręczna oszczędza wydajność, a
jednocześnie jest dla użytkownika całkowicie niewidoczna, dlatego takie wykorzystanie można uznać za uzasadnione.
Podsumowanie
Omówiliśmy, dlaczego warto:
- Usunąć wszystkie zmienne statyczne z kodu
- Deklarować zależności
- I używać dependency injection
Kiedy zastanawiasz się nad projektem kodu, pamiętaj, że każde static $foo
stanowi problem. Aby Twój kod był
środowiskiem szanującym DI, konieczne jest całkowite wyeliminowanie stanu globalnego i zastąpienie go za pomocą dependency
injection.
Podczas tego procesu być może odkryjesz, że trzeba podzielić klasę, ponieważ ma więcej niż jedną odpowiedzialność. Nie bój się tego; dąż do zasady pojedynczej odpowiedzialności.
Chciałbym podziękować Miškovi Hevery'emu, którego artykuły, takie jak Flaw: Brittle Global State & Singletons, są podstawą tego rozdziału.