Stan globalny i singletony

Ostrzeżenie: Poniższe konstrukcje są objawami źle zaprojektowanego kodu:

  • Foo::getInstance()
  • DB::insert(...)
  • Article::setDb($db)
  • ClassName::$var lub static::$var

Czy napotkałeś którąś z tych konstrukcji w swoim kodzie? Jeśli tak, to masz okazję je poprawić. Można by pomyśleć, że są to powszechne konstrukcje, często spotykane w przykładowych rozwiązaniach różnych bibliotek i frameworków. Jeśli tak jest, ich projekt kodu jest wadliwy.

Nie mówimy tutaj o jakiejś akademickiej czystości. Wszystkie te konstrukcje mają jedną wspólną cechę: wykorzystują stan globalny. Ma to destrukcyjny wpływ na jakość kodu. Klasy wprowadzają w błąd co do swoich zależności. Kod staje się nieprzewidywalny. Dezorientuje to programistów i zmniejsza ich wydajność.

W tym rozdziale wyjaśnimy, dlaczego tak się dzieje i jak uniknąć stanu globalnego.

Globalne powiązania

W idealnym świecie obiekt powinien 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żę między nimi referencji, to ani A, ani B nie będą mogły uzyskać dostępu do stanu drugiego obiektu ani go zmodyfikować. Jest to bardzo pożądana właściwość kodu. Przypomina to posiadanie baterii i żarówki; żarówka nie zaświeci się, dopóki nie połączysz jej z baterią za pomocą przewodu.

Nie dotyczy to jednak zmiennych globalnych (statycznych) lub singletonów. Obiekt A może bezprzewodowo uzyskać dostęp do obiektu C i zmodyfikować go bez przekazywania referencji, poprzez wywołanie C::changeSomething(). Jeśli obiekt B ma również dostęp do globalnego C, to A i B mogą wpływać na siebie nawzajem poprzez C.

Używanie zmiennych globalnych wprowadza nową formę bezprzewodowego sprzężenia, które nie jest widoczne z zewnątrz. Tworzy to zasłonę dymną, która komplikuje zrozumienie i używanie kodu. Aby naprawdę zrozumieć zależności, programiści muszą przeczytać każdą linijkę kodu źródłowego, a nie tylko zapoznać się z interfejsami klas. Co więcej, to uwikłanie jest całkowicie niepotrzebne. Stan globalny jest używany, ponieważ jest łatwo dostępny z dowolnego miejsca i umożliwia, na przykład, zapis do bazy danych za pomocą globalnej (statycznej) metody DB::insert(). Jednak, jak zobaczymy, korzyści, jakie oferuje, są minimalne, podczas gdy komplikacje, które wprowadza, są poważne.

Pod względem zachowania nie ma różnicy między zmienną globalną a statyczną. Są one równie szkodliwe.

Upiorne działanie na odległość

“Upiorne działanie na odległość” – tak Albert Einstein nazwał słynne zjawisko w fizyce kwantowej, które w 1935 roku przyprawiło go o dreszcze. Chodzi o splątanie kwantowe, którego osobliwość polega na tym, że gdy mierzymy informację o jednej cząstce, natychmiast wpływamy na inną cząstkę, nawet jeśli są one oddalone od siebie o miliony lat świetlnych. Co pozornie narusza podstawowe prawo wszechświata, że nic nie może podróżować szybciej niż światło.

W świecie oprogramowania “upiornym działaniem na odległość” możemy nazwać sytuację, w której uruchamiamy proces, o którym myślimy, że jest izolowany (bo nie przekazaliśmy mu żadnych referencji), ale nieoczekiwane interakcje i zmiany stanu zachodzą w odległych miejscach systemu, o których nie powiedzieliśmy obiektowi. Może to nastąpić tylko poprzez stan globalny.

Wyobraź sobie, że dołączasz do zespołu rozwijającego projekt, który ma dużą, dojrzałą bazę kodu. Twój nowy lider prosi cię o wdrożenie nowej funkcji i, jak dobry deweloper, zaczynasz od napisania testu. Ale ponieważ jesteś nowy w projekcie, robisz wiele testów eksploracyjnych “co się stanie, jeśli zadzwonię do tej metody” typu. I próbujesz napisać następujący test:

function testCreditCardCharge()
{
	$cc = new CreditCard('1234567890123456', 5, 2028); // numer karty
	$cc->charge(100);
}

Uruchamiasz kod, może kilka razy, i po jakimś czasie zauważasz na swoim telefonie powiadomienia z banku, że przy każdym uruchomieniu 100$ zostało pobrane z Twojej karty kredytowej 🤦‍♂️

