26.2 Osztálysablon, nem típusparaméter és CTAD

A függvénysablonok (Függvénysablon, instantiation és deduction (26.1)) megmutatták, hogyan lehet egy algoritmust a típusától függetlenül megfogalmazni. Az osztálysablon ugyanezt a gondolatot viszi át az adattípusokra: nem egy konkrét Buffer osztályt írunk meg int-re, double-re és Message-re, hanem egy típustermelő sablont, amelyből a fordító minden szükséges (T, N) párra külön osztályt állít elő. Ez a rész egy fix kapacitású Buffer<T, N> példán mutatja meg, hogyan függ össze a típusparaméter, az értékparaméter, a levezetés (deduction) és a headerben tartott definíció. A korábbi értéktípus-szemlélet (Értéktípus, kompozíció és immutable interfész (23.2)) itt közvetlenül hasznos: a buffer legyen önmagában konzisztens, másolható érték, ne rejtett mutató.

26.2.1 Osztálysablon és tagdefiníciók

Egy class template (osztálysablon) deklaráció egy típuscsaládot ír le; maga a sablon nem típus, hanem egy specializáció előállításának szabálya. Amikor a fordító a Buffer<int, 16> nevet látja, a sablonargumentumok helyettesítésével létrehoz egy valódi osztályt. Ezt nevezzük implicit példányosításnak (implicit instantiation). A specializáció élettartama, mérete és tagjai pontosan úgy viselkednek, mint egy kézzel írt osztályé.

#include <cstddef>
#include <stdexcept>
#include <initializer_list>

template<class T, std::size_t N>
class Buffer {
public:
    Buffer() = default;

    Buffer(std::initializer_list<T> init) {
        for (const T& v : init) push(v);
    }

    void push(const T& v) {
        if (size_ == N) throw std::length_error("Buffer tele");
        data_[size_++] = v;
    }

    T&       operator[](std::size_t i)       { return data_[i]; }
    const T& operator[](std::size_t i) const { return data_[i]; }

    std::size_t size() const noexcept { return size_; }
    static constexpr std::size_t capacity() noexcept { return N; }

private:
    T data_[N]{};
    std::size_t size_ = 0;
};

A tagfüggvényeket itt az osztályon belül definiáltuk, ezért implicit inline-ok. Ha kívül definiálnánk, a sablonfejlécet meg kell ismételni, és a minősített nevet kell használni:

template<class T, std::size_t N>
void Buffer<T, N>::push(const T& v) { /* ... */ }

Ez a forma mutatja meg, hogy a Buffer<T, N> nem önálló típusnév, hanem a sablon azonosítója a paraméterlistával. A typename és a template kulcsszavak szükségességére a függő neveknél (Dependent name, kétfázisú lookup és ADL (26.5)) még visszatérünk; itt elég annyit rögzíteni, hogy a tagdefiníció a sablon deklarációja alatt él, nem külön entitás.

Két feltétel fontos a példa helyességéhez. Egyrészt N > 0, különben a T data_[N] nulla hosszú tömb, ami nem szabványos C++. Másrészt az operator[] nem ellenőriz boundsot: a Buffer itt teljesítményorientált, a szerződés a hívónál van. A size_ == N ellenőrzés a push-ban viszont kötelező, különben írás történne a tömb után.

26.2.2 Nem típusparaméter és fix kapacitás

A std::size_t N non-type template parameter (nem típusú sablonparaméter, NTTP): nem típus, hanem fordítási idejű érték, amely a specializáció típusazonosságának része. A Buffer<int, 8> és a Buffer<int, 16> két különböző típus, még akkor is, ha azonos tagfüggvényeik vannak. Ez teszi lehetővé, hogy a N a data_ tömb méreteként és a capacity() visszatérési értékeként is szerepeljen, azaz ne kelljen futásidejű allokáció.

