Stato globale e singleton
Avviso: I seguenti costrutti sono un segno di codice mal progettato:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
ostatic::$var
Alcuni di questi costrutti compaiono nel tuo codice? Allora hai l'opportunità di migliorarlo. Forse pensi che si tratti di costrutti comuni, che vedi anche in soluzioni di esempio di varie librerie e framework. Se è così, allora il design del loro codice non è buono.
Ora non stiamo certo parlando di una sorta di purezza accademica. Tutti questi costrutti hanno una cosa in comune: utilizzano lo stato globale. E questo ha un impatto distruttivo sulla qualità del codice. Le classi mentono sulle loro dipendenze. Il codice diventa imprevedibile. Confonde i programmatori e riduce la loro efficienza.
In questo capitolo spiegheremo perché è così e come evitare lo stato globale.
Accoppiamento globale
In un mondo ideale, un oggetto dovrebbe essere in grado di comunicare solo con oggetti che gli sono stati passati direttamente. Se creo due oggetti
A
e B
e non passo mai un riferimento tra di loro, allora né A
né B
possono
accedere all'altro oggetto o modificarne lo stato. Questa è una proprietà molto desiderabile del codice. È simile a quando hai
una batteria e una lampadina; la lampadina non si accenderà finché non la colleghi alla batteria con un filo.
Questo però non vale per le variabili globali (statiche) o i singleton. L'oggetto A
potrebbe accedere senza
fili all'oggetto C
e modificarlo senza alcun passaggio di riferimento, chiamando
C::changeSomething()
. Se anche l'oggetto B
si appropria del C
globale, allora
A
e B
possono influenzarsi a vicenda tramite C
.
L'uso di variabili globali introduce nel sistema una nuova forma di accoppiamento senza fili, che non è visibile
dall'esterno. Crea una cortina fumogena che complica la comprensione e l'uso del codice. Affinché gli sviluppatori comprendano
veramente le dipendenze, devono leggere ogni riga del codice sorgente. Invece di familiarizzare semplicemente con le interfacce
delle classi. Si tratta inoltre di un accoppiamento del tutto inutile. Lo stato globale viene utilizzato perché è facilmente
accessibile da qualsiasi luogo e consente, ad esempio, di scrivere nel database tramite il metodo globale (statico)
DB::insert()
. Ma come mostreremo, il vantaggio che ciò porta è minimo, mentre le complicazioni che causa sono
fatali.
Dal punto di vista del comportamento, non c'è differenza tra una variabile globale e una statica. Sono ugualmente dannose.
Azione spettrale a distanza
“Azione spettrale a distanza” – così famosamente chiamò nel 1935 Albert Einstein un fenomeno della fisica quantistica che gli faceva venire la pelle d'oca. Si tratta dell'entanglement quantistico, la cui particolarità è che quando misuri l'informazione su una particella, influenzi istantaneamente l'altra particella, anche se sono distanti milioni di anni luce. Ciò sembra violare la legge fondamentale dell'universo, secondo cui nulla può propagarsi più velocemente della luce.
Nel mondo del software, possiamo chiamare “azione spettrale a distanza” una situazione in cui avviamo un processo che riteniamo isolato (perché non gli abbiamo passato alcun riferimento), ma in luoghi remoti del sistema si verificano interazioni e cambiamenti di stato imprevisti, di cui non avevamo idea. Ciò può accadere solo tramite lo stato globale.
Immagina di unirti a un team di sviluppatori di un progetto che ha una vasta e matura base di codice. Il tuo nuovo capo ti chiede di implementare una nuova funzionalità e tu, da bravo sviluppatore, inizi scrivendo un test. Ma poiché sei nuovo nel progetto, fai molti test esplorativi del tipo “cosa succede se chiamo questo metodo”. E provi a scrivere il seguente test:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // il numero della tua carta
$cc->charge(100);
}
Esegui il codice, magari più volte, e dopo un po' noti notifiche dalla banca sul cellulare, che ad ogni esecuzione sono stati addebitati 100 dollari dalla tua carta di pagamento 🤦♂️
Come diavolo ha fatto il test a causare un addebito reale di denaro? Operare con una carta di pagamento non è facile. Devi comunicare con un servizio web di terze parti, devi conoscere l'URL di questo servizio web, devi autenticarti e così via. Nessuna di queste informazioni è contenuta nel test. Peggio ancora, non sai nemmeno dove queste informazioni siano presenti, e quindi nemmeno come mockare le dipendenze esterne, in modo che ogni esecuzione non porti a un nuovo addebito di 100 dollari. E come avresti dovuto sapere, come nuovo sviluppatore, che quello che stavi per fare ti avrebbe reso più povero di 100 dollari?
Questa è l'azione spettrale a distanza!
Non ti resta che scavare a lungo in un sacco di codice sorgente, chiedere ai colleghi più anziani ed esperti, prima di capire
come funzionano i legami nel progetto. Ciò è dovuto al fatto che guardando l'interfaccia della classe CreditCard
non è possibile determinare lo stato globale che deve essere inizializzato. Nemmeno uno sguardo al codice sorgente della classe
ti dirà quale metodo di inizializzazione devi chiamare. Nel migliore dei casi, puoi trovare una variabile globale a cui si accede
e da essa cercare di indovinare come inizializzarla.
Le classi in un tale progetto sono bugiarde patologiche. La carta di pagamento finge che basti istanziarla e chiamare il metodo
charge()
. Di nascosto, però, collabora con un'altra classe PaymentGateway
, che rappresenta il gateway
di pagamento. Anche la sua interfaccia dice che può essere inizializzata separatamente, ma in realtà estrae le credenziali da
qualche file di configurazione e così via. Agli sviluppatori che hanno scritto questo codice è chiaro che
CreditCard
ha bisogno di PaymentGateway
. Hanno scritto il codice in questo modo. Ma per chiunque sia
nuovo nel progetto, è un mistero assoluto e ostacola l'apprendimento.
Come risolvere la situazione? Facilmente. Lascia che l'API dichiari le dipendenze.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Nota come improvvisamente le interconnessioni all'interno del codice diventano evidenti. Poiché il metodo
charge()
dichiara di aver bisogno di PaymentGateway
, non devi chiedere a nessuno come è interconnesso
il codice. Sai che devi crearne un'istanza e, quando ci provi, ti imbatti nel fatto che devi fornire i parametri di accesso.
Senza di essi, il codice non potrebbe nemmeno essere eseguito.
E soprattutto, ora puoi mockare il gateway di pagamento, così non ti verranno addebitati 100 dollari ogni volta che esegui il test.
Lo stato globale fa sì che i tuoi oggetti possano accedere segretamente a cose che non sono dichiarate nella loro API e, di conseguenza, rende le tue API bugiarde patologiche.
Forse non ci avevi pensato in questo modo prima, ma ogni volta che usi lo stato globale, stai creando canali di comunicazione segreti senza fili. L'azione spettrale a distanza costringe gli sviluppatori a leggere ogni riga di codice per comprendere le potenziali interazioni, riduce la produttività degli sviluppatori e confonde i nuovi membri del team. Se sei tu quello che ha creato il codice, conosci le vere dipendenze, ma chiunque venga dopo di te è perso.
Non scrivere codice che utilizza lo stato globale, dai la preferenza al passaggio delle dipendenze. Cioè, dependency injection.
Fragilità dello stato globale
Nel codice che utilizza lo stato globale e i singleton, non è mai certo quando e chi ha modificato questo stato. Questo rischio si presenta già durante l'inizializzazione. Il seguente codice dovrebbe creare una connessione al database e inizializzare il gateway di pagamento, ma lancia costantemente un'eccezione e trovare la causa è estremamente lungo:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Devi esaminare attentamente il codice per scoprire che l'oggetto PaymentGateway
accede senza fili ad altri
oggetti, alcuni dei quali richiedono una connessione al database. Quindi è necessario inizializzare il database prima di
PaymentGateway
. Tuttavia, la cortina fumogena dello stato globale ti nasconde questo. Quanto tempo avresti
risparmiato se le API delle singole classi non avessero mentito e avessero dichiarato le loro dipendenze?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);
Un problema simile si presenta anche quando si utilizza l'accesso globale alla connessione del database:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
Quando si chiama il metodo save()
, non è certo se sia già stata creata una connessione al database e chi sia
responsabile della sua creazione. Se volessimo, ad esempio, cambiare la connessione al database durante l'esecuzione, magari per
i test, dovremmo probabilmente creare altri metodi come DB::reconnect(...)
o
DB::reconnectForTest()
.
Consideriamo un esempio:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Dove abbiamo la certezza che quando si chiama $article->save()
si stia effettivamente utilizzando il database
di test? E se il metodo Foo::doSomething()
avesse cambiato la connessione globale al database? Per scoprirlo,
dovremmo esaminare il codice sorgente della classe Foo
e probabilmente anche di molte altre classi. Questo approccio,
tuttavia, fornirebbe solo una risposta a breve termine, poiché la situazione potrebbe cambiare in futuro.
E se spostassimo la connessione al database in una variabile statica all'interno della classe Article
?
class Article
{
private static DB $db;
public static function setDb(DB $db): void
{
self::$db = $db;
}
public function save(): void
{
self::$db->insert(/* ... */);
}
}
Questo non cambia assolutamente nulla. Il problema è lo stato globale ed è del tutto indifferente in quale classe si
nasconda. In questo caso, come nel precedente, non abbiamo alcun indizio, quando chiamiamo il metodo
$article->save()
, su quale database verrà scritto. Chiunque all'altro capo dell'applicazione avrebbe potuto
cambiare il database in qualsiasi momento usando Article::setDb()
. Sotto il nostro naso.
Lo stato globale rende la nostra applicazione estremamente fragile.
Esiste tuttavia un modo semplice per affrontare questo problema. Basta lasciare che l'API dichiari le dipendenze, garantendo così la corretta funzionalità.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Grazie a questo approccio, scompare la preoccupazione per modifiche nascoste e impreviste della connessione al database. Ora abbiamo la certezza di dove viene salvato l'articolo e nessuna modifica del codice all'interno di un'altra classe non correlata può più cambiare la situazione. Il codice non è più fragile, ma stabile.
Non scrivere codice che utilizza lo stato globale, dai la preferenza al passaggio delle dipendenze. Cioè, dependency injection.
Singleton
Il Singleton è un design pattern che, secondo la definizione della nota pubblicazione Gang of Four, limita una classe a una singola istanza e offre un accesso globale ad essa. L'implementazione di questo pattern di solito assomiglia al seguente codice:
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// e altri metodi che svolgono le funzioni della classe data
}
Purtroppo, il singleton introduce uno stato globale nell'applicazione. E come abbiamo mostrato sopra, lo stato globale è indesiderabile. Pertanto, il singleton è considerato un antipattern.
Non utilizzare singleton nel tuo codice e sostituiscili con altri meccanismi. I singleton non ti servono davvero. Tuttavia, se
hai bisogno di garantire l'esistenza di una singola istanza di una classe per l'intera applicazione, lascialo fare al container DI. Crea così un singleton applicativo, ovvero un
servizio. In questo modo, la classe smette di occuparsi di garantire la propria unicità (cioè non avrà il metodo
getInstance()
e una variabile statica) e svolgerà solo le sue funzioni. Così smetterà di violare il principio di
singola responsabilità.
Stato globale versus test
Quando scriviamo test, presumiamo che ogni test sia un'unità isolata e che nessuno stato esterno vi entri. E nessuno stato lascia i test. Al termine del test, tutto lo stato correlato al test dovrebbe essere rimosso automaticamente dal garbage collector. Grazie a ciò, i test sono isolati. Pertanto, possiamo eseguire i test in qualsiasi ordine.
Tuttavia, se sono presenti stati globali/singleton, tutte queste piacevoli supposizioni crollano. Lo stato può entrare e uscire dal test. Improvvisamente, l'ordine dei test può avere importanza.
Per poter testare affatto i singleton, gli sviluppatori spesso devono allentare le loro proprietà, ad esempio permettendo di
sostituire l'istanza con un'altra. Tali soluzioni sono nel migliore dei casi un hack, che crea codice difficile da mantenere e
comprendere. Ogni test o metodo tearDown()
, che influenzi qualsiasi stato globale, deve annullare queste
modifiche.
Lo stato globale è il più grande mal di testa nel unit testing!
Come risolvere la situazione? Facilmente. Non scrivere codice che utilizza singleton, dai la preferenza al passaggio delle dipendenze. Cioè, dependency injection.
Costanti globali
Lo stato globale non si limita solo all'uso di singleton e variabili statiche, ma può riguardare anche costanti globali.
Le costanti il cui valore non ci porta alcuna nuova (M_PI
) o utile (PREG_BACKTRACK_LIMIT_ERROR
)
informazione, sono chiaramente a posto. Al contrario, le costanti che servono come modo per passare informazioni senza
fili all'interno del codice, non sono altro che una dipendenza nascosta. Come ad esempio LOG_FILE
nell'esempio
seguente. L'uso della costante FILE_APPEND
è del tutto corretto.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
In questo caso, dovremmo dichiarare un parametro nel costruttore della classe Foo
, affinché diventi parte
dell'API:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Ora possiamo passare l'informazione sul percorso del file per il logging e modificarla facilmente secondo necessità, il che facilita il testing e la manutenzione del codice.
Funzioni globali e metodi statici
Vogliamo sottolineare che l'uso stesso di metodi statici e funzioni globali non è problematico. Abbiamo spiegato perché l'uso
di DB::insert()
e metodi simili è inappropriato, ma si è sempre trattato solo di una questione di stato globale,
che è memorizzato in qualche variabile statica. Il metodo DB::insert()
richiede l'esistenza di una variabile
statica, perché in essa è memorizzata la connessione al database. Senza questa variabile, sarebbe impossibile implementare il
metodo.
L'uso di metodi statici e funzioni deterministiche, come ad esempio DateTime::createFromFormat()
,
Closure::fromCallable
, strlen()
e molte altre, è in perfetta armonia con la dependency injection.
Queste funzioni restituiscono sempre gli stessi risultati dagli stessi parametri di input e sono quindi prevedibili. Non
utilizzano alcuno stato globale.
Esistono tuttavia anche funzioni in PHP che non sono deterministiche. Tra queste c'è ad esempio la funzione
htmlspecialchars()
. Il suo terzo parametro $encoding
, se non specificato, ha come valore predefinito il
valore dell'opzione di configurazione ini_get('default_charset')
. Pertanto, si consiglia di specificare sempre questo
parametro per evitare un eventuale comportamento imprevedibile della funzione. Nette lo fa costantemente.
Alcune funzioni, come ad esempio strtolower()
, strtoupper()
e simili, in un passato recente si
comportavano in modo non deterministico ed erano dipendenti dall'impostazione setlocale()
. Ciò causava molte
complicazioni, più spesso quando si lavorava con la lingua turca. Questa, infatti, distingue sia la lettera minuscola che
maiuscola I
con e senza punto. Quindi strtolower('I')
restituiva il carattere ı
e
strtoupper('i')
il carattere İ
, il che portava le applicazioni a causare una serie di errori
misteriosi. Questo problema è stato tuttavia risolto nella versione PHP 8.2 e le funzioni non dipendono più dalla locale.
È un bell'esempio di come lo stato globale abbia tormentato migliaia di sviluppatori in tutto il mondo. La soluzione è stata sostituirlo con la dependency injection.
Quando è possibile utilizzare lo stato globale?
Esistono alcune situazioni specifiche in cui è possibile utilizzare lo stato globale. Ad esempio, durante il debugging del codice, quando è necessario stampare il valore di una variabile o misurare la durata di una certa parte del programma. In tali casi, che riguardano azioni temporanee che verranno successivamente rimosse dal codice, è possibile utilizzare legittimamente un dumper o un cronometro globalmente accessibili. Questi strumenti, infatti, non fanno parte del design del codice.
Un altro esempio sono le funzioni per lavorare con le espressioni regolari preg_*
, che internamente memorizzano le
espressioni regolari compilate in una cache statica in memoria. Quindi, quando chiami la stessa espressione regolare più volte in
punti diversi del codice, viene compilata solo una volta. La cache risparmia prestazioni ed è allo stesso tempo completamente
invisibile per l'utente, quindi tale utilizzo può essere considerato legittimo.
Riepilogo
Abbiamo discusso perché ha senso:
- Rimuovere tutte le variabili statiche dal codice
- Dichiarare le dipendenze
- E usare la dependency injection
Quando pensi al design del codice, tieni presente che ogni static $foo
rappresenta un problema. Affinché il tuo
codice sia un ambiente che rispetta DI, è indispensabile sradicare completamente lo stato globale e sostituirlo con la dependency
injection.
Durante questo processo, potresti scoprire che è necessario dividere la classe, perché ha più di una responsabilità. Non averne paura; cerca di rispettare il principio di singola responsabilità.
Vorrei ringraziare Miško Hevery, i cui articoli, come Flaw: Brittle Global State & Singletons, sono alla base di questo capitolo.