26.6 Generikus interfész és sablondiagnosztikai labor

Ez az alfejezet egy labor. A célja nem új nyelvi elem bevezetése, hanem annak begyakorlása, hogy egy generikus felület viselkedését és a fordítási hibáit a szerződés szintjén tudd leírni. A korábbi részekben rögzítettük a template-instantiation, a dependent name, a kétfázisú lookup és az ADL szabályait Dependent name, kétfázisú lookup és ADL (26.5), valamint a noexcept határok és a hibapolicy kérdését noexcept határok és hibapolicy (25.3). Most ezeket egyetlen, kicsi, de életszerű kódpáron használjuk: egy fix kapacitású, veremtárolón élő pufferen (SmallBuffer) és egy általános transform segéden, amely egy bemeneti tartomány elemeit egy kimeneti tartományba képezi. A labor két kérdésre keresi a választ: mit követelünk meg a típus- és méretparamétertől, és hogyan feltételezzük a hívási szándékot, hogy elkerüljük a véletlen másolatokat.

A hangsúly a diagnosztikán van. Egy template-instantiation hibaüzenete első olvasásra ijesztő, de a legtöbbször három dolog egyikére vezethető vissza: a típusparaméter nem teljesíti a szerződést, a hívási kategória nem az, amit a szerző feltételezett, vagy a névfeloldás egy nem kívánt overloadot választott. A laborfeladat az, hogy mindhárom esetre legyen egy minimalizált, reprodukálható példád, és hogy a nyers hibaüzenetet API-feltétellé alakítsd.

26.6.1 A két komponens és a szerződés

A SmallBuffer<T, N> szándékosan szűk: fix, fordítási időben ismert kapacitás, nincs halomallokáció, az elemek a SmallBuffer objektumon belül élnek. A típusparaméter-szerződés a következő.

  1. N > 0, mert nulla kapacitású inline tömb nem értelmes.
  2. T legyen default-inicializálható (std::default_initializable<T>), mert a T data_[N]{} tömb mind az N elemet létrehozza, már a konstrukciókor.
  3. T legyen const T&-ről értékadható (std::assignable_from<T&, const T&>), mert a push_back a már létező elemre értékadással ír.

Ennél többet a SmallBuffer nem igényel: nyers bájtmásolást nem végez, ezért a triviális másolhatóság nem feltétel. Ez fontos, mert a labor mérőtípusa, a Counted, szándékosan nem triviálisan másolható: a másoló és mozgató műveletei számlálnak, és éppen ezek mérése a labor célja. A három feltételt a requires klóz rögzíti, így megsértésüket a fordító a példányosítás helyén jelzi.

A transform ezzel szemben csak azt követeli meg, hogy a bemenet elemei olvashatók legyenek, a kimenetnek legyen push_back művelete, a callable pedig legyen hívható a bemenet elemére. Ez utóbbi feltételt a C++20 requires-szel fejezzük ki, nem dokumentációval.

// include/small_buffer.hpp
#ifndef SMALL_BUFFER_HPP
#define SMALL_BUFFER_HPP

#include <concepts>
#include <cstddef>
#include <stdexcept>
#include <type_traits>
#include <utility>

template <typename T, std::size_t N>
    requires (N > 0) && std::default_initializable<T>
             && std::assignable_from<T&, const T&>
class SmallBuffer {
public:
    using value_type = T;
    using size_type  = std::size_t;

    static constexpr size_type capacity() noexcept { return N; }

    size_type size()  const noexcept { return size_; }
    bool      empty() const noexcept { return size_ == 0; }

    T&       operator[](size_type i)       noexcept { return data_[i]; }
    const T& operator[](size_type i) const noexcept { return data_[i]; }

    void push_back(const T& v) {
        if (size_ == N) throw std::length_error{"SmallBuffer full"};
        data_[size_++] = v;
    }

    void clear() noexcept { size_ = 0; }

