c++11-17 core knowledge of template (eight)-enable_if<> and SFINAE

c++11-17 core knowledge of templates (eight)-enable_if<> and SFINAE

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) > 4False, the template will be ignored. If sizeof(T) > 4true, 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<>::typewill 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<>::typewill 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) > 4the 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_typesecond 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 typeto 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.

Guess you like

Origin blog.csdn.net/hyl999/article/details/112683218