Älykkäät osoittimet

C++:n älykkäät osoittimet (smart pointer) ovat kirjastotietotyyppejä, jotka automatisoivat dynaamisesti varatun muistin vapauttamisen sen jälkeen, kun kukaan ei enää viittaa siihen. Selkokielellä sanottuna siis: varattu muisti vapautetaan automaattisesti, kun ohjelmassa ei enää ole yhtään (älykästä) osoitinmuuttujaa, joka osoittaa kyseiseen muistialueeseen.

Dynaamiseen muistinhallintaan voitaisiin käyttää myös tavallisia osoittimia, mutta silloin ohjelmoijan vastuulle jäisi myös muistin vapauttaminen, mikä helposti unohtuu. Tällä kurssilla dynaamiseen muistinhallintaan käytetään vain älykkäitä osoittimia.

Älykkäiden osoittimien käyttö vaatii ohjeman alkuun rivin

#include <memory>

josta saadaan käyttöön tyypit

shared_ptr
unique_ptr
weak_ptr

Tällä kurssilla niistä tutustutaan vain shared_ptr-tyyppiin.

Jaetut osoittimet (shared_ptr)

Muistin automaattinen vapauttaminen perustuu viitelaskureihin. Tällöin pidetään kirjaa siitä, miten monta osoitinta dynaamiseen muuttujaan osoittaa. Kun viitelaskurin arvo on nolla (eli dynaamiseen muuttujaan ei osoita yksikään osoitin), muuttujan muistitila voidaan vapauttaa. Viitelaskurin arvo saadaan selville kutsumalla funktiota use_count.

Älykkäiden (jaettujen) osoittimien luomiseen on erityinen funktio make_shared, jolla on seuraavia ominaisuuksia:

  • Muuttujan elinkaari alkaa sillä hetkellä, kun make_shared-funktiota kutsutaan.

  • Dynaamisella muuttujalla ei ole nimeä, mutta make_shared-operaation paluuarvoksi evaluoituu osoitin, jonka arvo kertoo, missä kohdassa keskusmuistia uusi dynaaminen muuttuja sijaitsee.

  • Jotta muuttujalla voisi tehdä jotain hyödyllistä, sen osoite (make_shared-funktion paluuarvo) on talletettava osoitinmuuttujaan.

  • Jos muuttuja on tyypiltään luokka, make_shared huolehtii rakentajan kutsumisesta. Tällöin make_shared-funktiolle annetaan parametreina rakentajan vaatimat parametrit.

Yksinkertainen esimerkki shared_ptr-osoittimen käytöstä:

#include <iostream>
#include <memory>  // Tämä pitää muistaa.

using namespace std;

int main()
{
    shared_ptr<int> int_oso_1 = make_shared<int>(1);
    {
        shared_ptr<int> int_oso_2( make_shared<int>(9) );

        cout << *int_oso_1 << " " << *int_oso_2 << endl;
        cout <<  int_oso_1 << " " <<  int_oso_2 << endl;
        cout <<  int_oso_1.use_count() << " " <<  int_oso_2.use_count() << endl << endl;

        *int_oso_1 = *int_oso_1 + 4;
        int_oso_2 = int_oso_1;

        cout << *int_oso_1 << " " << *int_oso_2 << endl;
        cout <<  int_oso_1 << " " <<  int_oso_2 << endl;
        cout << int_oso_1.use_count() << endl;
     }
     cout << int_oso_1.use_count() << endl;
 }

Määritellään shared_ptr-tyyppinen osoitinmuuttuja, johon voidaan tallentaa int-tyyppisen muuttujan muistiosoite. Alustetaan se osoittamaan dynaamisesti new’llä varattuun muuttujaan, jonka arvo on 1.

Määritellään toinenkin shared_ptr-osoitin, mutta nyt arvona on 9.

Käytetään shared_ptr-osoittimia.

Tulostetaan metodilla use_count osoittimen viitelaskurin arvo eli tieto siitä, kuinka monta shared_ptr-tyyppistä osoitinta yhteensä osoittaa siihen new’llä varattuun muistialueeseen, johon use_count-metodin kohteena oleva osoitin osoittaa.

Kun viitelaskurin arvo laskee nollaan, varattu muisti vapautetaan automaattisesti.

Esimerkin olennaisin asia: asetetaan int_oso_2 osoittamaan samaan muistiosoitteeseen kuin int_oso_1. Nyt dynaamisesti varattuun muistiin, johon int_oso_1 alunperin osoitti, osoittaakin kaksi shared_ptr-tyyppistä osoitinta.

