5.2.17 · D4C++ Programming

Exercises — SFINAE — substitution failure is not an error

2,408 words11 min readBack to topic

A tiny reminder of the vocabulary you'll need, so no symbol is unearned:

Related tools you may reach for: Templates and Type Deduction, std::enable_if and Tag Dispatch, decltype and Trailing Return Types, Type Traits (std::is_integral, std::void_t), void_t Detection Idiom, Overload Resolution, Concepts (C++20).


Level 1 — Recognition

L1.1 — Signature or body?

For each snippet, say whether the failure is SFINAE (silently removes the candidate) or a HARD error (stops compilation).

// (a)
template <typename T>
typename T::value_type a(const T&);      // called with T = int
 
// (b)
template <typename T>
void b(const T&) { typename T::value_type x; }   // called with T = int
Recall Solution
  • (a) The illegal thing int::value_type is in the return type = the signature. → SFINAE. Candidate a is silently dropped. If it was the only candidate you get "no matching function".
  • (b) The signature void b(const T&) is perfectly valid for T = int. The illegal int::value_type is inside the body. The body is not the immediate context → HARD error. One-line rule: broken header = toss the résumé; broken inside = interview crashes.

L1.2 — What is ::type?

State the value (or non-existence) of: std::enable_if<true, double>::type and std::enable_if<false, double>::type.

Recall Solution
  • enable_if<true, double>::type = double (the true partial specialization defines using type = T;).
  • enable_if<false, double>::type = does not exist. The primary template has no ::type, so naming it is a substitution failure → drops the overload.

Level 2 — Application

L2.1 — Predict the output

#include <cstdio>
template <typename T>
auto f(const T& t) -> decltype(t.size(), int{}) { return 1; }  // A
template <typename T>
int f(...) { return 2; }                                       // B
 
int main() {
    std::printf("%d %d\n", f(std::string("hi")), f(42));
}

What does it print, and why each number?

Recall Solution

Prints 1 2.

  • f(std::string("hi")): std::string has .size(), so decltype(t.size(), int{}) is valid → A survives and is a better match than the variadic ... of B → A wins → 1.
  • f(42): int has no .size(), so A's return type is a substitution failure → A is dropped → only B (variadic, worst match, always valid) remains → B wins → 2. The comma operator inside decltype: t.size() is evaluated only for validity, then discarded; the whole decltype yields int{}'s type, int.

L2.2 — Fix the overlap

template <typename T>
std::enable_if_t<std::is_integral_v<T>, void> g(T);           // A
template <typename T>
std::enable_if_t<sizeof(T) >= 1, void> g(T);                  // B

Call g(5). What goes wrong and how do you fix it?

Recall Solution

For T = int: condition A is true (int is integral) and condition B is sizeof(int) >= 1 is also true. Both overloads survive → ambiguous call → hard error, not "compiler picks one." Fix: make the conditions a clean partition. Replace B's condition with the negation of A:

template <typename T>
std::enable_if_t<!std::is_integral_v<T>, void> g(T);   // B fixed

Now exactly one of A / B is viable for every T.


Level 3 — Analysis

L3.1 — Why does return-type enable_if on twins break?

template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void h(T);
template <typename T, typename = std::enable_if_t<!std::is_integral_v<T>>>
void h(T);

These two look different (opposite conditions). But the compiler may reject them with "redefinition / cannot be overloaded." Explain why, and give the safe idiom.

Recall Solution

Why it breaks: a default template argument is not part of the function's signature for the purpose of distinguishing overloads. Both templates reduce to the same signature template<class T, class> void h(T). Two templates with identical signatures = redeclaration of the same template → error, before SFINAE ever runs. Safe idiom — put the enable_if in a non-type template parameter that participates in substitution:

template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
void h(T);
template <typename T, std::enable_if_t<!std::is_integral_v<T>, int> = 0>
void h(T);

Here std::enable_if_t<cond,int> is the type of a defaulted non-type parameter. When cond is false, that type doesn't exist → SFINAE drops the overload cleanly, and the two remain distinct.

L3.2 — Trace the drop

For getSize from the parent note:

template <typename T>
auto getSize(const T& t) -> decltype(t.size(), size_t{}) { return t.size(); }  // A
template <typename T> size_t getSize(...) { return 0; }                        // B

List the exact substitution steps for getSize(3.14) (a double).

Recall Solution
  1. Deduce T = double from const T& t matching the double argument.
  2. Substitute into A's return type: decltype( (3.14).size(), size_t{} ). The sub-expression t.size() requires double::size()double has no member sizesubstitution failure in the immediate context (return type).
  3. SFINAE: A is silently removed from the overload set.
  4. B has parameter ... (always viable, worst rank) and no constraint → B is the only survivor → chosen.
  5. getSize(3.14) returns 0. No hard error, no message about "double has no size."

Level 4 — Synthesis

L4.1 — Build a has_begin detector with void_t

Using the void_t Detection Idiom, write a trait has_begin<T> that is true when T has a member begin() and false otherwise. Then explain each moving part.

Recall Solution
#include <type_traits>
// Primary: assume NO begin. Second param defaulted to void.
template <typename T, typename = void>
struct has_begin : std::false_type {};
 
// Specialization: only viable when decltype(...) is a valid type -> void.
template <typename T>
struct has_begin<T, std::void_t<decltype(std::declval<T&>().begin())>>
    : std::true_type {};

Moving parts:

  • std::void_t<...> maps any valid type list to void. Its whole job is to be a "did this expression compile?" switch.
  • decltype(std::declval<T&>().begin()): declval<T&>() conjures a T& value without constructing one (we only need its type in the unevaluated decltype). If T has no begin, this is a substitution failure — but it's in the specialization's template argument, so SFINAE just makes the specialization non-viable.
  • When .begin() is valid → the second argument is void, which matches the primary's default = void, so the specialization (more specialized) is chosen → inherits true_type.
  • When .begin() is invalid → specialization drops out → primary (false_type) is used. Usage: has_begin<std::vector<int>>::value is true; has_begin<int>::value is false.

L4.2 — Same idea with a Concept (C++20)

Rewrite the L4.1 detector's intent using a concept and constrain a function.

Recall Solution
#include <concepts>
template <typename T>
concept HasBegin = requires (T& t) { t.begin(); };   // does t.begin() compile?
 
template <HasBegin T> void use(T& t) { /* iterate */ }
template <typename T> void use(T&)   { /* fallback */ }

The requires(T& t){ t.begin(); } block is exactly the SFINAE test made readable: it is true iff t.begin() is a well-formed expression. Concepts do the same removal from the overload set that SFINAE does — but the constraint is named and the error messages point at HasBegin instead of a cryptic no-match.


Level 5 — Mastery

L5.1 — Three-way dispatch with a clean partition

Write three overloads of describe(T):

  • integral types → returns 1
  • floating-point types → returns 2
  • everything else → returns 3

using enable_if, guaranteeing no ambiguity for any T. Then predict describe(7), describe(2.0), describe("hi").

Recall Solution
#include <type_traits>
template <typename T,
  std::enable_if_t<std::is_integral_v<T>, int> = 0>
int describe(T) { return 1; }
 
template <typename T,
  std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
int describe(T) { return 2; }
 
template <typename T,
  std::enable_if_t<!std::is_integral_v<T> && !std::is_floating_point_v<T>, int> = 0>
int describe(T) { return 3; }

The three conditions are a partition: integral, floating, and "neither." For any T exactly one is true, so exactly one overload survives — no ambiguity.

  • describe(7)7 is int (integral) → 1.
  • describe(2.0)2.0 is double (floating) → 2.
  • describe("hi") → the literal "hi" is const char[3], neither integral nor floating → 3. (See the partition diagram below — every type falls in exactly one region.)
Figure — SFINAE — substitution failure is not an error

L5.2 — Debug the crash

A student writes a "detector" that always hard-crashes:

template <typename T>
struct is_addable {
    static constexpr bool value = requires { std::declval<T>() + std::declval<T>(); };
    T probe = std::declval<T>() + std::declval<T>();   // <-- line X
};

Identify the exact line that makes it a hard error even for T = int, and fix the detector.

Recall Solution

Line X is the bug: T probe = std::declval<T>() + std::declval<T>(); is an evaluated member initializer inside the struct body. Two problems:

  1. std::declval<T>() may only appear in unevaluated contexts (decltype, sizeof, requires, noexcept) — using it in a real initializer is ill-formed for every T, including int.
  2. Even if it compiled, the initializer is in the body, so a non-addable T would give a hard error, not a clean false. Fix — keep the probe unevaluated, drop the member:
template <typename T>
struct is_addable {
    static constexpr bool value =
        requires (T a, T b) { a + b; };   // pure compile-time test, no body probe
};

Now is_addable<int>::value == true, is_addable<std::mutex>::value == false, and no hard error either way.


Recall One-line summaries to self-quiz

Where must the failure live for SFINAE? ::: In the signature (immediate context) — never the body. Two equally-viable SFINAE overloads → ? ::: Ambiguous call = hard error. How do you make twin overloads distinct? ::: std::enable_if_t<cond,int> = 0 as a non-type param, not a default. What does std::void_t<decltype(expr)> test? ::: Whether expr is a well-formed type/expression. Where may std::declval<T>() appear? ::: Only in unevaluated contexts (decltype/sizeof/noexcept/requires).


Connections

  • Parent: SFINAE
  • Templates and Type Deduction
  • std::enable_if and Tag Dispatch
  • decltype and Trailing Return Types
  • Type Traits (std::is_integral, std::void_t)
  • void_t Detection Idiom
  • Overload Resolution
  • Concepts (C++20)