Viittaukset ja osoittimet

 

Viittaukset

Haluatko tietää, mitä tapahtuu kun funktiota kutsutaan. Ai, et? No, tämä kasvattaakin luonnetta.

PC-tietokoneessa on erityinen muistialue, pino (stack). Kun funktiota kutsutaan, kääntäjä heittää funktiolle välitettävät parametrit pinoon. Sitten funktiossa parametrit luetaan sieltä pinosta. Muuttujia ei kuitenkaan siirretä pinoon, vaan kopioidaan. Kun muutamme funktiosta käsin jotain arvoa, ei alkuperäinen parametriksi annettu muuttuja muutu. Kuvittele, että teet funktion joka käsittelee sadan megatavun tietokantaa. Sinulla on kaksi ongelmaa: sadan megatavun kopioiminen syö prosessoriaikaa melko roimasti ja muuttaessasi tietokantaa funktiossa ei itse oikea tietokanta muutukaan.

Mutta eikö olisikin kätevää, jos funktiolle kerrottaisiin missä päin muistia tietokanta majailee? Tämän osoitteen avulla se voisi muutta itse tietokantaa. Tällöin tietokantaa ei tarvitsisi siirtääkään minnekään. Mikähän se mahtaisi olla semmoinen vempele joka tämän tempun osaisi..

Löpinät sikseen ja viittaukset esille! Viittaus (reference) on muuttuja, joka kertoo jonkun toisen muuttujan sijainnin muistissa. Viittaus ei siis sisällä sitä arvoa mikä muuttujalla on, vaan muuttujan muistipaikan osoitteen. Viittauksen tyyppi on sen muuttujan tyyppi, mihin viittaus viittaa. &-merkki kuitenkin tarkoittaa, että nyt olemme luomassa viittausta, emme "oikeaa" muuttujaa.

#include<iostream.h>

int  main()
{
  int muuttuja = 2;	      // muuttuja
  int& rViittaus = muuttuja;  // viittaus

  cout << "muuttuja on " << muuttuja << endl;
  cout << "rViittaus on " << rViittaus << endl;

  muuttuja = 5;

  cout << "muuttuja on " << muuttuja << endl;
  cout << "rViittaus on " << rViittaus << endl;

  rViittaus = 2;

  cout << "muuttuja on " << muuttuja << endl;
  cout << "rViittaus on " << rViittaus << endl;
  
  return EXIT_SUCCESS;
}

Kuten huomaat, viittausta käytetään ihan kuin muuttujaakin. Viittauksen osoitetta ei voi käsitellä sen luonnin jälkeen. Viittaus alustetaan jonkun muuttujan osoitteella ja se säilyttää arvonsa niin kauan kun se on olemassa.

Kun haluamme laittaa viittauksen funktion parametriksi, merkkaamme sen viittaukseksi &-operaattorilla ja käytämme sitä muuten kuten normaalia muuttujaakin. Tällä kertaa vaan muutamme alkuperäistä muuttujaa, emme sen kopioita. Näin se käy käytännössä:

#include<iostream.h>

void Kasvata(int& luku)
{
  luku++;
}

int main()
{
  int arvo = 5;
  cout << "arvo: " << arvo << endl;
  Kasvata(arvo);
  cout << "arvo: " << arvo << endl;
  
  return EXIT_SUCCESS;
}

Viittauksilla on kuitenkin yksi huono puoli: ne viittaavat samaan muuttujaan. Monissa asioissa, kuten linkitetyissä listoissa, tarvitaan viittausta, jonka kohdetta voidaan vaihtaa. Sellainen on osoitin.

 

Osoittimet

On oikeastaan väärin sanoa, että osoitin on viittaus, joka on hieman monipuolisempi. Pikemminkin viittaus on osoitin, josta on karsittu epäturvallisia ominaisuuksia. Osoittimien etu on siinä, että niiden oikeaa arvoa, siis itse osoitetta, voi käsitellä. Toisaalta se on niiden haittakin: osoitin joka osoittaa väärään paikkaan muistia on melkoinen aikapommi. Osoittimien käyttäminen on myös paljon hankalampaa, koska niiden kielioppi on monimutkaisempi.

Osoitin (pointer), kuten viittauskin, osoittaa johonkin muuttujaan. Sen tyyppi on sama kuin osoitetulla kohteella. Erona kuitenkin osoittimet merkitään *-merkillä. Se on sama kuin kertomerkki, mutta asiayhteydestä voidaan aina päätellä kumpi operaattori on kyseessä. Otetaan ja sorvataan nyt osoitin.

int muuttuja;	      // muuttuja
int* pOsoitin = 0;    // osoitin
pOsoitin = &muuttuja  // muuttujan osoite osoittimeen

Aluksi luodaan ihan normaali muuttuja. Sitten luodaan osoitin int-tyyppiseen tietoon, nimeltään pOsoitin ja alustetaan se nollaksi. Osoitin ei siis osoita mihinkään muistissa olevaan nollaan, vaan se osoittaa muistin kohtaan nolla. Silloin se on NULL-osoitin. Jos emme alustaisi osoitinta, se voisi osoittaa ihan mihin tahansa ja käytettäessä sitä kirjoittaisimme ihan minne tahansa ja sillä tavalla sotkisimme kaiken perinpohjaisesti. Kun alustamme osoittimen nollalla, se on vaarattomampi jos erehdymme sitä alustamattomana käyttämään.

Sitten marssitamme esiin uuden operaattorin. Se on osoite-operaattori, &. Sillä saadaan muuttujan osoite kaivettua esille. Tämä osoite tallennetaan sitten osoitinmuuttujaan.

Osoittimien käyttö on hieman monimutkaisempaa kuin viittauksien. Itse osoitinmuuttujan arvo on muistiosoite. Kun haluamme päästä käsiksi siinä muistiosoitteessa sijaitsevaan tietoon, meidän tulee käyttää osoitus-operaattoria, *.

int eka, toka;	   // kaksi muuttujaa
eka = 10;	   // alustetaan eka
int* pOsoitin = 0; // luodaan osoitin

pOsoitin = &eka;    // pOsoitin osoittaa ekaan
toka = *pOsoitin;  // toka sisältää arvon 10
cout << toka;	   // varmistetaan 

Kuten arvaat, osoitteiden ja osoitettujen tietojen kanssa menee helposti sekaisin. Niinpä kannattaakin käyttää viittauksia aina kuin mahdollista. Joskus kuitenkin pitää pystyä vaihtamaan osoituksen kohdetta, jolloin viittaukset eivät riitä.

int yy, kaa, koo;
yy = 1;
kaa = 2;
koo = 3;
int* pMonesko = 0;

pMonesko = &yy;
cout << *pMonesko;

pMonesko = &kaa;
cout << *pMonesko;

pMonesko = &koo;
cout << *pMonesko;

 

Salattu totuus taulukoista ja moninkertaisista osoittimista

Oppipoikani/tyttöni, olet jo edennyt pitkälle ja Voima on sinussa vahva. Ehkäpä tässä vaiheessa on oikea aika kertoa sinulle totuus - totuus taulukoista.

Taulukko ei todellisuudessa ole kuin osoitin. Kyllä, ymmärrän, se voi tuntua vaikealta hyväksyä. Mutta minäpä kerron tarkemmin. Taulukon nimi itseasiassa on vain osoitin taulukon ensimmäiseen alkioon. Taulukko-operaattori [] toimii siis samoin kuin osoite-operaattori *. Merkintä taulukko[5] voidaan tulkita myös *(taulukko+5). Siis otamme taulukon osoitteen ja lisäämme siihen viisi kertaa taulukon yhden alkion koon. Ei siis viisi tavua, vaan viisi kertaa taulukon alkion koon verran - oli koko sitten mikä tahansa. Kääntäjä hoitaa tämän laskutoimituksen. Jos halutaan taulukon kymmenennen (eli indeksissä yhdeksän olevan) alkion osoite, voidaan se tehdä perinteisesti &taulukko[9] tai gurummin taulukko+9. Ja hoplaa, nyt ymmärrät myös miksi taulukon ensimmäinen alkio on 0, eikä yksi. Koska taulukon nimi osoittaa jo ensimmäiseen alkioon, ei siihen pidä lisätä mitään - siis pitää lisätä 0 - jotta saadaan eka alkio hyppysiimme.

Toki vielä pitemmälle voidaan mennä ja luoda taulukollinen osoittimia. Se tapahtuu tyyliin

int* taulukko[10];

