Übungsaufgaben zum Einsatz von Vererbung: Liskov Substitution Principle und Favour Composition over Inheritance

https://bildung.social/@oerinformatik/

https://oer-informatik.de/vererbung-ersetzbarkeit-uebungsaufgaben

Clean Code, Liskov Substitution Principle, Favour Composition over Inheritance, Vererbung, Komposition, Delegation, Design by Contract, fragile Basisklasse, UML-Klassendiagramm, Übungsaufgaben, Prüfungsvorbereitung

tl/dr; (Bearbeitungszeit insgesamt 55 min, mehrere Aufgaben): Drei Übungsaufgaben zum Einsatz von Vererbung: Du findest im Mitfahrlabor LSP-Verstöße bei den Benachrichtigungskanälen und behebst sie, baust in der CSB-Rad-Kata die Kurierzustellung ohne LSP-Verstoß ein und ersetzt eine Vererbungshierarchie aus Fahrtangebot, Mitfahrerliste und ArrayList durch Komposition, mit Klassendiagramm vor und nach dem Umbau [AI-Assisted]. (Zuletzt geändert am 10.11.2026)

Diese Übungsaufgaben gehören zum Artikel Vererbung richtig einsetzen: Liskov Substitution Principle und Favour Composition over Inheritance.

Wortwolke zu den Übungsaufgaben: Liskov Substitution Principle, Favour Composition over Inheritance, Vererbung, Komposition, Delegation, Vorbedingung, Nachbedingung, fragile Basisklasse (mit KI erstellt)

Hinweise zur Bearbeitung: Als FIA musst Du zur Teil-2-Prüfung Aufgaben auf Kernkompetenz-Ebene lösen können, das ist Aufgabe 2.1. Von FIA wird zusätzlich erwartet, dass ihnen Aufgaben auf Profilkompetenz-Ebene (Aufgabe 3.1 und Aufgabe 3.2) keine Probleme bereiten. Die Codeausschnitte stehen in Java und Python; wähle die Sprache, die Du gerade lernst, und schreib Deine Lösung in dieser Sprache. Für die Aufgaben 2.1, 3.1 und 3.2 brauchst Du eine Entwicklungsumgebung mit JUnit 5 bzw. pytest, damit Du nach jedem Umbauschritt die Tests ausführen kannst. Achte auf die Operatoren: „Nenne“ verlangt Stichworte, „Ordne zu“ verlangt die Zuordnung mit kurzer Begründung, „Erläutere“ verlangt eine Erklärung mit Beispiel aus dem Code, „Baue um“, „Implementiere“ und „Schreibe“ verlangen lauffähigen Code, „Zeichne“ verlangt ein UML-Klassendiagramm, „Beurteile“ und „Begründe“ verlangen eine begründete eigene Aussage. Schreib Deine Lösung zuerst vollständig auf, bevor Du die Beispiellösung aufklappst.

Du möchtest eigene Aufgaben zum Üben von einem KI-Sprachmodell generieren lassen?

Kopiere den Text in ein KI-Sprachmodell Deiner Wahl und passe Kompetenzstufe, Anzahl, Bearbeitungszeit und Szenario bei Bedarf an. Wie die Aufgaben aufgebaut sind, beschreibt der Artikel Wie sind die Aufgaben aufgebaut?. Prüfe die Ergebnisse mit dem Lehrartikel: KI-Sprachmodelle machen Fehler.

Erstelle neue Übungsaufgaben zum Thema „Einsatz von Vererbung: Liskov
Substitution Principle und Favour Composition over Inheritance“.

Bauplan: Halte Dich an Aufbau, Kompetenzstufen, Operatoren,
Anforderungsbereiche, Punkte und Zeitschätzung, die im Artikel
https://oer-informatik.de/aufgaben-aufbau beschrieben sind.

Fachliche Grundlage:
- https://oer-informatik.de/vererbung-ersetzbarkeit
- https://oer-informatik.de/klassen-schneiden
- https://oer-informatik.de/uml-klassendiagramm-vererbung
- https://oer-informatik.de/uml-klassendiagramm-polymorphie
- https://oer-informatik.de/refactoring
- https://oer-informatik.de/erweitern-statt-aendern
- Stilbeispiel: https://oer-informatik.de/vererbung-ersetzbarkeit-uebungsaufgaben

Die Aufgaben sollen Inhalte zu diesen Schlagwörtern abdecken:
- Clean Code
- Liskov Substitution Principle
- Favour Composition over Inheritance
- Vererbung
- Komposition
- Delegation
- Design by Contract
- fragile Basisklasse
- UML-Klassendiagramm

Kompetenzstufe: Profilkompetenz (alternativ: Basiskompetenz, Kernkompetenz, Expertise)
Anzahl: 1 Aufgabe mit Teilaufgaben
Bearbeitungszeit in Minuten ca.: <5 bis 30 min>
Szenario: eines der Szenarien aus dem Bauplan

Vorgehen:
1. Lies zuerst die verlinkten Artikel. Kannst Du keine Webseiten
   abrufen, sag mir das, statt zu raten.
2. Steht bei einer Angabe noch ein Platzhalter in <…>, frag mich
   zuerst danach. Richte Punkte und Zahl der Teilaufgaben nach der
   Bearbeitungszeit (ca. 0,9 min je Punkt).
3. Gib je Aufgabe Titel mit Kompetenzstufe, Ausgangssituation und
   Teilaufgaben mit Operator und Punktzahl aus, dazu die geschätzte
   Bearbeitungszeit.
4. Gib die Beispiellösungen mit Punkteverteilung erst aus, wenn ich
   danach frage.

Aufgabe 2.1: Benachrichtigungskanäle im Mitfahrlabor ersetzbar machen (Kernkompetenz)

(ca. 15 min Bearbeitungszeit)

Die Mitfahr-App Mitfahrlabor erinnert Mitfahrende vor einer gebuchten Fahrt. Dafür gibt es mehrere Kanäle: Mail, Push-Nachricht in der App und die Flurmonitore der Schule. Die Flurmonitore zeigen offene Angebote anonym an, also nie Namen oder persönliche Nachrichten. Push-Nachrichten erreichen nur Personen, die Push in der App aktiviert haben. Ein Teammitglied hat alle drei Kanäle als Unterklassen einer gemeinsamen Oberklasse Benachrichtigung angelegt:

// Paket de.mitfahrlabor, Stand vor dem Umbau
// ===== Person.java =====
package de.mitfahrlabor;

public record Person(String name, String email, boolean pushAktiv) {
}

// ===== Benachrichtigung.java =====
package de.mitfahrlabor;

import java.util.ArrayList;
import java.util.List;

/** Ein Kanal, über den die App Personen benachrichtigt. */
public abstract class Benachrichtigung {

    protected final List<String> ausgang = new ArrayList<>();

    /** Stellt der Person den Text zu; danach steht genau ein Eintrag mehr im Ausgang. */
    public abstract void sende(Person person, String text);

    public List<String> ausgang() {
        return List.copyOf(ausgang);
    }
}

