Matalan tason ohjelmointi

Beta

7. Ohjelmistokehitys

C-kielinen ohjelma muodostuu yleensä useasta kooditiedostosta, joissa on viittauksia toisiinsa. Ohjelman käännösprosessissa jokainen kooditiedosto käännetään ensin erikseen objektitiedostoksi, minkä jälkeen objektitiedostot linkitetään lopulliseksi suoritettavaksi binääritiedostoksi.

Tavallinen tapa rakentaa C-kielinen projekti on käyttää otsikkotiedostoja, jotka esittelevät kooditiedostoissa määritellyt funktiot. Tämän avulla kooditiedostossa voidaan viitata funktioon, joka on määritelty toisessa kooditiedostossa. Tämä muistuttaa tapaa, jolla standardikirjaston funktioita käytetään.

Koodi tiedostoissa

Tarkastellaan esimerkkinä seuraavaa ohjelmaa, joka muodostuu kahdesta tiedostosta:

main.c

void print_message(char *message);

int main(void) {
    print_message("We get signal.");
    print_message("Main screen turn on.");
}

message.c

#include <stdio.h>
#include <string.h>

void print_stars(int n) {
    for (int i = 0; i < n; i++) {
        printf("*");
    }
    printf("\n");
}

void print_message(char *message) {
    int n = strlen(message);
    print_stars(n + 4);
    printf("* %s *\n", message);
    print_stars(n + 4);
}

Ohjelman voi kääntää seuraavalla komennolla:

$ gcc main.c message.c -o main

Tässä komennossa annetaan kaksi kooditiedostoa (main.c ja message.c), jotka käännetään binääritiedostoksi main.

Käännöksen jälkeen ohjelman voi suorittaa seuraavasti:

$ ./main
******************
* We get signal. *
******************
************************
* Main screen turn on. *
************************

C-kielessä jokainen kooditiedosto (.c) on erillinen käännösyksikkö (translation unit), joka käännetään objektitiedostoksi.

Ohjelman käännöksessä kaikki kooditiedostot käännetään ensin objektitiedostoiksi. Tämän jälkeen objektitiedostot linkitetään lopulliseksi suoritettavaksi binääritiedostoksi.

Teimme äsken koko käännöksen samalla komennolla, mutta käännöksen vaiheet on mahdollista tehdä myös erikseen. Seuraavat komennot kääntävät tiedostot main.c ja message.c objektitiedostoiksi main.o ja message.o:

$ gcc -c main.c -o main.o
$ gcc -c message.c -o message.o

Seuraava komento puolestaan linkittää objektitiedostot main.o ja message.o lopulliseksi binääritiedostoksi main:

$ gcc main.o message.o -o main

Näkyvyys ja esittely

C-kielessä tiedostossa määritellyt funktiot ja muuttujat ovat oletuksena näkyvissä kaikissa tiedostoissa koko ohjelman laajuisesti. Ulkoista funktiota tai muuttujaa voi käyttää, kunhan se esitellään ennen käyttämistä.

Esimerkiksi seuraavassa tiedostossa main.c käytetään funktiota secret_message ja muuttujaa secret_value, jotka on määritelty tiedostossa secret.c.

main.c

#include <stdio.h>

void secret_message(void);
extern int secret_value;

int main(void) {
    secret_message();
    printf("%d\n", secret_value);
}

secret.c

#include <stdio.h>

int secret_value = 1337;

void secret_message(void) {
    printf("Main screen turn on.\n");
}

Ulkoisen muuttujan esittelyssä tulee käyttää avainsanaa extern yllä olevalla tavalla. Myös ulkoisen funktion esittelyssä voi käyttää avainsanaa extern, mutta funktion tapauksessa avainsana ei ole pakollinen eikä sitä yleensä käytetä.

Ulkoisen muuttujan esittelyssä avainsana extern tarvitaan, koska muuten ei olisi selvää, että kyseessä on muuttujan esittely eikä määrittely. Esimerkiksi

int secret_value;

ei ole muuttujan esittely, vaan koodi määrittelee muuttujan secret_value, jonka alkuarvoksi tulee 0.

Koska funktiot ja muuttujat näkyvät oletuksena kaikkialla, kahdessa tiedostossa ei voi olla samannimisen funktion tai muuttujan määrittelyä. Tässä tulisi ongelmaksi, ettei olisi selvää, kumpaa funktiota tai muuttujaa tulisi käyttää.

Paikallinen näkyvyys

Avainsana static ilmaisee, että funktion tai muuttujan näkyvyys on paikallinen eli sitä voi käyttää vain tiedostossa, jossa se on määritelty.

