torstai 7. elokuuta 2008

Funktio-osoittimet C:ssä.





Suurin osa C ohjelmijista törmää jossain uransa vaiheessa termiin "callback function", "jump tabl" tms. Hieman syvemmälle porauduttuaan, ohjelmoija huomaa, että ideana on tunkea funktio johonkin muuttujaan, josta sitä voidaan tarvittaessa kutsua.. Tai itse asiassa, eihän funktiota mihinkään laiteta, vaan osoitin funktion alkuun. Muistammehan toki, että ohjelma on vain köntti komentoja ja dataa muistissa. Ja jossain päin muistia majailee myös funktiot. Silloinhan on oikeastaan varsin luonnollista tallettaa funktion alkuosoite muuttujaan, ja tarvittaessa pompauttaa ohjelman suoritus tiettyyn kohtaan koodia, riippuen siitä, mikä funktio muuttujaan on talletettu...

No ihan näin helppoahan se ei kuitenkaan voi olla, eihän kyse muuten olisi C-kielestä.

ongelmaksi tulee taas tietysti C:n tietotyypit. osa funktioista voi syöda argumentteinaan chareja, osa inttejä, ja osa vaikka mitä... Joihinkin funktioihin ei mene parametreja, toisiin menee useita, ja paluuarvotkin vielä vaihtelee. Siksipä emme voikkaan tehdä "geneeristä" funktio-osoitin tyyppiä, joka kävisi kaikille funktioille, vaan erilaisia argumentteja/palautusarvoja käyttäville funktioille on määritettävä omat funktio-osoitin tyyppinsä. Ja tästä syystä mikäli teemme funktion, joka ottaa argumenttinaan funktiopointterin, on usein käytännöllistä määrittää argumentin tyypiksi "osoitin funktioon joka ottaa argumenttinaan void * osoittimen, ja palauttaa myös sellaisen". Tällöin emme sido liikaa funktiomme käyttäjän käsiä - varsinkaan jos emme tiedä tarkasti mihin tarkoitukseen funktiotamme tullaan käyttämään.

No niin. Mihinkäs tätä hienoutta nyt sitten voidaan käyttää? Ainakin kaksi asiaa tulee äkkiä mieleen. "Callbackit" ja "jump tablet".

Callbackit:

Kuvitellaanpa että teet C:llä hienonhienoa matopelimoottoria. Moottori laskee madon paikan ja huolehtii siitä, että käyttäjällä on mahdollisuus suorittaa haluamansa toiminto jos mato eksyy kielletylle alueelle eli seinään. Mutta koska et voi tietää millaista matopeliä käyttäjä onkaan tekemässä, et voi vain summanmutikassa piirtää traagista Game over ruutua. Varsinkaan kun et tiedä, tahdotaanko Game over ruudussa näkyvän verinen kolarin ajanut mato, vaiko kenties täysverinen kyy..

Yksi mahdollinen tapa tietysti olisi tehdä globaali muuttuja esim int G_matoSeinassa, joka olisi alustettu 0:ksi, ja madon kohdatessa seinän, moottori muuttaisi arvon ykköseksi.Nyt varsinaisen pelin pitäisi kuitenkin jatkuvasti 'pollailla' muuttujaa, nähdäkseen onko sen arvo muuttunut. Tämä luonnollisesti kuluttaisi turhaan arvokasta CPU-aikaa, ja koska käyttöjärjestelmät joiden päällä matopelejä pelataan ovat harvoin reaaliaika systeemeitä, tulisi seinään törmäyksen, ja muuttujaa pollailevan "taskin" suoritukseen mahdollisesti "lagia". Eli mato törmäisi ensin seinään, ja vasta tovin päästä peli huomaisi piirtää Game over ruudun - mahdollisesti päivitettyään ensin madon paikan seinän sisään..

Callback funktion käyttö on yksi mahdollinen ratkaisu ongelmaan. Eli, matopelimoottorin käyttäjä kirjoittelee funktion, joka hoitaa game over ruudun piirron, ja pysäyttää pelin muun etenemisen seinääntörmäyksen yhteydessä. Tätä funktiota nyt sitten käytetään "callback funktiona". Ts, annamme matopelikoneelle moottorin starttauksen yhteydessä osoittimen tähän funktioon, ja kerromme moottorille että mikäli mato kohtaa seinän, moottorin tulee kutsua kyseistä funktiota. Eli pelimoottoriin pitää siis koodata "callbackin rekisteröinti funktio". Yksinkertaisimmillaan se voisi olla seuraavanlainen temppu:

