Matalan tason ohjelmointi

Beta

2. Tiedon käsittely

Ohjelman käsittelemä tieto esitetään tietokoneen muistissa aina pohjimmiltaan bitteinä ja tavuina. C-kielessä pääsemme suoraan käsiksi tiedon matalan tason esitystapaan: esimerkiksi kun ohjelmassa on muuttuja, pystymme tutkimaan tavu tavulta, miten muuttujan sisältö on tallennettu muistiin.

Tutustumme tässä luvussa erityisesti siihen, miten kokonaisluvut esitetään muistissa. Kokonaisluvun bittien määrä kertoo, miten suuri luku voi olla ja toisaalta paljonko tilaa luku vie muistissa. Esimerkiksi C-kielen int-tyyppi on tavallisesti 32-bittinen, jolloin sillä voidaan esittää kokonaisluku väliltä –231…231–1.

Tietotyypit

Tiedon käsittelyn perusyksiköt ovat bitti (bit) ja tavu (byte).

C-kielen tavallisia tietotyyppejä ovat kokonaisluvut char, short, int ja long sekä liukuluvut float ja double. Jokaisella tietotyypillä on kiinteä koko, joka vaikuttaa siihen, miten suuren tai tarkan luvun sillä pystyy esittämään.

Pienin tietotyyppi on char, jonka kokona on 1 tavu (8 bittiä). Jokaisen muun tietotyypin koko on jokin moninkerta tavuja riippuen käytetystä ympäristöstä. Esimerkiksi 64-bittisessä Linux-ympäristössä int-tyypin koko on 4 tavua (32 bittiä) ja long-tyypin koko on 8 tavua (64 bittiä).

C-kielen standardi ei määrittele tarkasti tietotyyppien kokoja. Itse asiassa standardi ei tarkasti ottaen edes lupaa tavun olevan 8 bittiä, vaikka näin onkin yleensä aina. Tietotyypeistä int ja long standardi lupaa, että int on ainakin 16 bittiä, long on ainakin 32 bittiä ja lisäksi int ei saa olla suurempi kuin long.

C-kielen ratkaisu jättää tietotyypin koko osittain avoimeksi eroaa monista muista kielistä. Esimerkiksi Javassa int-tyyppi on aina 32-bittinen ja Rustissa tietotyypin nimi (esimerkiksi i32) kertoo suoraan sen bittien määrän. C:ssä ajatuksena on, että tietotyyppien koot voidaan valita sopivasti eri ympäristöissä.

C:n standardikirjastoon kuuluu tiedosto stdint.h, jossa käytetyn C-kielen toteutuksen on mahdollista määritellä tyypit int8_t, int16_t, int32_t ja int64_t. Näissä tyypeissä ohjelmoija voi olla varma tyypin koosta, mutta toisaalta C-kielen toteutuksen ei ole pakko määritellä näitä tyyppejä.

Käytännössä nykyään yleensä C-kielen int-tyyppi on 32-bittinen ja long-tyyppi on joko 32-bittinen (Windows) tai 64-bittinen (Linux, Mac).

Tietotyypin koko

Operaattorin sizeof avulla voidaan selvittää tietyn tietotyypin koko käytetyssä ympäristössä. Operaattori antaa tietotyypin koon tavuina. Esimerkiksi seuraava koodi näyttää tietotyyppien char ja int koot:

printf("%zu\n", sizeof(char));
printf("%zu\n", sizeof(int));

Koodin tyypillinen tulostus on:

1
4

Operaattori sizeof palauttaa koon size_t-tyyppisenä kokonaislukuna, jota vastaa printf-funktiossa käytetty muotoilu %zu.

Tietotyypin koko vaikuttaa käytännössä siihen, miten suuria lukuja tietotyypin avulla voidaan käsitellä. Esimerkiksi kun int-muuttujan kokona on 4 tavua eli 32 bittiä, siihen voidaan tallentaa 232 erilaista kokonaislukua. Tämä tarkoittaa yleensä, että pienin ja suurin sallittu arvo ovat –231 ja 231–1.

