27.2 Concepts, requires és constrained overload

A generikus parser- és serializer-interfészek tipikus helyei annak, ahol a template-ek használhatósága és a hibaüzenetek olvashatósága egyszerre dől el. Ha a követelmények a függvény törzsében, static_assert-ek és if constexpr-ágak mélyén fogalmazódnak meg, a hívó csak a hibák lavinájából következtet arra, mit is várt a szerző. A C++20 concept-ek és requires kifejezések ezt a szerződést a szignatúrába emelik: a követelmény látható, dokumentált és a fordítás korai fázisában ellenőrzött. Ez a rész a Type traits, tag dispatch és if constexpr (27.1) eszközeit egészíti ki a deklaratív megszorítás (constraint) eszközeivel, és támaszkodik a Dependent name, kétfázisú lookup és ADL (26.5) névfeloldási szabályaira, valamint az Egyenlőség, spaceship és rendezési kategóriák (23.4) összehasonlítási szerződéseire.

27.2.1 A constraint-alapú generikusság célja

A hagyományos függvénysablon minden típust elfogad, majd a törzsben derül ki, hogy mely műveleteket várja el. A hiba helye és a hiba oka elválik: a hívás helyén csak annyit látunk, hogy egy mélyen beágyazott operator+ nem található. A concept egy névvel ellátott, compile-time kiértékelhető predikátum, amely a template paramétereire vonatkozó követelményeket fogalmazza meg. A constrained overload olyan függvénysablon, amelynek a paraméterlistája vagy a requires záradéka concept-ekkel szűkíti a lehetséges típusokat. A cél nem az, hogy minden lehetséges feltételt kifejezzünk, hanem hogy a fontos, dokumentált előfeltételek a felületen legyenek, és a nem megfelelő hívásokat a fordító a hívás helyén utasítsa el.

A concept-ek nem helyettesítik a dokumentációt. A szintaktikai követelmény azt mondja meg, hogy egy kifejezés jól formált-e; a szemantikai követelmény azt, hogy a művelet milyen viselkedést ígér. A fordító csak az előbbit ellenőrzi. A parser-interfésznél például a parse tagfüggvény létezése szintaktikai követelmény; az, hogy a parser a hibát egy konzisztens állapotban jelzi, és nem módosítja a bemenetet, szemantikai szerződés, amelyet a dokumentációnak kell rögzítenie.

27.2.2 Concept definíció és requires clause

A concept definíciója egy template fejléccel kezdődik, majd a concept kulcsszó, a név, az egyenlőségjel és egy constraint-kifejezés következik:

template <typename T>
concept Parser = requires(T& p, std::string_view input) {
    { p.parse(input) } -> std::same_as<ParseResult>;
};

A constraint-kifejezés lehet requires kifejezés, más concept hivatkozása, vagy ezek &&, ||, ! operátorokkal alkotott kombinációja. A concept nem tagfüggvény, nem változó, és csak template paraméterre vagy auto-ra vonatkozhat. A requires záradék (requires clause) a template paraméterlista után, a függvénydeklaráció előtt áll, és a függvényre vonatkozó megszorítást adja:

template <typename T>
    requires std::integral<T>
T add(T a, T b) { return a + b; }

A rövidített forma template <std::integral T> vagy void f(std::integral auto x). Több requires záradék is állhat egymás után; ezek konjunkciót alkotnak. A záradék nem tévesztendő össze a requires kifejezéssel: az előbbi a deklaráció része, az utóbbi a concept definíciójában vagy közvetlenül a záradékban álló, zárójelezett követelményhalmaz.

27.2.3 A requires expression négy követelménytípusa

A requires kifejezés négyféle követelményt tartalmazhat. Az egyszerű követelmény (simple requirement) csak a kifejezés jólformáltságát ellenőrzi:

template <typename T>
concept HasFoo = requires(T t) { t.foo(); };

A típuskövetelmény (type requirement) egy típus létezését írja elő; a függő típusnévhez typename kell:

