Die Diskussion ist fast so alt wie PHP 5: Brauchen wir Generics? Der Artikel "Compile time generics: yay or nay?" von Gina Banyard und Larry Garfield hat dieses Thema im vergangenen Sommer wieder in den Fokus gerückt.
Bereits ein Jahr zuvor, im August 2024, zeigten Arnaud Le Blanc, Derick Rethans und Larry Garfield in ihrem Artikel "State of Generics and Collections" auf, wie schwierig "echte" Generics technisch sind (Stichwort: Super-Linear Time Complexity).
Mein Standpunkt dazu wird einige überraschen: Ich bin mir nicht sicher, ob ich Generics in dieser Form zwingend brauche.
Nur um das klarzustellen: Dieser Artikel argumentiert nicht gegen die Einführung von Generics in PHP. Vielmehr zeigt er, dass PHP schon jetzt überraschend viel Unterstützung für den sehr häufigen Fall typsicherer Sammlungen bietet.
Generics
Generics zählen zu den mächtigsten Funktionen moderner Programmiersprachen wie Java, C#, TypeScript, Swift, Rust und Go. Die bekanntesten Use Cases sind dabei Collections wie List<Type> oder Map<Key, Value>.
Der fundamentalste Vorteil von Generics liegt in der Verlagerung der Verantwortung für die Typsicherheit von der Laufzeit zur Compilezeit. Dadurch kann der Compiler Typfehler bereits während der Kompilierung erkennen. Explizites Type Casting ist somit nicht mehr notwendig und die Wahrscheinlichkeit von Fehlern zur Laufzeit wird drastisch reduziert.
Die Dokumentation der Programmiersprache Dart dokumentiert dies beispielsweise explizit:
Generics let you share a single interface and implementation between many types, while still taking advantage of static analysis.
Generic Programming hat viel mehr Anwendungsmöglichkeiten als nur typsichere Sammlungen. Sie ist die Basis für viele Design Patterns, Frameworks und Architekturen in Programmiersprachen, die dieses Paradigma unterstützen. Beispiele sind Dependency Injection, asynchrone Programmierung und funktionale Patterns wie Monaden. Generics ermöglichen Code, der ausdrucksstark, wartungsfreundlich und leistungsstark ist.
In diesem Artikel werde ich mich aber auf typsichere Sammlungen konzentrieren. Nicht, weil ich die anderen Anwendungsfälle ignorieren will, sondern weil mich dieser hier am meisten interessiert.
Pragmatisch ohne Generics
Wenn ich mir nicht sicher bin, ob ich Generics in dieser Form zwingend benötige, was möchte ich dann?
Was ich möchte, ist Typsicherheit. Ich möchte garantieren können, dass eine Collection nur Objekte eines bestimmten Typs enthält. Wenn ich ehrlich auf meinen Code schaue, brauche ich dafür keine neue Syntax in PHP. Ich brauche lediglich Disziplin und die Features, die PHP uns bereits heute bietet.
Fast alles, was wir an Generics vermissen, lässt sich durch zwei Dinge lösen:
- Type Annotations für statische Codeanalyse mit PHPStan oder einem anderen Tool
- Type-Safe Collections für die Runtime-Sicherheit
Ich möchte hier zeigen, wie Punkt 2 heute schon ohne Kompromisse funktioniert und warum ich meine Lösung dem Vorschlag nativer Fluid Arrays vorziehe. Schauen wir uns mal den typischen Anwendungsfall "generische Sammlung" an, List<T>, und sehen wir, wie weit wir mit einfachem PHP kommen.
Ein Gatekeeper für den Input
Ein häufiger Einwand gegen im Userland implementierte ist:
Ich will nicht jedes Element in einer foreach Schleife prüfen, das ist mühsam.
Das müssen wir auch nicht. PHP liefert uns mit dem Unpacking Operator ... sowie variadischen Konstruktoren eine native, optimierte Typprüfung. Hier ist meine Implementierung einer TestCollection:
<?php declare(strict_types=1); /** * @template-implements IteratorAggregate<non-negative-int, Test> * * @immutable */ final readonly class TestCollection implements IteratorAggregate { /** * @var list<Test> */ private array $tests; /** * @param list<Test> $tests */ public static function fromArray(array $tests): self { assert(array_is_list($tests)); return new self(...$tests); } private function __construct(Test ...$tests) { $this->tests = $tests; } /** * @return list<Test> */ public function asArray(): array { return $this->tests; } public function getIterator(): TestCollectionIterator { return new TestCollectionIterator($this); } }
Die Methode fromArray(array $tests) ist ein Named Constructor und nutzt den Unpacking Operator ..., um das Array in einzelne Argumente zu zerlegen.
Beim Named Constructor Pattern werden statische Methoden mit aussagekräftigen Namen genutzt, um Objekte zu erzeugen, anstatt den Konstruktor direkt aufzurufen. Dadurch wird die Intention der Objekterzeugung explizit gemacht und es werden mehrere Erzeugungswege mit unterschiedlicher Semantik ermöglicht.
Der Konstruktor __construct(Test ...$tests) akzeptiert eine beliebige Anzahl an Werten vom Typ Test. Das Schöne daran: Wenn ich TestCollection::fromArray($data) aufrufe und in $data befindet sich auch nur ein falsches Element, wirft PHP sofort einen TypeError. Ich muss keine Validierungslogik schreiben. Die Sprache erledigt das für mich am Eingangstor.
Es lohnt sich, kurz auf die Auswirkungen des Unpacking-Operators einzugehen. Mit ...$tests wird zwar eine Argumentliste aus dem Eingabe-Array erstellt, aber bei den üblichen Größen von Collections, die wir normalerweise in unseren Anwendungen finden, ist dieser Aufwand in der Praxis kaum der Rede wert. Wenn ein System regelmäßig Collections mit Hunderttausenden oder Millionen von Elementen erstellt, arbeitet es bereits in einem ganz anderen Problemraum, in dem spezielle Datenstrukturen und sorgfältiges Performance Engineering erforderlich sind. Für die große Mehrheit der Anwendungsfälle überwiegen die durch dieses Muster gewonnene Klarheit und Typsicherheit bei weitem die geringen Kosten des Argument Unpackings.
Die Methode asArray() ermöglicht die Verwendung der Collection als Array. Das ist wichtig, damit wir über die Elemente der Collection iterieren können. Wie das geht, erkläre ich weiter unten.
Mit der Methode getIterator() implementieren wir das IteratorAggregate Interface von PHP. Objekte, die dieses Interface anbieten, können mit dem foreach Operator iteriert werden. Die Logik für das Iterieren der Elemente unserer TestCollection delegieren wir an das separate TestCollectionIterator Objekt, das wir in dieser Methode erzeugen.
Collections, wie ich sie hier vorstelle, müssen nicht readonly und immutable sein. Aber wann immer es möglich ist, bevorzuge ich Immutability.
Typsicherheit beim Iterieren
Es reicht nicht aus, sicherzustellen, dass nur Objekte vom Typ Test in die Collection gelangen. Genauso streng müssen wir gewährleisten, dass auch nur Objekte dieses Typs die Collection wieder verlassen.
Viele Entwicklerinnen und Entwickler würden die weiter oben beschriebene getIterator() Methode mit einem einfachen return new ArrayIterator($this->tests) implementieren. Das Problem dabei ist, dass die Methode current() von ArrayIterator einen Wert vom Typ mixed zurückliefert. Dadurch verlieren IDE und Tools für statische Codeanalyse oft den Faden oder müssen durch Annotationen in Kommentaren wie /** @var Test $item */ unterstützt werden.
Die Lösung ist ein eigener Iterator, der spezifisch für die eigene Collection-Klasse ist. Dank der Kovarianz von Rückgabetypen in PHP können wir den Vertrag von Iterator (current(): mixed) in unserer Implementierung verschärfen (current(): Test):
<?php declare(strict_types=1); /** * @template-implements Iterator<non-negative-int, Test> */ final class TestCollectionIterator implements Iterator { /** * @var list<Test> */ private readonly array $tests; /** * @var non-negative-int */ private int $position = 0; public function __construct(TestCollection $tests) { $this->tests = $tests->asArray(); } public function rewind(): void { $this->position = 0; } public function valid(): bool { return isset($this->tests[$this->position]); } /** * @return non-negative-int */ public function key(): int { return $this->position; } public function current(): Test { assert(isset($this->tests[$this->position])); return $this->tests[$this->position]; } public function next(): void { $this->position++; } }
Durch diesen expliziten Iterator erreichen wir Looping without Guessing:
<?php declare(strict_types=1); $tests = TestCollection::fromArray([new Test(/* ... */)]); foreach ($tests as $key => $test) { // ... }
Für das oben gezeigte Codebeispiel wissen IDE und Tools für statische Codeanalyse zweifelsfrei, dass in $key immer ein nicht-negativer Wert vom Typ int und in $test immer ein Objekt vom Typ Test ist.
Community-Diskussion
Die in diesem Artikel vorgestellte benutzerdefinierte Iterator-Implementierung hat eine coole Diskussion über die Annahmen ausgelöst, die dem TestCollectionIterator zugrunde liegen.
Andreas Heigl hat einen wichtigen Punkt zur Iterator-Semantik angesprochen. Normalerweise lassen Iteratoren zu, dass der interne Zeiger über den gültigen Bereich hinausgeht. In diesem Fall gibt current() false und key() null zurück, während valid() prüft, ob der Iterator innerhalb der Grenzen bleibt. Die vorgestellte Implementierung geht bewusst und explizit davon aus, dass Iterator-Methoden nur im Kontext von foreach-Schleifen aufgerufen werden, wo das Iterationsprotokoll von PHP sicherstellt, dass current() niemals aufgerufen wird, nachdem valid() false zurückgibt.
Diese Annahme mag gewagt erscheinen, vor allem in Bibliothekscode, wo Entwickler den Aufrufkontext nicht kontrollieren können, aber sie trifft auf die meisten Anwendungsfälle auf Anwendungsebene zu. Wichtig ist, dass diese Designentscheidung keine versteckte technische Schuld ist, sondern ein bewusster Kompromiss, der durch umfassende Tests deutlich gemacht wurde.
Die dazugehörige Testsuite überprüft, ob sich der Iterator unter den erwarteten Nutzungsmustern korrekt verhält, und wandelt so eine ansonsten implizite Annahme in ein dokumentiertes, validiertes Verhalten um. Dieser Ansatz zeigt, dass Tests mehr können als nur Fehler zu finden; sie können auch die Verträge und Einschränkungen, die die beabsichtigte Verwendung einer Komponente definieren, kodifizieren und kommunizieren.
Ich will nicht warten
Arnaud Le Blanc, Derick Rethans und Larry Garfield diskutieren in ihrem Artikel Generic Arrays. Sie unterscheiden zwischen:
-
Fluid Arrays: Der Typ wird durch den Inhalt bestimmt (
$a = [new A]istarray<A>). Sobald ich etwas anderes hinzufüge ($a[] = new B), ändert sich der Typ "fließend" zuarray<A|B>. -
Fixed Arrays: Der Typ wird bei der Erstellung fixiert (
array<int>). Das ist schwer mit der Copy-on-Write-Mechanik von PHP zu vereinbaren.
Die Autoren des Artikels sehen hier massive Hürden: Variance, Invariance, Performance. Collection-Objekte, so wie ich sie in diesem Artikel beschrieben habe, lösen dieses Dilemma pragmatisch: eine solche Collection ist effektiv ein Fixed Array.
Warum Boilerplate nicht wehtut
Ja, ich muss zwei Klassen und 90 Zeilen Code schreiben, nur um eine Liste von Objekten zu verwalten. Formal ist das Boilerplate. Aber ehrlich gesagt stört mich das nicht. Zumal nur 14 dieser 90 Zeilen Code relevant im Sinne der Softwaremetrik Logical Lines of Code sind.
Wenn dein Team das lieber mag, lässt sich dieses Muster leicht generieren und du kannst es auf Wunsch automatisieren. Nachdem ich diese Idee in Form von Shaku ausprobiert hatte, stellte ich fest, dass mir das einfache Copy/Paste/Adapt in der Praxis völlig ausreichte. Der eigentliche Vorteil liegt nicht in den 90 Zeilen Code selbst, sondern darin, dass jede Collection in der Codebasis gleich aussieht und sich gleich verhält, was die Konsistenz und Wartbarkeit erheblich verbessert.
Und wenn der Code erst einmal da ist, muss er in der Regel fast nie geändert werden. Er ist langweilig, stabil und tut genau das, was er soll. Er hält meinen restlichen Code sauber und typsicher.