24.2 Virtual, override, final és dispatch

A korábbi részekben az osztályokat értéktípusként, kompozícióval és védett invariánssal építettük fel, és láttuk, hogy az öröklés önmagában még nem ad dinamikus viselkedést: a bázisról elérhető tagok statikusan oldódnak fel (Öröklés, base subobject és hozzáférés). Most azt a szerződést vesszük, amely egy interfészt egy nyitott implementációhalmazzal köt össze: a virtuális függvényt. A cél nem az, hogy mindent hierarchiába szervezzünk — a sekély, egyértelmű hierarchia jó tervezési tanács, de nem nyelvi szabály —, hanem hogy pontosan lássuk, mikor dől el egy hívás célja, mi történik a névfeloldással és az alapértelmezett argumentumokkal, és mi az, amit a fordító optimalizálhat, de nem garantál. A dinamikus polimorfizmus ára egy indirekció a hívás helyén; ezt a költséget később, a virtuális hívás költségmodelljénél mérjük (Template, virtual és std::function költsége).

24.2.1 A virtuális függvény és a dinamikus típus

Egy kifejezés statikus típusa (static type) az, ahogyan a fordító a deklaráció alapján látja; a dinamikus típus (dynamic type) a hivatkozott objektum leginkább származtatott típusa. Precíz definíció: ha egy glvalue egy polimorf objektumra hivatkozik, akkor a dinamikus típusa annak a leginkább származtatott objektumnak a típusa, amelynek a használt objektum alobjektuma.

A dinamikus dispatch feltételei együtt teljesülnek:

  1. a hívott függvény virtuális, és a hívás nem minősített (b.f(), nem b.Base::f());
  2. a hívás implicit objektumargumentuma referencián vagy pointeren keresztül érkezik — azaz a statikus típus mellett létezhet eltérő dinamikus típus;
  3. az objektum élettartama már elkezdődött és még nem ért véget (a konstrukció és destrukció alatti részleges kivételt lásd később).

Ha ezek teljesülnek, akkor a hívás a dinamikus típusban érvényes final overrider-t hajtja végre. Ha bármelyik feltétel sérül — például érték szerinti példányon hívunk —, a statikus típus függvénye fut. Érték szerinti átadásnál ez egyben szeletelést is jelent; a szeletelés és a virtuális destruktor részletei a Virtuális destruktor, slicing és ownership részben vannak.

// ISO C++20, hosted; illusztráció, nem mért futtatás
#include <iostream>

struct Shape {
    virtual ~Shape() = default;
    virtual double area() const = 0;
};

struct Circle : Shape {
    explicit Circle(double r) : r_(r) {}
    double area() const override { return 3.14159265358979323846 * r_ * r_; }
private:
    double r_;
};

void report(const Shape& s) { std::cout << s.area() << '\n'; }

int main() {
    Circle c(1.0);
    Shape& ref = c;      // statikus típus: Shape&, dinamikus: Circle
    report(c);           // dinamikus dispatch: Circle::area
    Shape sliced = c;    // nem fordul le: Shape absztrakt, tehát itt szeletelés sem lehetséges
}

A példa utolsó sora szándékosan nem fordul le: absztrakt bázisból nem készíthető példány, így a leggyakoribb szeletelési csapda itt fordítási hibaként jelenik meg. Ez jó tulajdonság: a tiszta interfész kizárja a véletlen érték szerinti másolást.

24.2.2 override, final és az aláírás egyezése

Az aláírás (signature) a névből, a paramétertípus-listából, valamint a cv- és ref-minősítőkből áll; a visszatérési típus nem része. A felülírás két lépésben dől el ([C++20] [class.virtual]). (1) Felülír-e? Egy származtatott tagfüggvény akkor írja felül a bázis virtuális függvényét, ha neve, paramétertípus-listája (azonos típusok, nem csak konvertálhatók), cv- és ref-minősítője egyezik; a visszatérési típus ebben a lépésben nem számít. Ha a név egyezik, de a paraméterlista vagy a minősítés eltér, a függvény nem override, hanem elfedi (name hiding) a bázis azonos nevű tagjait. (2) Érvényes-e a felülírás? Ha a függvény felülír, további követelményeknek is meg kell felelnie: a visszatérési típus legyen azonos vagy kovariáns (osztálypointer/-referencia, ahol a származtatott visszatérési típus a bázisé egyértelmű, elérhető leszármazottja), és ne sértse a final-t. Az eltérő, nem kovariáns visszatérési típus tehát nem name hiding, hanem fordítási hiba — override nélkül is.

