Funktiot

 

Funktiot

Oletetaan että on asia, joka pitää tehdä ohjelmassa monta kertaa. Voi taas tietenkin tehdä sen joka paikassa - kasvattaa koodin kokoa turhaan. Mutta fiksumpi valjastaa funktiot käyttöönsä. Jos jatkamme filosofointia, voimme kuvitella tilanteen jossa tätä toimintoa pitäisi muuttaa. Tyhmempi joutuu muuttamaan sen joka kohdasta missä sitä käytetään, mutta fiksu muuttaa vain tekemäänsä funktiota. Kuinka näitä funktioita sitten tehdään, se selviää aivan heti:

funktion_paluuarvon_tyyppi funktion_nimi(parametri_yksi, parametri_kaksi);

Okei, se homma on käsitelty. Sitten polymorfismiin... :) Kuulenko jotain vikinää? Okei, selitetään sitten vähän perusteellisemmin.

Funktio (function) on kansantajuisesti toiminto. Se tekee jonkin tietyn toiminnon. Funktion toimintaa voidaan ohjata sille annettavilla parametreilla, ja se palauttaa tuloksena arvon. Näin teoriassa. Todellisuudessa funktiolle ei tarvitse antaa mitään parametrejä. Se voi vaikka tyhjentää ruudun, jolloin tarkempaa ohjausta ei tarvita. Funktion ei myöskään ole pakko palauttaa arvoa.

Kun funktio määritellään, pitää sen paluuarvon tyyppi määritellä. Jos ei haluta palauttaa yhtikäs mitään, annetaan paluuarvon tyypiksi void ("tyhjyys"). Sitten määrittelyssä annetaan funktion nimi. Minulla on tapana aloittaa funktion nimi isolla ja kirjoittaa jokainen nimen osa isolla. Muista! C++ tekee eron isojen ja pienten kirjainten välillä!

Funktiolle annettavat käynnistysarvot, parametrit, kirjoitetaan sulkeisiin. Niitä voi olla ihan miten paljon tahansa, tietenkin noin kymmenen on käytännön maksimi. Oliopohjaisissa ohjelmissa niitä tarvitaan vain muutama, melko usein ei yhtään. Älä kuitenkaan vaivaa päätäsi tällä vielä, kyllä ne oliot jo nurkan takana odottavat.

Kuvitellaan, että teemme jonkun tilastotieteellisen ohjelman, jossa pitää laskea kahden luvun keskiarvo. Niinpä teemme KeskiArvo()-funktion.

double KeskiArvo(double luku1, double luku2);

Määritellään funktio ensin. Sille voitaisiin kirjoittaa sisältö nyt heti, mutta minä ainakin pidän enemmän tavasta, jossa funktiot määritellään ohjelman alussa, sitten tulee main()-funktio ja lopussa annetaan funktioille lihaa luiden väliin. Funktion määrittely on nimeltään prototyyppi (sitä ei vielä yksinään voi käyttää mihinkään) ja itse sisällön määrittely on funktion runko.

Joten ohjelman loppuun tulisi:

double KeskiArvo(double luku1, double luku2) // Huomaa, ei ;-merkkiä!
{
  double tulos = 0;
  tulos = (luku1 + luku2) / 2;
  return tulos;
}

Funktiota käytettäisiin näin, jos haluttaisiin laskea kolmen ja viiden keskiarvo:

#include<iostream.h>

double KeskiArvo(double luku1, double luku2);

void main()
{
    cout << KeskiArvo(3, 5);
}

double KeskiArvo(double luku1, double luku2) // Huomaa, ei ;-merkkiä!
{
  double tulos = 0;
  tulos = (luku1 + luku2) / 2;
  return tulos;
}

Tarkastellaan vielä kohtaa:

double KeskiArvo(double luku1, double luku2);

double on funktion paluuarvon tyyppi. Eli kun funktio ajetaan, se palauttaa jonkun arvon, jonka pitää olla double-tyyppiä. Suluissa olevat ovat parametreja. double on parametrin tyyppi, ja luku1 on muuttuja johon se tallennetaan. luku2 on toinen double-tyyppinen parametri.

