MINT lernen

Übung — AFB III (Verallgemeinern und Reflektieren)

Zehn offene Aufgaben: Modelle erweitern, Kapselung beurteilen, Schnittstellen im Team entwerfen und Lösungsvarianten vergleichen.

Dein Fortschritt:
0 / 0 Aufgaben
3

Aufgabenblock — AFB III

Entwerfen statt nur nachvollziehen: Modelle für neue Anforderungen erweitern, Kapselungsentscheidungen beurteilen, Schnittstellen und Testfälle für ein Team festlegen und Varianten gegeneinander abwägen. Formuliere deine Antwort vollständig, bevor du die Lösung aufklappst — oft sind auch andere gut begründete Entwürfe richtig.

A1
Modell erweitern: Hotelzimmer mit mehreren Gästen
AFB III

Bisher kann ein Hotelzimmer nur einen Gast aufnehmen:

Zimmer
  • - nummer: Ganzzahl
  • - betten: Ganzzahl
  • - gast: Gast
  • c Zimmer(nummer: Ganzzahl, betten: Ganzzahl)
  • + einchecken(g: Gast): Wahrheitswert
  • + auschecken()
  • + istFrei(): Wahrheitswert
belegt von0..10..1
Gast
  • - name: Zeichenkette
  • c Gast(name: Zeichenkette)
  • + getName(): Zeichenkette
public boolean einchecken(Gast g) {
    if (gast == null) {
        gast = g;
        return true;
    }
    return false;
}

Neue Anforderung: In einem Zimmer dürfen so viele Gäste wohnen, wie es Betten hat. Die Rezeption möchte außerdem zu einem Gastnamen die Zimmernummer finden (Klasse Hotel mit einer Reihung aller Zimmer).

Erweitern Sie das Klassendiagramm und implementieren Sie die geänderte Klasse Zimmer sowie in Hotel die Methode findeZimmerVon(name: Zeichenkette): Ganzzahl (−1, wenn der Gast nicht im Hotel ist). Begründen Sie Ihre Entscheidung, ob Gast sein Zimmer kennen soll.

Ansatz: Aus „ein Gast“ wird „bis zu betten Gäste“: Ersetze das Attribut gast durch eine Reihung vom Typ Gast und einen Zähler belegung. Die Länge der Reihung übernimmt die Rolle von betten.
Suche: Hotel läuft über alle Zimmer (mit Null-Prüfung), jedes Zimmer prüft mit einer eigenen Methode, ob der Name bei ihm wohnt. So muss Hotel die Reihung im Zimmer nicht kennen.
Richtung: Wenn auch der Gast sein Zimmer kennt, müssen beim Ein- und Auschecken zwei Referenzen konsistent gehalten werden. Lohnt sich das hier?
Vollständige Lösung
Hotel
  • - zimmer: Reihung vom Typ Zimmer
  • c Hotel(anzahlZimmer: Ganzzahl)
  • + findeZimmerVon(name: Zeichenkette): Ganzzahl
hat1n
Zimmer
  • - nummer: Ganzzahl
  • - gaeste: Reihung vom Typ Gast
  • - belegung: Ganzzahl
  • c Zimmer(nummer: Ganzzahl, betten: Ganzzahl)
  • + getNummer(): Ganzzahl
  • + einchecken(g: Gast): Wahrheitswert
  • + auschecken()
  • + istFrei(): Wahrheitswert
  • + getBelegung(): Ganzzahl
  • + wohntHier(name: Zeichenkette): Wahrheitswert
belegt von0..1n
Gast
  • - name: Zeichenkette
  • c Gast(name: Zeichenkette)
  • + getName(): Zeichenkette
public class Zimmer {
    private int nummer;
    private Gast[] gaeste;
    private int belegung;

    public Zimmer(int nummer, int betten) {
        this.nummer = nummer;
        gaeste = new Gast[betten];
        belegung = 0;
    }

    public int getNummer() {
        return nummer;
    }

    public boolean einchecken(Gast g) {
        if (g == null || belegung == gaeste.length) {
            return false;
        }
        gaeste[belegung] = g;
        belegung = belegung + 1;
        return true;
    }

    public void auschecken() {
        for (int i = 0; i < belegung; i++) {
            gaeste[i] = null;
        }
        belegung = 0;
    }

    public boolean istFrei() {
        return belegung == 0;
    }

    public int getBelegung() {
        return belegung;
    }

    public boolean wohntHier(String name) {
        for (int i = 0; i < belegung; i++) {
            if (gaeste[i].getName().equals(name)) {
                return true;
            }
        }
        return false;
    }
}
public int findeZimmerVon(String name) {
    for (int i = 0; i < zimmer.length; i++) {
        if (zimmer[i] != null && zimmer[i].wohntHier(name)) {
            return zimmer[i].getNummer();
        }
    }
    return -1;
}

Die öffentliche Schnittstelle von Zimmer bleibt bis auf zwei neue Methoden gleich — Programmteile, die nur einchecken und istFrei nutzen, müssen nicht geändert werden. Das Attribut betten entfällt, weil gaeste.length dieselbe Information enthält (keine doppelte Datenhaltung).

