État global et singletons

Avertissement : Les constructions suivantes sont le signe d'un code mal conçu :

  • Foo::getInstance()
  • DB::insert(...)
  • Article::setDb($db)
  • ClassName::$var ou static::$var

Certaines de ces constructions apparaissent-elles dans votre code ? Alors vous avez l'occasion de l'améliorer. Vous pensez peut-être qu'il s'agit de constructions courantes que vous voyez même dans les exemples de solutions de diverses bibliothèques et frameworks. Si c'est le cas, alors la conception de leur code n'est pas bonne.

Nous ne parlons certainement pas ici d'une sorte de pureté académique. Toutes ces constructions ont une chose en commun : elles utilisent l'état global. Et celui-ci a un impact destructeur sur la qualité du code. Les classes mentent sur leurs dépendances. Le code devient imprévisible. Il embrouille les programmeurs et réduit leur efficacité.

Dans ce chapitre, nous expliquerons pourquoi il en est ainsi et comment éviter l'état global.

Couplage global

Dans un monde idéal, un objet ne devrait pouvoir 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 entre eux, alors ni A ni B ne peuvent accéder à l'autre objet ou modifier son état. C'est une propriété très souhaitable du code. C'est similaire à avoir une batterie et une ampoule ; l'ampoule ne s'allumera pas tant que vous ne la connecterez pas à la batterie avec un fil.

Mais cela ne s'applique pas aux variables globales (statiques) ou aux singletons. L'objet A pourrait accéder sans fil à l'objet C et le modifier sans aucun passage de référence, en appelant C::changeSomething(). Si l'objet B s'empare également du C global, alors A et B peuvent s'influencer mutuellement via C.

L'utilisation de variables globales introduit dans le système une nouvelle forme de couplage sans fil, qui n'est pas visible de l'extérieur. Elle crée un écran de fumée compliquant la compréhension et l'utilisation du code. Pour que les développeurs comprennent réellement les dépendances, ils doivent lire chaque ligne du code source. Au lieu de simplement se familiariser avec l'interface des classes. De plus, il s'agit d'un couplage totalement inutile. L'état global est utilisé parce qu'il est facilement accessible de n'importe où et permet, par exemple, d'écrire dans la base de données via la méthode globale (statique) DB::insert(). Mais comme nous le montrerons, l'avantage que cela apporte est minime, tandis que les complications qu'il provoque sont fatales.

Du point de vue du comportement, il n'y a pas de différence entre une variable globale et une variable statique. Elles sont tout aussi nuisibles.

Action fantôme à distance

“Action fantôme à distance” – c'est ainsi qu'Albert Einstein a fameusement nommé en 1935 un phénomène de la physique quantique qui lui donnait la chair de poule. Il s'agit de l'intrication quantique, dont la particularité est que lorsque vous mesurez une information sur une particule, vous influencez instantanément l'autre particule, même si elles sont séparées par des millions d'années-lumière. Ce qui semble violer la loi fondamentale de l'univers selon laquelle rien ne peut se propager plus vite que la lumière.

Dans le monde logiciel, nous pouvons appeler “action fantôme à distance” une situation où nous lançons un processus que nous croyons isolé (parce que nous ne lui avons passé aucune référence), mais où des interactions et des changements d'état inattendus se produisent dans des endroits éloignés du système, dont nous n'avions aucune idée. Cela ne peut se produire que par le biais de l'état global.

Imaginez que vous rejoigniez une équipe de développeurs sur un projet doté d'une base de code vaste et mature. Votre nouveau responsable vous demande d'implémenter une nouvelle fonctionnalité et, en tant que bon développeur, vous commencez par écrire un test. Mais comme vous êtes nouveau dans le projet, vous effectuez de nombreux tests exploratoires du type “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 après un certain temps, vous remarquez des notifications de votre banque sur votre mobile indiquant qu'à chaque exécution, 100 dollars ont été débités de votre carte de crédit 🤦‍♂️

Comment diable le test a-t-il pu provoquer un débit réel d'argent ? Opérer avec une carte de crédit n'est pas facile. Vous devez communiquer avec un service web tiers, vous devez connaître l'URL de ce service web, vous devez vous connecter, etc. Aucune de ces informations n'est contenue dans le test. Pire encore, vous ne savez même pas où ces informations sont présentes, et donc vous ne savez pas non plus comment mocker les dépendances externes pour que chaque exécution n'entraîne pas à nouveau le débit de 100 dollars. Et comment, en tant que nouveau développeur, auriez-vous pu savoir que ce que vous alliez faire vous rendrait 100 dollars plus pauvre ?

