Creative Commons License

Luku ja kirjoitus

C-kielessä luku- ja kirjoitus tapahtuu I/O-virtojen välityksellä. I/O-virta on abstraktio, jonka avustuksella voidaan toimittaa tietoa C-ohjelman ja ympäröivän järjestelmän välillä. I/O-virran kautta voidaan esimerkiksi lukea ja kirjoittaa käyttäjän syötettä tai tulostetta, tallentaa tietoa levylle, tai kommunikoida esimerkiksi tietoverkkoon. Ennenkuin I/O-virtaa voidaan käyttää, se pitää avata, ja vastaavasti käytön jälkeen virta tulee sulkea.

Toistaiseksi olemme käyttäneet kahta I/O-virtaa, jotka ovat oletusarvoisesti aina auki C-ohjelmissa. Näitä ei poikkeuksellisesti tarvitse erikseen avata. standardi-tulostevirtaa (stdout) käyttäen voidaan tulostaa tekstiä käyttäjän terminaalille. Esimerkiksi printf - funktio tuottaa annetun tekstin tähän virtaan. standardi-lukuvirtaa (stdin) käyttämällä voidaan lukea käyttäjän antamaa syötettä. Esimerkiksi scanf - funktio lukee saamiensa muotoilumääreiden mukaista syötettä lukuvirrasta. Lisäksi on standardi-virhevirta johon ohjelman tuottamat virheilmoitukset tulostuvat. Oletusarvoisesti nekin tulevat näytölle. Oletusvirtojen lisäksi ohjelman käyttöön voi avata muitakin virtoja, esimerkiksi tiedostoon kirjoittamista varten. Oletusvirrat voi myös ohjata johonkin toiseen kohteeseen, mutta tarkemmat siihen liittyvät yksityiskohdat eivät kuulu tämän kurssin piiriin.

Luku ja kirjoitus virtaan on puskuroitua. Puskuri on muistialue, johon tilapäisesti tallennetaan tuotettava tai luettava teksti ennenkuin se varsinaisesti välitetään kohteeseensa. Puskurointi auttaa käyttämään järjestelmän resursseja tehokkaammin, mutta saattaa aiheuttaa viivettä virtaa koskevan funktiokutsun ja sitä koskevan toimenpiteen välillä. Esimerkiksi standardi-tulostevirta on rivipuskuroitua: puskurissa oleva tieto välitetään kohteeseensa vasta kun riviä vaihdetaan (tai virta suljetaan). Yleensä viive ei käytännössä näy, mutta jos esimerkiksi printf:llä tulostettu teksti ei pääty rivinvaihtoon, ja heti sen jälkeen ohjelma kaatuu signaaliin (esim. segmentation fault), saattaa tulostetu teksti jäädä näyttämättä ruudulla.

I/O-virran avaaminen ja sulkeminen

C-ohjelmassa I/O-virtaa käsitellään FILE* - tietotyypin avulla. Se on abstrakti tietotyyppi, jota käsitellään C:n standardikirjastossa esiteltyjen funktioiden kautta (emme siis tiedä mitä tietotyyppi pitää sisällään, mutta se ei haittaa). Kuten tyypistä näkee, se on käytännössä osoitin johonkin muistipaikkaan, jossa virtaa koskevia tietorakenteita ylläpidetään. Uusi virta avataan fopen - funktiolla, jonka seurauksena järjestelmä varaa tarvittavat tietorakenteet, ja lopuksi palauttaa FILE* - osoittimen. fopen, kuten kaikki muutkin tiedostovirtoja koskevat funktiot, on määritelty stdio.h - otsakkeessa.

fopen - kutsu saa kaksi merkkijonoa parametrinaan: ensimmäisessä annetaan tiedostojärjestelmään viittaava tiedostonnimi, joka voi sisältää myös hakemistopolun. Toinen merkkijono kertoo missä tilassa tiedosto avataan. "r" tarkoittaa, että tiedostosta pelkästään luetaan; "w" tarkoittaa, että tiedostoon pelkästään kirjoitetaan, ja "r+" tarkoittaa, että tiedostosta voidaan sekä lukea että kirjoittaa. Kannattaa kurkata yllä olevan linkin takaa (tai man-sivuilta) funktion tarkka kutsurajapinta, mistä näkyvät parametrien ja paluuarvon tyypit tarkemmin.

On hyvä huomata, että ei ole lainkaan harvinaista, että virran avaaminen epäonnistuu, esimerkiksi kun annettua tiedostoa ei löydy. Siksi fopen - kutsun paluuarvo kannattaa aina tarkistaa. Jos se on NULL, avaaminen on epäonnistunut, eikä virtaa voida käyttää.

Kun virtaa ei enää tarvita, se suljetaan fclose - funktiolla, jonka parametriksi annetaan aiemmin saatu FILE - osoitin. Tällöin virran käyttämät resurssit vapautetaan, eikä virtaa luonnollisesti tämän jälkeen voi käyttää, ellei sitä avata uudestaan.

Lukeminen ja kirjoitus

fgetc - funktiolla voidaan lukea yksi merkki parametrina annetusta I/O-virrasta. Funktio palauttaa int-tyyppisen arvon joka sisältää merkin. Paluuarvo voi olla myös EOF (eli numerovakio -1), mikä tarkoittaa, että ollaan tultu virran loppuun. Merkkiarvo voi olla mikä tahansa arvo välillä 0 - 0xff, eli se voidaan sijoittaa unsigned char - tyyppiseen muuttujaan silloin kun luettu arvo ei ole EOF.

fputc - funktiolla voidaan vastaavasti kirjoittaa yksi merkki annettuun I/O-virtaan. Funktion ensimmäinen parametri on kirjoitettava merkki, ja toinen parametri kohteena oleva I/O-virta (kannattaa jälleen vilkaista tarkka muoto linkin takaa tai man-sivuilta). Funktio palauttaa kirjoitetun merkin, tai EOF, mikäli kirjoituksen yhteydessä sattui jokin virhe.

Seuraavassa esimerkissä havainnollistetaan näiden funktioiden ja I/O-virran peruskäyttöä. Esimerkissä on toteutettu funktio writeString, joka on hyvin samanlainen kuin pian esiteltävä fputs - kirjastofunktio. writeString kirjoittaa annetun merkkijonon I/O-virtaan, joka on annettu parametrina. Merkkijono kirjoitetaan tavu kerrallaan, kunnes tullaan merkkijonon päättävään 0-merkkiin. Esimerkin main-funktio avaa tiedoston "testfile" kirjoittamista varten, ja kirjoittaa merkkijonon "mystring" avattuun tiedostoon. Lopuksi I/O-virta suljetaan.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdlib.h>
#include <stdio.h>

int writeString(FILE *fp, const char *str) {
    while (*str) {
        // write characters until the end of string
        if (fputc(*str, fp) == EOF) {
            return -1;  // error in writing
        }
        str++;
    }
    return 0;
}

int main(void) {
    char *mystring = "One line written to file\n";

    // open 'testfile' for writing (remove previous content)
    FILE *f = fopen("testfile", "w");
    if (!f) {
        fprintf(stderr, "Opening file failed\n");
        exit(EXIT_FAILURE);  // ends program immediately (in stdlib)
    }
    writeString(f, mystring);
    fclose(f);
}

I/O-virta, tai esimerkiksi sen kohteena oleva tiedosto, voi sisältää mitä tahansa binääristä dataa, esimerkiksi kuvatiedoston tai ajettavan ohjelman. Jos tiedetään, että osoitettu tiedosto on ASCII-koodattu tekstitiedosto, sen sisältämät merkit voidaan kopioida C-merkkijonoksi ja käsitellä normaalina merkkijonona. Tyypillisesti tiedosto ei kuitenkaan sisällä 0-merkkiä, vaan kun tekstisisältö kopioidaan merkkijonoksi, kannattaa varmistua siitä, että loppuun tulee 0-merkki, jotta merkkijonofunktiot toimivat oikein.

Kun tiedosto sisältää binääridataa, esimerkiksi 0-merkillä ei ole mitään erityismerkitystä, vaan se kuuluu osaksi dataa. Tällaista tietoa ei voi käsitellä merkkijonofunktioiden kautta, vaan muistiblokkina, joka tulee käsitellä asianmukaisesti. Ohjelmalogiikan asia on huolehtia siitä, miten tällainen binääritiedosto tulee tarkemmin ottaen käsitellä.

