Creative Commons License

Tietokoneen muisti

Last modified: 2016-02-29 (Lisätty linkki stack/heap YouTube-videoon)

Nykyaikaisista tietokoneissa ja niiden käyttöjärjestelmissä on tyypillisesti käytössä virtuaalimuisti. Jokaiselle prosessille (eli karkeasti ottaen käyttäjän ajamalle ohjelmalle) on varattu oma muistiavaruutensa, jonka perusteella käyttöjärjestelmä ohjaa varsinaisen muistin käyttöä. Virtuaalimuisti suojaa ohjelman muistiavaruutta toisilta ohjelmilta, ja estää käyttäjän kirjoittaman ohjelman pääsyn toisten ohjelmien muistiavaruuteen. Normaalisti et voi siis kirjoittaa ohjelmaa, joka sotkisi toisten ohjelmien toimintaa. Oma ohjelma toki voi mennä sekaisin virheellisen muistinkäytön seurauksena.

Kukin ohjelma näkee siis oman 32- tai 64-bittisen muistiavaruutensa, josta tyypillisesti vain pieni osa on käytössä kerrallaan. Suurin osa muistipaikoista ei ole käytössä, ja viittaus tällaisiin osoitteisiin aiheuttaa "segmentation fault" signaalin käyttöjärjestelmältä. Muistiavaruus on jaettu erilaisiin lohkoihin sen perusteella, miten eri muistisegmenttejä on tarkoitus käyttää. Nämä lohkot on esitetty seuraavassa (yksinkertaistetussa) kuvassa:

Ohjelman koodi ladataan kääntäjän tuottamasta ELF (Executable and Linking Format) - binääritiedostosta virtuaalimuistin kirjoitussuojattuun koodisegmenttiin. Täältä tietoa voidaan lukea, mutta muistialuetta ei voi muokata. Kun ohjelma käynnistyy, prosessori alkaa suorittaa ohjelmaa koodisegmentin alkupisteestä edeten ohjelmassa eteenpäin käskyjen määräämällä tavalla. Toisin kuin esimerkiksi Javan tai Pythonin tapauksessa, välissä ei ole tulkkiohjelmistoa, vaan tietokone suorittaa ohjelmaa suoraan ilman välikäsiä.

Myös vakiomuotoiset merkkijonot sijaitsevat virtuaalimuistin kirjoitussuojatulla alueella, joka alustetaan ohjelmaa ladattaessa. Esimerkiksi C-ohjelman rivi char *name = "Jaska"; aiheuttaa merkkijonon "Jaska" sijoittumisen tälle alueelle (muista kuinka merkkijonoja määriteltiin). Näin ollen name - osoittimen päässä olevaa merkkijonoa ei voi muuttaa. Jos näin yritetään tehdä, käyttöjärjestelmä keskeyttää ohjelman segmentation fault - signaaliin.

Koodisegmentin lisäksi, muistitilaa voidaan allokoida alustetulle globaalille datalle ("initialized global data") tai alustamattomalle globaalille datalle ("uninitialized global data"). Globaalisti määritellyt muuttujat (tai staattiset muuttujat) sijoittuvat tälle alueelle.

Keko ("heap") on dynaamisille muistinvarauksille käytettävää tilaa. Kun ohjelma varaa muistia malloc - funktiota käyttäen (nähdään pian), se sijoittuu virtuaalimuistin tälle alueelle. Dynaamisesti varattu muisti säilyy varattuna, kunnes se erikseen vapautetaan free - funktiokutsulla. Käyttöjärjestelmä säätelee keon kokoa dynaamisesti ohjelman tekemien muistivarausten perusteella.

Pino ("stack") on muistialue, jota tietokone varaa niinikään dynaamisesti, mutta ilman ohjelman suoraa ohjausta. Pinoon tallentuu muunmuassa lista funktioista, joiden kautta ollaan nykyiseen suorituskohtaan päädytty. Tietokoneen pitää muistaa nämä, jotta se osaa palata funktion loppuessa oikeaan kohtaan edellisessä funktiossa. Esimerkiksi debuggerin "call stack" tai "backtrace" - näkymä näyttää mitä funktioita pinossa tällä hetkellä on. Kunkin funktion argumenttien ja paikallisten muuttujien vaatima tila myöskin varataan pinosta. Kun funktiosta poistutaan, tämä tila vapautetaan saman tien. Sen takia virheellisiä osoittimia "vanhoihin" paikallisiin muuttujiin tulee välttää.

Toistaiseksi modulien 1 ja 2 esimerkeissa ja harjoitustehtävissä olemme käyttäneet pelkästään pinon kautta varattuja paikallisia muuttujia. Nyt alamme käyttämään kekoa ja siihen tarvittavia dynaamisia muistivarausmekanismeja.

YouTubesta löytyy havainnollinen video (n. 17 min) siitä kuinka pino ja keko toimivat C-ohjelman yhteydessä. Kannattaa siis jossain vaiheessa vilkaista sitäkin.

Dynaaminen muistihallinta

Muistin varaaminen ja vapauttaminen

Muistia varataan keosta malloc - funktiota käyttäen. malloc on määritelty stdlib.h - otsakkeessa, eli kun haluat käyttää funktiota, kyseinen otsake on sisällytettävä ohjelmaan #include <stdlib.h> - komennolla. Myös muut tässä luvussa esitellyt muistinhallintafunktiot on määritelty samassa otsakkeessa.

Ohjelmaa kirjoittaessa tulee vastaan useita tilanteita, jolloin muisti on varattava dynaamisesti, eikä paikallisia pinosta varattuja muuttujia voida käyttää:

  • Tarvittavan muistin määrä ei ole ohjelmointiaikana tiedossa, koska se esimerkiksi riippuu ohjelman muusta toiminnasta tai käyttäjän syötteistä.

  • Funktiota suorittaessa syntyy tilatietoa, jota on kuljetettava funktion ulkopuolelle muualle ohjelmaan. Funktion paikallisia muuttujia ei voi tällöin käyttää, koska ne tuhoutuvat funktiosta poistuttaessa.

  • Tiedetään että tarvittavan muistin määrä vaihtelee ohjelman suorituksen aikana, eikä haluta varata turhaan käyttämätöntä muistia.

malloc - funktion tarkka määritelmä on void *malloc(size_t size). Funktio saa siis argumentikseen etumerkittömän kokonaisluvun jota varten on määritelty uusi size_t - tyyppi. Tämä kertoo varattavan muistin määrän tavuissa. Mikäli muistin varaus onnistuu, funktio palauttaa osoittimen varatun muistialueen alkuun. Mikäli varaus epäonnistuu, palautetaan vakio NULL, eli "0-osoitin". Muistinvarauksen jälkeen onkin hyvä tarkastaa paluuarvosta onnistuiko varaus, koska NULL-osoittimen käyttö johtaisi muistivirheeseen ja ohjelman keskeytymiseen. Kuten tyypillisesti C:ssä, varattu muisti on alustamatonta, eikä sen sisällöstä voi olettaa mitään, ennenkuin ohjelma on kirjoittanut muistialueelle jotain.

void* - tietotyyppi viittaa geneeriseen osoittimeen, johon voi tallettaa minkä tyyppistä tietoa tahansa. Tällainen osoitin voidaan helposti sijoittaa minkä tahansa muun tyyppiseen osoittimeen, kuten esimierkiksi int*, jonka jälkeen kääntäjä tietää, että kyseisen muistialueen alkiot ovat int - tyyppisiä, ja osaa käsitellä esimerkiksi osoitearitmetiikka oikein. void* - tyyppiseen osoitteeseen osoitearitmetiikkaa ei voida soveltaa, eikä sitä yleensä suoraan kannatakaan käyttää. On siis hyvä huomata, että malloc - funktiolla on paluuarvo, joka on jokin osoitin.

Alla oleva esimerkki näyttää kuinka malloc - funktiota käytetään.

Kun muisti on onnistuneesti varattu rivillä 6, varatun muistialueen alkuosoite sijoitetaan table - muuttujaan. Tässä tapauksessa varataan tilaa 100:lle int - tyyppiselle arvolle, eli tehdään dynaamisesti varattu taulukko, jonka sisältö kirjoitetaan riviltä 11 alkavassa silmukassa.

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

int main(void)
{
    int *table; // uninitialized at this point
    table = malloc(100 * sizeof(int));
    if (table == NULL) {
        return -1;  // memory allocation failed
    }
    int i;
    for (i = 0; i < 100; i++) {
        table[i] = 100 - i;
    }
    free(table);
}

Kun taulukolle varataan muistia, on tärkeää ottaa huomioon taulukon alkion vaatima tila kun määritellään kuinka paljon muistia tarvitaan. malloc - argumentiksi annetaan aina tarvittava tila tavuissa, joten kun varaamme tilaa 100:lle int - tyyppiselle kokonaisluvulle, meidän on tiedettävä kuinka monta tavua yksi int - arvo tarvitsee muistista. Tämä tapahtuu sizeof - operaattorilla rivillä 6. malloc:lle siis kerrotaan, että tarvitsemme tilaa 100 kertaa yhden int:in tarvitseman koon. sizeof - operaattoria on käytännössä pakko käyttää vaikka luulisimme tietävämmekin, että int vie esimerkiksi neljä tavua, koska eri ympäristöissä int - tietotyypin koko voi vaihdella: sitä ei ole standardoitu.

Kun muisti on varattu, muistialuetta voi käyttää kuten mitä tahansa taulukkoa, koska varattu muistiosoite on sijoitettu int * - tyyppiseen muuttujaan. Kun taulukkoa nyt indeksoidaan (rivi 12), kääntäjä osaa laskea mitä alkiota muistista käsitellään. Kuten aiemminkin, kääntäjä ei osaa välttää tilannetta, jossa taulukkoa indeksoidaan "yli" varatun alueen. Tällöin usein osutaan alueelle, jota käyttöjärjestelmä ei ollut varannut ohjelman käyttöön, ja aiheutetaan segmentation fault - signaali. Aina näin ei kuitenkaan käy.

