Göm menyn

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.

  1. 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).

  2. Skapa klassen Circle i paketet shapes.

  3. Lägg till de publika (public!) int-fälten x, y och radius.

  4. Lägg till ett publikt fält color av typen java.awt.Color.

    Abstract Window Toolkit (AWT) är Javas ursprungliga grafiksystem. Anledningen till att vi skriver hela java.awt.Color är att det finns många Color-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 i java.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).

  5. 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.

  1. Skapa klassen CircleTest och en main()-metod i den.

  2. I main() ska vi skapa en lista av Circle. Skriv new ArrayList<Circle>(), låt IDEA importera ArrayList, 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 i if-satsen med variabeln.

    VSCode: Extract to local variable

  3. Skapa ett par cirklar och lägg dem i listan (med listmetoden add()).

  4. Använd live-template iter (iterate collection) för att skapa en for-loop som går igenom varje Circle 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.

  5. 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

  1. Hoppa från CircleTest till Circle 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 i Circle och trycka Ctrl-B för att snabbt hoppa till definitionen, eller ctrl-klicka Circle. 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.

  2. 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

  1. Ställ markören i klassen Circle – inte bara i filen Circle.java, utan inuti class 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

  1. Skapa gränssnittet Shape i paketet shapes. 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.

  2. Nu ska vi se till att Circle implementerar Shape. På class-raden lägger vi därför till implements 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

  1. Lägg till en public metod som heter draw() i Shape. 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.

  2. Vi ser att draw() blir markerad med en varning. Det finns nämligen klasser som säger sig implementera Shape men som saknar denna metod! Motsvarande varning kan ses när man går till Circle: 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ör Shape. Tryck Alt+Insert och välj Implement Methods och sedan draw(). 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.)

  3. 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+Insert toString()) 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

  1. Alternativ 1 av 2: Lägg manuellt till metoddeklarationer för getX(), getY() och getColor() i Shape. Se till att de har samma returvärden och parametrar som i Circle, men saknar metodkropp. Vi kan ju inte implementera en fullständig getX() här uppe i Shape, eftersom gränssnittet själv inte har något x att returnera!

  2. Alternativ 2: Gå till Circle. Använd Refactor | Pull Members Up. Se till att Shape är vald i den översta väljaren. Markera getX(), getY() och getColor() 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 till Shape, om man till exempel ansåg att implementationen skulle vara samma för alla underklasser till Shape. Men nu är Shape 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 i Shape 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 i Circle.

    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.

  1. Gå till CircleTest. Klona klassen med hjälp av F5. Kalla den nya klassen ShapeTest. 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.

  2. I ShapeTest.main() ska vi inte skapa en lista av Circle, utan en lista av Shape 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

  3. 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().

  4. Kör ShapeTest och verifiera att du får ut rätt utskrifter. Även det gamla testprogrammet CircleTest 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.

  1. Skapa klassen Rectangle med de privata fälten x, y, width, height och color. Typerna vet ni sedan tidigare.

    Ge klassen en lämplig konstruktor.

    Skapa en toString()-metod.

  2. Implementera gränssnittet Shape och de metoder som krävs i detta gränssnitt.

  3. 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.

  1. Skapa klassen Text med de privata fälten x, y, size (storleken i punkter), color och text (en String) samt en lämplig konstruktor och toString()-metod.

  2. Implementera gränssnittet Shape och de metoder som ingår i gränssnittet.

  3. 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.

Överkurs
Java 8 och senare tillåter faktiskt vissa metoder att implementeras i ett gränssnitt/interface. Detta är tänkt att användas i specialfall när man behöver lägga till funktionalitet utan att kunna påverka de implementerande klasserna, inte som ett generellt programmeringsverktyg. Det leder också till mer komplicerade regler för overriding / prioritering av metoder. Av båda anledningarna undviker vi detta i den här grundkursen.

