Tie koodariksi

C++-ohjelmointi

Luku 11: Muistin käsittely

Muuttuja muistissa

Jokaisella muuttujalla on tietty paikka tietokoneen muistissa. Saamme selville muuttujan muistiosoitteen &-operaattorin avulla.
int a = 5;
cout << &a << "\n";
Koodin tulostus voi olla vaikkapa seuraava:
0x7ffcb35f5724
Tämä on heksamuodossa ilmoitettu muistiosoite, johon on tallennettu muuttujan sisältö.

Osoittimet

Osoitin (pointer) on muuttuja, joka osoittaa johonkin muistiosoitteeseen. Osoittimen määrittelyssä sen nimen edessä on *-merkki. Tämän jälkeen voimme laittaa osoittimen osoittamaan johonkin muuttujaan ja käsitellä muuttujaa osoittimen kautta *-merkin avulla.

Esimerkiksi seuraava koodi luo osoittimen p ja laittaa sen osoittamaan muuttujaan a. Tämän jälkeen muutamme osoittimen kautta muuttujan arvoa.

int a = 5;
int *p;
p = &a;
*p = 8;
cout << a << "\n"; // 8
Jos osoitin ei osoita mihinkään, tämän voi ilmaista arvolla nullptr (null pointer):
int *p;
p = nullptr;
Samaa tarkoittava merkintä on myös C-kielen mukainen NULL, jota käytettiin etenkin aiemmin.

Osoitin vs. viittaus

C++:ssa on kaksi samantapaista käsitettä: osoitin ja viittaus. Seuraavassa koodissa funktion testi parametrina on osoitin, jonka avulla muutos välittyy kutsukohtaan.
void testi(int *x) {
    *x = 5;
}

int main() {
    int x = 2;
    testi(&x);
    cout << x << "\n"; // 5
}
Seuraava koodi puolestaan käyttää viittausta, ja sen toiminta on sama:
void testi(int &x) {
    x = 5;
}

int main() {
    int x = 2;
    testi(x);
    cout << x << "\n"; // 5
}
Asiaa voi ajatella niin, että viittaus on osoitin, jonka käyttöä on rajoitettu. Osoittimen voi asettaa osoittamaan toiseen muuttujaan, mutta viittaus ei koskaan muutu sen luomisen jälkeen. Viittauksen käyttäminen on helpompaa, joten sitä kannattaa käyttää, jos ei ole tarvetta osoittimen ominaisuuksille.

Taulukko muistissa

Taulukon kaikki alkiot ovat peräkkäin muistissa yhtenäisellä alueella. Saamme taulukon ensimmäisen alkion muistiosoitteen taulukon nimen kautta, koska t tarkoittaa samaa kuin &t[0].

Voimme muuttaa osoittimen kohtaa muistissa yhteen- ja vähennyslaskun avulla. Esimerkiksi operaattorit ++ ja -- siirtävät osoitinta yhden alkion verran eteenpäin ja taaksepäin.

Seuraava koodi luo osoittimen p, joka viittaa aluksi taulukon alkuun. Sitten koodi tulostaa osoittimen sijainnin ja vastaavan arvon, siirtää osoitinta askeleen eteenpäin ja toistaa tulostamisen.

int t[5] = {1,2,3,4,5};
int *p = t;
cout << p << " " << *p << "\n";
p++;
cout << p << " " << *p << "\n";
Koodin tulostus voi olla seuraava:
0x7ffd17c1e070 1
0x7ffd17c1e074 2
Huomaa, että koska int-muuttujan koko on 4 tavua, muistiosoite kasvaa 4:llä.

Staattinen muistinvaraus

Staattinen muistinvaraus tarkoittaa, että muuttujalle varataan muistia ohjelman alussa ja muisti säilyy varattuna koko ohjelman suorituksen ajan. Tavallisin esimerkki tästä on globaali muuttuja, joka on määritelty ohjelman päätasolla. Esimerkiksi seuraavassa koodissa x on globaali muuttuja:
int x; // alkuarvo 0

void testi() {
    x++;
    cout << x << "\n";
}

int main() {
    testi(); // tulostaa 1
    testi(); // tulostaa 2
}
Myös funktion sisällä voi määritellä staattisen muuttujan static-sanan avulla. Tällainen muuttuja on käytettävissä vain funktion sisällä, mutta sille varataan kiinteä määrä muistia ohjelman alussa. Niinpä muuttujan arvo säilyy tallessa, jos funktiota kutsutaan monta kertaa.
void testi() {
    static int x; // alkuarvo 0
    x++;
    cout << x << "\n";
}

int main() {
    testi(); // tulostaa 1
    testi(); // tulostaa 2
}

Dynaaminen muistinvaraus pinosta

Pino (stack) on muistialue, josta varataan tilaa funktion suorittamista varten. Pinosta varataan tilaa funktion parametreille ja muille funktiokutsuun liittyville tiedoille sekä funktion sisällä luotaville paikallisille muuttujille. Kun funktion suoritus päättyy, sille varattu tila poistetaan pinosta.

Tarkastellaan esimerkkinä seuraavaa ohjelmaa:

void testi(int a) {
    int b = 2*a;
    cout << b << "\n";
}

int main() {
    testi(1);
    testi(3);
}
Ohjelman suorituksen aikana pinon sisältö muuttuu suunnilleen näin: Pinon ansiosta samaa muistia pystyy käyttämään uudestaan, kun funktiota kutsutaan useita kertoja.

Huomaa, että pinon enimmäiskoko on usein oletuksena melko pieni. Esimerkiksi Linuxissa pinon enimmäiskoko voi olla 8 megatavua. Tämän vuoksi ei ole hyvä idea varata funktiossa suurta taulukkoa tähän tapaan:

int main() {
    int t[10000000];
}
Taulukko ei ehkä mahdu pinoon ja ohjelma päättyy virheeseen. Ohjelma toimii kuitenkin mainiosti, kun vastaava taulukko luodaan esimerkiksi globaalisti päätasolla.

Dynaaminen muistinvaraus keosta

Ohjelma voi varata myös dynaamisesti muistia muistialueelta nimeltä keko (heap). Komento new varaa halutun määrän muistia keosta, ja komento delete vapauttaa aiemmin varatun muistin.

Esimerkiksi seuraava koodi varaa keosta muistia int-muuttujalle ja vapauttaa sitten muistin:

int *x = new int;
*x = 5;
delete x;
Taulukolle voi varata muistia melko samalla tavalla. Esimerkiksi seuraava ohjelma varaa muistia 10-alkioiselle taulukolle ja vapauttaa sitten muistin.
int *x = new int[10];
x[3] = 5;
delete[] x;
Keosta varaamisen etuna on, että siinä voi hallita täysin, milloin muistia varataan ja vapautetaan. Lisäksi keosta varattavan muistin määrää ei ole rajoitettu pinon tavoin, vaan on mahdollista varata suuri määrä muistia funktion sisällä. Muistin varaaminen keosta on kuitenkin hankalaa, eikä sitä kannata käyttää turhaan. C++:n tietorakenteet, kuten merkkijono string, varaavat sisäisesti muistia keosta.