Dedovanje

Dedovanje je način, da novi tipi obdržijo (podedujejo) atribute in metode drugih tipov. Poleg tega lahko dodajo novo obnašanje, ali spremenijo starega. Slednji koncept se imenuje overriding, ki ga ne mešati z Overloading.

Osnove dedovanja

Začnimo s preprostim primerom:

class Torta {
  public:
    void peci() { cout << "Torta se pece." << endl; }
};

class RojstnodnevnaTorta : public Torta {
  public:
    int st_sveck;
    RojstnodnevnaTorta() : st_sveck(0) {}
    void dodaj_svecke(int n) { st_sveck = n; }
};

Tukaj razred RojstnodnevnaTorta deduje od razreda Torta, saj je rojstnodnevna torta posebna vrsta torte - relacija “je” nakazuje, da je uporaba podrazreda primerna. Po drugi strani, rojstnodnevna torta ima svečke, kjer relacija “ima” označuje da so svečke njen atribut (in ne npr. da bi RojstnodnevnaTorta dedovala od svečke).

Sintaksa oblike class B : public A označuje dedovanje, pri čemer B dobi vse atribute in metode, ki jih ima A. Beseda public označuje, da atributi ohranijo enak nivo dostopa (npr. public, private ali protected), kot so ga imeli v A.

Pri dedovanju lahko B doda novo obnašanje in nove podatke, tako kot smo do zgoraj naredili za rojstnodnevno torto. Pravimo, da je A podrazred razreda B, B pa nadrazred razreda A.

Sedaj lahko uporabljamo v podrazredu tudi podedovane metode nadrazreda.

int main() {
    RojstnodnevnaTorta torta;
    torta.peci();  // podedovano od torte
    torta.dodaj_svecke(7);
}

Konstruktorji in destruktorji

Dodajmo v naše razrede še konstruktorje. Razred Torta naj ima maso, razred RojstnodnevnaTorta pa še ime slavljenca. Oglejmo si definiciji:

class Torta {
    double masa_;
  public:
    Torta(double masa) : masa_(masa) {}
    void peci() { cout << "Torta se pece." << endl; }
};

class RojstnodnevnaTorta : public Torta {
  public:
    string slavljenec_;
    int st_sveck;
    RojstnodnevnaTorta(const string slavljenec, double masa) :
        Torta(masa), slavljenec_(slavljenec), st_sveck(0) {}
    void dodaj_svecke(int n) { st_sveck = n; }
};

Razred Torta ima običajen konstruktor, razred RojstnodnevnaTorta pa mora v konstruktorju kot prvo stvar poklicati nek konstruktor razreda Torta, ki mu v zgornjem primeru poda maso. To se je zgodilo tudi v prejšnjem primeru, le da ni bilo eksplicitno napisano. Razred Torta je namreč imel default konstruktor, ki ne sprejeme parametrov in se je samodejno poklical pred klicem konstruktorja RojstnodnevnaTorta. Če imamo verigo dedovanja D : C : B : A se ob konstruiranje objekta D začne s klicem izbranega konstruktorja A, nadaljuje z B, C, konstruktor D pa se pokliče zadnji, ko so vsi starši že narejeni. Med drugim to pomeni, da so podedovani deli objekta že konstruirani in lahko npr. kličemo podedovane metode ali uporabljamo podedovane atribute. V drugih jezikih se tak klic pogosto naredi z uporabo super (npr. Python, Java).

Pri destruktorjih je zgodba podobna, a obrnjena okrog. Če na razredu tipa D pokličemo destruktor, se po koncu avtomatsko pokličejo tudi konstruktorji staršev, tako da so podedovani atributi od A uničeni zadnji.

Poglejmo si to še na primeru:

class A {
  public:
    A()  { cout << __func__ << endl; }
    ~A() { cout << __func__ << endl; }
};

class B : public A {
  public:
    B() { cout << __func__ << endl; }
    ~B() { cout << __func__ << endl; }
};

class C : public B {
  public:
    C() { cout << __func__ << endl; }
    ~C() { cout << __func__ << endl; }
};

class D : public C {
  public:
    D() { cout << __func__ << endl; }
    ~D() { cout << __func__ << endl; }
};

