判断类/结构体成员的Type Traits

分类: cpp

问题

假设我们有三个结构体:

struct A { int a; };
struct B { float a; };
struct C { int b; };

如何能够拥有一个函数f,对A类型输出int a成员的值,对B类型输出float a,对C类型输出int b呢?

主程序

下面的主程序代码用于测试我们的需求:

  A a{1};
  B b{2.2f};
  C c{3};

  f(a);
  f(b);
  f(c);

预期输出:

1
2.2
3

第一想法:函数重载

看上去多简单呀,不用多想,直接敲代码!

// f for `int a`
void f(A x) {
  printf("%d\n", x.a);
}

// f for `float a`
void f(B x) {
  printf("%f\n", x.a);
}

// f for `int b`
void f(C x) {
  printf("%d\n", x.b);
}

godbolt.org/z/JTvzo2

函数重载简单直接,不过f函数的使用范围被限制到了A/B/C这三个结构体上,并没有做到像鸭子类型那样处理问题。 换句话说,如果我们想对所有具有int a的结构体应用第一个f函数,而不是只针对A,函数重载就难以做到了。

问题升级

我们现在要求f函数,对包含int a成员的结构体输出int a的值,对包含float a成员的结构体输出float a的值,对包含int b成员的结构体输出int b的值。

比如我们现在又多了一个类:

struct D { float a; float b; };

甚至用户可以自定义任何类似的结构体,只要满足问题的成员要求,均应能被f所处理。

主程序

我们在主程序中增加了对D的处理:

  A a{1};
  B b{2.2f};
  C c{3};
  D d{4.4f, 4.4f}; // <<<<<

  f(a);
  f(b);
  f(c);
  f(d); // <<<<<

预期输出:

1
2.2
3
4.4

第一想法:函数模板

都知道模板大法好,那我们就先随心所欲写几个函数模板吧:

// f for `int a`
template <class T>
void f(T x) { printf("%d\n", x.a); }

// f for `float a`
template <class T>
void f(T x) { printf("%f\n", x.a); }

// f for `int b`
template <class T>
void f(T x) { printf("%d\n", x.b); }

嗯,显然是想简单了,编译之后会发现

redefinition of 'template<class T> void f(T)'

这三个函数签名完全一样,编译器怎么知道调用谁呢,肯定是不可以的。

godbolt.org/z/92pnsA

深度思考:SFINAE

我们需要根据成员的存在性,甚至成员的类型,来分配不同的函数以调用。
如果仅仅使用简单的函数的重载,我们只能一个个地枚举所有相关类型来定义函数。
然而当类型并不完全由我们掌握时,或者说用户自定义的类型也可以被应用时,就无法使用重载来做到了。

函数模板肯定是必须要用到的,但用起来也不能想当然,约束条件需要加上。
众所周知,函数模板具有SFINAE特性,可以根据不同情况来启用不同的函数。

SFINAE即“替换失败不是错误” (Substitution Failure Is Not An Error),当模板类型推导/替换失败时,不会出现编译错误,而是忽略/丢弃掉这个函数模板。

// f for `int a`
template <class T>
std::enable_if_t<is_A<T>::value> f(T x) {
  printf("%d\n", x.a);
}

// f for `float a`
template <class T>
std::enable_if_t<is_B<T>::value> f(T x) {
    printf("%f\n", x.a);
}

// f for `int b`
template <class T>
std::enable_if_t<is_C<T>::value> f(T x) {
    printf("%d\n", x.b);
}

这里我们又用到了std::enable_if_t<bool, T = void>参见enable_if)。
当其中的第一个bool类型模板参为true时,其类型就是T;否则,其类型不存在(替换失败),所在的模板函数被忽略/丢弃。

这样就很清楚了,只有满足is_A的类型,才能成功匹配到第一个f函数;其他几个看起来重复的重载函数,实际上都会被丢弃,因为他们的返回值类型不能被成功推断出来。

所以,问题转化为,如何编写is_A等类型分类辅助类呢?

这时候,只能施展Type Traits黑魔法了!

类型分类辅助类

我们来尝试定义一下:

template <class T>
struct is_A : public std::is_same<decltype(T::a), int> {};

template <class T>
struct is_B : public std::is_same<decltype(T::a), float> {};

template <class T>
struct is_C : public std::is_same<decltype(T::b), int> {};

std::is_same<A, B>参见is_same)基于std::bool_constant<true | false>,也就是std::integral_constant<bool, true | false>参见integral_constant),其拥有一个成员constexpr bool value
仅当A与B类型一致时,其为std::true_type,即其value == true

然而,编译失败!

<source>: In instantiation of 'struct is_C<A>':
<source>:41:34:   required by substitution of 'template<class T> std::enable_if_t<is_C<T>::value> f(T) [with T = A]'
<source>:51:6:   required from here
<source>:25:44: error: 'b' is not a member of 'A'
   25 | struct is_C : public std::is_same<decltype(T::b), int> {};
      |                                            ^

godbolt.org/z/bJMTlq

啊!这里并非函数模板的部分,并不存在SFINAE特性,当我们调用is_C<A>时,因为A不存在b成员,所以编译直接失败。

这可怎么办?

挪到函数模板中去

既然函数模板有SFINAE特性,那我们不妨把这个decltype挪一挪:

template <class T, class Base = std::is_same<decltype(T::a), int>>
struct is_A : public Base {};