Operaattorin sizeof lisäksi C:ssä voi selvittää tyyppien kokoa standardikirjaston tiedoston limits.h avulla. Tiedostossa määritellään esimerkiksi vakiot INT_MIN ja INT_MAX, jotka antavat int-muuttujan ala- ja ylärajan.

Esimerkiksi koodin

printf("%d %d\n", INT_MIN, INT_MAX);

tyypillinen tulostus on seuraava:

-2147483648 2147483647

Luvut bitteinä

Kaikki ohjelman käsittelemät tiedot tulee esittää tavalla tai toisella bitteinä tietokoneen muistissa. Katsotaan seuraavaksi tarkemmin, miten kokonaisluvut esitetään yleensä bittimuodossa.

Kokonaisluvun esitystapa

Tavallinen tapa esittää kokonaisluku bittimuodossa on esittää se binäärilukuna eli lukujärjestelmässä, jonka kantaluku on 2.

Esimerkiksi luku 123 on binäärilukuna 1111011, koska luku voidaan esittää seuraavasti 2:n potenssien summana:

123 = 26 + 25 + 24 + 23 + 21 + 20

Kun luku 123 tallennetaan int-muuttujaan koodilla

int x = 123;

niin se tallennetaan tyypillisesti muistiin 4-tavuisena binäärilukuna seuraavasti:

00000000 00000000 00000000 01111011

Tässä tapauksessa luku on sen verran pieni, että se mahtuu yhteen tavuun, jolloin kolme muuta tavua ovat pelkkää nollaa.

Binäärilukua indeksoidaan tavallisesti oikealta vasemmalle nollasta alkaen pienimmästä 2:n potenssista suurimpaan. Esimerkiksi yllä olevassa binääriluvussa on ykkösbitit kohdissa 0, 1, 3, 4, 5 ja 6. Luvun eniten merkitsevä bitti on kohdassa 31 ja vähiten merkitsevä bitti on kohdassa 0. Vastaavasti eniten merkitsevä tavu on 00000000 ja vähiten merkitsevä tavu on 01111011.

Kahden komplementti

Äsken kuvatulla tavalla pystyy esittämään hyvin nollan ja positiiviset kokonaisluvut, mutta kokonaisluku voi olla myös negatiivinen.

Tavallinen tapa käsitellä negatiivisia kokonaislukuja on sopia, että luvun bittiesityksen ensimmäinen bitti ilmaisee, onko luku positiivinen (0) vai negatiivinen (1). Yleensä käytetään kahden komplementtia, jossa luvun etumerkkiä voidaan vaihtaa kääntämällä kaikki bitit ja lisäämällä tulokseen yksi.

Esimerkiksi luku –123 esitetään 4-tavuisena binäärilukuna seuraavasti:

11111111 11111111 11111111 10000101

Vaikka kahden komplementti on yleisesti käytetty tapa negatiivisten kokonaislukujen esittämiseen, C-standardi on sallinut perinteisesti muitakin esitystapoja. Kuitenkin uusin C23-standardi määrittelee, että negatiiviset kokonaisluvut esitetään aina kahden komplementin avulla.

Lukualueet

C-kielessä on käytettävissä kahdenlaisia kokonaislukuja: etumerkilliset (signed) ja etumerkittömät (unsigned) kokonaisluvut.

Etumerkillinen luku voi olla positiivinen, negatiivinen tai nolla. Etumerkitön luku voi puolestaan olla vain positiivinen tai nolla. Etumerkki vaikuttaa siihen, miten suuria lukuja voidaan esittää, koska etumerkki vie luvussa yhden bitin tilaa.

Esimerkiksi tyyppi int on etumerkillinen, eli siihen voidaan tallentaa sekä positiivisia että negatiivisia lukuja. Tyyppi unsigned int sen sijaan on etumerkitön, eli sen kuvaama luku on aina vähintään nolla.

C-kielessä on vaihtoehtoisia nimiä samalle tietotyypille. Esimerkiksi int tarkoittaa samaa kuin signed int tai pelkkä signed. Vastaavasti unsigned int tarkoittaa samaa kuin unsigned.

Tavallinen tyypin int lukualue on –231…231–1. Tässä positiivisen luvun suuruuden yläraja on yksi pienempi kuin negatiivisella luvulla, koska etumerkkiä 0 käytetään sekä positiivisen luvun että nollan esittämiseen.