Esimerkiksi seuraavassa koodissa funktio print_stars on määritelty paikalliseksi, mikä on perusteltua, koska kyseessä on funktion print_message apufunktio, jota ei ole tarkoitus kutsua muualta.

static void print_stars(int n) {
    for (int i = 0; i < n; i++) {
        printf("*");
    }
    printf("\n");
}

void print_message(char *message) {
    int n = strlen(message);
    print_stars(n + 4);
    printf("* %s *\n", message);
    print_stars(n + 4);
}

Kun funktion tai muuttujan määrittelyssä on avainsana static, kahdessa tiedostossa voi olla samanniminen funktio tai muuttuja, koska määrittely on paikallinen eikä näy muihin tiedostoihin.

Projektin rakenne

Tavallinen tapa rakentaa C-kielinen projekti on luoda kullekin kooditiedostolle (.c) otsikkotiedosto (.h), joka esittelee kooditiedoston funktiot, joita voidaan kutsua muista kooditiedostoista. Esimerkiksi tiedosto message.h sisältää tiedostossa message.c olevien funktioiden esittelyt.

Kun tiedostossa halutaan käyttää tietyn kooditiedoston funktioita, vastaava otsikkotiedosto liitetään mukaan tiedoston alussa #include-direktiivillä. Esimerkiksi jos tiedostossa halutaan käyttää tiedoston message.c funktioita, tiedoston alkuun tulee seuraava rivi:

#include "message.h"

Huomaa ero #include-direktiivin käytössä:

Otsikkotiedoston käyttö

Seuraavassa esimerkissä tiedostossa main.c määritelty pääfunktio main kutsuu funktiota print_message. Otsikkotiedosto message.h sisältää funktion print_message esittelyn, jonka perusteella funktiota voidaan kutsua. Tiedosto message.c puolestaan sisältää funktion print_message määrittelyn.

main.c

#include "message.h"

int main(void) {
    print_message("We get signal.");
    print_message("Main screen turn on.");
}

message.h

void print_message(char *message);

message.c

#include "message.h"
#include <stdio.h>
#include <string.h>

static void print_stars(int n) {
    for (int i = 0; i < n; i++) {
        printf("*");
    }
    printf("\n");
}

void print_message(char *message) {
    int n = strlen(message);
    print_stars(n + 4);
    printf("* %s *\n", message);
    print_stars(n + 4);
}

Seuraavat komennot kääntävät ja suorittavat ohjelman:

$ gcc main.c message.c -o main
$ ./main
******************
* We get signal. *
******************
************************
* Main screen turn on. *
************************

Otsikkotiedoston message.h hyötynä on, että kun halutaan käyttää tiedoston message.c funktiota, riittää laittaa koodin alkuun #include-direktiivi, joka liittää koodiin tiedoston message.h sisällön. Esimerkiksi tiedoston main.c sisältö esikääntäjän käsittelyn jälkeen on:

void print_message(char *message);

int main(void) {
    print_message("We get signal.");
    print_message("Main screen turn on.");
}

Itse asiassa tiedostossa message.c ei olisi pakko olla riviä

#include "message.h"

koska tiedostossa on varsinainen funktion print_message määrittely. Kuitenkin on hyvä käytäntö lisätä tämä rivi esimerkiksi sen takia, että kääntäjä varmistaa funktion esittelyn ja määrittelyn yhteensopivuuden.

Tyyppimäärittely otsikkotiedostossa

Otsikkotiedosto voi sisältää funktioiden esittelyjen lisäksi myös esimerkiksi tietotyyppien määrittelyjä. Esimerkiksi seuraava tiedosto arraylist.h määrittelee taulukkolistan tietotyypin ArrayList sekä esittelee listaa käsittelevät funktiot:

arraylist.h

typedef struct ArrayList ArrayList;

struct ArrayList {
    int size;
    int capacity;
    int *items;
};

ArrayList* array_list_create(void);
void array_list_insert(ArrayList *list, int value);
void array_list_print(ArrayList *list);
void array_list_free(ArrayList *list);

Tiedosto arraylist.c puolestaan sisältää näiden funktioiden toteutukset:

arraylist.c

#include "arraylist.h"
#include <stdio.h>
#include <stdlib.h>

ArrayList* array_list_create(void) {
    ArrayList *list = malloc(sizeof(ArrayList));
    list->size = 0;
    list->capacity = 4;
    list->items = malloc(sizeof(int) * list->capacity);
    return list;
}

void array_list_insert(ArrayList *list, int value) {
    if (list->capacity == list->size) {
        list->capacity *= 2;
        list->items = realloc(list->items, sizeof(int) * list->capacity);
    }
    list->items[list->size] = value;
    list->size++;
}

