Design
Indata till designen är kravspecen som den uttrycks
i de modeller som skapades under analys.
Syftet med design är att tänka igenom hela det avsnitt av
koden som ska konstrueras och att skapa en tydlig bild av hur koden ska
struktureras. De klasser, metoder osv som finns i designmodellen är
tänkta att finnas i koden också.
Design är en väldigt viktig aktivitet för
att få en genomtänkt kod. Det är också en
relativt svår aktivitet som mycket handlar om erfarenhet. Här
är centrala delar av "designkunskaperna" sammanfattade i några
viktiga principer och designmönster.
Just eftersom design är så viktig måste
vi komma ihåg att det inte ska vara mer än "en stunds
eftertanke för att komma mer rätt från början".
Designen kommer aldrig att bli så bra att kodningen blir en
rent mekanisk översättning av diagrammen till källkodsfiler.
Mycket av det som finns i designmodellen kommer att ändras vid
kodning. Det är knappast meningsfullt att ägna mer än
en halv dag åt design i en enveckas iteration.
Designens grundprinciper
Det här är väldigt långt ifrån
en fullständig kurs i objektorienterad design, vi tar bara en
kort titt på några av de allra viktigaste principerna.
Först av allt, syftet:
Det ska vara lätt att ändra programmets beteende
och att utöka dess funktionalitet, detta ska kunna göras
utan att behöva ändra i befintlig kod.
Når vi dit kan programmet leva länge utan
att koden degenererar till en ounderhållbar gröt som ingen
vågar ändra i.
Definition - Implementation
Definitionen är det som andra objekt kan se, tex namn
på klasser och interface och signaturer för publika metoder.
Implementationen är till exempel privata metoder och koden som
publika metoder innehåller. Ingenting får vara beroende
av implementationen, det enda som ska användas av andra objekt
är definitionen. På så sätt är det möjligt
att ändra i implementationen utan att påverka någon
annan kod än den som ändras. Betrakta detta lilla exempel från
collection-API:et i paketet java.util:
List är ett interface som innehåller
definitioner av metoder för en lista. ArrayList och LinkedList
är klasser som innehåller helt olika implementationer av
en lista, dvs helt olika kod. Låt oss nu anta att något objekt
använder en lista på det här sättet:
List list = new ArrayList();
list.add(obj);
list.get(2);
//en massa andra operationer på listan.
Helt plötsligt kommer vi på att det var
dåligt att använda ArrayList, vi borde ha en
LinkedList i stället. Det enda som behöver göras
är att ändra första raden till List list = new
LinkedList(). Detta innebär att en helt annan kod (implementationen)
kommer att exekveras men tack vara att vi bara refererade till interfacet
List (definitionen) krävde det nästan ingen alls
förändring av koden! Interface borde användas på
detta sätt i mycket större utsträckning än vad
som är fallet!
Detta resonemang gäller även på andra nivåer,
vi kan till exempel ha ett paket med bara en publik klass (en sådan
klass brukar kallas fasad). Dess publika gränssnitt utgör
då paketets definition och alla andra klasser i paketet utgör
dess implementation.
Det är ofta ofarligt att ändra en implementation
men livsfarligt att ändra en definition eftersom det kan finnas
väldigt många beroenden av den.
Inkapsling
Enligt resonemanget ovan är viktigt att ha en stabil
definition. Exakt vad är det då som utgör definitionen
av till exempel en klass? Jo, det är allting som någonting
utanför klassen kan se, dvs klassens namn samt signaturen av dess
publika variabler, metoder samt inre klasser och interface (dvs dess
publika gränssnitt). Desto mindre denna definition är desto
lättare är det hålla den stabil. Alltså bör
klassen ha få publika metoder och helst inga publika variabler
alls.
Hur är det då med klasser, metoder osv som är
protected eller paketprivata? Protected behöver väldigt
sällan användas så det hoppar vi över. Paketprivat
åtkomst är däremot mycket vanlig. Den är mycket
närmre besläktad med privat åtkomst än med publik.
Låt oss anta att vi ska utveckla en viss begränsad funktionalitet,
till exempel hantering av ett bankkonto (inte av ett helt banksystem
utan bara av ett konto). Vi inser att det finns olika typer av konton,
olika räntor osv varför det inte räcker med en enda klass.
Vi utvecklar då ett kontopaket istället för en kontoklass.
Detta kontopaket blir en mycket väl sammanhållen enhet med
ett tydligt publikt gränssnitt. För tydlighets skull råkar
paketet innehålla mer än en klasser. Att dessa klasser anropar
varandras metoder (paketprivat åtkomst) är dock ett relativt
ofarligt beroende eftersom de är en så väl sammanhållen
enhet. Detta resonemang håller inte alltid till fullo eftersom
ett pakets innehåll inte alltid är så väl sammanhållet.
Det viktigaste är dock att begränsa den publika åtkomsten
även om privat i och för sig är bättre än paketprivat.
Protected undviker vi om vi inte vet mycket väl varför vi ska
ha det.
En annan aspekt av inkapsling är att variabler helst
alltid ska vara privata, ovanstående resonemang gäller
främst allt utom variabler. Anledningen till att variabler ska
vara privata är att om de accessas uteslutande via metoder i klassen
har vi full koll på hur de accessas. Vi kan till exempel i metoderna
utföra omvandlingar mellan enheter, utföra säkerhetskoller,
göra typkonverteringar osv. Att accessa variabler via metoder gör
också koden robustare. Om vi till exempel vill byta typ på
variabeln behöver vi inte byta typ på det publika gränssnittet
i metoden som accessar den. Vi har också möjlighet att till
exempel synkronisera access av variabeln om det helt plötsligt skulle
bli nödvändigt.
Hög sammanhållning (high cohesion)
En klass ska ha väldefinierad kunskap och en väldefinierad
uppgift. En klass Anställd ska till exempel inte innehålla
en massa information om företaget den anställde jobbar på.
Den kan i stället ha en referens till en klass Företag som
innehåller den informationen.
Vidare ska klasser bara innehålla metoder som är
relaterade till det klassen representerar. Klassen Anställd
ska till exempel innehålla metoder som hanterar dess namn och
adress och vilket företag den jobbar på, medans klassen
Företag ska innehålla metoder som hanterar till exempel
lönehantering för den anställde.
En slutsats av detta blir att det är viktigt att dela
upp klasser. Det är bara alltför vanligt att program innehåller
för få och för stora klasser som har för mycket
och för oklar kod som snart blir grötig och svårunderhållen.
Låg koppling (low coupling)
Klasser ska ha litet beroende av andra klasser, dvs de ska
inte i onödan anropa metoder på andra klasser eftersom koden
i dem då påverkas av ändringar av koden i de anropade
klasserna. Olika typer av beroenden är dock olika farliga. Det
är till exempel ofarligt att använda klasserna i paketen java.*
som levereras med J2SE eftersom dessa klasser är extremt stabila.
Mer generellt är ett beroende ofarligare ju stabilare den anropade
klassen är. Vidare är det ofarligare med ett beroende av en
klass i samma paket än av en klass i ett annat paket eftersom vi
har större koll på klassen i samma paket. Desto större
koll vi har på klassen vi är beroende av detso ofarligare
är beroendet.
Några fler tips (patterns)
Kanske inte direkt designens grundprinciper men vanliga
lösningar på vanliga problem
Controller
Problem
Vart ska ett system event (anropen från aktören
i SSD) ske? Vem ska hantera dem?
Lösning
Klasser som har detta ansvar kallas controller.
En variant är att använda klasser (eller metoder i klasser)
som representerar scenarion i ett use case. Det är bra (för
tydlighets skull) om namnet på use case:t finns med i namnet på
metoden (om det är en metod per use case/scenario) eller klassen
(om det är en klass per use case/scenario). Denna variant kallas
use case controller.
En annan lösning är att inte associera controllern
med scenarion utan med en del av programmet (tex ett paket). Klassen
hanterar då all användning av subsystemet oavsett scenario.
Detta kallas en facade controller.
Observera att en controller aldrig ska vara en del av användarggränssnittet.
Creator
Problem
Vem ska skapa nya instanser av klasser?
Lösning
Det är lämpligt att A skapar instanser av B om
- A innehåller objekt av B
- A registrerar objekt av B (A är till exempel
någon typ av rapport).
- A har hög koppling med B
- A har det data som krävs för att initiera
en instans av B.
Arkitektur
Arkitekturen är systemets "skelett", hur det ser ut i
stora drag. Arkitekturen talar inte om hur problem löses,
snarare att de kan lösas och var de löses.
Exakt hur arkitekturen ska se ut kan vi inte veta nu, det är
tvärtom det främsta syftet med elaboration att fastställa
en arkitektur. Är vi väldigt osäkra på arkitektur är
det bästa antagligen att börja utveckla systemet förutsättningslöst
och se vad det resulterar i för arkitektur. I det här fallet säger
erfarenheten att det är lämpligt att bygga arkitekturen kring
ett arkitekturellt mönster som kallas MVC (Model View
Controller). Vi måste dock vara beredda att ändra och utveckla
arkitekturen vartefter systemet utvecklas och vi får nya insikter
om det.
MVC
MVC delar in systemet i tre delar: modell, vy och kontroller. Modellen
innehåller programmets data, där sker också all hantering
av datat. Vyn består enbart avanvändargränssnittet, utan
någonsom helst logik. Kontrollern är den som hanterar användarens
inmatningar (skriva text, klicka med musen osv).
Det finns många olika möjligheter att implementera MVC, bilden
ovan är bara en grov skiss av en lösning. Vi kommer att titta
lite mer på det här i period tre.
Design av ett use case
Efter denna blixtkurs i design är det dags att designa
koden för use case:t överför pengar mellan konton.
Vi gör designen genom att rita sekvens- eller kollaborationsdiagram
för de två metoderna i vårt system sequence diagram. Den
första metoden är startaOverforing. Tills vidare struntar
vi i allt som har med subsystemet view att göra, dvs vi bryr
oss inte om hur användargränssnittet fungerar.
StartaOverforing
Första uppgiften är att välja en controller eftersom
det är den som ska ta emot anvädarens inmatningar. Kandidater
till controller är klasser i domänmodellen som kan anses hantera
överföringen, till exempel Kassör och Kassa. Vi väljer
Kassa med tanken att alla use case som startas i kassan ska hanteras av
den (facade controller, se ovan). Kan hända kommer det i framtiden
visa sig att det blir ohanterligt många use case, då får
vi dela på klassen Kassa men tills vidare har vi den som controller:
Vad ska hända i modellen när metoden startaOverforing anropas?
Ingenting ska uppdateras eftersom vi ännu inte vet något om vilka
uppdateringar som ska ske, det kommer i nästa operation (angeKtoOchBelopp).
Det enda vi kan göra här är att skapa (och koppla ihop) alla
objekt som behövs för att hantera angeKtoOchBelopp. Vilka
är då det? Vi tar en titt i domänmodellen och konstaterar
att de enda objekt som är specifika för en viss överföring
är overforing och overforingsRad. Övriga objekt måste redan
finnas. overforingRad ska hanteras av overforing, alltså måste
vi skapa overforing först. Vem ska då skapa overforing? Vi har
i och för sig inte så mycket att välja på, det enda
objekt som finns så här långt är kassa, men vore det
helt vansinnigt att använda det skulle vi få införa ett
nytt objekt. En titt på mönstret Creator ovan säger dock
att det är rimligt att kassa skapar overforing eftersom överföringar
registreras i kassan. Nu ser diagrammet ut så här:
Slutligen ska overforingsRad skapas. Enligt domänmodellen ska objekt
av OverforingsRad finnas "i" overforing. Detta säger oss att overforing
behöver någon datastruktur som ska innehålla alla overforingsRader.
Enligt Creator är det uppenbart att overforing själv ska skapa
denna datastruktur eftersom den ska innehålla den, har extremt hög
koppling till den och det dessutom knappast finns något annat objekt
över huvud taget som har någon koppling till den:
AngeKtoOchBelopp
Även här börjar vi med att välja controller och av
samma anledning som ovan väljer vi Kassa:
Enligt scenariot i use case:t ska detta anrop ska resultera i att ett
kvitto genereras, att beloppet uppdateras på de berörda kontona
och att transaktionen loggas.
Vi börjar med att ändra beloppet på de två kontona.
Ett konto skapas inte när ett uttag (eller insättning) ska göras,
det finns redan på något sätt. Hur? Och var? "På
riktigt" skulle det säkert finnas i en databas men databaser ingår
inte i den här kursen och dessutom påverkas inte designen av
var det finns. Vi kan helt enkelt anta att kontobjektet redan finns i minnet.
Vem ska hålla referensen till kontot? Vi tar en titt i domänmodellen.
Det kan inte vara Överföring eller ÖverföringsRad eftersom
det är objekt som bara finns medans use case:t pågår. Det
kan inte heller vara KontoSpecifikation eftersom den bara ska innehålla
information som är gemensam för alla konton av en viss typ och
inte till exempel saldo. Kund kanske kunde vara en idé men då
har vi samma problem med vem som ska ha referensen till Kund. För att
förenkla designen i det här exemplet väljer vi inte kund.
Kassör, Kassa och BankKontor har egentligen inget med Konto att göra,
enligt princien low coupling väljer vi ingen av dem. Den enda
som återstår nu ärBank. Vi väljer den, det är
rimligt att det finns en koppling mellan en bank och dess konton. Kassan
ska alltså be Banken om de konton den ska uppdatera. Detta innebär
att det måste finnas en koppling från Kassa till Bank. Den skapas
lämpligtvis redan när programmet startas, det måste vi komma
ihåg att fixa. Nu ser diagrammet ut så här:
Sedan ska eventuella uttagsavgifter dras. Information om uttagsavgifter
finns i KontoSpecifikationer som finns i KontoKatalogen. Hur får vi
tag i KontoKatalogen? Det är lämpligt att fundera i termer av high
coupling, vem bör ha hand om den informationen? Ett konto vet rimligvis
vilken typ det är av. Det är också bäst att Konto sköter
hanteringen av uttagsavgifter själv, skulle vi låta Kassa sköta
den blev det krångligt om någon annan än Kassa skulle göra
ett uttag. En bra lösning verkar vara att ett Konto alltid är
associerat med rätt KontoSpecifikation:
KontoKatalog används bara när ett konto skapas för att slå
upp en KontoSpecifikation av rätt typ och associera den med det nyskapade
Kontot.
Nu har vi sett till att saldona uppdateras, nu är det dags attskapa
kvittot. Informationen på kvittot beskrivs av en Öveföring.
Den skapades redan i StartaÖverföring, nu ska vi fylla
i dess innehåll. Eftersom Kassa redan har rätt Överföring
och Överföring ska innehålla ÖverföringsRader
är det lämpligt att Kassa säger åt Överföring
att skapa ÖverföringsRaderna:
Vi struntar i att designa loggningen nu så därför är
vi klara med modellen. Nu går vi över till vyn.
Vyn
Vi kommer att diskutera design av grafiska användargränssnitt
i period tre, nu ska vi bara se hur UI-klasserna kan kopplas ihop med modellen.
Det är lämpligt att ha en JPanel i botten av varje skärmbild
vi skapar eftersom vi då är fria att lägga den i vilken
JFrame, JPanel eller JApplet som helst. Det use
case vi håller på att designa behöver en skärmbild
OverforingPanel, den kanske ser ut ungefär så här:
En titt på vår arkitektur (MVC) säger att allting användaren
gör ska hanteras av controllern. Vyn ska inte göra något annat
än att visa aktuellt data för användaren och att ge användaren
möjlighet att utföra operationer (tex genom att klicka på
en knapp). Eftersom det är via vyn användaren ger indata kan vi
byta ut aktören kassör i diagrammen ovan mot klassen OverforingPanel.
En fråga är var metoderna actionPerformed ska finnas.
Det är alltid bäst att översätta till användargränssnittsoberoende
händelser så långt ut som möjligt, de ska alltså
ligga i OverforingPanel (lämpligtvis i inre klasser). På så
sätt blir controllern oberoende av användargränssnittet.
Om datat i modellen uppdateras och detta ska visas i vyn är den klassiska
MVC-lösningen att vyn på något sätt lyssnar efter händelser
från modellen, se avsnittet arkitektur ovan. I det här use case:t
behövs ingen sådan hantering, det enda som händer är
att kassa returnerar ett kvitto (Overforing) som vyn ska hantera på
något sätt:
Det var det, nu har vi designat hela scenariot. I period tre kommer vi att
prata mer om hur modellen, controllern och vyn kan kommunicera med varandra.
Use case StartUp
I varje iteration är det lämpligt utveckla tillräckligt
mycket av use caset StartUp för att kunna köra den funktionalitet
som läggs till i iterationen. Det är bäst att utveckla StartUp
sist för att veta säkert vad som ska startas.
Aktören i detta use case är metoden main. Det enda den
ska göra är att skapa alla nödvändiga objekt och koppla
ihop dem. Det bästa är att main skapar få större
objekt och att dessa sedan "vecklar ut sig", dvs skapar sina "underobjekt"
och kopplar ihop dem. Vi skulle kunna nöja oss med att main skapar ett
objekt i modellen, förslagsvis bank, och ett i vyn, tex någon JFrame
som ska visas när programmet skapas:
i kod blir main inte mycket mer än:
public static void main(String[] args) {
Bank bank = new Bank();
new MainJFrame(bank.getKassa());
}
Klassdiagram
Innan vi börjar koda kan det underlätta att rita ett klassdiagram.
Här kommer ett för modellen:
Undantag
Checked exceptions ska användas för alla fel som inte
kan upptäckas vid programmering. Det är till exempel ett
typiskt checked exception att det inte finns tillräckligt mycket
pengar på ett konto för att kunna utföra ett uttag. Å
andra sidan är det ett typiskt unchecked exception att försöka
läsa på för högt index i en array. I det första
fallet ska vi kasta en egenutvecklas klass som ärver av Exception,
i det andra fallet kastas automatiskt ArrayIndexOutOfBoundsException
som ärver av RuntimeException.
Här kommer några enkla riktlinjer för hur undantag
ska användas:
- Undantagen ska ha namn som beskriver felet.
- Skapa inte onödigt många undantagsklasser. Finns
det många undantagsklasser finns det risk att metodsignaturer ändras
hela tiden när vi kommer på nya undantag. Det kan räcka
med en klass per "subsystem", tex kastar alla klasser i paketet java.sql
undantaget SQLException oavsett vilket fel som uppstod i databasen. Om
vi använder få klasser är det klokt att låta undantaget
innehålla tex en sträng som beskriver felet.
- Undantag ska inte propagera upp genom flera lager/skikt.
- Många fel kan åtgärdas direkt men en del
fel måste ramla hela vägen upp till klienten och visas för
användaren. Ett annat subset av felen ska förmodligen loggas.
Paket
Hela diskussionerna som fördes om "low coupling - high cohesion"
och om inkapsling i avsnittet om designens grundprinciper gäller
paket precis lika väl som klasser. Det är viktigt att ett pakets
publika gränssnitt är stabilt, om ändringar sker inuti
ett paket är inte lika farligt. Desto fler beroenden det finns av ett
paket, desto stabilare måste det vara.
Vad gäller vårat banksystem börjar alla paketnamn
med att identifiera organisationen där de utvecklas:
se.kth.isk.leffe
Därefter kommer produktens namn:
se.kth.isk.leffe.bank
Sedan kommer namnet på skiktet där paketet finns:
se.kth.isk.leffe.bank.model
se.kth.isk.leffe.bankview
Sedan kan vi eventuellt dela upp skikten i ytterligare paket
efter funktionalitet. Om det finns några generella "bra att ha"-klasser
kan vi lägga dem i ett eget paket:
se.kth.isk.leffe.bank.util
se.kth.isk.leffe.bank.view.util
se.kth.isk.leffe.bank.model.util
Koden som startar programmet kan lämpligen ligga i ett eget paket:
se.kth.isk.leffe.bank.startup
Tester
Tester kan ligga i ett paket test inuti det paket de testar.
Tester för klasser i tex paketet
se.kth.isk.leffe.bank.model hamnar
alltså i så fall i paketet se.kth.isk.leffe.bank.model.test.
Ett annat alternativ är att lägga testerna i en anonym inre klass
i den klass de testar.