Mallit ja geneerinen ohjelmointi

 

Tee-se-itse: linkitetty lista

Käyn tässä läpi käytännönläheisen esimerkin, jonka avulla toivottavasti ymmärrät mikä juju mallien takana piilee. Kuvittele, että sinun pitäisi ohjelmoida ammuskelupeli. Unohtaen kaiken muun mitä räiskintäpelin ohjelmointiin voisikaan liittyä, keskitymme yhteen haasteeseen: miten hoitaa ammuttujen laukausten käsittely (siis niiden jotka lentelevät ympäriinsä). Selvästikin laukausta esittävät oliot pitää kerätä yhteen, jotta niiden sijaintia voi päivittää lentoradan mukaan ja mahdolliset osumat havaita. Yhtäaikaa voidaan joutua käsittelemään suuriakin määriä ammuksia (asearsenaali on hyvin konetulipainotteinen). Joten jos keräämme ammutut kutit taulukkoon, tulee sen olla suuri.

Suurimman osan ajasta ei lentäviä luoteja ole yhtään, koska pelaaja lymyilee vihollisten näkymättömissä. Eli suurimman osan ajasta taulukko veisi turhaan suuren määrän muistia ja teki siitä kuinka ison tahansa, aina voi tulla eteen tilanteita jossa se ei ole riittävän iso. Tarvitsemme fiksumpaa tietorakennetta, linkitettyä listaa. Lista idea on, että yhdestä tietoalkiosta on aina osoitin seuraavaan - siis sen kokoa ei ole rajoitettu ja alkioiden lisääminen ja poistaminen on helppoa.

Luonnostelemme Ammus-luokan:

class Ammus 
{
public: 
  // saantifunktiot
private:
  int x; 
  int y; 
  int z; 
  Ammus* seuraava; 
};

Koska Ammus-luokka mallintaa pelin todellisuuden käsitettä, sen pitäisi olla mahdollisimman yhdenmukainen pelissä suihkivien ammusten kanssa. Ja se onkin, ammuksella on sijainti jota kolmella koordinaatilla (x, y, z) kuvataan.

Sopiiko Ammus*-osoitin kuvaan? Ei, koska pelin todellisuudessa ammuksia ole mitenkään linkitetty toisiinsa. Ratkaisu ei ole siis paras mahdollinen: samaa linkitettyä listaa olisi mukava käyttää muuallakin ja toisaalta mikäli ammuksien tallentamiseen päätetään käyttää jotain vielä fiksumpaa tietorakennetta, on kurjaa mennä muuttamaan Ammus-luokkaa joka ei tietorakenneasiaan suuremmin liity. Siis linkitetyn listan toteutus pitää repiä irti Ammus-luokasta (toki listalla olisi myös oma luokkansa, jonka kautta sitä käytettäisiin; tässä on esitelty pelkästään yhtä linkkiä eli solmua esittävä luokka jotta esimerkit olisivat lyhyitä ja ytimekkäitä):

class ListanSolmu 
{
public: 
  // saantifunktiot 
private:    
  Ammus* alkio; 
  ListanSolmu* seuraava; 
};

Tämä on parempi: linkitetyn listan voi vaihtaa ilman että Ammus-luokkaan tarvitsee koskeakaan. Mutta listaa ei voi vieläkään käyttää muiden luokkien kanssa, koska sen osoitin tietoalkioon on Ammus*-tyyppinen. C++-kääntäjä huolehtii tietotyyppien oikeasta käytöstä - mikä toki on hyvin jalo ja kunnioitettava teko - eikä anna sijoittaa Ammus*-osoittimeen minkään muun tyyppistä tietoa.

Tarvitsisimme osoitinta, joka kattaa kaikki mahdolliset tietotyypit. Ja sellainen onkin, nimittäin void*. Mutta koska void* on tyypitön osoitin, ei sen käyttö anna kääntäjälle minkäänlaista vinkkiä siitä mitä me olemme oikein tekemässä, joten kääntäjä ei myöskään osaa varoittaa jos teemme huolimattomuuserheen. Siis void*-osoittimella toteutettuun listaan voimme ammuksien sekaan sijoitella epähuomiossa vaikka ensiapulaukkuja tai muuta ammuskelupeleissä yleistä tarveaineistoa - kääntäjä ei ärähdä ollenkaan. Toisaalta olisihan se voitto maailman rauhalle, jos konekivääri ampuisikin vain laastereita ja sideharsoa...

void*-osoittimet eivät ole hyvä idea. Itseasiassa ajatus tyypittömien osoittimien käytöstä näin korkean tason ohjelmoinnissa on loukkaus ketä tahansa osaavaa ohjelmoijaa kohtaan. Seuraavaksi turvaudummekin viisaisiin opetuksiin, joita tässä oppaassa tarjotaan. Käytämme abstraktiota, eli nousemme ylös ansakuopasta siirtämällä tämän pohdinnan korkeammalle abstraktiotasolle. Mehän haluamme listan, joka osaa käsitellä kaikkia pelin luokkia. Joten teemme luokan PeliObjekti, joka kuvaa mitä tahansa pelissä esiintyvää asiaa ja josta kaikki pelin luokan on periytetty. Nyt - kiitos polymorfismin - voimme tehdä listan, joka PeliObjekti-kantaluokan avulla voi käsitellä kaikkia pelin luokkia.