Lukemista ja kirjoittamista varten on muutama kehittyneempikin (ja tyypillisesti tehokkaampi) funktio, jotta tiedostoja ei tarvitsisi lukea merkki kerrallaan. Osa funktioista on tarkoitettu nimenomaan tekstidatan käsittelyyn, eli ne esimerkiksi lukevat tiedostoa rivi kerrallaan (eli olettavan '\n'-merkillä (koodi 10) olevan erityismerkityksen), kun taas toiset soveltuvat geneeriseen binääridataan, jolloin tiedostoa käsitellään muistilohko kerrallaan.

  • size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream) lukee nmemb kappaletta size - kokoisia yksiköitä I/O-virrasta stream, ja kopio tiedon muistialueeseen, johon osoitetaan osoittimella ptr. Koska emme tiedä tässä vaiheessa tiedon tarkkaa tyyppiä, ptr-tyyppinä täytyy käyttää geneeristä void-osoitinta, joka ohjelman myöhemmässä vaiheessa muunnetaan varsinaiseksi tietotyypiksi. Ennen funktion kutsumista tarvittava muisti pitää varata. ptr voi siis olla saatu esimerkiksi malloc-funktion paluuarvona. Funktio palauttaa luettujen yksiköiden lukumäärän. Jos paluuarvo on pienempi kuin nmemb, tyypillisesti esimerkiksi 0, lukeminen on loppunut kesken, esimerkiksi siksi, että ollaan tultu tiedoston loppuun, tai jokin muu virhe on tapahtunut. Keskeytymisen syytä ei paluuarvon perusteella voi tietää, vaan se pitää tutkia feof- ja ferror - funktioiden avulla (kuvaillaan hieman myöhemmin). Tätä funktiota voi käyttää siis sekä binääri- että tekstidatan käsittelyyn.

  • size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream) kirjoittaa nmemb kappaletta size - kokoisia yksiköitä I/O-virtaan stream muistiosoitteesta ptr. Funktio palauttaa kirjoitettujen yksiköiden lukumäärän, ja mikäli paluuarvo on pienempi kuin nmemb, kirjoittamisessa on tapahtunut jokin virhe. Tätäkin funktiota voi käyttää sekä binääri- että tekstidatan käsittelyyn.

  • char *fgets(char *s, int size, FILE *stream) lukee enintään size-1 merkkiä s:n osoittamaan muistialueeseen virrasta stream. Funktio lukee tiedostoa rivi kerrallaan, eli se palaa heti kun vastaan tulee rivinvaihto. fgets lisää myös automaattisesti 0-merkin luetun merkkijonon loppuun, vaikka tyypillisesti tiedostossa sellaista ei olekaan. Funktio on siis tarkoitettu nimenomaan tekstitiedostojen käsittelyyn. Se palauttaa osoittimen luettuun merkkijonoon (eli osoittimen s), tai NULL, mikäli ollaan tultu tiedoston loppuun, tai lukemisessa on tapahtunut virhe. Syy selviää jälleen feof ja ferror - funktioita käyttämällä.

  • int fputs(const char *s, FILE *stream) kirjoittaa merkkijonon s I/O-virtaan stream. Funktio olettaa että osoitteessa s on merkkijono, ja lopettaa kirjoittamisen kun tullaan 0-merkkiin, jota ei kirjoiteta tiedostoon. Toisinsanoen tämäkin funktio on tarkoitettu tekstidatan käsittelyyn. Funktio palauttaa jonkin ei-negatiivisen luvun mikäli kirjoitus onnistui, tai EOF, mikäli kirjoituksessa tapahtui virhe.

Yllä olevista funktioista saa lisätietoa esimerkiksi man-sivujen kautta. Hetken päästä nähdään esimerkkejä edellä mainittujen funktioiden käytöstä.

Muita funktiota I/O-virtojen käsittelyyn

Seuraavassa vielä lyhyesti muutamia I/O-virtojen käsittelyyn tarkoitettuja funktioita, jotka saattavat olla hyödyllisiä.

  • long ftell(FILE *stream) kertoo nykyisen sijainnin virrassa stream. Esimerkiksi tiedostoa lukiessa tai siihen kirjoittaessa voidaan ajatella että jokainen lukuoperaatio siirtää "osoitinta" tiedostossa eteenpäin, ja tämä funktio kertoo kuinka monta tavua tästä osoittimesta on tiedoston alkuun.

  • int fseek(FILE *stream, long offset, int whence) siirtää yllä mainitun osoittimen tiettyyn kohtaan tiedostoa virrassa stream. offset kertoo mihin kohtaan siirrytään (tavuissa laskettuna). ja whence sen, mistä offset:in laskeminen aloitetaan: tiedoston alusta (whence arvo SEEK_SET), nykyisestä kohdasta (SEEK_CUR), tai tiedoston lopusta (SEEK_END). offset voi siis olla myös negatiivinen silloin kun halutaan siirtyä taaksepäin.

  • int fprintf(FILE *stream, const char *format, ...) toimii kuten printf-funktio, mutta kohdistuu I/O-virtaan stream. Funktio toimii muuten aivan kuten printf, siinä on vain alussa yksi lisäparametri. Itse asiassa printf on tavallaan alias tälle funktiolle, joka kohdistuu aina stdout-oletusvirtaan.

  • int fscanf(FILE *stream, const char *format, ...) on vastaavasti kuten scanf, mutta se kohdistuu annettuun I/O-virtaan standardivirran stdin sijaan.

  • int feof(FILE *stream) palauttaa arvon 0, mikäli tiedoston käsittelyssä ei olla vielä päästy sen loppuun asti, tai jonkun muun arvon, mikäli tiedosto on käsitelty kokonaisuudessaan. Funktiota voi siis sellaisenaan käyttää esimerkiksi ehtolausekkeissa. Tämän funktion kohdalla on hyvä huomioida, että "end-of-file" - tila menee päälle vasta sitten, kun lukuoperaatiota ollaan kutsuttu lukuosoittimen ollessa tiedoston lopussa, eli lukeminen on ainakin kertaalleen epäonnistunut.

  • int ferror(FILE *stream) palauttaa arvon 0, mikäli I/O-virran käsittelyssä ei ole tapahtunut mitään virhettä, tai jonkin muun arvon, mikäli virhe on tapahtunut.

  • int fflush(FILE *stream) pakottaa järjestelmän puskuroiman tiedon toimittamisen eteenpäin, esimerkiksi ulosmenevän datan kohteena olevaan tiedostoon. Normaalisti se tapahtuu kuitenkin viimeistään silloin kun virta suljetaan. Paluuarvo on 0, mikäli operaatio onnistui, tai -1 mikäli tapahtui virhe.

Tässä välissä on hyvä mainita, että alussa mainitut kolme standardivirtaa löytyvät globaalien muuttujien stdin, stdout ja stderr avulla. Nämä ovat siis FILE* - tyyppisiä muuttujia, jotka on jo valmiiksi avattu ohjelman alussa, ja ne näkyvät kaikille funktioille.

Esimerkiksi kun kutsutaan

fprintf(stdout, "%d\n", an_int)

tapahtuu käytännössä sama asia kuin vastaavassa printf - kutsussa. Myös muita edellä mainittuja funktioita voi käyttää näiden oletusvirtojen kanssa.

Alla oleva esimerkki lukee tiedostoa "test.c" rivi kerrallaan, ja tulostaa kunkin rivin standardi-tulostevirtaan. Kuten asianmukaista on, eri operaatioiden onnistuminen testataan, ja mikäli niissä tapahtuu virhe, tulostetaan virheilmoitus standardi-virhevirtaan, joka siis oletusarvoisesti näkyy myös käyttäjälle. EXIT_FAILURE on vakiomuotoinen paluuarvo, joka on määritelty stdlib.h:ssa. Se kertoo järjestelmälle, että ohjelman suoritus päättyi virheeseen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    FILE *f;
    char buffer[100];

    f = fopen("test.c", "r"); // open file for reading
    if (!f) {
        fprintf(stderr, "Opening file failed\n");
        return EXIT_FAILURE;
    }
    while (fgets(buffer, sizeof(buffer), f) != NULL) {
        if (fputs(buffer, stdout) == EOF) {
            fprintf(stderr, "Error writing to stdout\n");
            fclose(f);
            return EXIT_FAILURE;
        }
    }
    fclose(f);
}

Kuten aiemmin mainittua, binääritiedostojen yhteydessä tulee käyttää fread ja fwrite - funktioita. Seuraava ohjelma kirjoittaa 10 alkion kokonaislukutaulukon tiedostoon "intarray" sellaisenaan, sekä tämän jälkeen lukee tiedostosta taulukon takaisin erikseen varattuun muistiin. Kannattaa huomioida sizeof - operaattorin käyttö alkion kokoa määritellessä, sekä feof - ja ferror - funktioiden käyttö.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <stdlib.h>
#include <stdio.h>

