Globaler Zustand und Singletons

Warnung: Die folgenden Konstrukte sind Symptome für schlecht entworfenen Code:

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

Finden Sie eines dieser Konstrukte in Ihrem Code? Wenn ja, dann haben Sie die Möglichkeit, ihn zu verbessern. Sie könnten denken, dass es sich um gängige Konstrukte handelt, die oft in Beispiellösungen verschiedener Bibliotheken und Frameworks zu sehen sind. Wenn das der Fall ist, ist ihr Code-Design mangelhaft.

Wir sprechen hier nicht von einer akademischen Reinheit. Alle diese Konstrukte haben eines gemeinsam: Sie verwenden einen globalen Zustand. Und das hat zerstörerische Auswirkungen auf die Codequalität. Die Klassen täuschen über ihre Abhängigkeiten hinweg. Der Code wird unberechenbar. Das verwirrt die Entwickler und mindert ihre Effizienz.

In diesem Kapitel wird erklärt, warum dies der Fall ist und wie man globale Zustände vermeiden kann.

Globale Verkettung

In einer idealen Welt sollte ein Objekt nur mit Objekten kommunizieren, die direkt an es übergeben wurden. Wenn ich zwei Objekte A und B erstelle und nie einen Verweis zwischen ihnen übergebe, dann können weder A noch B auf den Zustand des anderen zugreifen oder ihn ändern. Dies ist eine höchst wünschenswerte Eigenschaft von Code. Es ist so, als hätte man eine Batterie und eine Glühbirne; die Glühbirne leuchtet erst, wenn man sie über ein Kabel mit der Batterie verbindet.

Dies gilt jedoch nicht für globale (statische) Variablen oder Singletons. Das Objekt A könnte drahtlos auf das Objekt C zugreifen und es ohne jegliche Referenzübergabe ändern, indem es C::changeSomething() aufruft. Wenn das Objekt B auch auf das globale C zugreift, dann können sich A und B über C gegenseitig beeinflussen.

Die Verwendung globaler Variablen führt eine neue Form der drahtlosen Kopplung ein, die von außen nicht sichtbar ist. Sie schafft einen Nebelschleier, der das Verständnis und die Verwendung des Codes erschwert. Um die Abhängigkeiten wirklich zu verstehen, müssen Entwickler jede Zeile des Quellcodes lesen, anstatt sich nur mit den Klassenschnittstellen vertraut zu machen. Außerdem ist diese Verstrickung völlig unnötig. Globale Zustände werden verwendet, weil sie von überall her leicht zugänglich sind und beispielsweise das Schreiben in eine Datenbank über eine globale (statische) Methode DB::insert() ermöglichen. Wie wir jedoch sehen werden, ist der Nutzen, den er bietet, minimal, während die Komplikationen, die er mit sich bringt, schwerwiegend sind.

Was das Verhalten angeht, gibt es keinen Unterschied zwischen einer globalen und einer statischen Variable. Sie sind gleichermaßen schädlich.

Die spukhafte Wirkung in der Ferne

“Spukhafte Fernwirkung” – so nannte Albert Einstein 1935 ein Phänomen der Quantenphysik, das ihm eine Gänsehaut bereitete. Es handelt sich dabei um die Quantenverschränkung, deren Besonderheit darin besteht, dass die Messung von Informationen über ein Teilchen sofort Auswirkungen auf ein anderes Teilchen hat, selbst wenn sie Millionen von Lichtjahren voneinander entfernt sind. Dies verstößt scheinbar gegen das grundlegende Gesetz des Universums, dass sich nichts schneller als das Licht bewegen kann.

In der Software-Welt können wir von einer “spukhaften Fernwirkung” sprechen, wenn wir einen Prozess laufen lassen, von dem wir glauben, dass er isoliert ist (weil wir ihm keine Referenzen übergeben haben), aber unerwartete Wechselwirkungen und Zustandsänderungen an entfernten Stellen des Systems auftreten, von denen wir dem Objekt nichts gesagt haben. Dies kann nur über den globalen Zustand geschehen.

Stellen Sie sich vor, Sie treten in ein Projektentwicklungsteam ein, das über eine große, ausgereifte Codebasis verfügt. Ihr neuer Leiter bittet Sie, eine neue Funktion zu implementieren, und wie ein guter Entwickler beginnen Sie damit, einen Test zu schreiben. Da Sie aber neu im Projekt sind, machen Sie viele Tests vom Typ “was passiert, wenn ich diese Methode aufrufe”. Und Sie versuchen, den folgenden Test zu schreiben:

function testCreditCardCharge()
{
	$cc = new CreditCard('1234567890123456', 5, 2028); // Ihre Kartennummer
	$cc->charge(100);
}

Sie führen den Code aus, vielleicht mehrere Male, und nach einer Weile bemerken Sie auf Ihrem Telefon Benachrichtigungen von der Bank, dass jedes Mal, wenn Sie ihn ausführen, Ihre Kreditkarte mit 100 Dollar belastet wurde 🤦‍♂️

Wie um alles in der Welt konnte der Test eine tatsächliche Belastung verursachen? Es ist nicht einfach, mit einer Kreditkarte zu arbeiten. Sie müssen mit einem Webdienst eines Drittanbieters interagieren, Sie müssen die URL dieses Webdienstes kennen, Sie müssen sich anmelden und so weiter. Keine dieser Informationen ist in dem Test enthalten. Noch schlimmer ist, dass Sie nicht einmal wissen, wo diese Informationen vorhanden sind und wie Sie externe Abhängigkeiten simulieren können, damit nicht bei jedem Durchlauf erneut 100 Dollar fällig werden. Und woher sollten Sie als neuer Entwickler wissen, dass das, was Sie gerade tun wollten, Sie um 100 Dollar ärmer machen würde?

Das ist eine gespenstische Aktion aus der Ferne!

Es bleibt Ihnen nichts anderes übrig, als sich durch eine Menge Quellcode zu wühlen und ältere und erfahrenere Kollegen zu fragen, bis Sie verstehen, wie die Zusammenhänge im Projekt funktionieren. Das liegt daran, dass man bei einem Blick auf die Schnittstelle der Klasse CreditCard den globalen Zustand, der initialisiert werden muss, nicht feststellen kann. Selbst ein Blick in den Quellcode der Klasse verrät Ihnen nicht, welche Initialisierungsmethode Sie aufrufen müssen. Bestenfalls können Sie die globale Variable finden, auf die zugegriffen wird, und versuchen, daraus zu erraten, wie sie zu initialisieren ist.

Die Klassen in einem solchen Projekt sind pathologische Lügner. Die Zahlungskarte gibt vor, dass man sie einfach instanziieren und die Methode charge() aufrufen kann. Insgeheim interagiert sie jedoch mit einer anderen Klasse, PaymentGateway. Sogar ihre Schnittstelle sagt, dass sie unabhängig initialisiert werden kann, aber in Wirklichkeit bezieht sie Anmeldedaten aus einer Konfigurationsdatei usw. Den Entwicklern, die diesen Code geschrieben haben, ist klar, dass CreditCard PaymentGateway benötigt. Sie haben den Code auf diese Weise geschrieben. Aber für jeden, der neu in das Projekt einsteigt, ist dies ein völliges Rätsel und behindert das Lernen.

Wie kann man das Problem lösen? Ganz einfach. **Lassen Sie die API Abhängigkeiten deklarieren.

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

Beachten Sie, dass die Beziehungen innerhalb des Codes plötzlich offensichtlich sind. Indem Sie erklären, dass die Methode charge() PaymentGateway benötigt, müssen Sie niemanden fragen, wie der Code voneinander abhängig ist. Sie wissen, dass Sie eine Instanz der Methode erstellen müssen, und wenn Sie dies versuchen, stoßen Sie auf die Tatsache, dass Sie Zugriffsparameter bereitstellen müssen. Ohne sie würde der Code nicht einmal laufen.

Und das Wichtigste ist, dass Sie jetzt das Zahlungs-Gateway simulieren können, damit Sie nicht jedes Mal, wenn Sie einen Test durchführen, 100 Dollar bezahlen müssen.

Der globale Status bewirkt, dass Ihre Objekte heimlich auf Dinge zugreifen können, die nicht in ihren APIs deklariert sind, und macht Ihre APIs damit zu pathologischen Lügnern.