class PeliObjekti {};
class Ammus : public PeliObjekti 
{
public: 
  // saantifunktiot 
private:  
  int x; 
  int y; 
  int z; 
};
class ListanSolmu 
{ 
public: 
  // saantifunktiot 
private:  
  PeliObjekti* alkio; 
  ListanSolmu* seuraava; 
};

Tämä ei olekaan ihan huono ratkaisu, näin nimittäin Java-ohjelmointikielessä on toteutettu kielen standardikirjaston linkitetty lista ja muut tietorakenteet. Mutta Java on siinä mielessä erilainen tapaus, että Javassa kaikki luokat periytyvät kantaluokasta Object, joten listaan voi ihan oikeasti laittaa kaikentyyppistä tietoja. Meidän listamme toimii vain pelin sisällä, siihen ei voi esimerkiksi laittaa int-lukuja tai jonkun muun ohjelman olioita.

Huomaat varmaan myös, että tällä toteutuksella voidaan kyllä samaan listaan laittaa lyijyä ja laastareita, mutta Sidepakkaus-oliota ei voida vahingossa käsitellä kuin se olisi Ammus-olio. Siis tyyppiturvallisuus säilyy, vaikka lista voikin sisältää eri tietotyyppien olioita. Sen lisäksi, että tämä ratkaisu ei ole yleistättävissä kaikelle tiedolle, se on myöskin hitaahko. Varsinkin jos rakentaisimme monimutkaisempia rakenteita, ei polymorfismia käyttäen saavutettaisi huippusuorituskykyä.

Jos ongelmaan olisi olemassa unelmaratkaisu, se olisi tämänlainen: voitaisiin luoda koodia, joka pystyy käsittelemään kaikkea mahdollista tietoa, joka olisi tyyppiturvallinen ja joka tuottaisi yhtä tehokasta käännettyä koodia kuin saman C++-koodin kirjoittaminen käsin jokaiselle tietotyypille erikseen. Mutta ei vaan tule sellaista ratkaisua mieleen. Tietenkin yksi mahdollisuus olisi makrojen avulla rakentaa viritelmä, joka esikääntäjää käyttäen loisi useita versioita koodinpätkistä, mutta makromagian harjoittaminen on aina epäsuotavaa, koska lopputulos on vaikeasti ymmärrettävä ja huonosti hallittava kyhäelmä, joka ei ole edes kunnolla tyyppiturvallinen. Käsinkään ei viitsisi aina luoda uutta versiota koodista, kun kerta täysin samaa logiikka vain sovelletaan uuteen tietotyyppiin.

Valitettavasti ei ole varaa myöskään palkata toista ohjelmoijaa tekemään rutiininomaista koodin monistamista, eikä älykkäistä simpansseistakaan saa millään koulutettua hyviä C++-ohjelmoijia (toiseen suuntaan kouluttaminen on joskus, tosin harvoin, mahdollista). Mutta IT-maailmassa unelmat käyvät toteen ja ongelmiin löytyy täydellinen ratkaisu; saanen esitellä mallit:

template <class T> 
class ListanSolmu 
{
public: 
  // saantifunktiot 
private:   
  T* alkio; 
  ListanSolmu* seuraava; 
};

Mitä mallit oikeasti ovat?

Mallit ovat tekniikka, jonka avulla kääntäjän saa luomaan koodia käännöksen yhteydessä. Kuuluu vain ilmavaa huminaa, kun ongelmien raskas pilviverho haihtuu pois ja päivä paistaa taas. Mallien avulla saadaan kääntäjä luomaan automaattisesti listasta uusi versio kaikkia tarvittavia tietotyyppejä varten. Koska kääntäjä on asialla, se voi hoitaa tyyppien tarkistuksen täysin normaalisti ja havaita kurjat ohjelmointivirheet jo käännösvaiheessa. Ja koska työ tapahtuu käännöksen aikana, on lopputuloksena syntyvä ohjelma yhtä nopea kuin käsin räätälöity.

Tarkastellaan esimerkkiä lähemmin:

template <class T> 
class ListanSolmu 
{ 
public: 
  // saantifunktiot 
private: 
  T* alkio; 
  ListanSolmu* seuraava; 
};