// ===== MailBenachrichtigung.java =====
package de.mitfahrlabor;

public class MailBenachrichtigung extends Benachrichtigung {

    @Override
    public void sende(Person person, String text) {
        ausgang.add("Mail an " + person.email() + ": " + text);
    }
}

// ===== PushBenachrichtigung.java =====
package de.mitfahrlabor;

public class PushBenachrichtigung extends Benachrichtigung {

    @Override
    public void sende(Person person, String text) {
        if (!person.pushAktiv()) {
            throw new IllegalStateException("Push ist für " + person.name() + " nicht aktiviert");
        }
        ausgang.add("Push an " + person.name() + ": " + text);
    }
}

// ===== FlurmonitorAnzeige.java =====
package de.mitfahrlabor;

public class FlurmonitorAnzeige extends Benachrichtigung {

    @Override
    public void sende(Person person, String text) {
        throw new UnsupportedOperationException("Flurmonitore zeigen keine persönlichen Nachrichten");
    }

    public void zeige(String haltepunkt, String abfahrt, int freiePlaetze) {
        ausgang.add(abfahrt + " ab " + haltepunkt + ": " + freiePlaetze + " Plätze frei");
    }
}

// ===== Erinnerungsdienst.java =====
package de.mitfahrlabor;

import java.util.List;

public class Erinnerungsdienst {

    private final List<Benachrichtigung> kanaele;

    public Erinnerungsdienst(List<Benachrichtigung> kanaele) {
        this.kanaele = List.copyOf(kanaele);
    }

    public void erinnere(List<Person> mitfahrende, String fahrt) {
        for (Benachrichtigung kanal : kanaele) {
            if (kanal instanceof FlurmonitorAnzeige) {
                continue;
            }
            for (Person person : mitfahrende) {
                if (kanal instanceof PushBenachrichtigung && !person.pushAktiv()) {
                    continue;
                }
                kanal.sende(person, "Erinnerung: " + fahrt);
            }
        }
    }
}

Diese Tests sind grün:

package de.mitfahrlabor;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;
import org.junit.jupiter.api.Test;

class ErinnerungsdienstTest {

    private static final Person AYLIN = new Person("Aylin Kaya", "aylin.kaya@example.org", true);
    private static final Person LUKAS = new Person("Lukas Hofmann", "lukas.hofmann@example.org", false);
    private static final String FAHRT = "Mo 07:15 ab Gütersloh Hbf";

    private final MailBenachrichtigung mail = new MailBenachrichtigung();
    private final PushBenachrichtigung push = new PushBenachrichtigung();

    @Test
    void mailErreichtAlle() {
        new Erinnerungsdienst(List.of(mail)).erinnere(List.of(AYLIN, LUKAS), FAHRT);
        assertEquals(List.of("Mail an aylin.kaya@example.org: Erinnerung: " + FAHRT,
                "Mail an lukas.hofmann@example.org: Erinnerung: " + FAHRT), mail.ausgang());
    }

    @Test
    void pushNurBeiAktiviertemPush() {
        new Erinnerungsdienst(List.of(mail, push)).erinnere(List.of(AYLIN, LUKAS), FAHRT);
        assertEquals(List.of("Push an Aylin Kaya: Erinnerung: " + FAHRT), push.ausgang());
        assertEquals(2, mail.ausgang().size());
    }

    @Test
    void flurmonitorZeigtNurAnonymeAngebote() {
        FlurmonitorAnzeige monitor = new FlurmonitorAnzeige();
        monitor.zeige("Gütersloh Hbf", "07:15", 2);
        assertEquals(List.of("07:15 ab Gütersloh Hbf: 2 Plätze frei"), monitor.ausgang());
    }

    // entfällt nach dem Umbau: Der Flurmonitor kann dann gar nicht mehr übergeben werden
    @Test
    void erinnerungsdienstUebergehtFlurmonitor() {
        FlurmonitorAnzeige monitor = new FlurmonitorAnzeige();
        new Erinnerungsdienst(List.of(mail, monitor)).erinnere(List.of(AYLIN), FAHRT);
        assertEquals(List.of(), monitor.ausgang());
    }
}

a) Nenne drei Stellen im Code, an denen das Liskov Substitution Principle verletzt oder umgangen wird, und ordne jeder Stelle eine Art von Verstoß zu (Ausnahme in überschriebener Methode, verschärfte Vorbedingung, typabhängige Fallunterscheidung beim Aufrufer). (6 Punkte)

LSP-Verstöße bei den Benachrichtigungskanälen (Beispiellösung)
Stelle Art des Verstoßes
FlurmonitorAnzeige.sende wirft immer eine UnsupportedOperationException bzw. NotImplementedError Ausnahme in überschriebener Methode: Der Kanal kann nicht, was die Oberklasse verspricht.
PushBenachrichtigung.sende wirft, wenn Push nicht aktiviert ist verschärfte Vorbedingung: Die Oberklasse verlangt nur eine Person und einen Text.
Erinnerungsdienst.erinnere mit instanceof/isinstance für FlurmonitorAnzeige und PushBenachrichtigung typabhängige Fallunterscheidung beim Aufrufer: Der Dienst muss die Unterklassen kennen, um die Verstöße zu umgehen.

Bewertung: je Stelle 1 Punkt, je richtige Zuordnung 1 Punkt.

b) Erläutere am Vertrag der Methode sende (Vor- und Nachbedingung laut Kommentar), warum ein neuer Programmteil, der gegen Benachrichtigung geschrieben ist, mit diesen Unterklassen scheitern kann. Gib ein konkretes Beispiel an. (4 Punkte)

Der Vertrag von sende verlangt als Vorbedingung nur eine Person und einen Text und verspricht als Nachbedingung, dass danach genau ein Eintrag mehr im Ausgang steht (1 Punkt). PushBenachrichtigung verlangt zusätzlich pushAktiv, verschärft also die Vorbedingung; FlurmonitorAnzeige erfüllt die Nachbedingung nie (1 Punkt). Ein neuer Programmteil, etwa eine Funktion „Fahrt abgesagt“, die allen Bestätigten über alle Kanäle schreibt, kennt die Sonderfälle nicht: Sie bricht mit einer Ausnahme ab, sobald sie Lukas Hofmann (Push nicht aktiviert) über Push oder irgendwen über den Flurmonitor benachrichtigt (1 Punkt). Jeder neue Aufrufer müsste die instanceof-Abfragen des Erinnerungsdienstes kopieren, und jeder neue Kanal mit Einschränkung verlangt neue Abfragen bei allen Aufrufern (1 Punkt).

c) Baue den Code so um, dass das LSP eingehalten wird und der Erinnerungsdienst keine Unterklassen mehr abfragt. Der Flurmonitor ist fachlich kein Kanal für einzelne Personen. Die drei ersten Tests müssen grün bleiben; der vierte Test darf entfallen, wenn sich ein Flurmonitor gar nicht mehr als Benachrichtigung übergeben lässt. (6 Punkte)

