#+ 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.
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.
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 baserad på Optional
, 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 tillfalse
i den givna koden.En
Tracer
som läser upp de texter som är specificerade i vårtSimulator
-objekt – för att läsa upp texterna skapar vi enSpeechSynthesizer
: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 enSpeechSynthesizer
: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:
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:
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.