    T*       begin()       noexcept { return data_; }
    T*       end()         noexcept { return data_ + size_; }
    const T* begin() const noexcept { return data_; }
    const T* end()   const noexcept { return data_ + size_; }

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

#endif

A push_back kapacitáshiba esetén std::length_error-t dob, tehát nem noexcept. A transform sem lehet noexcept, hiszen továbbadja ezt a kivételt; ezt a hibapolicy-szerződés részeként kell dokumentálni, összhangban noexcept határok és hibapolicy (25.3)-gyel.

// include/transform.hpp
#ifndef TRANSFORM_HPP
#define TRANSFORM_HPP

#include <functional>
#include <utility>

// A callable-t a ciklusban többször hívjuk, ezért mindig lvalue-ként (fn),
// nem std::forward<F>(fn)-ként: egy rvalue-ra minősített operator() így nem
// "fogyaszthatja el" az állapotát az első hívásnál. A megszorítás pontosan
// ugyanazt az invoke-kifejezést ellenőrzi, amelyet a törzs használ.
template <typename In, typename Out, typename F>
    requires requires (const In& in, Out& out, F& fn) {
        in.begin(); in.end();
        std::invoke(fn, *in.begin());                  // hívható az elemmel?
        out.push_back(std::invoke(fn, *in.begin()));   // a kimenet fogadja?
    }
void generic_transform(const In& in, Out& out, F&& fn) {
    for (const auto& x : in) {
        out.push_back(std::invoke(fn, x));
    }
}

#endif

A bemenet const In&, mert csak olvassuk; ez nem deduction-, hanem interfész-döntés. A callable F&& forwarding reference, mert így semmilyen callable nem másolódik az átvételkor, legyen az lambda, állapotot hordozó funktor vagy tagadat-pointer. A törzsben viszont a callable-t lvalue-ként (fn) hívjuk, mert a ciklus többször hívja; a std::forward<F>(fn) ismételt használata egy &&-minősített operator() esetén már az első hívásnál „elfogyaszthatná” az állapotot. A megszorítás ugyanazt az std::invoke(fn, ...) kifejezést ellenőrzi ugyanazzal az értékkategóriával (F& fn, const elem), amit a törzs használ, két külön követelményként: hívható-e a callable az elemmel, és fogadja-e a kimenet az eredményt. Így a megszorítás és a ciklustörzs ugyanazt a felületet modellezi, és a std::invoke miatt tagadat-pointeres projekció (&Counted::v) is működik.

26.6.2 A const/lvalue/rvalue argumentum mátrix

A forwarding reference lényege, hogy a dedukált T nem a paraméter típusa, hanem egy „nyers” típus, amelyből a referencia-kollapszus szabálya állítja elő a tényleges paramétert. A laborban ezt a következő táblázattal rögzítjük; a séma template<class T> void f(T&&).

Hívás Dedukált T Paraméter típusa
int x; f(x); int& int&
f(7); int int&&
const int cx; f(cx); const int& const int&
f(std::move(cx)); const int const int&&
f("lit"); const char (&)[4] const char (&)[4]

Három következményt érdemes kiemelni. Először, T lvalue esetén referenciává válik, tehát a T&& alak nem jelenti azt, hogy a hívó csak rvalue-t adhat át. Másodszor, a const minősítő a dedukált típusba kerül, és nem tűnik el: f(cx) esetén T = const int&. Harmadik, a std::move(cx) nem rvalue-vé alakítja a cx-et, csak const int&&-t ad, tehát a transform nem tudja „elrabolni” a tartalmat; ez a klasszikus const-move csapda.

A generic_transform hívásakor a mátrix két helyen játszik: a bemeneti és kimeneti argumentumnál, valamint a callable-nél. A const In& alak miatt a bemenet lvalue marad. A callable-nél a dedukció a hívó szándékát rögzíti (lambda-temporaryra F a lambdatípus, lvalue funktorra F lvalue-referencia), de a törzs a callable-t szándékosan lvalue-ként hívja, mert többször használja; a std::forward<F>(fn) csak egyetlen, utolsó használatnál lenne indokolt. Ha std::ref-fel burkolt funktort ad, a reference_wrapper másolása olcsó.

26.6.3 Forwarding és accidental copy számlálás

A leglátványosabb hiba nem fordítási, hanem teljesítménybeli: a helper véletlenül másol. Ezt egy számláló típussal tesszük láthatóvá.

// include/counted.hpp
#ifndef COUNTED_HPP
#define COUNTED_HPP
#include <cstddef>

struct Counted {
    static inline std::size_t copies = 0;
    static inline std::size_t moves  = 0;

