Polymorfismi: Geneerisyys

Osaamistavoitteet

Tämän viikon teemana on erilaiset yleiskäyttöisyyden ja ohjelmoinnin sujuvoittamisen keinot. Opit selittämään polymorfismin erilaiset ilmenemismuodot. Lisäksi opit ymmärtämään geneerisen ohjelmoinnin perusteet. Javassa opit käyttämään geneerisyyttä ja kielen funktionaalisia piirteitä apuna ohjelmoinnissa.

Polymorfismi ytimeltään tarkoittaa sitä, että ohjelmassa voidaan käyttää yhtä nimeä tarkoittamaan useita eri asioita. Tähän saakka olemme keskittyneet polymorfismissa olioihin ja erityisesti periytymiseen: aliluokan oliota voidaan käsitellä sen kantaluokan oliona. Polymorfismi on kuitenkin moninaisempi käsite, koska nimeämistä käytetään monin eri tavoin ohjelmointikielissä. Kaikki tähtäävät kuitenkin samaan tavoitteeseen: koodin uudelleenkäyttöön ja saman toiminnallisuuden toteuttamiseen vain kerran. Tämä parantaa ohjelmien laatua ja sujuvoittaa niiden toteuttamista.

Tarkastellaan nyt polymorfismia hiukan lisää. Polymorfismi on alkujaan funktionaalisesta ohjelmoinnista peräisin oleva käsite. Yksinkertaistettuna polymorfismi tarkoittaa, että yhdellä nimellä on ohjelmassa useita merkityksiä. Käsite monimutkaistuu, kun mietimme, mihin kaikkeen nimiä käytetään ohjelmoinnissa: muuttujiin, funktioihin, luokkiin. Polymorfismi esiintyy siis ainakin neljässä eri roolissa:

  • Kuormittaminen (ad hoc -polymorfismi, engl. overloading) on tilanne, jossa yhdellä funktion nimellä on useita vaihtoehtoisia toteutuksia. Yleensä kuormitetut funktionimet erotellaan toisistaan käännösaikana. Esimerkki kuormitetuista funktiosta:

public class Overload {
    public void example( int value ){ ... }
    public void example( int value1, double value2){ ... }
    public void example( string value ){ ... }
 }
  • Uudelleen kirjoittaminen (engl. overriding, inclusion polymorphism) on periytymisestä tuttu tilanne, jossa aliluokka toteuttaa oman versionsa kantaluokan metodista. Tässäkin samasta funktiosta on kaksi toteutusta, mutta toisin kuin kuormittamisessa, itse metodin otsikon (paluuarvo, nimi, parametrien tyypit ja järjestys) pitää olla samat kanta- ja aliluokissa. Oikean toteutuksen valinta on myös ajoaikainen operaatio. Esimerkiksi:

public class BaseClass {
    public void example( int value ){ ... }
}

public class SubClass extends BaseClass {
    public void example( int value ){ ... }
}
  • Polymorfinen muuttuja (sijoituspolymorfismi, engl. assignment polymorphism) on muuttuja, joka on määritelty tietyntyyppiseksi, mutta joka todellisuudessa sisältää toisen tyyppisen muuttujan. Tämä on tuttu polymorfismin käyttötapaus periytymisestä, jolla aliluokan oliota käsitellään sen kantaluokkaosan kautta: BaseClass bc = new SubClass();

  • Geneerisyys tai mallit (engl. template) tarjoaa mahdollisuuden kirjoittaa yleiskäyttöisiä funktioita ja luokkia, joita sitten erikoistetaan käyttötilanteisiinsa sopiviksi. Yleiskäyttöisyys on myös periytymisessä tavoitteena: kantaluokkaan kootaan yhteistä toiminnallisuutta ja aliluokat erikoistavat kantaluokkansa toimintaa. Periytymisessä erikoistettavat palvelut ovat kuitenkin sidoksissa kantaluokan määrittelemään toteutukseen: parametrit, paluuarvot ja nimi pysyvät muuttumattomina, jolloin samalla tavalla toimivia palveluita ei päästä yleistämään, jos ne käsittelevät eri tyyppejä. Geneerisyys tarjoaa vaihtoehdon juuri tähän tarkoitukseen mahdollistamalla nimenomaan palveluiden käsittelemien tyyppien yleistämisen.

