Ongelmia C-kielen osoittimien (pointterien) kanssa?

Tämä kirjoitus käsittelee erästä perusvirhettä, joka on kirjoittajan arvion mukaan se tavallisin virhe, jonka ihmiset tekevät C-ohjelmointiuransa alussa opetellessaan käyttämään osoittimia.

Lukijan oletetaan tuntevan osoittimiin (pointtereihin) liittyvät perusmääritelmät kuten sen, miten osoitintyyppinen muuttuja määritellään. (Ks. TKK:n Tik-76.002-kurssin aineistossa olevaa kirjoitusta Osoitinmuuttujista ja niiden käytöstä.)

Osoittimia käytetään usein dynaamisten tietorakenteiden muodostamiseen ja käsittelyyn, ja silloin osoitin yleensä viittaa johonkin tietueeseen (structure). Perusasioiden havainnollistamiseksi tarkastellaan seuraavassa kuitenkin sellaista yksinkertaista tilannetta, jossa osoitin viittaa C-kielen perustyyppiä kuten double olevaan objektiin.

Olettakaamme siis, että ohjelmassa on määrittely

double *p;
Tällöinhän muuttaja p on tyyppiä "osoitin double-tyyppiseen objektiin" kun taas *p on tyyppiä double.

Se tavallinen virhe on, että varataan tilaa osoittimelle p ja sitten ruvetaan kirjoittamaan jotain muuttujaan *p ilman, että sille olisi varattu tilaa. Yksinkertaisimmassa tapauksessa tähän tapaan:

double *p;
*p = 1.0;
Tästä seuraa usein ohjelman kaatuminen johonkin virhetilanteeseen kuten Bus error tai Segmentation fault. Toiminta riippuu kuitenkin varsin monista asioista, sekä ohjelmasta että käytettävästä tietokoneesta. On täysin mahdollista, että ohjelma ei kaadu välittömästi mutta sotkeutuu niin, että myöhemmin saadaan virheilmoitus jostakin ohjelman muusta kohdasta, jossa ei ole mitään vikaa.

Käytännössä tilanne on usein mutkikkaampi, koska osoittimia käytetään dynaamisten tietorakenteiden yhteydessä eikä koodista välttämättä näe suoraan, että siinä esiintyy sentapainen rakenne kuin *p yllä.

Jos esimerkiksi on määritelty

typedef struct {char nimi[20]; int koodi;} henkilotietue;
henkilotietue *henkilo[100];
jolloin henkilo on taulukko, jossa on osoittimia määrätyntyyppisiin tietueisiin, ja sitten yritetään vaikkapa lukea dataa tyyliin
   for(j=0;(henkilo[0]->nimi[j]=getchar()) != '\n'; j++);
niin kyse on olennaisesti samanlaisesta tilanteesta kuin edellä kuvatussa yksinkertaisessa tapauksessa. Merkitseehän C-kielen sääntöjen mukaan rakenne henkilo[0]->nimi samaa kuin (*(henkilo[0])).nimi, joten meillä vain on p:n tilalla henkilo[0] ja yksinkertaisen sijoituksen tilalla operaatio, jossa muutetaan osoittimen osoittaman tietueen yhden kentän yhtä alkiota.

Virhe on siinä, että esimerkiksi määrittely

double *p;
varaa tilaa vain ja ainoastaan yhdelle osoittimelle, ei lainkaan tilaa double-tyyppiselle objektille. Vaikka osoittimet sattuisivatkin jossakin koneessa olemaan samankokoisia (tai pienempiä) kuin double-tyyppiset objektit, niin tila on varattu nimenomaan osoittimelle eikä sitä tilaa voi käyttää niiden objektien tallettamiseen, joihin osoitin voi viitata.

Periaatteelliselta kannalta olisi oikeampaa sanoa, että kielen määritelmän kannalta muuttujaa *p ei edes ole olemassa pelkän määrittelyn double *p; jälkeen. Puhe tilan varaamattomuudesta ehkä konkretisoi asioita: ohjelma yrittää kirjoittaa tilaan, jota ei ole asianmukaisesti varattu ohjelman käyttöön. Käytännössä ohjelma saattaa silloin kirjoittaa sellaiseen osoitteeseen, joka ei lainkaan ole ohjelman käytössä olevalla muistialueella, jolloin käyttöjärjestelmä ehkä havaitsee tilanteen ja katkaisee suorituksen ja antaa virheilmoituksen. Voi myös käydä niin, että ohjelma käytännössä kirjoittaa sellaiseen tilaan, joka on varattu jollekin muulle muuttujalle, jolloin ohjelma "satunnaisesti" muuttaa "satunnaisesti valitun" muuttujan arvoa. Voipa käydä niinkin, että ohjelma kirjoittaa oman koodinsa päälle ja alkaa käyttäytyä erittäin hullusti.

Itse asiassa tähän virheeseen useimmiten liittyy myös se, että osoitinmuuttujalle p ei ole annettu mitään arvoa, ei ainakaan järkevää arvoa. Silloin jo itse viittaus *p on laiton. Edellä olevassa tilanteessa on kyse tällaisesta, koska muuttujan p määrittelyssä sille ei anneta alkuarvoa eikä sille muutenkaan anneta arvoa ennen viittausta *p.

Seuraava on luonnollisestikin laillista:

double *p;
double x;
p = &x;
*p = 1.0;
Tällöinhän on, ennenkuin *p:hen viitataan, paitsi määritelty muuttuja p myös annettu sille arvo &x eli x:n osoite ja x:llä on asianmukainen määrittely. Toisin sanoen tällöin *p viittaa laillisesti johonkin double-tyyppiseen objektiin, jolle on varattu tila (muuttujan x määrittelyllä).

Käytännössä yleisempää kuin se, että osoitinmuuttuja asetetaan viittaamaan tavalliseen muuttujaan kuten yllä, on se, että halutaan luoda objekti, johon osoitinmuuttuja viittaa. Tässä kirjoituksessa kuvatun virheen perussyy lienee se, että kuvitellaan sellaisen luomisen sisältyvän osoitinmuuttujan määrittelyyn ikäänkuin määrittely double *p; varaisi tilan sekä p:lle että *p:lle. Tätä väärinkäsitystä ehkä ruokkii C-kielen määrittelyjen rakenne: kun kerran määrittely double x; määrittelee double-tyyppisen muuttujan x, jolle voidaan esim. seuraavaksi sijoittaa arvo, niin on helppoa johtua ajattelemaan, että määrittely double *p; määrittelee myös double-tyyppisen muuttujan *p, jolle voidaan esim. seuraavaksi sijoittaa arvo. Mutta oikea tulkinta on, että se määrittelee double * -tyyppisen muuttujan p ja että tästä seuraa, että voidaan käyttää double-tyyppistä muuttujaa *p jos ja kun muuttuja p on ensin asetettu osoittamaan johonkin (double-tyyppiseen) objektiin.

Tilan varaaminen uudelle objektille tehdään tavallisimmin C-standardissa määritellyllä malloc-funktiolla. Se kuuluu stdlib-kirjastoon eli sen käyttö edellyttää direktiiviä

#include <stdlib.h>
Funktio malloc on kuulunut C-kieleen alusta alkaen, mutta sen tarkka määritelmä on aikojen kuluessa muuttunut. Nykyisen C-standardin mukaan sen tyyppi on void * (aiemmin char *) eli se palauttaa "geneerisen osoittimen", jonka voi muuntaa mihin tahansa osoitintyyppiin. Muutosta ei C-standardin mukaisessa järjestelmässä yleensä tarvitse tehdä erikseen, vaan se tapahtuu automaattisesti. Funktion malloc argumentti on luku, joka ilmoittaa varattavan objektin koon muistin perusyksiköinä, käytännössä tavuina (bytes). Tämä koko tavallisimmin saadaan operaattorin sizeof avulla: sizeof(t) antaa tyyppiä t olevien objektien koon tavuina. Esimerkki:
double *p;
p = malloc(sizeof(double));
*p = 1.0;
Tällöin malloc-funktion kutsu varaa tilan objektille, jonka koko on double-tyyppisen objektin viemä tila, ja palauttaa osoittimen kyseiseen tilaan. Koska kutsu on sijoituksen oikeana puolena, niin sen tyyppi muuntuu automaattisesti sitä tyyppiä olevaksi, jota oikea puoli (tässä p) on, siis esimerkissämme tyyppiin double *.

Kutsu voidaan suorittaa myös osoitintyyppisen muuttujan määrittelyn yhteydessä, esim.

double *p = malloc(sizeof(double));
*p = 1.0;
käyttäen hyväksi sitä, että C:ssä muuttuja voidaan alustaa määrittelynsä yhteydessä. Koska alustava lauseke ei ole vakio vaan sisältää funktion kutsun, tämä on kuitenkin sallittua vain, jos määrittely on paikallinen eli jonkin funktion sisällä.

Jos on määritelty esimerkiksi taulukko, jonka alkiot ovat osoittimia, ja halutaan luoda kutakin alkiota kohden objekti, johon se viittaa, niin on parasta tehdä asia silmukalla. Esimerkki:

#define NIMPIT 20
#define TIETLKM 1000
#include <stdlib.h>>
typedef struct {char nimi[NIMPIT]; int koodi;} henkilotietue;
henkilotietue *henkilo[TIETLKM];
int i;
for(i=0; i<TIETLKM; i++)
  henkilo[i] = malloc(sizeof(henkilotietue));
Periaatteessa ohjelman tulisi aina malloc-funktion kutsun jälkeen tarkistaa, onnistuiko kutsu. Se voi nimittäin epäonnistua, jos haluttua määrää muistia ei voi varata; tavallisimmin tämä johtuu villiintyneestä rekursiosta. Virhetilanteissa malloc palauttaa arvon NULL, ja tällöin kaikki yritykset käyttää mallocin palauttamaa arvoa osoittimena ovat laittomia. Tarkistus voidaan tehdä esimerkiksi seuraavaan tapaan, olettaen, että error on ohjelmassa määritelty funktio, joka antaa sopivan virheilmoituksen ja lopettaa ohjelman suorituksen (esim. kutsulla exit(1)):
double *p;
p = malloc(sizeof(double));
if(p == NULL) error();
*p = 1.0;


Jukka Korpela
9.6.1997