Tietokoneen käytön ja ohjelmoinnin alkeet

Kurssin kotisivulle


9 C - osa 2

Funktiot

Tarkastellaan funktion määrittelemistä yksinkertaisen esimerkin avulla.


  int func (int x) ;

  main()
  {
    int y ;
    y = func(5) ;
    ...
  }

  int func (int x)
  {
    return 2*x + 1 ;
  }

Pääohjelmassa esiintyy funktiokutsu func(5). Kääntäjä ei voi pelkän kutsun perusteella tietää, minkä tyyppinen funktio on ja millaisia argumentteja sille pitäisi välittää. Siksi ennen pääohjelmaa on annettava funktion prototyyppi, joka sisältää funktion ja sen argumenttien määrittelyt. Käytännössä sen voi kirjoittaa kopioimalla editorilla funktion varsinaisen määrittelyn alun sopivaan paikkaan. Huomaa, että prototyyppi päättyy puolipisteeseen, jota varsinaisessa määrittelyssä ei ole.

Prototyyppi kertoo nyt, että func palauttaa arvonaan kokonaisluvun ja että sille täytyy antaa argumenttina yksi kokonaisluku.

Funktion suoritettava koodi on joukko aaltosulkujen välissä olevia lauseita. Funktion arvo välitetään kutsuvalle ohjelmalle lauseella return. Lause päättää samalla funktion suorituksen. Se voi sijaita missä tahansa kohtaa funktion sisällä. Jos se on esimerkiksi toistolauseessa, toisto lopetetaan ja suoritus jatkuu kutsuvasta ohjelmasta.

C-kielessä ei ole erikseen funktioita, jotka laskevat jonkin arvon, ja aliohjelmia, jotka eivät palauta mitään arvoa. Funktion arvoa ei tosin tarvitse käyttää mihinkään. Esimerkissä lauseen y=func(5) tilalla voisi olla pelkästään func(5), mikä tässä tapauksessa ei kylläkään olisi mielekästä, koska silloin funktio ei oikeastaan tekisi mitään.

Jos halutaan kirjoittaa aliohjelman tapainen funktio, jonka toiminta perustuu pelkästään sivuvaikutuksiin, funktion tyypiksi voidaan määritellä void. Se on funktio, joka ei tuota mitään arvoa.

Seuraavassa funktio printmean tulostaa argumenttiensa keskiarvon, mutta ei palauta kutsuvaan ohjelmaan mitään tietoja:


  void printmean (float x, float y) ;

  main()
  {
    ...
    printmean (1.0, 2.0) ;
    ...
  }

  void printmean (float x, float y)
  {
    printf("keskiarvo = %f \n", (x+y)/2) ;
  }


  float func (float x, float y) ;

  main()
  {
    float x, s ;
    x = func(1.0, 2.0) ;
    ...
  }

  float func (float x, float y)
  {
    float s ;
    x ++ ;
    s = (x+y)/2 ;
    return s ;
  }

Funktiolla func on kaksi argumenttia. Lisäksi funktiossa määritellään paikallinen muuttuja s. Pääohjelmassa on myös saman niminen muuttuja, mutta kummallekin varataan oma muistipaikkansa. Funktio saa tehdä muuttujalle s mitä tahansa, mutta se ei vaikuta pääohjelman muuttujaan.

Funktion argumentit ovat C-kielessä aina arvoparametreja. Ne ovat siis funktion paikallisia muuttujia, ja funktio voi käsitellä niitä sellaisina. Vaikka funktiossa argumentin x arvoa muutetaan lauseella x++, se ei vaikuta millään tavoin pääohjelmaan.

Tarkastellaan hieman realistisempaa esimerkkiä. Muunnetaan aikaisemmin esiintynyt harmonisen sarjan summaa laskeva ohjelma niin, että summa lasketaan funktiolla. Hetken mietittyämme päätämme, että funktiolle kannattaa välittää argumenttina raja, jota suuremmat termit otetaan mukaan.

   #include 
   #include 
   double harmonic (double limit) ;
   main() {
      printf("%lf\n", harmonic(0.001)) ;
   }

   double harmonic (double limit)
   { double sum=0.0, term=1.0 ;
     int i=1 ;
     while (term > limit) {
       sum += term ;
       i++ ; 
       term = 1.0/i ;
     }
     return sum ;
   }

Nyt funktiota harmonic voidaan kutsua monta kertaa eri argumentin arvoilla ja tutkia, miten summa kasvaa, kun rajaa pienennetään.