Funktiossa keskiarvolaskun tulos palautetaan return-käskyllä ja se annetaan vastaus-muuttujalle aivan kuten funktiokutsun tilalla olisi muuttuja.

Jos haluamme funktion, jolle ei anneta parametrejä ja joka ei palauta arvoa - tällainen funktio voisi esimerkiksi soittaa äänimerkin tietokoneen kaiuttimesta - määrittelemme sen näin:

void Funktio(); 

tai:

void Funktio(void); 

Kummin vaan - ylempi tulkitaan automaattisesti alemmaksi. Huomaa myös se tärkeä asia, että funktiota kutsuttaessa käytetään aina sulkumerkkejä. Hyvin ilkeää on, että mikäli unohdat sulkumerkit kutsusta, ei kalikka kalahda, nalli napsahda tai kääntäjä älähdä. Funktion nimi yksinään on funktion osoite muistissa ja sen käyttö on myös sallittua. Mikäli rivillä on vain pelkkä muistiosoite, eikä mitään käskyä, niin ohjelma ei sitten tee yhtään mitään. Eli kun tosissaan haluat sitä Funktiota kutsua, niin ohjeista ohjelmaa: Funktio();.

 

Funktion oletusparametrit

Funktiolle voidaan määritellä myös oletusparametrejä. Ne määritellään prototyypissä, tai jos funktiolla ei ole prototyyppiä, niin sitten rungossa. Kuitenkin vain kerran. Määrittely tehdään näin:

#include <iostream.h>

void Funktio(int a = 5, int b = 8);

void main()
{
	Funktio();
	Funktio(5);
	Funktio(5, 8);
}

void Funktio(int a, int b)
{
	cout << "a " << a << " b " << b << endl;
}

Ohjelma tulostaa ajettaessa:

a 5 b 8
a 5 b 8
a 5 b 8

Siis jos parametrille ei anneta mitään arvoa, käytetään oletusarvoa.

 

Funktioiden uudelleenmäärittely

C++:ssa funktiota ei valita pelkän nimen, vaan myös parametrien mukaan. Siis esimerkiksi int KeskiArvo(int eka, int toka) ja float KeskiArvo(float eka, float toka) ovat eri funktioita. Kun funktiokutsu suoritetaan, etsii kääntäjä parametrien perusteella oikean funktion ja kutsuu sitä.

Tätä ihmeellisyyttä kutsutaan funktioiden uudelleenmäärittelyksi, monimuotoisuudeksi, siis ulkomaankielellä polymorfismiksi. Polymorfiset funktiot ovat siitä kivoja, että niitä voi käyttää muistamatta muuttujien tyyppejä; kunhan oikea funktio on vaan tehty, hoitaa kääntäjä homman kotiin.

Hmm. Tulipas lyhyt selostus. Mitenkä minä vielä tätä lisäisin, eihän asia nyt niin yksinkertainen ole. No voihan vimputti, siinä se kyllä on. Ehkä minä vielä yhden esimerkin tähän kyhään, niin saatte vähän kosketusta käytännön toteutukseen. Huomaathan esimerkkiä tavatessasi, että int-lukuina annetut keskiarvot eivät tietenkään ole tarkkoja.

#include<iostream.h>

int KeskiArvo(int luku1, int luku2)
{
  double summa = (double)luku1 + (double)luku2;
  return (int)(summa / 2 + 0.5); // + 0.5 on köyhän miehen pyöristys, joka ei toimi kuin positiivisilla luvuilla
}

int KeskiArvo(int luku1, int luku2, int luku3)
{
  double summa = (double)luku1 + (double)luku2 + (double)luku3;
  return (int)(summa / 3 + 0.5);
}

double KeskiArvo(double luku1, double luku2)
{
  double summa = luku1 + luku2;
  return summa / 2;
}