Gast kennt Zimmer? Nicht nötig: Die einzige Anfrage „Wo wohnt X?“ beantwortet Hotel durch Suchen. Eine zweite Referenz müsste bei jedem Ein- und Auschecken mitgepflegt werden und könnte inkonsistent werden. Erst wenn sehr häufig vom Gast aus gefragt wird (z. B. Zimmerrechnung vom Gastprofil aus), wäre die Gegenrichtung sinnvoll.

A2
Kapselung beurteilen: Getränkeautomat
AFB III

Für einen Getränkeautomaten in der Schule schlägt ein Teammitglied diese Klasse vor:

Getraenkeautomat
  • - preis: Ganzzahl
  • - bestand: Ganzzahl
  • - guthaben: Ganzzahl
  • - einnahmen: Ganzzahl
  • c Getraenkeautomat()
  • + getPreis(): Ganzzahl
  • + setPreis(preis: Ganzzahl)
  • + getBestand(): Ganzzahl
  • + setBestand(bestand: Ganzzahl)
  • + getGuthaben(): Ganzzahl
  • + setGuthaben(guthaben: Ganzzahl)
  • + getEinnahmen(): Ganzzahl
  • + setEinnahmen(einnahmen: Ganzzahl)

Anforderungen: Der Automat fasst höchstens 40 Flaschen, nimmt nur Münzen zu 10, 20, 50, 100 und 200 Cent an, Preise sind Vielfache von 10 Cent. Nur der Hausmeister leert die Kasse.

Beurteilen Sie den Entwurf im Hinblick auf die Datenkapselung. Schlagen Sie eine verbesserte Schnittstelle vor und implementieren Sie sie.

Ansatz: Alle Attribute sind privat — formal gekapselt. Frage aber bei jedem Setter: Welche ungültigen Zustände kann man von außen damit herstellen?
Idee: Ersetze „Werte setzen“ durch Vorgänge aus der Wirklichkeit: Flaschen nachfüllen, Münze einwerfen, Getränk kaufen, Kasse leeren.
Prüfungen: Wer prüft die Kapazität, die Münzsorte, den Preis? Genau diese Methoden.
Vollständige Lösung

Urteil: Der Entwurf ist nur scheinbar gekapselt. Private Attribute mit ungeprüften Settern für alles verhalten sich praktisch wie öffentliche Attribute: setGuthaben(5000) erzeugt Geld aus dem Nichts, setBestand(-3) oder setBestand(99) einen unmöglichen Bestand, setEinnahmen(0) lässt Geld verschwinden. Außerdem gehen fachliche Zusammenhänge verloren: Ein Kauf muss Bestand, Guthaben und Einnahmen gemeinsam ändern — mit einzelnen Settern ist das Sache des Aufrufers und damit fehleranfällig.

Getraenkeautomat
  • - preis: Ganzzahl
  • - bestand: Ganzzahl
  • - guthaben: Ganzzahl
  • - einnahmen: Ganzzahl
  • - kapazitaet: Ganzzahl
  • c Getraenkeautomat(preis: Ganzzahl, kapazitaet: Ganzzahl)
  • + setPreis(preis: Ganzzahl)
  • + nachfuellen(flaschen: Ganzzahl): Ganzzahl
  • + einwerfen(muenze: Ganzzahl): Wahrheitswert
  • + kaufen(): Wahrheitswert
  • + rueckgabe(): Ganzzahl
  • + kassieren(): Ganzzahl
  • + getPreis(): Ganzzahl
  • + getBestand(): Ganzzahl
  • + getGuthaben(): Ganzzahl
public class Getraenkeautomat {
    private int preis;
    private int bestand;
    private int guthaben;
    private int einnahmen;
    private int kapazitaet;

    public Getraenkeautomat(int preis, int kapazitaet) {
        this.preis = 150;
        setPreis(preis);
        this.kapazitaet = kapazitaet;
        bestand = 0;
        guthaben = 0;
        einnahmen = 0;
    }

    public void setPreis(int preis) {
        if (preis > 0 && preis % 10 == 0) {
            this.preis = preis;
        }
    }

    public int nachfuellen(int flaschen) {
        int passt = kapazitaet - bestand;
        if (flaschen > passt) {
            flaschen = passt;
        }
        if (flaschen > 0) {
            bestand = bestand + flaschen;
            return flaschen;
        }
        return 0;
    }

    public boolean einwerfen(int muenze) {
        if (muenze == 10 || muenze == 20 || muenze == 50 || muenze == 100 || muenze == 200) {
            guthaben = guthaben + muenze;
            return true;
        }
        return false;
    }

    public boolean kaufen() {
        if (bestand > 0 && guthaben >= preis) {
            bestand = bestand - 1;
            guthaben = guthaben - preis;
            einnahmen = einnahmen + preis;
            return true;
        }
        return false;
    }

    public int rueckgabe() {
        int betrag = guthaben;
        guthaben = 0;
        return betrag;
    }

    public int kassieren() {
        int betrag = einnahmen;
        einnahmen = 0;
        return betrag;
    }

