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

Programmeerimine keeles C++ 2023/24 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
  • 9. STL andmestruktuurid I
  • 10. STL andmestruktuurid II
  • 11. OOP I Klassid
11 OOP I Klassid
11.1 Kodutöö
11.2 Harjutused
11.3 Videolingid
  • 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öö

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

OOP I Klassid

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

  • kasutada võtmesõna explicit
  • delegeerida konstruktoreid
  • kasutada võtmesõna this
  • teha vahet rvalue ja lvalue vahel
  • teha vahet objekti madala koopia (shallow copy) ja sügava koopia vahel (deep copy)
  • luua koopiakonstruktorit (copy constructor) ja teab kuidas seda kasutada
  • luua teisalduskonstruktorit (move constructor) ja teab kuidas seda kasutada
  • üle defineerida operaatoreid
    • koopiaomistamine (copy assignment)
    • teisaldusomistamine (move assignment)
  • paigutada klassiga seotud koodi mitmesse faili
  • luua destruktorit (destructor)
  Sisukord 
1. Võtmesõna explicit7. Koopiakonstruktor (copy constructor)13. Piilufunktsioonid (accessor functions ja mutator functions)
2. Konstruktorite delegeerimine8. Koopiaomistamine (copy assignment)14. Klass mitmes failis
3. Võtmesõna this9. Teisalduskonstruktor (move constructor)Enesetestid
4. lvalue ja rvalue10. Teisaldusomistamine (move assignment) 
5. rvalue viited (rvalue references)11. Operaatorite üledefineerimine (operator overloading) 
6. Madal (shallow) koopia ja sügav (deep) koopia12. Destruktorid (destructors) 

Klassidest (class)

Esialgne tutvus klassidega oli neljandal nädalal (klass, klassi liikmed, konstruktorid). Jätkame siin lähemalt tutvumist.

Võtmesõna explicit

Kui klassis on ühe parameetriga konstruktor, siis objekti loomiseks võib kasutada kas initsialiseerimist või omistamist. Järgmises näites on klassi A isendi loomiseks kasutatud käske A a1{5}; või A a2 = 4;. Mõlemad loovad objekti, mille isendimuutuja m_a väärtuseks on kas 5 või 4.

#include <iostream>
using namespace std;

class A{
public:
    A(int a): m_a{a}{};
    void kuva(){
        cout << "m_a: " << m_a << "\n";
    }
private:
    int m_a{};
};

int main() {
    A a1{5};
    a1.kuva();
    A a2 = 4; // luuakse objekt a2
    a2.kuva();
    return 0;
}
m_a: 5
m_a: 4

On olukordi, kus selline omistamine võib tekitada mittesoovitud tulemuse. Järgmises näites annab klassi Kuup isendite ruumala võrdlemine soovitud tulemuse, kuid ruumala võrdlemise arvuga if (kuup1.kas_ruumala_suurem(100)) tekitab uue kuubi, mille ruumala on 1000000.

#include <iostream>
using namespace std;

class Kuup{
public:
    Kuup() = default;
    Kuup(double külg): m_külg{külg}{};
    double ruumala(){
        return m_külg * m_külg * m_külg;
    }
    bool kas_ruumala_suurem(Kuup k){
        return ruumala() > k.ruumala();
    }
private:
    double m_külg{};
};

int main() {
    Kuup kuup1{5};
    Kuup kuup2{2};
    if (kuup1.kas_ruumala_suurem(kuup2)){
        cout << "kuup1 ruumala on suurem kui kuup2 ruumala\n";
    }
    else{
        cout << "kuup2 ruumala on suurem kui kuup1 ruumala\n";
    }
    if (kuup1.kas_ruumala_suurem(100)){
        cout << "kuup1 ruumala on suurem kui 100\n";
    }
    else{
        cout << "kuup1 ruumala on väiksem kui 100\n";
    }
    return 0;
}

kuup1 ruumala on suurem kui kuup2 ruumala
kuup1 ruumala on väiksem kui 100

Seda kõrvalefekti saab vältida võtmesõnaga explicit konstruktori ees, mis sunnib alati objekti loomiseks ilmutatult konstruktorit kasutama.

explicit Kuup(double külg): m_külg{külg}{};

Kuna funktsioonis kas_ruumala_suurem on parameetriks Kuup k, siis võrdlemine kuup1.kas_ruumala_suurem(100) annab nüüd vea, sest parameetriks peab olema Kast tüüpi objekt, mitte arv. Üldine soovitus ongi lisada ühe parameetriga konstruktori ette võtmesõna explicit.

Konstruktorite delegeerimine

Klassis võib olla mitu konstruktorit, st konstruktoreid saab üle defineerida. Koodi kordamise vältimiseks võib üks konstruktor pöörduda teise poole. Järgmises näites on kuubi konstruktoris kasti konstruktori poole pöördumine explicit Kast(double külg): Kast{külg, külg, külg}. Kuubi loomisel täidetakse kasti konstruktor 1. konstruktoris: ... ja seejärel kuubi konstruktor 2. konstruktoris: ....

#include <iostream>
using namespace std;

