Rajapinnat Javassa, osa 2

Nyt kun Javan geneerisyys on hieman tutumpi käsite, voimme tarkastella muutamia keskeisiä Javan luokkakirjastossa määritettyjä rajapintoja.

Rajapinta Comparable<T>

Aloitetaan geneerisestä rajapinnasta Comparable, jonka määritys esiteltiin jo aiemminkin:

public interface Comparable<T> {
  public int compareTo(T o);
}

Tämän rajapinnan funktion compareTo rooli on määrittää luokan olioille luonnollinen järjestys. Esimerkiksi Javan lajittelufunktioita Collections.sort(List<T> list) ja Arrays.sort(Object[] a) tai järjestettyä joukkoa TreeSet<T> voi käyttää antamatta erillistä vertailufunktiota ainoastaan, jos käsiteltävien alkioiden luokka toteuttaa rajapinnan Comparable. Tällöin alkioiden vertailu tehdään rajapinnan Comparable edellyttämällä funktiolla compareTo.

Alla on esimerkkinä yksinkertaistettu versio aiemmasta koordinaattipisteen esittämiseen sopivasta luokasta Point2D, joka toteuttaa rajapinnan Comparable<Point2D>. Luokkaan on toteutettu funktio compareTo(Point2D other), joka vertaa Point2D-oliota itseään toiseen Point2D-olioon other. Tässä esimerkissä toteutettu vertailu pohjautuu ensisijaisesti x- ja toissijaisesti y-koordinaattiin, ja näiden arvojen vertailussa hyödynnetään Double-luokan tarjoamaa valmista vertailufunktiota Double.compare.

public class Point2D implements Comparable<Point2D> { // Voi verrata toiseen Point2D-olioon.
  private double x;
  private double y;

  public Point2D(double x, double y) {
    this.x = x;
    this.y = y;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }

  // Toteutetaan rajapinnan Comparable<Point2D> vaatima compareTo-funktio.
  @Override
  public int compareTo(Point2D other) {
    int cmp = Double.compare(x, other.x);  // Ensin x-koordinaattien vertailu.
    if(cmp == 0) {
      cmp = Double.compare(y, other.y);    // Tarvittaessa y-koordinaattien vertailu.
    }
    return cmp;
  }
}

Seuraava koodinpätkä havainnollistaa, kuinka Point2D-olioita voisi nyt esim. lajitella ilman erillistä vertailufunktiota.

Point2D[] ps = {new Point2D(2.5, -1.5), new Point2D(-3.5, 4.5), new Point2D(0.5, 0.5)};
Arrays.sort(ps);       // Ei tarvitse antaa erillistä vertailufunktiota.
for(Point2D p : ps) {
  System.out.format(" (%.1f, %.1f)", p.getX(), p.getY());
}

Koodinpätkän suoritus tulostaisi jokseenkin:

(-3.5, 4.5) (0.5, 0.5) (2.5, -1.5)

Rajapinta Comparator<T>

Toinen alkioden järjestämisessä usein hyödynnetty Javan luokkakirjaston rajapinta on Comparator<T>. Alla on esitetty sen määritys, josta on jätetty pois oletus- ja staattiset funktiot.

public interface Comparator<T> {
  int compare(T a, T b);
  boolean equals(Object obj);
}

Rajapinnan oleellisin osa on funktio int compare(T a, T b). Rajapinta Comparator<T> määrittää efektiivisesti vertailuolion tyypin: tyyppiä Comparator<T> olevan (eli sen toteuttavan luokan) olion funktiolla compare voidaan vertailla kahta tyypin``T`` oliota a ja b keskenään. Erillisen vertailufunktion hyväksyvät Javan lajittelufunktiot sekä järjestystä ylläpitävät säiliöt ottavat vertailufunktion nimenomaan rajapinnan Comparator toteuttavan luokan oliona.

Vertailuolio vs. lambda-funktiona annettu vertailufunktio?

Lambda-funktiolla määritetyn vertailufunktion käyttö ei ole poikkeus edellämainittuun sääntöön: myös lambda-funktiota käytettäessä lajittelufunktio saa parametrikseen rajapinnan Comparator toteuttavan vertailuolion. Kun annamme lajittelufunktiolle parametriksi lambda-funktion, luo Java sen pohjalta automaattisesti rajapinnan Comparator toteuttavan olion, jolla on lambda-funktion kuvaamat askeleet suorittava jäsenfunktio compare. Tähän palataan tarkemmin funktionaalisten rajapintojen yhteydessä.

Alla on esimerkkinä rajapinnan Comparator<Point2D> toteuttava luokka CmpPoint2D, joka toteuttaa samanlaisen vertailulogiikan kuin ylempänä määritetty jäsenfunktio compareTo.

public class CmpPoint2D implements Comparator<Point2D> { // Voi verrata kahta Point2D-oliota.
  // Toteutetaan rajapinnan Comparator<Point2D> vaatima compare-funktio.
  @Override
  public int compare(Point2D a, Point2D b) {
    int cmp = Double.compare(a.getX(), b.getX());
    if(cmp == 0) {
      cmp = Double.compare(a.getY(), b.getY());
    }
    return cmp;
  }
}

Ylempänä esitetty Point2D-olioiden lajiteluesimerkki muuttuisi luokkaa CmpPoint2D käytettäessä esimerkiksi seuraavaan muotoon:

Point2D[] ps = {new Point2D(2.5, -1.5), new Point2D(-3.5, 4.5), new Point2D(0.5, 0.5)};
Arrays.sort(ps), new CmpPoint2D());  // Annetaan erillinen vertailuolio.
for(Point2D p : ps) {
  System.out.format(" (%.1f, %.1f)", p.getX(), p.getY());
}

Rajapinnan Comparator<T> toteuttava luokka on sinänsä aivan tavallinen luokka, jolle voi halutessaan määrittää muitakin jäseniä. Alla on esimerkkinä sellainen muunnelma luokasta CmpPoint2D, jonka rakentimelle voi vertailuolion luonnin yhteydessä antaa vertailulogiikkaa mukauttavan parametrin: tehdäänkö vertailu tavallisessa vai käänteisessä järjestyksessä.

public class CmpPoint2D implements Comparator<Point2D> { // Voi verrata kahta Point2D-oliota.
  // Yksityinen jäsenmuuttuja, joka kertoo vertailujärjestyksen: käänteinen vai ei?
  private final boolean reversed;

  public CmpPoint2D(boolean reversed) {  // Rakennin, joka ottaa jäsenen reversed alustusarvon.
    this.reversed = reversed;
  }

  public CmpPoint2D() {            // Parametriton rakennin: oletusjärjestys (reversed = false).
    this.reversed = false;
  }

  @Override
  public int compare(Point2D a, Point2D b) {
    int cmp = Double.compare(a.getX(), b.getX());
    if(cmp == 0) {
      cmp = Double.compare(a.getY(), b.getY());
    }
    return reversed ? -cmp : cmp; // Käännetään tulos päinvastaiseksi, jos reversed on true.
  }
}

Tätä muunnelmaa voisi käyttää seuraavan koodinpätkän esittämään tapaan:

Point2D[] ps = {new Point2D(2.5, -1.5), new Point2D(-3.5, 4.5), new Point2D(0.5, 0.5)};
Arrays.sort(ps), new CmpPoint2D(true));  // CmpPoint2D:lle true: saadaan käänteinen järjestys.
for(Point2D p : ps) {
  System.out.format(" (%.1f, %.1f)", p.getX(), p.getY());
}

Rajapinnat Iterable<E> ja Iterator<E>

Tarkastellaan seuraavaksi vielä Javan luokkakirjaston rajapintoja Iterable<E> ja Iterator<E>, joihin runsaasti käyttämämme muotoa for(V item : vals) oleva iteroiva silmukkakin nojautuu: tällaista silmukkaa voi käyttää jos ja vain jos vals on joko taulukko tai rajapinnan Iterable<E> toteuttava säiliö, jonka alkiot ovat yhteensopivia muuttujan item tyypin V kanssa.

Rajapinnan Iterable<E> määritys, ilman paria sen lisäksi määrittämää oletusfunktiota, näyttää seuraavalta. Javan luokkakirjastossa tosin käytetään tämän rajapinnan yhteydessä tyyppiparametrin nimeä T eikä E, mutta tämä on vain puhtaasti kosmeettinen seikka.

public interface Iterable<E> {
  Iterator<E> iterator();
}

Iterable<E> määrittää abstraktin jäsenfunktion iterator, jonka tulee palauttaa rajapinnan toteuttavan säiliön alkioita iteroiva tyyppiä Iterator<E> oleva iteraattoriolio. Rajapinnan Iterator<E> määritys, jälleen ilman oletusfunktioita, näyttää puolestaan seuraavalta:

public interface Iterator<E> {
  boolean hasNext();
  E next();
}

Iteraattorin funktion boolean hasNext() tulee palauttaa tieto siitä, onko iteroitavassa säiliössä olemassa seuraava vielä iteroimaton alkio (vai onko säiliö jo iteroitu läpi). Funktion next() tulee palauttaa seuraava vielä iteroimaton alkio (Javan dokumentaatio lisäksi määrittää, että se heittää NoSuchElementException-poikkeuksen, ellei seuraavaa alkiota enää ole). Muotoa for(V item : vals) oleva Iterable<E>-säiliötä iteroiva for-silmukka näyttää pinnan alla (Java-kääntäjän automaattisesti generoimana) karkeasti seuraavalta:

for(Iterator<E> iterator = vals.iterator(); iterator.hasNext(); ) {
  V item = iterator.next();
  // Tämän jälkeen silmukan rungon askeleet.
}

Yhteenveto edellisistä on, että jos itse toteutettua säiliötä halutaan käyttää iteroivassa for-silmukassa, tulee säiliön toteuttaa rajapinta Iterable<E>, ja sen oheen (yleensä sisään) tulee lisäksi toteuttaa sopiva iteraattoriluokka. Jotta iteraattoriolio voisi lukea säiliön alkioita, tulee sen yleensä ottaen sisältää jonkinlainen viite iteroitavaan säiliöön (linkitetyn rakenteen kohdalla säiliön solmuun) sekä mahdollisesti lisätietoa siitä, missä kohtaa iterointi etenee. Iteraattoriluokka on tämän vuoksi luontevaa toteuttaa ei-staattisena sisäisenä luokkana: ei-staattisen sisäisen luokan oliot voivat viitata suoraan (ilman erillistä viitemekanismia) ympäröivän luokan sen olion (eli tässä kontekstissa säiliön) jäseniin, jonka puitteissa sisäisen luokan olio on luotu.

Alla on hahmoteltu, miten geneeriseen esimerkkiluokkaamme LinkedStack<E> voitaisiin lisätä tuki iteroinnille. Tässä on toiston välttämisen vuoksi näytetty vain ne osat, jotka tulee lisätä aiemmin esitettyyn toteutukseen. Jos haluat kokeilla tätä koodia käytännössä, copy-pasteta koodit yhteen. Tässä toteutettu iteraattori askeltaa pinon alkiot läpi muuttamatta pinoa.

public class LinkedStack<E> implements Iterable<E> { // Nyt toteutetaan Iterable<E>
  // Sisäinen iteraattoriluokka. Voi olla yksityinenkin.
  private class LSIterator implements Iterator<E> {
      // Viite seuraavaksi iterointivuorossa olevaan solmuun. Alussa top.
    private Node nextNode = top;  // Voi suoraan viitata isäntäsäiliön jäseneen top.

    // Rajapinnan Iterator<E> vaatimat funktiot.
    @Override
    public boolean hasNext() {
      return nextNode != null;    // Seuraava on jäljellä, ellei nextNode ole null.
    }

    @Override
    public E next() {
      if(nextNode == null) {    // Heitetään poikkeus, ellei seuraavaa alkiota ole.
        throw new NoSuchElementException("No more values in the stack!");
      }
      E item = nextNode.getItem();          // Palautettava alkio talteen.
      nextNode = nextNode.getNext();        // Siirretään nextNode askel eteenpäin.
      return item;
    }
  }

  // Rajapinnan Iterable<E> vaatima funktio.
  @Override
  public Iterator<E> iterator() {
    return new LSIterator();        // Palautetaan uusi LSIterator-iteraattoriolio.
  }

  // Muut jäsenet kuin aiemmin, niitä ei näytetä tässä.
}

Seuraava koodinpätkä testaa edellä toteutettua iteroitavaa pinoa:

LinkedStack<Integer> intStack = new LinkedStack<>();
intStack.push(2022);
intStack.push(12);
intStack.push(6);
for(Integer i : intStack) {
  System.out.print(i + " ");
}

Koodinpätkän suoritus tulostaisi:

6 12 2022

Iteraattorin mitätöityminen

Edellisessä iteraattorin toteutuksessa on jätetty huomioimatta sellainen pikantti yksityiskohta, että yleensä iteraattori pitäisi mitätöidä, jos sen iteroimaa säiliötä muutetaan sen jälkeen, kun iteraattori on luotu. Tällainen tilanne voisi syntyä esimerkiksi pinomme tapauksessa seuraavasti:

LinkedStack<Integer> intStack = new LinkedStack<>();
intStack.push(2022);
Iterator<Integer> iterator = intStack.iterator(); // Luodaan pinon iteraattori.
intStack.pop();  // Muutetaan pinoa: nyt edellinen iteraattori mitätöityy.
System.out.format("Next value: %d%n", iterator.next());  // Ei saisi onnistua!

Jos iteraattori on mitätöitynyt, ei sen pitäisi enää suostua lukemaan uusia arvoja. Tämän tarkoituksena on välttää esimerkiksi yllä kuvattu tilanne, jossa iteraattori yrittäisi lukea säiliöstä jo poistettuja arvoja. Esimerkiksi Javan ArrayList-luokan iteraattori heittää ConcurrentModificationException-poikkeksen, jos iteraattori on mitätöitynyt.

Iteraattorin mitätöitymisen tarkistuksen voi toteuttaa esimerkiksi ylläpitämällä säiliössä juoksevaa laskuria, jota kasvatetaan jokaisen muutoksen jälkeen, ja tallettamalla kuhunkin iteraattoriolioon laskurin arvon kyseisen iteraattorin luontihetkellä. Iteraattori voi havaita mitätöitymisen vertaamalla omaa laskuriarvoaan laskurin nykyiseen arvoon: jos arvot poikkeavat toisistaan, on säiliötä muutettu iteraattorin luonnin jälkeen ja iteraattori on mitätöitynyt. Esimerkiksi ArrayList-luokan iteraattori on toteutettu tällä tavalla.

Nimettömät luokkamääritykset

Javassa on mahdollista määrittää jonkin ylityypin perivä tai toteuttava luokka tavallisen määrityksen lisäksi myös ns. nimettömänä luokkamäärityksenä suoraan kyseisen luokan olion luovan new-operaation yhteydessä. Tämä tapahtuu aivan kuin loisimme kyseisen ylityypin olion, mutta luokan rakentimelle välitettävän parametrilistan loppusulun perässä annetaankin luokkaamäärityksen runko (eikä esim. heti puolipistettä). Nimettömän luokkamäärityksen runko voi olla pitkälti samanlainen kuin tavallisenkin luokkamäärityksen runko, mutta esimerkiksi rakenninta siihen ei saa määrittää. Jäsenmuuttujat, jotka alustetaan suoraan niiden määritysten yhteydessä, ovat sallittuja. Nimetön luokkamääritys tyypillisesti määrittää korkeintaan muutaman jäsenfunktion, jotka esim. toteuttavat rajapinnan vaatimat jäsenfunktiot. Jos nimetön luokkamääritys alkaisi käydä monimutkaisemmaksi, voisi koodin selkeyden kannalta olla parempi määrittää se tavallisena luokkana.

Nimettömiä luokkamäärityksiä on tyypillisesti käytetty tilanteissa, joissa halutaan luoda jonkin rajapinnan tai abstraktin yliluokan toteuttava olio kertaluontoiseen käyttöön. Aiemmissa Javan versioissa, jotka eivät vielä tukeneet lambda-funktioita, nimettömiä luokkamäärityksiä käytettiin usein lajittelun vertailuolion määrittämiseen suoraan lajittelufunktion kutsussa.

Alla on esimerkki, kuinka Point2D-olioita voitaisiin lajitella antamalla lajittelufunktiolle nimettömällä luokkamäärityksellä luotu Comparator<Point2D>-olio. Huomannet, että luokan runko on identtinen ensimmäisen aiemmin annetun Comparator<Point2D>-luokan määrityksen kanssa.

Point2D[] ps = {new Point2D(2.5, -1.5), new Point2D(-3.5, 4.5), new Point2D(0.5, 0.5)};
Arrays.sort(ps, new Comparator<Point2D>(){ // Nimetön Comparator<Double>:n toteuttava luokka.
  // Toteutetaan rajapinnan Comparator<Point2D> vaatima compare-funktio.
  @Override
  public int compare(Point2D a, Point2D b) {
    int cmp = Double.compare(a.getX(), b.getX());
    if(cmp == 0) {
      cmp = Double.compare(a.getY(), b.getY());
    }
    return cmp;
  }
});  // Luokkamäärityksen loppusulku ja sort-kutsun loppusulku.
for(Point2D p : ps) {
  System.out.format(" (%.1f, %.1f)", p.getX(), p.getY());
}

Nimettömällä luokkamäärityksellä luodun olion ei tarvitse olla kertakäyttöinen: olion voi asettaa muuttujaan. Alla on esimerkki, joka on muuten samanlainen kuin edellä, mutta nyt vertailuolio asetetaan muuttujaan comparator. Tällöin samaa vertailuoliota voisi käyttää monessa eri lajittelukutsussa.

Point2D[] ps = {new Point2D(2.5, -1.5), new Point2D(-3.5, 4.5), new Point2D(0.5, 0.5)};
Comparator<Point2D> comparator = new Comparator<>(){ // Vertailuolio muuttujaan comparator.
  @Override
  public int compare(Point2D a, Point2D b) {
    int cmp = Double.compare(a.getX(), b.getX());
    if(cmp == 0) {
      cmp = Double.compare(a.getY(), b.getY());
    }
    return cmp;
  }
};
Arrays.sort(ps, comparator);  // Annetaan vertailuolio comparator parametrina.
for(Point2D p : ps) {
  System.out.format(" (%.1f, %.1f)", p.getX(), p.getY());
}

Vaikka edellä suoritetut lajittelut tehtäneen nykyisin useimmiten lambda-funktion avulla, on hyvä huomata, että nimettömät luokkamääritykset ovat lambda-funktioita selvästi monipuolisempia: lambda-funktio määrittää aina täsmälleen yhden funktion (joka riittääkin esim. lajitteluun), mutta nimetön luokkamääritys voi sisältää monen jäsenfunktion toteutukset sekä uusia jäsenmuuttujia. Näin ollen lambda-funktiot eivät täysin korvaa nimettomiä luokkamäärityksiä.

Rajapinnan Comparable<T> rooli on

Kun annamme lajittelufunktiolle parametriksi lambda-funktion

Jos itse toteutettua säiliötä halutaan käyttää iteroivassa for(V item : vals) for-silmukassa