Globaler Zustand und Singletons

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

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

Treten einige dieser Konstrukte in Ihrem Code auf? Dann haben Sie die Möglichkeit, ihn zu verbessern. Vielleicht denken Sie, dass dies übliche Konstrukte sind, die Sie vielleicht sogar in Beispiel-Lösungen verschiedener Bibliotheken und Frameworks sehen. Wenn dies der Fall ist, dann ist das Design ihres Codes nicht gut.

Wir sprechen hier definitiv nicht von irgendeiner akademischen Reinheit. Alle diese Konstrukte haben eines gemeinsam: Sie verwenden globalen Zustand. Und dieser hat einen zerstörerischen Einfluss auf die Codequalität. Klassen lügen über ihre Abhängigkeiten. Code wird unvorhersehbar. Er verwirrt Programmierer und reduziert ihre Effizienz.

In diesem Kapitel erklären wir, warum das so ist und wie man globalen Zustand vermeidet.

Globale Kopplung

In einer idealen Welt sollte ein Objekt nur mit Objekten kommunizieren können, die ihm direkt übergeben wurden. Wenn ich zwei Objekte A und B erstelle und niemals eine Referenz zwischen ihnen übergebe, dann können weder A noch B auf das andere Objekt zugreifen oder seinen Zustand ändern. Das ist eine sehr wünschenswerte Eigenschaft von Code. Es ist ähnlich wie bei einer Batterie und einer Glühbirne; die Glühbirne leuchtet nicht, solange Sie sie nicht mit einem Draht mit der Batterie verbinden.

Das gilt jedoch nicht für globale (statische) Variablen oder Singletons. Objekt A könnte drahtlos auf Objekt C zugreifen und es modifizieren, ohne dass eine Referenz übergeben wird, indem es C::changeSomething() aufruft. Wenn Objekt B ebenfalls auf das globale C zugreift, dann können sich A und B gegenseitig über C beeinflussen.

Die Verwendung globaler Variablen führt eine neue Form der drahtlosen Kopplung in das System ein, die von außen nicht sichtbar ist. Sie erzeugt eine Nebelwand, die 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 der Schnittstelle der Klassen vertraut zu machen. Es handelt sich zudem um eine völlig unnötige Kopplung. Globaler Zustand wird verwendet, weil er von überall leicht zugänglich ist und es beispielsweise ermöglicht, über eine globale (statische) Methode DB::insert() in die Datenbank zu schreiben. Aber wie wir zeigen werden, ist der Vorteil, den dies bringt, gering, während die dadurch verursachten Komplikationen fatal sind.

Aus Verhaltenssicht gibt es keinen Unterschied zwischen einer globalen und einer statischen Variablen. Sie sind gleichermaßen schädlich.

Spukhafte Fernwirkung

“Spukhafte Fernwirkung” – so nannte Albert Einstein 1935 berühmt ein Phänomen in der Quantenphysik, das ihm Gänsehaut bereitete. Es handelt sich um die Quantenverschränkung, deren Besonderheit darin besteht, dass, wenn man Informationen über ein Teilchen misst, man sofort das andere Teilchen beeinflusst, auch wenn sie Millionen von Lichtjahren voneinander entfernt sind. Dies scheint das Grundgesetz des Universums zu verletzen, dass sich nichts schneller als Licht ausbreiten kann.

In der Softwarewelt können wir “spukhafte Fernwirkung” eine Situation nennen, in der wir einen Prozess starten, von dem wir annehmen, dass er isoliert ist (weil wir ihm keine Referenzen übergeben haben), aber an entfernten Stellen im System unerwartete Interaktionen und Zustandsänderungen auftreten, von denen wir keine Ahnung hatten. Dies kann nur durch globalen Zustand geschehen.

Stellen Sie sich vor, Sie treten einem Entwicklerteam eines Projekts bei, das über eine umfangreiche, ausgereifte Codebasis verfügt. Ihr neuer Vorgesetzter bittet Sie, eine neue Funktion zu implementieren, und Sie beginnen als guter Entwickler mit dem Schreiben eines Tests. Da Sie jedoch neu im Projekt sind, führen Sie viele explorative Tests durch, wie z. B. “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 mehrmals, und nach einer Weile bemerken Sie Benachrichtigungen von Ihrer Bank auf Ihrem Handy, dass bei jedem Ausführen 100 Dollar von Ihrer Kreditkarte abgebucht wurden 🤦‍♂️

