#+ TITLE: EDAF60 - Lösningsförslag till tentamen den 25 oktober, 2021

Problem 1

  • Cohesion: Ett mått på hur väl sammanhållen en del av ett system är – ett delsystem med hög cohesion hanterar en eller högst några få olika saker.
  • Coupling: Ett mått på hur beroende av varandra olika delar av ett system är – två delsystem med låg coupling är relativt oberoende av varandra.
  • SRP: Single Responsibility Principle, säger att en klass (eller ett paket, eller en metod), skall ha en uppgift, och att den skall ha hela ansvaret för denna uppgift. SRP ger oss hög cohesion, och kan hjälpa oss att få lägre coupling.
  • DIP: Dependency Inversion Principle, säger att vi i vår kod bör vara beroende av abstraktioner (interfaces och abstrakta klasser), snarare än på konkreta klasser. DIP ger oss lägre coupling (den säger egentligen inte så mycket om cohesion).
  • OCP: Open Closed Principle, säger att vi bör designa våra program så att de är öppna för tillägg, utan att vi behöver ändra befintlig kod. I ett system med hög cohesion och låg coupling är det enklare att uppnå OCP (så om vi följer SRP och DIP är det enklare att följa OCP).

Problem 2

Delproblem 2a

Command Pattern används för att organisera kommandon i separata objekt, och låter oss utföra dem med ett givet metodanrop.

command-pattern.png

I objekten samlar vi ihop det som behövs för att göra något, och när vi anropar en given metod (execute i diagrammet ovan), så utförs det.

Mönstret ger oss DIP (vi använder kommando-interfacet när vi skall deklarera våra kommandon), och SRP (varje klass hanterar ett kommando, och utför hela kommandot). Och framförallt ger det oss OCP, eftersom det är enkelt att lägga till nya klasser som implementerar vårt kommando-interface.

Delproblem 2b

Decorator Pattern används när vi vill använda en 'färdig' klass, och vill lägga till någon extra funktionalitet utan att ändra den ursprungliga klassen.

decorator-pattern.png

Vi gör det genom att definiera en klass som implementerar samma interface som den ursprungliga klassen, och låter vår nya klass 'kapsla in' ett objekt av den ursprungliga klassen, som vi sedan delegerar vidare metodanropen till. I samband med att vi gör det kan vi lägga till vår nya funktionalitet.

I diagrammet ovan har vi en klass ConcreteA som är en 'riktig' A-klass, och en klass ADecorator, som 'kapslar in' ett objekt som implementerar A (exempelvis en ConcreteA). Klassen ADecorator implementerar A-interfacet, och gör det genom att i method() delegera vidare till method() i det inkapslade objektet, och lägga till den extra funktionalitet vi vill ha före eller efter anropet till det inkapslade objektet.

Mönstret hjälper oss med SRP (eftersom vi inte behöver skriva den dekorerande koden i original-klasserna, och därmed kan låta dem syssla med det de egentligen borde göra), och gör även att vi kan få OCP (ingenting behöver ändras i de befintliga klasserna för att vi skall få vår nya funktionalitet).

Problem 3

Delproblem 3a

Vi skriver om de metoder som kan returnera null, och låter dem returnera Optional-värden istället (i uppgiften slapp vi skriva om metoderna, det räckte att vi gav dem en ny returtyp, men i praktiken är det i princip inte svårare än att kapsla in våra gamla retur-värden i ett Optional.ofNullable-anrop):

interface FileSystem {
    // returns the root of the file system (often called /)
    Directory root();
}

interface Directory {
    // returns a subdirectory with a given name, if it exists
    Optional<Directory> into(String dirname);
    // returns a file with a given name, if it exists
    Optional<File> open(String filename);
}

interface File {
    // returns the contents of a file, as a String
    String contents();
}

Några har låtit root() i FileSystem returnera en Optional, men det står uttryckligen i uppgiften att vi alltid kommer att kunna hämta rot-katalogen. Och för contents() i File får vi bara en tom sträng om filen skulle vara tom, så vi har inga null-problem där heller.

