Javan geneerisyys, osa 2¶
Tarkastellaan vielä muutamia Javan geneerisyyteen liittyviä teknisiä seikkoja. 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ä seuraavanlaista 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 esimerkiksi
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. Lopullinen luokka
MyGenericClass
näyttääkin itse asiassa seuraavalta:
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 muun muassa 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 niin sanottu 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. FunktiolleremoveAll(Collection<?> c)
voi tämän ansiosta antaa parametriksi minkä tyyppisiä alkioita tahansa tallentavanCollection
-säiliön; kyseinen tyyppi saa olla täysin riippumaton rajapinnanList<E>
omaa tyyppiparametriaE
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 yliluokaltaObject
perityllä identiteettejä vertailevalla funktiolla. Jälkimmäinen ei koskaan voi luulla kahta erityyppistä oliota samanlaisiksi.
<? extends E>
täsmää tyypinE
tai sen minkä tahansa alityypin. FunktiolleaddAll(Collection<? extends E> c)
voi tämän ansiosta antaa parametriksiCollection
-säiliön, jonka alkioiden tyyppi on listan alkiotyyppiE
tai sen alityyppi.Funktion
addAll
kannalta on luontevaa sallia parametriksi tyypinE
lisäksi sen alityypit, koska määritelmällisesti alityypin olioita voidaan käsitellä myös niiden ylityypin (eli tässä tyypinE
) olioina.Tämän jokerimäärityksen eräs mahdollisesti hyödyllinen vaikutus on, että sen avulla tyyppiparametri voidaan rajata olemaan yhteensopiva jonkin tietyn konkreettisen tyypin kanssa. Esimerkiksi
<? extends SomeClass>
hyväksyisi tyyppiparametriksi vain tyypinSomeClass
tai sen alityypin, ja tämän ansiosta kyseistä tyyppiparametria vastaavan olion kautta olisi laillista viitata luokanSomeClass
jäseniin. Tavallisestihan geneerisissä luokissa tai funktioissa voisi viitata tyyppiparametria vastaavan olion kautta ainoastaan yliluokanObject
jäseniin.
<? super E>
täsmää tyypinE
tai sen minkä tahansa ylityypin. Funktiollesort(Comparator<? super E> c)
voi tämän ansiosta antaa parametriksiComparator
-olio, joka osaa vertailla listan alkiotyypinE
tai sen jonkin ylityypin olioita.Tässä funktiolle
sort
annettu vertailuolio osaa verrata keskenään kahta tyypinE
ylityypin alkiota, ja tämä soveltuu silloin myös tyyppiäE
olevien alkioiden lajitteluun, koska tyypinE
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
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 minkä tahansa tyyppisiä 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 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.