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

Mitkä seuraavista pitävät paikkansa Javassa