    public int getPreis() {
        return preis;
    }

    public int getBestand() {
        return bestand;
    }

    public int getGuthaben() {
        return guthaben;
    }
}

Nur setPreis bleibt als Setter — mit Prüfung. Kein Getter für einnahmen: Der Betrag wird nur beim Leeren durch kassieren() herausgegeben. Dass nur der Hausmeister kassieren darf, lässt sich mit Sichtbarkeiten nicht ausdrücken — das muss die Bedienoberfläche regeln (z. B. Schlüsselschalter). Im Konstruktor wird zuerst ein Standardpreis gesetzt, damit ein ungültiger Startpreis nicht zu 0 führt.

A3
Teamaufteilung und Schnittstelle: Leihrad-App
AFB III

Ein Dreierteam soll für den städtischen Fahrradverleih eine App entwickeln. Anforderungen: An jeder Station stehen bis zu 12 Leihräder. Eine Kundin kann an einer Station ein freies Rad ausleihen und es an einer beliebigen Station zurückgeben. Pro Kundin ist höchstens ein Rad gleichzeitig ausgeliehen. Die Leihdauer wird in Minuten erfasst; jede angefangene Stunde kostet 2 €.

Entwerfen Sie ein Klassendiagramm, das als Schnittstelle zwischen drei Teammitgliedern dienen kann, und legen Sie fest, wer welche Klasse implementiert. Geben Sie zu jeder Klasse einen Testfall an, der vor dem Programmieren vereinbart wird, und begründen Sie, warum die Schnittstelle vor der Implementierung feststehen muss.

Ansatz: Kandidaten sind Station, Leihrad und Kundin (oder Kunde). Pro Person eine Klasse — dann muss genau festgelegt sein, welche Methoden die anderen aufrufen dürfen.
Schnittstelle: Für jeden Aufruf „über die Klassengrenze“ brauchst du Name, Parameter, Rückgabetyp und das Verhalten im Fehlerfall (z. B. Station leer → null).
Testfälle: Ein Testfall besteht aus Ausgangszustand, Aufruf und erwartetem Ergebnis — mindestens ein Grenzfall ist Pflicht.
Vollständige Lösung
Kundin
  • - name: Zeichenkette
  • - rad: Leihrad
  • - kostenCent: Ganzzahl
  • c Kundin(name: Zeichenkette)
  • + ausleihen(s: Station, minute: Ganzzahl): Wahrheitswert
  • + zurueckgeben(s: Station, minute: Ganzzahl): Wahrheitswert
  • + getKostenCent(): Ganzzahl
leiht0..10..1
Leihrad
  • - nummer: Ganzzahl
  • - startMinute: Ganzzahl
  • c Leihrad(nummer: Ganzzahl)
  • + getNummer(): Ganzzahl
  • + starten(minute: Ganzzahl)
  • + beenden(minute: Ganzzahl): Ganzzahl
steht an0..120..1
Station
  • - name: Zeichenkette
  • - raeder: Reihung vom Typ Leihrad
  • - anzahl: Ganzzahl
  • c Station(name: Zeichenkette)
  • + getAnzahlFrei(): Ganzzahl
  • + radEntnehmen(): Leihrad
  • + radAbstellen(r: Leihrad): Wahrheitswert

Verhalten (Teil des Vertrags): radEntnehmen() liefert null, wenn die Station leer ist. radAbstellen liefert false bei 12 Rädern. beenden(minute) liefert die Leihdauer in Minuten. ausleihen scheitert, wenn die Kundin schon ein Rad hat oder die Station leer ist; zurueckgeben berechnet die Kosten mit 200 Cent pro angefangener Stunde.

Aufteilung: Person A Station, Person B Leihrad, Person C Kundin (sie nutzt die Methoden der beiden anderen und trägt die Ablauflogik).

Testfälle (vorher vereinbart): Station: leere Station, radEntnehmen() → null; Station mit 12 Rädern, radAbstellen(r) → false. Leihrad: starten(600), beenden(661) → 61. Kundin: Ausleihe um Minute 600, Rückgabe um 661 → getKostenCent() = 400 (zwei angefangene Stunden); zweites ausleihen ohne Rückgabe → false.

Begründung: Alle drei programmieren gleichzeitig. Person C ruft radEntnehmen() auf, bevor Person A es fertig hat — das geht nur, wenn Name, Parameter, Rückgabe und Fehlerverhalten verbindlich festgelegt sind. Ändert jemand eigenmächtig eine Signatur, passt der Quelltext beim Zusammenführen nicht mehr. Die vorher vereinbarten Tests prüfen, ob sich jede Klasse an den Vertrag hält.

A4
Varianten vergleichen: Termine im Kalender
AFB III

Für eine Kalender-App soll die Klasse Termin diese öffentliche Schnittstelle haben:

Termin
    • c Termin(titel: Zeichenkette, stunde: Ganzzahl, minute: Ganzzahl, dauerMin: Ganzzahl)
    • + getBeginn(): Zeichenkette
    • + getEnde(): Zeichenkette
    • + ueberschneidet(t: Termin): Wahrheitswert
    • + verschieben(minuten: Ganzzahl)

    Variante A speichert den Beginn als Zeichenkette (- beginn: Zeichenkette, z. B. „14:30“). Variante B speichert ihn als - beginnMin: Ganzzahl (Minuten seit Mitternacht, z. B. 870).

    Vergleichen Sie die beiden Varianten hinsichtlich des Aufwands für getEnde, ueberschneidet und verschieben. Implementieren Sie Variante B vollständig und erläutern Sie, welche Rolle die Kapselung beim Wechsel von A nach B spielt.

    Ansatz: Überlege für „14:30“, wie du daraus rechnen würdest: substring, Umwandeln in Zahlen, Übertrag bei 60 Minuten. In Variante B ist Rechnen eine einfache Addition.
    Überschneidung: Zwei Intervalle [b1; e1) und [b2; e2) überschneiden sich genau dann, wenn b1 < e2 und b2 < e1.
    Ausgabe in B: Stunde = min / 60, Minute = min % 60; bei einer Minute unter 10 eine führende „0“ ergänzen.
    Vollständige Lösung

    Vergleich: Variante A speichert genau das, was angezeigt wird — getBeginn() ist trivial. Für jede Rechnung muss die Zeichenkette aber zerlegt und in Zahlen umgewandelt werden; beim Verschieben um 45 Minuten braucht man Überträge über die volle Stunde und muss danach wieder eine korrekt formatierte Zeichenkette erzeugen. Variante B rechnet mit einer einzigen Zahl: Ende = Beginn + Dauer, Verschieben = Addition, Überschneidung = zwei Vergleiche. Nur die Ausgabe erfordert eine Umrechnung — an genau einer Stelle (private Hilfsmethode). Für diese Anforderungen ist B deutlich einfacher und weniger fehleranfällig.

    public class Termin {
        private String titel;
        private int beginnMin;
        private int dauerMin;
    
        public Termin(String titel, int stunde, int minute, int dauerMin) {
            this.titel = titel;
            beginnMin = stunde * 60 + minute;
            this.dauerMin = dauerMin;
        }
    
        private String alsUhrzeit(int minuten) {
            int h = minuten / 60;
            int m = minuten % 60;
            String text = h + ":";
            if (m < 10) {
                text = text + "0";
            }
            return text + m;
        }
    
        public String getBeginn() {
            return alsUhrzeit(beginnMin);
        }
    
        public String getEnde() {
            return alsUhrzeit(beginnMin + dauerMin);
        }
    
        public int getBeginnMin() {
            return beginnMin;
        }
    
        public int getEndeMin() {
            return beginnMin + dauerMin;
        }
    
        public boolean ueberschneidet(Termin t) {
            return beginnMin < t.getEndeMin() && t.getBeginnMin() < getEndeMin();
        }
    
        public void verschieben(int minuten) {
            beginnMin = beginnMin + minuten;
        }
    }

    Beispiel: Mathe 14:30 (50 min) und Chor 15:10 → Überschneidung, denn 870 < 940 und 910 < 920. Sport 15:20 überschneidet Mathe nicht (920 < 920 ist falsch).

    Kapselung: Weil die Attribute privat sind und nur über die fünf Methoden genutzt werden, merkt kein anderer Programmteil den Wechsel von A nach B. Die innere Darstellung ist austauschbar, solange die Schnittstelle gleich bleibt — ein Hauptargument für private Attribute. Die Hilfsgetter getBeginnMin() und getEndeMin() werden für ueberschneidet gebraucht; wer sie öffentlich macht, legt sich allerdings auf die Minuten-Darstellung fest.

    A5
    Richtung einer Beziehung: Buslinie und Haltestelle
    AFB III

    Für die Fahrgastinformation eines Verkehrsverbunds gibt es zwei Anforderungen: (1) Die App zeigt zu einer Linie alle Haltestellen in Fahrtrichtung. (2) Die Anzeigetafel an jeder Haltestelle listet alle Linien, die dort halten.

    Drei Entwürfe stehen zur Wahl:

    • A: Nur Buslinie kennt eine Reihung ihrer Haltestellen.
    • B: Nur Haltestelle kennt eine Reihung ihrer Buslinien.
    • C: Beide kennen einander.

    Erörtern Sie die drei Entwürfe im Hinblick auf beide Anforderungen, entscheiden Sie sich begründet und implementieren Sie für Ihre Entscheidung die Methode, mit der eine Haltestelle zu einer Linie hinzugefügt wird.

    Ansatz: Prüfe für jeden Entwurf, wie Anforderung (1) und (2) erfüllt werden können. Was müsste man bei A tun, um die Linien an einer Haltestelle herauszufinden?
    Konsistenz: Bei C gibt es zwei Referenzen für dieselbe Tatsache. Wie stellst du sicher, dass nie nur eine Seite eingetragen wird?
    Trick: Die Linie kann sich selbst mit this an die Haltestelle übergeben: h.linieEintragen(this);
    Vollständige Lösung

    A erfüllt (1) direkt; für (2) müsste die Haltestelle alle Linien des Verbunds durchsuchen — dazu braucht es eine zentrale Verwaltung aller Linien, und die Anzeige wird bei vielen Linien langsam. B erfüllt (2) direkt, aber (1) nicht: Die Reihenfolge der Halte einer Linie ist nirgends gespeichert. C erfüllt beide Anforderungen direkt, erkauft das aber mit doppelter Datenhaltung: Beide Seiten müssen immer zusammen geändert werden.

    Entscheidung: C, weil beide Anforderungen häufig und zeitkritisch sind (Anzeigetafel). Das Konsistenzproblem wird gelöst, indem es nur einen öffentlichen Weg gibt, einen Halt einzutragen: haltHinzufuegen in Buslinie trägt automatisch auch die Gegenrichtung ein.

    public class Buslinie {
        private String nummer;
        private Haltestelle[] halte;
        private int anzahlHalte;
    
        public Buslinie(String nummer, int maxHalte) {
            this.nummer = nummer;
            halte = new Haltestelle[maxHalte];
            anzahlHalte = 0;
        }
    
        public String getNummer() {
            return nummer;
        }
    
        public boolean haltHinzufuegen(Haltestelle h) {
            if (h == null || anzahlHalte == halte.length) {
                return false;
            }
            halte[anzahlHalte] = h;
            anzahlHalte = anzahlHalte + 1;
            h.linieEintragen(this);
            return true;
        }
    }
    public void linieEintragen(Buslinie b) {
        for (int i = 0; i < anzahlLinien; i++) {
            if (linien[i] == b) {
                return;
            }
        }
        if (anzahlLinien < linien.length) {
            linien[anzahlLinien] = b;
            anzahlLinien = anzahlLinien + 1;
        }
    }

    Die Doppelprüfung in linieEintragen verhindert, dass Linie 4 zweimal an der Tafel steht, wenn sie (z. B. als Ringlinie) eine Haltestelle zweimal anfährt. Eine Schwäche bleibt: linieEintragen ist öffentlich und könnte auch allein aufgerufen werden — das muss im Team als Regel vereinbart werden.

    A6
    Kopie oder Alias? Rezepte umrechnen
    AFB III

    Eine Rezept-App soll Rezepte auf eine andere Portionenzahl umrechnen, ohne das Original zu verändern. Eine Entwicklerin hat in der Klasse Rezept (Attribute titel, portionen, zutaten, anzahl) implementiert:

    public Rezept skalieren(int neuePortionen) {
        Rezept neu = new Rezept(titel, neuePortionen);
        for (int i = 0; i < anzahl; i++) {
            Zutat z = zutaten[i];
            z.setMengeGramm(z.getMengeGramm() * neuePortionen / portionen);
            neu.hinzufuegen(z);
        }
        return neu;
    }
    Rezept pf = new Rezept("Pfannkuchen", 4);
    pf.hinzufuegen(new Zutat("Mehl", 250));
    pf.hinzufuegen(new Zutat("Milch", 500));
    Rezept gross = pf.skalieren(6);
    Rezept klein = pf.skalieren(2);

    Bestimmen Sie die Mehlmenge im Rezept gross und die Milchmenge im Original pf nach der letzten Zeile. Beurteilen Sie die Implementierung und korrigieren Sie sie.

    g
    g
    Ansatz: Wie viele Zutat-Objekte gibt es? Zähle die new Zutat. neu.hinzufuegen(z) kopiert nur die Referenz.
    Zweiter Aufruf: Nach dem ersten Skalieren enthält das gemeinsame Mehl-Objekt 375 g — aber pf.portionen ist immer noch 4. Rechne 375 · 2 / 4 mit Ganzzahldivision.
    Korrektur: Für das neue Rezept müssen neue Zutat-Objekte mit der umgerechneten Menge erzeugt werden; das Original bleibt unangetastet.
    Vollständige Lösung

    Es gibt nur zwei Zutat-Objekte; pf, gross und klein verweisen alle auf dieselben. Erster Aufruf: Mehl 250 → 250 · 6 / 4 = 375, Milch 500 → 750. Zweiter Aufruf rechnet mit diesen bereits veränderten Werten, aber mit portionen = 4: Mehl 375 · 2 / 4 = 187 (Ganzzahldivision), Milch 750 · 2 / 4 = 375. Daher hat gross jetzt 187 g Mehl und das Original pf 375 g Milch — alle drei Rezepte zeigen die Mengen des kleinen Rezepts.

    Urteil: Die Methode verletzt die Anforderung „Original bleibt unverändert“ doppelt: Sie ändert die Zutaten des Originals (Seiteneffekt über die Referenz), und die Rezepte sind danach nicht unabhängig — jede spätere Änderung an einem wirkt auf alle. Zusätzlich entstehen Rundungsfehler, die sich aufschaukeln.

    public Rezept skalieren(int neuePortionen) {
        Rezept neu = new Rezept(titel, neuePortionen);
        for (int i = 0; i < anzahl; i++) {
            int menge = zutaten[i].getMengeGramm() * neuePortionen / portionen;
            neu.hinzufuegen(new Zutat(zutaten[i].getName(), menge));
        }
        return neu;
    }

    Mit der Korrektur gilt: gross 375 g Mehl, klein 125 g Mehl, pf unverändert 500 g Milch.

    A7
    Klasse entwerfen: Schachfiguren
    AFB III

    Für ein Schach-Lernprogramm soll zunächst geprüft werden, ob eine Figur auf einem leeren Brett ein Zielfeld erreichen kann. Felder werden wie im Schach als Zeichenkette notiert, z. B. „e4“ (Spalte a–h, Zeile 1–8). Unterstützt werden sollen Turm, Läufer, Dame und König.

    Entwerfen Sie eine einzige Klasse Figur (Klassenkarte) und implementieren Sie eine Methode kannZiehen(ziel: Zeichenkette): Wahrheitswert sowie ziehen(ziel: Zeichenkette): Wahrheitswert. Ungültige Zielangaben wie „i4“ oder „e9“ und das eigene Feld sollen abgelehnt werden. Beurteilen Sie anschließend, ob die Darstellung des Felds als Zeichenkette eine gute Wahl ist.

    Ansatz: Attribute: Art der Figur (Zeichenkette), Farbe (Wahrheitswert), Feld (Zeichenkette). Mit charAt(0) und charAt(1) erhältst du Spalte und Zeile als Zeichen.
    Zeichen rechnen: Zeichen kann man subtrahieren: 'e' - 'c' ergibt 2. So erhältst du die Abstände dx und dy; nimm davon den Betrag.
    Regeln: Turm: dx == 0 oder dy == 0. Läufer: dx == dy. Dame: eins von beiden. König: beide höchstens 1.
    Vollständige Lösung
    Figur
    • - art: Zeichenkette
    • - weiss: Wahrheitswert
    • - feld: Zeichenkette
    • c Figur(art: Zeichenkette, weiss: Wahrheitswert, feld: Zeichenkette)
    • + getFeld(): Zeichenkette
    • + kannZiehen(ziel: Zeichenkette): Wahrheitswert
    • + ziehen(ziel: Zeichenkette): Wahrheitswert
    • - istGueltig(f: Zeichenkette): Wahrheitswert
    public class Figur {
        private String art;
        private boolean weiss;
        private String feld;
    
        public Figur(String art, boolean weiss, String feld) {
            this.art = art;
            this.weiss = weiss;
            this.feld = feld;
        }
    
        public String getFeld() {
            return feld;
        }
    
        private boolean istGueltig(String f) {
            if (f.length() != 2) {
                return false;
            }
            char s = f.charAt(0);
            char z = f.charAt(1);
            return s >= 'a' && s <= 'h' && z >= '1' && z <= '8';
        }
    
        public boolean kannZiehen(String ziel) {
            if (!istGueltig(ziel) || ziel.equals(feld)) {
                return false;
            }
            int dx = ziel.charAt(0) - feld.charAt(0);
            int dy = ziel.charAt(1) - feld.charAt(1);
            if (dx < 0) {
                dx = -dx;
            }
            if (dy < 0) {
                dy = -dy;
            }
            boolean gerade = dx == 0 || dy == 0;
            boolean schraeg = dx == dy;
            if (art.equals("Turm")) {
                return gerade;
            }
            if (art.equals("Laeufer")) {
                return schraeg;
            }
            if (art.equals("Dame")) {
                return gerade || schraeg;
            }
            if (art.equals("Koenig")) {
                return dx <= 1 && dy <= 1;
            }
            return false;
        }
    
        public boolean ziehen(String ziel) {
            if (kannZiehen(ziel)) {
                feld = ziel;
                return true;
            }
            return false;
        }
    }

    Tests: Dame d1 → h5 true (diagonal, 4|4), d1 → e3 false; Turm a8 → a1 true; Läufer c1 → h6 true, c1 → c4 false, c1 → i4 false (ungültig).

    Beurteilung: Die Zeichenkette ist nah an der Schachnotation und leicht auszugeben, und istGueltig fängt Tippfehler ab. Für Berechnungen muss sie aber jedes Mal zerlegt werden. Zwei Ganzzahl-Attribute spalte und zeile (0–7) wären zum Rechnen und später für ein Brett als Reihung besser geeignet. Wegen der Kapselung ließe sich das intern umstellen, ohne dass sich die Schnittstelle ändert. Eine Schwäche des Entwurfs mit dem Attribut art ist die Kette von if-Abfragen: Jede neue Figurenart (Springer, Bauer) verlängert kannZiehen, und ein Tippfehler wie „turm“ fällt erst zur Laufzeit auf.

    A8
    Robuste Suche bewerten: E-Scooter-Flotte
    AFB III

    Ein Scooter-Verleih speichert seine Fahrzeuge in private EScooter[] scooter; — ein Platz pro Abstellplatz; leere Abstellplätze sind null. Die App soll Kundinnen den Scooter mit dem vollsten Akku anbieten, aber nur, wenn er mindestens 20 % hat. Ein Teammitglied liefert:

    public EScooter findeBesten() {
        EScooter bester = scooter[0];
        for (int i = 1; i < scooter.length; i++) {
            if (scooter[i].getAkku() > bester.getAkku()) {
                bester = scooter[i];
            }
        }
        return bester;
    }

    Bewerten Sie die Methode anhand der Anforderungen und geeigneter Testfälle. Implementieren Sie eine verbesserte Fassung und begründen Sie, welche Rückgabe sinnvoll ist, wenn kein geeigneter Scooter existiert. Erläutern Sie dabei, warum in der Bedingung scooter[i] != null && scooter[i].getAkku() >= 20 die Reihenfolge der Teilbedingungen wichtig ist.

    Ansatz: Denke dir drei Testfälle aus: (1) Platz 0 ist leer, (2) ein Platz in der Mitte ist leer, (3) alle Scooter haben weniger als 20 %. Was macht die Methode jeweils?
    Startwert: Statt mit scooter[0] zu starten, beginnt man mit bester = null („noch keiner gefunden“) und prüft alle Plätze ab Index 0.
    Kurzschluss-Auswertung: Bei && wird der rechte Teil nur ausgewertet, wenn der linke true ist.
    Vollständige Lösung

    Bewertung: Die Methode erfüllt die Anforderungen nicht. Test (1): Ist Platz 0 leer, löst bester.getAkku() eine NullPointerException aus. Test (2): Jeder leere Platz in der Mitte führt ebenso zum Absturz (scooter[i].getAkku()). Test (3): Bei Akkus von 12 %, 3 % und 9 % liefert sie den Scooter mit 12 % — die 20-%-Grenze fehlt ganz. Nur bei voll besetzter Reihung mit einem geeigneten Scooter arbeitet sie richtig.

    public EScooter findeBesten() {
        EScooter bester = null;
        for (int i = 0; i < scooter.length; i++) {
            if (scooter[i] != null && scooter[i].getAkku() >= 20) {
                if (bester == null || scooter[i].getAkku() > bester.getAkku()) {
                    bester = scooter[i];
                }
            }
        }
        return bester;
    }

    Rückgabe: null ist hier sinnvoll, weil es genau „kein passender Scooter“ bedeutet und keinen echten Scooter vortäuscht. Der Aufrufer muss das Ergebnis dann aber auf null prüfen, bevor er Methoden aufruft — das gehört in die Dokumentation der Schnittstelle. Ein erfundenes „Dummy-Objekt“ mit Akku 0 wäre schlechter, weil es leicht mit einem echten Fahrzeug verwechselt wird.

    Reihenfolge: Wegen der Kurzschluss-Auswertung von && wird scooter[i].getAkku() nur aufgerufen, wenn scooter[i] != null schon true war. In umgekehrter Reihenfolge würde der Getter zuerst auf null aufgerufen → NullPointerException.

    A9
    Doppelte vermeiden: Adressbuch
    AFB III

    Ein Adressbuch speichert Kontakte lückenlos auf den Plätzen 0 bis anzahl − 1 einer Reihung.

    Adressbuch
    • - kontakte: Reihung vom Typ Kontakt
    • - anzahl: Ganzzahl
    • c Adressbuch(max: Ganzzahl)
    • + suchen(name: Zeichenkette): Kontakt
    • + hinzufuegen(k: Kontakt): Wahrheitswert
    • + loeschen(name: Zeichenkette): Wahrheitswert
    • + getAnzahl(): Ganzzahl
    enthält1n
    Kontakt
    • - name: Zeichenkette
    • - telefon: Zeichenkette
    • c Kontakt(name: Zeichenkette, telefon: Zeichenkette)
    • + getName(): Zeichenkette
    • + getTelefon(): Zeichenkette

    Neue Anforderungen: Es darf keine zwei Kontakte mit demselben Namen geben. Beim Löschen darf keine Lücke entstehen, und die Reihenfolge der übrigen Kontakte soll erhalten bleiben.

    Implementieren Sie suchen, hinzufuegen und loeschen. Begründen Sie, warum der Vergleich kontakte[i] == k für die Doppelprüfung nicht ausreicht und warum hinzufuegen einen Wahrheitswert liefert.

    Ansatz: Schreibe zuerst suchen — dann kann hinzufuegen mit suchen(k.getName()) != null prüfen, ob der Name schon existiert.
    Löschen: Finde den Index i. Verschiebe dann alle Nachfolger um einen Platz nach vorn (kontakte[j] = kontakte[j + 1]), setze den letzten belegten Platz auf null und verringere anzahl.
    Vergleich: Zwei verschiedene Kontakt-Objekte können denselben Namen haben. Was vergleicht ==?
    Vollständige Lösung
    public class Adressbuch {
        private Kontakt[] kontakte;
        private int anzahl;
    
        public Adressbuch(int max) {
            kontakte = new Kontakt[max];
            anzahl = 0;
        }
    
        public Kontakt suchen(String name) {
            for (int i = 0; i < anzahl; i++) {
                if (kontakte[i].getName().equals(name)) {
                    return kontakte[i];
                }
            }
            return null;
        }
    
        public boolean hinzufuegen(Kontakt k) {
            if (k == null || anzahl == kontakte.length || suchen(k.getName()) != null) {
                return false;
            }
            kontakte[anzahl] = k;
            anzahl = anzahl + 1;
            return true;
        }
    
        public boolean loeschen(String name) {
            for (int i = 0; i < anzahl; i++) {
                if (kontakte[i].getName().equals(name)) {
                    for (int j = i; j < anzahl - 1; j++) {
                        kontakte[j] = kontakte[j + 1];
                    }
                    kontakte[anzahl - 1] = null;
                    anzahl = anzahl - 1;
                    return true;
                }
            }
            return false;
        }
    
        public int getAnzahl() {
            return anzahl;
        }
    }

    Begründung: kontakte[i] == k prüft nur, ob dasselbe Objekt schon gespeichert ist. Ein zweites Objekt new Kontakt("Ada", "0171 22") hätte eine andere Referenz und würde trotz gleichen Namens aufgenommen. Deshalb werden die Namen mit equals verglichen. Der Rückgabewert teilt dem Aufrufer mit, ob das Einfügen geklappt hat (Name doppelt oder Adressbuch voll) — ohne ihn müsste die Oberfläche selbst nachzählen, um eine Meldung anzuzeigen.

    Einfache Alternative beim Löschen: den letzten Kontakt auf die Lücke kopieren — schneller, aber dann ändert sich die Reihenfolge, was die Anforderung verbietet.

    A10
    Testfälle entwerfen: Regal im Lager
    AFB III

    Laut Pflichtenheft nimmt ein Lagerregal eine Kiste auf, wenn ein Fach frei ist und die Gesamtlast danach höchstens maxLastKg beträgt; sonst liefert einlagern false. Leere Fächer sind null; getGesamtgewicht() summiert die Gewichte aller eingelagerten Kisten. Die abgegebene Implementierung:

    public boolean einlagern(Kiste k) {
        if (getGesamtgewicht() + k.getGewichtKg() >= maxLastKg) {
            return false;
        }
        for (int i = 0; i <= faecher.length; i++) {
            if (faecher[i] == null) {
                faecher[i] = k;
                return true;
            }
        }
        return false;
    }
    Regal r = new Regal(4, 100);    // 4 Fächer, höchstens 100 kg
    r.einlagern(new Kiste(30));
    r.einlagern(new Kiste(40));
    r.einlagern(new Kiste(30));

    a) Ermitteln Sie das Gesamtgewicht des Regals nach diesen Aufrufen mit der abgegebenen Implementierung.

    b) Entwerfen Sie eine Reihe von Testfällen (Ausgangszustand, Aufruf, erwartetes Ergebnis), die das Pflichtenheft systematisch abdeckt. Beurteilen Sie, welche Ihrer Tests die Implementierung nicht besteht, und korrigieren Sie sie.

    kg
    Ansatz a: Nach zwei Kisten sind 70 kg im Regal. Setze die dritte Kiste in die Bedingung ein: Ist 70 + 30 >= 100?
    Testfälle: Denke in Klassen: normaler Fall, Grenzfall genau auf der Grenze, knapp darüber, alle Fächer belegt, leeres Regal. Die Grenzfälle finden die meisten Fehler.
    Hinweis: Was passiert in der Schleife, wenn alle Fächer belegt sind und das Gewicht noch passt? Welchen Wert erreicht i?
    Vollständige Lösung

    a) 30 kg und 40 kg werden eingelagert. Bei der dritten Kiste ist 70 + 30 >= 100 wahr → Ablehnung. Gesamtgewicht: 70 kg, obwohl laut Pflichtenheft 100 kg zulässig wären.

    b) Testfälle für ein Regal mit 4 Fächern und 100 kg:

    1. Leeres Regal, Kiste 30 kg → true, Gesamtgewicht 30. (bestanden)
    2. 70 kg eingelagert, Kiste 30 kg (genau 100) → true. Nicht bestanden: liefert false, weil >= statt >.
    3. 70 kg eingelagert, Kiste 31 kg → false. (bestanden)
    4. Alle 4 Fächer belegt (je 10 kg), Kiste 10 kg → false. Nicht bestanden: Die Schleife läuft bis i = 4, faecher[4] existiert nicht → ArrayIndexOutOfBoundsException.
    5. Fach 1 frei, Fächer 0, 2, 3 belegt, Kiste 5 kg → true, Kiste liegt in Fach 1. (bestanden)
    public boolean einlagern(Kiste k) {
        if (k == null || getGesamtgewicht() + k.getGewichtKg() > maxLastKg) {
            return false;
        }
        for (int i = 0; i < faecher.length; i++) {
            if (faecher[i] == null) {
                faecher[i] = k;
                return true;
            }
        }
        return false;
    }

    Ohne die Grenzfälle 2 und 4 wären beide Fehler unentdeckt geblieben — deshalb werden Testfälle schon im Entwurf gemeinsam festgelegt, bevor jemand programmiert. Die Prüfung k == null ergänzt einen weiteren Randfall (Aufruf mit null).