void main()
{
   int i1, i2, i3;
   i1 = 2;
   i2 = 4;
   i3 = 5;
   double d1, d2;
   d1 = 2.5;
   d2 = 6.66;

   cout << "Lukujen " << i1 << " ja " << i2 << " keskiarvo on " <<  KeskiArvo(i1, i2) <<  endl;
   cout << "Lukujen " << i1 << ", " << i2 << " ja " << i3 <<  " keskiarvo on " << KeskiArvo(i1, i2, i3) << endl;
   cout << "Lukujen " << d1 << " ja " << d2 << " keskiarvo on " << KeskiArvo(d1, d2) << endl;
}

Tämä oli vain kevyt raapaisu polymorfismiin; kuin avaisi jugurttipurkin ja nuolisi kannen, mutta jättäisi itse sisällön syömättä. Funktioiden uudelleenmäärittely on polymorfismia funktiotasolla - toisin sanoen melko vaisua polymorfismia. Yleensä olio-ohjelmoinnissa polymorfismilla tarkoitetaan periyttämisen avulla toteutettua olioiden polymorfismia, joka onkin kulmakivi monissa olio-pohjaisissa järjestelmissä.

 

main()-funktio

main() on funktio, josta ohjelman suoritus alkaa - tässä ei liene mitään uutta. Tähän mennessä main() on määritelty:

void main()
{
	// ohjelma
}

Tämä on kuitenkin hieman puutteellinen tapa, kuten kohta tulet huomaamaan. Määrittelyä on oppaan aluksi käytetty selkeyden vuoksi. Toinen yleinen, mutta hieman monimutkaisempi tapa on määritellä:

int main(void)
{
	// ohjelma 
	return 0;
}

Kuten huomaat, tavoissa on kaksi eroa. Parametrit ovat erilaisia, mutta käytännössä määrittelyt ovat identtisiä - määrittely main() tulkitaan C++-kielessä main(void). Tämä koskee kaikkia funktioita, ei pelkästään main():ia. Molemmat tavat toimivat, kuten alla olevasta esimerkistä huomaat:

#include <iostream.h>

int Funktio(void); // käytetään voidia

void main()
{
	cout << Funktio();
}

int Funktio() // ei käytetä voidia, mutta sama on Funktio silti..
{
	return 5;
}

Kysymys on siis pelkästään makuasiasta - joten tappelu alkakoon! Miksi sitten ei kirjoittaa tyhjää (void), kun parametrien lista kerta on tyhjä? No, onhan tyhjä määrittely melko kuvaava määrittely tyhjälle. Tarkkaavainen saattaisi todeta, että kun siihen jotain kirjoittaa, niin eihän se enää ole tyhjä. Minusta siis voidin poisjättäminen on havainnollisempaa. Toinen syy on siinä, että voidin voi sotkea void* -osoittimeen, kuten alla olevassa esimerkissä demonstroidaan:

#include <iostream.h>

int Funktio(void*);

void main()
{
	int luku = 5;
	cout << Funktio((void*)&luku);
}

int Funktio(void* param) 
{
	int* p = (int*)param;
	return (*p) + 1;
}

Jos olet aito aloittelija, niin saat olla melkoinen maaginen ihmemies mikäli täysin ymmärrät yllä olevan esimerkin - kun osoitinmuuttujia ei ole vielä edes vielä selitetty. Ja hyvä on jos et ymmärrä, nimittäin tuo ohjelma on hyvien sääntöjen, puhtaan järjen ja itsesuojeluvaiston vastainen. Mutta toimii silti. Ohjelmassa on kuitenkin oikeastaan vain yksi tärkeä rivi, jonka varmaan ymmärrät: Funktio():n prototyyppi.

int Funktio(void*);

Se on yhtä tähteä vaille samanlainen kuin edellisessä esimerkissä. Funktion toiminta on kuitenkin aivan erilainen. Tyhjän parametrilistan määrittely on helpommin erotettavissa void* -osoittimesta. Valitettavasti tämä perustelu ei ole mitenkään väkevä, koska void*-osoittimien käyttö C++-ohjelmissa on merkki huonosti suunnitellusta ohjelmasta. Ainoa syy niiden käyttöön on maksimaalisen nopeuden saavuttaminen, mutta vastapainona on melkoinen riski yhdellä ohjelmointivirheellä saada aikaan kokonainen virheiden kyinen pelto kynnettäväksi.

