mitmario.dev

Interfaces

PHP Sandbox 3 Min Lesezeit 4 BeispieleLektion 4 von 6

In den letzten beiden Lektionen ging es darum, Gemeinsames zusammenzufassen. Jetzt geht es um das Gegenteil: zwei Dinge, die nichts miteinander gemein haben außer der Frage, was man mit ihnen tun kann.

Das Problem: heute eine Datei, morgen etwas anderes

Stell dir eine Notizverwaltung vor, die ihre Einträge in eine Datei schreibt. Irgendwann sollen sie in eine Datenbank, und in den Tests soll gar nichts geschrieben werden, weil das langsam ist und Spuren hinterlässt.

Mit Vererbung kommst du hier nicht weit. Ein Dateispeicher und ein Speicher im Arbeitsspeicher sind nicht zwei Varianten derselben Sache, sie haben überhaupt keinen gemeinsamen Code. Gemeinsam ist nur, was man von ihnen verlangen kann.

Ein Interface ist genau diese Liste

Eine Zusage ohne einen einzigen Koerper
<?php

interface NotizSpeicher
{
    // Kein Koerper, nur die Ankuendigung. Und keine Sichtbarkeit
    // ausser public: eine Zusage, die niemand einloesen darf,
    // waere keine.
    public function merke(string $text): void;

    public function alle(): array;
}

class ArraySpeicher implements NotizSpeicher
{
    private array $notizen = [];

    public function merke(string $text): void
    {
        $this->notizen[] = $text;
    }

    public function alle(): array
    {
        return $this->notizen;
    }
}

$speicher = new ArraySpeicher();
$speicher->merke("Milch kaufen");
$speicher->merke("Brot holen");

echo count($speicher->alle()), " Notizen\n";
echo implode(" / ", $speicher->alle()), "\n";

interface NotizSpeicher schreibt auf, welche Methoden es gibt, mit welchen Parametern und welchem Rückgabetyp. Statt eines Körpers steht dort ein Semikolon. Es gibt keine Eigenschaften, keinen Code und nichts zu erben, nur die Zusage.

Eine Klasse sagt mit implements zu, dass sie diese Liste erfüllt. PHP prüft das beim Einlesen der Datei, nicht erst beim Aufruf.

Zwei Kleinigkeiten, über die man stolpert: Methoden in einem Interface sind immer public, etwas anderes ist gar nicht erlaubt, und der Zusatz darf trotzdem dastehen. Und ein Interface kann Konstanten enthalten, aber keine gewöhnlichen Eigenschaften. Eine Zusage sagt, was man tun kann, nicht wie es innen aussieht.

Der Gewinn liegt beim Aufrufer

Zwei Umsetzungen, ein Aufrufer
<?php

interface NotizSpeicher
{
    public function merke(string $text): void;

    public function alle(): array;
}

class ArraySpeicher implements NotizSpeicher
{
    private array $notizen = [];

    public function merke(string $text): void
    {
        $this->notizen[] = $text;
    }

    public function alle(): array
    {
        return $this->notizen;
    }
}

class DateiSpeicher implements NotizSpeicher
{
    public function __construct(private readonly string $pfad)
    {
        file_put_contents($this->pfad, "");
    }

    public function merke(string $text): void
    {
        file_put_contents($this->pfad, $text . "\n", FILE_APPEND);
    }