Template alussa tarkoittaa mallia. Emme siis tässä määrittele luokkaa, vaan mallin jonka mukaan kääntäjä osaa luoda uusia luokkia. Mikäli listaa ei käytetä ollenkaan, ei kääntäjä luo yhtään ListanSolmu<... -luokkaa. Jos oliot ovat piparkakkuja ja luokat muotteja joilla ne tehdään, ovat mallit valumuotteja jonka avulla piparkakkumuotit tehtaalla valmistetaan. Koodissa esiintyy eriskummallinen luokka T. Itseasiassa T ei ole luokka, vaan parametrisoitu tyyppi (T on määritelty class-tyyppiseksi eli tietotyypiksi, T voi siis olla luokka tai muuttujatyyppi). T on toisinsanoen tyyppi, joka voidaan syöttää parametrina. Huomaa että T ei voi olla mikä tahansa tyyppi, vaan sen pitää tukea kaikkia niitä operaatioita, joita malli sillä tekee. Jos T-oliosta kutsutaan metodia, jota sillä ei ole, antaa kääntäjä luonnollisesti virheilmoituksen.

Mallin avulla voidaan kertoa, että minkä tyypin listoja halutaan. Solmu-malliluokan instanssi ammuksia varten luotaisiin näin:

ListanSolmu<Ammus> ammusSolmu;

Lause itseasiassa luo kaksi asiaa: luokan ja olion. ListanSolmu<Ammus> saa kääntäjän luomaan ListanSolmu<...>-mallista instanssin ListanSolmu<Ammus>, siis uuden luokan. Sen jälkeen luodaan olio ammusSolmu; tähän luokkaan. Jos olisimme tekemässä piparkakkuja, niin ensin kone muotoilisi luodin muotoisen piparkakkumuotin, jolla sitten painettaisiin yksi leivonnainen.

Mitäpä tekisi seuraavanlainen koodi?

ListanSolmu<Ammus> ammusSolmu; 
ListanSolmu<Luotiliivi> luotiliivisolmuSolmu; 
ListanSolmu<Sidepakkaus> sideSolmu; 
ListanSolmu<Avainkortti> avainSolmu; 
ListanSolmu<Limsapullo> limsaSolmu; 
ListanSolmu<Vihollinen> vihollisSolmu; 
ListanSolmu<Rajahde> pommiSolmu; 
ListanSolmu<Tehtava> tehtavaSolmu; 
ListanSolmu<Pelaaja> tunariSolmu; 
ListanSolmu<Luodinreika> jalkiSolmu;

No, aika monta luokkaa, nimittäin kymmenen. Sitä koodia näpyttelisi käsin jo hetken jos toisenkin. Joidenkin mielestä mallien suuri ongelma on, että ne aiheuttavat koodin paisumista (code bloat). Siitähän tässäkin on kyse, kymmenen kertaa iso luokka on aika paljon koodia.

C++-kääntäjä voi ja sen pitäisi osata optimoida tämänkaltaisia tilanteita: mikäli luokissa on identtisiä metodeja, niitä ei tarvitse monistaa vaan yhdellä pärjää. Toki sellaiset metodit joissa käsitellään parametrisoitua tyyppiä tulee monistaa, koska ne käsittelevät erilaista tietoa eivätkä ole identtisiä. Mutta esimerkiksi oikeassa linkitetyn listan toteutuksessa suurin osa metodeista ei ole riippuvaisia listaan sijoitettujen alkioiden tyypistä (listan koko, onko lista lopussa? jne...) ja niinpä niitä ei tarvitse useita. Joten hyvä kääntäjä monistaa vain ne metodit jotka on ehdottomasti pakko ja natinat koodin paisumisesta ovat kohtuullisen perättömiä. Valitettavasti kaikki kääntäjät eivät ole hyviä, joten tiettyä varovaisuutta tulee noudattaa ettei käännetyn ohjelman koko paisu kuin pullasorsan maha.

Kun pohditaan millaista koodia mallit tuottavat, niin ei voi kuin todeta: säpäkkää. Koska jokaiselle tietotyypille generoidaan oma koodinsa, voi kääntäjä optimoida sen viimeiseen asti (toisin kuin käytettäessä virtuaalifunktioilla toteutettua polymorfismia) ja syntyvä konekielinen ohjelma on nopeampi mitä keskiverto ohjelmoija saisi aikaan konekielistä koodia viilaamalla.

Yksi inhottava käytännön puoli malleissa on. Koska käytettäessä malleja kääntäjä generoi C++-koodia ja yleisesti ottaen kääntäjä ei ole kovin luova nimeämään luokkia, voi tuloksena syntyä hyvin kummallisia virheilmoituksia. Tämä on erityisen totta kun käytetään C++:n mallikirjastoa, jossa malleja on käytetty ja paljon. Yksi virheilmoitus voi olla sivun pituinen eli toisinsanoen melko käsittämätön. Hyvät kääntäjät osaavat tuottaa fiksuja virheilmoituksia myös malleja käytettäessä, mutta varmasti pitkiä itkuvirsiä luritteleviin kääntäjiinkin törmää.

Esittelen nyt laajemman esimerkin mallien käytöstä. Siinä on vanha tuttu ListanSolmu<...>, jota käyttää LinkitettyLista<...> - eli homma on hoidettu tyylikkäästi. Listasta instantioidaan int- ja Ammus-versiot, ja niillä hieman temppuillaan jotta nähdään miten homma toimii. Kuten huomaat, mallien kanssa koodista tulee melko monimutkaista. Olen kommentoinut esimerkkiä paljon, jotta se varmasti avautuu. Suosittelen viettämään hetken jos toisenkin tuon pikku pätkän parissa, jotta mallien käyttö varmasti aukenee.