Ohjelmalohko, jossa int_oso_2 esiteltiin, päättyy. Tällöin myös muuttujan int_osa_2 elinikä päättyy. Nyt dynaamisesti varattuun muistiin, johon int_oso_1 alunperin osoitti, osoittaa enää yksi shared_ptr-tyyppistä osoitinta.

Muuttujien int_oso_1 ja int_oso_2 elinikä päättyy, jolloin niiden osoittamaan muistialueseen ei ole enää jäljellä yhtään shared_ptr-osoitinta: varattu muisti vapautetaan automaattisesti.

Ohjelman suoritus tuottaa seuraavat tulosteet:

1 9
0x2589010 0x2589060
1 1

5 5
0x2589060 0x2589060
2
1

Muista tässä ja muissakin suoritusesimerkeissä, joissa osoittimien arvoja tulostetaan näytölle, että muistiosoitteen arvo saattaa vaihdella eri suorituskerroilla.

Tarkastellaan vielä viitelaskureita seuraavan koodin tapauksessa:

Person person; // Person-olio
shared_ptr<Person> p1 = make_shared<Person>(person);
shared_ptr<Person> p2 = make_shared<Person>(person);
shared_ptr<Person> p3 = p1;

cout << p1.use_count() << endl; // tulostuu 2
cout << p2.use_count() << endl; // tulostuu 1
cout << p3.use_count() << endl; // tulostuu 2

Vaikka koodin perusteella voisi kuvitella, että osoittimet p1 ja p2 osoittaisivat samaan olioon, näin ei kuitenkaan viitelaskureiden kannalta ole. Asiaa valaisee alla oleva kuva:

Viitelaskureiden arvot

Funktio make_shared varaa muistia sen verran, kuin parametrina annettuna olio tai tyyppi tarvitsee. Yllä olevassa koodissa muistia varataan kaksi kertaa Person-olion tarvitsema määrä.

Jaettujen osoittimien ominaisuuksia

Lähes kaikki operaatiot (unaarinen *, ->, vertailu ja tulostus), jotka toimivat tavallisille osoittimille, toimivat myös shared_ptr-osoittimille. Suurimpana erona on, että operaattorit ++ ja -- eivät toimi shared_ptr-osoittimille. Lisäksi sijoitus toimii toisesta samantyyppisestä shared_ptr-osoittimesta.

Käydään vielä läpi muutama muu shared_ptr-olioiden hyödyllinen ominaisuus, joille saattaa joskus tulla tarvetta:

  • Jos shared_ptr-osoittimesta pitää saada muistiosoite normaalina C++-osoittimena, se tapahtuu get-metodilla:

    shared_ptr<double> shared_double_ptr = make_shared<double>( 1.0 );
    ...
    double *normaali_double_ptr = nullptr;
    ...
    normaali_double_ptr = shared_double_ptr.get();
    
  • shared_ptr-osoittimeen ei voi sijoittaa =-operaattorilla normaaliosoitinta.

  • shared_ptr-osoittimeen voi kuitenkin sijoittaa nullptr:in.

  • shared_ptr-osoitinta ei voi vertailla suoraan normaaliosoittimeen. Vertailu onnistuu kuitenkin, jos shared_ptr-osoitin muutetaan get-metodilla normaaliosoittimeksi, esimerkiksi:

    if(normaali_osoitin == shared_osoitin.get()) {
        ...
    }
    
  • shared_ptr-osoitinta voi vertailla nullptr:iin.

Tyypillä shared_ptr on yksi hankala ominaisuus: Jos niiden avulla muodostetaan “silmukka”, muistia ei koskaan vapauteta:

#include <iostream>
#include <memory>

using namespace std;

struct Testi
{
    // muita kenttiä
    // ···
    shared_ptr<Testi> osoite;
};

void fun()
{
    shared_ptr<Testi> oso1 = make_shared<Testi>( ... );
    shared_ptr<Testi> oso2 = make_shared<Testi>( ... );

    oso1->osoite = oso2;
    oso2->osoite = oso1;
}

Edellisestä on hyvä piirtää kuva, jotta ymmärtää kyseessä olevan eräänlainen muna-ennen-kanaa -ongelma. Nyt oso1:n osoittamaa muistia ei voida vapauttaa, koska osoitin oso2->osoite osoittaa siihen. Mutta toisaalta oso2:n osoittamaa muistia ei myöskään voi vapauttaa, koska oso1->osoite osoittaa siihen.

Esimerkki: Varaukset

Tarkastellaan tämän viikon esimerkkiä examples/06/reservations/. Siinä käsitellään kirjaston kirjojen varaamista viitelaskureiden avulla. Kun kirja varataan, laitetaan uusi jaettu osoitin osoittamaan kirjaa. Tällöin viitelaskurin arvo kertoo varausten määrän.