C'est l'action fantôme à distance !

Il ne vous reste plus qu'à fouiller longuement dans de nombreux codes sources, à interroger des collègues plus âgés et plus expérimentés, avant de comprendre comment fonctionnent les liens dans le projet. Cela est dû au fait qu'en regardant l'interface de la classe CreditCard, on ne peut pas déterminer l'état global qu'il faut initialiser. Même un coup d'œil au code source de la classe ne vous dira pas quelle méthode d'initialisation appeler. Au mieux, vous pouvez trouver une variable globale à laquelle on accède et essayer d'en déduire comment l'initialiser.

Les classes d'un tel projet sont des menteurs pathologiques. La carte de crédit prétend qu'il suffit de l'instancier et d'appeler la méthode charge(). En secret, elle coopère avec une autre classe PaymentGateway, qui représente la passerelle de paiement. Son interface dit également qu'elle peut être initialisée séparément, mais en réalité, elle extrait les informations d'identification d'un fichier de configuration, etc. Pour les développeurs qui ont écrit ce code, il est clair que CreditCard a besoin de PaymentGateway. Ils ont écrit le code de cette manière. Mais pour quiconque est nouveau dans le projet, c'est un mystère complet et cela entrave l'apprentissage.

Comment corriger la situation ? Facilement. Laissez l'API déclarer les dépendances.

function testCreditCardCharge()
{
	$gateway = new PaymentGateway(/* ... */);
	$cc = new CreditCard('1234567890123456', 5, 2028);
	$cc->charge($gateway, 100);
}

Remarquez comment les interconnexions à l'intérieur du code deviennent soudainement évidentes. Le fait que la méthode charge() déclare avoir besoin de PaymentGateway signifie que vous n'avez pas besoin de demander à qui que ce soit comment le code est interconnecté. Vous savez que vous devez créer son instance, et lorsque vous essayez de le faire, vous réalisez que vous devez fournir les paramètres d'accès. Sans eux, le code ne pourrait même pas s'exécuter.

Et surtout, vous pouvez maintenant mocker la passerelle de paiement, de sorte que 100 dollars ne vous seront pas facturés à chaque exécution du test.

L'état global fait que vos objets peuvent accéder secrètement à des choses qui ne sont pas déclarées dans leur API et, par conséquent, font de vos API des menteurs pathologiques.

Vous n'y avez peut-être pas pensé de cette façon auparavant, mais chaque fois que vous utilisez l'état global, vous créez des canaux de communication secrets sans fil. L'action fantôme à distance oblige les développeurs à lire chaque ligne de code pour comprendre les interactions potentielles, réduit la productivité des développeurs et embrouille les nouveaux membres de l'équipe. Si c'est vous qui avez créé le code, vous connaissez les dépendances réelles, mais quiconque viendra après vous sera désemparé.

N'écrivez pas de code qui utilise l'état global, préférez le passage de dépendances. C'est-à-dire l'injection de dépendances.

Fragilité de l'état global

Dans le code qui utilise l'état global et les singletons, on n'est jamais sûr de quand et qui a modifié cet état. Ce risque apparaît dès l'initialisation. Le code suivant est censé créer une connexion à la base de données et initialiser la passerelle de paiement, mais il lève constamment une exception et trouver la cause est extrêmement long :

PaymentGateway::init();
DB::init('mysql:', 'user', 'password');

Vous devez parcourir le code en détail pour découvrir que l'objet PaymentGateway accède sans fil à d'autres objets, dont certains nécessitent une connexion à la base de données. Il est donc nécessaire d'initialiser la base de données avant PaymentGateway. Cependant, l'écran de fumée de l'état global vous le cache. Combien de temps auriez-vous gagné si les API des classes individuelles ne mentaient pas et déclaraient leurs dépendances ?

$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);

Un problème similaire se pose lors de l'utilisation d'un accès global à la connexion à la 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 n'est pas certain que la connexion à la base de données a déjà été créée et qui est responsable de sa création. Si nous voulons, par exemple, modifier la connexion à la base de données à la volée, par exemple pour des tests, nous devrions probablement créer d'autres méthodes comme DB::reconnect(...) ou DB::reconnectForTest().

Considérons un exemple :

$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();

Où avons-nous la certitude que lors de l'appel de $article->save(), la base de données de test est réellement utilisée ? Et si la méthode Foo::doSomething() avait modifié la connexion globale à la base de données ? Pour le savoir, nous devrions examiner le code source de la classe Foo et probablement de nombreuses autres classes. Cette approche ne fournirait cependant qu'une réponse à court terme, car la situation pourrait changer à l'avenir.

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 est l'état global et peu importe dans quelle classe il se cache. Dans ce cas, comme dans le précédent, nous n'avons aucune idée, lors de l'appel de la méthode $article->save(), dans quelle base de données l'écriture aura lieu. N'importe qui à l'autre bout de l'application aurait pu modifier la base de données à tout moment en utilisant Article::setDb(). Sous notre nez.

L'état global rend notre application extrêmement fragile.

Il existe cependant un moyen simple de traiter ce problème. Il suffit de laisser l'API déclarer les dépendances, ce qui garantit le bon fonctionnement.

class Article
{
	public function __construct(
		private DB $db,
	) {
	}

	public function save(): void
	{
		$this->db->insert(/* ... */);
	}
}

$article = new Article($db);
// ...
Foo::doSomething();
$article->save();

Grâce à cette approche, la crainte de modifications cachées et inattendues de la connexion à la base de données disparaît. Nous avons maintenant la certitude de l'endroit où l'article est enregistré et aucune modification du code à l'intérieur d'une autre classe non liée ne peut plus changer la situation. Le code n'est plus fragile, mais stable.

N'écrivez pas de code qui utilise l'état global, préférez le passage de dépendances. C'est-à-dire l'injection de dépendances.

Singleton

Le singleton est un patron de conception qui, selon la Singleton_pattern de la célèbre publication du Gang of Four, limite une classe à une seule instance et offre un accès global à celle-ci. L'implémentation de ce patron ressemble généralement 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 remplissant les fonctions de la classe donnée
}

Malheureusement, le singleton introduit un état global dans l'application. Et comme nous l'avons montré ci-dessus, l'état global est indésirable. C'est pourquoi le singleton est considéré comme un anti-patron.

N'utilisez pas de singletons dans votre code et remplacez-les par d'autres mécanismes. Vous n'avez vraiment pas besoin de singletons. Cependant, si vous devez garantir l'existence d'une seule instance d'une classe pour toute l'application, laissez cela au conteneur DI. Créez ainsi un singleton d'application, c'est-à-dire un service. De cette façon, la classe cessera de s'occuper d'assurer sa propre unicité (c'est-à-dire qu'elle n'aura pas de méthode getInstance() ni de variable statique) et ne remplira que ses fonctions. Elle cessera ainsi de violer le principe de responsabilité unique.

État global versus tests

Lors de l'écriture de tests, nous supposons que chaque test est une unité isolée et qu'aucun état externe n'y entre. Et aucun état ne quitte les tests. Une fois le test terminé, tout l'état lié au test devrait être automatiquement supprimé par le garbage collector. Grâce à cela, les tests sont isolés. Nous pouvons donc exécuter les tests dans n'importe quel ordre.

Cependant, s'il y a des états globaux/singletons, toutes ces hypothèses agréables s'effondrent. L'état peut entrer et sortir du test. Soudain, l'ordre des tests peut avoir de l'importance.

Pour pouvoir tester les singletons, les développeurs doivent souvent assouplir leurs propriétés, par exemple en permettant de remplacer l'instance par une autre. De telles solutions sont au mieux des hacks qui créent un code difficile à maintenir et à comprendre. Chaque test ou méthode tearDown() qui affecte un état global doit annuler ces changements.

L'état global est le plus grand casse-tête des tests unitaires !

Comment corriger la situation ? Facilement. N'écrivez pas de code qui utilise des singletons, préférez le passage de dépendances. C'est-à-dire l'injection de dépendances.

Constantes globales

L'état global ne se limite pas à l'utilisation de singletons et de variables statiques, mais peut également concerner les constantes globales.

Les constantes dont la valeur ne nous apporte aucune information nouvelle (M_PI) ou utile (PREG_BACKTRACK_LIMIT_ERROR) sont clairement acceptables. En revanche, les constantes qui servent de moyen de passer sans fil des informations à l'intérieur du code ne sont rien d'autre qu'une dépendance cachée. Comme LOG_FILE dans l'exemple suivant. L'utilisation de la constante FILE_APPEND est tout à fait correcte.

const LOG_FILE = '...';