Nyt siis jokainen taulukon alkio osoittaa johonkin int-muuttujaan. Sijoitus taulukko[2]=3; ei ole todellakaan laillinen, koska silloin taulukko[2] osoittaa muistin kohtaan kolme, jossa on mitä sattuu. Kun sitä käytetään, on kaikki totaalisen sekaisin. Nyt seuraakin pähkinä: miten saataisiin kyseisen taulukon ensimmäisen alkion osoittama tieto esille? No, tietenkin näin: *taulukko[0]. Mutta myös tämmöinen on mahdollista: **taulukko. Ja mikä hienointa, tämä esimerkki ei ole teennäinen tai hatusta vedetty. Jos haluamme luoda taulukollisen vanhanaikaisia C-tyylisiä merkkijonoja - esimerkiksi kaikkien veljien nimet - niin tarvitsemme tuota **taulukko (tai *taulukko[]) osoitinta, siis osoitinta osoittimeen (kutsutaan myös kaksoisosoittimeksi tai tuplapointteriksi). Myös kun haluamme tehdä matriisia vastaavan taulukon, tarvitsemme osoitinta osoittimeen. Matriisi on kaksiulotteinen taulukko, siis esimerkiksi kuvapisteet ruudulla muodostavat eräänlaisen matriisin. Se voitaisiin tietenkin tehdä tavallisena kaksiulotteisena taulukkona (matriisi[][]), mutta "kaksoisosoittimen" käytössä on etunsa - ne ovat nopeampia ja joustavampia. Eikä osoitinhommailu rajoitu kaksoisosoittimeen, vaan voidaan tehdä myös osoitin osoittimen osoittimeen jne.. Käytännössä kaksinkertainen osoitin on suurin mitä tarvitaan. Mutta pitäähän sitä elämässä olla leikkiä, kuten alla olevassa esimerkissä:

#include <iostream.h>

int main()
{
	int a = 5;
	int *p = &a;
	int **pp = &p;
	int ***ppp = &pp;
	int ****pppp = &ppp;
	int *****ppppp = &pppp;
	
	cout << "a on " << *****ppppp << endl;
	
	return EXIT_SUCCESS;
}

 

Kun viittaillaan, osoitellaan ja tökitään miten sattuu

En päästä sinua kuitenkaan ihan näin helpolla, vaan tähän kohti lykätään vielä iso kasa hankalia viittaus- ja osoitustemppuja, jotta varmasti olet hahmottanut tilanteen.

#include <iostream.h> 
int main()
{
    int luku = 2;
    int &viittaus = luku; 
    int &toinenViittaus = viittaus; // int &toinenViittaus = luku;

    int *osoitin = &viittaus;       // int *osoitin = &luku;
    cout << "Arvo osoittimen kautta " << *osoitin << endl; 

    int *(&viittausOsoittimeen) = osoitin; // int *(viittausOsoittimeen) = &luku;
    cout << "Viittaus osoittimeen " << *viittausOsoittimeen << endl; 

    int taulukko[5] = {1, 2, 3, 4, 5};
    int (&viittausTaulukkoon)[] = taulukko;
    for (int i = 0; i < 5; i++) cout << viittausTaulukkoon[i] << " "; 

    const int &vakioViittaus = 3;
    
    return EXIT_SUCCESS;
}

Osoittimet funktioihin

Joo, myös funktioihin voi osoittimilla sohia. Vaan miksi pitäisi? Eikö sitä voi aina vaikka switch..case -rakenteella päättää mitä funktiota käytetään? Voi, mutta se on epäkätevää - millä sinänsä ei ole väliä - mutta myös koodin kopioimista! Härrendüdel, se on jo jotain.

Kuvitellaan vaikka tällainen vanhentunut esimerkki, että joku ohjelma tukee rautatasolla satoja näytönohjaimia. Eihän näin nykyään ole, kun on standardia vaikka millaista. Nykyään voisi vaikka ajatella, että ohjelma tukee kaikkia mahdollisia standardeja: Glide, OpenGl, GDI, DirectX... Pysytään nyt kuitenkin menneisyydessä: ohjelma käyttää näytönohjainkohtaisia funktioita näytön käsittelyyn: pikselin piirtoon jne.. Funktiot on kirjoitettu kymmenien eri ihmisten toimesta - eihän kenelläkään ole kymmeniä näytönohjaimia koneessaan millä testata funktioita. Funktiot ovat kaikki samanlaisia prototyypiltään, ainoastaan sisältö on erilainen. Pikselinpiirton käytetty funktio voisi vaikka olla tällainen:

int putPixel(int x, int y, char* buffer);