Tavallinen tyypin unsigned int lukualue on 0…232–1. Tässä positiivisen luvun suuruuden yläraja on noin kaksinkertainen verrattuna tyyppiin int, mutta ei ole mahdollista esittää negatiivista lukua.

Ylivuoto

Mitä tapahtuu, jos käytetty tietotyyppi on liian pieni lukua varten? Seuraava koodi sijoittaa muuttujaan x luvun 231–1, joka on käytetyssä ympäristössä int-tyypin lukualueen suurin luku. Tämän jälkeen koodi kasvattaa muuttujan arvoa yhdellä ja tulostaa muuttujan uuden arvon.

int x = 2147483647;
printf("%d\n", x); // 2147483647

x++;
printf("%d\n", x); // -2147483648

Tässä tapahtuu ylivuoto (overflow), eli luku pyörähtää ympäri ja siitä tulee lukualueen pienin luku. Huomaa, että ylivuoto ei aiheuta virhettä eikä siitä tule ilmoitusta ohjelman suorituksen aikana. Ohjelma toimii sinänsä, mutta ylivuodon takia tulos voi erota halutusta tuloksesta.

Vastaavasti ylivuoto tapahtuu toiseen suuntaan, jos muuttujassa on lukualueen pienin luku ja muuttujan arvoa vähennetään:

int x = -2147483648;
printf("%d\n", x); // -2147483648

x--;
printf("%d\n", x); // 2147483647

Tarkkaan ottaen C-standardi ei määrittele, mitä tapahtuu etumerkillisen luvun ylivuodossa. Useimmissa ympäristöissä luku pyörähtää ympäri yllä olevalla tavalla, mutta periaatteessa voisi tapahtua jotain muutakin. Sen sijaan standardi määrittelee, että etumerkittömän luvun ylivuodossa luku pyörähtää ympäri.

C-kielen tyyppi int ja Pythonin tyyppi int ovat siis hyvin erilaisia. C-kielessä tyypin int koko on rajoitettu (usein 4 tavua), minkä takia se kattaa vain tietyn lukualueen. Pythonissa tyypin int avulla voi esittää periaatteessa miten tahansa suuren kokonaisluvun, kuten luvun 123123, jossa on 258 numeroa:

x = 123**123

C-kielessä tyypin int käyttäminen on hyvin tehokasta, koska lukujen koko on rajattu ja niitä voi käsitellä suoraan tietokoneen prosessorin komennoilla. Toisaalta koodi voi antaa ylivuodon seurauksena väärän tuloksen, jos käsiteltävä luku menee lukualueen ulkopuolelle.

Pythonin tyypin int sisäinen toteutus on monimutkainen, koska suuria lukuja ei voi käsitellä suoraan tietokoneen laitteiston tasolla vaan niiden käsittely on toteutettu erikseen Pythonin suoritusympäristöön. Tämän takia lukujen käsittely Pythonissa on paljon hitaampaa kuin C-kielessä.

Bittioperaatiot

Luvun bittejä voidaan käsitellä tehokkaasti bittioperaatioiden avulla.

Esimerkkejä

Seuraava koodi esittelee and-, or- ja xor-operaatioita:

int a = 25;
int b = 42;

printf("%d\n", a & b); // 8
printf("%d\n", a | b); // 59
printf("%d\n", a ^ b); // 51

Bittimuodossa operaatiot näyttävät seuraavilta:

    25 = 00011001        25 = 00011001        25 = 00011001
    42 = 00101010        42 = 00101010        42 = 00101010
and -------------     or -------------    xor -------------
     8 = 00001000        59 = 00111011        51 = 00110011

Yllä on esitetty vain luvun viimeisen tavun sisältö bittimuodossa, koska kaikissa muissa tavuissa kaikki bitit ovat nollia.

Huomaa, että C-kielessä bittioperaattorit (& ja |) ja loogiset operaattorit (&& ja ||) toimivat eri tavalla. Esimerkiksi 2 & 4 tuottaa tuloksen 0, koska luvuissa ei ole ykkösbittiä samassa kohdassa, mutta 2 && 4 tuottaa tuloksen 1, koska molemmat luvut eroavat nollasta eli ne ovat tosia.

