C++ / TEMPLATES AND GENERIC PROGRAMMING
SFINAE and enable_if in legacy codebases
Use std::enable_if to switch overloads on compile-time conditions, hand-roll member detection traits, and read the pre-concepts constraint idioms.
What you will learn
- Read typename std::enable_if<C, R>::type as an overload that disappears when C is false
- Place the guard in the return type or a defaulted template parameter, not the body
- Write complementary conditions so exactly one overload survives substitution
- Build a decltype probe to detect a member when void_t and concepts are unavailable
Understanding SFINAE and enable_if in legacy codebases
When the compiler builds the candidate list for a call, it deduces the template arguments for each function template and substitutes them into the declaration. If that substitution produces something that is not a valid type or expression - a member typedef that does not exist, a T::value_type on an int, a call the type does not support - the candidate is quietly dropped instead of diagnosed. That is SFINAE: substitution failure is not an error. The useful mental model is that substitution is a filter applied before ranking, and only the survivors get compared.
std::enable_if exists to trip that filter on purpose. It is a class template whose member typedef named type only exists in the true specialisation, so writing typename std::enable_if<false, X>::type names a member that is not there and the enclosing candidate vanishes. Because only the declaration is examined during substitution, the guard has to live in the declaration: in the return type, in an extra template parameter with a default, or in a defaulted function parameter. Constructors have no return type, which is why library code puts the guard in a trailing template parameter there.
Two consequences dominate legacy code. First, the failure must happen in the immediate context: a static_assert or an unsupported call inside the function body compiles the candidate in, then fails hard, so no fallback is ever considered. Second, enable_if only removes candidates and never ranks them, so two constrained overloads need conditions that cannot both hold or the call is ambiguous. Pre-C++11 files do the same job with boost::enable_if or sizeof-based probes, C++17 adds std::enable_if_t and std::void_t, and C++20 concepts replace almost all of it - but the old spelling stays in the source you have to maintain.
<iostream>
<string>
<type_traits>
// The guard sits in the return type, so the two declarations have different
// signatures and at most one of them survives substitution for a given T.
template <typename T>
typename std::enable_if<std::is_integral<T>::value, std::string>::type
describe(T value) {
return "integer " + std::to_string(value);
}
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, std::string>::type
describe(T value) {
return "real " + std::to_string(value);
}
int main() {
std::cout << describe(42) << "\n";
std::cout << describe(2.5) << "\n";
std::cout << describe('A') << "\n"; // char is integral, promotes to 65
// describe("text"); // both ::type lookups fail -> no matching function
return 0;
}
Substitution failure in the immediate context of a template declaration silently removes that candidate from the overload set, and std::enable_if manufactures such a failure deliberately.
Worked examples
Detecting a member function without void_t
Builds a has_to_string trait from a decltype probe and uses it to pick between a real conversion and a fallback.
<iostream>
<string>
<type_traits>
struct Widget { std::string to_string() const { return "widget"; } };
struct Gadget {};
namespace detail {
// Declared only: never called, just asked about inside decltype.
template <typename U>
auto probe(U* p) -> decltype(p->to_string(), std::true_type());
std::false_type probe(...);
}
template <typename T>
struct has_to_string : decltype(detail::probe(static_cast<T*>(0))) {};
template <typename T>
typename std::enable_if<has_to_string<T>::value, std::string>::type
show(const T& x) { return x.to_string(); }
template <typename T>
typename std::enable_if<!has_to_string<T>::value, std::string>::type
show(const T&) { return "<opaque>"; }
int main() {
std::cout << show(Widget()) << "\n";
std::cout << show(Gadget()) << "\n";
std::cout << std::boolalpha << has_to_string<Gadget>::value << "\n";
}
Example explained
Line 1The probe template is only a viable candidate when p->to_string() is a valid expression, which is decided while substituting U.
Line 2The ellipsis overload is the worst possible match, so it wins only after the template candidate has been discarded.
Line 3decltype(detail::probe(static_cast<T*>(0))) resolves the call for its type alone, so the null pointer is never dereferenced and probe needs no definition.
Line 4The two show overloads carry negated conditions, so exactly one of them is in the overload set for any T.
Dispatching to memcpy only when the type allows it
Two overloads with identical parameter lists are separated purely by a trait in the return type.
<cstddef>
<cstring>
<iostream>
<string>
<type_traits>
template <typename T>
typename std::enable_if<std::is_trivially_copyable<T>::value>::type
copy_all(const T* src, T* dst, std::size_t n) {
std::memcpy(dst, src, n * sizeof(T));
std::cout << "memcpy path: ";
}
template <typename T>
typename std::enable_if<!std::is_trivially_copyable<T>::value>::type
copy_all(const T* src, T* dst, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) dst[i] = src[i];
std::cout << "loop path: ";
}
int main() {
int a[3] = {1, 2, 3};
int b[3] = {0, 0, 0};
copy_all(a, b, 3);
std::cout << b[0] << b[1] << b[2] << "\n";
std::string s[2] = {"x", "y"};
std::string t[2];
copy_all(s, t, 2);
std::cout << t[0] << t[1] << "\n";
}
Example explained
Line 1Both overloads take (const T*, T*, std::size_t), so only the differing return types keep them from being the same function.
Line 2std::enable_if<C>::type with the second argument omitted is void, which is exactly the return type wanted here.
Line 3std::string has non-trivial copy assignment, so is_trivially_copyable is false and the loop overload is the only survivor for T = std::string.
Line 4Reaching for memcpy is safe here because the trait, not the author, verified that the byte copy is legal for T.
Important notes
typename is mandatory in front of std::enable_if<...>::type because type is a dependent name, and omitting the second argument makes the result void, which only suits void-returning functions.
SFINAE fires only for parameters being deduced or substituted at that moment, so a member function of a class template cannot be constrained on the class's own parameter - by then the class is already instantiated - which is why legacy members add a dummy template <typename U = T>.
Common mistakes
Separating two overloads only by a defaulted template parameter: default template arguments are not part of a function template's signature, so the second line redeclares the first and GCC reports redefinition of default argument before any call exists.
Putting the check inside the body, for example calling x.to_string() or writing static_assert there: substitution already succeeded, the overload stays in the set, and you get a hard error instead of a fallback.
Using conditions that can both hold, such as std::is_integral<T>::value in one overload and sizeof(T) == 4 in another: for int both candidates survive and the call is ambiguous.
Try it yourself
Change, predict, then run
Write count_of(x) with two enable_if overloads: one returning x.size() for types that have a size() member, detected with a decltype probe, and one returning 1 for everything else. Print the result for std::string("hello"), std::vector<int>{1,2,3} and 42, and check you get 5, 3, 1.
Open the C++ workspaceCheck your understanding
A header declares template <class T, class = typename std::enable_if<std::is_integral<T>::value>::type> void f(T); and immediately below template <class T, class = typename std::enable_if<std::is_floating_point<T>::value>::type> void f(T); The file does not compile even though nothing calls f. Why?
- The two conditions overlap for char, so any call to f would be ambiguous.
- std::enable_if may only appear in a return type; as a template parameter default it has no effect.
- Default template arguments are not part of a function template's signature, so the second declaration redeclares the first and supplies a second default for the same parameter.
- std::is_floating_point requires a complete type, and T is incomplete at declaration time.
Show answer
Both lines declare the same template, template <class, class> void f(T), because the defaults are not part of the signature, so the second one is a redeclaration that redefines the default - an error at declaration time, with no call needed. Overlapping conditions sound plausible, but is_integral and is_floating_point are never both true, and an overlap would only surface as an ambiguity at a call. The usual fix is to make enable_if part of a non-type parameter's type, typename std::enable_if<C, int>::type = 0, or to move the guard into the return type.