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.
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.
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.
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.
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.
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) {} // 2Az 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.
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.
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.cppA 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.