Operaatio not vaikuttaa lukuun näin:

int x = 123;

printf("%d\n", ~x); // -124

Tulos on odotusten mukainen, koska kahden komplementissa luvun vastaluku saadaan kääntämällä kaikki bitit ja lisäämällä tulokseen yksi. Tässä vain bitit käännetään, joten tulos on yhden pienempi kuin vastaluku.

Bittimuodossa operaatio näyttää seuraavalta:

     123 = 00000000 00000000 00000000 01111011
not ------------------------------------------
    -124 = 11111111 11111111 11111111 10000100

Seuraava koodi esittelee bittisiirtoja:

int x = 6;

printf("%d\n", x << 1); // 12
printf("%d\n", x << 2); // 24

printf("%d\n", x >> 1); // 3
printf("%d\n", x >> 2); // 1

Vasen bittisiirto k askelta vastaa luvun kertomista 2k:lla ja oikea bittisiirto k askelta vastaa luvun jakamista 2k:lla (pyöristys alaspäin kokonaisluvuksi).

Bittimuodossa operaatiot näyttävät tältä:

      6 = 00000110          6 = 00000110
<< 1 -------------    << 2 -------------
     12 = 00001100         24 = 00011000


      6 = 00000110          6 = 00000110
>> 1 -------------    >> 2 -------------
      3 = 00000011          1 = 00000001

Bittiesitys koodilla

Seuraava koodi tulostaa muuttujassa x olevan luvun bittiesityksen:

int x = 123;

for (int k = 31; k >= 0; k--) {
    if (x & (1 << k)) {
        printf("1");
    } else {
        printf("0");
    }
}
printf("\n");

Koodin tulostus on seuraava:

00000000000000000000000001111011

Koodi käy läpi luvun bitit vasemmalta oikealle ja tarkastaa jokaisen bitin kohdalla, onko bitti 1 vai 0. Koodi olettaa, että int-luku muodostuu 32 bitistä.

Koodissa on käytössä bittimaski, joka muodostetaan bittisiirrolla 1 << k. Tässä bittimaskissa on tasan yksi ykkösbitti ja kaikki muut bitit ovat nollia. Jos operaatio x & (1 << k) tuottaa nollasta eroavan tuloksen, tiedetään, että luvussa x on ykkösbitti samassa kohdassa kuin luvussa 1 << k. Muussa tapauksessa luvussa x on nollabitti kyseisessä kohdassa.

Esimerkiksi kun k = 3, luvun x ja bittimaskin 1 << k and-operaatio näyttää tältä:

         x = 00000000000000000000000001111011
    1 << k = 00000000000000000000000000001000
and -----------------------------------------
             00000000000000000000000000001000

Tulos on positiivinen, joten luvussa x on ykkösbitti kyseisessä kohdassa.

Tieto muistissa

Hyvä mielikuva tietokoneen muistista on, että siinä on rivi muistipaikkoja, joihin jokaiseen voidaan tallentaa yksi tavu tietoa. Jokaisella muistipaikalla on muistiosoite, jonka kautta siihen voidaan viitata.

osoite   | 0        | 1        | 2        | 3        | ...
sisältö  | 01100100 | 00111011 | 11110110 | 00010000 | ...

Todellisuudessa tilanne on monimutkaisempi, koska käyttöjärjestelmä antaa ohjelman käyttöön virtuaalisia muistialueita, joiden muistiosoitteet eroavat tietokoneen laitteiston fyysisistä muistialueista. Tästä huolimatta yllä oleva malli on hyvä lähtökohta muistin käsittelyn ymmärtämiselle.

Muuttujan muistiosoite

Tarkastellaan seuraavaa koodia, joka määrittelee muuttujan:

int x = 123;

Koodin suorituksen aikana muuttujan arvo on jollakin tavalla tietokoneen muistissa, jotta muuttujaa pystyy käyttämään. C-kielessä muuttujan muistiosoite voidaan selvittää &-operaattorilla seuraavaan tapaan:

printf("%p\n", &x);

Tässä muotoilu %p tarkoittaa, että muistiosoite ilmoitetaan heksalukuna. Koodi voisi tulostaa muuttujan muistiosoitteen esimerkiksi näin:

