C++20 Template Constraints: SFINAE to Concepts
Comparing eleven constraint patterns for template functions in C++20, with a focus on symbol size, readability, and when to choose each approach
TL;DR
C++20 gives us multiple ways to constrain template functions: SFINAE, concepts with requires, and static_assert. Each has different effects on mangled symbol size and error messages. Named concepts in template parameters or requires clauses produce short symbols. Inline requires expressions and return-type SFINAE embed the full constraint into the symbol. For most cases: use a simple named concept for main constraints, static_assert with requires expressions for detailed validation.
Introduction
Template constraints in C++ solve two problems: preventing invalid instantiations (better errors) and enabling overload resolution (picking the right function). The mechanisms differ in what happens when constraints fail. SFINAE and requires clauses remove overloads silently. static_assert fails compilation with your message.
Before C++20, we used SFINAE tricks with enable_if and decltype. These worked but produced cryptic symbols and boilerplate. C++20's concepts promised cleaner syntax. They delivered, but also introduced new symbol bloat traps.
This post compares eleven constraint patterns for a simple function: sum(range) that adds elements. We care about three metrics: symbol size (linker/binary impact), readability, and error quality. Full code: godbolt.org/z/Kf4fqdhcG.
Background: Symbol Mangling
C++ encodes function signatures into symbols for the linker. Overloads need unique symbols, so the compiler mangles namespace, template parameters, argument types, and constraints into the symbol name. The example below uses Clang's default Itanium ABI mangling (also used by GCC). MSVC uses a different but conceptually similar scheme.
For sum_a<std::vector<int>> (see later) with no constraints:
_Z5sum_aISt6vectorIiSaIiEEEDaRKT_
Breaking it down:
_Z= Itanium ABI encoding5sum_a= function name (length-prefixed)I...E= template argument listSt6vectorIiSaIiEE=std::vector<int, std::allocator<int>>Da=autoreturn typeRKT_= reference to const first template parameter
Symbol size matters in template-heavy code: longer symbols mean larger binaries, slower link times, and harder debugging.
The Example Function
We implement sum() for ranges, assuming non-empty input, no default constructor, just copyability and operator+:
template <class Range>
auto sum(Range const& range)
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
This is the unconstrained baseline. Wrong types produce heaps of nested template errors.
Pattern A: Unconstrained
template <class Range>
auto sum_a(Range const& range)
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_aISt6vectorIiSaIiEEEDaRKT_ (33 chars)
Pros: Clean signature, minimal symbol size.
Cons: Error messages explode on invalid types, listing every failed instantiation in the function body.
Pattern B: Return-Type enable_if SFINAE
// Helper trait
template <class A>
[[maybe_unused]] auto test_op_add(int)
-> decltype(A(std::declval<A const&>() + std::declval<A const&>()), std::true_type{});
template <class A>
[[maybe_unused]] auto test_op_add(char) -> std::false_type;
template <class A>
constexpr bool has_op_add = decltype(test_op_add<A>(0)){};
// Constrained function
template <class Range>
auto sum_b(Range const& range)
-> std::enable_if_t<
has_op_add<decltype(*std::begin(range))>,
std::decay_t<decltype(*std::begin(range))>>
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_bISt6vectorIiSaIiEEENSt9enable_ifIX10has_op_addIDTdeclsr3stdE5beginfp_EEEENSt5decayIS4_E4typeEE4typeERKT_ (112 chars)
How it works: test_op_add<A>(int) returns std::true_type if A supports operator+, otherwise SFINAE removes it and test_op_add<A>(char) returns std::false_type. The enable_if_t in the return type removes the overload if the first argument is false.
Pros: Classic pre-C++20 SFINAE, removes overload on failure.
Cons: The entire return-type expression (including enable_if_t, has_op_add, decltype, std::begin) is encoded in the symbol. Peak verbosity.
Pattern C: Return-Type decltype SFINAE
template <class Range>
auto sum_c(Range const& range)
-> std::decay_t<decltype(
*std::begin(range) + *std::begin(range),
*std::begin(range))>
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_cISt6vectorIiSaIiEEENSt5decayIDTcmpldeclsr3stdE5beginfp_Edeclsr3stdE5beginfp_Edeclsr3stdE5beginfp_EEE4typeERKT_ (118 chars)
How it works: The comma operator evaluates *std::begin(range) + *std::begin(range) (which must compile), discards it, then returns the type of *std::begin(range). SFINAE removes the overload if the addition fails.
Pros: More concise than separate traits, inline constraint.
Cons: Symbol is even longer. Comma-expression trick is subtle.
Pattern D: Template-Argument enable_if SFINAE
template <class Range,
class = std::enable_if_t<
has_op_add<decltype(*std::begin(std::declval<Range>()))>>>
auto sum_d(Range const& range)
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_dISt6vectorIiSaIiEEvEDaRKT_ (34 chars)
How it works: The second template parameter is unnamed with a default type. The default is std::enable_if_t<...>, which is void if the constraint holds, and a substitution failure otherwise. The default is not part of the signature, so it does not appear in the symbol.
Pros: Clean symbol. This was the recommended pre-C++20 pattern.
Cons: Template parameter feels indirect. Two overloads with different constraints on the same unnamed parameter cause redefinition errors. See examples on std::void_t for similar SFINAE-friendly patterns.
Pattern E: Template-Argument requires Clause with requires Expression
template <class Range>
requires requires(Range const& range) {
*std::begin(range) + *std::begin(range);
}
auto sum_e(Range const& range)
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_eISt6vectorIiSaIiEEQrQRKT__Xpldeclsr3stdE5beginfL0p_Edeclsr3stdE5beginfL0p_EEEDaS5_ (90 chars)
How it works: The first requires is a requires clause (function constraint). The second requires is a requires expression (essentially evaluates to true if the body compiles). The expression takes arguments to introduce variables, avoiding std::declval.
Pros: C++20 syntax. Cleaner than SFINAE boilerplate, inline constraint definition.
Cons: "requires requires" looks odd. The full requires expression body is encoded in the symbol.
Pattern F: Trailing requires Clause with requires Expression
template <class Range>
auto sum_f(Range const& range)
requires requires {
*std::begin(range) + *std::begin(range);
}
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_fISt6vectorIiSaIiEEEDaRKT_QrqXpldeclsr3stdE5beginfp_Edeclsr3stdE5beginfp_EE (82 chars)
How it works: The requires clause moves after the signature, so range is visible. No need to introduce it in the requires expression.
Pros: Simpler requires expression. Natural reading order.
Cons: Symbol still encodes the full constraint expression.
Pattern G: Template-Argument requires Clause with Named Concept
template <class Range>
concept addable_range = requires(Range const& r) {
*std::begin(r) + *std::begin(r);
};
template <class Range>
requires addable_range<Range>
auto sum_g(Range const& range)
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_gISt6vectorIiSaIiEEQ13addable_rangeIT_EEDaRKS3_ (54 chars)
How it works: Define a concept with a requires expression, then reference the concept by name in the requires clause.
Pros: Concepts are not inlined into the symbol, only referenced by name. Much shorter, reusable constraint, better error messages (concept name appears).
Cons: Need separate concept definition.
Pattern H: Trailing requires Clause with Named Concept
template <class Range>
auto sum_h(Range const& range)
requires addable_range<Range>
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_hISt6vectorIiSaIiEEEDaRKT_Q13addable_rangeIS3_E (54 chars)
How it works: Same as (G), but with the requires clause after the signature.
Pros: Same benefits as (G). Natural reading order.
Cons: Same as (G).
Pattern I: Constrained Template Parameter
template <addable_range Range>
auto sum_i(Range const& range)
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_iITk13addable_rangeSt6vectorIiSaIiEEEDaRKT_ (50 chars)
How it works: Replace class or typename with a concept name in the template parameter list. Shorthand for introducing the parameter and constraining it.
Pros: Most compact syntax. Constraint is part of the parameter declaration.
Cons: Less flexible than requires clauses (cannot combine multiple concepts easily).
Pattern J: Constrained auto Parameter
auto sum_j(addable_range auto const& range)
{
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_jITk13addable_rangeSt6vectorIiSaIiEEEDaRKT_ (50 chars)
How it works: Abbreviated function template syntax. auto parameters make the function a template. Adding a concept before auto constrains the deduced type.
Pros: Shortest syntax. Looks like normal function, same symbol as (I).
Cons: Slightly obfuscates the fact that this is a template.
Pattern K: Unconstrained with static_assert
template <class Range>
auto sum_k(Range const& range)
{
static_assert(std::ranges::forward_range<Range>);
auto it = std::begin(range);
auto end = std::end(range);
auto v = *it++;
static_assert(requires { v + v; }, "elements must be addable");
while (it != end)
v = v + *it++;
return v;
}
Symbol: _Z5sum_kISt6vectorIiSaIiEEEDaRKT_ (33 chars)
How it works: No constraints on the signature. Use static_assert in the body to validate requirements. requires expressions work as static_assert arguments.
Pros: Minimal symbol. Custom error messages. Validates requirements at the point of use. Clear documentation of assumptions.
Cons: Does not participate in overload resolution. Error happens after the function is selected, not during.
Symbol Size Summary
| Pattern | Symbol Length | Contains Constraint |
|---|---|---|
| A. Unconstrained | 33 | No |
B. Return enable_if | 112 | Full expression |
C. Return decltype | 118 | Full expression |
D. Template arg enable_if | 34 | No (default not in symbol) |
E. Template arg requires requires | 90 | Full expression |
F. Trailing requires requires | 82 | Full expression |
| G. Template arg named concept | 54 | Concept name only |
| H. Trailing named concept | 54 | Concept name only |
| I. Constrained template parameter | 50 | Concept name only |
J. Constrained auto parameter | 50 | Concept name only |
K. static_assert | 33 | No |
Named concepts keep symbols compact. Inline requires expressions blow them up.
When to Use Each Pattern
Use named concepts (G, H, I, J) when you need overload resolution. Multiple functions with the same name, different concepts. Overload resolution picks the right one. Keep concept definitions focused and reusable.
Use static_assert (K) when there is only one valid interpretation. You want the function called with the right types, not picked over another overload. Error messages can be specific, minimal symbol impact.
Avoid return-type SFINAE (B, C) unless maintaining pre-C++20 code. Symbol bloat is significant.
Use template-argument SFINAE (D) only for pre-C++20 codebases where it was best practice.
Avoid inline requires expressions (E, F) for constraints that affect the symbol. Use them in static_assert instead or make a new named concept.
Practical Guidelines
- Start with a simple named concept for the main requirement (range, callable, numeric).
- Use
static_assertwithrequiresexpressions for detailed validation in the body. - Ask: if the type does not match, should the overload be removed (SFINAE/concepts) or should the user get a clear error message (
static_assert)? - For most functions, there is no valid alternative overload: use
static_assert. - When you have multiple overloads based on capabilities, use named concepts in the signature.
Example:
auto process(std::ranges::forward_range auto const& range)
{
using T = std::ranges::range_value_t<Range>;
static_assert(std::is_arithmetic_v<T>, "range elements must be numeric");
static_assert(requires(T a, T b) { a * b; }, "elements must be multipliable");
// implementation
}
The signature uses a standard concept. The body validates specific operations with clear messages.
Conclusion
C++20 concepts improve on SFINAE syntax, but there are some trade-offs. Inline requires expressions create long symbols, named concepts keep them short. static_assert gives better error messages when you do not need overload resolution.
My personal guideline:
For most code use a simple concept in the signature for the main requirement, static_assert for everything else. This keeps symbols compact, errors clear, and signatures readable.
Code on Godbolt: godbolt.org/z/Kf4fqdhcG
PS: The patterns in this post are not equivalent (some check different things, some skip checks). That is intentional: the main goal is to show syntax options and impact.