Zwei Schritte: Die Einschränkung von Push wird Teil des Vertrags der Oberklasse (neue Methode erreicht, Standard true), und FlurmonitorAnzeige erbt nicht mehr von Benachrichtigung. Person und MailBenachrichtigung bleiben unverändert.

// geänderte Klassen nach dem Umbau
// ===== Benachrichtigung.java =====
package de.mitfahrlabor;

import java.util.ArrayList;
import java.util.List;

/** Ein Kanal, über den die App einzelne Personen benachrichtigt. */
public abstract class Benachrichtigung {

    protected final List<String> ausgang = new ArrayList<>();

    /** Kann dieser Kanal die Person erreichen? Standard: ja. */
    public boolean erreicht(Person person) {
        return true;
    }

    /**
     * Stellt der Person den Text zu; danach steht genau ein Eintrag mehr im Ausgang.
     * Vorbedingung: erreicht(person) ist true.
     */
    public abstract void sende(Person person, String text);

    public List<String> ausgang() {
        return List.copyOf(ausgang);
    }
}

// ===== PushBenachrichtigung.java =====
package de.mitfahrlabor;

public class PushBenachrichtigung extends Benachrichtigung {

    @Override
    public boolean erreicht(Person person) {
        return person.pushAktiv();
    }

    @Override
    public void sende(Person person, String text) {
        if (!erreicht(person)) {
            throw new IllegalStateException("Push ist für " + person.name() + " nicht aktiviert");
        }
        ausgang.add("Push an " + person.name() + ": " + text);
    }
}

// ===== FlurmonitorAnzeige.java =====
package de.mitfahrlabor;

import java.util.ArrayList;
import java.util.List;

/** Zeigt offene Angebote anonym an; ist KEINE Benachrichtigung einzelner Personen. */
public class FlurmonitorAnzeige {

    private final List<String> ausgang = new ArrayList<>();

    public void zeige(String haltepunkt, String abfahrt, int freiePlaetze) {
        ausgang.add(abfahrt + " ab " + haltepunkt + ": " + freiePlaetze + " Plätze frei");
    }

    public List<String> ausgang() {
        return List.copyOf(ausgang);
    }
}

// ===== Erinnerungsdienst.java =====
package de.mitfahrlabor;

import java.util.List;

public class Erinnerungsdienst {

    private final List<Benachrichtigung> kanaele;

    public Erinnerungsdienst(List<Benachrichtigung> kanaele) {
        this.kanaele = List.copyOf(kanaele);
    }

    public void erinnere(List<Person> mitfahrende, String fahrt) {
        for (Benachrichtigung kanal : kanaele) {
            for (Person person : mitfahrende) {
                if (kanal.erreicht(person)) {
                    kanal.sende(person, "Erinnerung: " + fahrt);
                }
            }
        }
    }
}

Die drei verbliebenen Tests sind grün. Der Versuch, eine FlurmonitorAnzeige in die Liste der Kanäle zu legen, ist jetzt ein Übersetzungsfehler bzw. ein Fehler bei der Typprüfung; der vierte Test entfällt deshalb.

Andere Lösungen sind gleichwertig, etwa eine Liste von Kanälen je Person statt erreicht, oder ein Push-Kanal, der bei deaktiviertem Push still nichts tut, wenn der Vertrag der Oberklasse das ausdrücklich erlaubt (dann muss aber die Nachbedingung „ein Eintrag mehr“ angepasst werden).

Bewertung: Flurmonitor aus der Hierarchie gelöst (2 Punkte), Einschränkung von Push im Vertrag der Oberklasse bzw. vor dem Aufruf erfragbar (2 Punkte), Erinnerungsdienst ohne Typabfragen (1 Punkt), Tests grün (1 Punkt).

Aufgabe 3.1: Kurierzustellung in die Kata einbauen (Profilkompetenz)

(ca. 20 min Bearbeitungszeit)

Die CSB-Rad GmbH (siehe Szenario CSB-Rad) will kleine Bestellungen im Stadtgebiet per Lastenrad zustellen lassen. Die neue Versandart KURIER kostet pauschal 12,90 €, nimmt höchstens 25 kg mit und transportiert keine Räder. Die Bestätigungsmail nennt dann als letzte Zeile „Versand: Kurier (Lastenrad)“. Du startest mit Stand 06 der Code-Kata; im Begleit-Repository liegt er unter src/kata/java/stand-06 bzw. src/kata/python/stand-06. Der vollständige Code:

// Kata CSB-Rad, Stand 06, Paket de.csbrad.shop (src/main/java)
// ===== Position.java =====
package de.csbrad.shop;

/** Eine Bestellposition; Preise sind Nettopreise in Cent, Gewichte in Gramm. */
public record Position(String artikelnr, String bezeichnung, int nettopreisCent, int menge, int gewichtGramm, boolean istRad) {
}

// ===== Bestellanfrage.java =====
package de.csbrad.shop;

import java.util.List;

/** Parameterobjekt: alles, was der Bestellabschluss als Eingabe braucht. */
public record Bestellanfrage(List<Position> positionen, String versandart, String gutscheincode, String kundenEmail) {
}

// ===== Bestellung.java =====
package de.csbrad.shop;

import java.util.List;

/** Eine Bestellung kennt ihre Positionen und berechnet ihre Beträge selbst (Tell, don't ask). */
public final class Bestellung {

    private static final String GUTSCHEINCODE_KURIER = "KURIER10";
    private static final int GUTSCHEIN_RABATT_PROZENT = 10;
    private static final int MEHRWERTSTEUER_PROZENT = 19;

    private final List<Position> positionen;
    private final String versandart;
    private final String gutscheincode;
    private final String kundenEmail;
    private final Versandkostenrechner versandkostenrechner;
    private String bestellnummer;

    public Bestellung(Bestellanfrage anfrage, Versandkostenrechner versandkostenrechner) {
        pruefePositionen(anfrage.positionen());
        this.positionen = List.copyOf(anfrage.positionen());
        this.versandart = anfrage.versandart();
        this.gutscheincode = anfrage.gutscheincode();
        this.kundenEmail = anfrage.kundenEmail();
        this.versandkostenrechner = versandkostenrechner;
        if (warenwert() == 0) {
            throw new IllegalArgumentException("Bestellung ohne Warenwert");
        }
    }

    private static void pruefePositionen(List<Position> positionen) {
        if (positionen.isEmpty()) {
            throw new IllegalArgumentException("Bestellung ohne Positionen");
        }
        for (Position position : positionen) {
            if (position.menge() <= 0) {
                throw new IllegalArgumentException("Menge muss positiv sein: " + position.artikelnr());
            }
        }
    }

    public int warenwert() {
        return positionen.stream().mapToInt(p -> p.nettopreisCent() * p.menge()).sum();
    }

    public int rabatt() {
        if (GUTSCHEINCODE_KURIER.equals(gutscheincode)) {
            return prozentKaufmaennischGerundet(warenwert(), GUTSCHEIN_RABATT_PROZENT);
        }
        return 0;
    }