Att använda Optional som returvärden för dessa båda metoder skulle bara göra vår kod mer komplicerad – kursen handlar om att göra vår kod mer robust och lättläst, lösningar som bara komplicerar saker utan att vi vinner något på det ger därför avdrag.

Delproblem 3b

Med hjälp av deklarationerna i (a) kan vi skriva:

void showSystemConfig(FileSystem fileSystem) {
    var contents =
        fileSystem
        .root()
        .into("etc")
        .flatMap(d -> d.into("systemd"))
        .flatMap(d -> d.open("system.conf"))
        .map(f -> f.contents())
        .orElse("");
    System.out.println(contents);
}

Istället för att först hämta strängen och sedan skriva ut den kan vi göra något i stil med:

void showSystemConfig(FileSystem fileSystem) {
    fileSystem
        .root()
        .into("etc")
        .flatMap(d -> d.into("systemd"))
        .flatMap(d -> d.open("system.conf"))
        .ifPresentOrElse(f -> System.out.println(f.contents()),
                         () -> System.out.println(""));
}

Däremot kan vi inte göra upprepade .ifPresent-anrop, eftersom de 'returnerar' void (de 'bryter' anropskedjan).

Att använda isPreset()-anrop och get() i if-satser är betydligt krångligare än att använda null direkt, så det vill vi absolut inte göra (det stod i uppgiften att lösningen skulle vara baseradOptional, en lösning med isPreset()- och get()-anrop kan som mest sägas använda Optional, men den är definitivt inte baserad på det).

Problem 4

Delproblem 4a

Strategy Pattern säger att vi skall definiera ett interface för det som skall göras, och att vi sedan kan använda objekt som implementerar interfacet när vi vill få något gjort. Det finns andra mönster som man kan använda för att refaktorisera koden (exempelvis Template Method, eller Observer), men det står på tre ställen i uppgiftstexten att vi skall använda mönstret Strategy, så det är bara det som ger poäng.

Som uppgiften var formulerad är det inte helt klart om vi vill kunna variera innehållet i våra meddelanden utan att ändra i vår Simulation (OCP), eller om det bara är vart vi skickar våra meddelanden som varierar.

Det var helt OK att förutsätta att meddelanden inte kunde ändras, så att Simulation kan hårdkoda dem – det blir lite mindre flexibelt om vi exempelvis vill kunna ändra språk (för då måste vi ändra i Simulation), men samtidigt blir vår strategy i någon mening mer flexibel, eftersom den kan användas även vid andra tillfällen än de tre i uppgiften.

Om vi antar att våra meddelanden alltid är:

  • "Starting simulation",
  • "Halfway through simulation", och
  • "Finishing simulation",

och det enda som kan variera är vart vi skickar utskrifterna (exempelvis till 'ingenstans', till System.out eller till en SpeechSynthesizer), så kan vi skriva:

interface Tracer {
    void announce(String message);
}

class Simulation {

    private Tracer tracer;

    public Simulation() {
        // ... omitted code ...
    }

    public void useTracer(Tracer tracer) {
        this.tracer = tracer;
    }

    public void run() {
        tracer.announce("Starting simulation");
        // ... omitted first half of simulation ...
        tracer.announce("Halfway through simulation");
        // ... omitted last half of simulation ...
        tracer.announce("Finishing simulation");
    }
}

Om vi även vill kunna variera våra meddelanden beroende på vår Tracer (exempelvis att ge dem på svenska), så kan vi istället skriva:

interface Tracer {
    void starting();
    void halfway();
    void finishing();
}

class Simulation {

    private Tracer tracer;

    public Simulation() {
        // ... omitted code ...
    }

    public void useTracer(Tracer tracer) {
        this.tracer = tracer;
    }

    public void run() {
        tracer.starting();
        // ... omitted first half of simulation ...
        tracer.halfway();
        // ... omitted last half of simulation ...
        tracer.finishing();
    }
}

