Tietorakenteen kopiointi ja sijoitus

Lukuohje

Tämän luvun alkuosa sisältää taustatietoa aiheesta, johon paneudutaan myöhemmillä ohjelmointikursseilla tarkemmin. Halutessasi voit ohittaa tämän ja siirtyä suoraan luvun lopussa olevaan kohtaan Valmiin kopiorakentajan ja sijoitusoperaattorin estäminen.

Kertaus: Alustus tarkoittaa sitä, että muuttujalle asetetaan alkuarvo samalla kun se esitellään. Sijoitus puolestaan tarkoittaa muuttujan arvon asettamista operaattorilla =.

Tähän saakka alustukseen ja sijoitukseen ei ole ollut pakottavaa tarvettaa kiinnittää mitään huomiota, koska C++ huolehtii automaattisesti siitä, että ohjelmoijan itse määrittelemille tietotyypeillekin on olemassa nk. kopiorakentaja ja sijoitusoperaattori.

Käytännössä tämä tarkoittaa sitä, että seuraava koodi toimii, vaikka ohjelmoija ei ole itse erikseen määritellyt, mitä alustuksen ja sijoituksen yhteydessä pitäisi tapahtua.

class Mun_oma_luokka {
    ...
};
...
Mun_oma_luokka olio;
...
Mun_oma_luokka olio2{olio};  // Kopiorakentajaa kutsutaan
...
olio = olio2;  // Sijoitusoperaattorikin toimii

Jos alustus ja sijoitus jätetään C++:n automaattisesti määrittelemän toiminnallisuuden varaan, oletustoiminta on se, että alustusarvo ja sijoitettava arvo kopioidaan bitti bitiltä kohdearvoksi.

Yksinkertaisten tietorakenteiden kanssa (esimerkiksi Pelaaja-olio Mölkyn pistelaskennassa) tämä on juuri se, mitä halutaan. Mutta jos kopioitava tieto sisältää osoittimia, varsinkin jos ne osoittavat dynaamisesti varattuun tietorakenteeseen, ajaudutaan lähes poikkeuksetta ongelmiin.

Ajatellaan seuraavaa ei sinällään mitenkään monimutkaista tilannetta, jossa käytetään normaaliosoittimien avulla toteutettua tehtävälistaa:

Lista lista;
...
if ( ... ) {
    Lista apulista{ lista };  // HUOM!!!
    ...
}
// Tässä kohdassa homma on mennyt pieleen.

Toinen täysin vastaava tilanne olisi:

Lista lista;
...
if ( ... ) {
    Lista apulista;
    ...
    apulista = lista;  // HUOM!!!
    ...
}
// Tässäkin kohdassa homma on mennyt pieleen.

Pinnallisesti tuossa ei vaikuttaisi olevan mitään väärää, mutta kun piirretään kuva tilanteesta, jossa ollaan, kun HUOM!!!-merkinnällä kommentoitu rivi on suoritettu:

Kuva tilanteesta, jossa ollaan, kun edellisen koodin HUOM-rivi on suoritettu

Ongelma konkretisoituu heti, kun poistutaan lohkosta, jossa apulista on määritelty paikalliseksi muuttujaksi: Sen elinkaari päättyy, jolloin sen purkajaa kutsutaan.

Purkaja vapauttaa kaiken apulistalle varatun muistin, joka sattuu olemaan sama muisti, joka on varattu listalle. Muuttujan lista jäsenmuuttujat jäävät osoittamaan muistiin, joka on jo vapautettu (jäänneviitteitä).

Loppupeleissä edellisen esimerkin pointti on se, että C++:n valmiiksi määrittelemän kopiorakentajan ja sijoitusoperaattorin käyttö tilenteissa, joissa kopioitava arvo sisältää osoittimia, johtaa helposti ongelmiin.

Kannattaa muuten huomata, että seuraavassakin esimerkissä asiat menevät pieleen (miksi?):

void funktio(Mun_oma_luokka muodollinen_parametri) {
    ...
}
...
int main() {
    Mun_oma_luokka todellinen_parametri;
    ...
    funktio(todellinen_parametri);
    ...
}

Mahdolliset ratkaisut sijoitus- ja alustusongelmaan ovat:

  • Estetään itse tehtyjen osoittimia sisältävien tietotyyppien sijoittaminen ja alustaminen kokonaan. Tämä voidaan tehdä siten, että jos jompaakumpaa vaarallista operaatiota yritetään, saadaan käännösvirhe.

  • Toteutetaan tietotyypeille sijoitusoperaattorista ja kopiorakentajasta sellaiset versiot, että ne oikeasti kopioivat alkiot rakenteesta toiseen. Tämä tarkoittaa, että ne varaavat jokaiselle kopiotavalle alkiolle uutta muistia new-käskyllä ja sitä kautta rakentavat tyhjästä uuden kopion alkuperäisestä rakenteesta. Tätä lähestymistapaa kutsutaan syväkopioinniksi (deep copy).

    (Mekanismi, jossa kopioidaan vain muistiosoite (tämä menetelmä siis johtaa helposti ongelmiin), on nimeltään matalakopiointi (shallow copy).)

Tällä kurssilla katsotaan vain helpompi tapa, jossa kopiointi estetään kokonaan. Syväkopioinin pohdinta jätetään myöhemmille opintojaksoille, koska siihen liittyy tässä vaiheessa epäolennaisten C++:n ominaisuuksien selittäminen ja ymmärtäminen.

Valmiin kopiorakentajan ja sijoitusoperaattorin estäminen

C++:n automaattisesti luomien valmiiden kopiorakentajan ja sijoitusoperaattorin käyttö estetään helposti lisäämällä luokan julkiseen rajapintaan:

class Lista {
public:
    Lista(const Lista& alustusarvo) = delete;
    Lista& operator=(const Lista& sijoitusarvo) = delete;
    ...
};

Parametrien tyyppien on oletava const-viitteitä luokan olioon ja sijoitusoperaattorin on palautettava viite luokan olioon.

Tällä kurssilla edellä esitelty esto kannattaa tehdä aina, kun kyseessä on omatekemä tietorakenne, jonka private-osaan kätkeytyy dynaaminen tietorakenne. Näin toimiessaan välttyy monelta virheeltä ja vaikealta debuggausrupeamalta.

Tarkoittaako tämä nyt sitä, että ei ole mahdollista määritellä funktioita, joille annetaan parametrina omatekemiä dynaamisia tietorakenteita? Ei, koska edelleen on mahdollista määritellä sellaisia funktioita, joiden kutsussa kopiorakentajaa ei tarvita: jos parametrin tyyppi on viite tai const-viite, esimerkiksi:

bool lue_tehtavatiedosto(Lista& tehtavat);
bool talleta_tehtavatiedosto(const Lista& tehtavat);

Noissahan ei kummassakaan ole mitään tarvetta estetyn kopiorakentajan käytölle.

Kopiorakentajaa tarvitaan vain silloin, kun kyseessä on arvoparametri, koska arvoparametri alustetaan todellisesta parametrista kopiorakentajan avulla.