Az override kulcsszó nem változtat a szemantikán: szerződéssé teszi a szándékot, és a fordítót ellenőrző eszközzé. Ha a függvény nem ír felül semmit, fordítási hiba keletkezik — pontosan ez a kívánt viselkedés, mert a csendben elrejtett virtuális függvény az egyik leggyakoribb dinamikus polimorfizmus-hiba.

struct Base {
    virtual void draw() const;
    virtual ~Base() = default;
};

struct Bad : Base {
    void draw();                 // NEM override: hiányzik a const; elrejti Base::draw-t
};

struct Good : Base {
    void draw() const override;  // fordítási hiba lenne, ha nem egyezne az aláírás
};

A második lépés hibája külön próbát érdemel, mert itt az override hiánya sem segít elrejteni a problémát:

// override_return_error.cpp — C++20: szándékosan hibás (negatív próba)
struct Base {
    virtual int  id() const;
    virtual Base* clone() const;
    virtual ~Base() = default;
};
struct Derived : Base {
    long     id() const;            // hiba: felülír, de a visszatérési típus nem
                                    // azonos és nem kovariáns ('override' nélkül is)
    Derived* clone() const override; // helyes: kovariáns visszatérési típus
};

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -c override_return_error.cpp elutasítja: conflicting return type specified for 'virtual long int Derived::id() const', overridden function is 'virtual int Base::id() const'. A kovariáns clone nem kap diagnosztikát. Ezzel szemben a fenti Bad::draw() (hiányzó const) nem felülír, ezért ott nincs hiba, csak elfedés — erre figyelmeztet a -Woverloaded-virtual.

A final két helyen állhat: függvényen azt tiltja meg, hogy további származtatott osztály felülírja; osztályon azt, hogy abból származtassanak. Szemantikailag nem változtat a hívás szabályán, csak a további felülírást zárja ki — ez viszont az optimalizálás számára hasznos információ (lásd a devirtualizációt). A GCC és a Clang -Woverloaded-virtual figyelmeztetése nem ISO-szabály, hanem compiler-diagnosztika, amely éppen az elrejtéses hibát jelzi.

24.2.3 Tiszta virtuális függvény és absztrakt osztály

A tiszta virtuális (pure virtual) függvény deklarációja = 0 inicializálót tartalmaz. Az ilyen függvényt tartalmazó osztály absztrakt (abstract): nem példányosítható, de pointer és referencia készíthető hozzá. Egy származtatott osztály akkor konkrét, ha minden örökölt tiszta virtuális függvényre van érvényes felülírója. A tiszta virtuális függvénynek lehet definíciója is; az csak minősített névvel hívható (Base::f()), a dinamikus hívásba nem kerül be.

Az interfész-osztály tipikus formája: csak tiszta virtuális függvények, virtuális destruktor és lehetőleg semmilyen adattag. A virtuális destruktor kötelező jellegéről, a bázispointeren keresztüli delete nem definiált viselkedéséről a Virtuális destruktor, slicing és ownership rész szól; itt csak annyit rögzítünk, hogy a destruktor virtualitása nem a dispatch-hez, hanem a törlés helyességéhez kell.

24.2.4 Statikus névfeloldás és dinamikus dispatch lépései

Egy hívás feloldása rétegekre bontható; a rétegek sorrendje magyarázza a látszólag meglepő viselkedéseket.

Lépés Mikor történik Mit határoz meg Mi alapján
Névfeloldás fordításkor mely deklarációk jönnek szóba a statikus típus hatóköre (+ ADL)
Overload resolution fordításkor melyik deklaráció lesz a hívott argumentumok statikus típusa
Alapértelmezett argumentum fordításkor a hiányzó argumentumok értéke a kiválasztott deklaráció
Hozzáférés-ellenőrzés fordításkor legális-e a hívás a kiválasztott deklaráció access-specifierje
Célfüggvény kiválasztása futáskor melyik test fut a dinamikus típus final overridere (ha virtuális)

Ebből két következmény adódik, amelyeket érdemes külön megjegyezni.

Az alapértelmezett argumentum statikusan kötődik. A virtuális dispatch a testet választja ki, az alapértelmezett argumentumot nem.

#include <iostream>