void array_list_print(ArrayList *list) {
    printf("[");
    for (int i = 0; i < list->size; i++) {
        if (i != 0) printf(", ");
        printf("%d", list->items[i]);
    }
    printf("]\n");
}

void array_list_free(ArrayList *list) {
    free(list->items);
    free(list);
}

Nyt taulukkolistaa voidaan käyttää tiedostossa main.c seuraavasti:

main.c

#include <stdio.h>
#include "arraylist.h"

int main(void) {
    ArrayList *list = array_list_create();

    array_list_insert(list, 42);
    array_list_insert(list, 5);
    array_list_insert(list, 1337);

    printf("%d\n", list->items[0]); // 42
    printf("%d\n", list->items[1]); // 5
    printf("%d\n", list->items[2]); // 1337

    array_list_free(list);
}

Otsikkotiedoston arraylist.h ansiosta tiedostossa main.c on käytettävissä taulukkolistan funktiot sekä tyypin ArrayList määrittely. Koodi tarvitsee tietoa tyypin ArrayList määrittelystä, koska koodi viittaa tyypin kenttään items, kun se näyttää listalla olevat alkiot.

Toisaalta voitaisiin haluta, että tietorakenteen sisäinen toteutus ei ole ulkopuolisen kooditiedoston saatavilla. Tässä tapauksessa tämä tarkoittaisi, ettei main.c-tiedostossa saisi viitata tyypin ArrayList kenttiin.

Tietorakenteen sisäisen toteutuksen voi kätkeä muuttamalla toteutusta niin, että tiedostossa arraylist.h on vain typedef-määrittely ja varsinainen struct-määrittely on tiedostossa arraylist.c. Tällöin tiedostossa main.c voi kutsua tiedoston arraylist.c funktioita kuten ennenkin, mutta ei ole mahdollista viitata tyypin ArrayList kenttiin.

Include-vahti

Hyvä käytäntö on lisätä otsikkotiedostoon include-vahti, joka varmistaa, että otsikkotiedoston sisältö liitetään mukaan enintään kerran. Tavallinen tapa toteuttaa include-vahti on seuraava:

arraylist.h

#ifndef ARRAYLIST_H
#define ARRAYLIST_H

typedef struct ArrayList ArrayList;

struct ArrayList {
    int size;
    int capacity;
    int *items;
};

ArrayList* array_list_create(void);
void array_list_insert(ArrayList *list, int value);
void array_list_print(ArrayList *list);
void array_list_free(ArrayList *list);

#endif // ARRAYLIST_H

Tässä direktiivit #ifndef ja #endif rajaavat koodin, joka liitetään mukaan siinä tapauksessa, ettei nimeä ARRAYLIST_H ole määritelty. Kun koodi liitetään mukaan, sen alussa direktiivi #define määrittelee nimen ARRAYLIST_H. Tämä varmistaa, että koodi liitetään mukaan enintään kerran.

Kommentti // ARRAYLIST_H selventää, että direktiivi #endif liittyy aiempaan direktiiviin #ifndef ARRAYLIST_H. Tämä kommentti ei ole pakollinen, vaan direktiivi #endif toimisi myös ilman kommenttia.

Tässä tapauksessa include-vahdin hyöty on siinä, että sen ansiosta koodissa ei määritellä useita kertoja ArrayList-tyyppiä, vaikka #include "arraylist.h" suoritettaisiin useita kertoja esikääntäjän käsitellessä koodia. C-kielessä ei ole sallittua määritellä useita kertoja samaa tietotyyppiä.

Jos otsikkotiedostossa on vain funktioiden esittelyjä, include-vahtia ei sinänsä tarvita, koska saman funktion esitteleminen useita kertoja ei haittaa. Kuitenkin hyvä käytäntö on laittaa include-vahti kaikkiin otsikkotiedostoihin riippumatta tiedostojen tämänhetkisestä sisällöstä.

Make-työkalu

Make on työkalu, jonka avulla voidaan automatisoida C-projektin kääntäminen. Ideana on kuvailla projektin hakemistossa tiedostossa Makefile, miten projekti tulee kääntää, minkä jälkeen käännöksen voi tehdä helposti komennolla make.

Tarkastellaan esimerkkinä aiempaa viestejä näyttävää ohjelmaa, joka muodostuu tiedostoista main.c, message.c ja message.h. Voimme tehdä esimerkiksi seuraavan tiedoston Makefile projektia varten:

Makefile

CC = gcc
CFLAGS = -Wall -Wextra -O2