0x7ffe9c985da0

Heksaluku on 16-järjestelmän luku, jossa numeromerkit ovat 0–9 ja a–f. Luvun alkuliite 0x ilmaisee, että kyseessä on heksaluku.

Esimerkiksi heksaluvun 0x5da0 esitys 10-järjestelmässä on

5 · 163 + 13 · 162 + 10 · 161 + 0 · 160 = 23968.

Huomaa, että muuttujan muistiosoite riippuu siitä, miten käyttöjärjestelmä antaa ohjelman käyttöön muistia ohjelman suoritushetkellä. Jos kokeilet suorittaa yllä olevan koodin itse, saat lähes varmasti eri tuloksen kuin esimerkissä.

Tarkemmin ottaen muuttujan muistiosoite tarkoittaa, missä muistipaikassa on muuttujan sisällön ensimmäinen tavu. Kaikki muuttujan sisällölle varatut tavut ovat peräkkäin muistissa tästä osoitteesta alkaen.

Tässä tapauksessa muuttuja x on int-tyyppinen ja vie tilaa 4 tavua, joten tilanne muistissa näyttää seuraavalta:

Osoittimet

Keskeinen käsite C-ohjelmoinnissa on osoitin (pointer), jonka kautta voidaan käsitellä muistin sisältöä muistiosoitteen perusteella.

Esimerkiksi seuraava koodi määrittelee osoittimen p, joka osoittaa int-tyyppiseen arvoon muistissa:

int *p;

Osoitin määritellään siis muuten samalla tavalla kuin tavallinen muuttuja, mutta ennen muuttujan nimeä tulee merkki *.

Osoittimen määrittelyn voi kirjoittaa myös muodossa

int* p;

joka korostaa sitä, että muuttujan tyyppi on int* eli int-osoitin.

Kuitenkin voi pitää parempana tyylinä kirjoittaa *p, koska useamman osoittimen määrittelyssä merkki * tulee laittaa ennen kunkin muuttujan nimeä.

Esimerkiksi määrittely

int* a, b;

tarkoittaa, että a on int-osoitin kun taas b on tavallinen int-muuttuja. Tässä siis int* ei tarkoita, että kaikki seuraavat muuttujat ovat osoittimia.

Selkeä tapa määritellä int-osoittimet a ja b on seuraava:

int *a, *b;

Osoittimen määrittelyn jälkeen osoittimen arvoksi voidaan asettaa jokin muistiosoite, kuten jonkin muuttujan osoite muistissa. Esimerkiksi seuraava koodi asettaa osoittimen p arvoksi muuttujan x muistiosoitteen:

p = &x;

On myös mahdollista määritellä osoitin ja asettaa muistiosoite samalla kertaa:

int *p = &x;

Kun osoitin p viittaa johonkin kohtaan muistissa, syntaksilla *p voidaan käsitellä kyseisessä kohdassa olevaa tietoa. Esimerkiksi kun osoitin p sisältää muuttujan x muistiosoitteen, *p viittaa muuttujan x sisältöön.

Seuraava koodi näyttää tarkemmin, miten osoitinta voidaan käyttää. Koodi määrittelee muuttujan x ja osoittimen p, joka osoittaa muuttujaan x. Tämän jälkeen koodi muuttaa muuttujan x arvoa osoittimen p kautta.

int x = 123;
int *p = &x;

printf("%d\n", *p); // 123

*p = 42;
printf("%d\n", *p); // 42
printf("%d\n", x); // 42

Muistin käsittely

Katsotaan seuraavaksi, miten voimme käsitellä muistin sisältöä tavu kerrallaan osoittimen avulla.

Seuraava koodi määrittelee osoittimen p, joka osoittaa muuttujan x muistiosoitteeseen. Osoittimen tyyppinä on unsigned char, eli osoitin osoittaa yksittäiseen tavuun muistissa ja tavun sisältö tulkitaan etumerkittömänä kokonaislukuna (kokonaisluku väliltä 0–255).

int x = 123456;
unsigned char *p;
p = (unsigned char*)&x;

Koska &x antaa int-osoittimen, koodissa on tyyppimuunnos (unsigned char*), joka muuttaa osoittimen unsigned char -tyyppiseksi.

