C koodausohjeita

Mureakuha

Loikkaa: valikkoon, hakuun

Sisällysluettelo

Ohjelmankehityssilmukka

Ohjelmaa kirjoitetaan yleensä silmukassa: kirjoita tai muuta koodia, käännä se, kokeile ohjelmaa. Tämä silmukka on yleensä syytä pitää melko lyhyenä. On harvoin järkevää kirjoittaa suuria määriä koodia kerrallaan, vaan on yleensä parempi kirjoittaa ja kokeilla pieniä ohjelman osia. Mikäli ohjelmaa muutetaan monesta kohtaa ja kokeillaan ohjelmaa vasta kaikkien muutosten jälkeen, eikä ohjelma toimi, on hankala selvittää, mikä muutos rikkoi ohjelman. Jopa syntaksivirheet on helpompi korjata, mikäli niitä on vähän.

Kaikkein pahin tapa on kirjoittaa ohjelma ensin kokonaisuudessaan ja viikkoa ennen kuin ohjelman on oltava valmis, ilmoittaa "ohjelma on valmis, se tarvitsee enää vain kääntää".

Ohjelmointi kokeilun ja erehdyksen kautta on uhkapeliä. Mieti ensin, mitä on tarkoitus tehdä, mikä on paras tapa tehdä se, sitten tee se niin. Korjaa virhe vasta sen jälkeen, kun tiedä täsmälleen, mikä virheen aiheuttaa ja ole aina tietoinen, millä tavalla korjaus korjaa virheen. On erittäin helppoa muutella koodia sattumanvaraisesti ja saada virhe häviämään; virhettä ei kuitenkaan ole korjattu, se on vain piilossa.

Koodin kirjoittamisjärjestys

On yleensä järkevämpi suunnitella ohjelma "ylhäältä alas", eli jakaa kokonaisuus pienempiin, helpommin hallittavissa oleviin osiin (ja nämä osat tarvittaessa edelleen pienempiin osiin). Tämä johtaa yleensä parempilaatuisiin ohjelmiin kuin "alhaalta ylös" tai "sieltä täältä" -suunnittelumenetelmät.

Ohjelmakoodia sen sijaan voi olla järkevä kirjoittaa muutenkin kuin aloittaen pääohjelmasta. Jotta ohjelmankehityssilmukan voisi pitää lyhyenä ja jotta ohjelmaa voisi jo sen kirjoitusaikana testata, se kannattaa kirjoittaa aloittaen itsenäisistä paloista. Tämä voi hyvinkin tarkoittaa esimerkiksi sitä, että ensin toteutetaan ohjelmasta alimman tason osat.

Ohjelmakoodi kannattaa kirjoittaa heti oikein. Huonon version kirjoittaminen aiheuttaa monikertaisesti työtä, koska huonokin versio pitää saattaa edes jotenkuten toimivaksi ja sen jälkeen hyvän version joutuu kuitenkin kirjoittamaan kokonaan uusiksi. Tämä ei kuitenkaan tarkoita, etteikö prototyyppejä pitäisi tehdä ja käyttää, tai useampia eri lähestymistapoja tutkia ja verrata käytännössä, vaan esimerkiksi sitä, että virheenkäsittely koodataan mukaan heti, ei vasta loppuvaiheessa.

Työkalujen oikeasta käytöstä

Ohjelmoijan tärkeimmät työkalut ovat editori ja kääntäjä. Editorin käytöstä ei tässä sen enempää.

Modernit kääntäjät osaavat auttaa ohjelmoijaa monin tavoin. Esimerkiksi ne osaavaat analysoida koodia ja varoittaa monista yleisistä virheistä. Varoitukset on usein erikseen käännettävä päälle. On harvoin järkevää pitää varoituksia pois kytkettynä (pois lukien jotkin varoitukset, jotka ovat hyödyllisiä vain tietyissä tilanteissa). Toisaalta on aina pyrittävä siihen, että kääntäjä ei varoita mistään; varoituksiin on yleensä suhtauduttava kuten virheilmoituksiin.