    public int warenwertNachRabatt() {
        return warenwert() - rabatt();
    }

    public int versandkosten() {
        return versandkostenrechner.berechne(this);
    }

    public int mehrwertsteuer() {
        return prozentKaufmaennischGerundet(warenwertNachRabatt() + versandkosten(), MEHRWERTSTEUER_PROZENT);
    }

    public int gesamtbetrag() {
        return warenwertNachRabatt() + versandkosten() + mehrwertsteuer();
    }

    public int gesamtgewichtGramm() {
        return positionen.stream().mapToInt(p -> p.gewichtGramm() * p.menge()).sum();
    }

    public int anzahlRaeder() {
        return positionen.stream().filter(Position::istRad).mapToInt(Position::menge).sum();
    }

    public boolean enthaeltRad() {
        return positionen.stream().anyMatch(Position::istRad);
    }

    public String versandart() {
        return versandart;
    }

    public String kundenEmail() {
        return kundenEmail;
    }

    public String bestellnummer() {
        return bestellnummer;
    }

    void vergibNummer(String nummer) {
        if (bestellnummer != null) {
            throw new IllegalStateException("Bestellung hat schon eine Nummer: " + bestellnummer);
        }
        bestellnummer = nummer;
    }

    private static int prozentKaufmaennischGerundet(int betragCent, int prozent) {
        return (betragCent * prozent + 50) / 100;
    }
}

// ===== Versandkostenrechner.java =====
package de.csbrad.shop;

import java.util.Set;

/** Kennt die Versandregeln der CSB-Rad GmbH. */
public class Versandkostenrechner {

    private static final Set<String> VERSANDARTEN = Set.of("PAKET", "SPEDITION", "ABHOLUNG");
    private static final int PAKETPAUSCHALE_CENT = 690;
    private static final int HOECHSTGEWICHT_PAKET_GRAMM = 31500;
    private static final int VERSANDKOSTENFREI_AB_CENT = 10000;
    private static final int SPEDITION_JE_RAD_CENT = 4900;

    public void pruefeVersandmoeglich(Bestellung bestellung) {
        if (!VERSANDARTEN.contains(bestellung.versandart())) {
            throw new IllegalArgumentException("Unbekannte Versandart: " + bestellung.versandart());
        }
        if (bestellung.versandart().equals("PAKET") && bestellung.enthaeltRad()) {
            throw new IllegalArgumentException("Räder können nicht als Paket versandt werden");
        }
    }

    public int berechne(Bestellung bestellung) {
        return switch (bestellung.versandart()) {
            case "PAKET" -> paketkosten(bestellung);
            // ohne Rad wird eine Speditionspauschale berechnet
            case "SPEDITION" -> Math.max(1, bestellung.anzahlRaeder()) * SPEDITION_JE_RAD_CENT;
            default -> 0;
        };
    }

    private int paketkosten(Bestellung bestellung) {
        if (bestellung.warenwertNachRabatt() >= VERSANDKOSTENFREI_AB_CENT) {
            return 0;
        }
        int anzahlPakete = Math.max(1, (bestellung.gesamtgewichtGramm() + HOECHSTGEWICHT_PAKET_GRAMM - 1) / HOECHSTGEWICHT_PAKET_GRAMM);
        return anzahlPakete * PAKETPAUSCHALE_CENT;
    }
}

// ===== Mailversand.java =====
package de.csbrad.shop;

import java.util.ArrayList;
import java.util.List;

/** Verschickt Bestätigungsmails; hier nur in einen Postausgang (Liste). */
public class Mailversand {

    private final List<String> postausgang = new ArrayList<>();

    public void sendeBestaetigung(Bestellung bestellung) {
        postausgang.add("An: " + bestellung.kundenEmail() + "\nBestellung " + bestellung.bestellnummer()
                + "\nGesamtbetrag: " + alsEuroText(bestellung.gesamtbetrag()) + "\n" + versandzeile(bestellung.versandart()));
    }

    public List<String> postausgang() {
        return List.copyOf(postausgang);
    }

    private static String versandzeile(String versandart) {
        return switch (versandart) {
            case "PAKET" -> "Versand: Paket";
            case "SPEDITION" -> "Versand: Spedition";
            default -> "Abholung in der Werkstatt Bielefeld-Sennestadt";
        };
    }

    private static String alsEuroText(int betragCent) {
        return (betragCent / 100) + "," + String.format("%02d", betragCent % 100) + " EUR";
    }
}

// ===== BestellRepository.java =====
package de.csbrad.shop;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/** Speichert Bestellungen (hier im Arbeitsspeicher) und vergibt fortlaufende Bestellnummern. */
public class BestellRepository {

    private static final int ERSTE_BESTELLNUMMER = 1001;

    private final List<Bestellung> bestellungen = new ArrayList<>();
    private int naechsteNummer = ERSTE_BESTELLNUMMER;

    public void speichere(Bestellung bestellung) {
        bestellung.vergibNummer("CSB-" + naechsteNummer);
        naechsteNummer = naechsteNummer + 1;
        bestellungen.add(bestellung);
    }

    public Optional<Bestellung> findeNachNummer(String bestellnummer) {
        return bestellungen.stream().filter(b -> b.bestellnummer().equals(bestellnummer)).findFirst();
    }

    public List<Bestellung> alle() {
        return List.copyOf(bestellungen);
    }
}

// ===== Bestellabschluss.java =====
package de.csbrad.shop;

/** Ablauf des Bestellabschlusses: prüfen, speichern, bestätigen. */
public class Bestellabschluss {

    private final Versandkostenrechner versandkostenrechner = new Versandkostenrechner();
    private final Mailversand mailversand = new Mailversand();
    private final BestellRepository repository = new BestellRepository();

    public Bestellung abschliessen(Bestellanfrage anfrage) {
        Bestellung bestellung = new Bestellung(anfrage, versandkostenrechner);
        versandkostenrechner.pruefeVersandmoeglich(bestellung);
        repository.speichere(bestellung);
        mailversand.sendeBestaetigung(bestellung);
        return bestellung;
    }

    public Mailversand mailversand() {
        return mailversand;
    }

    public BestellRepository repository() {
        return repository;
    }
}

Die Tests von Stand 06:

package de.csbrad.shop;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
 * Charakterisierungstests (seit Stand 04), angepasst an die Klassen aus Stand 06.
 * betraege(): {Warenwert, Rabatt, Versandkosten, Mehrwertsteuer, Gesamtbetrag} in Cent.
 */
class BestellabschlussTest {