TARGET = main
OBJS = main.o message.o

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) $(OBJS) -o $(TARGET)

main.o: main.c message.h
	$(CC) $(CFLAGS) -c main.c

message.o: message.c message.h
	$(CC) $(CFLAGS) -c message.c

clean:
	rm -f $(OBJS) $(TARGET)

Tiedostossa Makefile sisentämiseen tulee käyttää tabulaattoria. Jos sisennys tehdään välilyönneillä, komento make ei hyväksy tiedostoa.

Voimme nyt kääntää ohjelman komennolla make:

$ make
gcc -Wall -Wextra -O2 -c main.c
gcc -Wall -Wextra -O2 -c message.c
gcc -Wall -Wextra -O2 main.o message.o -o main

Tässä tapauksessa suoritetaan kolme gcc-komentoa, joista kaksi ensimmäistä komentoa kääntävät kooditiedostot objektitiedostoiksi ja kolmas komento linkittää objektitiedostot lopulliseksi ohjelmaksi.

Käännöksen jälkeen ohjelma on valmis suoritettavaksi:

$ ./main
******************
* We get signal. *
******************
************************
* Main screen turn on. *
************************

Voimme myös käyttää komentoa make clean poistamaan käännöksen aikana muodostetut tiedostot:

$ make clean
rm -f main.o message.o main

Tämän jälkeen projektin voisi kääntää uudestaan puhtaalta pöydältä.

Makefile-tiedosto

Katsotaan nyt tarkemmin, mistä osista äskeinen Makefile-tiedosto muodostuu. Tiedoston alussa määritellään muuttujia:

CC = gcc
CFLAGS = -Wall -Wextra -O2

Muuttuja CC kertoo kääntäjäohjelman nimen, ja muuttuja CFLAGS sisältää kääntäjälle annettavat parametrit.

TARGET = main
OBJS = main.o message.o

Muuttuja TARGET kertoo lopullisen ohjelmatiedoston nimen, ja muuttuja OBJS sisältää objektitiedostot, joita käännöksessä tarvitaan.

Tämän jälkeen tiedostossa on sääntöjä, jotka määrittelevät, miten halutut tiedostot saadaan aikaan käännösprosessissa.

all: $(TARGET)

Ensimmäinen sääntö määrittää käännöksen oletustavoitteen, joka on saada aikaan tiedosto TARGET eli lopullinen ohjelmatiedosto. Tätä tavoitetta käytetään, kun suoritetaan komento make ilman parametreja tai komento make all.

$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) $(OBJS) -o $(TARGET)

Tämä sääntö tarkoittaa, että kun halutaan tuottaa tiedosto TARGET, tarvitaan ensin tiedostot OBJS. Kun tiedostot OBJS ovat olemassa, voidaan suorittaa toisella rivillä oleva käännöskomento.

main.o: main.c message.h
	$(CC) $(CFLAGS) -c main.c

message.o: message.c message.h
	$(CC) $(CFLAGS) -c message.c

Nämä säännöt kertovat, miten objektitiedostot main.o ja message.o saadaan tuotettua. Esimerkiksi kun halutaan tuottaa tiedosto main.o, tarvitaan ensin tiedostot main.c ja message.h, minkä jälkeen voidaan suorittaa annettu käännöskomento.

clean:
	rm -f $(OBJS) $(TARGET)

Tämä sääntö kuvaa, miten komento make clean toimii. Komento poistaa kaikki käännöksen aikana tuotetut tiedostot hakemistosta.

Yleisesti ottaen Makefile-tiedoston sääntö on seuraavaa muotoa:

target: dependencies
	command

Tulkintana on, että kun halutaan tuottaa target, tarvitaan ensin tiedostot dependencies ja sitten suoritetaan komento command.

Tässä target voi olla joko tiedoston nimi (kuten main.o) tai muu nimetty tavoite, joka ei ole tiedosto (kuten clean).

Jos dependencies on tyhjä, tavoitetta varten ei tarvitse olla olemassa mitään tiedostoja. Jos command on tyhjä, ei suoriteta mitään komentoa.

Tiedostojen riippuvuudet

Maken yhtenä hyötynä on, että komento make ottaa huomioon tiedostojen riippuvuudet ja suorittaa tietyn käännöskomennon vain, jos jokin käännettävä tiedosto on muuttunut edellisen käännöksen jälkeen.

Esimerkiksi jos koko projekti on jo käännetty uusimmilla tiedostoilla, make ei tee mitään vaan antaa seuraavan viestin:

$ make
make: Nothing to be done for 'all'.