Erityisesti on C-koodia käännettäessä käytettävä varoituksia prototyypeistä. Kielen määritelmän mukaan on sallittua kutsua funktiota, jolle ei ole annettu prototyyppiä. Käytännössä tämä on miltei aina virhe. Useimmat kääntäjät osaavat varoittaa tälläisestä kutsusta.

Mikäli ohjelma on jaettu useampaan tiedostoon, on myös toivottavaa, että kääntäjä varoittaa sellaisista funktioista, joille ei ole annettu prototyyppiä, kun ne määritellään. Tämä on tärkeää sen takia, että muuten on vaikea tarkistaa, että prototyyppi ja määrittely ovat yhtäpitäviä. Mikäli koko ohjelma on yhdessä tiedostossa, on usein riittävää, että aliohjelmat järjestetään siten, että määrittely tulee ennen kutsua.

Tunnusten valinta

Tunnusten valinta on tärkeää, koska hyvin valitut tunnukset helpottavat ohjelman ymmärtämistä ja huonot vaikeuttavat sitä. Hyvä tunnus on kuvaava, eli tunnus kertoo vakion, muuttujan, tyypin tai aliohjelman tarkoituksen. Moni tunnus pärjää pelkällä hyvällä nimellä, eikä vaadi kommenttia selittämään olemassaolonsa tarkoitusta.

Tunnusten kuvaavuus

Koska tunnuksia käytetään eri tavoin, ei kaikkien tunnusten tarvitse olla yhtä kuvaavia. Esimerkiksi silmukkamuuttujien käyttö on itsestään selvää jokaiselle vähänkään ohjelmointia harrastaneelle, eikä silmukkamuuttujan nimen tarvitse olla kovinkaan kuvaava. Itse asiassa, koska asia kuitenkin on selvä, on pitkä silmukkamuuttujan nimi häiritsevä, koska pitkä nimi on hankala lukea ja kiinnittää turhan paljon huomiota itseensä. Samoin muutkin paikalliset muuttujat lyhyissä, yksinkertaisissa aliohjelmissa on yleensä parempi nimetä lyhyesti, vaikkapa käyttäen yleisesti tunnettuja lyhennyksiä.

etsittyjen_lukumaara = 0;
for (tutkittava = 0; tutkittava < taulukon_koko; ++tutkittava)
    if (taulukko[tutkittava] == etsittava)
        ++etsittyjen_lukumaara;
 
lkm = 0;
for (i = 0; i < taulukon_koko; ++i)
    if (taulukko[i] == etsittava_alkio)
       ++lkm;

Tunnuksia i, j ja k käytetään usein silmukkamuuttujina, laskureina tai muuten apumuuttujina. Tunnukset n ja m ovat yleensä lukumääriä. Monilla sovellusaloilla on vakiintuneita käytäntöjä suureiden nimeämiseksi; näitä on syytä noudattaa.

Kaikkien tunnusten ei kuitenkaan ole hyvä olla lyhyitä. Paikallisten muuttujien lyhyyden eräänä puolusteluna on se, että ne ovat yleensä apumuuttujia (joten niihin ei pidä joutua kiinnittämään kovin paljoa huomiota) ja lisäksi niitä käytetään vain pienessä osassa ohjelmaa (olettaen että aliohjelma on lyhyt, kuten hyvä on). Globaaleja tunnuksia sen sijaan käytetään -- tai ainakin voidaan käyttää -- missä tahansa kohtaa ohjelmassa. Tällöin niiden tulee olla huomattavasti kuvaavampia kuin paikallisten tunnusten, koska tunnuksen määrittely on hankalampi löytää.

Tunnusten yksikäsitteisyys eri moduleissa

Ohjelman voi usein jakaa alijärjestelmiin tai moduleihin. Jotta eri moduleissa olevat nimet eivät menisi sekaisin, on jokaiselle modulille valittava yksikäsitteinen etuliite, joka liitetään ainakin jokaisen modulin ulkopuolelle näkyvän nimen eteen. Esimerkiksi listojan käsittelevän modulin kaikki aliohjelmat nimetään tyyliin list_create, list_next, jne., ei create_list, next_in_list.

Kaikki tunnukset pyritään määrittelemään funktion tai tiedoston sisäisiksi, mikäli niillä ei ole erityistä syytä olla globaaleja.

Tunnusten kirjoitustapa

Tunnuksissa käytetään pääsääntöisesti vain pieniä kirjaimia; sanat erotetaan alaviivalla. Tunnuksissa pyritään välttämään muita kuin ohjelmaprojektin yhteydessä muutenkin käytettäviä lyhenteitä.

Vakioiden nimeämiseen käytettävät makrot kirjoitetaan kokonaan suurilla kirjaimilla. Funktionomaiset makrot kirjoitetaan kokonaan pienillä kirjaimilla. Makrojen käyttöä muihin tarkoituksiin on vältettävä.

Tietotyypit (typedef) voi nimetä isolla alkukirjaimella.

Kommentointi

Kommentoinnin tarkoituksena on kertoa sellaista, mitä koodista itsestään ei näy. Kommentoinnin tarkoituksena ei siis ole toistaa sitä, mitä koodi tekee.

Kommentit on syytä kirjoittaa käyttäen hyvää kieltä: niin kirjoitusasultaan kuin kieliopiltaan ja kirjoitustyyliltään kommenttien tulee olla korkeatasoisia. Kommentit saavat olla lyhyitä ja muistuttaa toisiaan. Mikäli kaksi aliohjelmaa tekee samantapaisia asioita, on jopa hyvä, että niiden kuvauksetkin ovat samantapaisia. Sen sijaan ei ole järkevää pelkästään viitata toisen aliohjelman kommenttiin, koska se vaatii lukijalta selaamista ja ohjelman muuttajalta tietoa siitä, että toisen aliohjelman kommentti itse asiassa kuvaakin kahta erillistä aliohjelmaa.

Kommenttien kirjoittamisessa on erityisesti vältettävä passiivia. Älä siis kirjoita "lajitellaan taulukko päivämäärän mukaan", vaan mieluummin "lajittele taulukko päivämäärän mukaan" (imperatiivi), tai vieläkin mieluummin "aliohjelma lajittelee taulukon päivämäärän mukaan" (kuvaus). Sen sijaan tieteellisissä teksteissä usein käytettävä kertomuksellinen muoto "lajittelemme taulukon päivämäärän mukaan" on kelvollinen.

Mikäli jokin asia voidaan kertoa koodina, jonka kääntäjä tarkistaa tai joka tarkistaa asioita ajon aikana, ei ole syytä kertoa asiaa (pelkästään) kommenttina. Automaattiset tarkistukset auttavat löytämään virheitä automaattisesti; kommentit auttavat korkeintaan silloin, jos joku ne lukee. C-kielessä on valmiita työkaluja, esikääntäjä ja assert, tarkistusten lisäämiseksi koodiin; monissa nykyaikaisissa kielissä on samantapaisia tai kehittyneempiä työkaluja. Kaikissa kielissä on kuitenkin mahdollista kirjoittaa tarkistuksen tekevä koodi suoraan muun koodin sekaan.

Kommentteja on pidettävä ajan tasalla. Mikäli koodia muutetaan, on aina samalla päivitettävä vastaava kommentti. Kommentteja ei kuitenkaan saa kirjoittaa vasta jälkikäteen, kun ohjelma muuten on valmis, koska tällöin kaikki ohjelman luomisvaiheen perustelut ovat kaikonneet muistista. Kaiken kukkuraksi, mikäli kommentit kirjoittaa ensin, ohjelman rakennetta tulee ajatelleeksi tarkemmin, joten ohjelman laatukin on usein parempi.

Kommenteissa käytetään jotakin kieltä, jota lukijakunta ymmärtää. Kaikissa kommenteissa käytetään samaa kieltä. Kommenttien kielen ja tunnusten kielen on hyvä, mutta ei välttämätöntä, olla sama.

Aliohjelmien kommentointi

Ennen jokaista aliohjelmaa on kirjoitettava kommentti, joka kertoo mitä funktio tekee, miten sitä kutsutaan, sen kytkennät, sekä sen olettamat ja tekemät tilanvaraukset (kuka tekee ja kuka vapauttaa). Sen sijaan toiminnasta ei tarvitse kertoa, mikäli algoritmi ja koodi ovat yksinkertaisia.

Funktioiden palauttama arvo sekä sen laskemistapa on kerrottava.