#include <iostream>

using std::cout;
using std::endl;

// Ensimmäinen malli, linkitetyn listan solmu. Tämä ei näy listan käyttäjälle.
template <class T> 
class ListanSolmu { 

public:
  ListanSolmu(); // Kuten huomaat, C++ sallii pientä lepsuilua muodostimen nimeämisessä
  //ListanSolmu<T>(); // Tarkkaan ottaen oikean niminen muodostin (toimii myös)

  void asetaAlkio(const T* alkio); 
  const T* annaAlkio();

  void asetaSeuraava(ListanSolmu<T>* seuraava); 
  ListanSolmu<T>* annaSeuraava();

private:  
  const T* alkio; // Alkio on const T*, koska se ei voi muuttua
  ListanSolmu<T>* seuraava; // Seuraava solmu linkkien ketjussa
};

// Kun mallimetodi määritellään, ei se kuulu luokkaan vaan malliin 
// (eli ListanSolmu::ListanSolmu() ei kelpaa)
template <class T> 
ListanSolmu<T>::ListanSolmu()
{
  alkio = 0;
  seuraava = 0;
}

template <class T> 
void ListanSolmu<T>::asetaAlkio(const T* alkio)
{
  this->alkio = alkio; // this, jotta luokan alkio ja parametrialkio eivät sekoitu
}

template <class T> 
const T* ListanSolmu<T>::annaAlkio()
{
  return alkio;
}


// Huomaa, että seuraava:n tyyppi ei ole ListanSolmu. Kääntäjä pystyy päättelemään
// tyyppejä ja tässä kävisi myös pelkkä ListanSolmu, mutta ListanSolmu<T> on tarkka 
// ja yleensä kannattaa määritellä aina mahdollisimman tarkasti mitä haluaa, kääntäjän
// järjenlentoon ei ole aina luottaminen... 
template <class T>
void ListanSolmu<T>::asetaSeuraava(ListanSolmu<T>* seuraava)
{
  this->seuraava = seuraava;
}

template <class T>
ListanSolmu<T>* ListanSolmu<T>::annaSeuraava()
{
  return seuraava;
}

template <class T>
class LinkitettyLista
{
public:
  LinkitettyLista();

  void LisaaListaan(const T& alkio);

  const T& AnnaEnsimmainen();
  const T& AnnaSeuraava();

  bool OnkoViimeinen();

private:
  ListanSolmu<T>* alku; // Ensimmäinen solmu, ei sisällä listan tietoa  
  ListanSolmu<T>* nykyinen; // Listan läpikäyntiä varten
};

template <class T>
LinkitettyLista<T>::LinkitettyLista()
{
  alku = new ListanSolmu<T>; // Tyhjä "otsikkosolmu"
  nykyinen = alku;
}

// LisaaListaan lisää listan loppuun
template <class T>
void LinkitettyLista<T>::LisaaListaan(const T& alkio)
{
  // Luo uusi solmu tietoa varten
  ListanSolmu<T>* uusi = new ListanSolmu<T>();
  uusi->asetaAlkio(&alkio);

  // Lähde liikkeelle nykyisestä ja kulje listan loppuun asti
  ListanSolmu<T>* solmu = nykyinen;
  while (solmu->annaSeuraava() != 0) solmu = solmu->annaSeuraava();

  // Lisää listan jatkoksi
  solmu->asetaSeuraava(uusi);

  // Päivitä nykyinen
  nykyinen = uusi;
}

template <class T>
const T& LinkitettyLista<T>::AnnaEnsimmainen()
{
  nykyinen = alku; // Otsikkosolmusta ei voi vielä ottaa alkiota koska se on tyhjä
  return AnnaSeuraava();
}


template <class T>
const T& LinkitettyLista<T>::AnnaSeuraava()
{
  // Ota askel eteenpäin linkkien ketjussa
  nykyinen = nykyinen->annaSeuraava();

  // Jos olemme tulleet loppuun, palauta nolla (muuntuu tyypiksi T)
  if (nykyinen == 0) return 0;

  // Palauta itse alkio
  return *(nykyinen->annaAlkio());
}

// bool on kaksiarvoinen tietotyyppi (tosi / epätosi)
// int kävisi myös paluuarvon tyypiksi
template <class T>
bool LinkitettyLista<T>::OnkoViimeinen()
{
  // Olemmeko yli listan lopun?
  return nykyinen != 0;
}

// Listan kokeilua varten
class Ammus 
{
  int x;
  int y;
  int z;
};