Toinen ero äsken esitellyissä main()-funktioissa on paluuarvo - ja tämä onkin tärkeämpi tapaus. Mihin main():n paluuarvo palautuu? Kuka main():ia edes kutsuu? Vastaus on, että kääntäjä kutsuu. Kun käynnistät ohjelman, käyttöjärjestelmä lataa sen ja rupeaa suorittamaan sitä. Se, että miten käyttöjärjestelmä käynnistää ohjelman riippuu ihan käyttöjärjestelmästä. Yksinkertaisessa tapauksessa se vain rupeaa syöttämään prosessorille ohjelmakoodia tiedoston alusta, yleensä kuitenkin ohjelmatiedostoissa (kuten .exe-tiedostoissa) on alussa jonkinlainen tunnisteosa, jonka käyttöjärjestelmä lukee ja siirtyy sen jälkeen ennalta määrättyyn kohtaan ohjelmakooditiedostoa ja rupeaa suorittamaan ohjelmaa siitä eteenpäin. Niinpä meidän kääntäjämme ovelasti järjestää asiat niin, että juuri siinä kohden mistä ohjelman suoritus alkaa on kutsu main()-funktioon. Eli käyttöjärjestelmä antaa alkusysäyksen ohjelmalle ja käyttöjärjestelmä myöskin saa main()-funktion paluuarvon.

Paluuarvo on siis on arvo, jonka ajettu ohjelma palauttaa käyttöjärjestelmään. Paluuarvo on peräisin Unixeista, joissa sitä hyödynnetäänkin melko ahkerasti. On hyvän ohjelmointitavan mukaista, että ohjelmat palauttavat arvon, jolla viestivät siitä kuinka ohjelman suoritus onnistui. Standardi on, että onnistunut ohjelma palauttaa arvon EXIT_SUCCESS (EXIT_SUCCESS on siis vakio, muuttuja joka on kääntäjän määrittelemä). Jos jokin menee pieleen, palautetaan EXIT_FAILURE. EXIT_SUCCESS on lähes poikkeuksetta 0, kun taas EXIT_FAILURE riippuu järjestelmästä riippuen - yleensä se on 1 tai 255. Yleinen tapa palauttaa nolla main():n onnistuneen suorituksen päätteeksi ei siis ole ihan kaikkien taiteen sääntöjen mukainen, vaikka käytännössä aina toimiikin.

Paluuarvo on hyvin kätevä, kun rakennetaan komentojonoja, jotka kutsuvat C++-ohjelmaa ja jatkavat toimintaansa suorituksen onnistumisen, siis paluuarvon, perusteella. Esimerkiksi kun kopiointiohjelma palauttaa EXIT_FAILURE-arvon mikäli levytila loppuu kopioidessa kesken, voi varmuuskopioita tekevä komentojono lukea paluuarvon ja sen perusteella käynnistää sähköpostiohjelman, joka lähettää ylläpitäjälle "Vaihda nauhavarmistimeen uusi nauha"-viestin. Unixissa lapsiprosessien suorituksen onnistumista voi myös tutkia paluuarvon perusteella. Useissa Unix-shelleissä paluuarvo löytyy $?-ympäristömuuttujasta, kun taas DOSissa (ja NT4 DOS-shellissä) paluuarvo löytyy muuttujasta ERRORLEVEL. Sen voit todeta vaikka tekemällä ohjelman, jonka main() palauttaa tietyn arvon, ajamalla sen ja komentamalla komentorivillä:

echo %ERRORLEVEL%

