Globaler Zustand und Singletons
Warnung: Die folgenden Konstrukte sind Anzeichen für schlecht entworfenen Code:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
oderstatic::$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:
- Alle statischen Variablen aus dem Code zu entfernen
- Abhängigkeiten zu deklarieren
- 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.