Warum eine static Property in der Basisklasse von allen Subklassen geteilt wird
Statische Eigenschaften wirken auf den ersten Blick wie ein bequemer Ort für klassenweiten Zustand, etwa einen Zähler oder eine Registry. Sobald Vererbung ins Spiel kommt, entsteht jedoch ein Fallstrick, der erfahrene Entwickler regelmässig überrascht: Eine in der Basisklasse deklarierte statische Property wird nicht pro Subklasse dupliziert, sondern von allen Subklassen gemeinsam genutzt, sofern sie nicht explizit neu deklariert wird. Dieser Artikel zeigt das Problem an konkreten Beispielen und beschreibt Wege, es zuverlässig zu vermeiden.
Inhaltsverzeichnis
- 1. Das Grundproblem: eine Property, viele Klassen
- 2. Wie Vererbung bei static Properties technisch funktioniert
- 3. Typischer Bug: Counter-Pattern über mehrere Subklassen
- 4. Typischer Bug: Registry-Pattern mit geteilter Property
- 5. Lösung 1: Property in der Subklasse neu deklarieren
- 6. Late Static Binding als teilweise Lösung
- 7. Warum LSB allein nicht ausreicht
- 8. Auswirkung auf Testbarkeit
- 9. Praxisempfehlung: Wann static Properties überhaupt sinnvoll sind
- 10. Zusammenfassung
- 11. FAQ
1. Das Grundproblem: eine Property, viele Klassen
Eine statische Property wird nicht pro Objektinstanz gespeichert, sondern einmalig pro Klassendeklaration. Das ist ihr eigentlicher Zweck: Zustand, der über alle Instanzen einer Klasse hinweg geteilt wird. Bei Vererbung führt genau dieser Mechanismus jedoch zu einer Überraschung, die auf den ersten Blick der objektorientierten Intuition widerspricht: Erbt eine Klasse B von einer Klasse A und deklariert die statische Property nicht selbst neu, teilen sich A und B denselben Speicherplatz für diese Property.
Das bedeutet konkret: Ein Schreibzugriff auf die Property über B::$value verändert denselben Wert, der auch über A::$value sichtbar ist, und umgekehrt. Für Entwickler, die aus Sprachen mit striktem Instance-per-Class-Verhalten kommen, ist das kontraintuitiv, denn bei gewöhnlichen, nicht statischen Properties erzeugt jede Instanz ihren eigenen, unabhängigen Speicherplatz. Bei statischen Properties gilt diese Isolation nur zwischen unterschiedlichen Klassendeklarationen, nicht zwischen Basisklasse und Subklasse ohne eigene Deklaration.
2. Wie Vererbung bei static Properties technisch funktioniert
Technisch betrachtet legt PHP für jede Klasse, die eine statische Property deklariert, genau einen Speicherslot an. Eine Subklasse, die keine eigene Deklaration derselben Property vornimmt, erbt lediglich den Zugriff auf diesen bereits existierenden Slot der Basisklasse, erzeugt aber keinen neuen. Erst wenn eine Subklasse dieselbe Property mit demselben Namen explizit erneut deklariert, erhält sie einen eigenen, unabhängigen Slot, der ab diesem Zeitpunkt getrennt vom Slot der Basisklasse existiert.
Dieses Verhalten lässt sich gut mit einem Blick in die Class-Table von PHP nachvollziehen: Solange keine Neu-Deklaration erfolgt, verweist die Property-Definition der Subklasse im Speicher schlicht auf dieselbe Speicheradresse wie in der Basisklasse. Das ist konsistent mit dem allgemeinen PHP-Prinzip, dass Vererbung standardmässig Verhalten und Zugriffe teilt, sofern nicht explizit überschrieben wird, gilt hier aber eben auch für den zugrunde liegenden Speicher selbst, nicht nur für Methoden.
3. Typischer Bug: Counter-Pattern über mehrere Subklassen
Ein besonders häufiger Fall ist ein Zähler, der die Anzahl erzeugter Instanzen protokollieren soll. Wird die statische Zähler-Property in einer gemeinsamen Basisklasse deklariert, zählt sie nicht etwa Instanzen pro Subklasse, sondern die Gesamtzahl über alle Subklassen hinweg, was selten die eigentliche Absicht ist. Ein Entwickler, der pro Subklasse eine eigene Zählung erwartet, wundert sich über Werte, die viel zu hoch erscheinen, weil sie fälschlich alle Subklassen zusammenrechnen.
Das Beispiel zeigt zwei Fahrzeugklassen, die von einer gemeinsamen Basisklasse erben. Beide sollen unabhängig zählen, wie viele Instanzen jeweils erzeugt wurden. Ohne Neu-Deklaration der Property landen aber alle Erzeugungen im selben, geteilten Zähler, unabhängig davon, welche konkrete Subklasse instanziert wurde.
<?php
declare(strict_types=1);
class Vehicle
{
protected static int $instanceCount = 0;
public function __construct()
{
static::$instanceCount++;
}
}
final class Car extends Vehicle
{
}
final class Truck extends Vehicle
{
}
new Car();
new Car();
new Truck();
// Überraschung: beide Werte sind identisch, weil sich Car und Truck
// denselben Speicherslot der Basisklasse teilen
var_dump(Car::$instanceCount); // int(3)
var_dump(Truck::$instanceCount); // int(3)
4. Typischer Bug: Registry-Pattern mit geteilter Property
Ein weiteres verbreitetes Beispiel ist ein Registry-Pattern, bei dem verschiedene Subklassen jeweils eine eigene Sammlung registrierter Objekte führen sollen. Wird das interne Array als statische Property in der Basisklasse deklariert, landen Registrierungen aus allen Subklassen versehentlich in derselben geteilten Sammlung. Ein Plugin-System etwa, bei dem jede Plugin-Kategorie ihre eigene Registry erwartet, vermischt auf diese Weise unbeabsichtigt Einträge verschiedener Kategorien.
Solche Bugs sind besonders tückisch, weil sie in kleinen Tests oft unentdeckt bleiben, sofern nur eine Subklasse pro Testlauf verwendet wird. Erst wenn mehrere Subklassen im selben Prozess, etwa in einer länger laufenden Anwendung oder in einer Test-Suite mit vielen Testklassen, gemeinsam aktiv sind, wird der geteilte Zustand sichtbar, häufig als schwer reproduzierbarer, von der Ausführungsreihenfolge abhängiger Fehler.
5. Lösung 1: Property in der Subklasse neu deklarieren
Die direkteste Lösung besteht darin, dieselbe statische Property mit demselben Namen explizit in jeder Subklasse erneut zu deklarieren. Damit erhält jede Subklasse ihren eigenen, unabhängigen Speicherslot, und Zugriffe über den jeweiligen Klassennamen bleiben vollständig voneinander isoliert. Diese Lösung ist einfach, erfordert aber Disziplin: Jede neue Subklasse muss die Neu-Deklaration nicht vergessen, sonst tritt der Fallstrick erneut auf.
In der Praxis empfiehlt es sich, diese Neu-Deklaration mit einem klaren Kommentar zu versehen, der erklärt, warum sie nötig ist, damit spätere Entwickler die scheinbar redundante Zeile nicht versehentlich entfernen. Ein PHPStan- oder Psalm-Test, der prüft, dass jede Subklasse mit eigenem Zustandsbedarf tatsächlich eine eigene Deklaration besitzt, kann diese Disziplin zusätzlich automatisiert absichern.
<?php
declare(strict_types=1);
class Vehicle
{
protected static int $instanceCount = 0;
public function __construct()
{
static::$instanceCount++;
}
}
final class Car extends Vehicle
{
// Erzeugt einen eigenen, von Vehicle unabhängigen Speicherslot
protected static int $instanceCount = 0;
}
final class Truck extends Vehicle
{
protected static int $instanceCount = 0;
}
new Car();
new Car();
new Truck();
var_dump(Car::$instanceCount); // int(2), korrekt isoliert
var_dump(Truck::$instanceCount); // int(1), korrekt isoliert
6. Late Static Binding als teilweise Lösung
Im vorherigen Beispiel fällt auf, dass static::$instanceCount statt self::$instanceCount verwendet wurde. Das ist Late Static Binding: static:: verweist zur Laufzeit auf die tatsächlich aufgerufene Klasse, nicht auf die Klasse, in der die Methode ursprünglich definiert wurde. Ohne Late Static Binding würde self::$instanceCount im Konstruktor der Basisklasse immer auf die Property von Vehicle zugreifen, selbst wenn Car::$instanceCount separat deklariert wurde.
Wichtig ist, dass Late Static Binding allein den geteilten-Zustand-Fallstrick nicht löst, es steuert lediglich, welcher Speicherslot beim Zugriff angesprochen wird, erzeugt aber keinen neuen Slot. Erst die Kombination aus static:: für den Zugriff und einer expliziten Neu-Deklaration der Property in der Subklasse führt zum gewünschten, isolierten Verhalten. Das Thema Late Static Binding selbst wird ausführlich im gleichnamigen Artikel behandelt.
7. Warum LSB allein nicht ausreicht
Ein häufiges Missverständnis ist die Annahme, static:: allein würde bereits für isolierten Zustand pro Subklasse sorgen. Ohne Neu-Deklaration der Property zeigt static::$instanceCount in Car und in Truck jedoch weiterhin auf denselben Speicherslot der Basisklasse, weil schlicht kein zweiter Slot existiert, auf den verwiesen werden könnte. Late Static Binding ändert nur, über welchen Klassennamen im Code auf die Property zugegriffen wird, nicht wie viele physische Speicherorte es gibt.
Diese Unterscheidung zwischen Zugriffsmechanismus und Speicherort ist der Kern des Missverständnisses. Wer beide Konzepte klar trennt, erkennt schnell, dass isolierter Zustand pro Subklasse zwei Voraussetzungen braucht: einen eigenen Speicherslot durch Neu-Deklaration und einen dynamischen Zugriffsmechanismus durch static:: statt self::, damit vererbte Methoden aus der Basisklasse überhaupt den richtigen, subklassenspezifischen Slot ansprechen.
8. Auswirkung auf Testbarkeit
Geteilter statischer Zustand wird besonders in Testsuiten zum Problem, weil PHPUnit-Testklassen standardmässig im selben Prozess ausgeführt werden. Ein Test, der eine statische Property einer Basisklasse verändert, kann dadurch das Ergebnis eines vollkommen unabhängigen, später ausgeführten Tests beeinflussen, der zufällig eine Subklasse derselben Basisklasse verwendet. Solche Abhängigkeiten von der Testreihenfolge gehören zu den schwierigsten Fehlerbildern in der Testpraxis.
Eine robuste Testsuite sollte deshalb entweder auf statischen Zustand in Basisklassen ganz verzichten oder ihn in jedem Test explizit zurücksetzen, etwa in einer setUp- oder tearDown-Methode. Noch besser ist es, den geteilten Zustand von vornherein durch Dependency Injection zu ersetzen, wodurch das Problem strukturell verschwindet, weil kein Zustand mehr auf Klassenebene, sondern auf Instanzebene existiert.
9. Praxisempfehlung: Wann static Properties überhaupt sinnvoll sind
Statische Properties sind grundsätzlich dann sinnvoll, wenn Zustand bewusst klassenweit und nicht instanzweit geteilt werden soll, etwa bei einem echten Singleton oder bei reinen Konstanten-ähnlichen Caches, die niemals subklassenspezifisch variieren sollen. Sobald jedoch Vererbung mit dem Wunsch nach subklassenspezifischem Zustand kombiniert wird, ist ein statisches Feld fast immer die falsche Wahl.
Die robustere Alternative ist in den meisten Fällen ein Dependency-Injection-Container oder eine explizite Instanzvariable, die pro Objekt oder pro Subklassen-Singleton verwaltet wird, statt sich auf den impliziten, oft verwirrenden Vererbungsmechanismus statischer Properties zu verlassen. Wo statische Properties dennoch unverzichtbar sind, sollte die Neu-Deklaration in jeder Subklasse zur festen Konvention werden, idealerweise abgesichert durch einen automatisierten Check im Code-Review-Prozess.
| Szenario | Property neu deklariert? | Ergebnis | Empfehlung |
|---|---|---|---|
| Basisklasse, self:: Zugriff | Nein | Alle Subklassen teilen einen Slot | Vermeiden bei Subklassen-Zustand |
| Basisklasse, static:: Zugriff | Nein | Weiterhin ein gemeinsamer Slot | Late Static Binding löst es allein nicht |
| Subklasse mit eigener Deklaration | Ja | Eigener, isolierter Slot | Korrektes Muster für Counter/Registry |
| Singleton-Pattern | Ja, bewusst in Basisklasse | Gewollt geteilter Zustand | Passt zum Zweck von static |
| Testsuite mit mehreren Subklassen | Abhängig vom Code | Reihenfolgeabhängige Fehler möglich | Zustand in setUp zurücksetzen oder DI nutzen |
Mironsoft
PHP-Modernisierung, Code-Qualität und Legacy-Refactoring
Gewachsener PHP-Code, der niemand mehr gern anfasst?
Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.
Legacy-Refactoring
Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.
Code-Qualität etablieren
PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.
Versions-Upgrade
PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.
10. Zusammenfassung
Statische Eigenschaften und Vererbung
Kernmechanismus
Eine statische Property gehört zur deklarierenden Klasse, nicht automatisch zu Subklassen.
Typischer Bug
Counter- und Registry-Pattern zählen ungewollt über alle Subklassen hinweg zusammen.
Lösung
Explizite Neu-Deklaration derselben Property in jeder Subklasse erzeugt einen eigenen Slot.
LSB-Grenze
Late Static Binding steuert nur den Zugriff, erzeugt aber keinen neuen Speicherplatz.