Jokaisesta parametrista on kerrottava sen tarkoitus ja luonne (in, out, inout), sekä mitä aliohjelma olettaa parametrista kutsuttaessa (eli minkälaisia arvoja aliohjelma olettaa saavansa) ja mikä on parametrin arvo kutsun jälkeen. Tyyppi selviää ohjelmakoodista, mutta kaikenlaiset rajoitukset on selvitettävä, esimerkiksi mikäli kokonaislukutyyppiselle muuttujalle kelpaavat vain positiiviset arvot.

Jokaisesta aliohjelman käyttämästä globaalista muuttujasta, tiedostosta ja muusta aliohjelman ulkopuolisesta oliosta on kerrottava vastaavat asiat kuin parametreista.

Aliohjelman kommentin tarkoituksena on siis selostaa tarkasti, miten aliohjelmaa käytetään. Tarkoituksena on, että pelkästään aliohjelmaa edeltävän kommentin ja aliohjelman otsikon perusteella aliohjelmaa pystyy käyttämään muutkin, kuin sen alkuperäinen tekijä.

On usein järkevää kirjoittaa aliohjelman otsikko ja kommentti ensin, ennen lauseosaa. Kommenttia kirjoitettaessa joutuu miettimään tarkkaan, miten aliohjelman pitäisi eri tilanteissa käyttäytyä, joten koodin kirjoittaminen jälkeenpäin on helpompaa.

Alkukommentin voi muotoilla haluamallaan tavalla. Eräs hyvä tapa on esitelty alla. Sen ideana on tehdä kommentista visuaalisesti hyvin erottuva (vasemman laidan tähtirivi), sekä jäsentää kaikki aliohjelmakommentit samalla tavalla ja samalla varmistaa, että kaikki tarpeellinen tieto tulee mukaan. Visuaalista erottuvuutta voi lisätä vielä piirtämällä tähtimerkeillä laatikko koko kommentin ympärille, mutta tämä tekee kommentin muokkaamisesta hankalaa.

/*
 * Purpose: Greet the user.
 * Arguments: `username' is the username of the user.
 * Description: greet_user will output to the standard output
 *      a greeting to the user named in the `username'
 *      argument.  It will make sure that the greeting has
 *      actually been output to the terminal by flushing all
 *      buffers (in stdio and the kernel), and will check for
 *      errors.
 * Return: -1 for error, 0 for OK.
 */
int greet_user(const char *username) {
        ...
}

Aliohjelman sisältöä ei yleensä tarvitse kommentoida, mikäli aliohjelma on melko lyhyt ja yksinkertainen, eikä koodissa ole snobbailtu kielen yksityiskohtien hallinnalla. (Tämän voikin asettaa tavoitteekseen koodia kirjoittaessaan.)

Loppusulkuja ei tarvitse kommentoida. Koodin rakenne osoitetaan järjestelmällisellä sisennyksellä. Mikäli alkusulku ei näy samalla sivulla, aliohjelma on liian pitkä ja monimutkainen ja se on strukturoitava uudelleen. (Tähän on joitakin poikkeuksia, mutta harvemmin kuin luulisi.)

Globaalit muuttujat, vakiot ja tietotyypit

Globaalit muuttujat, vakiot ja tietotyypit kommentoidaan vastaavasti kuin aliohjelmat. On usein järkevää ryhmitellä useampi muuttuja, vakio ja tietotyyppi yhteen, esimerkiksi seuraavalla tavalla.

/*
 * Purpose: The list of all possible items in the storage.
 * Description: The items in the storage are identified by
 *      a key (field `key' below).  Each record holds the
 *      number of items left in the storage, and the unit
 *      price of each (in cents).
 */
 
struct item {
        int key;
        int count;      /* number of articles, >= 0 */
        double price;   /* price in cents, >= 0 */
};
 
#define MAX_ITEMS 32
struct item item_table[MAX_ITEMS];

Muuttujien sallittu arvoalue ja yksikkö on kerrottava.

Sisennykset

Koodin rakenne ilmaistaan visuaalisesti sisennyksillä. Sisäkkäiset ohjausrakenteet sisennetään, jolloin on helppo nähdä, mikä koodin osa ohjaa mitä muita osia. Ei siis näin:

void kanna_merkkijono(char *s) {
size_t i, j;
char c;
 
for (i = 0, j = strlen(s); i+1 < j; ++i, --j) {
c = s[i];
s[i] = s[j];
s[j] = c;
}
}

vaan näin:

void kanna_merkkijono(char *s) {
    size_t i, j;
    char c;
 
    for (i = 0, j = strlen(s); i+1 < j; ++i, --j) {
        c = s[i];
        s[i] = s[j];
        s[j] = c;
    }
}

Sisennykset voi tehdä välilyönneillä ja/tai tabulaattorimerkeillä. Tabulaattoriväli on yleensä 8, mutta sen voi yleensä säätää haluamakseen (tabulaattorivälin muuttaminen on kuitenkin harvoin järkevää). On kuitenkin muistettava, että tällöin kaikkien koodin lukijoiden on myös säädettävä tabulaattorivälinsä samaksi. Tabulaattoriväli ja sisennyksen suuruus eivät välttämättä ole sama asia, monissa editoreissa ne voi säätää erikseen.

Sisennyksen suurus on yleensä 2--8 merkkiä. Suuruudella ei ole kovin suurta väliä, kunhan se on sama koko ohjelmassa.

Muuta asettelusta

Koodirivit saavat olla korkeintaa 78 merkkiä pitkiä (kun käytetään kahdeksan sarakkeen sarkaimia).

Aliohjelman muu ulkonäkö (välilyöntien ja tyhjien rivien käyttö, sulkujen asettelu, jne) selvinnee seuraavasta esimerkistä. Muitakin tapoja on ja niitä saa noudattaa, mutta on tärkeää noudattaa samaa tapaa kaikkialla yhden projektin ohjelmakoodissa.

int foo(int bar)
{
    int i;
    
    assert(bar != 0);
    if (bar < 0) {
        for (i = 0; i > bar; --i) {
            while (globaali_muuttuja < 0) {
                globaali_muuttuja += i;
                printf("%d\n", i);
            }
        }
    } else {
        do {
            --bar;
            switch (bar % 3) {
            case 0:
                printf("0\n");
                break;
            case 1:
                printf("1\n");
                /*FALLTHROUGH*/
            case 2:
                printf("2\n");
                break;
            default:
                assert(0);      /* eeek! */
                abort();        /* die! */
            }
        } while (bar > 0);
    }
    return bar;
}

Ehtofunktiot

Ohjelmissa on usein monimutkaisia ehtoja. Nämä voi usein kirjoittaa omiksi funktioikseen, vaikka niitä käytettäisiinkin vain kerran. Ehdon käyttökohta on tällöin lyhyt ja ytimekäs ja ehtokin on usein helpompi kirjoittaa selkeästi. Lisäksi, kun miettii miten välittää kaikki ehtofunktiolle välitettävä tieto, ohjelman rakennekin voi selkiintyä.

Esimerkki: on käytettävissä tietue, jonka kenttiin on talletettu päivämäärän eri osat (vuosi, kuukausi, päivä). Tehtävänä on tulostaa kahdesta päivämäärästä aikaisempi, kun päivämäärät on talletettu muuttujiin pvm1 ja pvm2.

if (pvm1.vv < pvm2.vv ||
    (pvm1.vv == pvm2.vv &&
     (pvm1.kk < pvm2.kk ||
      (pvm1.kk == pvm2.kk && pvm1.pp < pvm2.pp))))
    printf("%d.%d.%d\n", pvm1.pp, pvm1.kk, pvm1.vv)
else
    printf("%d.%d.%d\n", pvm2.pp, pvm2.kk, pvm2.vv);

Tuota ehtoa ei hevin voi sanoa selkeäksi! Jos ehdon kirjoittaa ehtofunktioksi, ratkaisu on huomattavasti selkeämpi.

int pvm_on_aikaisempi(Paivamaara pvm1, Paivamaara pvm2) {
    if (pvm1.vv < pvm2.vv)
        return 1;
    else if (pvm1.vv > pvm2.vv)
        return 0;
    else if (pvm1.kk < pvm2.kk)
        return 1;
    else if (pvm1.kk > pvm2.kk)
        return 0;
    else
        return pvm1.pp < pvm2.pp;
}
...
if (pvm_on_aikaisempi(pvm1, pvm2))
    printf("%d.%d.%d\n", pvm1.pp, pvm1.kk, pvm1.vv)