    public function alle(): array
    {
        return file($this->pfad, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    }
}

// Diese Funktion kennt nur die Zusage. Wohin die Notizen wirklich
// gehen, sieht sie nie.
function bericht(NotizSpeicher $speicher, string $marke): string
{
    $speicher->merke("Milch kaufen");
    $speicher->merke("Brot holen");

    return $marke . ": " . count($speicher->alle()) . " Notizen";
}

foreach (["Speicher" => new ArraySpeicher(), "Datei" => new DateiSpeicher("notizen.txt")] as $marke => $speicher) {
    echo get_class($speicher), " ist ein NotizSpeicher: ", $speicher instanceof NotizSpeicher ? "ja" : "nein", "\n";
    echo bericht($speicher, $marke), "\n";
}

bericht(NotizSpeicher $speicher, string $marke) ist die interessante Zeile im ganzen Beispiel. Die Funktion nimmt die Zusage als Typ, nicht eine Klasse. Sie funktioniert deshalb mit beiden Speichern, und sie funktioniert auch mit dem dritten, den es noch gar nicht gibt.

Damit dreht sich die Abhängigkeit um: Vorher hätte bericht() wissen müssen, dass es einen DateiSpeicher gibt. Jetzt weiß sie nur noch, was ein Speicher können muss, und wer sie aufruft, entscheidet, welcher es ist. Dass der zweite wirklich geschrieben hat, siehst du im Terminal: cat notizen.txt zeigt die beiden Zeilen, und ls die Datei daneben.

Mit instanceof kannst du fragen, ob ein Objekt eine Zusage erfüllt. Im Alltag brauchst du das selten, denn die Typangabe erledigt es schon; für eine Fallunterscheidung ist es trotzdem da.

Interface oder abstrakte Klasse?

Zusage gegen Erbe
<?php

interface Zaehlbar
{
    public function anzahl(): int;
}

interface Leerbar
{
    public function leeren(): void;
}

// Erben kann eine Klasse nur von einer Oberklasse. Zusagen darf
// sie beliebig viele geben.
class Korb implements Zaehlbar, Leerbar
{
    private array $waren = ["Apfel", "Birne"];

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

    public function leeren(): void
    {
        $this->waren = [];
    }
}

$korb = new Korb();

echo "Vorher:  ", $korb->anzahl(), "\n";
$korb->leeren();
echo "Nachher: ", $korb->anzahl(), "\n";

foreach ([Zaehlbar::class, Leerbar::class] as $zusage) {
    echo $zusage, ": ", $korb instanceof $zusage ? "ja" : "nein", "\n";
}

Der Unterschied passt in einen Satz: Eine abstrakte Klasse gibt Code vor, ein Interface nur eine Zusage. Wenn deine beiden Varianten Code teilen, nimm die abstrakte Klasse. Wenn sie nur die Bedienung teilen, nimm das Interface.

Dazu kommt eine harte Grenze, die die Entscheidung oft abnimmt: Erben kann eine Klasse nur von einer einzigen Oberklasse. Zusagen darf sie beliebig viele geben. Im Beispiel ist der Korb gleichzeitig Zaehlbar und Leerbar, und keine der beiden Zusagen weiß von der anderen.

Beides zusammen geht übrigens auch: eine abstrakte Klasse, die ein Interface umsetzt und einen Teil davon schon erledigt. Das ist ein häufiges Muster in fremden Bibliotheken, und wenn du beide Hälften kennst, liest es sich von selbst.

Was PHP wirklich durchsetzt

Wenn eine Methode fehlt
<?php

interface NotizSpeicher
{
    public function merke(string $text): void;

    public function alle(): array;
}

echo "Bis hierher laeuft alles.\n";

// PHP prueft die Zusage beim Einlesen der Datei, nicht erst beim
// Aufruf. Deshalb kommt hinter dieser Zeile nichts mehr.
require __DIR__ . "/kaputt.php";

echo "Diese Zeile wird nie erreicht.\n";

Fehlt eine zugesagte Methode, bricht PHP ab, sobald es die Klasse anlegt. Die Meldung nennt die Klasse, das Interface und die fehlende Methode beim Namen, und zwar bevor auch nur eine Zeile davon gelaufen ist. Das ist der handfeste Teil der Zusage. Ein Rechtschreibfehler ist es dabei nicht: php -l kaputt.php im Terminal meldet „No syntax errors detected”, und php index.php bricht trotzdem beim require ab.

Der andere Teil ist eine Frage der Ehrlichkeit: Ein Interface prüft Namen und Signatur, nicht das Verhalten. Nichts hindert eine Klasse daran, merke() zu haben und darin gar nichts zu tun. Was eine Methode leisten soll, steht weiterhin nur in deinem Kopf, im Namen und in den Tests.

Und genau darauf läuft es später hinaus: In Abschnitt 17 wird der Speicher im Arbeitsspeicher zum Werkzeug, mit dem sich die Notizverwaltung testen lässt, ohne eine einzige Datei anzufassen. Am aufrufenden Code ändert sich dafür nichts, und das ist der ganze Trick.

Zum Mitnehmen

Eine abstrakte Klasse gibt Code vor, ein Interface nur eine Zusage. Der Unterschied klingt klein und ist der Grund, warum derselbe Code einmal in eine Datei und einmal in den Arbeitsspeicher schreiben kann, ohne den Unterschied überhaupt zu kennen.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 4 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.