Stare globală și singleton-uri
Avertisment: Următoarele construcții sunt un semn al unui cod prost proiectat:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
saustatic::$var
Apar unele dintre aceste construcții în codul dvs.? Atunci aveți ocazia să îl îmbunătățiți. Poate vă gândiți că sunt construcții obișnuite, pe care le vedeți poate chiar și în soluții demonstrative ale diverselor biblioteci și framework-uri. Dacă este așa, atunci designul codului lor nu este bun.
Acum nu vorbim deloc despre vreo puritate academică. Toate aceste construcții au un lucru în comun: utilizează starea globală. Și aceasta are un impact distructiv asupra calității codului. Clasele mint despre dependențele lor. Codul devine imprevizibil. Încurcă programatorii și le reduce eficiența.
În acest capitol vom explica de ce este așa și cum să evitați starea globală.
Cuplare globală
Într-o lume ideală, un obiect ar trebui să poată comunica doar cu obiectele care i-au fost transmise direct. Dacă creez două obiecte
A
și B
și nu transmit niciodată o referință între ele, atunci nici A
, nici
B
, nu pot ajunge la celălalt obiect sau să îi modifice starea. Aceasta este o proprietate foarte dorită a
codului. Este similar cu situația în care aveți o baterie și un bec; becul nu va lumina până nu îl conectați la baterie
cu un fir.
Dar acest lucru nu este valabil pentru variabilele globale (statice) sau singleton-uri. Obiectul A
ar putea ajunge
fără fir la obiectul C
și să îl modifice fără nicio transmitere de referință, prin apelarea
C::changeSomething()
. Dacă obiectul B
se agață și el de C
global, atunci A
și B
se pot influența reciproc prin intermediul C
.
Utilizarea variabilelor globale introduce în sistem o nouă formă de cuplare fără fir, care nu este vizibilă din
exterior. Creează o perdea de fum care complică înțelegerea și utilizarea codului. Pentru ca dezvoltatorii să înțeleagă
cu adevărat dependențele, trebuie să citească fiecare linie de cod sursă. În loc să se familiarizeze pur și simplu cu
interfața claselor. Mai mult, este o cuplare complet inutilă. Starea globală se folosește deoarece este ușor accesibilă de
oriunde și permite, de exemplu, scrierea în baza de date prin metoda globală (statică) DB::insert()
. Dar, așa
cum vom arăta, avantajul pe care îl aduce este nesemnificativ, în timp ce complicațiile pe care le provoacă sunt fatale.
Din punct de vedere comportamental, nu există nicio diferență între o variabilă globală și una statică. Sunt la fel de dăunătoare.
Acțiune înfricoșătoare la distanță
“Acțiune înfricoșătoare la distanță” – așa a numit celebrul Albert Einstein în 1935 un fenomen din fizica cuantică care îi dădea fiori. Este vorba despre inseparabilitatea cuantică, a cărei particularitate este că atunci când măsori informația despre o particulă, influențezi instantaneu cealaltă particulă, chiar dacă sunt la milioane de ani-lumină distanță. Ceea ce pare să încalce legea fundamentală a universului, că nimic nu se poate propaga mai repede decât lumina.
În lumea software, putem numi “acțiune înfricoșătoare la distanță” situația în care pornim un proces despre care credem că este izolat (deoarece nu i-am transmis nicio referință), dar în locuri îndepărtate ale sistemului apar interacțiuni neașteptate și modificări de stare despre care nu aveam nicio idee. Acest lucru se poate întâmpla doar prin intermediul stării globale.
Imaginați-vă că vă alăturați unei echipe de dezvoltatori ai unui proiect care are o bază de cod extinsă și matură. Noul dvs. șef vă cere să implementați o nouă funcționalitate și, ca un dezvoltator bun, începeți prin scrierea unui test. Dar, fiind nou în proiect, faceți multe teste exploratorii de tipul “ce se întâmplă dacă apelez această metodă”. Și încercați să scrieți următorul test:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // numărul cardului dvs.
$cc->charge(100);
}
Rulați codul, poate de mai multe ori, și după un timp observați pe mobil notificări de la bancă că la fiecare rulare s-au retras 100 de dolari de pe cardul dvs. de plată 🤦♂️
Cum naiba a putut testul să provoace retragerea reală de bani? Operarea cu un card de plată nu este ușoară. Trebuie să comunicați cu un serviciu web terț, trebuie să cunoașteți URL-ul acestui serviciu web, trebuie să vă autentificați și așa mai departe. Nicio informație de acest gen nu este conținută în test. Mai rău, nici măcar nu știți unde sunt prezente aceste informații și, prin urmare, nici cum să mock-uiți dependențele externe, astfel încât fiecare rulare să nu ducă la retragerea din nou a 100 de dolari. Și cum trebuia să știți, ca dezvoltator nou, că ceea ce urmați să faceți va duce la sărăcirea cu 100 de dolari?
Aceasta este acțiunea înfricoșătoare la distanță!
Nu vă rămâne decât să scormoniți îndelung în multe coduri sursă, să întrebați colegii mai vechi și mai
experimentați, până când înțelegeți cum funcționează legăturile în proiect. Acest lucru este cauzat de faptul că,
privind interfața clasei CreditCard
, nu se poate identifica starea globală care trebuie inițializată. Nici măcar
privirea în codul sursă al clasei nu vă dezvăluie ce metodă de inițializare trebuie să apelați. În cel mai bun caz,
puteți găsi o variabilă globală la care se accesează și din ea să încercați să ghiciți cum să o inițializați.
Clasele dintr-un astfel de proiect sunt mincinoși patologici. Cardul de plată pretinde că este suficient să îl
instanțiați și să apelați metoda charge()
. În secret, însă, colaborează cu o altă clasă
PaymentGateway
, care reprezintă poarta de plată. Și interfața sa spune că poate fi inițializată separat, dar
în realitate își extrage credențialele dintr-un fișier de configurare și așa mai departe. Dezvoltatorilor care au scris
acest cod le este clar că CreditCard
are nevoie de PaymentGateway
. Au scris codul în acest fel. Dar
pentru oricine este nou în proiect, este un mister total și împiedică învățarea.
Cum să reparați situația? Ușor. Lăsați API-ul să declare dependențele.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Observați cum legăturile din interiorul codului devin brusc evidente. Prin faptul că metoda charge()
declară
că are nevoie de PaymentGateway
, nu trebuie să întrebați pe nimeni cum este legat codul. Știți că trebuie să
creați instanța sa și, când încercați să faceți acest lucru, veți descoperi că trebuie să furnizați parametrii de
acces. Fără ei, codul nici măcar nu ar rula.
Și, cel mai important, acum puteți mock-ui poarta de plată, astfel încât să nu vi se taxeze 100 de dolari la fiecare rulare a testului.
Starea globală face ca obiectele dvs. să poată accesa în secret lucruri care nu sunt declarate în API-ul lor și, în consecință, transformă API-urile dvs. în mincinoși patologici.
Poate că nu v-ați gândit la asta înainte în acest fel, dar ori de câte ori utilizați starea globală, creați canale de comunicare secrete fără fir. Acțiunea înfricoșătoare la distanță îi obligă pe dezvoltatori să citească fiecare linie de cod pentru a înțelege interacțiunile potențiale, reduce productivitatea dezvoltatorilor și îi încurcă pe noii membri ai echipei. Dacă sunteți cel care a creat codul, cunoașteți dependențele reale, dar oricine vine după dvs. este neajutorat.
Nu scrieți cod care utilizează starea globală, preferați transmiterea dependențelor. Adică injecția de dependență.
Fragilitatea stării globale
În codul care utilizează starea globală și singleton-uri, nu este niciodată sigur când și cine a modificat această stare. Acest risc apare deja la inițializare. Următorul cod ar trebui să creeze o conexiune la baza de date și să inițializeze poarta de plată, însă aruncă constant o excepție și găsirea cauzei este extrem de anevoioasă:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Trebuie să parcurgeți codul în detaliu pentru a descoperi că obiectul PaymentGateway
accesează fără fir
alte obiecte, dintre care unele necesită o conexiune la baza de date. Prin urmare, este necesar să inițializați baza de date
înainte de PaymentGateway
. Cu toate acestea, perdeaua de fum a stării globale ascunde acest lucru de dvs. Cât timp
ați economisi dacă API-urile claselor individuale nu ar minți și și-ar declara dependențele?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);
O problemă similară apare și la utilizarea accesului global la conexiunea bazei de date:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
La apelarea metodei save()
, nu este sigur dacă a fost deja creată conexiunea la baza de date și cine poartă
responsabilitatea pentru crearea sa. Dacă dorim, de exemplu, să schimbăm conexiunea la baza de date în timpul rulării, de
exemplu pentru teste, ar trebui probabil să creăm alte metode precum DB::reconnect(...)
sau
DB::reconnectForTest()
.
Să luăm în considerare un exemplu:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Unde avem certitudinea că la apelarea $article->save()
se utilizează într-adevăr baza de date de test? Ce
se întâmplă dacă metoda Foo::doSomething()
a schimbat conexiunea globală la baza de date? Pentru a afla, ar
trebui să examinăm codul sursă al clasei Foo
și probabil și al multor altor clase. Această abordare ar aduce
însă doar un răspuns pe termen scurt, deoarece situația se poate schimba în viitor.
Și ce se întâmplă dacă mutăm conexiunea la baza de date într-o variabilă statică în interiorul clasei
Article
?
class Article
{
private static DB $db;
public static function setDb(DB $db): void
{
self::$db = $db;
}
public function save(): void
{
self::$db->insert(/* ... */);
}
}
Acest lucru nu a schimbat absolut nimic. Problema este starea globală și este complet irelevant în ce clasă se ascunde. În
acest caz, la fel ca în cel precedent, nu avem niciun indiciu la apelarea metodei $article->save()
despre în ce
bază de date se va scrie. Oricine de la celălalt capăt al aplicației ar fi putut schimba oricând baza de date folosind
Article::setDb()
. Sub nasul nostru.
Starea globală face aplicația noastră extrem de fragilă.
Există însă o modalitate simplă de a aborda această problemă. Este suficient să lăsăm API-ul să declare dependențele, asigurându-se astfel funcționalitatea corectă.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Datorită acestei abordări, dispare teama de modificări ascunse și neașteptate ale conexiunii la baza de date. Acum avem certitudinea unde se salvează articolul și nicio modificare a codului în interiorul altei clase nelegate nu mai poate schimba situația. Codul nu mai este fragil, ci stabil.
Nu scrieți cod care utilizează starea globală, preferați transmiterea dependențelor. Adică injecția de dependență.
Singleton
Singleton este un pattern de design care, conform “definiției” din celebra publicație Gang of Four, limitează clasa la o singură instanță și oferă acces global la aceasta. Implementarea acestui pattern seamănă de obicei cu următorul cod:
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// și alte metode care îndeplinesc funcțiile clasei respective
}
Din păcate, singleton introduce starea globală în aplicație. Și, așa cum am arătat mai sus, starea globală este nedorită. Prin urmare, singleton este considerat un antipattern.
Nu utilizați singleton-uri în codul dvs. și înlocuiți-le cu alte mecanisme. Chiar nu aveți nevoie de singleton-uri. Cu
toate acestea, dacă trebuie să garantați existența unei singure instanțe a clasei pentru întreaga aplicație, lăsați acest
lucru pe seama containerului DI. Creați astfel un singleton
de aplicație, adică un serviciu. Astfel, clasa încetează să se mai ocupe de asigurarea propriei unicități (adică nu va
avea metoda getInstance()
și variabila statică) și va îndeplini doar funcțiile sale. Astfel, nu va mai încălca
principiul responsabilității unice.
Stare globală versus teste
La scrierea testelor, presupunem că fiecare test este o unitate izolată și că nicio stare externă nu intră în el. Și nicio stare nu părăsește testele. După finalizarea testului, toată starea asociată cu testul ar trebui eliminată automat de garbage collector. Datorită acestui fapt, testele sunt izolate. Prin urmare, putem rula testele în orice ordine.
Cu toate acestea, dacă sunt prezente stări globale/singleton-uri, toate aceste presupuneri plăcute se destramă. Starea poate intra și ieși din test. Brusc, ordinea testelor poate conta.
Pentru a putea testa singleton-urile, dezvoltatorii trebuie adesea să le relaxeze proprietățile, de exemplu, permițând
înlocuirea instanței cu alta. Astfel de soluții sunt, în cel mai bun caz, hack-uri care creează cod dificil de întreținut
și de înțeles. Fiecare test sau metodă tearDown()
care afectează orice stare globală trebuie să anuleze aceste
modificări.
Starea globală este cea mai mare durere de cap la testarea unitară!
Cum să reparați situația? Ușor. Nu scrieți cod care utilizează singleton-uri, preferați transmiterea dependențelor. Adică injecția de dependență.
Constante globale
Starea globală nu se limitează doar la utilizarea singleton-urilor și a variabilelor statice, ci se poate referi și la constantele globale.
Constantele a căror valoare nu ne aduce nicio informație nouă (M_PI
) sau utilă
(PREG_BACKTRACK_LIMIT_ERROR
) sunt în mod clar în regulă. Dimpotrivă, constantele care servesc ca o modalitate de
a transmite fără fir informații în interiorul codului nu sunt altceva decât o dependență ascunsă. Cum ar fi
LOG_FILE
în exemplul următor. Utilizarea constantei FILE_APPEND
este complet corectă.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
În acest caz, ar trebui să declarăm un parametru în constructorul clasei Foo
, pentru ca acesta să devină
parte a API-ului:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Acum putem transmite informația despre calea către fișierul de logare și o putem schimba ușor după nevoie, ceea ce facilitează testarea și întreținerea codului.
Funcții globale și metode statice
Dorim să subliniem că utilizarea în sine a metodelor statice și a funcțiilor globale nu este problematică. Am explicat
în ce constă inadecvarea utilizării DB::insert()
și a metodelor similare, dar întotdeauna a fost vorba doar de
o chestiune de stare globală, care este stocată într-o variabilă statică. Metoda DB::insert()
necesită
existența unei variabile statice, deoarece în ea este stocată conexiunea la baza de date. Fără această variabilă, ar fi
imposibil să se implementeze metoda.
Utilizarea metodelor statice și a funcțiilor deterministe, precum DateTime::createFromFormat()
,
Closure::fromCallable
, strlen()
și multe altele, este în perfectă concordanță cu injecția de
dependență. Aceste funcții returnează întotdeauna aceleași rezultate pentru aceiași parametri de intrare și sunt deci
previzibile. Nu utilizează nicio stare globală.
Există însă și funcții în PHP care nu sunt deterministe. Printre acestea se numără, de exemplu, funcția
htmlspecialchars()
. Al treilea său parametru $encoding
, dacă nu este specificat, are ca valoare
implicită valoarea opțiunii de configurare ini_get('default_charset')
. De aceea se recomandă specificarea
întotdeauna a acestui parametru și prevenirea astfel a unui eventual comportament imprevizibil al funcției. Nette face acest
lucru în mod consecvent.
Unele funcții, precum strtolower()
, strtoupper()
și altele similare, s-au comportat nedeterminist
în trecutul recent și au fost dependente de setarea setlocale()
. Acest lucru a cauzat multe complicații, cel mai
adesea la lucrul cu limba turcă. Aceasta distinge literele mici și mari I
cu și fără punct. Astfel,
strtolower('I')
returna caracterul ı
și strtoupper('i')
caracterul İ
, ceea
ce a dus la faptul că aplicațiile au început să provoace o serie de erori misterioase. Această problemă a fost însă
eliminată în PHP versiunea 8.2 și funcțiile nu mai sunt dependente de locale.
Este un exemplu frumos despre cum starea globală a chinuit mii de dezvoltatori din întreaga lume. Soluția a fost înlocuirea sa cu injecția de dependență.
Când este posibil să se utilizeze starea globală?
Există anumite situații specifice în care este posibil să se utilizeze starea globală. De exemplu, la depanarea codului, când trebuie să afișați valoarea unei variabile sau să măsurați durata unei anumite părți a programului. În astfel de cazuri, care se referă la acțiuni temporare ce vor fi ulterior eliminate din cod, este posibil să se utilizeze legitim un dumper sau un cronometru disponibil global. Aceste instrumente nu fac parte din designul codului.
Un alt exemplu sunt funcțiile pentru lucrul cu expresii regulate preg_*
, care stochează intern expresiile
regulate compilate într-un cache static în memorie. Astfel, când apelați aceeași expresie regulată de mai multe ori în
diferite locuri ale codului, aceasta se compilează o singură dată. Cache-ul economisește performanța și, în același timp,
este complet invizibil pentru utilizator, prin urmare o astfel de utilizare poate fi considerată legitimă.
Rezumat
Am discutat de ce are sens:
- Să eliminați toate variabilele statice din cod
- Să declarați dependențele
- Și să utilizați injecția de dependență
Când vă gândiți la designul codului, gândiți-vă că fiecare static $foo
reprezintă o problemă. Pentru ca
codul dvs. să fie un mediu care respectă DI, este necesar să eliminați complet starea globală și să o înlocuiți folosind
injecția de dependență.
În timpul acestui proces, este posibil să descoperiți că este necesar să împărțiți clasa, deoarece are mai mult de o responsabilitate. Nu vă temeți de acest lucru; urmăriți principiul responsabilității unice.
Aș dori să îi mulțumesc lui Miško Hevery, ale cărui articole, precum Flaw: Brittle Global State & Singletons, stau la baza acestui capitol.