javabog.dk  |  << forrige  |  indhold  |  næste >>  |  programeksempler  |  om bogen

5 Nedarvning

Indhold:

Kapitlet forudsættes i resten af bogen.

Forudsætter kapitel Fejl: Henvisningskilde ikke fundet, Fejl: Henvisningskilde ikke fundet.

I dette kapitel vil vi se på, hvordan man kan genbruge programkode, ved at tage en eksisterende klasse og udbygge den med flere metoder og variabler (nedarvning).

5.1 At udbygge eksisterende klasser

Hvad gør man, hvis man ønsker en klasse, der ligner en eksisterende klasse, men alligevel ikke helt er den samme?

Svaret er: Man kan definere underklasser, der arver (genbruger en del af koden) fra en anden klasse og kun definerer den ekstra kode, der skal til for at definere underklassen i forhold til stamklassen (kaldet superklassen).

Arv (eng.: inheritance) er et meget vigtigt element i objektorienterede sprog. Med nedarvning kan man have en hel samling af klasser, der ligner hinanden på visse punkter, men som er forskellige på andre punkter.

5.1.1 Eksempel: En falsk terning

Hvis man vil snyde i terningspil, findes der et kendt trick: Bruge sine egne terninger, hvor man har boret 1'er-sidens hul ud, kommet bly i hullet og malet det pænt over, så det ikke kan ses. Sådan en terning vil have meget lille sandsynlighed for at få en 1-er og en ret stor sandsynlighed for at få en 6'er.

Herunder har vi lavet en nedarvning fra Terning (se afsnit Fejl: Henvisningskilde ikke fundet) til en ny klasse, FalskTerning, ved at starte erklæringen med:

public class FalskTerning extends Terning

Vi har dermed automatisk overtaget (arvet) alle metoder og variabler fra Terning-klassen. Dvs. at et FalskTerning1-objekt også har en værdi-variabel og en toString()-metode, nogenlunde ligesom hvis vi havde kopieret dem ind i den nye klasse.

Vi ændrer nu klassens opførsel, ved at definere en anden udgave af kast()-metoden:

/
  




FalskTerning1 overtager
egenskaber (arver) fra
Terning, men definerer
sin egen udgave af kast().

** En Terning-klasse for falske terninger. */
public class FalskTerning1 extends Terning
{
  /** tilsidesæt kast med en "bedre" udgave */
  @Override
  public void kast()
  {
    // udskriv så vi kan se at metoden bliver kaldt
    // System.out.println("[kast() på FalskTerning1] ");

    int værdi = (int) (6*Math.random() + 1);
    // er det 1 eller 2? Så lav det om til 6!
    if ( værdi <= 2 ) værdi = 6;

    setVærdi(værdi); // sæt værdien på terningen 
  }
}

Symbolet @Override er en annotation, en anmærkning til compileren om at kast() omdefinerer en metode, der allerede eksistere i superklassen.

I klassediagrammet til højre er nedarvningen vist med en hul pil fra FalskTerning1 til Terning. Dette kaldes også en er-en-relation; FalskTerning1 er en Terning, da den jo har alle de egenskaber (variabler og metoder), en terning har.

Kort sagt:

En klasse kan arve variabler og metoder fra en anden klasse
Klassen, der nedarves fra, kaldes superklassen
Klassen, der arver fra superklassen, kaldes underklassen
Underklassen kan tilsidesætte (omdefinere) metoder arvet fra superklassen ved at definere dem igen

Andre steder i litteraturen er der brugt talrige betegnelser for superklasse, underklasse og tilsidesættelse. Her er et udpluk:

Superklasse kaldes også: Basisklasse, forældreklasse, stamklasse.

Underklasse kaldes også: Afledt klasse, nedarvet klasse, subklasse.

Tilsidesætte (eng.: override) kaldes også: omdefinere, overskrive.

I vores eksempel er superklassen Terning. Underklassen FalskTerning1 har tilsidesat metoden kast().

I det følgende program kastes med to terninger, en rigtig og en falsk:

public class Snydespil1
{
  public static void main(String[] arg)
  {
    Terning t1 = new Terning();
    FalskTerning1 t2 = new FalskTerning1();

    System.out.println("t1: "+t1); // kunne også kalde t1.toString()
    System.out.println("t2: "+t2);

    for (int i=0; i<5; i++)
    {
      t1.kast();
      t2.kast();
      System.out.println("t1=" + t1 + "  t2=" + t2);
      if (t1.getVærdi() == t2.getVærdi()) System.out.println("To ens!");
    }
  }
}

t1: 1
t2: 3
t1=1  t2=5
t1=1  t2=6
t1=4  t2=3
t1=6  t2=6
To ens!
t1=2  t2=6

Vi kan altså bruge FalskTerning1-objekter på præcis samme måde som Terning-objekter. Bemærk, hvordan t2 giver 6 meget oftere end t1.

5.1.2 At udbygge med flere metoder og variabler

Lad os nu se på et eksempel, hvor vi definerer nogle variabler og metoder i nedarvningen.


public class FalskTerning2 extends Terning
{
  public int snydeværdi;

  public void sætSnydeværdi(int nySnydeværdi)
  {
    snydeværdi = nySnydeværdi;
  }

  public void kast()
  {
    //System.out.println("[kast() på FalskTerning2] ");

    int værdi = (int) (6*Math.random() + 1);

    // 1 eller 2? Så lav det om til snydeværdi!
    if ( værdi <= 2 ) værdi = snydeværdi;

    setVærdi(værdi); // sæt værdien på terningen 
  }
}

FalskTerning2 har fået en ekstra variabel, snydeværdi, og en ekstra metode, sætSnydeværdi()1, der sætter snydeværdi til noget andet.

public class Snydespil2
{
  public static void main(String[] arg)
  {
    FalskTerning2 t1 = new FalskTerning2();
    t1.sætSnydeværdi(4);

    for (int i=0; i<3; i++)
    {
      t1.kast();
      System.out.println("t1=" + t1);
    }
  }
}

t1=4
t1=4
t1=6

5.1.3 Nøgleordet super

Nogen gange ønsker man i en nedarvet klasse, at få adgang til superklassens metoder, selvom de måske er blevet tilsidesat med en ny definition i nedarvningen. F.eks. kunne det være rart, hvis vi kunne genbruge den oprindelige kast()-metode i FalskTerning.

Med super refererer man til de metoder, der er kendt for superklassen. Dermed kan vi skrive en smartere udgave af FalskTerning:

public class FalskTerning3 extends Terning
{
  public void kast ()
  {
    super.kast(); // kald den oprindelige kast-metode

    // blev det 1 eller 2? Så lav det om til en 6'er!
    if ( getVærdi() <= 2 ) setVærdi(6);
  }
}

super.kast() kalder kast()-metoden i superklassen. Derefter tager vi højde for, at det er en falsk terning.

5.1.4 Opgaver

  1. Lav en LudoTerning, der arver fra Terning. Tilsidesæt toString() med en, der giver "*" på en 3er og "globus" på en 4er (vink: kopiér Terning's toString()-metode over i LudoTerning og ret i den). Afprøv klassen.

  2. Byg videre på opgave Fejl: Henvisningskilde ikke fundet og opret klassen Transportmiddel. Et transportmiddel har en farve, et navn, et antal tilbagelagte kilometer og en nypris. Definér metoderne
    public void bevæg(int antalKilometer) // opdaterer antal kilometre
    public double pris()
    // giver den vurderede salgspris
    public String toString()
    // giver en beskrivelse af transportmidlet
    Opret nedarvningerne Cykel, Skib og Bil med hver sin pris()-metode.

  3. Forestil dig en virksomhed, hvor der er forskellige slags personer: Ansatte og kunder. De ansatte er delt op i medarbejdere og ledere. Skitsér et passende klassediagram.

  4. Lav klasserne til et skak-spil: Definér superklassen Brik med egenskaben farve (sort eller hvid), position x og position y (hver mellem 1 og 8). Definér også metoden
    public boolean kanFlytteTil(int xNy, int yNy) // om brikken kan flytte dertil
    der (for Brik) returnerer sand, hvis positionen eksisterer (xNy og yNy er mellem 1 og 8).
    Definér nedarvningerne Bonde og Taarn med tilsidesat kanFlytteTil().

  5. Lav et system til at arbejde med forskellige geometriske figurer.
    Opret klassen Figur med metoderne beregnAreal() og beregnOmkreds().
    Lav nedarvningerne Punkt, Linje (med variablen længde), Cirkel (med variabel radius), Rektangel (med variablerne højde og bredde).

5.2 Polymorfe referencer


Snydespil2MedPolymorfi
efter punkt A

Se på følgende eksempel:

public class Snydespil2medPolymorfi
{
  public static void main(String[] arg)
  {
    FalskTerning2 ft = new FalskTerning2();
    ft.sætSnydeværdi(4);

    Terning t;
    t = ft;
                                       // punkt A
    for (int i=0; i<3; i++)
    {
      t.kast();
      System.out.println("t=" + t);
    }
  }
}

t=4
t=6
t=6

Hov: Terning-variablen t refererer nu pludselig til et FalskTerning2-objekt ?!

    t = ft;

Der er altså ikke overensstemmelse mellem typen på venstre side (Terning) og typen på højre side (FalskTerning2). Hvad så med typesikkerheden?

5.2.1 Dispensation fra traditionel typesikkerhed

Typesikkerhed gør, at man ikke f.eks. kan tildele et Point-objekt til en Terning-variabel uden at få en fejl under oversættelsen.

Hvis man kunne det, ville programmerne kunne indeholde mange fejl, der var svære at finde. Hvis man f.eks. et eller andet sted i et kæmpeprogram havde sat en Terning-variabel til at referere til et Point-objekt, og det var tilladt, hvad skulle der så ske, når man så (måske langt senere i en anden del af programmet) forsøgte at kalde dette objekts kast()-metode? Et Point-objekt har jo ingen kast()-metode. Det kunne blive meget svært at finde ud af, hvor den forkerte tildeling fandt sted. Sagt med andre ord: Normalt skal vi være lykkelige for, at Java har denne regel om typesikkerhed.

Der er imidlertid en meget fornuftig dispensation fra denne regel:

En variabel kan referere til objekter af en underklasse af variablens type

t-variablen har ikke ændret type (det kan variabler ikke), men den peger nu på et objekt af typen FalskTerning2. Dette objekt har jo alle metoder og data, som et Terning-objekt har, så vi kan ikke få kaldt ikke-eksisterende metoder, hvis vi bare "lader som om", den peger på et Terning-objekt. At FalskTerning2-objektet også har et ekstra felt, snydeværdi, og en ekstra metode, kan vi være ligeglade med, de kan bare ikke ses fra referencevariablen t.

Dispensationen giver altså mening, fordi en nedarvning (f.eks. et FalskTerning2-objekt) set udefra kan lade, som om det også er af superklassens type (et Terning-objekt). Udefra har det jo mindst de samme felter og metoder, da det har arvet dem.

Selvom variablen t af typen Terning refererer til et FalskTerning2-objekt, kan man kun bruge den til at anvende metoder/variabler i objektet, som stammer fra Terning-klassen:

  t.snydeværdi = 4;  // sprogfejl: snydeværdi er ikke defineret i Terning
  t.sætSnydeværdi(4);// sprogfejl: sætSnydeværdi() er ikke defineret i Terning

5.2.2 Polymorfi

En væsentlig detalje omkring denne dispensation er, at det er objektets type, ikke variablens, der bestemmer, hvilken metodekrop der bliver udført, når vi kalder en metode:

  t.kast();  // kalder FalskTerning2's kast, 
            // fordi t peger på et FalskTerning2-objekt.

Herover kalder vi altså den kast()-metode, der findes i FalskTerning2-klassen. Den kigger således ikke på variablen t's type (så ville den jo bare udføre kast()-metoden i Terning).

Variablens type bestemmer, hvilke metoder der kan kaldes
Objektets type bestemmer, hvilken metodekrop der bliver udført

Af samme grund kaldes det at definere en metode, som allerede findes, fordi den er arvet, for tilsidesættelse af metoden. Man tilsidesætter metodens opførsel med en anden opførsel (en anden metodekrop).

5.2.3 Eksempel på polymorfi: Brug af Raflebaeger

Da FalskTerning2-objekter også er af type Terning, kan de bruges i et Raflebaeger:

public class SnydeMedBaeger
{
  public static void main(String[] arg)
  {
    Raflebaeger bæger = new Raflebaeger(0); // opret et bæger med nul terninger

    Terning t = new Terning();
    bæger.tilføjTerning(t);   // føj en almindelig terning til bægeret

    FalskTerning2 ft = new FalskTerning2();
    ft.sætSnydeværdi(6);
    bæger.tilføjTerning(ft); // parameteren er et objekt af typen Terning,
                             // og dermed også af typen FalskTerning2.

    ft = new FalskTerning2();
    ft.snydeværdi=6;
    t=ft;               // t bruges som mellemvariabel for sjov.
    bæger.tilføjTerning(t);

    for (int i=1; i<10; i++)
    {
      bæger.ryst();
    }
  }
}

I SnydeMedBaeger kaldes Raflebaeger's ryst()-metode. Hvis du nu kigger i definitionen af dennes ryst()-metode (se afsnit Fejl: Henvisningskilde ikke fundet), kan du se, at den kalder kast()-metoden på de enkelte objekter i "terninger"-listen:

  public void ryst()
  {
    for (Terning t : terninger) 
    {
      t.kast();
    }
  }

Da to af objekterne, vi har lagt ind i bægeret, er af typen FalskTerning2, vil Raflebaeger's ryst()-metode, når den kommer til et objekt af denne type, kalde FalskTerning2's kast() helt automatisk. Resultatet er altså, at vi får større sandsynlighed for at få seksere.

Faktisk har vi ændret den måde, et Raflebaeger-objekt opfører sig på, helt uden at ændre i Raflebaeger-klassen! Raflebaeger ved ikke noget om FalskTerning2, men kan alligevel bruge den.

En programmør kan altså lave en Raflebaeger-klasse, som kan alt muligt smart: Kaste terninger, se hvor mange ens der er, tælle summen af øjnene, se om der er en stigende følge (eng.: straight) osv.

Når en anden programmør vil lave en ny slags terning (f.eks. en snydeterning), behøver han ikke sætte sig ind i, hvordan Raflebaeger-klassen virker og lave tilpasninger af den, for den kan automatisk bruge hans egen nye slags terning!

5.2.4 Hvilken vej er en variabel polymorf ?

Når følgende er muligt:

    Terning t;
    FalskTerning2 ft;

    ft = new FalskTerning2();
    t = ft;

Hvad så med det omvendte? Kan man tildele en FalskTerning2-variabel en reference til et objekt af typen Terning?

Svaret er: Nej!

D


Efter punkt A
(programmet vil ikke oversætte)

et er jo typen af ft (FalskTerning2), der bestemmer, hvilke metoder og variabler vi kan bruge med ft. Dvs. vi ville kunne skrive:

    Terning t;
    FalskTerning2 ft;

    t = new Terning();
    ft = t;                  // sprogfejl
    ft.snydeværdi = 2;

Hvis den sidste sætning kunne udføres, ville det være uheldigt: Terning-objektet som ft refererer til, har jo ingen snydeværdi!

Det er altså et brud på typesikkerhedsreglen og Java tillader det derfor ikke. Man må ikke kunne kalde noget, der ikke findes på objektet.

Bemærk, at her, som i andre sammenhænge, kigger Java kun på en linje ad gangen. F.eks. giver nedenstående stadig en sprogfejl, selvom det i princippet kunne lade sig gøre:

    Terning t;
    FalskTerning2 ft;

    t = new FalskTerning2();
    ft = t;                  // sprogfejl
    ft.snydeværdi = 2;

Her refererer ft i sidste linje til et rigtigt FalskTerning2-objekt, og den sidste linje ville derfor give mening, men programmet kan ikke oversættes, fordi typesikkerhedsreglen med dispensation ikke er opfyldt.

Et andet eksempel

Forestiller vi os den generelle klasse Dyr, med nedarvninger Hest og Hund, kan man skrive

    Dyr d = new Hest();

da en Hest er-et Dyr. Men vi kan ikke skrive

    Hest h = new Dyr();

da Dyr er en generel klasse, der kunne være et hvilket som helst slags dyr (herunder f.eks. også en hund). Den kan vi ikke lægge ind i en Hest-variabel.

5.2.5 Reference-typekonvertering

Dispensationen i typesikkerhedsreglen svarer til den implicitte værditypekonvertering for simple typer: Ved konvertering fra int til double behøver programmøren ikke angive eksplicit, at denne værdi skal forsøges konverteret. Når en typekonvertering med garanti giver det ønskede, laver Java den implicit.

I foregående eksempel så vi noget, der burde gå godt, men hvor Javas typeregel forhindrer oversættelse. Her er vi nødt til at bruge eksplicit reference-typekonvertering:


Efter punkt A

  Terning t;
  FalskTerning2 ft;

  t = new FalskTerning2();
  ft = (FalskTerning2) t; // OK, men muligvis
                          // køretidsfejl

                          // punkt A
  ft.snydeværdi = 2;

Det ligner en almindelig eksplicit værditypekonvertering (eng.: cast) og Javas betegnelse for det er også det samme.

Hvis reference-typekonverteringen går galt (det opdages først under programudførelsen), kommer der en køretidsfejl (ClassCastException opstår) og programmet stopper.

Der er dog nogle tilfælde, hvor Java, selv når man har lavet en reference-typekonvertering, kan opdage en uheldig konvertering. Hvis ingen af de to klasser ikke arver fra hinanden, får man en compilerfejl under oversættelsen:

  Terning t = new Terning();
  Point p;
  p = (Point) t;         // Sprogfejl: Point og Terning er urelaterede

5.3 Eksempel: Et matador-spil

Med arv kan man skabe et hierarki af klasser, der ligner hinanden (fordi de har alle fællestrækkene fra superklassen) og samtidig kan opføre sig forskelligt (polymorfi).

Her er vist klassediagrammet fra et matadorspil. Det er en skitse; et rigtigt matadorspil ville indeholde flere detaljer.

Klassen Felt, øverst i diagrammet, indeholder fællestrækkene for alle matadorspillets felter. Alle felter har et navn (f.eks. "Hvidovrevej"), og derfor definerer vi variablen navn her.

/** Superklassen for alle matadorspillets felter */
public class Felt
{
  String navn;       // feltets navn, f.eks. "Hvidovrevej"

  /** kaldes når en spiller passerer dette felt */
  public void passeret(Spiller sp) 
  {
    sp.besked("Du passerer "+navn);
  }

  /** kaldes når en spiller lander på dette felt */
  public void landet(Spiller sp)
  {
  }
}

Alle felter skal også kunne håndtere, at spilleren lander på eller passerer feltet. Vi forestiller os, at metoderne landet() og passeret() bliver kaldt af en anden del af programmet, når en spillers brik henholdsvis lander på eller passerer feltet. I Felt-klassen er metoderne defineret til ikke at gøre noget.

Bemærk hvordan vi får overført en reference til spilleren når passeret() og landet() kaldes. Vi skal bruge Spiller-objektet til at kalde metoder på spilleren (f.eks. stille spøgsmål eller overføre penge).

Her er klassen Spiller med oplysningerne om spilleren:

/** Definition af en spiller */
public class Spiller
{
  String navn;       // spillerens navn, f.eks. "Søren"
  double konto;      // antal kroner på spillerens konto
  int feltnr;        // hvad nummer felt spilleren står på. ”Start” er nummer 0

  public Spiller(String navn, double konto)
  {
    this.navn = navn;
    this.konto = konto;
    feltnr = 0;
  }

  /** En besked til spilleren */
  public void besked(String besked)
  {
    System.out.println(navn+": "+besked);
  }

  /** Et ja/nej-spørgsmål. Svarer brugeren ja returneres true, ellers false */
  public boolean spørgsmål(String spørgsmål)
  {
    String spm = navn+": Vil du "+spørgsmål+"?";
    String svar = javax.swing.JOptionPane.showInputDialog(spm, "ja");
    System.out.println(spm+" "+svar);
    if (svar!=null && svar.equals("ja")) return true;
    else return false;
  }

  public void transaktion(double kr)
  {
    konto = konto + kr;
    System.out.println(navn+"s konto lyder nu på "+konto+" kr.");
  }

  public void betal(Spiller modtager, double kr)
  {
    System.out.println(navn+" betaler "+modtager.navn+": "+kr+" kr.");
    modtager.transaktion(kr);
    transaktion(-kr);
  }
}

Under Felt har vi klasserne Helle, Start, Rederi og Gade, der indeholder data og programkode, som er specifik for de forskellige slags felter i matadorspillet. De arver alle fra Felt og er derfor tegnet med en er-en-relation til Felt i diagrammet.

Klassen Helle er simpel; den skal lægge 15000 kr. til spillerens kassebeholdning, hvis spilleren lander på feltet. Det gøres ved at tilsidesætte den nedarvede landet()-metode med en, der overfører penge til spilleren.

/** Helle - hvis man lander her får man en gevinst */
public class Helle extends Felt
{
  double gevinst;

  public Helle (int gevinst)
  {
    navn = "Helle";                 // navn er arvet fra Felt
    this.gevinst = gevinst;
  }

  public void landet(Spiller sp)    // tilsidesæt metode i Felt
  {
    sp.besked("Du lander på helle og får overført "+gevinst);
    sp.transaktion(gevinst);        // opdater spillers konto
  }
}

I konstruktøren sætter vi feltets navn. Gevinsten ved at lande her er en parameter til konstruktøren. Metodekaldet sp.transaktion(gevinst) beder spiller-objektet om at føje gevinsten til kontoen.

Klassen Start skal overføre 5000 kr. til spilleren, der passerer eller lander på feltet. Dette gøres ved at tilsidesætte både landet() og passeret().

/** Startfeltet - når en spiller passerer dette felt, får han 5000 kr */
public class Start extends Felt
{
  double gevinst;

  public Start(double gevinst)
  {
    navn = "Start";
    this.gevinst = gevinst;
  }

  public void passeret(Spiller sp)                 // tilsidesæt metode i Felt
  {
    sp.besked("Du passerer start og modtager "+gevinst);
    sp.transaktion(gevinst);                      // kredit/debit af konto
  }

  public void landet(Spiller sp)                   // tilsidesæt metode i Felt
  {
    sp.besked("Du lander på start og modtager "+gevinst);
    sp.transaktion(gevinst);
  }
}

Nu kommer vi til felter, der kan ejes af en spiller, nemlig rederier og gader. De har en ejer-variabel, der refererer til en Spiller (og er derfor tegnet med en har-relation til klassen Spiller i diagrammet), en pris og en leje for at lande på grunden.

/** Et rederi, der kan købes af en spiller */
public class Rederi extends Felt
{
  Spiller ejer;
  double pris;
  double grundleje;

  public Rederi(String navn, double pris, double leje)
  {
    this.navn = navn;
    this.pris = pris;
    this.grundleje = leje;
  }

  public void landet(Spiller sp)
  {
    sp.besked("Du er landet på "+navn);
    if (sp==ejer)
    {                                       // spiller ejer selv grunden
      sp.besked("Det er din egen grund");
    }
    else if (ejer==null)
    {                                       // ingen ejer grunden, så køb den
      if (sp.konto > pris)
      {
        if (sp.spørgsmål("købe "+navn+" for "+pris))
        {
          sp.transaktion( -pris );
          ejer=sp;
        }
      }
      else sp.besked("Du har ikke penge nok til at købe "+navn);
    }
    else
    {                                       // feltet ejes af anden spiller
      sp.besked("Leje: "+grundleje);
      sp.betal(ejer, grundleje);            // spiller betaler til ejeren
    }
  }
}

Når en spiller lander på et rederi, skal der overføres penge fra spilleren til ejeren af grunden. Dette gøres ved at tilsidesætte den nedarvede landet()-metode med en, der overfører beløbet mellem parterne. Først tjekkes, om spilleren er den samme som ejeren (sp==ejer). Hvis dette ikke er tilfældet, tjekkes, om der ingen ejer er (ejer==null) og hvis der ikke er, kan spilleren købe grunden (ejer sættes lig spilleren). Ellers beordres spilleren til at betale et beløb til ejeren: sp.betal(ejer, grundleje).

Klassen Gade repræsenterer en byggegrund og objekter af type Gade har derfor, ud over ejer, pris og grundleje, en variabel, der husker, hvor mange huse der er bygget på dem.

Når en spiller lander på grunden, skal der ske nogenlunde det samme som for et Rederi bortset fra, at hvis det er ejeren, der lander på grunden, kan han bygge et hus.

/** En gade, der kan købes af en spiller og bebygges */
public class Gade extends Felt
{
  Spiller ejer;
  double pris;
  double grundleje;
  int antalHuse = 0;
  double huspris;

  public Gade(String navn, double pris, double leje, double huspris)
  {
    this.navn = navn;
    this.pris = pris;
    this.grundleje = leje;
    this.huspris = huspris;
  }

  public void landet(Spiller sp)
  {
    sp.besked("Du er landet på "+navn);

    if (sp==ejer)
    {                                          // eget felt
      sp.besked("Det er din egen grund");
      if (antalHuse<5 && sp.konto>huspris &&  // bemærk: kun hvis betingelserne
        sp.spørgsmål("købe hus for "+huspris))// er opfyldt stilles spørgsmålet
      {                                       // byg et hus
        ejer.transaktion( -huspris );
        antalHuse = antalHuse + 1;
      }
    }
    else if (ejer==null)
    {                                          // ingen ejer grunden, køb den?
      if (sp.konto > pris)
      {
        if (sp.spørgsmål("købe "+navn+" for "+pris))
        {
          sp.transaktion( -pris );
          ejer=sp;
        }
      }
      else sp.besked("Du har ikke penge nok til at købe "+navn);
    }
    else
    {                                          // felt ejes af anden spiller
      double leje = grundleje + antalHuse * huspris;
      sp.besked("Leje: "+leje);
      sp.betal(ejer, leje);                   // spiller betaler til ejeren
    }
  }
}

Lad os nu lave en klasse, der har som ansvarsområde at repræsentere et helt matadorspil, med lister over alle felter og spillere og som holder styr på, hvis tur det er:

import java.util.ArrayList;

public class Matadorspil
{
  ArrayList<Felt> felter = new ArrayList<Felt>(); // indeholder alle felter

  ArrayList<Spiller> spillere = new ArrayList<Spiller>();  // alle spillere

  int spillersTur = 0;

  public Matadorspil() 
  {
    felter.add(new Start(5000));
    felter.add(new Gade("Rødovrevej", 10000, 400,1000));
    felter.add(new Gade("Hvidovrevej",10000, 400,1000));
    felter.add(new Rederi("Maersk", 17000,4200));
    felter.add(new Gade("Gade 3", 12000, 500,1200));
    felter.add(new Gade("Gade 4", 12000, 500,1200));
    felter.add(new Gade("Gade 5", 15000, 700,1500));
    felter.add(new Helle(15000));
    felter.add(new Gade("Frederiksberg Allé", 20000,1100,2000));
    felter.add(new Gade("Rådhuspladsen",      20000,1100,2000));
  }
}

I konstruktøren bygges en liste med alle felterne på brættet op. Alle felterne behandles som objekter af typen Felt (selvom de egentlig er nedarvninger).

Et spil kan spilles ved at oprette et Matadorspil-objekt for at få et bræt og derpå lægge to spillere ind i spillerlisten. Herunder spilles også 20 runder, hvor hver spiller får en tur:

public class BenytMatadorspil
{
  public static void main(String[] arg)
  {
    Matadorspil spil = new Matadorspil();
    spil.spillere.add(new Spiller("Søren",50000));   // opret spiller Søren
    spil.spillere.add(new Spiller("Gitte",50000));   // opret spiller Gitte

    // løb gennem 20 runder (40 ture)
    for (spil.spillersTur=0; spil.spillersTur<40; spil.spillersTur++)
    {
      // tag skiftevis Søren og Gitte (% er forklaret i afsnit Fejl: Henvisningskilde ikke fundet)
      Spiller sp = spil.spillere.get(spil.spillersTur % spil.spillere.size());
      int slag = (int)(Math.random()*6)+1;     // og slå et terningkast (1-6)
      System.out.println("***** "+sp.navn+" på felt "+sp.feltnr+" slår "+slag);

      for (int i=1; i<=slag; i=i+1)                  // nu rykkes der
      {
        // gå til næste felt. Hvis vi når over antal felter så tæl fra 0
        sp.feltnr = sp.feltnr + 1;
        if (sp.feltnr == spil.felter.size()) sp.feltnr=0;
        Felt felt = spil.felter.get(sp.feltnr);

        if (i<slag) felt.passeret(sp); // kald passeret() på passerede felter
        else felt.landet(sp);          // kald landet() på sidste felt
        try { Thread.sleep(300); } catch (Exception e) {} // vent 0.3 sek
      }
      try { Thread.sleep(3000); } catch (Exception e) {} // tur slut, vent 3 sek
    }
  }
}

Når vi skal spille en tur, finder vi et tilfældigt tal mellem 1 og 6 og rykker spilleren frem. Vi kalder passeret() på hver af de passerede felter og landet() på den sidste.

Bemærk hvordan vi sørger for, at variablen feltnr vedbliver at have en værdi mellem 0 og antallet af felter med operatoren %, der giver resten af en division (se afsnit Fejl: Henvisningskilde ikke fundet).

Linjen nederst får programmet til at holde en pause i tre sekunder, inden det går videre (try og catch vil blive forklaret i kapitel Fejl: Henvisningskilde ikke fundet, Fejl: Henvisningskilde ikke fundet).

Her er et eksempel på uddata fra en kørsel af programmet:

***** Søren på felt 0 slår 1
Søren: Du er landet på Rødovrevej
Søren: Vil du købe Rødovrevej for 10000.0? ja
Sørens konto lyder nu på 40000.0 kr.
***** Gitte på felt 0 slår 6
Gitte: Du passerer Rødovrevej
Gitte: Du passerer Hvidovrevej
Gitte: Du passerer Maersk
Gitte: Du passerer Gade 3
Gitte: Du passerer Gade 4
Gitte: Du er landet på Gade 5
Gitte: Vil du købe Gade 5 for 15000.0? ja
Gittes konto lyder nu på 35000.0 kr.
***** Søren på felt 1 slår 1
Søren: Du er landet på Hvidovrevej
Søren: Vil du købe Hvidovrevej for 10000.0? ja
Sørens konto lyder nu på 30000.0 kr.
***** Gitte på felt 6 slår 3
Gitte: Du passerer Helle
Gitte: Du passerer Frederiksberg Allé
Gitte: Du er landet på Rådhuspladsen
Gitte: Vil du købe Rådhuspladsen for 20000.0? ja
Gittes konto lyder nu på 15000.0 kr.
***** Søren på felt 2 slår 5
Søren: Du passerer Maersk
Søren: Du passerer Gade 3
Søren: Du passerer Gade 4
Søren: Du passerer Gade 5
Søren: Du lander på helle og får overført 15000.0
Sørens konto lyder nu på 45000.0 kr.
***** Gitte på felt 9 slår 1
Gitte: Du lander på start og modtager 5000.0
Gittes konto lyder nu på 20000.0 kr.
***** Søren på felt 7 slår 1
Søren: Du er landet på Frederiksberg Allé
Søren: Vil du købe Frederiksberg Allé for 20000.0? ja
Sørens konto lyder nu på 25000.0 kr.
***** Gitte på felt 0 slår 3
Gitte: Du passerer Rødovrevej
Gitte: Du passerer Hvidovrevej
Gitte: Du er landet på Maersk
Gitte: Vil du købe Maersk for 17000.0? ja
Gittes konto lyder nu på 3000.0 kr.
***** Søren på felt 8 slår 5
Søren: Du passerer Rådhuspladsen
Søren: Du passerer start og modtager 5000.0
Sørens konto lyder nu på 30000.0 kr.
Søren: Du passerer Rødovrevej
Søren: Du passerer Hvidovrevej
Søren: Du er landet på Maersk
Søren: Leje: 4200.0
Søren betaler Gitte: 4200.0 kr.
Gittes konto lyder nu på 7200.0 kr.
Sørens konto lyder nu på 25800.0 kr.
***** Gitte på felt 3 slår 3
Gitte: Du passerer Gade 3
Gitte: Du passerer Gade 4
Gitte: Du er landet på Gade 5
Gitte: Det er din egen grund
Gitte: Vil du købe hus for 1500.0? ja
Gittes konto lyder nu på 5700.0 kr.
***** Søren på felt 3 slår 2
Søren: Du passerer Gade 3
Søren: Du er landet på Gade 4
Søren: Vil du købe Gade 4 for 12000.0? ja
Sørens konto lyder nu på 13800.0 kr.
***** Gitte på felt 6 slår 1
Gitte: Du lander på helle og får overført 15000.0
Gittes konto lyder nu på 20700.0 kr.

... (og så videre)

5.3.1 Polymorfi

Polymorfi vil sige, at objekter af forskellig type bruges på en ensartet måde uden hensyn til deres præcise type.

Matadorspillet udnytter polymorfi til at behandle alle feltobjekter ens (ved at kalde landet() og passeret() fra Spiller's tur()-metode), selvom de er af forskellig type.

Fidusen er, at programkoden i tur()-metoden kan skrives uafhængigt af, hvilke felt-typer der findes: De rigtige landet()- og passeret()-metoder i nedarvningerne vil automatisk blive kaldt, selvom tur() kun kender til Felt-klassen.

Polymorfi er et kraftfuldt redskab til at lave meget fleksible programmer, der senere kan udvides, uden at der skal ændres ret meget i den eksisterende kode.

For eksempel kan vi til enhver tid udbygge matadorspillet med flere felttyper uden at skrive programmet om. Den programkode, der arbejder på felterne, Spiller-klassens tur()-metode, kender faktisk slet ikke til andre klasser end Felt!

En forudsætning for at udnytte polymorfi-mekanismen er, at objekterne "sørger for sig selv", dvs. at data og programkode er i de objekter, som de handler om.

5.3.2 Opgaver

  1. Tilføj en Bryggeri-klasse til matadorspillet og prøv, om det virker. På bryggerier afhænger lejen af, hvor stort et slag spilleren slog, da han landede på feltet, men lad foreløbig lejen være tilfældig.
    Du kan evt. kopiere Gade.java i stedet for at skrive koden forfra. Husk at indsætte et Bryggeri i felter-listen i Matadorspil (eller i BenytMatadorspil's main()-metode).

  2. Ret derefter, så lejen af et bryggeri afhænger af, hvor stort et slag spilleren slog.
    Tilføj nu feltet slag til Spiller og brug den fra BenytMatadorspil i stedet for den lokale variabel (slet den for at undgå misforståelser). Nu kan man altid se, hvad spilleren slog sidst ved at kigge på værdien af slag. Udnyt dette i landet()-metoden i Bryggeri til at lade lejen afhænge af, hvad spilleren slog.

5.4 Stamklassen Object

Klassen Object (i pakken java.lang) er 'alle klassers moder', dvs. superklasse for alle andre klasser. Arver en klasse ikke fra noget andet, vil den automatisk arve fra Object.

Alle klasser arver fra Object

Således arver f.eks. Terning fra Object. FalskTerning1 arver indirekte fra Object gennem Terning. Alle standardklasserne arver også fra Object, muligvis gennem andre klasser.

Det er derfor, at bl.a. toString()-metoden findes på alle objekter; den er defineret i Object og arves til alle klasser i Java. Her ses, hvad der sker, hvis man udskriver et (f.eks. Boks-) objekt, der ikke har sin egen toString():

...
  Boks b = new Boks();
  System.out.println("b = "+b);   // b.toString() kaldes implicit
...

  b = Boks@4852d1b0

Den bruger toString() fra Object og man kan altså se, at implementationen af toString() i Object returnerer klassens navn, et '@' og et tal2, f.eks. Boks@4852d1b0.

En anden metode i Object er equals(). Den har vi brugt til at undersøge, om strenge er ens, men den findes altså på ethvert objekt og kan f.eks. også bruges til at undersøge, om to lister indeholder de samme elementer. Bemærk, at man selv skal definere equals()-metoden på sine egne klasser, før den fungerer som forventet3.

5.4.1 Referencer til objekter

Når alle objekter arver fra Object, kan man i en variabel af denne type gemme en reference til ethvert slags objekt, jf. reglerne om typekonvertering:

  Object obj;
  obj = new Point();
  obj = "hej";
  obj = new FalskTerning2();

Omvendt kan man ikke rigtig bruge variablen til noget, før man har lavet en eksplicit typekonvertering af referencen obj:

  Terning t;
  t = (Terning) obj;
  t.kast();

Her var vi heldige, at obj faktisk refererede til en (underklasse af) Terning. Vi får først at vide, om typekonverteringen er gået godt på kørselstidspunktet.

Tilsvarende kan man bruge Object som parameter/returtype og få et fleksibelt, men ikke særlig sikkert program. Klassen ArrayList benytter sig af dette: Den er en liste af typen Object, det er derfor, man kan gemme alle slags objekter i den.

  ArrayList l = new ArrayList(); // kunne også skrive new ArrayList<Object>()
  Point p = new Point();
  l.add(p);

Metoden add() får noget af typen Object (dvs. et hvilket som helst objekt) som parameter.

Nedenfor er vist det samme, men her bruges en mellemvariabel, der illustrerer, at der sker en implicit reference-typekonvertering (p skal jo konverteres fra en Point-reference til en Object-reference):

  ArrayList l = new ArrayList(); // liste med alle slags objekter (type Object)
  Point p = new Point();
  Object obj;                    // overflødig mellemvariabel
  obj = p;                       // implicit reference-typekonvertering
  l.add(obj);

Når man kalder get() for at få fat i objektet igen, er det nødvendigt med en eksplicit reference-typekonvertering, fordi konverteringen sker den anden vej, fra superklasse til underklasse:

  p = (Point) l.get(0);

Igen vises det samme blot med mellemvariabel, så man kan se, hvilken typekonvertering der finder sted:

  o = l.get(0);        // ingen konvertering
  p= (point) o;        // eksplicit reference-typekonvertering

5.5 Konstruktører i underklasser

Vi minder om, at:

Underklassen skal selv definere, hvordan dets objekter skal kunne oprettes, så den skal selv definere sine konstruktører. Den kan have færre eller flere konstruktører end superklassen.

Når man definerer en konstruktør på en underklasse, skal man kun initialisere den nye del af objektet. Har man f.eks. tilføjet nye variabler, skal konstruktøren initialisere dem.

Den arvede del af objektet initialiseres ved, at man fra underklassens konstruktør kalder en konstruktør fra superklassen. Dette gøres med sætningen: "super(...);" med eventuelle parametre. Man bruger altså her super som en metode. Det skal gøres som den første sætning i konstruktøren. Hvis man ikke selv kalder super() som det første, sker der det, at super bliver kaldt automatisk uden parametre.

Konstruktører skal defineres separat i en underklasse
En konstruktør kalder først en af superklassens konstruktører
Superklassens konstruktør kan kaldes med: super(parametre)
Hvis man ikke selv kalder en af superklassens konstruktører, indsætter Java automatisk et kald af superklassens konstruktør uden parametre

Herunder definerer vi Boks3medDensitet, der arver fra Boks3 (se afsnit Fejl: Henvisningskilde ikke fundet). Det nye er variablen densitet og metoden vægt(). Objektet kan oprettes med: new Boks3medDensitet(), som opretter boksen med standardværdier eller med: new Boks3medDensitet(lgd,b,h,d), hvor man angiver længde, bredde, højde og massefylden (densitet).


Boks3medDensitet tillader
oprettelse på andre måder
end superklassen Boks3


public class Boks3medDensitet extends Boks3
{
  private double densitet;

  public Boks3medDensitet()
  {
    // super(); kaldes automatisk hvis intet andet angives
    densitet = 10.0;
  }

  public Boks3medDensitet(double lgd, double b,  
          double h, double densitet)
  {
    // vælg en anden konstruktør i superklassen 
    // end den uden parametre
    super(lgd,b,h); 
    this.densitet = densitet;
  }

  public double vægt()
  {
    // superklassen udregner volumen for os
    return volumen() * densitet; 
  }
}

5.5.1 Konsekvenser

Ovenstående regler kombineret med reglerne for standardkonstruktøren har nogle pudsige konsekvenser. Lad os se på et eksempel med al overflødig kode skåret væk:

public class A
{
  public A(int i) { }
}
public class B extends A
{
}

Klassen B vil ikke oversætte, fordi den af Java vil blive lavet om til:

public class B extends A
{
  public B() // standardkonstruktør indsættes automatisk af Java
  {
    super();
  }
}

Standardkonstruktøren i B vil altså prøve at kalde konstruktøren i A uden parametre, men den findes jo ikke, fordi A har en anden konstruktør. Compileren kommer med fejlmeddelelsen "constructor A() not found". Så er vi nødt til at angive konstruktøren i superklassen:

public class B extends A
{
  public B()    // vi er nødt til at definere underklassens konstruktør...
  {
    super(42); // ... så vi kan angive parametre til superklassens konstruktør
  }
}

Hvis vi ønsker en konstruktør med de samme parametre i B som i A, vil det se således ud:

public class B extends A
{
  public B(int i)    // vi er nødt til at definere underklassens konstruktør...
  {
    super(i); // ... så vi kan angive parametre til superklassens konstruktør
  }
}

Der er til gengæld ingen problemer med:

public class A2
{
}

og

public class B2 extends A2
{
}

Java laver det om til:

public class A2
{
  public A2() { } // indsættes automatisk af Java
}

og

public class B2 extends A2
{
  public B2()     // indsættes automatisk af Java
  {
    super();
  }
}

5.6 Ekstra eksempler

5.6.1 Matadorspillet version 2

Dette eksempel viser, hvordan man kan spare programkode (og dermed programmeringstid) med nedarvning. Samtidig viser det brugen af konstruktører i underklasser.

Se igen på programkoden til Rederi og Gade. Der er meget programkode, som er ens for de to klasser. Faktisk implementerer de kode, der er fælles for alle grunde, der kan ejes af en spiller og derfor vil det være hensigtsmæssigt, at følgende kode var i en Grund-klasse:

Det har vi gjort herunder.

Vi har været forudseende og flyttet beregningen af lejen ud fra landet() og ind i en separat metode beregnLeje(), fordi netop denne er meget forskellig for Rederi og Gade.

/** Mellemklasse mellem 'Felt' og underliggende klasser som Gade og Rederi */

public class Grund2 extends Felt
{
  Spiller ejer;
  double pris;
  double grundleje;

  public Grund2(String navn, double pris, double leje)
  {
    this.navn=navn;
    this.pris=pris;
    this.grundleje=leje;
  }

  public double beregnLeje()
  {
    return grundleje;
  }

  public void landet(Spiller sp)
  {
    sp.besked("Du er landet på "+navn);
    if (sp==ejer)
    {                                       // spiller ejer feltet
      sp.besked("Det er din egen grund");
    }
    else if (ejer==null)
    {                                       // ingen ejer grunden, så køb den
      if (sp.konto > pris)
      {
        if (sp.spørgsmål("købe "+navn+" for "+pris))
        {
          sp.transaktion( -pris );
          ejer=sp;
        }
      }
      else sp.besked("Du har ikke penge nok til at købe "+navn);
    }
    else
    {                                       // felt ejes af anden spiller
      double leje = beregnLeje();           // udregn lejen
      sp.besked("Leje: "+leje);
      sp.betal(ejer, leje);                 // spiller betaler til ejeren
    }
  }
}

Nu er Rederi ret nem. Den skal nemlig (i denne simple udgave) opføre sig præcis som Grund2. Vi skal blot definere konstruktøren, som kalder den samme konstruktør i Grund2:

/** Et rederi, der kan købes af en spiller */

public class Rederi2 extends Grund2 
{
  public Rederi2(String navn, double pris, double leje)
  {
    super(navn, pris, leje); // overfør værdierne til superklassens konstruktør
  }
}

Nu kommer vi til Gade. Her er beregnLeje() tilsidesat til også at tage højde for antallet af huse. Med super kan vi faktisk spare en hel del arbejde. Gaderne kan genbruge meget af landet()-metoden, men der er dog en ekstra mulighed for at bygge hus. Derfor kalder vi superklassens landet()-metode. Derefter, hvis spilleren, der er landet på gaden, er ejeren, prøver vi at bygge et hus.

/** En gade, der købes af en spiller og bebygges */

public class Gade2 extends Grund2
{
  int antalHuse;                             // antal huse og pris
  double huspris;

  public Gade2(String navn, double pris, double leje, double huspris)
  {
    super(navn, pris, leje);                 // kald Grund2's konstruktør
    this.huspris=huspris;
    antalHuse = 0;
  }

  public double beregnLeje()                 // tilsidesæt Grund2's beregnLeje()
  {
    return grundleje + antalHuse * huspris;
  }

  public void landet(Spiller sp)
  {
    super.landet(sp);                        // brug gamle landet()
    if (sp==ejer)
    {                                        // eget felt; byg hus?
      if (antalHuse<5 && sp.konto>huspris && sp.spørgsmål("købe hus for "+pris))
      {                                     // byg et hus
        ejer.transaktion( -huspris );
        antalHuse = antalHuse + 1;
        sp.besked("Du bygger hus på "+navn+" for "+huspris);
      }
    }
  }
}

Denne måde at programmere på kan spare meget kode: Læg mærke til, at vi har sparet næsten halvdelen af koden væk i de to nye klasser!

Herunder ses klassediagrammet for de nye klasser.

Da Grund2 har en spiller (ejeren), er der en pil fra Grund2 til Spiller, en har-relation. Resten af pilene symboliserer er-en-relationer, f.eks. Gade2 er en Grund2, Grund2 er et Felt.

I kapitel Fejl: Henvisningskilde ikke fundet, Fejl: Henvisningskilde ikke fundet kan du læse mere om, hvordan man designer sine klasser og hvordan man vælger hvilke er-en- og har-en-relationer, der skal være mellem klasserne.

5.6.2 Opgaver

  1. Ret i Matadorspil.java til at bruge Rederi2 og Gade2 i stedet for Rederi og Gade. Kør programmet og følg med i, hvad der sker i Gade2's konstruktør og landet()-metode.

  2. Føj en Bryggeri-klasse til matadorspillet version 2. Husk at kalde super() i konstruktøren (hvorfor er det nødvendigt?). Du kan evt. kopiere Gade2.java i stedet for at skrive koden forfra. Hvor meget kode kan du spare?

5.7 Test dig selv

Dette afsnit er ikke omfattet af Åben Dokumentslicens.
Du skal købe bogen for at måtte læse dette afsnit.
Jeg erklærer, at jeg allerede har købt bogen
Jeg lover at anskaffe den i nær fremtid.

5.8 Resumé

Dette afsnit er ikke omfattet af Åben Dokumentslicens.
Du skal købe bogen for at måtte læse dette afsnit.
Jeg erklærer, at jeg allerede har købt bogen
Jeg lover at anskaffe den i nær fremtid.

5.9 Avanceret

Dette afsnit er ikke omfattet af Åben Dokumentslicens.
Du skal købe bogen for at måtte læse dette afsnit.
Jeg erklærer, at jeg allerede har købt bogen
Jeg lover at anskaffe den i nær fremtid.

5.9.1 Initialisering uden for konstruktørerne

Dette afsnit er ikke omfattet af Åben Dokumentslicens.
Du skal købe bogen for at måtte læse dette afsnit.
Jeg erklærer, at jeg allerede har købt bogen
Jeg lover at anskaffe den i nær fremtid.

5.9.2 Kalde en konstruktør fra en anden konstruktør

Dette afsnit er ikke omfattet af Åben Dokumentslicens.
Du skal købe bogen for at måtte læse dette afsnit.
Jeg erklærer, at jeg allerede har købt bogen
Jeg lover at anskaffe den i nær fremtid.

5.9.3 Metoder erklæret final

Dette afsnit er ikke omfattet af Åben Dokumentslicens.
Du skal købe bogen for at måtte læse dette afsnit.
Jeg erklærer, at jeg allerede har købt bogen
Jeg lover at anskaffe den i nær fremtid.

5.9.4 Metoder erklæret protected

Dette afsnit er ikke omfattet af Åben Dokumentslicens.
Du skal købe bogen for at måtte læse dette afsnit.
Jeg erklærer, at jeg allerede har købt bogen
Jeg lover at anskaffe den i nær fremtid.

5.9.5 Variabel-overskygning

Dette afsnit er ikke omfattet af Åben Dokumentslicens.
Du skal købe bogen for at måtte læse dette afsnit.
Jeg erklærer, at jeg allerede har købt bogen
Jeg lover at anskaffe den i nær fremtid.

1Egentlig er sætSnydeværdi() overflødig, da snydeværdi er public, men vi skal bruge den til at illustrere en pointe i næste afsnit.

2En såkaldt hashkode, der med stor sandsynlighed er unik. Dette er ikke specielt informativt, så man definerer ofte sin egen toString()

3Man kan altid kalde equals(), men det kan godt være, at den ikke giver det forventede, hvis den ikke er defineret i klassen. ArrayList og String har defineret den, mens f.eks. StringBuilder ikke har. Da får man opførslen fra Object, der kun undersøger, om objekterne er de samme (samme sted i hukommelsen), den tager ikke højde for, om to objekter indeholder ens data.

javabog.dk  |  << forrige  |  indhold  |  næste >>  |  programeksempler  |  om bogen
http://javabog.dk/ - Forord af Jacob Nordfalk.
Licens og kopiering under Åben Dokumentlicens (ÅDL) hvor intet andet er nævnt (79% af værket).

Ønsker du at se de sidste 21% af dette værk (267325 tegn) skal du købe bogen. Så får du pæne figurer og layout, stikordsregister og en trykt bog med i købet.