Tämän funktion kanssa on kuitenkin vielä ongelma. Se ei kerro kutsuvalle ohjelmalle, montako termiä sarjasta laskettiin. Tässä tapauksessa asia on tietysti triviaali, mutta sarjan ollessa mutkikkaampi, termien määrää on turha ruveta laskemaan uudestaan, kun se on funktion tiedossa. Se pitäisi vain välittää sieltä jotenkin kutsuvaan pääohjelmaan. Funktio voi kuitenkin palauttaa vain yhden arvon, joten miten sieltä saadaan ulos kaksi lukua? Tähän on useita vaihtoehtoja, ainakin kolme.

  1. Termien määrän ilmoittaa globaali muuttuja, jonka sekä pääohjelma että funktio näkevät.
  2. Funktiolle välitetään toinenkin argumentti, jonka arvona termien lukumäärä palautetaan.
  3. Funktion arvo ei olekaan luku, vaan kokonainen tietue, joka sisältää sekä sarjan summan että termien lukumäärän. Tätä käsitetllään vähän myöhemmin.

Ensimmäinen vaihtoehto on siis määritellä globaali muuttuja, johon termien lukumäärä sijoitetaan.

   #include 
   #include 
   double harmonic (double limit) ;
   int nrofterms ;

   main() {
      double sum ;
      int count ;
      sum = harmonic (0.001) ;
      printf("%d termiä, summa = %lf\n", nrofterms, sum) ;
   }

   double harmonic (double limit)
   { double sum=0.0, term=1.0 ;
     int i=1 ;
     while (term > limit) {
       sum += term ;
       i++ ; 
       term = 1.0/i ;
     }
     nrofterms = i ;
     return sum ;
   }

Nyt funktio sijoittaa lopussa muuttujan nrofterms arvoksi termien lukumäärän. Tämä on globaali muuttuja, jonka arvon myös pääohjelma näkee. Menetelmä on toimiva, mutta varsin ruma tällaisessa tilanteessa.

Siistimpi keino on välittää funktiolle kaksi argumenttia.

   #include 
   #include 
   double harmonic (double limit, int *count) ;
   main() {
      double sum ;
      int nrofterms ;
      sum = harmonic (0.001, &nrofterms) ;
      printf("%d termiä, summa = %lf\n", nrofterms, sum) ;
   }

   double harmonic (double limit, int *count)
   { double sum=0.0, term=1.0 ;
     int i=1 ;
     while (term > limit) {
       sum += term ;
       i++ ; 
       term = 1.0/i ;
     }
     *count = i ;
     return sum ;
   }

Tämä alaa näyttää hivenen kryptiseltä, sillä tässä käytetään osoittimia. Aloitetaan pääohjelmasta. Muuttuja nrfoterms on kokonaislukumuuttuja, siis tarkkaaan ottaen tuon muuttujan eli tietyn muistipaikan sisältämä luku. Merkintä & nrofterms puolestaan tarkoittaa tuon muistipaikan osoitetta. Funktiolle ei siis välitetä muuttujan arvoa, vaan sen talletuspaikan osoite.

Funktiossa puolestaan esiintyy merkintä *count. Määrittelyn mukaan *count on kokonaisluku, joten count on jotakin muuta. Se on tuon kokonaisluvun talletuspaikan osoite. Merkki * tarkoittaa osoittimen osoittaman muistipaikan sisältöä. *count on siis se kokonaisluku, jonka osoite on count. Merkintätapa ja kutsun eroaminen prototyypistä saattaa aiheuttaa sekaannuksia, mutta asialle ei voi mitään.

Tämän temppuilun syynä on se, että funktion kaikki parametrit ovat arvoparametreja. Niiden arvot kopioidaan funktion paikallisiin muuttujiin, ja funktio ei pääse käsiksi alkuperäisiin arvoihin. Tämä suojamuuri kierretään niin, että funktiolle välitetäänkin muuttujan talletuspaikan osoite. Funktion ei tarvitse muuttaa tätä osoitetta, mutta se pääsee käsiksi osoitteesta löytyvän muuttujan sisältöön. Näppärää, mutta ei kovin tyylikästä. Tämä on yksi niistä C-kielelle ominaisista konekielimäisistä ratkaisuista, jotka tekevät kielestä paikoitellen varsin vaikeaselkoista.

Sekavaa? Ei tämä vielä mitään. Pahempaa on tulossa.

Taulukot

Yksinkertaisista muuttujista voidaan muodostaa taulukoita, joissa on useita samantyyppisiä muuttujia.

Numeerisissa tehtävissä tarvitaan usein taulukoita esimerkiksi vektorien ja matriisien esittämiseen. C:ssä taulukoiden käsittely poikkeaa esimerkiksi Fortranista. Niiden kanssa on syytä olla erittäin tarkkana, varsinkin, jos niitä on välitettävä argumentteina funktioille.

Yksiulotteiset taulukot

Esimerkiksi

    int taulu[10] ;

määrittelee taulukon, joka sisältää kymmenen kokonaislukua.