    static final Position LICHTSET = new Position("LIC-SET-01", "Lichtset Nabendynamo StVZO", 7563, 1, 350, false);
    static final Position BREMSE = new Position("BRE-HYD-01", "Hydraulische Scheibenbremse (Paar)", 13361, 1, 750, false);
    static final Position BUEGELSCHLOSS = new Position("SCH-BUE-01", "Bügelschloss, gleichschließend", 6218, 1, 1300, false);
    static final Position RAHMENSCHLOSS = new Position("SCH-RAH-01", "Rahmenschloss, gleichschließend", 4118, 1, 450, false);
    static final Position STADTRAD = new Position("RAD-CITY-01", "Stadtrad „Promenade“ 7-Gang", 67143, 1, 16800, true);
    static final Position EBIKE = new Position("RAD-EB-02", "E-Bike „Kanal“ Mittelmotor 500 Wh", 252017, 1, 24500, true);
    static final String MAIL = "mira.engel@example.org";

    private Bestellabschluss abschluss;

    @BeforeEach
    void neuerAbschluss() {
        abschluss = new Bestellabschluss();
    }

    static int[] betraege(Bestellung b) {
        return new int[] {b.warenwert(), b.rabatt(), b.versandkosten(), b.mehrwertsteuer(), b.gesamtbetrag()};
    }

    static Position menge(Position p, int m) {
        return new Position(p.artikelnr(), p.bezeichnung(), p.nettopreisCent(), m, p.gewichtGramm(), p.istRad());
    }

    @Test
    void paketUnterFreigrenze() {
        assertArrayEquals(new int[] {7563, 0, 690, 1568, 9821}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "PAKET", null, MAIL))));
    }

    @Test
    void paketAbFreigrenzeIstVersandfrei() {
        assertArrayEquals(new int[] {13361, 0, 0, 2539, 15900}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BREMSE), "PAKET", null, MAIL))));
    }

    @Test
    void freigrenzeGiltNachRabatt() {
        assertArrayEquals(new int[] {10336, 1034, 690, 1898, 11890}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BUEGELSCHLOSS, RAHMENSCHLOSS), "PAKET", "KURIER10", MAIL))));
    }

    @Test
    void ohneRabattUeberFreigrenze() {
        assertArrayEquals(new int[] {10336, 0, 0, 1964, 12300}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BUEGELSCHLOSS, RAHMENSCHLOSS), "PAKET", null, MAIL))));
    }

    @Test
    void schwerePaketbestellungMitRabatt() {
        assertArrayEquals(new int[] {155450, 15545, 0, 26582, 166487}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(menge(BUEGELSCHLOSS, 25)), "PAKET", "KURIER10", MAIL))));
    }

    @Test
    void speditionJeRad() {
        assertArrayEquals(new int[] {149412, 0, 9800, 30250, 189462}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(menge(STADTRAD, 2), menge(LICHTSET, 2)), "SPEDITION", null, MAIL))));
    }

    @Test
    void speditionOhneRadKostetEinePauschale() {
        assertArrayEquals(new int[] {7563, 0, 4900, 2368, 14831}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "SPEDITION", null, MAIL))));
    }

    @Test
    void abholungMitRabattcode() {
        assertArrayEquals(new int[] {252017, 25202, 0, 43095, 269910}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(EBIKE), "ABHOLUNG", "KURIER10", MAIL))));
    }

    @Test
    void unbekannterCodeGibtKeinenRabatt() {
        assertArrayEquals(new int[] {7563, 0, 690, 1568, 9821}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "PAKET", "SOMMER20", MAIL))));
    }

    @Test
    void radAlsPaketWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> abschluss.abschliessen(new Bestellanfrage(List.of(STADTRAD), "PAKET", null, MAIL)));
        assertEquals(0, abschluss.mailversand().postausgang().size());
        assertEquals(0, abschluss.repository().alle().size());
    }

    @Test
    void leereBestellungWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> abschluss.abschliessen(new Bestellanfrage(List.of(), "PAKET", null, MAIL)));
        assertEquals(0, abschluss.mailversand().postausgang().size());
    }

    @Test
    void mengeNullWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> abschluss.abschliessen(new Bestellanfrage(List.of(menge(LICHTSET, 0)), "PAKET", null, MAIL)));
    }

    @Test
    void unbekannteVersandartWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "BRIEF", null, MAIL)));
    }

    @Test
    void mailFuerPaketversand() {
        abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "PAKET", null, MAIL));
        assertEquals("An: mira.engel@example.org\nBestellung CSB-1001\nGesamtbetrag: 98,21 EUR\nVersand: Paket", abschluss.mailversand().postausgang().get(0));
    }

    @Test
    void mailFuerAbholung() {
        abschluss.abschliessen(new Bestellanfrage(List.of(EBIKE), "ABHOLUNG", "KURIER10", MAIL));
        assertEquals("An: mira.engel@example.org\nBestellung CSB-1001\nGesamtbetrag: 2699,10 EUR\nAbholung in der Werkstatt Bielefeld-Sennestadt",
                abschluss.mailversand().postausgang().get(0));
    }

    @Test
    void bestellungenWerdenFortlaufendNummeriertUndGespeichert() {
        abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "PAKET", null, MAIL));
        abschluss.abschliessen(new Bestellanfrage(List.of(BREMSE), "PAKET", null, MAIL));
        assertEquals(2, abschluss.repository().alle().size());
        assertEquals("CSB-1001", abschluss.repository().alle().get(0).bestellnummer());
        assertEquals("CSB-1002", abschluss.repository().alle().get(1).bestellnummer());
        assertEquals(15900, abschluss.repository().alle().get(1).gesamtbetrag());
    }
}

a) Ein Teammitglied schlägt vor, die Paketregeln in eine Klasse Paketversand zu ziehen und Kurierzustellung davon erben zu lassen, weil die Prüfung auf Räder gleich ist. Beurteile den Vorschlag mit Hilfe des Liskov Substitution Principle. Gehe auf Vorbedingung und Nachbedingung von pruefe und der Kostenberechnung ein. (4 Punkte)

Die Vererbung verletzt das LSP (1 Punkt). Vorbedingung: Für Paketversand.pruefe ist jedes Gewicht zulässig, schwere Bestellungen gehen in mehreren Paketen raus. Ein überschriebenes pruefe, das ab 25 kg ablehnt, verschärft die Vorbedingung (1 Punkt). Nachbedingung: Die Paketkosten sind ab 100 € Warenwert nach Rabatt 0 und wachsen sonst mit der Paketzahl; der Kurier verlangt immer 12,90 €, die Nachbedingung wird also geändert. Auch eine Paketzahl hat der Kurier nicht (1 Punkt). Jeder Programmteil, der mit Paketversand rechnet (Packplanung, Auswertungen), würde bei einer Kurierzustellung falsche Werte oder Ausnahmen bekommen; die Tests der Kata würden das nicht zeigen, weil sie nur über den Versandkostenrechner gehen. Besser: Komposition, die Kurierzustellung benutzt die Rad-Prüfung eines Paketversand-Objekts (1 Punkt).

b) Schreibe Tests für die neue Versandart: Pauschale samt Mailzeile, Ablehnung über 25 kg (ohne dass etwas gespeichert wird) und Ablehnung von Rädern. Nutze die Testdaten aus den vorhandenen Tests. (4 Punkte)

