27.5 Customization point, CRTP és policy design

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

27.5.1 Dynamic és static polymorphism: a döntési térkép

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

27.5.2 CRTP: statikus interfész a leszármazottal

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_sink

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

27.5.2.1 Az incomplete derived type csapdái

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:

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.

27.5.3 Policy template és kompozíció

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.

27.5.4 Testreszabási pontok: tag, ADL és CPO

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

27.5.4.1 Tagfüggvény

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.

27.5.4.2 ADL-alapú szabad függvény

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.

27.5.4.3 Miért nem elég a csupasz ADL?

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.

27.5.5 Concept-alapú extension point

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.

27.5.6 Költségek: code bloat, compile-time coupling, ABI

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.

27.5.7 Type erasure mint a harmadik út

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:

27.5.8 Tipikus hibák

  1. Privát impl CRTP-ben. A bázis nem fér hozzá; tedd nyilvánossá, vagy friend-dé.
  2. 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.
  3. sizeof(D) a bázis törzsében. Ill-formed, mert a D befejezetlen.
  4. Csupasz std::swap(a, b) generikus kódban. Megkerüli a felhasználó ADL-es swap-ját.
  5. Túl széles concept. 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.
  6. Dokumentálatlan policy. Ha a policy csak „valami callable”, a hiba a felhasználónál derül ki; adj neki conceptet.
  7. Policy a publikus ABI-ban. Minden fogyasztó újrafordul, és két verzió között nincs kompatibilitás.

27.5.9 Feladatok

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?

27.5.10 Megoldások

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 O(logn)O(\log n), mert a kapacitás mértani sorozatban nő; az összes másolt elem száma ∑k2k<2n\sum_{k} 2^k < 2n, tehát amortizáltan O(1)O(1) egy push_back. Az exact_policy minden lépésben n elemet másol, összesen O(n2)O(n^2) 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.

27.5.11 Összefoglaló

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.