Kun muistia ei enää tarvita, se tulee vapauttaa free - funktiota käyttäen. free saa yhden parametrin, osoittimen aiemmin varatun muistialueen alkuun. Tämän osoittimen pitää olla sama, joka malloc - kutsusta on saatu. Osoitin johonkin varatun muistialueen keskelle aiheuttaa ajon aikaisen virheen. Varattua muistia ei voikaan vapauttaa "osittain", vaan vapautukset tapahtuvat samankokoisissa yksiköissä kuin varauksetkin on tehty.

Varatun muistilohkon voi vapauttaa vain kerran. Mikäli samaa muistialuetta yritetään vapauttaa toiseen kertaan, syntyy virhetilanne ja ohjelma keskeytyy.

Käyttämättömän muistin vapauttaminen on tärkeää, jotta järjestelmä saa muistin uuteen käyttöön. Ohjelman joka jättää vapauttamatta aiemmin varattua muistia sanotaan vuotavan muistia. Mikäli tällainen ohjelma pyörii järjestelmässä hieman pidemmän aikaa, se saattaa pikku hiljaa kuluttaa kaiken käytössä olevan muistin, mikä vaikuttaa muihin ohjelmiin, ja järjestelmän toimintaan kokonaisuudessaan, koska näiden muistin varaaminen vaikeutuu. Kun järjestelmän muisti on vähissä, sen toiminta tyypillisesti ensin hidastuu merkittävästi, ja tilanteen jatkuessa ohjelmia voidaan joutua keskeyttämään.

Aiemmin varatun muistialueen kokoa voidaan muuttaa realloc - funktiolla. Funktion tarkka määrittely on void *realloc(void *ptr, size_t size). Se saa parametrikseen osoittimen aiemmin varattuun muistiin (ptr), sekä muistialueen uuden koon (size), joka voi olla suurempi tai pienempi kuin muistialueen aiempi koko. Tämäkin funktio palauttaa paluuarvonaan osoittimen säädetyn muistialueen alkuun.

realloc:in kanssa on hyvä huomioida, että muistialueen osoite saattaa muuttua kutsun seurauksena. Siksi paluuarvo kannattaa ottaa huolella käyttöön, eikä olettaa että muistialueen osoite olisi sama kuin ennenkin. Käytännössä voi ajatella että realloc koostuu loogisesti seuraavista operaatioista: 1) varataan uusi muistialue joka vastaa uutta kokoa; 2) Kopioidaan aiemman muistialueen sisältö (tai osa siitä, jos koko pienenee) uudelle muistialueelle; 3) vapautetaan vanha muistialue.

Alla oleva esimerkki näyttää kuinka realloc toimii:

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

int main(void)
{
    int *table; // uninitialized at this point
    table = malloc(100 * sizeof(int));
    if (table == NULL) {
        return -1;  // memory allocation failed
    }
    int i;
    for (i = 0; i < 100; i++) {
        table[i] = 100 - i;
    }

    int *newtable = realloc(table, 50 * sizeof(int));
    if (!newtable)
        free(table);  // realloc failed, old pointer still valid
    else
        free(newtable);  // realloc succeeded, old pointer is released
}

Ohjelma alkaa kuten aiempi esimerkki, mutta rivillä 14 aiemmin varatun taulukon koko puolitetaan 50:n kokonaislukuun. realloc:in kanssa saa olla tarkkana mitä tapahtuu silloin kun muistialueen koon muuttaminen ei onnistukaan ja funktio palauttaa NULL. Tällöin aiempi muistialue säilyy edelleen varattuna. Tämän asia joudutaan ottamaan huomioon riveillä 15 ja 16, kun muistialuetta vapautetaan.

realloc:ia voi kutsua myös siten, että aiemman muistialueen osoitteeksi annetaan NULL. Tällöin toiminta vastaa malloc - funktion toimintaa.

Lisäksi muistin varausta varten on olemassa funktio calloc, joka varaa N kappaletta tietyn kokoisia elementtejä ja lisäksi alustaa muistin nollilla, muista muistinvarausfunktioista poiketen. Tarkka muoto on void * calloc(size_t count, size_t size);. Voisi siis esimerkiksi kutsua table = calloc(100, sizeof(int));.

Valgrindin käyttö

Valgrind on työkalu jolla voi analysoida ohjelman toimintaa eri tavoin, erityisesti liittyen sen muistinhallintaan. Valgrindin avulla voi jäljittää muistin käyttöön liittyviä virheitä, jotka muutoin saattaisivat olla vaikeita löytää, esimerkiksi kun virheellinen muistikäyttö aiheuttaa satunnaista väärää toimintaa. Valgrind löytää myös muistivuodot, sekä tapaukset joissa alustamatonta muistia käytetään osana ohjelmalogiikkaa.

Valgrindiä käytetään ohjelman ajon aikana. Ensin C-ohjelma käännetään normaalisti suoritettavaksi tiedostoksi. Suoritettava tiedosti voidaan ajaa valgrindin kanssa komennolla valgrind ohjelma, jossa ohjelma on suoritettavan ohjelman nimi. Tällöin ohjelma toimii muuten normaalisti, mutta valgrind analysoi sen toimintaa käsky käskyltä. Sivuvaikutuksena tämä tyypillisesti hidastaa ohjelman toimintaa merkittävästi.

Analysoitava ohjelma kannattaa kääntää -g kääntäjäoption kanssa, jolloin suoritettavan ohjelman oheen tallentuvat viitteet funktionimiin ja lähdekoodin rivinumeroihin, mikä helpottaa valgrind-tulosteen lukemista merkittävästi. Kurssitehtävien mukana tulevat Makefilet sisältävät tämän option aina.

Lisää tietoa Valgrindin toiminnasta ja käytöstä löytyy Valgrindin webbisivulta.

Valgrind on yleisesti saatavilla useimmissa Linux-jakelupaketeissa, mutta valitettavasti sen saatavuus Mac:llä ja Windowsilla on rajallinen. Mac:lle on olemassa versio Valgrindista, mutta ainakin allekirjoittaneella se toimii hyvin epävarmasti. Windowsille ei ole allekirjoittaneen tiedossa olevaa Valgrind-toteutusta. Kierrokselta 3 alkaen TMC-serveri ajaa aina Valgrindin tehtävien testaamisen yhteydessä, eikä päästä testejä läpi, mikäli Valgrind-virheitä esiintyy.

Seuraavassa esimerkkejä parista yksinkertaisesta Valgrind-virheestä. Oletetaan esimerkiksi seuraava (huonosti toteutettu) funktio, joka varaa muistia muistamatta koskaan vapauttaa sitä. Funktio pyrkii varamaan dynaamisesti muistia merkkijonolle (string), joka kopioidaan tähän muistialueelle (array). Muistialueen kuvitellaan sisältävän taulukon 80:n merkin mittaisia merkkijonoja.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
char *add_string(char *array, unsigned int *size, const char *string)
{
    array = malloc(80 * (*size + 1));
    if (array == NULL) {
        return NULL;
    }

    strncpy(&array[*size], string, 79);
    array[*size + 79] = 0;
    *size += 1;
    return array;
}

Kun ohjelma ajetaan Valgrindin kanssa, Valgrind tulostaa seuraavan ilmoituksen:

1
2
3
4
5
6
7
==358== 80 bytes in 1 blocks are definitely lost in loss record 29 of 34
==358==    at 0x4C244E8: malloc (vg_replace_malloc.c:236)
==358==    by 0x401C50: add_string (source.c:3)
==358==    by 0x4014E9: test_moduli3 (test_source.c:13)
==358==    by 0x404B70: srunner_run_all (in /tmc/test/test)
==358==    by 0x401894: tmc_run_tests (tmc-check.c:99)
==358==    by 0x4015E8: main (test_source.c:38)

Viesti kertoo muistivuodosta ("blocks are definitely lost"), eli muistia on varattua malloc:ia käyttäen rivillä 3., mutta sitä ei koskaan vapauteta. Valgrind ilmoitus viittaa funktioon add_string ja source.c - tiedoston riviin 3. Samassa ilmoituksessa näkyy myös kutsupino, eli ne funktiot joiden kautta ongelmalliseen funktioon on päädytty. Kaikissa listatuista funktioista ei siis ole virhettä. Virhettä kannattaa lähteä etsimään ensimmäisestä itse tekemästäsi funktiosta listasta, eli tässä tapauksessa add_string:stä. Tästä Valgrind-virheestä pääsee eroon, kun taulukolle varattu muisti vapautetaan jossain kohdassa ohjelmaa free - funktiolla.

Valgrind luokittelee muistivuotovirheet eri kategorioihin sen perusteella, ovatko varattujen muistialueiden osoitteet vielä tallessa. Joskus käy niin, että osoittimien ylläpito on toteutettu virheellisesti, esimerkiksi ohjelma kirjoittaa osoitinmuuttujan paikalle jotain muuta vapauttamatta ensin kyseistä muistialuetta. Tällöin muistia ei enää voi vapauttaa, vaikka ohjelmassa olisikin free - kutsu suunnilleen oikeassa paikassa.

Tässä hieman muokattu versio yllä olevasta ohjelmasta, joka aiheuttaa erilaisen Valgrind-virheen. Nyt ohjelma pyrkii kasvattamaan aiemmin varattua merkkijonotaulukkoa uudella parametrinaan saamallaan merkkijonolla (string). Kukin taulukon alkio sisältää siis tilaa 80:n merkin merkkijonolle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
char *add_string(char *array, unsigned int *size, const char *string)
{
    char *newarray = realloc(array, 80 * (*size));
    if (newarray == NULL) {
        if (array)
            free(array);
        return NULL;
    }

    strncpy(&newarray[*size * 80], string, 79);
    newarray[(*size * 80) + 79] = 0;
    *size += 1;
    return newarray;
}