Jak u licha test mógł spowodować faktyczne obciążenie? Nie jest łatwo operować kartą kredytową. Musisz wejść w interakcję z usługą internetową strony trzeciej, musisz znać adres URL tej usługi internetowej, musisz się zalogować i tak dalej. Żadna z tych informacji nie jest zawarta w teście. Co gorsza, nie wiesz nawet, gdzie te informacje są obecne, a zatem jak kpić z zewnętrznych zależności, aby każdy bieg nie powodował ponownego naliczania 100 USD. A jako nowy deweloper, jak miałeś wiedzieć, że to, co zamierzasz zrobić, doprowadzi do tego, że będziesz uboższy o 100 dolarów?

To jest upiorne działanie na odległość!

Nie masz wyboru, musisz przekopać się przez wiele kodu źródłowego, pytając starszych i bardziej doświadczonych kolegów, aż zrozumiesz, jak działają połączenia w projekcie. Wynika to z faktu, że patrząc na interfejs klasy CreditCard, nie można określić stanu globalnego, który musi zostać zainicjalizowany. Nawet patrząc na kod źródłowy klasy nie dowiesz się, którą metodę inicjalizacji należy wywołać. W najlepszym przypadku możesz znaleźć zmienną globalną, do której uzyskuje się dostęp, i spróbować zgadnąć, jak ją zainicjalizować z tego.

Klasy w takim projekcie są patologicznymi kłamcami. Karta płatnicza udaje, że można ją po prostu zainicjować i wywołać metodę charge(). Jednak potajemnie współdziała z inną klasą, PaymentGateway. Nawet jej interfejs mówi, że może być inicjalizowana niezależnie, ale w rzeczywistości ściąga poświadczenia z jakiegoś pliku konfiguracyjnego i tak dalej. Dla programistów, którzy napisali ten kod, jest jasne, że CreditCard potrzebuje PaymentGateway. Napisali kod w ten sposób. Ale dla każdego nowego w projekcie jest to kompletna tajemnica i utrudnia naukę.

Jak naprawić tę sytuację? Łatwo. Pozwól API zadeklarować zależności.

function testCreditCardCharge()
{
	$gateway = new PaymentGateway(/* ... */);
	$cc = new CreditCard('1234567890123456', 5, 2028);
	$cc->charge($gateway, 100);
}

Zauważ, jak zależności wewnątrz kodu są nagle oczywiste. Deklarując, że metoda charge() potrzebuje PaymentGateway, nie musisz nikogo pytać, w jaki sposób kod jest współzależny. Wiesz, że musisz stworzyć jego instancję, a kiedy próbujesz to zrobić, natrafiasz na fakt, że musisz dostarczyć parametry dostępu. Bez nich kod nawet by się nie uruchomił.

I co najważniejsze, możesz teraz kpić z bramki płatności, więc nie zostaniesz obciążony 100 $ za każdym razem, gdy uruchomisz test.

Stan globalny powoduje, że twoje obiekty mogą potajemnie uzyskać dostęp do rzeczy, które nie są zadeklarowane w ich interfejsach API, a w rezultacie czyni twoje interfejsy API patologicznymi kłamcami.

Być może wcześniej nie myślałeś o tym w ten sposób, ale zawsze, gdy używasz stanu globalnego, tworzysz tajne kanały komunikacji bezprzewodowej. Przerażające zdalne działanie zmusza programistów do czytania każdej linijki kodu, aby zrozumieć potencjalne interakcje, zmniejsza produktywność programistów i dezorientuje nowych członków zespołu. Jeśli jesteś tym, który stworzył kod, znasz prawdziwe zależności, ale każdy, kto przyjdzie po tobie, nie ma pojęcia.

Nie pisz kodu, który używa globalnego stanu, wolą przekazać zależności. To jest zastrzyk zależności.

Kruchość globalnego państwa

W kodzie, który używa stanu globalnego i singletonów, nigdy nie ma pewności, kiedy i przez kogo ten stan został zmieniony. To ryzyko pojawia się już podczas inicjalizacji. Poniższy kod ma za zadanie utworzyć połączenie z bazą danych i zainicjalizować bramkę płatniczą, ale ciągle rzuca wyjątek, a znalezienie przyczyny jest niezwykle żmudne:

PaymentGateway::init();
DB::init('mysql:', 'user', 'password');

