Arv


Hva er arv

I Java er det mulig å opprette en klasse (kalt subklasse) som «arver» fra en annen klasse (kalt superklasse) ved å bruke kodeordet extends i klassedeklarasjonen. Dette innebærer at

Subklassen kan være forskjellig fra superklassen på to måter:

Fordeler:

  • En enkel og rask måte å gjenbruke kode på. Hjelper til med DRY-prinsippet (Don’t Repeat Yourself).
  • Kan i sjeldne tilfeller representere naturlige relasjoner mellom klasser, og slik sett være en modell som er enkel å forstå.
  • Kan styrke single responsibility -prinsippet ved å skille ut ett ansvarsområde til én klasse.
  • Kan gjøre det lettere å forstå subklassen hvis man allerede kjenner superklassen; forskjellene drukner ikke i likhetene.
  • Kan brukes for å lage rammeverk. For eksempel krever Swing at vi arver deres klasser når vi bruker rammeverket.

Ulemper:

  • Kan redusere modularitet ved at endringer i superklassen kan ødelegge for subklassene.
  • Kan bryte innkapsling ved at implementasjonsdetaljer i superklassen blir eksponert til subklassene.
  • Kan redusere modularitet ved at subklasser må ta hensyn til superklassen sine implementasjonsdetaljer.
  • Kan føre til komplekse relasjoner som er utfordrende å forstå og feilsøke.

Ulempene gjør at det er svært krevende å i ettertid modifisere en klasse som blir arvet av andre. Dersom et alternativt design ikke medfører spesielt store ulemper, vil det ofte være å foretrekke.

Eksempel på arv

En subklasse utvider en superklasseVi lager en klasse ManualGearCar som uvider klassen Car. Legg merke til i koden under:

class Car {
  int speed;

  void changeSpeed(int delta) {
    speed += delta;
  }

  void stop() {
    speed = 0;
  }

  int getSpeed() {
    return speed;
  }
}

class ManualGearCar extends Car {
  // Ekstra felter og variabler
  int gear;

  void setGear(int newGear) {
    this.gear = newGear;
  }

  int getGear() {
    return this.gear;
  }

  // Overskrevne metoder
  @Override
  void stop() {
    super.stop(); // Kaller stop-metoden i superklassen først
    this.gear = 0;
  }
}

public class Main {
  public static void main(String[] args) {
    Car car = new Car();
    ManualGearCar manualGearCar = new ManualGearCar();

    // Begge objektene har changeSpeed og getSpeed som definert i superklassen
    car.changeSpeed(10);
    manualGearCar.changeSpeed(11);
    System.out.println(car.getSpeed()); // 10
    System.out.println(manualGearCar.getSpeed()); // 11

    // Kun ManualGear -variabler har tilgang til metodene setGear og getGear
    manualGearCar.setGear(2);
    System.out.println(manualGearCar.getGear()); // 2

    // Begge objektene har typen Car
    System.out.println(car instanceof Car); // true
    System.out.println(car instanceof ManualGearCar); // false
    System.out.println(manualGearCar instanceof Car); // true
    System.out.println(manualGearCar instanceof ManualGearCar); // true
    
    // Vi kan derfor lagre ManualGearCar -objekter i Car -variabeler
    Car mcar = manualGearCar; // mcar er samme objekt som manualGearCar

    // Selv om både car og mcar er variabler av typen Car, er objektene de
    // refererer til likevel i ulike klasser
    System.out.println(car.getClass()); // Car
    System.out.println(mcar.getClass()); // ManualGearCar

    // Metoden stop() er definert ulikt i de to klassene, og vil derfor ha
    // ulik effekt. Dette til tross for at begge variablene car og mcar
    // har typen Car; det er **klassen objektet er i** som bestemmer
    // hvilken metode som blir kalt, ikke hvilken type variabelen har.
    car.stop(); // Kaller stop-metoden slik den er definert i Car
    mcar.stop(); // Kaller stop-metoden slik den er definert i ManualGearCar
    System.out.println(car.getSpeed()); // 0
    System.out.println(manualGearCar.getSpeed()); // 0
    System.out.println(manualGearCar.getGear()); // 0
  }
}