Bremse (133,61 €, 750 g) und Bügelschloss (62,18 €, 1300 g) kosten zusammen 195,79 €; dazu 12,90 € Kurier, 19 % Mehrwertsteuer auf 208,69 € sind 39,65 €, Gesamtbetrag 248,34 €. 20 Bügelschlösser wiegen 26 kg.

// Ergänzung in BestellabschlussTest
    // ---- neue Anforderung in Stand 07: Kurierzustellung ----

    @Test
    void kurierKostetPauschale() {
        assertArrayEquals(new int[] {19579, 0, 1290, 3965, 24834},
                betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BREMSE, BUEGELSCHLOSS), "KURIER", null, MAIL))));
        assertEquals("An: mira.engel@example.org\nBestellung CSB-1001\nGesamtbetrag: 248,34 EUR\nVersand: Kurier (Lastenrad)",
                abschluss.mailversand().postausgang().get(0));
    }

    @Test
    void kurierUeber25KiloWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class,
                () -> abschluss.abschliessen(new Bestellanfrage(List.of(menge(BUEGELSCHLOSS, 20)), "KURIER", null, MAIL)));
        assertEquals(0, abschluss.repository().alle().size());
    }

    @Test
    void kurierNimmtKeineRaeder() {
        assertThrows(IllegalArgumentException.class,
                () -> abschluss.abschliessen(new Bestellanfrage(List.of(STADTRAD), "KURIER", null, MAIL)));

Bewertung: Pauschale mit allen Beträgen (1 Punkt), Mailzeile (1 Punkt), Ablehnung über 25 kg mit Prüfung, dass nichts gespeichert ist (1 Punkt), Ablehnung von Rädern (1 Punkt).

c) Implementiere die Versandart KURIER ohne LSP-Verstoß, sodass alle alten und neuen Tests grün sind. Ziehe dazu zuerst die Paketregeln in eine eigene Klasse Paketversand und nutze sie in der neuen Klasse per Komposition. (10 Punkte)

Neue Klassen Paketversand und Kurierzustellung, geänderter Versandkostenrechner (Stand 07 der Kata):

// Kata CSB-Rad, Stand 07: neue und geänderte Klassen
// ===== Paketversand.java =====
package de.csbrad.shop;

/** Versandregeln für Pakete: Pauschale je angefangene 31,5 kg, frei ab 100 Euro Warenwert nach Rabatt. */
public class Paketversand {

    static final int PAKETPAUSCHALE_CENT = 690;
    static final int HOECHSTGEWICHT_PAKET_GRAMM = 31500;
    static final int VERSANDKOSTENFREI_AB_CENT = 10000;

    public void pruefe(Bestellung bestellung) {
        if (bestellung.enthaeltRad()) {
            throw new IllegalArgumentException("Räder können nicht als Paket versandt werden");
        }
    }

    public int anzahlPakete(Bestellung bestellung) {
        return Math.max(1, (bestellung.gesamtgewichtGramm() + HOECHSTGEWICHT_PAKET_GRAMM - 1) / HOECHSTGEWICHT_PAKET_GRAMM);
    }

    public int kosten(Bestellung bestellung) {
        if (bestellung.warenwertNachRabatt() >= VERSANDKOSTENFREI_AB_CENT) {
            return 0;
        }
        return anzahlPakete(bestellung) * PAKETPAUSCHALE_CENT;
    }
}

// ===== Kurierzustellung.java =====
package de.csbrad.shop;

/**
 * Neu in Stand 07: Zustellung per Lastenradkurier im Stadtgebiet, Pauschale, höchstens 25 kg, keine Räder.
 * Bewusst KEINE Unterklasse von Paketversand: Sie würde die Vorbedingung verschärfen (25 kg statt beliebig vieler Pakete)
 * und damit das Liskovsche Substitutionsprinzip verletzen. Gemeinsames (Rad-Prüfung) wird per Komposition genutzt.
 */
public class Kurierzustellung {

    static final int KURIERPAUSCHALE_CENT = 1290;
    static final int HOECHSTGEWICHT_KURIER_GRAMM = 25000;

    private final Paketversand paketregeln = new Paketversand();

    public void pruefe(Bestellung bestellung) {
        paketregeln.pruefe(bestellung);
        if (bestellung.gesamtgewichtGramm() > HOECHSTGEWICHT_KURIER_GRAMM) {
            throw new IllegalArgumentException("Kurierzustellung nur bis 25 kg");
        }
    }

    public int kosten(Bestellung bestellung) {
        return KURIERPAUSCHALE_CENT;
    }
}

// ===== Versandkostenrechner.java =====
package de.csbrad.shop;

import java.util.Set;

/** Kennt die Versandarten der CSB-Rad GmbH und verteilt auf die jeweiligen Regeln. */
public class Versandkostenrechner {

    private static final Set<String> VERSANDARTEN = Set.of("PAKET", "SPEDITION", "ABHOLUNG", "KURIER");
    private static final int SPEDITION_JE_RAD_CENT = 4900;

    private final Paketversand paketversand = new Paketversand();
    private final Kurierzustellung kurierzustellung = new Kurierzustellung();

    public void pruefeVersandmoeglich(Bestellung bestellung) {
        if (!VERSANDARTEN.contains(bestellung.versandart())) {
            throw new IllegalArgumentException("Unbekannte Versandart: " + bestellung.versandart());
        }
        switch (bestellung.versandart()) {
            case "PAKET" -> paketversand.pruefe(bestellung);
            case "KURIER" -> kurierzustellung.pruefe(bestellung);
            default -> { }
        }
    }

    public int berechne(Bestellung bestellung) {
        return switch (bestellung.versandart()) {
            case "PAKET" -> paketversand.kosten(bestellung);
            // ohne Rad wird eine Speditionspauschale berechnet
            case "SPEDITION" -> Math.max(1, bestellung.anzahlRaeder()) * SPEDITION_JE_RAD_CENT;
            case "KURIER" -> kurierzustellung.kosten(bestellung);
            default -> 0;
        };
    }
}

Im Mailversand kommt eine Zeile für die Versandzeile hinzu:

// Mailversand, Stand 07
    private static String versandzeile(String versandart) {
        return switch (versandart) {
            case "PAKET" -> "Versand: Paket";
            case "SPEDITION" -> "Versand: Spedition";
            case "KURIER" -> "Versand: Kurier (Lastenrad)";
            default -> "Abholung in der Werkstatt Bielefeld-Sennestadt";
        };
    }

Alle 19 Tests sind grün (mvn test bzw. python -m pytest).

Bewertung: Paketversand als eigene Klasse mit unverändertem Verhalten (3 Punkte), Kurierzustellung ohne Vererbung mit Rad-Prüfung über ein Paketversand-Objekt (3 Punkte), Gewichtsgrenze und Pauschale (2 Punkte), Anschluss im Versandkostenrechner und im Mailversand (1 Punkt), Tests grün (1 Punkt).

d) Im Stand 07 unterscheidet der Versandkostenrechner die Versandarten weiterhin mit switch. Ein Teammitglied möchte eine gemeinsame abstrakte Oberklasse Versandart mit pruefe, anzahlPakete und kosten einführen, von der alle Versandarten erben. Beurteile den Vorschlag und mache einen Gegenvorschlag. (4 Punkte)

