Lösningar till konstruktionsuppgifter på tentamen i EDAF60, oktober 2023

Uppgifter

Tentamensuppgifterna finns här.

Lösningsförslag för konstruktionsuppgifterna

Problem 1

(a)

Denna uppgift är väldigt lik problem 2 på seminarium 1, vi hade från början de två interfacen:

interface PatientDB {
    Patient findPatient(String ssn); // null om patienten inte finns
    void addPatient(String ssn, String firstName, String lastName);
}

interface Patient {
    void addMeasurement(String measure, double value);
}

Eftersom vi inte kan vara säkra på att metoden findPatient hittar någon patient med ett given personnummer skulle vi normalt vilja att den returnerade en Optional<Patient>, men för att hantera en tom sådan Optional på ett idiomatiskt sätt skulle vi behöva trixa lite (se AddMeasurementCommand nedan), så jag använde en klumpigare deklaration.

I uppgiften stod att vi ville ha ett interface:

interface Command {
    void execute(PatientDB db, PrintStream output);
}

och vi vill ha kommando-klasser för att hantera två typer av rader: ny-patient och mätning.

För ny-patient behöver vi spara personnummer och namn, så vår kommando-klass skulle kunna bli:

class AddPatientCommand implements Command {

    private String ssn, lastName, firstName;

    public AddPatientCommand  (String ssn,
                               String lastName,
                               String firstName) {
        this.ssn = ssn;
        this.lastName = lastName;
        this.firstName = firstName;
    }

    public void execute(PatientDB db,
                        PrintStream output) {
        db.addPatient(ssn, lastName, firstName);
    }
}

På motsvarande sätt kan vi för mätning skriva:

class AddMeasurementCommand implements Command {

    private String ssn, measure;
    private double value;

    public AddMeasurementCommand  (String ssn,
                                   String measure,
                                   double value) {
        this.ssn = ssn;
        this.measure = measure;
        this.value = value;
    }

    public void execute(PatientDB db,
                        PrintStream output) {
        var patient = db.findPatient(ssn);
        if (patient != null) {
            patient.addMeasurement(measure, value);
        } else {
            output.println("Error: no patient with ssn = " + ssn);
        }
    }
}

Om vi hade använt en Optional<Patient> som returvärde från findPatient (och det hade alltså varit den mest logiska returtypen), så borde vi skriva execute som:

public void execute(PatientDB db,
                    PrintStream output) {
    db
        .findPatient(ssn)
        .ifPresentOrElse(patient -> patient.addMeasurement(measure, value),
                         () -> output.println("Error: no patient with ssn = " + ssn));
    }
}

men vi har inte övat tillräckligt mycket på Optional för att jag skulle kunna begära att ni skall känna till det (att testa om db.findPatient(ssn).isPresent() vore inte bättre än att returnera null och testa på null, bara krångligare).

Enligt uppgiften skulle vi även ha en factory som skapar kommandon från raderna vi får från laboratoriet, och när vi skriver en sådan factory är det bekvämt att ha en Command-klass som beskriver ett ogiltigt kommando, så vi börjar med:

class IllegalCommand implements Command {

    private String command;

    public IllegalCommand  (String command) {
        this.command = command;
    }

    public void execute(PatientDB db,
                        PrintStream output) {
        output.println("Illegal command: " + command);
    }
}

Detta är en variant av ett mönster som kallas Null Object Pattern (det gör att vi får ett slags "null"-objekt som vi faktiskt kan använda som om det vore ett vanligt objekt) – vi använde en precis denna typ av klass när vi löste problem 2 under seminarium 1. Enligt uppgiftstexten kunde ni välja att anta att felaktiga kommandon inte förekommer, och då behövs inte denna klass.

Vi kan nu hantera våra två kommandon (och returnera IllegalCommand om vi får något annat):

class CommandFactory {

    public Command parse(String line) {
        var tokens = line.split(",");
        return
            switch (tokens[0]) {
            case "ny-patient" ->
                new AddPatientCommand(tokens[1],
                                      tokens[2],
                                      tokens[3]);

            case "mätning" ->
                new AddMeasurementCommand(tokens[1],
                                          tokens[2],
                                          Double.parseDouble(tokens[3]));

            default ->
                new IllegalCommand(line);
            };
    }
}