class Kast {
public:
    Kast() = default;
    Kast(double pikkus, double laius, double kõrgus): m_pikkus{pikkus}, 
        m_laius{laius}, m_kõrgus{kõrgus}{
        cout << "1. konstruktoris: " << pikkus << "\n";
    };
    explicit Kast(double külg): Kast{külg, külg, külg}{
        cout << "2. konstruktoris: " << külg << "\n";
    };
private:
    double m_pikkus{};
    double m_laius{};
    double m_kõrgus{};
};

int main() {
    Kast kast1{1.1, 2.2, 3.3};
    Kast kuup{4.4};
    return 0;
}
1. konstruktoris: 1.1
1. konstruktoris: 4.4
2. konstruktoris: 4.4

Võtmesõna this

Võtmesõna this kasutatakse viidana (pointer) jooksvale objektile. Kui on pöördumine objekti liikmefunktsiooni poole, siis kompilaator lisab pöördumisele peidetud viida vastavale objektile. Näiteks pöördumises

kast1.ruumala();
on peidetud viit Kast* tüüpi ja viitab isendile kast1 (st on objekti kast1 aadress). Seda viita saab funktsiooni sees kasutada:

   double ruumala(){
        return this->m_pikkus * this->m_laius * this->m_kõrgus;
    } 

Siin this->m_külg viitab klassi Kast konkreetse objekti kast isendimuutujale m_külg. Tegelikult just selliselt kompilaator funktsiooni realiseeribki. Täiendame funktsiooni ruumala

double ruumala() {
    cout << "this: " << this << "\n";
    return this->m_pikkus * this->m_laius * this->m_kõrgus;
}

ja loome main funktsioonis Kast isendi

 Kast kast{1.1, 2.2, 3.3};
 cout << "&kast: " << &kast << "\n";
 cout << "kast.ruumala(): " << kast.ruumala() << "\n";

Ekraanil kuvatud objekti kast aadress ja this viit on samad.

&kast: 0xcf64bff880
kast.ruumala(): this: 0xcf64bff880
7.986

lvalue ja rvalue

Iga avaldis on kas lvalue või rvalue. Neid sõnu näeb üsna sageli kompilaatori veakirjeldustes. Tegelikult, lvalue on avaldis, millega on seotud asukoht mälus. Omistamise vasakul poolel peab olema lvalue. Siiski, lvalue võib olla omistamise nii vasakul kui ka paremal poolel. Kõik muutujad on lvalue.

rvalue on ajutine komponent, mis ei eksisteeri kauem kui avaldis seda kasutab. Tal on väärtus, kuid talle ei saa omistada väärtust. Literaalid on rvalue. Järgmiseks on lihtsad näited lvalue ja rvalue kohta.

int i;
i = 5*6; 

Teisel real i on lvalue, kuid avaldise 5*6 tulemuseks on rvalue (30).

Järgmine avaldis ei kompileeru, sest vasakul pool on rvalue:

5*6 = i; 

Üldiselt, avaldis muutub lauseks (käsuks), kui lisada lõppu semikoolon ;. Mõlemad järgmised read on käsud

30;
abs(-2); 

Esimene rida on rvalue 30, kuid kuna ta on ajutine, siis pole tal mingit mõju ja C++ kompilaator optimeerib ta ära. Teises reas pöördutakse funktsiooni abs poole ja tulemus on rvalue, mida edaspidi ei kasutata ning kompilaator optimeerib ta ära. See illustreerib funktsiooni poole pöördumist tagastusväärtust kasutamata.

rvalue viited (rvalue references)

C++11 defineerib uut tüüpi viite (reference), milleks on rvalue viide. Enne C++11 ei olnud võimalust koodis näidata, kas edastatud rvalue on ajutine või mitte. Kui funktsioonile antakse edasi viide (reference) objektile, siis funktsioon peab olema ettevaatlik, et mitte muuta viidet, sest see mõjutaks objekti, millele see viitab. Kui viide (reference) on ajutisele objektile, siis on funktsioonil vabad käed, sest objekt ei ole pärast funktsiooni lõpetamist 'elus'. C++11 võimaldab kirjutada koodi spetsiaalselt ajutiste objektide jaoks selliselt, et ajutiste objektide korral saab neist andmeid lihtsalt teisaldada (move). Seevastu kui viide ei ole ajutisele objektile, siis tuleb andmed sealt kopeerida. Kui andmed on suured, siis hoiab teisaldamine ära potentsiaalselt kuluka mälu eraldamise ja kopeerimise.

Vaatame järgmist koodi

#include <iostream>
using namespace std;

string globaalne{"globaalne"};

string& getGlobaalne(){
    return globaalne;
}
string& getStaatiline(){
    static string str {"staatiline"};
    return str;
}
string getAjutine(){
    return "ajutine";
}

int main() {
    cout << getGlobaalne() << '\n';
    cout << getStaatiline() << '\n';
    cout << getAjutine() << '\n';
    return 0;
}
globaalne
staatiline
ajutine

