Nasljeđivanje/proširivanje

Nove klase možemo definirati polazeći od već postojećih metodom nasljeđivanja (proširivanja). Kada klasu B definiramo proširivanjem klase A tada u osnovi klasa B:

Gornje tvrdnje treba korigirati u odnosu na prava pristupa jer je mehanizam nasljeđivanja ovisan o pravima pristupa pa je i samo nasljeđivanje kvalificirano labelama public, protected ili private.

Kada klasa B proširuje klasu A, onda govorimo da je klasa A nadklasa ili bazna klasa, a klasa B potklasa ili izvedena klasa.

1uml_A_B.png

Jednostavno proširivanje klase: Primjer

Pođimo od klase Osoba koja drži podatke o osobi (ime, prezime, ID):

class Osoba{
    public:
        Osoba(std::string ime, std::string prezime, int id = 0) :
                      mime(ime), mprezime(prezime), mid(id) {}

        // Postavi (novi) id
        void setId(int id){ mid = id; }
        void print(std::ostream & out) const;
        virtual ~Osoba(){}
    private:
        std::string mime;
        std::string mprezime;
        int         mid;
};

(Novi element ovdje je virtualni destruktor kojeg ćemo objasniti kasnije.)

Klijent

Proširujući klasu Osoba dolazimo do klase Klijent koja ima dodatno polje: msaldo. Nasljeđivanje klasa, odnosno konstrukcija klase proširivanjem omogućava nam da iskoristimo već postojeći kod u klasi Osoba:

class Klijent : public Osoba {
    public:
        Klijent(std::string ime, std::string prezime,
                int id = 0, double saldo = 0.0) :
                Osoba(ime, prezime, id), msaldo(saldo) {}

        void setSaldo(double saldo){ msaldo = saldo;}
        void print(std::ostream & out) const;
    private:
       double msaldo;
};

Sintaksa

Kod nasljeđivanja koristimo sljedeću sintaksu:

class ime_klase :  oznaka_pristupa ime_bazne_klase

gdje oznaka pristupa može biti public, private i protected. U gornjem kodu klasa Klijent proširuje klasu Osoba pa imamo:

class Klijent : public Osoba
osoba-klijent.png

Ciljevi nasljeđivanja

Nasljeđivanje i prava pristupa

Pristup članicama bazne klase ovisi o sljedećem:

  • Pravima pristupa u baznoj klasi;
  • Tipu nasljeđivanja.

Prava pristupa u baznoj klasi:

  • Varijable i metode označene public vidljive su izvan klase te su kao takve vidljive i u proširenoj klasi.
  • U proširenoj klasi nisu vidljive privatne varijable i metode bazne klase.
  • protected članovi nisu vidljivi izvan klase ali su vidljivi u proširenoj klasi. Članovi deklarirani protected imaju i ovo svojstvo:

Objekt izvedene klase može dohvatiti protected član svoje bazne klase samo na objektu kroz koji je izveden. To je u suprotnosti s pristupom privatnim članovima klase: kod unutar klase X može pristupiti privatnoj varijabli klase X na bilo kojoj instanci klase X.

Tip nasljeđivanja

Tip nasljeđivanja određen je labelom pristupa koju smo iskoristili pri definiciji klase: to može biti public, private i protected:

Sljedeća tabela sumira gornja pravila:

Prava pristupa članicama u bazi

Nasljeđivanje

public

protected

private

public

public

protected

nedostupno

protected

protected

protected

nedostupno

private

private

private

nedostupno

Napomena: Ako labela koja određuje tip nasljeđivanja nije eksplicitno dana, onda se podrazumijeva da je nasljeđivanje privatno ako je klasa deklarirana pomoću ključne riječi class, odnosno javno ako je deklarirana sa struct. Na primjer,

class A{ /* ... */ };
class B : A {/* ... */ };   // privatno nasljeđivanje
struct C : A {/* ... */ };  // javno nasljeđivanje

Javno i privatno nasljeđivanje