template <typename T>
concept HasValueType = requires { typename T::value_type; };

Az összetett követelmény (compound requirement) a kifejezés jólformáltsága mellett a visszatérési típust is ellenőrzi, opcionálisan noexcept elvárással:

template <typename T>
concept HasSize = requires(const T& t) {
    { t.size() } -> std::convertible_to<std::size_t>;
};

A beágyazott követelmény (nested requirement) egy másik constraint igazságát írja elő a requires kifejezésen belül:

template <typename T>
concept SizedIntegral = requires(T t) {
    requires std::integral<T>;
    { t.size() } -> std::convertible_to<std::size_t>;
};

A négy forma együtt lehetővé teszi, hogy a követelményeket közel a használathoz, mégis újrahasznosítható módon fogalmazzuk meg. A gyakorlatban érdemes a requires kifejezéseket névvel ellátott concept-ekbe csomagolni, mert így a hibaüzenetekben a concept neve jelenik meg, és a követelmények kombinálhatók.

27.2.4 Abbreviated function template és standard concept-ek

Az abbreviated function template a auto paraméter elé tett concept-tel függvénysablont hoz létre:

void print_value(const std::integral auto& value) {
    std::cout << value << '\n';
}

Ez ekvivalens egy template <std::integral T> void print_value(const T&) deklarációval, de a szignatúra olvashatóbb. A standard library számos concept-et kínál: std::same_as, std::convertible_to, std::derived_from, std::integral, std::floating_point, std::copyable, std::movable, std::invocable, std::predicate, valamint a ranges névtérben std::ranges::range, std::ranges::input_range és társai. Ezek a fogalmak a szintaktikai követelményt és a dokumentált szemantikai elvárást együtt képviselik. A std::equality_comparable például megköveteli az == és != jólformáltságát, de a szemantikai szerződés — az ekvivalenciareláció reflexivitása, szimmetriája és tranzitivitása — a felhasználó felelőssége marad.

27.2.5 Constraint normalization és subsumption

A fordító a constraint-eket atomikus megszorításokra (atomic constraint) bontja ([C++20] [temp.constr.normal]). Egy atomikus megszorítás olyan kifejezés, amelyet a && és || operátorok már nem bontanak tovább — például egy requires kifejezés vagy egy std::is_integral_v<T> jellegű logikai kifejezés. A concept-hivatkozás (A<T>) viszont nem önálló atom: a normalizálás kifejti, és helyére a concept definíciójának normál formája kerül, a paraméterek behelyettesítésével. Két atomi megszorítás csak akkor azonos, ha ugyanabból a forráskifejezésből (ugyanarról a forráshelyről) származik, és a paraméterleképezésük is egyezik; két szövegre azonos, de külön leírt requires kifejezés két különböző atom. A normalizálás után a részben rendezés (subsumption) dönti el, hogy két constrained overload közül melyik a specifikusabb. A C<T> && D<T> bekebelezi C<T>-t, mert a normál formája tartalmazza azt; a C<T> || D<T> nem. A következő vázlat a lényeget mutatja meg, nem formális bizonyítás:

template <typename T>
concept A = requires { typename T::a; };

template <typename T>
concept B = A<T> && requires { typename T::b; };

template <A T> void f(T) {}   // 1
template <B T> void f(T) {}   // 2

Az A normál formája egyetlen atom: az A definíciójában leírt requires { typename T::a; }. A B normál formája ennek az atomnak és a requires { typename T::b; } atomnak a konjunkciója — az A<T> hivatkozás kifejtésekor ugyanaz az atom kerül bele, amely az A definíciójában jött létre. Ezért a B bekebelezi az A-t, és a 2-es overload a specifikusabb. Ha viszont a B-t a concept újrahasználata nélkül, ugyanazzal a szöveggel írjuk meg, a fordító nem látja a kapcsolatot:

// atoms.cpp — C++20: az atomi megszorítás a forráshelyhez kötött
#include <cstdio>

template <typename T>
concept A = requires { typename T::a; };                 // itt jön létre az atom

template <typename T>
concept B = A<T> && requires { typename T::b; };         // A<T> kifejtve: ugyanaz az atom

template <typename T>
concept B2 = requires { typename T::a; }                 // szövegre azonos, de MÁSIK atom
          && requires { typename T::b; };

template <A T>  int f(T) { return 1; }
template <B T>  int f(T) { return 2; }                   // B bekebelezi A-t

template <A T>  int g(T) { return 1; }
template <B2 T> int g(T) { return 2; }                   // B2 nem kebelezi be A-t

struct AB { using a = int; using b = int; };

int main() {
    std::printf("f: %d\n", f(AB{}));   // 2
#ifdef AMBIGUOUS
    std::printf("g: %d\n", g(AB{}));   // kétértelmű
#endif
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic atoms.cpp kimenete f: 2. -DAMBIGUOUS mellett a g(AB{}) call of overloaded 'g(AB)' is ambiguous hibát ad: a B2 első requires kifejezése szövegre megegyezik az A-éval, de másik forráshelyen áll, ezért másik atom, és a B2 nem kebelezi be az A-t. Ez a subsumption gyakorlati következménye: a szűkebb concept-nek hivatkoznia kell a tágabbra, különben a fordító nem látja a kapcsolatot.

27.2.6 Diagnosztika és tipikus hibák

A concept-ek legnagyobb gyakorlati haszna a diagnosztikában jelentkezik: a hívás helyén a fordító azt írja, hogy a CounterParser nem elégíti ki a Parser concept-et, és megnevezi a sikertelen követelményt. A hagyományos SFINAE-s megoldásoknál ehhez képest a hibaüzenet rövidebb és a szerződéshez közelebb áll. A tipikus hibák közül az első a requires záradék és a requires kifejezés összekeverése: a záradék nem kerülhet a függvény törzsébe, és a requires requires kétszeres használata legtöbbször concept-tel kerülhető el. A második a typename elhagyása típuskövetelményből: requires { T::value_type; } helyett requires { typename T::value_type; } kell. A harmadik a túl gyenge összetett követelmény: a requires(T t) { t.size(); } akkor is igaz, ha a size() visszatérési típusa void; a { t.size() } -> std::convertible_to<std::size_t> ezt kizárja. A negyedik a nem függő kifejezés a requires kifejezésben: a requires { sizeof(int) > 0; } mindig igaz, és nem használja a template paramétert, ezért nem ad információt. Az ötödik a szemantikai követelmény elhagyása: a concept csak a szintaxist ellenőrzi, a viselkedési szerződést a dokumentációnak kell rögzítenie.

27.2.7 Kidolgozott példa: generikus parser interfész

A következő példa egy minimális parser-interfészt mutat be. A Parser concept megköveteli a parse tagfüggvényt, amely ParseResult-ot ad vissza. A ReusableParser ehhez hozzáadja a reset tagfüggvényt, és hivatkozik a Parser-re, hogy a subsumption működjön. A két parse overload közül a ReusableParser-es a specifikusabb.

#include <concepts>
#include <iostream>
#include <optional>
#include <string>
#include <string_view>

struct ParseResult {
    bool ok;
    std::string value;
    std::string error;
};

template <typename P>
concept Parser = requires(P& p, std::string_view input) {
    { p.parse(input) } -> std::same_as<ParseResult>;
};

template <typename P>
concept ReusableParser = Parser<P> && requires(P& p) {
    { p.reset() } -> std::same_as<void>;
};

template <Parser P>
ParseResult parse(P& p, std::string_view input) {
    return p.parse(input);
}

template <ReusableParser P>
ParseResult parse(P& p, std::string_view input) {
    p.reset();
    auto result = p.parse(input);
    result.value = "[reset] " + result.value;
    return result;
}

struct CounterParser {
    int parse_count = 0;
    int reset_count = 0;
    ParseResult parse(std::string_view input) {
        ++parse_count;
        return {true, std::string(input), {}};
    }
    void reset() { ++reset_count; }
};

struct OneShotParser {
    ParseResult parse(std::string_view input) {
        return {true, std::string(input), {}};
    }
};

int main() {
    CounterParser cp;
    auto r1 = parse(cp, "hello");
    std::cout << r1.value << " parse_count=" << cp.parse_count
              << " reset_count=" << cp.reset_count << '\n';

    OneShotParser op;
    auto r2 = parse(op, "world");
    std::cout << r2.value << '\n';
}

A buildparancs a laborprofilban rögzített GCC vagy Clang C++20 módjában:

g++ -std=c++20 -Wall -Wextra -pedantic -o parser_demo parser_demo.cpp
# vagy
clang++ -std=c++20 -Wall -Wextra -pedantic -o parser_demo parser_demo.cpp

A CounterParser megfelel a ReusableParser-nek, ezért a fordító a második parse overloadot választja: a reset() meghívása után a parse eredménye a [reset] hello értéket kapja, a parse_count és a reset_count egyaránt 1 lesz. A OneShotParser csak a Parser-nek felel meg, ezért az első overload fut, és a world értéket adja vissza. A tényleges kimenet a labor profiljától függ; a példa nem tekinthető futás nélkül teszteltnek.

27.2.8 Összefoglaló

A concept egy névvel ellátott compile-time predikátum, amely a template paramétereire vonatkozó követelményeket rögzíti. A requires kifejezés egyszerű, típus-, összetett és beágyazott követelményeket tartalmazhat; a requires záradék a deklaráció része. Az abbreviated function template a concept-et az auto paraméterbe emeli. A standard concept-ek szintaktikai és dokumentált szemantikai szerződést egyesítenek, de a szemantikát a fordító nem ellenőrzi. A constraint-normalizálás és a subsumption teszi lehetővé, hogy a specifikusabb overloadot a fordító válassza; ehhez a szűkebb concept-nek hivatkoznia kell a tágabbra. A jó diagnosztika feltétele a pontos, de nem túl gyenge követelmény, és a szemantikai elvárások dokumentálása.

27.2.9 Feladatok

27.2.9.1 HasSize concept

Definiálj egy HasSize concept-et, amely megköveteli, hogy a T típusnak legyen size() tagfüggvénye, amely std::size_t-re konvertálható. Írj egy print_size függvényt, amely ezt a concept-et használja, és működjön std::vector<int>-re és std::string-re. Magyarázd el, miért nem fordul le int argumentummal.

27.2.9.2 Serializable concept

Definiálj egy Serializable concept-et, amely megköveteli a to_string() tagfüggvényt std::string visszatérési típussal, és a from_string(std::string_view) statikus tagfüggvényt std::optional<T> visszatérési típussal. Írj egy roundtrip függvényt, amely ezt használja, és egy Point struct-ot, amely megfelel a concept-nek.

27.2.9.3 TransactionalParser concept

Egészítsd ki a parser példát egy TransactionalParser concept-tel, amely a ReusableParser-t terjeszti ki commit() és rollback() tagfüggvényekkel. Definiálj egy parse overloadot a TransactionalParser-re, és mutasd meg, hogy a subsumption miatt ez a specifikusabb overload fut. Magyarázd el, miért kell a TransactionalParser definíciójában szerepelnie a ReusableParser<P> hivatkozásnak.

27.2.9.4 Addable diagnosztika

Adott a következő concept:

template <typename T>
concept Addable = requires(T a, T b) { a + b; };

Mi a hiba ebben a definícióban? Javítsd ki, és magyarázd el, miért ad jobb diagnosztikát a javított változat.

27.2.10 Megoldások

27.2.10.1 HasSize concept megoldása

template <typename T>
concept HasSize = requires(const T& t) {
    { t.size() } -> std::convertible_to<std::size_t>;
};

template <HasSize T>
void print_size(const T& value) {
    std::cout << value.size() << '\n';
}

A std::vector<int> és a std::string size() tagfüggvénye std::size_t-re konvertálható, ezért megfelelnek. Az int-nek nincs size() tagfüggvénye, ezért a concept hamis, és a hívás a hívás helyén fordítási hibát ad. A hibaüzenet a HasSize concept-et nevezi meg, nem a print_size törzsét. Az ellenőrzés feltétele: a print_size(std::vector<int>{1, 2, 3}) hívás kiírja a 3-at, a print_size(42) hívás nem fordul le.

27.2.10.2 Serializable concept megoldása

template <typename T>
concept Serializable = requires(const T& t, std::string_view s) {
    { t.to_string() } -> std::same_as<std::string>;
    { T::from_string(s) } -> std::same_as<std::optional<T>>;
};

template <Serializable T>
std::optional<T> roundtrip(const T& value) {
    return T::from_string(value.to_string());
}

struct Point {
    int x;
    int y;
    std::string to_string() const {
        return std::to_string(x) + "," + std::to_string(y);
    }
    static std::optional<Point> from_string(std::string_view s) {
        auto comma = s.find(',');
        if (comma == std::string_view::npos) return std::nullopt;
        int x = std::stoi(std::string(s.substr(0, comma)));
        int y = std::stoi(std::string(s.substr(comma + 1)));
        return Point{x, y};
    }
};

A Point megfelel a Serializable-nek, mert a to_string() std::string-et ad vissza, a from_string pedig std::optional<Point>-ot. A roundtrip(Point{1, 2}) a Point{1, 2}-t adja vissza. Az int nem felel meg, mert nincs to_string() tagfüggvénye. Az ellenőrzés feltétele: a roundtrip(Point{1, 2}) értéke Point{1, 2}, és a roundtrip(42) hívás nem fordul le.

27.2.10.3 TransactionalParser concept megoldása

template <typename P>
concept TransactionalParser = ReusableParser<P> && requires(P& p) {
    { p.commit() } -> std::same_as<void>;
    { p.rollback() } -> std::same_as<void>;
};

template <TransactionalParser P>
ParseResult parse(P& p, std::string_view input) {
    p.reset();
    auto result = p.parse(input);
    if (result.ok) {
        p.commit();
    } else {
        p.rollback();
    }
    return result;
}

A TransactionalParser normál formája tartalmazza a ReusableParser<P>-t, amely tartalmazza a Parser<P>-t. Ezért a TransactionalParser bekebelezi a ReusableParser-t és a Parser-t is, így a három parse overload közül ez a specifikusabb. Ha a ReusableParser<P> hivatkozás kimaradna, a TransactionalParser nem kebelezné be a ReusableParser-t, és egy mindkettőnek megfelelő típusnál a hívás kétértelmű lenne. Az ellenőrzés feltétele: egy TransactionalCounterParser típus esetén a parse hívás a TransactionalParser overloadot választja, a commit_count értéke 1, a reset_count értéke 1.

27.2.10.4 Addable diagnosztika megoldása

A hiba az, hogy a concept csak az a + b kifejezés jólformáltságát ellenőrzi. Ha egy típusnak van operator+-szal, amely void-ot ad vissza, a concept igaz lesz, de a return a + b; utasítás hibás. A javított változat:

template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};

Ez a visszatérési típusnak a T-re való konvertálhatóságát is megköveteli, ezért a hiba már a hívás helyén, a concept szintjén jelentkezik. A régi változatnál a hiba a függvény törzsében, a return utasításnál derülne ki, ami nehezebben olvasható és a szerződéstől távolabb esik. Az ellenőrzés feltétele: egy olyan típus, amelynek operator+-a void-ot ad vissza, a javított concept-tel nem fordul le, a régi változattal viszont a concept igaz lenne.