Eine gemeinsame Abstraktion ist sinnvoll, weil der switch mit jeder Versandart wächst und an mehreren Stellen steht (1 Punkt). anzahlPakete gehört aber nicht hinein: Abholung, Spedition und Kurier haben keine Pakete und müssten eine Ausnahme werfen oder einen erfundenen Wert liefern, also das LSP verletzen (1 Punkt). Gegenvorschlag: eine schlanke Schnittstelle Versandart nur mit pruefe und kosten (und ggf. der Mailzeile), die alle Versandarten vollständig erfüllen können (1 Punkt). anzahlPakete bleibt eine Methode von Paketversand. Eine Schnittstelle statt einer abstrakten Oberklasse vermeidet zusätzlich die Kopplung an geerbte Implementierung (1 Punkt). Dieser Umbau ist Thema des Artikels Erweitern statt ändern.

Aufgabe 3.2: Fahrtangebot und Mitfahrerliste durch Komposition entflechten (Profilkompetenz)

(ca. 20 min Bearbeitungszeit)

Im Mitfahrlabor darf eine Fahrt nur so viele Mitfahrende haben, wie Plätze angeboten sind, und Minderjährige dürfen nur mit Einwilligung der Erziehungsberechtigten mitfahren. Um sich Arbeit zu sparen, hat das Team die Mitfahrerliste von der Listenklasse der Standardbibliothek erben lassen und das Fahrtangebot wiederum von der Mitfahrerliste:

UML-Klassendiagramm vor dem Umbau: Mitfahrerliste erbt von ArrayList mit dem Typparameter Mitfahrer (Methoden add, addAll, remove, clear, size und weitere) und ergänzt das Attribut plaetze sowie die Methoden bestaetige und freiePlaetze; Fahrtangebot erbt von Mitfahrerliste und ergänzt Haltepunkt, Abfahrt, Entfernung, Cent je km, kostenbeitragCent und monitorzeile; Mitfahrer ist ein Datentyp mit Name, minderjaehrig und einwilligungLiegtVor (mit KI erstellt)

// Paket de.mitfahrlabor, Stand vor dem Umbau
// ===== Mitfahrer.java =====
package de.mitfahrlabor;

public record Mitfahrer(String name, boolean minderjaehrig, boolean einwilligungLiegtVor) {
}

// ===== Mitfahrerliste.java =====
package de.mitfahrlabor;

import java.util.ArrayList;

/** Bestätigte Mitfahrende einer Fahrt; erbt von ArrayList, „damit man nicht alles neu schreiben muss“. */
public class Mitfahrerliste extends ArrayList<Mitfahrer> {

    private final int plaetze;

    public Mitfahrerliste(int plaetze) {
        this.plaetze = plaetze;
    }

    public void bestaetige(Mitfahrer mitfahrer) {
        if (freiePlaetze() == 0) {
            throw new IllegalStateException("Die Fahrt ist voll");
        }
        if (mitfahrer.minderjaehrig() && !mitfahrer.einwilligungLiegtVor()) {
            throw new IllegalArgumentException("Einwilligung der Erziehungsberechtigten fehlt");
        }
        add(mitfahrer);
    }

    public int freiePlaetze() {
        return plaetze - size();
    }
}

// ===== Fahrtangebot.java =====
package de.mitfahrlabor;

/** Ein Fahrtangebot ist eine Mitfahrerliste mit Haltepunkt, Abfahrt und Kostenbeitrag. */
public class Fahrtangebot extends Mitfahrerliste {

    private final String haltepunkt;
    private final String abfahrt;
    private final int entfernungKm;
    private final int centJeKm;

    public Fahrtangebot(String haltepunkt, String abfahrt, int plaetze, int entfernungKm, int centJeKm) {
        super(plaetze);
        this.haltepunkt = haltepunkt;
        this.abfahrt = abfahrt;
        this.entfernungKm = entfernungKm;
        this.centJeKm = centJeKm;
    }

    public int kostenbeitragCent() {
        return entfernungKm * centJeKm;
    }

    public String monitorzeile() {
        return abfahrt + " ab " + haltepunkt + ": " + freiePlaetze() + " Plätze frei";
    }
}

Diese Tests sind grün:

package de.mitfahrlabor;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class FahrtangebotTest {

    private static final Mitfahrer AYLIN = new Mitfahrer("Aylin Kaya", false, false);
    private static final Mitfahrer OKSANA = new Mitfahrer("Oksana Melnyk", true, true);
    private static final Mitfahrer LINA = new Mitfahrer("Lina Hartwig", true, false);

    private final Fahrtangebot fahrt = new Fahrtangebot("Gütersloh Hbf", "07:15", 2, 17, 8);

    @Test
    void bestaetigenBelegtPlaetze() {
        fahrt.bestaetige(AYLIN);
        assertEquals(1, fahrt.freiePlaetze());
        assertEquals("07:15 ab Gütersloh Hbf: 1 Plätze frei", fahrt.monitorzeile());
    }

    @Test
    void volleFahrtWirdAbgelehnt() {
        fahrt.bestaetige(AYLIN);
        fahrt.bestaetige(OKSANA);
        assertThrows(IllegalStateException.class, () -> fahrt.bestaetige(new Mitfahrer("Ben Richter", false, false)));
    }

    @Test
    void minderjaehrigOhneEinwilligungWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> fahrt.bestaetige(LINA));
        assertEquals(2, fahrt.freiePlaetze());
    }

    @Test
    void kostenbeitragJePerson() {
        assertEquals(136, fahrt.kostenbeitragCent());
    }
}

a) Prüfe beide Vererbungsbeziehungen mit der ist-ein-Probe. Nenne außerdem zwei Wege, auf denen Code die Platzregel oder die Einwilligungsregel umgehen kann, und beschreibe die Folge für freiePlaetze(). (5 Punkte)

  • „Eine Mitfahrerliste ist eine ArrayList“ stimmt höchstens technisch; fachlich hat sie eine Liste von Personen. Die Vererbung dient nur der Wiederverwendung (1 Punkt).

  • „Ein Fahrtangebot ist eine Mitfahrerliste“ stimmt nicht: Ein Fahrtangebot hat Mitfahrende (1 Punkt).

  • Umgehen: Weil alle öffentlichen Methoden der Liste geerbt sind, kann jeder Aufrufer add/append, addAll/extend, set, remove oder clear direkt aufrufen, an bestaetige vorbei (2 Punkte für zwei Wege). Folge: Mit drei Personen auf zwei Plätzen liefert freiePlaetze() den Wert -1, und eine minderjährige Person ohne Einwilligung steht in der Liste; die Invariante 0 ≤ freie Plätze ≤ Plätze gilt nicht mehr (1 Punkt).