Tämän jälkeen voimme lukea muistista muuttujan x sisällön tavu kerrallaan. Koska kyseessä on int-muuttuja, siinä on yhteensä 4 tavua. Ensimmäinen tavu on muistissa kohdassa p, toinen tavu on kohdassa p + 1 jne.

printf("%d\n", *p);
printf("%d\n", *(p + 1));
printf("%d\n", *(p + 2));
printf("%d\n", *(p + 3));

Kun suoritamme koodin, saamme seuraavan tuloksen:

64
226
1
0

Tästä näkee, että luku 123456 on tallennettu muistiin neljänä tavuna 64, 226, 1 ja 0. Tämä vastaa luvun esittämistä seuraavasti:

224 · 0 + 216 · 1 + 28 · 226 + 20 · 64 = 123456

Tässä käytössä on little endian -tavujärjestys, jossa tavut ovat muistissa ”käänteisessä” järjestyksessä: ensimmäisenä vähiten merkitsevä tavu ja viimeisenä eniten merkitsevä tavu. Tätä tavujärjestystä käytetään yleisesti esimerkiksi tietokoneiden prosessoreissa.

Seuraava koodi näyttää luvun kunkin tavun sisällön bittimuodossa. Koodi hakee kunkin tavun sisällön muuttujaan byte ja tulostaa sen bitit.

for (int i = 0; i < 4; i++) {
    char byte = *(p + i);
    printf("byte %d: ", i);
    for (int k = 7; k >= 0; k--) {
        if (byte & (1 << k)) {
            printf("1");
        } else {
            printf("0");
        }
    }
    printf("\n");
}

Koodin tulostus on seuraava:

byte 0: 01000000
byte 1: 11100010
byte 2: 00000001
byte 3: 00000000

Luvun 123456 bittiesitys on siis:

00000000 00000001 11100010 01000000

Rakenteinen tieto

Avainsana struct määrittelee rakenteisen tietotyypin, joka muodostuu nimetyistä kentistä. Struct-tyyppiä voi käyttää koodissa varsin samalla tavalla kuin yksittäisen lukuarvon sisältävää perustietotyyppiä.

Esimerkiksi seuraava koodi määrittelee tietotyypin struct Position, jonka kentät ovat int-tyyppiset koordinaatit x ja y:

struct Position {
    int x, y;
};

Voimme käyttää tietotyyppiä koodissa seuraavasti:

struct Position pos = {2, 3};
printf("%d %d\n", pos.x, pos.y); // 2 3
pos.x = 4;
printf("%d %d\n", pos.x, pos.y); // 4 3

Esimerkki

Seuraava koodi esittelee struct-tyypin käyttämistä hieman laajemmassa ohjelmassa. Koodi määrittelee muuttujat p1 ja p2 ja laskee sitten niiden etäisyyden Pythagoraan lauseella funktiolla distance.

#include <stdio.h>
#include <math.h>

struct Position {
    int x, y;
};

double distance(struct Position a, struct Position b) {
    double dx = a.x - b.x;
    double dy = a.y - b.y;
    return sqrt(dx * dx + dy * dy);
}

int main(void) {
    struct Position p1 = {2, 3};
    struct Position p2 = {5, 4};

    printf("%d %d\n", p1.x, p1.y); // 2 3
    printf("%d %d\n", p2.x, p2.y); // 5 4

    printf("%lf\n", distance(p1, p2)); // 3.162278
}

Koodi käyttää neliöjuuren laskemiseen funktiota sqrt, joka on esitelty standardikirjaston otsikkotiedostossa math.h. Linuxissa tämän otsikkotiedoston kanssa käännöskomennossa tulee olla mukana lippu -lm:

$ gcc points.c -o points -lm

Otsikkotiedosto math.h on poikkeuksellinen, koska koodia kääntäessä tulee käyttää lippua -lm. Muissa standardikirjaston otsikkotiedostoissa ei tarvitse käyttää erillistä lippua käännöskomennossa.

Lippu -lm tarkoittaa, että ohjelman käännöksessä linkitetään mukaan matematiikkakirjasto. Vaikka matematiikkakirjasto kuuluu standardikirjastoon, se on haluttu historiallisista syistä pitää erillisenä kirjastona käännösvaiheessa.