A C++20 alapban az NTTP-k megengedett kategóriái bővültek. Integrál és enumerációs típusok, pointerek, referenciák, nullptr, valamint bizonyos osztálytípusok használhatók. A structural type (strukturális típus) az a class type, amelynek minden bázisa és nem statikus adattagja public és nem mutable, és maga is literál típus; a tagoknak is ilyen típusúnak (vagy ilyenek tömbjének) kell lenniük. Ilyen típus például egy egyszerű címke:

struct Extent { std::size_t value; };   // aggregátum, minden tag public, nem mutable

template<Extent E>
struct StaticExtent {
    static constexpr std::size_t value = E.value;
};

static_assert(StaticExtent<Extent{16}>::value == 16);

A C++20 előtti szabványokban ez a fajta osztály-NTTP nem volt elérhető; a példa tehát -std=c++20 mellett értelmezhető, és a támogatást a konkrét compiler verziójával ellenőrizni kell. A StaticExtent<Extent{16}> és a StaticExtent<Extent{32}> itt is különböző típus.

Az auto NTTP (template<auto N>) azt jelenti, hogy a paraméter típusát a levezetés adja meg a megadott argumentumból. Ez kényelmes, de van egy fontos következménye: a template<auto N> és a template<std::size_t N> nem ekvivalensek. Az előbbinél Constant<42> esetén N típusa int, az utóbbinál std::size_t. A Constant<-1> az első esetben érvényes lehet, a másodikban a konverzió miatt nagy size_t értékké alakulna, ami meglepő viselkedés. Ha a típus számít, ne bízzuk auto-ra.

template<auto N>
struct Constant { static constexpr auto value = N; };

static_assert(Constant<42>::value == 42);

26.2.3 Alapértelmezett sablonargumentum

A sablonparamétereknek lehet alapértelmezett értékük, ami a függvényparaméterekhez hasonlóan csak a jobb oldalon következhet. Ez csökkenti a kiírást, de nem változtat azon, hogy a specializáció típusa a tényleges argumentumoktól függ:

template<class T, std::size_t N = 8>
class Buffer;   // N alapértelmezett

Buffer<int> b1;        // Buffer<int, 8>
Buffer<int, 16> b2;    // Buffer<int, 16>

Fontos részlet, hogy az alapértelmezett argumentum nem „olvasztja össze” a specializációkat: Buffer<int> és Buffer<int, 8> ugyanaz a típus, de Buffer<int> és Buffer<int, 16> nem. Ha egy függvényt Buffer<int, 8> paraméterrel deklarálunk, a Buffer<int> átadható, a Buffer<int, 16> nem. Ez a fix kapacitás lényege: a méret a típus része, tehát a fordítás során ismert és ellenőrizhető.

Egy további következmény, hogy az alapértelmezett argumentumot a deklaráció tartalmazza, és a template fejlécében csak egyszer adható meg. Headerben ez természetes, hiszen a felhasználó minden TU-ban ugyanazt a deklarációt látja.

26.2.4 CTAD és deduction guide

A C++17 bevezette a class template argument deduction (CTAD) mechanizmusát: osztálysablon példányosításánál a template argumentumok bizonyos esetekben a konstruktor argumentumaiból levezethetők, így nem kell kiírni őket.

Buffer b{1, 2, 3};          // Buffer<int, ?> -- N nem vezethető le
Buffer<int, 3> c{1, 2, 3};  // explicit

Az első sor nem működik, mert az std::initializer_list<T> típusából T levezethető (int), de a lista hossza nem része a típusnak, így N-re nincs jelölt. Az implicit deduction guide-ok ugyanis a konstruktorok szignatúrájából képződnek, és az initializer_list hossza nem szerepel a szignatúrában. Itt kell explicit deduction guide-ot írni:

template<class T, class... Rest>
Buffer(T, Rest...) -> Buffer<T, 1 + sizeof...(Rest)>;

