Javan geneerisyys, osa 1¶
Luokkien perusteiden yhteydessä esitelty LinkedStack
-luokkana määritetty pino käsitteli olioita
Object
-viitteiden avulla. Tämän tavan yksi heikkous on, ettemme voi pinon määrityksen yhteydessä
ilmaista haluavamme tallettaa siihen nimenomaan tietyntyyppisiä olioita. Esimerkiksi seuraavat
askeleet ovat Java-kääntäjän näkökulmasta laillisia, mutta johtavat ajonaikaiseen virheeseen:
LinkedStack integerStack = new LinkedStack(); // Pino Integer-lukuja varten?
stack.push("5"); // Hups: pinoon String-merkkijono "10".
stack.push(10); // Pinoon autoboxingin myötä Integer-arvo 10.
...
Integer a = (Integer) stack.pop(); // Ok, pinon päältä Integer-arvo 10.
Integer b = (Integer) stack.pop(); // Virhe! Päällä "5", joka ei muunnu Integeriksi.
Tällainen tyyppien sekoittamista koskeva virhe löytyisi jo käännösaikana, jos LinkedStack
tukisi samanlaisen tyyppiparametrin käyttöä kuin esimerkiksi ArrayList
. Tällöin edellistä
vastaavan koodinpätkän ensimmäiset rivit olisivat seuraavaa muotoa, ja kääntäjä osaisi jo heti
merkkijonon “5” lisäysyrityksen yhteydessä antaa virheen, koska “5” ei sellaisenaan kelpaa
Integer
-olioksi.
LinkedStack<Integer> integerStack = new LinkedStack<>(); // Pino Integer-lukuja varten.
stack.push("5"); // Kääntäjän virhe: yritys lisätä String Integer-pinoon.
Jotta LinkedStack
hyväksyisi alkioiden tyypin määrittävän tyyppiparametrin, täytyy sen
luokkamääritys muuttaa geneeriseksi eli hyväksymään tyyppiparametreja. Tämä on Javassa
syntaktisesti varsin suoraviivaista: lisätään luokkamäärityksessä luokan nimen perään muotoa
<tyyppiParametri1, ..., tyyppiParametriN>
oleva tyyppiparametrilista, joka koostuu
kulmasulkeiden sisällä pilkuilla erotelluista tyyppiparametreista. Tyyppiparametrit toimivat hieman
samaan tapaan kuin tavalliset parametrit, mutta tyyppiparametrit vain välittävät arvojen sijaan
tyyppejä. Tyyppiparametreihin sidotaan jotkin konkreettiset tyypit, kun koodissa viitataan
geneeriseen luokkaan konkreettisen parametrilistan kera. Tyyppiparametreja käytetään geneerisen
luokan määrityksen sisällä aivan kuin ne olisivat konkreettisia tyyppejä. Tyyppiparametrit voi
sinänsä nimetä jokseenkin yhtä vapaasti kuin tavallisetkin parameterit. Vakiintunut käytäntö
kuitenkin on, että tyyppiparametrit nimetään käyttäen yksittäisiä isoja kirjaimia.
Seuraava luokkamääritys havainnollistaa geneerisen luokkamäärityksen syntaksia:
// Joidenkin kahden tyypin A ja B suhteen parametrisoitu geneerinen luokka.
public class MyGenericClass<A, B> {
private A valA; // Jäsenmuuttuja, jonka tyypin kertoo tyyppiparametri A.
private B valB; // Jäsenmuuttuja, jonka tyypin kertoo tyyppiparametri B.
// Rakennin, joka alustaa jäsenet.
// Huomaa, ettei tyyppiparametrilista ole osa rakentimen nimeä.
MyGenericClass(A valA, B valB) {
this.valA = valA;
this.valB = valB;
}
public A getValA() {
return valA;
}
public B getValB() {
return valB;
}
}
Eräs geneerisen luokan toteutuksessa huomioitava seikka on, ettei tyyppiparametrin mukaisen tyypin
oliossa voi viitata kuin sellaisiin jäseniin, jotka tyypillä tiedetään varmasti olevan. Tämä
tarkoittaa perustapauksessa sitä, että voi viitata vain kaikilla olioilla varmasti oleviin
yliluokalta Object
periytyviin jäseniin. Tämän vuoksi tyyppiparametrit sopivat ensisijaisesti
olioiden “passiiviseen” käsittelyyn; tilanteisiin, jossa ei tarvitse kutsua olion jäsenfunktioita
tai viitata sen muihinkaan jäseniin. Säiliöt ovat tyypillisesti tällaisia: ne vain tallettavat
olioita tarvitsematta tietää, millaisia jäseniä olioilla on. Jos meillä olisi tarve olettaa, että
olio on yhteensopiva nimenomaan jonkin tietyn tyypin kanssa, ei kyseisen olion tyyppiä yleensä
tulisi alunperinkään määrittää tyyppiparametrina vaan konkreettisena tyyppinä. Esimerkiksi jos
edellä muuttujan valB
haluttaisiin olevan yhteensopiva vaikkapa tyypin Reader
kanssa,
tulisi se tavallisesti määrittää suoraan tapaan Reader valB
ja jättää tyyppiparametri B
pois.
Kuten edellä todettiin, geneerisen luokan tyyppiparametrit saavat konkreettiset tyyppinsä, kun
luokkaan viitataan tyyppiparametrien kera. Esimerkiksi Alla on tyyppi
MyGenericClass<String, Double>
, joka vastaisi yksinkertaistetusti tulkittuna seuraavanlaista
luokkaa, joka olisi jo alunperin määritetty käyttäen tyyppiparametrien A
ja B
tilalla
tyyppejä String
ja Double
:
// Yksinkertaistus siitä, miten kääntäjä tulkitsee tyypin MyGenericClass<String, Double>.
public class MyGenericClass {
private String valA; // Tyyppiparametrin A tilalla String.
private Double valB; // Tyyppiparametrin B tilalla Double.
MyGenericClass(String valA, Double valB) { // A ja B -> String ja Double
this.valA = valA;
this.valB = valB;
}
public String getValA() { // A -> String
return valA;
}
public Double getValB() { // B -> Double
return valB;
}
}
Myöhemmin selitetään, miksi edellä on kyseessä varsin karkea yksinkertaistus.
Javassa geneerisen luokan tyyppiparametriksi voi välittää vain viitetyypin. Tämä myös on taustasyy
sille, miksi Javan omiin säiliöluokkiinkin voi tallettaa ainoastaan viitetyyppisiä alkioita.
Esimerkiksi tyyppi MyGenericClass<int, double>
olisi primitiivityyppien vuoksi laiton.
Alla on realistisempana esimerkkinä luokan LinkedList
geneerinen versio, jossa alkioiden tyypin
ilmoittavalle tyyppiparametrille on annettu nimi E
.
// Päätason geneerinen luokka LinkedStack. Yksi tyyppiparametri: E
public class LinkedStack<E> {
// Sisäinen luokka Node, joka tallettaa tyypin E alkioita.
public class Node {
private Node next;
private E item; // Alkion tyyppi on E eli se konkreettinen tyyppi, joka
// luokkaan viitatessa välitetään tyyppiparametrina E.
private Node(Node next, E item) { // Alkion tyyppi E.
this.next = next;
this.item = item;
}
Node getNext() {
return this.next;
}
E getItem() { // Palauttaa alkion eli paluuarvon tyyppi E.
return this.item;
}
}
private Node top = null;
private int n = 0;
public Node peek() {
return this.top;
}
public Node pop() {
Node result = this.top;
if(this.top != null) {
this.n -= 1;
this.top = this.top.getNext();
}
return result;
}
public void push(E item) { // Alkion tyyppi E.
this.top = new Node(this.top, item);
this.n += 1;
}
public int size() {
return this.n;
}
// Testausta varten main-funktio, joka olettaa komentoriviparametrien esittävän
// kokonaislukuja ja tallettaa ne sekä String- että Integer-pinoihin.
public static void main(String[] args) {
LinkedStack<String> strStack = new LinkedStack<>(); // String-pino.
LinkedStack<Integer> intStack = new LinkedStack<>(); // Integer-pino.
for(String arg : args) {
strStack.push("\"" + arg + "\""); // Heittomerkeillä ympäröitynä String-pinoon.
intStack.push(Integer.parseInt(arg)); // Kokonaisluvuksi muutettuna Integer-pinoon.
}
for(int i = 0; i < args.length; i++) {
System.out.println("Current stack sizes: " + strStack.size() + " and "
+ intStack.size());
String topStr = strStack.peek().getItem();
Integer topInt = intStack.peek().getItem();
System.out.println(" Top items: " + topStr + " and " + topInt);
System.out.println("Now popping the top items.");
strStack.pop();
intStack.pop();
}
System.out.println("Current stack sizes: " + strStack.size() + " "
+ intStack.size());
}
}
Testikoodin suoritus esim. tapaan java LinkedStack 10 20 30
tulostaa:
Current stack sizes: 3 and 3
Top items: "30" and 30
Now popping the top items.
Current stack sizes: 2 and 2
Top items: "20" and 20
Now popping the top items.
Current stack sizes: 1 and 1
Top items: "10" and 10
Now popping the top items.
Current stack sizes: 0 0
Tämä geneerinen luokka LinkedStack
alkaa olla jo kohtalaisen realistinen esimerkki geneerisen
luokan toteutuksesta. Esimerkiksi Javan säiliöluokkien toteutukset ovat varsin samantapaisia,
joskin ne ovat toki tyypillisesti ominaisuuksiltaan paljon monipuolisempia.
Geneeriset rajapinnat¶
Geneerisyys yleistyy suoraviivaisesti myös rajapintoihin. Alla on esimerkkinä Javan luokkakirjaston
rajapinnan Comparable
määritys:
public interface Comparable<T> {
public int compareTo(T o);
}
Geneerisen rajapinnan Comparable<T>
toteuttavalla luokalla tulee olla julkinen jäsenfunktio
int compareTo(T o)
. Rajapinnan taustalla on sopimus, että kyseisen jäsenfunktion tulee
verrata oliota itseään parametrina saatuun olioon o
ja palauttaa vertailun tuloksen kertova
int
-arvo. Tämä rajapinta on siinä mielessä keskeinen, että nimenomaan sen toteutus määrittää
toteuttavan luokan olioille “oletusjärjestyksen”. Tähän palataan myöhemmin tarkemmin.
Geneeriset funktiot¶
Geneerisyys ei koske pelkästään luokkia tai rajapintoja: myös yksittäisen funktion voi määrittää
geneeriseksi (riippumatta siitä, onko esim. sen sisältävä luokka geneerinen). Tämä tehdään
lisäämällä funktion määritykseen tyyppiparametrilista funktion paluuarvon tyypin eteen. Geneerisiä
funktiota käytetään usein varsinkin silloin, kun funktio ottaa parametrinaan geneerisen luokan
olion. Alla on esimerkkinä staattinen geneerinen apufunktio, joka saa parametrinaan geneerisen
List<E>
-listan list
ja E
-olion query
ja laskee, kuinka monta kertaa query
esiintyy listassa.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ListUtils {
// Geneerinen funktio: tyyppiparametrilista paluuarvon eteen.
// Määritetään tässä funktiolle count yksi tyyppiparametri E
// lisäämällä <E> paluuarvon tyypin int eteen.
// Funktiota kutsuessa parametrin list alkioiden ja laskettavan
// alkion query tulee olla keskenään jotain sama tyyppiä E.
public static <E> int count(List<E> list, E query) {
int count = 0;
if(query == null) { // Erikoistapaus: query on null.
for(E item : list) {
if(item == null) { // Onko myös item null?
count += 1;
}
}
}
else { // Tyypillisempi(?) tapaus: query ei ole null.
for(E item : list) {
if(query.equals(item)) { // Vertailu query-olion equals-funktiolla.
count += 1;
}
}
}
return count;
}
// Main-funktio testausta varten.
public static void main(String[] args) {
// ArrayList<Integer>, johon satunnaisesti null tai välin 1-5 arvoja.
ArrayList<Integer> is = new ArrayList<>();
Collections.addAll(is, 2, 5, 1, null, 4, 2, 4, 4, 1, 5,
5, null, 5, null, 4, 2, 4, 2, 3, 4, null, 3, 1, 3);
// Lasketaan ja tulostetaan Integer-listan is kaikkien arvojen lukumäärät.
System.out.format("The list has %d values whose counts are:%n", is.size());
for(Integer i = 1; i <= 5; i++) {
System.out.format(" %d: %d times%n", i, count(is, i));
}
System.out.format(" %s: %d times%n%n", null, count(is, null));
// ArrayList<String>, johon satunnaisesti sanoja "one", "two" ja "three".
ArrayList<String> ss = new ArrayList<>();
Collections.addAll(ss, "one", "three", "one", "two", "one", "three", "two");
// Lasketaan ja tulostetaan lisäksi String-listan ss kaikkien arvojen lukumäärät.
System.out.format("The list has %d values whose counts are:%n", ss.size());
for(String s : List.of("one", "two", "three")) {
System.out.format(" \"%s\": %d times%n", s, count(ss, s));
}
}
}
Testiohjelma tulostaa:
The list has 24 values whose counts are:
1: 3 times
2: 4 times
3: 3 times
4: 6 times
5: 4 times
null: 4 times
The list has 7 values whose counts are:
"one": 3 times
"two": 2 times
"three": 2 times