int main() {
    cout << "test konstruktorjev in destruktorjev:" << endl;
    { D d; }
    return 0;
}

Makro __func__ je poseben ukaz v C++, ki se tekom prevajanja razširi v ime funkcije, kjer smo ga uporabili. Če poženemo zgornji program, se izpiše

test konstruktorjev in destruktorjev:
A
B
C
D
~D
~C
~B
~A

kar se sklada z zgornjo razlago. Prav tako vidimo, da so vsi klici konstruktorjev ali destruktorjev staršev avtomatski in jih prevajalnik sam zgenerira namesto nas.

Slicing

Eno izmed osnovnih načel dedovanja je, da lahko spremenljivko bolj specifičnega tipa shranimo kot manj specifičen tip. Z našim primerom od prej gre sklep tako: ker je RojstnodnevnaTorta tudi Torta, lahko spremenljivko tipa RojstnodnevnaTorta shranimo v spremenljivko tipa Torta.

RojstnodnevnaTorta rt("Janez", 3.4);
Torta t = rt;

Pri tem zgubimo vse informacije o tem, da je t kdaj bila RojstnodnevnaTorta in na spremenljivki t lahko kličemo le metode in dostopamo do atributov, ki jih ima Torta. Ta proces se imenuje slicing ali object slicing, saj od podobjekta odrežemo stran vse metode in atribute, ki jih osnovni objekt nima. To je z vidika alokacije prostora smiselno, za spremenljivko tipa Torta imamo rezervirano toliko prostora, kot ga potrebujemo zanjo in dodatne informacije morajo preč.

Hiding

Recimo, da sedaj spremenimo definicijo razreda RojstnodnevnaTorta, tako da odstranimo dodatne konstruktorje in dodamo lastno metodo peci.

class RojstnodnevnaTorta : public Torta {
  public:
    int st_sveck;
    RojstnodnevnaTorta() : Torta(1.0), st_sveck(0) {}
    void dodaj_svecke(int n) { st_sveck = n; }
    void peci() { cout << "Pecem rojstnodnevno torto." << endl; }
};

Sedaj poglejmo, kaj se zgodi, ko pokličemo

RojstnodnevnaTorta rt;
Torta t = rt;
rt.peci();
t.peci();

Izpiše se Pecem rojstnodnevno torto., čemur sledi še Torta se pece.. To je zato, ker je t tipa Torta, rt pa tipa RojstnodnevnaTorta in metoda peci se na teh dveh razredih obnaša različno. v veliko programskih jezikih, npr. v Javi, bi se obakrat izpisalo Pecem rojstnodnevno torto., saj bi jezik se vedno vedel, da se, kljub temu, da je t tipa Torta, v njem skriva RojstnodnevnaTorta. V C++ zaradi slicing-a temu ni tako. Z zgornjim primerom smo dosegli le, da na objektu rt ne moremo več direktno metode peci iz razreda Torta, saj jo je skrila enako imenovana metoda peci iz razreda RojstnodnevnaTorta. Temu procesu se v angleščini reče hiding, saj metoda iz podrazreda prepreči dedovanje (skrije) metode iz nadrazreda, ki imajo enako ime. To bi se zgodilo tudi, če metoda peci ne bi imela popolnoma enakih parametrov, kot metoda peci iz razreda Torta. Primer:

class RojstnodnevnaTorta : public Torta {
  public:
    int st_sveck;
    RojstnodnevnaTorta() : Torta(1.0), st_sveck(0) {}
    void dodaj_svecke(int n) { st_sveck = n; }
    void peci(int m) { cout << "Pecem rojstnodnevno torto za " << m << "minut." << endl; }
};

Tudi v tem primeru prek objekta rt ne bi morali poklicati rt.peci() brez parametrov, saj se to sklicuje na skrito (in zato ne podedovano) metodo peci iz razreda Torta. Dobimo napako:

torta.cpp: In function ‘int main()’:
torta.cpp:21:13: error: no matching function for call to ‘RojstnodnevnaTorta::peci()’
     rt.peci();
             ^
torta.cpp:15:10: note: candidate: ‘void RojstnodnevnaTorta::peci(int)’
     void peci(int m) { cout << "Pecem rojstnodnevno torto za " << m << "minut." << endl; }
          ^~~~
