Stato globale e singleton
Attenzione: i costrutti seguenti sono sintomi di codice progettato male:
Foo::getInstance()DB::insert(...)Article::setDb($db)ClassName::$varoppurestatic::$var
Qualcuno di questi costrutti compare nel vostro codice? Se sì, avete un'occasione per migliorare. Penserete forse che siano costrutti comuni, magari visti nelle soluzioni di esempio di varie librerie e framework. Se è così, la progettazione del loro codice è difettosa.
Non stiamo parlando di una purezza accademica. Tutti questi costrutti hanno una caratteristica in comune: usano lo stato globale. E lo stato globale ha un effetto deleterio sulla qualità del codice. Le classi diventano ingannevoli riguardo alle proprie dipendenze. Il codice diventa imprevedibile. Confonde gli sviluppatori e ne riduce l'efficienza.
In questo capitolo spiegheremo perché è così e come evitare lo stato globale.
Intreccio globale
In un mondo ideale un oggetto dovrebbe comunicare solo con gli 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 allo stato dell'altro o modificarlo. È una proprietà molto desiderabile del codice. È come avere una batteria e una
lampadina: la lampadina non si accende finché non la collegate alla batteria con un filo.
Questo però non vale per le variabili globali (statiche) o per i singleton. L'oggetto A potrebbe accedere
senza fili all'oggetto C e modificarlo senza che sia passato alcun riferimento, chiamando
C::changeSomething(). Se anche l'oggetto B attinge al C globale, allora A e
B possono influenzarsi a vicenda attraverso C.
L'uso delle variabili globali introduce una nuova forma di accoppiamento senza fili, invisibile dall'esterno. Crea una
cortina di fumo che rende il codice più difficile da capire e da usare. Per cogliere davvero le dipendenze, gli sviluppatori
devono leggere ogni riga del codice sorgente, invece di affidarsi alle interfacce delle classi. Per di più questo accoppiamento
è del tutto superfluo. Lo stato globale si usa perché è facilmente accessibile da ovunque e permette, per esempio, di scrivere
su un database tramite un metodo globale (statico) DB::insert(). Come mostreremo, però, la comodità percepita è
minima rispetto alle gravi complicazioni che introduce.
Dal punto di vista del comportamento non c'è differenza tra una variabile globale e una statica. Sono ugualmente dannose.
L'inquietante azione a distanza
“Inquietante azione a distanza”: così Albert Einstein chiamava un fenomeno della fisica quantistica che gli metteva i brividi. Si riferisce all'entanglement quantistico, dove misurare una proprietà di una particella influisce istantaneamente su un'altra particella a essa legata, indipendentemente dalla distanza che le separa, anche milioni di anni luce, il che sembra violare la legge fondamentale dell'universo secondo cui nulla può viaggiare più veloce della luce.
Nel mondo del software l'“inquietante azione a distanza” descrive la situazione in cui eseguiamo un processo che crediamo isolato (perché non è stata passata esplicitamente alcuna dipendenza), eppure in parti lontane del sistema avvengono interazioni e cambiamenti di stato inattesi, a nostra insaputa. Questo può accadere solo attraverso lo stato globale.
Immaginate di entrare in un team di sviluppo, in un progetto con una base di codice grande e matura. Il vostro nuovo responsabile vi chiede di implementare una nuova funzionalità e voi, da bravi sviluppatori, iniziate scrivendo un test. Ma poiché siete nuovi nel progetto, fate molti test esplorativi del tipo “cosa succede se chiamo questo metodo”. E provate a scrivere il test seguente:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // il numero della vostra carta
$cc->charge(100);
}
Eseguite il codice, magari più volte, e dopo un po' notate le notifiche della banca sul telefono: a ogni esecuzione sono stati addebitati 100 dollari sulla vostra carta di credito! 🤦♂️
Come diavolo ha potuto il test provocare un addebito reale? Operare con una carta di credito non è semplice. Serve interagire con un servizio web di terze parti, conoscerne l'URL, autenticarsi e così via. Nessuna di queste informazioni è presente nel test. Peggio ancora, non sapete dove si trovino queste informazioni, il che rende impossibile simulare le dipendenze esterne per evitare l'addebito di 100 dollari a ogni esecuzione del test. E come potevate sapere, da sviluppatori appena arrivati, che quello che stavate per fare vi avrebbe reso più poveri di 100 dollari?
Questa è un'inquietante azione a distanza!
Siete costretti a spulciare un codice sorgente enorme e a consultare i colleghi più esperti per capire i collegamenti del
progetto. Questa difficoltà nasce perché l'interfaccia della classe CreditCard non rivela la necessaria
inizializzazione dello stato globale. Anche esaminando il codice sorgente della classe potreste non capire quale metodo di
inizializzazione chiamare. Nel migliore dei casi potreste trovare la variabile globale a cui si accede e provare a dedurre come
inizializzarla.
Le classi di un progetto del genere sono bugiarde patologiche. La classe CreditCard finge che basti istanziarla e
chiamarne il metodo charge(). In segreto, però, interagisce con un'altra classe, PaymentGateway, che
rappresenta il gateway di pagamento. Anche l'interfaccia di PaymentGateway può suggerire un'inizializzazione
indipendente, ma in realtà potrebbe pescare le credenziali da un file di configurazione e così via. Gli sviluppatori originali
sanno che CreditCard richiede PaymentGateway. Hanno scritto loro il codice così. Ma per chi arriva dopo
è un mistero completo, che ne ostacola l'apprendimento e la capacità di contribuire efficacemente.
Come sistemare la situazione? Facile. Fate in modo che l'API dichiari le dipendenze.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Notate come le interdipendenze all'interno del codice diventino immediatamente evidenti. Poiché il metodo
charge() dichiara di aver bisogno di un PaymentGateway, non dovete più indovinare questa dipendenza né
chiedere in giro. Sapete di dover creare un'istanza e, così facendo, scoprirete i parametri di accesso necessari. Senza di essi
il codice non girerebbe nemmeno.
E soprattutto, ora potete simulare il gateway di pagamento, così non vi verranno addebitati 100 dollari a ogni esecuzione del test.
Lo stato globale permette agli oggetti di accedere in segreto a dipendenze non dichiarate nelle loro API, trasformando di fatto le vostre API in bugiarde patologiche.
Forse non l'avevate mai vista così, ma ogni volta che usate lo stato globale create canali di comunicazione segreti senza fili. Questa inquietante azione a distanza costringe gli sviluppatori a leggere ogni riga di codice per capire le possibili interazioni, riducendo la produttività e confondendo i nuovi membri del team. Se siete voi ad aver scritto il codice, conoscete le vere dipendenze, ma chiunque venga dopo di voi è all'oscuro di tutto.
Evitate di scrivere codice che si affida allo stato globale; preferite passare le dipendenze esplicitamente. Abbracciate la dependency injection.
La fragilità dello stato globale
Nel codice che usa lo stato globale e i singleton non potete mai essere certi di quando o da chi lo stato sia stato modificato. Questo rischio si manifesta perfino durante l'inizializzazione. Il codice seguente vorrebbe creare una connessione al database e inizializzare un gateway di pagamento, ma solleva ripetutamente eccezioni, e trovarne la causa con il debug è estremamente noioso:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Dovete seguire con cura il codice per scoprire che l'oggetto PaymentGateway accede senza fili ad altri oggetti,
alcuni dei quali richiedono una connessione al database. Il database va quindi inizializzato prima di PaymentGateway.
La cortina di fumo dello stato globale, però, ve lo nasconde. Quanto tempo si risparmierebbe se le API di queste classi fossero
oneste e dichiarassero le proprie dipendenze?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, /* ... */);
Un problema simile nasce usando l'accesso globale a una connessione al database:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
Chiamando il metodo save() non è chiaro se una connessione al database sia stata stabilita né chi sia
responsabile di stabilirla. Se dobbiamo cambiare dinamicamente la connessione al database (per esempio per i test), potremmo
ricorrere all'aggiunta di metodi come DB::reconnect(...) o DB::reconnectForTest().
Considerate un esempio:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Come possiamo essere sicuri che, chiamando $article->save(), venga davvero usato il database di test? E se il
metodo Foo::doSomething() avesse cambiato la connessione globale al database? Per stabilirlo dovremmo esaminare il
codice sorgente di Foo e magari di molte altre classi. E questa indagine darebbe solo una risposta temporanea,
perché la situazione potrebbe cambiare in seguito.
E se spostassimo la connessione al database in una variabile statica dentro la 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 proprio nulla. Il problema è lo stato globale in sé, indipendentemente dalla classe in cui è nascosto. In
questo scenario, come nel precedente, chiamando $article->save() non abbiamo alcuna certezza su quale database
verranno scritti i dati. Chiunque, in qualsiasi punto dell'applicazione, potrebbe aver cambiato il database in qualsiasi momento
con Article::setDb(). A nostra insaputa.
Lo stato globale rende la nostra applicazione estremamente fragile.
Esiste però un modo semplice di affrontare questo problema. Basta far dichiarare all'API le dipendenze necessarie al corretto funzionamento.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Questo approccio elimina le preoccupazioni su cambiamenti nascosti o inattesi della connessione al database. Ora abbiamo la certezza di dove l'articolo venga salvato, e le modifiche in classi non correlate non possono più influenzarlo. Il codice non è più fragile, ma stabile.
Evitate di scrivere codice che si affida allo stato globale; preferite passare le dipendenze esplicitamente. Abbracciate la dependency injection.
Singleton
Il singleton è un design pattern che, per definizione della famosa pubblicazione della Gang of Four, limita una classe a una sola istanza e ne offre un accesso globale. L'implementazione di questo pattern somiglia di norma al codice seguente:
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
}
Purtroppo il singleton introduce nell'applicazione lo stato globale. E come abbiamo mostrato sopra, lo stato globale è indesiderabile. Ecco perché il singleton è considerato un antipattern.
Non usate i singleton nel vostro codice e sostituiteli con altri meccanismi. Davvero non avete bisogno dei singleton. Se però
dovete garantire che di una classe esista una sola istanza in tutta l'applicazione, delegate questa responsabilità al container DI. Nasce così un singleton nell'ambito
dell'applicazione, comunemente chiamato servizio. La classe stessa è allora libera dalla gestione della propria unicità (cioè
non avrà un metodo getInstance() né una proprietà statica con l'istanza) e può concentrarsi solo sulle proprie
responsabilità. Smetterà così di violare il principio di responsabilità singola.
Stato globale e test
Scrivendo i test, idealmente presupponiamo che ogni test sia un'unità isolata, in cui non entra né da cui esce alcuno stato esterno. Al termine di un test, tutto lo stato a esso associato dovrebbe essere ripulito automaticamente dal garbage collector. Questo rende i test isolati. Possiamo quindi eseguirli in qualsiasi ordine.
Quando però sono presenti stati globali o singleton, questi presupposti vantaggiosi crollano. Lo stato può entrare nei test e uscirne. All'improvviso l'ordine dei test può contare.
Per riuscire perfino a testare del codice che usa i singleton, gli sviluppatori devono spesso comprometterne l'integrità, per
esempio permettendo di sostituire l'istanza del singleton. Soluzioni del genere sono, nel migliore dei casi, espedienti che
portano a codice difficile da mantenere e da capire. Ogni test (o il suo metodo tearDown()) che modifica lo stato
globale deve annullare con cura quelle modifiche.
Lo stato globale è il più grande grattacapo dei test unitari!
Come sistemare la cosa? Semplice. Evitate di scrivere codice che usa i singleton; preferite passare le dipendenze esplicitamente. Abbracciate la dependency injection.
Costanti globali
Lo stato globale non si limita all'uso dei singleton e delle variabili statiche, ma può riguardare anche le costanti globali.
Le costanti il cui valore rappresenta una verità universale (M_PI) oppure offre un'informazione autonoma
(PREG_BACKTRACK_LIMIT_ERROR) sono in generale accettabili. Al contrario, le costanti usate come modo per iniettare
senza fili informazioni nel codice sono di fatto dipendenze nascoste. Come LOG_FILE nell'esempio seguente.
L'uso della costante FILE_APPEND è invece del tutto corretto.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Dovremmo invece dichiarare il percorso del file di log come parametro del costruttore della classe Foo, rendendolo
una parte esplicita della sua API:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Ora passiamo esplicitamente il percorso del file di log. Possiamo cambiarlo facilmente secondo necessità, il che semplifica i test e la manutenzione del codice.
Funzioni globali e metodi statici
Vogliamo sottolineare che l'uso dei metodi statici e delle funzioni globali non è di per sé problematico. Abbiamo spiegato
i problemi di metodi come DB::insert(), ma il problema di fondo è sempre stato lo stato globale sottostante, di
norma salvato in una variabile statica. Il metodo DB::insert() si affida a una variabile statica per conservare la
connessione al database. Senza quella variabile sarebbe impossibile implementare il metodo.
Usare metodi statici e funzioni deterministici come Closure::fromCallable(), strlen() e molti altri
è perfettamente compatibile con la dependency injection. Queste funzioni sono prevedibili, perché restituiscono sempre lo stesso
risultato per gli stessi parametri di ingresso. Non usano alcuno stato globale.
In PHP esistono però funzioni non deterministiche. Tra queste, per esempio, la funzione htmlspecialchars(). Il
suo terzo parametro, $encoding, se omesso assume come valore predefinito quello dell'opzione di configurazione
default_charset (ini_get('default_charset')). È quindi consigliabile indicare sempre questo parametro,
per prevenire possibili comportamenti imprevedibili. Nette lo fa sistematicamente.
Alcune funzioni, come strtolower() e strtoupper(), hanno avuto in tempi recenti un comportamento non
deterministico, dipendente dall'impostazione del locale (setlocale()). Questo ha causato molte complicazioni,
soprattutto lavorando con la lingua turca. Il turco, infatti, distingue tra la “I” con e senza punto, sia minuscola sia
maiuscola. Di conseguenza strtolower('I') restituiva ı (i minuscola senza punto) e
strtoupper('i') restituiva İ (I maiuscola con punto), il che portava a numerosi errori misteriosi
nelle applicazioni. Questo problema è però stato risolto in PHP 8.2 e le funzioni non dipendono più dal locale.
È un buon esempio di come lo stato globale (l'impostazione del locale) abbia creato problemi a migliaia di sviluppatori in tutto il mondo. La soluzione finale è consistita nel rendere le funzioni indipendenti dal locale, eliminando di fatto la dipendenza nascosta.
Quando è possibile usare lo stato globale?
Esistono situazioni specifiche e limitate in cui usare lo stato globale può essere accettabile. Per esempio durante il debugging, quando dovete scaricare il valore di una variabile o misurare il tempo di esecuzione di una porzione di codice. In questi casi, che riguardano azioni temporanee destinate a essere rimosse in seguito dal codice, usare un dumper o un timer globalmente accessibile può essere legittimo. Questi strumenti non fanno parte della progettazione di fondo dell'applicazione.
Un altro esempio riguarda le funzioni di PHP per le espressioni regolari (preg_*), che internamente mettono in
cache in memoria statica le espressioni regolari compilate. Quando chiamate più volte, in punti diversi del codice, funzioni con
la stessa espressione regolare, l'espressione viene compilata una sola volta. Questa cache migliora le prestazioni ed è del tutto
invisibile all'utente, il che rende questo uso dello stato statico interno in generale accettabile.
Riepilogo
Abbiamo parlato del perché abbia senso:
- eliminare dal vostro codice tutte le proprietà statiche modificabili (lo stato globale)
- dichiarare esplicitamente le dipendenze
- e usare la dependency injection
Progettando il vostro codice, ricordate che ogni static $foo modificabile è una potenziale fonte di problemi. Per
creare un ambiente amico della DI è essenziale eliminare completamente lo stato globale e sostituirlo con la dependency
injection.
Durante questo processo potreste scoprire la necessità di dividere classi che hanno più responsabilità. Non esitate a farlo; puntate al principio di responsabilità singola.
Vorrei ringraziare Miško Hevery, i cui articoli, come Flaw: Brittle Global State & Singletons, sono alla base di questo capitolo.