Nyt Valgrind tulostaa seuraavaa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
==358== Invalid write of size 1
==358==    at 0x4C25A4F: strncpy (mc_replace_strmem.c:339)
==358==    by 0x401D33: add_string (source.c:10)
==358==    by 0x401579: test_moduli3 (test_source.c:13)
==358==    by 0x404C90: srunner_run_all (in /tmc/test/test)
==358==    by 0x401924: tmc_run_tests (tmc-check.c:99)
==358==    by 0x401678: main (test_source.c:38)
==358==  Address 0x518c690 is 0 bytes after a block of size 0 alloc'd
==358==    at 0x4C244E8: malloc (vg_replace_malloc.c:236)
==358==    by 0x4C24562: realloc (vg_replace_malloc.c:525)
==358==    by 0x401CE4: add_string (source.c:3)
==358==    by 0x401579: test_moduli3 (test_source.c:13)
==358==    by 0x404C90: srunner_run_all (in /tmc/test/test)
==358==    by 0x401924: tmc_run_tests (tmc-check.c:99)
==358==    by 0x401678: main (test_source.c:38)

Virheilmoitus kertoo, että strncpy - funktio yrittää kirjoittaa virheelliseen muistipaikkaan, jota ei ole asianmukaisesti varattu ("invalid write of size 1"). Ilmoituksesta käy ilmi myös, että strncpy:ä kutsutaan add_string - funktion rivillä 10. Tämä on siis otollinen kohta aloittaa virheen etsintä.

size - muuttuja viittaa taulukon nykyisen kokoon (muistiviitteen kautta). Jos esimerkiksi size on 1, taulukolle varataan tilaa 80 merkkiä rivillä 3. Rivillä 10 kuitenkin aletaan tällöin kopioida merkkijonoa alkaen 80:stä merkistä, eli kopioitava merkkijono tulee ylittämään varatun muistialueen koon, kun taas muistialueen alkua ei käytetä hyväksi lainkaan. Kenties tarkoitus oli kopioida merkkijono kohtaan &newarray[(*size - 1) * 80], jolloin muistialuetta ei ylitettäisi.

Valgrind-ilmoituksessa viitataan myös realloc:iin rivillä 3. Tämä ei kuitenkaan tarkoita virhettä, vaan Valgrind kertoo vain, että strncpy:n ongelmallinen muistialue on varattu kyseisessä kohtaa ohjelmaa. Varsinainen ongelma on kuitenkin strncpy:n virheellinen käyttö.

Valgrindin kanssa on kohtuullisen tavallista, että yksi virhe ohjelmassa aiheuttaa ryöpyn virheitä, jotka palautuvat kyseiseen virheeseen. Siksi kannattaakin aloittaa Valgrind-ilmoitusten luku ensimmäisestä alkaen, ja keskittyä ensimmäisen virheen korjaamiseen. Samalla saattaa korjaantua moni muukin virhe.

Kertausta osoittimista

Edellisessä esimerkissä saattaa häiritä runsas osoitinviittausten käyttö. size - muuttuja ei olekaan pelkkä kokonaisluku, vaan viittaus kokonaislukuun jossain toisaalla ohjelmassa. Tämä johtuu siitä, että add_string - funktio kasvattaa kyseistä kokonaislukua yhdellä suorituksensa aikana. Muuttunut arvo halutaan pitää tallessa, joten varsinainen muuttuja säilytetään toisaalla ohjelmassa, ja funktion täytyy päivittää sitä osoittimen avulla. Muutokset paikallisessa muuttujassa häviäisivät heti kun funktiosta poistutaan.

&newarray[*size * 80] on myöskin osoitin. newarray[*size * 80] tarkoittaa taulukon erästä alkiota, eli yhtä char - tyyppistä arvoa. strncpy haluaa kuitenkin parametrikseen osoittimen, joten & - operaattorilla haemme kyseisen alkion osoitteen. Tällöin lausekkeen tietotyypiksi tulee char *. & - operaattori lisää tietotyyppiin aina yhden "tähden".

Task 02_arrays_1: Dynaaminen taulukko (1 pts)

Tavoite: Tulet sinuiksi dynaamisen muistinvarauksen kanssa kokonaislukutaulukon yhteydessä.

Toteuta funktio dyn_reader joka varaa taulukon int - tyyppisille arvoille. Taulukossa tulee olla tilaa n:lle alkiolle. n annetaan funktiolle parametrina. Kun taulukko on varattu, sinun tulee lukea taulukon alkioille arvot käyttäjän syötteestä scanf - funktiota käyttäen. Kun vaadittu määrä kokonaislukuja on luettu, funktio palauttaa osoittimen dynaamisesti varattuun taulukkoon.

Task 02_arrays_2: Lisää taulukkoon (1 pts)

Tavoite: Harjoittele dynaamisesti varatun taulukon koon kasvattamista.

Toteuta funktio add_to_array joka lisää yhden kokonaisluvun aiemmin dynaamisesti varattuun taulukkoon (arr). Taulukon aiempi koko annetaan parametrissa num, ja uusi lisättävä kokonaisluku kerrotaan parametrissa newval. Varmista että taulukossa on riittävästi tilaa uuden kokonaisluvun lisäämiseksi, ja testaa että funktio toimii, kun sitä kutsutaan useita kertoja peräkkäin.

Funktioita muistin käsittelyyn

string.h - otsakkeessa määritellään funktioita muistin sisällön käsittelyyn, esimerkiksi kopiointiin ja tiedon siirtoon liittyen. Näistä saattaa olla joissain tilanteissa hyötyä. Funktiot ovat saman kaltaisia kuin modulissa 2 esitellyt merkkijonofunktiot, mutta erona on se, että seuraavassa olevat funktiot eivät käsittele 0-merkkiä mitenkään erityisellä tavalla. Toisin sanoen ne eivät oleta että käsiteltävät muistialueet sisältävät merkkijonoja.

  • memcpy kopioi osan muistista toiseen osoitteeseen. Funktion tarkka muoto on void *memcpy(void *destination, const void *source, size_t n). source on osoitin muistialueeseen joka kopoidaan, ja destination kertoo mihin osoitteeseen kyseinen alue kopioidaan. n kertoo kuinka monta tavua kopioidaan. Funktio kopioi aina tämän määrän tavuja, riippumatta siitä kuinka monta nollamerkkiä tulee vastaan. Kannattaa huomioida että argumentit ovat void*- osoittimia, eli niiden paikalla voidaan käyttää mitä tahansa osoittimia. On myös hyvä huolehtia, että kohdeosoitteessa on varattuna riittävästi muistia, eli vähintäänkin n tavua.

  • memmove on muuten samanlainen kuin memcpy, mutta se toimii myös silloin kun source ja destination ovat osittain päällekkäiset. memcpy (tai strcpy) eivät sitä salli. Funktion tarkka muoto on void *memmove(void *destination, const void *source, size_t n).

  • memcmp vertailee kahta muistilohkoa. Tarkka muoto on int memcmp(const void *mem1, const void *mem2, size_t n). mem1 ja mem2 ovat kaksi vertailtavaa muistilohkoa, joista n tavua vertaillaan. Funktio palauttaa 0 jos muistilohkot ovat samat, tai erisuuri kuin 0, mikäli ne poikkeavat toisistaan.

  • memset täyttää muistialueen sisällön annetulla arvolla. Tarkka muoto on void *memset(void *mem, int c, size_t len). mem viittaa käsiteltävään muistialueeseen, c on 8-bittinen arvo, joka muistialueen jokaiseen tavuun kirjoitetaan, ja len kertoo kuinka monta tavua kirjoitetaan. Funktion tyypillinen käyttö on esimerkiksi annetun muistialueen täyttäminen 0:lla.

Alla esimerkki jossa näitä funktioita 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
24
25
26
27
28
29
30
31
#include <string.h> // memset, memcpy, memcmp, ...
#include <stdlib.h> // malloc, free
#include <stdio.h>  // printf

int main(void)
{
    char *mem1, *mem2;
    mem1 = malloc(1000);
    if (!mem1) {
        printf("Memory allocation failed\n");
        exit(-1);  // no use continuing this program
    }
    mem2 = malloc(1000);
    if (!mem2) {
        printf("Memory allocation failed\n");
        free(mem1);
        exit(-1);  // terminating
    }

    memset(mem1, 1, 1000);
    memset(mem2, 0, 1000);
    if (memcmp(mem1, mem2, 1000) != 0) {
        printf("the memory blocks differ\n");
    }
    memcpy(mem2, mem1, 1000);
    if (memcmp(mem1, mem2, 1000) == 0) {
        printf("the memory blocks are same\n");
    }
    free(mem1);
    free(mem2);
}

Task 03_join: Liitä taulukot (1 pts)

Tavoite: Harjoittele muistinkäsittelyfunktioita. Lisäksi tällä kertaa teet oman lähdetiedostosi ja siihen liittyvät otsakemäärittelyt itse.

Toteuta funktio join_arrays joka saa parametrikseen kolme kokonaislukutaulukkoa, sekä kustakin taulukosta sen alkioiden lukumäärän. Kaikkiaan funktio saa siis kuusi parametria, jotka on esiteltävä tässä järjestyksessä:

  • ensimmäisen taulukon alkioiden lukumäärä (unsigned integer)
  • osoitin ensimmäiseen kokonaislukutaulukkoon (alkiot tyyppiä int)
  • toisen taulukon alkioiden lukumäärä (unsigned integer)
  • osoitin toiseen kokonaislukutaulukkoon (tyyppiä int)
  • kolmannen taulukon alkioiden lukumäärä (unsigned integer)
  • osoitin kolmanteen kokonaislukutaulukkoon (tyyppiä int)