Ja vaikka et itse käyttäisi ohjelmaa tällaiseen tarkoitukseen, saat hyvin pienellä vaivalla aikaan hyvää mieltä muille, kun teet ohjelmasi niin että se viestii toiminnastaan paluuarvolla (esimerkiksi: EXIT_SUCCESS (tai 0) = virheetön toiminta, 1 = ajonaikainen virhe, 2 = virhe tiedoston avauksessa jne.. tai yksinkertaisesti ja standardisti: EXIT_SUCCESS = virheetön suoritus, EXIT_FAILURE = virheellinen suoritus).

Tästä eteenpäin käytämme oppaassa oikeaoppista main()-määrittelyä. Kaikkien taiteen sääntöjen ja pölyisten oppikirjojen mukainen main()-funktio on siis tällainen:

int main()
{
	// ohjelma
	return EXIT_SUCCESS;
}

 

Lohkot, lauseet ja lausekkeet

Kolme L:ää, lohkot, lauseet ja lausekkeet. Mitä ne ovat? Aloitamme lauseesta, se on nimittäin helppo nakki. Lause on pätkä ohjelmaa, joka tekee jonkun tietyn asian. Lause päättyy puolipisteeseen ( ; ).

a = 10 * b; // lause

Lause voi myös olla tyhjä, silloin se siis on pelkkä puolipiste. Normaalin lauseen voi korvata monella lauseella, siis lohkolla. Lohko on {}-merkkien väliin kerätty kasa lauseita. if-käskyn jälkeen tulee lause, jonka voi tietenkin korvata lohkolla. Eli:

if (a == 10) doSomething(); // lause

if (a == 10)		    // lohko
{
	a += 150;
	b--;
}

Lohkoa ei siis päätetä puolipistein, mutta lohkon sisällä olevat lauseet kylläkin. Lohkoilla on myös tärkeä merkitys muuttujien näkyvyyden kannalta. Tästä sitten seuraavassa kappaleessa.

Sitten viimeinen villahousu, lauseke. Lauseke on pätkä, jolla on joku arvo. Vanha kansa sanoo, että lauseke palauttaa arvon. Lauseke 10 + 15 palauttaa arvon 25. Tämä arvo voidaan sitten hyödyntää lauseessa, sijoittamalla se vaikka johonkin.

 c = 10 + 15; 

Mutta myös lause on lauseke. Lauseke c=10 + 15 palauttaa c:hen sijoitetun arvon, eli voidaan kirjoittaa:

a = c = 10 + 15; 

Nyt kannattaisi Sekalaiset-kohdasta lukea hyvän koodin kirjoitusta käsittelevä kohta ja sitten palata Muuttujien näkyvyyteen.

 

Muuttujien näkyvyys

Muuttujilla on näkyvyysalueita. Tällä tavalla ei muistia hukata, kun turhat muuttujat poistuvat käytöstä. Muuttujat voidaan jakaa raa'asti kahteen osaan, globaaleihin (global) ja paikallisiin (local). Globaaleja muuttujia käytetään hyvin harvoin, hyvin suunnitelluissa ohjelmissa ehkä ei koskaan. Itseasiassa en muista milloin viimeksi olisin globaalin muuttujan määritellyt, siitä on jo aikaa. Globaalit muuttujat näkyvät koko ohjelmassa. Ne ovat siis kaikkien käytettävissä. Paikalliset muuttujat näkyvät vain siinä lohkossa, missä ne ovat määritelty. Kun lohko loppuu, muuttujat hävitetään. Funktion parametrit ovat funktion paikallisia muuttujia.

Globaali muuttuja määritellään ohjelman alussa ennen main() -lohkoa. Paikallinen muuttuja määritellään lohkon sisällä ja se häviää lohkon lopussa. Muuttujia ei C++:ssa tarvitse määritellä lohkon alussa, kuten C:ssa, vaan ne voi määritellä missä haluaa. Eroa siis ei ole itse muuttujan määrittelyssä, vaan missä kohti muuttuja sijaitsee.

Esimerkki: (kääntäjä ei suostu tätä kääntämään!)

void Funktio();

int olenGlobaaliMuuttuja;

