PHP 7: Typsichere Arrays von Objekten

Mit PHP 7 kann man sich dazu entscheiden, typsichereren Code zu schreiben als zuvor, dank skalaren Type Hints und Rückgabetypen.

function repeat(string $text, int $times) : string;

Aber was ist mit Arrays? Es gibt immer noch nur den generischen “array” Type Hint, man kann nicht spezifizieren was das Array enthält. Für die IDE kann man PhpDoc Kommentare hinzufügen:

/**
 * @return User[]
 */
function allUsers() : array;

Jetzt können IDEs wie PhpStorm mit Code-Vervollständigung für Elemente des zurückgegebenen Arrays helfen. Aber wir können nicht von Prüfungen zur Laufzeit profitieren, wie mit echten Type Hints.

Für Argumente gibt es einen partiellen Workaround mit variadischen Argumenten. Nehmen wir die folgende Funktion:

/**
 * @param User[] $users
 */
function deleteUsers(array $users);

Mit variadischen Argumenten können wir sie umschreiben zu:

function deleteUsers(User ...$users);

Die Benutzung ändert sich auch, zu deleteUsers(...$users); Bei diesem Aufruf wird das Argument $users in einzelne Variablen “unpacked”, und in der Methode selbst wieder in ein Array $users “packed”. Jedes Element wird dabei auf den Typ User validiert. $users kann auch ein Iterator sein, er wird dann beim Aufruf in ein Array konvertiert.

Leider gibt es keinen entsprechenden Workaround für Rückgabetypen, und es funktioniert nur mit dem letzten Argument.

Siehe auch: Type hinting in PHP 7 – array of objects

Ich nutze diese Technik oft in PHP 7 Code, aber es gibt noch eine andere, die die genannten Schwächen nicht hat:

Collection objects

Jedes Mal, wenn ich ein Array von Objekten benötige, erstelle ich stattdessen eine Klasse. Hier ist ein einfaches Beispiel:

class Users extends ArrayIterator
{
    public function __construct(User ...$users)
    {
        parent::__construct($users);
    }
    public function current() : User
    {
        return parent::current();
    }
    public function offsetGet($offset) : User
    {
        return parent::offsetGet($offset);
    }
}

Diese Collection erbt von ArrayIterator, so dass sie fast wie ein Array genutzt werden kann: iterieren mit foreach und auf Elemente mit [] zugreifen. Array-Funktionen können nicht genutzt werden, aber mit iterator_to_array() kann die Collection in ein normales Array konvertiert werden.

Beachte, dass nur Rückgabetypen geändert werden können, restriktiver zu sein als in der Elternklasse. Also würde es nicht funktionieren, offsetSet zu überschreiben und das Objekt wird in unvalidiertem Status bleiben, bis auf die Elemente zugegriffen wird.

Wenn wir das ArrayAccess Interface nicht benötigen, um auf Elemente mit [] zuzugreifen, gibt es eine Lösung dafür: den Array Iterator kapseln und nur die benötigten Methoden öffentlich machen:

class Users extends IteratorIterator
{
    public function __construct(User ...$users)
    {
        parent::__construct(new ArrayIterator($users));
    }
    public function current() : User
    {
        return parent::current();
    }
}

(hast du dich jemals gefragt, warum IteratorIterator existiert? Hier ist ein Anwendungsfall!)

Dieses Users Objekt kann nur mit User Elementen erstellt werden, dank variadischer Constructor Argumente. Irgendetwas anderes zu übergeben, resultiert in einem catchable TypeError.

new Users($user1, $user2, $user3);

Und es ist sogar immutable, es kann nach der Instantiierung nicht mehr verändert werden. Aber wenn wir es verändern müssen, können wir nun typsichere Methoden dafür hinzufügen, wie:

public function add(User $user)
{
    $this->getInnerIterator()->append($user);
}
public function set(int $key, User $user)
{
    $this->getInnerIterator()->offsetSet($key, $user);
}

Man kann es auch zählbar machen (weil wir wissen dass der innere Iterator Countable implementiert):

    public function count() : int
    {
        return $this->getInnerIterator()->count();
    }

Array Funktionen

Wie gesagt, wenn man Array-Funktionen benötigt, kann man das Objekt immer mit iterator_to_array() konvertieren. Doch es gibt eine andere Lösung, die oft sinnvoller ist: die Funktion in die Collection Klasse aufnehmen. Für Sortierfunktionen ist es einfach, da sie bereits Teil von ArrayIterator sind, so dass wir es genauso machen können wie mit count() oben.

Aber nehmen wir ein anderes Beispiel und führen zwei User Collections zusammen:

public function merge(Users $other)
{
    return new Users(
        array_merge(
            iterator_to_array($this),
            iterator_to_array($other)
        )
    );
}

Das kann dann so genutzt werden:

$allUsers = $goodUsers->merge($badUsers);