Wie um alles in der Welt konnte der Test dazu führen, dass tatsächlich Geld abgebucht wird? Die Handhabung einer Kreditkarte ist nicht einfach. Sie müssen mit einem Webdienst eines Drittanbieters kommunizieren, Sie müssen die URL dieses Webdienstes kennen, Sie müssen sich anmelden und so weiter. Keine dieser Informationen ist im Test enthalten. Schlimmer noch, Sie wissen nicht einmal, wo diese Informationen vorhanden sind, und daher auch nicht, wie Sie externe Abhängigkeiten mocken können, damit nicht bei jeder Ausführung erneut 100 Dollar abgebucht werden. Und wie hätten Sie als neuer Entwickler wissen sollen, dass das, was Sie tun wollten, dazu führen würde, dass Sie um 100 Dollar ärmer sind?

Das ist spukhafte Fernwirkung!

Es bleibt Ihnen nichts anderes übrig, als sich lange durch eine Menge Quellcode zu wühlen und ältere und erfahrenere Kollegen zu fragen, bis Sie verstehen, wie die Abhängigkeiten im Projekt funktionieren. Dies liegt daran, dass beim Betrachten der Schnittstelle der Klasse CreditCard der globale Zustand, der initialisiert werden muss, nicht erkannt werden kann. Selbst ein Blick in den Quellcode der Klasse verrät Ihnen nicht, welche Initialisierungsmethode Sie aufrufen müssen. Im besten Fall finden Sie eine globale Variable, auf die zugegriffen wird, und können daraus versuchen abzuleiten, wie sie initialisiert wird.

Klassen in einem solchen Projekt sind pathologische Lügner. Die Kreditkarte tut so, als ob es ausreicht, sie zu instanziieren und die Methode charge() aufzurufen. Im Verborgenen arbeitet sie jedoch mit einer anderen Klasse PaymentGateway zusammen, die das Zahlungsgateway darstellt. Auch deren Schnittstelle besagt, dass sie separat initialisiert werden kann, aber tatsächlich holt sie sich Anmeldeinformationen aus einer Konfigurationsdatei und so weiter. 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 im Projekt ist, ist es ein absolutes Rätsel und behindert das Lernen.

Wie kann man die Situation beheben? 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, wie die Abhängigkeiten innerhalb des Codes plötzlich offensichtlich sind. Dadurch, dass die Methode charge() deklariert, dass sie PaymentGateway benötigt, müssen Sie niemanden fragen, wie der Code verknüpft ist. Sie wissen, dass Sie eine Instanz davon erstellen müssen, und wenn Sie dies versuchen, stoßen Sie darauf, dass Sie Zugriffsparameter angeben müssen. Ohne sie ließe sich der Code nicht einmal ausführen.

Und vor allem können Sie jetzt das Zahlungsgateway mocken, sodass Ihnen nicht bei jedem Testlauf 100 Dollar berechnet werden.

Globaler Zustand führt dazu, dass Ihre Objekte heimlich auf Dinge zugreifen können, die nicht in ihrer API deklariert sind, und macht Ihre APIs dadurch zu pathologischen Lügnern.

Vielleicht haben Sie bisher nicht so darüber nachgedacht, aber wann immer Sie globalen Zustand verwenden, erstellen Sie geheime drahtlose Kommunikationskanäle. Spukhafte Fernwirkung zwingt Entwickler, jede Codezeile zu lesen, um potenzielle Interaktionen zu verstehen, reduziert die Produktivität der Entwickler und verwirrt 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 ratlos.

Schreiben Sie keinen Code, der globalen Zustand verwendet, bevorzugen Sie die Übergabe von Abhängigkeiten. Also Dependency Injection.

Zerbrechlichkeit des globalen Zustands

In Code, der globalen Zustand und Singletons verwendet, ist nie sicher, wann und wer diesen Zustand geändert hat. Dieses Risiko tritt bereits bei der Initialisierung auf. Der folgende Code soll eine Datenbankverbindung herstellen und das Zahlungsgateway initialisieren, wirft jedoch ständig eine Ausnahme, und die Suche nach der Ursache ist extrem langwierig:

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

Sie müssen den Code detailliert durchgehen, um festzustellen, dass das PaymentGateway-Objekt drahtlos auf andere Objekte zugreift, von denen einige eine Datenbankverbindung erfordern. Daher muss die Datenbank vor PaymentGateway initialisiert werden. Die Nebelwand des globalen Zustands verbirgt dies jedoch vor Ihnen. Wie viel Zeit hätten Sie gespart, wenn die API der einzelnen Klassen nicht gelogen und ihre Abhängigkeiten deklariert hätte?

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

Ein ähnliches Problem tritt auch bei der Verwendung des globalen Zugriffs auf die Datenbankverbindung auf:

use Illuminate\Support\Facades\DB;

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

Beim Aufruf der Methode save() ist nicht sicher, ob die Datenbankverbindung bereits hergestellt wurde und wer für ihre Erstellung verantwortlich ist. Wenn wir beispielsweise die Datenbankverbindung zur Laufzeit ändern möchten, etwa für Tests, müssten wir wahrscheinlich weitere Methoden wie DB::reconnect(...) oder DB::reconnectForTest() erstellen.

Betrachten wir ein Beispiel:

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

Woher haben wir die Gewissheit, dass beim Aufruf von $article->save() tatsächlich die Testdatenbank verwendet wird? Was wäre, wenn die Methode Foo::doSomething() die globale Datenbankverbindung geändert hätte? Um dies herauszufinden, müssten wir den Quellcode der Klasse Foo und wahrscheinlich auch vieler anderer Klassen untersuchen. Dieser Ansatz würde jedoch nur eine kurzfristige Antwort liefern, da sich die Situation in Zukunft ändern kann.

Und 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(/* ... */);
	}
}

Dadurch hat sich überhaupt nichts geändert. Das Problem ist der globale Zustand, und es ist völlig egal, in welcher Klasse er sich versteckt. In diesem Fall haben wir, genau wie im vorherigen, beim Aufruf der Methode $article->save() keinen Hinweis darauf, in welche Datenbank geschrieben wird. Irgendjemand am anderen Ende der Anwendung könnte die Datenbank jederzeit mit Article::setDb() ändern. Unter unseren Händen.

Globaler Zustand macht unsere Anwendung extrem zerbrechlich.

Es gibt jedoch eine einfache Möglichkeit, dieses Problem zu lösen. Lassen Sie einfach die API Abhängigkeiten deklarieren, um die korrekte Funktionalität sicherzustellen.

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

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

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

Dank dieses Ansatzes entfällt die Sorge über versteckte und unerwartete Änderungen der Datenbankverbindung. Jetzt haben wir die Gewissheit, wohin der Artikel gespeichert wird, und keine Codeänderungen innerhalb einer anderen, nicht zusammenhängenden Klasse können die Situation mehr ändern. Der Code ist nicht mehr zerbrechlich, sondern stabil.

Schreiben Sie keinen Code, der globalen Zustand verwendet, bevorzugen Sie die Übergabe von Abhängigkeiten. Also Dependency Injection.

Singleton

Singleton ist ein Entwurfsmuster, das laut der Definition aus der bekannten Publikation der Gang of Four eine Klasse auf eine einzige Instanz beschränkt und einen globalen Zugriff darauf 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 weitere Methoden, die die Funktionen der gegebenen Klasse erfüllen
}

Leider führt das Singleton globalen Zustand in die Anwendung ein. Und wie wir oben gezeigt haben, ist globaler Zustand unerwünscht. Daher wird das Singleton als Anti-Pattern betrachtet.

Verwenden Sie keine Singletons in Ihrem Code und ersetzen Sie sie durch andere Mechanismen. Sie benötigen Singletons wirklich nicht. Wenn Sie jedoch sicherstellen müssen, dass nur eine einzige Instanz einer Klasse für die gesamte Anwendung existiert, überlassen Sie dies dem DI-Container. Erstellen Sie so einen Anwendungs-Singleton, also einen Dienst. Dadurch kümmert sich die Klasse nicht mehr um die Sicherstellung ihrer eigenen Einzigartigkeit (d. h. sie hat keine getInstance()-Methode und keine statische Variable) und erfüllt nur noch ihre Funktionen. So hört sie auf, das Prinzip der einzigen Verantwortung zu verletzen.

Globaler Zustand versus Tests

Beim Schreiben von Tests gehen wir davon aus, dass jeder Test eine isolierte Einheit ist und kein externer Zustand in ihn eintritt. Und kein Zustand verlässt die Tests. Nach Abschluss eines Tests sollte der gesamte zugehörige Zustand automatisch vom Garbage Collector entfernt werden. Dadurch sind die Tests isoliert. Daher können wir Tests in beliebiger Reihenfolge ausführen.

Wenn jedoch globale Zustände/Singletons vorhanden sind, zerfallen all diese angenehmen Annahmen. Zustand kann in den Test eintreten und ihn 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, etwa indem sie erlauben, die Instanz durch eine andere zu ersetzen. Solche Lösungen sind bestenfalls Hacks, die schwer wartbaren und verständlichen Code erzeugen. Jeder Test oder jede tearDown()-Methode, die einen globalen Zustand beeinflusst, muss diese Änderungen rückgängig machen.