Funktion tulee liittää nämä kolme taulukkoa yhdeksi taulukoksi, joka sisältää kaikki kolmessa taulukossa listatut kokonaisluvut yllä mainitussa järjestyksessä. Uusi yhdistetty taulukko tulee varata dynaamisesti ja funktion tulee palauttaa osoitin tähän uuteen taulukkoon. Et saa muuttaa alkuperäisiä taulukoita mitenkään.

main.c - tiedoston main-funktiosta näet esimerkin kuinka yllä kuvattua funktiota kutsutaan. source.c:n lisäksi sinun pitää muokata source.h - tiedostoa, jotta main - funktio ja tehtävän testit näkevät toteuttamasi funktion. Muista myös sisällyttää tarvitsemasi C-kirjaston otsakkeet. Huomaa että alkutilassa ohjelma ei edes käänny, ennenkuin olet toteuttanut vähintäänkin jonkinlaisen rungon join_arrays - funktiosta, joka vastaa main.c:n olettamaa funktiorajapintaa.

Rakenteiset tietotyypit

Tosiinsa liittyviä muuttujia voi koota yhteen omaksi koostetuksi tietotyypikseen rakenteisten tietotyyppien (struct) avulla. Rakenteisella tietotyypillä on nimi, jonka avulla sitä voidaan käyttää muuttujissa, sekä funktioiden parametreinä ja paluuarvona, tai ylipäätään missä tietotyyppejä normaalistikin käytetään. Rakenteista tietotyyppiä voi käyttää osoittimien kanssa kyten muitakin tietotyyppejä, ja voipa rakenteinen tietotyyppi olla osa jonkin toisen rakenteisen tietotyypin määrittelyä.

Rakenteisen tietotyypin määrittely

Rakenteinen tietotyyppi määritellään struct määreen avulla. Rakenteinen tietotyyppi koostuu kentistä, joilla kullakin on nimi sekä tietotyyppi, joka on joku C:n perustietotyypeistä, tai jokin muu, aiemmin määritelty tietotyyppi. Kentän tietotyyppi voi myös olla taulukko tai osoitin.

Alla on esimerkki rakenteisen tietotyypin "person" määrittelystä. Tietotyypin kentät määritellään omassa lohkossaan aaltosulkeiden sisällä, ja kunkin kentän määrittely loppuu puolipisteeseen. Tietotyypissä person on kaksi kenttää: 'name' - kenttä on ilmeisestikin merkkijono, eli osoitin char - taulukon alkuun. Lisäksi const-määrittely kertoo, että nimi-merkkijonoa ei voi muokata sen jälkeen kun se on sijoitettu rakenteiseen tietotyyppiin. Tämän lisäksi person - rakenteessa on kenttä 'age', joka on kokonaisluku. Myös rakenteen määrittelyn lopussa, aaltosulun perässä, käytetään puolipistettä.

1
2
3
4
struct person {
    const char *name;
    int age;
};

Yllä oleva ohjelmanpätkä ei tee muuta kuin määrittelee uuden tietotyypin. Yhtään kyseistä rakennetta käyttävää muuttujaa ei ole vielä määritelty. Muuttujia voi määritellä tietotyypin määrittelyn yhteydessä lisäämällä niitä tietotyyppimäärittelyn perään seuraavasti:

1
2
3
4
struct person {
    const char *name;
    int age;
} guyA, guyB;

Nyt meillä on kaksi muuttujaa, guyA ja guyB, jotka molemmat tallentavat rakenteista tietotyyppiä vastaavan tietojoukon, eli nimen ja iän.

On myös mahdollista määritellä muuttujat, mutta jättää rakenteinen tietotyyppi nimeämättä:

1
2
3
4
struct {
    const char *name;
    int age;
} guyA, guyB;

Tämä on harvinaisempaa, koska useimmiten tietotyypin nimeämisestä on hyötyä. Tietotyypin nimen avulla rakenteiseen tietotyyppiin voidaan viitata toisaalla ohjelmassa ilman että sen kenttiä tarvitsee jälleen uudelleen määritellä. Esimerkiksi struct person jussi; määrittelee uuden muuttujan nimeltään jussi, joka noudattaa aiemmin määriteltyä rakenteista tietotyyppiä.

Koska suuret ohjelmistot on usein jaettu useisiin ohjelmamoduleihin (eli .c - tyyppisiin lähdetiedostoihin), joissa saattaa olla tarvetta käyttää samoja rakenteisia tietotyyppejä, tapana on esitellä rakenteiset tietotyypit .h - päätteisissä otsaketiedostoissa, jolloin kyseinen tietotyyppi saadaan käyttöön useaan ohjelmamoduliin sisällyttämällä otsake ohjelmaan #include - direktiiviä käyttäen.

Rakenteisen tietotyypin käyttäminen

Kun rakenteinen tietotyyppi on määritelty, sitä voidaan käyttää uusien muuttujien esittelyyn tai funktioiden parametreina, paljolti kuten C:n perustietotyyppejäkin. Kun rakenteista tietotyyppiä käyttävää muuttujaa käsitellään, joudutaan usein käsittelemään suoraan tietotyypin yksittäisiä kenttiä. Tämä onnistuu piste-notaatiolla: muuttujan nimen ja käsiteltävän kentän nimi erotetaan pisteellä. Seuraava esimerkki valaisee miten tämä toimii.

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

struct person {
    const char *name;
    int age;
};

int main(void)
{
    struct person lady = { "Kirsi", 22 };
    struct person dude;
    dude.name = "Pertti"; // note "const char*" in the name field
    dude.age = 40;
    printf("name of lady: %s\n", lady.name);
}

Esimerkin tapauksessa person - tietotyyppiä ei ole määritelty otsakkeessa, vaan C-lähdetiedostossa, mikä on teknisesti ottaen mahdollista. Sen jälkeen main - funktiossa esitellään muuttuja lady, joka alustetaan muuttujan esittelyn yhteydessä. Tässä kannattaa huomioida alustuksessa käytetty notaatio: Rakenteisen tietotyypin kenttiin sijoitettavat arvot listataan aaltosulkeiden sisällä pilkulla erotettuna. Parametrien tyyppien tulee vastata rakenteen määrittelyä, eli tässä tapauksessa ensin annetaan merkkijono, ja toiseksi kokonaisluku.

On myös mahdollista jättää muuttuja alustamatta, kuten tehdään muuttujan dude tapauksessa. Tällöin C:lle tyypillisesti tietotyypin kenttien sisältö saattaa olla mitä tahansa, kunnes niille on asetettu jokin arvo. Riveillä 12 ja 13 nähdään kuinka rakenteisen tietotyypin kenttiin sijoitetaan arvot. Ensin kerrotaan mitä muuttujaa käsitellään, ja pisteellä erotettuna mitä kenttää käsitellään. Rakenteisen tietotyypin kenttiä voi käyttää lausekkeessa kuten muitakin muuttujia, kuten nähdään printf:n yhteydessä rivillä 14.

Taulukoista poiketen rakenteista tietotyyppiä käyttävä muuttuja voidaan sijoittaa suoraan toiseen muuttujaan yksinkertaisella sijoituslausekkeella, kuten alla olevassa esimerkissä tehdään. Tällöin kaikki kentät kopioituvat muuttujasta toiseen. Kuten muistetaan, kokonaisia taulukoita ei samalla tavalla voi kopioida, elleivät ne sisälly rakenteiseen tietotyyppiin.

1
2
3
struct person lady = { "Kirsi", 22 };
struct person dude;
dude = lady;  // what??

Yllä olevan sijoituksen voisi tehdä myös memcpy:ä käyttäen: memcpy(&dude, &lady, sizeof(struct person));, mutta sijoitusoperaattorin käyttö on toki useimmiten helpompaa. person - rakenteen tapauksessa on hyvä huomioida, että itse merkkijonoa ei kopioida, koska rakenteessa tallennetaan vain osoitin merkkijonoon. Sijoituksen jälkeen dude ja lady viittaavaat samaan muistiosoitteeseen ja samaan merkkijonoon. Koska tietotyyppi oli määritelty muuttumattomaksi const - määreellä, tämä ei todennäköisesti haittaa ohjelman toimintaa.

Rakenteista tietotyyppiä voi käyttää funktion parametrina esimerkiksi seuraavaan tyyliin:

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

struct person {
    const char *name;
    int age;
};

int has_enough_age(struct person p)
{
    if (p.age >= 18)
        return 1;
else
        return 0;
}

int main(void)
{
    struct person guy = { "Kalle", 22 };
    if (has_enough_age(guy)) {
        printf("You may enter!\n");
    }
}

Koska C:ssä funktioparametrien arvot kopioidaan funktion käyttöön, tietorakenteesta tehdään erillinen kopio has_enough_age - funktion pinokehykseen. Mikäli p - muuttujaa muutetaan, muutokset eivät näy main - funktiossa. Jos haluttaisiin muutosten heijastuvan myös kutsuvalle funktiolle, olisi parempi käyttää osoitinta alkuperäiseen guy - muuttujaan.

Rakenteista tietotyyppiä voidaan käyttää myös funktion paluuarvona, jolloin return - lauseessa annettu muuttuja ja sen kaikki kentät kopioituvat takaisin kutsujalle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int has_enough_age(struct person p)
{
    if (p.age >= 18)
        return 1;
    else
        return 0;
}

struct person make_older(struct person p)
{
    p.age++;
    return p;
}

int main(void)
{
    struct person guy = { "Kalle", 15 };
    while (!has_enough_age(guy)) {
        printf("waiting one year...\n");
        guy = make_older(guy);
    }
    printf("age is now: %d\n", guy.age);
}

Yllä oleva ohjelma tulostaa seuraavaa:

1
2
3
4
waiting one year...
waiting one year...
waiting one year...
age is now: 18

Rakenteinen tietotyyppi ja osoittimet

