Arvutiteaduse instituut
  1. Kursused
  2. 2024/25 kevad
  3. Programmeerimine keeles C++ (LTAT.03.025)
EN
Logi sisse

Programmeerimine keeles C++ 2024/25 kevad

  • Pealeht
  • 1. Muutujad ja andmetüübid
  • 2. Keele põhikonstruktsioonid I
  • 3. Keele põhikonstruktsioonid II
  • 4. Klass, struktuur, mallid
  • 5. Dünaamiline mäluhaldus I
  • 6. Dünaamiline mäluhaldus II
  • 7. Kontrolltöö 1

Seitsmendal nädalal toimub 1. kontrolltöö

1. kontrolltöö näidis on Moodles

  • 8. Dünaamiline mäluhaldus III
8 Dünaamiline mäluhaldus III
8.1 Kodutöö
8.2 Harjutused
8.3 Videolingid
  • 9. STL andmestruktuurid I
  • 10. STL andmestruktuurid II
  • 11. OOP I Klassid
  • 12. OOP II Pärilus ja polümorfism
  • 13. Erindite töötlemine
  • 14. Täiendavad teemad
  • 15. Kontrolltöö 2

Viieteistkümnendal nädalal toimub 2. kontrolltöö

2. kontrolltöö näidis on Moodles

  • 16. Projekti esitlus
  • Mõned viited - vajalikud kaaslased
  • Vanad materjalid
  • Juhendid
  • Viited

Dünaamiline mäluhaldus III

Pärast selle praktikumi läbimist üliõpilane

  • teab, mis vahe on toorviitadel (raw pointers) ja tarkadel viitadel (smart pointers)
  • teab tarkade viitade eri tüüpe (unique_ptr, shared_ptr, weak_ptr)
  • teab, millist tarka viita antud olukorras valida
  • oskab kirjeldada, milliseid probleeme targad viidad lahendavad

Sisukord

1. Targad viidad (smart pointers)
2. Unikaalne viit (unique_ptr)
3. Funktsioonimall make_unique
4. Jagatud viit (shared_ptr)
5. Nõrk viit (weak_ptr)
Enesetestid

Targad viidad (smart pointers)

Kõiki siiani käsitletud viidatüüpe nimetatakse toorviitadeks (raw pointers), sest need muutujad sisaldavad ainult mäluaadresse. Toorviit võib hoida muutuja aadressi või vaba mäluosa aadressi.

Tark viit (smart pointer) on objekt, mis käitub nagu toorviit, aga teeb palju rohkem. Kõige olulisem aspekt on, et tarkade viitade korral ei pea muretsema mälu vabastamise pärast, st ei ole vaja kasutada operaatoreid delete ja delete [] mälu vabastamiseks. Tarkade viitade korral vabastatakse mälu automaatselt.

Seni oleme pidanud kuhjamälu korrektseks kasutamiseks meeles pidama võtmesõnaga new loodud viidad ja need ka õigesti kustutama. Lihtsamate programmide puhul ei ole see raske, kuid suuremate programmide puhul võib muutuda see keeruliseks.

Vaatame järgmist funktsiooni:

int jaga(int jagatav, int jagaja) {
   int *jagatavPtr = new int(jagatav); // loome viida jagatavale
   int *jagajaPtr = new int(jagaja);   // loome viida jagajale
   if (*jagatavPtr == 0) { // kui väärtus = 0, siis tagastame 0
       return 0;
   }
   if (*jagajaPtr == 0) { // kui väärtus = 0, siis tagastame 0
       return 0;
   }
   int tulemus = *jagatavPtr / *jagajaPtr; // teostame tehte
   delete jagatavPtr; // vabastame mälu
   delete jagajaPtr;  // vabastame mälu
   return tulemus; 
}

NB! Funktsioonis kasutatud viidad on vaid näiteks, tegelikult on siin viitade kasutamine ebavajalik.

Peale vaadates tundub funktsioon korrektne, kuid nii see ei ole. Juhul kui jagatav või jagaja väärtus on 0, siis toimub funktsioonist väljumine viitasid vabastamata. Sellistel juhtudel tekib mäluleke.

Funktsiooni on võimalik modifitseerida järgnevalt (lisades mälu vabastamise ka erijuhtude juurde):

