Globalno stanje in singletoni
Opozorilo: Naslednje konstrukcije so znak slabo načrtovane kode:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
alistatic::$var
Ali se nekatere od teh konstrukcij pojavljajo v vaši kodi? Potem imate priložnost za njeno izboljšanje. Morda si mislite, da gre za običajne konstrukcije, ki jih vidite na primer tudi v vzorčnih rešitvah različnih knjižnic in ogrodij. Če je temu tako, potem načrtovanje njihove kode ni dobro.
Zdaj zagotovo ne govorimo o neki akademski čistosti. Vse te konstrukcije imajo eno skupno: izkoriščajo globalno stanje. In to ima uničujoč vpliv na kakovost kode. Razredi lažejo o svojih odvisnostih. Koda postane nepredvidljiva. Zmede programerje in zmanjšuje njihovo učinkovitost.
V tem poglavju si bomo razložili, zakaj je temu tako in kako se globalnemu stanju izogniti.
Globalna povezanost
V idealnem svetu bi moral objekt biti sposoben komunicirati samo z objekti, ki so mu bili neposredno posredovani. Če ustvarim dva objekta
A
in B
in nikoli ne posredujem reference med njima, potem se niti A
niti B
ne
moreta dostopati do drugega objekta ali spremeniti njegovega stanja. To je zelo zaželena lastnost kode. Podobno je, kot če imate
baterijo in žarnico; žarnica ne bo svetila, dokler je z baterijo ne povežete z žico.
To pa ne velja pri globalnih (statičnih) spremenljivkah ali singletonih. Objekt A
bi se lahko brezžično
dostopal do objekta C
in ga modificiral brez kakršnega koli posredovanja reference, s klicem
C::changeSomething()
. Če se objekt B
prav tako oprime globalnega C
, potem se
A
in B
lahko medsebojno vplivata prek C
.
Uporaba globalnih spremenljivk v sistem vnaša novo obliko brezžične povezanosti, ki od zunaj ni vidna. Ustvarja
dimno zaveso, ki otežuje razumevanje in uporabo kode. Da bi razvijalci odvisnosti resnično razumeli, morajo prebrati vsako
vrstico izvorne kode. Namesto zgolj seznanitve z vmesnikom razredov. Gre poleg tega za popolnoma nepotrebno povezanost. Globalno
stanje se uporablja zato, ker je enostavno dostopno od kjerkoli in omogoča na primer zapis v podatkovno bazo prek globalne
(statične) metode DB::insert()
. Ampak kot si bomo pokazali, je prednost, ki jo to prinaša, neznatna, nasprotno pa
povzroča usodne zaplete.
Z vidika obnašanja ni razlike med globalno in statično spremenljivko. Sta enako škodljivi.
Strašljivo delovanje na daljavo
“Strašljivo delovanje na daljavo” – tako je slavno leta 1935 Albert Einstein poimenoval pojav v kvantni fiziki, ki mu je naganjal kurjo polt. Gre za kvantno prepletenost, katere posebnost je, da ko izmerite informacijo o enem delcu, s tem takoj vplivate na drugi delec, tudi če sta med seboj oddaljena milijone svetlobnih let. Kar navidezno krši osnovni zakon vesolja, da se nič ne more širiti hitreje od svetlobe.
V svetu programske opreme lahko “strašljivo delovanje na daljavo” poimenujemo situacijo, ko zaženemo nek proces, za katerega menimo, da je izoliran (ker mu nismo posredovali nobenih referenc), vendar na oddaljenih mestih sistema pride do nepričakovanih interakcij in sprememb stanja, o katerih nismo imeli pojma. Do tega lahko pride samo prek globalnega stanja.
Predstavljajte si, da se pridružite ekipi razvijalcev projekta, ki ima obsežno napredno kodno bazo. Vaš novi vodja vas prosi za implementacijo nove funkcije in vi kot pravi razvijalec začnete s pisanjem testa. Ker pa ste v projektu novi, delate veliko raziskovalnih testov tipa “kaj se zgodi, če pokličem to metodo”. In poskusite napisati naslednji test:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // številka vaše kartice
$cc->charge(100);
}
Zaženete kodo, morda večkrat, in po nekem času opazite na mobilnem telefonu obvestila iz banke, da se je ob vsakem zagonu odštelo 100 dolarjev z vaše plačilne kartice 🤦♂️
Kako za vraga je lahko test povzročil dejansko odtegnitev denarja? Upravljanje s plačilno kartico ni enostavno. Morate komunicirati s spletno storitvijo tretje osebe, morate poznati URL te spletne storitve, morate se prijaviti in tako naprej. Nobena od teh informacij ni vsebovana v testu. Še huje, niti ne veste, kje so te informacije prisotne, in torej niti kako mockati zunanje odvisnosti, da vsak zagon ne bi vodil k temu, da se ponovno odšteje 100 dolarjev. In kako ste kot novi razvijalec morali vedeti, da bo to, kar se pripravljate storiti, vodilo k temu, da boste za 100 dolarjev revnejši?
To je strašljivo delovanje na daljavo!
Ne preostane vam drugega, kot da se dolgo prebijate skozi veliko izvorne kode, sprašujete starejše in izkušenejše kolege,
preden razumete, kako povezave v projektu delujejo. To je posledica tega, da ob pogledu na vmesnik razreda
CreditCard
ni mogoče ugotoviti globalnega stanja, ki ga je treba inicializirati. Celo pogled v izvorno kodo razreda
vam ne bo razkril, katero inicializacijsko metodo morate poklicati. V najboljšem primeru lahko najdete globalno spremenljivko,
do katere se dostopa, in iz nje poskusite uganiti, kako jo inicializirati.
Razredi v takem projektu so patološki lažnivci. Plačilna kartica se pretvarja, da jo zadostuje instancirati in poklicati
metodo charge()
. Skrito pa sodeluje z drugim razredom PaymentGateway
, ki predstavlja plačilni prehod.
Tudi njen vmesnik pravi, da jo je mogoče inicializirati samostojno, vendar v resnici potegne poverilnice iz neke konfiguracijske
datoteke in tako naprej. Razvijalcem, ki so to kodo napisali, je jasno, da CreditCard
potrebuje
PaymentGateway
. Kodo so napisali na ta način. Ampak za vsakogar, ki je v projektu nov, je to popolna uganka in
ovira učenje.
Kako situacijo popraviti? Enostavno. Pustite API-ju, da deklarira odvisnosti.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Opazite, kako so naenkrat povezave znotraj kode očitne. S tem, ko metoda charge()
deklarira, da potrebuje
PaymentGateway
, vam ni treba nikogar spraševati, kako je koda povezana. Veste, da morate ustvariti njeno instanco,
in ko to poskusite, naletite na to, da morate dodati dostopne parametre. Brez njih kode ne bi bilo mogoče niti zagnati.
In predvsem zdaj lahko plačilni prehod mockate, tako da se vam ob vsakem zagonu testa ne bo zaračunalo 100 dolarjev.
Globalno stanje povzroča, da se vaši objekti lahko skrivaj dostopajo do stvari, ki niso deklarirane v njihovem API-ju, in posledično delajo iz vaših API-jev patološke lažnivce.
Morda o tem prej niste tako razmišljali, ampak kadarkoli uporabljate globalno stanje, ustvarjate skrivne brezžične komunikacijske kanale. Strašljivo delovanje na daljavo sili razvijalce, da berejo vsako vrstico kode, da bi razumeli potencialne interakcije, zmanjšuje produktivnost razvijalcev in zmede nove člane ekipe. Če ste vi tisti, ki ste kodo ustvarili, poznate dejanske odvisnosti, ampak vsakdo, ki pride za vami, je nemočen.
Ne pišite kode, ki izkorišča globalno stanje, dajte prednost posredovanju odvisnosti. Torej dependency injection.
Krhkost globalnega stanja
V kodi, ki uporablja globalno stanje in singletone, nikoli ni gotovo, kdaj in kdo je to stanje spremenil. To tveganje se pojavlja že pri inicializaciji. Naslednja koda naj bi ustvarila povezavo s podatkovno bazo in inicializirala plačilni prehod, vendar nenehno meče izjemo in iskanje vzroka je izjemno dolgotrajno:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Morate podrobno pregledovati kodo, da ugotovite, da objekt PaymentGateway
brezžično dostopa do drugih objektov,
od katerih nekateri zahtevajo povezavo s podatkovno bazo. Torej je treba inicializirati podatkovno bazo prej kot
PaymentGateway
. Vendar dimna zavesa globalnega stanja to pred vami skriva. Koliko časa bi prihranili, če API
posameznih razredov ne bi lagal in bi deklariral svoje odvisnosti?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);
Podobna težava se pojavlja tudi pri uporabi globalnega dostopa do povezave s podatkovno bazo:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
Pri klicu metode save()
ni gotovo, ali je bila povezava s podatkovno bazo že ustvarjena in kdo nosi odgovornost
za njeno ustvarjanje. Če želimo na primer spreminjati povezavo s podatkovno bazo med izvajanjem, na primer zaradi testov, bi
morali najverjetneje ustvariti dodatne metode, kot na primer DB::reconnect(...)
ali
DB::reconnectForTest()
.
Razmislimo o primeru:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Kje imamo gotovost, da se pri klicu $article->save()
res uporablja testna podatkovna baza? Kaj če je metoda
Foo::doSomething()
spremenila globalno povezavo s podatkovno bazo? Za ugotovitev bi morali pregledati izvorno kodo
razreda Foo
in verjetno tudi mnogih drugih razredov. Ta pristop bi prinesel le kratkoročen odgovor, saj se situacija
lahko v prihodnosti spremeni.
In kaj če povezavo s podatkovno bazo premaknemo v statično spremenljivko znotraj razreda Article
?
class Article
{
private static DB $db;
public static function setDb(DB $db): void
{
self::$db = $db;
}
public function save(): void
{
self::$db->insert(/* ... */);
}
}
S tem se sploh nič ni spremenilo. Težava je globalno stanje in popolnoma vseeno je, v katerem razredu se skriva. V tem
primeru, enako kot v prejšnjem, nimamo pri klicu metode $article->save()
nobenega namiga o tem, v katero bazo
podatkov se bo zapisalo. Kdorkoli na drugem koncu aplikacije je lahko kadarkoli z Article::setDb()
bazo podatkov
spremenil. Nam pod rokami.
Globalno stanje naredi našo aplikacijo izjemno krhko.
Obstaja pa preprost način, kako se s to težavo spopasti. Zadostuje, da API deklarira odvisnosti, s čimer se zagotovi pravilna funkcionalnost.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Zahvaljujoč temu pristopu odpade skrb za skrite in nepričakovane spremembe povezave z bazo podatkov. Zdaj imamo gotovost, kam se članek shranjuje in nobene spremembe kode znotraj druge nepovezane razreda že ne morejo situacije spremeniti. Koda ni več krhka, ampak stabilna.
Ne pišite kode, ki izkorišča globalno stanje, dajte prednost posredovanju odvisnosti. Torej dependency injection.
Singleton
Singleton je načrtovalski vzorec, ki po definiciji iz znane publikacije Gang of Four omejuje razred na eno samo instanco in ponuja globalni dostop do nje. Implementacija tega vzorca se običajno podobna naslednji kodi:
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// in druge metode, ki opravljajo funkcije danega razreda
}
Na žalost singleton v aplikacijo uvaja globalno stanje. In kot smo si pokazali zgoraj, je globalno stanje nezaželeno. Zato je singleton obravnavan kot antipattern.
Ne uporabljajte v svoji kodi singletonov in jih nadomestite z drugimi mehanizmi. Singletonov resnično ne potrebujete. Če pa
morate zagotoviti obstoj ene same instance razreda za celotno aplikacijo, pustite to DI vsebniku. Ustvarite tako aplikacijski singleton, ali
storitev. S tem se razred preneha ukvarjati z zagotavljanjem svoje lastne edinstvenosti (tj. ne bo imel metode
getInstance()
in statične spremenljivke) in bo opravljal samo svoje funkcije. Tako ne bo več kršil načela ene
same odgovornosti.
Globalno stanje proti testom
Pri pisanju testov predpostavljamo, da je vsak test izolirana enota in da vanj ne vstopa nobeno zunanje stanje. In nobeno stanje testov ne zapušča. Po zaključku testa bi moralo biti vse povezano stanje s testom samodejno odstranjeno z garbage collectorjem. Zahvaljujoč temu so testi izolirani. Zato lahko teste izvajamo v poljubnem vrstnem redu.
Če pa so prisotna globalna stanja/singletoni, se vse te prijetne predpostavke razblinijo. Stanje lahko vstopa v test in izstopa iz njega. Naenkrat lahko postane pomemben vrstni red testov.
Da bi sploh lahko testirali singletone, morajo razvijalci pogosto sprostiti njihove lastnosti, na primer tako, da dovolijo
zamenjavo instance z drugo. Take rešitve so v najboljšem primeru hack, ki ustvarja težko vzdržljivo in razumljivo kodo. Vsak
test ali metoda tearDown()
, ki vpliva na katero koli globalno stanje, mora te spremembe vrniti nazaj.
Globalno stanje je največja bolečina pri unit testiranju!
Kako situacijo popraviti? Enostavno. Ne pišite kode, ki izkorišča singletone, dajte prednost posredovanju odvisnosti. Torej dependency injection.
Globalne konstante
Globalno stanje se ne omejuje samo na uporabo singletonov in statičnih spremenljivk, ampak se lahko nanaša tudi na globalne konstante.
Konstante, katerih vrednost nam ne prinaša nobene nove (M_PI
) ali koristne
(PREG_BACKTRACK_LIMIT_ERROR
) informacije, so nedvomno v redu. Nasprotno pa konstante, ki služijo kot način, kako
brezžično posredovati informacijo znotraj kode, niso nič drugega kot skrita odvisnost. Kot na primer
LOG_FILE
v naslednjem primeru. Uporaba konstante FILE_APPEND
je popolnoma pravilna.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
V tem primeru bi morali deklarirati parameter v konstruktorju razreda Foo
, da postane del API-ja:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Zdaj lahko posredujemo informacijo o poti do datoteke za beleženje in jo enostavno spreminjamo po potrebi, kar olajša testiranje in vzdrževanje kode.
Globalne funkcije in statične metode
Želimo poudariti, da sama uporaba statičnih metod in globalnih funkcij ni problematična. Razložili smo, v čem je
neprimernost uporabe DB::insert()
in podobnih metod, vendar je vedno šlo le za zadevo globalnega stanja, ki je
shranjeno v neki statični spremenljivki. Metoda DB::insert()
zahteva obstoj statične spremenljivke, ker je v njej
shranjena povezava z bazo podatkov. Brez te spremenljivke bi bilo nemogoče metodo implementirati.
Uporaba determinističnih statičnih metod in funkcij, kot na primer DateTime::createFromFormat()
,
Closure::fromCallable
, strlen()
in mnogih drugih, je v popolnem skladu z dependency injection. Te
funkcije vedno vračajo enake rezultate iz enakih vhodnih parametrov in so torej predvidljive. Ne uporabljajo nobenega globalnega
stanja.
Obstajajo pa tudi funkcije v PHP, ki niso deterministične. K njim spada na primer funkcija htmlspecialchars()
.
Njen tretji parameter $encoding
, če ni naveden, ima kot privzeto vrednost vrednost konfiguracijske možnosti
ini_get('default_charset')
. Zato se priporoča ta parameter vedno navesti in preprečiti morebitno nepredvidljivo
obnašanje funkcije. Nette to dosledno počne.
Nekatere funkcije, kot na primer strtolower()
, strtoupper()
in podobne, so se v nedavni preteklosti
nedeterministično obnašale in bile odvisne od nastavitve setlocale()
. To je povzročalo veliko zapletov,
najpogosteje pri delu s turškim jezikom. Ta namreč razlikuje malo in veliko črko I
s piko in brez pike. Tako je
strtolower('I')
vračalo znak ı
in strtoupper('i')
znak İ
, kar je vodilo
k temu, da so aplikacije začele povzročati vrsto skrivnostnih napak. Ta težava pa je bila odpravljena v PHP različici
8.2 in funkcije niso več odvisne od locale.
Gre za lep primer, kako je globalno stanje mučilo na tisoče razvijalcev po vsem svetu. Rešitev je bila zamenjava z dependency injection.
Kdaj je mogoče uporabiti globalno stanje?
Obstajajo določene specifične situacije, ko je mogoče izkoristiti globalno stanje. Na primer pri razhroščevanju kode, ko morate izpisati vrednost spremenljivke ali izmeriti trajanje določenega dela programa. V takih primerih, ki se nanašajo na začasna dejanja, ki bodo kasneje odstranjena iz kode, je mogoče legitimno izkoristiti globalno dostopen dumper ali štoparico. Ti orodji namreč niso del načrtovanja kode.
Drug primer so funkcije za delo z regularnimi izrazi preg_*
, ki interno shranjujejo prevedene regularne izraze
v statični predpomnilnik v pomnilniku. Ko torej kličete isti regularni izraz večkrat na različnih mestih kode, se prevede
samo enkrat. Predpomnilnik varčuje z zmogljivostjo in hkrati je za uporabnika popolnoma neviden, zato lahko tako uporabo
štejemo za legitimno.
Povzetek
Pregledali smo, zakaj ima smisel:
- Odstraniti vse statične spremenljivke iz kode
- Deklarirati odvisnosti
- In uporabljati dependency injection
Ko razmišljate o načrtovanju kode, mislite na to, da vsak static $foo
predstavlja težavo. Da bi vaša koda
bila okolje, ki spoštuje DI, je nujno popolnoma izkoreniniti globalno stanje in ga nadomestiti z dependency injection.
Med tem procesom morda ugotovite, da je treba razred razdeliti, ker ima več kot eno odgovornost. Ne bojte se tega; prizadevajte si za načelo ene same odgovornosti.
Rad bi se zahvalil Mišku Heveryju, čigar članki, kot je Flaw: Brittle Global State & Singletons, so osnova tega poglavja.