C++: SFINAE

SFINAE

一个基本的重载协议(SFINAE最初思想来源)

SFINAE全称为:替换失败非错(substituion failure is not an error)

我们已经看了不少的模板特化,那么当一个函数名和某个函数模板名称匹配后,他的重载协议大致如此:

  • 根据名称找出所有适用的函数和函数模板
  • 对于适用的函数模板,要根据实际情况对模板形参进行替换;替换过程中如果发生错误,这个模板会被丢弃
  • 在上面两步生成的可行函数集合中,编译器会寻找一个最佳匹配,产生对该函数的调用
  • 如果没有找到最佳匹配,或者找到多个匹配程度相当的函数,则编译器需要报错

在这儿,体现的是 SFINAE 设计的最初用法:如果模板实例化中发生了失败,没有理由编译就此出错终止,因为还是可能有其他可用的函数重载的。

编译期成员检测(有效/无效)

当然在后面人们发现了SFINAE可以有其他用处,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<typename T>
struct has_reserve
{
struct good{char dummy;};
struct bad{char dummy[2];};

//第二个参数需要是第一个参数的成员函数指针,参数为size_t,返回值为void
template<class U, void (U::*)(size_t)>
struct SFINAE{};

//定义一个要求 SFINAE* 类型的 reserve 成员函数模板,返回值是good
template<class U>
static good reserve(SFINAE<U, &U::reserve>*);

//定义一个reserve 成员函数模板,返回值是bad, ...是variadic function
//一些资料:https://en.cppreference.com/w/c/variadic
//https://www.geeksforgeeks.org/variadic-functions-in-c/
template<class U>
static bad reserve(...);

//结果是 true 还是 false,取决于 nullptr 能不能和 SFINAE* 匹配成功
//进一步来看,取决于模板参数 T 有没有返回类型是 void、接受一个参数并且类型为 size_t 的成员函数 reserve
static const bool value = sizeof(reserve<T>(nullptr)) == sizeof(good);
};

上面这段代码的作用是?

C++11开始出现了一个enable_if的模板(type_traits.h),可以选择性的启用某个函数的重载:

比如我们想要向容器尾部添加元素,我们希望的原型是这样的:

1
2
template <typename C, typename T>
void append(C& container, T* ptr, size_t size);

reserver这个函数常常出现在容器里,表示给容器对象分配至少能容纳n个元素的内存。

显然,container 有没有 reserve 成员函数,是对性能有影响的——如果有的话,我们通常应该预留好内存空间,以免产生不必要的对象移动甚至拷贝操作。利用 enable_if和上面的has_reserve 模板,我们就可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
template<typename T>
struct has_reserve
{
struct good{char dummy;};
struct bad{char dummy[2];};

//第二个参数需要是第一个参数的成员函数指针,参数为size_t,返回值为void
template<class U, void (U::*)(size_t)>
struct SFINAE{};

//定义一个要求 SFINAE* 类型的 reserve 成员函数模板,返回值是good
template<class U>
static good reserve(SFINAE<U, &U::reserve>*);

//定义一个reserve 成员函数模板,返回值是bad, ...是variadic function
//一些资料:https://en.cppreference.com/w/c/variadic
//https://www.geeksforgeeks.org/variadic-functions-in-c/
template<class U>
static bad reserve(...);

//结果是 true 还是 false,取决于 nullptr 能不能和 SFINAE* 匹配成功
//进一步来看,取决于模板参数 T 有没有返回类型是 void、接受一个参数并且类型为 size_t 的成员函数 reserve
static const bool value = sizeof(reserve<T>(nullptr)) == sizeof(good);
};

//对于某个 type trait,添加 _t 的后缀等价于其 type 成员类型。因而,我们可以用 enable_if_t 来取到结果的类型
//enable_if_t<has_reserve<C>::value, void> 的意思可以理解成:如果类型 C 有成员的话,那我们启用下面的成员函数,它的返回类型为 void。
template<typename C, typename T>
enable_if_t<has_reserve<C>::value, void>
append(C& container, T* ptr, size_t size)
{
container.reserve(container.size() + size);
for(size_t i = 0; i< size; ++i)
{
container.push_back(ptr[i]);
}
}

template<typename C, typename T>
enable_if_t<!has_reserve<C>::value, void>
append(C& container, T* ptr, size_t size)
{
for(size_t i = 0; i< size; ++i)
{
container.push_back(ptr[i]);
}
}
  • 对于某个 type trait,添加 _t 的后缀等价于其 type 成员类型。因而,我们可以用 enable_if_t 来取到结果的类型
  • enable_if_t<has_reserve<C>::value, void>的意思可以理解成:如果类型 C 有成员的话,那我们启用下面的成员函数,它的返回类型为 void。

简单来说通过enable_if+ 我们自己写的has_reserve实现了编译期选择了合适的函数。