int main(void)
{
    int numbers[10] = { 1, 0, -2, 3, 10, 4, 3, 2, 3, 9 };
    FILE *fp = fopen("intarray", "w");
    if (!fp) {
        fprintf(stderr, "Could not open file\n");
        return EXIT_FAILURE;
    }
    size_t n = fwrite(numbers, sizeof(int), 10, fp);
    if (ferror(fp)) {
        fprintf(stderr, "Error occurred\n");
        return EXIT_FAILURE;
    }
    fprintf(stdout, "%lu items written\n", n);  // same as printf
    fclose(fp);

    // re-open file for reading, and read the integers
    fp = fopen("intarray", "r");
    int *num2 = malloc(10 * sizeof(int));
    n = fread(num2, sizeof(int), 10, fp);

    // feof indicator should not be set yet, because we did not read
    // past the end of file
    if (feof(fp)) {
        fprintf(stderr, "prematurely reached end of file\n");
        return EXIT_FAILURE;
    } else if (ferror(fp)) {
        fprintf(stderr, "error occurred\n");
        return EXIT_FAILURE;
    }
    fprintf(stdout, "%lu items read\n", n);

    // should not read anything, because we should be at the end of file
    n = fread(num2, sizeof(int), 10, fp);
    if (feof(fp)) {
        fprintf(stdout, "%lu items read, EOF indicator is set\n", n);
    }

    fclose(fp);
    free(num2);
    return EXIT_SUCCESS;
}

Ohjelman tuottama tiedosto on 40 tavua pitkä, eli 10 kertaa 4-tavuisen kokonaisluvun tarvitsema määrä. Tällaista tiedostoa ei voi avata tekstieditorilla, mutta esimerkiksi hexdump - komentorivityökalua voi käyttää binääritiedoston sisällön tarkasteluun tavu kerrallaan seuraavaan tyyliin:

$ ./a.out
10 items written
10 items read
0 items read, EOF indicator is set

$ hexdump -C intarray 
00000000  01 00 00 00 00 00 00 00  fe ff ff ff 03 00 00 00  |................|
00000010  0a 00 00 00 04 00 00 00  03 00 00 00 02 00 00 00  |................|
00000020  03 00 00 00 09 00 00 00                           |........|
00000028

Heksadesimaalidumpista voi huomata kuinka kukin lukuarvo vaatii tiedostosta 4 tavua, ja kuinka Intel-pohjaisissa koneissa vähiten merkitsevä tavu tallentuu tiedostoon ensiksi muiden tavujen ollessa positiivisten lukujen tapauksessa 0, koska lukuarvot ovat pieniä.

Task 01_filedump: Tiedostodumppi (2 pts)

Tavoite: Harjoitellaan tiedoston lukemista

Toteuta funktiot 'textdump' ja 'hexdump' jotka lukevat annettua tiedostoa (jonka nimi in filename - parametrissa) ja tulostavat tiedoston sisällön ruudulle. Molempien funktioiden tulee palauttaa paluuarvonaan luettujen tavujen määrä, tai -1 mikäli tiedoston avaamisessa sattui virhe. Sinun tulee tulostaa tiedostot seuraavissa formaateissa:

Kohdassa (a) tulostetaan tiedoston sisältö merkkeinä. Mikäli tiedostossa tulee vastaan merkki jota ei voi tulostaa (eli funktio isprint palauttaa epätoden arvon), ruudulle tulee tulostaa merkki '?', muussa tapauksessa tulostetaan tiedostosta luettu merkki.

Kohdassa (b) tulostetaan tiedoston sisältö heksadumppina: kukin tiedostossa oleva tavu tulostetaan kahden merkin pituisessa heksadesimaalimuodossa siten että alle 0x10:n oleviin lukuihin lisätää 0 eteen. Kunkin luvun perässä on yksi välimerkki, ja kullakin rivillä tulee olla enintään 16 merkkiä, jonka jälkeen siirrytään seuraavalle riville. Myös rivin viimeistä merkkiä seuraa välimerkki. Tässä esimerkki tulosteesta:

0e 54 65 65 6d 75 20 54 65 65 6b 6b 61 72 69 30 
30 30 30 30 41 00 00 14 45 4c 45 43 2d 41 31 31 
30 30 00 00 00 00 00 00 00 

Task 02_stats: Tilastoja (2 pts)

Tavoite: Lisää harjoittelua tiedostonkäsittelystä

Toteuta seuraavat funktiot tekstitiedostojen analysoimiseen:

(a) Rivilaskuri

Toteuta funktio int line_count(const char *filename) joka laskee rivien lukumäärän annetussa tiedostossa, ja palauttaa lukumäärän paluuarvonaan. Jos tiedoston avaamisessa tai lukemisessa tapahtuu virhe, funktion tulee palauttaa -1. Jos tiedosto on tyhjä, siinä ei ole yhtään riviä. Jos tiedoston viimeisellä rivillä on sisältöä, mutta rivi ei pääty rivinvaihtomerkkiin, se tulee laske omaksi rivikseen.

(b) Sanalaskuri

Toteuta funktio int word_count(const char *filename) joka laskee tiedostossa esiintyvien sanojen määrän. Määrittelemme tässä harjoituksessa sanan sellaiseksi, jossa on vähintään yksi kirjainmerkki (isalpha). Kaksi sanaa erotetaan toisistaan jollain välimerkillä ("whitespace", isspace). Mikäli tiedoston avaamisessa tai lukemisessa tapahtuu virhe, tulee palauttaa -1. (Kannattaa huomioida, että shell-komento 'wc -w' määrittelee sanan eritavalla, joten sitä ei voi käyttää tämän funktion testaamiseen).

Tämän sivun lopussa on lisää tietoa merkkien luokitteluun (ja muuhunkin) liittyviä kirjastofunktioista.

Task 03_base64: Base64 (2 pts)

Tavoite: Harjoitusta tiedostoon lukuun ja kirjoitukseen, sekä bittioperaatioita.

HUOM: Tämä saattaa olla kierroksen vaikein tehtävä. Jos olet epävarma siitä miten tehtävää kannattaa lähteä ratkaisemaan, kannattaa ehkä katsoa ensin muita tehtäviä, ja palata sitten tähän.

Base64-koodausta käytetään kun binäärisisältöä pitää muuttaa tekstimuotoon, esimerkiksi tiedoston sisällyttämiseksi vaikkapa sähköpostiviestiin. Tässä harjoituksessa sinun tulee toteuttaa funktiot to_base64 (a), joka lukee tiedoston yhdestä tiedostosta ja kirjoittaa sen toiseen tiedostoon Base64-koodatussa muodossa; sekä from_base64 (b), joka tekee käänteisen operaation, eli lukee Base64-koodatun tiedoston ja kirjoittaa sen alkuperäisessä muodossaan tiedostoon. Toisin sanoen, kun funktiot toimivat oikein, ja niitä kutsutaan peräkkäin, lopputulokseksi pitäisi tulla täsmälleen sama tiedosto kuin mistä lähdettiin liikkeelle.

Base64-koodauksen idea on, että lähteenä oleva tiedosto tai merkkijono muutetaan 6-bittisiin yksiköihin, jotka esitetään tulostettavilla merkeillä (A-Z, a-z, 0-9, +, /). Tämä voidaan tehdä esimerkiksi niin, että otetaan lähteestä kolme 8:n bitin numeroa (eli yhteensä 24 bittiä) ja muutetaan ne neljäksi 6-bitin numeroksi (edelleen 24 bittiä). Bittikombinaatio pysyy siis täsmälleen samana, mutta se jaetaan eri "tavuihin" toisella tavalla. Kukin 6-bittinen luku muutetaan kirjainmerkiksi tämän taulukon avulla. Samalla Wikipedia-sivulla on myös hyödyllisiä diagrammeja, jotka havainnollistavat koodauksen ideaa.

On mahdollista, että lähdetiedoston pituus ei ole kolmella jaollinen. Tällöin puuttuvat bitit oletetaan 0:ksi, ja kokonaan käyttämättömien 6-bittisten merkkien paikalle merkataan täytemerkiksi '='. Wikipedia antaa tästäkin esimerkin.

Wikipedia-sivulla on myös muuta taustaa ja lisätietoa Base64-koodauksesta. Algoritmista on olemassa erilaisia variaatioita, mutta noudatamme alkuperäistä, RFC 1421:ssä määriteltyä formaattia, eli seuraavasti:

  • Kunkin koodatun rivin tulee olla 64 merkkiä pitkä, paitsi viimeisen rivin, joka voi olla lyhyempi. Tässä tehtävässä käytämme yksinkertaista Unix-rivinvaihtoa ('\n'), emmekä "CRLF"-yhdistelmää monien muiden toteutusten tapaan. Viimeisellä rivillä ei ole rivinvaihtomerkkiä.

  • Kaikkien rivien pituuden tulee olla 4:llä jaollinen. Tarpeen mukaan viimeisen rivin loppuun täytyy lisätä täytemerkkejä ('='), jotta tämä ehto täyttyy.