struct Base {
    virtual void log(int level = 0) const { std::cout << "B" << level << '\n'; }
    virtual ~Base() = default;
};
struct Derived : Base {
    void log(int level = 1) const override { std::cout << "D" << level << '\n'; }
};

void demo(Base& b, Derived& d) {
    b.log();          // Derived::log fut, de a default argumentum 0 (Base deklarációjából)
    d.log();          // Derived::log, default argumentum 1
    d.Base::log();    // minősített hívás: nincs dinamikus dispatch, Base::log fut
}

A b.log() hívásnál a statikus típus Base&, ezért az overload resolution a Base::log deklarációt választja, és innen veszi a level = 0 alapértéket; a virtuális dispatch viszont a Derived::log testét futtatja. A d.Base::log() minősített hívás, amely kikapcsolja a dinamikus dispatch-et — ez hasznos akkor, amikor a bázis implementációját akarjuk „láncoltan” meghívni.

A származtatott osztály neve elrejtheti a bázis overloadjait. Ha a származtatott osztályban bármely tagot deklarálunk egy néven, a bázis összes azonos nevű tagja elrejtőzik, kivéve, ha using Base::f;-fel behozzuk őket.

struct B { virtual void f(int); virtual ~B() = default; };
struct D : B {
    void f(double);          // nem override (más paramétertípus), és elrejti B::f(int)-et
    // using B::f;           // e nélkül a D-n keresztül az f(int) nem látszik
};

A kovariáns visszatérés (covariant return) lehetővé teszi, hogy a felülíró függvény visszatérési típusa a báziséval kompatibilis, szűkebb osztályra mutató pointer vagy referencia legyen. Feltételek: mindkét típus osztályra mutató pointer vagy referencia, a származtatott visszatérési osztály a bázis visszatérési osztály leszármazottja, a konverzió egyértelmű és elérhető, a cv-minősítés nem enyhül. Okos pointerrel ez nem működik, mert a std::unique_ptr<Derived> nem leszármazottja a std::unique_ptr<Base>-nek.

struct Node {
    virtual Node* clone() const = 0;
    virtual ~Node() = default;
};
struct Expr : Node {
    Expr* clone() const override { return new Expr(*this); }  // kovariáns
};

24.2.5 Konstrukció és destrukció alatti dispatch

A konstruktor és a destruktor testében a dinamikus típus nem a „végleges” osztály, hanem az éppen futó konstruktor, illetve destruktor osztálya. Ennek az az indoka, hogy a származtatott adattagok a bázis konstruktorának futásakor még nem jöttek létre, a bázis destruktora pedig már a származtatott rész megszűnése után fut; ha ilyenkor a származtatott override hívódna, az nem létező tagokat érne el.

#include <iostream>
struct Base {
    Base() { init(); }                       // itt Base::init fut, nem Derived::init
    virtual void init() { std::cout << "Base\n"; }
    virtual ~Base() = default;
};
struct Derived : Base {
    void init() override { std::cout << "Derived\n"; }
};
// Derived d;  ->  a várt (nem mért) kimenet: "Base"

Indoklás vázlata. Tegyük fel, hogy a bázis konstruktorából a származtatott override hívódna. Ekkor a hívás olyan tagot érne el, amelynek élettartama még nem kezdődött el, ami önmagában nem definiált viselkedés lenne; a nyelv ezért a konstrukció alatt futó osztályt tekinti dinamikus típusnak, és ezzel a részleges objektumot konzisztens állapotban tartja. Ugyanez érvényes a destruktorokra, fordított sorrendben.

Gyakorlati következmény: virtuális függvényt konstruktorból vagy destruktorból hívni csak akkor biztonságos, ha a bázisértékű viselkedés helyes — tipikusan tiszta virtuális esetén ez nem definiált viselkedés. A „regisztráció a konstruktorban” minta ezért C++-ban hibás; a regisztrációt külön, a teljes objektum létrejötte utáni lépésre kell tenni.

24.2.6 Devirtualizáció: optimalizálás, nem garancia

A fordító bizonyos esetekben devirtualizálhatja a hívást, azaz statikus hívássá alakíthatja. Tipikus esetek: a dinamikus típus a fordítási egységben bizonyíthatóan ismert (lokális objektum, final osztály vagy függvény, névtelen névtérbe zárt típus), egészprogramos optimalizálás (LTO), illetve spekulatív devirtualizáció típusellenőrzéssel. Ez a hívás megfigyelhető viselkedését nem változtathatja meg — a szabvány szerinti szemantika ugyanaz —, csak a költségét. Következmény: a teljesítményre nem lehet helyességi garanciát építeni, és a devirtualizáció meglétére nem lehet API-szerződést alapozni; az csak a laborban mért, konkrét buildre érvényes megfigyelés lehet.