Javno, zaštićeno i privatno nasljeđivanje predstavljaju tri različita koncepta koji se upotrebljavaju u različitim situacijama. Kao prvo, zaštićeno nasljeđivanje se rijetko koristi, tako da ga možemo slobodno zanemariti.

Konstrukcija objekta izvedene klase

Svaki objekt izvedene klase sastoji se od dva dijela: podobjekta bazne klase i podobjekta proširene klase.

2A_B.png

Na primjer u slučaju klase Klijent imamo ovu sliku:

klijent-obj.png

Zadatak konstruktora je inicijalizirati oba dijela klase. Prvo se dešava inicijalizacija baznog dijela, a zatim inicijalizacija varijabli u izvedenoj klasi, redom kako su deklarirane u klasi.

Konstruktor izvedene klase

Konstruktor izvedene klase mora uvijek prvo pozvati konstruktore svih svojih baznih klasa u inicijalizacijskoj listi, u poretku u kojem su bazne klase deklarirane.

// konstruktor bazne klase
Osoba(std::string ime, std::string prezime, int id = 0) :
                 mime(ime), mprezime(prezime), mid(id) {}

// konstruktor izvedene klase
Klijent(std::string ime, std::string prezime, int id = 0, double saldo = 0.0) :
                                          Osoba(ime, prezime, id), msaldo(saldo) {}
Napomena
Ako se u defaultnom konstruktoru izvedene klase ispusti konstruktor bazne klase iz inicijalizacijske liste, onda će prevodilac ubaciti poziv defaultnom konstruktoru bazne klase.
class A{
  public:
    A() : mx(0) {}
  private:
    int mx;
};

class B : public A{
    public:
        B() : my(0) {} // prevodilac ubacuje poziv defaultnom
    private:           // konstruktoru od A
        int my;
};

Lanac nasljeđivanja

Lanac nasljeđivanja može biti proizvoljno dugačak. Objekt najizvedenije klase ima dio koji odgovara svakoj njegovoj nadklasi.

visest-nas.png

Nasljeđivanje i kontrola kopiranja

U kontrolu kopiranja ulaze:

Ukoliko ne implementiramo CC ili OP prevodilac će to učiniti za nas. To vrijedi i za proširenu klasu bez obzira da li bazne klase koriste vlastite ili sintetizirane članove. Ako jednu od te dvije funkcije (CC ili OP) ne implementiramo prevodilac će sintetizirati defaultnu verziju koja prvo poziva odgovarajuću funkciju u baznim klasama, a zatim vrši defaultnu inicijalizaciju ili pridruživanje članova proširene klase.

Kada implementiramo CCtor ili OP u proširenoj klasi, onda te funkcije moraju pozvati odgovarajuće funkcije iz bazne klase koje će izvršiti kopiranje baznog dijela objekta. CCtor to čini analogno kao drugi konstruktori, pozivanjem baznog/baznih CCtora u inicijalizacijskoj listi. OP mora pozvati bazni OP eksplicitno pomoću operatora dosega.

Napomena
Bazna se klasa sama brine o svome kopiranju.

Primjer - Osoba

U klasi Osoba je sintetizirana kontrola kopiranja posve zadovoljavajuća. Sve tražene funkcije dobivamo na ovaj način:

class Osoba{
    public:
        Osoba(std::string ime, std::string prezime, int id = 0) :
                      mime(ime), mprezime(prezime), mid(id) {}

        // Tražimo defaultnu kontrolu kopiranja
        Osoba(Osoba const & ) = default;
        Osoba(Osoba && ) = default;

        Osoba & operator=(Osoba const &) = default;
        Osoba & operator=(Osoba &&) = default;

        // Postavi (novi) id
        void setId(int id){ mid = id; }
        void print(std::ostream & out) const;
        virtual ~Osoba(){}
    private:
        std::string mime;
        std::string mprezime;
        int         mid;
};

Primjer - Klijent