Molemmat funktiot palauttavat paluuarvonaan tiedostosta luettujen merkkien määrän, tai -1, mikäli tiedoston käsittelyssä tapahtui virhe.

Lisävinkkejä to_base64 - toteutukseen:

  • On suositeltavaa, että aloitat testaamisen lyhyillä lähdetiedostoilla ja yksinkertaisilla merkkijonoilla, kuten esimerkiksi "Man", joka annetaan Wikipedian esimerkissä. Sen jälkeen kasvatat testitiedoston pituutta asteittain ja tarkistat että saat odotetun lopputuloksen. Vasta kun olet itse vakuuttunut algoritmin toimivuudesta, se kannattaa lähettää TMC:lle.

  • Aloita vaikkapa tarvittavien binäärioperaatioiden toteuttamisesta. Niiden hahmotteluun kynä ja paperi saattavat olla hyödyllisiä. Tarvitset ainakin bittien siirto-operaattoria. Binäärilaskutoimitus ottaa siis sisään kolme 8-bittistä (char) arvoa, ja sen tulisi tuottaa neljä 6-bittistä arvoa (esimerkiksi char, jossa kaksi ylintä bittiä ovat aina 0).

  • Kun olet saanut neljä 6-bittistä lukuarvoa, ne tulee muuntaa tulostettavaksi ASCII-merkiksi. Tehtäväpohjan mukana tulee merkkijono (eli merkkitaulukko) 'encoding', jossa on kaikki 64 Base64-merkkiä ovat oikeassa järjestyksessä. Tästä taulukosta on varmastikin hyötyä.

  • Vasta kun bittien pyörittely, ja muunnos Base64-merkeiksi näyttää toimivan, lisää lopuksi tarvittava täytemerkkien käsittely ja tulosteen jako 64:n merkin riveihin.

Kuten aina, kannattaa käyttää src/main.c - tiedostoa testaamiseen, ennenkuin lähetät ratkaisusi TMC:hen.

Esikääntäjä

Aivan kurssin alussa mainittiin, että ajettavan ohjelman tuottaminen C-koodista tapahtuu kolmessa vaiheessa: esikäännös, käännös ja linkkaus. Esikääntäjä käsittelee tekstimuotoista lähdekoodia ja muokkaa sitä "raakamuotoon", josta kääntäjä alkaa sitten tuottamaan tietokoneen ymmärtämää konekielistä ohjelmaa. Esikäännöksessä esimerkiksi #include directiivien osoittamat otsaketiedostot liitetään osaksi lähdekoodia. Lisäksi poistetaan kommentit (koska kääntäjä ei niitä tarvitse), ja esimerkiksi muutetaan merkkijonoissa esiintyvät erikoismerkit, kuten \n niitä vastaaviksi ASCII-arvoiksi (kuten tässä tapauksessa 10).

Esikäännöksen jälkeen ohjelmaa voi edelleen lukea tekstimuotoisena, mutta siitä on tullut huomattavasti pidempi include-otsakkeiden sisällyttämisen vuoksi, sekä tyypillisesti vaikealukuisempi. Voit tarkastella esikääntäjän tuottamaa tulosta antamalla gcc-kääntäjälle komentorivillä -E käännösoption. Tällöin se jättää myöhemmät käännösvaiheet suorittamatta, ja tuottaa vain esikäännetyn tiedoston.

Perusteita

