mitmario.dev

Vererbung

PHP Sandbox 4 Min Lesezeit 5 BeispieleLektion 3 von 6

In der letzten Lektion hat jede Klasse für sich gestanden. Jetzt kommen zwei dazu, die einander ähnlich sind, und damit die Frage, was man mit der Ähnlichkeit anfängt.

Das Problem: dieselbe Methode, zweimal aufgeschrieben

Zweimal dasselbe, nur der Preis ist anders
<?php

class Brief
{
    public function __construct(
        private readonly string $name,
        private readonly int $gramm,
    ) {
    }

    public function preis(): float
    {
        return 0.85;
    }

    public function zeile(): string
    {
        return $this->name . ": " . number_format($this->preis(), 2, ",", ".");
    }
}

class Paket
{
    public function __construct(
        private readonly string $name,
        private readonly int $gramm,
    ) {
    }

    public function preis(): float
    {
        return 2.99 + $this->gramm / 1000 * 1.25;
    }

    // Wortgleich mit der Fassung oben. Wer hier etwas aendert,
    // muss daran denken, es zweimal zu tun.
    public function zeile(): string
    {
        return $this->name . ": " . number_format($this->preis(), 2, ",", ".");
    }
}

echo (new Brief("Brief", 20))->zeile(), "\n";
echo (new Paket("Paket", 2000))->zeile(), "\n";

Brief und Paket unterscheiden sich in genau einer Methode. Der Konstruktor ist gleich, zeile() ist wortgleich, und beides steht zweimal da.

Das ist nicht nur unschön. Es ist eine Verabredung, die niemand einhalten muss: Wer morgen das Format der Zeile ändert, ändert es an einer Stelle und übersieht die andere. Das Programm läuft weiter und gibt zwei verschiedene Formate aus.

extends zieht das Gemeinsame nach oben

Das Gemeinsame in einer Oberklasse
<?php

abstract class Sendung
{
    public function __construct(
        private readonly string $name,
        protected readonly int $gramm,
    ) {
    }

    abstract public function preis(): float;

    public function zeile(): string
    {
        return $this->name . ": " . number_format($this->preis(), 2, ",", ".");
    }
}

class Brief extends Sendung
{
    public function preis(): float
    {
        return 0.85;
    }
}

class Paket extends Sendung
{
    public function preis(): float
    {
        return 2.99 + $this->gramm / 1000 * 1.25;
    }
}

echo (new Brief("Brief", 20))->zeile(), "\n";
echo (new Paket("Paket", 2000))->zeile(), "\n";

class Brief extends Sendung heißt: Brief hat alles, was Sendung hat, und darf etwas dazutun. Der Konstruktor steht nur noch einmal da, zeile() ebenfalls, und die Unterklassen bestehen aus genau der Methode, in der sie sich wirklich unterscheiden.

Wichtig ist dabei, was geerbt wird. Methoden und Eigenschaften wandern nach unten, aber die Sichtbarkeit gilt weiter:

  • public und protected sind in der Unterklasse benutzbar.
  • private ist es nicht. Eine Eigenschaft, die in Sendung private ist, existiert für Brief nicht. Ein Zugriff darauf ist keine Fehlermeldung über Sichtbarkeit, sondern eine ganz gewöhnliche Undefined property-Warnung, denn aus Sicht der Unterklasse gibt es sie schlicht nicht.

Genau dafür ist protected da, und das ist auch schon seine ganze Aufgabe: „diese Klasse und alles, was von ihr erbt”. Im Beispiel ist $gramm deshalb protected, denn Paket::preis() rechnet damit. $name bleibt private, weil ihn nur zeile() in der Oberklasse braucht.

Im Reiter Debug ist beides zu sehen. Nach Aufzeichnen stehen in zeile() $this->name und $this->gramm in der Liste; einen Schritt weiter, in Brief::preis(), steht dort nur noch $this->gramm. Der Name ist für die Unterklasse nicht etwa unsichtbar, er ist nicht da. Und die Zeile mit return $this->name taucht zweimal auf, einmal mit "Brief" und einmal mit "Paket"; dazwischen springt die Liste einmal in das preis() von Brief und einmal in das von Paket. Eine Methode, zwei Ergebnisse.

Überschreiben, und parent:: für den Fall dazwischen

Mit parent ergaenzen statt ersetzen
<?php

class Sendung
{
    public function __construct(protected readonly int $gramm)
    {
    }

    public function preis(): float
    {
        return 2.99 + $this->gramm / 1000 * 1.25;
    }
}

class Express extends Sendung
{
    public function __construct(int $gramm, private readonly bool $samstag)
    {
        parent::__construct($gramm);
    }

    // Nicht ersetzen, sondern draufrechnen.
    public function preis(): float
    {
        return parent::preis() + ($this->samstag ? 4.00 : 2.00);
    }
}

$normal = new Sendung(2000);
$express = new Express(2000, true);

echo "Normal:  ", number_format($normal->preis(), 2, ",", "."), "\n";
echo "Express: ", number_format($express->preis(), 2, ",", "."), "\n";

Schreibt eine Unterklasse eine Methode noch einmal, die es oben schon gibt, gilt ihre Fassung. Das ist Überschreiben, und meistens will man genau das.

