C++17之SFINAE

目录

Background

最近面试了几位拥有3年以上经历的工程师,简历上都写了熟悉STL标准库及模板编程,不过问到类型萃取和SFINAE之类的点都不怎么了解。虽说技术服务于业务,应用开发对模板要求并不高,不过多了解些模板能加深对标准库的理解。借此契机写点简单的demo介绍一下SFINAE这个Tricky的机制。

Intro

SFINAE[Substitution failure is not an error]意思是当编译器遇到模板参数不匹配的情况下,会自动跳过匹配失败的模板,继续查找符合的模板。

// foo.hpp
struct Foo {
  typedef int fooType;
};

template <typename T> bool test_foo(typename T::fooType) { // Defination #1
  // only if T has a nested type fooType
  return true;
}
template <typename T> bool test_foo(T) { return false; }  // Defination #2

inline void test1() {
  assert(test_foo<Foo>(1));
  assert(!test_foo<int>(1)); // compile error if #2 was deleted
}

上面的代码中共定义了两个版本的test_foo函数模板,#1要求模板参数T必须拥有内嵌类型fooType,因此test_foo<Foo>会优先匹配#1。实际的查找过程如下:

  1. 根据调用进行Name Loopup
  2. 查找到#1,进行参数类型替换,并加入到解析集中;
  3. 查找到#2,参数类型不符合,从解析集中删除;
  4. 如果解析集为空,编译失败;
  5. 如果解析集中有匹配的函数,根据参数类型找到最匹配的函数;

借助这一特性可以在编译器做一些决断,例如判断模板类型T是否有内嵌类型,是否有指定成员函数之类。

// run.hpp
struct Human {
  void eat() {}
  void run(double speed) {}
};

struct Robot {
  void charge() {}
  void run(double speed) {} // if delete this method, assert fails
};

template <typename T> struct has_run {
  template <typename U, void (U::*)(double)> struct SFINAE {};

  template <typename U> static char test(SFINAE<U, &U::run> *); // #1

  template <typename U> static int test(...); // #2

  static constexpr bool value = sizeof(test<T>(nullptr)) == sizeof(char);
};

// used a lot in type_traits
template <typename T> constexpr bool has_run_v = has_run<T>::value; // #3

inline void test() {
  assert(has_run<Human>::value);
  assert(has_run<Robot>::value);
  assert(has_run_v<Human>);
  assert(has_run_v<Robot>);
}

上例中定义了一个has_run模板检测T类型是否有run方法,SFINAE模板中模板第二个参数是一个参数类型为double的成员函数指针。如果T类型定义了run方法,test<T>(nullptr)会匹配返回类型为char的test方法,从而使得value = true

在C++17前,如果要判断template<typename T1, typename T2>中的T1、T2是否为相同类型,通常需要使用std::is_same<T1, T2>::value的形式,新版本中则可以使用std::is_same_v<T1, T2>,底层实现类似#3处的定义,并没有什么magic。

Into SFINAE

使用std::enable_if

上面的写法过于繁琐,C++11引入了enable_if,其定义如下:

template< bool B, class T = void > // 第一个参数是bool类型
struct enable_if;

这里先给出简单demo,再解释其参数的含义。

struct Human {
  static constexpr bool has_run = true;
  void eat() {}
  void run(double speed) {}
};

struct Robot {
  static constexpr bool has_run = false;
  void charge() {}
};

template <typename T> struct has_run {
  static constexpr bool value = T::has_run;
};

template <typename T> constexpr bool has_run_v = has_run<T>::value;

template <typename T, typename = std::enable_if_t<has_run_v<T>>> // #1
void run(T &t, double speed) {
  t.run(speed);
}

template <typename T, typename = std::enable_if_t<!has_run_v<T>>,
          typename = void> // #2
void run(T &t, double speed) {
  // do nothing
}

inline void test() {
  Human h;
  run(h, 1);

  Robot r;
  run(r, 1); // Should compile and do nothing
}

这里将SFINAE修改为内置has_runtag的方式进行类型萃取,简化部分代码。下面定义了两个run函数,两个模板参数个数不同,防止重定义,如果删除#2处的typename = void,由于两个模板函数的模版参数列表和函数参数列表完全相同,编译器将无法区分。

为了理解enable_if的原理,需要参考源码:

  /// Alias template for enable_if
  template<bool _Cond, typename _Tp = void>
    using enable_if_t = typename enable_if<_Cond, _Tp>::type
      /// Define a member typedef `type` only if a boolean constant is true.
  template<bool, typename _Tp = void>
    struct enable_if
    { };
  // Partial specialization for true.
  template<typename _Tp>
    struct enable_if<true, _Tp>
    { typedef _Tp type; };

在我们的demo中,Human类具有run方法,且我们未在enable_if_t中指定第二个模板参数的类型,因此has_run_v<Human>匹配为enable_if<true, void>::type, 实际偏特化后为