b) Ein Teammitglied schlägt vor, einfach add (Python: append) zu überschreiben und dort die Prüfungen aus bestaetige aufzurufen. Beurteile den Vorschlag mit Blick auf die fragile Basisklasse und das Liskov Substitution Principle. (5 Punkte)

  • Fragile Basisklasse: Ob addAll (Python: extend, insert, +=, Slice-Zuweisung) intern add bzw. append aufruft, ist ein Implementierungsdetail der Standardbibliothek. In Java ruft ArrayList.addAll das add nicht auf, in CPython ruft list.extend das append nicht auf. Die Prüfung würde also über diese Wege weiter umgangen, und man müsste jede einzelne ändernde Methode kennen und überschreiben, auch solche, die in künftigen Versionen dazukommen (2 Punkte).

  • LSP: Wer eine List bzw. list bekommt, darf erwarten, dass add das Element anhängt (in Java liefert add laut Vertrag true). Ein add, das bei voller Fahrt oder fehlender Einwilligung eine Ausnahme wirft, verschärft die Vorbedingung. Programmteile, die eine Mitfahrerliste als gewöhnliche Liste weiterreichen (Sortieren, Kopieren, Zusammenführen), würden überraschend scheitern (2 Punkte).

  • Fazit: Der Vorschlag repariert die Symptome. Die Ursache ist, dass eine Klasse mit eigenen Regeln von einer allgemeinen Sammlung erbt; besser ist Komposition (1 Punkt).

c) Baue Mitfahrerliste und Fahrtangebot so um, dass sie nicht mehr erben, sondern Komposition und Delegation nutzen. Nach außen sollen nur fachliche Operationen sichtbar sein: bestätigen, stornieren, freie Plätze abfragen und die Bestätigten lesen, ohne die interne Liste ändern zu können. Die vorhandenen Tests müssen grün bleiben; ergänze einen Test für das Stornieren. (8 Punkte)

// Paket de.mitfahrlabor nach dem Umbau (Mitfahrer unverändert)
// ===== Mitfahrerliste.java =====
package de.mitfahrlabor;

import java.util.ArrayList;
import java.util.List;

/** Bestätigte Mitfahrende einer Fahrt; nutzt eine Liste per Komposition und schützt die Platzregel. */
public class Mitfahrerliste {

    private final List<Mitfahrer> bestaetigte = new ArrayList<>();
    private final int plaetze;

    public Mitfahrerliste(int plaetze) {
        this.plaetze = plaetze;
    }

    public void bestaetige(Mitfahrer mitfahrer) {
        if (freiePlaetze() == 0) {
            throw new IllegalStateException("Die Fahrt ist voll");
        }
        if (mitfahrer.minderjaehrig() && !mitfahrer.einwilligungLiegtVor()) {
            throw new IllegalArgumentException("Einwilligung der Erziehungsberechtigten fehlt");
        }
        bestaetigte.add(mitfahrer);
    }

    public void storniere(Mitfahrer mitfahrer) {
        bestaetigte.remove(mitfahrer);
    }

    public int freiePlaetze() {
        return plaetze - bestaetigte.size();
    }

    public List<Mitfahrer> bestaetigte() {
        return List.copyOf(bestaetigte);
    }
}

// ===== Fahrtangebot.java =====
package de.mitfahrlabor;

import java.util.List;

/** Ein Fahrtangebot HAT eine Mitfahrerliste und reicht nur die fachlichen Operationen weiter. */
public class Fahrtangebot {

    private final String haltepunkt;
    private final String abfahrt;
    private final int entfernungKm;
    private final int centJeKm;
    private final Mitfahrerliste mitfahrende;

    public Fahrtangebot(String haltepunkt, String abfahrt, int plaetze, int entfernungKm, int centJeKm) {
        this.haltepunkt = haltepunkt;
        this.abfahrt = abfahrt;
        this.entfernungKm = entfernungKm;
        this.centJeKm = centJeKm;
        this.mitfahrende = new Mitfahrerliste(plaetze);
    }

    public void bestaetige(Mitfahrer mitfahrer) {
        mitfahrende.bestaetige(mitfahrer);
    }

    public void storniere(Mitfahrer mitfahrer) {
        mitfahrende.storniere(mitfahrer);
    }

    public int freiePlaetze() {
        return mitfahrende.freiePlaetze();
    }

    public List<Mitfahrer> bestaetigte() {
        return mitfahrende.bestaetigte();
    }

    public int kostenbeitragCent() {
        return entfernungKm * centJeKm;
    }

    public String monitorzeile() {
        return abfahrt + " ab " + haltepunkt + ": " + freiePlaetze() + " Plätze frei";
    }
}

Ergänzter Test:

    @Test
    void stornierenGibtPlatzFrei() {
        fahrt.bestaetige(AYLIN);
        fahrt.storniere(AYLIN);
        assertEquals(2, fahrt.freiePlaetze());
        assertEquals(0, fahrt.bestaetigte().size());
    }

Alle Tests sind grün. Code, der wie vorher addAll bzw. extend am Fahrtangebot aufruft, lässt sich nicht mehr übersetzen bzw. fällt bei der Typprüfung durch.

Bewertung: interne Liste privat in Mitfahrerliste (2 Punkte), Fahrtangebot hält eine Mitfahrerliste und delegiert (2 Punkte), Stornieren (1 Punkt), Bestätigte nur als unveränderliche Kopie (1 Punkt), Tests grün und Test für Stornieren (2 Punkte).

d) Zeichne das UML-Klassendiagramm nach dem Umbau mit Attributen, Methoden und Beziehungen samt Multiplizitäten. Vermerke die Invariante der Mitfahrerliste als Notiz. (4 Punkte)

UML-Klassendiagramm nach dem Umbau: Fahrtangebot mit privaten Attributen für Haltepunkt, Abfahrt, Entfernung und Cent je km und den Methoden bestaetige, storniere, freiePlaetze, bestaetigte, kostenbeitragCent und monitorzeile enthält genau eine Mitfahrerliste namens mitfahrende; Mitfahrerliste mit dem Attribut plaetze und den Methoden bestaetige, storniere, freiePlaetze und bestaetigte aggregiert beliebig viele Mitfahrer als bestaetigte; eine Notiz nennt die Invariante 0 kleiner gleich freiePlaetze kleiner gleich plaetze (mit KI erstellt)

Die Beziehung zwischen Fahrtangebot und Mitfahrerliste ist eine Komposition, weil die Liste mit dem Angebot entsteht und vergeht. Die Mitfahrenden existieren unabhängig vom Angebot, deshalb eine Aggregation oder einfache Assoziation; beides ist richtig.

Bewertung: keine Generalisierung mehr, Komposition mit Multiplizitäten (1 Punkt), fachliche Methoden in beiden Klassen (1 Punkt), private Attribute (1 Punkt), Notiz mit Invariante (1 Punkt).


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Übungsaufgaben zum Einsatz von Vererbung: Liskov Substitution Principle und Favour Composition over Inheritance” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/vererbung-ersetzbarkeit-uebungsaufgaben veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/design-prinzipien/clean-code. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 10.11.2026.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: