Rajapinnat Javassa, osa 1

Rajapinta muistuttaa luokkaa, mutta sitä ei ole tarkoitettu määrittämään kokonaisvaltaista toiminnallisuutta vaan lähinnä kuvailemaan tyypin ominaisuudet. Rajapinta määrittää tyypin, jota voi käyttää muuttujien tyyppinä, mutta rajapinnasta itsestään ei voi suoraan luoda olioita vaan rajapintaa vastaavat oliot ovat aina jonkin kyseisen rajapinnan toteuttavan luokan olioita. Edellä käytetty ilmaisu “toteuttaminen” on rajapintojen vastine perinnälle. Esimerkiksi luokka ArrayList toteuttaa rajapinnan List.

Rajapinta määritellään lähes täysin samaan tapaan kuin luokka, mutta määrityksessä käytetään avainsanan class sijaan avainsanaa interface. Alla on esimerkkinä kahden Javan luokkakirjaston tarjoaman varsin yksinkertaisen rajapinnan AutoCloseable ja Readable määritykset (joiden tulisi olla eri tiedostoissa AutoCloseable.java ja Readable.java ja lisäksi sisältää tarvittavat, tästä pois jätetyt, import-lauseet):

public interface AutoCloseable {
  void close() throws Exception;
}

public interface Readable {
  int read(CharBuffer cb) throws IOException;
}

Edellä rajapinta AutoCloseable määrittää tyypin, jolla on julkinen jäsenfunktio void close(), ja rajapinta Readable tyypin, jolla on julkinen jäsenfunktio int read(CharBuffer cb). Nämä funktiot on kuitenkin ainoastaan esitelty: ne ovat ns. abstrakteja jäsenfunktioita eli jäsenfunktioita, joille ei ole määritetty toteutusta (funktion runkoa) vaan ainoastaan funktion otsake. Kun luokka toteuttaa rajapinnan, on sen annettava konkreettiset toteutukset kaikille toteutettavan rajapinnan määrittämille abstrakteille jäsenfunktioille. Edelliset rajapintamääritykset eivät erikseen määrittäneet jäsenfunktioitaan julkisiksi: rajapintojen jäsenfunktiot ovat automaattisesti julkisia, ellei erikseen toisin määritetä.

Luokka voi toteuttaa yhtäaikaa monta eri rajapintaa. Rajapinnan toteuttaminen ilmaistaan lisäämällä luokan nimen perään avainsana implements, jonka perässä luetellaan pilkuilla eroteltuina kaikki luokan toteuttamat rajapinnat. Jos luokka myös perii jonkin yliluokan, annetaan edellä mainittu extends-osuus vasta perityn luokan nimen perässä.

Alla on (sinänsä täysin keinotekoinen) esimerkkikoodi, jossa määritetään ylempänä esitellyt rajapinnat AutoCloseable ja/tai Readable toteuttavat luokat A, B ja C. Näitä kolmea luokka ei ole tässä määritetty julkisiksi, jotta koodia voisi testata asettamatta luokkia A, B ja C erillisin tiedostoihin.

class A implements AutoCloseable {  // A toteuttaa rajapinnan AutoCloseable.
  // Ilmaistaan annotaatiolla @Override, että tässä toteutetaan rajapinnalta "peritty" funktio.
  @Override
  public void close() throws Exception {   // A:n pitää toteuttaa AutoCloseable:n funktio close.
    System.out.println("Closing A...");
  }
}

class B implements AutoCloseable, Readable { // B toteuttaa AutoCloseable:n ja Readable:n.
  @Override
  public void close() throws Exception {           // Toteutettaa AutoCloseable:n funktio close.
    System.out.println("Closing A...");
  }

  @Override
  public int read(CharBuffer cb) throws IOException {  // Toteutettava Readable:n funktio read.
    System.out.println("B is reading...");
    return 0;
  }
}

class C implements Readable {  // C toteuttaa rajapinnan Readable.
  @Override
  public int read(CharBuffer cb) throws IOException {  // Toteutettava Readable:n funktio read.
    System.out.println("C is reading...");
    return 0;
  }
}