Beim Hinzufügen von Methoden zu den Collections, sei so spezifisch wie möglich, um Logik in die Collection aufzunehmen, die dorthin gehört. Zum Beispiel, anstatt eine generische uasort() Methode hinzuzufügen, erstelle exakt die Sortier-Methode(n), die benötigt werden:

public function sortByAge()
{
    $this->getInnerIterator()->uasort(
        function(User $a, User $b) {
            return $a->age() <=> $b->age();
        }
    );
}

Das macht den Client-Code viel klarer (was mit Arrays so nicht möglich gewesen wäre):

$users->sortByAge()

Mit filter/map/reduce Methoden haben wir auch die Option, mit Collection Pipelines zu arbeiten, wie ich es in einem früheren Blog Post beschrieben habe: Collection Pipelines in PHP

Domain Logic

Wenn ich eine Collection von Objekten benötige, erstelle ich oft zuerst eine einfache Klasse, die von IteratorIterator erbt, wie im Beispiel oben. Der offensichtliche Vorteil ist Typsicherheit, aber es macht es auch viel einfacher, später Domain-Logik an der richtigen Stelle einzubauen. Sobald ich Methoden habe, die auf Collections (bzw. Arrays) von Objekten arbeiten, kann ich sie direkt zur Collection-Klasse hinzufügen, wo sie natürlich hingehören. Andernfalls würden sie vermutlich in einer Utility oder Helper Klasse landen (Stop using Helpers!), oder sogar im Client-Code, der die Collection nutzt.

Nehmen wir diesen Teil eines Kartenspiels:

class AwesomeCardGame
{
  /** @var Card[] */
  private $stock;
  /** @var Card[] */
  private $pile;
  /**
   * shuffle discard pile and put back to stock
   */
  public function turnPile()
  {
    $this->stock = $this->pile
    $this->pile = [];
    $this->turnAllCards($this->stock);
    shuffle($this->stock);
  }
  private function turnAllCards(array $cards)
  {
    foreach ($cards as $card) {
      $card->turn();
    }
  }
}

Hier wird Spiel-Logik auf hoher Ebnene mit Implementierungs-Details vermischt. Mit einem Karten-Collection Objekt können wir Methoden die auf Karten operieren dorthin schieben und die Game Klasse auf einer Abstraktions-Ebene belassen:

class AwesomeCardGame
{
  /** @var Cards */
  private $stock;
  /** @var Cards */
  private $pile;
  /**
   * shuffle discard pile and put back to stock
   */
  public function turnPile()
  {
    $this->pile->moveTo($this->stock);
    $this->stock->turnAll();
    $this->stock->shuffle();
  }
}

Interfaces

Es ist oft nützlich, die Collection zuerst als Interface zu definieren. Es erweitert das Iterator Interface und enthält zumindest die current() Methode mit spezifiziertem Rückgabewert.

Minimales Beispiel:

interface Users extends \Iterator
{
  public function current() : User;
}

Mit einer Standard-Implementierung als gewrapptem ArrayIterator:

final class UsersArray implements Users extends \IteratorIterator
{
  public function __construct(User ...$users)
  {
    parent::__construct(new ArrayIterator($users));
  }
  public function current() : User
  {
    return parent::current();
  }
}

Warum ist das nützlich?

Austauschbare Implementierung

Erstens, wenn der Code von Dritten genutzt werden wird, können sie die Implementierung austauschen. Mein Lieblingsbeispiel sind Lazy Loading Collections mit Generatoren für bessere Performance mit großen Collections:

final class LazyLoadingUsers implements Users extends \IteratorIterator
{
  public function __construct(Generator $generator)
  {
    parent::__construct($generator);
  }
  public function current() : User
  {
    return parent::current();
  }
}

die dann etwa so instantiiert werden kann:

$userGenerator = function(PDOStatement $stmt) {
  while ($user = $stmt->fetchObject(User::class)) {
    yield $user;
  }
}
new LazyLoadingUsers(
  $userGenerator($pdo->query('SELECT * FROM users'))
);

Hier werden die User Objekte jeweils erst von der Datenbank gelesen, während tatsächlich über die Collection iteriert wird. Da Generatoren nur einmal iteriert werden können, wird die Collection auch nicht alle Objekte auf einmal im Speicher vorhalten.

Erweiterbarkeit mit Dekoratoren

Zweitens wird es möglich, die Klasse mit Dekoratoren zu erweitern, die die orginale Instanz wrappen und das selbe Interface implementieren, aber zusätzliches Verhalten hinzufügen. Man kann auch die Collection dazu verwenden, alle Elemente on the fly zu dekorieren.

Sagen wir, die Standard Implementierung von Card in unserem tollen Kartenspiel hat eine __toString() Methode, die Farbe und Wert als reinen Text zurückgibt, und haben einen Dekorator für eine HTML Repräsentation hinzugefügt:

final class HtmlCard implements Card
{
  /** @var Card */
  private $card;
  public function __construct(Card $card)
  {
    $this->card = $card;
  }

