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:

  • nasljeđuje varijable i metode klase A,

  • prerađuje neke metode iz klase A, dajući im novu implementaciju,

  • dodaje svoje varijable i metode na one nasljeđene iz klase A.

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
  • Podklasa — u ovom slučaju Klijent — nasljeđuje sve varijable i metode bazne klase — u ovom slučaju Osoba:

osoba-klijent.png

Ciljevi nasljeđivanja

  • Konzistentnost među klasama koje modeliraju slične i međusobno zavisne koncepte.

  • Iskorištavanje već postojećeg koda (code reuse);

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:

  • Kod javnog (public) nasljeđivanja varijable i metode iz bazne klase čuvaju svoju razinu pristupa u izvedenoj klasi. Javne članice iz bazne klase ostaju javne u izvedenoj klasi i zaštićene članice iz bazne klase ostaju zaštićene u izvedenoj klasi. (Privatne bazne članice nisu vidljive u izvedenoj klasi).

  • Kod zaštićenog (protected) nasljeđivanja javne i zaštićene članice iz bazne klase postaju zaštićene u izvedenoj klasi. (Privatne bazne članice nisu vidljive u izvedenoj klasi).

  • Kod privatnog (private) nasljeđivanja javne i zaštićene članice iz bazne klase postaju privatne u izvedenoj klasi. (Privatne bazne članice nisu vidljive u izvedenoj klasi).

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.

  • Javno nasljeđivanje se najčešće koristi jer u njemu proširena klasa nasljeđuje sučelje bazne klase. Sve javne članice ( = sučelje) bazne klase ostaju javne u izvedenoj klasi, tako da izvedena klasa prema van izgleda kao bazna klasa s nekim dodacima. Tip izvedene klase jeste tip bazne klase (plus nešto više); u našem slučaju Klijent je Osoba (osoba s računom je još uvijek osoba). Dakle, javno nasljeđivanje modelira vrlo blisku povezanost dvije klasa u kojoj izvedena klasa osigurava proširenje funkcionalnosti bazne klase, odnosno prošireni tip predstavlja specijalizaciju baznog tipa. Javno nasljeđivanje treba koristiti samo u takvim situacijama.

  • Privatno nasljeđivanje je nešto sasvim drugo. Javno sučelje bazne klase postaje ovdje privatnim dijelom izvedene klase, a ne njegovim sučeljem. Prema tome izvedeni tip nije ujedno i bazni tip, on ne dijeli njegovo sučelje. Kako privatne članice služe implementaciji klase možemo zaključiti da privatno nasljeđivanje treba koristiti kada klasu želimo implementirati koristeći funkcionalnost bazne klase.

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) {}
  • Ako izvedena klasa nema konstruktora prevodilac će sintetizirati defaultni konstruktor koji prvo poziva defaultni konstruktor bazne klase.

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:

  • konstruktor kopije (CC), kopiranjem i premještanjem

  • operator pridruživanja (OP), kopiranjem i premještanjem

  • destruktor.

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

  • Za izvedenu klasu vrijedi isto pravilo kao i za baznu: ako nemamo CCtor ili OP u izvedenoj klasi, a postoji potreba za njim, prevodilac će sintetizirati jedan.

  • Sintetizirani CCtor ili OP poziva odgovarajuću operaciju na baznoj klasi i zatim kopira članice izvedene klase.

  • Prevodilac može sintetizirani CCtor ili OP kao izbrisan, na primjer, ako bazna klasa nema odgovarajuću operaciju.

  • Prisutnost destruktora u baznoj klasi sprečava sintetiziranje operacija premještanjem. Tada operacije premještanjem neće biti sintetizirane niti u izvedenoj klasi. (Sjetimo se da prevodilac sintetizira konstruktor i OP premještanjem samo ako klasa ne definira niti jedan član kontrole kopiranja.)

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.
  • Kada prevodilac traži definiciju nekog imena koje se koristi u proširenoj klasi on će definiciju prvo potražiti u proširenoj klasi, a zatim u baznoj klasi. Tek nakon toga traži u okružujućem dosegu.

  • Zbog ugnježdenja dosega javlja se mogućnost skrivanja članica bazne klase pomoću članica istog imena izvedene 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.

  • using deklaracija primijenjena na bazni konstruktor ne može promijeniti prava pristupa koja ima konstruktor bazne klase.

  • Ako je bazni konstruktor explicit, onda je to i generirani konstruktor izvedene klase.

  • Izvedena klasa može pored nasljeđenih konstruktora definirati i svoje konstruktore. Ako neki od nasljeđenih konstruktora uzima iste argumente kao konstruktor definiran u izvedenoj klasi, on se tada ne nasljeđuje.

  • Ako bazni konstruktor ima defaultne parametre u izvedenoj klasi će biti definiran niz odgovarajućih konstruktora.

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

  • Statički tip je onaj s kojim je varijabla deklarirana i on je poznat pri kompilaciji.

  • Dinamički tip reference ili pokazivačke varijable je tip objekta na koji varijabla referira ili pokazuje. Taj tip nije poznat pri kompilaciji i može biti svaki tip koji proširuje statički tip.

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

  • Kada se referenca (ili pokazivač) na tip bazne klase inicijalizira objektom proširene klase (ili adresom proširene klase) onda sam objekt proširene klase ne doživljava nikakvu promjenu; jedino bazna referenca (pokazivač) referira (pokazuje) na bazni dio objekta proširene klase.

  • Kada se objekt bazne klase inicijalizira objektom proširene klase, onda dolazi do kopiranja onog dijela proširene klase koji pripada baznoj klasi u objekt bazne klase. Kažemo da dolazi do izrezivanja (slicing) dijela objekta proširene klase.

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