template <class T, class Base = std::is_same<decltype(T::a), float>>
struct is_B : public Base {};

template <class T, class Base = std::is_same<decltype(T::b), int>>
struct is_C : public Base {};

运行成功!输出的结果也是完全正确的!

哈哈,看来也不过如此,没什么难度嘛!

godbolt.org/z/mVxP53

别高兴得太早

欸,别着急,现在就泼一盆冷水!

既然is_A这些都是类型分类辅助类(一种Type Traits),我能不能直接用呢?
换句话说,我不想只用他们来启用不同的函数,而是直接用来判断类型是否满足我们的要求。
比如,在主程序里面加上下面这句代码:

printf("%d\n", is_A<C>::value);

什么?!编译又失败!

<source>: In function 'int main()':
<source>:18:55: error: 'a' is not a member of 'C'
   18 | template <class T, class Base = std::is_same<decltype(T::a), int>>
      |                                                       ^
<source>:51:24: error: template argument 2 is invalid
   51 |   printf("%d\n", is_A<C>::value);
      |                        ^

godbolt.org/z/OG9n3L

这是因为在enable_if里面使用is_A时,如果成员不存在,会导致替换失败,触发SFINAE,丢弃相应的函数。
然而这里我们直接使用is_A辅助类,输出其value的值。如果类型推导失败,is_A确实就没有定义了。

模板偏特化

那我们就要想办法为额外的情况定义辅助类等价于std::false_type

此时问题分为两部分,首先判断成员是否存在,然后借助is_same判断成员类型。如果前者首先得到了判断,后者其实并不需要SFINAE。

那么首先,我们定义辅助模板类is_A,在默认的情况下等价于std::false_type

template <class T, class = void>
struct is_A : public std::false_type {};

然后利用模板偏特化,对存在成员a的类型T特殊处理:

template <class T>
struct is_A<T, std::void_t<decltype(T::a)>> : public std::is_same<decltype(T::a), int> {};

可以看到,模板偏特化保证了这里的is_A只针对存在成员T::a,而后面的继承则声明该类等价于std::is_same<decltype(T::a), int>
由于偏特化的保证,后面的等价关系就不必担心T::a不存在了。

is_Bis_C同理:

template <class T, class = void>
struct is_B : public std::false_type {};

template <class T>
struct is_B<T, std::void_t<decltype(T::a)>> : public std::is_same<decltype(T::a), float> {};

template <class T, class = void>
struct is_C : public std::false_type {};

template <class T>
struct is_C<T, std::void_t<decltype(T::b)>> : public std::is_same<decltype(T::b), int> {};

godbolt.org/z/BajTRU

补充

如果需要同时判断多个成员是否存在,甚至结合其他的谓词进行复杂的逻辑判断,那么可以利用std::conjunctionstd::disjunctionstd::negation组合我们的判断。

例程

在下面的例程中,is_A判断结构体是否同时含有成员int aint bis_B判断结构体是否同时含有成员int aint c

#include <cstdio>
#include <type_traits>

struct A {
  int a;
  int b;
};

struct B {
  int a;
  int c;
};

template <class T, class = void, class = void>
struct is_A : public std::false_type {};

template <class T>
struct is_A<T, std::void_t<decltype(T::a)>, std::void_t<decltype(T::b)>>
    : public std::conjunction<std::is_same<decltype(T::a), int>,
                              std::is_same<decltype(T::b), int>> {};

template <class T, class = void, class = void>
struct is_B : public std::false_type {};

template <class T>
struct is_B<T, std::void_t<decltype(T::a)>, std::void_t<decltype(T::c)>>
    : public std::conjunction<std::is_same<decltype(T::a), int>,
                              std::is_same<decltype(T::c), int>> {};

// f for `int a` & `int b`
template <class T>
std::enable_if_t<is_A<T>::value> f(T x) {
  printf("%d %d\n", x.a, x.b);
}

// f for `int a` & `int c`
template <class T>
std::enable_if_t<is_B<T>::value> f(T x) {
  printf("%d %d\n", x.a, x.c);
}

int main() {
  A a{1, 1};
  B b{2, 2};

  f(a);
  f(b);
}

输出:

1 1
2 2

godbolt.org/z/kRV0Gd

再补充

利用C++20的新特性Concept会更简单哦~

例程

#include <concepts>
#include <cstdio>

struct A {
  int a;
  int b;
};

struct B {
  int a;
  int c;
};

template <class T>
concept TA = requires(T x) {
  { x.a } -> std::same_as<int&>;
  { x.b } -> std::same_as<int&>;
};

template <class T>
concept TB = requires(T x) {
  { x.a } -> std::same_as<int&>;
  { x.c } -> std::same_as<int&>;
};

// f for `int a` & `int b`
template <TA T>
void f(T x) {
  printf("%d %d\n", x.a, x.b);
}

// f for `int a` & `int c`
template <TB T>
void f(T x) {
  printf("%d %d\n", x.a, x.c);
}

int main() {
  A a{1, 1};
  B b{2, 2};

  printf("%d %d\n", TA<A>, TA<B>);

  f(a);
  f(b);
}

输出:

1 0
1 1
2 2

可以看到主函数中间的那个printfTA<A>就是一个bool值,所以concept本身就可以用于类型判断啦!

godbolt.org/z/muetad