Ch15.3: Concepts: Requirements the Compiler Checks
1. What a Template Without a Concept Costs You
A plain function template accepts anything. The compiler does not look at the body until something calls it, and then it substitutes the argument into the body and reports whatever breaks — several pages deep, in a header you did not write.
template <typename T>
[[nodiscard]] inline ::std::string_view show(T const &t) noexcept
{
return t.name();
}
show(42) does not say “42 has no name()”. It
says the instantiation of show<int> failed, and points at the body
with a note that int has no member name. With three nested
templates the message becomes a page of “in instantiation of” lines.
A concept fixes this by stating the requirement up front, where the caller can read it and the compiler checks it before instantiating anything.
2. A Concept Is a Named requires-Expression
template <typename T>
concept named = requires(T const t) {
{ t.name() } -> ::std::convertible_to<::std::string_view>;
};
template <named T>
[[nodiscard]] inline ::std::string_view show(T const &t) noexcept
{
return t.name();
}
Read named as a predicate on types. named<widget> is
true or false; named<int> is
false, checked without compiling the body.
The requires(T const t) { ... } form is a
requirement list. Each entry has one of four shapes:
| Requirement | Means |
|---|---|
t.name() |
the expression is well-formed |
{ t.name() } -> X |
well-formed, and its type satisfies concept X |
{ t.name() } noexcept |
well-formed, and it cannot throw — a real requirement in this book |
typename T::value_type |
the type exists |
Everything inside the braces is unevaluated: nothing is called, nothing is
run, and a failure is not an error — it just makes the concept
false.
3. Constraining a Comparator
This book requires every comparator to be noexcept
(Ch8.1). A concept can say exactly that,
and then a comparator that forgot noexcept is rejected at the call site
instead of being silently slower or, worse, throwing out of a comparison:
template <typename F>
concept comparator = requires(F f, int a, int b) {
{ f(a, b) } noexcept -> ::std::convertible_to<bool>;
};
template <comparator F>
inline void my_sort(int *first, ::std::size_t n, F cmp) noexcept
{
for (::std::size_t i{1}; i < n; ++i)
{
for (::std::size_t j{i}; j != 0 && cmp(first[j], first[j - 1]); --j)
{
auto const t{first[j]};
first[j] = first[j - 1];
first[j - 1] = t;
}
}
}
my_sort(data, 3, ::std::ranges::greater{}) compiles: the call operator of
::std::ranges::greater is noexcept. A lambda written
without noexcept does not compile, and this compiler says exactly why:
error: no matching function for call to ‘my_sort(int [3], int, main()::<lambda(int, int)>)’
• candidate 1: ‘template<class F> requires comparator<F> void my_sort(int*, std::size_t, F)’
• constraints not satisfied
{ f(a, b) } noexcept -> ::std::convertible_to<bool>;
• ‘f.main()::<lambda(int, int)>(a, b)’ is not ‘noexcept’
That is the entire benefit in six lines: the requirement is written once, in a place the caller can find, and the error names the requirement instead of the body.
4. Constraining by Traits As Well As by Syntax
The two kinds of requirement combine freely. A concept can ask for a member function (syntax) and for a property of the type (semantics):
template <typename T>
concept byte_like = ::std::integral<T> && (sizeof(T) == 1);
::std::integral<T> is a library concept built on
::std::is_integral_v; (sizeof(T) == 1) is a plain constant
expression. Both must hold.
Prefer the syntax half where it exists. ::std::integral is a promise
about a type the standard library owns; requires { t.size() } is a
promise about whatever type the caller passes, and it stays true for the caller's
type even when the standard library adds another overload.
5. Subsumption: the Narrower Concept Wins
When two overloads are both viable, and one of them requires everything the other requires plus more, the more constrained one is chosen. That is not a tie-break hack; it follows from the concepts being subsets of each other.
template <typename T>
concept integral_like = ::std::integral<T>;
template <typename T>
concept byte_like = integral_like<T> && (sizeof(T) == 1);
template <integral_like T>
[[nodiscard]] inline ::std::string_view kind(T) noexcept
{
return "integral";
}
template <byte_like T>
[[nodiscard]] inline ::std::string_view kind(T) noexcept
{
return "byte";
}
kind(static_cast<char>(1)) prints byte, because
byte_like is written in terms of integral_like and is
therefore known to be stricter. Note the direction: the subsumption only works when
the stricter concept's definition contains the weaker one by name. Two
unrelated concepts that happen to overlap are ambiguous, and the call does not
compile.
6. Concept Overloads: One Overload per Category
The usual reason to write a concept is to build a small family of overloads, one per kind of type, each of which can assume what its concept guarantees:
template <::std::integral T>
[[nodiscard]] inline ::std::string_view classify(T) noexcept
{
return "integral";
}
template <::std::floating_point T>
[[nodiscard]] inline ::std::string_view classify(T) noexcept
{
return "floating point";
}
classify(1) selects the first, classify(2.5) the second, and
classify(widget{}) is a compile error naming both candidates —
which is the point: the set is closed, and a type outside it is rejected instead of
silently taking some generic branch.
This page now has to warn you about it, because a concept overload set is the most fragile dispatch mechanism C++ has. The next two sections are the warning, and the last is what to write instead.
7. Constraints Are the Last Thing Overload Resolution Looks At
Overload resolution ranks candidates in a fixed order. Constraints are last:
| Step | Rule |
|---|---|
| 1 | better conversion sequence wins (exact match beats promotion beats conversion beats ellipsis) |
| 2 | then: a non-template beats a template, on equal conversions |
| 3 | then: the more specialised template wins |
| 4 | then, last of all: the more constrained template wins |
So a constraint decides nothing until everything else has tied. Two measured consequences, both of which cost an afternoon when they happen in a real code base.
A plain overload you forgot about wins outright
[[nodiscard]] inline ::std::string_view priority(int) noexcept
{
return "plain int overload";
}
template <::std::integral T>
[[nodiscard]] inline ::std::string_view priority(T) noexcept
{
return "integral template";
}
priority(1)=plain int overload
priority(char)=integral template
Both are exact matches for 1, so step 2 applies and the non-template
wins — the integral constraint never got a vote. For
char the non-template would need a promotion, so the template's exact
match wins at step 1.
A more specialised template also wins
[[nodiscard]] inline ::std::string_view ranks(long) noexcept
{
return "long overload";
}
template <byte_like T>
[[nodiscard]] inline ::std::string_view ranks(T) noexcept
{
return "byte template";
}
ranks(char)=byte template
Here the template wins, again at step 1: an exact match beats a non-template that needs a conversion. Reverse the argument types and the answer flips. That is the problem in one sentence: which overload runs depends on conversions you did not write, and a constraint cannot save you, because it is consulted last.
8. What to Write Instead: One Dispatcher
If the choice is between a fixed set of categories, do not express it as an overload
set at all. Classify the type once, as a constexpr value
(Ch15.4), and have a single
function switch on it with if constexpr
(Ch15.8):
enum class kind
{
integral,
floating,
named,
unknown
};
template <typename T>
inline constexpr kind kind_of = []() constexpr {
if constexpr (::std::integral<T>)
{
return kind::integral;
}
else if constexpr (::std::floating_point<T>)
{
return kind::floating;
}
else if constexpr (requires(T const &t) { t.name(); })
{
return kind::named;
}
else
{
return kind::unknown;
}
}();
Three things improved at once. The order of the branches is the order the author
wrote, not a tie-break rule buried in the standard. A type that matches nothing gets
the unknown branch, deliberately, instead of a compile error or a silent
conversion. And the selection is a value, so it can be printed, put in a table, or
tested with static_assert:
static_assert(kind_of<int> == kind::integral);
static_assert(kind_of<::std::float64_t> == kind::floating);
9. Tag Types: Make the Overload Set Impossible to Confuse
The dispatcher still has to call something, and the temptation is to make that something an overload set again. Do not: give each branch its own tag type, and pass the tag as the first argument. A tag is a distinct type that nothing converts to, so overload resolution has exactly one exact-match candidate and no conversions to rank:
template <kind k>
struct kind_tag
{
};
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::integral>, T) noexcept
{
return "integral";
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::named>, T const &value) noexcept
{
return value.name();
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(T const &value) noexcept
{
return describe(kind_tag<kind_of<T>>{}, value);
}
This is the shape fast_io uses everywhere: ::fast_io::io_reserve_type<char, T>
is exactly such a tag, and every printing or scanning customisation takes it as its
first parameter
(Ch15.11). The tag makes the
call unambiguous, carries the type information the function needs as a
type, and means that adding a new category cannot silently capture an
existing call — there is no conversion between
kind_tag<kind::integral> and
kind_tag<kind::named>.
The same trick works at run time with a value tag: an
enum class parameter that names the operation is better than three
overloads that differ only in their constraints, because the caller says what it
wants instead of hoping the overload set agrees.
10. Where the Concept Is Checked
Concepts appear in four places, all of them compile time:
- As a type constraint:
template <named T>. - As a trailing clause:
template <typename T> requires named<T>. Same meaning, different spelling; use the clause when the constraint is long. - As a shorthand variable:
named<T>is aboolyou may test withstatic_assert, or use inif constexpr(Ch15.8). - As an ad-hoc
requires:requires { t.size(); }inside a function body, with no name attached.
The whole program below shows all of it — the overload set, the two priority surprises, the dispatcher and the tags — in one file:
#include <fast_io.h>
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <string_view>
struct widget
{
[[nodiscard]] ::std::string_view name() const noexcept { return "widget"; }
};
struct plain
{
int value{};
};
// ---- concept overloads: one overload per category
template <::std::integral T>
[[nodiscard]] inline ::std::string_view classify(T) noexcept
{
return "integral";
}
template <::std::floating_point T>
[[nodiscard]] inline ::std::string_view classify(T) noexcept
{
return "floating point";
}
// ---- constraints are the LAST tie-break: a plain overload silently wins
[[nodiscard]] inline ::std::string_view priority(int) noexcept
{
return "plain int overload";
}
template <::std::integral T>
[[nodiscard]] inline ::std::string_view priority(T) noexcept
{
return "integral template";
}
// ---- a better conversion rank outranks the non-template preference
[[nodiscard]] inline ::std::string_view ranks(long) noexcept
{
return "long overload";
}
template <typename T>
concept byte_like = ::std::integral<T> && (sizeof(T) == 1);
template <byte_like T>
[[nodiscard]] inline ::std::string_view ranks(T) noexcept
{
return "byte template";
}
// ---- the recommended shape: classify once, dispatch with if constexpr, key with a tag
enum class kind
{
integral,
floating,
named,
unknown
};
template <typename T>
inline constexpr kind kind_of = []() constexpr {
if constexpr (::std::integral<T>)
{
return kind::integral;
}
else if constexpr (::std::floating_point<T>)
{
return kind::floating;
}
else if constexpr (requires(T const &t) { t.name(); })
{
return kind::named;
}
else
{
return kind::unknown;
}
}();
template <kind k>
struct kind_tag
{
};
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::integral>, T) noexcept
{
return "integral";
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::floating>, T) noexcept
{
return "floating point";
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::named>, T const &value) noexcept
{
return value.name();
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::unknown>, T) noexcept
{
return "something else";
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(T const &value) noexcept
{
return describe(kind_tag<kind_of<T>>{}, value);
}
int main()
{
try
{
::fast_io::io::println("classify(1)=", classify(1));
::fast_io::io::println("classify(2.5)=", classify(2.5));
::fast_io::io::println("priority(1)=", priority(1));
::fast_io::io::println("priority(char)=", priority(static_cast<char>(1)));
::fast_io::io::println("ranks(char)=", ranks(static_cast<char>(1)));
::fast_io::io::println("describe(1)=", describe(1));
::fast_io::io::println("describe(2.5)=", describe(2.5));
::fast_io::io::println("describe(widget)=", describe(widget{}));
::fast_io::io::println("describe(plain)=", describe(plain{}));
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
Output:
classify(1)=integral
classify(2.5)=floating point
priority(1)=plain int overload
priority(char)=integral template
ranks(char)=byte template
describe(1)=integral
describe(2.5)=floating point
describe(widget)=widget
describe(plain)=something else
11. What Concepts Cannot Do
A concept is a compile-time predicate. It cannot decide anything at run time, it
cannot let one variable hold a widget now and a gadget in
the next iteration, and it does not exist in the binary at all.
The two things people reach for instead are inheritance with virtual
(Chapter 18) and type erasure
(Ch15.10). This chapter uses
the second one, because it keeps every call statically bound except the one that has
to be dynamic.
12. Rules of Thumb
- Name a concept when it is used twice; use an inline
requireswhen it is used once. - Constrain in terms of other concepts, so that subsumption can see the containment.
- Put
noexceptin the concept when the algorithm depends on it; this book's comparators and allocators always do. - Do not constrain what you do not use. A concept that demands five members and uses two rejects types for no reason.
- Do not build a dispatch out of concept overloads. Constraints are the last thing overload resolution consults, so an unrelated overload, a conversion or a more specialised template can win instead.
- Classify the type once into a
constexprvalue and dispatch withif constexpr; that is deterministic, orderable and greppable. - Give each branch its own tag type as the first parameter. A tag cannot be
confused by a conversion, and it is how fast_io's
io_reserve_typeworks. - Remember that a concept does not reduce bloat: it only rejects bad arguments earlier. Bloat is Ch15.9.