Esikääntäjää voidaan ohjata esikäännösdirektiiveillä, jotka alkavat aina risuaita-merkillä (#). Tästä nähdään että esimerkiksi #include-rivit käsitellään jo esikäännösvaiheessa. Tällaiset direktiivit siis käsitellään esikäännösvaiheessa, ja esikäännöksen jälkeen niitä ei enää esiinny ohjelmassa.

Toisin kuin normaalissa C-koodissa, jonka ohjelmoija voi muokata hyvin vapaasti, ja jossa lauseet erotellaan puolipisteellä, esikäännösdirektiiveissä muotoilu on tärkeä. Esikäännösohje alkaa aina rivin alussa olevalla risuaidalla, ja se päättyy rivinvaihtoon. Myöskään puolipistettä ei lisätä esikäännösdirektiivin loppuun. Mikäli esikäännösdirektiivistä tulee niin pitkä, ettei sitä kätevästi voi esittää yhdellä rivillä, taaksepäin osoittavalla kenoviivalla (backslash, '\') voidaan ohjetta jatkaa seuraavalle riville.

Vakiot ja makrot

Yksi yleisimmistä esikäännösohjeista #include:n jälkeen on #define jolla voidaan määritellä vakiomerkkijonoja, jotka esikääntäjä korvaa jollain toisella tekstillä (tai numerolla). Tarkka muoto olisi jotain seuraavaa:

#define NIMI jotain tekstia

Jolloin jokainen ohjelmassa esiintyvä NIMI käydään korvaamassa perässä annetulla tekstillä. Kannattaa huomioida, että esikääntäjä operoi tekstimuotoisen sisällön kanssa "jotain tekstiä" voi olla mikä tahansa merkkijono, numerovakio tai lauseke. Mikäli tuloksena syntyy C-kääntäjän mielestä merkityksetön lauseke, esikääntäjä ei sitä huomaa. Käännösvaiheessa tällöin tulee kuitenkin käännösvirhe. #define:n avulla voi siis halutessaan tuottaa hyvin vaikeaselkoista, mutta silti toimivaa koodia. Tästä esimerkkinä vaikkapa shakkipeli, joka voitti "Internatinal Obfuscated C Code" - kilpailun joitain vuosia sitten.

Alla esimerkki yksinkertaisesta #define:ä käyttävästä ohjelmasta:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <string.h>

#define MAXSTRING 80

int main(void) {
    char str[MAXSTRING];  // varataan 80 merkille tilaa

    // kopioidaan enintään 79 merkkiä
    strncpy(str, "string", MAXSTRING - 1);
}

On melko yleinen käytäntö, että esikääntäjälle esitettyjen vakioiden ja makrojen nimet on kirjoitettu isoin kirjaimin, vaikka niin ei tokikaan ole pakko tehdä.

Vaihtoehtoisesti MAXSTRING olisi voitu määritellä globaalina vakiomuuttujana: const int MAXSTRING = 80. Tällöin sen käsittely tapahtuisi kääntäjän toimesta, ja esimerkiksi tyyppitarkistukset tehtäisiin saman tien.

#define - määrittelyn voi poistaa myöhemmin ohjelmassa #undef - määrittelyllä, jonka jälkeen kyseistä makroa tai vakiota ei enää voi käyttää.

#define - makrolle voi myös antaa parametreja. Tällöin parametrit sijoittuvat asianomaisille paikoille esikääntäjän tehdessä makroa vastaavaa korvausoperaatiota. Tämä eroaa C-funktioista siten, että jälleen toimitaan vain tekstiä korvaamalla, eikä parametreillä esimerkiksi ole esikääntäjässä määriteltyä tyyppiä.

Esimerkiksi meillä voisi olla seuraavanlainen makromäärittely:

#define GROW_MEM(Var, Size) Var = realloc(Var, Size)

sekä sitä käyttävä ohjelma:

1
2
3
4
5
int main(void)
{
    char *p = malloc(100);
    GROW_MEM(p, 200);
}

GROW_MEM korvautuu siis annetulla realloc-kutusulla, jonka paluuarvo sijoitetaan Var- makroparametrin ilmaisemaan muuttujaan. Esikäännöksen jälkeen rivi 4 näyttäisi siis tältä:

p = realloc(p, 200);

Koska makron määrittelemässä merkkijonossa ei ollut puolipistettä (kuten ei useinkaan tapana), se pitää erikseen lisätä rivillä 4, jotta merkitään lauseen päättyminen. Jälleen esikääntäjä ei huomaisi mitään, jos esimerkiksi Size parametriksi annettaisiin joku merkkijono, tai Var parametriksi muuttuja jota ei ole esitelty, mutta käännösvaiheessa tulisi virheitä.

Muita ominaisuuksia

Esikääntäjä tukee ehtolauseketta #if jolle voi antaa loogisia operaattoreita käyttäen esikääntäjän tukemia vakioita. Esikääntäjä ei kuitenkaan tiedä C-ohjelman esittelemistä muuttujista mitään. Mikäli #if - ehto toteutuu, riviä seuraavat ohjelmarivit sisältyvät käännökseen, kunnes vastaan tulee #endif, joka päättää kyseisen ohjelmalohkon. Lisäksi käytössä ovat #else ja #elif - direktiivit. Jälkimmäinen tarkoittaa "else if", ja toimii kuten vastaava rakenne C:ssä ja muissa ohjelmointikielissä.

Alla esimerkki ohjelmasta josta voidaan ajatella olevan kaksi versiota. Versiosta riippuen ohjelmaan sisällytetään eri otsaketiedostot, koska eri versioissa voi olla käytössä esimerkiksi hieman erilaiset tietorakenteet tai kutsurajapinnat.

1
2
3
4
5
6
7
#if (VERSION == 1)
#include "hdr_ver1.h"
#elif (VERSION == 2)
#include "hdr_ver2.h"
#else
#error "Unknown version"
#endif

#error - direktiivi aiheuttaa automaattisen käännösvirheen ja käännöksen keskeytymisen virheilmoituksella joka annetaan tässä yhteydessä. VERSION on siis esikääntälle määritelty vakio. Näitä voidaan antaa esimerkiksi gcc:n komentoriviparametreina.

#define:llä voidaan määritellä vakioita myös siten, että niille ei ole määritelty arvoa, vaikka vakio itsessään on olemassa. Tällä tavoin voidaan esittää ja testata binääristä tietoa jonkun asian tilasta.

Yleinen tällainen käyttö on ns. "include-vahti", jolla estetään tietyn otsakkeen sisältyminen ohjelmaan toistuvasti. Tämä on mahdollista, koska usein otsaketiedostot itsessään sisällyttävät toisia otsaketiedostoja. Tällöin sama otsaketiedosto voi tulla ohjelmaan mukaan useita reittejä, jolloin samoja nimiä yritetään määritellä useaan kertaan, mikä aiheuttaa käännösvirheen.

Alla esimerkki include-vahdista, joka sijoitetaan otsaketiedoston alkuun. Mikäli SOME_HEADER_H - vakiota ei ole määritelty, käsitellään otsake normaalisti. Alussa kuitenkin heti määritellään kyseinen vakio, joten mikäli kyseistä otsaketta haetaan uudestaan, se käytännössä sivuutetaan. #ifdef - ehto siis testaa onko kyseistä nimeä määritelty, kun taas #ifndef on tosi mikäli testattavaa nimeä ei ole määritelty.

1
2
3
4
5
6
#ifndef SOME_HEADER_H  // at the beginning of file
#define SOME_HEADER_H

// some header content

#endif // at the end of the file

Esikääntäjässä on myös eräitä automaattisesti määrittyviä vakionimiä, joista voi olla hyötyä esimerkiksi debugauksen tai virhetilastoinnin yhteydessä:

  • __DATE__ korvautuu sen hetkisellä päivämäärällä. Koska tämä arvioidaan esikäännösvaiheessa, se ei toimi kuten muuttuja, vaan käytännössä kertoo milloin ohjelma on käännetty.

  • __TIME__ korvautuu aikaleimalla. Jälleen kyseessä siis hetkestä jolloin kyseinen ohjelma on (esi)käännetty.

  • __FILE__ korvautuu C-lähdetiedoston nimellä. Tätä voi hyödyntää esimerkiksi omien debug-makrojen yhteydessä (jollainen myös assert on), jolloin nähdään heti mistä tiedostosta ilmoitus on lähtöisin. Kannattaa muistaa että yleensä ohjelmat koostuvat lukuisista lähdetiedostoista.

  • __LINE__ korvautuu rivinumerolla jolla kyseinen vakio esiintyy. Jälleen tämän avulla saadaan tarkempaa tietoa debugausta varten. Tyypillisesti tätä (kuten FILE - makroakin) käytetään jonkun toisen makron sisällä, jotta esikääntäjä osaa sijoittaa ne osoittamaan oikeaa riviä.

Alla yksinkertainen makro, joka tulostaa lähdetiedoston nimen, rivin, sekä lisäksi jonkin selventävän viestin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <stdio.h>

#ifdef DEBUG
#define MYDEBUG(Msg) fprintf(stderr, "File: %s, Line: %d: %s", \
                             __FILE__, __LINE__, Msg)
#else
#define MYDEBUG(Msg)
#endif

int main(void) {
    MYDEBUG("Starting\n");
    for (int a = 0; a < 10; ) { a++; }
    MYDEBUG("At the end\n");
}

Kun ohjelman nyt kääntää esimerkiksi näin: gcc testi.c, ja sen ajaa, ruutuun ei ilmesty mitään, koska DEBUG-makroa ei ole määritelty. Sen sijaan, jos kääntäjälle kertoo, että kyseinen makronimi on määritelty: gcc -DDEBUG testi.c, ruutuun tulostuu seuraavaa:

File: testi.c, Line: 11: Starting
File: testi.c, Line: 13: At the end

Tällä niksillä voidaan siis kääntää ohjelma debugaus-tilassa -D - vivun kanssa, jolloin ruudulle tulee selventäviä ilmoituksia, mutta kun ohjelmasta käännetään lopullinen versio, makron kautta tulevat ilmoitukset voidaan poistaa käännetystä koodista. Tässäkin taupauksessa MYDEBUG - makro pitää kuitenkin määritellä tyhjänä, koska muuten rivit 11 ja 13 aiheuttaisivat myöhemmässä vaiheessa virheen.

Task 04_arraytool: Taulukkomakroja (3 pts)

Tavoite: Harjoittele makrojen käyttöä.

Tässä tehtävässä ei ole varsinaista .c - pääteistä tiedostoa työstettäväksi perinteisen main.c:n lisäksi, vaan tehtävä tehdään src/arraytool.h - otsakkeeseen. Sinne sinun tulee toteuttaa seuraavat makrot:

(a): CHECK(cond, msg) joka tarkistaa loogisen ehdon cond, ja mikäli ehto ei täyty, tulostaa merkkijonon msg standarditulostevirtaan. Tämä siis on hieman kuten C-kirjastossa määritelty assert-makro, mutta ei keskeytä ohjelman suoritusta. Esimerkki makron käytöstä: CHECK(5 > 10, "5 > 10 failed\n");

(b): MAKE_ARRAY(type, n) joka luo dynaamisesti varatun taulukon, jossa on n alkiota tyypiä type. Makro palauttaa osoittimen varattuun muistiin. Esimerkki makron käytöstä: void *ptr = MAKE_ARRAY(int, 10);

(c): ARRAY_IDX(type, array, i) joka käsittelee taulukkoa array kohdasta i. Taulukon alkioiden tyyppi on annettu parametrissa type. Esimerkki makron käytöstä: ARRAY_IDX(int, ptr, i) = i * 2;

Kun makrot on oikein toteutettu, oletuksena mukana tuleva src/main.c varaa kokonaislukutaulukon 10:lle luvulle, alustaa sen ja tulostaa taulukon sisällön. Funktio testaa myös CHECK-makroa. Voit toki muuttaa main-funktiota haluamasi mukaan.

Vaihtuvan mittaiset parametrilistat

C-funktioiden parametrilistat ovat pääosin kiinteästi määriteltyjä ja tiukasti tyypitettyjä. Joissain tapauksissa parametrien määrää ja tyyppiä ei pystytä tarkasti määrittelemään. Yleisin esimerkki tästä on printf, ja muut funktiot jotka käsittelevät muotoilumääreitä. C-kielessä on mekanismi tällaisten funktioiden toteuttamiseksi.

Funktiolle voidaan määritellä vaihtuvan mittainen parametrilista seuraavalla notaatiolla:

int printf(const char *fmt, ... )

Yllä on printf - funktion määrittely. Kuten tässä vaiheessa jo tiedetään, ensimmäinen parametri on aina merkkijono. Sitä seuraa vaihtuva määrä muita parametreja, joiden tyyppiä ei ole määritelty funktion määrittelyn yhteydessä. printf-toteutus päättelee parametrien määrän merkkijonoon upotetuista muotoilumääreistä.

Parametrilista käsitellään va_list - tietotyypin avulla, sekä käyttämällä makroja va_start, va_arg ja va_end. Nämä on määritelty stdarg.h - otsakketiedostossa.

Seuraavassa esimerkki funktiosta, joka laskee keskiarvon vaihtuvan mittaisesta joukosta liukulukuja.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdarg.h>

double keskiarvo(int n, ... )
{
    va_list args;
    double sum = 0;
    va_start(args, n);
    for (int i = 0; i < n; i++) {
        sum += va_arg(args, double);
    }
    va_end(args);
    return sum / n;
}

int main(void)
{
    printf("keskiarvo: %f\n", keskiarvo(4, 1.0, 10.0, 0.1, 0.2));
    printf("toinen: %f\n", keskiarvo(2, 0.1, 0.3));
}

va_start aloittaa parametrilistan käsittelyn, ja kertoo sen parametrin, jonka jälkeen vaihtuvan mittainen lista alkaa. Tällaisessa funktiossa pitää siis olla vähintään yksi kiinteästi määritelty perinteinen parametri.

Parametrit poimitaan listasta yksi kerrallaan va_arg - makrolla, jolle annetaan parametriksi parametrilistaa osoittava muuttuja, sekä seuraavan parametrin tyyppi. Tämä pitää siis tietää jollain tapaa. Tässä funktiossa se on helppoa, koska voimme olettaa että kaikki käsiteltävät luvut ovat double - tyyppisiä. printf - toteutus päättelee seuraavan tyypin ensimmäisessä merkkijonossa olevien muotoilumääreiden perusteella. Kun kaikki parametrit on käsitelty, lopuksi pitää vielä lopettaa parametrilistan käsittely kutsumalla va_end - makroa.

main-funktiosta nähdään, että nyt keskiarvo - funktiota voi kutsua vaihtuvalla määrällä parametreja, kunhan laskettavien lukujen määrä on oikein listattu.

Task 05_myprint: Kokonaislukutulostin (1 pts)

Tavoite: Harjoitellaan vaihtuvan mittaisten parametrilistojen käyttöä.

Toteuta funktio myprint joka tulostaa vaihtuvan määrän kokonaislukuja standarditulostevirtaan noudatten parametrinaan saamaansa muotoilumäärittelyä. Funktio voi siis saada vaihtuvan määrän parametreja: ensimmäinen parametri on aina (muuttumaton) merkkijono, joka määrittelee tulosteen ulkoasun, kuten printf:kin tekee. Sitä seuraa kokonaislukuja (int), joiden määrä riippuu muotoilumerkkijonosta, ja siinä olevien '&' - merkkien määrästä. Funktiossamme '&' toimii muotoilumääreenä, ja se tulee korvata kyseisellä kohdalla parametrilistassa olevalla kokonaisluvulla. Toteutamme siis yksinkertaistetun version printf-funktiosta. Koska tulostamme vain kokonaislukuja, mitään lisämääreitä korvattavaan merkkiin ei tarvita.

Esimerkiksi tämä on yksi hyväksyttävä tapa kutsua funktiota: myprint("Number one: &, number two: &\n", 120, 1345);

Funktion tulee palauttaa kokonaisluku, joka kertoo kuinka monta muotoilumäärettä funktiossa oli.

Tässä tehtävässä C-lähdetiedostot myprint.c ja myprint.h ovat kokonaan tyhjiä, ja sinun tulee täyttää ne kokonaan itse. Kannattaa siis lukea tehtävänanto ja mukana tuleva main-funktio tarkkaan, jotta funktion nimi ja muut määrittelyt menevät oikein.

Jos toteutuksesi toimii oikein, oletuksena mukana tuleva main-funktio tulostaa seuraavaa:

Hello!
Number: 5
Number one: 120, number two: 1345
Three numbers: 12 444 5555
I just printed 3 integers

Vinkki: Muistutuksena, että strchr palauttaa osoittimen seuraavaan kohtaan merkkijonossa, jossa annettu merkki esiintyy. fputc:n avulla voit tulostaa yhden merkin kerrallaan, myös standarditulostevirtaan. Näistä funktioista saattaa olla hyötyä.

Funktio-osoittimet

Olemme käyttäneet tähän mennessä funktioita jo useaan kertaan, mutta tässä kohtaa on hyvä tarkastella mikä funktio oikeastaan on. Aiemmasta muistetaan, että virtuaalimuisti on jaettu eri lohkoihin, joista yksi on kirjoitussuojattu koodisegmentti. Kun ohjelma käynnistetään, sen konekieliset käskyt kopioidaan kääntäjän tuottamasta binääritiedostosta (esim. "a.out") tähän segmenttiin. Koodi on C-ohjelmassa jaettu funktioihin, mutta koneen näkökulmasta se on vain jono käskyjä.

Funktion nimen voidaan ajatella olevan osoitin koodisegmenttiin siihen kohtaan josta kyseisen funktion toteutus alkaa. Se on ikään kuin globaali vakiomuuttuja, johon liittyy myös parametreja ja funktiota suorittaessa pinon hallintaan liittyvä toiminta paikallisten muuttujien käsittelemiseksi.

Edellä kuvatusta seuraa, että funktioihin voi myös viitata oikean tyyppisen osoitinmuuttujan avulla. Tällaista muuttujaa kutsutaan funktio-osoittimeksi. Funktio-osoittimien avulla voidaan toteuttaa erilaista dynaamisesti vaihtuvaa toimintaa ohjelman eri tiloissa: voidaan kuvitella esimerkiksi roolipeli, jossa hahmon hyökkäyskäyttäytyminen riippuu hahmon lajista tai käytettävissä olevasta aseesta. Haluamme kuitenkin kutsua hyökkäysfunktiota yhdellä ja samalla nimellä, jotta ohjelma olisi helpompi ylläpitää kun pelin seuraavissa versioissa lisätään uusia hahmotyyppejä ja aseita.

Seuraava esimerkki pyrkii havainnollistamaan miten funktio-osoittimia määritellään ja käytetään:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdlib.h>

int funcAdd(int a)
{
    return a + 1;
}

int main(void)
{
    // The following declares four variables for function pointers
    int (*add_one)(int) = funcAdd;
    void* (*varaa)(size_t);
    void (*vapauta)(void *);
    void* (*varaa_uudestaan)(void *, size_t);
    // above pointers are now uninitialized

    int b = add_one(1);

    // set the pointers to the addresses of functions in C library
    varaa = malloc;
    vapauta = free;
    varaa_uudestaan = realloc;
}

Rivillä 11 määritellään funktio-osoitin nimeltä "add_one", joka viittaa funktioon jolla on yksi int-tyyppinen parametri, sekä int-tyyppinen paluuarvo. Samalla osoitin alustetaan osoittamaan funktioon func_add. On tärkeää, että funktiolla johon osoitetaan on täsmälleen samanlainen argumenttilista ja paluuarvo. Funktio-osoitinmäärittelyn syntaksi sulkuineen ja parametrilistoineen saattaa vaatia ensi alkuun hieman totuttelua.

Vastaavasti rivillä 12 määritellään funktio-osoitin "varaa", jolla on geneerinen void-osoitin paluuarvona, sekä size_t tyyppinen parametri. Rivillä 13 määritellään funktio-osoitin "vapauta", jolla ei ole paluuarvo, mutta geneerinen void-osoitin parametrinaan, ja rivillä "varaa_uudestaan" jälleen void-osoitin paluuarvona, sekä kaksi parametria. Kolmea jälkimmäistä ei alusteta heti alkuvaiheessa, joten niitä ei vielä voi käyttää.

Rivillä 17 kutsutaan funktiota funktio-osoittimen välityksellä. Kuten nähdään, funktion kutsuminen funktio-osoitinta käyttämällä tapahtuu täsmälleen samalla syntaksilla, kuin tavallisestikin. Kutsun jälkeen b:n arvoksi tulee tietysti 2.

Sijoitusoperaatio funktio-osoittimeen toimii kuten mikä muu tahansa sijoitus, kuten rivelillä 20-22 nähdään, kunhan funktio-osoittimen tyyppi on sama. Funktio-osoittimen tyyppi määräytyy paluuarvon tyypistä, sekä funktion parametrien tyypeistä. Esimerkikin kaikki funktio-osoittimet ovat siis eri tyyppisiä. Funktio-osoittimilla voidaan viitata tietysti myös kirjastofunktioihin, kunhan tyypit ovat oikein. Vaikka kyseiset funktiot eivät näy omassa ohjelmassamme, niiden määrittelyt löytyvät stdlib.h - otsakkeesta.

Funktio-osoittimen arvon voi tietysti määritellä uudestaan aina tarpeen mukaan. Seuraavaksi tehdäänkin vaihtoehtoinen toteutus C-kirjaston free - funktiolle (funktio "just_kidding"). Sen paluuarvon ja parametrilistan tulee tietysti olla sama kuin alkuperäisellä funktiolla. Jotta tulee varmasti todistettua, että funktioiden nimet todella viittaavat osoittimiin, tulostetaan lisäksi ohjelmassa käytettyjen funktioiden osoitteet, ja huomataan kuinka funktio-osoittimen vapauta arvo viittaa näistä tiettyihin. Kyseinen ohjelmahan vuotaa muistia, koska oma toteutuksemme ei itse asiassa vapauta mitään.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdlib.h>
#include <stdio.h>

void just_kidding(void *ptr)
{
    printf("Did not release the memory block starting at %p\n", ptr);
}

int main(void) {
    void* (*varaa)(size_t);
    void (*vapauta)(void *);
    void* (*varaa_uudestaan)(void *, size_t);
    // above pointers are now uninitialized

    // set the pointers to the addresses of functions in C library
    varaa = malloc;
    vapauta = free;
    varaa_uudestaan = realloc;

    printf("free: %p\n", free);
    printf("just_kidding: %p\n", just_kidding);
    printf("main: %p\n", main);
    printf("vapauta: %p\n", vapauta);

    vapauta = just_kidding;
    printf("vapauta: %p\n", vapauta);

    void *os = varaa(100);  // i.e., malloc
    os = varaa_uudestaan(os, 200);  // i.e., realloc
    vapauta(os);  // i.e., free ..umm.. or actually not
}

Ohjelma tulostaa allekirjoittaneen laitteella:

free: 0x7fff90f79ee4
just_kidding: 0x10ad05df0
main: 0x10ad05e20
vapauta: 0x7fff90f79ee4
vapauta: 0x10ad05df0
Did not release the memory block starting at 0x7fe03bc03a20

Funktio-osoittimet toimivat kuten mitkä tahansa tietotyypit: Niitä voi välittää toisten funktioiden parametreina tai paluuarvoina, niitä voi käyttää osana tietorakenteita, jne. Näin esimerkiksi funktiokutsussa voimme määritellä kutsuttavalle funktiolle myös toiminnallisuutta, eikä pelkästään staattisia arvoja.

Esimerkki tästä on esimerkiksi stdlib.h:ssa määritelty qsort - funktio, joka järjestää saamansa taulukon alkiot uuteen järjestykseen. qsort:in hienous on siinä, että se ei oleta taulukon alkioiden tyypistä mitään, vaan sillä voidaan tarvittaessa järjestellä vaikka tietorakenteita jollain kriteerillä. Tästä johtuen funktiolle pitää kertoa, kuinka kahden keskinäisen taulukon järjestys määräytyy antamalle sille parametrina funktio, jolla vertailu suoritetaan. qsort-määrittely näyttää tältä:

void qsort (void *base, size_t nmemb, size_t size,
            int (*compar)(const void *, const void *));

base - parametri viittaa järjestettävään taulukkoon. Koska sen alkiot voivat olla mitä tyyppiä tahansa, on käytettävä geneeristä void-osoitinta. nmemb kertoo kuinka monta alkiota taulukossa on, ja size kertoo yhden alkio koon. Tämäkin täytyy erikseen kertoa, koska qsort-funktio ei missään vaiheessa tiedä millaisia alkioita se käsittelee. Normaalistihan, kun varsinainen tietotyyppi tiedetään, C:n sisäänrakenettu osoitinaritmetiikka pitää huolen että taulukkoa voidaan iteroida oikein. Viimeinen parametri on compar, joka on funktio-osoitin funktioon joka vertailee kahta saamaansa arvoa. Jälleen on käytettävä void-osoittimia, koska kutsurajapinnan on toimittava geneerisesti kaikille tyypeille.

Alla olevassa esimerkissä käytämme kyseistä funktiota järjestelemään nimiä aakkosjärjestykseen. Määrittelemme vertailufunktion name_compare, joka vertailee ensisijaisesti sukunimiä aakkosjärjestyksen mukaisesti strcmp-funktiota käyttäen, mutta mikäli sukunimet ovat samat, etunimi määrää keskinäisen järjestyksen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <stdlib.h>
#include <stdio.h>

struct name {
    char *last;
    char *first;
};

int name_compare(const void *a, const void *b)
{
    const struct name *name_a = a;
    const struct name *name_b = b;

    // first compare the last names
    int res = strcmp(name_a->last, name_b->last);
    if (res != 0)
        return res;
    else
        // if last names are same, first names decide order
        return strcmp(name_a->first, name_b->first);
}

int main(void) {
    struct name array[4] = {
        {"Kimalainen", "Kalle"},
        {"Mehilainen", "Maija"},
        {"Ampiainen", "Kerttu"},
        {"Ampiainen", "Antti"}
    };
    qsort(array, 4, sizeof(struct name), name_compare);
    for (size_t i = 0; i < 4; i++) {
        printf("%s, %s\n", array[i].last, array[i].first);
    }
}

Kuten nähdään, qsortin avulla alkoiden järjestäminen saadaan hoidettua vaivattomasti, kunhan vain toteutamme sopivan vertailufunktion.

Funktio-osoitinta voidaan käyttää myös tietorakenteen kenttänä. Alussa viitattiin roolipeliin, joten seuraavassa sen kaltainen esimerkki, joka esittää tätä tapausta:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <stdlib.h>
#include <stdio.h>

struct monster {
    char *name;
    int hitpoints;
    int (*attack)(struct monster *, struct monster *); // function pointer
};

int punch(struct monster *me, struct monster *target) {
    int damage = rand() % 5;
    printf("%s punches %s with %d damage\n", me->name, target->name, damage);
        target->hitpoints -= damage;
    return damage;
}

int bite(struct monster *me, struct monster *target) {
    int damage = 20;
    printf("%s bites %s with %d damage\n", me->name, target->name, damage);
        target->hitpoints -= damage;
    return damage;
}

int main(void) {
    struct monster goblin = { "goblin", 20, punch };
    struct monster vampire = { "vampire", 10, bite };

    vampire.attack(&vampire, &goblin);
    goblin.attack(&goblin, &vampire);

    // goblin starts biting as well
    goblin.attack = bite;
    goblin.attack(&goblin, &vampire);
}

Tässä esimerkissä huomionarvoista on lähinnä se, että hyökkäystä koskevat funktiokutsut viittaavatkin nyt tietorakenteen kenttänä olevaan funktio-osoittimeen ja näyttävät siksi hieman erilaisilta. Yhtälailla meillä voisi olla taulukko funktio-osoittimia, jolloin syntaksi muuttuisi vielä erikoisemmaksi, esimerkiksi tyyliin paluuarvo = taulukko[i](parametri);, mutta toimisi edelleen.

Task 05_sheet: Taulukkolaskenta (4 pts)

Tavoite: Harjoitellaan funktio-osoittimien käyttöä. Samalla sivutaan muita aiemmin käsiteltyjä aiheita, kuten tyyppimäärittelyjä, unioneita, kaksiulotteisia taulukoita, jne.

Tässä tehtävässä toteutetaan kaksiulotteinen taulukkolaskentaohjelma. Taulukon kukin solu voi olla kolmessa tilassa: a) määrittelemätön, eli solulla ei ole sisältöä; b) solussa on vakiomuotoinen double - tyyppinen lukuarvo; c) solussa on funktio, joka suorittaa laskutoimituksen parametrinaan saaman alueen yli taulukossa.