Kuten muitakin tyyppejä, myös rakenteiseen tietotyyppiin voidaan viitata osoittimella. Tällöin normaaliin tapaan tietotyypin nimen perään annetaan tähtimerkki (*), merkkaamaan että kyseinen tietotyyppi sisältää muistiosoitteen.

Koska rakenteisten tietotyyppien käyttö osoittimien kautta on melko yleistä C:ssä, kieli määrittelee "nuolioperaattorin" (->), jolla viitataan tietorakenteen kenttiin silloin kun käytössä on osoitin. Alla oleva esimerkki näyttää miten tämä toimii:

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

int main(void)
{
    struct person lady = { "Kirsi", 15 };
    struct person *kirsi = &lady;
    kirsi->age = 18;
    printf("age: %d\n", lady.age);

    struct person *guy;
    guy = malloc(sizeof(struct person));
    guy->name = "Pertti";
    guy->age = 40;
    free(guy);
}

Nuolioperaattori on periaatteessa optionaalinen: vastaavan C-kielisen ohjelman voisi kirjoittaa myös ilman tätä operaattoria. Ylläolevan ohjelman rivi 7 voitaisiin esittää myös näin: (*kirsi).age = 18;. Sulut ovat tässä tapauksessa pakollisia, koska pistenotaatio on laskujärjestyksessä ennen viittausoperaattoria. Varsinkin monimutkaisten tietorakenteiden kanssa tämä notaatio käy hankalaksi, joten siksi nuolioperaattoria käytetään selkeyttämään ohjelmaa.

Ylläolevassa ohjelmassa kirsi on viittaus muuttujaan lady, ja kun ensimmäiseen kohdistetaan sijoitusoperaatio, käytännössä muutetaan rakenteisen muuttujan lady sisältöä.

Jälkimmäisessä osassa funktiota muodostamme uuden muuttujan guy, jolle varataan muisti dynaamisesti keosta käyttäen malloc - funktiota (rivi 11). Tällöin tietoa on pakko käsitellä osoittimen kautta, kuten tässä tehdään. Kun rakenteiselle tietotyypille varataan tilaa, sizeof - operaattori on käytännössä välttämätön: se kertoo kuinka paljon tilaa rakenne kokonaisuudessaan vie. Tätä ei kannata yrittää laskea itse, koska kääntäjä saataa lisätä tyhjiä välejä kenttien väliin tehostaakseen kenttien käsittelyä. Tietotyypin vaatima tila kokonaisuudessaan saattaa siis olla enemmän kuin kenttien kokojen yhteenlaskettu summa.

Osoitearitmetiikka toimii myös rakenteisten tietotyyppien kanssa, mikä mahdollistaa taulukoiden muodostamisen rakenteisista tietotyypeistä. Kun tällaista osoitinta "lisätään yhdellä", osoitin siirtyy eteenpäin suoraan rakenteisen tietotyypin vaatiman tilan verran, seuraavaan alkioon oletetussa taulukossa.

Seuraavassa hieman monimutkaisempi esimerkki, jossa käsitellään person - tietotyypistä koostettua taulukkoa. Olemme nyt muuttaneet myös hieman rakenteen määritelmää siten, että name - kenttä onkin nyt muutettavissa oleva merkkijono, eli const - määre on tiputettu pois.

Funktio määrittelee 10 alkion members taulukon, jossa kukin alkio on yksi person - rakenne. Kymmenen alkiota käydään läpi, ja kussakin tapauksessa nimelle varataan malloc:ia käyttäen riittävästi tilaa, ja sitten generoidaan kullekin henkilölle yksillöllinen (mutta mielikuvitukseton) nimi: "guy-A", "guy-B", jne... Kannattaa kiinnittää huomiota missä järjestyksessä taulukon indeksointi tapahtuu, ennen kuin tietorakenteen kenttiin viitataan. Toisaalta name - kenttä on myös itsessään merkkijonotaulukko, eli sitäkin voi indeksoida, kuten tässä nimeä generoidessa tehdäänkin.

Kannattaa myös huomioida varatun muistin vapautus lopussa: se pitää tehdä erikseen kullekin taulukon nimelle, koska malloc kutsuttiin erikseen jokaiselle nimelle. free - kutsujen määrän pitää vastata malloc - kutsujen määrää.

 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
46
47
48
49
50
51
52
53
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct person {
    char *name;
    int age;
};

int main(void)
{
    struct person members[10];
    struct person *current = &members[0];
    int count = 0;
    char *nameprefix = "guy-";
    const int NameLen = 5;

    /* Need to allocate name separately, because structure does not
       reserve space for it */
    for (int i = 0; i < 10; i++) {
        members[i].name = malloc(NameLen + 1);
        // if malloc does not succeed, name remains unspecified
        if (members[i].name) {
            /* Dynamically build unique name for each
               member: "guy-A", "guy-B", "guy-C", .... */
            strcpy(members[i].name, nameprefix);
            members[i].name[4] = 'A' + i;
            members[i].name[NameLen] = '\0';
        }
        members[i].age = i + 60;
    }

    // find first person who is at least 65 years old
    /* could also use:
       members[count].age */
    while (count < 10 && current->age < 65) {
        current++;  // move to next element in array
        count++;
    }

    // in principle we might have not succeeded allocating all names
    // therefore current->name could be NULL
    if (count < 10 && current->name) {
        printf("found: %s, age: %d\n", current->name, current->age);
        // or: printf("found: %s", members[count].name);
    }

    // remember to release allocated memory
    for (int i = 0; i < 10; i++) {
        if (members[i].name)
            free(members[i].name);
    }
}

Yksi vaihtoehto olisi ollut määritellä tietorakenne person seuraavasti:

1
2
3
4
struct person {
    char name[20];
    int age;
};

Tällöin erillistä malloc - varausta ei tarvittaisi, koska kullekin nimelle varataan 20 merkin verran tilaa osana person - tietorakennetta, ja kääntäjä varaa tämän tilan automaattisesti (pinosta) samalla kun muuttujaa tai taulukkoa esitellään.

Dynaamisesti varattu taulukko

Joskus tarvitsemme vaihtelevan määrän tietoalkioita, jotka kaikki noudattavat samaa rakenteista tietotyyppiä. Tällöin rakenteet voidaan tallentaa taulukkoon, kuten edellä nähtiin, mutta mikäli emme ohjelmointivaiheessa tiedä kuinka monta tietoalkiota ohjelma tulee tarvitsemaan, tarvittava tila täytyy varata dynaamisesti, ja tilaa tulee tarvittaessa kasvattaa kun uusia tietoalkioita tulee saataville.

Muisti varataan tietysti malloc - kutsulla, mutta pitää olla tarkkana sen suhteen kuinka paljon muistia tarvitsemme. Alla oleva ohjelma varaa dynaamisesti taulukon tutuksi tulleelle person - rakenteelle.

 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
#include <string.h>
#include <stdlib.h>

struct person {
    char *name;
    int age;
};

int main(void)
{
    struct person *people;
    int num_people = 10;
    const char *defaultname = "John Doe";

    people = malloc(sizeof(struct person) * num_people);
    for (int i = 0; i < num_people; i++) {
        people[i].name = malloc(strlen(defaultname) + 1);
        strcpy(people[i].name, defaultname);
        people[i].age = i + 30;
    }

    // do something else

    for (int i = 0; i < num_people; i++) {
        free(people[i].name);
    }
    free(people);
}

Rivillä 15 varataan malloc:ia käyttäen riittävästi tilaa 10 henkilölle. Kukin henkilö vie sizeof(struct person) tavua tilaa. Tässä tapauksessa olemme luottavaisa, emmekä testaa onnistuiko muistin varaus. Mikäli se ei onnistu, malloc palauttaa NULL-osoittimen ja ohjelma kaatuu segmentation fault - virheeseen kun yrittämme käyttää people - taulukkoa.

Muistin varaamisen jälkeen people - muuttujaa voidaan käyttää kuten mitä tahansa taulukkoa: sitä voidaan indeksoida normaalisti, ja yksittäisiin kenttiin viitataan kuten ennenkin.

Taulukon varaamisen lisäksi kullekin tietoalkiolle pitää erikseen varata tila name - merkkijonolle, koska tietorakenteen määrittely sisälsi pelkän osoittimen. Tätä varten kutsumme malloc:ia uudestaan jokaisen alkion kohdalla, ja varaamme tilaa riittävästi jotta defaultname saadaan kopioitua varattuun paikkaan. Merkkijono pitää siis kopioida strcpy - funktiota käyttäen. Suora sijoitus aiheuttaisi väärän lopputuloksen.

Kun taulukkoa ei enää tarvita, sen vaatima tila vapautetaan free - funktiota käyttäen. Koska kussakin alkiossa oli dynaamisesti varattu merkkijono, myös ne pitää vapauttaa erikseen kustakin alkiosta. Lopuksi koko taulukko voidaan vapauttaa yhdellä free - kutsulla, koska taulukon varaaminenkin tapahtui yhdellä malloc - kutsulla.

Mikäli taulukkoon tarvitsee myöhemmin lisätä alkiota, sen kokoa täytyy kasvattaa realloc - funktiota käyttäen.

Etukäteismäärittely

On kohtuullisen tavallista, että rakenteisen tietotyypin määrittelyssä viitataan toisiin rakenteisiin tietotyyppeihin. Jotta johonkin tietotyyppiin voitaisiin viitata, kyseinen tietotyyppi tulee määritellä ensiksi. Joskus tulee vastaan kehämäisiä tilanteita, joissa tietotyyppien väliset viittaukset menevät ristiin, eikä ole mahdollista rakentaa ohjelmaa siten, ettei tarvittaisi viittausta tyyppiin jota ei ole vielä esitelty. Tällöin kääntäjälle täytyy kertoa, että "tämän nimisen tietotyypin määrittely seuraa hetken perästä, mutta tässä vaiheessa riittää tietää, että tämänniminen tyyppi on olemassa". Tällaista kutsutaan etukäteismäärittelyksi (forward declaration), ja se tehdään alla olevan ohjelman rivillä 3. Rakenteiden person ja pet keskinäisen järjestyksen vaihtaminen ei ratkaise ongelmaa, että tietorakenteen sisällä on viittaus aiemmin määrittelemättömään tietotyyppiin.

