Periytyminen

 

Luokkahierarkiat

Päivän mörkö on koodin kopioiminen. Tämä ohjelmoitsijoiden perivihollinen voi vapaaksi päästessään aiheuttaa suurta tuhoa ja mielipahaa. Ongelmahan on siis se, että joudumme monistamaan samaa ohjelmakoodia moniin ohjelman eri kohtiin. Se johtaa siihen, että meillä on tuplasti enemmän ylläpidettävää ja vaarallinen mahdollisuus unohtaa toisen kopion päivittäminen.

Pieni esimerkki taas kehiin, näitä klassikkoja Libertyn kirjasta. On luokat Koira ja Kissa. Ne ovat erilaisia, koska kissa haukkuu ja koira naukuu. Eikunsiis... Kuitenkin, ne liikkuvat samalla tavalla: neljällä jalalla kävellen. Joutuisimme siis kopioimaan kissan Liiku()-metodin Koira-luokkaan, mikä olisi hyvin harmillista. Toisaalta kissojen ja koirien esittäminen samalla luokalla ei ole järkevää, koska erojakin on paljon.

Ongelmaan on kuitenkin ratkaisu. Voimme ajatella, että kissat ja koirat ovat nisäkkäitä - ja eläimiä. Tämän pohjalta voisimme rakentaa tällaisen luokkahierarkian.

Eläinten perimähierarkia

Tuo biologisesti hyvin korrekti hierarkia voidaan kääntää C++:ksi suoraan. Tarvitsemme vain yhtä uutta niksiä, nimittäin perintää. Idea on simppeli: Koira perii luokan Nisakas. Se siis saa kaikki Nisakas-luokan ominaisuudet, joita se sitten voi täydentää omillaan. Samankaltaisuudet täytyy koodata vain Nisakas-luokkaan, joten työmäärä vähenee. Ja mikä tärkeintä, meillä on koodista vain yksi versio ylläpidettävänä. Eiköhän tuo ala olla jo selvää, joten läimäistään nyt se periytymisen kielioppi tähän.

class Nisakas
{
public:
  int ika;
  int paino;
};

class Koira : public Nisakas
{
public:
  int hannanPituus;
};

int main()
{
  Koira jesse;
  jesse.ika = 10; // näin ei saisi tehdä, halusin vaan pitää esimerkin lyhyenä
  
  return EXIT_SUCCESS;
}

Koira siis perii luokan Nisakas. Koska jesse on koira, se on perinyt Nisakas-luokalta jäsenmuuttujan ika ja niinpä sitä voidaan käyttää. Metodit periytyvät myös, mutta niitä en laiskuudessani jaksanut kirjoittaa. Koira ei pelkästään ole samankaltainen kuin Nisakas, vaan Koira on Nisakas. Nisakas-osoittimeen voi sijoittaa Koira olion - tosin ainoastaan Nisakkaassa olevia ominaisuuksia voi käyttää osoittimen kautta.

Käytännössä periminen tapahtuu siis kirjoittamalla luokan määrittelyssä.

: public KantaLuokka

public tarkoittaa julkista periytymistä, mikä on yleisimmin käytetty periytymisen muoto. Muilla muodoilla ei kannata vielä stressata itseään.

Jos jäsentieto määritellään yksityiseksi, se ei näy luokan perillisille. Toisaalta sen määritteleminen julkiseksi olisi hirmuinen riski, koska jokainen huolimaton luokan käyttäjä voisi sotkea sen. Niinpä jäsentiedon voi määritellä suojatuksi, protected, jolloin vain luokan jäsenfunktiot pääsevät siihen käsiksi, mutta se on käytössä myös luokan perillisissä.

Kantaluokan metodeja voidaan korvata kirjoittamalla niistä uusi versio periytyvään luokkaan. Tämä kuitenkin piilottaa kaikki kantaluokasta peräisin olevat sen funktion versiot. Jos kantaluokassa on metodit Metodi(), Metodi(int) ja Metodi(int, int) ja periytyvässä luokassa uudelleenmääritellään metodi Metodi(int), ei siinä luokassa voida käyttää kantaluokan metodeja Metodi() ja Metodi(int, int).

 

Muodostinfunktiot

Kun luokkaa luodaan, kutsutaan ensin sen kantaluokan muodostinfunktiota ja sen jälkeen luokan omaa muodostinfunktiota. Koska luokan perillinen on myös sen kantaluokan jäsen, kuten vaikka koira on nisäkäs, pitää nisäkäsosa luoda ennen koiraosan luomista. Sama pätee myös tuhoajafunktioihin, mutta niitä tietenkin kutsutaan päinvastaisessa järjestyksessa. Ensin siis tuhotaan koira, sitten nisäkäs. Koiran tai yleensä nisäkkään tuhoaminen on kyllä tuhmaa. Sellaista ei saa tehdä kuin virtuaalimaailmassa.