Geneerisyys

Todetaan heti alkuun, ettei geneerisyys kokonaisuutena ole kurssin osaamistavoitteistossa ydinaineksessa. Se on kuitenkin monipuolinen ohjelmoinnin työkalu, jonka perusasioista on hyvä olla perillä, vaikkei niitä omassa ohjelmoinnissaan vielä käyttäisi. Geneerisyydessä funktio tai luokka voidaan parametrisoida niiden käsittelemän tiedon tyypin osalta. Tavoitteena on monikäyttöinen funktio tai luokka.

Suunnittelutasolla kaikenlaisen yleiskäyttöisen koodin sekä koodin uudelleenkäytön mahdollistamisessa on tärkeää voida tunnistaa, mitkä osat ohjelmaa eri käyttökohteet tarvitsevat yhteneväistä ja muuttumattomana pysyvää toteutusta (pysyvyys, engl. communality) ja missä niiden tarpeet vaihtelevat (vaihtelevuus, engl. variability). Tätä kutsutaan pysyvyys- ja vaihtelevuusanalyysiksi, jonka avulla monikäyttöinen komponentti voidaan suunnitella. Pysyvyys ja vaihtelevuus ovat suunnittelussa toistensa vastavoimia: mitä yleiskäyttöisempään komponenttiin pyritään, sitä enemmän vaihtelevuutta on. Toisaalta, jos käyttötilanteita rajataan paljon, pysyvyyttä saadaan lisättyä, mutta komponentin käyttökohteet supistuvat. Ohjelmointikielissä pysyvyyden ja vaihtelevuuden hallinnan mekanismeja ovat tuttu periytyminen sekä geneerisyys.

Geneerinen funktio tai luokka on parametrisoitu tyypillä. Geneerinen komponentti – funktio tai luokka – toimii siis tietyllä yhdenmukaisella tavalla usealle erilaiselle tietotyypille. Tämä tarkoittaa sitä, että geneerisissä toteutuksissa käytetään tyyppiparametria korvaamaan koodissa puuttuvaa tietotyypin nimeä samaan tapaan kuin funktion toteutuksessa parametrien nimiä käytetään korvaamaan vasta funktion kutsussa parametrien saamia arvoja. Geneerisyys on itseasiassa jo hyvin tuttua käyttämistäsi ohjelmointikielistä: C++:n std::vector<double> tai Javan ArrayList<Double> ovat tällaisia monikäyttöisiä, geneerisiä tietorakenteita, joiden sisältämien alkioiden tietotyyppi määritellään parametrin avulla. Yhdessä geneerisessä toteutuksessa voi olla useita aukijätettyjä tyyppejä. Kun toteutusta sitten koodissa käytetään, se instantioidaan antamalla auki jätetyille tyypeille todelliset arvot.

Geneerinen funktio voi siis toimia usealle eri parametrityypille. Geneerisen parametrin nimeä käytetään samalla tavalla kuin mitä tahansa tyyppiä funktion toteutuksessa. Geneerinen luokka puolestaan toimii mallina joukolle luokkia, jotka toimivat samalla tavalla, mutta tietty osa niiden toteutuksesta eroaa tyypiltään toisistaan. Geneerinen luokka ei siis itsessään ole vielä luokka, vaan se määrittää joukolle samankaltaisia luokkia mallin, josta instantioidaan koodissa luokka antamalla luokan tyyppiparametreille konkreettinen tyyppi.

