Das Bild zeigt mehrere ineinandergreifende Zahnräder in Weiß und Gelb auf einer dunklen Tafeloberfläche. Eine Hand ergänzt oder hebt gerade eines der gelben Zahnräder hervor. Diese Hand kann als Sinnbild für den Entwickler gelesen werden, der gezielt neue, typensichere Komponenten in ein bestehendes System einfügt, ohne den Mechanismus zu stören.

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:

  1. Type Annotations für statische Codeanalyse mit PHPStan oder einem anderen Tool
  2. 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.

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] ist array<A>). Sobald ich etwas anderes hinzufüge ($a[] = new B), ändert sich der Typ "fließend" zu array<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.

Seit über 35 Jahren entwickle ich Software, davon fast 30 Jahre mit PHP. Mehr als 25 Jahre widme ich mich außerdem schon der Entwicklung von PHPUnit. Das Wissen aus dieser Zeit fließt in meine Artikel ein, doch es ist nur die Spitze des Eisbergs.

Wenn du und dein Team von meiner Erfahrung durch Beratung und individuelles Coaching profitieren möchtet, freue ich mich auf deine Nachricht.