int main()
{
  // Luo numerolista
  LinkitettyLista<int> pisteet;
  
  // Lisää sinne muutamia lukuja
  pisteet.LisaaListaan(1);
  pisteet.LisaaListaan(2);
  pisteet.LisaaListaan(3);
  pisteet.LisaaListaan(4);
  pisteet.LisaaListaan(5);

  // Katsotaan tulevatko oikeat luvut ulos...
  for (int i = pisteet.AnnaEnsimmainen(); pisteet.OnkoViimeinen(); i = pisteet.AnnaSeuraava())
    cout << i << endl;
  
  // Instantioidaan listasta myös Ammus-versio (eli luokka LinkitettyLista<Ammus>)
  LinkitettyLista<Ammus> paukut;
  paukut.LisaaListaan(*new Ammus());

  return EXIT_SUCCESS;
}

Mallien spesialisointi

Malleja ei voida soveltaa pelkästään luokkiin ja niiden metodeihin, vaan myös yksittäisiin funktioihin. On siis mahdollista luoda funktio summaa, jossa summattavien tyyppi on parametrisoitu:

template <class T>
summaa(T t1, T t2)
{
  return t1 + t2;
}

int i = summaa<int>(1, 2); // esimerkki funktion käytöstä

Miten toimisi summaa<string>? No huonosti, koska se ei summaisi merkkijonoja vaan yhdistäisi ne. Meidän tulisi siis pystyä ohjelmoimaan malli tälle erikoistapaukselle. Aivan kuten luokka voidaan periyttää ja siitä luoda erikoistunut lapsiluokka, myös malleja voi spesialisoida. Spesialisoitu versio summaa-funktiosta voi käyttää yleisempää versiota hyväkseen, vain muuttaen merkkijonot luvuiksi ja tuloksen takaisin merkkijonoksi. Huomaa tärkeä ero täydellisen (complete spesialisation) ja osittaisen (partial spesialisation) spesialisoinnin välillä: täydellinen spesialisointi on luokka, osittainen taas malli jonka parametrisoinnit ovat vain yleistä mallia rajatumpia. Eli summaa<string> on täydellinen spesialisointi, koska siinä tyyppi ei ole millään tavalla parametrisoitu, kun taas summaa<T*> on osittainen, koska siinä tyyppi voi olla mitä vain, sillä rajoituksella että se on osoitin. Täydellisessä spesialisoinnissa ei parametrilistassa saa olla tyyppejä, koska niitä ei käytetä mallin rungossa.

#include<cstdlib>
#include<iostream>
#include<sstream>
#include<string>

// Yleinen summaa-malli
template <class T> 
T summaa(T t1, T t2) 
{ 
  return t1 + t2; 
} 

// Koska teemme täydellisen spesialisoinnin, määrittelemme paluuarvon
// ja parametrit string-tyyppisiksi. Koska emme käytä parametrisoitua
// tyyppiä T, se ei myöskään saa olla mallin parametrilistassa.
// Vain osittainen spesialisointi saa käyttää parametrisoitua tyyppiä,
// (esim. T* olisi osittainen spesialisointi) - mutta täydellisessä
// spesialisoinnissa parametrisointia ei tietenkään tarvittaisikaan!
template <>
string summaa<string>(string t1, string t2)
{
 // Tämä metodi itseasiassa tekee vain tyyppimuunnoksia
 // ja delegoi itse työn yleiselle mallille

 stringstream ss; // jotta voimme muuttaa intin stringiksi 

 // kutsutaan yleisen version int-instanssia hoitamaan itse homma
 ss << summaa<int>(std::atoi(t1.c_str()), std::atoi(t2.c_str()));

 return ss.str(); // tyyppimuunnos takaisin
}

int main()
{
 string kolme("3"), nelja("4");
 std::cout << summaa<string>(kolme, nelja);
 return EXIT_SUCCESS;
}

Kuten todettiin, spesialisoinnin voi tehdä esimerkiksi kaikille osoittimille. Tällainen tarve tulee esille, mikäli malli vaatii operaatioita, jotka on määritelty (järkevästi) osoittimen osoittamassa tyypissä. Hyvä esimerkki on summaa. summaa<int*> palauttaa kahden osoittimen yhteenlasketun osoitteen, mikä on täysin hyödytön tulos. Parempi olisi, että se palauttaisi osoittimen int-lukuun, joka on annettujen osoittimien osoittamien lukujen summa. Mikäli osoittimet ovat tyyppiä void*, ei tietenkään mitään summaa voida laskea ja silloin palautetaan tyhjä osoitin (osoitin, jonka arvo on 0). Tämä saataisiin aikaan seuraavilla malleilla (pelkät esittelyt):

template <class T> summaa(T t1, T t2);
template <class T> summaa<T*>(T t1, T t2);
template <class T> summaa<void*>(T t1, T t2);

Kääntäjä poimisi aina kaikista spesialisoiduimman version, eli void*-osoittimille void*-version, muille osoittimille osoitinversion ja yleisen version lopuille. Huomaa että osoitinversion parametrit eivät ole T*-tyyppisiä, vaan T-tyyppisiä. Jos luodaan summaa<int*>, sopii tyyppimaski T* tyyppiin int* niin, että T on int. Eli silloin summaa saisi int-parametreja.

 

Mallit käytäntöjen parametrisoijana