Jälkimmäisessä tapauksessa taulukkoon sijoitetaan siis käytännössä funktio-osoitin, joka suorittaa halutun laskutoimituksen. Kaikki tällaiset funktiot saavat kaksi koordinaattia parametrinaan: laskettavan alueen vasemman ylänurkan koordinaatit, ja laskettavan alueen oikean alanurkan koordinaatit. Funktioiden tulee palauttaa double-tyyppinen paluuarvo, joka tullaan näyttämään taulukon kyseisessä kohdassa.

Tehtäväpohjassa on seuraavia valmiiksi määriteltyjä funktioita, joita kutsutaan src/main.c - tiedostosta. Tässäkin tehtävässä on suositeltavaa, että testaat ohjelmaa main-funktiota käyttäen ennenkuin lähetät sen TMC:lle tarkistettavaksi.

  • parse_command lukee käyttäjältä komennon jonka seurauksena taulukkoon voidaan kirjoittaa joko staattinen lukuarvo, tai yksi kolmesta mukana tulevasta funktiosta. Koordinaatit ilmaistaan kahdella kirjainmerkillä komennon alussa. Esimerkiksi AA 6 asettaa vasemman ylänurkan arvoksi 6, ja BA sum CC EE laskee ruutuun (1,0) summan 3x3-kokoisen alueen yli välillä (2,2) ja (4,4).

  • print_sheet tulostaa taulukon nykytilan.