int jaga(int jagatav, int jagaja) {
   int *jagatavPtr = new int(jagatav);
   int *jagajaPtr = new int(jagaja);
   if (*jagatavPtr == 0) {
       delete jagatavPtr;
       delete jagajaPtr;
       return 0;
   }
   if (*jagajaPtr == 0) {
       delete jagatavPtr;
       delete jagajaPtr;
       return 0;
   }
   int tulemus = *jagatavPtr / *jagajaPtr;
   delete jagatavPtr;
   delete jagajaPtr;
   return tulemus;
}

Nüüd on funktsioon küll mäluleketeta, kuid lisasime dubleerivalt juurde 4 rida koodi.

Unikaalne viit (unique_ptr)

Sellistes olukordades aitab päises <memory> olev andmestruktuur nimega unique_ptr. Antud andmestruktuur haldab new ja delete väljakutseid meie eest. Soovitud tüüpi objekt luuakse automaatselt kuhjamälus unique_ptr objekti loomisel ning kustutatakse, kui unique_ptr objekti eluiga lõpeb. Järgmiste näidete korral on vajalik lause #include <memory>. Antud näite korral funktsioonis loodud unikaalsete viitade eluiga lõpeb funktsioonist väljumisel.

int jaga(int jagatav, int jagaja) {
   unique_ptr<int> jagatavPtr(new int(jagatav)); // luuakse unikaalne viit jagatavale
   unique_ptr<int> jagajaPtr(new int(jagaja)); // luuakse unikaalne viit jagajale
   if (*jagatavPtr == 0) {
       return 0;
   }
   if (*jagajaPtr == 0) {
       return 0;
   }
   int tulemus = *jagatavPtr / *jagajaPtr; // tehakse tehe
   return tulemus;
}

Enam ei ole vaja loodud viitade jaoks võtmesõna delete kasutada ning mälulekkeid funktsioonis ei teki. Ükskõik millisel funktsioonist väljumisel lõpeb loodud viitade eluiga ning kuhjamälus loodud täisarvud kustutatakse.

Paneme tähele, et unikaalse viidaga opereerimine on identne tavalise viidaga opereerimisele. Sealt väärtuse lugemiseks on võimalik kasutada sümbolit *, klassitüüpi viida puhul on olemas ka operaator ->.

Kui unikaalne viit viitab mingile objektile, siis ta on selle objekti omanik ja ükski teine unikaalne viit ei saa sellele viidata, st unikaalset viita ei saa kopeerida ja unikaalset viita ei saa luua koopia omistamise (copy assignment) teel. Küll aga on võimalik unikaalset viita üle anda teisele unikaalsele viidale (siis esimene hävib). Seda saab teha teisaldusomistamise (move assignment) teel. Kui klassis on isendiväli, mis on unikaalne viit (unique pointer), siis kompilaator kustutab automaatselt selle klassi koopiakonstruktori (copy constructor) ja koopiaomistamise (copy assignment).

Funktsioonimall make_unique

Alternatiivselt on võimalik unikaalset viita luua kasutades funktsioonimalli make_unique:

int main() {
   auto ptr = make_unique<int>(10); // loome unikaalse viida täisarvu 10 jaoks
   cout << "ptr = " << *ptr << '\n'; // ekraanile sisu
   return 0;
}
ptr = 10

Paneme tähele, et nüüd on võimalik kasutada võtmesõna auto ning võtmesõna new ei ole enam tarvis kirjutada. Mall make_unique käitub talle parameetrina antud argumendi tüübi konstruktorina.

See tähendab, et saame klassi

class Klass {
   int m_A{0};
   int m_B{0};
public:
   Klass(int a, int b) : m_A{a}, m_B{b} {} // konstruktor
};

korral klassi objektile unikaalse viida loomiseks kasutada järgmist koodilõiku:

auto klassiObjekt = make_unique<Klass>(1, 2);

NB! Konstruktori ja funktsioonimalli parameetrite arv ja tüübid peavad kokku langema.

Antud funktsioonimalli on mõistlik eelistada konstruktoriga viida loomisele kahel põhjusel:

  • kasutades malli, on võimalik kasutada võtmesõna auto, elimineerides sellega palju tüüpide jmt ümberkirjutamist.
  • mall on loodud paremini erindeid haldama – kui klassi konstruktor peaks viskama mingit sorti erindi, annab funktsioonimalli kasutamine parema võimaluse erindiga tegeleda. Erindeid vaatame tulevastes praktikumides.

Täpsemalt saab lugeda järgneva StackOverflow postituse vastusest ja viidatud linkidest: https://stackoverflow.com/a/37514601.

Unikaalset viita ei tohiks kunagi jagada mingi teise funktsiooni, objekti ega skoobiga. Olgugi, et mõnel korral võib programm töötada ootuspäraselt, siis ei ole see hea tava ning võib tekkida erinevaid algselt nähtamatuid probleeme.

Proovi unikaalsest viidast luua koopia. Mis kompilaator selle peale ütleb? Nt

auto klassiObjekt = make_unique<Klass>(1, 2);
//Klass* p = klassiObjekt; // viga, unikaalset viita ei saa kopeerida

Kõikidel tarkade viitadel on defineeritud operaatorid * ja ->. Samuti on võimalik funktsiooniga get saada targast viidast kätte toorest viita. Viimasega tuleb olla väga ettevaatlik, sest kui nüüd kustutada toores viit, siis viib targa viida kasutamine ettearvamatu tulemuseni.

Olgu meil lihtne struktuur Paar, milles on kaks täisarvu:

struct Paar {
    int m_a, m_b;
    Paar(int a, int b) : m_a{a}, m_b{b} {}
};

Defineerime unikaalse viida ja kasutame operaatoreid * ja ->:

unique_ptr<Paar> paar(new Paar{1, 1});
paar->m_a = 10; // juurdepääs objekti liikmele
Paar* p = paar.get(); 
cout << "paar: " << paar << " " << paar->m_a << " " << (*paar).m_b << '\n';
cout << "p: " << p << " " << p->m_a << " " << (*p).m_b << '\n';
paar: 0x21a511c6810 10 1
p: 0x21a511c6810 10 1

Esimesena kuvatakse viidatava aadress, seejärel kahel viisil viidatava liikmed. Käsuga Paar* p = paar.get() saame muutujasse p unikaalsest viidast paar toore viida (raw pointer). Näeme, et mõlemad aadressid on samad.

NB! Targast viidast funktsiooniga get saadud toorest viita ei tohi käsuga delete kustutada!

Ühele objektile saab korraga viidata üks unikaalne viit. Unikaalset viita saab üle anda teisele unikaalsele viidale (siis esimene hävib). Siin on mitu võimalust:

  • kasutada funktsiooni move, st teha teisaldusomistamine (move assignment)
unique_ptr<Paar> paar(new Paar{1, 1});
cout << "paar: " << paar << " " << paar->m_a << " " << (*paar).m_b << '\n';
// üle andmine uuele viidale
unique_ptr<Paar> paar1{move(paar)};
cout << "paar: " << paar << '\n';
cout << "paar1: " << paar1 << " " << paar1->m_a << " " << paar1->m_b << '\n';
// üle andmine olemasolevale viidale
paar = move(paar1);
cout << "paar: " << paar << " " << paar->m_a << " " << (*paar).m_b << '\n';
cout << "paar1: " << paar1 << '\n';
paar: 0x27b399d1ac0 1 1
paar: 0
paar1: 0x27b399d1ac0 1 1
paar: 0x27b399d1ac0 1 1
paar1: 0

Käsuga unique_ptr<Paar> paar1{move(paar)} antakse viit paar üle uuele unikaalsele viidale paar1, kusjuures viit paar tühistatakse, st saab väärtuse nullptr. Analoogiliselt saab viita üle anda juhul, kui viit on juba olemas. Näiteks, käsuga paar = move(paar1) antakse viit paar1 tagasi viidale paar ja viit paar1 tühistatakse, st saab väärtuse nullptr. Väljatrükist on näha, et viidatava objekti aadress jääb samaks ja ülevõetud viida väärtuseks on 0.

  • kasutades funktsioone release ja reset.

Funktsioon release tagastab viida ja loobub omandist ning funktsioon reset vabastab objekti ja seab viidaks nullptr.

Näiteks,

// loome unikaalse viida make_unique abil
// sulgudes () on konstruktori argumendid
unique_ptr<Paar> paar{make_unique<Paar>(1, 1)};
cout << "paar: " << paar << " " << paar->m_a << " " << paar->m_b << '\n';
// anname viida üle paar1-le
unique_ptr<Paar> paar1{paar.release()}; //uus viit paar1
// paar on nüüd nullptr
// paar1 võttis üle
cout << "paar: " << paar << '\n';
cout << "paar1: " << paar1 << " " << paar1->m_a << " " << paar1->m_b << '\n';
// kui viit on juba olemas, siis kasutada reset
paar = make_unique<Paar>(2, 2);
// paar aadress on uus
cout << "paar: " << paar << " " << paar->m_a << " " << paar->m_b << '\n';
paar.reset(paar1.release()); // paar1 antakse üle paar-ile
// paar1 on nüüd nullptr
// endine paar objekt vabastatakse
// paar võttis üle paar1
cout << "paar1: " << paar1 << '\n';
cout << "paar: " << paar << " " << paar->m_a << " " << paar->m_b << '\n';
paar: 0x1ab5c611ac0 1 1
paar: 0
paar1: 0x1ab5c611ac0 1 1
paar: 0x1ab5c611ae0 2 2
paar1: 0
paar: 0x1ab5c611ac0 1 1

Funktsiooni parameetriks saab olla viide (reference) unikaalsele viidale unique_ptr. Järgmises näites on funktsiooni fun parameetriteks viide unikaalsele viidale ja täisarv: unique_ptr<int>& sp, int a. Funktsiooni fun1 esimeseks parameetriks on unikaalne viit, aga funktsiooni ei ole võimalik olemasoleva unikaalse viidaga välja kutsuda. Küll aga saab funktsiooni fun1 välja kutsuda olemasoleva viida omandi üleandmise (teisaldamise) abil, kasutades funktsiooni move või uue viida loomise abil.

void fun(unique_ptr<int>& sp, int a){
    cout << *sp << " " << sp << '\n';
    *sp += a;
}
void fun1(unique_ptr<int> sp, int a){ 
    cout << *sp << " " << sp << '\n';
    *sp += a;
}
int main(){
    unique_ptr<int> sp{new int(10)};
    cout << *sp << " " << sp << '\n';
    fun(sp, 5); // fun parameetriks on viide (reference)
    cout << *sp << " " << sp << '\n';
   //fun1(sp, 5); // viga, olemasolevat unik. viita ei saa funktsioonile edastada
    *sp += 5; // muudame sp-s hoitavat täisarvu
    fun1(move(sp), 5); // sp omandi üleandmine
    cout << "sp "<< sp << '\n'; // nüüd sp on nullptr
    return 0;
}
10 0x27bc5ec1ac0
10 0x27bc5ec1ac0
15 0x27bc5ec1ac0
20 0x27bc5ec1ac0
sp 0

Unikaalsete viitade kohta saab täpsemalt uurida aadressil https://en.cppreference.com/w/cpp/memory/unique_ptr.

Jagatud viit (shared_ptr)

Kui on vaja loodud viita jagada teiste funktsioonidega, objektidega, skoopidega jne, oleks mõistlik kasutada andmestruktuuri jagatud viit ehk shared_ptr. Jagatud viit tagab selle, et objekt kustutatakse alles siis, kui mitte ükski ressurss (funktsioon, objekt, ...) seda enam ei kasuta. Selle tagamiseks kasutatakse prügikorje tehnikat nimega viidete loendamine, vt täpsemalt https://en.wikipedia.org/wiki/Reference_counting.

Jagatud viida loomine on analoogne unikaalse viida loomisele. Selleks on samuti olemas konstruktor ning funktsioonimall make_shared, mis töötab analoogselt mallile make_unique.

Paneme tähele, et jagatud viita on võimalik kopeerida ja omistada teisele muutujale. Järgnev koodilõik on töötav C++ programm:

class Klass {
    int m_A{0};
    int m_B{0};
public:
    Klass(int a, int b) : m_A{a}, m_B{b} {} // konstruktor

    void set_A(int a){ // isendimuutuja seadmine
        m_A = a;
    }
    void valjasta() { // ekraanile
        cout << "Klass: a = " << m_A << ", b = " << m_B << '\n';
    }
};
void tootleAndmeid(shared_ptr<Klass> klass) { // viit kopeeritakse
    klass->valjasta(); // liikmefunktsiooni kutsumiseks tuleb kasutada -> operaatorit
}
int main() {
    auto klass = make_shared<Klass>(1, 2); // jagatud viida tegemine
    tootleAndmeid(klass);
    klass->valjasta();
    shared_ptr<Klass> klass1 = klass; // teine muutuja hakkab viitama samale objektile
    klass1->set_A(5); // isendimuutuja seadmine
    klass1->valjasta(); // isendiväljad uue viidaga
    klass->valjasta(); // isendiväljad vana viidaga
    return 0;
}
Klass: a = 1, b = 2
Klass: a = 1, b = 2
Klass: a = 5, b = 2
Klass: a = 5, b = 2

Seda, mitu jagatud viita antud objektile viitab, saab teada jagatud viida funktsiooni use_count abil. Jagatud viitade kohta saab täpsemalt uurida aadressil https://en.cppreference.com/w/cpp/memory/shared_ptr.

Nõrk viit (weak_ptr)

Viimasena vaatame nõrka viita ehk andmestruktuuri weak_ptr. Nõrk viit on käitumiselt sarnane jagatud viidale: seda võib jagada, kopeerida jne. Küll aga ei halda see objektide loomist ega kustutamist. Nõrk viit saab viidata objektile, mida haldab jagatud viit (shared_ptr), kuid ta ei käitu otse viidana, st pole "päris". Nõrga viida loomisel saab seda initsialiseerida jagatud viida abil, nt

auto p = make_shared<int>(25);
weak_ptr<int> wp{p};

Siin mõlemad muutujad p ja wp viitavad samale objektile. Kuna nõrk viit ise mälu haldamist ei tee, tuleb selles oleva väärtuse saamiseks kasutada liikmefunktsioone expired ja lock. Funktsioon expired tagastab tõeväärtuse, kas objekt on kustutatud ja lock tagastab jagatud viida objektile (juhul kui see eksisteerib). Nõrga viida korral ei saa kasutada otsendamist (*) ega aadressi väljundvoogu saatmist:

auto p = make_shared<int>(25);
weak_ptr<int> wp{p};
//cout << *wp << '\n'; // viga, weak_ptr ei saa otsendada
//cout << wp << '\n'; // viga, weak_ptr-l ei ole operaatorit <<
if(!wp.expired()){ // kas viidatav jagatud viit on olemas
    cout << wp.lock() << " " << *(wp.lock()) <<'\n';
}
0x1c4313a1ae0 25

Vaatame veel ühte näidet. Siin on main funktsioonis sisse toodud lisaplokk {} abil. Nõrk viit norkKlass deklareeritakse väljaspool sisemist plokki. Sisemises plokis luuakse jagatud viit klass ja initsialiseeritakse selle abil norkKlass. Sisemise ploki lõppedes jagatud viit koos viidatava objektiga hävitatakse ja !norkKlass.expired() annab tulemuseks false.

int main() {
weak_ptr<Klass> norkKlass; // nõrga viida loomine
    {
        auto klass = make_shared<Klass>(1, 2);
        norkKlass = klass; // hakkab viitama jagatud viidale
        norkKlass.lock()->valjasta();
        cout << "Kas norkKlass eksisteerib? " << boolalpha << !norkKlass.expired() << '\n';
    }
    cout << "Kas norkKlass eksisteerib? " << boolalpha << !norkKlass.expired() << '\n';    
   //norkKlass.lock()->valjasta(); 
   // programm võib lõpetada veaga, sest norkKlass ei eksisteeri enam
    return 0;
}
Klass: a = 1, b = 2
Kas norkKlass eksisteerib? true
Kas norkKlass eksisteerib? false

Siin jagatud viit kustutas loodud klassi objekti peale esimest sulgevat loogelist sulgu. Seega antud objekt edaspidi enam mälus ei eksisteeri. Näeme seda ka funktsiooni expired kasutades. Teise funktsiooni valjasta kutse enam midagi ei väljasta või tekitab programmi töö lõppemise veaga (uuri põhjust funktsiooni lock dokumentatsioonist).

Nõrkade viitade kohta saab täpsemalt uurida aadressil https://en.cppreference.com/w/cpp/memory/weak_ptr.

Enesetestid

NB! Enesetestides eeldame, et on kasutatud standardnimeruumi (using namespace std;)

<< Näita enesetesti >>

<< Näita enesetesti >>

<< Näita enesetesti >>

<< Näita enesetesti >>

  • Arvutiteaduse instituut
  • Loodus- ja täppisteaduste valdkond
  • Tartu Ülikool
Tehniliste probleemide või küsimuste korral kirjuta:

Kursuse sisu ja korralduslike küsimustega pöörduge kursuse korraldajate poole.
Õppematerjalide varalised autoriõigused kuuluvad Tartu Ülikoolile. Õppematerjalide kasutamine on lubatud autoriõiguse seaduses ettenähtud teose vaba kasutamise eesmärkidel ja tingimustel. Õppematerjalide kasutamisel on kasutaja kohustatud viitama õppematerjalide autorile.
Õppematerjalide kasutamine muudel eesmärkidel on lubatud ainult Tartu Ülikooli eelneval kirjalikul nõusolekul.
Courses’i keskkonna kasutustingimused