public class InterfaceTest {
  public static void main(String[] args) throws Exception {
    // A ja B toteuttavat rajapinnan AutoCloseable, joten niitä voidaan käsitellä
    // AutoCloseable-olioina. Asetetaan A- ja B-oliot AutoCloseable-taulukkoon.
    AutoCloseable[] acs = {new A(), new B()};
    for(AutoCloseable ac : acs) {
      ac.close();  // Tässä voidaan viitata funktioon close, koska AutoCloseable määrittää sen.
    }

    // B ja C toteuttavat rajapinnan Readable, joten niitä voidaan käsitellä
    // Readable-olioina. Asetetaan B- ja C-oliot Readable-taulukkoon.
    Readable[] rs = {new B(), new C()};
    CharBuffer cb = CharBuffer.allocate(0);  // Tyhjä CharBuffer ihan vain read-kutsua varten.
    for(Readable r : rs) {
      r.read(cb);  // Tässä voidaan viitata funktioon read, koska Readable määrittää sen.
    }
  }
}

Tarkemmin ottaen Javassa rajapinnan määritys voi sisältää seuraavia osia:

  • Jäsenmuuttujia, joiden on pakko olla julkisia staattisia vakiojäsenmuuttujia, jotka alustetaan suoraan jäsenmuuttujan määrityksen yhteydessä.

    • Kääntäjä olettaa automaattisesti määreet public static final, mutta ne on laillista antaa erikseenkin.

    • Rajapinnat eivät voi määrittää muunlaisia (esim. yksityisiä tai ei-staattisia) jäsenmuuttujia.

  • Julkisia jäsenfunktioita (rajapinnan jäsenfunktio on ilman erillistä määritystä public).

    • Abstrakteja ei-staattisia jäsenfunktioita.

      • Abstrakti = ei anneta toteusta vaan ainoastaan esitellään funktio (sen otsake ilman runkoa).

      • Rajapinnan toteuttavan luokan on annettava funktiolle konkreettinen toteutus.

      • Nämä lienevät rajapintojen tärkein osa. Ne määrittävät, millaisia jäsenfunktioita ja siten millaisia toimintoja rajapinnan toteuttavan luokan tulee tarjota.

    • Ei-staattisia jäsenfunktioita, joille on annettu oletustoteutus.

      • Funktion otsakkeen edessä annetaan määre default ilmaisemaan, että kyseessä on oletustoteutus.

      • Kokonainen funktiomääritys, joka toteutetaan samaan tapaan kuin tavallisen luokan jäsenfunktio. Huomaa kuitenkin, että koska rajapinta ei voi määrittää ei-staattisia jäsenmuuttujia, ei oletustoteutus voi viitata sellaisiin vaan sen on pitkälti nojauduttava muihin rajapinnan määrittämiin jäsenfunktioihin.

      • Rajapinnan toteuttava luokka perii oletustoteutuksen (eikä luokan siten ole pakko toteuttaa sitä itse).

    • Staattisia jäsenfunktioita (joille on annettu toteutus; eivät voi olla abstrakteja).

      • Toteutus pitkälti kuin tavallisen luokan staattinen jäsenfunktio.

  • Yksityisiä jäsenfunktioita (annettu eskplisiittisesti määre private).

    • Rooli: rajapinnan sisäisiä apufunktioita (käytännössä tarvinnee todella harvoin).

    • Voivat olla ei-staattisia tai staattisia ja niille on pakko antaa toteutus.

Keskitymme etenkin rajapintojen tärkeimpiin osiin eli abstrakteihin jäsenfunktiohin.