Kõiki kolme funktsiooni saab kasutada string tüüpi muutujale omistamiseks. Esimesed kaks funktsiooni tagastavad nn 'elus' objekti, kuid kolmas tagastab ajutise objekti. Funktsioonis getAjutine tagastatud sõne eksisteerib nii kaua kuni täidetakse funktsiooni väljakutsuvat käsku.

Selgituseks:

Avaldis string globaalne{"globaalne"} defineerib globaalse muutuja globaalne, mis on elus kuni programmi töö lõpuni. Funktsiooni getStaatiline sees defineeritakse käsuga static string str {"staatiline"} muutuja str, mis on kättesaadav funktsiooni poole pöördumisel. Selline muutuja on kasulik näiteks juhul, kui on vaja loendada funktsiooni poole pöördumisi. Klassi staatiline static liige on kättesaadav koos klassi (mitte objekti) nimega, sest ta on ühine kõigile selle klassi objektidele.

NB! Praktikas kasutatakse globaalset ja staatilist muutujat harva ja see peab olema väga põhjendatud!

Vaatame järgmist funktsiooni

void sõneKasutamine(string& rs){
    string s{rs}; // tehakse koopia
    for (size_t i{}; i < s.length(); ++i)    {
        if (s[i] == 'a' )
            s[i] = '_';
    }
    cout << s << '\n';
}

Siin luuakse uus sõne ja muudetakse seda (tähemärk a asendatakse märgiga _). Esialgne viitega (reference) edastatud sõne string& rs ei muutu.

Funktsioonile saab edastada ka rvalue viidet. See tähendab, et seda objekti on võimalik muuta. Funktsioonil on järgmine kuju:

void sõneKasutamine(string&& s) {
    for (size_t i{}; i < s.length(); ++i) {
        if (s[i] == 'a')
            s[i] = '_';
    }
    cout << s << '\n';
}

Siin on eelmise funktsiooniga võrreldes kaks muudatust. Esiteks, funktsiooni parameeter on rvalue viide string&& s. Teiseks, me ei defineeri uut muutujat, vaid muudame funktsiooni parameetrina edastatud objekti. Me teame nüüd, et string&& s on ajutine objekt ja seda võib muuta ja uut muutujat ei ole vaja defineerida. Nüüd on meil kaks üledefineeritud funktsiooni (void sõneKasutamine(string& rs) ja void sõneKasutamine(string&& s)) ja kompilaator valib, kumba kasutada:

string s{"Tabasalu"};
sõneKasutamine(getGlobaalne()); // string& versioon
sõneKasutamine(getStaatiline()); // string& versioon
sõneKasutamine(getAjutine()); // string&& versioon
sõneKasutamine(s); // string& versioon
sõneKasutamine("Tabasalu"); // string&& versioon
glob__lne
st__tiline
_jutine
T_b_s_lu
T_b_s_lu

Märgime siin, et getGlobaalne() ja getStaatiline() tagastavad viite objektile, mis on elus programmi töö lõpuni ja seetõttu kompilaator valib funktsiooni, kus parameetriks on lvalue viide. Funktsioon getAjutine() tagastab ajutise objekti ja seetõttu valib kompilaator rvalue viitega funktsiooni. Sama kehtib ka literaaliga pöördumisel sõneKasutamine("Tabasalu").

Madal (shallow) koopia ja sügav (deep) koopia

Objekte on võimalik edastada funktsioonile argumendiks ja ka tagastada funktsioonist. Sellisel juhul C++ loob uue objekti ja kasutab koopiakonstruktorit, et kopeerida originaalobjektist andmed uude objekti.

Samuti on võimalik sama tüüpi objekte omistada ühelt muutujalt teisele kasutades operaatorit =. Omistamisel kasutatakse koopia omistamise operaatorit. Kui kasutaja ei ole defineerinud oma koopiakonstruktorit ega ka üle defineerinud koopia omistamise operaatorit, siis kompilaator loob vaikimisi mõlemad.

Kompilaatori poolt genereeritud koopiakonstruktor ja koopia omistamise operaator teostavad liikmekaupa kopeerimist. Seda nimetatakse madalaks koopiaks (shallow copy). See töötab korrektselt, kui klassi liikmete hulgas ei ole objekte, viitasid (pointer), massiive jms. Kui klassi liikmeks on näiteks viit (pointer) massiivile, siis peale madala koopia tegemist viitab nii originaal m kui ka koopia m1 samale massiivile:

 

Oletame näiteks, et originaalobjekt hävib ja mälu vabastatakse viida (pointer) alt. Sellisel juhul koopiaobjekti jääb rippuv viit (dangling pointer) ja võib tekkida programmi ettearvamatu käitumine või käitusaegne viga.

Sügava koopia korral kopeeritakse ka viidatavad objektid:

 

Koopiakonstruktor (copy constructor)

Objekti initsialiseerimisel teise objekti abil kaasatakse koopiakonstruktor. Kui me ise ei loo koopiakonstruktorit, siis kompilaator genereerib koopiakonstruktori, mis teeb objektist madala koopia. Järgmises näites on kasutaja loodud koopiakonstruktor, mis kopeerib liikmed ja kuvab ekraanile teate.

#include <iostream>
using namespace std;

class A{
    int m_x{};
    int m_y{};
public:
    A() = default;
    A(int x, int y) : m_x{x}, m_y{y}{}
    // koopiakonstruktor
    A(const A& ra) : m_x{ra.m_x}, m_y{ra.m_y}{
        cout << "Koopiakonstruktoris.\n";
    }
};

int main() {
    A a1{1, 2};
    A a2 = a1; // kaasatakse koopiakonstruktor
    A a3{a2};// kaasatakse koopiakonstruktor
    return 0;
}
Koopiakonstruktoris.
Koopiakonstruktoris.

Koopiakonstruktori parameetriks on konstantne (me ei muuda originaali) klassi A viide (reference): const A& ra. Vaatame olukorda, kus kasutaja koopiakonstruktor on vajalik sügava koopia tegemiseks. Siin klassi liikmeks on lisaks täisarvule ka viit (pointer) täisarvule, mis hõivab mälu konstruktoris. Koopiakonstruktoris ei kopeerita viita, vaid viida jaoks hõivatakse uus mälu, mis sisaldab sama väärtust nagu originaal m_p{new int(*ra.m_p). Koopiakonstruktoris kuvame jooksva objekti aadressi this ja kopeeritava objekti aadressi (&ra). Lisame klassi ka operaatori << üledefineerimise klassi liikmete ekraanile kuvamiseks. Selles on kontroll, kas viit (pointer) on kehtiv if (a.m_p), sest mittekehtiva viida otsendamine (dereference) võib viia ettearvamatu tulemuseni. Klassis on ka destruktor, kus hõivatud mälu vabastatakse.

#include <iostream>
using namespace std;

class A{
    int m_x{};
    int* m_p{}; // viit (pointer)
public:
    A() = default;
    A(int x, int y) : m_x{x}, m_p{new int(y)}{}
    // koopiakonstruktor
    A(const A &ra) : m_x{ra.m_x}, m_p{new int(*ra.m_p)} {
        cout << "Koopiakonstruktoris " << this << " " << &ra << "\n";
    }
    // destruktor
    ~A(){
        delete m_p;
    }
    friend ostream &operator<<(ostream &os, A &a);
};

ostream &operator<<(ostream &os, A &a) {
    os << "m_x = " << a.m_x;
    if (a.m_p){ 
        os << " *m_p = " << *a.m_p << '\n';
    }
    else {
        os << " m_p = nullptr\n";
        }
    return os;
}

int main() {
    A a1{1, 2};
    cout << "a1: " << a1;
    A a2 = a1; // kaasatakse koopiakonstruktor
    cout << "a2: " << a2;
    A a3{a2};// kaasatakse koopiakonstruktor
    cout << "a3: " << a3;
    return 0;
}

a1: m_x = 1 *m_p = 2
Koopiakonstruktoris 0x286e5ffd90 0x286e5ffda0
a2: m_x = 1 *m_p = 2
Koopiakonstruktoris 0x286e5ffd80 0x286e5ffd90
a3: m_x = 1 *m_p = 2

Käsuga A a2 = a1 kaasatakse koopiakonstruktor, milles kuvatakse a2 ja a1 aadressid. Peale käsu täitmist on objekti a2 sisu sama, mis objektil a1. Käsuga A a3{a2} kaasatakse samuti koopiakonstruktor, milles kuvatakse a3 ja a2 aadressid. Peale käsu täitmist on objekti a3 sisu sama, mis objektil a2. Objektide endi aadressid ei muutu.

Täpsemalt saab koopiakonstruktoritest uurida aadressil https://en.cppreference.com/w/cpp/language/copy_constructor

Koopiaomistamine (copy assignment)

Seni oleme kasutanud koopiakonstruktoreid objekti loomiseks teise objekti abil. Samuti saame kopeerida ühe objekti sisu teise objekti peale objekti loomist. Selleks kasutatakse koopia omistamise operaatorit (copy assignment operator). Koopia omistamise operaatori poole pöördutakse siis, kui objekt on juba olemas. Näiteks, ridadel 2 ja 3 toimub pöördumine koopiakonstruktori poole, aga real 5 toimub koopiaomistamine, sest objekt a4 on juba olemas ja talle omistatakse uus objekt.

1 A a1{1, 2};
2 A a2 = a1; // koopiakonstruktor
3 A a3{a2};// koopiakonstruktor
4 A a4;
5 a4 = a2; // koopiaomistamine 

Koopia omistamise operaatoril on järgmine signatuur: A& operator=(const A& ra). Operaator tagastab viite objektile.

Klassi sees deklareeritud koopia omistamise operaatorile

A& operator=(const A& ra);

vastab väljaspool klassi definitsioon

A& A::operator=(const A& ra){...}

Defineerime klassile A koopia omistamise operaatori, mis teeb omistatavast objektist sügava koopia.

A& operator=(const A& ra){
    if(this != &ra){ // kas omistamine iseendale
        m_x = ra.m_x;
        // vabastame vana mälu
        if (m_p){delete m_p;}
        // Hõivame uue mälu ja kopeerime väärtuse
        m_p = new int(*ra.m_p);
    }
    cout << "Koopiaomistamine " << this << " " << &ra << "\n";
    // Tagastame viite (reference) jooksvale objektile
    return *this;
}

Iseendale omistamist if(this != &ra) on vaja kontrollida sellepärast, et ei toimuks kogemata mälu vabastamist. Siin this on viit (pointer) omistamise vasakul pool olevale objektile ja &ra on viit (pointer) omistamise paremal pool olevale objektile. Kui tegemist on sama objektiga, siis peale mälu vabastamist võib sama viida otsendamine (dereference) viia programmi kokkujooksmiseni. Loome kaks objekti a1 ja a2 ning rakendame koopiaomistamist

A a1{1, 2};
A a2{3, 4};
cout << "a1: " << " " << &a1 << " " << a1;
cout << "a2: " << " " << &a2 << " " << a2;
a2 = a1; // koopiaomistamine, tehakse sügav koopia
cout << "a2: " << " " << &a2 << " " << a2;
a1:  0x1f671ff9a0 m_x = 1 *m_p = 2
a2:  0x1f671ff990 m_x = 3 *m_p = 4
Koopiaomistamine 0x1f671ff990 0x1f671ff9a0
a2:  0x1f671ff990 m_x = 1 *m_p = 2

Käsk a2 = a1 käivitab koopiakonstruktori ja peale käsu täitmist on objekti a2 sisu identne objekti a1 sisuga. Objektide a1 ja a2 aadressid ei ole muutunud.

Täpsemalt saab koopiaomistamisest uurida aadressil https://en.cppreference.com/w/cpp/language/copy_assignment

Teisalduskonstruktor (move constructor)

Lisaks kopeerimisele on võimalik andmeid ühelt objektilt teisele ka teisaldada (move). Seda nimetatakse ka teisaldussemantikaks move semantics ja saavutatakse see teisalduskonstruktori (move constructor) ja teisaldusomistamise (move assignment) abil. Kui kasutaja ei ole defineerinud oma teisalduskonstruktorit ega ka üle defineerinud teisaldusomistamise operaatorit, siis kompilaator loob vaikimisi mõlemad. Vaatame nüüd, kuidas neid üle defineerida. Objekt, kust andmed teisaldatakse, jäetakse seisukorda, mis lubab destruktoril käivituda. On lubatud ka edaspidi sellele objektile omistamine. Teisaldamine on efektiivne programmi täitmise kiiruse mõttes, sest ei pea tegema aega ja mälu nõudvaid koopiaid. Teisalduskonstruktori signatuur on järgmine: A (A&& ra), kus parameetri tüübiks on rvalue viide (reference). Teisalduskonstruktori parameeter ei ole konstantne, sest teisalduskonstruktor võib võõrandada andmeid oma argumendilt. Teisalduskonstruktorile rvalue viite edastamiseks võib kasutada funktsiooni move. Standardnimeruumi funktsioon move ei teisalda ise midagi, vaid edastab parameetrina saadud objektist rvalue viite (reference). Lisame klassi A teisalduskonstruktori

// teisalduskonstruktor
A(A&& ra) : m_x{ra.m_x}, m_p{ra.m_p} {
    // vabastame mälu originaalist
    ra.m_x = 0;
    ra.m_p = nullptr;
    cout << "Teisalduskonstruktoris " << this << " " << &ra << "\n";
}

Teisalduskonstruktoris 'omandatakse' andmed rvalue viitega saadud objektilt ja objekti enda andmed nullitakse (vabastatakse viidatud mälu). Loome objekti a2, mis teisalduskonstruktori abil saab endale objekti a1 andmed. Objektis a1 on peale teisaldamist nullptr. Objekti a3 loomisel kasutame ajutist objekti A{3, 4}, mille edastame funktsiooni move abil teisalduskonstruktorile rvalue viitena. Kui loome objekti a3 käsuga A a3 = {A{3, 4}}, st ei rakenda funktsiooni move, siis luuakse objekt a3 tavakonstruktoriga.

A a1{1, 2};
cout << "a1:" << &a1 << " "<< a1;
A a2 = move(a1);
cout << "a1: " << &a1 << " " << a1;
cout << "a2:" << &a2 << " " << a2;
A a3 = {move(A{3, 4})};
cout << "a3:" << &a3 << " " << a3;
a1:0xc1cdfffb60 m_x = 1 *m_p = 2
Teisalduskonstruktoris 0xc1cdfffb50 0xc1cdfffb60
a1: 0xc1cdfffb60 m_x = 0 m_p = nullptr
a2:0xc1cdfffb50 m_x = 1 *m_p = 2
Teisalduskonstruktoris 0xc1cdfffb40 0xc1cdfffb70
a3:0xc1cdfffb40 m_x = 3 *m_p = 4

Täpsemalt saab teisalduskonstruktoritest uurida aadressil https://en.cppreference.com/w/cpp/language/move_constructor

Teisaldusomistamine (move assignment)

Teisaldusomistamine käivitatakse siis, kui meil on deklareeritud objekt ja siis püüame talle omistada rvalue viidet (reference). Seda tehakse teisaldusoperaatori (move assignment operator) abil. Teisaldusoperaatori signatuur on järgmine: A& operator=(A&& ra), kus parameetri tüübiks on rvalue viide. Teisaldusoperaatori parameeter ei ole konstantne, sest teisaldusoperaator 'omastab' andmed oma argumendilt. Defineerime klassis A üle teisaldusomistamise operaatori

// teisaldusomistamise operaatori üledefineerimine
A &operator=(A &&ra) { 
    if (this != &ra) { // kontroll, kas sama objekt
        if (m_p) delete m_p; // kustutame jooksvas objektis hõivatud mälu
        // tõstame andmed üle
        m_x = ra.m_x; // m_x = move(ra.m_x);
        m_p = ra.m_p; // m_p = move(ra.m_p);
        // vabastame 'ajutise' objekti sisust
        ra.m_x = 0;
        ra.m_p = nullptr;
    }
    cout << "Teisaldusomistamine " << this << " " << &ra << "\n";
    // Tagastame viite (reference) jooksvale objektile
    return *this;
}

Siin this on viit (pointer) objektile, kuhu teisaldatakse ja &ra on viit (pointer) objektile, kust teisaldatakse. Kui objektis this on viiteid varem hõivatud mälule, siis tuleb need vabastada. Edasi tõstame andmed ja viidad ümber ja tühjendame objekti, kust teisaldatakse. Lõpuks tagastame viite (reference) jooksvale objektile (kuhu teisaldati). Vaatame kahte näidet. Esimeses on deklareeritud muutuja a1 (käivitatakse vaikekonstruktor). Kuvatud infost on näha, et a1 on tühi. Reas 3 olev käsk a1 = A{5, 6} käivitab teisaldusomistamise operaatori, sest paremal pool on rvalue. Peale omistamist on objekti a1 sisu identne rea 3 paremal pool oleva ajutise objektiga.

1 A a1;
2 cout << "a1: " << &a1 << " " << a1;
3 a1 = A{5, 6}; // käivitatakse teisaldusomistamine
4 cout << "a1: " << &a1 << " " << a1;
a1: 0x2caabff680 m_x = 0 m_p = nullptr
Teisaldusomistamine 0x2caabff680 0x2caabff690
a1: 0x2caabff680 m_x = 5 *m_p = 6

Teises näites on meil kaks olemasolevat objekti a1 ja a2 ja rea 5 abil teisaldame objekti a1 sisu objektile a2. Funktsioon move edastab teisaldusomistamise operaatorile rvalue viite (reference). Teisaldusomistamise käigus vabastatakse objektis a2 olev viit (pointer). Väljatrükis on näha, et objektis a2 on objekti a1 sisu ja objekt a1 ise on nüüd tühi.

1 A a1{1, 2};
2 A a2{3, 4};
3 cout << "a1: " << a1;
4 cout << "a2: " << a2;
5 a2 = move(a1);
6 cout << "a1: " << a1;
7 cout << "a2: " << a2;
a1: m_x = 1 *m_p = 2
a2: m_x = 3 *m_p = 4
Teisaldusomistamine 0xac065ffcb0 0xac065ffcc0
a1: m_x = 0 m_p = nullptr
a2: m_x = 1 *m_p = 2

Täpsemalt saab teisaldusomistamisest uurida aadressil https://en.cppreference.com/w/cpp/language/move_assignment

Operaatorite üledefineerimine (overloading)

Klassi isendeid võib kasutada avaldistes operandidena. Näiteks, kui ob1 ja ob2 on mingi klassi objektid, siis avaldised

ob1 + ob2;
ob1++;

võiksid anda tulemuseks sama tüüpi objekti. Selleks tuleb üle defineerida operaatorid klassi jaoks. Operaatoreid defineerime üle siis, kui tulemuseks on mõistlik sisu. Milliseid operaatoreid saab üle defineerida? Sagedamini üledefineeritavateks operaatoriteks on aritmeetilised operaatorid, binaared operaatorid, tõeväärtusoperaatorid, unaarsed operaatorid, võrdlusoperaatorid, liitoperaatorid, () ja [].

Vaatame näiteks klassi Vektor (NB! See ei ole andmestruktuur!).

#include <iostream>
#include <cmath>
using namespace std;
class Vektor {
public:
    Vektor(double x, double y) : m_x{x}, m_y{y} {}
    Vektor() = default;

    double pikkus() {
        return round(sqrt(m_x * m_x + m_y * m_y) * 100) / 100.0;
    }

    void trüki() {
        cout << "(" << m_x << ", " << m_y << ")\n";
    }

private:
    double m_x{};
    double m_y{};
};

int main() {

    Vektor v1{1, 2};
    Vektor v2{3, 5};
    v1.trüki();
    v2.trüki();
    cout << "v1.pikkus(): " << v1.pikkus() << '\n';
    cout << "v2.pikkus(): " << v2.pikkus() << '\n';
    return 0;
}
(1, 2)
(3, 5)
v1.pikkus(): 2.24
v2.pikkus(): 5.83

Tasandi vektoril on kaks koordinaati (isendimuutujad m_x ja m_y). Klassil on kaks konstruktorit - vaikekonstrukror ja kahe parameetriga konstruktor. Vektori pikkuse arvutamiseks on liikmefunktsioon pikkus, kus arvutamiseks läheb vaja teegi <cmath> funktsioone sqrt ja round. Funktsioon trüki kuvab ekraanile vektori koordinaadid sobival kujul. Tasandi vektoritega saab teha tehteid, nt liita, lahutada ja skalaariga korrutada. Kõigepealt defineerime skalaariga korrutamise. Selleks tuleb klassi lisada funktsioon, mille nimeks on operator*=. Skalaariga korrutamisel korrutame mõlemat koordinaati parameetriks oleva arvuga ja funktsioon ei tagasta midagi

void operator*=(double arv){
    m_x *= arv;
    m_y *= arv;
}

Funktsioon main:

int main() {
    Vektor v1{1, 2};
    v1 *= 2.5;
    v1.trüki();
    return 0;
}
(2.5, 5)

Vektorite liitmise defineerime väljaspool klassi. Selleks, et funktsioon väljaspool klassi saaks kasutada klassi privaatseid isendimuutujaid, tuleb funktsioon deklareerida klassi sõbraks (friend). Seda tehakse klassi sees deklaratsiooniga. Kuna vektorite liitmisel operande ei muudeta (tulemuseks on uus vektor), on mõistlik liidetavad vektorid kuulutada konstantseteks. Kopeerimise vältimiseks on liidetavad vektorid antud viidetena (references).

#include <iostream>
#include <cmath>
using namespace std;

class Vektor {
public:
    Vektor(double x, double y) : m_x{x}, m_y{y} {}
    Vektor() = default;
    double pikkus() {
        return round(sqrt(m_x * m_x + m_y * m_y) * 100) / 100.0;
    }
    void trüki() {
        cout << "(" << m_x << ", " << m_y << ")\n";
    }
    friend Vektor operator+(const Vektor& vasak, const Vektor& parem);
    void operator*=(double arv) {
        m_x *= arv;
        m_y *= arv;
    }
private:
    double m_x{};
    double m_y{};
};
Vektor operator+(const Vektor& vasak, const Vektor& parem) {
    return Vektor{vasak.m_x + parem.m_x, vasak.m_y + parem.m_y};
}

int main() {
    Vektor v1{1, 2};
    Vektor v2{3, 5};
    Vektor v3 = v1 + v2;
    v3.trüki();
    return 0;
}

(4, 7)

Defineerime veel üle vektori väljundvoogu saatmise. Selleks lisame klassi deklaratsiooni operaatori << üledefineerimise kohta, kus ostream on standardväljund.

friend ostream& operator<<(ostream& os, const Vektor& v);

Operaatori << üledefineerimine on väljaspool klassi, kus väljundvoogu os saadetakse vektori koordinaadid sobival kujul.

ostream& operator<<(ostream& os, const Vektor& v){
    os << "(" << v.m_x << ", " << v.m_y << ")\n";
    return os;
}

main funktsioonis saab nüüd vektorit mugavalt ekraanile kuvada

Vektor v1{1, 2};
Vektor v2{3, 5};
cout << v1 << v2 << '\n';

Tulemuseks on

(1, 2)
(3, 5)

Täpsemalt saab operaatorite üledefineerimist uurida lehel https://en.cppreference.com/w/cpp/language/operators

Destruktorid (destructors)

Objektid, mis luuakse konstruktori abil, tuleb peale kasutamise lõppu mälust kustutada. See ülesanne sisaldab reeglina mälu vabastamist, failide sulgemist jms. Seda saab teha spetsiaalse funktsiooni, destruktori, abil. Kui näiteks konstruktor avab faili, siis destruktor peab selle sulgema. Destruktor kannab klassi nime, mille ees on märk ~ ja tal ei ole tagastustüüpi ega ka parameetreid. Destruktorit ei ole võimalik üle defineerida, st igal klassil on täpselt üks destruktor. Kui klassis ei ole defineeritud destruktorit, siis kompilaator lisab nn vaikedestruktori. Destruktor deklareeritakse klassi public sektsioonis ja tal on järgmine süntaks:

~klassi-nimi();

Destruktor kutsutakse välja automaatselt objekti eluea lõpul. Täiendame kasti näidet destruktoriga, mille sisuks on teksti "Kast destruktoris." ekraanile kuvamine. Luuakse kaks klassi Kast isendit, üks kolme parameetriga konstruktori abil ja teine vaikekonstruktoriga, kus midagi ekraanile ei kuvata. Peale töö lõppu mõlemad objektid hävitatakse ja mõlema korral kutsutakse välja destruktor.

#include <iostream>

using namespace std;

class Kast {
    double m_pikkus{};
    double m_laius{};
    double m_kõrgus{};
public:
    Kast() = default; 
    Kast (double pikkus, double laius, double kõrgus);
    // destruktori deklaratsioon
   ~Kast();
    double ruumala();
};

Kast::Kast(double pikkus, double laius, double kõrgus) {
    cout << "Kast konstruktoris.\n";
    m_pikkus = pikkus;
    m_laius = laius;
    m_kõrgus = kõrgus;
}
// destruktori definitsioon
Kast::~Kast() {
    cout << "Kast destruktoris.\n";
}
double Kast::ruumala() {
    return m_pikkus * m_laius * m_kõrgus;
}
int main() {
    Kast kast(1.1, 2.2, 3.3);
    cout << "Kasti ruumala: " << kast.ruumala() << "\n";
    Kast kast2; 
    cout << "Kasti ruumala: " << kast2.ruumala() << "\n";
    cout << "Viimane rida main-is.\n";
    return 0;
}
Kast konstruktoris.
Kasti ruumala: 7.986
Kasti ruumala: 0
Viimane rida main-is.
Kast destruktoris.
Kast destruktoris.

NB! Destruktorit on mõtet defineerida ainult siis, kui sellel on mõistlik sisu. Näiteks, kui klassi liikmete hulgas on viidamuutujaid (pointers), siis rippuvate viitade vältimiseks on vaja need destruktoris kustutada.

Kui klassis on hõivatud mälu (nt käsuga new), siis tuleb see destruktoris kindlasti vabastada.

Piilufunktsioonid (accessor functions ja mutator functions)

Klassi mitteprivaatseid liikmeid saab kätte operaatori . abil. Sageli on vaja juurdepääsu klassi privaatsetele isendimuutujatele. Selleks saab klassi definitsiooni lisada avalikud piilufunktsioonid isendimuutujate kättesaamiseks ja muutmiseks:

#include <iostream>
using namespace std;
class Kast {
    double m_pikkus{};
    double m_laius{};
    double m_kõrgus{};
public:
    Kast() = default;
    Kast(double pikkus, double laius, double kõrgus);
    double getPikkus();
    double getLaius();
    double getKõrgus();
    void setPikkus(double pikkus);
    void setLaius(double laius);
    void setKõrgus(double kõrgus);
    double ruumala();
};
double Kast::getPikkus(){
    return m_pikkus;
}
double Kast::getLaius(){
    return m_laius;
}
double Kast::getKõrgus(){
    return m_kõrgus;
}
void Kast::setPikkus(double pikkus) {
    if (pikkus > 0) {
        m_pikkus = pikkus;
    }
}
void Kast::setLaius(double laius){
    if (laius > 0){
        m_laius = laius;
    }
}
void Kast::setKõrgus(double kõrgus){
    if (kõrgus > 0){
        m_kõrgus = kõrgus;
    }
}
double Kast::ruumala() {
    return m_pikkus * m_laius * m_kõrgus;
}
Kast::Kast(double pikkus, double laius, double kõrgus) : m_pikkus{pikkus}, m_laius{laius}, m_kõrgus{kõrgus} {
    cout << "Kast konstruktoris.\n";
}
int main() {
    Kast kast;
    kast.setPikkus(1.1);
    kast.setLaius(2.2);
    kast.setLaius(-5); //ei tee midagi
    cout << "Kasti laius: " << kast.getLaius() << "\n";
    kast.setKõrgus(3.3);
    cout << "Kasti ruumala: " << kast.ruumala()<< "\n";
    return 0;
}
Kasti laius: 2.2
Kasti ruumala: 7.986

Funktsiooni, mis tagastab isendimuutuja väärtuse, nimetatakse inglise keeles accessor funktsiooniks. Üldlevinud kokkuleppe kohaselt on sellise funktsiooni nimi prefiksiga get, millele järgneb isendimuutuja nimi suure algustähega, nt getPikkus, getLaius, getKõrgus. Funktsiooni, mille abil saame isendimuutujate väärtust muuta, nimetatakse inglise keeles mutator funktsiooniks ja selle nimes kasutatakse prefiksit set, nt setPikkus, setLaius, setKõrgus. Sageli nimetatakse neid funktsioone lihtsalt getters ja setters. Eesti keeles kasutatakse ka nimetust get- ja set funktsioonid. Meie näites on set-funktsioonides parameetri kontroll, mis välistab kasti negatiivse mõõtme (konstruktor kasutab neid funktsioone).

NB! Keskkond CLion pakub piilufunktsioonide automaatset genereerimist.

Klass mitmes failis

Paigutame nüüd klassi definitsiooni päisefaili kast.h ja konstruktorite ja funktsioonide realisatsioonid faili kast.cpp. Funktsioon main jääb faili main.cpp. Päisefaili paigutame alamkausta include ja kast.cpp faili alamkausta src:

 

Failide sisu näeb siit:

<< kast.h >>


<< kast.cpp >>


<< main.cpp >>


<< CMakeLists.txt >>


Allikas https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines.html#Rc-org annab järgmised soovitused päisefailidele:

  • nimetada päisefailid laiendiga .h ja koodifailid laiendiga .cpp
  • päisefailid ei tohiks sisaldada funktsioonide, konstruktorite, jne realisatsioone, välja arvatud inline funktsioonid
  • päisefail ei tohiks sisaldada globaalsel tasandil lauset using namespace ...

Enesetestid

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

<< Näita enesetesti >>

<< Näita enesetesti >>

<< Näita enesetesti >>

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