Luokan muodostinfunktio voi myös antaa parametreja kantaluokan muodostimelle. Se tapahtuu kirjoittamalla muodostinfunktion sisällön määrittelyn yhteyteen : kantaLuokka(parametri).

#include <iostream.h>

class Nisakas
{
public:
  Nisakas(int p);

  int ika;
  int paino;
};


class Hirvio : public Nisakas
{
public:
  Hirvio(int paino, int silmienLkm);
};

Nisakas::Nisakas(int p)
{
  cout  << "Nisäkäs luotu" << endl;
  paino = p;
}

Hirvio::Hirvio(int paino, int silmienLkm) : Nisakas(paino)
{
  cout  << "Hirviö luotu" << endl;
}

int main()
{
  Hirvio roellipeikko(85, 2);
  
  return EXIT_SUCCESS;
}

Hirviota luodessa luodaan siis Nisakas, jolle annetaan parametriksi haluttu ikä. Toisin sanoen annettu paino passitetaan kantaluokan muodostinfunktiolle.

 

Moniperintä

Jos ryhdyt tarkemmin miettimään eläinkuntaa, voi joidenkin eläinten sijoittaminen edellä esitettyyn luokkahierarkiaan olla vaikeaa. Esimerkiksi lohikäärme: sehän on toisaalta kala, mutta toisaalta matelija. Pitäisikö se periyttää kalasta vai matelijasta? C++ tukee moniperintää, joten kysymykseen on kolmaskin vastausvaihtoehto: periytetään se molemmista!

class Lohikaarme : public Kala, Matelija
{
public:
  Lohikaarme(int suomujenLkm, int pituus);
};

Lohikaarme::Lohikaarme(int suomujenLkm, int pituus) : Kala(suomujenLkm), Matelija(pituus)
{
  cout  << "Lohikaarme luotu";
}

Näin ongelma ratkaistiin, Lohikaarme on sekä Kala että Matelija ja se perii molempien kantaluokkiensa ominaisuudet. Esimerkistä myös näet kuinka monen kantaluokan muodostinfunktioita voidaan kutsua - siinähän ei toki ole mitään erikoista tai yllättävää.

 

Virtuaaliset funktiot

Nyt jätämme eläintarhan ja palaamme puhtaasti tietokonemaailman asioihin. Todennäköisesti katsot parhaillaan ruutua, joka on täynnä painonappeja, vierityspalkkeja ja muita graafisia vipuja joiden avulla ohjelmia käytetään. Oletko tullut ajatelleeksi, kuinka noita vempeleitä käytetään?

Oletetaan, että jokainen vipstaaki on oma luokkansa (todellisuudessakin monet graafiset käyttöliittymät toimivat näin). Otetaan hyvin yksinkertainen ongelma: kun piirretään uusi ikkuna, pitää jokaista oliota pyytää piirtämään itsensä. Koska erityyppisiä vipstaakeja on paljon, syntyy hankala if-kasauma kun päätämme mitä luokkaa milloinkin käytämme. Luokat tarvitsisivat jotain yhteistä, koska jokaisen namiskuukkelin tulisi pystyä piirtämään itsensä. Yhteistä piirtofunktiota ei voi tehdä, koska vitkuttimet ovat erinäköisiä, joten ne piirretäänkin erilailla. Eli tarvitsisimme yhtenäisen liittymän erilaisiin toiminnallisuuksiin.

Auttaisiko periyttäminen? Halleluja! Teemme yhteisen kantaluokan Vipstaakkeli, josta kaikki erilaiset nappulat ja palkit periytyvät. piirraItsesi()-metodi voi sijaita kantaluokassa. Kun haluamme piirtää namiskat ruudulle, voimme käsitellä niitä kantaluokan osoittimen kautta ja kutsua piirraItsesi()-metodia - kätevästi yhdessä silmukassa. Itse luokalla ei ole väliä, koska kaikki periytyvät Vipstaakkelista ja siten niitä voi käyttää Vipstaakkeli-osoittimen kautta.

Mutta voihan räkä! Kuten edellä kerrotiin, kantaluokan osoittimen kautta käytettäessä voidaan käyttää vain kantaluokan ominaisuuksia ja niinpä ollen kutsutaan vain kantaluokan piirraItsesi()-metodia, eikä sitä oikeaa lapsiluokan metodia. Voi kun ohjelmointi osaakin olla kinkkistä. Tähän asti opituilla nikseillä ongelma ei ratkea, mutta C++:ssahan niitä niksejä riittää ihan haitaksi asti.

C++ antaa mahdollisuuden määritellä funktio virtuaaliseksi. Se tarkoittaa sitä, että siitä funktiosta kutsutaan aina luokkahierarkiassa alinna olevaa toteutusta. Eli jos Vipstaakkelin piirraItsesi() on virtuaalinen, niin silloin kutsutaan lapsiluokan piirraItsesi()-metodia vaikka funktiokutsu tehdäänkin Vipstaakkeli-osoittimen kautta. Eli suomeksi: kun piirraItsesi() määritellään virtuaaliseksi, ratkeaa ongelmamme siihen.

