Älykkäät osoittimet

Vaikka kaikki dynaamisen muistin käsittely voidaan aina toteuttaa edellä esiteltyjen C++-kielen osoittimien sekä new- ja delete-käskyjen avulla, niiden käyttö on usein melko sotkuista. Varsinkin monimutkaisia dynaamisia rakenteita käsiteltäessä päädytään hyvin usein tilanteeseen, jossa kaikkea new’llä varattua muistia ei muisteta vapauttaa deletellä. Tämän tehtävän helpottamiseksi C++ tarjoaa valmiina työkaluina nk. älykkäitä osoittimia (smart pointer).

C++:n älykkäät osoittimet 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.

Älykkäät osoitintyypit ovat siitä mukavia, että niiden käyttö ei radikaalisti eroa normaalien osoittimien käytöstä, mutta kaupan päälle saavutetaan se ilo, että ohjelmoijan ei tarvitse itse murehtia muistin vapauttamisesta.

Ä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.

shared_ptr-osoittimet

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( new 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_2 = *int_oso_2 - 4;
    int_oso_1 = int_oso_2;

    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;
}
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.
Sama toimenpide kuin edellisellä rivillä, mutta käytetään shared_ptr-osoittimen alkuarvona make_shared-funktion muodostamaa osoitinta. Erona edelliseen on se, että tämä tapa on nopeampi.
shared_ptr-osoittimia käytetään samantyylisesti kuin tavallisiakin osoittimia.

Tulostetaan metodilla use_count kummastakin osoittimesta 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_1 osoittamaan samaan muistiosoitteeseen kuin int_oso_2. Nyt dynaamisesti varattuun muistiin, johon int_oso_1 alunperin osoitti, ei ole enää jäljellä yhtään shared_ptr-tyyppistä osoitinta: varattu muisti vapautetaan automaattisesti.
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 2

Huomaa, kuinka esimerkissä ei ole yhtään delete-käskyä, vaikka dynaamista muistia varataan sekä new’llä että make_shared-funktiolla. Tämähän juuri oli älykkäiden osoittimien idea, vastuu dynaamisesti varatun muistin vapauttamisesta on siirretty shared_ptr-olioille. Tässä käytetään usein termiä omistaja (owner), joka on vain hienompi termi kuvaamaan sitä, kenen vastuulle muistin vapauttaminen kuuluu.

Edellisessä esimerkissä verrattiin shared_ptr-osoittimien käyttämistä tavallisten osoittimien käyttämiseen. 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( new double );
    ...
    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(normaaliosoitin == sharedosoitin.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 {
    // Tässä kohdin jotain muita kenttiä.
    // ···
    shared_ptr<Testi> osoite;
};

int main() {
    shared_ptr<Testi> oso1(new Testi);
    shared_ptr<Testi> oso2(new 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.

Tehtävälista shared_ptr-osoittimilla

Tutustu Qt Creatorissa projektiin examples/10/task_list_v2. Jos haluat myös suorittaa ja editoida ohjelmakoodia, kopioi se student-hakemiston alle.

Projekti sisältää edellisen kierroksen esimerkkiä vastaavan listarakenteen toteutuksen shared_ptr-osoittimia käyttäen. Muokatussa esimerkissä ei ole algoritmisesti muuta uutta kuin se, että List-luokalle ei ole tarvinnut toteuttaa purkajaa (tai käyttää delete-käskyjä missään muussakaan yhteydessä), koska shared_ptr-osoittimet huolehtivat muistin vapauttamisesta, kun mikään ei enää osoita siihen.

Huomaa kuitenkin, että kaikki linkitetyn listan käsittelemiseen liittyvät eri tilanteet täytyy huomioida samoin kuin tavallisillakin osoittimilla (ensimmäisen alkion lisääminen tyhjään listaan, viimeisen alkion poistaminen listasta, jne).