Manchmal will man aber nicht ersetzen, sondern ergänzen: der Expressversand kostet das Normale plus einen Zuschlag. Dafür gibt es parent::, mit dem du die Fassung der Oberklasse aufrufst und ihr Ergebnis weiterverwendest. Dasselbe gilt für den Konstruktor: Wenn eine Unterklasse einen eigenen braucht, ruft sie mit parent::__construct(...) den von oben auf. Vergisst sie das, wird der Konstruktor der Oberklasse gar nicht ausgeführt. Die geerbten Eigenschaften haben dann keinen Wert, und der erste Zugriff darauf bricht mit Typed property Sendung::$gramm must not be accessed before initialization ab, also mit demselben Fehler wie das halbfertige Objekt aus Lektion 13.1.

Der Reiter Debug zeigt genau diesen Moment: An der Zeile mit parent::__construct($gramm) steht in der Liste schon $this->samstag, aber noch kein $this->gramm. Erst beim nächsten Schritt in einer Methode von Express, in preis(), ist es da.

abstract: der Bauplan, von dem es kein Haus gibt

Ein Bauplan ohne Haus
<?php

abstract class Sendung
{
    // Kein Koerper, nur ein Semikolon: jede Variante muss das
    // selbst wissen.
    abstract public function preis(): float;
}

class Brief extends Sendung
{
    public function preis(): float
    {
        return 0.85;
    }
}

echo "Brief: ", number_format((new Brief())->preis(), 2, ",", "."), "\n";

try {
    new Sendung();
} catch (Error $fehler) {
    echo "Abgelehnt: ", $fehler->getMessage(), "\n";
}

abstract class Sendung heißt: Diese Klasse ist nur zum Erben da. new Sendung() ist ein Error mit der Meldung Cannot instantiate abstract class Sendung, und das ist richtig so, denn eine „Sendung” ohne Angabe, welche, hat keinen Preis.

abstract public function preis(): float; ist die zweite Hälfte davon: eine Methode ohne Körper, nur mit Semikolon. Sie sagt „das muss jede Variante selbst wissen”, und PHP setzt es durch: Eine Unterklasse, die sie nicht schreibt, lässt sich nicht einmal einlesen.

Damit ist die abstrakte Klasse mehr als ein Sammelbecken. Sie ist eine Liste aus zwei Teilen: Das hier gilt für alle, und das hier muss jeder selbst beantworten.

Und was daraus wird, wenn es eine Seite werden soll

Zwei Sendungsarten als Preisuebersicht
<?php

abstract class Sendung
{
    public function __construct(
        protected readonly string $name,
        protected readonly int $gramm,
    ) {
    }

    abstract public function preis(): float;

    // Eine Methode in der Oberklasse, zwei Ergebnisse: preis()
    // kommt jeweils aus der Unterklasse.
    public function reihe(): string
    {
        return "<tr><td>" . htmlspecialchars($this->name, ENT_QUOTES, "UTF-8")
            . "</td><td>" . $this->gramm . " g</td><td>"
            . number_format($this->preis(), 2, ",", ".") . " EUR</td></tr>";
    }
}

class Brief extends Sendung
{
    public function preis(): float
    {
        return 0.85;
    }
}

class Paket extends Sendung
{
    public function preis(): float
    {
        return 2.99 + $this->gramm / 1000 * 1.25;
    }
}

// Das style-Element muss mit in die Ausgabe: der Browser bekommt
// einen Abzug ohne Adresse, eine eigene CSS-Datei laedt dort nicht.
echo "<style>body{font-family:system-ui;margin:1rem}";
echo "table{border-collapse:collapse}";
echo "td,th{border:1px solid #999;padding:.35rem .6rem;text-align:left}</style>\n";

echo "<h1>Porto</h1>\n";
echo "<table><tr><th>Sendung</th><th>Gewicht</th><th>Preis</th></tr>\n";

foreach ([new Brief("Brief", 20), new Paket("Paket", 2000)] as $sendung) {
    echo $sendung->reihe(), "\n";
}

echo "</table>\n";

Dieses Beispiel gibt HTML aus, und deshalb läuft ein Server und im Reiter Browser steht eine Seite. Es ist das einzige in diesem Abschnitt, alle anderen spielen auf der Kommandozeile.

Sieh dir an, was hier eigentlich passiert: reihe() steht einmal in der Oberklasse und liefert trotzdem für Brief und Paket verschiedene Zeilen, weil $this->preis() jeweils die Fassung der Unterklasse nimmt. Das ist der praktische Gewinn der ganzen Lektion, und er steht in einer einzigen Methode.

Wann Vererbung passt, und wann nicht

Vererbung passt, wenn zwei Klassen dieselbe Sache in Varianten sind. Ein Brief ist eine Sendung, ein Paket ist eine Sendung. Die Probe dafür ist der Satz „ein X ist ein Y”: Wenn er sich falsch anhört, ist es keine Vererbung.

Sie passt nicht, wenn zwei Dinge sich nur ähneln oder wenn eine Klasse etwas hat, statt etwas zu sein. Eine Bestellung hat eine Adresse, sie ist keine. Solche Fälle löst man, indem man das Objekt hineingibt, nicht indem man erbt.

Und noch etwas: Der Kurs bleibt bei einer Ebene. Drei oder vier Stufen sind schnell gebaut, und danach muss man beim Lesen jeder Methode raten, aus welcher der vier Klassen sie eigentlich kommt. Wo es eng wird, ist meistens nicht ein tieferer Baum die Antwort, sondern die nächste Lektion.

Zum Mitnehmen

Zwei Klassen, die zu vier Fünfteln dasselbe tun, sind zwei Stellen, an denen dieselbe Änderung nötig wird. Vererbung zieht das Gemeinsame nach oben. Sie ist dabei ein schärferes Werkzeug, als sie aussieht, und dieser Artikel sagt auch, wann man sie stehen lässt.

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 5 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.