Ehdottomasti yleisin tapa käyttää malleja on tietorakenneluokkien luominen. Koska rakenteiden tulee toimia kaikilla tietotyypeillä ja koska niiden tulee olla nopeita, sopivat mallit kuin nyrkki silmään. Toinen hyvä käyttökohde malleille on käytäntöjen parametrisointi (policy parametrization). Siis mahdollisuus päättää jonkin toiminnon toteutus tarpeen mukaan. Esimerkki voisi olla mallifunktio "suositeltavampi":

template <class T> suositeltavampi(T t1, T t2)
{
  if (ti > t2) return t1;
  else return t2;
}

Se valitsisi kahdesta syötteestä aina paremmin soveltuvan. Mutta soveltuvuus ei ole pelkästään suureemmuutta, olisi siis hyvä jos vertailuehto olisi asetettavissa tilanteen mukaan. Eli toisinsanoen olisi hyvä, jos vertailukäytäntö olisi parametrisoitavissa. Voimmekin tehdä luokan, jossa on määritelty staattinen vertailumetodi ja joka voidaan antaa suositeltavampi-mallille parametrina. Tätä luokkaa kutsutaan käytäntöluokaksi (policy class):

#include<iostream>
#include<string>

using std::string;
using std::cout;
using std::endl;

// Käytäntö, joka suosii pidempiä rakenteita.
// Toimii vain tyypeillä jotka sisältävät metodin "int size()".
template <class T>
class Pidempi
{
public:
  static bool ParempiKuin(T t1, T t2) {
    return t1.size() > t2.size();
  }
};

template <class T, class K> 
T suositeltavampi(T t1, T t2)
{
  if (K::ParempiKuin(t1, t2)) return t1;
  else return t2;
}

int main()
{
  string nimi1("Aki");
  string nimi2("Aki-Petteri");

  // Huomaa tärkeä välilyönti > > -merkkien välissä, ettei sitä 
  // tulkita >>-operaattoriksi!

  string lapsenNimi = suositeltavampi<string, Pidempi<string> >(nimi1, nimi2); 
  cout << "Kastan sinut " << lapsenNimi << "..." << endl;

  return EXIT_SUCCESS;
}

Hyviä käyttökohteita parametrisoiduille käytännöille ovat juuri vertailut (esimerkiksi tietorakenteen järjestely suuruusjärjestykseen) tai pysyvien olioiden instantiointi (haetaan verkon yli, luetaan tiedostosta, luetaan tietokannasta jne...). Huomaa, että myös virtuaalifunktioita käyttäen voidaan saavuttaa sama asia. Virtuaalifunktiolla toteutettuna myös käytäntöä voidaan vaihtaa kesken suorituksen - toisin kuin mallien avulla. Mutta toisaalta mallit ovat rutkasti nopeampia, mikä on erityisen tärkeää jos käsiteltävän tiedon määrä on suuri, kuten tietorakenteita tai suuria pysyvien olioiden laumoja käsitellessä asia yleensä on.

 

Mallien monimutkainen käyttö

Mallien erikoisemmat käyttömahdollisuudet perustuvat kahteen asiaan: mallien avulla ei voi parametrisoida pelkästään tyyppejä (esimerkiksi class T), vaan myös ihan normaaleja muuttujia (kuten int i). Lisäksi muuttujan tyyppi voi olla mallissa parametrisoitu tyyppi, kunhan parametrisoidun tyypin määrittely on ennen muuttujan määrittelyä. Pieni esimerkki selventänee asiaa:

#include<iostream>

template <class T, int koko, T oletusarvo>
class Taulukko 
{
public:
  Taulukko() {
    for (int i = 0; i < koko; i++)
    taulu[i] = oletusarvo;
  }

  T taulu[koko]; 
};


int main()
{
  Taulukko<int, 2, 3> luvut;

  std::cout << luvut.taulu[1] << std::endl; // tulostaa 3...

  return 0;
}

Näin voi luoda taulukon, joka on sovellettavissa kaikille tyypeille ja josta voidaan luoda oikeankokoinen versio eri tarkoituksiin. Koska luominen ja koon asettaminen tapahtuu käännöksen yhteydessä, voi kääntäjä optimoida taulukon käyttöä paremmin kuin ajonaikaisia rakenteita. Tämä tarkoittaa myös sitä, että koko- ja oletusarvo-parametrien tulee olla vakioita. Jokaiselle käytetylle eri tyypin, koon ja oletusarvon yhdistelmälle luodaan oma luokkansa käännöksen aikana, joten normaali muuttuja ei kelpaa: sen arvoa kun ei voi tietää vielä käännöksen aikana. Vaikka tällainen mallimagia onkin mahdollista, kannattaa kuitenkin usein miettiä onko se perusteltua. Mallien käytössä on yleensä paras rajoittua geneerisiin tietotyyppeihin ja tarvittaessa käytäntöjen parametrisointiin.

 

Mitä mallit abstraktisti ovat? *

