Javan geneerisyys, osa 2

Tarkastellaan vielä muutamia Javan geneerisyyteen liittyviä teknisiä detaljeja. Nämä koskevat nimenomaan Javan tapaa toteuttaa geneerisyys. Esimerkiksi C++:n geneerisyys on toteutettu varsin erilailla (selvästi monipuolisemmin, joskin myös monimutkaisemmin) kuin Javassa.

Aiemmin käytettiin esimerkkinä seuraavaanlaista geneeristä luokkaa:

public class MyGenericClass<A, B> {
  private A valA;
  private B valB;

  MyGenericClass(A valA, B valB) {
    this.valA = valA;
    this.valB = valB;
  }

  public A getValA() {
    return valA;
  }

  public B getValB() {
    return valB;
  }
}

Sen perään todettiin, että yksinkertaistaen voisi ajatella, että Java-kääntäjä tulkitsisi esim. tyypin MyGenericClass<String, Double> seuraavasti:

public class MyGenericClass {
  private String valA;
  private Double valB;

  MyGenericClass(String valA, Double valB) {
    this.valA = valA;
    this.valB = valB;
  }

  public String getValA() {
    return valA;
  }

  public Double getValB() {
    return valB;
  }
}

Tämä tulkinta kuitenkin pätee vain käännöksen alkuvaiheessa, ja lopullinen luokka MyGenericClass näyttääkin seuravalta:

public class MyGenericClass {
  private Object valA;
  private Object valB;

  MyGenericClass(Object valA, Object valB) {
    this.valA = valA;
    this.valB = valB;
  }

  public Object getValA() {
    return valA;
  }

  public Object getValB() {
    return valB;
  }
}

Kaikkien tyyppiparametrien tilalle onkin lopulta korvattu Object! Tämä menettely johtuu yksinkertaisesti siitä, että Javassa “geneerisyys” on lähinnä käännöksenaikaisia tyyppitarkistuksia tukeva mekanismi. Ajatus, että samasta geneerisestä luokasta voisi luoda monia erilaisia variaatioita käyttämällä erilaisia tyyppiparametreja, on Javassa ainoastaan illuusio: geneerisestä luokasta luodaan vain yksi kaikkien luokan olioiden jakama toteutus, jossa kaikkien tyyppiparametrien tilalla on Object. Tällaista Javan geneerisen luokan varsinaista ilmentymää, josta tyypit on häivytetty, kutsutaan “raa’aksi tyypiksi” (raw type), ja siihen voi (ainakin vielä; Java tulevaisuudessa ehkä kieltää sen) viitata jättämällä tyyppiparametrilistan kokonaan pois. Esimerkiksi sekä ArrayList<String> että ArrayList<Double> pelkistyvät lopulta raa’aksi tyypiksi ArrayList.

Kaikkien tyyppiparametrien korvaaminen tyypillä Object toimii monessa suhteessa ongelmitta, koska Object on kaikkien viitetyyppien yliluokka ja siten esimerkiksi edellä luokan MyGenericClass funktioparametrit ja jäsenmuuttujat ovat yhteensopivia kaikkien olioiden kanssa. Menettely sisältää myös huomattavia rajoitteita, josta kohta lisää.

Tarkastellaan seuraavaa koodinpätkää, jossa käytetään luokkaa MyGenericClass:

MyGenericClass<String, Double> mgc1 = new MyGenericClass<>("data", 2.5);
String s = mgc1.getValA();
Double d = mgc1.getValB();
MyGenericClass<Integer, Character> mgc2 = new MyGenericClass<>(7, 'Y');
int i = mgc2.getValA();
char c = mgc2.getValB();

Java-kääntäjä suorittaisi tässä ensin tyyppitarkistukset: ovatko olioiden mgc1 ja mgc1 luontiin ja käyttöön liittyvät operaatiot laillisia niiden tyyppiparametrien suhteen. Esimerkiksi yritys asettaa int i = mgc1.getValB(); johtaisi kääntäjän virheilmoitukseen, koska tyyppiparametrien perusteella mgc1.valB on String. Tarkistusten jälkeen kääntäjä pelkistäisi luokan MyGenericClass raa’aksi tyypiksi ja samalla lisäisi koodiin tarvittaessa tyyppiparametreja vastaavat tyyppimuunnokset. Lopputulos olisi suunnilleen seuraavanlainen:

MyGenericClass mgc = new MyGenericClass("data", 2.5);  // Ok: kelpaavat Object-parametreiksi.
String s = (String) mgc.getValA();                     // Tyyppimuunnos Object -> String.
Double d = (Double) mgc.getValB();                     // Tyyppimuunnos Object -> Double.
MyGenericClass mgc2 = new MyGenericClass(7, 'Y'); // Ok: kelpaavat Object-parametreiksi.
int i = mgc2.getValA();                           // Tyyppimuunnos Object -> Integer -> int.
char c = mgc2.getValB();                          // Tyyppimuunnos Object -> Character -> char.

Tämä havainnollistaa sitä, että yksinkertaiset alkioiden välitystä koskevat operaatiot onnistuvat, vaikka geneerinen luokka olisikin pelkistynyt raa’aksi tyypiksi. Javassa kuitenkin on merkittäviä rajoituksia, millaisia operaatioita geneerisen luokan sisällä voi tehdä.

Tarkastellaan seuraavaa erittäin yksinkertaista geneeristä (ja laitonta!) luokkaa:

public class IllegalGenerics<T> {
  public T val = new T();
}

Miksi tämä luokka on laiton? Tarkastellaan seuraavaa sitä käyttävää koodinpätkää:

IllegalGenerics<Date> ig1 = new IllegalGenerics<>();
IllegalGenerics<String> ig2 = new IllegalGenerics<>();
Date d = ig1.val;
String s = ig2.val;

Koodi näyttää sinänsä lailliselta: voisimme kuvitella, että IllegalGenerics<Date>-olion ig1 luonti alustaa Date val = new Date() ja IllegalGenerics<String>-olion ig2 luonti String val = new String(). Tällöin (jos Date olisi java.util.Date) ig1 olisi nykyhetkeä kuvaava Date-olio ja ig2 tyhjä merkkijono. Edellä kuvitellut alustukset ovat kuitenkin mahdottomia, koska IllegalGenerics olisi ajonaikana (kun alustukset tehtäisiin) jo pelkistynyt raa’aksi tyypiksi:

public class IllegalGenerics {
  public Object val = new Object();
}

On selvää, ettei tällainen yleisluontoinen Object-olion luonti voisi kuvittelemallamme tavalla luoda yhdessä kohdassa Date-oliota ja toisessa kohdassa String-oliota. Geneerinen luokka ei tiedä ajonaikana enää mitään siitä, millaisten tyyppiparametrien kanssa sitä on lähdekoodissa käytetty.

Java ei myöskään salli tyyppiparametria T vastaavan taulukon luontia: esimerkiksi operaatio new T[10] tuottaisi käännösvirheen. Tämä voi tuntua sikäli yllättävältä, että operaation new T[10] pelkistetty muoto new Object[10] sinänsä tuottaisi taulukon, johon voisi tallettaa 10 tyypin T alkioita. Rajoite juontuu mm. Javan sopimuksesta, että Javan taulukot varmistavat ajonaikana, ettei taulukkoon aseteta vääräntyyppistä alkiota. Tämä ei onnistuisi järkevästi pelkistetyn taulukon tapauksessa, koska taulukolla ei olisi keinoa tutkia, yritetäänkö siihen tallettaa tyypin T alkioita vai jotain muuta. Jos geneerisen tyypin alkioita haluaa tallettaa taulukkoon, tulee käyttää eksplisiittisesti Object-taulukkoa ja tehdä tarvittaessa tyyppimuunnos Object T. Tyyppimuunnos ei tässä tapauksessa varsinaisesti muuttaisi olion tyyppiä (oletus, että olio on tyyppiä T) mutta vaaditaan, koska Object-viite sinänsä voisi viitata jonkin muunkin tyyppiseen olioon.

Alla on esimerkki edelläkuvatusta Object-taulukon käyttötavasta. Tässä tulee samalla myös esimerkki yksinkertaisen iteraattorin toteutuksesta taulukkopohjaiselle säiliölle:

public class GenericArray<E> implements Iterable<E> {  // Iteroitava tyyppi.
  private Object[] vals;         // E-alkiot tallettava Object-taulukko.
  private int size;              // Taulukon koko.

  public GenericArray(int size) {
    vals = new Object[size];
    this.size = size;
  }

  @SuppressWarnings("unchecked")
  public E get(int i) {
    return (E) vals[i];  // Tyyppimuunnos, joka ilmaisee paluuarvon tyypin olevan E. Oletamme
  }                      // tämän aina lailliseksi (että taulukossa oikeasti on vain E-olioita).

  public void set(int i, E val) {
    vals[i] = val;
  }

  public int size() {
    return size;
  }

  // Tästä alkaa iteraattorin toteutukseen liityvä osuus.
  public Iterator<E> iterator() {
    return new GAIterator();
  }

  private class GAIterator implements Iterator<E> {
    private int pos = 0;        // Iteroinnin nykyindeksi (liittyen taulukkoon vals).

    @Override
    public boolean hasNext() {
      return pos < size;        // Onko nykykohta vielä taulukon sisällä?
    }

    @Override
    @SuppressWarnings("unchecked")
    public E next() {
      if(pos >= size) {         // Heitetään poikkeus, ellei seuraavaa alkiota ole.
         throw new NoSuchElementException("No more values in the array!");
      }
      return (E) vals[pos++];   // Palautetaan iteroitavan indeksin alkio taulukosta vals.
    }                           // ja kasvatetaan iteroitavaa indeksiä askel eteenpäin.
  }
}

Esimerkissä on lisätty annotaatio @SuppressWarnings("unchecked") funktioiden get ja next eteen. Se ilmaisee kääntäjälle, että “tiedämme mitä teemme; älä varoita tässä funktiossa tehtävistä tarkistamattomista tyyppimuunnoksista”. Koodi kääntyisi ilman annotaatiotakin, mutta kääntäjä antaisi varoituksen “Note: GenericArray.java uses unchecked or unsafe operations.”. Varoitus koskee tyyppimuunnosta geneeriseen tyyppiin E, kuten (E) vals[i]. Java tavallisesti tarkistaa viitetyyppien välisen tyyppimuunnoksen laillisuuden ajonaikana. Geneeristä tyyppiä koskevan tyyppimuunnoksen kohdalla tarkistusta ei tehdä, koska tieto tyypistä E on häivytetty lopullisesta raa’asta luokasta GenericArray. Kyseessä on siis ns. tarkistamaton tyyppimuunnos, jonka laillisuus jää sen varaan, että todella käsittelemme ainoastaan tyypin E kanssa yhteensopivia olioita.

Tätä yksinkertaista GenericArray-säiliötä voisi käyttää esimerkiksi seuraavasti:

GenericArray<String> sa = new GenericArray<>(2);
GenericArray<Double> da = new GenericArray<>(3);
sa.set(0, "abc");
da.set(1, 3.14);
System.out.format("Stored string \"%s\" and double %.2f%n", sa.get(0), da.get(1));
for(String s : sa) {       // Toteuttaa Iterable-rajapinnan, joten voi iteroida.
  System.out.print("\"" + s + "\" ");
}
System.out.println();
for(Double d : da) {       // Toteuttaa Iterable-rajapinnan, joten voi iteroida.
  System.out.print(d + " ");
}

Koodinpätkä tulostaisi jokseenkin:

Stored string "abc" and double 3.14
"abc" "null"
null 3.14 null

Esimerkiksi Javan ArrayList<E> on toteutettu saman periaatteen mukaan kuin GenericArray<E>: alkiot talletetaan sisäisesti Object-taulukkoon ja tarvittaessa tyyppimuunnetaan tyyppiin E vaimentaen varoitukset annotaatiolla @SuppressWarnings("unchecked").

Tyyppiparametrien jokerimääritykset <?>, <? extends E> ja <? super E>

Jos selaat vaikkapa geneerisen rajapinnan List<E> dokumentaatiota, löydät sieltä esimerkiksi seuraavantapaiset jäsenfunktiot:

  • addAll(Collection<? extends E> c)

  • removeAll(Collection<?> c)

  • sort(Comparator<? super E> c)

Nämä herättänevät kysymyksen, mitä parametrien tyyppiparametrilistojen hieman erikoiset muodot <?>, <? extends E> ja <? super E> tarkoittavat. Nämä ovat eräänlaisia tyypin “jokerimäärityksiä”, jotka sallivat tyyppiparametrin täsmätä joukon erilaisia tyyppejä.

  • <?> täsmää minkä tyypin tahansa. Funktiolle removeAll(Collection<?> c) voi tämän ansiosta antaa parametriksi minkä tyyppisiä alkioita tahansa tallettavan Collection-säiliön; kyseinen tyyppi saa olla täysin riippumaton rajapinnan List<E> omaa tyyppiparametria E vastaavasta konkreettisesta tyypistä.

    • Toimii sinänsä oikein, koska funktio tutkii olioiden samanlaisuuden funktiolla equals, josta joko löytyy asianomaisia tyyppejä järkevästi vertaileva toteutus tai vertailu tehdään yliluokalta Object perityllä identiteettejä vertailevalla funktiolla. Jälkimmäinen ei koskaan voi luulla kahta erityyppistä oliota samanlaisiksi.

  • <? extends E> täsmää tyypin E tai sen minkä tahansa alityypin. Funktiolle addAll(Collection<? extends E> c) voi tämän ansiosta antaa parametriksi sellaisia Collection-säiliön, jonka alkioiden tyyppi on listan alkiotyyppi E tai sen alityyppi.

    • Funktion addAll kannalta on luontevaa sallia parametriksi tyypin E lisäksi sen alityypit, koska määritelmällisesti alityypin olioita voidaan käsitellä myös niiden ylityypin (eli tässä tyypin E) olioina.

    • Tämän jokerimäärityksen eräs mahdollisesti hyödyllinen efekti on, että sen avulla tyyppiparametri voidaan rajata olemaan yhteensopiva jonkin tietyn konkreettisen tyypin kanssa. Esim. <? extends SomeClass> hyväksyisi tyyppiparametriksi vain tyypin SomeClass tai sen alityypin, ja tämän ansiosta kyseistä tyyppiparametria vastaavan olion kautta olisi laillista viitata luokan SomeClass jäseniin. Tavallisestihan geneerisissä luokissa tai funktioissa voisi viitata tyyppiparametria vastaavan olion kautta ainoastaan yliluokan Object jäseniin.

  • <? super E> täsmää tyypin E tai sen minkä tahansa ylityypin. Funktiolle sort(Comparator<? super E> c) voi tämän ansiosta antaa parametriksi sellaisen Comparator-olio, joka osaa vertailla listan alkiotyypin E tai sen jonkin ylityypin olioita.

    • Tässä funktiolle sort annettu vertailuolio osaa verrata keskenään kahta tyypin E ylityypin alkiota, ja tämä soveltuu silloin myös tyyppiä E olevien alkioiden lajitteluun, koska tyypin E alkioita voidaan määritelmällisesti käsitellä myös sen ylityypin alkioina.

Jokerimäärityksessä voi käyttää myös konkreettista tyyppiä (luokkaa tai rajapintaa). Esimerkiksi jokerimääritys <? extends Number> täsmäisi luokan Number sekä sen aliluokat, kuten esim. Double, Float ja Integer.

Ylempänä tarkasteltujen kolmen funktion yhteydessä tuli jo hieman ilmi, mitä hyötyä jokerimäärityksien käytöstä voisi olla. On hyvä huomauttaa, että jokerimäärityksiä tarvitaan yleensäottaen tilanteissa, joissa halutaan täsmätä jonkinlainen tyyppiparametrista riippuva tyyppi, jossa tyyppiparametri saa vaihdella. Esimerkiksi jos haluaisimme määrittää funktion, joka hyväksyy parametrikseen yleensäottaen jonkintyyppisiä alkioita tallettavan ArrayList-olion, tulee parametrin tyypiksi määrittää ArrayList<?>. Esimerkiksi ArrayList<Object> ei kelpaisi, sillä se hyväksyisi parametrikseen vain nimenomaan tyyppiä ArrayList<Object> olevia olioita. Tässä ei siis päde sellainen tyyppihierarkiaan pohjautuva logiikka, että koska Object on kaikkien tyyppien ylityyppi, pitäisi minkätyyppisiä olioita tahansa sisältävän listan olla yhteensopiva Object-listan kanssa. Esimerkiksi alustus ArrayList<Object> ol = new ArrayList<Integer>() olisi laiton.

Edelliseen on ihan hyvä syy: muuten esimerkiksi olisi mahdollista toimia seuraavasti.

// Esimerkki, joka tuottaisi kääntäjän virheen.
ArrayList<Integer> ia = new ArrayList<>();
ArrayList<Object> oa = ia;  // Ok, jos ArrayList<Object> ja ArrayList<Integer> yhteensopivia.
oa.add("I am not an Integer!");  // Tämä on sinänsä sallittua, koska String on Object.

Edellä oltaisiin onnistuttu lisäämään Integer-listaan epäyhteensopiva String-merkkijono. Esimerkkikoodi ei tosiaan kuitenkaan käänny, koska kääntäjä ei hyväksyisi asetusta oa = ia.

Java on edellisen seikan suhteen siinä mielessä hieman ristiriitainen kieli, että taulukoiden kohdalla tyyppi Object[] on yhteensopiva kaikkien viitetyyppisten taulukoiden kanssa ja toimisi esimerkiksi sellaisen funktion parametrin tyyppinä, jonka halutaan hyväksyvän parametrikseen millaisen (viitetyyppisiä alkioita sisältävän) taulukon tahansa. Esimerkiksi asetus Object[] oa = new Integer[5] on laillinen. Tämän vuoksi edellistä kääntäjän hylkäämää esimerkkiä vastaava taulukkoihin pohjautuva versio onnistuttaisiin kääntämään ja suorittamaan:

// Esimerkki, joka kääntyy, mutta johtaisi ajonaikaiseen virheeseen.
Integer[] ia = new Integer[2];
Object[] oa = ia;   // Tämä on laillinen: Object[] ja Integer[] ovat Javassa yhteensopivia.
oa[0] = "I am not an Integer!";  // Tämä on sinänsä sallittua, koska String on Object.

Edellä näennäisesti onnistuttaisiin lisäämään String-merkkijono Integer-taulukkoon. Tämä koodi kuitenkin aiheuttaa ajonaikaisen virheen, koska Javassa taulukot tietävät oman aidon tyyppinsä ja tarkistavat ajonaikana, ettei taulukkoon yritetä asettaa epäyhteensopivia alkioita. Sen sijaan geneeriset säiliöt eivät raa’aksi tyypiksi pelkistyttyään tiedä tallettamiensa alkioiden tyyppejä eikä niiden siten ole mahdollista tehdä vastaavaa ajonaikaista tyyppitarkistusta. Tämä ero osaltaan selittää, miksi taulukoiden ja geneeristen tyyppien yhteensopivuus määräytyy eri tavalla.