else
    printf("%d.%d.%d\n", pvm2.pp, pvm2.kk, pvm2.vv);

Itse ehtofunktio on hieman pidempi, mutta silti selkeämpi. Ehdon käyttökohta on huomattavasti selkeämpi. Huomaa myös, että ehtofunktio on kirjoitettu siten, että sitä on helppo käyttää muissakin yhteyksissä ja että sen nimi on valittu siten, että se kuullostaa ehdolta.

Testaamisesta ja luotettavuudesta

Testaaminen on oleellinen osa ohjelman kehitysprosessia. Ohjelmaa on testattava koko sen kehityksen ajan. Suunnitteluvaiheessa ohjelmaa testataan "päässä"; koodausvaiheessa ohjelmaa kokeillaan edes vähän sitä mukaan kuin koodia saadaan kirjoitetuksi. Testaus ei siis ole kokonaan erillinen vaihe, vaikka koodauksen valmistuttua voidaankin vielä pitää erillinen perusteellisempi testausvaihekin. Testauksesta ei tässä yhteydessä sen enempää.

Koodin luotettavuudella tarkoitetaan sen virhetilanteiden huomaamista ja käsittelyä. Hyvä ohjelma huomaa kaikki virhetilanteet ja käsittelee kunkin sille sopivalla tavalla, kuitenkin aina niin, että käyttäjän data ja työ eivät häviä. Kääntäen: ohjelma joka ei toimi näin on huono.

Virhetilanteiden huomaamiseksi on jokainen käsky tai operaatio, joka palauttaa jonkinlaisen virhekoodin, tarkistettava. Ne käskyt, jotka eivät palauta mitään virhekoodia joko eivät voi epäonnistua tai niitä ei voi käyttää hyvässä ohjelmassa. (Esimerkiksi ohjelmointikieli, joka ei salli ohjelman selviytyä tulostusoperaation yhteydessä tapahtuvasta virheestä, ei mahdollista hyvän ohjelman kirjoittamista ollenkaan, pois lukien sellaiset ohjelmat, jotka eivät tulosta mitään.) Joissakin kielissä virhetilanteiden tarkistaminen on toteutettu toisin, esimerkiksi poikkeuksin (englanniksi exception), mutta sama periaate pätee.

Kun virhe on huomattu, se pitää käsitellä. Vain harvat virheet voi jättää huomiotta (tälläisten virheiden käsittely on kommentoitava, jotta lukija ei luulisi virheen jääneen vahingossa huomiotta). Virheen oikea käsittelytapa vaihtelee ohjelmasta toiseen; yhdessä ohjelmassa voi riittää virheilmoituksen tulostaminen näytölle ja ohjelman suorituksen katkaisu, toisessa virheilmoitus pitää kirjoittaa lokitiedostoon ja ohjelman pitää jatkaa suoritustaan kadottamatta tai vääristämästä tietoja. Virheiden huomaaminen ja käsittely ei yleensä ole helppoa, mutta se on välttämätöntä ja jotta se toimisi hyvin, se pitää suunnitella mukaan ohjelmaan alusta asti.

assert ja asen käyttö

Ohjelman jokaisessa kohdassa on tiettyjä ehtoja, joiden oletetaan olevan voimassa. Jos ne eivät ole, ohjelmassa on virhe, yleensä pahanlaatuinen. Nämä ehdot voi C-kielessä helposti kirjoittaa koodin sekaan assert-makrolla ja tarkistaa ne automaattisesti. Jos esimerkiksi jonkin aliohjelman osoitinparametri ei saa olla null-osoitin, se voidaan tarkistaa helposti assert-makrolla:

#include <assert.h>
...
int something(char *s) {
        assert(s != NULL);
 
        /* loppuosa funktioita voi olettaa, että s ei ole NULL */
}

Mikäli parametrina annettu ehto ei ole voimassa, assert keskeyttää ohjelman ja tulostaa ongelmallisen ehdon, sekä lähdekooditiedoston nimen ja rivinumeron (nämä helpottavat vian etsintää).

Monimutkaiset ehdot kannattaa esittää useamman assert:n avulla, mikäli mahdollista. Ei siis näin:

assert(s != NULL && *s != '\0');

vaan näin:

assert(s != NULL);
assert(*s != '\0');

Näin assert:n tulostamasta virheilmoituksesta näkee heti, mikä osaehto ei toteutunut.

Silmukoilla on ns. silmukkaehto (englanniksi loop invariant), jonka on oltava voimassa silmukan lauseosan alussa ja lopussa. Esimerkiksi binäärihaussa silmukkaehto voisi olla:

while (left < right) {
    assert(table[left] <= x);
    assert(x <= table[right]);
 
    mid = (left + right) / 2;
    if (table[mid] == x)
        left = right = mid;
    else if (table[mid] < x)
        left = mid + 1;
    else
        right = mid - 1;
}
assert(left > right || table[left] == x);

Tietorakenteille on hyvä kirjoittaa tarkistusfunktio, joka tarkistaa edes yleisimmät tietorakenteen virhetilanteet (esimerkiksi syklinen lista tai puu). Tälläistä funktiota on hyvä käyttää assert:n kautta tietorakennetta käyttävän funktion alussa (ja ehkä lopussa). assert:lla ei saa ikinä tarkistaa muunlaisia ehtoja, vain ohjelman oikeellisuuden kannalta tärkeitä ehtoja. Esimerkiksi syötteiden oikeellisuutta ei saa tarkastaa assert:lla. Koodin korvaamisesta tietorakenteella

Pitkän, tautologisen koodin voi usein korvata sopivalla tietorakenteella ja lyhyemmällä koodilla, joka tulkitsee tietorakennetta. Tyypillinen esimerkki on komentotulkin komentoja tunnistava osa. Suoraviivainen tapa on tehdä se seuraavasti:

if (strcmp(line, "help") == 0)
    help();
else if (strcmp(line, "exit") == 0)
    do_exit();
else if (strcmp(line, "north") == 0 || strcmp(line, "n") == 0)
    go_north();
...
else
    unknown_command();

Tätä koodia ei kuitenkaan ole kovin miellyttävää muokata tai lukea. Siitä saa paljon helpomman seuraavalla tavalla:

struct {
    char *name;
    void (*func)(void);
} commands[] = {
    { "help", help },
    { "exit", do_exit },
    { "north", go_north },
    { "n", go_north },
    ...
    { NULL },
};
int i;
 
for (i = 0; commands[i].name != NULL; ++i) {
    if (strcmp(line, commands[i].name) == 0) {
        commands[i].func();
        break;
    }
}
if (commands[i].name == NULL)
    unknown_command();

Funktioiden rajapinnoista

Funktioiden on hyvä olla mahdollisimman löyhästi kytkettyjä muuhun ohjelmaan, eli mahdollisimman itsenäisiä. Itsenäisyys tarkoittaa tässä sitä, että muuta ohjelmaa voi muuttaa ilman, että funktiota tarvitsee muuttaa ja päinvastoin.

Itsenäisyyteen vaikuttavat esimerkiksi seuraavat tekijät:

  • Globaalien muuttujien käyttö.
  • Itse määriteltyjen tyyppien käyttö.
  • ...

Itsenäistä funktiota on usein helpompi käyttää uudestaan toisessa ohjelmassa.

Rajapinnan on myös hyvä olla yksinkertainen. Näin kutsujan on helpompi käyttää sitä.

Usein kannattaa yrittää määritellä funktion tehtävä siten, että funktio ei voi epäonnistua. Näin kutsujan ei tarvitse tarkistaa virhettä.