Periyttämisellä toteutetun polymorfismin avulla voidaan luoda koodia, joka pystyy käsittelemään useita eri tyyppejä. Siinä kaikki perustuu kantaluokkaan, eli koodin vaikutusalue voidaan ajatella puuna, jonka juurena on kantaluokka ja oksina periytyvä luokkahierarkia. Malleilla toteutetun koodin vaikutusalue on kaikkien niiden tietotyyppien lista, jotka tukevat kaikkia koodissa käytettyjä operaatioita - mitään järkeä operaatioissa ei tarvitse olla, kunhan ne on määritelty. Malleihin verrattuna polymorfismi on täsmäase, sen vaikutusalue on pienempi ja paljon rajatumpi: vaikka kantaluokkaa käsittelevän koodin kirjoittaja ei voikaan aina arvata, millaisia lapsiluokkia luokalla tulee olemaan, niin huomattavasti vaikeampi on mallia kirjoittavan ohjelmoijan arvata minkälaiset luokat tulevatkaan tukemaan mallin vaatimia operaatioita - ja miten järjettömästi ne sen tulevat tekemään.

Itseasiassa mallien käyttökin voidaan ajatella polymorfismina. Puhutaankin C++:n kahdesta erilaisesta polymorfismista, käännöksenaikaisesta - tai parametrisoidusta - polymorfismista (mallit) ja ajonaikaisesta polymorfismista (periyttäminen ja virtuaalifunktiot). Näillä kahdella on sekä käytännöllisiä että teoreettisia eroja. Käytännössä mallit ovat nopeampia, mutta hieman hankalampia ja epäintuitiivisempia.

Teoreettisesti tärkein ero on vaikutusalueella. Ajonaikaisen polymorfismin vaikutusalue on pienempi, mutta ennen kaikkea hallittavampi. Koska virtuaalifunktioiden määrittelyt rajoittavat huomattavasti lapsiluokkien toteuttajia, on polymorfisen koodin kirjoittajalla paljon selkeämpi ja rajatumpi kuva koodin vaikutuksesta kohteena oleviin tietotyyppeihin.

Yleensä, kuten myös tässä oppaassa, polymorfismilla viitataan juuri ajonaikaiseen polymorfismiin ja mallien avuilla luotua monimuotoisuutta kutsutaan geneeriseksi ohjelmoinniksi. Geneerinen eli yleinen kuvaa hyvin mallien käyttöä: malli eli parametrisoitu ohjelmanpätkä antaa yleisen valtakirjan kaikille, jotka sitä haluavat käyttää. Perinteinen polymorfismi vaatii hyvin rajoittavasti, että kohteena oleva olio on jotakin tiettyä: lapsiluokan olio on kantaluokan olio, siis PaloAuto on Auto. Vaikka polymorfinen koodi voikin käsitellä PaloAutoja, KuormaAutoja ja PakettiAutoja, ne kaikki ovat Autoja. Vastakohtana geneerinen lista voidaan instantioida Autoille tai int*-osoittimille, eikä Auto ole int* tai int* ole Auto. Niillä ei ole juuri mitään muuta yhteistä, kuin että niissä on määritelty sijoitusoperaattori - mikä riittääkin linkitetyn listan toteutukselle. Tämä yleisyys voi olla vaarallistakin ja johtaa väärin toimivaan koodin: esimerkiksi yhteenlasku on normaali toimitus numeroille, mutta kahdella osoittimella tehtynä ynnäys ei tuota mitään järkevää - mutta on mahdollista!

Toki malleja on mahdollista spesialisoida ja luoda osoittimille oma versionsa, joka tuottaa järkeviä tuloksia. Tässä kuitenkin havaitaan, että liian pitkälle vietynä päädymme kopioimaan tietotyyppien välisiä suhteita (esim. luokkahierarkiat) omassa spesialisaatiorakenteessamme, mikä ei ole hyvä merkki - kaksinkertainen rakenne on tuhatkertainen päänvaiva. Voikin havaita, että parametrisoiduilta tyypeiltä ei saa vaatia kovin useita tai monimutkaisia operaatioita - niitä tulisi käyttää hyvin rajatulla tavalla. Mikäli tähän ei pystytä, on hyvä ratkaisu tehdä väliin erillinen kerros geneerisiä tyyppejä, joiden avulla operaatiot voidaan suorittaa. Esimerkki tästä  löytyy C++:n standardikirjastosta (STL:stä), jossa algoritmit kuten järjestely ovat yhteydessä tietorakenteisiin erillisten iteraattorien avulla.

Malleja ja perimistä on mahdollista yhdistää. Malliluokka voi periä tavallisen luokan tai malliluokan, tavallinen luokka ei tietenkään voi periä malliluokkaa. Mutta mikäli periminen laajennetaan täysimittaiseksi olio-ohjelmoinniksi (mukaanlukien virtuaalifunktioiden käyttö), ei malleja kannata sotkea enää mukaan. Parametrisoiduista virtuaalifunktioista tehdyt "tuplapolymorfiset" rakennelmat ovat hyvin vaikeita hahmottaa. Yleisesti ottaen olio-ohjelmointia ja geneeristä ohjelmointia ei kannatta yhdistää - oliorakennelmat voivat kyllä käyttää geneeristä kirjastoa (kuten STL:ää) tai geneerinen ohjelma voi käyttää oliopohjaista järjestelmää, kunhan niiden välinen raja on selkeä.

 