torta.cpp:15:10: note:   candidate expects 1 argument, 0 provided

ki pove le, da smo metodo peci poklicali narobe. Prevajalnik clang++ je tukaj bolj uporabniku prijazen:

torta.cpp:21:8: error: too few arguments to function call, expected 1, have 0; did you mean 'Torta::peci'?
    rt.peci();
       ^~~~
       Torta::peci
torta.cpp:6:10: note: 'Torta::peci' declared here
    void peci() { cout << "Torta se pece." << endl; }
         ^
1 error generated.

in namigne, da smo morda želeli poklicati metodo iz nadrazreda. Če želimo poleg metod v podrazredu tudi metode z enakim imenom iz osnovnega razreda, moramo njihovo dedovanje eksplicitno zahtevati. To lahko storimo z ukazom using, kot v primeru spodaj.

class RojstnodnevnaTorta : public Torta {
  public:
    int st_sveck;
    RojstnodnevnaTorta() : Torta(1.0), st_sveck(0) {}
    void dodaj_svecke(int n) { st_sveck = n; }
    using Torta::peci;
    void peci(int m) { cout << "Pecem rojstnodnevno torto za " << m << "minut." << endl; }
};

Sedaj imamo na voljo tako rt.peci() (eksplicitno podedovano iz razreda Torta) in rt.peci(7) iz razreda RojstnodnevnaTorta. Če bi imeli obe metodi isto ime, ki morali (pa tudi sedaj lahko) metodo iz nadrazreda klicati z polno kvalificiranim imenom kot rt.Torta::peci(). Zaenkrat sicer še ne vemo, kaj so virtualne metode, toda princip skrivanja je zanje enak kot za običajne metode (kadar ne pride v igro overriding).

Polimorfizem in virtualne funkcije

Pred branjem morate biti seznanjeni s snovjo v poglavju Kazalci in reference. Zaradi enostavnosti bomo v tem poglavju uporabljali navadne kazalce, vendar vse deluje enako tudi s pametnimi kazalci.

Polimorfizem (angl. polymorphism) pomeni “imeti več oblik” in se v kontekstu dedovanja nanaša na to, imamo lahko več podrazredov istega nadrazreda, ki se obnašajo vsak na svoj način, medtem ko še vedno imajo iste metode, predpisane s strani nadrazreda.

Denimo da imamo spodnjo situacijo:

struct Animal {
    string oglasanje() const { return ""; }
};

struct Dog : public Animal {
    string oglasanje() const { return "Hov"; }
};

struct Cat : public Animal {
    string oglasanje() const { return "Nyaa"; }
};

int main() {
    Cat c;
    Dog d;
    vector<Animal> v;
    v.push_back(c);
    v.push_back(d);
    for (const Animal& a : v) {
        cout << a.oglasanje() << endl;
    }
    return 0;
}

Ko poženemo zgornji program, ki radi, da se izpiše Nyaa in Hov, saj smo v v shranili mačko in psa. Toda, kot smo se naučili v razdelku Slicing se objekta Cat in Dog pretvorita v Animal in vse dodatne informacije izginejo. Izpiše se torej dvakrat prazen niz. Toda, če uporabimo kazalce, problem z različnimi velikostmi objektov, ko nadrazredu priredimo podrazred, izgine. Oba objekta sta kazalca enake velikosti (kakršna pač je na tem sistemu) in lahko kažeta na različno velika objekta. toda, to da še ni ovir, ne pomeni da je obnašanje tako. Koda spodaj

Cat* c = new Cat();
Dog* d = new Dog();
vector<Animal*> v;
v.push_back(c);
v.push_back(d);
for (const Animal* a : v) {
    cout << a->oglasanje() << endl;
}
return 0;

še vedno izpiše dva prazna niza: oba objekta sta kazalca na tip Animal in enako kot prej se pokliče metoda oglasanje na tipu Animal.

To, da bi se metoda oglasanje obnašala drugače, glede na to ali je vrednost, na katero kaže kazalec, v resnici tipa Cat, stane nekaj operacij. Pri drugih jezikih (npr. Java) se to vedno preveri in uporabnik za vsak klic plača te operacije, filozofija C++ pa je, da uporabnik ne plača, za stvari, ki jih ni zahteval in moramo polimorfično obnašanje posebej zahtevati.