int rekisteroi_seina_callback( void (*seinaCBF)() )
{
//Tarkista ettei seinaCBF ole NULL
if(NULL==seinaCBF)
return 0;

/*
Talleta funktion osoite johonkin moottorin sisäiseen tietorakenteeseen myöhempää käyttöä varten
*/

G_seinacallback=seinaCBF;
return 1;
}

Ja kohta jossa seinäcallbackkia kutsutaan:

.
.
.
if( !mato_seinassa_checkki() )
{
(*G_seinacallback)();
}

ja näin käyttäjä rekisteröisi seinäcallbackin:

void tee_tama_jos_seinassa()
{
.
.
.
}

void kaynnista_matomoottori()
{
rekisteroi_seina_callback( &tee_tama_jos_seinassa );
}

Tai:

void kaynnista_matomoottori()
{
void (*seinaCBF)() = &tee_tama_jos_seinassa;
rekisteroi_seina_callback( seinaCBF );
}


Usein kannattaa kuitenkin käyttäjää ajatellen määritellä tarvittavaa funktio-osoitinta vastaava tyyppi, jolloin käyttäjän ei tarvitse käyttää C:n sekavia tietotyyppimäärityksiä vaan käyttäjä pystyy tekemään asiat definoidun tyypin nimellä. Eli:

typedef void (*seina_callback)()

int rekisteroi_seina_callback( seina_callback seinaCBF )
{
//Tarkista ettei seinaCBF ole NULL
if(NULL==seinaCBF)
return 0;

/*
Talleta funktion osoite johonkin moottorin sisäiseen tietorakenteeseen myöhempää käyttöä varten
*/

G_seinacallback=seinaCBF;
return 1;
}

Ja kohta jossa seinäcallbackkia kutsutaan:


.
.
.
if( !mato_seinassa_checkki() )
{
(*G_seinacallback)();
}

ja näin käyttäjä rekisteröisi seinäcallbackin:

void tee_tama_jos_seinassa()
{
.
.
.
}

void kaynnista_matomoottori()
{
rekisteroi_seina_callback( &tee_tama_jos_seinassa );
}

Tai:

void kaynnista_matomoottori()
{
seina_callback seinaCBF = &tee_tama_jos_seinassa;
rekisteroi_seina_callback( seinaCBF );
}


Eli tiivisteenä (sis 120% funktio-osoitin asiaa):

Funktio-osoittimen määrittely: (kts. C:n määrittelyjen luku)
parametriton, paluuarvoton funkti:
void (*nimi)();

arvon palauttava, parametrillinen funktio:
<paluuarvon> (*nimi)(<arg1>, <arg2>, ..., <argn>)

Eli esim.
double (*laske_summa_ptr)(double, double);

tai
void * (*varaa_muistia_ptr)(size_t maara);


Funktio-osoittimen alustus:

int *functio(char *foo, int bar)
{
}
...
int *(*osoitin_funktioon)(char *,int);
osoitin_funktioon = &funktio;

& - merkki voidaan useimpien kääntäjien kohdalla jättää pois funktion edestä, mutta portattavuuden takia se kannattaa varalta siinä pitää. Sitäpaitsi, kun kerran funktion osoite on se mitä oikeastikkin haetaan, on kauniimpaa käyttää &-operaattoria, jolla osoite oikeastikkin haetaan.
(osoitin_funktioon = funktio; /* tämä siis luultavasti toimisi myös, mutta on ruma ja laiska tapa jonka käyttäjät tulisi käristää hiljaisella tulella. */ )

funktio-osoittimen käyttö funktiota kutsuttaessa:
tulos = (*laske_summa)(1.00,1.00);

Ja jälleen on useimmilla kääntäjillä tuettuna myös kerettiläisyyden ruumiillistuma:
tulos = laske_summa (1.00,1.00);

Ja sitten, ihan vain virkistääksemme muistiamme edelliseen artikkeliin liittyen, katsomme yhden tyyppihirviön:

int* (*(*foo)(int *,char *))(double,int *);