Hvordan bruke arv

TL;DR

  • Vær svært forsiktig med bruk av arv hvis du ikke har en god grunn. Det er ofte bedre å bruke komposisjon.
  • La klasser være final med mindre du designer en klasse spesielt for å bli arvet.
  • Når du arver en klasse:
    • Bruk alltid @Override-annotasjonen når du overskriver metoder.
    • Bruk super-referansen for å kalle metoder i superklassen når du overskriver metoder.
    • Overhold Liskovs substitusjonsprinsipp.

Liskovs substitusjonsprinsipp

Hvis S er en subklasse av T, da må alt som kan bevises om T også kunne bevises om S.
Barbara Liskov
Barbara Liskov
Bilde: Kenneth C. Zirkel CC-BY-SA 3.0
 

Liskovs substitusjonsprinsipp sier med andre ord at hvis S er en subklasse av T, skal ethvert S-objekt kunne benyttes som om det var et T-objekt uten at den som bruker objektet (i den tro at det egentlig er en T) på noe tidspunkt blir overrasket.

I noen få tilfeller stemmer denne relasjonen naturlig for to klasser. Da kan arv være hensiktsmessig. For eksempel kan vi i en database for utlånsobjekter i et bibliotek ha en superklasse LoanableItem og subklasser Book og CD. Men å kjenne igjen når to konsepter har en slik relasjon er ikke alltid lett.

Anta at vi har en klasse S som arver fra en klasse T. Da:

  • S kan legge til metoder og feltvariabler som ikke finnes i T, men kan ikke fjerne metoder eller feltvariabler som finnes i T.

  • Alle metoder i S kan kun endre feltvariablene arvet fra T slik at de fremdeles er en gyldig tilstand hvis tolket som et T-objekt.

  • En metode i S som overskriver en metode fra T må akseptere minimum de argumentene som metoden i T aksepterer, og må returnere verdier eller utføre tilstandsendringer som er innenfor kontrakten/intensjonen til den overskrevne metoden i T.

Man leser gjerne på internettsider som forklarer arv at konseptet innebærer et «er» -forhold mellom to klasser. For eksempel, en bil med manuelt gir er en bil, så derfor gir det mening at ManualGearCar arver fra Car. Dette kan gi oss en pekepinn på at arv kan være mulig; men det er på ingen måte nok, som vi skal se i neste avsnitt.

Er et kvadrat et rektangel?

En klasse som representerer et rektangel kan være:

public class Rectangle {
  private int width;
  private int height;

  /**
   * Change the size of the rectangle
   * @param width new width
   * @param height new height
   */
  public void setSize(int width, int height) {
    this.width = width;
    this.height = height;
  }

  /** Get the area of the rectangle */
  public int getArea() {
    return this.width * this.height;
  }
}

Om vi skulle tenke tanken at alle kvadrater er rektangler, så lager vi kanskje en subklasse av Rectangle som representerer kvadrater fordi vi tenker at arv representerer et er -forhold. Åpenbart kan vi ikke beholde setSize -metoden slik den er, fordi vi må sørge for at width og height er like. Men hvordan skal den være?

// Alternativ 1: Endre kommentaren til metoden
/**
 * Change the size of the rectangle. Caller must use the same
 * number for width and height.
 * 
 * @param width new width
 * @param height new height
 */
@Override
public void setSize(int width, int height) {
  super.setSize(width, height);
}

// Alternativ 2: Benytter kun én av parametrene
@Override
void setSize(int width, int height) {
    super.setSize(width, width);
}