To storimo z besedo virtual pred neko metodo. Ta označuje, da pri tej metodi podpiramo polimorfično obnašanje in dovolimo, da jo podrazredi predefinirajo (overridajo). Virtualne funkcije niso virtualne v smislu da ne obstajajo (te bomo spoznali kasneje), ampak so virtualne zgolj v smislu, da deklaracija ni direktno povezana z implementacijo. Kot bomo videli, so to funkcije, za katere je implementirano dinamično razvrščanje (angl. dynamic dispatch).

Spremenimo definicijo Animal v sledečo.

struct Animal {
    virtual string oglasanje() const { return ""; }
};

Vse kar smo dodali, je beseda virtual, ki označuje, da naj se pri klicu funkcije oglasanje uporabi polimorfizem: med izvajanjem programa (in ne pri prevajanju, kot ponavadi), se glede na trenuten tip kazalca na objekt izbere, katera implementacija virtualne funkcije oglasanje bo poklicana. Izbere se tisto, ki pripada objektu, ki je dejansko shranjen na mestu, kamor kaže kazalec. S spremenjeno definicijo, bi zadnji primer izpisal Nyaa in Hov, saj je prvi objekt (čeprav shranjen kot Animal*) v resnici tipa Cat* in bi se poklicala njegova metoda ogasanje (ki je predefinirala tisto iz Animal). Enako se zgodi za drugi element. Temu obnašanju pravimo polimorfizem in dinamičnemu klicanju glede na tip objekta med izvajanjem dynamic dispatch. Enako obnašanje dobimo, če kličemo metode prek referenc na objekte.

void oglasaj(const Animal& a) {
    cout << a.oglasanje() << endl;
}

int main() {
    oglasaj(Cat());
    oglasaj(Dog());
    return 0;
}

Izpiše se Nyaa in Hov, saj je klic prek reference polimorfičen.

Predefinicije virtualnih funkcij so avtomatsko virtualne, tako da ni potrebno ponovno pred njih pisati besede virtual. Da pa se izognemo morebitnim napakam, je dobro uporabiti besedo override s katero prevajalniku (in programerju) nakažemo, da je mišljeno, da ta funkcija predefinira neko virtualno funkcijo iz nadrazreda. Primer, ko nam to pomaga, sledi. Denimo, da definiramo podrazred Dog tako, pri čemer si mislimo, da smo predefinirali oglasanje.

struct Dog : public Animal {
    string oglasanje() { return "Hov"; }
};

Toda, Če bi pognali Animal* a = new Dog(); cout << a->oglasanje() << endl; bi bili najprej prijetno presenečeni, ker prevajalnik nebi javil napak, in nato neprijetno presenečeni, ker bi se izpisal prazen niz. To je zato, ker smo pozabili const pri zgornji metodi in je prevajalnik to smatral kot drugo metodo, ki je samo skrila (v smislu razdelka Hiding) metodo oglasanje iz nadrazreda. Če pa uporabimo override

struct Dog : public Animal {
    string oglasanje() override { return "Hov"; }
};

pa nas prevajalnik posvari:

program.cpp:9:12: error: ‘std::__cxx11::string Dog::oglasanje()’ marked ‘override’, but does not override
     string oglasanje() override { return "Hov"; }
            ^~~~~~~~~

Prevajalnik Clang, nam celo predlaga, da smo morda mislili predefinirati metodo, ki smo jo ponesreči skrili in celo pove, v čem se razlikujeta:

program.cpp:9:24: error: non-virtual member function marked 'override' hides virtual member function
    string oglasanje() override { return "Hov"; }
                       ^
programprogram.cpp:5:20: note: hidden overloaded virtual function 'Animal::oglasanje' declared here: different qualifiers (const vs none)
    virtual string oglasanje() const { return "";}
                   ^

Podobno se zgodi, če smo ponesreči pozabili metodo v nadrazredu označiti kot virtualno, čeprav smo popolnoma pravilno predefinirali metodo spodaj. V tem primeru se brez override prevajalnik prav tako ne pritoži in program samo ne deluje po naših željah, medtem ko z override dobimo jasno napako