U klasi Klijent dodajemo kontrolu kopiranja da bismo pokazali implementaciju. Defaultna kontrola kopiranja bi i ovdje bila dovoljna.

class Klijent : public Osoba {
    public:
        Klijent(std::string ime, std::string prezime, int id = 0, double saldo = 0.0) :
                                                 Osoba(ime, prezime, id), msaldo(saldo) {}

        Klijent(Klijent const &);
        Klijent(Klijent &&) noexcept;

        Klijent & operator=(Klijent const &);
        Klijent & operator=(Klijent &&) noexcept;

        void setSaldo(double saldo){ msaldo = saldo;}
        void print(std::ostream & out) const;
    private:
       double msaldo;
};

Primjer - implementacija

Konstruktori kopije prvo pozivaju (u inicijalizacijskoj listi) konstruktor kopije bazne klase koji kopira bazni dio objekta. Zatim vrše kopiranje članica iz izvedenog dijela objekta.

// CCtor
Klijent::Klijent(Klijent const & k) : Osoba(k), msaldo(k.msaldo){}

// M-Ctor
Klijent::Klijent(Klijent && k) noexcept : Osoba(std::move(k)), msaldo(k.msaldo) {}

Operatori pridruživanja moraju eksplicitno pozvati operator pridruživanja bazne klase i zatim izvršiti pridruživanje izvedenog dijela klase.

// OP
Klijent & Klijent::operator=(Klijent const & k){
    if(this != &k){
        Osoba::operator=(k);  // operator pridruživanja bazne klase
        msaldo = k.msaldo;
    }
    return *this;
}

// M-OP
Klijent & Klijent::operator=(Klijent && k) noexcept{
    if(this != &k){
        Osoba::operator=(std::move(k)); // operator pridruživanja bazne klase
        msaldo = k.msaldo; // move nije potreban
    }
    return *this;

}

Destruktor

Destruktor u proširenoj klasi nikada, za razliku od konstruktora i OP, nije dužan brinuti o destrukciji baznog dijela klase, već samo o dijelu deklariranom u proširenoj klasi. Stoga u destruktoru nikada ne pozivamo eksplicitno destruktor bazne klase. Prevodilac pri destrukciji objekta poziva redom destruktore (implementirane+sintetizirane) počeviši od onog u najvišoj proširenoj klasi pa prema najnižoj baznoj klasi. Objekt se, dakle, destruira u obrnutom redosljedu od onog u kojem je konstruiran.

S druge strane, ako je klasa namjenjena da bude bazna klasa, onda u njoj treba deklarirati virtualan destruktor, koji najčešće neće raditi ništa, ali je esencijalno da je prisutan.

Sintetizirana kontrola kopiranja u izvedenoj klasi

Doseg i nasljeđivanje

Znamo da svaka klasa predstavlja doseg pa stoga to vrijedi i za proširenu klasu. Pri tome vrijedi sljedeće pravilo:

Napomena
Doseg proširene klase je ugnježden u dosegu bazne klase.

Prerađivanje funkcija

Kada u izvedenoj klasi definiramo metodu iste signature (imena te broja i tipa argumenata) kakvu ima funkcija iz bazne klase onda kažemo da smo funkciju iz bazne klase preradili. Zbog pravila dosega na objektu izvedene klase uvijek će biti pozvana metoda iz izvedene klase (prerađena metoda), a ne ona iz bazne klase. Kako dvije metode imaju posve istu signaturu ukupan efekt je taj da je promijenjena implementacija metode iz bazne klase.

Na primjer, metoda

void print(std::ostream & out) const;

iz klase Osoba prerađena je u klasi Klijent.

Primjer.

Nasljeđivanje i prerađivanje funkcija.

#include <iostream>

using namespace std;

struct A
{
    void f() { cout << "A.f()" << endl;}
    void g() { cout << "A.g()" << endl;}
    void f(int x) { cout << "A.f(int)" << endl;}
};

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