Seuraavassa tilanteessa aiemmin käännettyä projektia on muutettu niin, että tiedoston main.c sisältö on muuttunut. Nyt käännös etenee näin:

$ make
gcc -Wall -Wextra -O2 -c main.c
gcc -Wall -Wextra -O2 main.o message.o -o main

Tässä riittää kääntää tiedosto main.c objektitiedostoksi main.o ja linkittää sitten lopullinen ohjelma. Koska tiedosto message.c ei ole muuttunut, aiemmin tuotettu message.o kelpaa edelleen eikä sitä tarvitse kääntää uudestaan.

Erityisesti suurissa projekteissa on hyödyllistä, että make suorittaa vain tarvittavat käännöskomennot. Suurissa projekteissa koko projektin kääntäminen voi viedä kauankin aikaa. Käännösaika voi lyhentyä huomattavasti, kun ei tarvitse kääntää kaikkia tiedostoja uudestaan vaan vain muuttuneet tiedostot.

Ulkoiset kirjastot

Luvussa 1 teimme esimerkkiohjelman, joka laskee annetun luvun kertoman:

#include <stdio.h>

int main(void) {
    printf("Enter a number: ");
    int number;
    scanf("%d", &number);

    long factorial = 1;
    for (int x = 1; x <= number; x++) {
        factorial *= x;
    }

    printf("The factorial of %d is %ld\n", number, factorial);
}

Tämän ohjelman rajoituksena on, ettei sillä voi laskea kovin suurta kertomaa, koska C:n lukutyypit ovat rajoitetun kokoisia. Esimerkiksi Linuxissa tyyppi long on tavallisesti 64-bittinen, minkä takia siihen mahtuu enintään luvun 20 kertoma:

Enter a number: 20
The factorial of 20 is 2432902008176640000

Luvun 21 kertomaksi tulee negatiivinen tulos merkkinä siitä, että luku on pyörähtänyt ympäri ylivuodon seurauksena:

Enter a number: 21
The factorial of 21 is -4249290049419214848

C-kielen standardikirjastossa ei ole tapaa käsitellä suuria kokonaislukuja, mutta voimme käyttää tähän ulkoista GMP-kirjastoa (GNU Multiple Precision Arithmetic Library), jolla pystyy käsittelemään mielivaltaisen suuria kokonaislukuja.

Kirjaston asennus ja käyttö

Ensimmäinen vaihe ulkoisen GMP-kirjaston käyttämisessä on asentaa kirjasto kehitysympäristöön. Esimerkiksi Ubuntussa voimme asentaa paketin libgmp-dev seuraavasti:

$ sudo apt install libgmp-dev

Tämä komento asentaa järjestelmään tarvittavat tiedostot, jotta voimme käyttää ohjelmassa GMP-kirjaston otsikkotiedostoa gmp.h sekä liittää kirjaston mukaan ohjelman käännöksessä.

Seuraavassa on uusi toteutus kertoman laskevasta ohjelmasta käyttäen GMP-kirjastoa. Tässä toteutuksessa tuloksen suuruutta ei ole rajoitettu, vaan ohjelmalla voi laskea hyvin suurenkin kertoman.

#include <stdio.h>
#include <gmp.h>

int main(void) {
    printf("Enter a number: ");
    int number;
    scanf("%d", &number);

    mpz_t factorial;
    mpz_init(factorial);
    mpz_set_ui(factorial, 1);

    for (int x = 1; x <= number; x++) {
        mpz_mul_ui(factorial, factorial, x);
    }

    gmp_printf("The factorial of %d is %Zd\n", number, factorial);
    mpz_clear(factorial);
}

Ohjelma määrittelee mpz_t-tyyppisen muuttujan factorial, johon voidaan tallentaa suuri kokonaisluku. Tämän jälkeen ohjelma laskee kertoman mpz-funktioilla ja tulostaa lopuksi tuloksen gmp_printf-funktiolla.

Seuraava komento kääntää ohjelman:

$ gcc factorial.c -o factorial -lgmp

Komennossa on mukana parametri -lgmp, jonka ansiosta GMP-kirjasto linkitetään mukaan käännöksessä.

Ohjelman suoritus voi näyttää seuraavalta:

Enter a number: 100
The factorial of 100 is 93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000

GMP-kirjastossa on myös suorempi tapa laskea luvun kertoma käyttäen valmista funktiota mpz_fac_ui seuraavasti:

#include <stdio.h>
#include <gmp.h>

int main(void) {
    printf("Anna luku: ");
    int number;
    scanf("%d", &number);

    mpz_t factorial;
    mpz_init(factorial);
    mpz_fac_ui(factorial, number);

    gmp_printf("Luvun %d kertoma on %Zd\n", number, factorial);
    mpz_clear(factorial);
}