Huomaa, että yllä mainitut funktiot eivät toimi oikein ennenkuin olet toteuttanut muutamia muita funktioita seuraavassa olevan tehtävänannon mukaisesti.

Sinun tulee toteuttaa alla mainitut tehtäväkohdat annetussa järjestyksessä:

(a) Taulukon luominen ja vapauttaminen

Toteuta seuraavat funktiot:

  • create_sheet joka varaa taulukon tarvitseman muistin, eli Sheet tietorakenteen ja siitä viitattavan kaksiulotteisen taulukon.

  • free_sheet joka vapauttaa kaiken muistin jonka create_sheet() varasi

  • get_cell joka palauttaa osoittimen parametreissa osoitettuun soluun taulukossa. Funktion tulee olla turvallinen, mikäli sitä yritetään kutsua taulukon yli osoittavilla indekseillä. Tällöin tulee palauttaa NULL, sen sijaan että ohjelma esimerkiksi kaatuisi tai heittäisi Valgrind-virheitä.

Testit tulevat käyttämään hyväkseen näitä funktioita, joten tämä osio pitää toteuttaa ensin hyväksytysti, jotta seuraavat voidaan tehdä. Kannattaa myös tehdä TMC-palautus, ennenkuin siirryt seuraavaan kohtaan.

