5.2.18 · D4C++ Programming

Exercises — Concepts (C++20) — constraining templates

2,909 words13 min readBack to topic

This page is a ladder. Each rung is one level of thinking, from "can you spot a concept?" all the way to "can you design a concept library?". Every problem has a full solution hidden in a collapsible callout — read the problem, try it in your head or on paper, then open the solution. After each level there is one [!mistake] callout that steel-mans the trap most people fall into there: why the wrong answer feels right, and the fix.

Everything here builds on the parent: Concepts (C++20) — constraining templates. If a term feels unfamiliar, it was defined there or in Templates — function & class templates, SFINAE and std::enable_if, type_traits — std::integral, std::floating_point, Overload Resolution & Subsumption, constexpr — compile-time evaluation, Ranges library (C++20), and auto and decltype.

Before the first exercise, read the figure below — it is the picture behind every problem on this page.

Figure — Concepts (C++20) — constraining templates

Level 1 — Recognition

You are only asked to read and evaluate: is the concept true or false for a given type, and does the syntax mean what it says.

Exercise 1.1 (L1)

Given (with #include <concepts>)

template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;

State the value of each: Numeric<int>, Numeric<double>, Numeric<bool>, Numeric<char>, Numeric<std::string>, Numeric<float*>.

Recall Solution 1.1

A concept is a constexpr bool, so each is literally true or false.

  • Numeric<int>true (std::integral<int> is true).
  • Numeric<double>true (std::floating_point<double> is true).
  • Numeric<bool>true. Surprise! In C++ bool is an integral type, so std::integral<bool> is true.
  • Numeric<char>true. char is also integral.
  • Numeric<std::string>false (neither integral nor floating).
  • Numeric<float*>false. A pointer to float is not a floating-point type; pointers are not integral either.

Count of true: 4 (int, double, bool, char).

Exercise 1.2 (L1)

Which of the four constraint syntaxes below are legal and mean the same thing ("constrain T to Numeric")?

// (a) auto sq(Numeric auto x) { return x * x; }
// (b) template<Numeric T> T sq(T x) { return x * x; }
// (c) template<typename T> requires Numeric<T> T sq(T x) { return x * x; }
// (d) template<typename T> T sq(T x) requires Numeric<T> { return x * x; }
Recall Solution 1.2

All four (a, b, c, d) are legal and equivalent. They are the four syntaxes from the parent note: abbreviated function template, constrained template parameter, requires-clause, trailing requires-clause. Number of legal forms: 4.


Level 2 — Application

Now you apply concepts: pick the surviving overload, or write a small constraint yourself.

Exercise 2.1 (L2)

Given (with #include <concepts>)

template<std::integral T>        void f(T);   // (A)
template<std::floating_point T>  void f(T);   // (B)

For each call, name the chosen overload (or "error"): f(5), f(3.14), f('a'), f(2.0f), f(true), f("hi").

Recall Solution 2.1

A candidate whose constraint is false is silently removed; the call fails only if zero remain.

  • f(5)int is integral → (A) survives, (B) removed → (A).
  • f(3.14)double is floating → (B).
  • f('a')char is integral → (A).
  • f(2.0f)float is floating → (B).
  • f(true)bool is integral → (A).
  • f("hi")const char* is neither → both removed → error ("no matching function call").

Chosen: A, B, A, B, A, error. Number of successful calls: 5.

Exercise 2.2 (L2)

Write a concept Addable<T> that is true exactly when a + b compiles for two T values and the result has type T. Then say whether Addable<int> and Addable<std::string> hold.

Recall Solution 2.2
#include <concepts>   // for std::same_as
 
template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::same_as<T>;
};

requires(T a, T b) declares fictional test variables. { a + b } is a compound requirement: the expression must compile, and -> std::same_as<T> pins its type to exactly T.

  • Addable<int>int + int is inttrue.
  • Addable<std::string>string + string is a std::stringtrue.

Both hold: 2 of 2 true.


Level 3 — Analysis

Here you reason about why a design behaves as it does — ambiguity, subsumption, silent removal.

Exercise 3.1 (L3)

Given (with #include <concepts> and #include <type_traits>)

template<typename T> concept Integral        = std::integral<T>;
template<typename T> concept SignedIntegral  = Integral<T> && std::is_signed_v<T>;
 
template<Integral T>       void g(T);   // (A)
template<SignedIntegral T> void g(T);   // (B)

Which overload does g(-7) (an int) call, and why? What about g(7u) (an unsigned)?

Recall Solution 3.1

g(-7) calls (B). int satisfies both Integral and SignedIntegral, so both candidates survive. When two candidates both apply, subsumption breaks the tie: SignedIntegral is defined as Integral<T> && ..., so it requires strictly more. The compiler prefers the more-constrained overload → (B).

g(7u) calls (A). unsigned is integral but not signed, so SignedIntegral<unsigned> is false → (B) is removed. Only (A) survives → (A). No ambiguity because only one candidate remains.

Exercise 3.2 (L3)

Explain why the concept Integral2<T> = std::integral<T> == true; and Integral<T> = std::integral<T>; do not subsume each other, even though they are logically identical. (Conceptual — what property must hold for subsumption?)

Recall Solution 3.2

Subsumption compares constraints by their atomic constraint structure, not by logical truth. Two constraints subsume only when one is syntactically built from the same atomic pieces as the other (broken into && / || "atoms"). std::integral<T> is one atom; std::integral<T> == true is a different atom (a comparison expression). The compiler treats them as unrelated atoms and cannot prove one implies the other — so neither subsumes the other, and if both apply you get an ambiguity error. Lesson: express refinements by reusing the same named concept with &&, never by rewriting the condition.


Level 4 — Synthesis

Now you build multi-part concepts and constrained templates that solve a stated problem.

Exercise 4.1 (L4)

Design a concept Printable<R> that is true when R can be iterated with a range-for and each element can be sent to std::cout <<. Then write print_all constrained by it.

Recall Solution 4.1
#include <iostream>   // std::cout, operator<<
#include <iterator>   // std::begin, std::end
 
template<typename R>
concept Printable = requires(R r) {
    std::begin(r);   // (1) simple requirement: iterable
    std::end(r);
    requires requires(decltype(*std::begin(r)) x) {   // (2) see terminology note below
        std::cout << x;
    };
};
 
template<Printable R>
void print_all(const R& r) {
    for (const auto& x : r) std::cout << x << ' ';
}

Terminology — get the two requires straight (this is the confusing part):

  • requires(R r) { ... } — this outermost one is the requires-expression. It opens a { ... } list of requirements and, as a whole, evaluates to a bool. concept Printable = <that bool>.
  • Lines std::begin(r); and std::end(r); are simple requirements — each just has to compile.
  • The line requires requires(...) { ... }; contains both kinds of requires back-to-back:
    • The first word requires is a nested requirement — a requirement whose job is "the following constraint must hold". A nested requirement always starts with the keyword requires.
    • The second requires(decltype(*std::begin(r)) x) { std::cout << x; } is a fresh, inner requires-expression. It introduces a fictional element x (whose type is the element type decltype(*std::begin(r))) and demands std::cout << x compiles.

So the pattern requires requires(...) = (nested requirement keyword) + (a whole new requires-expression). We need the nested form here because we want to test a constraint about the element type, not about R directly.

  • Result: std::vector<int> satisfies Printable; std::vector<NoStreamType> is rejected at the call site, not deep inside the loop.

Exercise 4.2 (L4)

Using concepts, write two overloads of to_number: one for std::integral types that returns the value unchanged, one for std::floating_point types that returns the value rounded to the nearest integer and cast to long long. Predict the result of to_number(42), to_number(3.7), and to_number(-2.4).

Recall Solution 4.2

Why std::llround? The spec says "round to the nearest integer, as a long long". The standard library function that does exactly that in one call is std::llround (from #include <cmath>): it takes a floating value and returns a long long, rounding half away from zero. That is why we use it rather than, say, a plain (long long) cast — a bare cast truncates toward zero and would not satisfy "round to nearest" (e.g. (long long)3.7 == 3, wrong). We deliberately swap in llround precisely because it matches the "nearest, as long long" requirement.

#include <concepts>   // std::integral, std::floating_point
#include <cmath>      // std::llround
 
template<std::integral T>
long long to_number(T x) { return x; }                    // (A)
 
template<std::floating_point T>
long long to_number(T x) { return std::llround(x); }      // (B)  returns long long directly
  • to_number(42)int → (A) → 42.
  • to_number(3.7)double → (B) → llround(3.7) = 4.
  • to_number(-2.4)double → (B) → llround(-2.4) = -2 (nearest integer; -2.4 rounds toward -2).

Answers: 42, 4, -2.


Level 5 — Mastery

Synthesis under subtle constraints: subsumption ordering, degenerate/edge types, and choosing why a tool.

Exercise 5.1 (L5)

You have three overloads (with #include <concepts>):

template<typename T>                                    void h(T);   // (A) unconstrained
template<std::integral T>                               void h(T);   // (B)
template<std::integral T> requires (sizeof(T) >= 4)     void h(T);   // (C)

For each call, name the chosen overload: h(int{}), h(char{}) (assume sizeof(char)==1), h(3.14).

Recall Solution 5.1

A constrained template is preferred over a less-constrained/unconstrained one when both are viable. Among constrained ones, subsumption picks the more-constrained.

  • h(int{})sizeof(int)==4. Viable: (A) always, (B) integral true, (C) integral && 4>=4 true. (C) subsumes (B) subsumes (A) → (C).
  • h(char{}) → integral true but sizeof(char)==1 < 4 → (C) removed. Viable: (A), (B). (B) is constrained, (A) is not → (B).
  • h(3.14)double not integral → (B), (C) removed. Only (A) survives → (A).

Chosen: (C), (B), (A).

Note on subsumption: (C)'s clause requires (sizeof(T) >= 4) is an extra atom on top of integral; because (B) and (C) share the std::integral<T> atom, (C) is recognised as strictly more-constrained. This is the correct way to refine (contrast Exercise 3.2).

Exercise 5.2 (L5)

Consider the concept (with #include <concepts> for std::convertible_to, and #include <cstddef> for std::size_t):

template<typename T>
concept Container = requires(T t) {
    typename T::value_type;          // (i) type requirement
    { t.size() } -> std::convertible_to<std::size_t>;  // (ii) compound
    t.begin();                       // (iii) simple
};

For std::vector<int>, int, and std::array<double,0> (a zero-length array), state whether Container holds and justify the degenerate case.

Recall Solution 5.2

First, std::convertible_to<X, Y> is another standard concept from <concepts>: it is true when an X can be implicitly converted to Y. Here { t.size() } -> std::convertible_to<std::size_t> means "t.size() compiles and its result converts to std::size_t".

  • std::vector<int> → has value_type, .size() returning std::size_t, and .begin()true.
  • int → has no nested value_type, no .size(), no .begin()the type requirement (i) fails firstfalse.
  • std::array<double,0> → this is the degenerate case: a zero-length container. Container asks only whether the operations exist, not whether the container is non-empty. std::array<double,0> still has value_type = double, still has a valid .size() (returning 0), and still has .begin() (equal to .end()). So the concept holds → true.

Edge lesson: concepts test the shape of the type's interface, never runtime contents. An empty range is a valid range; the concept correctly says true.


Recall Self-test summary (open only after finishing)

L1 true-count for Numeric ::: 4 (int, double, bool, char) L2 f("hi") outcome ::: error — both overloads removed L3 g(-7) chooses ::: (B) SignedIntegral, by subsumption L4 to_number(3.7) ::: 4 L4 to_number(-2.4) ::: -2 L5 h(char{}) chooses ::: (B), because (C)'s size constraint is false L5 Container<std::array<double,0>> ::: true — interface exists even for an empty container