Etukäteismäärittely toimii vain osoittimien yhteydessä: kääntäjä tietää, että osoitin vie aina muistiosoitteen verran tilaa, mutta se ei pysty varaamaan tilaa varsinaiselle rakenteelle ennenkuin se on määritelty.

 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 <stdio.h>

struct pet;

struct person {
    const char *name;
    int age;
    struct person *marriedTo;
    struct pet *myPet;
};

struct pet {
    struct person *owner;
    const char *name;
};

int main(void)
{
    struct person man = { "Jorma", 50, NULL, NULL };
    struct person lady = { "Kirsi", 15, &man, NULL };
    struct pet cat = { &lady, "Kisu" };

    man.marriedTo = &lady;
    lady.myPet = &cat;
    printf("Name of the cat: %s\n", man.marriedTo->myPet->name);
}

Ylläolevassa ohjelmassa person - rakenteeseen on siis lisätty kaksi uutta kenttää: osoitin toiseen henkilöön (marriedTo) ja osoitin pet - tyyppiseen rakenteeseen. Molemmissa on pakko käyttää osoitinta, koska kyseisessä kohdassa kääntäjä ei vielä tiedä paljonko tietorakenteet vaativat tilaa.

Ohjelman lopussa on main - funktio, joka esittelee pari henkilöä, sekä lemmikin, sekä asettaa niiden väliset viitteet. Sisäkkäisten tietorakenteiden kenttien välillä voidaan ketjuttaa viittauksia jotta päästään käsiksi haluttuun kenttään, kuten ohjelman lopussa olevassa printf - lauseessa tehdään. Pystytkö seuraamaan mitä ohjelma tulostaa? (sen voi aina kopioida omaan editoriin ja kääntää itse)

Task 04_vessel: Laiva (1 pts)

Last modified: 2016-03-11 (Lisätty pari sanaa osoittimista ja mallocista)

Tavoite: Harjoitellaan rakenteisten tietotyyppien määrittelyä ja peruskäyttöä.

Tässä harjoituksessa sinun tulee toteuttaa kolme asiaa: 1) määrittele rakenteinen tietotyyppi vessel, joka kuvaa kuvitteellista valtamerialusta; 2) toteuta funktio create_vessel joka luo uuden instanssin määrittelemästäsi vessel - tietotyypistä; 3) toteuta funktio print_vessel joka tulostaa vessel - rakenteen sisällön.

vessel - tietorakenteessa tulee olla seuraavat kentät. On tärkeää että kenttien nimet ovat kuten alla annettu, koska muuten src/main.c tai tehtävän testikoodit eivät käänny.

  • name: laivan nimi merkkijonona. Merkkijono tulee kopioida create_vessel - funktion parametrista p_name. Et voi sijoittaa sitä suoralla sijoitusoperaattorilla. Nimeä tulee voida muokata jälkeenpäin, ja siinä on oltava tilaa 30 merkille (plus lopetusmerkki). Tila on varattava pinosta (stack) eikä keosta (heap), eli älä käytä dynaamista muistia tähän. Jos funktion parametrina annettu nimi on pidempi kuin 30 merkkiä, se tulee katkaista 30 merkkiin.

  • length: laivan pituus double-tyyppisenä liukulukuna. Voit olettaa että pituus ilmaistaan metreinä (tosin asialla ei suurta käytännön merkitystä tässä tehtävässä).

  • depth: laivan syväys double-tyyppisenä liukulukuna. Tämänkin osalta voi olettaa että syväys ilmaistaan metreinä.

  • crg: laivan lasti. Tämän tulisi vastata yhtä cargo - tietorakennetta, joka on annettu source.h - otsakkeessa.

Sinun tulee määritellä vessel - rakenne source.h - otsaketiedostossa, jotta muut lähdetiedostot näkevät sen.

create_vessel saa vastaavat parametrit kuin mitä määriteltävässä vessel-tietorakenteessa on. Sinun tulee sijoittaa nämä arvot luomaasi vessel - tyyppiseen muuttujaan. Kannattaa kiinnittää erityishuomiota siihen miten käsittele merkkijonoparametria name. Merkkijonosta tulee luoda kopio.

HUOM: create_vessel palauttaa paluuarvonaan kopion luomastasi vessel-tietorakenteesta, paluuarvo ei siis ole osoitin. Funktion ulkopuolella oleva koodi ei osaa vapauttaa mitään varaamaasi muistia, joten malloc:in käyttö aiheuttaa muistivuodon (ellet vapauta muistia saman tien, missä ei olisi hirveästi järkeä). Kannattaa miettiä tarvitsetko osoittimia lainkaan tässä tehtävässä.

print_vessel tulostaa annetun vessel - rakenteen sisällön siten että jokainen tietorakenteen kenttä tulostuu omalle rivilleen, mukaanlukien cargo-rakenteeseen kuuluvat kentät. Liukuluvut tulee tulostaa siten että niistä näytetään yksi desimaali. Esimerkiksi, kun edellä mainitut funktiot on toteutettu oikein, src/main.c:n esimerkkiohjelman tulisi tulostaa seuraavaa:

Apinalaiva
180.0
8.5
Bananas
10000
1500.0

Abstrakti tietotyyppi

Monimutkaisemmat (rakenteiset) tietotyypit, jotka koostuvat useista parametreista vaativat tarkkuutta: tietyt parametriyhdistelmät saattavat olla "järjettömiä", ja joskus parametrien käsittely vaatii tarkkuutta, esimerkiksi kun parametria varten on tarvittu dynaamista muistia.

Hyvä suunnitteluperiaate on eristää tällaiset ohjelman määrittelemät omat tietotyypit kukin omaksi ohjelmamodulikseen, ja piilottaa tietotyypin sisäinen toteutus ohjelmiston muilta osilta. Sen sijaan tietotyyppiä käytetään pelkästään julkisesti määriteltyjen funktioiden (eli rajapinnan) avulla, jotka pitävät huolen että tietotyypin parametreja käytetään oikein. Kun tämä tehdään oikein, tietotyypin toteutus suojautuu paremmin ohjelmointivirheiltä, joiden todennäköisyys kasvaa sitä mukaa kun ohjelmiston koko ja sen parissa työskentelevien ohjelmoijien määrä kasvaa.

Tällaista rakennetta kutsutaan abstraktiksi tietotyypiksi. Koska tietotyypin sisäinen toteutus on piilotettu muulta ohjelmalta, sitä voidaan korjata ja parannella ilman että muun ohjelmiston tarvitsee asiasta välittää. Kunhan funktiorajapinta on määritelty hyvin, ja siihen ei kosketa, muu ohjelmisto jatkaa toimintaansa normaalisti, vaikka tietotyypin sisäistä toteutusta muutettaisiinkin.

Monista muista (esim. olio-pohjaisista) ohjelmointikielistä poiketen C:ssä ei ole sisäänrakennettua mekanismia abstraktien tietotyyppien toteuttamiseksi, vaan ne pitää toteuttaa funktioiden avulla.

Tietotyyppiä varten määritellään julkinen otsaketiedosto (esim. "vector.h"), jossa kuvataan funktiorajapinta, jonka kautta tietotyyppiä käytetään. Otsaketiedostosta ei käy ilmi, miten tietotyyppi on oikeasti toteutettu. Toteutus löytyy C-kielisestä lähdetiedostosta "vector.c", mutta tietotyypin käyttäjän ei tarvitse välittää miten tietotyyppi on toteutettu.

Alla oleva esimerkki määrittelee uuden abstraktin vektoritietotyypin. "vector.h" - otsake voisi näyttää tältä:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#ifndef AALTO_VECTOR_H
#define AALTO_VECTOR_H

/* Define Vector type to be used in interfaces, but don't show
   its internal structure */
typedef struct vector_st Vector;

/* Allocates a new vector from heap, returns pointer to the vector */
Vector* vectorCreate(double x, double y, double z);

/* Releases the vector from heap */
void vectorFree(Vector *v);

/* Calculates sum of two vecors. Result is returned in new vector object
   allocated from heap */
Vector* vectorSum(const Vector *a, const Vector *b);

/* Calculates the length of the vector */
double vectorLength(const Vector *v);

/* Prints the vector to standard output */
void vectorPrint(const Vector *v);

#endif // AALTO_VECTOR_H

Otsakkeen alussa on esimerkki myös ns "include-vahdista" (include guard), joka estää käännösvirheet sellaisissa tilanteissa että kyseiseen otsakkeeseen viitataan useaan kertaan samassa käännösyksikössä. Tämä saattaa kuulostaa erikoiselta, mutta tilanne on kohtuullisen tavallinen suuremmissa ohjelmistoissa, joissa otsakkeet sisältävät viittauksia edelleen toisiin otsakkeisiin, ja näinollen muodostavat monimutkaisempia vittausketjuja. Palaamme myöhemmin esikääntäjä makroihin, ja siihen mitä #define ja #ifndef edellä merkitsevät.

Julkisessa rajapinnassa on hyvä olla tarkkana määreiden käytössä, ja esimerkiksi käyttää const - määrettä johdonmukaisesti ilmaisemaan, että funktio ei tule muuttamaan saamiensa parametrien sisältöä.

Vektorin sisäinen toteutus on tiedostossa "vector.c", joka sisältää varsinaisen tietorakenteen määrittelyn (vector_st) sekä sitä käyttävien funktioiden toteutuksen. Vektoria käyttävien ohjelman osien ei siis esimerkiksi tarvitse tietää kuinka tietorakenne muodostuu, koska ne käyttävät vektoria pelkästään funktioiden avulla. Alla osa vektorin toteutuksesta.

 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