(b) Solun arvon asettaminen

Toteuta seuraavat funktiot:

  • set_value joka asettaa double - tyyppisen arvon annettuun paikkaan taulukossa.

  • set_func joka asettaa annetun funktion parametreineen, eli funktio-osoittimen annettuun paikkaan taulukossa.

Kummankin funktion tulee olla turvallisia yli taulukon menevillä indekseillä. Kun indeksoidaan yli taulukon, funktiot eivät tee mitään.

(c) Solun arvon määrittely

Toteuta funktio eval_cell joka palauttaa double - tyyppisen arvon riippuen annetun solun sisällöstä. Jos solun tyyppi on VALUE, funktio palauttaa vain tämän arvon. Jos tyyppi on FUNC, kyseistä funktiota kutsutaan sille tallennettuja parametreja käyttäen, ja funktion tuottama tulos palautetaan. Jos solun tyyppi on UNSPEC, tai jos funktiota kutsutaan parametreilla jotka osoittavat taulukon yli, palautetaan vakio NAN (not-a-number), joka on määritelty math.h - otsakkeessa. (Huom: jos haluat testata onko jonkun muuttujan arvo NAN, sinun tulee käyttää isnan - makroa).

(d) Kolme funktiota taulukkolaskentaan

Toteuta funktiot, joilla lasketaan annetun alueen suurin arvo (max), annetulla alueella olevien lukujen summa, ja annetulla alueella määriteltyjen solujen lukumäärä.

  • maxfunc palauttaa suurimman arvon annetun vasemman yläkulman ja oikean alakulman välisellä alueella. Mikäli alueella on määrittelemättömiä soluja, tai taulukon ulkopuolella olevia koordinaatteja, ne tulee ohittaa.

  • sumfunc palauttaa annetulla alueella olevien lukujen summan. Määrittelemättömät solut ja ulkopuolella olevat koordinaatit tulee sivuuttaa.

  • countfunc palauttaa niiden solujen lukumäärän annetulla alueella, joilla on jokin määritelty arvo (joko staattinen numero, tai funktion määräämä).

Näitä funktioita kutsutaan eval_cell funktiosta sen mukaan, mitä taulukkoon on talletettu.

Kannattaa huomioida, että funktioiden käsittelemällä alueella voi olla staattisten arvojen lisäksi myös toisia funktioita. Jos solu sisältää funktion, sen arvo tulee määrittää osana laskutoimitusta. Toisin sanoen, yllä mainituissa funktioissa kannattaa sisäisesti käyttää eval_cell funktiota, joka tarvittaessa kutsuu edelleen toisia funktioita aiemmin määritellyn mukaisesti.

Joitain hyödyllisiä funktioita

Alla joitain C-kirjaston funktioita jotka saattavat olla hyödyllisiä loppukurssin aikana, tai ihan muuten vain.

Funktioita merkkien käsittelyyn

ctype.h - otsakkeessa on määritelty funktioita merkkien käsittelyyn ja luokitteluun. Niillä voi esimerkiksi kysyä, onko kyseinen merkki aakkosiin kuuluva, vai onko se numero, vai kenties whitespace-välimerkki (kuten välilyönti tai tab). Lisäksi löytyy funktiot merkkien konvertoimiseen pienistä kirjaimista isoiksi ja päin vastoin. Seuraavassa lyhyt kuvaus muutamasta funktiosta, ja lisätietoa löytyy jälleen man-sivuilta.

  • isalpha testaa onko annettu merkki aakkosiin kuuluva kirjain.

  • isdigit testaa onko kyseinen merkki numero

  • isspace testaa onko merkki ns. whitespace-merkki. Näitä ovat mm. välilyönti, tabulaattori ja rivinvaihto.

  • isalnum testaa onko kyseinen merkki joko numero tai kirjain (isalpha || isdigit).

  • islower testaa onko kyseinen merkki pieni kirjain.

  • isupper testaa onko kyseinen merkki iso kirjain.

Alla on esimerkki isalpha - funktion käytöstä. Muut edellä mainitut funktiot toimivat samalla periaatteella.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <ctype.h>

unsigned int countLetters(const char *string)
{
    unsigned int count = 0;
    do {
        if (isalpha(*string))
            count++;
    } while (*string++);
    return count;
}

int main(void)
{
    char *str = "abc 123 DEF";
    printf("letters: %u\n", countLetters(str));
}

Lisäksi toupper muuttaa annetun merkin isoksi kirjaimeksi (jos se oli pieni kirjain), ja palauttaa sen. tolower muuttaa kirjainmerkin pieneksi, jos mahdollista.

Lisää merkkijono-operaatioita

Olemme käyttäneet printf- ja scanf-funktioita muotoillun tulosteen tuottamiseen, sekä käyttäjän syötteen lukemiseen, sekä fprintf- ja fscanf - funktioita vastaavasti yleisemmin tiedostojen ja I/O-virtojen yhteydessä.

Vastaavat funktiot löytyvät myös merkkijonovariaatioina, jolloin kohteena ei olekaan terminaali-ikkuna tai tiedosto, vaan annettu muistipuskuri, johon tuotetaan (tai josta luetaan) merkkijono.

Ne ovay seuraavanlaisia:

  • int sprintf(char *str, const char *format, ...) on muuten samanlainen kuin perinteinen printf, mutta ensimmäisenä parametrina on osoitin muistipaikkaan johon tuotetaan merkkijono annettua muotoilua noudattaen. Mitään ei siis varsinaisesti tulosteta ruudulle.

  • int sscanf(const char *str, const char *format, ...) vastaavasti lukee ensimmäisenä parametrina annetusta merkkijonosta arvoja annettuihin muuttujiin muotoilumääreen osoittamalla tavalla. Sitä käytetään muutoin samalla tavalla kuin scanf-funktiota.

Merkkijonoja voi konvertoida numeroiksi myös seuraavilla funktioilla:

  • long int strtol(const char *nptr, char **endptr, int base) (määritelty stdlib.h:ssa) muuttaa parametrin nptr osoittaman merkkijonon numeroarvoksi. endptr on osoitin char * - tyyppiseen muuttujaan, johon funktio tallentaa osoittimeen ensimmäiseen sellaiseen merkkiin, joka ei ollut numero. Jos siis kutsun jälkeen endptr osoittaa samaan paikkaan kuin nptr, merkkijonossa ei ollut numeroa lainkaan. base kertoo esitetyn numeron kantaluvun: esimerkiksi desimaaliluvuille se on 10.

  • atoi on helppokäyttöisempi funktio numeron lukemiseksi merkkijonosta, mutta sen käyttöä ei suositella, mm. siksi että funktio ei osaa kertoa onnistuiko numeron luku vai ei.

Matemaattisia funktioita

Monet matemaattisen funktiot on määritelty math.h - otsakkeessa, ja ne operoivat double - tyyppisillä liukuluvuilla. Jotta funktioita voisi käyttää, matematiikkakirjasto pitää sisällyttää käännökseen kääntäjän -lm - komentorivioptiolla. Alla muutamia funktioita, joista jälleen lisätietoa manuaalisivuilla

  • round pyöristää lukuarvon lähimpään kokonaislukuun

  • ceil pyöristää seuraavaan suurempaan kokonaislukuun

  • floor pyöristää seuraavaan pienempään kokonaislukuun

  • pow korottaa lukuarvon annettuun potenssiin

  • sqrt laskee neliöjuuren

  • fabs palauttaa itseisarvon

  • exp laskee eksponenttifunktion annettuun potenssiin korotettuna.

  • log laskee luonnollisen logaritmin

  • cos laskee kosinin (radiaaneina)

  • sin laskee sinin (radiaaneina)

  • tan laskee tangentin (radiaaneina)

Lisäksi stdlib.h - sisältää joitain funktioita kokonaisluvuille. Esimerkiksi satunnaislukuja voi generoida [rand] - funktiolla, joka palauttaa pseudosatunnaisluvun välillä 0 ja RAND_MAX. Tämä luku voidaan rajoittaa halutulle pienemmälle lukualueelle esimerkiksi modulo-operaatiolla. Satunnaislukugeneraattorille annetaan siemenluku srand - funktiolla.