I båda fallen får vi en klass Simulation som vi kan kompilera, men vi behöver minst en implementation av interfacet Tracer för att köra. Några lät Tracer-objektet vara parameter till konstruktorn (och hade ingen useTracer-metod), och det är helt OK!

Ganska många valde att skicka in sin Tracer som parmeter till run()-metoden, och även om det ändrar signaturen på metoden, så är det en rimlig sak att göra (så det ger inget avdrag).

Några valde att använda Strategy Pattern för att lyfta ut hela simuleringen (dvs att låta sin 'strategy' implementera run()-metoden i Simulation), men det är olyckligt på flera sätt – dels innebär det att vi eventuellt måste skicka in information som nu finns i vårt Simulation-objekt till alla nya strategier, så att de skall kunna köra simuleringen, dessutom innebär det att vi kommer att behöva upprepa kod (om vi inte använder exempelvis Template Method Pattern, men då är det inte längre Strategy som är det centrala mönstret i vår lösning).

Delproblem 4b

I uppgiften skulle vi skriva ett huvudprogram som körde den modifierade simuleringen från (a) två gånger, först en gång utan utskrifter, och sedan en gång där vi använder ett SpeechSynthesizer-objekt för att läsa upp den text vi skrev ut i den ursprungliga Simulation-klassen ovan. För att göra det måste vi implementera två olika slags Tracer-objekt, hur vi gör det beror naturligtvis på hur vi valde att tolka uppgiften i (a).

Om vi hårdkodar våra meddelanden i Simulation så får vi:

  • En Tracer som inte ger någon utskrift alls:

    class NoTrace implements Tracer {
    
        void announce(String message) {
        }
    }
    

    Denna klass motsvarar att vi hade kört med trace satt till false i den givna koden.

  • En Tracer som läser upp de texter som är specificerade i vårt Simulator-objekt – för att läsa upp texterna skapar vi en SpeechSynthesizer:

    class SpeechTracer implements Tracer {
    
        private SpeechSynthesizer voice = new SpeechSynthesizer();
    
        void announce(String message) {
            voice.say(message);
        }
    }
    

Om vi vill kunna bestämma vilka meddelanden vi skall ha när vi skapar vår Tracer, så att vi inte behöver skriva om vår Simulation varje gång vi vill ändra dem (OCP), så får vi istället:

  • En Tracer som inte ger någon utskrift alls:

    class NoTrace implements Tracer {
    
        void starting() {
        }
    
        void halfway() {
        }
    
        void finishing() {
        }
    }
    
  • En Tracer som läser upp de texter som vi hade i uppgiftstexten – för att kunna läsa upp texten skapar vi en SpeechSynthesizer:

    class SpeechTracer implements Tracer {
    
        private SpeechSynthesizer voice = new SpeechSynthesizer();
    
        void starting() {
            voice.say("Starting simulation");
        }
    
        void halfway() {
            voice.say("Halfway through simulation");
        }
    
        void finishing() {
            voice.say("Finishing simulation");
        }
    }
    

    Poängen med denna lösning är alltså att vi nu enkelt kan göra en svensk version, genom att ändra texterna i respektive metod. Eftersom valet av språk och utmatningsenhet är ortogonalt, så kan vi introducera ytterligare en abstraktion här, som låter oss 'injicera' vår utmatningsenhet, men det lämnas som en övning till den intresserade (vi kan använda antingen Template Method Pattern, eller kanske ytterligare en Strategy).

    Det var några som ville göra om vår SpeechSynthesizer, men det finns ingen anledning att göra det, den fungerar bra för det som vi vill använda den till i uppgiften.

Med dessa båda 'strategier' till hjälp kan vi nu köra simuleringen:

class Main {

    public static void main(String[] args) {
        new Main().run();
    }

    void run() {
        var simulation = new Simulation();
        simulation.useTracer(new NoTrace());
        simulation.run();
        simulation.useTracer(new SpeechTracer());
        simulation.run();
    }
}

De som lät 'tracern' vara ett argument till konstruktorn fick här skapa ett Simulation-objekt för varje simulering, och det är helt OK!

