C++ Module 9 – Templates and concepts
Type traits, if constexpr and reading SFINAE code
Ask questions about types with <type_traits>, choose code paths at compile time with if constexpr, and rewrite older enable_if and void_t code as concepts.
What you will learn
- Ask questions about types with the traits of <type_traits>, after stripping references
- Predict which branch of an if constexpr chain each instantiation keeps
- Read older SFINAE code built on enable_if and void_t
- Fix two enable_if overloads that redefine each other
- Rewrite enable_if and detection code as concepts
Before you start
What you’ll learn · Before you start
What you will learn
- Ask questions about types with the traits of <type_traits>, after stripping references
- Predict which branch of an if constexpr chain each instantiation keeps
- Read older SFINAE code built on enable_if and void_t
- Fix two enable_if overloads that redefine each other
- Rewrite enable_if and detection code as concepts
Before you start
On this page
std::is_integral_v<const int&> is false, std::is_signed_v<double> is true, and std::is_signed_v<char> is
true on this Mac but false on Arm Linux. Questions about types have exact answers, not always the expected ones.
A type trait: A class template of <type_traits> that the compiler answers about a type, such as std::is_integral<T>, read through its _v variable template, or that produces a new type, such as std::remove_cvref_t<T>. Every answer is known while compiling. asks them while the program compiles, and if constexpr uses the answer to
keep one branch of a function template for each type, so that one template can run different code for numbers,
strings and containers.
You will read the answers of four traits for eight types, write an if constexpr chain whose order matters, see why a
plain if fails to compile where if constexpr succeeds, and read the SFINAE: Substitution failure is not an error: when putting deduced template arguments into a function template's declaration gives an invalid type or expression, the template quietly leaves the set of candidates. An error inside the body is still an error. code of
libraries written before C++20, which uses std::enable_if and std::void_t to drop a template whose declaration
does not fit a type, then rewrite it with the concepts of the previous lesson. Every example ran with Apple clang 21
and GCC 16.
A type trait answers a question about a type
The header <type_traits> declares more than a hundred traits: std::is_integral_v<T> is true when T is an
integer type, and std::remove_cvref_t<T> is T without const, volatile or a reference. Each trait is a class
template, and since C++17 a _v variable template gives its answer directly (paper P0006R0), while since C++14 a
_t alias gives the type a transformation produces (paper N3655). Older code spells the same things out as
std::is_integral<T>::value and typename std::remove_cv<T>::type. This program asks four questions of eight types:
// The same four questions asked of eight types, all answered by the compiler.
#include <print>
#include <string>
#include <type_traits>
template <typename T>
void row(const char* name) {
std::println("{:<12} integral={:<5} signed={:<5} floating={:<5} integral after remove_cvref={}", name,
std::is_integral_v<T>, std::is_signed_v<T>, std::is_floating_point_v<T>,
std::is_integral_v<std::remove_cvref_t<T>>);
}
int main() {
row<int>("int");
row<unsigned>("unsigned");
row<const int&>("const int&");
row<bool>("bool");
row<char>("char");
row<double>("double");
row<std::string>("std::string");
row<int*>("int*");
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra traits.cpp -o traits && ./traits
g++-16 -std=c++23 -Wall -Wextra traits.cpp -o traits-gcc && ./traits-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra traits.cpp -o traits && ./traits int integral=true signed=true floating=false integral after remove_cvref=true unsigned integral=true signed=false floating=false integral after remove_cvref=true const int& integral=false signed=false floating=false integral after remove_cvref=true bool integral=true signed=false floating=false integral after remove_cvref=true char integral=true signed=true floating=false integral after remove_cvref=true double integral=false signed=true floating=true integral after remove_cvref=false std::string integral=false signed=false floating=false integral after remove_cvref=false int* integral=false signed=false floating=false integral after remove_cvref=false $ g++-16 -std=c++23 -Wall -Wextra traits.cpp -o traits-gcc && ./traits-gcc int integral=true signed=true floating=false integral after remove_cvref=true unsigned integral=true signed=false floating=false integral after remove_cvref=true const int& integral=false signed=false floating=false integral after remove_cvref=true bool integral=true signed=false floating=false integral after remove_cvref=true char integral=true signed=true floating=false integral after remove_cvref=true double integral=false signed=true floating=true integral after remove_cvref=false std::string integral=false signed=false floating=false integral after remove_cvref=false int* integral=false signed=false floating=false integral after remove_cvref=false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 traits_session.py
The traits fall into a few families:
| Family | Examples | What it gives |
|---|---|---|
| Primary categories | is_integral, is_floating_point, is_pointer, is_enum, is_class |
what kind of type it is |
| Properties | is_signed, is_const, is_trivially_copyable |
a further fact about it |
| Relations | is_same, is_base_of, is_convertible |
how two types relate |
| Transformations | remove_cvref_t, decay_t, make_unsigned_t, conditional_t<B, X, Y> |
a new type; conditional_t picks X or Y |
Four rows of the program’s output hold the usual surprises:
const int&is not integral, because a reference type is a category of its own. Strip it first withstd::remove_cvref_t, as the last column does; template parameters deduced from forwarding reference: A reference written auto&&, or T&& in a template whose T is deduced, that binds to anything: it becomes an lvalue reference for a named variable and an rvalue reference for a temporary. More in “C++ auto, decltype and type aliases” are often references, so this comes up constantly.doubleis signed: for arithmetic types the standard definesis_signed_v<T>asT(-1) < T(0), which holds for floating-point types. Test for floating point before you test for signed.boolis integral but not signed. Check for it first when it must be treated differently from numbers.charis signed, on this Mac. The next section shows why that answer is not universal.
Key idea
Strip references and const with std::remove_cvref_t before you ask, and ask about bool and floating point
before you ask about the sign.
How a trait is built
A trait is an ordinary class template with a value member, usually inherited from std::true_type or
std::false_type, plus specialisations for the types whose answer differs, as the specialisation lesson built by hand.
A few traits, such as is_union and is_trivially_copyable, cannot be written in plain C++ and use built-in compiler
functions instead. Write traits of your own for questions about your own types, but never specialise the standard
ones: a program that adds specialisations to the templates of <type_traits> has
undefined behaviour: What a program does when it breaks a rule for which the C++ standard sets no requirements, such as reading past the end of an array. The compiler may assume it never happens, so the result can change with the compiler and the optimisation level. More in “Object lifetime and undefined behaviour in C++”, unless a trait’s description allows it.
Checkpoint
Check: Type traits and their surprises
Plain char is signed on one target and unsigned on another
The C++ standard lets each platform decide whether plain char is signed, so the answer is
implementation-defined behaviour: Behaviour that the C++ standard leaves to each implementation, which must document its choice, such as sizeof(int). A program that relies on it is valid, but may give other results with another compiler or platform. More in “Object lifetime and undefined behaviour in C++”. Apple’s ARM64 platforms make it signed;
the standard ABI for 64-bit Arm, which Linux follows, makes it unsigned. A trait answers for the platform the program
is compiled for:
// Is plain char signed? The answer belongs to the platform the program is compiled for.
#include <print>
#include <type_traits>
int main() {
char c = static_cast<char>(200);
std::println("std::is_signed_v<char> = {}, char(200) holds {}", std::is_signed_v<char>, static_cast<int>(c));
} from session import run
# The compiler defines __CHAR_UNSIGNED__ on targets whose char is unsigned.
run("""
clang++ --target=aarch64-linux-gnu -dM -E -x c++ /dev/null | grep CHAR_UNSIGNED || echo "not defined"
clang++ --target=arm64-apple-macos -dM -E -x c++ /dev/null | grep CHAR_UNSIGNED || echo "not defined"
clang++ -std=c++23 char_sign.cpp -o char_sign && ./char_sign
clang++ -std=c++23 -funsigned-char char_sign.cpp -o char_sign && ./char_sign
g++-16 -std=c++23 -funsigned-char char_sign.cpp -o char_sign-gcc && ./char_sign-gcc
""") Output
$ clang++ --target=aarch64-linux-gnu -dM -E -x c++ /dev/null | grep CHAR_UNSIGNED || echo "not defined" #define __CHAR_UNSIGNED__ 1 #define __WCHAR_UNSIGNED__ 1 $ clang++ --target=arm64-apple-macos -dM -E -x c++ /dev/null | grep CHAR_UNSIGNED || echo "not defined" not defined $ clang++ -std=c++23 char_sign.cpp -o char_sign && ./char_sign std::is_signed_v<char> = true, char(200) holds -56 $ clang++ -std=c++23 -funsigned-char char_sign.cpp -o char_sign && ./char_sign std::is_signed_v<char> = false, char(200) holds 200 $ g++-16 -std=c++23 -funsigned-char char_sign.cpp -o char_sign-gcc && ./char_sign-gcc std::is_signed_v<char> = false, char(200) holds 200
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 char_sign_session.py
Asked to compile for 64-bit Arm Linux, Clang defines the macro __CHAR_UNSIGNED__; for this Mac it does not. On the
Mac std::is_signed_v<char> is true and char(200) holds -56, the same bits read as a signed value. Both
compilers accept -funsigned-char, which makes plain char unsigned on any target, and the same program then prints
false and 200:
| Built for | Plain char |
Shown by |
|---|---|---|
| This Mac (Apple ARM64) | signed: char(200) holds -56 |
the run, and no __CHAR_UNSIGNED__ |
| 64-bit Arm Linux | unsigned | Clang defines __CHAR_UNSIGNED__ for that target |
Any target, with -funsigned-char |
unsigned: char(200) holds 200 |
the runs with Clang and GCC 16 |
Code that needs a definite range should use signed char, unsigned char or std::uint8_t; a trait can then
confirm it with static_assert.
Checkpoint
Check: Plain char and the target
if constexpr keeps one branch for each instantiation
if constexpr is an if whose condition is a compile-time answer. to_text uses four of them to turn a value into
text, with one rule for bool, one for numbers, one for strings and one for ranges, whose elements it converts
recursively:
// One function template with a different body for each kind of type, chosen by if constexpr.
#include <print>
#include <ranges>
#include <string>
#include <string_view>
#include <type_traits>
#include <vector>
template <typename T>
std::string to_text(const T& value) {
if constexpr (std::is_same_v<T, bool>) {
return value ? "yes" : "no";
} else if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_convertible_v<const T&, std::string_view>) {
return '"' + std::string(std::string_view(value)) + '"';
} else if constexpr (std::ranges::range<T>) {
std::string out = "[";
for (const auto& element : value) {
if (out.size() > 1) out += ", ";
out += to_text(element); // a call for the element type: another instantiation
}
return out + "]";
} else {
static_assert(false, "to_text: no rule for this type");
}
}
int main() {
std::println("{}", to_text(42));
std::println("{}", to_text(2.5));
std::println("{}", to_text(true));
std::println("{}", to_text(std::string("chai")));
std::println("{}", to_text("samosa"));
std::println("{}", to_text(std::vector<int>{3, 1, 4}));
std::println("{}", to_text(std::vector<std::vector<std::string>>{{"a", "b"}, {}, {"c"}}));
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra to_text.cpp -o to_text && ./to_text
g++-16 -std=c++23 -Wall -Wextra to_text.cpp -o to_text-gcc && ./to_text-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra to_text.cpp -o to_text && ./to_text 42 2.500000 yes "chai" "samosa" [3, 1, 4] [["a", "b"], [], ["c"]] $ g++-16 -std=c++23 -Wall -Wextra to_text.cpp -o to_text-gcc && ./to_text-gcc 42 2.500000 yes "chai" "samosa" [3, 1, 4] [["a", "b"], [], ["c"]]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 to_text_session.py
Each call instantiates to_text for one type, and in each instantiation the conditions are constants. The compiler
keeps the first branch whose condition is true and discards the rest: each
discarded statement: The branch an if constexpr does not take. Inside a template it is not instantiated for that template argument, so it may hold code that would not compile for the current type; in ordinary code it is still checked. is not instantiated, so code that makes no sense for that
type, such as std::to_string on a std::vector, is never compiled for it. Step through the seven calls and watch
which branch each type keeps:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- to_text(42). T is int. It is not bool, but it is arithmetic, so the second branch is kept and std::to_string writes 42. The string, range and static_assert branches are discarded: never compiled for int.
- to_text(2.5). T is double, arithmetic too, so to_text<double> keeps the same branch. std::to_string writes a double with six decimals: 2.500000.
- to_text(true). T is bool, and the first test catches it before the arithmetic one can, so true is written as yes. bool is an arithmetic type, so with the tests swapped std::to_string(true) would give 1.
- A std::string. T is std::string: not bool, not arithmetic, but convertible to std::string_view, so the third branch puts it in quotes. A string is also a range of chars; the range test is never reached for it.
- A string literal. The parameter is const T&, so "samosa" keeps its array type: T is char[7], six letters and the final zero. A reference to that array converts to std::string_view, so it is quoted like a string.
- A vector of ints. std::vector<int> fails the first three tests and is a range, so the loop runs, calling to_text for each element: to_text<int>, the instantiation of the first step, made once and used three times.
- Vectors of vectors. The nested vector uses three instantiations, one a level: the outer one keeps the range branch, to_text<std::vector<std::string>> keeps it too, and to_text<std::string>, made already for "chai", keeps the string branch.
The vector of vectors of strings uses three instantiations, one per level: both vector levels keep the range branch,
and to_text<std::string> keeps the string branch. Three calls of the program, for int, std::string and
std::vector<int>, show three instantiations that each keep a different branch:
One template, a different branch in each instantiation
Text description of the diagram
The function template to_text<T> has four if constexpr branches, for bool, arithmetic types, string-like types and ranges, and ends with static_assert(false). The compiler instantiates it once per type it is called with.
- to_text<int> keeps the arithmetic branch, which calls std::to_string(value). The string-like branch, the range branch and the static_assert are discarded: they are never compiled for int.
- to_text<std::string> keeps the string-like branch, which puts the text in quotes. The range branch and the static_assert are discarded, even though a std::string is also a range of characters, because the string-like branch comes first.
- to_text<std::vector<int>> keeps the range branch, a loop that calls to_text<int> for each element, which is the first instantiation again.
The order of the branches matters. bool comes before the arithmetic test because bool is an arithmetic type, and
std::to_string(true) would give 1. The string test comes before the range test because a std::string is also a
range of characters.
Checkpoint
Check: One branch kept per instantiation
A plain if compiles every branch
With an ordinary if, both branches are compiled for every T, even though only one can run:
// The same idea with a plain if: both branches are compiled for every T.
#include <string>
#include <type_traits>
template <typename T>
std::string to_text(const T& value) {
if (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else {
return '"' + std::string(value) + '"';
}
}
int main() {
std::string a = to_text(42);
std::string b = to_text(std::string("chai"));
} // to_text with a type it has no rule for: the static_assert(false) in the last branch fires.
#include <string>
#include <type_traits>
struct Point {
int x, y;
};
template <typename T>
std::string to_text(const T& value) {
if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else {
static_assert(false, "to_text: no rule for this type");
}
}
int main() {
std::string a = to_text(42);
std::string b = to_text(Point{1, 2});
} from session import run
# A plain if: count the lines and list the errors only
run("""
clang++ -std=c++23 -c plain_if.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
clang++ -std=c++23 -c plain_if.cpp 2>&1 | grep -E "error:|requested here"
g++-16 -std=c++23 -c plain_if.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
# A type with no rule, in C++23 and in C++17 mode
clang++ -std=c++23 -c unsupported.cpp
g++-16 -std=c++17 -c unsupported.cpp
""") Output
$ clang++ -std=c++23 -c plain_if.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
129 lines, 2 errors
$ clang++ -std=c++23 -c plain_if.cpp 2>&1 | grep -E "error:|requested here"
plain_if.cpp:10:22: error: no matching conversion for functional-style cast from 'const int' to 'std::string' (aka 'basic_string<char>')
plain_if.cpp:15:21: note: in instantiation of function template specialization 'to_text<int>' requested here
plain_if.cpp:8:16: error: no matching function for call to 'to_string'
plain_if.cpp:16:21: note: in instantiation of function template specialization 'to_text<std::string>' requested here
$ g++-16 -std=c++23 -c plain_if.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
230 lines, 3 errors
# A type with no rule, in C++23 and in C++17 mode
$ clang++ -std=c++23 -c unsupported.cpp
unsupported.cpp:14:23: error: static assertion failed: to_text: no rule for this type
14 | static_assert(false, "to_text: no rule for this type");
| ^~~~~
unsupported.cpp:20:21: note: in instantiation of function template specialization 'to_text<Point>' requested here
20 | std::string b = to_text(Point{1, 2});
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++17 -c unsupported.cpp
unsupported.cpp: In instantiation of 'std::string to_text(const T&) [with T = Point; std::string = std::__cxx11::basic_string<char>]':
unsupported.cpp:20:28: required from here
20 | std::string b = to_text(Point{1, 2});
| ~~~~~~~^~~~~~~~~~~~~
unsupported.cpp:14:23: error: static assertion failed: to_text: no rule for this type
14 | static_assert(false, "to_text: no rule for this type");
| ^~~~~
• 'false' evaluates to false
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 if_constexpr_errors_session.py
to_text<int> fails in the string branch and to_text<std::string> in the number branch: two errors from Clang in
129 lines, three from GCC in 230. Step through what each instantiation asks of its two branches, then the safe
ending:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The condition is a constant. to_text(42) instantiates to_text<int>, and is_arithmetic_v<int> is true while compiling. With a plain if, that changes nothing about compiling: both branches are compiled for int.
- The number branch for int. Line 8 calls std::to_string(value) with an int, which is fine. This branch is the one that would run.
- The text branch for int. Line 10 builds std::string(value) from an int, which no constructor accepts: no matching conversion for functional-style cast from 'const int'. An error in a branch that can never run.
- The same for std::string. to_text(std::string("chai")) instantiates to_text<std::string>. Now line 8 is the problem: std::to_string has no overload for a string, no matching function for call to 'to_string'.
- Two errors, neither can run. Clang reported the two errors in 129 lines, GCC 16 three errors in 230. With if constexpr, each instantiation discards the branch that does not fit, and to_text.cpp compiles.
- The safe ending. unsupported.cpp ends its if constexpr chain with static_assert(false, "to_text: no rule for this type"). It fires only for a type that reaches that branch: Point, with your message, at line 14.
- In C++17 mode too. Paper P2593R1 allowed this as a fix to earlier standards, so GCC 16 with -std=c++17 also reported it only for Point. The int call keeps the other branch and compiles.
The second file shows that safe ending for an if constexpr chain. Its last branch holds
static_assert(false, "…"), which fires only when a type reaches it, here Point, with your own message.
static_assert(false) in a discarded branch
Older compilers rejected static_assert(false) in a template even in a branch that was never taken, and code used a
helper such as always_false<T> instead. Paper P2593R1 changed the rule as a fix to earlier standards (core issue
2518): Clang accepts it from Clang 17, and in the run above GCC 16 with -std=c++17 reports it only for Point,
the one type that reaches it.
Discarding happens only inside a template, where the condition depends on a template parameter. In an ordinary
function, if constexpr (sizeof(int) == 2) never runs its branch, but the branch is still checked like any other
code, so a call to a function that was never declared is an error there.
Checkpoint
Check: A plain if compiles every branch
SFINAE quietly drops a template whose declaration fails
Before C++20 there was no way to write a constraint, so libraries used a rule of template argument deduction instead. When the compiler substitutes the deduced types into a function template’s declaration and the result is an invalid type or expression, deduction fails and the template quietly leaves the set of candidates. The standard limits this to the “immediate context”, the declaration itself: an error inside the body is still an error. That rule is SFINAE, “substitution failure is not an error”.
std::enable_if_t<Condition, T> names the type T when Condition is true and names nothing at all when it is
false. Put it in a declaration and the declaration becomes invalid exactly when the condition fails:
gcd_enable_if.cpp
// C++11 to C++17: enable_if removes the template from overload resolution unless T is an integer type.
#include <type_traits>
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
T gcd_of(T a, T b) {
while (b != 0) {
T r = a % b;
a = b;
b = r;
}
return a;
}
int main() {
int g = gcd_of(84, 36);
double h = gcd_of(8.4, 3.6);
return g + static_cast<int>(h);
} gcd_concept.cpp
// C++20: the same rule as a concept.
#include <concepts>
template <std::integral T>
T gcd_of(T a, T b) {
while (b != 0) {
T r = a % b;
a = b;
b = r;
}
return a;
}
int main() {
int g = gcd_of(84, 36);
double h = gcd_of(8.4, 3.6);
return g + static_cast<int>(h);
} from session import run
run("""
clang++ -std=c++23 -c gcd_enable_if.cpp
clang++ -std=c++23 -c gcd_concept.cpp
g++-16 -std=c++23 -c gcd_enable_if.cpp
g++-16 -std=c++23 -c gcd_concept.cpp
""") Output
$ clang++ -std=c++23 -c gcd_enable_if.cpp
gcd_enable_if.cpp:16:16: error: no matching function for call to 'gcd_of'
16 | double h = gcd_of(8.4, 3.6);
| ^~~~~~
gcd_enable_if.cpp:5:3: note: candidate template ignored: requirement 'std::is_integral_v<double>' was not satisfied [with T = double]
5 | T gcd_of(T a, T b) {
| ^
1 error generated.
[exit status 1]
$ clang++ -std=c++23 -c gcd_concept.cpp
gcd_concept.cpp:16:16: error: no matching function for call to 'gcd_of'
16 | double h = gcd_of(8.4, 3.6);
| ^~~~~~
gcd_concept.cpp:5:3: note: candidate template ignored: constraints not satisfied [with T = double]
5 | T gcd_of(T a, T b) {
| ^
gcd_concept.cpp:4:11: note: because 'double' does not satisfy 'integral'
4 | template <std::integral T>
| ^
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__concepts/arithmetic.h:28:20: note: because 'is_integral_v<double>' evaluated to false
28 | concept integral = is_integral_v<_Tp>;
| ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -c gcd_enable_if.cpp
gcd_enable_if.cpp: In function 'int main()':
gcd_enable_if.cpp:16:22: error: no matching function for call to 'gcd_of(double, double)'
16 | double h = gcd_of(8.4, 3.6);
| ~~~~~~^~~~~~~~~~
• there is 1 candidate
• candidate 1: 'template<class T, typename std::enable_if<is_integral_v<T>, int>::type <anonymous> > T gcd_of(T, T)'
gcd_enable_if.cpp:5:3:
5 | T gcd_of(T a, T b) {
| ^~~~~~
• template argument deduction/substitution failed:
• error: no type named 'type' in 'struct std::enable_if<false, int>'
gcd_enable_if.cpp:4:70:
4 | template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
| ^
[exit status 1]
$ g++-16 -std=c++23 -c gcd_concept.cpp
gcd_concept.cpp: In function 'int main()':
gcd_concept.cpp:16:22: error: no matching function for call to 'gcd_of(double, double)'
16 | double h = gcd_of(8.4, 3.6);
| ~~~~~~^~~~~~~~~~
• there is 1 candidate
• candidate 1: 'template<class T> requires integral<T> T gcd_of(T, T)'
gcd_concept.cpp:5:3:
5 | T gcd_of(T a, T b) {
| ^~~~~~
• template argument deduction/substitution failed:
• constraints not satisfied
In file included from gcd_concept.cpp:2:
• /opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/concepts: In substitution of 'template<class T> requires integral<T> T gcd_of(T, T) [with T = double]':
• required from here
gcd_concept.cpp:16:22:
16 | double h = gcd_of(8.4, 3.6);
| ~~~~~~^~~~~~~~~~
• required for the satisfaction of 'integral<T>' [with T = double]
/opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/concepts:109:13:
109 | concept integral = is_integral_v<_Tp>;
| ^~~~~~~~
• the expression 'is_integral_v<_Tp> [with _Tp = double]' evaluated to 'false'
/opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/concepts:109:24:
109 | concept integral = is_integral_v<_Tp>;
| ^~~~~~~~~~~~~~~~~~
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gcd_session.py
Both versions reject gcd_of(8.4, 3.6) at the call. Step through what the compiler did with the enable_if
version, from the call to the candidate that disappears:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A call with doubles. Line 16 calls gcd_of(8.4, 3.6). The only gcd_of is a template whose second template parameter is std::enable_if_t<std::is_integral_v<T>, int> = 0.
- T is double. Both arguments are doubles, so deduction gives T = double, with no conflict.
- The declaration with double. Before a candidate can be compared with others, the compiler puts double into its declaration. std::is_integral_v<double> is false, so the second parameter becomes std::enable_if_t<false, int>.
- A type that does not exist. std::enable_if<false, int> has no member named type, so std::enable_if_t<false, int> names nothing, and the declaration is invalid for double.
- Dropped, not rejected. An invalid declaration during substitution is not an error: the template quietly leaves the candidates. That is SFINAE. An error inside the body would still be an error, but the body is never reached.
- The error at the call. No candidate is left, so the call fails: no matching function for call to 'gcd_of'. Clang says the requirement 'std::is_integral_v<double>' was not satisfied; GCC 16 names the mechanism, no type named 'type' in std::enable_if<false, int>.
Clang recognises the enable_if pattern and reports requirement 'std::is_integral_v<double>' was not satisfied,
which is as clear as its concept message. GCC 16 reports the mechanism instead of the reason: no type named 'type' in 'struct std::enable_if<false, int>'. For the concept, both compilers name integral and the trait inside it. The
concept also reads better in the declaration and takes part in subsumption; enable_if conditions never make one
overload more specialised than another.
enable_if hides in four places, and you should recognise all of them:
- the return type, as
std::enable_if_t<cond, T> f(T); - a default template argument,
typename = std::enable_if_t<cond>; - a non-type template parameter,
std::enable_if_t<cond, int> = 0, as ingcd_enable_if.cpp; - an extra function parameter with a default value.
Checkpoint
Check: SFINAE with enable_if
Two templates that differ only in a default argument clash
The default-argument form has a trap. Default template arguments are not part of a template’s signature, so two templates that differ only in them are the same template declared twice:
redecl.cpp
// Two "overloads" that differ only in a default template argument declare the same template twice.
#include <string>
#include <type_traits>
template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
std::string kind(T) { return "integer"; }
template <typename T, typename = std::enable_if_t<std::is_floating_point_v<T>>>
std::string kind(T) { return "floating point"; }
int main() {
return static_cast<int>(kind(1).size() + kind(1.0).size());
} redecl_fixed.cpp
// The C++11 to C++17 fix: put enable_if in the type of a non-type parameter, which is part of the signature.
#include <print>
#include <string>
#include <type_traits>
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
std::string kind(T) { return "integer"; }
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
std::string kind(T) { return "floating point"; }
int main() {
std::println("{} and {}", kind(1), kind(1.0));
} from session import run
run("""
clang++ -std=c++23 -c redecl.cpp 2>&1 | grep -E "error:"
g++-16 -std=c++23 -c redecl.cpp 2>&1 | grep -E "error:"
clang++ -std=c++23 -Wall -Wextra redecl_fixed.cpp -o redecl_fixed && ./redecl_fixed
g++-16 -std=c++23 -Wall -Wextra redecl_fixed.cpp -o redecl_fixed-gcc && ./redecl_fixed-gcc
""") Output
$ clang++ -std=c++23 -c redecl.cpp 2>&1 | grep -E "error:"
redecl.cpp:8:34: error: template parameter redefines default argument
redecl.cpp:9:13: error: redefinition of 'kind'
redecl.cpp:12:29: error: no matching function for call to 'kind'
redecl.cpp:12:46: error: no matching function for call to 'kind'
$ g++-16 -std=c++23 -c redecl.cpp 2>&1 | grep -E "error:"
redecl.cpp:9:13: error: redefinition of 'template<class T, class> std::string kind(T)'
redecl.cpp:12:50: error: no matching function for call to 'kind(double)'
• error: no type named 'type' in 'struct std::enable_if<false, void>'
$ clang++ -std=c++23 -Wall -Wextra redecl_fixed.cpp -o redecl_fixed && ./redecl_fixed
integer and floating point
$ g++-16 -std=c++23 -Wall -Wextra redecl_fixed.cpp -o redecl_fixed-gcc && ./redecl_fixed-gcc
integer and floating point
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 redecl_session.py
Both compilers report a redefinition of kind, and Clang adds template parameter redefines default argument: it
reads the second declaration as the first template again, with a second default for a parameter that already has one.
The two conditions are never compared, because by then there is only one template. Step through what the compiler
compares, before and after the fix:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first kind. redecl.cpp declares template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>: a second, unnamed type parameter whose default holds the condition.
- The second kind. The second kind has the same shape, with std::is_floating_point_v<T> in the default. To a reader the two say different things.
- Defaults are not compared. Default template arguments are not part of a template's signature. Both read template <typename T, typename> std::string kind(T): the same template, defined twice. Both compilers report a redefinition of kind.
- The condition moves into a type. redecl_fixed.cpp writes the condition as the type of a non-type parameter: std::enable_if_t<std::is_integral_v<T>, int> = 0. The type of a template parameter is part of the signature.
- A different type each. The second kind uses std::enable_if_t<std::is_floating_point_v<T>, int>, a different type, so the two are different templates, and both may exist.
- Each call keeps one. For kind(1), substituting int into the floating version gives std::enable_if_t<false, int>, so SFINAE drops it; kind(1.0) drops the other. The program printed integer and floating point on both compilers.
The pre-C++20 fix moves the condition into the type of a non-type parameter, std::enable_if_t<cond, int> = 0,
which is part of the signature, so the two templates differ. In C++20, kind(std::integral auto) and
kind(std::floating_point auto) say it without either trick.
Checkpoint
Check: Overloads that redefine each other
The detection idiom asks whether an expression compiles
The other common idiom answers “does this type have a size() member?”. std::void_t<…> is void for any list of
valid types (paper N3911), so a partial specialisation: A definition of a class or variable template for a family of argument lists, written as a pattern that keeps parameters to deduce, such as template <typename T> struct TypeName<T*>, which matches every pointer type. More in “C++ template specialisation and overloading” that names an
expression inside it matches only when the expression is valid. This is the
detection idiom: The C++17 way to ask whether an expression compiles for a type: a primary template that answers false, and a partial specialisation with std::void_t<decltype(expression)> that matches, and answers true, only when the expression is valid.:
// Does T have a size() member? Once with the C++17 detection idiom, once with a C++20 concept.
#include <list>
#include <print>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
// The partial specialisation is chosen only when the expression inside void_t is valid for T.
template <typename T, typename = void>
struct has_size : std::false_type {};
template <typename T>
struct has_size<T, std::void_t<decltype(std::declval<const T&>().size())>> : std::true_type {};
// The same question as a concept.
template <typename T>
concept HasSize = requires(const T& t) { t.size(); };
struct Bag {
int size() const { return 3; }
};
struct Box {};
template <typename T>
void row(const char* name) {
std::println("{:<18} has_size={:<5} HasSize={}", name, has_size<T>::value, HasSize<T>);
}
int main() {
row<std::vector<int>>("std::vector<int>");
row<std::list<int>>("std::list<int>");
row<std::string>("std::string");
row<int[4]>("int[4]");
row<Bag>("Bag");
row<Box>("Box");
} from session import run
run("""
clang++ -std=c++23 -Wall -Wextra detect.cpp -o detect && ./detect
g++-16 -std=c++23 -Wall -Wextra detect.cpp -o detect-gcc && ./detect-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra detect.cpp -o detect && ./detect std::vector<int> has_size=true HasSize=true std::list<int> has_size=true HasSize=true std::string has_size=true HasSize=true int[4] has_size=false HasSize=false Bag has_size=true HasSize=true Box has_size=false HasSize=false $ g++-16 -std=c++23 -Wall -Wextra detect.cpp -o detect-gcc && ./detect-gcc std::vector<int> has_size=true HasSize=true std::list<int> has_size=true HasSize=true std::string has_size=true HasSize=true int[4] has_size=false HasSize=false Bag has_size=true HasSize=true Box has_size=false HasSize=false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 detect_session.py
has_size<T> starts from the primary template: The general definition of a template, such as template <typename T> struct TypeName, that its specialisations refine. It is used for every argument list that no full or partial specialisation matches. More in “C++ template specialisation and overloading”, std::false_type, whose second
parameter defaults to void. For has_size<Bag> the compiler also tries the specialisation; substituting Bag into
decltype(std::declval<const Bag&>().size()) succeeds, void_t of it is void, the same as the default, so the
specialisation matches and the answer is true. Step through Bag, Box and int[4], then the three standard
containers:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The default is void. has_size<Bag> names only one argument, so the second takes its default: has_size<Bag, void>. The compiler now looks for a partial specialisation that matches those two arguments.
- Bag has size(). The specialisation matches when void_t<decltype(…size())> is void. For Bag, std::declval<const Bag&>().size() is a valid int expression, so void_t of it is void: a match.
- true for Bag. The specialisation derives from std::true_type, so has_size<Bag>::value is true, as the program printed.
- Box has no size(). For Box the expression is invalid. That failure happens while substituting into the specialisation, so it is not an error: SFINAE discards the specialisation, and the primary template, std::false_type, answers false.
- An array of ints. int[4] has no member functions at all, so .size() is invalid for it too, and has_size<int[4]> is false. std::size(array) would work, but that is a different expression.
- The standard containers. std::vector<int>, std::list<int> and std::string all have size(), so the specialisation matches and all three print has_size=true. The concept HasSize gave the same six answers in one line.
For Box the substitution fails, SFINAE discards the specialisation and the primary template answers false.
std::declval<const T&>() produces an expression of type const T& without creating an object, which is allowed only
in unevaluated places such as decltype. The concept HasSize asks the same question in one line, and the two agree
for all six types.
Checkpoint
Check: Detecting members with void_t
Every SFINAE idiom has a concept that says it directly
Old code mixes the spellings of several standards, and each one dates the code that uses it: ::type and ::value
need only C++11, a _t alias needs C++14, and a _v variable or std::void_t needs C++17. Knowing which standard an
idiom belongs to tells you what a library still supports, and which concept can replace it. Walk through where each
idiom of this lesson comes from:
- C++11
enable_if in the standard library
Paper N2240 proposed std::enable_if and std::conditional for the library of the standard that became C++11. enable_if<cond, T> has a member type only when cond is true, which is what SFINAE in a declaration needs. The spelling of the time is typename std::enable_if<std::is_integral<T>::value, int>::type.
- C++14
_t aliases for the transformations
Paper N3655 added alias templates such as std::remove_cv_t and std::enable_if_t, so typename std::remove_cv<T>::type became std::remove_cv_t<T>, the form gcd_enable_if.cpp and redecl.cpp use.
- C++17
_v answers, void_t and if constexpr new syntax
Paper P0006R0 added variable templates such as std::is_integral_v<T>; N3911 added std::void_t, the base of has_size in detect.cpp; and P0292R2 gave the language if constexpr, which to_text.cpp uses.
- C++20
Concepts say it directly new syntax
Paper P0734R0 brought concepts, requires-clauses and requires-expressions into the language and P0898R3 the standard concepts, so gcd_concept.cpp writes template <std::integral T> where gcd_enable_if.cpp needed enable_if.
- Defect report
static_assert(false) in a template
Paper P2593R1, a fix to earlier standards (core issue 2518), lets static_assert(false) wait until a branch is instantiated. Clang accepts it from Clang 17, and GCC 16 in -std=c++17 mode reported it only for Point. Older code used a helper such as always_false<T> instead.
The timeline has 5 events, from C++11 to Defect report.
| You read | You can write |
|---|---|
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0> |
template <std::integral T> |
std::enable_if_t<cond, R> as a return type |
R with a requires-clause |
has_size<T>::value, built with std::void_t |
requires (const T& t) { t.size(); } |
tag dispatch on std::true_type and std::false_type |
if constexpr, or overloads on concepts |
static_assert(always_false<T>) |
static_assert(false) |
Rewrite one overload set at a time and keep its behaviour. A condition that removed a template in SFINAE removes it as
a constraint too, but two things change: overlapping concepts now order the overloads by subsumption, where
enable_if needed mutually exclusive conditions, and error messages name the concept. A library that must still build
as C++17 keeps its enable_if; it can test __cpp_concepts and offer both.
Checkpoint
Check: Rewriting SFINAE as concepts
Practise: a describe function for any type
describe.h declares describe<T>(), which should return a short description of T such as "unsigned integer, 4 bytes". Its comment lists seven cases in the order to test them, and bytes(n) writes “1 byte” or “4 bytes” for
you:
C header · describe.h
// Starter code for "Your turn": write describe<T>().
#pragma once
#include <cstddef>
#include <string>
#include <type_traits>
// "1 byte" or "4 bytes": the size part of every description.
inline std::string bytes(std::size_t n) {
return std::to_string(n) + (n == 1 ? " byte" : " bytes");
}
// A short description of T, such as "unsigned integer, 4 bytes". The cases, in the order to test them:
// bool "bool, 1 byte"
// signed or unsigned integer types "signed integer, 4 bytes", "unsigned integer, 2 bytes"
// floating-point types "floating point, 8 bytes"
// pointers "pointer, 8 bytes"
// enumerations "enum, 1 byte"
// classes "class, 24 bytes"
// anything else "other, 16 bytes"
// References and const describe the type they refer to: describe<const int&>() is "signed integer, 4 bytes".
template <typename T>
std::string describe() {
// TODO: one if constexpr branch per case, on std::remove_cvref_t<T>.
return "other, " + bytes(sizeof(T));
} C++ · describe_tests.cpp
// Sample tests for describe.h. Build: clang++ -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests
#include <cstdint>
#include <print>
#include <string>
#include "describe.h"
enum class Colour : std::uint8_t { red, green, blue };
struct Pixel {
std::uint8_t r, g, b;
};
int checks = 0;
int failures = 0;
void check(const std::string& got, const std::string& want) {
++checks;
if (got != want) {
++failures;
std::println("FAIL got \"{}\", want \"{}\"", got, want);
}
}
int main() {
check(describe<int>(), "signed integer, 4 bytes");
check(describe<unsigned short>(), "unsigned integer, 2 bytes");
check(describe<signed char>(), "signed integer, 1 byte");
check(describe<bool>(), "bool, 1 byte");
check(describe<double>(), "floating point, 8 bytes");
check(describe<float>(), "floating point, 4 bytes");
check(describe<const long long&>(), "signed integer, 8 bytes");
check(describe<int*>(), "pointer, " + bytes(sizeof(int*)));
check(describe<Colour>(), "enum, 1 byte");
check(describe<Pixel>(), "class, 3 bytes");
check(describe<std::string>(), "class, " + bytes(sizeof(std::string)));
std::println("{} of {} checks passed", checks - failures, checks);
return failures == 0 ? 0 : 1;
} from session import run
# The sample tests, built against the starter code before the TODO is done.
run("""
clang++ -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests && ./describe_tests
g++-16 -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests-gcc && ./describe_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests && ./describe_tests FAIL got "other, 4 bytes", want "signed integer, 4 bytes" FAIL got "other, 2 bytes", want "unsigned integer, 2 bytes" FAIL got "other, 1 byte", want "signed integer, 1 byte" FAIL got "other, 1 byte", want "bool, 1 byte" FAIL got "other, 8 bytes", want "floating point, 8 bytes" FAIL got "other, 4 bytes", want "floating point, 4 bytes" FAIL got "other, 8 bytes", want "signed integer, 8 bytes" FAIL got "other, 8 bytes", want "pointer, 8 bytes" FAIL got "other, 1 byte", want "enum, 1 byte" FAIL got "other, 3 bytes", want "class, 3 bytes" FAIL got "other, 24 bytes", want "class, 24 bytes" 0 of 11 checks passed [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests-gcc && ./describe_tests-gcc FAIL got "other, 4 bytes", want "signed integer, 4 bytes" FAIL got "other, 2 bytes", want "unsigned integer, 2 bytes" FAIL got "other, 1 byte", want "signed integer, 1 byte" FAIL got "other, 1 byte", want "bool, 1 byte" FAIL got "other, 8 bytes", want "floating point, 8 bytes" FAIL got "other, 4 bytes", want "floating point, 4 bytes" FAIL got "other, 8 bytes", want "signed integer, 8 bytes" FAIL got "other, 8 bytes", want "pointer, 8 bytes" FAIL got "other, 1 byte", want "enum, 1 byte" FAIL got "other, 3 bytes", want "class, 3 bytes" FAIL got "other, 32 bytes", want "class, 32 bytes" 0 of 11 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 describe_session.py
Every check fails at first, because the starter answers “other” for everything. Write one if constexpr branch per
case, on std::remove_cvref_t<T>, so that const long long& describes a long long. Mind the order the traits table
taught: bool before the integers, and floating point before any test of is_signed_v. The last check compares
std::string with its own sizeof, which differs between the two standard libraries: 24 bytes in libc++ and 32 in
libstdc++, as the starter’s output shows. When all is done, both compilers print 11 of 11 checks passed. Then ask
for describe<char>() and predict what it prints with -funsigned-char.
Then test what you can predict without a compiler: a trait’s answer, the branch a type keeps, the template SFINAE drops and the C++20 spelling of an old idiom. Practice opens with the concept you have answered least well, and each choice carries the reason it fits or fails here:
Practice that adapts to you
Practise this lesson
13 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does if constexpr do that a plain if does not?
Its condition must be a constant expression, and in a template the branch that is not taken is discarded: it is not
instantiated for that type, so it may contain code that would not compile for it. With a plain if, both branches are
compiled for every type, even if only one can run, so if (std::is_arithmetic_v<T>) return std::to_string(value);
fails for a std::string argument. if constexpr lets one function template hold a separate body for numbers,
strings and containers. Two details make a good answer complete. Discarding happens only inside a template, where the
condition depends on a template parameter; in ordinary code the skipped branch is still fully checked. And a final
else with static_assert(false, "…") turns an unsupported type into a clear error, which current compilers accept
even in C++17 mode.
The usual follow-ups probe where if constexpr can choose and where it cannot. Decide these two:
Checkpoint
Interview round: two follow-up questions
Two harder questions take traits into code you maintain: two enable_if overloads that refuse to build, and a type dispatch that writes the wrong values in production:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A C++17 library defines two serialize(T) overloads: one with typename = std::enable_if_t<std::is_integral_v<T>>, one with the same form for floating-point types. The build fails with "redefinition of serialize". Explain the cause, give the C++17 fix, and say what C++20 changes.
-
Fresher to mid
A to_json(value) template tests std::is_integral_v<T>, then std::is_floating_point_v<T>, then strings, with if constexpr. In production, true is written as 1, char fields appear as numbers, and a const int& member reaches the static_assert(false) at the end. Explain each bug and fix the chain.
Key takeaways
<type_traits>answers questions about types at compile time, in categories, properties, relations and transformations read through_vand_t, and references are not integral whiledoubleis signed.- Plain
charis signed on this Mac and unsigned on Arm Linux, so code that needs a range namessigned char,unsigned charorstd::uint8_t. if constexprkeeps the first true branch of each instantiation and discards the rest, so the order of its tests decides whatbooland strings become.- A plain
ifcompiles every branch for every type, and a finalstatic_assert(false, "…")is the safe ending of anif constexprchain. - SFINAE drops a template when substitution makes its declaration invalid, as
enable_ifdoes forgcd_of(8.4, 3.6), and templates that differ only in default template arguments clash. - The detection idiom with
std::void_tasks whether an expression compiles, and concepts say every one of these idioms more readably, with clearer errors and ordered overloads.
Terms in this lesson
- detection idiom
- The C++17 way to ask whether an expression compiles for a type: a primary template that answers false, and a partial specialisation with std::void_t<decltype(expression)> that matches, and answers true, only when the expression is valid.
- discarded statement
- The branch an if constexpr does not take. Inside a template it is not instantiated for that template argument, so it may hold code that would not compile for the current type; in ordinary code it is still checked.
- SFINAE
- Substitution failure is not an error: when putting deduced template arguments into a function template's declaration gives an invalid type or expression, the template quietly leaves the set of candidates. An error inside the body is still an error.
- type trait
- A class template of <type_traits> that the compiler answers about a type, such as std::is_integral<T>, read through its _v variable template, or that produces a new type, such as std::remove_cvref_t<T>. Every answer is known while compiling.
References
- Working Draft, Programming Languages: C++ (N4950), 8.5.2 The if statement, 13.10.3.1 Template argument deduction, 21.3 Metaprogramming and type traits (ISO/IEC JTC1/SC22/WG21)
- N2240: Two missing traits: enable_if and conditional (ISO/IEC JTC1/SC22/WG21)
- P0292R2: constexpr if: A slightly different syntax (ISO/IEC JTC1/SC22/WG21)
- P2593R1: Allowing static_assert(false) (ISO/IEC JTC1/SC22/WG21)
- N3911: TransformationTrait Alias void_t (ISO/IEC JTC1/SC22/WG21)
- N3655: TransformationTraits Redux, v2 (ISO/IEC JTC1/SC22/WG21)
- P0006R0: Adopt Type Traits Variable Templates from Library Fundamentals TS for C++17 (ISO/IEC JTC1/SC22/WG21)
- P0734R0: Wording Paper, C++ extensions for Concepts (ISO/IEC JTC1/SC22/WG21)
- P0898R3: Standard Library Concepts (ISO/IEC JTC1/SC22/WG21)
- Writing ARM64 code for Apple platforms (Apple)
- Procedure Call Standard for the Arm 64-bit Architecture (AArch64) (Arm)
- Clang 17.0.1 Release Notes (CWG2518, static_assert(false)) (LLVM Project)
- Options Controlling C Dialect (-funsigned-char) (GNU Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress