Template metaprogramming and SFINAE

C++ · Expert · 20 min

Library code and older codebases are full of these techniques, and they explain how the standard library makes compile-time decisions.

Template metaprogramming uses templates to compute with types and constants at compile time. The standard toolkit lives in <type_traits>: predicates like std::is_integral_v<T>, std::is_trivially_copyable_v<T> and std::is_base_of_v<B, D>; transformations like std::remove_cvref_t<T>, std::decay_t<T> and std::conditional_t<cond, A, B>; and std::integral_constant / std::bool_constant as the base for writing your own traits.

SFINAE, "substitution failure is not an error", is the rule that makes the old techniques work. While the compiler substitutes template arguments into a candidate's signature, an invalid type or expression just removes that candidate from overload resolution instead of causing an error. Errors inside the function body are still hard errors.

  • std::enable_if_t<cond, int> = 0 as a template parameter enables an overload only when cond holds.
  • The detection idiom uses partial specialisation with std::void_t<decltype(expr)> to ask "does this expression compile?": if it does, the specialisation that inherits from std::true_type is chosen.
  • decltype and std::declval<T>() let you form expressions on types without creating objects.

C++17 if constexpr removed much of the need for overload tricks: the discarded branch isn't instantiated, so it may contain code that wouldn't compile for that T. Fold expressions replaced most recursive variadic templates.

C++20 concepts replace the rest. template <std::integral T> says what enable_if used to say, with better errors and real subsumption rules, and a requires expression is the detection idiom built into the language. Write new code with concepts and if constexpr; learn SFINAE to read and maintain existing libraries, and for the rare cases where you still need a trait class.

Example

#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

// SFINAE: each overload exists only if its enable_if condition holds
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
std::string kind(T) { return "integral"; }
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
std::string kind(T) { return "floating"; }

// Detection idiom: does `t.size()` compile 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 {};

template <typename T>
auto length(const T& x) {
    if constexpr (has_size<T>::value) return x.size();   // other branch not instantiated
    else return sizeof(T);
}

int main() {
    std::cout << kind(1) << ' ' << kind(1.0) << '\n';                       // integral floating
    std::cout << length(std::vector<int>(5)) << ' ' << length(42) << '\n';  // 5 4
    static_assert(has_size<std::string>::value && !has_size<int>::value);
    static_assert(requires(std::string s) { s.size(); });   // C++20 way to ask the same
}

Remember

  • SFINAE: invalid substitution in a signature drops the candidate silently
  • <type_traits> + decltype/declval are the metaprogramming toolkit
  • Prefer concepts and if constexpr in new code

Watch out for

  • Expecting SFINAE to apply to errors inside a function body — only the immediate signature context is SFINAE-friendly.

Check yourself

  1. What does SFINAE do when substituting T into a candidate's return type produces an invalid type?
  2. Why does `if constexpr (has_size<T>::value) return x.size(); else return sizeof(T);` compile for T = int?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: en.cppreference.com/w/cpp/language/sfinae

Builds on

Unlocks

Nothing builds on this yet.