Geneerinen ohjelmointi *

Geneeriseen ohjelmointiin voi pureutua vertaamalla sitä olio-ohjelmointiin. Toki geneeriset ohjelmatkin sisältävät olioita ja luokkia, mutta olio-ohjelmoinnilla tarkoitan tässä koko konseptia, periytymistä, virtuaalifunktioita, luokkahierarkioita ja yleensä kaiken mallintamista olioiden avulla. Olio-ohjelmoinnissa abstraktit käsitteet mallinnetaan kantaluokkien avulla (esim. Ajoneuvo). Geneerisessä ohjelmoinnissa taas abstraktiot eivät ole niin konkreettisia, vaan vaatimuksia siitä mitä operaatioita parametrisoidun tyypin tulee tukea (esimerkiksi taulukkomuotoisen tietorakenteen tulee tukea indeksointia eli []-operaattoria - eli taulukkomuotoisen tietorakenteen abstraktioon kuuluu vaatimus indeksoinnin tukemisesti).

Mikäli vaatimusta ei täytetä, johtaa se virheeseen käännöksessä. Kaikkia vaatimuksia ei kuitenkaan voida mallintaa C++:n rakenteiden avulla. Esimerkiksi jos järjestelyfunktiolle annetaan järjesteltävän alueen alku- ja loppupiste, se ei pysty havaitsemaan jos pisteet sijaitsevat eri tietorakenteissa. Oliomallissa luokka Indeksi sisältäisi tiedon siitä mihin tietorakenteeseen se kuuluu, joten tarkistaminen olisi paljon helpompaa. Niinpä geneerisestä ohjelmoinnista voidaankin havaita hyvin yleinen ilmiö: mitä laajemmin joku asia on sovellettavissa, sitä voimattomampi se on. Sellainen perustuslaki, jonka kaikki maailman maat voisivat allekirjoittaa, saisi sisältää vain harmaasävyisen kansilehden.

Laajamittaisessa geneerisessä ohjelmoinnissa tuleekin soveltaa käytäntöä, jolla abstraktioiden vaatimukset voidaan dokumentoida selkeästi. Mitä vaaditaan tietorakenteelta? Entä taulukolta? Geneerisen ohjelmoinnin yhteydessä puhutaan konsepteista, eli määritelmistä jotka kuvaavat kuinka eri osat ovat yhteydessä toisiinsa ja kuinka järjestelmään voidaan lisätä uusia ominaisuuksia. Jotta suurta geneeristä järjestelmää voi hallita ja kehittää, tulee konseptit dokumentoida selkeästi.

Geneerisessä ohjelmoinnissa pääroolissa eivät ole luokat. Esimerkiksi algoritmit - siis käytännössä mallifunktiot - ovat keskeisiä. Luokkien välillä ei myöskään ole juurikaan riippuvuuksia, kuten olio-ohjelmoinnissa. Jotta yleisiä operaatioita voidaan soveltaa luokkiin, luokkien tulee olla yksinkertaisia - joten niiden yhdistely, periyttäminen ja muut vastaavat monimutkaisuudet eivät ole käytettävissä. Järjestelmän älykkyys ei ole luokkien sisällä, vaan siinä miten algoritmeja sovelletaan tietorakenteisiin.

Geneerisille järjestelmille on yleistä käytäntöjen parametrisointi. Toisaalta luokat ovat kapseloituja yksikköjä, mutta toisaalta niistä on erotettu tiettyjä käytäntöjä, jotka on parametrisoitu. Käytännöt ovatkin luokan kapseloimaan käsitteeseen verrattuna eri ulottuvuudessa - siis sillä miten tietorakenteita järjestellään ei ole merkitystä erilaisten tietorakenteiden muodostamassa ulottuvuudessa. Käytännöt ovatkin hyvin samantyyppinen ratkaisu kuin aspektit olio-ohjelmoinnissa.

Yhteenvetona voisi todeta, että geneerinen ohjelmointi ei ole eräs olio-ohjelmoinnin erityistapaus. Geneerinen tapa käsitellä abstraktioita on erilainen ja huonommin C++-kielen tukema kuin oliomallinnuksessa. Niinpä geneeristä ohjelmointia ei tulisikaan soveltaa suuriin sovelluksiin: oliomallinnuksen on todettu auttavan suurten sovelluksien mallintamisessa, mutta geneerinen ohjelmointi ei sisällä yhtä voimakkaita ominaisuuksia. Yleiskäyttöisten kirjastojen tai alijärjestelmien toteuttamiseen geneerinen ohjelmointi soveltuu hyvin, josta oiva esimerkki ovat yleiset tietorakenteet ja algoritmit, jotka C++:n STL-standardikirjastossa on toteutettu geneerisen ohjelmoinnin avulla.

 

Takaisin