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...
För att kunna utforska arv på ett bra sätt tar vi nu en liten paus från Tetris, men vi återkommer dit i labb 4.
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.
Uppgift: 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.
-
Skapa paketet
se.liu.dinadress.shapes
för den här labben. Lägg även detta paket "bredvid"lab1
och övriga paket (högerklicka mappen motsvarande ditt liuid i IDEA, och välj New / Package). -
Skapa klassen
Circle
i paketetshapes
. -
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 qualified name 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.
Uppgift: 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
CircleTest
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, men också när man upptäcker att ett uttryck börjar bli långt och att man vill bryta ut en del av uttrycket till en egen variabel för bättre läsbarhet.
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.VSCode: Extract to local variable
-
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.
Klassdiagram för cirkelklasserna
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 CircleTest
skapar
instanser av klassen Circle
. Vi kommer att se fler UML-diagram senare.
Just detta diagram
är skapat
i IDEA Ultimate Edition genom att skapa ett diagram,
lägga till Circle
och CircleTest
, och välja Show
Dependenies. IDEA detekterar då att den ena klassen
skapar instanser av den andra. I kursen använder vi
som standard IDEA Community Edition, som inte har den här
funktionen.
Uppgift 3.2: Defensiv programmering
Att 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.
Uppgift: Kontrollera parametrar
-
Hoppa från
CircleTest
tillCircle
genom att använda Navigate | Class: Ctrl-N, filtrera genom att skriva "Cir", och trycka Enter på rätt klass. Detta är en mycket användbar genväg.VSCode: Ctrl-T ska hoppa till godtycklig symbol (klassnamn, metodnamn och så vidare). Ctrl-P ska söka efter ett filnamn istället, vilket ofta matchar ett klassnamn i Java. Se också Navigate and edit Java source code.
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, eller ctrl-klickaCircle
. Detta fungerar för alla klasser, metoder och fält, inklusive de från "språkets egna" klasser (t.ex.System
).VSCode: F12 ska hoppa till definitionen.
-
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 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.)
Uppgift: Kapsla in information
-
Ställ markören i klassen
Circle
– inte bara i filenCircle.java
, utan inuticlass Circle { ...här... }
.Tryck shift två gånger och skriv Encaps. Välj sedan "Encapsulate Fields..." (plural).
Dubbel-skift är "search everywhere" och låter dig söka bland alla menyer och i princip alla inställningar, liksom alla klasser, metoder, fält med mera. Detta är samma som att välja Refactor | Encapsulate Fields men kan vara trevligare för den som är mer tangentbordsbunden.
Bocka för alla fält samt avmarkera Set Access (vi vill inte skapa setters) och avmarkera även Use accessors even when field is accessible. Tryck Refactor.
VSCode: Denna refactoring verkar inte finnas. Här kan det vara bättre att tillfälligt gå över till IDEA. Uppgiften finns ändå kvar eftersom vi vill lära ut användbara refactorings.
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 CircleTest
.
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
Om 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.
Uppgift: Skapa gränssnittet Shape
-
Skapa gränssnittet
Shape
i paketetshapes
. Som förut högerklickar vi på paketet och väljer New | Java Class, men nu väljer vi Interface i drop-down-menyn i dialogen. -
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.
Ett klassdiagram för Shape
Nu kan vi även illustrera att Circle
realiserar (implementerar) gränssnittet Shape
.
Att 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()
.
Uppgift: Att rita ut en Shape
-
Lägg till en public metod som heter
draw()
iShape
. 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" ({...}
) och bara avslutas med semikolon. -
Vi ser att
draw()
blir markerad med en varning. 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 "public class Circle 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.VSCode: Override/implement methods
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.VSCode: Generate toString()
Att införa 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.
Uppgift: Inför 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
.VSCode: Denna refactoring verkar inte finnas. Här kan det vara bättre att tillfälligt gå över till IDEA. Uppgiften finns ändå kvar eftersom vi vill lära ut användbara refactorings.
Nu har vi deklarerat att alla Shape
-klasser måste ha dessa metoder.
Uppgift: Testa gränssnitt och klass
Vi gör nu ett första test av gränssnittet och klassen tillsammans. Detta gör vi i en ny testklass.
-
Gå till
CircleTest
. Klona klassen med hjälp av F5. Kalla den nya klassenShapeTest
. På det sättet får ni med strukturen i det gamla testprogrammet.VSCode: Oklart om denna funktionalitet finns. Man kan annars utföra detta i IDEA eller manuellt skapa en ny klass med samma struktur utan hjälp av kloning.
-
I
ShapeTest.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.
VSCode: Rename
-
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
ShapeTest
och verifiera att du får ut rätt utskrifter. Även det gamla testprogrammetCircleTest
ska fortfarande fungera!
Om klassdiagram
Nu vill vi illustrera att det finns två olika klasser som skapar cirklar.
Uppgift 3.4: Implementera ett gränssnitt
Uppgift: Skapa klassen 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.
Uppgift: Skapa klassen 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.
Om 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.
Om 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.
Uppgift: Skapa klassen 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.VSCode: Denna refactoring verkar inte finnas. Här kan det vara bättre att tillfälligt gå över till IDEA. Uppgiften finns ändå kvar eftersom vi vill lära ut användbara refactorings.
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.
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.
Om 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):
Att implementera kö och stack – sammansättning
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: Skapa klasser för kö och stack
-
Skapa klassen
Queue
.Du kan gärna lägga den i ett nytt paket,
lab3
, eftersom den inte hör ihop medshapes
. -
Skapa ett privat fält
List<Person> elements
och initialisera detta till en nyArrayList<>
. 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 | Generate | Delegate Methods och sedan välja
elements
kan man därefter välja precis vilka metoder man vill delegera.VSCode: Generate delegate methods
I moderna versioner av Java finns relativt många metoder man skulle kunna delegera. Du behöver främst delegera
size(), isEmpty(), contains(), iterator(), add(), remove(), clear()
. Om du delegerar andra metoder och får problem/varningar i deklarationer som<E>
och<T>
, radera då helt enkelt de metoderna från den kod som skapades.Delegera inte metoderna
equals()
ellerhashCode()
. -
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
Om equals()
i Java
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");
Om hashCode()
i Java
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
.
Uppgift: Implementera 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).VSCode: Generate hashcode and equals
-
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.8: 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.
Uppgift: Skapa 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.
Om att 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.
Uppgift: 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.
VSCode: Denna refactoring verkar inte finnas. Här kan det vara bättre att tillfälligt gå över till IDEA. Uppgiften finns ändå kvar eftersom vi vill lära ut användbara refactorings.
-
Nu undrar vi om det här införde några problem. Tryck Ctrl-F9 i IDEA för att kompilera. Jodå:
ShapeTest
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(getRandomText()); 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:
-
Gå genom kodinspektionen som du får via issues i Gitlab, och polera implementationen av hela labb 1-3 inför demonstration och inlämning. Har du inte tittat på den tidigare kan du öppna ditt projekt i Gitlab via den projekt-URL som du fick när projektet skapades, och gå till Plan | Issues:
Här klickar du på det senaste ärendet (issue) och laddar ner den HTML-fil som är bifogad till ärendet. Öppna den och läs!
Kravet är inte nödvändigvis att exakt alla varningar ska åtgärdas. Dessutom kan ju kodinspektionen ha fel – den försöker så gott den kan men kan ge falska varningar för kod som faktiskt redan är korrekt. Men den är ändå ett bra sätt att få snabb återkoppling på labbarna och att se saker man inte hade tänkt på, för att minska risken för komplettering.
Titta genom översikten och gå genom koden för att se var olika varningar har uppstått. Varningarna ger också en möjlighet att lära sig mer under tiden man programmerar, istället för att vi räknar upp alla tänkbara detaljer på en föreläsning. Därför kan kodinspektionen be att du ska ändra på något som inte har diskuterats på annan plats.
När du har uppdaterat din kod checkar du in och pushar till Gitlab igen, så ska en ny issue med ny kodanalys komma inom kort.
-
Visa och demonstrera slutresultatet för labb 3 för din handledare – det krävs för att få godkänt! Vid demonstrationen ska du redan ha gått genom kodinspektionen. (Om handledaren är upptagen kan du gå vidare med nästa labb under tiden.)
-
Lämna in koden enligt inlämningsinstruktionerna.
Labb av Jonas Kvarnström, Mikael Nilsson 2014–2024.
Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2024-02-08