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).
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:
b.f(), nem b.Base::f());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.
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.
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.
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
};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.
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.
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 shapesA 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.
override: a fordító nem jelzi, hogy a függvény
csak elrejt, nem felülír. Javítás: mindig override, és
-Woverloaded-virtual.const vagy paramétertípus:
void f(int) vs. void f(long) nem override,
hanem elrejtés; a hívás a statikus típuson keresztül a bázisra esik
vissza.using nélkül: a bázis változatai nem látszanak a
származtatott statikus típuson keresztül.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.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.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
};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ó.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.
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.