Ez a guide azt mondja: ha a Buffer-t egy T típusú értékkel és tetszőleges számú további argumentummal inicializáljuk, a levezetett típus Buffer<T, 1 + sizeof...(Rest)>. Ezzel a Buffer b{1, 2, 3} alakból Buffer<int, 3> lesz. Fontos, hogy a guide csak a típus levezetésében vesz részt; a tényleges inicializálást továbbra is a konstruktor végzi, és ha az initializer_list konstruktort választja a túlterhelés, akkor a viselkedés ahhoz igazodik. A guide és a konstruktor közötti összhangot nekünk kell biztosítanunk.

Egy másik gyakori eset a beépített tömb átvétele:

template<class T, std::size_t N>
Buffer(const T (&)[N]) -> Buffer<T, N>;

Ez a const char[] literáloknál és fix tömböknél hasznos, mert a tömb hossza itt valóban a típus része.

A CTAD-nak vannak korlátai, amelyeket érdemes fejben tartani. Először is csak deklarációban működik: függvényparaméter típusaként nem használható Buffer b formában, ott továbbra is explicit típust kell írni. Másodszor, ha a nevet minősítjük vagy egy már létező típusra hivatkozunk, a CTAD nem lép be. Harmadszor, az alias template-en keresztüli CTAD csak C++20 óta létezik (P1814). A fordító az eredeti sablon guide-jaiból (az implicitekből és a felhasználóiakból is) származtat guide-okat az aliasra: a guide visszatérési típusát az alias jobb oldalára illeszti, és csak azokat tartja meg, amelyekből az alias paraméterei levezethetők. Korlátok: az alias jobb oldalának az eredeti sablon egy specializációjának kell lennie (template<class T> using P = T*; nem működik), és ha egy alias-paraméter a guide-ból nem vezethető le, az a guide kiesik. C++17-ben az alias-CTAD nem létezett.

template<class T, std::size_t N> using FixedBuf = Buffer<T, N>;
template<class T> using Vec = std::vector<T>;

FixedBuf fb{1, 2, 3};   // C++20: Buffer<int, 3>, a Buffer(T, Rest...) guide-ból
Vec v{1, 2, 3};         // C++20: std::vector<int>

Buildjegyzőkönyv (lab-gcc profil). A fejezet Buffer osztályával és a fenti Buffer(T, Rest...) guide-dal a static_assert(std::is_same_v<decltype(fb), Buffer<int, 3>>) és a static_assert(std::is_same_v<decltype(v), std::vector<int>>) g++ -std=c++20 és Clang 22 -std=c++20 alatt is teljesült, a program fb.size() == 3-mal futott. -std=c++17 alatt a GCC alias template deduction only available with '-std=c++20' hibát ad.

Egy csapda az alapértelmezett sablonargumentummal: ha a Buffer deklarációja N = 8 alapértéket ad, akkor a Buffer b{1, 2, 3} listás inicializálásnál az initializer_list konstruktorból képzett guide az elsőbbséget élvező első fázisban levezethetővé válik (N az alapértékből jön), és az eredmény Buffer<int, 8> lesz, nem Buffer<int, 3> — a saját guide nem is jut szóhoz. A lab-gcc profilban ezt egy static_assert hibája mutatta meg (Buffer<int, 8> vs. Buffer<int, 3>). A guide és a konstruktor összhangját ezért a teljes definícióval kell ellenőrizni.

26.2.5 Alias template és variable template

Az alias template nem új típus, hanem név a meglévő specializációra. Segítségével a hosszú sablonneveket rövidíthetjük, és a szándékot dokumentálhatjuk:

template<class T> using SmallBuffer = Buffer<T, 8>;
SmallBuffer<int> sb;   // Buffer<int, 8>

Két dolgot kell tudni róla. Egyrészt az alias nem specializálható, és nem lehet rá részleges specializációt írni; ha ilyesmire van szükség, magát az eredeti sablont kell specializálni. Másrészt C++20 előtt az alias nem vett részt a CTAD-ban; C++20-tól az eredeti sablon guide-jaiból származtatott guide-okkal igen, ahogy fentebb láttuk. Az alias tehát kényelmi eszköz, nem új entitás.

A variable template C++14 óta létezik, és fordítási idejű konstansok típusfüggő családját adja:

template<class T>
inline constexpr T zero = T{};

template<class T>
inline constexpr T pi = T(3.14159265358979323846L);

A zero<int> és a zero<double> külön változó. Headerben a template és a constexpr együtt biztosítja, hogy minden TU-ban ugyanaz a definíció legyen elérhető; az inline itt a szándékot fejezi ki, és C++17-től megengedett. A variable template-re is igaz, hogy a specializáció a template argumentumoktól függ, tehát nem lehet futásidejű értékkel példányosítani.

26.2.6 Headeres definíció, ODR és code bloat

A sablon definíciójának abban a fordítási egységben elérhetőnek kell lennie, ahol példányosítják. Ezért a sablonokat headerben tartjuk, nem .cpp-ben. Ha a tagfüggvény definícióját kivennénk egy .cpp-be, a felhasználó TU-ban csak a deklaráció lenne látható, és a linker „undefined reference” hibát adna a példányosított tagra. Ez a leggyakoribb kezdő hiba.

Az ODR (one definition rule) alól a sablonok kivételt élveznek: ugyanaz a sablon több TU-ban is definiálható, feltéve, hogy a definíciók tokenre azonosak, és a nevek ugyanarra az entitásra oldódnak fel. Ez teszi lehetővé a headeres használatot. A linker a Buffer<int, 16> azonos példányosításait jellemzően összevonja (COMDAT/weak symbol mechanizmus), így a programban egy példány marad. A gyakorlatban mégis code bloat léphet fel: sok különböző (T, N) pár sok külön kódot generál, és a fordítási idő nő.

Két eszköz segít. Az explicit instantiation egy TU-ban előállítja a kívánt specializációt:

template class Buffer<int, 16>;

Az extern template deklaráció viszont azt mondja a fordítónak, hogy ne példányosítsa a specializációt ebben a TU-ban, mert máshol már megvan:

extern template class Buffer<int, 16>;

Ezzel csökkenthető az ismételt példányosítás, de csak akkor, ha a programban pontosan egy helyen van tényleges definíció, különben linkelési hibát kapunk. Az explicit instantiation és az extern template tehát kézben tartott, nem automatikus optimalizálás; a helyességet a linker és a tesztelés igazolja.

26.2.7 Tipikus hibák

A leggyakoribb minta, hogy a tagfüggvényt .cpp-be tesszük, majd a hívó TU-ból linkelünk. A javítás: a definíció headerbe kerül, vagy explicit instantiation történik a .cpp-ben. A második gyakori hiba, hogy Buffer b{1,2,3}-at írunk deduction guide nélkül; itt a T levezethető, de N nem, ezért a fordítás sikertelen. A harmadik: template<auto N>-t használunk ott, ahol a típus számít, és a negatív vagy Nem size_t érték miatt a specializáció mérete váratlan lesz. A negyedik: az alias template-re bízzuk a CTAD-ot, és azt tapasztaljuk, hogy nem működik. Az ötödik: a Buffer-t N = 0-val példányosítjuk, ami a nulla hosszú tömb miatt nem szabványos. Mindegyik hiba javítható a szerződés pontos kimondásával és a fordítási figyelmeztetések komolyan vételével.

26.2.8 Feladatok

  1. Alap. Egészítsd ki a Buffer<T, N> osztályt pop() és clear() tagfüggvényekkel, amelyek a méretet csökkentik, de nem végeznek allokációt! Írj egy main-t, amely Buffer<int, 4>-et használ, és ellenőrzi a capacity() és size() értékét!

  2. Közepes. Adj a Buffer-hez olyan explicit deduction guide-ot, amely const char (&)[N] argumentumból Buffer<char, N-1>-et vezet le (a lezáró nulla nélkül)! Magyarázd el, miért N-1, és mi történik, ha a tömb nem tartalmaz lezáró nullát!

  3. Nehéz. Vezess be egy Extent structural type-ot, és készíts StaticExtent<E> sablont! Példányosítsd Extent{16} és Extent{32} értékkel, és igazold static_assert-tel, hogy a két specializáció különböző típus! Mi történik, ha az Extent tagja mutable?

  4. Diagnosztikai. Írj egy két TU-ból álló programot, amelyben a Buffer<int, 8>::push definíciója egy .cpp-ben van! Figyeld meg a linker hibát, majd javítsd kétféleképpen: headerbe helyezéssel és explicit instantiationnal!