Globaler Zustand ist der größte Kopfschmerz beim Unit-Testing!

Wie kann man die Situation beheben? Einfach. Schreiben Sie keinen Code, der Singletons verwendet, bevorzugen Sie die Übergabe von Abhängigkeiten. Also Dependency Injection.

Globale Konstanten

Globaler Zustand beschränkt sich nicht nur auf die Verwendung von Singletons und statischen Variablen, sondern kann auch globale Konstanten betreffen.

Konstanten, deren Wert uns keine neue (M_PI) oder nützliche (PREG_BACKTRACK_LIMIT_ERROR) Information bringt, sind eindeutig in Ordnung. Im Gegensatz dazu sind Konstanten, die als Mittel dienen, Informationen drahtlos in den Code zu übergeben, nichts anderes als versteckte Abhängigkeiten. Wie z. B. 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 einen Parameter im Konstruktor der Klasse Foo deklarieren, damit er Teil der API wird:

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

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

Jetzt können wir die Information über den Pfad zur Log-Datei übergeben und sie bei Bedarf leicht ändern, was das Testen und die Wartung des Codes erleichtert.

Globale Funktionen und statische Methoden

Wir möchten betonen, dass die Verwendung statischer Methoden und globaler Funktionen an sich nicht problematisch ist. Wir haben erklärt, warum die Verwendung von DB::insert() und ähnlichen Methoden ungeeignet ist, aber es ging immer nur um den globalen Zustand, der in einer statischen Variablen gespeichert ist. Die Methode DB::insert() erfordert die Existenz einer statischen Variablen, da darin die Datenbankverbindung gespeichert ist. Ohne diese Variable wäre es unmöglich, die Methode zu implementieren.

Die Verwendung deterministischer statischer Methoden und Funktionen wie DateTime::createFromFormat(), Closure::fromCallable, strlen() und vielen anderen steht in vollem Einklang mit der Dependency Injection. Diese Funktionen geben bei gleichen Eingabeparametern immer die gleichen Ergebnisse zurück und sind daher vorhersagbar. Sie verwenden keinen globalen Zustand.

Es gibt jedoch auch Funktionen in PHP, die nicht deterministisch sind. Dazu gehört beispielsweise die Funktion htmlspecialchars(). Ihr dritter Parameter $encoding, falls nicht angegeben, hat als Standardwert den Wert der Konfigurationsoption ini_get('default_charset'). Daher wird empfohlen, diesen Parameter immer anzugeben, um mögliches unvorhersehbares Verhalten der Funktion zu vermeiden. Nette tut dies konsequent.

Einige Funktionen wie strtolower(), strtoupper() und ähnliche verhielten sich in der jüngeren Vergangenheit nicht deterministisch und waren von der Einstellung setlocale() abhängig. Dies verursachte viele Komplikationen, am häufigsten bei der Arbeit mit der türkischen Sprache. Diese unterscheidet nämlich sowohl Klein- als auch Großbuchstaben I mit und ohne Punkt. So gab strtolower('I') den Buchstaben ı zurück und strtoupper('i') den Buchstaben İ, was dazu führte, dass Anwendungen eine Reihe rätselhafter Fehler verursachten. Dieses Problem wurde jedoch in PHP Version 8.2 behoben, und die Funktionen sind nicht mehr von der Locale abhängig.

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

Wann ist die Verwendung von globalem Zustand möglich?

Es gibt bestimmte spezifische Situationen, in denen die Verwendung von globalem Zustand möglich ist. 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 sich auf temporäre Aktionen beziehen, die später aus dem Code entfernt werden, ist es legitim, einen global verfügbaren Dumper oder eine Stoppuhr zu verwenden. Diese Werkzeuge sind nämlich nicht Teil des Code-Designs.

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

Zusammenfassung

Wir haben besprochen, warum es sinnvoll ist:

  1. Alle statischen Variablen aus dem Code zu entfernen
  2. Abhängigkeiten zu deklarieren
  3. Und Dependency Injection zu verwenden

Wenn Sie über das Code-Design nachdenken, denken Sie daran, dass jedes static $foo ein Problem darstellt. Damit Ihr Code eine Umgebung ist, die DI respektiert, ist es unerlässlich, den globalen Zustand vollständig zu beseitigen und ihn durch Dependency Injection zu ersetzen.

Während dieses Prozesses stellen Sie möglicherweise fest, dass eine Klasse aufgeteilt werden muss, da sie mehr als eine Verantwortung hat. Scheuen Sie sich nicht davor; streben Sie das Prinzip der einzigen 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