Sie haben vielleicht noch nie darüber nachgedacht, aber immer wenn Sie einen globalen Zustand verwenden, schaffen Sie geheime drahtlose Kommunikationskanäle. Unheimliche Remote-Aktionen zwingen Entwickler dazu, jede Codezeile zu lesen, um mögliche Interaktionen zu verstehen, verringern die Produktivität der Entwickler und verwirren neue Teammitglieder. Wenn Sie derjenige sind, der den Code erstellt hat, kennen Sie die tatsächlichen Abhängigkeiten, aber jeder, der nach Ihnen kommt, ist ahnungslos.

Schreiben Sie keinen Code, der globale Zustände verwendet, sondern übergeben Sie lieber Abhängigkeiten. Das heißt, Dependency Injection.

Die Zerbrechlichkeit des globalen Staates

Bei Code, der globale Zustände und Singletons verwendet, ist es nie sicher, wann und von wem dieser Zustand geändert wurde. Dieses Risiko ist bereits bei der Initialisierung gegeben. Der folgende Code soll eine Datenbankverbindung erstellen und das Zahlungs-Gateway initialisieren, aber er löst immer wieder eine Ausnahme aus, und die Suche nach der Ursache ist extrem mühsam:

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

Sie müssen den Code im Detail durchgehen, um herauszufinden, dass das Objekt PaymentGateway drahtlos auf andere Objekte zugreift, von denen einige eine Datenbankverbindung benötigen. Sie müssen also die Datenbank vor PaymentGateway initialisieren. Der Nebel des globalen Zustands verbirgt dies jedoch vor Ihnen. Wie viel Zeit würden Sie sparen, wenn die API der einzelnen Klassen nicht lügen und ihre Abhängigkeiten deklarieren würde?

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

Ein ähnliches Problem ergibt sich bei der Verwendung des globalen Zugriffs auf eine Datenbankverbindung:

use Illuminate\Support\Facades\DB;

class Article
{
	public function save(): void
	{
		DB::insert(/* ... */);
	}
}

Beim Aufruf der Methode save() ist nicht sicher, ob bereits eine Datenbankverbindung erstellt wurde und wer für deren Erstellung verantwortlich ist. Wenn wir zum Beispiel die Datenbankverbindung spontan ändern wollten, vielleicht zu Testzwecken, müssten wir wahrscheinlich zusätzliche Methoden wie DB::reconnect(...) oder DB::reconnectForTest() erstellen.

Betrachten wir ein Beispiel:

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

Wo können wir sicher sein, dass beim Aufruf von $article->save() wirklich die Testdatenbank verwendet wird? Was wäre, wenn die Methode Foo::doSomething() die globale Datenbankverbindung ändern würde? Um das herauszufinden, müssten wir den Quellcode der Klasse Foo und wahrscheinlich vieler anderer Klassen untersuchen. Dieser Ansatz würde jedoch nur eine kurzfristige Antwort liefern, da sich die Situation in der Zukunft ändern kann.

Was wäre, wenn wir die Datenbankverbindung in eine statische Variable innerhalb der Klasse Article verschieben?

class Article
{
	private static DB $db;

	public static function setDb(DB $db): void
	{
		self::$db = $db;
	}

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

Das ändert überhaupt nichts. Das Problem ist ein globaler Zustand und es spielt keine Rolle, in welcher Klasse es sich versteckt. In diesem Fall, wie auch im vorherigen, haben wir keine Ahnung, in welche Datenbank geschrieben wird, wenn die Methode $article->save() aufgerufen wird. Jeder am entfernten Ende der Anwendung könnte die Datenbank jederzeit mit Article::setDb() ändern. Unter unseren Händen.

Der globale Zustand macht unsere Anwendung extrem anfällig.

Es gibt jedoch eine einfache Möglichkeit, mit diesem Problem umzugehen. Lassen Sie die API einfach Abhängigkeiten deklarieren, um die ordnungsgemäße Funktionalität zu gewährleisten.

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

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

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

Auf diese Weise wird die Sorge vor versteckten und unerwarteten Änderungen an Datenbankverbindungen beseitigt. Jetzt wissen wir genau, wo der Artikel gespeichert ist, und keine Code-Änderungen in einer anderen, nicht verwandten Klasse können die Situation mehr verändern. Der Code ist nicht mehr anfällig, sondern stabil.

Schreiben Sie keinen Code, der globale Zustände verwendet, sondern übergeben Sie lieber Abhängigkeiten. Daher Dependency Injection.

Singleton

Singleton ist ein Entwurfsmuster, das gemäß der Definition aus der berühmten Gang of Four-Publikation eine Klasse auf eine einzige Instanz beschränkt und globalen Zugriff auf diese bietet. Die Implementierung dieses Musters ähnelt normalerweise dem folgenden Code:

class Singleton
{
	private static self $instance;