Tässä on pieni esimerkkitoteutus aiheesta. Määrittelin Vipstaakkeli::piirraItsesi()-metodin puhtaasti virtuaaliseksi, siis tein sijoituksen = 0, enkä kirjoittanut mitään runkoa. Se tarkoittaa sitä, että funktiota ei ole toteutettu. Luokkaan, jossa on toteuttamattomia funktioita, ei voi tietenkään luoda olioita, koska olion funktioita pitää pystyä kutsumaan ja huono sellaista on kutsua johon ei ole ohjelmoitu mitään sisältöä. Siis pelkkää Vipstaakkeli-oliota ei voi olla olemassa: ja sehän on vain hyvä, koska Vipstaakkeli on abstrakti ja hassu sana, ja käytännössä vipstaakkelit ovat aina nappeja, palkkeja tai valikoita - pelkkää "vipstaakkelia" ei ole konkreettisesti olemassa. Myös mikäli lapsiluokka ei määrittele runkoa piirraItsesi()-metodille, ei siitäkään voi luoda olioita - eli lapsiluokkien ohjelmoijat eivät voi unohtaa metodin ohjelmoimista.Vipstaakkeli on abstrakti luokka (abstract class).

#include<iostream.h>

class Vipstaakkeli 
{
public:
  virtual void piirraItsesi() = 0;
}; 

class Painonappi : public Vipstaakkeli
{
public:
  void piirraItsesi();
};

class Vierityspalkki : public Vipstaakkeli
{
public:
  void piirraItsesi();
};

void Painonappi::piirraItsesi()
{
	 cout << "Painonappi piirretty" << endl;
}

void Vierityspalkki::piirraItsesi()
{
	 cout << "Vierityspalkki piirretty" << endl;
}

int main()
{
	const int vipsLkm = 2;
  	Vipstaakkeli* ikkunanVipstaakkelit[vipsLkm];
  	ikkunanVipstaakkelit[0] = new Painonappi();
  	ikkunanVipstaakkelit[1] = new Vierityspalkki();

  	for (int i = 0; i < vipsLkm; i++)
    		ikkunanVipstaakkelit[i]->piirraItsesi();
    		
    return EXIT_SUCCESS;
}

 

Polymorfismi isolla P:llä

Edellisessä kappaleessa läpikäyty esimerkki on sitä Polymorfismia, siis ohjelmointitekniikka johon sanalla polymorfismi useimmiten viitataan. Funktiotason polymorfismi on vain pieni näppärä konsti verrattuna äsken esitettyyn. Luokkahierarkioiden ja virtuaalisten funktioiden avulla toteutettu polymorfismi voi toimia pohjana koko järjestelmän rakenteelle - kuten äsken käsitellyn ikkunointijärjestelmän yhteydessä olikin.

Polymorfismin hienous piilee siinä, että ohjelmassa voidaan tunnetun liittymän kautta käyttää tuntematonta toteutusta. Siis kun esimerkissä tunnetaan Vipstaakkeli-luokka, voidaan sen luokan kautta käyttää Painonappi- ja Vierityspalkkiluokkien ominaisuuksia - tietämättä itse Painonappi- tai Vierityspalkkiluokista yhtään mitään. Ajattele kuinka helppoa on lisätä erilaisia vipstaakkeleja tai muuttaa vaikka Painonapin toteutusta! Se ei vaadi mitään muutoksia yhteisen Vipstaakkeli-liittymän eli kantaluokan kautta toimivaan koodiin.

Olio-ohjelmissa ei pitäisikään törmätä tilanteeseen, jossa käsipelillä, eli if- tai switch..case-rakenteilla, valitaan saman toiminnon eri muoto. Siis tehdään sama asia, mutta se joudutaan tekemään erilailla erilaisten luokkien kanssa. Jos luokilla on yhteisiä ominaisuuksia, tulee niitä voida käyttää samalla tavalla, olivatpa sitten toimintojen luokkakohtaiset toteutukset millaisia tahansa.

Periytymisen avulla toteutettu polymorfismi on ajonaikaista polymorfismia, koska kutsuttava funktio päätetään vasta ohjelman suorituksen aikana. Virtuaalista funktiota kutsuttaessa lähdekoodissa ei mitenkään kerrota, mitä tiettyä funktion toteutusta kutsutaan, vaan ohjelman tulee päätellä se ajonaikana kutsun kohteena olevan olion tyypistä. Tämä operaatio vaatii jonkin verran suoritusaikaa, joten virtuaaliset funktiot ovat hitaampia kuin tavalliset. C++:lla voidaan käyttää myös käännöksenaikaista polymorfismia, joka toteutetaan mallien avulla.

Takaisin