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ó.
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.
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);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.
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}; // explicitAz 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.
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.
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.
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.
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!
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!
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?
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!
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.
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.