c++11-17 core knowledge of templates (eight)-enable_if<> and SFINAE
- Introduction
- Use enable_if<> to disable the template
- enable_if<>example
- Use Concepts to simplify enable_if<>
- SFINAE (Substitution Failure Is Not An Error)
Introduction
class Person {
private:
std::string name;
public:
// generic constructor for passed initial name:
template <typename STR>
explicit Person(STR &&n) : name(std::forward<STR>(n)) {
std::cout << "TMPL-CONSTR for '" << name << "'\n";
}
// copy and move constructor:
Person(Person const &p) : name(p.name) {
std::cout << "COPY-CONSTR Person '" << name << "'\n";
}
Person(Person &&p) : name(std::move(p.name)) {
std::cout << "MOVE-CONSTR Person '" << name << "'\n";
}
};
The constructor is a perfect forwarding, so:
std::string s = "sname";
Person p1(s); // init with string object => calls TMPL-CONSTR
Person p2("tmp"); // init with string literal => calls TMPL-CONSTR
But when trying to call copy constructor, an error will be reported:
Person p3(p1); // ERROR
But if the parameter is const Person or move constructor, it is correct:
Person const p2c("ctmp"); // init constant object with string literal
Person p3c(p2c); // OK: copy constant Person => calls COPY-CONSTR
Person p4(std::move(p1)); // OK: move Person => calls MOVE-CONST
The reason is: According to the overloading rules of C++, for one nonconstant lvalue Person p
, member template
template<typename STR>
Person(STR&& n)
Would be better than copy constructor
Person (Person const& p)
Because STR will be directly substituted as Person&, and the copy constructor also needs a const conversion.
Maybe providing a nonconstant copy constructor will solve this problem, but what we really want to do is to disable the member template when the parameter is of type Person. This can be achieved through std::enable_if<>
.
Use enable_if<> to disable the template
template<typename T>
typename std::enable_if<(sizeof(T) > 4)>::type
foo() {
}
When sizeof(T) > 4
False, the template will be ignored. If sizeof(T) > 4
true, the template will be expanded to:
void foo() {
}
std::enable_if<> is a type extraction (type trait), which determines its behavior based on a given expression at compile time (the first parameter):
- If this expression is true, it
std::enable_if<>::type
will return:- If there is no second template parameter, the return type is void.
- Otherwise, the return type is the type of its second parameter.
- If the result of the expression is false, it
std::enable_if<>::type
will not be defined. According to SFINAE (substitute failure is not an error) described below,
this will cause the template containing std::enable_if<> to be ignored.
Example of passing the second parameter to std::enable_if<>:
template<typename T>
std::enable_if_t<(sizeof(T) > 4), T>
foo() {
return T();
}
If the expression is true, then the template will be expanded to:
MyType foo();
If you think it's a bit ugly to put enable_if<> in the declaration, the usual approach is:
template<typename T,
typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {
}
When sizeof(T) > 4
the time, this will be extended to:
template<typename T,
typename = void>
void foo() {
}
There is also a more common practice is to cooperate with using:
template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;
template<typename T,
typename = EnableIfSizeGreater4<T>>
void foo() {
}
enable_if<>example
We use enable_if<> to solve the problem in the introduction:
template <typename T>
using EnableIfString = std::enable_if_t<std::is_convertible_v<T, std::string>>;
class Person {
private:
std::string name;
public:
// generic constructor for passed initial name:
template <typename STR, typename = EnableIfString<STR>>
explicit Person(STR &&n) : name(std::forward<STR>(n)) {
std::cout << "TMPL-CONSTR for '" << name << "'\n";
}
// copy and move constructor:
Person(Person const &p) : name(p.name) {
std::cout << "COPY-CONSTR Person '" << name << "'\n";
}
Person(Person &&p) : name(std::move(p.name)) {
std::cout << "MOVE-CONSTR Person '" << name << "'\n";
}
};
Core points:
- Use using to simplify the writing of std::enable_if<> in member template functions.
- When the parameters of the constructor cannot be converted to a string, the function is disabled.
So the following call will execute as expected:
int main() {
std::string s = "sname";
Person p1(s); // init with string object => calls TMPL-CONSTR
Person p2("tmp"); // init with string literal => calls TMPL-CONSTR
Person p3(p1); // OK => calls COPY-CONSTR
Person p4(std::move(p1)); // OK => calls MOVE-CONST
}
Note the wording in different versions:
- C++17 :
using EnableIfString = std::enable_if_t<std::is_convertible_v<T, std::string>>
- C++14 :
using EnableIfString = std::enable_if_t<std::is_convertible<T, std::string>::value>
- C++11 :
using EnableIfString = typename std::enable_if<std::is_convertible<T, std::string>::value>::type
Use Concepts to simplify enable_if<>
If you still feel that enable_if<> is not intuitive enough, you can use the Concept introduced by C++20 mentioned in the previous article.
template<typename STR>
requires std::is_convertible_v<STR,std::string>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}
We can also define the condition as a general Concept:
template<typename T>
concept ConvertibleToString = std::is_convertible_v<T,std::string>;
...
template<typename STR>
requires ConvertibleToString<STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}
It can even be changed to:
template<ConvertibleToString STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}
SFINAE (Substitution Failure Is Not An Error)
It is very common to do function overloading for different parameter types in C++. The compiler needs to choose the most suitable function for a call.
When these overloaded functions include template functions, the compiler generally performs the following steps:
- Determine the template parameter type.
- Replace the function parameter list and the template parameter of the return value (substitute)
- Decide which function is the best match based on the rules.
But the result of the replacement may be meaningless. At this time, the compiler will not report an error, but will ignore this function template.
We call this principle: SFINAE ("substitution failure is not an error)
But substitution is not the same as instantiation: even if the template does not need to be instantiated in the end, it must be replaced (otherwise, step 3 above cannot be performed). However, it will only replace the relevant content that appears directly in the function declaration (not including the function body).
Consider the following example:
// number of elements in a raw array:
template <typename T, unsigned N>
std::size_t len(T (&)[N]) {
return N;
}
// number of elements for a type having size_type:
template <typename T>
typename T::size_type len(T const &t) {
return t.size();
}
When passing an array or string, only the first function template matches, because the T::size_type
second template function will be ignored:
int a[10];
std::cout << len(a); // OK: only len() for array matches
std::cout << len("tmp"); // OK: only len() for array matches
In the same way, passing a vector will only match the second function template:
std::vector<int> v;
std::cout << len(v); // OK: only len() for a type with size_type matches
Note that this is different from passing an object, which has a size_type member, but does not have a size() member function. E.g:
std::allocator<int> x;
std::cout << len(x); // ERROR: len() function found, but can’t size()
The compiler will match the second function according to the SFINAE principle, but the compiler will report std::allocator<int>
the size() member function that cannot be found . The second function will not be ignored during the matching process, but an error will be reported during the instantiation process.
The use of enable_if<> is the most direct way to implement SFINAE.
SFINAE with decltype
Sometimes it is rare to define a suitable expression for the template.
For example, in the above example, if the parameter has a size_type member but no size member function, then the template is ignored. The previous definition is:
template<typename T>
typename T::size_type len (T const& t) {
return t.size();
}
std::allocator<int> x;
std::cout << len(x) << '\n'; // ERROR: len() selected, but x has no size()
Such a definition will cause the compiler to select the function but will report an error during the instantiation phase.
To deal with this situation, generally do this:
- Use
trailing return type
to specify the return type (auto -> decltype) - Put all expressions that need to be true before the comma operator.
- Define an object whose type is the return type at the end of the comma operator.
such as:
template<typename T>
auto len (T const& t) -> decltype( (void)(t.size()), T::size_type() ) {
return t.size();
}
Here, the parameter of decltype is a comma expression, so the last T::size_type()
is the return value type of the function. The preceding comma (void)(t.size())
must be established.