	public static function getInstance(): self
	{
		self::$instance ??= new self;
		return self::$instance;
	}

	// und andere Methoden, die die Funktionen der Klasse ausführen
}

Leider führt das Singleton einen globalen Zustand in die Anwendung ein. Und wie wir oben gezeigt haben, ist ein globaler Zustand unerwünscht. Deshalb wird das Singleton als Antipattern betrachtet.

Verwenden Sie keine Singletons in Ihrem Code und ersetzen Sie sie durch andere Mechanismen. Sie brauchen Singletons wirklich nicht. Wenn Sie jedoch die Existenz einer einzigen Instanz einer Klasse für die gesamte Anwendung garantieren müssen, überlassen Sie dies dem DI-Container. Erstellen Sie also ein Anwendungssingleton oder einen Dienst. Dadurch wird die Klasse nicht mehr für ihre eigene Einzigartigkeit sorgen (d. h. sie wird keine getInstance() -Methode und keine statische Variable haben) und nur ihre Funktionen ausführen. Damit wird das Prinzip der einzigen Verantwortung nicht mehr verletzt.

Globaler Zustand vs. Tests

Beim Schreiben von Tests gehen wir davon aus, dass jeder Test eine isolierte Einheit ist und dass kein externer Zustand in ihn eintritt. Und kein Zustand verlässt die Tests. Wenn ein Test abgeschlossen ist, sollte jeder mit dem Test verbundene Zustand automatisch vom Garbage Collector entfernt werden. Dadurch werden die Tests isoliert. Daher können wir die Tests in beliebiger Reihenfolge ausführen.

Wenn jedoch globale Zustände/Singletons vorhanden sind, sind alle diese schönen Annahmen hinfällig. Ein Zustand kann einen Test betreten und verlassen. Plötzlich kann die Reihenfolge der Tests eine Rolle spielen.

Um Singletons überhaupt testen zu können, müssen Entwickler oft ihre Eigenschaften lockern, indem sie beispielsweise zulassen, dass eine Instanz durch eine andere ersetzt wird. Solche Lösungen sind bestenfalls Hacks, die schwer zu wartenden und schwer zu verstehenden Code produzieren. Jeder Test oder jede Methode tearDown(), die einen globalen Zustand beeinflusst, muss diese Änderungen rückgängig machen.

Der globale Zustand ist das größte Problem bei Unit-Tests!

Wie kann man das Problem lösen? Ganz einfach. Schreiben Sie keinen Code, der Singletons verwendet, sondern ziehen Sie es vor, Abhängigkeiten zu übergeben. Das heißt, dependency injection.

Globale Konstanten

Der globale Status ist nicht auf die Verwendung von Singletons und statischen Variablen beschränkt, sondern kann auch für globale Konstanten gelten.

Konstanten, deren Wert uns keine neuen (M_PI) oder nützlichen (PREG_BACKTRACK_LIMIT_ERROR) Informationen liefert, sind eindeutig in Ordnung. Umgekehrt sind Konstanten, die dazu dienen, Informationen innerhalb des Codes drahtlos weiterzugeben, nichts anderes als eine versteckte Abhängigkeit. Wie LOG_FILE im folgenden Beispiel. Die Verwendung der Konstante FILE_APPEND ist völlig korrekt.

const LOG_FILE = '...';

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

In diesem Fall sollten wir den Parameter im Konstruktor der Klasse Foo deklarieren, um ihn zum Bestandteil der API zu machen:

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

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

Jetzt können wir Informationen über den Pfad zur Protokollierungsdatei übergeben und ihn bei Bedarf leicht ändern, was das Testen und Warten des Codes erleichtert.

Globale Funktionen und statische Methoden

Wir möchten betonen, dass die Verwendung von statischen Methoden und globalen Funktionen an sich nicht problematisch ist. Wir haben die Unangemessenheit der Verwendung von DB::insert() und ähnlichen Methoden erläutert, aber es ging immer um den globalen Zustand, der in einer statischen Variablen gespeichert wird. Die Methode DB::insert() erfordert das Vorhandensein einer statischen Variablen, weil sie die Datenbankverbindung speichert. Ohne diese Variable wäre es unmöglich, die Methode zu implementieren.

Die Verwendung von deterministischen statischen Methoden und Funktionen, wie DateTime::createFromFormat(), Closure::fromCallable, strlen() und viele andere, ist mit der Dependency Injection vollkommen vereinbar. Diese Funktionen liefern immer die gleichen Ergebnisse für die gleichen Eingabeparameter und sind daher vorhersehbar. Sie verwenden keinen globalen Zustand.

Allerdings gibt es in PHP Funktionen, die nicht deterministisch sind. Dazu gehört z.B. die Funktion htmlspecialchars(). Ihr dritter Parameter, $encoding, wird, wenn er nicht angegeben wird, standardmäßig mit dem Wert der Konfigurationsoption ini_get('default_charset') belegt. Es wird daher empfohlen, diesen Parameter immer anzugeben, um ein unvorhersehbares Verhalten der Funktion zu vermeiden. Nette tut dies konsequent.

Einige Funktionen, wie strtolower(), strtoupper() und ähnliche, haben sich in der jüngsten Vergangenheit nicht deterministisch verhalten und waren von der Einstellung setlocale() abhängig. Dies führte zu zahlreichen Komplikationen, vor allem bei der Arbeit mit der türkischen Sprache. Das liegt daran, dass die türkische Sprache zwischen Groß- und Kleinschreibung I mit und ohne Punkt unterscheidet. So gab strtolower('I') das Zeichen ı und strtoupper('i') das Zeichen İ zurück, was dazu führte, dass Anwendungen eine Reihe von mysteriösen Fehlern verursachten. Dieses Problem wurde jedoch in der PHP-Version 8.2 behoben, und die Funktionen sind nun nicht mehr vom Gebietsschema abhängig.

Dies ist ein schönes Beispiel dafür, wie der globale Zustand Tausende von Entwicklern auf der ganzen Welt geplagt hat. Die Lösung bestand darin, ihn durch Dependency Injection zu ersetzen.

Wann ist es möglich, einen globalen Status zu verwenden?

Es gibt bestimmte Situationen, in denen es möglich ist, globale Zustände zu verwenden. Zum Beispiel beim Debuggen von Code, wenn Sie den Wert einer Variablen ausgeben oder die Dauer eines bestimmten Programmteils messen müssen. In solchen Fällen, die temporäre Aktionen betreffen, die später aus dem Code entfernt werden, ist es legitim, einen global verfügbaren Dumper oder eine Stoppuhr zu verwenden. Diese Werkzeuge sind nicht Teil des Codeentwurfs.

Ein weiteres Beispiel sind die Funktionen für die Arbeit mit regulären Ausdrücken preg_*, die intern kompilierte reguläre Ausdrücke in einem statischen Cache im Speicher ablegen. Wenn Sie denselben regulären Ausdruck mehrmals in verschiedenen Teilen des Codes aufrufen, wird er nur einmal kompiliert. Der Cache spart Leistung und ist außerdem für den Benutzer völlig unsichtbar, so dass eine solche Verwendung als legitim angesehen werden kann.

Zusammenfassung

Wir haben gezeigt, warum es Sinn macht

  1. Entfernen Sie alle statischen Variablen aus dem Code
  2. Deklarieren Sie Abhängigkeiten
  3. Und verwenden Sie Dependency Injection

Wenn Sie über den Entwurf von Code nachdenken, sollten Sie bedenken, dass jedes static $foo ein Problem darstellt. Damit Ihr Code eine DI-konforme Umgebung wird, ist es unerlässlich, den globalen Zustand vollständig zu beseitigen und durch Dependency Injection zu ersetzen.

Während dieses Prozesses kann es vorkommen, dass Sie eine Klasse aufteilen müssen, weil sie mehr als eine Verantwortung hat. Machen Sie sich keine Gedanken darüber; streben Sie das Prinzip der einen Verantwortung an.

Ich möchte Miško Hevery danken, dessen Artikel wie Flaw: Brittle Global State & Singletons die Grundlage für dieses Kapitel bilden.

Version: 3.x