Eli, aletaan käymään läpi keskeltä, oikea -vasen - oikea (ellei kohdata sulkeita) systeemillä.
Siis: "Foo on.."
Sulkeet, eli vasemmalta => osoitin:
   "foo on osoitin..."
oikealle => funktio:
   "foo on osoitin
   ...funktioon..."
vasemmalle => osoitin,
   "foo on osoitin
   ...funktioon
   ...joka palauttaa osoittimen..."
oikealle => funktio
   "foo on osoitin
   ...funktioon
   ...joka palauttaa osoittimen
   ...funktioon..."
vasemmalle, oikealla ei enää mitään => osoitin ja int
   "foo on osoitin
   ...funktioon
   ...joka palauttaa osoittimen
   ...funktioon
   ...joka palauttaa osoittimen int tyypin lukuun"

... Toivotaan, että tuo meni oikein... Jompi kumpi teistä lukijoistani voisi varmaan tarkistaa tämän?

Ja sitten vielä se Jump Table

Ja ennen kun lopetan, kerron vielä lyhyesti mikä on hyppytaulu "jump table":

Eli, kuvitellaan tilanne, jossa meillä on funktio foo, joka palauttaa kokonaisluvun väliltä 0-5, siten, että palautetusta luvusta riippuen haluamme tehdä jonkin tempun. Mikäli temput ovat riittävän yksinkertaisia, eivätkä vaadi kovin paljoa informaatiota alkutilasta, saattaa olla if-else tai switch-case häkkyrää selkeämpi (ja tehokkaampi!) tapa käyttää "jump tablea".

Eli, luomme temppujen tekoon 5 eri funktiota. Kukin siis suorittaa yhden tempun. Pakotamme funktioille vielä samanlaiset argumentit.
Eli meillä on funktiot:

void temppu1(void);
void temppu2(void);
void temppu3(void);
void temppu4(void);
void temppu5(void);

Funktio josta saamme paluuarvon olkoot vaikka funktio

int mitas_seuraavaksi();


Nyt sitten jump table:

typedef void (*temppu_osoitin)(void);

void tee_temput()
{
temppu_osoitin temput[5];
temppu[0]=&temppu1;
temppu[1]=&temppu2;
temppu[2]=&temppu3;
temppu[3]=&temppu4;
temppu[4]=&temppu5;

/*
Nyt käytämme hyppytaulukkoamme. Vältämme siis funktion paluuarvon vertailun, mikä saattaa olla hyvinkin kriittinen ajansäästö jossain esim kiireellisissä loopissapyöritettävissä funktioissa DSP ympäristössä
*/

*(temppu[ mitas_seuraavaksi()])();
}


Ja lopuksi huomautus kaikille veneilijöille, sekä muille lukijoilleni:
1. Esimerkit ovat jälleen kerran testaamatta, joten bugeja voi olla. Kiitän jokaisesta bugikorjauksesta jonka saan!
2. C++:n luokan jäsenfunktioille funktio-osoittimet ovat hieman erilaisia, mukana roudattavan this osoittimen takia..

Ehkäpä minun pitäisi seuraavaksi jaaritella C++ funktiopointtereista?
Ehkä, ehkä ei. Nyt on kuitenkin aika taas lopettaa, ja todeta että näemme ehkä ensi jaksossa taas, mitä se sitten pitääkään sisällään :)

3 kommenttia:

Maz kirjoitti...

Kiitos Metabolixin tarkkaavaisuuden, seuraava korjaus on nyt tehty:

int* *(*foo)(int *,char *)(double,int *);
=>
int* (*(*foo)(int *,char *))(double,int *);

Anonyymi kirjoitti...

*(temppu[ mitas_seuraavaksi())();

-> Tuossa on taulukon indexin lopettava sulkumerkki tavallinen, kun pitäisi olla hakasulku.

Nuo esimerkit voisivat olla selkeämpiä jos funktion ottaisivat jotain argumentteja.

Maz kirjoitti...

Kiitoksiet Anonyymille. Esimerkistä oli todellakin hakasulku jäänyt puuttumaan. No nyt se on korjattu.

Ja mitä tulee argumentteihin, varsinaisessa tyyppimäärittelyjä valoittavassa blogitekstissäni taisin esiteällä funktio-osoittimia argumenttien kanssa.
( http://c-ohjelmoijanajatuksia.blogspot.com/2008/08/c-kuinka-luet-tyypimrittelyj.html )