SFINAE in C++
(This passage contains 0 words)
SFINAE Before C++20
Why SFINAE?
The mechanism of templates tells us that when a compiler wants to instantiate a template, it first replaces template parameters with the types deduced at compile time. There're times when the replaced types does not fit in. For example, the following template function requires the true type of argument to be one that's printable.
template <typename T>
void print(T something) {
std::cout << something << std::endl;
}
Surely you don't want to print a vector with this template, since you know that vectors can't be printed directly with std::cout <<. And if you do so, you will receive a long compile-time error. This is where SFINAE, or substitution failure is not an error, comes into play. Since we have a template prototype that's failed to instantiate, can the compiler find a compatible one? We'll provide it with such a compatible template (let the type be NotPrintable), and for now we just ignore the detailed implementation.
template <typename T>
void print(T something) {
std::cout << something << std::endl;
}
class NotPrintable; // This type can't be printed
void print_NotPrintable_objects(NotPrintable); // For printing NotPrintable objects
template <>
void print<NotPrintable>(NotPrintable something) {
print_NotPrintable_objects(something);
}
In this example, we have an incompatible template and a compatible one. Surely the compiler sees the incompatible one first, since it's before the compatible one. If the compiler finds that the type doesn't match for the incompatible one and generates a fatal error instantly, the compilation will be interrupted and the compiler will never see the compatible one, even if it's right behind the incompatible one. Therefore, SFINAE requires the compiler to temporarily ignore incompatible templates and look for a compatible one, and a fatal error is generated only if all templates are incompatible.
This looks similar to function overloading. However, SFINAE differs from overloading in its nature. Overloading is achieved through regarding overloaded functions as individual functions by adjusting function names, like adding a prefix or suffix. However, SFINAE features in invoking the right template according to whether the template succeeds in instantiation or not. If a template instantiates successfully, then it's the correct choice; If not, switch to the next one until every template is tried.
How to SFINAE?
Before C++20, SFINAE can be performed through various ways. One of them being template specialization, it's the same as our previous example. Another important utility called "enable if" is introduced in C++11, which can have the following implementation.
template <bool, typename T>
struct std::enable_if {};
template <typename T>
struct std::enable_if<true, T> { using type = T; };
Let's inspect the definition of "enable if". The first definition indicates that the template class has no member at anytime. And the second template specialization shows that when the first template parameter is given true, it has a type alias that uses a member type as template parameter. There're many ways to make use of "enable if", and a typical one is to use its type as return value. Let's do it step by step.
First, std::enable_if is introduced in <type_traits>. Including <utility > also does the job.
#include <type_traits>
Next, we can write a type that can't be printed directly with a message wrapped in it.
struct NotPrintable { static constexpr char* text = (char[]){ "Secret message!" }; };
Then we can define a static structure to store the condition that indicates whether one type is NotPrintable or not. Since we need to know the condition at compile time, using an enum class should be a good choice.
template <typename T> struct is_not_printable { enum { value = 0 }; };
template <> struct is_not_printable<NotPrintable> { enum { value = 1 }; };
Finally, we can use std::enable_if to determine different behaviors of a print template. Based on whether the object is NotPrintable or not, we can have two versions of print.
template <typename T>
typename std::enable_if<is_not_printable<T>::value, void>::type print(T)
// When T is NotPrintable, value is true, and return type is void
{
std::cout << T::text << std::endl; // Output the secret message
}
template <typename T>
typename std::enable_if<!is_not_printable<T>::value, void>::type print(T something)
// When T is not NotPrintable, value is false, and return type is still void
{
std::cout << something << std::endl; // Output something
}
Now let's run a demo in main function and observe what happens when print is fed with objects of different types.
int main() {
print("Hello, world!"); // Is not NotPrintable
print(NotPrintable{}); // Is NotPrintable
}
/* Output:
Hello, world!
Secret message!
*/
Other Ways to Use SFINAE
We have shown that the core of SFINAE is that when a compiler cannot find a compatible template and substitute a type, it searches every available template first rather than generating an error. So the key of SFINAE is to
- 1. Let the compiler search for all available templates.
- 2. Provide at least one compatible template.
- 3. Ensure that conditional values are obtained at compile time.
Now let's try some more complicated cases. Check out each of the following example.
// 1. Work with return type deduction
template <typename T> auto print(T)
-> typename std::enable_if<is_not_printable<T>::value, void>;
// 2. Work with template parameter
template <typename T, typename std::enable_if<is_not_printable<T>::value = 0>
// Use = 0 to make the parameter be ignored by the compiler
void print(T);
// 3. Work with expression SFINAE (When the expression is invalid, SFINAE works)
template <typename T> auto print(T something)
-> decltype(static_cast<void>(std::cout << something));
// Note that every statement except declarations and definitions has a return value
// std::cout << returns std::ostream&
STL Type Traits
STL provides many compile-time conditional structures like our is_not_printable. They are known as type traits. They all have a member value representing whether certain conditions are met. Therefore, type traits are suitable for creating SFINAE programs. The following list displays some of these type traits (Check out CPP Reference).
std::is_integral<T> \\ Checks whether T is an integral type
std::is_floating_point<T> \\ Checks whether T is a float type
std::is_void<T> \\ Checks whether T is of type void
std::is_null_pointer<T> \\ Checks whether T is std::nullptr_t (Since C++14)
std::is_array<T> \\ Checks whether T is an array type that overloads []
std::is_class<T> \\ Checks whether T is a class
std::is_function<T> \\ Checks whether T is a function type that overloads ()
std::is_refernce<T> \\ Checks whether T is either rvalue reference or lvalue reference
Some type traits reveal the relationship between two types.
std::is_same<T, U> \\ Checks whether T and U are the same
std::is_base_of<Base, Derived> \\ Checks whether Base is the base for Derived
std::is_convertible<From, To> \\ Checks whether To can be convered from From
Some type traits perform certain operations on a type, adding or removing a feature or something.
std::remove_cv<T>/std::remove_const<T>/std::remove_volatile<T> \\ Removes const and/or volatile
std::add_cv<T>/std::add_const<T>/std::add_volatil<T> \\ Adds const and/or volatile
std::remove_pointer<T>/std::add_pointer<T> \\ Removes/adds pointer type
std::make_signed<T>/std::make_unsigned<T> \\ Make a type signed/unsigned
std::decay<T> \\ Similar to passing a value to a function: Removes reference, cv qualifiers and convert array operators to pointers, function types to function pointers.
These type traits can relieve us from writing our own. But in some cases such as when we have types defined by ourselves, writing our own (like is_not_printable) might be more helpful than using STL type traits.
Constraints and Concepts
Introduced in C++20, constraints and concepts have significantly improved SFINAE mechanism with more readable grammar. They can be regarded as a powerful utility that enables and disables templates according to the specific conditions.
Constraints, as its name suggests, are conditions or requirements placed on template parameters. To make constraints reusable by different templates, we can define a set of specific requirements with a name, which is called a concept. The constraints on a template parameter might involve serveral different concepts. Concepts are similar to type traits, but they offer a restriction at a higher level, and in a more expressive and more abstract way.
Basic Syntax
Basic Concept
The basic syntax concept definitions follow is a declarative one, where a concept is declared by keyword concept and is given a compile-time condition immediately. Since concepts work in templates, it's meaningless to declare a concept outside a template, which will take effect on none of template parameters.
// After C++20
template <typename T>
concept concept_name = conditional_expression<T>;
One important thing about concept definitions is that the conditional expression must be a constant expression with a boolean value at compile time. This can be done with a slight modification on a type trait. For example, to check whether one class is or is not SomeType, we can write the following concept.
#include <type_traits> // To use std::is_class_v
// Our SomeType
struct SomeType { static constexpr int value{2024}; };
// A type trait for SomeType
template <typename T>
struct is_sometype { enum { value = 0 }; };
template <>
struct is_sometype<SomeType> { enum { value = 1 }; };
// Wirte constexpr boolean expression
template <T>
inline constexpr bool is_sometype_v = is_sometype<T>::value;
// A concept that requires T to be both a class and SomeType
template <typename T>
concept SomeTypeConcept = is_sometype<T> and std::is_class_v<T>;
Now we have our own concept. Next we can put it to use in a template to restrict the type of a parameter. This can be done in a quite simple manner. For example, let's write a function that prints the value in SomeType, and prints "Unsupported type" when it receives an object that's not SomeType.
#include <iostream>
template <SomeTypeConcept T> // Use directly in template parameters
void print_sometype(T) {
std::cout << T::value << std::endl;
}
template <typename T>
void print_sometype(T) {
std::cout << "Unsupported type" << std::endl;
}
int main() {
print_sometype("Hello, world");
print_sometype(SomeType{});
/* Output:
Unsupported type
2024
*/
}
Requires Clause
Keyword requires specifies a condition which template parameters must follow and therefore can be used in both the declaration of concepts and constraints. The basic syntax of using this keyword is majorly the requires clause, which can be represented as the following code.
// After C++20
requires (arguments) {
{ expression_1 } -> concept_name_1;
{ expression_2 } -> concept_name_2;
{ expression_3 } -> concept_name_3;
...
}
A concept can be defined directly with requires clause. Let's modify our previous code with requires clause. In the following example, std::convertible_to is a concept that can be implemented with std::is_convertible_v.
#include <concepts>
template <typename T>
concept SomeTypeConcept = requires(T t) {
{ t } -> std::convertible_to<SomeType>;
};
Apart from declaring a concept, requires clause can be used in template constraints in a more readable manner than using concepts in template parameters. The syntax is clear and easy to understand.
// All are ok
template </* template parameter list */>
requires concept_name
/* function declaration */
template </* template parameter list */>
/* function declaration */
requires concept_name
For example, let's rewrite our print_sometype function with requires clause.
template <typename T>
requires requires(T t) { { t } -> SomeTypeConcept; }
void print_sometype(T) {
std::cout << T::value << std::endl;
}
Note that the first requires means that the template "requires" a concept and the second requires declares a concept. This can be put more simply with our pre-defined concept.
template <typename T> requires SomeTypeConcept<T>
void print_sometype(T) {
std::cout << T::value << std::endl;
}
Reference
1. CPP Reference (https://en.cppreference.com/).
2. C++17 STL Cookbook by Jacek Galowicz, 2017.