program.cpp:9:30: error: only virtual member functions can be marked 'override'
    string oglasanje() const override { return "Hov"; }
                             ^~~~~~~~~

Uporaba override je tako zelo priporočena in pri prevajalnikih obstajajo celo zastavice, ki opozorijo, da smo to besedo pozabili.

Warning

Polimorfično obnašanje dobimo samo, če imamo oboje: virtualno funkcijo, ki smo jo klicali prek kazalca ali reference, ki je tipa našega nadrazreda.

To pomeni, da tudi če je metoda virtualna, pa jo kličemo direktno na objektu nadrazreda, se bo klicala metoda nadrazreda, in ne od potencialnega otroka (zaradi slicinga). Prav tako, tudi če kličemo metodo prek kazalca, ki je z enakim imenom definirana v podrazredu, pa je nismo označili kot virtualne, se bo zopet poklicala metoda nadrazreda. To pokaže naslednji primer:

struct A {
    void f() { cout << "A::f" << endl; }
    virtual void g() { cout << "A::g" << endl; }
};

struct B : public A {
    void f() { cout << "B::f" << endl; }
    void g() override { cout << "B::g" << endl; }
};

int main() {
    A a;
    B b;
    A ab = B();
    a.f(); a.g();
    b.f(); b.g();
    ab.f(); ab.g();

    cout << "------------" << endl;

    A* pa = &a;
    B* pb = &b;
    A* pab = &b;
    pa->f(); pa->g();
    pb->f(); pb->g();
    pab->f(); pab->g();

    cout << "------------" << endl;

    A& ra = a;
    B& rb = b;
    A& rab = b;
    ra.f(); ra.g();
    rb.f(); rb.g();
    rab.f(); rab.g();
}

Pri prvem sklopu se vedno kličejo metode (ne glede na virtualnost) pripadajoče tipu objekta, saj kličemo direktno prek objekta. Pri drugem in tretjem sklopu pa se metoda g pri klicu prek kazalca ali reference kliče polimorfično in tudi v zadnji vrstici dobimo izpis B::g.

A::f
A::g
B::f
B::g
A::f
A::g
------------
A::f
A::g
B::f
B::g
A::f
B::g
------------
A::f
A::g
B::f
B::g
A::f
B::g

Stvari postanejo komplicirane, če imamo na kupu več funkcij z enakim imenom in različnimi parametri, nekatere so virtualne, nekatere niso in lahko z predefiniranjem neke metode uvedemo skrivanje neke druge...

Konstruktorji in virtualni destruktorji

Osnove o konstruktorjev in destruktorjev so razložene v razdelku Konstruktorji in destruktorji, ta razdelek opisuje njihovo obnašanje pri dedovanju.

Če razred B deduje od razreda A, potem se vedno ko naredimo objekt tipa B najprej pokliče konstruktor starša, nato pa še naš konstruktor. to med drugim pomeni, da se v konstruktorju B lahko zanašamo na obstoj in pravilno delovanje vseh metod objekta A. Tak sistem konstruiranja objektov zagotavlja, da konstruktor A skrbi za vse, kar je v povezavi s tipom A, konstruktor B pa za vse kar je v povezavi s tipom B.

Podobno velja tudi pri destruktorjih. Pri uničenju objekta tipa B se najprej pokliče destruktor B, ki kot zadnje dejanje pokliče starševski destruktor.

Primer (z napako, razloženo kasneje):

struct A {
    A() { cout << "A ctor" << endl; }
    ~A() { cout << "A dtor" << endl; }
};

struct B : public A {
    B() { cout << "B ctor" << endl; }
    ~B() { cout << "B dtor" << endl; }
};

int main() {
    B b;
    return 0;
}

Zgornja koda izpiše

A ctor
B ctor
B dtor
A dtor

Poglejmo si še, kaj se zgodi, če kličemo objekte prek pointerjev. Funkcijo main od zgoraj spremenimo v

int main() {
    A* b = new B();
    delete b;
    return 0;
}

in ko jo poženemo, dobimo

A ctor
B ctor
A dtor