// Alternativ 3: Ekstra metodesignatur som kun har én parameter
// (vil ikke fjerne den arvede metoden, kommer bare i tillegg)
void setSize(int edgeLength) {
    super.setSize(edgeLength, edgeLength);
}

// Alternativ 4: Krasj hvis width og height ikke er like
@Override
void setSize(int width, int height) {
    if (width != height) {
        throw new IllegalArgumentException("Width and height must be equal");
    }
    super.setSize(width, height);
}

Alternativer 1 og 3 er dårlige alternativer fordi objektene plutselig kan slutte å faktisk representere et kvadrat, noe som tross alt var poenget med å ha et objekt av denne typen. Alternativer 2 og 4 bryter med Liskovs substitusjonsprinsipp fordi de ikke kan brukes som om de var et rektangel. Dermed må vi konkludere med at selv om et kvadrat «er» et rektangel rent matematisk, er det likevel ikke et arve-forhold mellom disse begrepene.

Dersom vi ønsker å lage båden en kvadrat-klasse og en rektangel-klasse og gjenbruke så mye kode som mulig, hva skal vi gjøre i stedet? Noen muligheter:

Man kan gjenbruke koden fra rektangel-klassen i en kvadrat-klasse ved bruk av komposisjon. Da har vi et rektangel-objekt som feltvariabel i kvadrat-klassen. Slik møter vi prinsippet om å ikke skrive den samme koden mer enn én gang samtidig som vi unngår problemene med arv.

Ulempen med denne løsningen er at vi får mange «videresend» -metoder som kaller på en tilsvarende metode i feltvariabelen. Dette er likevel en liten kostnad, og disse metodene er relativt lite utsatt for feil.

public class Square {
  private Rectangle rectangle;

  public Square(int edgeLength) {
    this.rectangle = new Rectangle();
    this.rectangle.setSize(edgeLength, edgeLength);
  }

  public void setSize(int edgeLength) {
    this.rectangle.setSize(edgeLength, edgeLength);
  }

  public int getArea() {
    return this.rectangle.getArea();
  }
}

Rectangle og Square deler felles grensesnitt ShapeDersom det er et poeng at både rektangler og kvadrater kan lagres i den samme variabelen, kan vi la både rektangler og kvadrater implementere et felles grensesnitt som inneholder de felles metodene (her: getArea). Dette kan gjerne kombineres med komposisjon.

interface Shape {
  int getArea();
}

class Rectangle implements Shape {
    // ...
}

class Square implements Shape {
    // ...
}

Problemet med at et kvadrat ikke kan benyttes som et rektangel senere, var knyttet til at det var mulig å endre størrelsene til objektene etter at de var først opprettet. Hvis vi fjerner denne muligheten og i stedet lager objekter som er uforanderlige (som kun kan endre størrelse i konstruktøren), da kan vi lage en klasse som representerer et kvadrat ved å arve en rektangel-klasse uten å bryte med Liskovs substitusjonsprinsipp.

public class Rectangle {
  private final int width;
  private final int height;

  public Rectangle(int width, int height) {
    this.width = width;
    this.height = height;
  }

  public int getArea() {
    return this.width * this.height;
  }
}
class Square extends Rectangle {
    public Square(int edgeLength) {
        super(edgeLength, edgeLength);
    }
}

Klasser som er final

Når man definerer en klasse er det mulig å definere den som final, for eksempel slik:

public final class Rectangle {
  // ...
}

Da vil det ikke være mulig å arve fra denne klassen. Dette er en god praksis.

Unntaket er selvfølgelig dersom klassen er designet spesielt for å bli arvet.

Object

Klassen Object er en spesiell klasse i Java. Dersom man oppretter en klasse uten å spesifisere at den arver en annen klasse, arver den i stedet Object-klassen. Derfor er alle klasser enten direkte eller indirekte en subklasse av Object. I denne klassen finnes det en rekke metoder som kan kalles på ethvert objekt, for eksempel