  public function __toString()
  {
    $plain = parent::__toString();
    return sprintf(
      '<img src="%s" alt="%s" />',
      $this->imageUrl($plain)
      $plain
    );
  }
  private function imageUrl(string $plain) : string
  {
    // ...
  }
}

Nun können wir auch einen Dekorator für die Collection schreiben, der eine beliebige Cards Collection nimmt und ihre Elemente mit HtmlCard dekoriert:

final class HtmlCards implements Cards
{
  /** @var Cards */
  private $cards;
  public function __construct(Cards $cards)
  {
    $this->cards = $cards;
  }
  public function current() : Card
  {
    return new HtmlCard(parent::current());
  }
  public function next() { $this->cards->next(); }
  public function rewind() { $this->cards->rewind(); }
  public function valid() { return $this->cards->valid(); }
  public function key() { return $this->cards->key(); }
}

Zusammenfassung

Das war eine Menge Code. Fassen wir zusammen:

  • Variadische Argumente können genutzt werden, um den Element-Typ für ein einzelnes Array Argument zu definieren
  • Einfache Collection Objekte als Array-Ersatz implementieren ist sehr einfach mit IteratorIterator
  • Man kann sie mit iterator_to_array() konvertieren, aber es ist besser, Array-Methoden stattdessen in die Collection aufzunehmen
  • Sobald man Collection Objekte hat, ziehen sie Geschäftslogik geradezu an, es stellt sich heraus dass sie für Methoden die auf den Elementen operieren der richtige Platz sind
  • Mit Interfaces gewinnt man Flexibilität. Zum Beispiel können Array-basierte Collections durch Generator-basierte Collections ersetzt werden oder Dekoratoren hinzugefügt werden
  • Mit Collections lassen sich noch mehr coole Dinge anstellen, siehe: Collection pipelines

Bild: Roo Reynolds CC-BY-NC 2.0

11 Replies to “PHP 7: Typsichere Arrays von Objekten”

  1. One of my own criticisms of PHP now it has type safety (so since 2015) is the productivity burden of such code, where in languages like Java and C# you can write a user class, then have the simple `user[] someListOfUsers` as an argument or variable.

    Another problem of this that nobody seems to be talking about is the fact PHP arrays are not arrays at all, but rather some hashmap (can’t think of anyone else doing the same), but I’d suppose that to be a topic for another day.

  2. Nice article :).

    PHP really needs generics… That way you could simply have a type safe collection without all the boilerplate (a new class for each entity for which you want to use it).

    By the way, can’t you make the constructor private and add static methods like (little bit coloured naming here) `UsersCollection::safe(User …$users)` and `UsersCollection::unsafe(Traversable $traversable)`? Then you can construct a `UsersCollection` from any iterator whatsover, specifically generators. Makes thing very composable.

    And if you let `UsersCollection` extend a general `Collection` class, then you could easily write generic helper functions like `Collection::filter(callable $f)` and `Collection::take(int $n)`. For instance:

    “`
    public function filter(callable $f): Collection
    {
    return new static(new CallbackFilterIterator($this, $f));
    }
    “`

    By using `new static`, you’d return actually a `UsersCollection` if you call `$users->filter($f)`.

    Thanks

    1. Thanks for your suggestions! As you already noticed in the gist, as soon as you extend any SPL iterator, you cannot make the constructor private (I tried that too). I like the “safe” and “unsafe” naming, though. And yeah, to add generic methods like filter to a base class makes sense if you want to use them. Of course this would be a perfect use case for generics, but it doesn’t look like we’ll get them anytime soon.

  3. I find arrays quite bad itself in performance and memory consumption. Now I prefer using own data structures with own binary tree indexes.

    Also prefer having using auto-generated contracts based on interface, don’t like a lot of boilerplate to write myself.

    Maybe in feature we have generics (templates) built into PHP some day.

    1. For improved performance, did you try php-ds? Vector, Set etc. all implement Traversable, so they could also be wrapped by IteratorIterator instead of using the ArrayIterator as described here. A code generator might be interesting here, but I prefer something like PhpStorm templates or a command line tool instead of magic at runtime.

    1. Because it’s needlessly more complex than __construct(User ...$users) and I’m fine with new Users($user1, $user2) instead of new Users([$user1, $user2]).

      Or am I missing the point of “just”? What’s the benefit?

    1. First of all, type safety, as mentioned. None of the SPL data structures and not even the new DS datastructures provide that, so you can use them as internal data structure (i.e. inner iterator), but not as a replacement.

  4. The idea is nice. But currently it’s not possible to pass more than one variadic variable in a constructor.

    This is not working.

    `<?php

    class Comment {}
    class User {}

    class SomeLogic
    {
    public function __construct(User …$users, Commment …$commments)
    {

    }
    }`

    What we need is a type safe array in PHP.

Hinterlasse eine Antwort

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind markiert *