État global & singletons
Attention : les constructions suivantes sont le symptôme d'un code mal conçu :
Foo::getInstance()DB::insert(...)Article::setDb($db)ClassName::$varoustatic::$var
Est-ce que l'une de ces constructions apparaît dans votre code ? Si oui, vous avez une occasion de l'améliorer. Vous pensez peut-être qu'il s'agit de constructions courantes, vues par exemple dans les solutions proposées par diverses bibliothèques et frameworks. Si c'est le cas, la conception de leur code est défaillante.
Il ne s'agit pas ici d'une quelconque pureté académique. Toutes ces constructions ont un trait commun : elles utilisent l'état global. Et l'état global a un effet néfaste sur la qualité du code. Les classes deviennent trompeuses quant à leurs dépendances. Le code devient imprévisible. Il désoriente les développeurs et réduit leur efficacité.
Dans ce chapitre, nous expliquerons pourquoi il en est ainsi et comment éviter l'état global.
Interconnexion globale
Dans un monde idéal, un objet ne devrait communiquer qu'avec les objets qui lui ont été directement passés. Si je crée deux objets
A et B et que je ne passe jamais de référence de l'un à l'autre, alors ni A ni
B ne peut accéder à l'état de l'autre ni le modifier. C'est une propriété du code hautement souhaitable. C'est
comme avoir une pile et une ampoule : l'ampoule ne s'allumera pas tant que vous ne l'aurez pas reliée à la pile par un fil.
Cela ne vaut cependant pas pour les variables globales (statiques) ni pour les singletons. L'objet A peut accéder
sans fil à l'objet C et le modifier sans qu'aucune référence lui ait été passée, en appelant
C::changeSomething(). Et si l'objet B puise lui aussi dans le C global, alors
A et B peuvent s'influencer mutuellement à travers C.
L'utilisation de variables globales introduit une nouvelle forme de couplage sans fil, invisible de l'extérieur. Elle
crée un écran de fumée qui rend le code plus difficile à comprendre et à utiliser. Pour saisir réellement les dépendances,
les développeurs doivent lire chaque ligne du code source au lieu de se fier simplement aux interfaces des classes. De plus, ce
couplage est totalement inutile. On utilise l'état global parce qu'il est facilement accessible de partout et qu'il permet, par
exemple, d'écrire dans une base de données par une méthode globale (statique) DB::insert(). Mais comme nous allons
le montrer, le confort apparent est minime au regard des graves complications qu'il apporte.
Du point de vue du comportement, il n'y a aucune différence entre une variable globale et une variable statique. Elles sont tout aussi nuisibles.
L'action fantomatique à distance
“L'action fantomatique à distance” – c'est ainsi qu'Albert Einstein a qualifié, dans une formule restée célèbre, un phénomène de la physique quantique qui lui donnait la chair de poule. Il s'agit de l'intrication quantique, où la mesure d'une propriété d'une particule affecte instantanément une autre particule intriquée, quelle que soit la distance qui les sépare, fût-elle de millions d'années-lumière, ce qui semble violer la loi fondamentale de l'univers selon laquelle rien ne peut aller plus vite que la lumière.
Dans le monde du logiciel, ‘l'action fantomatique à distance’ décrit une situation où nous exécutons un processus que nous croyons isolé (puisque aucune dépendance ne lui a été explicitement passée), alors que des interactions et des changements d'état inattendus se produisent, à notre insu, dans des parties éloignées du système. Cela ne peut arriver que par l'état global.
Imaginez que vous rejoigniez une équipe de développement sur un projet à la base de code vaste et mature. Votre nouveau chef vous demande d'implémenter une nouvelle fonctionnalité et, en bon développeur, vous commencez par écrire un test. Mais comme vous êtes nouveau sur le projet, vous faites beaucoup de tests exploratoires du genre ‘que se passe-t-il si j'appelle cette méthode’. Et vous essayez d'écrire le test suivant :
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // votre numéro de carte
$cc->charge(100);
}
Vous exécutez le code, peut-être plusieurs fois, et au bout d'un moment vous remarquez les notifications de la banque sur votre téléphone : 100 $ ont été débités de votre carte de crédit à chaque exécution ! 🤦♂️
Comment diable le test a-t-il pu provoquer un vrai débit ? Manipuler une carte de crédit n'a rien de simple. Il faut communiquer avec un service web tiers, connaître son URL, s'authentifier, etc. Aucune de ces informations ne figure dans le test. Pire encore, vous ne savez pas où elles se trouvent, ce qui rend impossible de simuler les dépendances externes pour éviter le débit de 100 $ à chaque exécution du test. Et, en tant que nouveau développeur, comment auriez-vous pu savoir que ce que vous vous apprêtiez à faire allait vous appauvrir de 100 $ ?
Voilà une action fantomatique à distance !
Vous êtes contraint d'éplucher un code source considérable et de consulter vos collègues expérimentés pour comprendre les
interconnexions du projet. Cette difficulté vient du fait que l'interface de la classe CreditCard ne révèle pas
l'initialisation nécessaire de l'état global. Même l'examen du code source de la classe ne révélera peut-être pas quelle
méthode d'initialisation appeler. Au mieux, vous trouverez la variable globale à laquelle elle accède et tenterez d'en déduire
comment l'initialiser.
Les classes d'un tel projet sont des menteuses pathologiques. La classe CreditCard fait semblant de pouvoir être
simplement instanciée et de voir sa méthode charge() appelée. En réalité, elle communique en secret avec une
autre classe, PaymentGateway, qui représente la passerelle de paiement. Même l'interface de
PaymentGateway peut laisser croire à une initialisation indépendante alors qu'en réalité, elle va peut-être
chercher les identifiants dans un fichier de configuration, et ainsi de suite. Les développeurs d'origine savent que
CreditCard a besoin de PaymentGateway. Ils ont écrit le code ainsi. Mais pour les nouveaux venus, c'est
un mystère complet qui les empêche d'apprendre et de contribuer efficacement.
Comment corriger la situation ? Facilement. Que l'API déclare ses dépendances.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Remarquez comme les interdépendances du code sautent immédiatement aux yeux. Parce que la méthode charge()
déclare avoir besoin d'une PaymentGateway, vous n'avez plus à deviner cette dépendance ni à poser la question.
Vous savez que vous devez en créer une instance et, ce faisant, vous découvrirez les paramètres d'accès nécessaires. Sans
eux, le code ne s'exécuterait même pas.
Et surtout, vous pouvez désormais simuler la passerelle de paiement, si bien que 100 $ ne vous seront plus débités à chaque exécution d'un test.
L'état global permet aux objets d'accéder en secret à des dépendances non déclarées dans leur API, ce qui transforme vos API en menteuses pathologiques.
Vous ne l'aviez peut-être jamais vu ainsi, mais chaque fois que vous utilisez l'état global, vous créez des canaux de communication sans fil secrets. Cette action fantomatique à distance oblige les développeurs à lire chaque ligne de code pour comprendre les interactions possibles, ce qui réduit la productivité et déroute les nouveaux membres de l'équipe. Si c'est vous qui avez écrit le code, vous connaissez les vraies dépendances, mais celui qui vient après vous n'en a aucune idée.
Évitez d'écrire du code qui repose sur l'état global ; préférez le passage explicite des dépendances. Adoptez l'injection de dépendances.
Fragilité de l'état global
Dans un code qui utilise l'état global et des singletons, vous ne pouvez jamais être sûr de quand ni par qui l'état a été modifié. Ce risque se manifeste dès l'initialisation. Le code suivant a l'intention de créer une connexion à la base de données et d'initialiser une passerelle de paiement, mais il lève sans cesse des exceptions et en déboguer la cause est extrêmement pénible :
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Vous devez suivre méticuleusement le code pour découvrir que l'objet PaymentGateway accède sans fil à d'autres
objets, dont certains ont besoin d'une connexion à la base de données. La base de données doit donc être initialisée avant
PaymentGateway. Mais l'écran de fumée de l'état global vous le cache. Combien de temps serait gagné si l'API de
ces classes était honnête et déclarait ses dépendances ?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, /* ... */);
Un problème similaire survient lorsqu'on utilise un accès global à la connexion de base de données :
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
Lors de l'appel de la méthode save(), on ne sait pas si une connexion à la base de données a été établie ni
qui est chargé de l'établir. Si nous avons besoin de changer dynamiquement la connexion (par exemple pour les tests), nous
risquons d'en venir à ajouter des méthodes comme DB::reconnect(...) ou DB::reconnectForTest().
Prenons un exemple :
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Comment être sûr que la base de données de test est réellement utilisée lors de l'appel à
$article->save() ? Et si la méthode Foo::doSomething() avait changé la connexion globale ? Pour le
savoir, il nous faudrait inspecter le code source de Foo et peut-être de bien d'autres classes. Et cette enquête ne
donnerait qu'une réponse temporaire, car la situation peut changer plus tard.
Et si nous déplacions la connexion à la base de données dans une variable statique à l'intérieur de 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(/* ... */);
}
}
Cela ne change absolument rien. Le problème, c'est l'état global lui-même, quelle que soit la classe dans laquelle il est
caché. Dans ce scénario, comme dans le précédent, lors de l'appel à $article->save() nous n'avons aucune
certitude quant à la base de données dans laquelle les données seront écrites. N'importe qui, n'importe où dans
l'application, a pu changer la base à n'importe quel moment à l'aide de Article::setDb(). À notre insu.
L'état global rend notre application extrêmement fragile.
Il existe pourtant une façon simple de régler ce problème. Il suffit que l'API déclare ses dépendances pour garantir un fonctionnement correct.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Cette approche élimine les craintes de modifications cachées ou inattendues de la connexion à la base de données. Nous savons désormais avec certitude où l'article est enregistré, et les modifications apportées à des classes sans rapport ne peuvent plus l'affecter. Le code n'est plus fragile, il est stable.
Évitez d'écrire du code qui repose sur l'état global ; préférez le passage explicite des dépendances. Adoptez l'injection de dépendances.
Singleton
Le singleton est un patron de conception qui, selon la définition de la célèbre publication du Gang of Four, restreint une classe à une instance unique et offre un accès global à celle-ci. L'implémentation de ce patron ressemble habituellement au code suivant :
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// et d'autres méthodes qui assurent les fonctions de la classe
}
Malheureusement, le singleton introduit l'état global dans l'application. Et comme nous l'avons montré plus haut, l'état global n'est pas souhaitable. C'est pourquoi le singleton est considéré comme un anti-pattern.
N'utilisez pas de singletons dans votre code et remplacez-les par d'autres mécanismes. Vous n'avez vraiment pas besoin de
singletons. En revanche, si vous devez garantir qu'une seule instance d'une classe existe dans toute l'application, déléguez
cette responsabilité au conteneur DI. Vous obtenez ainsi un
singleton à l'échelle de l'application, ce qu'on appelle couramment un service. La classe elle-même est alors libérée de la
gestion de son unicité (elle n'aura donc ni méthode getInstance() ni propriété statique d'instance) et peut se
consacrer uniquement à ses responsabilités. Elle cessera ainsi de violer le principe de responsabilité unique.
L'état global face aux tests
Quand nous écrivons des tests, nous partons idéalement du principe que chaque test est une unité isolée, qu'aucun état extérieur n'y entre et n'en sort. Une fois le test terminé, tout état qui lui est associé devrait être nettoyé automatiquement par le ramasse-miettes. C'est ce qui rend les tests isolés. Nous pouvons donc exécuter les tests dans n'importe quel ordre.
Mais dès que des états globaux ou des singletons entrent en jeu, ces hypothèses bienvenues s'effondrent. L'état peut entrer dans les tests et en sortir. Soudain, l'ordre des tests peut avoir de l'importance.
Pour seulement pouvoir tester du code comportant des singletons, les développeurs doivent souvent compromettre leur
intégrité, par exemple en permettant de remplacer l'instance du singleton. De telles solutions ne sont, au mieux, que des
bricolages qui aboutissent à un code difficile à maintenir et à comprendre. Chaque test (ou sa méthode
tearDown()) qui modifie l'état global doit méticuleusement annuler ces changements.
L'état global est le plus gros casse-tête des tests unitaires !
Comment y remédier ? Simplement. Évitez d'écrire du code qui utilise des singletons ; préférez le passage explicite des dépendances. Adoptez l'injection de dépendances.
Constantes globales
L'état global ne se limite pas à l'usage des singletons et des variables statiques, il peut aussi concerner les constantes globales.
Les constantes dont les valeurs représentent des vérités universelles (M_PI) ou fournissent une information
autonome (PREG_BACKTRACK_LIMIT_ERROR) sont généralement acceptables. En revanche, les constantes utilisées comme
moyen d'injecter sans fil de l'information dans le code sont en fait des dépendances cachées. Comme
LOG_FILE dans l'exemple suivant. L'usage de la constante FILE_APPEND est, lui, parfaitement correct.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Nous devrions plutôt déclarer le chemin du fichier de log comme paramètre du constructeur de la classe Foo,
pour en faire une partie explicite de son API :
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Nous passons maintenant explicitement le chemin du fichier de log. Nous pouvons le changer facilement selon les besoins, ce qui simplifie les tests et la maintenance du code.
Fonctions globales et méthodes statiques
Nous voulons souligner que l'usage des méthodes statiques et des fonctions globales n'est pas problématique en soi. Nous
avons expliqué les problèmes posés par des méthodes comme DB::insert(), mais le problème de fond était toujours
l'état global sous-jacent, généralement stocké dans une variable statique. La méthode DB::insert() repose sur
une variable statique qui contient la connexion à la base de données. Sans cette variable, il serait impossible d'implémenter
la méthode.
L'usage de méthodes et de fonctions statiques déterministes comme Closure::fromCallable(), strlen()
et bien d'autres est parfaitement compatible avec l'injection de dépendances. Ces fonctions sont prévisibles, car elles
renvoient toujours le même résultat pour les mêmes paramètres d'entrée. Elles n'utilisent aucun état global.
Il existe cependant en PHP des fonctions qui ne sont pas déterministes. C'est le cas, par exemple, de la fonction
htmlspecialchars(). Son troisième paramètre, $encoding, s'il est omis, prend par défaut la valeur de
l'option de configuration default_charset (ini_get('default_charset')). Il est donc recommandé de
toujours indiquer ce paramètre afin d'éviter un comportement potentiellement imprévisible. Nette le fait systématiquement.
Certaines fonctions, comme strtolower() et strtoupper(), avaient encore récemment un comportement
non déterministe, dépendant du réglage de la locale (setlocale()). Cela a causé de nombreuses complications, le
plus souvent lors du travail avec la langue turque. Le turc distingue en effet le ‘I’ avec et sans point, aussi bien en
minuscule qu'en majuscule. Ainsi, strtolower('I') renvoyait ı (i minuscule sans point) et
strtoupper('i') renvoyait İ (I majuscule avec point), d'où quantité d'erreurs mystérieuses dans les
applications. Ce problème a toutefois été corrigé dans PHP 8.2 et ces fonctions ne dépendent plus de la locale.
C'est un bon exemple de la façon dont l'état global (le réglage de la locale) a tourmenté des milliers de développeurs dans le monde entier. La solution a finalement consisté à rendre les fonctions indépendantes de la locale, autrement dit à supprimer la dépendance cachée.
Quand peut-on utiliser l'état global ?
Il existe des situations particulières et limitées où l'usage de l'état global peut être acceptable. Par exemple pendant le débogage, quand vous avez besoin de dumper la valeur d'une variable ou de mesurer le temps d'exécution d'un morceau de code précis. Dans ces cas, qui concernent des actions temporaires qui seront ensuite retirées du code, l'utilisation d'un dumper ou d'un chronomètre accessible globalement peut être légitime. Ces outils ne font pas partie de la conception même de l'application.
Autre exemple : les fonctions de PHP dédiées aux expressions régulières (preg_*), qui mettent en cache en
interne, dans une mémoire statique, les expressions régulières compilées. Lorsque vous appelez ces fonctions plusieurs fois
avec la même expression régulière dans votre code, l'expression n'est compilée qu'une seule fois. Ce cache améliore les
performances et est totalement invisible pour l'utilisateur, si bien que cet usage d'un état statique interne est généralement
acceptable.
Résumé
Nous avons expliqué pourquoi il est judicieux de :
- éliminer de votre code toutes les propriétés statiques modifiables (l'état global),
- déclarer explicitement les dépendances,
- et utiliser l'injection de dépendances.
Quand vous concevez votre code, gardez à l'esprit que chaque static $foo modifiable est une source potentielle de
problèmes. Pour créer un environnement propice à la DI, il est essentiel d'éliminer complètement l'état global et de le
remplacer par l'injection de dépendances.
Au cours de ce travail, vous découvrirez peut-être la nécessité de scinder des classes qui ont plusieurs responsabilités. N'hésitez pas à le faire ; visez le principe de responsabilité unique.
Je tiens à remercier Miško Hevery, dont les articles comme Flaw: Brittle Global State & Singletons sont à la base de ce chapitre.