Uppgift: Skapa klassen AbstractShape

  1. Skapa en ny "tom" klass public abstract class AbstractShape. Se till att den implementerar Shape – alla subklasser till AbstractShape ska ju vara Shapes.

  2. Byt ut implements Shape mot extends AbstractShape i klasserna Circle, Rectangle och Text. Detta innebär att klasserna blir AbstractShape, men de kommer fortfarande också att vara Shapes eftersom detta gränssnitt implementeras av AbstractShape.

  3. 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 till AbstractShape, inte Shape. Markera fälten x,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 eller getRadius(), eftersom bara cirklar har radier. Inte heller draw() eller toString(): 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.

    1. De valda fälten blir protected i AbstractShape istället för private, så att subklasser fortfarande kan komma åt dem.

    2. IDEA skapar också automatiskt en konstruktor för AbstractShape.

    3. Eftersom fälten nu är flyttade är det upp till AbstractShape att hantera initialisering av dem. Vissa tilldelningar i konstruktorn för Circle ersätts därför med anropet super(x,y,color), som vidarebefordrar parametrarna från Circle upp till AbstractShape:s konstruktor. (Ett anrop till super() ligger alltid först i en konstruktor.)

  4. Tyvärr vet inte IDEA att den ska göra motsvarande ändringar i de andra klasserna.

    Navigera till Rectangle. Ta bort fälten x/y/color, ta bort motsvarande getters, och ta bort initialiseringen ur konstruktorn.

    Lägg sedan till super()-anrop motsvarande det i Circle-konstruktorn. Ta bort de tre getter-implementationer som nu blev onödiga eftersom samma kod redan ärvs ner från AbstractShape.

    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ån AbstractShape

  • I OtherShape kan vi välja att implementera hela Shape själva, kanske för att klassen redan ärver från OtherBase 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 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

  1. Skapa klassen Queue.

    Du kan gärna lägga den i ett nytt paket, lab3, eftersom den inte hör ihop med shapes.

  2. Skapa ett privat fält List<Person> elements och initialisera detta till en ny ArrayList<>. Eftersom varje Queue har sina egna element får detta fält inte vara statiskt.

  3. Det finns metoder i ArrayList som vi vill göra tillgängliga för användaren av Queue. size() är en sådan metod. Eftersom elements håller koll på antalet element den innehåller, skriver vi helt enkelt bara en size()-metod i Queue som returnerar resultatet av elements.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 metoderna isEmpty(), clear() och contains() på precis samma sätt, genom att ha en metod som direkt anropar och returnerar svaret från motsvarande metod i elements.

    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() eller hashCode().

  4. 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() och elements.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 specifikt Queue-objekt), inte i kö-klassen i sin helhet. Metoderna du skriver här ska alltså inte vara statiska.

  5. Nu är Queue färdig och det är dags att skapa Stack. 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 namnet Stack så skapas en ny klass som ser precis ut som Queue. Byt namn på enqueue/dequeue till push/pop och ändra koden i dem så att Stack beter sig som en stack.

  6. 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 en while-loop som itererar så länge stacken inte är tom.

    Gör även samma sak med en kö.

  7. När vi nu tar en sista titt på vad vi har gjort upptäcker vi ett par saker:

    • Stack och Queue 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.

Överkurs
Javas 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.

Överkurs
Att implementera hashCode() i Java motsvaras av att implementera __hash__() i Python.
Överkurs
Tanken med hashkoder, som är heltal, är att:
  • 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 hink 78 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 miljarder int.

Uppgift: Implementera equals() och hashCode()

  1. Skapa equals()-metoder i AbstractShape. Använd IDEA som hjälp via Code | Generate | equals() and hashCode(): Alt+Insert. Vi är egentligen bara intresserade av equals(), 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 i hashCode().

    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 vara null. Detta är för att inte generera onödiga null-jämförelser som kan ta någon extra nanosekund. Om du är osäker så ange att fälten kan vara null (detta kan vara något mindre effektivt men ger alltid rätt resultat).

    VSCode: Generate hashcode and equals

  2. 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

  1. 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!
        }
    }
  2. 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

  1. Just nu har varje Shape-klass en draw()-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 ett Graphics-objekt. Det betyder i sin tur att metoden måste skrivas om för att ett Graphics-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 type Graphics, name g. 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 till draw() om det finns en Graphics-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.

  2. 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 anropar draw() 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.

  3. Ä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);
  4. 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:

  1. 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.

  2. 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.)

  3. Lämna in koden enligt inlämningsinstruktionerna.

I timeline kan du se vid vilka datum du senast kan demonstrera och lämna in koden för labb 1-3 för att garanterat hinna få en omgång kommentarer innan din slutliga inlämning (där du också lämnar in labb 4 och kanske labb 5). Det ökar chansen att du kan få godkänt på hela labbserien redan vid första inlämningen.
Om du lämnar in labb 3 efter ovanstående deadline kan det hända att du får kommentarer på hela labbserien på en gång, efter hela labbseriens deadline.

Labb av Jonas Kvarnström, Mikael Nilsson 2014–2024.


Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2024-02-08