Delproblem 4c

Hur detta diagram ser ut beror på vilken lösning ovan som vi använder – för lösningen med tre olika metoder i vår strategy får vi:

simulation-classes.png

Det som gör detta till Strategy är att Simulation har ett Tracer-attribut som kan referera till olika slags 'tracers' (jag glömde markera det i figuren innan jag scannade den).

Delproblem 4d

Även detta diagram beror av vilken lösning vi hade ovan – (för lösningen med tre olika metoder i vår strategy får vi:

simulation-sequence.png

Vid rättningen har jag bara tittat på precis den del av simuleringen som vi bad er illustrera (jag vill inte 'straffa' någon som gjort mer än vi bad om).

Problem 5

Vi hade här inledningsvis:

class JumpEq implements Instruction {

    private int target;
    private Operand left, right;

    public JumpEq  (int target, Operand left, Operand right) {
        this.target = target;
        this.left = left;
        this.right = right;
    }

    public void execute(Memory memory, PC pc) {
        if (left.getWord(memory).equals(right.getWord(memory))) {
            pc.jumpTo(target);
        } else {
            pc.step();
        }
    }
}

I båda deluppgifterna skall vi använda Template Method Pattern, och det kräver att vi bryter ut den gemensamma koden till en gemensam superklass, med en 'mall-metod' – det som skiljer klasserna åt hanterar vi sedan i antingen en abstrakt metod, som i deluppgift (a), eller med hjälp av ett lambda-uttryck, som i deluppgift (b), men i båda fallen skall de anropas från mall-metoden (som i detta fall kommer att bli execute).

I uppgiften står det uttryckligen lösningar som inte baseras på Template Method Pattern ger inga poäng, så man måste i superklassen ha en mall-metod, och denna måste anropa antingen den abstrakta metoden i (a), eller lambdauttrycken i (b).

Delproblem 5a

Det som kommer att skilja klasserna åt är hur de jämför orden som vi får från våra operander, så vi kan deklarera en abstrakt metod, shouldJump, som tar två Word-parametrar, och returnerar en boolean.

Vår template-metod, execute, kan anropa denna abstrakta metod, och vår gemensamma abstrakta klass blir då:

abstract class JumpConditional implements Instruction {

    private int target;
    private Operand left, right;

    public JumpConditional  (int target, Operand left, Operand right) {
        this.target = target;
        this.left = left;
        this.right = right;
    }

    protected abstract boolean shouldJump(Word left, Word right);

    public final void execute(Memory memory, PC pc) {
        if (shouldJump(left.getWord(memory), right.getWord(memory))) {
            pc.jumpTo(target);
        } else {
            pc.step();
        }
    }
}

Med hjälp av denna kan vi implementera de två hopp-klasserna:

class JumpEq extends JumpConditional {

    public JumpEq  (int target, Operand left, Operand right) {
        super(target, left, right);
    }

    protected boolean shouldJump(Word left, Word right) {
        return left.equals(right);
    }
}

class JumpNeq extends JumpConditional {

    public JumpNeq  (int target, Operand left, Operand right) {
        super(target, left, right);
    }

    protected boolean shouldJump(Word left, Word right) {
        return !left.equals(right);
    }
}

Vi kan istället välja att ha en abstrakt metod som tar exempelvis Memory som parameter, och hämta orden med getWord(Memory) inne i subklasserna (om operanderna är protected-deklarerade), men det gör att vi måste upprepa våra getWord(Memory)-anrop i varje subklass, så det är inte helt optimalt (det ger ändå poäng i uppgiften, men inte full poäng).

En sak som vi inte vill göra är att låta execute bli den abstrakta metoden, och låta subklasserna implementera den för att därifrån eventuellt anropa en eller flera nya metoder i superklassen. Vi skulle visserligen kunna återvinna en del kod från superklassens nya metoder, men det skulle göra att vi inte längre har någon template method (i lösningarna ovan är det ju execute som är vår mall-metod, varifrån vi anropar den abstrakta metoden), så vi löser inte längre uppgiften som den var formulerad (och återigen, i uppgiften står det uttryckligen att "lösningar som inte baseras på Template Method Pattern ger inga poäng", så det skulle vara konstigt att ge poäng för en lösning som inte innehåller en mall-metod).

Delproblem 5b

Den teknik som vi använder här behandlades bland annat i uppgift 3 på seminarium 2, och under föreläsning 7 (vi har även diskuterat den under minst två frågestunder).

I denna uppgift blir den gemensamma superklassen inte nödvändigtvis abstrakt, istället för att ha en abstrakt metod kommer vi nu att lagra ett lambdauttryck för testet som attribut i vår superklass.

Vi kan börja med att deklarera ett interface för denna funktion (som alltså skall testa om vi skall hoppa) – den skall ta två ord, och returnera true eller false:

interface BinaryWordPredicate {
    boolean check(Word left, Word right);
}

Detta är ett SAM-interface, så vi kan implementera det med hjälp av ett lambda-uttryck.

Vi kan nu skriva en gemensam superklass som tar ett sådant lambdauttryck som argument till konstruktorn, och lagrar det som ett attribut. Vi testar sedan hopp-villkoret genom att anropa check-metoden (som alltså är vårt lambdauttryck):

class JumpConditional implements Instruction {

    private int target;
    private Operand left, right;
    private BinaryWordPredicate jumpCondition;

    public JumpConditional  (int target,
                             Operand left,
                             Operand right,
                             BinaryWordPredicate jumpCondition) {
        this.target = target;
        this.left = left;
        this.right = right;
        this.jumpCondition = jumpCondition;
    }

    public final void execute(Memory memory, PC pc) {
        if (jumpCondition.check(left.getWord(memory), right.getWord(memory))) {
            pc.jumpTo(target);
        } else {
            pc.step();
        }
    }
}

Med hjälp av denna blir våra hopp-klasser väldigt enkla:

class JumpEq extends JumpConditional {

    public JumpEq  (int target, Operand left, Operand right) {
        super(target, left, right, (lhs, rhs) -> lhs.equals(rhs));
    }
}

class JumpNeq extends JumpConditional {

    public JumpNeq  (int target, Operand left, Operand right) {
        super(target, left, right, (lhs, rhs) -> !lhs.equals(rhs));
    }
}

Det stod i uppgiften att vi ville "Implementera klasserna JumpEq och JumpNeq med hjälp av Template Method Pattern med lambdauttryck", några av er stoppade ett steg före, och visade bara hur man kan skapa

var jumpEq = new JumpConditional(target,
                                 left,
                                 right,
                                 (lhs, rhs) -> lhs.equals(rhs));
var jumpNeq = new JumpConditional(target,
                                  left,
                                  right,
                                  (lhs, rhs) -> !lhs.equals(rhs));

och man fick nästan full poäng även för detta (för full poäng ville vi dock ha klasserna, och anropet inne i konstruktorn). Anropen ovan illustrerar hur flexibelt detta sätt att implementera Template Method Pattern är – om vi hade haft andra jämförelse-metoder på våra Word-objekt så skulle vi väldigt enkelt kunna skapa nya alternativa hopp-satser.

Det finns flera andra sätt att använda lambda-uttryck när vi implementerar Template Method Pattern, exempelvis kan vi ha en abstrakt metod precis som i (5a) ovan, och använda ett lambda-uttryck inne i implementationen av metoden, eller skriva en metod som låter subklasserna returnera lambda-uttryck, och sedan använda dessa i execute i superklassen, men dessa lösningar gör vår kod mer komplicerad, utan att vi vinner någonting på det (och vi tappar möjligheten att skapa nya instruktioner som i satserna ovan). I kursen vill vi skriva enkel och ren kod, inte göra saker mer komplicerade än nödvändigt, så vi ger inga (eller möjligen några få) poäng till lösningar som komplicerar vår kod utan att vi vinner något på det.