    int v{};

    Counted() = default;
    explicit Counted(int x) : v{x} {}
    Counted(const Counted& o) : v{o.v} { ++copies; }
    Counted(Counted&& o) noexcept : v{o.v} { ++moves; }
    Counted& operator=(const Counted& o) { v = o.v; ++copies; return *this; }
    Counted& operator=(Counted&& o) noexcept { v = o.v; ++moves; return *this; }
    ~Counted() = default;
};
#endif

A driver két variánst hasonlít össze: egy transform_by_value segédet, amely a callable-t érték szerint veszi át, és a generic_transform-et, amely forwarding referenciát használ.

// src/main.cpp
#include "small_buffer.hpp"
#include "transform.hpp"
#include "counted.hpp"
#include <cstdio>
#include <utility>

template <typename In, typename Out, typename F>
void transform_by_value(const In& in, Out& out, F fn) {
    for (const auto& x : in) out.push_back(fn(x));
}

// Külön instrumentált callable: a saját számlálója csak a funktor
// másolásait/mozgatásait méri, a Counted-számlálótól függetlenül.
struct CountingFn {
    static inline std::size_t copies = 0;
    static inline std::size_t moves  = 0;
    CountingFn() = default;
    CountingFn(const CountingFn&) { ++copies; }
    CountingFn(CountingFn&&) noexcept { ++moves; }
    Counted operator()(const Counted& c) const { return Counted{c.v * 2}; }
};

int main() {
    SmallBuffer<Counted, 8> in;
    SmallBuffer<Counted, 8> out;
    for (int i = 0; i < 4; ++i) in.push_back(Counted{i});
    out.clear();

    auto twice = [](const Counted& c) { return Counted{c.v * 2}; };

    // 1. mérés: elemek másolása (Counted-számláló), állapot nélküli lambda.
    Counted::copies = Counted::moves = 0;
    transform_by_value(in, out, twice);
    std::printf("by_value:  copies=%zu moves=%zu size=%zu\n",
                Counted::copies, Counted::moves, out.size());

    out.clear();
    Counted::copies = Counted::moves = 0;
    generic_transform(in, out, twice);
    std::printf("forwarding: copies=%zu moves=%zu size=%zu\n",
                Counted::copies, Counted::moves, out.size());

    // 2. mérés: a callable másolása (CountingFn-számláló).
    CountingFn fn;
    out.clear();
    CountingFn::copies = CountingFn::moves = 0;
    transform_by_value(in, out, fn);
    std::printf("fn by_value:   copies=%zu moves=%zu\n",
                CountingFn::copies, CountingFn::moves);

    out.clear();
    CountingFn::copies = CountingFn::moves = 0;
    generic_transform(in, out, fn);
    std::printf("fn forwarding: copies=%zu moves=%zu\n",
                CountingFn::copies, CountingFn::moves);

    // Tagadat-pointer mint projekció: std::invoke(&Counted::v, x) == x.v
    SmallBuffer<int, 8> ids;
    generic_transform(in, ids, &Counted::v);
    std::printf("projection: size=%zu last=%d\n", ids.size(), ids[ids.size() - 1]);
}

A fordítás és a futtatás külön laborlépés:

mkdir -p build
g++ -std=c++20 -Wall -Wextra -Wpedantic -Iinclude \
    src/main.cpp src/reader_a.cpp src/reader_b.cpp src/small_buffer_inst.cpp \
    -o build/lab
./build/lab

A labor teljes fájlleltára: include/small_buffer.hpp, include/transform.hpp, include/counted.hpp, include/small_buffer_extern.hpp, src/main.cpp, src/reader_a.cpp, src/reader_b.cpp, src/small_buffer_inst.cpp, valamint a negatív tesztek a test/ könyvtárban (neg_small_buffer.cpp, neg_callable.cpp).

Buildjegyzőkönyv (lab-gcc profil). A labor figyelmeztetés nélkül fordult; a kimenet:

by_value:  copies=4 moves=0 size=4
forwarding: copies=4 moves=0 size=4
fn by_value:   copies=1 moves=0
fn forwarding: copies=0 moves=0
projection: size=4 last=3

A két mérés két külön számlálót olvas. Az elemek másolása (Counted-számláló): mindkét változatban 4, és ez a várt, elfogadott eredmény. A twice lambda állapot nélküli, ezért az érték szerinti átvétele egyetlen Counted-et sem másol; a négy másolás a négy kimeneti elem push_back-értékadásából jön (data_[size_++] = v, ahol v const Counted&). A callable másolása (CountingFn-számláló): az érték szerinti transform_by_value lvalue fn-ből pontosan egy másolatot készít (copies=1), a forwarding-referenciás generic_transform egyet sem. A kvalitatív elvárás tehát: a callable-másolás a CountingFn számlálóján jelenik meg, és nem keveredik az elemek másolásával. Ha a lambda érték szerint elkapott Counted-et hordozna, az egy callable-másolás a Counted-számlálón is +1-ként jelentkezne a négy elemmásolás mellett — ezért mérünk külön funktorral.

A negatív tesztek (test/neg_callable.cpp, -DNEG_NOT_CALLABLE, illetve -DNEG_NO_PUSH_BACK) mindkettő constraints not satisfied hibával bukik. Nem hívható objektumnál a jegyzet az első követelményt nevezi meg (the required expression 'std::invoke(fn, (* in.begin()))' is invalid), push_back nélküli kimenetnél csak a másodikat ('out.push_back(std::invoke(fn, (* in.begin())))' is invalid).

// test/neg_callable.cpp  -- szándékosan nem fordul
#include "small_buffer.hpp"
#include "transform.hpp"

struct NoPushBack { };
struct NotCallable { };

int main() {
    SmallBuffer<int, 4> in;
    SmallBuffer<int, 4> out;
#if defined(NEG_NOT_CALLABLE)
    generic_transform(in, out, NotCallable{});
#elif defined(NEG_NO_PUSH_BACK)
    NoPushBack sink;
    generic_transform(in, sink, [](int x) { return x; });
#endif
}

Érdemes megfigyelni, hogy a generic_transform belsejében a for (const auto& x : in) miatt a callable argumentuma const Counted&, nem másolat. Ez a push_back által végzett másolást nem spórolja meg, mert a SmallBuffer::push_back a kimenetbe ír. Ha a push_back-ot emplace_back-kel és a callable visszatérési értékének közvetlen átadásával váltanánk ki, a konstruktor egy lépésben futna le.

26.6.4 Header több translation unitban

A template definíciójának a headerekben kell élnie, mert az instantiation a fordító dolga, nem a linkeré. Ha a transform.hpp-t két .cpp is beemeli, mindkét fordítási egységben példányosodnak ugyanazok a sablonok. Ez nem ODR-sértés, mert a template-ek (és az osztályon belül definiált tagfüggvények) implicit inline jelölésűek, és a szabvány megengedi, hogy több fordítási egységben azonos definíció szerepeljen.

// src/reader_a.cpp
#include "transform.hpp"
#include "small_buffer.hpp"
#include "counted.hpp"

void run_a(SmallBuffer<Counted, 8>& in, SmallBuffer<Counted, 8>& out) {
    generic_transform(in, out, [](const Counted& c) { return Counted{c.v + 1}; });
}
// src/reader_b.cpp
#include "transform.hpp"
#include "small_buffer.hpp"
#include "counted.hpp"

void run_b(SmallBuffer<Counted, 8>& in, SmallBuffer<Counted, 8>& out) {
    generic_transform(in, out, [](const Counted& c) { return Counted{c.v - 1}; });
}

A két generic_transform különböző F típusokkal példányosul, tehát két külön szimbólum keletkezik. Ugyanarra az F-re viszont ugyanaz a weak szimbólum jön létre mindkét egységben, és a linker egyet választ. Ezt a nm -C build/reader_a.o kimenetén ellenőrizheted (a reader_a.o a g++ -std=c++20 -Iinclude -c src/reader_a.cpp -o build/reader_a.o paranccsal készül). A lab-gcc profilban a SmallBuffer<Counted, 8ul> tagfüggvényei, például a push_back, W (weak) jelölést kapnak. A generic_transform példánya viszont t (lokális): a lambda típusa a fordítási egységhez kötött, ezért az ilyen F-fel készült példány nem is lehet közös a két egység között.

26.6.5 Explicit instantiation összevetése

Nagy projektekben a sok azonos instantiation lassítja a fordítást. A template class SmallBuffer<Counted, 8>; explicit instantiation egy fordítási egységben kényszeríti a példányosítást, az extern template class SmallBuffer<Counted, 8>; pedig a többi egységben elnyomja az implicit példányosítást.

// src/small_buffer_inst.cpp
#include "small_buffer.hpp"
#include "counted.hpp"

template class SmallBuffer<Counted, 8>;   // kényszerített példány
// include/small_buffer_extern.hpp
#pragma once
#include "small_buffer.hpp"
#include "counted.hpp"

extern template class SmallBuffer<Counted, 8>;  // máshol példányosul

A trade-off három ponton mérhető: fordítási idő, objektumfájl mérete, linkelési idő. Az explicit instantiation akkor nyer, ha ugyanazt a sablont sok fordítási egység használja, és a T/N kombinációk zárt halmaza ismert. Hátránya, hogy a felhasználónak gondoskodnia kell arról, hogy az extern template deklaráció minden olyan egységben látható legyen, ahol az implicit példányosítást el akarja nyomni; ha kimarad, a példányosítás visszatér, és nem kapsz hibát, csak lassabb buildet.

26.6.6 Negatív fordítási tesztek és olvasható diagnosztika

A negatív fordítási teszt egy olyan forrás, amelynek szándékosan nem szabad lefordulnia, és amelynek a hibaüzenetét ellenőrizzük. A cél az, hogy a hibaüzenet egy static_assert vagy requires feltételre vezethető legyen vissza, ne egy mélyen beágyazott STL-instantiation láncra.

A SmallBuffer szerződéséhez két negatív típus tartozik, egy-egy megsértett feltétellel:

// test/neg_small_buffer.cpp  -- szándékosan nem fordul
#include "small_buffer.hpp"

struct NoDefault {                 // nincs default konstruktor
    explicit NoDefault(int) {}
};

struct ConstMember {               // const tag: nem értékadható
    const int v = 0;
};

#ifdef NEG_ASSIGN
using Bad = SmallBuffer<ConstMember, 4>;
#else
using Bad = SmallBuffer<NoDefault, 4>;
#endif

int main() {
    Bad b;
    (void)b;
}

A requires klóz miatt a fordító a hibaüzenetben a SmallBuffer deklarációját és a klózt nevezi meg, alatta pedig a ténylegesen megsértett feltételt; nem az STL belsejére mutat. Mivel a klóz az osztálysablon deklarációján áll, egy osztályon belüli static_assert meg sem jelenne: a példányosítás már a klóz ellenőrzésén elbukik. Saját szövegű üzenethez a feltételt nevesített conceptbe lehet kiemelni.

A teszt futtatása shell-szintű; mindkét változatnak meg kell buknia, és mindkettőnek a saját okával:

mkdir -p build
if g++ -std=c++20 -Iinclude -fsyntax-only test/neg_small_buffer.cpp 2>build/diag1.txt; then
  echo "FAIL: expected compile error (NoDefault)"; exit 1
fi
grep -q "_Tp = NoDefault" build/diag1.txt

if g++ -std=c++20 -Iinclude -DNEG_ASSIGN -fsyntax-only test/neg_small_buffer.cpp 2>build/diag2.txt; then
  echo "FAIL: expected compile error (ConstMember)"; exit 1
fi
grep -q "__lhs = static_cast" build/diag2.txt

Buildjegyzőkönyv (lab-gcc profil). Mindkét változat template constraint failure hibával bukott. A NoDefault esetén a jegyzet a default_initializable alatti is_constructible_v<_Tp, _Args ...> [with _Tp = NoDefault; _Args = {}] kifejezést nevezi meg, a ConstMember esetén az assignable_from __lhs = static_cast<_Rhs&&>(__rhs) követelményét. A grep minták a libstdc++ conceptjeinek szövegéhez kötöttek; más fordítóhoz igazítani kell őket.

Egy másik tipikus negatív eset a forwardoló segédnél lép fel: ha a generic_transform-et olyan típussal hívjuk, amelynek nincs push_back művelete, a requires feltétel hamis lesz, és a hiba a hívás helyére mutat. Ha viszont a requires-t elhagynánk, a hiba a transform.hpp belsejébe esne, és a hívó számára követhetetlen lenne. A requires tehát nem esztétikai, hanem diagnosztikai eszköz.

26.6.7 Tipikus hibák és javításuk

A leggyakoribb minta, hogy a szerző T-t érték szerint veszi át a callable-nél (F fn), és ezzel minden híváskor másol. A javítás F&& átvétel; a hívás a törzsben std::invoke(fn, ...), lvalue-ként, ha a callable-t többször hívjuk. A std::forward<F>(fn) csak az utolsó (egyetlen) használatnál helyes.

A második minta a const T&& félreértése: std::move(cx) után a T const int, tehát a T&& paraméter nem teszi lehetővé az elrablást. A javítás: ha tényleg mozgatni akarunk, a const-ot már a hívó oldalon el kell hagyni.

A harmadik minta az explicit instantiation és az extern template sorrendjének felcserélése: ha az extern template deklaráció után következik egy implicit használat, a fordító a deklarációt figyelmen kívül hagyhatja, és a példányosítás újra megtörténik. Az extern template deklarációnak minden olyan egységben láthatónak kell lennie, ahol a példányosítást el akarod nyomni.

26.6.8 Összefoglaló

A labor két dolgot mutatott meg. Először, a generikus interfész szerződése több szinten él: a SmallBuffer-nél a típus- és méretparaméterre vonatkozó requires/static_assert, a generic_transform-nél a bemenet és a kimenet műveleteire vonatkozó requires. Másodszor, a hívási kategória és a forwarding nem stílus kérdése: a Counted számlálóval láthatóvá tehető, hol keletkezik felesleges másolat, és a mátrix alapján megjósolható, mit fog tenni a std::forward. A header Több TU-ban élő template-jei ODR-biztosak, az explicit instantiation építési időért cserébe optimalizál, a negatív fordítási teszt pedig akkor jó, ha a hibaüzenet a saját szerződésünkre mutat.

26.6.9 Feladatok

1. feladat (magyarázó). Egészítsd ki a const/lvalue/rvalue mátrixot a generic_transform hívására: SmallBuffer<Counted,8> buf; generic_transform(buf, out, [](auto x){ return x; }); esetén mi lesz In, Out, F dedukált típusa, és miért hívja a törzs a callable-t lvalue-ként, nem std::forward<F>(fn)-nel? Indokold a választ a referencia-kollapszus szabályával.

2. feladat (kódolási). Módosítsd a generic_transform-et úgy, hogy opcionálisan egy reserve-szerű előkészítést is végezzen a kimeneten, ha az támogatja a reserve műveletet, egyébként ne tegyen semmit. A megoldás legyen C++20-kompatibilis, és ne regresszálja a meglévő requires feltételt.

3. feladat (diagnosztikai). Írj negatív fordítási tesztet arra az esetre, amikor a callable visszatérési típusa nem konvertálható a kimenet value_type-jára, és fogalmazd meg a requires feltételt úgy, hogy a hibaüzenet a transform.hpp-ra és a hívásra mutasson, ne az STL belsejére. Add meg az ellenőrző shell-parancs vázát is.

26.6.10 Megoldások

1. megoldás. A buf lvalue SmallBuffer<Counted,8>, a const In& paraméterhez In = SmallBuffer<Counted,8>. Az out szintén lvalue, Out = SmallBuffer<Counted,8>. A lambda-kifejezés prvalue, tehát F a lambda típusa (nem referencia), az fn paraméter típusa F&&. A std::forward<F>(fn) ezt xvalue-ként adná tovább; a ciklusban azonban a callable-t négyszer hívjuk, és egy &&-minősített hívóoperátor az első hívásnál elhasználhatná az állapotát. Ezért a törzs std::invoke(fn, x)-et hív, ahol fn lvalue, és a megszorítás is F& fn-nel ellenőriz. A const In& miatt a bemenet nem módosítható, és a for (const auto& x : in) miatt az x is const Counted&; az auto x paraméter ezt lemásolja, tehát ez a feladat szándékosan bemutat egy accidental copy-t, amit auto const&-re cserélve lehet megszüntetni.

2. megoldás. A requires feltételt bővíteni kell egy opcionális ággal, de a reserve hiánya ne tegye hamissá a feltételt. C++20-ban erre a requires feltétel nem alkalmas közvetlenül; egy külön if constexpr (requires { out.reserve(0); }) blokk a függvénytestben oldja meg:

template <typename In, typename Out, typename F>
    requires requires (const In& in, Out& out, F& fn) {
        in.begin(); in.end();
        std::invoke(fn, *in.begin());
        out.push_back(std::invoke(fn, *in.begin()));
    }
void generic_transform(const In& in, Out& out, F&& fn) {
    if constexpr (requires { out.reserve(in.size()); }) {
        out.reserve(in.size());
    }
    for (const auto& x : in) {
        out.push_back(std::invoke(fn, x));
    }
}

Az if constexpr ága csak akkor példányosul, ha a reserve hívás jól formált; ellenkező esetben a blokk eldobásra kerül, és nem okoz hibát. Ellenőrzési feltétel: a SmallBuffer (amelynek nincs reserve-je) változatlanul fordul, és a std::vector-ral hívott változat is.

3. megoldás. A requires feltételbe be kell venni a visszatérési típus konvertálhatóságát. A kimenet típusát a decltype(*out.begin()) adja meg:

template <typename In, typename Out, typename F>
    requires requires (const In& in, Out& out, F& fn) {
        in.begin(); in.end();
        requires std::convertible_to<
            std::invoke_result_t<F&, decltype(*in.begin())>,
            std::remove_cvref_t<decltype(*out.begin())>>;
        out.push_back(std::invoke(fn, *in.begin()));
    }
void generic_transform(const In& in, Out& out, F&& fn);

A negatív tesztnél a callable egy olyan típust ad vissza, amelyből nincs konverzió a kimenet value_type-jára. Az ellenőrző parancs:

if g++ -std=c++20 -Iinclude -fsyntax-only test/neg_transform.cpp 2>build/diag.txt; then
  echo "FAIL"; exit 1
fi
grep -q "constraints not satisfied" build/diag.txt

A grep minta fordítófüggő; a lényeg, hogy a diagnosztika a generic_transform deklarációjára és a hívás helyére mutasson.