#include &lt;assert.h&gt;
#include "vector.h" // to ensure that the interface is compatible

struct vector_st {
    double x, y, z;
};

Vector* vectorCreate(double x, double y, double z)
{
    Vector *v = malloc(sizeof(struct vector_st));
    v->x = x;
    v->y = y;
    v->z = z;
    return v;
}

void vectorFree(Vector *v)
{
    assert(v);  // fails if v is NULL
    free(v);
}

Vector* vectorSum(const Vector *a, const Vector *b)
{
    assert(a); // check that value is not NULL
    assert(b != NULL); // other way to check that value is not NULL
    Vector *ret = malloc(sizeof(struct vector_st));
    ret->x = a->x + b->x;
    ret->y = a->y + b->y;
    ret->z = a->z + b->z;
    return ret;
}
/* ...continues with some other Vector management functions... */

Yllä oleva esimerkki käyttää myös assert - makroa, jolla voi varmistaa pitääkö annettu ehto paikkaansa. assert:ia käytetään silloin, kun ohjelman toiminta edellyttää esim. määritellyn osoittimen olemassaoloa. assert - ehdon epäonnistuminen keskeyttää ohjelman oletusarvoisesti välittömästi. Kun ohjelma toimii oikein, assert - ehtojen tulisi siis aina olla tosia, mutta niitä halutaan käyttää varmistamaan "vahvoja oletuksia" ohjelman toimintaan liittyen. Kehitysvaiheessa halutaan saadaan selkeä varoitus tilanteista, joissa nämä vahvat oletukset pettävät, jotta päästään korjaamaan virheen syy. assert - makro määritellään "assert.h" - otsakkeessa.

Kun abstrakti tietotyyppi on määritelty, muualla ohjelmassa on sisällytettävä sen määrittävä otsaketiedosto (tässä "vector.h"). Varsinaisesta C-lähdetiedostosta emme ole kiinnostuneita. Nyt vektoreita voi käsitellä vain annettuja funktioita kutsumalla, kuten alla nähdään:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include "vector.h"

int main(void)
{
    // create two vectors and calculate their sum into third vector
    Vector *v1 = vectorCreate(1, 3, 4);
    Vector *v2 = vectorCreate(0, -2, 2);
    Vector *v3 = vectorSum(v1, v2);

    // print the result
    vectorPrint(v3);

    // release all three vectors that were allocated
    vectorFree(v1);
    vectorFree(v2);
    vectorFree(v3);
}

Task 05_fraction: Murtoluku (4 pts)

Nyt harjoitellaan abstraktin datatyypin tekemistä toteuttamalla uusi numerotyyppi, murtoluku (fraction), joka koostuu osoittajasta (numerator) ja nimittäjästä (denominator). Esimerkiksi 2/3 ja 4/6 ovat murtolukuja, joissa 2 ja 4 ovat osittajia, ja 3 ja 6 nimittäjiä. Nämä murtoluvut ovat myös yhtä suuria. Määrittelemme uuden tietotyypin Fraction esittämään murtolukuja.

Tässä tehtävässä sinun tulee toteuttaa seuraavat funktiot. Kustakin funktiokokonaisuudesta saa yhden pisteen, ja tehtävä on siten kokonaisuudessaan neljän pisteen arvoinen.

Tehtävässä annetaan otsake fraction.h, joka määrittelee julkisen rajapinnan, jonka kautta murtolukuja käsitellään. Sinun tulee toteuttaa funktiot ja tarvitsemasi tietorakenteet tiedostoon fraction.c.

(a) Aseta arvo

Toteuta funktio Fraction* setFraction(unsigned int numerator, unsigned int denominator) joka varaa uuden murtoluvun dynaamisesti keosta ja asettaa sen esittämään lukua, jonka osoittaja ja nimittäjä parametreissa annetaan. Funktion tulee palauttaa luotu murtoluku (eli osoitin siihen).

Lisäksi sinun tulee toteuttaa seuraavat yksinkertaiset funktiot:

  • void freeFraction(Fraction* f) joka vapauttaa murtoluvulle varatun muistin.

  • unsigned int getNum(const Fraction *f) joka palauttaa annetun murtoluvun osoittajan.

  • unsigned int getDenom(const Fraction *f) joka palauttaa annetun murtoluvun nimittäjän.

(b) Vertaile arvoja

Toteuta funktio int compFraction(const Fraction *a, const Fraction *b) joka palauttaa -1, mikäli murtoluku a on pienempi kuin b, 0 mikäli murtoluvut ovat yhtä suuria, tai 1, mikäli murtoluku a on suurempi kuin b. Funktion tulee toimia oikein myös silloin kun nimittäjät ovat erisuuria.

Huom: Seuraavia tehtäväkohtia koskevat testit käyttävät tätä toteutustasi testaamaan funktioiden toimintaa. Siksi sinun tulee toteuttaa tämä tehtäväkohta oikein, ennenkuin siirryt tehtäväkohtiin (c) ja (d).

(c) Summalasku

Toteuta funktio Fraction *add(const Fraction *a, Fraction *b) joka laskee murtolukujen a ja b summan, ja palauttaa tuloksena syntyvän uuden murtoluvun paluuarvonaan. Palautettava tulos on uusi murtolukuobjekti, joka varataan dynaamisesti. Funktion tulee toimia oikein myös silloin kun summattavien lukujen nimittäjät poikkeavat toisistaan. Lopputulosta ei tarvitse supistaa käyttämään pienintä mahdollista nimittäjää.

Vinkki: kannattaa aloittaa muuntamalla summattavat murtoluvut sellaisiksi, että ne käyttävät samaa nimittäjää.

(d) Supista murtoluku

Toteuta funktio void reduce(Fraction *val)

joka supistaa annetun murtoluvun pienimpään mahdolliseen nimittäjään. Esimerkiksi jos val esittää murtolukua 3/6, sen tulisi supistua murtoluvuksi 1/2. Pienimmän nimittäjän etsiminen onnistuu hakemalla pienin yhteinen tekijä osoittajalle ja nimittäjälle. Tehtäväpohjasta löytyy valmiiksi annettu funktio unsigned int gcd(unsigned int u, unsigned int v) jonka avulla tämä onnistuu. (funktion toteutus on otettu wikipediasta)

Task 06_oodi: Oodi (3 pts)

Tavoite: Harjoittele dynaamisesti varattujen ja kokoaan muuttavien taulukoiden käyttöä tietorakenteista koostetuista alkioista.

Tässä tehtävässä toteutat yksinkertaisen tietokannan kurssiarvosanojen tallentamiseen. Tietokannan jokainen tietue noudattaa struct oodi rakennetta, ja tietokanta muodostuu dynaamisesti varatusta taulukosta, jossa siis on useita struct oodi - instansseja peräkkäin. Aina kun uusi alkio lisätään taulukkoon, pitää varmistua että taulukolle on varattu riittävästi muistia uuden alkion tallentamiseen.

struct oodi - määrittely ja sen kenttien kuvaukset löytyvät tiedostosta oodi.h. Kannattaa perehtyä tietorakenteen määrittelyyn ja selvittää itselleen mitkä kentät saavat tarvitsemansa tilan osana tietorakennetta, ja mille kentille tarvittava tila pitää varata erikseen. Esimerkiksi kenttä course on merkkijono, jolle pitää dynaamisesti varata tarvittu tila erikseen.

Harjoituksessa on kolme kohtaa, joista kustakin saa yhden pisteen. Kannattaa toteuttaa tehtäväkohdat annetussa järjestyksessä, koska myöhemmät tehtäväkohdat saattavat riippua aiemmin toteutetuista funktioista. Kannattaa myös testata ohjelmaasi jokaisen tehtäväkohdan välissä, ja mikäli se näyttää toimivan, lähettää pisteytystä varten palvelimelle. Vaikka koko tehtävä ei olisikaan valmis, yksittäisistä tehtäväkohdista voi silti saada pisteitä.

(a) Alusta opiskelijatietue

Toteuta funktio 'init_record' joka alustaa annetun oodi - rakenteen osoittimen or osoittamassa paikassa saamiensa parametrien (p_student, p_course, jne...) pohjalta. Sinun ei tarvitse varata muistia tässä tehtäväkohdassa, vaan voit olettaa että or - osoitin viittaa muistialueeseen jossa on riittävästi tilaa varattuna. Sinun tulee kuitenkin varata muistia niille rakenteen kentille, joiden vaatima tila ei sisälly oodi - rakenteeseen. Kannattaa esimerkiksi kiinnittää huomiota merkkijonoihin.

Funktio palauttaa arvon 1, jos alustus onnistui, tai 0 jos se epäonnistui jostain syystä. Funktio epäonnistuu esimerkiksi silloin, jos sille annetaan virheellinen opiskelijanumero, jossa on enemmän kuin 6 merkkiä. Voit olettaa että mikä tahansa 6 merkkiä pitkä (tai lyhyempi) merkkijono on hyväksyttävä opiskelijanumero.

(b) Lisää uusi tietue

Toteuta funktio 'add_record' joka lisää uuden oodi - tietorakenteen dynaamisesti varatun taulukon loppuun, ja tarvittaessa varaa taulukolle lisää tilaa. Osoitin taulukon alkuu tulee funktioparametrissa array, ja taulukon nykyinen pituus parametrissa length. Lisättävä tieto annetaan parametrissa newrec. On syytä huomioida että newrec:n sisältö tulee kopioida taulukkoon oikealle paikalle, ja jälleen kannattaa kiinnittää huomiota osoittimien käsittelyyn.

Funktio palauttaa osoittimen taulukkoon lisäyksen jälkeen. Tämähän saattaa olla eri osoite kuin se, joka array - parametrissa annettiin sisään.