int main()
{
    A a;             // objekt tipa A
    A *pa = &a;      // pokazivač na objekt tipa A
    A &ra = a;       // referenca na objekt tipa A

    B b;             // objekt tipa B
    B *pb = &b;      // pokazivač na objekt tipa B
    B &rb = b;       // referenca na objekt tipa B

    a.f();           // A::f()
    pa->f();         // A::f()
    ra.f();          // A::f()

    b.f();           // B::f()
    pb->f();         // B::f()
    rb.f();          // B::f()

    b.g();           // A::g()
    pb->g();         // A::g()
    rb.g();          // A::g()

    b.f(3);          // Greška pri kompilaciji
    pb->f(3);        // Klasa B nema funkciju tipa
    rb.f(3);         // void f(int)

    return 0;
}

Kada prevodilac traži funkciju danog imena potraga završava čim je ime nađeno. Tek tada ide provjera da li se funkcija može pozvati sa zadanim argumentima. Ako ne može, potraga za funkcijom se ne nastavlja u okružujućem dosegu. Posljedica: f() u izvedenoj klasi skriva f(int) u baznoj klasi.

Operator dosega

Iako je funkcija void A::f(int) skrivena u proširenoj klasi ona se u njoj može dohvatiti pomoću operatora dosega :: Tako bi u prethodnoj main funkciji sljedeći kod bio ispravan:

b.A::f(3);      // Dohvat pomoću operatora dosega
pb->A::f(3);    // Dohvat pomoću operatora dosega
rb.A::f(3);     // Dohvat pomoću operatora dosega

Using deklaracija

1. U nekim drugim situacijama mi zaista želimo preraditi samo jednu iz skupa preopterećenih funkcija. U tim slučajevima dužni smo sve preopterećene funkcije dovesti u lokalni doseg. To je upravo ono što radi deklaracija using. U deklaraciji using daje se samo ime funkcije (bez argumenata) s efektom da se sve preopterećene funkcije tog imena dovode u lokalni doseg. Tada je samo potrebno preopteretiti onu funkciju kojoj želimo promijeniti implementaciju.

2. Kod privatnog i zaštićenog nasljeđivanja prava pristupa elementima bazne klase postaju restriktivnija no što su to u baznoj klasi. Pojedinoj metodi ili varijabli iz bazne klase možemo vratiti njena originalna prava pristupa u proširenoj klasi pomoću using deklaracije. Potrebno je samo using deklaraciju staviti u odgovarajuću sekciju klase. Pri tome se prava pristupa ne mogu povećati iznad onih koje varijabla/metoda ima u baznoj klasi.

class A{
    public:
        A(int size_) : size(size_) {}
        int n() const {return size;}
        // ....
    private:
        int size;
        // ...
};


class B : private A{
    public:
        B(int size) : A(size) {}
        using A::n;      // Dovođenje funkcije n() u javnu sekciju klase B
};

Using deklaracija djeluje na ime, tako da ako imamo više preopterećenih funkcija imena n, sve će one pomoću using deklaracije biti dovedene u javnu sekciju klase B.

Napomena. Izraz

using namespace std;

je using direktiva kojom sva imena iz imenika std postaju vidljiva od mjesta direktive do kraja dosega.

Nasljeđivanje konstruktora, C++11

Ako se using deklaracija primijeni na konstruktor bazne klase onda prevodilac sintetizira jedan konstruktor izvedene klase za svaki konstruktor bazne klase. Sintetizirani konstruktor izvedene klase uzima iste argumente kao odgovarajući konstruktor bazne klase i poziva konstruktor bazne klase s tim argumentima.

class Base{
  public:
    Base() : mx(0) {}
    Base(int x) : mx(x) {}
    int mx;
};

class Derived : public Base {
  public:
    using Base::Base;  // Nasljeđivanje konstruktora
    int my;
};

Prevodilac sintetizira dva konstruktora u javnoj sekciji izvedene klase:

Derived() : Base(), my(0) {}
Derived(int x) : Base(x), my(0) {}

