SFINAE in C++: enable_if, Expression SFINAE and When to Switch to Concepts
Key takeaways
SFINAE errors are hard to read, and two overloads whose conditions overlap become ambiguous. The post explains which substitution failures count, where to put enable_if, how to build custom traits with void_t, and how SFINAE compares with tag dispatch and concepts.
What is SFINAE?
SFINAE (Substitution Failure Is Not An Error) is a C++ principle where template substitution failure is not an error. When the compiler fails to instantiate a template, it doesn’t immediately error—it tries other overloads.
// For integer types
template<typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
process(T value) {
return value * 2;
}
// For floating-point types
template<typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type
process(T value) {
return value / 2.0;
}
int main() {
std::cout << process(10) << std::endl; // 20 (integer)
std::cout << process(10.0) << std::endl; // 5.0 (floating-point)
}
Output:
20
5
What happens for process(10) is worth spelling out, because it is the whole mechanism. Overload resolution first collects every function template named process and tries to deduce T for each. Deduction gives T = int for both. The compiler then substitutes int into each signature. For the first, std::enable_if<true, int>::type is int, a valid return type. For the second, std::enable_if<false, int> has no member type, so the return type is ill-formed. Instead of reporting an error, the compiler quietly removes that candidate from the overload set, and only the integral version remains. std::enable_if is nothing more than a struct template that has a type member when its condition is true and none when it is false, turning a boolean into “exists” or “fails to substitute”.
“Substitution failure” covers only errors in the immediate context of the declaration: the function type, its template parameters and default template arguments, and the expressions and types formed from them. An error discovered later, inside the function body or inside a class template that had to be instantiated to answer a question, is a hard error. This rule is what makes SFINAE both useful and treacherous.
Why needed?:
- Type-based overloading: Different implementations per type
- Compile-time checks: Type trait validation
- Flexibility: Conditional template instantiation
- Type safety: Prevent wrong type usage
// ❌ Without SFINAE: error
template<typename T>
void func(T value) {
value.size(); // Error if T doesn't have size()
}
// ✅ With SFINAE: substitution failure → try other overload
template<typename T>
std::enable_if_t<has_size<T>::value, void>
func(T value) {
std::cout << value.size() << '\n';
}
template<typename T>
std::enable_if_t<!has_size<T>::value, void>
func(T value) {
std::cout << "No size()\n";
}
SFINAE Resolution Flow:
flowchart TD
A[Template call] --> B{Substitution}
B -->|Success| C[Instantiate]
B -->|Failure| D{Other overload?}
D -->|Yes| E[Try other overload]
D -->|No| F[Compile error]
E --> B
C --> G[Compile success]
SFINAE Triggers:
| Situation | Example | SFINAE? |
|---|---|---|
| Missing type member | typename T::value_type | ✅ |
| Invalid expression | decltype(t.size()) | ✅ |
enable_if condition false | std::enable_if_t<false, T> | ✅ |
| Function body error | static_assert | ❌ (hard error) |
// SFINAE: substitution failure
template<typename T>
auto func(T t) -> decltype(t.size()) { // If T has no size(), substitution fails
return t.size();
}
// Hard error: function body
template<typename T>
void func(T t) {
static_assert(has_size<T>::value); // Hard error
}
The two func templates above are separate illustrations, not one overload set. The trailing-return-type version fails to substitute for int because t.size() is not a valid expression, so that overload is simply unavailable. The static_assert version is chosen first and only then instantiated, so the assertion fires as a compile error with your message. That difference is the design choice between the two: SFINAE says “this overload does not exist for this type, try another”, while static_assert says “this call is a mistake, and here is why”, which gives a much better error when there is no alternative overload to fall back to.
A substitution failure in five lines
// Substitution failure example
template<typename T>
typename T::value_type func(T container) { // If T has no value_type?
return container[0];
}
// int has no value_type → substitution failure
// But not an error (SFINAE)
// Compiler tries other overloads
std::enable_if
Here is the add implementation:
// C++11
template<typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
add(T a, T b) {
return a + b;
}
// C++14 (concise)
template<typename T>
std::enable_if_t<std::is_integral<T>::value, T>
add(T a, T b) {
return a + b;
}
// C++17 (more concise)
template<typename T>
auto add(T a, T b) -> std::enable_if_t<std::is_integral_v<T>, T> {
return a + b;
}
Key: std::enable_if enables overload only when condition is true.
(The third form uses std::is_integral_v, which is C++17; the trailing return type itself works in C++11.) There are three places to put enable_if: the return type, as shown; an extra function parameter with a default value (rarely used now); or a template parameter. The template-parameter form keeps the return type readable and is the only option for constructors, which have no return type. It comes with a trap that catches almost everyone once:
// ❌ Error: redefinition — default template arguments are not part of the signature
template<typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void f(T);
template<typename T, typename = std::enable_if_t<std::is_floating_point_v<T>>>
void f(T);
// ✅ Make the condition part of the template parameter's type instead
template<typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
void f(T);
template<typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void f(T);
In the first pair, both templates have the signature template<typename, typename> void f(T), differing only in a default argument, so the compiler sees the same template declared twice (error: redefinition of 'template<class T, class> void f(T)'). In the second pair, the second template parameter is a non-type parameter whose type depends on the condition, which makes the two templates genuinely different. That std::enable_if_t<..., int> = 0 idiom is the most robust way to write SFINAE constraints before C++20.
Overloading by type category, detecting containers, and serialization
Overloads for integral, floating-point, and pointer types
#include <type_traits>
#include <iostream>
#include <string>
// Integer types
template<typename T>
std::enable_if_t<std::is_integral_v<T>, void>
print(T value) {
std::cout << "Integer: " << value << std::endl;
}
// Floating-point types
template<typename T>
std::enable_if_t<std::is_floating_point_v<T>, void>
print(T value) {
std::cout << "Float: " << value << std::endl;
}
// Pointer types
template<typename T>
std::enable_if_t<std::is_pointer_v<T>, void>
print(T value) {
std::cout << "Pointer: " << value << std::endl;
}
// String
void print(const std::string& value) {
std::cout << "String: " << value << std::endl;
}
int main() {
print(42); // Integer
print(3.14); // Float
int x = 10;
print(&x); // Pointer
print(std::string("Hello")); // String
}
Output:
Integer: 42
Float: 3.14
Pointer: 0x7ffeeb3b4a8c
String: Hello
The conditions in this example are chosen to be mutually exclusive: a type is integral, floating point, or a pointer, never two at once, so every call has exactly one viable template. The non-template print(const std::string&) is preferred over templates when it matches exactly. Two calls show where the design leaks. print('a') prints “Integer: a”, because char is an integral type (as is bool). And print("literal") deduces T = const char*, so it takes the pointer overload and prints an address instead of the text, because the exact template match beats the conversion to std::string. Both are the kind of surprise that pushes people toward naming the exact set of types a function accepts.
Detecting push_back with declval
#include <vector>
#include <list>
#include <set>
#include <type_traits>
// has_push_back check
template<typename T, typename = void>
struct has_push_back : std::false_type {};
template<typename T>
struct has_push_back<T, std::void_t<decltype(std::declval<T>().push_back(std::declval<typename T::value_type>()))>>
: std::true_type {};
// Containers with push_back
template<typename Container>
std::enable_if_t<has_push_back<Container>::value, void>
addElement(Container& c, typename Container::value_type value) {
c.push_back(value);
std::cout << "Using push_back" << std::endl;
}
// Containers without push_back
template<typename Container>
std::enable_if_t<!has_push_back<Container>::value, void>
addElement(Container& c, typename Container::value_type value) {
c.insert(c.end(), value);
std::cout << "Using insert" << std::endl;
}
int main() {
std::vector<int> vec;
addElement(vec, 10); // push_back
std::list<int> lst;
addElement(lst, 20); // push_back
std::set<int> s;
addElement(s, 30); // insert
}
Output:
Using push_back
Using push_back
Using insert
has_push_back is a detection trait built from three pieces. std::declval<T>() produces an expression of type T in an unevaluated context without requiring a constructor. decltype(...) asks for the type of the call push_back(value_type) without evaluating it. std::void_t<...> turns that type into void if it is valid. The primary template has a defaulted second parameter of void, so has_push_back<std::vector<int>> is really has_push_back<std::vector<int>, void>; the partial specialization matches exactly that when the expression is valid, and fails to substitute otherwise, leaving the primary template’s false_type. For std::set, the fallback uses insert(c.end(), value), the hinted insert that works for every standard container that supports insertion.
A serializer that picks an overload per type
#include <sstream>
#include <type_traits>
// Arithmetic type serialization
template<typename T>
std::enable_if_t<std::is_arithmetic_v<T>, std::string>
serialize(T value) {
return std::to_string(value);
}
// String serialization
std::string serialize(const std::string& value) {
return "\"" + value + "\"";
}
// Container serialization
template<typename Container>
std::enable_if_t<
std::is_same_v<typename Container::value_type, int> ||
std::is_same_v<typename Container::value_type, double>,
std::string
>
serialize(const Container& container) {
std::ostringstream oss;
oss << "[";
bool first = true;
for (const auto& item : container) {
if (!first) oss << ", ";
oss << serialize(item);
first = false;
}
oss << "]";
return oss.str();
}
int main() {
std::cout << serialize(42) << std::endl;
std::cout << serialize(3.14) << std::endl;
std::cout << serialize(std::string("Hello")) << std::endl;
std::vector<int> vec = {1, 2, 3};
std::cout << serialize(vec) << std::endl;
}
Output:
42
3.140000
"Hello"
[1, 2, 3]
The container overload shows SFINAE doing work that is easy to overlook. When serialize(42) is resolved, the container template is also a candidate, with Container = int, and typename int::value_type is ill-formed. That is a substitution failure in the return type, so the candidate disappears silently. Without SFINAE, merely declaring a template that mentions Container::value_type would break every call with a non-class argument. std::to_string(3.14) produces "3.140000" because it formats like printf("%f"); std::format or std::to_chars give the shortest round-trip representation instead. The condition here also hard-codes int and double as element types; a more general version would require that serialize(item) itself is valid for the element type, which is exactly the kind of recursive constraint that concepts express more clearly.
Detecting smart pointers by specialization
#include <memory>
#include <type_traits>
// is_smart_ptr trait
template<typename T>
struct is_smart_ptr : std::false_type {};
template<typename T>
struct is_smart_ptr<std::unique_ptr<T>> : std::true_type {};
template<typename T>
struct is_smart_ptr<std::shared_ptr<T>> : std::true_type {};
template<typename T>
struct is_smart_ptr<std::weak_ptr<T>> : std::true_type {};
// Smart pointer handling
template<typename T>
std::enable_if_t<is_smart_ptr<T>::value, void>
process(const T& ptr) {
if (ptr) {
std::cout << "Smart pointer: " << *ptr << std::endl;
}
}
// Raw pointer handling
template<typename T>
std::enable_if_t<std::is_pointer_v<T>, void>
process(T ptr) {
if (ptr) {
std::cout << "Raw pointer: " << *ptr << std::endl;
}
}
int main() {
auto sp = std::make_shared<int>(42);
process(sp); // Smart pointer
int x = 10;
process(&x); // Raw pointer
}
Output:
Smart pointer: 42
Raw pointer: 10
This trait is built by specialization rather than by detection: it lists the types it recognizes. That is precise but closed; a std::unique_ptr<T, CustomDeleter> is not recognized, because the specialization only matches the default deleter (writing template<typename T, typename D> struct is_smart_ptr<std::unique_ptr<T, D>> fixes it). More subtly, the trait says true for std::weak_ptr, but process then fails with a hard error for a weak_ptr argument, because if (ptr) and *ptr are invalid for it and those errors are in the function body, outside SFINAE’s reach. The trait should describe what the function actually needs (“dereferenceable and testable”), not a family of types; a detection trait on *ptr and static_cast<bool>(ptr) would exclude weak_ptr automatically.
Return Type SFINAE
Here is the getValue implementation:
// Return type SFINAE
template<typename T>
auto getValue(T& container, size_t index)
-> decltype(container[index]) {
return container[index];
}
// For types with at() method
template<typename T>
auto getValue(T& container, size_t index)
-> decltype(container.at(index)) {
return container.at(index);
}
Key: Return type decltype enables SFINAE based on expression validity.
Be careful with this pair: for std::vector, std::deque or std::string, both expressions are valid, both overloads survive substitution, and neither is more specialized, so getValue(vec, 0) fails with call of overloaded 'getValue(...)' is ambiguous. Expression SFINAE only removes candidates; it does not rank the ones that remain. When one approach should be preferred where both work, the usual fix is a priority tag, an extra parameter whose derived-to-base conversion ranks the overloads:
template<int N> struct priority : priority<N - 1> {};
template<> struct priority<0> {};
template<typename T>
auto getValueImpl(T& c, size_t i, priority<1>) -> decltype(c.at(i)) { return c.at(i); }
template<typename T>
auto getValueImpl(T& c, size_t i, priority<0>) -> decltype(c[i]) { return c[i]; }
template<typename T>
decltype(auto) getValue(T& c, size_t i) { return getValueImpl(c, i, priority<1>{}); }
A call with priority<1>{} matches the priority<1> overload exactly and needs a conversion for priority<0>, so bounds-checked at() wins whenever it exists, and operator[] is the fallback (for raw arrays, for example).
Expression SFINAE
Here is the add implementation:
// Expression validity check
template<typename T>
auto add(T a, T b) -> decltype(a + b) {
return a + b;
}
// If operator+ doesn't exist, substitution fails
Key: decltype in return type enables SFINAE based on expression validity.
Writing decltype(a + b) instead of T does two jobs at once: it removes the overload for types without operator+, and it gives the correct result type for types where a + b is not a T (for short, a + b is an int after promotion). The limit is the immediate-context rule again. If operator+ exists but its body fails to compile for some T, for example a generic operator+ template that is unconstrained, the error happens after overload resolution has already chosen it, and SFINAE cannot help. That is why well-behaved generic libraries constrain their own operators.
Ambiguity, unreadable conditions, and hard errors
Ambiguous overloads
// ❌ Ambiguous
template<typename T>
std::enable_if_t<std::is_integral_v<T>, void>
func(T value) {}
template<typename T>
std::enable_if_t<std::is_signed_v<T>, void>
func(T value) {}
// int satisfies both (ambiguous)
// ✅ Clear conditions
template<typename T>
std::enable_if_t<std::is_integral_v<T> && std::is_unsigned_v<T>, void>
func(T value) {}
template<typename T>
std::enable_if_t<std::is_integral_v<T> && std::is_signed_v<T>, void>
func(T value) {}
The error for the ambiguous version appears only at a call site, such as func(42): error: call of overloaded 'func(int)' is ambiguous, followed by both candidates. The first version also has a quieter problem: std::is_signed_v<double> is true, so the “signed” overload accepts floating-point types, which the author probably did not intend. With SFINAE, each overload’s condition must be written knowing all the others, and adding a new overload later means revisiting every existing condition to keep them disjoint. This lack of ordering is the main thing C++20 concepts fix: a constraint that subsumes another is considered more specialized, so std::integral and std::signed_integral overloads can coexist without manual exclusion.
Conditions too complex to read
// ❌ Hard to read
template<typename T>
std::enable_if_t<
std::is_integral_v<T> &&
!std::is_same_v<T, bool> &&
std::is_signed_v<T>,
void
>
func(T value) {}
// ✅ Extract to type trait
template<typename T>
struct is_signed_int : std::conjunction<
std::is_integral<T>,
std::negation<std::is_same<T, bool>>,
std::is_signed<T>
> {};
template<typename T>
std::enable_if_t<is_signed_int<T>::value, void>
func(T value) {}
A named trait gives the condition a name that appears in error messages and can be reused and tested on its own (static_assert(is_signed_int<long>::value);). std::conjunction and std::negation (C++17) also short-circuit: if an earlier trait is false, later ones are not instantiated, which matters when a later trait would be ill-formed for types that fail the earlier check. && between _v values does not short-circuit instantiation, since all operands are evaluated as template arguments first.
Hard errors outside the immediate context
// ❌ Hard error (not SFINAE)
template<typename T>
void func(T value) {
static_assert(std::is_integral_v<T>, "Integer types only");
// static_assert is not SFINAE
}
// ✅ SFINAE
template<typename T>
std::enable_if_t<std::is_integral_v<T>, void>
func(T value) {
// Substitution failure handled
}
When concepts replace all of this
// C++17: SFINAE
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
add(T a, T b) {
return a + b;
}
// C++20: Concepts (more concise)
template<std::integral T>
T add(T a, T b) {
return a + b;
}
Key: Concepts provide clearer syntax and better error messages than SFINAE.
SFINAE vs Concepts vs Tag Dispatch
Here is the func implementation:
// SFINAE (C++11/14/17)
template<typename T>
std::enable_if_t<std::is_integral_v<T>, void>
func(T value) {}
// Concepts (C++20)
template<std::integral T>
void func(T value) {}
// Tag Dispatch (alternative)
template<typename T>
void func_impl(T value, std::true_type) {
// Integer type
}
template<typename T>
void func_impl(T value, std::false_type) {
// Other type
}
template<typename T>
void func(T value) {
func_impl(value, std::is_integral<T>{});
}
Comparison:
| Approach | Readability | C++ Version | Error Messages |
|---|---|---|---|
| SFINAE | Low | C++11+ | Cryptic |
| Concepts | High | C++20+ | Clear |
| Tag Dispatch | Medium | C++11+ | Medium |
All three select an implementation at compile time; none has a runtime cost. They differ in where the decision lives. SFINAE puts it in each overload’s signature, which is what you need when the overloads must be visible to callers (for example, so that another trait can ask “is func(x) callable?”). Tag dispatch keeps one public function and routes to private helpers by passing a type such as std::true_type, which reads naturally when the choice is a single yes/no question and needs no disjoint conditions. C++17’s if constexpr often replaces both for the same job: inside one function body, if constexpr (std::is_integral_v<T>) { ... } else { ... } discards the branch that does not apply. Its limit is that it does not remove the function from overload resolution, so it cannot make func(x) “not callable” for unsupported types.
Custom Type Traits
// has_size check
template<typename T, typename = void>
struct has_size : std::false_type {};
template<typename T>
struct has_size<T, std::void_t<decltype(std::declval<T>().size())>>
: std::true_type {};
// Usage
template<typename T>
std::enable_if_t<has_size<T>::value, size_t>
getSize(const T& container) {
return container.size();
}
Key: std::void_t + decltype + std::declval enable custom type trait detection.
Serialization, container detection, and conditional members
Type-based serialization
#include <type_traits>
#include <sstream>
template<typename T>
std::enable_if_t<std::is_arithmetic_v<T>, std::string>
serialize(T value) {
return std::to_string(value);
}
template<typename T>
std::enable_if_t<std::is_same_v<T, std::string>, std::string>
serialize(const T& value) {
return "\"" + value + "\"";
}
template<typename T>
std::enable_if_t<
!std::is_arithmetic_v<T> &&
!std::is_same_v<T, std::string> &&
std::is_class_v<T>,
std::string
>
serialize(const T& value) {
return "{object}";
}
// Usage
std::cout << serialize(42) << '\n'; // 42
std::cout << serialize(3.14) << '\n'; // 3.140000
std::cout << serialize(std::string{"Hi"}) << '\n'; // "Hi"
Output:
42
3.140000
"Hi"
Container detection
template<typename T, typename = void>
struct is_container : std::false_type {};
template<typename T>
struct is_container<T, std::void_t<
typename T::value_type,
decltype(std::declval<T>().begin()),
decltype(std::declval<T>().end())
>> : std::true_type {};
template<typename T>
std::enable_if_t<is_container<T>::value, void>
printSize(const T& container) {
std::cout << "Size: " << container.size() << '\n';
}
template<typename T>
std::enable_if_t<!is_container<T>::value, void>
printSize(const T& value) {
std::cout << "Not a container\n";
}
Conditional member functions
template<typename T>
class Optional {
T value_;
bool hasValue_;
public:
// Only for copyable types
template<typename U = T>
std::enable_if_t<std::is_copy_constructible_v<U>, Optional>
clone() const {
return *this;
}
// Only for movable types
template<typename U = T>
std::enable_if_t<std::is_move_constructible_v<U>, Optional>
move() {
return std::move(*this);
}
};
Key: SFINAE enables conditional member functions based on type properties.
The template<typename U = T> line is essential, and forgetting it is a classic mistake. SFINAE needs a template parameter of the member function itself that is being deduced. If the condition used T directly, it would be evaluated when the class Optional<T> is instantiated, not when clone() is called, and for a non-copyable T the whole class would fail to compile with a hard error. Introducing U, defaulted to T, delays the check until overload resolution for clone(). The same pattern cannot conditionally enable special member functions like the copy constructor, because a template constructor is never a copy constructor; standard library types solve that with base classes that selectively delete copy and move operations, and C++20 solves it directly with a requires clause on the special member.
A related gap appears in the container-detection pattern above: printSize is constrained with is_container, but calls container.size(), which is_container never checked. A type with begin(), end() and value_type but no size() (such as std::forward_list) passes the trait and then fails in the function body with a hard error. The trait must check everything the function body uses.
Advanced: void_t and Detection Idiom
void_t Pattern
// has_size detection
template<typename T, typename = void>
struct has_size : std::false_type {};
template<typename T>
struct has_size<T, std::void_t<decltype(std::declval<T>().size())>>
: std::true_type {};
// has_begin detection
template<typename T, typename = void>
struct has_begin : std::false_type {};
template<typename T>
struct has_begin<T, std::void_t<decltype(std::declval<T>().begin())>>
: std::true_type {};
Key: std::void_t maps any types to void, enabling SFINAE when expression is invalid.
Detection with void_t (C++17)
The name “detection idiom” often refers to std::experimental::is_detected from the Library Fundamentals TS v2, which wraps this pattern into a reusable template (is_detected<to_string_t, T> with template<class T> using to_string_t = decltype(std::declval<T>().to_string());). It never became part of the C++ standard, so most code writes the void_t form directly, as below, or uses a small in-house is_detected implementation.
#include <type_traits>
template<typename T, typename = void>
struct has_to_string : std::false_type {};
template<typename T>
struct has_to_string<T, std::void_t<decltype(std::declval<T>().to_string())>>
: std::true_type {};
template<typename T>
std::enable_if_t<has_to_string<T>::value, std::string>
stringify(const T& value) {
return value.to_string();
}
template<typename T>
std::enable_if_t<!has_to_string<T>::value && std::is_arithmetic_v<T>, std::string>
stringify(const T& value) {
return std::to_string(value);
}
Note that the second condition includes !has_to_string<T>::value even though arithmetic types can never have a member to_string(). That is defensive, and it illustrates the maintenance cost described under ambiguous overloads: every overload restates the negation of the others. Types satisfying neither condition, such as std::string, get no overload at all, and the error message lists both candidates with “template argument deduction/substitution failed” and the reason for each, which is readable for two overloads and painful for ten. In my experience this is the point where teams either extract the logic into a single function with if constexpr, or move to concepts as soon as the codebase allows C++20.
FAQ
Q1: When to use SFINAE?
A:
- Type-based function overloading: Different implementations per type
- Template metaprogramming: Compile-time type checks
- Conditional instantiation: Allow only specific types
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
add(T a, T b) {
return a + b;
}
Q2: enable_if vs Concepts?
A:
- enable_if: C++11/14/17, complex syntax
- Concepts: C++20, concise and clear
// enable_if
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
add(T a, T b) { return a + b; }
// Concepts
template<std::integral T>
T add(T a, T b) { return a + b; }
Recommendation: Use concepts for new C++20 code; SFINAE for backward compatibility.
Q3: Performance impact?
A: None. SFINAE is compile-time only.
// No runtime cost
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
func(T value) { return value; }
Q4: Hard error vs SFINAE?
A:
- Hard error:
static_assert, compilation fails - SFINAE: Substitution failure, try other overloads
// Hard error
template<typename T>
void func(T value) {
static_assert(std::is_integral_v<T>); // Error
}
// SFINAE
template<typename T>
std::enable_if_t<std::is_integral_v<T>, void>
func(T value) {} // Substitution failure → try other overload
Q5: Tag Dispatch vs SFINAE?
A:
- Tag Dispatch: Compile-time selection through ordinary overloading on a tag argument; simpler, one public entry point
- SFINAE: Compile-time selection by removing overloads; needed when the constraint must be visible to callers
// Tag Dispatch
template<typename T>
void func_impl(T value, std::true_type) { } // Integer
template<typename T>
void func_impl(T value, std::false_type) { } // Other
template<typename T>
void func(T value) {
func_impl(value, std::is_integral<T>{});
}
// SFINAE
template<typename T>
std::enable_if_t<std::is_integral_v<T>, void>
func(T value) { }
Recommendation: Use tag dispatch for simpler cases; SFINAE for complex type constraints.
Q6: Can I create custom type traits?
A: Yes. Use std::void_t and decltype.
template<typename T, typename = void>
struct has_size : std::false_type {};
template<typename T>
struct has_size<T, std::void_t<decltype(std::declval<T>().size())>>
: std::true_type {};
Q7: SFINAE downsides?
A:
- Complexity: Code becomes complex
- Error messages: Hard to read
- Debugging: Difficult
// Complex SFINAE
template<typename T>
std::enable_if_t<
std::is_integral_v<T> &&
!std::is_same_v<T, bool> &&
std::is_signed_v<T>,
void
>
func(T value) {}
Fix: Extract to named type traits, use concepts when available.
Migrating SFINAE to concepts
// Before (SFINAE)
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
add(T a, T b) {
return a + b;
}
// After (Concepts)
template<std::integral T>
T add(T a, T b) {
return a + b;
}
The migration is usually mechanical: each enable_if condition becomes a requires clause or a named concept, and each void_t detection trait becomes a requires expression (requires(T t) { t.size(); }). The behavioral improvement comes from subsumption, which removes the need for mutually exclusive conditions. SFINAE knowledge stays relevant anyway, because most of the standard library implementation and many widely used libraries still rely on it, and reading their error messages requires recognizing the pattern.
Related Articles
- How <type_traits> Works
- C++20 Concepts
- Tag Dispatching in C++: Overload Selection by Tag Types, vs if constexpr and Concepts