Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

c++11-17 模板核心知识(八)—— enable_if<>与SFINAE #158

Open
zhangyachen opened this issue Nov 22, 2020 · 0 comments
Open

c++11-17 模板核心知识(八)—— enable_if<>与SFINAE #158

zhangyachen opened this issue Nov 22, 2020 · 0 comments
Labels

Comments

@zhangyachen
Copy link
Owner

zhangyachen commented Nov 22, 2020

引子

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";
  }
};

构造函数是一个perfect forwarding,所以:

std::string s = "sname";
Person p1(s);            // init with string object => calls TMPL-CONSTR
Person p2("tmp");     // init with string literal => calls TMPL-CONSTR

但是当尝试调用copy constructor时会报错:

Person p3(p1);    // ERROR

但是如果参数是const Person或者move constructor则正确:

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

原因是:根据c++的重载规则,对于一个nonconstant lvalue Person p,member template

template<typename STR>
Person(STR&& n)

会优于copy constructor

Person (Person const& p)

因为STR会直接被substituted为Person&,而copy constructor还需要一次const转换。

也许提供一个nonconstant copy constructor会解决这个问题,但是我们真正想做的是当参数是Person类型时,禁用掉member template。这可以通过std::enable_if<>来实现。

使用enable_if<>禁用模板

template<typename T>
typename std::enable_if<(sizeof(T) > 4)>::type
foo() {
}

sizeof(T) > 4为False时,该模板就会被忽略。如果sizeof(T) > 4为true时,那么该模板会被扩展为:

void foo() {
}

std::enable_if<>是一种类型萃取(type trait),会根据给定的一个编译时期的表达式(第一个参数)来确定其行为:

  • 如果这个表达式为true,std::enable_if<>::type会返回:
    • 如果没有第二个模板参数,返回类型是void。
    • 否则,返回类型是其第二个参数的类型。
  • 如果表达式结果false,std::enable_if<>::type不会被定义。根据下面会介绍的SFINAE(substitute failure is not an error),
    这会导致包含std::enable_if<>的模板被忽略掉。

给std::enable_if<>传递第二个参数的例子:

template<typename T>
std::enable_if_t<(sizeof(T) > 4), T>
foo() {
return T();
}

如果表达式为真,那么模板会被扩展为:

MyType foo();

如果你觉得将enable_if<>放在声明中有点丑陋的话,通常的做法是:

template<typename T,
typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {
}

sizeof(T) > 4时,这会被扩展为:

template<typename T,
typename = void>
void foo() {
}

还有种比较常见的做法是配合using:

template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;

template<typename T,
typename = EnableIfSizeGreater4<T>>
void foo() {
}

enable_if<>实例

我们使用enable_if<>来解决引子中的问题:

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";
  }
};

核心点:

  • 使用using来简化std::enable_if<>在成员模板函数中的写法。
  • 当构造函数的参数不能转换为string时,禁用该函数。

所以下面的调用会按照预期方式执行:

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
}

注意在不同版本中的写法:

  • 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

使用Concepts简化enable_if<>

如果你还是觉得enable_if<>不够直观,那么可以使用之前文章提到过的C++20引入的Concept.

template<typename STR>
requires std::is_convertible_v<STR,std::string>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}

我们也可以将条件定义为通用的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)) {
...
}

甚至可以改为:

template<ConvertibleToString STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
...
}

SFINAE (Substitution Failure Is Not An Error)

在C++中针对不同参数类型做函数重载时很常见的。编译器需要为一个调用选择一个最适合的函数。

当这些重载函数包含模板函数时,编译器一般会执行如下步骤:

  • 确定模板参数类型。
  • 将函数参数列表和返回值的模板参数替换掉(substitute)
  • 根据规则决定哪一个函数最匹配。

但是替换的结果可能是毫无意义的。这时,编译器不会报错,反而会忽略这个函数模板。

我们将这个原则叫做:SFINAE(“substitution failure is not an error)

但是替换(substitute)和实例化(instantiation)不一样:即使最终不需要被实例化的模板也要进行替换(不然就无法执行上面的第3步)。不过它只会替换直接出现在函数声明中的相关内容(不包含函数体)。

考虑下面的例子:

// 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(); 
}

当传递一个数组或者字符串时,只有第一个函数模板匹配,因为T::size_type导致第二个模板函数会被忽略:

int a[10];
std::cout << len(a);        // OK: only len() for array matches
std::cout << len("tmp");      // OK: only len() for array matches

同理,传递一个vector会只有第二个函数模板匹配:

std::vector<int> v;
std::cout << len(v);    // OK: only len() for a type with size_type matches

注意,这与传递一个对象,有size_type成员,但是没有size()成员函数不同。例如:

std::allocator<int> x;
std::cout << len(x);     // ERROR: len() function found, but can’t size()

编译器会根据SFINAE原则匹配到第二个函数,但是编译器会报找不到std::allocator<int>的size()成员函数。在匹配过程中不会忽略第二个函数,而是在实例化的过程中报错。

而使用enable_if<>就是实现SFINAE最直接的方式。

SFINAE with decltype

有的时候想要为模板定义一个合适的表达式是比较难得。

比如上面的例子,假如参数有size_type成员但是没有size成员函数,那么就忽略该模板。之前的定义为:

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()

这么定义会导致编译器选择该函数但是会在instantiation阶段报错。

处理这种情况一般会这么做:

  • 通过trailing return type来指定返回类型 (auto -> decltype)
  • 将所有需要成立的表达式放在逗号运算符的前面。
  • 在逗号运算符的最后定义一个类型为返回类型的对象。

比如:

template<typename T>
auto len (T const& t) -> decltype( (void)(t.size()), T::size_type() ) {
    return t.size();
}

这里,decltype的参数是一个逗号表达式,所以最后的T::size_type()为函数的返回值类型。逗号前面的(void)(t.size())必须成立才可以。

(完)

朋友们可以关注下我的公众号,获得最及时的更新:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant