26.4 Specialization, overload és explicit instantiation

A template-ek generikus szerződést adnak: a primary template megfogalmazza, milyen műveleteket várunk el egy típustól, a specializáció pedig ezt a szerződést pontosítja egy konkrét típusra vagy típusmintára. A specializáció azonban nem varázslat: láthatósági, ODR- és overload-resolution következményei vannak. Ugyanakkor az explicit instantiation nem specializáció, hanem fordítási szervezési eszköz: azt dönti el, melyik fordítási egységben készüljön el a példányosított kód. Ez a fejezet ezt a három mechanizmust választja szét: teljes és részleges specializáció, függvény-overload, valamint explicit instantiation és extern template. A cél, hogy eld tudd dönteni, melyik mechanizmus milyen szerződést és milyen fordítási szervezést támogat.

26.4.1 Primary template, teljes specializáció és a szerződés

A primary template a sablon általános definíciója. A teljes specializáció (explicit specialization) a sablon minden paraméterére konkrét argumentumot ad. Osztálysablonnál ez azt jelenti, hogy a specializáció a primary mintájára illeszkedő konkrét argumentumlistára külön definíciót ad. A specializáció nem örökli a primary tagjait: ha a primarynak van egy encode tagja, a specializációnak magának kell biztosítania azt, amit a használat megkövetel.

Ez a gyakorlatban egy programozói szerződés. A szabvány nem kényszeríti ki, hogy a specializáció ugyanazt az interfészt adja, mint a primary; azt sem, hogy ugyanazt a viselkedést adja. A szerződés a felhasználó és a template szerzője között jön létre. Ha a specializáció eltér, a program attól függően fordul vagy nem fordul, hogy a specializáció látható-e a használat helyén.

#include <string>
#include <vector>

template <typename T>
struct serializer {
    static std::string encode(T const&) { return "generic"; }
};

template <>
struct serializer<int> {
    static std::string encode(int v) { return std::to_string(v); }
};

template <typename T>
struct serializer<std::vector<T>> {
    static std::string encode(std::vector<T> const& v) {
        std::string out = "[";
        for (std::size_t i = 0; i < v.size(); ++i) {
            if (i) out += ",";
            out += serializer<T>::encode(v[i]);
        }
        out += "]";
        return out;
    }
};

Itt a serializer<int> teljes specializáció, a serializer<std::vector<T>> pedig részleges specializáció. A serializer<std::vector<int>> a részleges specializációt használja, és azon belül a serializer<int> teljes specializációját. A primary generikus szerződése: van encode, amely std::string-et ad vissza. A specializációk ezt megtartják, de más implementációt adnak.

A teljes specializáció akkor jó választás, ha egy konkrét típusra teljesen más implementációt akarsz adni, és a típus nem illeszkedik egy általánosabb mintára. Ha a viselkedést csak egy típusmintára akarod szabni, a részleges specializáció a természetes eszköz.

26.4.2 Részleges specializáció osztály- és változósablonoknál

A részleges specializáció (partial specialization) a primary paraméterlistájának egy mintájára illeszkedik, de nem minden paramétert köt le. Osztálysablonnál ez a primary egy specializált változata; a fordító akkor választja, ha a konkrét argumentumlista illeszkedik a specializáció mintájára, és a specializáció jobb illeszkedés, mint a primary.

template <typename T, typename U>
struct pair_traits {
    static constexpr bool same = false;
};

template <typename T>
struct pair_traits<T, T> {
    static constexpr bool same = true;
};

A pair_traits<int, int> a részleges specializációt választja, a pair_traits<int, double> a primaryt. A részleges specializáció feltétele, hogy a specializációs argumentumlista illeszkedjen a primary paraméterlistájára, és a minta ne legyen teljesen általános. A pair_traits<T, T> minta illeszkedik, de nem minden paramétert köt le külön-külön; ezért részleges.

Változósablonoknál a részleges specializáció C++14 óta megengedett. Ez különösen type trait-eknél hasznos:

template <typename T>
inline constexpr bool is_pointer_v = false;

template <typename T>
inline constexpr bool is_pointer_v<T*> = true;

template <typename T>
inline constexpr bool is_pointer_v<T* const> = true;

A változósablon részleges specializációja ugyanúgy működik, mint az osztálysabloné: a minta illeszkedését vizsgálja. A is_pointer_v<int*> értéke true, a is_pointer_v<int> értéke false.

Fontos különbség: a részleges specializáció osztály- és változósablonnál megengedett, függvénysablonnál nem. Ez nem stíluskérdés, hanem nyelvi szabály.

26.4.3 Függvénysablon: nincs részleges specializáció, van overload

A függvénysablonnak nincs részleges specializációja. Ha egy függvénysablonra egy típusmintára specializált viselkedést akarsz adni, overloadot írsz. Az overload egy új függvénysablon, amely részt vesz az overload resolutionben; a részleges specializáció hiánya miatt ez a természetes és hordozható megoldás.

#include <iostream>
#include <vector>

template <typename T>
void print(T const&) {
    std::cout << "generic\n";
}

template <typename T>
void print(std::vector<T> const&) {
    std::cout << "vector\n";
}

A print(std::vector<int>{}) hívás a vektoros overloadot választja, mert az jobb illeszkedés, mint a generikus print(T const&). A print(42) hívás a generikusat választja. Ez nem specializáció, hanem overload: a két függvénysablon versenyez az overload resolutionben.

A függvénysablon teljes specializációja megengedett, de törékeny. Az explicit specializáció nem vesz részt az overload resolutionben; csak akkor választódik, ha a primaryt választották, és a template argumentumok pontosan illeszkednek. Ezért a függvénysablonoknál az overload általában kiszámíthatóbb.

#include <iostream>

template <typename T>
void f(T) { std::cout << "primary\n"; }

template <>
void f<int>(int) { std::cout << "int specialization\n"; }

template <typename T>
void f(T*) { std::cout << "pointer overload\n"; }

int main() {
    f(0);      // int specialization
    int x = 0;
    f(&x);     // pointer overload
}

A f(0) hívásnál a primary f(T) T=int, és a f<int>(int) teljes specializáció pontos illeszkedés, ezért azt használja. A f(&x) hívásnál a pointer overload f(T*) jobb illeszkedés, mint a primary f(T), ezért az overloadot választja; a specializáció nem versenyez vele. Ez szemlélteti, miért kell a függvénysablonoknál az overloadot előnyben részesíteni, ha a viselkedést típusmintára akarod szabni.

A gyakorlati döntés tehát: ha a viselkedést egy típusmintára akarod szabni, és a hívás függvényhíváson keresztül történik, írj overloadot. Ha a típusrendszerben akarsz egy specializált definíciót adni, és a típus például egy osztálysablon argumentuma, használj class partial specializationt. A függvénysablon partial specializationja nem opció.

26.4.4 Láthatóság, ODR és a specializáció helye

A specializáció láthatósága nem részletkérdés: a program jelentését befolyásolja. A specializációt a primary template deklarációja után, de az első olyan használat előtt kell deklarálni, amely implicit instantiációt okozna. Ha a specializáció csak később válik láthatóvá, a program ill-formed, de diagnosztika nem kötelező. Ezért a specializációk headerbe kerüljenek, a primary után.

Az explicit specializáció nem lesz automatikusan inline attól, hogy a primary inline volt. Ha függvényspecializációt teszel headerbe, és több fordítási egységben is definiálod, inline-nak kell lennie, különben megsérted az ODR-t. Az osztálysablon specializációja headerbe kerülhet, mert a class definíciókra az ODR többszörös definíciót enged meg azonos definícióval. A függvényspecializációnál ez nem ilyen egyszerű: a definíciót inline-ként kell megjelölni, ha headerben van.

Az ODR alapelve: egy programban egy entitásnak egy definíciója lehet; a template-ek és az inline függvények kivételek, de a definícióknak minden fordítási egységben azonosaknak kell lenniük. Az explicit instantiation definition legfeljebb egyszer szerepelhet a programban; ha többször, az ODR-sértés, diagnosztika nem kötelező.

26.4.5 Explicit instantiation: definition és extern template

Az explicit instantiation definition azt kéri a fordítótól, hogy egy adott fordítási egységben példányosítsa a sablont:

template struct Widget<int>;
template void process<int>(int);

Az extern template deklaráció azt kéri, hogy ebben a fordítási egységben ne történjen meg az implicit példányosítás, mert a definíció máshol elérhető:

extern template struct Widget<int>;
extern template void process<int>(int);

A tipikus elrendezés: a sablon definíciója headerben van; egy .cpp fájlban explicit instantiation definition szerepel a gyakran használt specializációkra; a többi .cpp fájlban extern template deklaráció szerepel a használat előtt. Így a példányosítás egyszer történik meg, a többi fordítási egység csak hivatkozik rá.

widget.hpp:

#ifndef WIDGET_HPP
#define WIDGET_HPP

#include <cstddef>

template <typename T>
struct Widget {
    T value;
    std::size_t size() const;
};

template <typename T>
std::size_t Widget<T>::size() const { return sizeof(T); }

#endif

widget.cpp:

#include "widget.hpp"

template struct Widget<int>;
template struct Widget<double>;

main.cpp:

#include "widget.hpp"

extern template struct Widget<int>;
extern template struct Widget<double>;

int main() {
    Widget<int> a{42};
    Widget<double> b{3.14};
    return static_cast<int>(a.size() + b.size());
}

Buildparancs:

g++ -std=c++20 -O2 -Wall -Wextra -pedantic -c widget.cpp -o widget.o
g++ -std=c++20 -O2 -Wall -Wextra -pedantic -c main.cpp -o main.o
g++ widget.o main.o -o app

Az extern template nem specializáció, hanem példányosítási kérés. Ha a definíciót végül nem adja meg senki, a linker hibát jelez. Az explicit instantiation definition csak azokat a tagokat példányosítja, amelyek a definition pontján definiáltak; ha egy tagot később definiálsz, lehet, hogy nem lesz példányosítva. Ez a szabvány részletes szabálya, ezért a definíciók sorrendjére figyelni kell.

26.4.6 Fordítási idő és bináris méret mérése

Az explicit instantiation elsődleges nyeresége a fordítási idő és a fordítási egységek mérete. Ha sok .cpp fájl használja ugyanazt a sablont, implicit instantiációval mindegyik példányosítja a sablont; extern template-tel a példányosítás egy helyre kerül. A végső bináris méretre gyakorolt hatás bizonytalanabb: a linker gyakran összevonja a gyenge szimbólumokat, így a bináris méret nem feltétlenül csökken. A mérést mindkét szinten el kell végezni.

Mérési terv:

  1. Két buildet készíts: (A) minden fordítási egységben implicit instantiáció, extern template nélkül; (B) explicit instantiation definition egy .cpp-ben, extern template a többi .cpp-ben.
  2. GCC-nél -ftime-report, Clang-nál -ftime-trace kapcsolóval mérd a fordítási időt. A -ftime-trace JSON-t ír, amelyet külön eszközzel lehet elemezni.
  3. A fordítási egységek méretét size-dal, a szimbólumokat nm -C-vel vizsgáld. A végső bináris méretet szintén size-dal mérd.
  4. Rögzítsd a compiler és a standard library verzióját, a targetet, az optimalizálási szintet, a fordítási egységek számát és a buildparancsokat. A mérés csak így reprodukálható.
g++ -std=c++20 -O2 -ftime-report -c main.cpp -o /dev/null
clang++ -std=c++20 -O2 -ftime-trace -c main.cpp -o /dev/null
nm -C widget.o | grep Widget
size app

Ezek a parancsok a laborhoz tartoznak; a fejezet nem állítja, hogy a példák le lettek futtatva. A várt irány az, hogy a main.o mérete és a main.cpp fordítási ideje csökken, ha a példányosítás máshol történik. A végső bináris méretnél a linker összevonása miatt lehet, hogy nem látszik különbség. A mérés célja nem egyetlen szám, hanem a mechanizmus költségének megértése.

26.4.7 Gyakori hibák és javításuk

26.4.8 Feladatok

26.4.8.1 1. feladat: mechanizmusválasztás

Magyarázd el, mi a különbség a class template partial specialization és a function template overload között. Miért nincs függvénysablon részleges specializációja? Adj egy példát, amelyben a kettő közötti választás megváltoztatja a program viselkedését.

26.4.8.2 2. feladat: specializációs lánc

Írj egy serializer<T> primary template-et, egy teljes specializációt int-re, egy teljes specializációt std::string-re, valamint egy részleges specializációt std::vector<T>-re. A vektoros specializáció a serializer<T>::encode-ot használja az elemekre. Ügyelj arra, hogy a specializációk a primary után és a használat előtt legyenek láthatók.

26.4.8.3 3. feladat: diagnosztika

Adott egy header, amelyben a serializer<int> specializáció a main után van definiálva. Mi a hiba? Hogyan javítod? Mi a helyzet akkor, ha a specializáció függvénysabloné, és nincs inline? Fogalmazz meg ellenőrzési feltételt a javításra.

26.4.8.4 4. feladat: mérési terv

Tervezz egy mérést, amely összehasonlítja az implicit instantiációt az explicit instantiation + extern template megoldással. Milyen parancsokat használsz, mit rögzítesz, és mire kell vigyázni az értelmezésnél?

26.4.9 Megoldások

26.4.9.1 1. feladat megoldása

A class template partial specialization a típusmintára illeszkedik, és a primaryt helyettesíti, ha a minta illeszkedik. A function template overload egy új függvénysablon, amely részt vesz az overload resolutionben. A függvénysablonnak azért nincs részleges specializációja, mert a függvényeknél az overload resolution és a template argument deduction már így is összetett; a részleges specializáció plusz dimenziót adna, és a függvényspecializációk nem vesznek részt az overload resolutionben. Az overload természetesebb és kiszámíthatóbb.

Példa: a print(T const&) és a print(std::vector<T> const&) overloadok közül a print(std::vector<int>{}) a vektorosat választja, a print(42) a generikusat. Ha ugyanezt függvénysablon részleges specializációval akarnád megoldani, nem fordulna le. Ellenőrzés: a hívások a várt overloadot választják; a fordító nem jelez hibát a részleges specializáció miatt, mert az nem szerepel a kódban.