Mikäli funktio voi epäonnistua, virhekoodi palautetaan funktion arvona ja kaikki muu tieto parametrien kautta. Yleensä on hyvä pyrkiä noudattamaan samantapaista paluukoodia; esimerkiksi kirjoittaja käyttää yleensä tapaa ``-1 on virhe, >=0 on onnistunut" (0 voi esimerkiksi olla syötteen loppu, >0 datan luvun onnistuminen). Paluukoodiin ei ole hyvä sekoittaa muuta tietoa, esimerkiksi luetun datan määrää, koska tällöin toisaalta paluukoodin merkitys hämärtyy, toisaalta ongelmaksi voi tulla paluukoodin arvoalueen riittämättömyys. Esimerkiksi UNIXin read-systeemikutsu palauttaa -1 virhetilanteessa, 0 tiedoston lopussa ja >0 luetun tiedon määränä. Tällöin on toisaalta virhetilanteessa mahdoton tietää, kuinka paljon jo ehdittiin lukea, toisaalta luetun tiedon määrä on korkeintaan puolet kokonaisluvun arvoalueesta, mikä ei esimerkiksi 16-bittisissä järjestelmissä ole ollenkaan tarpeeksi.

Tilanvaraus

C-kielessä eräs rajapintasuunnittelun ongelma on tilanvaraus. Vaihtoehtoja on lueteltu alla.

Staattinen muuttuja funktion sisällä

char *neliomj(int n) {
    static char str[100];
    sprintf(str, "%d", n*n);
    return str;
}

Tässä ongelmana on se, että merkkijono muuttuu, kun funktiota kutsutaan seuraavan kerran. Esimerkiksi seuraava kutsutapa tuottaa ongelmia:

printf("eka: %s\ntoka:%s\n", neliomj(1), neliomj(2));

(Tässä ei edes ole mitään takeita siitä, tulostuuko ``1 1" vai ``4 4"!)

Globaali muuttuja

char str[100];
...
void neliomj(int n) {
    sprintf(str, "%d", n*n);
}

Tässä ei siis kannata edes palauttaa mitään, koska kutsuja voi yhtä hyvin käyttää globaalia muuttujaa suoraan. Ainoa ero tämän ja funktion sisäisen staattisen muuttujan kanssa onkin, että kutsuja voi käyttää muuttujan nimeä.

Dynaamisesti varattu muistitila

char *neliomj(int n) {
    char *p;
 
    p = malloc(100);
    if (p == NULL)
            retu
    sprintf(p, "%d", n*n);
    return p;
}

Tässä ongelma on se, että kutsu voi epäonnistua. Kutsujan täytyy siis aina tarkistaa, että kutsu onnistui. Lisäksi kutsujan täytyy muistaa vapauttaa muistitila, muuten ohjelma hukkaa muistia.

Kutsuja varaa tilan ja välittää osoittimen parametrina

void neliomj(int n, char *buf, int max) {
    int used;
 
    used = sprintf(buf, "%d", n*n);
    assert(used < max);
}

Tässä kutsuja on varannut muistitilan valmiiksi ja funktion itsensä tarvitsee vain huolehtia siitä, ettei se ylitä varattua muistitilaa. (sprintf:n tapauksessa ei ole mahdollista rajata muistitilan käyttöä; tässä sen takia varmistetaan assert:lla, ettei sprintf ylittänyt rajoja.)

Tämä on usein miellyttävin niin funktion kirjoittajalle kuin sen kutsujalle. Mikään tapa ei ole kovin miellyttävä, koska joka tapauksessa ainakin kutsuja joutuu tekemään jonkin verran töitä vain saadakseen yhden vaivaisen funktiokutsun hoidettua. Tässä tavassa on kuitenkin se toivo, että jos useampi funktio käyttää samaa tapaa ja niitä käytetään saman merkkijonon muokkaamiseen, ei kutsujan tarvitse huolehtia tilan varauksesta ja siihen mahdollisesti liittyvästä virheentarkistuksesta kuin kerran.

Tehokkuudesta ja optimoinnista

Ohjelman tehokkuus lähtee algoritmista. Huono algoritmivalinta johtaa tehottomaan ohjelmaan ja kääntäen tehotonta ohjelmaa voi parhaiten optimoida vaihtamalla parempaan algoritmiin.

Optimointia ei pidä tehdä säkki päässä, vaan ohjelman käyttäytymistä on mitattava ennen ja jälkeen optimointiyrityksiä. UNIXissa tähän on käytettävissä työkalut gprof ja time.


Lars Wirzenius, liw(at)iki.fi

Henkilökohtaiset työkalut