Kot vidimo, se destruktor B ni poklical. To pravzaprav ni nepričakovano, ker smo b naredili kot objekt tipa B, sta te poklicala oba konstruktorja, nato smo ga shranili v kazalec tipa A, ko pa smo ga uničili, se je poklical le destruktor A, saj je to bil takratni tip objekta. Toda to pomeni, da je pol objekta (B-jev del) ostalo nepospravljenega. Obnašanje je podobno kot pri virtualnih in navadnih funkcijah (glej Polimorfizem in virtualne funkcije). Rešitev je, da se destruktor B označi kot virtualen. Če vemo, da objekta ne bomo nikoli uničevali prek kazalca na starševski razred, tega ni potrebno storiti, vendar se je za vsak slučaj dobro navaditi, da pri vsakem dedovanju označimo destruktor v spodnjem razredu kot virtualen, da se izognemo potencialnim težavam v prihodnosti.

Ko destruktor B izgleda kot ~B() { cout << "B dtor" << endl; }, dobimo pričakovan izhod

A ctor
B ctor
B dtor
A dtor

Povedali smo, da prevajalnik za nas pokliče starševske konstruktorje, kar drži, če obstaja default konstruktor za starša. Če ima starševski konstruktor parametre, ga moramo pri konstruiranju poklicati eksplicitno:

struct A {
    A(int a, int b) {}
};

struct B : public A {
    B() : A(2, 3) {}  // klic starševega konstruktorja
};

Čiste virtualne funkcije in abstraktni razredi

TODO

Primer:

#include <string>
#include <iostream>
#include <vector>
#include <memory>

using namespace std;

struct Being {
    virtual string zadnje_besede() const = 0;
    void die() {
        cout << zadnje_besede() << endl;
    }
    virtual ostream& print(ostream& os) const {
        return os << "bitje";
    }
    virtual ~Being() {}
};
ostream& operator<<(ostream& os, const Being& b) {
    return b.print(os);
}

struct Plant : public Being {
    string zadnje_besede() const override {
            return "Screw vegans.";
    }
};

struct Animal : public Being {
    virtual string oglasanje() const = 0;
    string zadnje_besede() const override {
        return "Ouch.";
    }
    ostream& print(ostream& os) const override {
        Being::print(os);
        return os << " animal:";
    }
};

struct Dog : public Animal {
    string name;
    Dog(string name) : name(name) {}
    string oglasanje() const override {
        return "Hov " + name;
    }
    string zadnje_besede() const override {
        return "Wasn't I a good boy?";
    }
    ostream& print(ostream& os) const override {
        Animal::print(os);
        return os << " Dog: " << name;
    }
};

struct Cat : public Animal {
    string oglasanje() const override {
        return "Nyaa";
    }
};

struct Duck : public Animal {
    string oglasanje() const override {
        return "Quack";
    }
};

int main() {
    // Animal a;

    vector<unique_ptr<Animal>> v;
    v.push_back(make_unique<Dog>("Piki"));
    v.push_back(make_unique<Cat>());
    v.push_back(make_unique<Dog>("Fido"));
    v.push_back(make_unique<Duck>());
    v.push_back(make_unique<Dog>("Jakob"));
    v.push_back(make_unique<Cat>());

    /*
    for (const auto& p : v) {
        cout << p->oglasanje() << endl;
    }*/

    vector<unique_ptr<Being>> b;
    b.push_back(make_unique<Dog>("Piki"));
    b.push_back(make_unique<Cat>());
    b.push_back(make_unique<Duck>());
    b.push_back(make_unique<Plant>());
    for (const auto& p : b) {
        p->die();
        try {
            // cout << typeid(p.get()).name() << endl;
            Animal* a = dynamic_cast<Animal*>(p.get());
            if (a == nullptr) {
                cout << "a is null" << endl;
            } else {
                // cout << "here" << endl;
                cout << a->oglasanje() << endl;
                // cout << "here" << endl;
            }

        } catch (std::bad_cast& bc) {
            cout << "to ni Animal" << endl;
        }
    }

    cout << "------------------" << endl;
    for (const auto& p : b) {
        cout << *p << endl;
    }


    Dog d("Fifi");
    cout << d << endl;

    return 0;
}

Daljši primer uporabe - risanje oblik

TODO