Kopiointi ja osoittimet

Struct-tyypin sisältö kopioidaan muuttujan sijoituksessa ja funktion parametrina. Esimerkiksi seuraava koodi kopioi muuttujan a sisällön muuttujaan b ja muuttaa sen jälkeen muuttujan b kenttää.

struct Position a = {1, 2};
struct Position b;

b = a;
b.x = 5;

printf("%d %d\n", a.x, a.y); // 1 2
printf("%d %d\n", b.x, b.y); // 5 2

Usein kuitenkin struct-muuttujaa käsitellään osoittimen kautta, jos ei ole erityisesti tarvetta tehdä kopiota sen sisällöstä. Tämä voi tehostaa muuttujan käsittelyä, koska muuttujan kenttiä ei tarvitse kopioida vaan riittää välittää osoitin muuttujaan.

Seuraavassa koodissa p on osoitin, joka osoittaa muuttujaan a. Koodi muuttaa muuttujan a kentän x sisältöä osoittimen p kautta. Syntaksi p->x tarkoittaa samaa kuin (*p).x eli se viittaa kentän sisältöön osoittimen kautta.

struct Position a = {1, 2};
struct Position *p;

p = &a;
p->x = 5;

printf("%d %d\n", a.x, a.y); // 5 2
printf("%d %d\n", p->x, p->y); // 5 2

Osoitinta voidaan käyttää myös, kun struct-muuttuja annetaan funktion parametriksi. Esimerkiksi seuraavassa on aiemman funktion distance uusi toteutus, jossa parametreja ei kopioida vaan ne välitetään osoittimina.

double distance(struct Position *a, struct Position *b) {
    double dx = a->x - b->x;
    double dy = a->y - b->y;
    return sqrt(dx * dx + dy * dy);
}

Nyt funktiolle tulee antaa parametrina muuttujien osoitteet:

printf("%lf\n", distance(&p1, &p2));

Tämä muutos voi tehostaa funktion toimintaa, koska funktiolle ei välitetä kopioita struct-muuttujista vaan vain osoittimet.

Tässä tapauksessa struct Position sisältää vain kaksi int-kenttää eli sen sisältö vie vähän tilaa. Tämän takia ei ole käytännössä merkitystä, välitetäänkö tieto kopiona vai osoittimen avulla. Kuitenkin tilanne muuttuisi, jos struct-tyyppi sisältäisi paljon tietoa, jolloin sitä olisi hidasta kopioida.

Kentät muistissa

Struct-tyypin kenttien sisällöt tallennetaan muistiin samassa järjestyksessä kuin kentät esiintyvät tietotyypin määrittelyssä. Kunkin kentän sisältö tallennetaan muistiin samalla tavalla kuin yksittäisen muuttujan sisältö.

Esimerkiksi seuraava koodi esittelee tyypin struct Position muistinkäyttöä:

struct Position pos;
printf("%zu\n", sizeof(pos)); // 8
printf("%p\n", &pos.x); // 0x7ffe5d3fc070
printf("%p\n", &pos.y); // 0x7ffe5d3fc074

Tässä tilanne on selkeä: sizeof(struct Position) antaa tuloksen 8, koska tietotyypissä on kaksi int-tyyppistä kenttää, jotka on sijoitettu peräkkäin muistissa ja kumpikin kenttä vie tilaa 4 tavua. Tilanne muistissa näyttää tältä:

Kuitenkin muistinvaraus monimutkaistuu, jos struct-tyypin osana on eri kokoisia kenttiä. Tällöin kääntäjä yleensä kohdistaa kentät muistissa seuraavasti:

Kohdistus tehostaa koodin toimintaa, koska tietokoneen prosessorin kannalta on helpompaa käsitellä muistissa olevaa tietoa kohdistettuna. Esimerkiksi prosessori pystyy lukemaan muistista int-arvon tehokkaammin, kun se sijaitsee kohdistettuna 4:llä jaollisessa muistiosoitteessa.

Esimerkki 1

struct Test {
    int a;
    char b;
};