Kutsuessa funktiota pitäisi siis valita kymmenistä erilaisista putPixeleistä. Tulee melkoinen kasa switcheja ja vielä lohduttomampi viidakko caseja, kun näytönkäsittelyyn liittyviä kohtia on kuitenkin useissa kohdissa ohjelmaa. Ei, ei tuohon kelkkaan kannata kivuta. Nuoruus vaan menee pilalle kun näpyttelee kymmeniä kertoja samoja rivejä tietokoneen näyttöpäätteellä. Ja sitten jos rupeaa leikepöydän kauttaa niitä viljelemään, niin mielenterveys tärväytyy virheitä etsiessä. Kyllä se menee ihan selvästi funktiopointteriksi. Sellainen muuten määriteltäisiin edellisessä esimerkissä näin:

int (*pPutPixel)(int, int, char*);

Eli ihan kuin tavallinen funktio, * vaan nimen eteen merkkaamaan osoitinta. Eipä liene yllätys kenellekään. Sulkujen kanssa ei kuitenkaan pidä mennä hökeltämään, nimittäin tällaisessa muodossa juttu ei mene läpi:

int *pPutPixel(int, int, char*);

Juksasin, kyllä se läpi menee. Ei vaan tosin toimi. Kun sulkeilla ei viestitä, että * liittyy funktion nimeen, se tulkitaan liittyväksi int:iin. Siis yllä oleva on funktio, joka palauttaa osoittimen int-tietoon - ei funktio-osoitin funktioon, joka palauttaa int-muuttujan.

Jos palaamme taas esimerkkiin. Meidän tulee vain kerran valita oikea grafiikkafunktio, kas näin:

int (*pPutPixel)(int, int, char*);

switch (naytonOhjain)
{
	case STANDARD_SVGA: pPutPixel = SvgaPutPixel; break;
	case ET4000: pPutPixel = Et4000PutPixel; break;
	case CIRRUS: pPutPixel = CirrusPutPixel; break;
}

pPutPixel(100, 100, puskuri);
(*pPutPixel)(100, 100, puskuri); 

 

Kuten huomaat, sijoitus funktio-osoittimeen tapahtuu käyttämällä funktion nimeä ilman sulkeita. Kaikki PutPixel-funktiot palauttavat int-muuttujan ja ottavat parametreikseen (int, int, char*). Funktio-osoittimen avulla kutsutaan funktiota kuten se olisi tavallinen funktio. Myös osoitinoperaattoria voidaan käyttää, kuten viimeisellä rivillä on tehty. Tavalla ei ole väliä; kaksi viimeistä riviä tekevät saman asian.

Funktio-osoittimia voi myös kätevästi kasata taulukoksi. Jos esimerkiksi pitää ohjelmoida joku toimintosarja, jonka ohjelma suorittaa, voi funktio-osoitintaulukko olla näpsäkkä ratkaisu. Varsinkin kun kyse on nopeudesta osoittimet puolustavat kyllä paikkaansa. Monimutkaiset valintarakenteet ovat hitaita, mutta osoittimet vain aavistuksen tai ei yhtään hitaampia kuin suora käyttö.

 

Funktio-osoitintaulukot ja funktio-osoittimet parametreina

Funktio-osoitintaulukon määrittelemisessä ei ole mitään erikoista. Kunhan muistaa, että osoittimen nimi on sen kaiken muun sotkun keskellä: siis taulukoinnin tapauksessa hakasulkeet tulevat nimen perään, ei koko litanian perään. Ja sulkeita ei myöskään kannata unohtaa.

void (*pFunktioTaulukko[5])(int, int);

Funktiopointteri kulkee myös mukavasti parametrina. Funktion prototyypissä voidaan laittaa näin:

void Funktio(int (*) (int, int));

..jos ei haluta antaa parametrille nimeä siinä vaiheessa. Myös nimi voidaan antaa, mikä on pakko tehdä viimeistään funktion runkoa määritellessä:

void Funktio(int (*pFunktio) (int, int))
{
	pFunktio(1, 2); // kutsutaan osoitettua funktiota
}

Näissä vähän kehittyneemmissä jutuissa funktio-osoittimeen liittyvän litanian kirjoittaminen meinaa olla harmittavan hankalaa. Niinpä kannattaakin ottaa tyypin määrittely (typedef) käyttöön:

typedef int (*IPF) (int, int);
IPF pFunktio;
void Funktio(IPF);

Määritellään siis IPF (paluuarvo Int, Pointer to Function), jonka avulla tehdään yksi funktio-osoitin ja funktio, joka ottaa parametrikseen sen tyyppisen funktio-osoittimen.

 

Takaisin