Här får vi exekveringsfel om användaren skickar felaktigt formaterade rader, men enligt uppgiftstexten kunde vi förutsätta att åtminstone ny-patient och mätning alltid var korrekt formaterade (och vi kan låta vår factory returnera ett IllegalCommand om de skulle stöta på något fel när vi försöker skapa dem).

Med dessa klasser till vår hjälp kan vi nu skriva run-metoden i JournalingSystem – vi behöver en factory för att skapa våra kommandon, och skall sedan iterera igenom samtliga rader i vår Scanner, skapa motsvarande kommando-objekt, och köra dem. Det enklaste sättet att iterera igenom samtliga element i en Scanner är att använda forEachRemaining:

class JournalingSystem {

    public void run(PatientDB db,
                    Scanner input,
                    PrintStream output) {
        var factory = new CommandFactory();
        input
            .forEachRemaining(line ->
                              factory
                              .parse(line)
                              .execute(db, output));
    }
}

men man får full poäng även för:

class JournalingSystem {

    public void run(PatientDB db,
                    Scanner input,
                    PrintStream output) {
        var factory = new CommandFactory();
        while (input.hasNext()) {
            factory
                .parse(input.next())
                .execute(db, output);
        }
    }
}

eller motsvarande.

Och det går naturligtvis bra att mellanlagra resultaten, exempelvis som:

class JournalingSystem {

    public void run(PatientDB db,
                    Scanner input,
                    PrintStream output) {
        var factory = new CommandFactory();
        while (input.hasNext()) {
            var line = input.next();
            var command = factory.parse(line);
            command.execute(db, output);
        }
    }
}

Några skrev även klasser som implementerade PatientDB och Patient, men det var egentligen inte meningen att ni skulle göra det – vi behöver inga sådana klasser för att vi skall kunna skriva run i JournalingSystem, det är först när vi utifrån skall anropa run och inte har tillgång till någon databas som dessa klasser måste implementeras (men det inträffar aldrig i denna tentamen). De extra klasser som eventuellt skrevs 'i onödan' kommer inte att rättas alls, och man kan inte få något avdrag även om man skulle ha gjort något fel i dem.

(b)

Vår lösning i (a) är ganska OCP, men den går inte hela vägen. För att den skall vara helt OCP skall vi kunna lägga till nya kommandon utan att ändra någon gammal kod, och det kan vi nästan – det är lätt att skriva en ny klass som implementerar Command, men vi måste även ändra vår befintliga factory så att den kan returnera den nya typen av kommandon, så vi kan inte göra tillägget helt utan att modifiera befintlig kod.

(c)

Vi vill kunna anropa run i en JournalingSystem, och efteråt få ut lite extra information från vår databas, så vi dekorerar databasen.

Det traditionella sättet att göra det är något i stil med:

class DecoratedPatientDB implements PatientDB {

    private PatientDB db;
    private Set<String> addedSSNs = new HashSet<String>();

    public DecoratedPatientDB  (PatientDB db) {
        this.db = db;
    }

    public void addPatient(String ssn,
                           String firstName,
                           String lastName) {
        addedSSNs.add(ssn);
        db.addPatient(ssn, firstName, lastName);
    }

    public Patient findPatient(String ssn) {   // behövs om vi skall implementera PatientDB
        return db.findPatient(ssn);
    }

    public int nbrOfAddedPatients() {
        return addedSSNs.size();
    }
}

Vi kan nu kapsla in den databas som skickas in till runWithStats:

void runWithStats(PatientDB db,
                  JournalingSystem journalingSystem,
                  Scanner input) {
    var decoratedDB = new DecoratedPatientDB(db);
    journalingSystem.run(decoratedDB, input, System.out);
    System.out.printf("Number of added patients: %d\n",
                      decoratedDB.nbrOfAddedPatients());
}

Ett praktiskt problem med att dekorera PatientDB är att det finns flera (i detta fall två) metoder i interfacet, och att de alla måste implementeras av vår dekoratör när vi skriver som ovan (vi gör dock inget stort avdrag om ni glömmer att implementera findPatient).