Tässä sizeof(struct Test) on 8. Muistialueen lopussa on 3 täytetavua, joiden ansiosta muistialueen yhteiskoko on jaollinen 4:llä (tyypin int koko).

Esimerkki 2

struct Test {
    char a;
    int b;
    char c;
};

Tässä sizeof(struct Test) on 12. Kummankin char-kentän jälkeen on 3 täytetavua, koska int-kentän tulee sijaita 4:llä jaollisessa osoitteessa ja lisäksi muistialueen yhteiskoon tulee olla 4:llä jaollinen.

Esimerkki 3

struct Test {
    char a;
    char c;
    int b;
};

Tässä sizeof(struct Test) on 8. Kentät ovat samat kuin edellisessä esimerkissä, mutta ne on mahdutettu pienempään tilaan järjestämällä ne toisella tavalla.

Voisiko kääntäjä etsiä automaattisesti kenttien järjestyksen, jossa muistia kuluu mahdollisimman vähän? Tämä ei ole mahdollista, koska kääntäjän ei ole sallittua muuttaa kenttien järjestystä muistissa, vaan ohjelmoija voi luottaa kenttien olevan samassa järjestyksessä kuin tietotyypin määrittelyssä.

Esimerkki 4

struct __attribute__((__packed__)) Test {
    char a;
    int b;
    char c;
};

Tässä sizeof(struct Test) on 6, koska olemme ohjeistaneet kääntäjää pakkaamaan kentät tiiviisti ilman kohdistusta. Tämä on gcc-kääntäjän erikoissyntaksi, joka ei toimi välttämättä muissa kääntäjissä.

Vaikka kenttien pakkaaminen säästää tilaa, tämä ei ole koodin tehokkuuden kannalta hyvä ratkaisu, koska kenttää b ei ole kohdistettu oikealla tavalla.

Union-tyyppi

Avainsana union määrittelee union-tyypin, jossa kenttien sisältö tallennetaan päällekkäin muistiin. Esimerkiksi seuraava koodi määrittelee tyypin union Test, jossa on päällekkäin int-muuttuja a ja float-muuttuja b.

union Test {
    int a;
    float b;
};

Seuraava koodi esittelee tyypin käyttämistä:

union Test x;
x.b = 3.14159;
printf("%d\n", x.a); // 1078530000
printf("%f\n", x.b); // 3.141590

Tässä kentän b arvon asettaminen vaikuttaa myös kentän a arvoon, koska kentät ovat päällekkäin muistissa. Tästä näkee, miltä sama muistin sisältö näyttää kokonaisluvuksi ja liukuluvuksi tulkittuna.

Tässä liukuluku 3.14159 on tallennettu IEEE 754 -standardin mukaisena yksinkertaisen tarkkuuden liukulukuna, joka muodostuu seuraavista osista:

  • Etumerkki: 1 bitti (0 = positiivinen, 1 = negatiivinen)
  • Eksponentti: 8 bittiä (lisättynä 127)
  • Mantissa: 23 bittiä (desimaaliosa binäärilukuna)

Luvun bittiesitys on seuraava:

01000000 01001001 00001111 11010000

Tässä tapauksessa osat ovat:

  • Etumerkki: 0 (positiivinen)
  • Eksponentti: 10000000 (vastaa lukua 128)
  • Mantissa: 10010010000111111010000

Luvun suuruus saadaan kaavalla

2128–127 · (1 + 2–1 + 2–4 + 2–7 + 2–12 + 2–13 + 2–14 + 2–15 + 2–16 + 2–17 + 2–19),

joka antaa tuloksena noin 3.14159.

Union-tyyppi tarjoaa vaihtoehtoisen tavan tutkia muuttujan sisältöä tavutasolla. Seuraavassa tietotyypissä on päällekkäin int-muuttuja ja unsigned char -taulukko, jossa on neljä alkiota:

union Test {
    int a;
    unsigned char b[4];
};

Pystymme nyt tutkimaan seuraavasti, miten int-luku muodostuu tavuista:

union Test x;
x.a = 123456;
printf("%d\n", x.b[0]); // 64
printf("%d\n", x.b[1]); // 226
printf("%d\n", x.b[2]); // 1
printf("%d\n", x.b[3]); // 0