26.2.9 Megoldások

1. megoldás. A pop() a méretet csökkenti, a clear() nullázza; egyik sem szabadít fel memóriát, mert a tároló statikus méretű. A helyes változat:

void pop() { if (size_ > 0) --size_; }
void clear() noexcept { size_ = 0; }

Az ellenőrzés: Buffer<int, 4> b; b.push(1); b.push(2); után b.size() == 2, b.capacity() == 4. A pop() üres bufferre nem csökkent negatívba, mert a size_ előjel nélküli. Ellenőrzési feltétel: a capacity() fordítási időben is használható, például static_assert(Buffer<int,4>::capacity() == 4);.

2. megoldás. A guide:

template<std::size_t N>
Buffer(const char (&)[N]) -> Buffer<char, N - 1>;

A const char[N] típusban N a tömb hossza, amely tartalmazza a lezáró nullát, ezért a hasznos karakterek száma N-1. Ha a tömb nem tartalmaz lezáró nullát, a Buffer mérete eggyel kisebb lesz a tényleges hossznál, és a push a végétől olvashatna; ezt a hívónak kell garantálnia, vagy a guide-ot csak std::string_view-alapú átvételre érdemes használni. A guide csak a típust vezeti le, a konstruktor továbbra is a Buffer(std::initializer_list<T>) vagy más konstruktor szerint inicializál.

3. megoldás. Az Extent aggregátum, minden tagja public és nem mutable, ezért structural type. A StaticExtent<Extent{16}> és a StaticExtent<Extent{32}> két különböző specializáció; a static_assert ezt a típusazonosságot ellenőrzi. Ha az Extent tagja mutable, a típus már nem structural type, és a sablonargumentumként való használat fordítási hibát ad. Ez a korlát a C++20 szabálya; a pontos támogatást a compiler verziójával egyeztetni kell.

4. megoldás. A .cpp-ben definiált tagra a hívó TU-ból „undefined reference to Buffer<int, 8>::push(int const&)” hibát kapunk. Javítás headerbe helyezéssel: a tagdefiníció a headerbe kerül. Javítás explicit instantiationnal: a .cpp-ben template class Buffer<int, 8>; szerepel, a headerben pedig a deklaráció. Ellenőrzési feltétel: a program linkel, és a push viselkedése mindkét változatban azonos; a két megoldás közül a headerbe helyezés a rugalmasabb, az explicit instantiation a bloat csökkentésére szolgál.

26.2.10 Összefoglaló

Az osztálysablon típuscsaládot ír le, amelyből a fordító a template argumentumok helyettesítésével konkrét specializációt állít elő. A nem típusú paraméter fordítási idejű érték, amely része a típusazonosságnak; a fix kapacitású buffer ezt használja ki. A C++20 structural type és auto NTTP bővíti a lehetőségeket, de a típus pontos megválasztása továbbra is a programozó felelőssége. Az alapértelmezett argumentum kényelmet ad, a CTAD és a deduction guide a kiírást csökkenti, de az alias template nem örökli a CTAD-ot. A sablonok headerben élnek, az ODR-megengedés és a linker összevonás teszi lehetővé a többszörös definíciót; az explicit instantiation és az extern template a code bloat kézi szabályozásának eszköze. A példák -std=c++20 mellett értelmezhetők; a konkrét compiler- és library-támogatást a laborprofilban rögzített verzióval kell ellenőrizni, és a kód helyességét fordfítással és teszttel kell igazolni, nem feltételezéssel.