(c) Päivitä arvosanaa

Toteuta funktio change_grade, joka päivittää arvosanaa (newgrade) ja suorituspäivää (newdate) taulukossa olevaan alkioon. Muutettava taulukon alkio tunnistetaan opiskelijanumerolla (p_student) ja kurssikoodilla (p_course). Molempien pitää täsmätä, jotta muutos voidaan tehdä. Mikäli vastaavaa alkiota ei löydy, funktio ei muuta mitään. Funktio palauttaa arvon 1, mikäli jotain alkiota muutettiin, tai 0, mikäli vastaavaa taulukon alkiota ei löytynyt, eikä muutosta näin ollen tehty. Voit olettaa että kukin opiskelijanumero/kurssikoodi - yhdistelmä esiintyy taulukossa korkeintaan kerran.

Linkitetty lista

Linkitetty lista on yleinen tietorakenne, jonka avulla dynaamisesti varatuista solmuista voidaan tehokkaasti muodostaa järjestetty lista. Linkitetty lista poikkeaa taulukosta siinä, että kukin alkio on varattu muistista erikseen, eivätkä alkiot välttämättä sijaitse peräkkäisissä kohdissa muistia. Sen sijaan alkiot on ketjutettu toisiinsa osoittimien avulla.

Jos oletetaan, että meillä on yksinkertainen linkitetty lista, jonka kuhunkin solmuun tallennetaan yksi kokonaisluku, tarvitsemme seuraavanlaisen tietorakenteen:

1
2
3
4
struct intlist {
    int value;
    struct intlist *next;
};

Varsinaisen kokonaislukualkion lisäksi tarvitaan siis osoitin samantyyppiseen intlist rakenteeseen, josta löytyy seuraava listaan tallennettu kokonaisluku. Listan voi visualisoida seuraavalla tavalla:

Linkitettyä listaa käyttävällä ohjelmalla on tyypillisesti vain osoitin listan ensimmäiseen alkioon. Jos esimerkiksi listasta etsitään jotain tiettyä alkiota, ohjelman pitää siis alkaa seuraamaan next - osoittimien ketjua listan alkioita eteenpäin. Listan viimeisen alkion tunnistaa siitä, että sen next - osoitin on NULL.

Seuraava ohjelma tulostaa kaikki linkitettyyn listaan tallennetut alkiot.

1
2
3
4
5
6
7
8
9
struct intlist *first;

// alusta linkitetty lista jollain tapaa...

struct intlist *current = first;
while (current) {  // toista, kunnes NULL-osoitin tulee vastaan
    printf("value: %d\n", current->value);
    current = current->next;
}

Kun linkitetyn listan loppuun lisätään uusi alkio, seuraavat toimenpiteet tarvitaan:

  1. Varaa muistia uudelle listan alkiolle: esimerkiksi yllä olevan listan tapauksessa: struct intlist *new = malloc(sizeof(struct intlist));. Alusta varatun tietorakenteen kentät haluamallasi tavalla. Koska uudesta alkiosta tulee listan viimeinen alkio, next - osoittimeksi asetetaan NULL.

  2. Etsi tämän hetkisen listan viimeinen alkio etenemällä listaa next - osoittimia käyttäen. Viimeisen alkion tunnistaa siitä, että next - osoitin on NULL (current->next == NULL, tms).

  3. Muokkaa (aiemman) viimeisen alkion next - osoitinta siten, että se viittaa askelessa 1 varaamaasi uuteen tietosolmuun. (current->next = new, tms.). Nyt varaamasi uusi alkio on linkitetty listan loppuun.

Sama diagrammin avulla esitettynä:

Kun listasta poistetaan alkio, poistettavaa edeltävä next - osoitin tulee päivittää siten, että se "ohittaa" poistettavan alkion, ja viittaa sitä seuraavaan alkioon. Tai mikäli poistettava alkio sattuu olemaan listan lopussa, edeltäväksi next - osoittimeksi asetetaan NULL merkkaamaan listan loppua. Poistettavalle alkiolle varattu muisti tulee tietysti tässä vaiheessa vapauttaa free - kutsulla.

Linkitetyn listan käsittelyssä tulee erityisesti kiinnittää huomiota listan alku- ja loppupään käsittelyyn. Miten esimerkiksi toimitaan, kun listan ensimmäinen alkio poistetaan?

On hyvä vertailla edellä esitettyä kahta vaihtoehtoista tapaa dynaamisesti kokoaan vaihtavan listan tallentamiseen:

  • dynaamisesti varattu taulukko vaatii koko taulukon uudelleen varaamisen, kun kokoa tarvitsee muuttaa. Toisaalta taulukon mihin tahansa alkioon on nopea päästä käsiksi taulukkoa normaalisti indeksoimalla.

  • Linkitettyyn listaan on nopea lisätä ja poistaa alkioita. Toisaalta listan keskellä olevaan alkioon pääsee käsiksi vain seuraamalla linkitetyn listan osoittimia, mikä on hitaampaa kuin taulukon indeksointi.

Linkitetty lista on malliesimerkki tietotyypistä, joka olisi hyvä esittää abstraktina tietotyyppinä, pelkästään hyvin määritellyn rajapinnan kautta, jotta muualta ohjelmasta ei ohjelmointivirheiden seurauksena vahingossa sotketa listan rakennetta.

Task 07_queue: Harjoitusjono (3 pts)

Tavoite: Harjoittele tietorakenteiden ja osoittimien pyörittelyä linkitetyn listan avulla.

Tässä tehtävässä toteutetaan yksikertainen jonotusjärjestelmä opiskelijoita varten. Kukin jonon alkio sisältää opiskelijanumeron sekä opiskelijan nimen. Jono toteutetaan abstraktina tietotyyppinä Queue, ja sen käsittelyyn tarvittavat funktiot toteutetaan tiedostossa queue.c. queue.h määrittelee julkisen rajapinnan jonon käsittelyyn ja queuepriv.h sisältää määrittelyt jotka ovat jonototeutuksen sisäisiä, ja jota toisten ohjelmamodulien ei tarvitse tuntea. Jono toteutetaan linkitettynä listana.

Tässä linkitetyn listan variaatiossa ylläpidetään osoitinta sekä ensimmäiseen listan alkioon että viimeiseen listan alkioon, sen lisäksi että listan alkiot yhdistetään toisiinsa next - osoittimien avulla. Listan viimeisen alkion next osoitin on aina NULL.

Alkutilanteessa osoittimet first ja last ovat NULL-osoittimia. Kun listaan lisätään ensimmäinen (ja ainut) alkio, molemmat osoittimet siiretään osoittamaan siihen. Tämän jälkeen uudet lisäykset listaan aiheuttavat last - osoittimen siirtymisen, mutta first - osoitin jää jonon ensimmäisen alkion kohdalle.

Listaa käydään läpi seuraamalla next - osoittimia alkio kerrallaan. Kun lisäät alkion listan loppuun, sinun tulee siis päivittää edellisen alkion next - osoitinta (joka sitä ennen oli NULL), sekä lisäksi last - osoitinta.

Listan jokainen alkio varataan erikseen dynaamisesti muistista.

Jotkut funktiot Queue - tyypin käsittelyksi on jo valmiiksi toteutettu, mutta tärkeimmät funktiot sinun tulee toteuttaa. Nämä ovat seuraavat:

(a) Lisää opiskelija jonoon

Toteuta funktio 'Queue_enqueue' joka lisää uuden opiskelijan jonon perään. Funktio saa parametrikseen osoittimen tämän hetkiseen jonoon (q), sekä lisättävän opiskelijan opiskelijanumeron (id), sekä nimen (name). Funktio palauttaa arvon 1, mikäli lisäys onnistui tai 0, mikäli se epäonnistui. Mikäli opiskelijanumero on virheellinen (enemmän kuin 6 merkkiä), funktion ei tule lisätä mitään, vaan palauttaa 0. Koska opiskelijan nimen pituus ei ole tiedossa, eikä sitä voida varata osana jonotietorakennetta, se pitää varata erikseen dynaamisesti, kuten uuden jonoelementin vaatima tilakin.

Sinun ei tarvitse tarkistaa löytyykö opiskelija jo ennestään jonosta.

Testaamisen ja debugaamisen helpottamiseksi kannattaa hyödyntää main.c - tiedostoa ja sillä tuotettavaa src/main - ohjelmaa ennenkuin tehtävä lähetetään TMC:n tarkistettavaksi.

(b) Ota jonon ensimmäinen

Toteuta funktio 'Queue_dequeue' joka poistaa jonon ensimmäisen alkion ja vapauttaa sille varatun muistin. Funktio palauttaa arvon 1, mikäli jotain otettiin jonon kärjestä pois, tai 0 mikäli mitään ei poistettu (esimerkiksi jos jono oli jo ennestään tyhjä).

Tämä rajapinta mahdollistaa sen, että koko jonon saa kerralla tyhjäksi lauseella while (Queue_dequeue(queue));.

(c) Tiputa annettu alkio

Toteuta funktio 'Queue_drop' joka poistaa annetun opiskelijanumeron jonosta, ja vapauttaa sen tarvitseman muistin. Kyseinen alkio voi sijaita missä jonon kohdassa tahansa, ja poiston jälkeen jonon pitää säilyä edelleen eheänä ja toimintakuntoisena, eli kaikki osoittimet pitää päivittää asiaan kuuluvasti.

Funktio palauttaa arvon 1, mikäli annettua opiskelijanumeroa vastaava opiskelija löytyi ja se poistettiin, tai 0, mikäli kyseistä opiskelijaa ei löytynyt, eikä mitään poistettu. Kukin funktiokutsu poistaa vain yhden alkion: jos opiskelija sattuu olemaan jonossa useamman kerran, vain ensimmäinen täsmäävä alkio poistetaan.