Pärilus ja polümorfism
Pärast selle praktikumi läbimist üliõpilane
- oskab koostada klasse kasutades pärilust (inheritance)
- teab, kuidas kasutada piiritlejaid (access specifier)
- oskab kasutada funktsioonide ülekatmist (overriding) ja varajast seostamist (early binding)
- teab, mis on virtuaalsed (virtual) funktsioonid
- saab aru polümorfismi (polymorphism) mõistest
- teab, mis on abstraktsed funktsioonid ja abstraktsed klassid
Pärilus
Pärilus on üks objektorienteeritud programmeerimise põhimõistetest. Pärilus on tarkvara korduvkasutus, kus üks klass (alamklass) pärib endale teise klassi (ülemklassi) andmed ja käitumise ja täiendab ennast uute võimalustega. Öeldakse, et alamklass on päritud ülemklassist. Alamklassi objekti ja ülemklassi objekti vahel on järgmine seos: alamklassi objekt on ülemklassi objekt, kuid mitte vastupidi. Näiteid pärimisest:
Näiteks, iga kolmnurk on kujund; iga nelinurk on kujund, iga võrdkülgne kolmnurk on kolmnurk, aga samal ajal ka kujund jne. Kolmnurgal on kõik kujundi omadused ja lisaks veel kolmnurgale iseloomulikud andmed ja omadused. Samas, iga kolmnurk ei ole võrdkülgne, iga nelinurk ei ole ristkülik jne.
Alamklassis näidatakse pärimist kooloniga
class Kolmnurk: public Kujund{ ... };
Seni on klassi liikmed olnud kas piiritlejaga public
või private
. Kui klassi liige on public
, on ta kättesaadav ka väljaspool klassi, samas klassi private
liikmetele on juurdepääs ainult klassi seest (välja arvatud klassi friend
funktsioonid). On ka kolmas piiritleja protected
. Kui klassi liige on protected
, siis väljaspool klassi ei ole ta kättesaadav, välja arvatud klassi pärimisel. Klassi pärimisel on see liige kättesaadav ka alamklassis.
Pärimine võib olla public
, protected
või private
. Piiritleja puudumise korral on klassi pärimine private
ja struktuuri pärimine public
. Vaatame neid lähemalt.
public
Ülemklassi kõikpublic
liikmed on ka alamklassipublic
liikmed ja ülemklassi kõikprotected
liikmed on ka alamklassiprotected
liikmed.protected
Ülemklassi kõikpublic
liikmed on alamklassiprotected
liikmed ja ülemklassi kõikprotected
liikmed on ka alamklassiprotected
liikmed.private
Ülemklassi kõikpublic
japrotected
liikmed on alamklassiprivate
liikmed.
Ühelgi juhul ei ole ülemklassi private
liikmed alamklassis kättesaadavad.
Konstruktorid päriluse korral
Konstruktoreid ei pärita, kuid ülemklassi konstruktori poole saab (ja tuleb) alamklassist vajadusel pöörduda. Kui alamklassi konstruktoris on parameetreid, mis kuuluvad ülemklassi, siis on soovitatav nende initsialiseerimiseks pöörduda ülemklassi konstruktori poole. Kui alamklassi konstruktoris on parameetreid ja ülemkassi konstruktori poole pöördumine puudub, siis kompilaator lisab pöördumise ülemklassi vaikekonstruktori ()
poole.
Vaatame näidet, kus Isik
on ülemklass ja Töötaja
on alamklass:
#include <iostream> #include <string> using namespace std; class Isik { public: Isik() { cout << "Isik() konstruktoris\n"; }; Isik(string nimi, string ik) : m_nimi{nimi}, m_ik{ik} { cout << "Isik(string, string) konstruktoris: " << m_nimi << " " << ik << '\n'; } private: string m_nimi{}; string m_ik{}; }; class Töötaja : public Isik { public: Töötaja() { cout << "Töötaja() konstruktoris\n"; } Töötaja(string nimi, string ik, string amet) : Isik(nimi, ik), m_amet{amet} { cout << "Töötaja(string, string, string) konstruktoris: " << m_amet << '\n'; } Töötaja(string amet) : m_amet{amet}{ cout << "Töötaja(string) konstruktoris: " << m_amet << '\n'; } private: string m_amet{}; }; int main() { Isik isik1{}; // Isik() konstruktor (vaikekonstruktor) cout << "---\n"; Isik isik2{"Maie Maasikas", "60303032525"}; //Isik(string, string) konstruktor cout << "---\n"; Töötaja töötaja1{}; // Isik() konstruktor, Töötaja() konstruktor cout << "---\n"; Töötaja töötaja2{"Kaie", "60202023535", "insener"}; // Isik(string, string) konstruktor, Töötaja (string, string, string) konstruktor cout << "---\n"; Töötaja töötaja3{"ehitaja"}; // Isik() konstruktor, Töötaja(string) konstruktor return 0; } | Isik() konstruktoris --- Isik(string, string) konstruktoris: Maie Maasikas 60303032525 --- Isik() konstruktoris Töötaja() konstruktoris --- Isik(string, string) konstruktoris: Kaie 60202023535 Töötaja(string, string, string) konstruktoris: insener --- Isik() konstruktoris Töötaja(string) konstruktoris: ehitaja |
Näites on nii ülemklassis kui ka alamklassis vaikekonstruktor. Vastavalt soovitustele
https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines.html#main
peab konstruktor looma täielikult initsialiseeritud objekti. Antud juhul garanteerib vaikekonstruktoriga objekti loomisel selle isendimuutujate initsialiseerimine klassis Isik
string m_nimi{}; // tühi sõne string m_ik{}; // tühi sõne
ja klassis Töötaja
string m_amet{}; //tühi sõne
Alamklassis ei ole juurdepääsu ülemklassi privaatsetele liikmetele. Alamklassi Töötaja
kolme parameetriga konstruktoris on pöördumine ülemklassi kahe parameetriga konstruktori poole ja seejärel isendimuutuja m_amet
initsialiseerimine.
Töötaja(string nimi, string ik, string amet) : Isik(nimi, ik), m_amet{amet} { cout << "Töötaja konstruktoris: " << m_amet << '\n'; }
Objektide loomisest lähemalt:
Isik isik1{};
Objekti loob klassiIsik
parameetriteta konstruktor (vaikekonstruktor).Isik isik2{"Maie Maasikas", "60303032525"};
Objekti loob klassiIsik
kahe parameetriga konstruktor.Töötaja töötaja1{};
Objekti loomine toimub kahes etapis: alamklassiTöötaja
vaikekonstruktorist pöördutakse ülemklassiIsik
vaikekonstruktori poole ja seejärel täidetakseTöötaja
vaikekonstruktor.Töötaja töötaja2{"Kaie", "60202023535", "insener"};
Objekti loomine toimub kahes etapis: alamklassiTöötaja
kolme parameetriga konstruktorist pöördutakse ülemklassi kahe parameetriga konstruktori poole ja seejärel täidetakse klassiTöötaja
kolme parameetriga konstruktor.Töötaja töötaja3{"ehitaja"};
Kuna klassiTöötaja
ühe parameetriga konstruktoris ei ole pöördumist ülemklassi konstruktori poole, siis kompilaator lisab pöördumise ülemklassiIsik
vaikekonstruktori poole ja seejärel täidetakseTöötaja
ühe parameetriga konstruktor.
Kui alamklass luuakse vaikekonstruktoriga, siis toimub automaatselt pöördumine ülemklassi vaikekonstruktori poole.
Kui alamklass luuakse parameetritega konstruktoriga ja konstruktoris puudub pöördumine ülemklassi konstruktori poole, siis toimub automaatselt pöördumine ülemklassi vaikekonstruktori poole.
Pärimine mitmest ülemklassist
Klassisl võib olla mitu ülemklassi. Pärimisel tuleb iga ülemklassi ette lisada piiritleja (tavaliselt public
). Alamklassis saab kasutada ülemklasside andmeid ja funktsioone. Näites on ülemklasside isendimuutujad protected
, et nad oleksid alamklassis otse kättesaadavad. Privaatsete isendimuutujate kättesaamiseks tuleb kasutada avalikke piilumeetodeid (get, set). Ülemklassides Ülem1
ja Ülem2
on isendimuutujad m_x
ja m_y
, millele on juurdepääs alamklassis Alam
. Peameetodis alamklassi Alam
tüüpi objektil ob
on juurdepääs mõlema ülemklassi avalikele liikmetele.
#include <iostream> using namespace std; class Ülem1{ protected: int m_x{}; public: void näitaMX(){ cout << "m_x: " << m_x << '\n'; } }; class Ülem2{ protected: int m_y{}; public: void näitaMY(){ cout << "m_y: " << m_y << '\n'; } }; class Alam: public Ülem1, public Ülem2{ public: void setXY(int x, int y){ m_x = x; m_y = y; } }; int main() { Alam ob{}; ob.setXY(1, 2); // klassis Alam ob.näitaMX(); // klassis Ülem1 ob.näitaMY(); // klassis Ülem2 //ob.m_x{1}; // viga, m_x on kättesaadav vaid klassides Ülem1 ja Alam //ob.m_y{2}; // viga, m_y on kättesaadav vaid klassides Ülem2 ja Alam return 0; } | m_x: 1 m_y: 2 |
Täpsemalt saab päriluse kohta uurida aadressil https://en.cppreference.com/w/cpp/language/derived_class
Funktsioonide ülekatmine (overriding) ja varajane seostamine (early binding)
Alamklass pärib kõik ülemklassi andmed ja funktsioonid. Alamklassis on võimalik nii andmeid kui ka funktsioone lisada. Mõnikord on vaja, et ülemklassis defineeritud funktsioon käituks alamklassis teisiti. Siis on võimalik defineerida alamklassis sama nime, parameetrite tüübi ja arvu ning tagastustüübiga funktsioon, mis peidab (hides) ülemklassi funktsiooni alamklassi objekti eest. Seda nimetatakase ka funktsioonide ülekatmiseks (overriding). Vaatame klasside hierarhiat Kujund
<-Nelinurk
<-Ristkülik
, kus igas klassis on oma versioon funktsioonist joonista()
. Peafunktsioonis loome igast klassist objekti ja pöördume funktsiooni joonista()
poole. Tulemus on ootuspärane: pöördumine on objekti tüüpi klassi funktsiooni poole, nt Nelinurk
tüüpi objekti korral pöördutakse Nelinurk
funktsiooni joonista
poole jne.
#include <iostream> using namespace std; class Kujund { public: void joonista() { cout << "Kujund: joonista()\n"; } }; class Kolmnurk : public Kujund { public: virtual void joonista() { cout << "Kolmnurk: joonista()\n"; } }; class Nelinurk : public Kujund { public: void joonista() { cout << "Nelinurk: joonista()\n"; } }; class Ristkülik : public Nelinurk { public: void joonista() { cout << "Ristkülik: joonista()\n"; } }; int main() { Kujund k1{}; Kolmnurk kn1{}; Nelinurk n1{}; Ristkülik r1{}; k1.joonista(); kn1.joonista(); n1.joonista(); r1.joonista(); return 0; } | Kujund: joonista() Kolmnurk: joonista() Nelinurk: joonista() Ristkülik: joonista() |
Klasside pärimisel alamklassi tüüpi objekt on ka ülemklassi tüüpi objekt. See võimaldab ülemklassi tüüpi kasutada ka alamklassi korral. Järgmises näites on muutujad n2
ja r2
ülemklassi Kujund
tüüpi. Kuigi nad tegelikult viitavad alamklassi objektidele, on nende tüüp Kujund
ja pöördumisel funktsiooni joonista()
poole käivitub klassi Kujund
funktsioon joonista()
. Seda nimetatakse varajaseks seostamiseks (early binding), kus kompilaator valib muutuja tüübi järgi funktsiooni, mille poole pöörduda.
int main() { Kujund k1{}; Kolmnurk kn1{}; Nelinurk n1{}; Ristkülik r1{}; Kujund* kn2{&kn1}; // kn2 viitab kn1-le Kujund* n2{&n1}; // n2 viitab n1-le Kujund* r2{&r1}; // r2 viitab r1-le kn2->joonista(); n2->joonista(); r2->joonista(); return 0; } | Kujund: joonista() Kujund: joonista() Kujund: joonista() |
Ülemklassi tüübi alla viimine on kasulik andmekogumites (nt massiiv, vector
, jne), kus ei saa hoida eri tüüpi andmeid. Näites on loodud massiiv Kujund
tüüpi objektidest (massiivi elemendid on objektide aadressid). Ka siin rakendatakse ülemklassi Kujund
funktsiooni joonista()
.
int main() { Kujund k1{}; Kolmnurk kn1{}; Nelinurk n1{}; Ristkülik r1{}; Kujund* kn2{&kn1}; Kujund* n2{&n1}; Kujund* r2{&r1}; Kujund* kujundid[]{&k1, kn2, n2, r2}; for (Kujund* k: kujundid) { k->joonista(); } return 0; } | Kujund: joonista() Kujund: joonista() Kujund: joonista() Kujund: joonista() |
Kuidas saaks korraldada nii, et ülemklassi tüüpi muutuja, mis viitab alamklassi objektile, pöörduks nö "õige" funktsiooni poole? Seda võimaldavad virtuaalsed (virtual) funktsioonid.
Virtuaalsed (virtual) funktsioonid ja polümorfism (polymorphism)
Virtuaalne funktsioon on liikmefunktsioon, mis deklareeritakse ülemklassis ja kaetakse üle alamklassis. Funktsioonile lisatakse võtmesõna virtual
. Kui ülemklassi tüüpi muutuja, mis viitab alamklassi objektile, pöördub virtuaalse funktsiooni poole, siis C++ otsustab täitmise käigus, millise funktsiooni poole pöörduda. Näiteks, kui funktsioon joonista()
on virtuaalne, siis n2->joonista()
pöördub Nelinurk
funktsiooni poole ja r2->joonista()
pöördub Ristkülik
funktsiooni poole. Seda nimetatakse hiliseks seostamiseks (late binding) või ka dünaamiliseks (dynamic) seostamiseks. Alamklassis virtuaalse funktsiooni ülekatmisel ei pea kasutama võtmesõna virtual
, kuid seda on selguse mõttes siiski soovitav teha.
NB! Hiline seostamine töötab ainult viitade (pointer) või viidete (reference) korral.
#include <iostream> using namespace std; class Kujund { public: virtual void joonista() { cout << "Kujund: joonista()\n"; } }; class Kolmnurk : public Kujund { public: virtual void joonista() { cout << "Kolmnurk: joonista()\n"; } }; class Nelinurk : public Kujund { public: virtual void joonista() { cout << "Nelinurk: joonista()\n"; } }; class Ristkülik : public Nelinurk { public: virtual void joonista() { cout << "Ristkülik: joonista()\n"; } }; int main() { Kujund k1{}; Kolmnurk kn1{}; Nelinurk n1{}; Ristkülik r1{}; Kujund* kn2{&kn1}; Kujund* n2{&n1}; Kujund* r2{&r1}; kn2->joonista(); n2->joonista(); r2->joonista(); cout << "---\n"; Kujund* kujundid[]{&k1, kn2, n2, r2}; for (Kujund* k: kujundid) { k->joonista(); } return 0; } | Kolmnurk: joonista() Nelinurk: joonista() Ristkülik: joonista() --- Kujund: joonista() Kolmnurk: joonista() Nelinurk: joonista() Ristkülik: joonista() |
Näites on ka massiiv, mille elemendid viitavad Kujund
tüüpi objektidele. Tänu virtuaalsetele funktsioonidele valitakse täitmise käigus iga objekti (kuigi on Kujund
tüüpi) korral sobiv funktsioon sõltuvalt sellest, mis tüüpi objektile viidamuutuja viitab.
Sõna polümorfism tähendab paljude vormide olemasolu. Polümorfism keeles C++ tähendab, et sama nimega liikmefunktsioonil on klasside hierarhias palju eri vorme. Defineerides funktsioonid virtuaalseteks, sõltub konkreetse funktsiooni valimine funktsiooni käivitava objekti tüübist. Ehk siis, virtuaalsed funktsioonid võimaldavad rakendada polümorfismi.
Virtuaalsete funktsioonide poole saab pöörduda ka otse (alam)klassi objekti korral, nagu on näha järgmises näites
Kujund k1{}; Kolmnurk kn1{}; Nelinurk n1{}; Ristkülik r1{}; k1.joonista(); kn1.joonista(); n1.joonista(); r1.joonista(); | Kujund: joonista() Kolmnurk: joonista() Nelinurk: joonista() Ristkülik: joonista() |
Praktikud soovitavad alamklassides mittevirtuaalseid funktsioone selguse mõttes mitte üle katta, sest juurdepääs sellistele funktsioonidele on ainult täpselt sama tüüpi objektist ja klasside hierarhiat ei ole vaja, vt
Effective C++: 50 Specific Ways to Improve Your Programs and Designs (Addison-Wesley Professional Computing Series) Subsequent Edition, by Scott Meyers.
Täpsemalt saab virtuaalsete funktsioonide kohta uurida aadressil https://en.cppreference.com/w/cpp/language/virtual
Teisendamine dynamic_cast
abil
Operaatori dynamic_cast
abil on võimalik alamklassi teisendada ülemklassiks. Olgu meil klassid Isik
ja Tudeng
, kusjuures Tudeng
on klassi Isik
alamklass. Isikul on nimi ja tudengil lisaks õppekava. Mõlema klassi jaoks on defineeritud operator<<
, kusjuures alamklassi operator<<
definitsioonis ei pea ülemklassi oma üle kordama. Lühiduse eesmärgil on kogu kood ühes failis.
#include <iostream> #include <string> using namespace std; class Isik{ public: Isik() = default; Isik(string nimi): m_nimi{nimi}{} friend ostream& operator<< (ostream& os, Isik& i); private: string m_nimi{}; }; class Tudeng : public Isik{ public: Tudeng(string nimi, string oppekava) : Isik(nimi), m_oppekava{oppekava}{} friend ostream& operator<<(ostream& os, Tudeng& t); private: string m_oppekava{}; }; int main() { Isik isik{"Kalle Kass"}; Tudeng tudeng{"Mia Moos", "informaatika"}; cout << isik << '\n'; cout << tudeng << '\n'; return 0; } ostream& operator<< (ostream& os, Isik& i){ os << "Isik: " << i.m_nimi; return os; } ostream& operator<<(ostream& os, Tudeng& t){ os << dynamic_cast<Isik&>(t) << " on tudeng õppekaval " << t.m_oppekava; return os; } | Isik: Kalle Kass Isik: Mia Moos on tudeng õppekaval informaatika |
NB! Seda, kas dynamic_cast
on võimalik, saab kontrollida ainult siis, kui kasutada viitasid (pointer).
Täpsemalt saab uurida aadressil https://en.cppreference.com/w/cpp/language/dynamic_cast
Abstraktsed funktsioonid ja abstraktsed klassid
Mõnikord ei ole võimalik ülemklassis virtuaalsele funktsioonile sisu anda. Sellisel juhul võib selle funktsiooni kuulutada abstraktseks ehk puhtaks virtuaalseks funktsiooniks (pure virtual function). Kuulutame klassis Kujund
virtuaalse funktsiooni joonista()
abstraktseks:
virtual void joonista() = 0;
Kuna nüüd klass Kujund
sisaldab funktsiooni, millel puudub realisatsioon, siis sellest klassist isendit (objekti) teha ei saa. Sellist klassi nimetatakse abstraktseks (abstract). Abstraktsest klassist pärimisel tuleb abstraktsed funktsioonid üle katta. Kui seda ei tehta, on päritud klass samuti abstraktne. Abstraktset klassi võib kasutada viida või viite tüübina. Eelmises näites muutub ainult see, et klassist Kujund
ei saa objekti teha:
#include <iostream> using namespace std; class Kujund { public: virtual void joonista() = 0; // abstraktne virtuaalne funktsioon }; class Kolmnurk : public Kujund { public: virtual void joonista() { cout << "Kolmnurk: joonista()\n"; } }; class Nelinurk : public Kujund { public: virtual void joonista() { cout << "Nelinurk: joonista()\n"; } }; class Ristkülik : public Nelinurk { public: virtual void joonista() { cout << "Ristkülik: joonista()\n"; } }; int main() { //Kujund k1{}; // Klassist Kujund ei saa objekti teha Kolmnurk kn1{}; Nelinurk n1{}; Ristkülik r1{}; // k1.joonista(); kn1.joonista(); n1.joonista(); r1.joonista(); cout << "---\n"; Kujund* kn2{&kn1}; // Klassi Kujund võib viida tüübina kasutada Kujund* n2{&n1}; Kujund* r2{&r1}; kn2->joonista(); n2->joonista(); r2->joonista(); cout << "---\n"; Kujund* kujundid[]{kn2, n2, r2}; // Siit on eemaldatud k1 for (Kujund* k: kujundid) { k->joonista(); } return 0; } | Kolmnurk: joonista() Nelinurk: joonista() Ristkülik: joonista() --- Kolmnurk: joonista() Nelinurk: joonista() Ristkülik: joonista() --- Kolmnurk: joonista() Nelinurk: joonista() Ristkülik: joonista() |
Kuna abstraktne klass sisaldab abstraktseid funktsioone, siis saab seda kasutada alamklasside jaoks liidesena (interface).
Abstraktsete klasside kohta saab täpsemalt uurida aadressil https://en.cppreference.com/w/cpp/language/abstract_class
Staatilised (static) muutujad ja funktsioonid
Mõnikord on vaja, et klassi liige oleks seotud klassiga, mitte klassi objektidega. Näiteks laenude intressimäär (eeldame, et kõigil laenudel on sama intressimäär). Või on vaja loendada, mitu laenu on võetud. Üks lahendus objektide loendamiseks on globaalne muutuja, mida suurendatakse konstruktoris. C++ võimaldab seda lahendada klassimuutujaga, mille ees on piiritleja static
. Vaatame näidet klassist Laen
, kus on ainult privaatsed väljad intressi ja laenude arvu jaoks.
private: static int LaenudeArv; static double intress;
Kuna need väljad ei ole seotud ühegi klassi objektiga ja ei ole konstantsed (const
, consexpr
), siis saab neid algväärtustada väljaspool klassi, kasutades klassi skoobi operaatorit Laen::
.
double Laen::intress = 0.001; int Laen::LaenudeArv = 0;
Klassi staatilised liikmed eksisteerivad väljaspool klassi objekte, st objektid ei sisalda staatiliste andmeliikmetega seotud andmeid. On ainult üks intress
ja laenudeArv
, mida jagavad kõik Laen
objektid.
Staatilised võivad olla ka funktsioonid. Staatilise funktsiooni sisu defineerimine võib olla ka väljaspool klassi. Väljaspool klassi ei pea enam võtmesõna static
kasutama. Staatilised liikmed on kättesaadavad klassi kõikides funktsioonides (ka sellistes, mis ei ole static
). Klassi liikmefunktsioonides on staatilised liikmed kättesaadavad otse, ilma skoobioperaatorita.
Väljaspool klassi saab mitteprivaatsete staatiliste liikmete poole pöörduda, kasutades kas klassi skoobioperaatorit või klassi objekti (ka viita), nt
Laen::getLaenudeArv(); l1.getLaenudeArv();
#include <iostream> #include <string> using namespace std; class Laen { public: Laen() { LaenudeArv += 1; // Suurendatakse iga objekti loomisel } static double getIntress() { return intress; } static void setIntress(double); static int getLaenudeArv() { return LaenudeArv; } private: static int LaenudeArv; static double intress; }; double Laen::intress = 0.001; // Algväärtustamine int Laen::LaenudeArv = 0; // Algväärtustamine void Laen::setIntress(double uusIntress) { intress = uusIntress; } int main() { Laen l1{}; cout << "Laenude arv: " << l1.getLaenudeArv() << " " << Laen::getLaenudeArv() << '\n'; cout << "Intress: " << l1.getIntress() << " " << Laen::getIntress() << '\n'; Laen l2{}; l1.setIntress(0.05); // või Laen::setIntress(0.05); cout << "Laenude arv: " << l1.getLaenudeArv() << " " << l2.getLaenudeArv() << '\n'; cout << "Intress: " << l1.getIntress() << " " << l2.getIntress() << '\n'; return 0; } | Laenude arv: 1 1 Intress: 0.001 0.001 Laenude arv: 2 2 Intress: 0.05 0.05 |
Klassi staatiliste liikmete kohta saab täpsemalt uurida aadressil https://en.cppreference.com/w/cpp/language/static