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:

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