Musisz szczegółowo przejrzeć kod, aby znaleźć, że obiekt PaymentGateway uzyskuje dostęp do innych obiektów bezprzewodowo, z których niektóre wymagają połączenia z bazą danych. Musisz więc zainicjalizować bazę danych przed PaymentGateway. Jednak zasłona dymna stanu globalnego ukrywa to przed tobą. Ile czasu zaoszczędziłbyś, gdyby API każdej klasy nie kłamało i nie deklarowało swoich zależności?

$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);

Podobny problem pojawia się podczas korzystania z globalnego dostępu do połączenia z bazą danych:

use Illuminate\Support\Facades\DB;

class Article
{
	public function save(): void
	{
		DB::insert(/* ... */);
	}
}

Podczas wywoływania metody save() nie ma pewności, czy połączenie z bazą danych zostało już utworzone i kto jest odpowiedzialny za jego utworzenie. Na przykład, gdybyśmy chcieli zmienić połączenie z bazą danych w locie, być może w celach testowych, prawdopodobnie musielibyśmy stworzyć dodatkowe metody, takie jak DB::reconnect(...) lub DB::reconnectForTest().

Rozważmy przykład:

$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();

Skąd możemy mieć pewność, że testowa baza danych jest naprawdę używana podczas wywoływania metody $article->save()? Co by było, gdyby metoda Foo::doSomething() zmieniła globalne połączenie z bazą danych? Aby się tego dowiedzieć, musielibyśmy zbadać kod źródłowy klasy Foo i prawdopodobnie wielu innych klas. Jednak takie podejście dałoby tylko krótkotrwałą odpowiedź, ponieważ sytuacja może się zmienić w przyszłości.

Co by się stało, 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(/* ... */);
	}
}

To w ogóle niczego nie zmienia. Problem jest stanem globalnym i nie ma znaczenia, w której klasie się ukrywa. W tym przypadku, podobnie jak w poprzednim, nie mamy pojęcia, do jakiej bazy danych jest zapisywana w momencie wywołania metody $article->save(). Ktokolwiek na odległym końcu aplikacji mógłby w każdej chwili zmienić bazę danych za pomocą Article::setDb(). Pod naszymi rękami.

Stan globalny sprawia, że nasza aplikacja jest ekstremalnie krucha.

Istnieje jednak prosty sposób na poradzenie sobie z tym problemem. Wystarczy kazać API zadeklarować zależności, aby zapewnić odpowiednią funkcjonalność.

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 i nieoczekiwane zmiany w połączeniach z bazą danych. Teraz mamy pewność, gdzie przechowywany jest artykuł i ż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 korzysta z globalnego stanu, wolisz przekazać zależności. A więc dependency injection.

Singleton

Singleton to wzorzec projektowy, który z definicji ze słynnej publikacji Gang of Four ogranicza klasę do pojedynczej instancji i oferuje do niej globalny dostęp. Implementacja tego wzorca zazwyczaj przypomina następujący kod:

class Singleton
{
	private static self $instance;

	public static function getInstance(): self
	{
		self::$instance ??= new self;
		return self::$instance;
	}

	// oraz inne metody realizujące funkcje klasy
}

Niestety, singleton wprowadza do aplikacji stan globalny. A jak pokazaliśmy powyżej, stan globalny jest niepożądany. Dlatego właśnie singleton jest uważany za antypattern.

Nie używaj singletonów w swoim kodzie i zastąp je innymi mechanizmami. Naprawdę nie potrzebujesz singletonów. Jeśli jednak musisz zagwarantować istnienie pojedynczej instancji klasy dla całej aplikacji, zostaw to kontenerowi DI. W ten sposób utwórz singleton aplikacji, czyli usługę. Dzięki temu klasa przestanie zapewniać własną unikalność (tzn. Nie będzie miała metody getInstance() i zmiennej statycznej) i będzie wykonywać tylko swoje funkcje. Tym samym przestanie naruszać zasadę pojedynczej odpowiedzialności.

Stan globalny a testy

Pisząc testy, 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. Kiedy test się kończy, wszelkie stany związane z testem powinny być automatycznie usuwane przez garbage collector. To sprawia, że testy są odizolowane. Dlatego możemy uruchamiać testy w dowolnej kolejności.

Jednakże, jeśli obecne są globalne stany/singletony, wszystkie te miłe założenia ulegają załamaniu. Stan może wejść i wyjść z testu. Nagle okazuje się, że kolejność wykonywania testów może mieć znaczenie.

Aby w ogóle testować singletony, programiści często muszą rozluźnić ich właściwości, być może pozwalając na zastąpienie jednej instancji inną. Takie rozwiązania są w najlepszym wypadku hackami, które produkują kod trudny do utrzymania i zrozumienia. Każdy test lub metoda tearDown(), która wpływa na jakikolwiek stan globalny, musi cofnąć te zmiany.

