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
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:
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;
)