C:ssä taulukon indeksointi alkaa aina nollasta. Tätä ei voi muuttaa, vaikka monissa tapauksissa jokin muu alaraja olisi tehtävän kannalta luontevampi. Seurauksena on joskus ikävää indeksiaritmetiikkaa.

Edellisen esimerkin taulukon alkiot ovat siis taulu[0], taulu[1], ... taulu[9] ; Taulukon määrittelyssä esiintyvä luku 10 ei siis ole indeksin suurin sallittu arvo, vaan alkioiden lukumäärä.

Esimerkin taulukko voitaisiin nollata silmukalla

    for (i=0; i<10; i++) taulu[i]=0 ;

Silmukan rajojen kanssa on oltava tarkkana. Tässä lausetta toistetaan vain niin kauan kuin i on aidosti pienempi kuin 10.

Hieman (tai joskus aika paljonkin) hämmentävää on, että taulukon nimi on itse asiassa osoitin taulukolle varatun tilan alkuun. Siten seuraavat sijoituslauseet toimivat täsmälleen samalla tavoin:

      taulu[5] = 1;
      *(taulu + 5) = 1;

Oikeastaan jälkimmäisestä muodosta näkee selvemmin, mistä on kysymys, ja edellistä voi pitää vain sille sovitusta merkinnästä, joka muistuttaa enemmän muiden kielten merkintätapoja. Merkintä on sikäli hämäävä, että taulu on osoitin, joten tekisi mieli kirjoittaa sen eteen tähti, kun tarkoitus on kajota osoittimen osoittaman muuttujan sisältöön. Niin ei kuitenkaan saa tehdä.

Tämä vaatii tarkkaavaisuutta, kun taulukoita välitetään argumentteina funktioille. Koska funktiolle välitetään vain taulukon osoite, sen sisältöä ei kopioida funktion paikalliseksi muuttujaksi, mikä merkitsee merkittävää ajansäästöä, jos taulukko on hyvin iso.