class Foo
{
	public function doSomething()
	{
		// ...
		file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
		// ...
	}
}

Dans ce cas, nous devrions déclarer un paramètre dans le constructeur de la classe Foo pour qu'il fasse partie de l'API :

class Foo
{
	public function __construct(
		private string $logFile,
	) {
	}

	public function doSomething()
	{
		// ...
		file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
		// ...
	}
}

Maintenant, nous pouvons passer l'information sur le chemin du fichier journal et la modifier facilement selon les besoins, ce qui facilite les tests et la maintenance du code.

Fonctions globales et méthodes statiques

Nous tenons à souligner que l'utilisation de méthodes statiques et de fonctions globales n'est pas problématique en soi. Nous avons expliqué pourquoi l'utilisation de DB::insert() et de méthodes similaires est inappropriée, mais il s'agissait toujours uniquement d'une question d'état global stocké dans une variable statique. La méthode DB::insert() nécessite l'existence d'une variable statique car la connexion à la base de données y est stockée. Sans cette variable, il serait impossible d'implémenter la méthode.

L'utilisation de méthodes statiques et de fonctions déterministes, telles que DateTime::createFromFormat(), Closure::fromCallable, strlen() et bien d'autres, est parfaitement conforme à l'injection de dépendances. Ces fonctions renvoient toujours les mêmes résultats pour les mêmes paramètres d'entrée et sont donc prévisibles. Elles n'utilisent aucun état global.

Il existe cependant des fonctions en PHP qui ne sont pas déterministes. Parmi elles, par exemple, la fonction htmlspecialchars(). Son troisième paramètre $encoding, s'il n'est pas spécifié, prend par défaut la valeur de l'option de configuration ini_get('default_charset'). C'est pourquoi il est recommandé de toujours spécifier ce paramètre et d'éviter ainsi un comportement éventuellement imprévisible de la fonction. Nette le fait systématiquement.

Certaines fonctions, telles que strtolower(), strtoupper() et similaires, se comportaient de manière non déterministe dans un passé récent et dépendaient du paramètre setlocale(). Cela causait de nombreuses complications, le plus souvent lors du travail avec la langue turque. Celle-ci distingue en effet les lettres I majuscules et minuscules avec et sans point. Ainsi, strtolower('I') renvoyait le caractère ı et strtoupper('i') le caractère İ, ce qui entraînait l'apparition de nombreuses erreurs mystérieuses dans les applications. Ce problème a cependant été corrigé dans la version PHP 8.2 et les fonctions ne dépendent plus de la locale.

C'est un bel exemple de la façon dont l'état global a tourmenté des milliers de développeurs dans le monde entier. La solution a été de le remplacer par l'injection de dépendances.

Quand est-il possible d'utiliser l'état global ?

Il existe certaines situations spécifiques où il est possible d'utiliser l'état global. Par exemple, lors du débogage de code, lorsque vous devez afficher la valeur d'une variable ou mesurer la durée d'une certaine partie du programme. Dans de tels cas, qui concernent des actions temporaires qui seront ultérieurement supprimées du code, il est légitime d'utiliser un dumper ou un chronomètre globalement disponible. Ces outils ne font en effet pas partie de la conception du code.

Un autre exemple sont les fonctions pour travailler avec les expressions régulières preg_*, qui stockent en interne les expressions régulières compilées dans un cache statique en mémoire. Ainsi, lorsque vous appelez la même expression régulière plusieurs fois à différents endroits du code, elle n'est compilée qu'une seule fois. Le cache économise les performances et est en même temps totalement invisible pour l'utilisateur, c'est pourquoi une telle utilisation peut être considérée comme légitime.

Résumé

Nous avons discuté des raisons pour lesquelles il est judicieux de :

  1. Supprimer toutes les variables statiques du code
  2. Déclarer les dépendances
  3. Et utiliser l'injection de dépendances

Lorsque vous réfléchissez à la conception du code, gardez à l'esprit que chaque static $foo représente un problème. Pour que votre code soit un environnement respectant la DI, il est essentiel d'éradiquer complètement l'état global et de le remplacer par l'injection de dépendances.

Au cours de ce processus, vous découvrirez peut-être qu'il est nécessaire de diviser une classe car elle a plus d'une responsabilité. N'ayez pas peur de cela ; visez le principe de responsabilité unique.

Je tiens à remercier Miško Hevery, dont les articles, tels que Flaw: Brittle Global State & Singletons, sont à la base de ce chapitre.

version: 3.x