int main()
{
  int olenPaikallinenMuuttuja;
  Funktio();
  olenGlobaaliMuuttuja = 1;        // onnistuu
  olenPaikallinenMuuttuja = 1;     // onnistuu
  olenMyosPaikallinenMuuttuja = 1; // ei onnistu
  return EXIT_SUCCESS;
}

void Funktio()
{
  int olenMyosPaikallinenMuuttuja;
  olenGlobaaliMuuttuja = 1;        // onnistuu
  olenMyosPaikallinenMuuttuja = 1; // onnistuu
  olenPaikallinenMuuttuja = 1;     // ei onnistu
}

Globaali muuttuja määritellään ohjelman alussa. Sitä voidaan käyttää mistä vain. Ohjelmassa määritellään myös kaksi paikallista muuttuja ja niitä voidaan käyttää vain niistä lohkoista missä ne on määritelty.

Hyvään ohjelmointitapaan kuuluu globaalien muuttujien välttäminen. Miksi? Koska niille voidaan antaa arvo mistä vaan, niin niille voidaan antaa väärä arvo mistä vaan. Väljästi totuuspohjainen esimerkki: on kaksi funktiota. Toinen on tehty valmiiksi ja lopussa on virheentarkistukseen liittyvä rivi:

if (globaaliTila = 1) return 0;

Tämän tarkoitus olisi siis tarkistaa, että globaaliTila on 1 eli kaikki on kunnossa. Vertailuoperaattori ==:n sijaan onkin tullut vain yksi =, mikä meinaa sijoitusta. Sijoitus on aina tosi, siis funktio palauttaa arvon 0 aina. Ja sen lisäksi globaaliTila:n sijoitetaan arvo 1, joten jos vaikka todellisuudessa globaaliTila olisi 1000 (maailmanloppu kolkuttaa ovella ja Belsebuubi puhaltaa vaskiseen torveensa), kaikki nollautuisi tämän rivin jälkeen. Se on riittävän inhottavaa jo yksinään, mutta kun virhettä ei heti huomata - kaikkihan näyttää toimivan, vaikka todellisuudessa ei toimisikaan - ja rupeamme kirjoittamaan toista funktiota, huomaamme että kaikki ei ole kunnossa vaikka globaaliTila niin väittääkin. Tässä vaiheessa ohjelmoija seisoo polvia myöten juoksuhiekassa ja punapyllyiset paviaanit kutittelevat häntä nenästä strutsinsulilla - tai ainakin yhtä hermostuttava tilanne on. Vaatii paljon epätoivoa, että meidän tuskastunut ohjelmoijamme vielä kerran katsoo jo valmiiksi luultuun funktioon ja löytää virheen sieltä. Nyrkkisääntö on: mahdollisuus muuttaa muuttujan arvoa tulee olla vain niillä ohjelman kohdilla, jossa sitä oikeasti tarvitaan. Globaaleja muuttujia on mahdollista muuttaa joka ikisessä ohjelman kohdassa, mikä on noin 99% liikaa.

Muuttujan näkyvyysalue ei ole aivan sama asia kuin viittausalue, siis se alue jossa muuttujan nimeä voidaan käyttää siihen viittaamiseen. Ero tulee esille silloin, kuin paikallisesti määritellään muuttuja, joka on samanniminen kuin joku globaali muuttuja. Silloin muuttujan nimi viittaa paikalliseen muuttujaan - niin kauan kuin se on olemassa. Siis vähempiarvoinen, paikallisempi muuttuja on jonossa ekana jos samannimisiä muuttujia on paljon. Jos haluamme tehdä selväksi, että tarkoitamme globaalia muuttujaa, käytämme :: (kaksi kaksoispistettä) -operaattoria. Tällä ei vielä tässä vaiheessa ole juurikaan merkitystä, mutta alla on kuitenkin demonstraatioesitys aiheesta.

#include <iostream.h>

int muuttuja = 1;

int main()
{
	int muuttuja = 2;
	
	cout << "Paikallinen muuttuja " << muuttuja << endl;
	cout << "Globaali muuttuja " << ::muuttuja << endl;
	return EXIT_SUCCESS;
}

Takaisin