Geneeristen toteutusten lähtökohta on, ettei tyyppiparametrille suoraan anneta mitään vaatimuksia. On kuitenkin selvää, ettei geneerinen toteutus voi aina toimia mille tahansa tyypille, koska eri tyyppien ja olioiden rajapinnat eroavat toisistaan. Geneerisessä toteutuksessa saatetaan siis käyttää tyyppiparametrille jotain sellaista toiminnallisuutta, jota kaikilla ohjelman tyypeillä ei ole. Esimerkkinä tällaisesta toimii niinkin yksinkertainen toiminnallisuus kuin <-pienemmyysvertailu. Näin ollen geneerisessä ohjelmoinnissa on keskeistä dokumentoida geneerisille toteutuksille niiden tyyppiparametreihin kohdistamat vaatimukset selkeästi. Esimerkkejä tällaisesta huomaat, kun luet geneerisyyttä tarjoavien ohjelmointikielten kirjastototeutusten dokumentaatioita. Lisäksi itse geneerinen toteutus on hyvä pyrkiä tekemään niin, ettei tyyppiparametrilta päädytä vaatimaan enempää kuin mikä on välttämätöntä toteutuksen kannalta. Tämä vaatii usein ohjelmoijalta tarkkaavaisuutta ja kokemusta.

Geneerinen ohjelmointi ei siis ole yksinkertaista. Siinä törmätään herkästi myös tilanteisiin, joissa itse geneeriseksi tarkoitetun toteutuksen pitäisi mukauttaa omaa rakennettaa tyyppiparametrin mukaan. Tyyppiparametrina saadun tyypin jotkin ominaisuudet esimerkiksi vaikuttavat koodissa tehtäviin toteutusratkaisuihin. Tällaisesta itsetutkiskelusta käytetään nimitystä metaohjelmointi (engl. metaprogramming). Se jätetään tässä kohtaa maininnan tasolle ja siten täysin jatkokurssien aiheeksi. Lisää voit halutessasi lukea kirjasta Rintala, Jokinen: Olioiden ohjelmointi C++:lla tai Czarnecki, Eisenecker: Generative Programming: Methods, Tools, and Applications.

Tyyppiparametrien nimeämisestä

Tyyppiparametrit on koodissa hyvä nimetä siten, että ne erottuvat toteutuksesta geneerisinä selkeästi. Yleinen käytäntö on nimetä tyyppiparametrit yksittäisillä isoilla kirjaimilla, koska näin ne erottuvat selkeästi primitiivityyppien, luokkien ja rajapintojen nimistä. Yleisimmät tyyppiparametrinimet ovat: T – tyyppi, V – arvo, S, U, V, jne. – 2., 3., 4. jne. tyyppi, N – numero ja E – tietorakenteen alkio. Tämä on kuitenkin vain konventio, voit nimetä tyyppiparametrit kulloisenkin ohjelmointityylin mukaisesti.

Geneerisyys eri kielissä

Kuten aiemmin on jo todettu, Javan geneerisyyden toteutus eroaa merkittävästi esimerkiksi C++:n geneerisyydestä. Javan geneerisyyteen palaamme vielä seuraavassa osioissa lisää.

C++:n tapa toteuttaa geneerisiä funktioita ja luokkia on mallien (engl. template) käyttö. Tästä C++:n standardikirjastosta usein käytetty nimi Standard Template Library (STL) on peräisin. Keskeinen ero Javaan on, ettei C++ vaadi tyyppiparametreilta minkäänlaista sukulaisuutta periytymishierarkiassa.

C++:ssa malli alkaa sanalla template, jota seuraa <>-suluissa mallin tyyppiparametrit.

//function template
template <typename T>
bool compare (T p1, T p2) {
    return p1 < p2;
}

compare<int>(1,2);

//class template
template <typename T, typename U>
class Example
{
  public:
     Example(T first, U second);
     T getFirst() const;
     U getSecond() const;
  private:
     T first_;
     U second_;
};

template <typename T, typename U>
T Example<T, U>::getFirst() const{ return first_;}

Example<int, int> ex(1, 2);

Toisin kuin Javassa, C++:ssa kääntäjä kääntää jokaisesta mallista instantioidusta versiosta, jossa tyyppiparametreille on annettu eri arvot, oman koodinsa. Kääntäjä myös käsittelee luokan metodeita kuin funktiomalleja: se instantioi vain ne luokan metodit, joita oikeasti luokan olioista ohjelmassa kutsutaan. Mallien käyttö vähentääkin siis ohjelmoijan työtä vaikuttamatta juurikaan lopullisen ajettavan ohjelman kokoon.

Polymorfismi: geneerisyys (kesto 19:15)

Mitkä seuraavista ovat totta polymorfismista

Geneerisyydelle pätee