26.4.9.2 2. feladat megoldása

#include <string>
#include <vector>

template <typename T>
struct serializer {
    static std::string encode(T const&) { return "generic"; }
};

template <>
struct serializer<int> {
    static std::string encode(int v) { return std::to_string(v); }
};

template <>
struct serializer<std::string> {
    static std::string encode(std::string const& s) { return "\"" + s + "\""; }
};

template <typename T>
struct serializer<std::vector<T>> {
    static std::string encode(std::vector<T> const& v) {
        std::string out = "[";
        for (std::size_t i = 0; i < v.size(); ++i) {
            if (i) out += ",";
            out += serializer<T>::encode(v[i]);
        }
        out += "]";
        return out;
    }
};

A specializációk a primary után vannak, és a használat előtt láthatók. A serializer<std::vector<int>> a részleges specializációt használja, azon belül a serializer<int> teljes specializációját. Ellenőrzés: serializer<std::vector<int>>::encode({1,2}) eredménye [1,2]; a serializer<std::string>::encode("x") eredménye "x". A pontos kimenet a std::to_string és a string-összefűzés miatt a laborban ellenőrizendő.

26.4.9.3 3. feladat megoldása

A specializáció a használat után van deklarálva, ezért a program ill-formed, de diagnosztika nem kötelező. A fordító a primaryt használhatja, és a specializáció nem érvényesül. Javítás: a specializációt a primary után, de az első használat előtt kell deklarálni, lehetőleg headerben.

Ha a specializáció függvénysabloné, és headerben van, inline kell, különben több fordítási egységben definiálva ODR-sértés. Ellenőrzés: a specializáció használatának helyén a specializáció látható; a nm -C kimenetében a specializáció szimbóluma a várt módon jelenik meg; a program viselkedése a specializált implementációt tükrözi.

26.4.9.4 4. feladat megoldása

Két buildet készítek. Az (A) változatban minden fordítási egység implicit instantiációt végez. A (B) változatban egy .cpp tartalmazza az explicit instantiation definitiont, a többi .cpp pedig extern template deklarációt a használat előtt. GCC-nél -ftime-report, Clang-nál -ftime-trace kapcsolóval mérem a fordítási időt. A fordítási egységek méretét size-dal, a szimbólumokat nm -C-vel vizsgálom. A végső bináris méretet szintén size-dal mérem.

Rögzíteni kell a compiler és a standard library verzióját, a targetet, az optimalizálási szintet, a fordítási egységek számát és a buildparancsokat. Vigyázni kell arra, hogy a linker összevonhatja a gyenge szimbólumokat, ezért a bináris méret nem feltétlenül csökken; a fő nyereség a fordítási idő és a fordítási egységek mérete. A mérés csak akkor értelmes, ha a sablon tényleg sok fordítási egységben példányosul. Ellenőrzés: a (B) változatban a main.o mérete és a main.cpp fordítási ideje csökken; a végső bináris méret változása külön mérendő.

26.4.10 Összefoglaló

A teljes specializáció egy konkrét argumentumlistára ad új definíciót; a részleges specializáció egy típusmintára illeszkedik, és osztály- vagy változósablonnál használható. A függvénysablonnak nincs részleges specializációja; helyette overloadot írunk. A függvénysablon teljes specializációja megengedett, de nem vesz részt az overload resolutionben, ezért törékeny. A specializációt a primary után, az első használat előtt kell deklarálni; a függvényspecializáció headerben inline kell legyen. Az explicit instantiation definition és az extern template nem specializáció, hanem fordítási szervezés: a példányosítást egy fordítási egységbe tereli. A fordítási idő és a fordítási egységek mérete mérhető; a végső bináris méretre gyakorolt hatás a linker összevonása miatt bizonytalan.

26.4.11 Források és ellenőrzési korlátok

A fejezethez nem érkezett forrásjegyzet, ezért a szabványparagrafusokra való pontos hivatkozás, a compiler-specifikus viselkedés és a mérési eredmények ebben a szövegben nem ellenőrzöttek. A megadott kiinduló URL-ek – a WG21 nyilvános dokumentumai, a GCC és a Clang dokumentációja – továbbtanulási kiindulópontok, nem a fejezet állításainak bizonyítékai. A laborban a rögzített compiler- és standard library-verzióval, a szabvány nyilvános draftjának megfelelő szakaszával és tényleges méréssel kell ellenőrizni a specializáció láthatóságára, az ODR-re és az explicit instantiation hatására vonatkozó állításokat. A C++20-as alaphoz tartozó nyilvános draft konkrét URL-je nem szerepelt a forrásjegyzetben; ennek hiányát a labornak kell pótolnia.