Luokan ja sen toteuttaman rajapinnan suhde muistuttaa luokan ja sen perimän yliluokan suhdetta: rajapinnan toteuttaminen tarkoittaa, että luokalla on kaikki rajapinnan määrittämät ominaisuudet ja kyseisen luokan olioita voidaan siten käsitellä toteutetun rajapinnan tyyppisinä olioina. Kuten alussa todettiin, ei rajapinnasta itsesään voi olla olemassa konkreettisia ilmentymiä. Rajapinnan tyyppisinä olioina käsittely tarkoittaa käytännössä sitä, että viitemuuttujan tyyppi voi vastata rajapintaa ja tällaisen viitemuuttujan kautta voidaan viitata kyseisen rajapinnan määrittämiin jäseniin. Esimerkiksi koska rajapinta AutoCloseable määrittää jäsenfunktion close, on AutoCloseable-tyyppisen viitteen kautta sallittua kutsua jäsenfunktiota close.

Rajapinta voi myös periä toisia rajapintoja. Tämä tapahtuu samaan tapaan kuin luokkien kohdallakin käyttäen avainsanaa extends, mutta erona vain mahdollisuus periä yhtäaikaa monta rajapintaa. Perivä rajapinta perii perimiensä rajapintojen kaikki julkiset jäsenet. Tämä tarkoittaa käytännössä, että useita rajapintoja perivän täyttävän luokan on tarjottava toteutukset kaikkien perittyjen rajapintojen abstrakteille jäsenfunktioille.

Eräs yksinkertainen esimerkki rajapintojen välisestä perinnästä on rajapinnan AutoCloseable perivä rajapinta Closeable.

public interface Closeable extends AutoCloseable {
  void close() throws IOException;
}

Rajapinta Closeable määrittää, ja itse asiassa korvaa perimänsä, jäsenfunktion close. Funktioiden ainoa ero on, että rajapinta Closeable tarkentaa funktion close mahdollisesti aiheuttaman poikkeuksen tyypiksi IOException (eikä Exception). Rajapinnan ei tarvitse antaa toteutuksia myöskään perimilleen jäsenfunktioille, ja tässäkin close säilyi abstraktina jäsenfunktiona, jonka rajapinnan toteuttavan luokan tulee toteuttaa.

Abstraktit luokat

Rajapinnan kaltainen vaillinainen tyyppi voidaan määrittää Javassa myös ns. abstraktina luokkana. Luokka määritetään abtraktiksi lisäämällä sen määrityksessä avainsanan class eteen määre abstract. Abstrakti luokka muistuttaa rajapintaa: abstraktista luokasta ei voi luoda olioita vaan sitä vastaavat olio ovat aina jonkin sen perivän luokan olioit, ja abstraktilla luokalla voi olla abstrakteja eli ainoastaan esiteltyjä jäsenfunktioita. Abstrakti luokka on muuten täysin tavallinen luokka. Rajapinnan ja abstraktin merkittävimmät erot ovat:

  • Rajapinnan abtraktia jäsenfunktiota ei tarvitse erikseen määrittää abstraktiksi, mutta abstraktin luokan kunkin abstraktin jäsenfunktion eteen pitää lisätä määre abstract.

  • Rajapinta toteutetaan avainsanalla implements, abstrakti luokka peritään avainsanalla extends.

  • Luokka voi toteuttaa yhdellä kertaa monta eri rajapintaa mutta periä vain yhden abstraktin luokan.

    • Abstraktin luokan periminen vastaa säännöiltään tavallista luokkien perintää, joten moniperintä on kielletty. Oikeastaan ainoa ero tavallisen luokan ja abstraktin luokan perinnässä on, että abstraktin luokan perivän luokan tulee joko antaa toteutukset kaikille perityille abstrakteille jäsenfunktioille tai olla itsekin abstrakti luokka.

  • Abtrakti luokka voi määrittää kaikenlaisia jäseniä, mutta rajapinta on rajoitetumpi (ei voi esimerkiksi määrittää oliokohtaisia jäsenmuuttujia).

Alla on esimerkkinä taulukkosäiliön yliluokaksi tarkoitettu abstrakti luokka AbstractArray sekä sen perivä luokka Array. AbstractArray jättää perivän luokan vastuulle varsinaisen taulukon varauksen sekä funktioiden get, set ja size toteutuksen, ja tarjoaa itse esimerkkinä yhden näiden kolmen funktion varassa toimivan funktion reverse. Luokat käsittelevät alkoita Object-viitteiden kautta eli taulukkoon voi tallettaa kaikentyyppisiä olioita. Esimerkissä on mukana myös poikkeusten heittäminen, jos koko tai indeksi on laiton. Tässä heitetyt poikkeustyypit ovat Javassa valmiiksi käytettävissä (kuuluvat pakkaukseen java.lang).

public abstract class AbstractArray {
  public abstract Object get(int i);               // Lukee indeksin i alkion.
  public abstract void set(int i, Object item);    // Asettaa indeksin i alkion.
  public abstract int size();                      // Palauttaa taulukon koon.

  public void reverse() {  // Kääntää taulukon alkiot päinvastaiseen järjestykseen.
    for(int start = 0, end = size() - 1; start < end; start++, end--) {
      Object tmp = get(start);
      set(start, get(end));
      set(end, tmp);
    }
  }
}

public class Array extends AbstractArray {  // Peritään AbstractArray.
  private Object[] items;        // Alkiot tallettava Object-taulukko.
  private int size;              // Taulukon koko.

  public Array(int size) {       // Rakennin alustaa taulukon parametrin kokoiseksi.
    if(size < 0) {
      throw new NegativeArraySizeException(String.format("Illegal size: %d", size));
    }
    items = new Object[size];
    this.size = size;
  }

  public Object get(int i) {
    if(i < 0 || i >= size) {
      throw new IndexOutOfBoundsException(String.format("Illegal index: %d", i));
    }
    return items[i];
  }

  public void set(int i, Object val) {
    if(i < 0 || i >= size) {
      throw new IndexOutOfBoundsException(String.format("Illegal index: %d", i));
    }
    items[i] = val;
  }

  public int size() {
    return size;
  }
}

Taulukkoa Array voisi käyttää esimerkiksi seuraavasti:

Array arr = new Array(3);
arr.set(0, "One");
arr.set(1, "Two");
arr.set(2, "Three");
System.out.format("%s %s %s%n", arr.get(0), arr.get(1), arr.get(2));
arr.reverse();
System.out.format("%s %s %s%n", arr.get(0), arr.get(1), arr.get(2));

Esimerkki tulostaisi:

One Two Three
Three Two One

Abstrakteja luokkia käytetään lähinnä sellaisten toistensa kanssa samankaltaisten luokkien yliluokkana, joille abstrakti yliluokka voi tarjota yhteistä toiminnallisuutta (esimerkiksi valmiiksi toteutettuja jäsenfunktioita, joita aliluokat voivat hyödyntää). Javan luokkakirjasto sisältää esimerkiksi abstraktin luokan AbstractList, joka on tarkoitettu taulukkoon pohjautuvan lista-säiliön yliluokaksi ja sisältää valmiit toteutukset monelle tällaisen listan jäsenfunktiolle. Esimerkiksi ArrayList on luokan AbstractList aliluokka. Edellisen esimerkin toimintaperiaate muistutti osin näitä luokkia. ArrayList ja AbstractList ovat tarkemmin ottaen geneerisiä luokkia. Geneerisyyttä käsitellään kohta.

Tyyppimuunnos ja olion tyypin tutkiminen

Tässä vaiheessa lienee käynyt selväksi, että alityypin olioon on aina laillista viitata sen ylityypin mukaisella viitteellä, koska alityypillä on myös ylityyppinsä ominaisuudet. Koska se, mihin jäseniin viitemuuttujan kautta saa viitata, määräytyy viitteen tyypin perusteella, ei alityypin oliosta voi tällöin viitata kuin sen ylityypin määrittämiin jäseniin. Esimerkiksi seuraava ei onnistu, koska ylityypillä Object ei ole jäsenfunktiota length:

Object o = "I am a string";
o.length();      // Kääntäjän virheilmoitus: cannot find symbol o.length().

Jotta ylityypin kautta viitatun alityypin omia (ylityypiltä puuttuvia) jäsenfunktioita voisi kutsua, täytyy siihen viitata alityyppiä vastaavalla viitteellä. Tämä vaatii pakotetun tyyppimuunnoksen, koska muunnos ylityypistä alityyppiin ei ole itsestäänselvästi laillinen. Esimerkiksi alla ensimmäinen yritys tuottaisi käännösvirheen mutta toinen onnistuisi:

Object o = "I am a string";
String s = o; // Kääntäjän virheilmoitus: Object cannot be converted to String.
String s = (String) o;     // Laillinen, koska o oikeasti on String.

Java tarkistaa viitetyyppien välisen tyyppimuunnoksen laillisuuden ajonaikana. Ellei muunnettu olio ole oikeasti yhteensopiva kohdetyypin kanssa, aiheutuu ClassCastException. Esimerkiksi alla laiton yritys muuntaa String-olio o Integer-tyyppiseksi aiheuttaa poikkeuksen.

Object o = "I am a string";
try {
  Integer i = (Integer) o;     // Laiton yritys muuntaa String -> Integer.
}
catch(ClassCastException e) {
  System.out.println("An illegal cast brought me here...");
}

Tässä on hyvä painottaa, että tyyppimuunnoksen laillisuus riippuu vain olion omasta aidosta tyypistä; sillä ei ole merkitystä, minkätyyppisellä viitteellä olioon viitataan ennen tyyppimunnosta. Lisäksi oleellinen seikka on, ettei viitetyyppien välinen tyyppimuunnos muuta oliota: se muuntaa vain viittauksen tyypin. Olion oma aito luokka ja ominaisuudet säilyvät ennallaan.

Olion tyyppiä voi tarkastella ajonaikana esimerkiksi operaattorilla instanceof. Muotoa o instanceof T olevan operaatio tulos on totuusarvo, joka kertoo, onko olio o yhteensopiva tyypin T kanssa. Tämä ei siis rajoitu pelkästään olion omaan varsinaiseen tyyppiin vaan tulos on true myös jos T on olion o ylityyppi. Olion o tyyppimuunnos tyyppiin T on laillinen jos ja vain jos o instanceof T on true.

Tarkastellaan esimerkiksi Float-olion kanssa yhteensopivia tyyppejä. Luokalla Float on yliluokat Number ja Object ja se toteuttaa mm. rajapinnan Constable (joka on lähinnä Java-virtuaalikoneen vakioarvojen esittämiseen liittyvä rajapinta). Alla tutkitaan Float-olion yhteensopivuus näiden tyyppien sekä luokan Double kanssa. Tulostuksessa käytetty määre %b vastaa totuusarvoa.

Object o = 3.14F;
System.out.format("o instanceof Object: %b%n", o instanceof Object);
System.out.format("o instanceof Number: %b%n", o instanceof Number);
System.out.format("o instanceof Float: %b%n", o instanceof Float);
System.out.format("o instanceof Constable: %b%n", o instanceof Constable);
System.out.format("o instanceof Double: %b%n", o instanceof Double);

Tämä koodinpätkä tulostaisi:

o instanceof Object: true
o instanceof Number: true
o instanceof Float: true
o instanceof Constable: true
o instanceof Double: false

Näistä viimeisin kuvastaa sitä, kuinka viitetyyppien väliset tyyppimuunnokset pohjautuvat tyyppihierarkiaan eikä olioiden arvojen periaatteelliseen yhteensopivuuteen. Siksi esimerkiksi alla ensimmäinen tyyppimuunnos epäonnistuu (f instanceof Double on false), mutta jälkimmäinen automaattisten boxing-muunnosten kautta primitiiviarvojen välisenä tyyppimuunnoksena tapahtuva muunnos onnistuu:

Float f = 3.14F;
Double d = (Double) f;  // Käännösvirhe: Float cannot be converted to Double.
Double d = (double) f;  // Ok: Float -> float -> double -> Double.

Mitkä seuraavista pitävät paikkansa Javassa

Koodausdemo (kesto 1:07:01)