Globalny stan jest największym bólem głowy w testach jednostkowych!

Jak naprawić tę sytuację? Proste. Nie pisz kodu, który używa singletonów, wolisz przekazywać zależności. Czyli dependency injection.

Stałe globalne

Stan globalny nie jest ograniczony do używania singletonów i zmiennych statycznych, ale może również dotyczyć stałych globalnych.

Stałe, których wartość nie dostarcza nam żadnych nowych (M_PI) lub użytecznych (PREG_BACKTRACK_LIMIT_ERROR) informacji są oczywiście OK. I odwrotnie, stałe, które służą jako sposób na bezprzewodowe przekazywanie informacji wewnątrz kodu, są niczym więcej niż ukrytą zależnością. Jak LOG_FILE w poniższym przykładzie. Używanie 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 uczynić go częścią interfejsu 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 logowania i łatwo ją zmienić w razie potrzeby, co ułatwi testowanie i utrzymanie kodu.

Funkcje globalne i metody statyczne

Chcemy podkreślić, że używanie metod statycznych i funkcji globalnych samo w sobie nie jest problematyczne. Wyjaśniliśmy niewłaściwość używania DB::insert() i podobnych metod, ale zawsze chodziło o stan globalny przechowywany w zmiennej statycznej. Metoda DB::insert() wymaga istnienia zmiennej statycznej, ponieważ przechowuje ona połączenie z bazą danych. Bez tej zmiennej implementacja metody byłaby niemożliwa.

Stosowanie deterministycznych metod i funkcji statycznych, takich jak DateTime::createFromFormat(), Closure::fromCallable, strlen() i wielu innych, jest doskonale zgodne z wstrzykiwaniem zależności. Funkcje te zawsze zwracają te same wyniki z tych samych parametrów wejściowych i dlatego są przewidywalne. Nie używają one ż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 nie zostanie określony, domyślnie przyjmuje wartość opcji konfiguracyjnej ini_get('default_charset'). Dlatego zaleca się zawsze podawać ten parametr, aby uniknąć ewentualnego nieprzewidywalnego zachowania funkcji. Nette konsekwentnie to robi.

Niektóre funkcje, takie jak strtolower(), strtoupper(), i tym podobne, miały w niedawnej przeszłości niedeterministyczne zachowanie i zależały od ustawienia setlocale(). Powodowało to wiele komplikacji, najczęściej podczas pracy z językiem tureckim. Dzieje się tak dlatego, że język turecki rozróżnia duże i małe litery I z i bez kropki. Tak więc strtolower('I') zwracał znak ı, a strtoupper('i') zwracał znak İ, co prowadziło do aplikacji powodujących wiele tajemniczych błędów. Jednak ten problem został naprawiony w PHP w wersji 8.2 i funkcje nie są już zależne od locale.

Jest to ładny przykład tego, jak stan globalny nękał tysiące programistów na całym świecie. Rozwiązaniem było zastąpienie go zastrzykiem zależności.

Kiedy można użyć stanu globalnego?

Istnieją pewne specyficzne sytuacje, w których możliwe jest użycie stanu globalnego. Na przykład, gdy debugujemy kod i musimy zrzucić wartość zmiennej lub zmierzyć czas trwania określonej części programu. W takich przypadkach, które dotyczą działań tymczasowych, które później zostaną usunięte z kodu, uzasadnione jest użycie dostępnego globalnie dumpera lub stopera. Narzędzia te 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. Gdy wywołujesz to samo wyrażenie regularne wiele razy w różnych częściach kodu, jest ono kompilowane tylko raz. Cache oszczędza wydajność, a także jest całkowicie niewidoczny dla użytkownika, więc takie użycie można uznać za uzasadnione.

Podsumowanie

Pokazaliśmy, dlaczego to ma sens

  1. Usuń z kodu wszystkie zmienne statyczne
  2. Zadeklarować zależności
  3. I używać zastrzyku zależności

Rozważając projekt kodu, pamiętaj, że każdy static $foo reprezentuje problem. Aby twój kod był środowiskiem respektującym DI, konieczne jest całkowite wyeliminowanie stanu globalnego i zastąpienie go zastrzykiem zależności.

Podczas tego procesu może się okazać, że musisz podzielić klasę, ponieważ ma ona więcej niż jedną odpowiedzialność. Nie przejmuj się tym; dąż do zasady jednej 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.

wersja: 3.x