Funktion määrittelystä ei näy, että argumenttina on oikeastaan taulukko, sillä varsinainen argumentti on vain osoitin. Seuraavassa esimerkissä määritellään funktio, jolla nollataan taulukko.

    void nollaa (int *t, int n) ;
    main()
    {
      int taulu[10] ;
      nollaa (taulu, 10) ;
      ...
    }
    void nollaa (int *t, int n) 
    { int i ;  
      for (i=0; i

Funktiota kirjoitettaessa on vain tiedettävä, että t on taulukko; funktion prototyypistä se ei näy millään tavoin.


/* lajitellaan taulukko nousevaan jarjestykseen */
#include 
void tulosta (int *t, int n) ;
void lajittele (int *t, int n) ;

main()
{
  int taulu[10] = { 1, 0, 2, 3, 5, 2, 4, 1, 7, 3 } ;
  lajittele (taulu, 10) ;
  tulosta (taulu, 10) ;
}

void tulosta (int *t, int n) 
{ int i ;  
  for (i=0; i

Kaksiulotteiset taulukot Käyttöjärjestelmän ohjelmoija tarvitsee aniharvoin useampiulotteisia taulukoita. Siksi sellaisten käsittely C:ssä on jäänyt hivenen mutkikkaaksi. Numeerisissa sovelluksissa niitä sen sijaan tarvitaan tuon tuostakin.

Kaksiulotteisen taulukon määrittely voisi olla:

    float matriisi[3][4] ;

Tämä määrittelee taulukon, jossa on kolme vaakariviä ja neljä pystysaraketta. Tämä kuitenkin toteutetaan käytännössä yksiulotteisena taulukkona, jonka neljä ensimmäistä alkiota vastaavat ensimmäistä vaakariviä, neljä seuraavaa toista riviä jne. Taulukon nimi matriisi on tässäkin tapauksessa osoitin taulukon alkuun.

Esimerkiksi seuraavat lauseet toimivat samalla tavoin:

    matriisi[2][3] = 1.0 ;
    *(matriisi + 2*4 + 3) = 1.0 ;

Taaskin ensimmäinen muoto on oikeastaan vain lyhennemerkintä jälkimmäiselle.

Seuraavassa esimerkissä lasketaan matriisitulo C=AB:

   double A[3][3], B[3][3], C[3][3], sum ;
   int i, j, k ;
   ...
   for (i=0; i<3; i++)
   for (j=0; i<3; j++)
   { sum = 0 ;
     for (k=0; k<3; k++) sum += A[i][k] * B[k][j] ;
     C[i][j] = sum ;
   }

Merkkijonot

Koska C:n yksi käyttötarve on ollut Unixin käyttöliittymän kirjoittaminen, siihen on rakennettu hyvät työkalut juuri merkkijonojen käsittelyyn. Osoittimien avulla voidaan kätevästi tutkia ja muokata merkkijonoja. Lisäksi monet usein tarvittavat toimenpiteet on toteutettu kirjastofunktioina.

Merkkijono on oikeastaan vain taulukko, joka sisältää joukon peräkkäisiä merkkejä. Määrittely voisi olla esimerkiksi

    char nimi[30] ;

Merkkijonon yksittäistä merkkiä voidaan käsitellä kuten muidenkin taulukoiden alkioita:

    nimi[0] = 'a' ;
    nimi[1] = nimi[0] ;
    if (nimi[i] == 'z') ...

Kokonaista merkkijonoa ei voi kopioida toisen arvoksi yhdellä sijoitusoperaatiolla (kuten ei taulukkoakaan). Myöskään kahden eri merkkijonon samanlaisuutta ei voi tutkia yhdellä ==-operaattorilla. Molemmat voidaan toteuttaa helposti toistolauseilla, mutta näitä toimenpiteitä tarvitaan niin usein, että niiden suorittamista varten on olemassa joukko valmiita funktioita.

Merkkijonoja käsittelevät funktiot tunnistavat merkkijonon päättymisen merkistä \ 0 eli käytännössä merkkikoodista 0. Tämä tarkoittaa siis, että muistipaikan sisältö on 0; merkin '0' merkkikkodi on jotakin muuta.

Merkkijono voidaan kopioida funktiolla strcpy. Funktiolla on kaksi argumenttia, joista ensimmäinen ilmoittaa, mihin merkkijono kopioidaan, ja toinen, mistä kopioidaan. Järjestys voi tuntua omituiselta, mutta se vastaa sijoituslauseen järjestystä, jossa muutettava suure on vasemmalla puolella ja sille annettava arvo oikealla. Toinen merkkijonojen kopiointifunktio on strncpy, jossa on vielä kolmas argumentti; se kertoo, kuinka monta merkkiä kopioidaan.

Kahta merkkijonoa voidaan verrata funktillla strcmp. Jos funktion arvo on nolla, merkkijonot ovat samoja. Jos arvo on negatiivinen, ensimmäinen argumentti on toista "pienempi" eli aakkosissa toisen merkkijonon edellä. Jos arvo on positiivinen, ensimmäinen argumentti on aakkosissa toista myöhempi. Jos halutaan verrata vain tiettyä määrää merkkejä merkkijonojen alkupäästä, käytetään funktiota strncmp.

Näiden merkkijonofunktioiden käyttö nälyy seuraavasta esimerkkiohjelmasta:

    char nimi1[30], nimi2[30], nimi3[10] ;
    int i ;
    ...
    strcpy(nimi1, "huihai") ;
    strcpy(nimi2, nimi1) ;

    strncpy(nimi3, nimi1, 5) ;
    ...
    if((i=strcmp(nimi1, nimi2))==0)
      printf("merkkijonot ovat samoja\n") ;
    else (if i < 0) 
      printf("%s on aakkosissa ennen %s\n", nimi1, nimi2) ;

    if(strncmp(nimi1, nimi3, 2)==0)
      printf("merkkijonojen alut ovat samoja\n") ;

Omat muuttujatyypit

Useita muuttujia voidaan paketoida tietueeksi, jota sitten voidaan käsitellä yhtenä kokonaisuutena. Seuraavassa esimerkissä määritellään tietuetyyppi, jolle annetaan nimeksi kaupunki:

    struct kaupunki 
      { 
        float pituus, leveys ;
        char nimi[20] ; 
      } ;

Tämä määrittelee vasta, millainen tietue on kyseessä, muttei varaa sille lainkaan tilaa. Määrittelyn jälkeen voidaan luoda muuttujia, jotka ovat tyyppiä kaupunki esimerkiksi seuraavilla tavoilla:

    struct kaupunki town,
                    hesa = {25.0, 60.0, "Helsinki" },
                    turku = {22.3, 60.4, "Turku" } ;
    struct kaupunki kaupungit[100] ;

Ensimmäinen näistä varaa tilan muuttujalle, muttei anna sille mitään alkuarvoa, toisessa asetetaan myös tietueen komponenttien alkuarvot luettelemalla ne samassa järjestyksessä kuin tietueen määrittelyssä. Kolmas määrittely varaa taulukon, johon mahtuu sadan kaupungin tiedot.

Tietueen arvot voidaan kopioida toiseen tietueeseen yhdellä sijoituslauseella:

    kaupungit[1] = hesa ;

Tietueen komponentteja voidaan käsitellä erikseen tavallisten muuttujien tapaan. Niihin viitataan operaattorilla ., jota seuraa komponentin nimi:

    town.pituus = 30.0 ; town.leveys = 63.0 ;
    kaupungit[2].pituus = 20.0 ;
    z = kaupungit[1].leveys ;
    

Tietuetyyppi voi esiintyä myös funktion arvona. Seuraavassa määritellään kompleksilukutyyppi ja funktio add, joka laskee argumentteina annettujen kompleksilukujen summan ja palauttaa sen arvonaan. Tämä arvo voidaan sitten sijoittaa kompleksulukutyyppiseen muuttujaan.

    struct complex { float re, im ; } ;
    struct complex z, u, v = {1.0, 0.0}  ;
    u = {0.5, 1.0} ;
    z = add(u, v) ;
    ...
    
    struct cmplx add (struct cmplx u, struct cmplx v)
    {
      struct cmplx val ;
      val.x = u.x + v.x ;
      val.y = u.y + v.y ;
      return val ;
    }

Joissakin C:n toteutuksissa kompleksityyppi on jo valmiina, jolloin ohjelma voitaisiin kirjoittaa lyhyesti:

    complex z, u, v = 1.0  ;
    u = 0.5 + I ; 
    z = u + v ;

Komentorivin argumentit

Esimerkiksi Unixin komennolle cat voidaan antaa komentorivillä tulostettavan tiedoston nimi. Komento suorittaa itse asiassa C-ohjelman, joka tulkitsee kutsun perään kirjoitetut argumentit. Samalla tavoin argumentteja voidaan välittää mille tahansa C-ohjelmalle.

Tehdään oma version cat-ohjelmasta. Olkoon se mycat.c:

#include 
FILE *input ;
main(int argc, char **argv)
{ int c ;

  if (argc != 1)
    { fprintf(stderr,"usage: mycat file\n") ; exit(1) ; }

  input = fopen(argv[1]) ;
  if (input == null)
    { fprintf(stderr,"cannot open file %s\n", argv[1]) ; exit(2) ; }

  while ((c=fgetchar(input)) != EOF) putchar(c) ; 

  return 0;
}

Kuten jo on todettu, pääohjelma on samanlainen funktio kuin muutkin, ja sille voidaan välittää argumentteja. Käytännössä sillä voi olla kaksi argumenttia. Ensimmäinen ( argc) on kokonaisluku, joka kertoo argumenttien lukumäärän. Numerointi alkaa nollasta: argumentti 0 on ohjelman nimi, 1 ensimmäinen ohjelmalle välitetty parametri jne. Jos argv on nolla, ohjelmalle ei ole annettu yhtää parametria.

Toinen argumentti näyttää hieman erikoiselta. Se voitaisiin yhtä hyvin kirjoittaa myös muotoon char *argv[]. Kyseessä on joukko osoittimia, jotka osoittavat komentoriville kirjoitettujen merkkijonojen alkuun: argv[1] osoittaa ensimmäisen parametrin alkuun argv[2] toisen parametrin alkuun jne.

Esimerkin tapauksessa ohjelmalle halutaan välittää täsmälleen yhden tiedoston nimi, joten argumenttien määrän on oltava 1. Ellei niin ole, annetaan virheilmoitus ja lopetetaan ohjelman suoritus kutsumalla funktiota exit. Jos Unixin komento toimii normaalista, se palauttaa arvon 0. Nollasta poikkeava arvo tarkoittaa virhetilannetta. Tämän periaatteen mukaisesti ohjelmamme palauttaa ykkösen, jos sitä kutsuttiin väärällä tavalla.

Seuraaavaksi yritetään avata tiedosto, jonka nimi välitettiin argumenttina. Jos tiedoston avaaminen ei onnistu (todennäköisin syy on, ettei tiedostoa ole), ohjelman suoritus lopetetaan virheilmoitukseen ja taaskin palautetaan nollasta poikkeava paluukoodi.

Ohjelma voidaan nyt suorittaa samalla tavoin kuin mikä tahansa Unixin komento:

    ./mycat tiedosto.txt

Kirjastot

Funktion ohjelmakoodi voidaan kirjoittaa samaan tiedostoon pääohjelman kanssa. Se ei kuitenkaan ole välttämätöntä, vaan funktiot voivat olla myös eri tiedostoissa. Ne voidaan myös kääntää erikseen ja tallettaa käännetyt versiot eli funktioiden objektikoodi. Silloin ne vain linkitetään mukaan pääohjelmaan.

Edellä olleen varsin triviaalin esimerkin aliohjelma voitaisiin kirjoittaa omaan tiedostoonsa. Olkoon tiedoston nimi vaikka funktio.c:

  int func (int x)
  {
    return 2*x + 1 ;
  }

Tämä käännetään komennolla

   gcc -c funktio.c

Valitsin -c tarkoittaa, että ohjelma vain käännetään, mutta ei linkitetä. Objektikoodi talletetaan tiedostoon funktio.o.

Pääohjelma voisi olla kuten edelläkin, nyt vain ilman funktiota. Olkoon tämä tiedostossa paaohjelma.c:


  int func (int x) ;

  main()
  {
    int y ;
    y = func(5) ;
    ...
  }

Käännöskomennossa on ilmoitettava, että mukaan linkitetään myös funktion objektitiedosto:

   gcc  funktio.o paaohjelma.c

Tämän jälkeen tuloksena on suoritettava tiedosto a.out.

Edellä esitetty menettely kaipaa vielä parantelua. Vaikka funktio onkin eri tiedostossa, sen prototyypin täytyy olla pääaohjelman tiedostossa. Muuten kääntäjä ei voi tietää, millaine kutsuttava funktio oikein on.

Erillisessä tiedostossa voi kuitenkin olla lukuisia funktioita, joten kaikkien prototyyppien kirjoittaminen näkyviin olisi hankalaa. Siksi onkin parempi koota prototyypit omaan tiedostoonsa. Tehdään tiedosto funktio.h:

  int func (int x) ;

Tiedoston nimen tarkenne .h viittaa juuri tällaisiin tiedostoihin (h=header).

Nyt tämä tiedosto saadaan käyttöön include-direktiivillä seuraavasti:

  #include "funktio.h"
  main()
  {
    int y ;
    y = func(5) ;
    ...
  }

Aikaisemmin on esiintynyt direktiivi

  #include 

Tässä tiedoston nimi on kulmasuluissa, kun se äskeisessä esimerkissä on lainausmerkeissä. Merkinnöillä on tietty ero. Kulmasuluissa olevat nimet viittaavat systeemikirjastoihin, joita haetaan tietyistä ennalta määrätyistä hakemistoista ja jotka tulevat käyttöjärjestelmän mukana. Lainausmerkeissä olevat tiedostot ovat yleensä käyttäjän omia, ja niiden sijaintipaikka täytyy ilmoittaa. Esimerkin tapauksess tiedosto on samassa hakemistossa kuin pääohjelma, mutta lainausmerkkien sisällä voi olla myös mutkikkaampi hakupolku.

Funktiokirjaston merkittävä etu on tehtävän jäsentelyssä. Kirjastsoon voidaan koota yleiskäyttöisiä funktioita, joita useat eri ohjelmat tarvitsevat. Kirjastoa voidaan kehittää muista ohjelmista erillään, kunhan vain funktioiden kutsut ja vaikutukset määritellään tarkasti. Kirjastoihin voidaan myös siirtää jo testattuja ja toimivia funktioita, joihin ei ole tarvetta kajota.

Make

Jos ohjelmaan linkitetään useita kirjastoja, käännöskomento voi olla mutkikas. Se voidaan kirjoittaa komentotiedostoon, mutta parempi työkalu on make-komento.

Oletetaan, että pääohjelma on tiedostossa testiohj.c:

  #include 
  #include "funktio.h"
  main()
  {
    int y ;
    y = func(5) ;
    printf("%d\n",y) ;
  }

Funktion määrittely on tiedostossa funktio.c:

  int func (int x)
  {
    return 2*x + 1 ;
  }

Tiedostossa funktio.h on funktion prototyyppi:

  int func (int x) ;

Tehdään tiedosto, jonka nimi on makefile:

#
#  make-tiedoston esimerkki
#  paaohjelma tiedostossa testiohj.c, 
#  mukaan linkitetaan funktio tiedostosta funktio.c
#
all: funktio.o testiohj

funktio.o: funktio.c funktio.h
	gcc -c funktio.c

testiohj: testiohj.c\
        funktio.o
	gcc -o testiohj testiohj.c funktio.o

clean: 
	rm testiohj funktio.o

Makefile-tiedostossa määritellään, miten tiedostot riippuvat toisistaan. Esimerkiksi funktion objektikoodi funktio.o riippuu tiedostoista funktio.c ja funktio.h. Jos näitä muutetaan, objektitiedosto on luotava kääntämällä lähdekoodi funktio.c. Make-tiedostossa annetaan ohje (gcc-komento), miten tämä tapahtuu.

Käännös tapahtuu nyt komennolla

  make

Komento tutkii tiedostojen aikamääreitä. Makefilen komennoista suoritetaan vain ne, jotka ovat tarpeen muuttuneiden tiedostojen vuoksi.

Komennolla

   make clean

voidaan siivota binääritiedostot.

Virhetilanteet

Kappaleessa ??.3 mainittiin jo ikuinen silmukka, jonka toiminta jatkuu, kunnes se katkaistaan väkivalloin. Jos ohjelma suoltaa tavaraa ruudulle, se voidaan pysäyttää Ctrl-C -näppäimellä.

Ellei tämä onnistu, ohjelma voidaan aina tappaa Unixin kill-komennolla:

  • Mennään johonkin toiseen ikkunaan, joka toimii normaalisti.
  • Komennolla ps etsitään tapettavan ohjelman prosessin numero.
  • Ohjelma tapetaan komennolla kill -9 n, missä n on prosessin numero.

Ohjelma voi myös tehdä jonkin virheellisen toiminnon, joka aiheuttaa sen keskeytymisen.

Joillakin laitteistoilla ohjelma voi keskeytyä aritmeettiseen virheeseen, kuten nollalla jakamiseen tai yritykseen laskea negatiivisen luvun logaritmia. Myös yritys sijoittaa muuttujalle niin iso arvo, ettei se mahdu muuttujalle varattuun tilaan, voi keskeyttää ohjelman. PC:ssä näin ei yleensä käy, koska niissä käytetään laajennettua aritmetiikkaa, jossa esimerkiksi nollalla jaon tulos on ääretön (inf) ja negatiivisen luvun logaritmi epäkelpo luku (nan).

Varsin tavallisia ja usein hankalasti selvitettäviä ovat virheelliset muistiviittaukset. Taulukkojen indeksejä ei normaalisti tarkisteta, joten viittaus taulukkoon voi kohdistua myös taulukon ulkopuolelle. Kyseessä on tehokkuus: tarkitus edellyttäisi, että jokaisen taulukkoviittauksen yhteyteen lisätään koodia, joka tarkistaa indeksin olevan sallituissa rajoissa.

Pahimmassa tapauksessa ohjelma jatkaa toimintaansa, mutta tulokset ovat tietysti mitä sattuu. Viittaus saattaa aiheuttaa myös virhetilanteen, joka kaataa ohjelman yleensä legendaariseen ilmoitukseen "segmentation fault".

Strukturoitu ohjelmointi

Miten hallita laajoja ohjelmointitehtäviä?

Jaetaan tehtävä pienempiin osiin (aliohjelmiin).

Top down -menetelmä

  1. Määrittele tarkasti, mitä tietoja ohjelma saa ja mitä sen täytyy niille tehdä.
  2. Jos tehtävä on riittävän yksinkertainen, kirjoita sen suorittava ohjelmakoodi.
  3. Muuten jaa tehtävä pienempiin osiin ja määrittele tarkasti kunkin osan tehtävä ja liitäntä muuhun ohjelmaan.
  4. Suorita vaiheet 1-4 erikseen kullekin osatehtävälle.

Bottom up -menetelmä

Työkalupakkauksen idea. Ohjelma jaetaan tasoihin, joista kukin toteutetaan alempien tasojen työkalujen avulla. (vrt. Unix)

Usein käytetään molempia yhdessä: Ylimmän tason logiikka jäsennellään top-down-menetelmällä, alemmilla tasoilla käytetään olemassaolevia kirjastoaliohjelmia.

Haja-ajatuksia ohjelmointityylistä

Tietyn tehtävän suorittava ohjelma voidaan toteuttaa hyvin monella tavalla. Onko jokin ratkaisu parempi kuin toinen? Mitään ehdottomia kriteereitä ei ole, vaan ainakin osittain ne riippuvat ohjelman käyttötarkoituksesta.

Käyttäjän kannalta tärkeää on ohjelman toimivuus ja raskaissa laskennallisissa tehtävissä myös tehokkuus. Ohjelmoijan ja ohjelman ylläpidon kannalta tärkeää on ohjelman selkeys ja ymmärrettävyys. Ohjelmointityyli on kuitenkin abstraktimpi ja vaikeammin määriteltävä asia kuin toimivuus tai tehokkuus.

Ohjelman on parempi tehdä yksi asia kunnolla kuin jotenkuten vähän sitä sun tätä. Ennen ohjelmointia on määriteltävä täsmällisesti, mitä ohjelmalta vaaditaan.

Käytetystä tekniikasta riippumatta strukturoitu ohjelmointi pyrkii ohjelman pilkkomiseen pieniin, helposti hallittaviin ja riittävän yksinkertaisiin moduuleihin.

Toimivuus
Ohjelman käyttäjän kannalta tärkeintä on, että ohjelma toimii oikein ja vaatimusten mukaisesti. Toimivuuteen voi katsoa kuuluvaksi myös ohjelman käytettävyyden.

  • Ohjelman on toimittava oikein kaikilla sallituilla syöttöaineistoilla.
  • Ohjelman on ilmoitettava virheellisistä syöttötiedoista ja varoitettava arvoista, jotka ovat luultavasti järjettömiä.
  • Jokaiseen ohjelmaan liittyy erilaisia rajoituksia. Jos ohjelmaa yritetään käyttää tilanteessa, johon se ei sovellu, ohjelman on varoitettava käyttäjää.
  • Tarkistusten on oltava niin kattavia, ettei ohjelma kaadu millään syöttöaineistolla, vaan päättyy hallitusti.
  • Käytön on oltava mahdollisimman helppoa ja havainnollista.
  • Ohjelman ei pidä vaatia sellaisia tietoja, jotka se voi itse laskea tai ottaa muuten selville.
  • Syöttötietojen tulee olla käyttäjän kannalta luontevassa muodossa.

Tehokkuus
Jos ohjelma on tarkoitettu esimerkiksi suurten numeeristen tehtävien ratkaisemiseen tai pitkiin simulointeihin, sen on oltava ennen kaikkea tehokas.

Ohjelmointityylistä voi joskus joutua tinkimään tehokkuuden kustannuksella, mutta yleensä selkeästi kirjoitettu ohjelma toimii myös tehokkaasti. Oikean ratkaisumenetelmän löytämisellä on suurempi merkitys kuin olemassaoleven koodin viilaamisella ja jippoilulla.

Tehokkuutta käsitellään enemmän numeriikan kurssilla.

Aliohjelmat
Aliohjelman pitäisi olla aina ymmärrettävissä kerralla yhtenä kokonaisuutena. Mitä monimutkaisempia kontrollirakenteita aliohjelma sisältää, sitä lyhyempi sen on oltava.

Kukin aliohjelma tekee yhden täsmällisesti määritellyn toimenpiteen. Tämä ei tarkoita, että toiminnon pitäisi olla yksinkertainen, pääasia on että se voidaan määritellä yksinkertaisesti, esim. "lajittele tiedosto", "etsi osittaisdifferentiaaliyhtälön ratkaisu, joka toteuttaa annetut reunaehdot", "todista Goldbachin konjektuuri". Samaan aliohjelmaan ei pidä kasata sekalaisia toimenpiteitä, jotka eivät kuulu loogisesti yhteen.

Aliohjelmien tulee olla toisistaan mahdollisimman riippumattomia. Globaalien muuttujien avulla tulisi välittää mahdollisimman vähän tietoa. Jos aliohjelmalla on hyvin paljon parametreja, jotakin voi olla vialla.

Kontrollirakenteet
C-kielessä on paljon keinoja esittää asiat hyvin lyhyesti. Ohjelman lyhyys ei ole itsetarkoitus, vaan johtaa usein vaikeaselkoiseen koodiin.

Tärkeintä on kirjoittaa ohjelma niin, että sen toiminta on helposti ymmärrettävissä.

Kommentit
Kommenttien avulla voit helpottaa niiden henkilöiden työtä, jotka joskus joutuvat muuttamaan tai tutkimaan ohjelmaasi. Tähän joukkoon kuulut myös itse.

Jokaisen ohjelmatiedoston alussa pitäisi olla selostus siitä, mihin ohjelmaa tai tässä tiedostossa olevaa ohjelmanosaa käytetään. Ohjelmaa kehitellessään tulee helposti tehneeksi tiedostoja, joilla on sellaisia havainnollisia nimiä kuin a, test, koe3, prog tai xx. Muutamaa päivää myöhemmin ei enää itsekään muista, mitä mikin niistä tekee.

Mikäli ohjelmaan on linkitettävä muissakin tiedostoissa olevia moduleita, alkukommenteissa olisi hyvä kertoa myös käännös- ja linkitysohjeet. Hyödyksi on myös tieto, mitä syöttö- ja tulostustiedostoja ohjelma mahdollisesti tarvitsee.

Aliohjelman alussa tulisi olla lyhyt selostus aliohjelman toiminnasta, sen parametreista, mahdollisista globaaleista muuttujista, sivuvaikutuksista ja funktion tapauksessa sen palauttamasta arvosta.

Syöttö ja tulostus
Ohjelman ei pidä vaatia käyttäjältä turhia tietoja, ja sen tulisi ymmärtää hyvin vapaamuotoista syöttötietoa. Tulostuksen on oltava niin itsensä selittävää, ettei sen tulkintaan tarvita monimutkaisia käyttöohjeita.

Testaa syöttöarvojen järkevyys. Identifioi järjettömät syöttöarvot, ja toivu tilanteesta mikäli mahdollista. Testaa erityisesti (odottamattomat) tiedostojen loput.

Tee syöttö mahdollisimman yksinkertaiseksi ja helpoksi. Vapaamuotoinen syöttö on järkevää pieniä kontrolliarvoja varten, vain suuret data-aineistot kannattaa lukea määrämuotoisena. Tulostus sen sijaan kannattaa useimmiten tehdä määrämuotoisesti.

Sekalaista
Eristä laiteriippuvat osat erillisiksi aliohjelmiksi

Testaa rajatapaukset. Varo taulukkojen indeksien ylityksiä, ja laskureiden "yhdellä pielessä" tilanteita. Tarkkaile muuttujien arvojen erikoistapauksia, ja muita erikoistilanteita, jotka usein ovat virheiden syinä.

Alusta kaikki muuttujat, älä luota, että ne ovat nollia automaattisesti.

Varo pyöristysvirheitä: 10.0 * 0.1 on tuskin koskaan 1.0! Älä vertaa liukulukujen yhtäsuuruutta.