Ako izvedena klasa ima dodatne varijable članice one su inicijalizirane dodijeljenim vrijednostima.

Konverzija izvedenog tipa u bazni tip

Reference i pokazivači

Svaki prošireni tip koji je nastao javnim nasljeđivanjem nekog baznog tipa dijeli sa svojim baznim tipom isto javno sučelje pa se može koristiti kao bazni tip u sljedećem smislu:

  • Referenca ili pokazivač baznog tipa mogu se inicijalizirati objektom (ili adresom objekta) izvedenog tipa.
  • Objekt baznog tipa može se inicijalizirati objektom izvedenog tipa.
Klijent a("Igor", "Herceg", 1, 1000.0);
Klijent b("Z.", "Elvis", 2, -1000.0);

Osoba * pa = &a;  // Pokazivač na baznu klasu uzima adresu objekta proširene klase
Osoba & rb = a;   // Referenca na baznu klasu inicijalizirana objektom proširene klase

pa = &b;        // o.k.

To nam pravilo omogućava pisanje vrlo generalnog koda. Na primjer, funkcija koja prima referencu (ili pokazivač) na tip Osoba može uzeti kao argument tipa Klijent. Na primjer,

Funkcije koje uzimaju pokazivač ili referencu na baznu klasu

Pretpostavimo da imamo definiranu sljedeću funkciju:

void f(Osoba & rv, int id)
{
    rv.setId(id);
}

Tada je legalan poziv

f(a, 13); // a je Klijent

ali, ako referenca (ili pokazivač) tipa bazne klase referira (pokazuje) na objekt proširene klase, onda se kroz nju (njega) može dohvatiti samo bazni dio proširene klase. To znači da unutar funkcije f() ne možemo dohvatiti setSaldo().

Statički i dinamički tip varijable (reference ili pokazivača)

Budući da reference i pokazivači nekog tipa mogu referirati (pokazivati) na objekte svih proširenih tipova korisno je razlikovati statički i dinamički tip varijable (reference ili pokazivača).

U prethodnom primjeru, u funkciji f statički tip varijable rv je referenca na Osoba. Dinamički tip varijable rv je bio Klijent koji proširuje (osnovni) statički tip Osoba.

Objekti - nasuprot referencama i pokazivačima

Na primjer, u slučaju da smo funkciju f definirali na sljedeći način:

void f(Osoba rv, int id)
{
     rv.setId(id);
}

tada bi poziv

f(a); // a je Klijent

ostao legalan, ali bi ponašanje koda bilo bitno drugačije. Kada funkcija f dobije objekt proširene klase, umjesto objekta bazne klase, ona zove konstruktor kopije koji inicijalizira formalni argument funkcije izrezujući (slicing) bazni dio argumenta.

Isto proces se dešava kod pridruživanja objekta proširene klase objektu bazne klase, samo što se tamo poziva operator pridruživanja.

Osoba a;
Klijent b;
a = b;  // izrezivanje podobjekta

Nasljeđivanje i statički članovi klase

Kada imamo hijerarhiju klasa izvedenu iz bazne klase koja ima statičku varijablu, tada je ta varijabla zajednička čitavoj hijerarhiji klasa. Možemo ju dohvatiti kroz objekt bilo kojeg tipa koji proširuje baznu klasu ili pomoću operatora dosega, polazeći od bilo koje klase iz lanca nasljeđivanja:

class A{
    public:
        static int y;
        static int f() {return y; }
};

int A::y = 0;  // Definicija statičke varijable

class B : public A {
    public:
        int x;
};


int main()
{
    B b;     // Objekti svih klasa izvedenih iz A
    A a;     // dijele istu statičku varijablu.

    b.y  = 3;

    std::cout << B::f() << std::endl; // ispisuje 3
    std::cout << A::f() << std::endl; // ispisuje 3

    a.y = 4;

    std::cout << b.f() << std::endl;// ispisuje 4
    std::cout << a.f() << std::endl;// ispisuje 4

    return 1;
}