24.2.7 Kidolgozott példa: interfész és több implementáció

Az alábbi, önmagában fordítható példa egy interfészt és két konkrét implementációt mutat, override-tal, final-lal és clone()-nal.

// shapes.cpp — ISO C++20, hosted, Linux
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
    virtual std::string name() const = 0;
    virtual std::unique_ptr<Shape> clone() const = 0;
};

class Circle final : public Shape {
public:
    explicit Circle(double r) : r_(r) {}
    double area() const override { return 3.14159265358979323846 * r_ * r_; }
    std::string name() const override { return "circle"; }
    std::unique_ptr<Shape> clone() const override {
        return std::make_unique<Circle>(*this);   // Circle final: nincs további dinamikus típus
    }
private:
    double r_;
};

class Rectangle : public Shape {
public:
    Rectangle(double w, double h) : w_(w), h_(h) {}
    double area() const override { return w_ * h_; }
    std::string name() const override { return "rectangle"; }
    std::unique_ptr<Shape> clone() const override {
        return std::make_unique<Rectangle>(*this);
    }
private:
    double w_, h_;
};

void print_all(const std::vector<std::unique_ptr<Shape>>& xs) {
    for (const auto& p : xs) {
        std::cout << p->name() << ' ' << p->area() << '\n';  // dinamikus dispatch
    }
}

int main() {
    std::vector<std::unique_ptr<Shape>> v;
    v.push_back(std::make_unique<Circle>(1.0));
    v.push_back(std::make_unique<Rectangle>(2.0, 3.0));
    print_all(v);
}

Fordítás és a figyelmeztetések ellenőrzése (a tanuló laborjában; a jelen szöveg nem tartalmaz futtatási eredményt):

g++ -std=c++20 -Wall -Wextra -Wpedantic -Wnon-virtual-dtor -Woverloaded-virtual shapes.cpp -o shapes
clang++ -std=c++20 -Wall -Wextra -Wpedantic shapes.cpp -o shapes

A print_all szignatúrája szándékosan const std::unique_ptr<Shape>&: a tároló nem tulajdonolja a mutatókat, a Shape objektumok tulajdonosa a vector és a benne lévő unique_ptr-ek. A clone() visszatérési típusa itt nem kovariáns, mert okos pointerrel nem lehet az; a kovariáns változatot nyers pointerrel fentebb láttuk.

24.2.8 Gyakori hibák

24.2.9 Feladatok

  1. Magyarázó. Adott: struct B { virtual void f() const; virtual ~B() = default; }; struct D : B { void f(); }; Mi történik B& b = d; b.f(); esetén, és mit tenne a fordító, ha a D::f-re override-ot írnánk? Különböztesd meg a névfeloldást a dispatch-től.
  2. Kódolási. Egészítsd ki a shapes.cpp-t egy Triangle osztállyal (final, clone-nal), és egy total_area(const std::vector<std::unique_ptr<Shape>>&) szabad függvénnyel. Legyen a Rectangle-ből származó Square osztály, amely felülírja a name()-et, és maga is final. Ellenőrzési feltétel: a build a fenti kapcsolókkal figyelmeztetés nélkül lefut, és egy szándékosan override nélkül hagyott felülírás fordítási hibát okoz.
  3. Diagnosztikai. Az alábbi kódrészletben három, a fejezetben tárgyalt hiba van. Keresd meg őket, indokold, majd javítsd a kódot.
struct Base {
    Base() { setup(); }
    virtual void setup() {}
    virtual void log(int level = 0) const {}
    virtual ~Base() = default;
};
struct Derived : Base {
    void setup() override {}
    void log(int level = 1) const {}   // hiányzik az override
    void log(double) {}                // elrejti a bázis log-jait
};
  1. Érvelő. Egy kollégád azt állítja, hogy egy final osztályra építve „biztonságosan optimalizálhatunk, mert a hívás biztosan statikus lesz”. Fogalmazd meg pontosan, mi igaz ebből, és mi az, ami nem garantálható.

24.2.10 Megoldások

