Mutabilitet


Definisjoner

Et objekt er muterbart dersom noe i objektet kan endres etter at objektets konstruktør er ferdig utført. Dette inkluderer å endre verdien til instansvariabler direkte, men også dersom det er mulig å endre på noe inne i en instansvariabel/i et referert objektet som inngår i komposisjonen av dette objektet.

Et objekt er uforanderlig (engelsk: immutable) dersom det ikke kan muteres. En klasse er uforanderlig dersom alle objekter i klassen er uforanderlige.

Fordeler med uforanderlige klasser

  • Robusthet: man unngår alle bugs forårsaket av aliaser (flere referanser til samme objekt). Dette er en svært stor familie med bugs, der noen av dem er ordentlig guffne å feilsøke.
  • Anvendelighet: datastrukturer basert på hashing, slik som oppslagsverk og mengder, krever at hash’en ikke endrer seg. Dette vil alltid være tilfelle med en uforanderlig klasse. Det er derfor trygt å bruke uforanderlige objekter i mengder eller som nøkler i oppslagsverk.
  • Redusert kompleksitet: uforanderliger objekter er lett å forholde seg til. Det blir også lettere å skrive modulær kode (man trenger ikke ta hensyn til at andre deler av koden plutselig muterer objektet vårt).

Ulemper med uforanderlige klasser

  • Dårligere effektivitet: det er ikke mulig å endre kun én feltvariabel i et uforanderlig objekt – for å representere en slik endring må det opprettes et helt nytt objekt, noe som kan kreve mye tid (vil få praktisk betydning dersom det gjelder f. eks. en liste med hundrevis av elementer).
  • Økt kompleksitet: å kommunisere endringer mellom ulike deler av programmet kan bli mer omstendelig.

Ofte er fordelene med uforanderlige objekter større enn ulempene, særlig hvis objektet ikke inneholder lister eller andre store samlinger med ting. Som utgangspunkt bør vi forsøke å gjøre klassene våre uforanderlige; og hvis vi ikke klarer det, gjør så store deler av klassen så uforanderlig som mulig.

Eksempler fra Java sitt standardbibliotek

I Java sitt standardbibliotek finnes det mange ulike klasser. Noen av klassene er uforanderlige, mens andre kan muteres. Noen ganger vil det være klasser som strengt tatt representerer samme type ting, men hvor én er muterbar, mens den andre ikke er det. Som tommelfingerregel bør du alltid benytte deg av den uforanderlige varianten, med mindre du har spesielt behov for mutering (f. eks. på grunn av effektivitetshensyn).

Uforanderlig Muterbar
String StringBuilder
LocalDate Date

Final

En variabel som er final kan kun tilordnes verdi én gang i løpet av sin levetid. Forskjellen man opplever dersom en variabel er deklarert som final, er at man får kompileringsfeil dersom man likevel skulle prøve å endre variabelen.

For at en klasse skal være uforanderlig, er det en forutsetning at alle instansvariabler er enten final eller såkalt «final i praksis» (dette innebærer at en kunne gjort variabelen final uten at det ville ført til kompileringsfeil).

Et eksempel på en klasse med final instansvariabler:

public final class CellPosition {

  // Instansvariablene er final:
  private final int row;
  private final int col;

  public CellPosition(int row, int col) {
    this.row = row;
    this.col = col;
  }

  public int getRow() {
    return this.row;
  }

  public int getCol() {
    return this.col;
  }

  // KOMPILERINGSFEIL! Fordi instansvariabelen 'row' er final,
  // er det ikke mulig å gi variabelen en ny verdi. Denne metoden
  // ville ikke kompilert:
  public void nextRow() {
    this.row = this.row + 1; 
  }

  // Men hvis vi ikke får lov å mutere objektet, hvordan går vi da
  // til neste rad? Løsning: la den som kaller metoden ta imot et
  // NYTT objekt i stedet for å mutere dette.
  public CellPosition nextRow() {
    return new CellPosition(this.row + 1, this.col);
  }
}

Det er også mulig å gjøre en klasse final, som også er illustrert over. Dette innebærer at det ikke er mulig å lage subklasser. For at en klasse skal være uforanderlig, kan det ikke eksistere subklasser som kan muteres. Derfor er det lurt å la uforanderlige klasser være final, slik at de ikke plutselig blir muterbare i fremtiden. Litt mer om dette i kursnotater om arv.

Dersom vi skal lage en uforanderlig klasse, er det en god start å gjøre selve klassen og alle feltvariabler final. Men husk på at dette i seg selv ikke er tilstrekkelig; klassen vil fremdeles være muterbar dersom en av instansvariablene tillater muterbare objekter, og du på en eller annen måte gjør det mulig for andre å mutere dette objektet (eller du muterer det selv).

Record

En record -klasse er en klasse som kun har final instansvariabler. Slike klasser er egnet når hensikten er å samle noen verdier på samme sted.

Et record -objekt i seg selv kan ikke endres, og vil være uforanderlig dersom alle variablene til record-objektet selv har en uforanderlig type. Med andre ord vil det å bruke record-objekter aldri i seg selv introdusere mutablitet.

Et record-objekt i Java tilsvarer på sett og vis et tuple-objekt i Python – en låst samling med verdier. Men som alltid med Java, er det behov for å ha kontroll på hvilke typer hver av verdiene i record’en har; derfor må vi selv definere hva slags typer record-objekter vi kan ha.

Å definere en record-klasse kan være svært kortfattet og enkelt:

public record CellPosition(int row, int col) {}

Koden over oppretter en klasse som heter CellPosition. Vi kan så opprette og bruke objekter i CellPosition -klassen slik:

// Opprette et nytt CellPosition-objekt
CellPosition pos = new CellPosition(3, 4);

// Hente ut verdier fra CellPosition -objektet
int r = pos.row();
int c = pos.col();

En record er en snarvei for å lage en vanlig klasse som følger et bestemt mønster. Record-klassen over tilsvarer nokså nøyaktig en klasse som ser slik ut:

import java.util.Objects;

// en klasse som er final
public final class CellPosition {

  // private final instansvariabler
  private final int row;
  private final int col;

  // en konstruktør med én parameter per instansvariabel, som inialiserer disse
  public CellPosition(int row, int col) {
    this.row = row;
    this.col = col;
  }

  // gettere for hver instansvariabel
  public int row() {
    return this.row;
  }

  public int col() {
    return this.col;
  }

  // hensiktsmessige equals, hashCode og toString -metoder
  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;

    CellPosition that = (CellPosition) o;
    return Objects.equals(this.row, that.row)
        && Objects.equals(this.col, that.col);
  }

  @Override
  public int hashCode() {
    return Objects.hash(this.row, this.col);
  }

  @Override
  public String toString() {
    return "CellPosition[" +
        "row=" + row +
        ", col=" + col +
        ']';
  }
}

Om vi bruker record for å opprette denne klassen i stedet, sparer vi oss altså for mange linjer med lite kreativ kode. Det gjør koden lettere å lese, lettere å endre og mindre utsatt for feil.

En record -klasse gir oss automatisk:

Selv om det ikke er mulig å legge til flere instansvariabler enn dem som defineres i record-deklarasjonen, er det er fullt mulig å skrive metoder i en record-klasse. Man kan sågar skrive egne konstruktører i tillegg til (eller i stedet for) den som følger med:

public record CellPosition(int row, int col) {

  // Ekstra konstruktør som lager objekter med standard-verdi
  public CellPosition(int row) {
    this(row, 0); // standard-verdi 0 for kolonne
  }

  // Metoder fungerer som i en vanlig klasse
  public CellPosition nextRow() {
    return new CellPosition(this.row + 1, this.col);
  }
}