struct enable_if<true, void>
{
    typedef void type;
}

在使用Robot类调用run函数时,has_run_v<Robot>为false,#1, #2处的enable_if_t实例化后为

struct enable_if { } // #1
struct enable_if<true, Robot> { // #2
    typedef Robot type;
}
template<Robot, Robot, void> // #2
void run(Robot & t, double speed) { }

此时#1的第二个模板参数typename = ?,无法找到对应的type,如果把#2删掉,这里就会报错。enable_if正是根据这种内嵌类型type的存在进行匹配。

decltype & declval

decltype用于编译时类型推导,其参数是一个表达式。decltype返回该表达式的类型,但不会对表达式进行求值。

class Test {
public:
  Test() { std::cout << "Test constructor\n"; }
  static Test build() { Test(); }
};
void func() {
  using tType = decltype(Test::build()); // no contruct
  tType t;
}
// output: Test constructor

假如Test类没有public的默认构造函数decltype会推导失败。

class Test {
private:
  Test() = delete;
};
void func() { using tType = decltype(Test()); } // error

使用std::declval可解决这个问题,std::declval可以在类型没有默认构造函数时进行推导,它的作用是返回一个类型为T&&的Fake对象,并允许推导该类型的成员函数。

class Test {
private:
  Test() = delete;
};
void func() { using tType = decltype(std::declval<Test>()); } // success! tType = Test

借助以上工具,可以写出以下代码检测类型是否具有某成员函数:

template <typename T>
auto test_func() -> decltype(std::declval<T>().charge(), void()) {
  std::cout << "T has charge method\n";
}

test_func<Robot>(); // output: T has charge method

注意C++中逗号表达式的值是最后一个逗号后表达式的值,因此test_func的返回值类型是decltype(void()),由于decltype参数要求是表达式,所以需要写void()而非void

void_t

先看定义,简单来说void_t的作用就是将任意参数类型转换为void类型:

#if __cplusplus >= 201703L || !defined(__STRICT_ANSI__) // c++17 or gnu++11
#define __cpp_lib_void_t 201411L
  /// A metafunction that always yields void, used for detecting valid types.
  template<typename...> using void_t = void;
#endif

  /// integral_constant
  template<typename _Tp, _Tp __v>
    struct integral_constant
    {
      static constexpr _Tp                  value = __v;
      typedef _Tp                           value_type;
      typedef integral_constant<_Tp, __v>   type;
      constexpr operator value_type() const noexcept { return value; }
#if __cplusplus > 201103L

#define __cpp_lib_integral_constant_callable 201304L

      constexpr value_type operator()() const noexcept { return value; }
#endif
    };

  /// The type used as a compile-time boolean with true value.
  using true_type =  integral_constant<bool, true>;

  /// The type used as a compile-time boolean with false value.
  using false_type = integral_constant<bool, false>;

借助以上工具,模板可以再次更新:

// check.hpp
template <typename T, typename = void> struct check_run : std::false_type {}; // #1

template <typename T> // #2
struct check_run<T, std::void_t<decltype(std::declval<T>().run(1))>> // just a random number, does't important
    : std::true_type {};

inline void check() {
  assert(check_run<Human>::value);
  assert(check_run<Robot>::value); // failed
}

在使用Human类进行匹配时,#1可以成功推导T = Human,#2可以推导T = Human且使用以上工具推导第二个模版参数为void,此时两个模板都能成功推导,编译器会选择一个最合适的偏特化进行匹配。
在使用Robot类进行匹配时,只有#1可以成功推导,因此check_run<Robot>::value为false。

constexpr

C++17中constexpr不仅可以定义编译器常量,更可以用在模板中用于编译器分支选择,对于上面的用例,更简单的写法如下:

template <typename T> void check_human() {
  if constexpr (has_run_v<T>) {
    std::cout << "T has run method\n";
  } else {
    std::cout << "T does not have run method\n";
  }
}

C++20,新时代

C++20引入了conceptrequire的机制,让C++开发者可以丢弃enable_if这种复杂且丑陋的语法,直接看代码:

template <typename T>
concept has_run_method = requires(T t) { t.run(1); };

template <has_run_method T> void runrunrun(T &&t) {
  t.run(1);
  std::cout << "T has run method\n";
}

这里直接用concept约束T类型必须有run方法,
concept的约束初看类似Rust中的Trait bound,不过还略微有些不同。日后可以写篇文章单独分析一下。

trait Comparable {
    fn less_than(&self, other: &Self) -> bool;
    fn equals(&self, other: &Self) -> bool;
}

fn sort<T: Comparable>(vec: &mut Vec<T>) {
    vec.sort_by(|a, b| a.less_than(b) || !b.equals(a));
}

Tags: #C++