1. megoldás. A D::f() hiányzó const miatt nem override, hanem elrejtés: a D statikus típusán keresztül az f() hívás a D::f-et jelöli, de a B&-n keresztül a névfeloldás a B::f deklarációt választja, és mivel az virtuális, a dinamikus típus D — csakhogy a D-nek nincs érvényes overridere, ezért a B::f fut. A névfeloldás és az overload resolution fordításkor, a statikus típus alapján történik; a dispatch csak a kiválasztott virtuális deklaráció után lép be. Ha a D::f-re override-ot írunk, a program nem fordul le, mert nincs felülírható bázisfüggvény — ez a kívánt diagnosztika.

2. megoldás. A Triangle:

class Triangle final : public Shape {
public:
    Triangle(double a, double b, double c) : a_(a), b_(b), c_(c) {}
    double area() const override {
        const double s = (a_ + b_ + c_) / 2.0;
        return s * (s - a_) * (s - b_) * (s - c_) > 0.0
             ? std::sqrt(s * (s - a_) * (s - b_) * (s - c_)) : 0.0;
    }
    std::string name() const override { return "triangle"; }
    std::unique_ptr<Shape> clone() const override {
        return std::make_unique<Triangle>(*this);
    }
private:
    double a_, b_, c_;
};

class Square final : public Rectangle {
public:
    explicit Square(double side) : Rectangle(side, side) {}
    std::string name() const override { return "square"; }
};

A total_area a print_all mintájára iterál, és double-t ad vissza. Indoklás: a Square a Rectangle-től örököl, de a Rectangle nincs final-lal lezárva, ezért a származtatás legális; a Square saját final-ja további származtatást tilt. Ellenőrzési feltétel: a -Wall -Wextra -Wpedantic -Woverloaded-virtual kapcsolókkal a build hibátlan, és a clone() override jelölése nélkül a -Wsuggest-override (GCC) jelzi a hiányt; a Square::name override nélkül szintén elrejtés lenne.

3. megoldás. Hibák: (a) a Base konstruktora virtuális setup()-ot hív, ezért a Derived::setup sosem fut le a konstrukció alatt — a Derived állapota inicializálatlan maradhat, ha a setup a származtatott tagokra épül; (b) a Derived::log(int) override nélkül is felülír, de a jelölés hiánya elrejti a szándékot, és egy jövőbeli aláírás-változás csendben elrejtéssé alakítja; (c) a Derived::log(double) elrejti a bázis összes log tagját, így Derived statikus típuson az int-es változat nem látszik. Javítás: a setup() hívást a konstruktorból kivenni, és a teljes objektum létrejötte után explicit módon hívni; a log(int)-re override; a log(double) elé using Base::log;, vagy a két overload összevonása. Ellenőrzési feltétel: a javított kód a figyelmeztetés-kapcsolókkal tiszta, és a Base&-en keresztüli log() hívás a Derived testét futtatja, de a Base deklarációjából vett level = 0 alapértékkel.

4. megoldás. Igaz, hogy a final osztály vagy függvény esetén a fordító bizonyíthatja a dinamikus típust, és devirtualizálhat; ez azonban optimalizálási döntés, nem nyelvi garancia. Nem garantálható, hogy egy adott buildben a hívás tényleg statikus lesz, és az sem, hogy a költség eltűnik: a fordító dönthet a spekulatív devirtualizáció mellett is, illetve egészprogramos optimalizálás nélkül nem láthatja a teljes programot. A helyességet nem érinti, mert a devirtualizáció megfigyelhető viselkedést nem változtathat; a teljesítményre vonatkozó állítást csak mérés támaszthatja alá, konkrét compiler- és buildprofillal.

24.2.11 Összefoglaló

A virtuális függvény szerződése: a hívás neve és overloadja a statikus típus alapján dől el, a testet a dinamikus típus final overridere adja — de csak akkor, ha a hívás nem minősített, a függvény virtuális, referencia vagy pointer az objektumargumentum, és az objektum élettartama teljes. Az override a fordítót teszi ellenőrzővé, a final a további felülírást zárja ki. Az alapértelmezett argumentumok és a névfeloldás statikusak, ezért a dinamikus dispatch nem „örökíti át” őket; a konstrukció és destrukció alatt a dinamikus típus az éppen futó osztály. A devirtualizáció optimalizálás, nem szerződés: a teljesítményre csak mérés, a helyességre a nyelvi szabály ad garanciát.