Labb 3: Ärvning, hierarkier, enklare GUI
Introduktion
I denna labb kommer vi att experimentera med typhierarkier med hjälp av arvsmekanismen (inheritance) i Java. Med arv kan vi modellera att en klass är ett specialfall av en annan klass. En lastbil är t.ex. en sorts bil, och en länkad lista är en sorts lista. Detta kommer vi att kontrastera med sammansättning (composition), som vi använder för att modellera det faktum att ett objekt har något: En kö har en lista där den lagrar sina element, men den är inte en lista.
Vi introducerar också fler funktioner i IDEA som underlättar programmering. Eftersom man ofta inte modellerar perfekt från början fokuserar vi på funktionalitet som hjälper till när man upptäcker problem och felaktigheter. Därför inför vi till en början också vissa sådana problem och felaktigheter i koden. Se det inte som ineffektivitet utan ett sätt att lära sig effektivt underhåll av kod! Ni kommer säkert att få jobba en hel del med kod som skrivits av programmerare som inte är lika bra som ni, t.ex. av er själva för några veckor sedan...
Efter föreläsning 4, inkapsling, livstid, organisation
Efter föreläsning 5, pekare, interface, typhierarkier
Uppgift 3.1: Ett grafikprogram – vår första form
Genomgående uppgift: Grafikprogram
Vi tar nu åter ett steg bort från Tetris för att undersöka en programtyp där typhierarkier har en mer framträdande roll.
Föreställ dig att du ska skapa ett vektorbaserat grafikprogram så som Inkscape eller Adobe Illustrator. Då kommer du att behöva ett antal klasser som representerar vektorbaserade former, t.ex. cirklar och rektanglar.
Olika former har olika egenskaper, men också många likheter. De har en position där de ska målas ut, en färg, och kanske också beskrivning av dess ram och innehåll. Kanske har formen en bild som ska visas inuti, kanske ska den vara fylld med ett mönster, osv.
Vi kommer nu att modellera sådana former för att undersöka hur ärvning kan vara till hjälp för att representera både likheter och skillnader.
Att göra 3.1.1: Circle
Vi vill komma igång snabbt! Otåliga som vi är bestämmer vi oss för att skapa en cirkelklass, utan att tänka så mycket på framtiden eller hur den ska kopplas till övriga formklasser.
-
Tidigare klasser låg i paketet
se.liu.ida.dinadress.tddd78.lab2
. Högerklicka på paketettddd78
, välj New | Package och skapalab3
. Detta ska hamna på samma nivå somlab1
ochlab2
i klassträdet. -
Skapa klassen
Circle
i paketetlab3
. -
Lägg till de publika (
public
!)int
-fältenx
,y
ochradius
. -
Lägg till ett publikt fält
color
av typenjava.awt.Color
.Abstract Window Toolkit (AWT) är Javas ursprungliga grafiksystem. Anledningen till att vi skriver hela
java.awt.Color
är att det finns mångaColor
-klasser i olika paket och vi vill testa ett alternativt sätt att få fram exakt rätt klass. Om du ställer markören ijava.awt.Color
så kommer IDEA att visa en glödlampa till vänster. Klicka på den eller tryck Alt+Enter, och välj Replace with import, så kommer IDEA att istället importera klassen och vi kan använda namnet utan att ange paketdelen (java.awt
). -
Skapa en konstruktor som tar in parametrar och tilldelar värden till alla fält.
Att göra 3.1.2: Test
Vi gör nu ett första test av klassen. Även om vi är otåliga ska detta göras tidigt för att vi inte ska låta eventuella fel få nya följdfel när vi fortsätter programmera.
-
Skapa klassen
TestCircle
och enmain()
-metod i den. -
I
main()
ska vi skapa en lista avCircle
. Skrivnew ArrayList<Circle>()
, låt IDEA importeraArrayList
, och testa IDEAs Extract Variable enligt nedan.Info: Extract Variable
Genom att markera ett uttryck eller stå i en variabel kan man snabbt få IDEA att introducera en variabel som representerar detta. Man gör det med Refactor | Extract | Variable: Ctrl-Alt-V. Det är användbart t.ex. då man vill skapa en variabel snabbt. Anta att vi har skrivit i koden:
new ArrayList<Circle>()
Efter att vi trycker Ctrl-Alt-V och väljer namn på variabeln får vi:
final ArrayList<Circle> circles = new ArrayList<>();
IDEA har själv hittat på ett variabelnamn, som man givetvis kan byta ut. Notera att "
<>
" står för "samma typparametrar som tidigare på raden", dvs. "<Circle>
".Extract Variable är också användbart om man har ett långt uttryck som man vill dela upp, t.ex. en
if
-sats där villkoret är väldigt långt. Genom att markera en del av den och ta extract variable skapas en variabel som tilldelas värdet av deluttrycket och sedan ersätts detta iif
-satsen med variabeln. -
Skapa ett par cirklar och lägg dem i listan (med listmetoden
add()
). -
Använd live-template iter (iterate collection) för att skapa en
for
-loop som går igenom varjeCircle
i listan och för varje cirkel skriver ut dess x- och y-koordinater. När man fyller i en live-template kan man använda Tab för att navigera mellan variabler som namnges.Notera att iter använder en for-loop-variant som är mer lik Pythons:
for (Type element : collection)
, som itererar en gång för varje element i en samling eller array. -
Kör programmet och verifiera att du får ut rätt utskrifter.
Att förstå: Klassdiagram
Nu kan vi använda
ett klassdiagram
i UML för att se strukturen hos de klasser vi har skapat. Här
nedan syns att TestCircle
skapar instanser
av klassen
Circle
. Vi kommer att se fler UML-diagram senare.
Uppgift 3.2: Defensiv programmering
Att förstå 3.2.1: Kontrollera parametrar
Hittills har vi låtit anropare skicka in vilka värden som helst när
ett objekt skapas. På detta sätt kan vi till exempel skapa och
rita ut en Circle
med negativ radie:
Circle cir = new Circle(10, 10, -1, Color.BLACK);
Men hur ska en sådan cirkel kunna ritas ut på skärmen, när vi kommer till den grafiska delen av labben? Vi borde förbjuda sådana cirklar från att skapas, eller rättare sagt, se till att cirklar vägrar att skapas med sådana radier. Det är en del av ansvarsfördelningen i objektorienterad programmering, där klasser själva kan "bestämma över" sina objekt.
Att göra 3.2.1: Kontrollera parametrar
-
Hoppa från
TestCircle
tillCircle
genom att använda Navigate | Class: Ctrl-N och skriva "Cir", som expanderas automatiskt när du trycker Enter. Detta är en mycket användbar genväg.Eftersom vi redan har en referens till
Circle
i närheten kan vi även ställa markören iCircle
och trycka Ctrl-B för att snabbt hoppa till definitionen. Detta fungerar för alla klasser, metoder och fält, inklusive de från "språkets egna" klasser (t.ex.System
). -
Lägg till följande rader överst i
Circle
-konstruktorn:if (radius < 0) { throw new IllegalArgumentException("Negativ radie!"); }
Detta är en exception, ett undantag. Vi kommer att gå genom mer om exceptions i kursen, men hittills räcker det att veta att denna kod avbryter konstruktorn och signalerar ett fel till anroparen. Eftersom konstruktorn avbröts skapas heller inget cirkelobjekt!
Detta exemplifierar två viktiga programmeringsbegrepp.
En klass har kontroll över sina objekt. Nu kan ingen skapa en cirkel med negativ radie.
Defensiv programmering. Vi ska inte tänka att "ingen galning skulle få för sig att ge en negativ radie, så det fallet kan vi ignorera". Man vet aldrig hur radien räknas ut eller vad anroparen har missat att tänka på (eller hur galen någon är, för den delen). Vi tar hand om alla tänkbara och otänkbara fall, så får vi färre svårhittade buggar.
Att förstå 3.2.2: Kapsla in information
Vi har kommit en bit, men inte hela vägen. Vi gjorde ett misstag
i förra uppgiften och lät informationen i cirkelklassen
vara public
(alla kan komma åt den och ändra den).
Därför kan någon fortfarande skriva:
Circle cir = new Circle(10, 10, 1, Color.BLACK);
cir.radius = -1;
Detta skapar en Circle
med positiv radie, som vi sedan
ändrar på "utifrån". Att göra en kontroll i konstruktorn räckte alltså
inte! Problemet är att fältet har publik åtkomst så det kan ändras
när som helst, varifrån som helst.
En bra lösning på detta är att ändra åtkomsten till privat. Då
skyddar vi klassen så att Circle
själv kan bestämma att
inga felaktiga värden kan tilldelas redan i konstruktorn. Med IDEAs
hjälp kan detta åtgärdas snabbt.
(Varför gjorde vi inte rätt från början? Att man gör den här typen av misstag ibland är oundvikligt. Vi vill inte bara ge en tillrättalagd instruktion utan visa hur man fixar problem så att de inte behöver leva kvar i koden.)
Att göra 3.2.2: Kapsla in information
-
I klassen
Circle
, välj Refactor | Encapsulate Fields. Bocka för alla fält samt avmarkera Set Access och Use accessors even when field is accessible.
Att förstå: Vad hände?
All kod som hämtade värden med t.ex. minCirkel.x
har
nu automatiskt skrivits om till att hämta detta
med minCirkel.getX()
istället. Detta kan vi till
exempel se i TestCircle
.
Eftersom fälten nu är privata är det bara kod i Circle
själv som kan ändra på dem efter att en cirkel skapas.
Eftersom Circle
aldrig gör det, är alla cirklar
skyddade ifrån att deras fält ändras efter att de skapas.
Hade vi bockat för Set Access hade även setter-metoder skapats. Då hade man kunnat ändra värden "i efterhand", men bara genom dessa metoder, som då kunde innehålla samma typ av säkerhetskontroller som konstruktorn.
Uppgift 3.3: Skapa ett eget gränssnitt – Shape
Att förstå 3.3.1: Gränssnittet Shape
Vi kommer inte att nöja oss med cirklar utan kommer att skapa många olika typer av former. På ytan liknar det situationen för de olika blocktyperna i Tetris, men där var det väldigt enkelt att representera alla de olika varianterna som data – en tvådimensionell array där vi lagrade blockets form. Här är det jobbigare att skapa en intern datastruktur som låter samma klass representera både linjer, rektanglar, cirklar, text och många andra saker som vi kan vilja rita upp på skärmen (även om det så klart är teoretiskt möjligt). Därför föredrar vi i detta fall att skapa olika klasser för de olika formerna, där varje klass kan ha sin egen utritningskod som kan skilja sig radikalt från koden i andra klasser.
Samtidigt vill vi ju kunna hantera dessa på ett gemensamt sätt. Till exempel ska man kunna ha en "lista av former", där vi inte behöver ange om det är kvadrater, cirklar, romber eller 18-hörningar som finns i listan.
Vi åstadkommer detta genom att skapa
gränssnittet Shape
, som ska implementeras av samtliga
formklasser. Då kan vi helt enkelt använda en "lista
av Shape
", och denna lista
accepterar alla former – men till skillnad från
generella listor i Python accepterar den enbart former,
inte t.ex. strängar. När man plockar en form ur listan vet man att
det finns en metod för att rita upp den, men det är
subtypspolymorfism som används för att se till att rätt
implementation anropas.
Att göra 3.3.1: Gränssnittet Shape
-
Skapa gränssnittet
Shape
i paketetlab3
. Som förut högerklickar vi på paketet och väljer New | Java Class, men nu väljer vi Interface i drop-down-menyn som finns i fönstret Create New Class som kommer fram. -
Nu ska vi se till att
Circle
implementerarShape
. Påclass
-raden lägger vi därför tillimplements Shape
.Eftersom
Shape
ännu inte har några metoder är detta tillräckligt för tillfället.
Att förstå: Klassdiagram
Nu kan vi även illustrera att Circle
realiserar
(implementerar) gränssnittet Shape
.
Att förstå 3.3.2: Rita ut en Shape
Det gränssnitt vi har skapat kan användas för att koppla ihop
klasser i en hierarki, men den säger absolut ingenting om vad de
klasserna behöver kunna göra. Har vi
en List<Shape>
kan vi alltså plocka ut formerna
ur listan, men sedan kan vi inte be dem göra något.
Vi behöver ha några metoder i Shape
. Vi vill
till exempel kunna säga till en godtycklig Shape
att
måla ut sig på skärmen. Vi åstadkommer detta genom att skapa
metoden draw()
som deklareras redan
i Shape
. Detta ses som ett "krav" att alla klasser som
implementerar Shape
måste tillhandahålla en sådan
metod. Då behöver inte utomstående kod känna till några detaljer om
hur t.ex. en 18-hörning ska ritas ut, eller ens att
formen är en 18-hörning. Det räcker att veta att man har
en Shape
och att varje Shape
kan rita ut
sig själv med
draw()
.
Att göra 3.3.2: Rita ut en Shape
-
Lägg till en public metod som heter
draw()
. Den ska inte ha några inparametrar och inte heller lämna något returvärde (vad skriver man för att indikera detta?). Som alla metoder i gränssnitt ska den också sakna "kodkropp" ({...}
). -
Vi ser att
draw()
blir gul. Det finns nämligen klasser som säger sig implementeraShape
men som saknar denna metod! Motsvarande varning kan ses när man går tillCircle
: IDEA stryker under "implements Shape
" med röd färg.Placera markören någonstans i
Circle
där du vill ha de metoder som krävs förShape
. Tryck Alt+Insert och välj Implement Methods och sedandraw()
. Då fyller IDEA i rätt metodbeskrivning.För enkelhets skull kommer vi inte att måla ut något än, utan bara skriva ut "innehållet" i de former som ska målas ut. Låt därför metoden skriva ut
"Ritar: " + this
.(Vad är "
@Override
", som IDEA genererar? Detta är en annotering som visar att detta implementerar eller override:ar en metod som är definierad längre upp i klasshierarkin. Ni får veta mer om detta under en senare föreläsning.) -
Nu minns vi att standardutskriften för ett objekt inte var särskilt användbar. IDEA varnar till och med för detta genom att "
this
" blir gul ("Call to default 'toString' on 'this'").Använd IDEA för att generera en
toString()
(Alt+InserttoString()
) som innehåller värdet på alla fält.
Att förstå 3.3.3: Flera metoder i Shape
Vad gör vi med de metoder som redan fanns i Circle
?
Är det några av dessa som även hör hemma i Shape
?
Ja, om vi tänker på betydelsen hos Shape
kan vi inse
att alla formklasser borde ha x- och y-koordinater samt en
färg. Därmed borde även Shape
ha
metoderna getX()
, getY()
och getColor()
! Då kommer Java att låta oss anropa
dem även när vi bara vet att vi har en Shape
, utan
att vi behöver veta vilken konkret typ av Shape
vi
har.
Att göra 3.3.3: Flera metoder i Shape
-
Alternativ 1 av 2: Lägg manuellt till metoddeklarationer för
getX()
,getY()
ochgetColor()
iShape
. Se till att de har samma returvärden och parametrar som iCircle
, men saknar metodkropp. Vi kan ju inte implementera en fullständiggetX()
här uppe iShape
, eftersom gränssnittet själv inte har någotx
att returnera! -
Alternativ 2: Gå till
Circle
. Använd Refactor | Pull Members Up. Se till attShape
är vald i den översta väljaren. MarkeragetX()
,getY()
ochgetColor()
i medlemslistan.Men vad är det som ska flyttas upp? Om
Shape
hade varit en klass hade man kunnat välja att flytta hela implementationen av dessa metoder tillShape
, om man till exempel ansåg att implementationen skulle vara samma för alla underklasser tillShape
. Men nu ärShape
ett gränssnitt, som inte ska innehålla någon kod – bara metodsignaturer. Därför är "Make abstract" automatiskt vald. Därmed gör IDEA en metodsignatur iShape
och låter själva implementationen finnas kvar i den ursprungliga klassen. Metoder i gränssnitt är alltid abstrakta == har ingen implementation.Vilken ordning metoderna hamnar i kan bero på i vilken ordning man väljer dem i listan!
Välj Refactor. Detta skapar rätt metoddeklarationer i
Shape
och lägger också automatiskt till@Override
på metoderna iCircle
.
Nu har vi deklarerat att alla Shape
-klasser måste ha dessa metoder.
Att göra 3.3.4: Test
Vi gör nu ett första test av gränssnittet och klassen tillsammans. Detta gör vi i en ny testklass.
-
Gå till
TestCircle
. Klona klassen med hjälp av F5. Kalla den nya klassenTestShapes
. På det sättet får ni med strukturen i det gamla testprogrammet. -
I
TestShapes.main()
ska vi inte skapa en lista avCircle
, utan en lista avShape
där vi så småningom även ska kunna lägga in rektanglar och andra former.Vi måste också ändra loopen på motsvarande sätt, så att den plockar ut godtyckliga former och inte kräver att få cirklar.
Variabler bör så klart också döpas om, så vi inte har en lista av former som heter "circles". Ställ markören i ett variabelnamn och välj Refactor | Rename:Shift+F6. Skriv in det nya namnet på variabeln (eller välj ett av förslagen som kommer upp) och tryck enter. IDEA byter automatiskt alla förekomster av variabelnamnet.
-
Nu bör slutet av koden se ut ungefär så här:
for (Shape shape : shapes) { System.out.println(shape.getX() + " " + shape.getY()); }
Men nu har vi ju en riktig "ritmetod", så vi ersätter utskriftsraden med ett anrop till
shape.draw()
. -
Kör
TestShapes
och verifiera att du får ut rätt utskrifter. Även det gamla testprogrammetTestCircle
ska fortfarande fungera!
Att förstå: Klassdiagram
Nu vill vi illustrera att det finns två olika klasser som skapar cirklar.
Uppgift 3.4: Implementera ett gränssnitt
Att göra 3.4.1: Rectangle
Vår nästa form är en rektangel. Även den ska implementera
gränssnittet Shape
.
-
Skapa klassen
Rectangle
med de privata fältenx
,y
,width
,height
ochcolor
. Typerna vet ni sedan tidigare.Ge klassen en lämplig konstruktor.
Skapa en
toString()
-metod. -
Implementera gränssnittet
Shape
och de metoder som krävs i detta gränssnitt. -
Utöka testprogrammet så det också lägger några rektanglar i listan. Testa.
Att göra 3.4.2: Text
Vår sista form är en text. Även den ska implementera
gränssnittet Shape
.
-
Skapa klassen
Text
med de privata fältenx
,y
,size
(storleken i punkter),color
ochtext
(enString
) samt en lämplig konstruktor ochtoString()
-metod. -
Implementera gränssnittet
Shape
och de metoder som ingår i gränssnittet. -
Utöka testprogrammet på lämpligt sätt. Testa.
Att förstå: Klassdiagram
Nu kan vi illustrera att en av testklasserna skapar cirklar, medan
den andra skapar både cirklar, texter och rektanglar. Vi kan också
visa att de olika formklasserna implementerar Shape
.
Uppgift 3.5: Dela på kod med en abstrakt klass
När du kommer hit kan det hända att vi inte har gått genom abstrakta klasser på föreläsningarna. Detta blir i så fall ett av tillfällena där vi provar först och diskuterar teorin efteråt.
Att förstå 3.5.1: Kodlukter och upprepad kod
Vissa delar av de klasser som just har skapats är väldigt lika.
Vi har upprepat deklarationer av till
exempel x
, y
, color
och
motsvarande getters, och upprepning är sällan ett gott tecken.
Det är inte direkt en bugg, eftersom programmet fortfarande kan
fungera utmärkt. Men vi har
en kodlukt,
vilket kan definieras som ett symptom som tyder på ett djupare
problem i koden. Koden "luktar lite konstigt", och då
måste vi se om det beror på att vi har gjort något fel.
Vi vet ju att de klasser vi har implementerat har ett naturligt
sammanhang: De är specialfall av Shape
. Då borde de
så klart också dela på den kod som är gemensam: Den härstammar ju
från att de faktiskt begreppsmässigt hänger ihop. Det måste vi
fixa! (Återigen kunde vi så klart ha gjort rätt från början
istället, men eftersom man inte alltid vet exakt vart man är på
väg när man börjar skriva koden, behöver man lära sig hur
man fixar problem på ett effektivt sätt!)
Det hade kanske varit smidigt om man kunde lägga den gemensamma
delen av implementationen i Shape
, men ett gränssnitt
är inte tänkt för detta: Gränssnitt anger vilka metoder som måste
finnas men vi ska inte implementera dem direkt där. Lösningen är
att vi använder oss av en abstrakt klass istället, som vi
kallar AbstractShape
.
Vi kommer att diskutera abstrakta klasser i mer detalj under
den andra av de två föreläsningarna om typhierarkier.
Just nu räcker det att veta att
abstrakta klasser tillåter oss att implementera vissa metoder men
lämna vissa "ofärdiga". Detta är alltså ett ett mellansteg mellan
gränssnitt och vanliga konkreta klasser, och precis som med
gränssnitt kan man inte skapa nya objekt genom att direkt anropa
en konstruktor i den abstrakta klassen. Vi kan ju inte skapa
"new List()
" eftersom metoderna i List
saknar implementation, men vi kan skapa "new
ArrayList()
" där ArrayList
är en konkret
implementation av List
. På samma sätt kan vi inte
skapa objekt med new AbstractShape()
, men vi kommer
att kunna skapa objekt av dess konkreta subtyper.
Att ha både ett gränssnitt och en abstrakt klass ger flexibilitet: Man kan välja att ärva från den abstrakta klassen eller att implementera gränssnittet direkt. Detta diskuteras mer under föreläsningarna.
Vi skapar därför en ny klass AbstractShape
som kommer
att kunna innehålla även fält och metoder. Sedan kan formerna ärva
sitt beteende ifrån denna. En abstrakt klass har även den
egenskapen att man inte behöver implementera alla metoder i
den. Eftersom vi inte kan implementera en
"generell" draw()
för godtyckliga former (vad skulle
det betyda?) kan vi lämna till subklasser att implementera
denna. Detta får alltså som effekt att man inte kan skapa objekt
av klassen AbstractShape
eftersom den inte är
fullständigt specificerad.
Att göra 3.5.1: AbstractShape
-
Skapa en ny "tom" klass
public abstract class AbstractShape
. Se till att den implementerarShape
– alla subklasser tillAbstractShape
ska ju varaShape
s. -
Byt ut
implements Shape
motextends AbstractShape
i klassernaCircle
,Rectangle
ochText
. Detta innebär att klasserna blirAbstractShape
, men de kommer fortfarande också att varaShape
s eftersom detta gränssnitt implementeras avAbstractShape
. -
Nu är vi redo att flytta ut vissa definitioner till den abstrakta klassen.
Navigera till
Circle
och välj Refactor | Pull Members Up. Välj att medlemmar ska dras upp tillAbstractShape
, inteShape
. Markera fältenx,y,color
, som är gemensamma för alla former. Markera även motsvarande getters. Den här gången görs metoderna inte abstrakta: Implementationen ska följa med upp.Vi markerar inte
radius
ellergetRadius()
, eftersom bara cirklar har radier. Inte hellerdraw()
ellertoString()
: De ska visserligen finnas i alla former men behöver ha en egen implementation i varje klass.Vilken ordning medlemmarna och konstruktorparametrarna hamnar i kan bero på i vilken ordning man väljer dem i listan! Blir det fel kan ni göra "undo" och göra om denna refactoring.
Välj Refactor. IDEA gör nu några ändringar för att koden fortfarande ska fungera.
-
De valda fälten blir
protected
iAbstractShape
istället förprivate
, så att subklasser fortfarande kan komma åt dem. -
IDEA skapar också automatiskt en konstruktor för
AbstractShape
. -
Eftersom fälten nu är flyttade är det upp till
AbstractShape
att hantera initialisering av dem. Vissa tilldelningar i konstruktorn förCircle
ersätts därför med anropetsuper(x,y,color)
, som vidarebefordrar parametrarna frånCircle
upp tillAbstractShape
:s konstruktor. (Ett anrop tillsuper()
ligger alltid först i en konstruktor.)
-
-
Tyvärr vet inte IDEA att den ska göra motsvarande ändringar i de andra klasserna.
Navigera till
Rectangle
. Ta bort fältenx/y/color
, ta bort motsvarande getters, och ta bort initialiseringen ur konstruktorn.Lägg sedan till
super()
-anrop motsvarande det iCircle
-konstruktorn. Ta bort de tre getter-implementationer som nu blev onödiga eftersom samma kod redan ärvs ner frånAbstractShape
.Gör samma sak för
Text
.
Som ni märker kan det krävas lite jobb för att hålla koden i god form. Det är lätt hänt att man undviker det, men det förlorar man ofta på i det långa loppet. I projektet kommer vi att ge komplettering för duplicerad kod av den här typen.
Vi vill poängtera att vi inte införde den abstrakta klassen bara för att "spara kod", utan att det faktiskt fanns ett naturligt sammanhang mellan klasserna också: De är olika sorters former.
Att förstå: Varför både Shape
och AbstractShape
?
Varför behåller vi både Shape
och AbstractShape
? Jo, om vi gör på det sättet får
vi mer flexibilitet:
I
Circle
kan vi välja att ärva ner användbar kod frånAbstractShape
I
OtherShape
kan vi välja att implementera helaShape
själva, kanske för att klassen redan ärver frånOtherBase
och alltså inte kan ärva från någon annan klass.
Uppgift 3.6:
Delegering eller arv – Stack
och Queue
Syfte
Nu har vi testat typhierarkier och ärvning, två begrepp som ofta associeras med just objektorienterade språk. Dessa begrepp ger oss bra och användbara verktyg, men risken är att man då fastnar för just verktyget och försöker anpassa alla problem till detta.
If all you have is a hammer, everything looks like a nail.

Vi ska inte använda ärvning bara för att det finns och "verkar så objektorienterat". Ofta finns det andra sätt att programmera som passar bättre – t.ex. komposition med delegering. Vi ska nu prova detta.
Att förstå 3.6.1: Kö och stack
Vi skall nu skapa två enkla datastrukturer: kö och stack. För
enkelhets skull går vi inte in närmare på generiska typer
(som ArrayList<Elementtyp>
), utan håller oss
till Person
som element.
I en kö kan man bara lägga till element längst bak
(enqueue
) och ta bort längst fram
(dequeue
). Detta är enligt mottot "först in, först ut"
(FIFO):
I en stack ("trave", som en trave tallrikar) påverkar man
däremot alltid det översta elementet, med metoderna push
och
pop
. Mottot är här "sist in, först ut" (LIFO):
Vi kommer att behöva någonstans att lagra de element som skall ligga
i datastrukturerna. För att göra det enkelt för oss använder vi en
redan existerande datastruktur, ArrayList
från förra
övningen. På så vis lämnar vi t.ex. över problemet att allokera
lagom mycket lagringsutrymme till denna färdiga klass.
Men hur skall vi använda en ArrayList
för att
implementera detta?
Ett sätt är genom arv. Vi skulle då göra
t.ex. Queue
till en
underklass till ArrayList
. Genom arv skulle
våra klasser få alla metoder från ArrayList
. Smidigt och bra?
Ett av problemet med den här lösningen är att man får en
datastruktur som kan modifieras på "fel" sätt. Till exempel kan man
i en lista lägga till och ta bort element var som helst, inte bara i
början som vi vill med en kö. Detta går delvis att arbeta sig runt,
men det finns bättre alternativ. Det är ju inte så att
en Queue
är en ArrayList
med
extra funktionalitet, utan den har faktiskt mindre funktionalitet!
På ungefär samma sätt är en bil inte en ratt, även om den har en ratt.
Därför vill vi istället använda sammansättning
(composition). Det betyder helt enkelt att vi låter
vår Queue
ha och använda
en ArrayList
istället för att vara en.
Uppgift 3.6.1: Kö och stack
-
Skapa klassen
Queue
. -
Skapa ett privat fält
List<Person> elements
och initialisera detta till en nyArrayList<Person>
. Eftersom varjeQueue
har sina egna element får detta fält inte vara statiskt. -
Det finns metoder i
ArrayList
som vi vill göra tillgängliga för användaren avQueue
.size()
är en sådan metod. Eftersomelements
håller koll på antalet element den innehåller, skriver vi helt enkelt bara ensize()
-metod iQueue
som returnerar resultatet avelements.size()
. Vi delegerar alltså det egentliga arbetet till elements-listan. (IDEA kan hjälpa till – läs vidare!)Vi delegerar arbetet till
elements
även för metodernaisEmpty()
,clear()
ochcontains()
på precis samma sätt, genom att ha en metod som direkt anropar och returnerar svaret från motsvarande metod ielements
.Delegering är så pass vanligt att IDEA kan automatisera det åt oss. Genom att välja Code | Delegate Methods och sedan välja
elements
kan man därefter välja precis vilka metoder man vill delegera. -
Vi behöver även några "egna" metoder. Lägg till:
enqueue()
, som tar en person som parameter och lägger till den sist i kön, och-
dequeue()
, som hämtar personen som är först i kön, tar bort den ur kön (listan), och returnerar den.
Dessa ska använda sig av metoderna
elements.add()
ochelements.remove(int index)
för att ta ut första elementet i elements och stoppa in ett element sist i listan.Tänk på att t.ex.
enqueue
ska lägga till ett element i en specifik kö (ett specifiktQueue
-objekt), inte i kö-klassen i sin helhet. Metoderna du skriver här ska alltså inte vara statiska. -
Nu är
Queue
färdig och det är dags att skapaStack
. Klasserna är lika på alla punkter utom var objekt läggs till/tas ut. Därför kan man använda IDEAs funktion clone class som man kommer åt genom att trycka F5 när markören står i klassnamnet. Ange bara namnetStack
så skapas en ny klass som ser precis ut somQueue
. Byt namn påenqueue/dequeue
tillpush/pop
och ändra koden i dem så attStack
beter sig som en stack. -
Skriv ett testprogram.
Skapa en stack, lägg i tur och ordning in 5 olika personer i denna, och plocka sedan ut och skriv ut element i den ordning de kommer. Vi kunde använda en
for
-loop, men vi har egentligen inget behov av att veta vilken position vi är på – bara om det finns fler element kvar eller inte. Därför använder vi istället enwhile
-loop som itererar så länge stacken inte är tom.Gör även samma sak med en kö.
-
När vi nu tar en sista titt på vad vi har gjort upptäcker vi ett par saker:
Stack
ochQueue
har något gemensamt: De är en sorts listmanipulatorer som behandlar saker i listor. Då kan det kanske finnas anledning för detta att synas även i typhierarkin, genom att klasserna implementerar ett gränssnitt eller ärver från en gemensam abstrakt klass.De har också en del gemensam kod, för att lagra saker i listor och vidarebefordra anrop till listor. Detta skulle man kunna flytta upp till en gemensam abstrakt klass.
Gör detta! Skapa en superklass med något lämpligt namn, t.ex.
ListManipulator
. Flytta de gemensamma implementationerna dit. Här kan IDEAs Extract Superclass vara till hjälp.(Var detta nödvändigt? Kanske inte till 100 procent i just detta fall. Men det är bra att lära sig hitta förbättringsmöjligheter och att öva på att göra förbättringarna. I projektet är det inte ovanligt att bedömningar dras ner av strukturella problem där man t.ex. bör införa lämpliga klasser som representerar delade egenskaper.
Uppgift 3.7: Likhet, identitet och namn
Att förstå 3.7.1: equals()
och hashCode()
I Java används operatorn "==" för att jämföra värden, och värdet
av en "objektvariabel" är en pekare. Därför skulle
följande kod skriva ut false
:
Circle c1 = new Circle(1,1,1,Color.BLACK);
Circle c2 = new Circle(1,1,1,Color.BLACK);
if (c1 == c2) System.out.println("true");
else System.out.println("false");
Eftersom c1
och c2
pekar på olika objekt
har de olika värde, trots att de två objekten har har
identiskt "innehåll" / state.
Om man istället vill jämföra objekten som pekas på, används i Java
metoden equals()
som jämför ett objekt med ett annat
godtyckligt objekt.
equals()
motsvaras av Pythons ==
.Javas
==
motsvaras av Pythons is
.
Så här kunde det se ut innan vi
införde AbstractShape
:
public class Circle implements Shape {
...
public boolean equals(Object other) {
// I am an object and can't be equal to "no object"!
if (other == null) return false;
// Does the other one have exactly the same class?
// Otherwise we're not equal!
if (other.getClass() != this.getClass())
return false;
// Use casting to get a pointer of type Circle
Circle that = (Circle) other;
// Check if all fields are equal.
// For primitive types we use ==,
// and for objects we use equals().
return this.x == that.x &&
this.y == that.y &&
this.color.equals(that.color) &&
this.radius == that.radius;
}
}
Men nu när vi har AbstractShape
har vi ju delat upp
fälten i två metoder. Då måste det istället se ut ungefär så här,
så att varje klass kan ta hand om sina egna fält. Läs
kommentarerna!
public class AbstractShape implements Shape {
public boolean equals(Object other) {
// I am an object and can't be equal to "no object"!
if (other == null) return false;
// Does the other one have exactly the same class?
// Otherwise we're not equal!
if (other.getClass() != this.getClass())
return false;
AbstractShape that = (AbstractShape) other;
return this.x == that.x &&
this.y == that.y &&
this.color.equals(that.color);
}
}
public class Circle extends AbstractShape {
public boolean equals(Object other) {
// Are we equal according to the superclass?
// Using "super.method(...)" calls the implementation that
// was defined in the superclass: AbstractShape (above),
// which tests that o!=null, that o has the same class,
// and that x/y/color are equivalent.
if (!super.equals(o)) return false;
// Are all of my own "new" fields equal as well?
return this.radius == that.radius;
}
}
Då skulle följande kod skriva ut true
:
Circle c1 = new Circle(1,1,1);
Circle c2 = new Circle(1,1,1);
if (c1.equals(c2)) System.out.println("true");
else System.out.println("false");
Metoden hashCode
returnerar en hashkod för ett
objekt, något som behövs om man t.ex. ska använda objektet som
nyckel i en HashMap
(Javas motsvarighet till
Pythons dict
). Detta kommer ni att lära er mer om i
senare kurser.
hashCode()
i Java motsvaras av att
implementera __hash__()
i Python.
Om två objekt är lika (equals), MÅSTE de ha samma hashkod. Ekvivalent: Om två objekt har olika hashkod, måste de vara olika.
Om man vill leta efter ett visst objekt i en samling, räcker det alltså att leta bland de objekt som har samma hashkod. Alla andra objekt kan man ignorera. Det kan man använda till att t.ex. lägga alla objekt vars hashkod slutar på
00
i en delsamling (som brukar kallas "hink"), alla som slutar på01
i en annan hink, och så vidare. Vill man se om ett visst objekt finns med någonstans, räcker det att titta i hinken som innehåller objekt med samma slutsiffror. Objekten i de andra hinkarna har ju andra slutsiffror, och alltså andra hashkoder, och är alltså garanterat olika.-
Om två objekt är olika (!equals), VILL vi gärna att de har olika hashkod, men det är OK att det råkar bli samma.
Alla objekt kunde t.ex. ha hashkod
012345678
, men då hamnar alla objekt i hink78
och man får ingen nytta av hashkoden.Men så länge som hashkoderna blir ganska jämnt fördelade är det helt OK att det ibland blir krockar. Oavsett vad hashkoden är kommer vi att leta bland alla objekt i den valda hinken, och kommer att hitta rätt. Det tar bara mer tid om det inte är en jämn fördelning.
En bra implementation av
hashCode()
tar hänsyn till många olika aspekter av objektet för att räkna ut en hashkod som är väl fördelad över alla 4 miljarderint
.
Att göra 3.7.1: equals()
och hashCode()
-
Skapa
equals()
-metoder i AbstractShape. Använd IDEA som hjälp via Code | Generate | equals() and hashCode(): Alt+Insert. Vi är egentligen bara intresserade avequals()
, men IDEA skapar ocksåhashCode()
. Denna metod används för vissa datastrukturer men är inte relevant för oss just nu.IDEA frågar: "Accept subclasses as parameter to equals() method?". Kryssa inte i den rutan. Vi vill bara att två objekt ska kunna anses lika om de har exakt samma klass.
Därefter får du välja vilka fält du anser vara relevanta för att två objekt ska vara lika. Om två
AbstractShape
-objekt har samma koordinater men olika färg, ska de då anses vara likadana eller inte? Det avgör du själv utifrån programmets behov. Normalt anger man exakt samma fält när IDEA frågar vad som ska vara med ihashCode()
.Om det finns icke-primitiva fält, t.ex.
color
som är en objektpekare, kommer IDEA att fråga om de kan garanteras att inte varanull
. Detta är för att inte generera onödiganull
-jämförelser som kan ta någon extra nanosekund. Om du är osäker så ange att fälten kan varanull
(detta kan vara något mindre effektivt men ger alltid rätt resultat). -
Titta på de skapade
equals()
-metoderna. Verkar de rimliga? Man kan aldrig vara säker på att automatgenererad kod helt överensstämmer med vad man själv tänkte.
Efter föreläsning 6, GUI
Uppgift 3.9: Grafiskt gränssnitt för former
Nu är det dags att se till att ritprogrammet faktiskt kan rita ut sina former. Vi ska också titta mer på refactoring i IDEA.
Om du är snabb och hinner hit innan GUI-föreläsningen kan du ändå fortsätta om du vill: Vi ska inte gå vidare och skriva ett fullfjädrat ritprogram, utan nöjer oss med att implementera ett mycket enkelt program som visar upp ett fönster, plus att skriva om utritningsmetoderna så att de faktiskt ritar i detta fönster. För detta ger vi ett enkelt kodskelett.
Att göra 3.9.1: Grunden till en diagramkomponent
-
Vi kallar en uppsättning former för ett diagram. Skapa därför ny klass,
DiagramComponent
, enligt följande mönster. Detta ska bli en grafisk komponent som vet hur man ritar upp ett helt diagram på skärmen.import javax.swing.*; import java.awt.*; public class DiagramComponent extends JComponent { @Override protected void paintComponent(final Graphics g) { super.paintComponent(g); // Senare ska vi rita upp alla former här! } }
-
Lägg till ett privat fält
shapes
som innehåller en lista av former.Skapa en konstruktor som sätter
shapes
till en ny lista (detta har ni gjort förr!).Lägg till metoden
public void addShape(Shape s)
, som ska addera en ny form till listan.
Att förstå 3.9.2: Rita ut former
Alla komponentklasser har metoden paintComponent()
,
som kommer att anropas automatiskt av Java när det är dags att
rita upp en specifik grafisk komponenten på skärmen. Er uppgift
blir nu att skriva en sådan metod, med kod som ritar upp alla
former som finns i komponentens formlista.
För att rita i komponenten använder
man Graphics
-objektet som man får som parameter.
Namnet Graphics
är egentligen lite missvisande.
Egentligen kunde detta ha hetat Painter
, eftersom det
är den som har metoder för att rita ut pixlar, linjer och så
vidare på skärmen, t.ex. drawLine()
.
Ett sätt att lösa detta är att paintComponent
själv
vet hur varje typ av form ska ritas ut. Detta har stora nackdelar
i och med att informationen om formerna blir centraliserad:
DiagramComponent
måste ha detaljerad kunskap om
hur alla former ska ritas ut. Vad var det då för poäng
med att kunna hantera dem på ett generellt sätt, som "någon
sorts Shape
"?
Istället ska vi låta varje form själv veta hur den ritas
ut. DiagramComponent
:s uppgift blir då helt enkelt
att veta hur man ritar ut många former på skärmen. I
vårt enkla exempel kan detta bestå av att rita upp dem i
godtycklig ordning, men i ett mer avancerat program
skulle DiagramComponent
också t.ex. behöva rita ut
dem i rätt ordning ("bakifrån och fram").
Det första steget i paintComponent ()
blir därför att
iterera över de former som finns i listan och för var och en av
dem anropa draw()
, så formen själv kan rita ut sig.
Att göra 3.9.2: Rita ut former
-
Just nu har varje
Shape
-klass endraw()
-metod, men den var ju bara en platshållare som just nu skriver ut lite text. Den metoden måste göras om för att rita med hjälp av ettGraphics
-objekt. Det betyder i sin tur att metoden måste skrivas om för att få ettGraphics
-objekt som parameter.Även här kan IDEA hjälpa till. Ställ markören i ordet
draw
och tryck Ctrl-F6: Refactor | Change Signature. Tryck det gröna plusset och fyll in typeGraphics
, nameg
. Det talar om att metoden ska få en ny parameter, både i Shape och i alla tre konkreta implementationer. Klicka sedan i "use any var", vilket gör att IDEA också lägger till en parameter till anropen tilldraw()
om det finns enGraphics
-variabel tillgänglig där anropet sker. Tryck sedan Refactor.Denna refactoring kan även användas för att byta ordning på parametrar i metoddeklaration och alla metodanrop.
-
Nu undrar vi om det här införde några problem. Tryck Ctrl-F9 för att kompilera. Jodå:
TestShapes
fungerar inte längre, eftersom den anropardraw()
utan parametrar. Vi kommer snart att testa detta i vår nya grafiska klass istället, så vi kommenterar bort anropet och kompilerar om igen. -
Ändra implementationerna av
draw()
i alla tre konkreta formklasser. Kommentera bort utskrifterna och använd istället följande kodrader som ledning till hur man kan rita ut olika typer av former eller text:g.setColor(color); g.drawOval(x, y, width, height); // calc. from radius! g.drawRect(x, y, width, height); g.setFont(new Font("serif", Font.PLAIN, size)); g.drawString(text, x, y);
-
Nu behöver vi till slut ett fönster som kan visa upp diagramkomponenten. Vi diskuterar hur detta fungerar på GUI-föreläsningarna. För tillfället nöjer vi oss med att basera koden på denna exempelkod för klassen
DiagramViewer
. Den slumpar fram ett antal former som visas på skärmen. Fungerar den som den är, eller behöver den justeras?package shapes; import javax.swing.*; import java.awt.*; import java.util.List; import java.util.Random; public class DiagramViewer { private final static List<Color> COLORS = List.of(Color.BLACK, Color.RED, Color.GREEN, Color.BLUE, Color.CYAN, Color.YELLOW, Color.MAGENTA); // Set a fixed seed 0 so you always get the same // shapes (for debugging) private final static Random rnd = new Random(0); private static Color getRandomColor() { return COLORS.get(rnd.nextInt(COLORS.size())); } private static Circle getRandomCircle() { return new Circle(rnd.nextInt(400), rnd.nextInt(400), rnd.nextInt(200), getRandomColor()); } private static Rectangle getRandomRectangle() { return new Rectangle(rnd.nextInt(400), rnd.nextInt(400), rnd.nextInt(200), rnd.nextInt(200), getRandomColor()); } private static Text getRandomText() { return new Text(rnd.nextInt(400), rnd.nextInt(400), "Hello"); } public static void main(String[] args) { DiagramComponent comp = new DiagramComponent(); final Random rnd = new Random(0); for (int i = 0; i < 10; i++) { switch (rnd.nextInt(3)) { case 0: comp.addShape(getRandomCircle()); break; case 1: comp.addShape(getRandomRectangle()); break; case 2: comp.addShape(getRandomCircle()); break; } } JFrame frame = new JFrame("Mitt fönster"); frame.setLayout(new BorderLayout()); frame.add(comp, BorderLayout.CENTER); frame.setSize(800, 600); frame.setVisible(true); } }
Gör klart klassen, starta, och se ditt nya slumpmässiga konstverk!
Avslutning
Här slutar tredje laborationen. Det är dags att visa och demonstrera slutresultatet för din handledare – det krävs för att få godkänt!
Du behöver inte skicka in din kod just nu, utan handledaren tittar på det viktigaste vid demonstrationen. Det du har skrivit kommer däremot att följa med i en senare inlämning, och då kan handledaren göra en övergripande genomgång av allt du har gjort.
Passa gärna på att fråga om det är något du undrar över, och be om återkoppling på det du har skrivit!
Fortsätt direkt med nästa labb om handledaren är upptagen.
Labb av Jonas Kvarnström, Mikael Nilsson 2014–2020.
Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2020-01-08