当时我们也发现了这类函数都是有返回值的,在返回值上用enable_if+ 我们自己写的has_reserve实现了编译期选择了合适的函数,如果没有返回值(比如构造函数)或者你不想手写返回值类型的时候该怎么办呢?

https://en.cppreference.com/w/cpp/types/enable_if 未来填坑这种解决方法。

编译期成员检测(仅有效)

如果只需要在某个操作有效的情况下启用某个函数,而不需要考虑相反的情况的话,有另外一个技巧可以用。对于上面的 append 的情况,如果我们想限制只有具有 reserve 成员函数的类可以使用这个重载,我们可以把代码简化成:

1
2
3
4
5
6
7
8
9
template <typename C, typename T>
auto append(C& container, T* ptr,size_t size)-> decltype(declval<C&>().reserve(1U),void())
{
container.reserve(container.size() + size);
for (size_t i = 0; i < size; ++i)
{
container.push_back(ptr[i]);
}
}

declval: 这个模板用来声明一个某个类型的参数,但这个参数只是用来参加模板的匹配,不允许实际使用。使用这个模板,我们可以在某类型没有默认构造函数的情况下,假想出一个该类的对象来进行类型推导

因此declval<C&>().reserve(1U)就是在测试 C& 类型的对象是不是可以拿 1U 作为参数来调用 reserve 成员函数, 如果不可以就是替换失败(Substitution failure) 。

C++ 里的逗号表达式的意思是按顺序逐个估值,并返回最后一项。所以,上面这个函数的返回值类型是 void

再谈一下declval<C&>().reserve(1U)这个方式和 enable_if不同,很难表示否定的条件。如果要提供一个专门给没有reserve 成员函数的 C 类型的 append 重载,这种方式就不太方便了。因而,这种方式的主要用途是避免错误的重载。因此declval<C&>().reserve(1U)替换失败后发现没有其他可替换的了直接报错,避免错误的重载。

void_t

C++17引入了void_t, 定义很简单。

1
2
template <typename...>
using void_t = void;

利用decltype、declval 和模板特化,我们可以把 has_reserve 的定义大大简化:

换句话说,这个类型模板会把任意类型映射到 void。它的特殊性在于,在这个看似无聊的过程中,编译器会检查那个任意类型的有效性。利用 decltype、declval 和模板特化,我们可以把has_reserve的定义大大简化:

1
2
3
4
5
6
template <typename T,typename = void_t<>>
struct has_reserve : false_type {};

//偏特化(数量偏)
template <typename T>
struct has_reserve<T,void_t<decltype(declval<T&>().reserve(1U))>>: true_type {};

当第二个模板能被满足时,编译器就会选择第二个特化的模板;而只有第二个模板不能被满足时,才会回到第一个模板的通用情况。

有了这个 has_reserve模板,我们就可以继续使用其他的技巧,如 enable_if和下面的标签分发,来对重载进行限制。

继承false_type/true_type我们可以查询has_reverse中的静态的value(来自false_type/true_type),回想我们在最开始写的那个东西中有一个has_reserve<C>::value和这个作用就等价了,而代码量小了非常多。

1
2
3
4
5
6
7
8
9
10
template<typename C, typename T>
enable_if_t<has_reserve<C>::value, void>
append(C& container, T* ptr, size_t size)
{
container.reserve(container.size() + size);
for(size_t i = 0; i< size; ++i)
{
container.push_back(ptr[i]);
}
}

标签分发

现在我们完成了has_reserve<C>::value替代.

那么enable_if的替代有吗,当然也有,是一种叫做标签分发的技术:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template<typename C,typename T>
void _append(C& container, T* ptr, size_t size, true_type)
{
container.reserve(container.size() + size);
for(size_t i = 0; i < size. ++i)
{
container.push_back(ptr[i]);
}
}

template<typename C,typename T>
void _append(C& container, T* ptr, size_t size, false_type)
{l
for(size_t i = 0; i < size. ++i)
{
container.push_back(ptr[i]);
}
}

template<typename C, typename T>
void append(C& container, T* ptr, size_t size)
{
_append(container, ptr, size, integral_constant<bool,has_reserve<C>::value>{});
}

如果我们用 void_t 那个版本的has_reserve 模板的话,由于模板的实例会继承false_typetrue_type 之一,代码可以进一步简化为:

1
2
3
4
5
6
template <typename T,typename = void_t<>>
struct has_reserve : false_type {};

//偏特化(数量偏)
template <typename T>
struct has_reserve<T,void_t<decltype(declval<T&>().reserve(1U))>>: true_type {};
1
2
3
4
5
template<typename C, typename T>
void append(C& container, T* ptr, size_t size)
{
_append(container, ptr, size, has_reserve<C>{});
}

标签分发并没有使用 enable_if 显得方便。作为一种可以替代 enable_if的通用惯用法,你还是需要了解一下。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!