Vi skulle kunna vara lite fiffiga och istället utvidga en befintlig implementaton av PatientDB, och överskugga bara de metoder som vi vill dekorera. I uppgiften fanns det ingen sådan färdig implementation, men om vi hade haft en klass AcmePatientDB så skulle vi kunna skriva:

class PatientsDBWithStats extends AcmePatientDB {

    private Set<String> addedSSNs = new HashSet<String>();

    public PatientsDBWithStats  () {}

    public void addPatient(String ssn, String firstName, String lastName) {
        addedSSNs.add(ssn);
        super.addPatient(ssn, firstName, lastName);
    }

    public int nbrOfAddedPatients() {
        return addedSSNs.size();
    }
}

Om detta skall räknas som Decorator Pattern eller inte kan diskuteras, det påminner lite om skillnaden mellan Adapter Object Pattern och Adapt Class Pattern (bortsett från att vi här inte behöver anpassa till något annat interface).

Problem 2

(a)

Att implementera en klass som IntWord är egentligen något som man lär sig redan i grundkursen, så för att få poäng på uppgiften på denna tenta måste man göra det på ett sätt som svarar mot vad vi diskuterat i denna kurs (och vad vi gjorde i projektet).

Några viktiga saker:

  • Vi behöver ett int-attribut, men det skall inte synas utanför klassen eftersom det skulle göra API-et krångligare än nödvändigt, och dessutom riskera att öka coupling.
  • Parametrarna till våra metoder är deklarerade som Word (annars skulle klassen inte implementera gränssnittet Word), och det finns ingen publik metod som ger oss ett int (eller någon annan numerisk typ) från ett Word, så vi måste 'trixa' med typningen – i lösningen nedan använder jag en metod för att konvertera typerna (DRY), men det går bra även med vanlig explicit typning på varje ställe där det behövs.
  • Vi vill att konstruktorn till IntWord skall vara paket-skyddad, så att vi bara kan skapa IntWord-objekt med hjälp av vår IntWordFactory.
class IntWord extends Word {

    private int value;

    IntWord  (int value) {    // package protected
        this.value = value;
    }

    private IntWord ref(Word other) {
        return (IntWord)other;
    }

    public boolean equals(Word other) {
        return value == ref(other).value;
    }

    public void copy(Word other) {
        value = ref(other).value;
    }

    public void add(Word lhs, Word rhs) {
        value = ref(lhs).value + ref(rhs).value;
    }

    public void mul(Word lhs, Word rhs) {
        value = ref(lhs).value * ref(rhs).value;
    }
}

class IntWordFactory implements WordFactory {

    public Word word(String s) {
        return new IntWord(Integer.parseInt(s));
    }
}

Istället för att typkonvertera med metoden ref ovan kan vi använda en metod som direkt ger värdet på heltalsattributet:

class IntWord extends Word {

    private int value;

    private int valueOf(Word other) {
        return ((IntWord)other).value;
    }

    public boolean equals(Word other) {
        return value == valueOf(other)
    }

    public void copy(Word other) {
        value = valueOf(other);
    }

    public void add(Word lhs, Word rhs) {
        value = valueOf(lhs) + valueOf(rhs);
    }

    // ...
}

I vår lösning får vi exekveringsfel om någon skulle försöka skapa ett IntWord med en sträng som inte är ett heltal, eller om någon skulle anropa någon av metoderna med ett annat Word som inte var ett IntWord, men det var helt OK i projektet, eftersom vi i så fall vill göra användaren uppmärksam på att något har gått fel (den värsta typen av fel är inte dem som får våra program att krascha, utan dem som vi inte upptäcker på grund av att programmen försöker lösa dem utan att meddela oss).

(b)

  • Vi behöver interfacet Environment för att vår formelberäknare skall kunna hämta värdet av de celler som ett uttryck beror av – formelberäknaren var färdigimplementerad i exp-paketet (alltså långt innan ni implementerade er modell), och behövde veta hur man hämtar sådana värden från modellen.
  • Det är den klass som håller reda på samtliga våra celler, Sheet, som implementerar interfacet, eftersom det är den klass som vet hur man får tillgång till varje enskild cell baserat på dess adress.