Kirjasto järjestelmässä

Kun GMP-kirjasto asennetaan Linux-järjestelmään, tiettyihin järjestelmän hakemistoihin sijoitetaan kirjastoon liittyviä tiedostoja. Esimerkiksi Ubuntussa kirjaston asentaminen voisi lisätä järjestelmään seuraavat tiedostot:

Tiedostot on sijoitettu järjestelmässä hakemistoihin, joista kääntäjä osaa etsiä niitä. Kun koodissa on direktiivi #include <gmp.h>, kääntäjä osaa etsiä tiedostoa gmp.h hakemistosta /usr/include. Kun käännöskomennossa on parametri -lgmp, kääntäjä olettaa tämän viittaavan libgmp.so-tiedostoon hakemistossa /usr/lib.

Oletuksena kirjasto linkitetään ohjelmaan dynaamisesti, jolloin käännetyn ohjelman osaksi ei tule kirjaston koodia. Tässä tapauksessa ohjelma vaatii toimiakseen järjestelmässä olevan kirjastotiedoston libgmp.so, jota käytetään ohjelman suoritusvaiheessa.

Dynaamisen linkityksen etuna on, ettei kirjaston käyttäminen kasvata ohjelmatiedoston kokoa, vaan kaikki GMP-kirjastoa käyttävät ohjelmat voivat käyttää yhteisesti tiedostoa libgmp.so. Jos kirjasto päivitetään järjestelmässä, päivitys vaikuttaa automaattisesti kaikkiin sitä käyttäviin ohjelmiin. Toisaalta ohjelmaa ei voi käyttää, jos järjestelmässä ei ole tiedostoa libgmp.so.

Seuraava komento näyttää dynaamisesti linkitetyt kirjastot, jotka ohjelma tarvitsee käynnistyäkseen:

$ objdump -p factorial | grep NEEDED
  NEEDED               libgmp.so.10
  NEEDED               libc.so.6

Tässä tapauksessa ohjelma tarvitsee kaksi kirjastoa: GMP-kirjaston (libgmp.so) sekä C-standardikirjaston (libc.so).

Dynaamisen linkityksen vaihtoehtona on staattinen linkitys, jolloin käytetään tiedostoa libgmp.a ja kirjaston koodi tulee ohjelman osaksi. Seuraava komento kääntää ohjelman niin, että GMP-kirjasto linkitetään staattisesti:

$ gcc factorial.c -o factorial -l:libgmp.a

Kun GMP-kirjasto on linkitetty staattisesti, ohjelmaa voi käyttää ilman järjestelmässä olevaa GMP-kirjastoa, mutta ohjelmatiedoston koko on suurempi.

Kurssin opettajan järjestelmässä ohjelmatiedosto factorial vie tilaa 16208 tavua GMP-kirjaston dynaamisella linkityksellä ja 179528 tavua staattisella linkityksellä. Ohjelmatiedosto vie siis yli 10-kertaisesti tilaa, kun käytetään staattista linkitystä dynaamisen linkityksen sijasta.

Python-projekti

Hyvä tapa oppia, miten C-kieltä käytetään todellisissa projekteissa, on perehtyä tällaisiin projekteihin. Tutustumme seuraavaksi hieman Python-suoritusympäristön (CPython) toteutukseen ja teemme siihen oman täydennyksen.

Python-suoritusympäristön lähdekoodi on saatavilla GitHubissa, joten pystymme helposti kloonaamaan projektin omalle koneellemme. Käännetään projekti seuraamalla README.md-tiedoston ohjeistusta:

$ ./configure
$ make

Melko pitkän odotuksen jälkeen käännös valmistuu ja voimme käynnistää itse kääntämämme Python-tulkin näin:

$ ./python
Python 3.16.0a0 [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import math
>>> math.factorial(100)
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000
>>> quit()

Tämän perusteella vaikuttaa siltä, että käännös onnistui ja meillä on nyt koneellamme uusin Python-versio käytettävissä.

Kokeillaan seuraavaksi lisätä Python-kieleen uusi ominaisuus, jonka avulla pystyy tarkastamaan, onko merkkijono palindromi. Luonteva tapa toteuttaa tällainen ominaisuus on lisätä merkkijonoon metodi ispalindrome, joka palauttaa arvon True tai False. Metodin tulisi toimia seuraavasti:

Nyt täytyy enää selvittää, miten Pythonin merkkijonometodit on toteutettu ja miten voimme lisätä mukaan oman metodimme.

Projektin tutkiminen

Python-suoritusympäristö on laaja projekti, ja vaatii työtä edes löytää, mitä tiedostoa tai tiedostoja meidän tulisi muuttaa uuden metodin lisäämiseksi. Tässä tilanteessa hyvä keino on valita jokin metodi, joka on lähellä tulevaa omaa metodiamme, ja ottaa mallia sen toteutuksesta.

Tässä tapauksessa sopiva metodi esikuvaksi on vaikkapa metodi isprintable, joka kertoo, ovatko merkkijonon kaikki merkit tulostettavia. Tämä metodi on hyvä esikuva, koska se on merkkijonon metodi, joka oman tulevan metodimme tavoin käy läpi merkkijonon sisällön ja palauttaa arvon True tai False.

Etsitään nyt komennolla git grep tiedostoja, joissa esiintyy metodin nimi isprintable. Seuraavat tiedostot vaikuttavat lupaavilta:

$ git grep isprintable
...
Objects/clinic/unicodeobject.c.h:PyDoc_STRVAR(unicode_isprintable__doc__,
Objects/clinic/unicodeobject.c.h:"isprintable($self, /)\n"
Objects/clinic/unicodeobject.c.h:    {"isprintable", (PyCFunction)unicode_isprintable, METH_NOARGS, unicode_isprintable__doc__},
Objects/clinic/unicodeobject.c.h:unicode_isprintable_impl(PyObject *self);
Objects/clinic/unicodeobject.c.h:unicode_isprintable(PyObject *self, PyObject *Py_UNUSED(ignored))
Objects/clinic/unicodeobject.c.h:    return unicode_isprintable_impl(self);
Objects/unicodectype.c:   See documentation of `str.isprintable` for details.
Objects/unicodeobject.c:str.isprintable as unicode_isprintable
Objects/unicodeobject.c:unicode_isprintable_impl(PyObject *self)
...

Näissä tiedostoissa on seuraavaa metodiin liittyvää koodia:

unicodeobject.c.h

PyDoc_STRVAR(unicode_isprintable__doc__,
"isprintable($self, /)\n"
"--\n"
"\n"
"Return True if all characters in the string are printable, False otherwise.\n"
"\n"
"A character is printable if repr() may use it in its output.");

#define UNICODE_ISPRINTABLE_METHODDEF    \
    {"isprintable", (PyCFunction)unicode_isprintable, METH_NOARGS, unicode_isprintable__doc__},

static PyObject *
unicode_isprintable_impl(PyObject *self);

static PyObject *
unicode_isprintable(PyObject *self, PyObject *Py_UNUSED(ignored))
{
    return unicode_isprintable_impl(self);
}

Tässä määritellään UNICODE_ISPRINTABLE_METHODDEF, joka ilmeisesti sisältää metodin nimen, kuvauksen ja sisäisen kutsutavan. Metodin toteutukseen liittyy kaksi funktiota unicode_isprintable_impl ja unicode_isprintable, joista jälkimmäinen funktio kutsuu suoraan ensimmäistä funktiota.

unicodeobject.c

static PyObject *
unicode_isprintable_impl(PyObject *self)
{
    Py_ssize_t i, length;
    int kind;
    const void *data;

    length = PyUnicode_GET_LENGTH(self);
    kind = PyUnicode_KIND(self);
    data = PyUnicode_DATA(self);

    /* Shortcut for single character strings */
    if (length == 1)
        return PyBool_FromLong(
            Py_UNICODE_ISPRINTABLE(PyUnicode_READ(kind, data, 0)));

    for (i = 0; i < length; i++) {
        if (!Py_UNICODE_ISPRINTABLE(PyUnicode_READ(kind, data, i))) {
            Py_RETURN_FALSE;
        }
    }
    Py_RETURN_TRUE;
}

Tämä vaikuttaa olevan metodin varsinainen toteutus C-kielisenä funktiona. Tästä saa hyvää käsitystä siitä, miten oman metodimme toteutus tulisi rakentaa ja miten koodi voi käsitellä merkkijonon pituutta ja yksittäisiä merkkejä:

Samasta tiedostosta löytyy vielä seuraava hyödyllinen kohta:

unicodeobject.c

static PyMethodDef unicode_methods[] = {
    UNICODE_ENCODE_METHODDEF
    UNICODE_REPLACE_METHODDEF
    UNICODE_SPLIT_METHODDEF
    // ...
    UNICODE_ISALNUM_METHODDEF
    UNICODE_ISIDENTIFIER_METHODDEF
    UNICODE_ISPRINTABLE_METHODDEF
    // ...

Tämä vaikuttaa olevan lista, joka määrittelee merkkijonossa käytettävissä olevat metodit. Listalla on mukana UNICODE_ISPRINTABLE_METHODDEF, joka on määritelty tiedostossa unicodeobject.c.h.

Uuden metodin toteutus

Nyt meillä on tarvittava pohja siihen, että voimme kokeilla toteuttaa uuden metodin. Aloitetaan lisäämällä oman metodimme ispalindrome tiedot tiedostoon unicodeobject.c.h. Voimme käyttää lähes samaa koodia kuin metodissa isprintable, kunhan teemme siihen muutaman muutoksen:

unicodeobject.c.h

PyDoc_STRVAR(unicode_ispalindrome__doc__,
"ispalindrome($self, /)\n"
"--\n"
"\n"
"Return True if the string is a palindrome, False otherwise.\n");

#define UNICODE_ISPALINDROME_METHODDEF    \
    {"ispalindrome", (PyCFunction)unicode_ispalindrome, METH_NOARGS, unicode_ispalindrome__doc__},

static PyObject *
unicode_ispalindrome_impl(PyObject *self);

static PyObject *
unicode_ispalindrome(PyObject *self, PyObject *Py_UNUSED(ignored))
{
    return unicode_ispalindrome_impl(self);
}

Tässä ei ole selvää, miksi tulee olla kaksi eri funktiota (unicode_ispalindrome_impl ja unicode_ispalindrome), mutta on varminta toimia samalla tavalla kuin olemassa olevan metodin toteutuksessa.

Nyt kun UNICODE_ISPALINDROME_METHODDEF on määritelty, lisäämme sen myös tiedostoon unicodeobject.c metodien listalle.

Seuraava vaihe on tehdä varsinainen metodin toteutus. Koska on epävarmaa, olemmeko ymmärtäneet oikein metodin toteuttamistavan, tehdään ensin testimielessä seuraava metodin toteutus:

unicodeobject.c

static PyObject *
unicode_ispalindrome_impl(PyObject *self)
{
    Py_RETURN_TRUE;
}

Tässä on tarkoituksena, että metodi palauttaa aina arvon True riippumatta siitä, onko merkkijono palindromi. Tämä testimetodi kertoo, olemmeko oikealla polulla metodin luomisessa. Jos metodin toteutus on tehty oikein, meidän pitäisi pystyä kutsumaan metodia ispalindrome Pythonissa.

Seuraavat komennot kääntävät projektin uudestaan ja testaavat metodia:

$ make
$ ./python
Python 3.16.0a0 [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> "abcba".ispalindrome()
True
>>> "test".ispalindrome()
True
>>> "saippuakauppias".ispalindrome()
True

Tilanne näyttää hyvältä: pystymme kutsumaan Python-tulkissa metodia ispalindrome, joka vaikuttaa antavan aina arvon True, kuten tässä vaiheessa pitääkin.

Nyt voimme keskittyä metodin toiminnallisuuden toteuttamiseen:

unicodeobject.c

static PyObject *
unicode_ispalindrome_impl(PyObject *self)
{
    Py_ssize_t length;
    int kind;
    const void *data;

    length = PyUnicode_GET_LENGTH(self);
    kind = PyUnicode_KIND(self);
    data = PyUnicode_DATA(self);

    for (int i = 0; i < length / 2; i++) {
        Py_UCS4 left = PyUnicode_READ(kind, data, i);
        Py_UCS4 right = PyUnicode_READ(kind, data, length - 1 - i);

        if (left != right) {
            Py_RETURN_FALSE;
        }
    }

    Py_RETURN_TRUE;
}

Tässä silmukka käy läpi merkkijonon kohdat puoliväliin asti ja tarkastaa jokaisessa kohdassa, ovatko vasen ja oikea merkki samat vastaavissa kohdissa. Koodi käyttää merkin tallentamiseen tietotyyppiä Py_UCS4, jonka käyttämiseen on otettu mallia toisesta tiedostossa olevasta funktiosta.

Nyt voimme taas kääntää projektin ja testata metodia:

$ make
$ ./python
Python 3.16.0a0 [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> "abcba".ispalindrome()
True
>>> "test".ispalindrome()
False
>>> "saippuakauppias".ispalindrome()
True

Vaikuttaa siltä, että metodi ispalindrome toimii nyt oikealla tavalla, eli olemme onnistuneesti toteuttaneet uuden metodin Pythoniin. Myös metodin ohjetekstin näyttäminen toimii:

>>> help(str.ispalindrome)
Help on method descriptor ispalindrome:

ispalindrome(self, /) unbound builtins.str method
    Return True if the string is a palindrome, False otherwise.