Ez a fejezet a statikus és dinamikus polimorfizmus közötti mérnöki döntésről szól, nem trükkök gyűjteményéről. A cél az, hogy a generikus kódban meg tudd nevezni azt a pontot, ahol a felhasználó viselkedést injektál, és hogy ez a pont szűk, dokumentált, ellenőrizhető szerződés legyen. A C++20 alapú tárgyalás épít a korábbi részekre: a virtuális dispatch költségmodelljére (Virtual, override, final és dispatch), a kétfázisú névkeresésre (Dependent name, kétfázisú lookup és ADL), valamint a variadikus sablonokra (Variadic template, pack és fold expression).
A dinamikus polimorfizmus (dynamic polymorphism)
lényege, hogy a hívott műveletet futásidőben, a dinamikus típus alapján
választjuk ki; C++-ban ennek kanonikus formája a virtuális függvény és a
vtable-n keresztüli indirekt hívás. A statikus
polimorfizmus (static polymorphism) ezzel szemben fordítási
időben rögzíti a konkrét típust; C++-ban sablonparaméter,
if constexpr, concept, CRTP vagy
policy-compozíció formájában jelenik meg.
A döntés nem esztétikai. Négy tengelyen kell megnézni:
| szempont | dinamikus (virtuális) | statikus (sablon/CRTP/policy) |
|---|---|---|
| hívásköltség | indirekt hívás, gyakran nem inline-olható | közvetlen hívás, inline-olható |
| típusok halmaza | futásidőben nyílt, új típusú library plugin is csatlakozhat | fordítási időben zárt, minden típus ismert a TU-ban |
| heterogén tárolás | std::unique_ptr<Base> konténerben |
nem lehetséges egyetlen konténerben |
| ABI | stabil: a vtable-elrendezés és a névtorzítás rögzíthető | instanciációnként új szimbólum, nem stabil határ |
| kódeloszlás | egy f kódpéldány |
minden T-re külön kódpéldány |
| diagnosztika | fordítási hiba ritka, futásidejű hiba lehet | fordítási hiba, hosszú template-üzenetek |
A gyakorlati szabály: a dinamikus határ ott van, ahol a felhasználó kódjának nem kell újrafordulnia (plugin, shared library, nyelvi határ). A statikus határ ott van, ahol a teljesítmény és az inline-olhatóság dominál, és a felhasználó amúgy is a te fejlesztői környezetedben fordít. A rossz döntés tipikus jele: virtuális interfész olyan helyen, ahol a teljes típushalmaz egy headerben ismert (felesleges indirekció), vagy sablon olyan helyen, ahol a fogyasztó binárisát nem akarod újrafordítani (ABI-törés).
A CRTP (curiously recurring template pattern) formája:
template <class Derived>
class Base {
public:
void interface() { static_cast<Derived*>(this)->implementation(); }
};
class Concrete : public Base<Concrete> {
public:
void implementation();
};A minta lényege, hogy a bázis sablonparamétere a leszármazott, így a
bázis fordítási időben ismeri a konkrét osztályt, és a
static_cast zéró költségű lefelé konvertálás. Nincs vtable,
nincs indirekció, a hívás inline-olható. Cserébe a
Base<Derived> és a Derived egyetlen
típuspár, nem lehet Base<A> és
Base<B> mutatóit egy konténerben tárolni.
Teljes, önálló példa:
// crtp_sink.cpp
#include <cstddef>
#include <cstdio>
template <class Derived>
class PacketSink {
public:
std::size_t write(const char* data, std::size_t n) {
return static_cast<Derived*>(this)->write_impl(data, n);
}
protected:
~PacketSink() = default; // nem kell virtuális destruktor
};
class CountingSink : public PacketSink<CountingSink> {
public:
std::size_t write_impl(const char* /*data*/, std::size_t n) {
total_ += n;
return n;
}
std::size_t total() const { return total_; }
private:
std::size_t total_ = 0;
};
int main() {
CountingSink s;
PacketSink<CountingSink>& ref = s; // statikus felület
std::size_t w = ref.write("abcd", 4);
std::printf("w=%zu total=%zu\n", w, s.total());
return (w == 4 && s.total() == 4) ? 0 : 1;
}Build:
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 crtp_sink.cpp -o crtp_sink
# vagy
clang++ -std=c++20 -Wall -Wextra -Wpedantic -O2 crtp_sink.cpp -o crtp_sinkA várt kimenet w=4 total=4, és a kilépési kód 0. A
write_impl nyilvános kell legyen, mert a
hozzáférést a PacketSink kontextusában ellenőrzi a fordító;
ha privát szeretnéd tartani, a bázist friend-dé kell tenni.
Ez a leggyakoribb CRTP-hiba.
A Base<Derived> osztály törzsének deklarációi
akkor instanciálódnak, amikor a Derived definíciója éppen
fut — vagyis a Derived ekkor befejezetlen
(incomplete). Ebből két szigorú következmény adódik:
sizeof(Derived),
alignof(Derived) vagy olyan kifejezés, amely a
Derived teljességét igényli. Ez ill-formed, nem „csak
figyelmeztetés”.Derived már teljes. Ezért kerül a static_cast
a függvénytörzsbe, nem a deklarációba.template <class D>
struct Bad {
static_assert(sizeof(D) > 0, "D nem teljes itt"); // fordítási hiba
};
struct X : Bad<X> { int v; };A másik csapda az élettartam: a bázis konstruktorában vagy
destruktorában static_cast<D*>(this)->... hívása
nem definiált viselkedés (undefined behavior, UB). A
konstruktor idején a Derived még nem kezdte meg
élettartamát, a destruktor idején pedig már befejezte; a mutató nem
mutat D típusú objektumra. Ha inicializálás után kell
viselkedés, azt ne a báziskonstruktorban, hanem egy külön
init()/finalize() hívásban vagy a
Derived konstruktorában intézd.
A policy egy sablonparaméter, amely egy szűk
viselkedést (ellenőrzés, allokáció, növekedési stratégia,
összehasonlítás) injektál. A standard könyvtárban ilyen a
std::char_traits, a std::allocator, a
std::hash, a std::less.
// policy_span.cpp
#include <cstddef>
#include <stdexcept>
struct checked_policy {
static void verify(std::size_t i, std::size_t n) {
if (i >= n) throw std::out_of_range{"index"};
}
};
struct unchecked_policy {
static constexpr void verify(std::size_t, std::size_t) noexcept {}
};
template <class Checking = checked_policy>
class IntSpan {
public:
IntSpan(int* data, std::size_t n) noexcept : data_{data}, n_{n} {}
int& operator[](std::size_t i) { Checking::verify(i, n_); return data_[i]; }
std::size_t size() const noexcept { return n_; }
private:
int* data_;
std::size_t n_;
};Build:
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -c policy_span.cpp -o policy_span.o.
A kompozíció vs. öröklés kérdése itt konkrét: az
állapot nélküli policy-tagot [[no_unique_address]]-szel
tagként tartva (C++20) vagy üres bázisként örökölve is el lehet helyezni
nulla mérettel (empty base optimization, EBO). Az öröklés rövidebb
hívásokat ad, de a policy tagjai bekerülnek az osztály névterébe, és a
típus ABI-ja jobban függ a policy-től. A tagként tartás tisztább, de a
híváshoz policy_.verify(...) kell — ha a policy
állapotmentes, a fordító ezt is eltünteti. A követelmény mindkét esetben
dokumentálandó: a policy statikus
verify(std::size_t, std::size_t) tagfüggvényt
adjon, amely hibánál eldob (checked, azaz
throw), egyébként visszatér. Ez a szerződés a policy
nevében és a fejléc kommentjében is szerepeljen, mert a sablon nem
kényszeríti ki.
A customization point (testreszabási pont) az a jól megnevezett hely, ahol a könyvtár a felhasználó típusára vonatkozó viselkedést kéri. Két ősi forma létezik, ezekből lett a C++20-as CPO (customization point object).
A legegyszerűbb: a felhasználó tagfüggvényt ír, a könyvtár meghívja. Előnye, hogy kézenfekvő, és a hozzáférés-vezérlést a nyelv kezeli; hátránya, hogy a konverziók miatt egy „majdnem megfelelő” típus is átcsúszhat, és a tag nem örökölhető felül írás nélkül.
A klasszikus minta a swap:
namespace my {
struct Buf { int* p; std::size_t n; };
void swap(Buf& a, Buf& b) noexcept; // ADL innen találja meg
}
template <class T>
void my_swap(T& a, T& b) {
using std::swap; // kétlépéses lookup
swap(a, b); // ordinary lookup + ADL az instanciáláskor
}A using std::swap; nélkül a std::swap nem
látszana; a csupasz std::swap(a, b) viszont
megkerülné a felhasználó my::swap-ját. A
kétlépéses minta tehát kötelező, ha a generikus kódot akarod
kiszolgálni.
Mert a hívás a hívó kontextusától függ: ha a könyvtár egy belső
segédfüggvényből hívja, ott az ADL halmaza más lehet, és egy nem várt
túlterhelés részt vehet. A C++20 CPO ezt úgy zárja ki,
hogy a testreszabási pontot egy function object
(niebloid) mögé teszi, így a hívás helyén nincs szabad függvény, amit
ADL kiválaszthatna. A standardban ilyen a
std::ranges::swap, std::ranges::begin,
std::ranges::size. Mellette a szabvány explicit
engedélyezett specializációkat is használ:
std::hash<T>,
std::numeric_limits<T>,
std::formatter<T, char>,
std::ranges::enable_borrowed_range<T>. Ezek
dokumentált, szűk szerződések: a specializációnak pontosan a megadott
tagokat és jelentést kell adnia.
A C++20 concept a testreszabási pont
szerződését fejezi ki: megmondja, milyen kifejezésnek
kell fordulnia, milyen visszatérési típussal. Ez nem pusztán
dokumentáció, mert részt vesz a túlterhelés-feloldásban, és a
hibaüzenetet a hívás helyére teszi.
// serialize_ext.cpp
#include <concepts>
#include <cstddef>
#include <cstdio>
#include <string>
template <class T>
concept MemberSerializer = requires(const T& t, char* out, std::size_t cap) {
{ t.serialize_to(out, cap) } -> std::same_as<std::size_t>;
};
template <class T>
concept AdlSerializer = requires(const T& t, char* out, std::size_t cap) {
{ serialize_to(t, out, cap) } -> std::same_as<std::size_t>;
};
namespace detail {
template <class T>
std::size_t dispatch(const T& t, char* out, std::size_t cap) {
if constexpr (MemberSerializer<T>) {
return t.serialize_to(out, cap); // elsőbbség: tag
} else {
return serialize_to(t, out, cap); // ADL
}
}
} // namespace detail
template <class T>
requires MemberSerializer<T> || AdlSerializer<T>
std::size_t serialize(const T& t, char* out, std::size_t cap) {
return detail::dispatch(t, out, cap);
}
struct Name { // tagfüggvényes testreszabás
std::string v;
std::size_t serialize_to(char* out, std::size_t cap) const {
std::size_t n = v.size() < cap ? v.size() : cap;
for (std::size_t i = 0; i < n; ++i) out[i] = v[i];
return n;
}
};
namespace metrics { // ADL-es testreszabás
struct Counter { int hits; };
inline std::size_t serialize_to(const Counter& c, char* out, std::size_t cap) {
return static_cast<std::size_t>(std::snprintf(out, cap, "%d", c.hits));
}
} // namespace metrics
int main() {
char buf[32];
Name n{"cafe"};
std::size_t a = serialize(n, buf, sizeof buf);
metrics::Counter c{42};
std::size_t b = serialize(c, buf, sizeof buf);
std::printf("%zu %zu\n", a, b);
}Build:
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 serialize_ext.cpp -o serialize_ext
(a várt kimenet 4 2, mert a 42 két bájt). A
szerződés akkor szűk, ha három dolgot rögzít: a pontos
kifejezést (t.serialize_to(out, cap)), a visszatérési
típust (std::size_t) és a részleges buffer szemantikáját (a
példában „a lehető legtöbbet, a ténylegesen írt hosszt adja vissza”). A
negyedik, opcionális elem a kivételgarancia: itt nincs dobás, de ezt ki
kell mondani.
Figyelj rá, hogy az AdlSerializer az argumentum
típusának társított névterében keres, nem abban, ahol a hívó
van. Ha a Widget a ui névtérben van, akkor az
ui::serialize_to hívódik meg, az
app::serialize_to nem.
Minden sabloninstanciáció külön típus, saját
szimbólumokkal. Bounds<checked>,
Bounds<unchecked> két teljesen független típus: két
kódpéldány, két type_info, két sorozat inline-olt hívást.
Ez a code bloat. Mérséklése: (1) a policy-t ne vidd be
a publikus interfész típusába, csak a belső megvalósításba; (2) egy nem
sablonos magra delegálj, és a sablon csak a vékony ellenőrző burkot
adja; (3) extern template + egy explicit instanciációs TU,
hogy a gyakori specializáció ne instanciálódjon minden fordítási
egységben. Ez a compile-time coupling másik oldala: ha
a policy fejléce változik, minden őt instanciáló TU újrafordul.
ABI szempontból a kulcs: a template specializációknak nincs
stabil ABI-juk két különböző fordító, sőt két verzió között
sem. Ezért a könyvtár nyilvános határán ne jelents meg
template osztályt, ha a fogyasztó binárisát nem akarod
újrafordítani; a stabil határ extern "C" vagy nem sablonos,
pimpl-es osztály. A virtuális interfész épp azért marad meg
ott, ahol van stabil ABI-igény: a vtable elrendezés és a névtorzítás
adott ABI (x86-64 System V) mellett specifikált, míg a
sabloninstanciáció nem az.
A type erasure (típuseltüntetés) a statikus és a
dinamikus világ hídja: kifelé értékszemantikás, konkrét típus
(AnySink), belül egy virtuális Concept
interfész és egy sablonos Model<S>. A
std::function, a std::any és a saját típusú
Sink mind ezt használja. Az előképet a lambdák és a
function wrapper fejezet tárgyalja részletesen (invoke, function wrapper és type erasure); itt csak
a döntési szabály kell:
Model<T> allokáció és a virtuális
hívás.impl CRTP-ben. A bázis nem fér
hozzá; tedd nyilvánossá, vagy friend-dé.static_cast<D*>(this)->... a bázis
konstruktorában/destruktorában. UB; a D
élettartama még nem kezdődött / már véget ért.sizeof(D) a bázis törzsében.
Ill-formed, mert a D befejezetlen.std::swap(a, b) generikus
kódban. Megkerüli a felhasználó ADL-es
swap-ját.requires(T t) { t.serialize(out, cap); } elfogad olyan
típust is, ahol implicit konverzió történik; írd ki a pontos szignatúrát
és a visszatérési típust.1. feladat (magyarázó + kód). Készíts
Comparable<D> CRTP-mixint, amely ==,
!=, <, <=,
>, >= operátorokat ad, feltéve hogy
D-nek van int compare(const D&) const
tagfüggvénye (-1/0/1 szemantikával). Fogalmazd meg a szerződést, és
mondd meg, miért nem lehet compare privát.
2. feladat (kódolási). Készíts
Buffer<Growth> osztályt, ahol a Growth
policy statikus
grow(std::size_t current, std::size_t needed) -> std::size_t
függvényt ad. Valósíts meg két policyt: double_policy
(amortizált, max(2*current, needed)) és
exact_policy (needed). Indokold, melyiknél
mennyi újraallokáció történik n egymást követő
push_back során, és mit jelent ez a másolási költségre.
3. feladat (diagnosztikai). Adott a
serialize extension point. A felhasználó a
Widget-et a ui névtérben definiálja, de a
serialize_to-t namespace app-ba tette. A kód
nem fordul, vagy nem az ő függvénye hívódik. Magyarázd meg, miért, és
add meg a javítást.
4. feladat (kiegészítő). A
PacketSink-et szeretnéd egy
std::vector<std::unique_ptr<...>>-ban heterogén
módon tárolni. Melyik két megoldás jön szóba, és melyik milyen ABI- és
teljesítménykompromisszummal jár?
1. megoldás. A minta a Barton–Nackman-trükk:
template <class D>
struct Comparable {
friend bool operator==(const D& a, const D& b) { return a.compare(b) == 0; }
friend bool operator!=(const D& a, const D& b) { return a.compare(b) != 0; }
friend bool operator<(const D& a, const D& b) { return a.compare(b) < 0; }
friend bool operator<=(const D& a, const D& b) { return a.compare(b) <= 0; }
friend bool operator>(const D& a, const D& b) { return a.compare(b) > 0; }
friend bool operator>=(const D& a, const D& b) { return a.compare(b) >= 0; }
};A szerződés: compare teljes rendezést
ad, tranzitív, antiszimmetrikus, reflexív egyenlőséggel; az
int eredménye pontosan -1, 0 vagy 1. A friend
itt nem barátság a D felé, hanem a nem-tag operátorok
definiálása a bázison belül; ezek ADL-lel találhatók, és nem igényelnek
hozzáférést. A compare nem lehet privát,
mert a friend függvények a Comparable
kontextusában nézik fel, nem a D-ében. Ellenőrzési
feltétel: static_assert(std::totally_ordered<My>); a
<compare> fejlécből.
2. megoldás. A policy:
struct double_policy {
static std::size_t grow(std::size_t current, std::size_t needed) {
std::size_t twice = current == 0 ? 8 : current * 2;
return twice < needed ? needed : twice;
}
};
struct exact_policy {
static std::size_t grow(std::size_t, std::size_t needed) { return needed; }
};A double_policy mellett n
push_back esetén az újraallokációk száma
,
mert a kapacitás mértani sorozatban nő; az összes másolt elem száma
,
tehát amortizáltan
egy push_back. Az exact_policy minden lépésben
n elemet másol, összesen
másolás. Ellenőrzési feltétel: a grow soha ne adjon
current-nél kisebb értéket, és needed-nél
kisebbet se, különben az invariáns sérül. A current*2
túlcsordulását current > SIZE_MAX/2 esetre kezeld.
3. megoldás. Az ADL a Widget
társított névtereiben keres, azaz ui-ban
(és a template-argumentumai névtereiben), nem a hívás helyén és nem
app-ban. Mivel a Widget nem
app-beli, az app::serialize_to nem látszik.
Javítás: tedd a serialize_to-t a ui névtérbe
(ez a szabványos és ajánlott), vagy — ha ez nem lehetséges — adj a
Widget-nek tagfüggvényt, mert a
MemberSerializer elsőbbséget élvez. Ellenőrzési feltétel:
static_assert(AdlSerializer<ui::Widget>); a definíció
után.
4. megoldás. Két út: (a) adj a
PacketSink<D> mellé egy absztrakt
IPacketSink bázist, amelynek virtuális
write-ja van, és a PacketSink<D> ezt
implementálja — ez heterogén tárolást ad, de visszahozza az indirekciót
és a vtable-t, cserébe stabil ABI-t; (b) készíts AnySink
type erasure-t, amely értékszemantikát ad, de allokál és virtuálisan
hív. A statikus út (std::variant a zárt típushalmazzal)
csak akkor járható, ha a típusok zártak; ez a leggyorsabb, de minden új
típus újrafordul.
A statikus polimorfizmus fordítási időben zárja le a típushalmazt,
cserébe közvetlen hívásokat, inline-olhatóságot és kisebb futásidejű
overheadet ad — ára a kódeloszlás, a fordítási kapcsolódás és az
instanciációnkénti ABI. A CRTP akkor hasznos, ha egy bázis statikus
felületet akar adni a leszármazottnak; a két fő csapda az incomplete
Derived a bázis törzsében és a
static_cast<D*>(this) a konstruktorban/destruktorban.
A policy template egy szűk viselkedést injektál; a kompozíció és az EBO
közötti választás ABI- és olvashatósági kérdés. A testreszabási pontot
három szinten lehet megadni: tagfüggvénnyel, ADL-lel (kétlépéses
using std::swap; formában) és CPO-val, amely a hívást
kiveszi az ADL hatóköréből. A C++20 concept a szerződést
nem dokumentációként, hanem kikényszeríthető előfeltételként rögzíti. Ha
a kódot binárisan akarod megosztani, a virtuális interfész vagy a type
erasure a helyes választás; ha a teljes típushalmaz a te TU-dban fordul,
a statikus út a gyorsabb és a biztonságosabb.