在阅读学习 ZLToolKit 源码时,从如下一段代码中了解到 enable_if 和 SFINAE 的概念,从而引入了对模板元编程的了解。
template<class R, class... ArgTypes>
class TaskCancelableImp<R(ArgTypes...)> : public TaskCancelable {
public:
using Ptr = std::shared_ptr<TaskCancelableImp>;
using func_type = std::function<R(ArgTypes...)>;
~TaskCancelableImp() = default;
template<typename FUNC>
TaskCancelableImp(FUNC &&task) {
_strongTask = std::make_shared<func_type>(std::forward<FUNC>(task));
_weakTask = _strongTask;
}
// 任务是可取消的
void cancel() override {
_strongTask = nullptr;
}
operator bool() {
return _strongTask && *_strongTask;
}
void operator=(std::nullptr_t) {
_strongTask = nullptr;
}
R operator()(ArgTypes ...args) const {
auto strongTask = _weakTask.lock();
if (strongTask && *strongTask) {
return (*strongTask)(std::forward<ArgTypes>(args)...);
}
return defaultValue<R>();
}
template<typename T>
static typename std::enable_if<std::is_void<T>::value, void>::type
defaultValue() {}
template<typename T>
static typename std::enable_if<std::is_pointer<T>::value, T>::type
defaultValue() {
return nullptr;
}
template<typename T>
static typename std::enable_if<std::is_integral<T>::value, T>::type
defaultValue() {
return 0;
}
protected:
std::weak_ptr<func_type> _weakTask;
std::shared_ptr<func_type> _strongTask;
};
对于上述示例,重点关注36至50行的代码中 enable_if 的用法。下面按照 “简单了解模板元编程的概念”、“引入 SFINAE 的概念”,“结合 enable_if 来说明 SFINAE 和模板元编程” 的顺序组织下文。
模板元编程(Template Metaprogramming,简称TMP)是编写 template-based C++程序并执行与编译期的过程。所谓 Template Metaprogram(模板源程序)是以C++编写、执行于C++编译器内的程序。 --《Effective C++》
一句话概括模板元编程,使用C++编写基于模板的程序。
下面通过一个例子来进一步了解一下模板元编程,示例来源 < C++新标准011:模板元编程敲门砖:搞懂SFINAE>。代码示例通过注释进行描述。
// 省略一些必要的头文件
// 下面定义了3个重载函数len,其中有两个是函数模板
// 定义了一个接收原始数组为形参的函数模板
template<class T, unsigned N>
std::size_t len(T(&arr)[N])
{
std::cout << "std::size_t len(T(&arr)[N])" << std::endl;
return N;
}
// 定义了一个函数模板,针对 STL 中的容器进行模板特化。
// 对于STL中的容器,都定了一个 size_type 类型和一个 size() 成员函数
template<class T>
typename T::size_type len(const T& t)
{
std::cout << "T::size_type len(T const& t)" << std::endl;
return t.size();
}
// 接收任意参数的方法
size_t len(...)
{
std::cout << "unsigned len(...)" << std::endl;
return 0;
}
int main()
{
int a[5] = {1, 2, 3, 4, 5};
std::cout << len(a) << std::endl;
std::vector<int> nums{1, 2, 3, 4, 5};
std::cout << len(nums) << std::endl;
std::cout << len(3.14) << std::endl;
}
编译运行的输出结果为:
std::size_t len(T(&)[N])
5
T::size_type len(T const& t)
5
size_t len(...)
0
对上述代码和运行结果进行简单的说明:
len(a)
实例化选择了第一个函数模板;len(nums)
实例化选择了第二个函数模板;len(3.14)
实例化选择了第三个函数。这涉及函数模板的重载决议,稍后会简单介绍,这里先暂时跳过。下面对上述示例进行一些变化,来引出 SFINAE 的概念。发生变化的部分添加了对应的注释。
// 添加了一个自定义类,注意,这个自定义类中虽然定义了size_type的别名,但没有定义size成员函数
// 因此当对这个类对象调用len函数时,选择的重载版本应该是第三个非模板函数 len(...)
// 但事实并非如此
class MyClass {
public:
using size_type = size_t;
};
template<class T, unsigned N>
std::size_t len(T(&)[N])
{
std::cout << "std::size_t len(T(&)[N])" << std::endl;
return N;
}
template<class T>
typename T::size_type len(const T& t)
{
std::cout << "T::size_type len(T const& t)" << std::endl;
return t.size();
}
size_t len(...)
{
std::cout << "size_t len(...)" << std::endl;
return 0;
}
int main()
{
int a[5] = {1, 2, 3, 4, 5};
std::cout << len(a) << std::endl;
std::vector<int> nums{1, 2, 3, 4, 5};
std::cout << len(nums) << std::endl;
std::cout << len(3.14) << std::endl;
// 自定义类型作为实参调用len函数
MyClass my;
std::cout << len(my) << std::endl;
}
由编译结果可知,len(my)
选择了第二个函数进行实例化,但因为 MyClass 类中没有定义 size 成员函数,因此最终编译不通过。一个疑惑产生,len(my)
既然实例化第二个函数模板最终会导致编译失败,那为什么在函数重载时不直接选择第三个函数?这就涉及在重载决议(Overload Resolution)了。重载决议的规则比较琐碎,本文只进行简单的介绍,具体细节可查看给出的参考资料。下面通过了解重载决议来理解为什么编译器的行为是这样选择的,然后就能较好的理解 SFINAE 的概念了。
重载决议的过程按如下步骤进行:
下面结合上述例子进行分析。
len(my)
这行代码时,因为名为 len 的函数有三个,因此将这三个函数构建为 len 函数调用的候选函数集。len(my)
函数语句进行调用,但编译器需要选择最合适的一个进行调用。重点来了,编译器选择最合适函数的依据是什么?答:编译器通过函数的形参类型进行最佳匹配,而与函数体中的定义无关。最佳匹配的细节不在本文展开,具体可查阅相关资料。在上述例子中,第二模板函数实例化推导后的形参类型完全匹配,因此最终编译器对 len(my)
函数调用的最佳匹配函数为第二个模板函数的实例化版本。以上就是结合本例,当遇到函数模板时,对重载决议过程的分析。
我们进一步分析,会发现,上述编译失败是因为在模板函数中,实参替换形参后,被选择为了最佳匹配函数,而这种替换没有考虑函数体中的定义,导致了最终的编译失败。那一个自然的疑问是,既然这种替换失败会导致编译错误,那有没有说明方法让这种模板替换失败不作为一种错误,而是将这替换失败的函数模板从函数候选集中剔除出去。答案是,有的。这就引出了模板元编程中的 SFINAE 概念。下面就来了解 SFINAE 的概念,以及如何实现替换失败而不报错。
SFINAE,“Substitution Failure Is Not An Error”。“替换失败不是错误”。
This rule applies during overload resolution of function templates: When substituting the explicitly specified or deduced type for the template parameter fails, the specialization is discarded from the overload set instead of causing a compile error. This feature is used in template metaprogramming.
编译器在模板函数重载过程中,会使用实参类型对模板函数的声明做替换,注意,这里只对函数模板声明做替换,而不对函数体中的定义做替换。这里还需要认识到一点,即使函数模板被实参替换后通过函数声明可以作为可行的候选函数集,也不能说明该替换后的函数就是可行的,因为还没有对模板函数的函数体进行校验。当对模板函数进行展开校验后,若发现该函数是调用是不正确(如上述的例子所示),则把该函数从候选集中剔除出去,这就是模板元编程中的 SFINAE。实现 SFINAE 需要一些 ”编程技巧“。下面对上述示例进行修改,以实现 SFINAE。
// 将示例中的第二函数模板修改为如下所示,其余不变
template<typename T>
decltype(T().size(), typename T::size_type()) len(T const& t)
{
std::cout << "decltype(T().size(), typename T::size_type()) len(T const& t)" << std::endl;
return t.size();
}
编译运行结果如下所示:
std::size_t len(T(&)[N])
5
decltype(T().size(), typename T::size_type()) len(const T& t)
5
size_t len(...)
0
size_t len(...)
0
由运行结果可知,len(my)
重载决议的最终结果为第三个非模板函数。
下面来分析一下修改后的代码,看看它是如何实现 SFINAE 的。
typename T::size_type
。T().size()
语句,且执行顺序是在 typename T::size_type()
语句的前面。而在执行 T().size()
语句时,会进行实参替换,替换为 MyClass().size(),而 MyClass 类中是没有定义 size 成员方法的,因此该替换是失败的。通过这种编程技巧,相当于我们提前告诉了编译器,你若将该函数模板进行实例化时,实例化的对象是MyClass时,替换后该函数是不匹配的,因此就不将其加入到可行的候选函数集中。上述 SFINAE 的实现技巧是不是还是略显复杂?针对上例,我们可以使用C++标准库提供的 enable_if 来实现 SFINAE。
使用 enable_if 修改后代码如下:
template<typename T, typename T2 = typename std::enable_if<!std::is_same<T,MyClass>::value>::type>
typename T::size_type len(const T& t)
{
return t.size();
}
下面来看看 enable_if 和 is_same 是如何使用,并分析上述修改后的代码是如何实现 SFINAE 的。
enable_if 的实现等价于如下代码,这里就不扒源码了。。。
template<bool B, class T = void>
struct enable_if {};
template<class T>
struct enable_if<true, T> { typedef T type; };
有上述代码可知,enable_if 的实现非常简单,有一个模板和一个模板偏特化组成。其作用也比较简单,当模板参数 B 为 true 时,其类中定义了一个 type 成员,等价于形参类型T;当模板参数B为 false 时,其表现为一个空类。其中的模板参数 T 可以省略,省略后其表示为 void 类型。
单纯的看 enable_if 的实现,确实不好想到它有什么用途。实际上,enable_if 常常结合 type_traits 头文件中的其它模板类来使用,例如上面示例中出现的 is_same。下面先来看看 is_same 的用途。
is_same 的声明如下所示,也是一个模板类。在其模板类中定义了一个成员变量 value,当 T 和 U是相同的类型时,value 为 true,否则 value 为 true。(is_same在这就不过解释了)
template< class T, class U >
struct is_same;
回到修改后的 enable_if 代码,当使用实参 my 推导模板形参时,T被实例化为 MyClass 类型,而对 typename T2 = typename std::enable_if::value>::type>
,我们来看下其表现得含义。std::is_same
判断 T 和 MyClass 指向的是同一类型,因此其 value 成员为 true,因此 !std::is_same
该表达式为 false,从而 std::enable_if 不具有成员变量 type。因此,对于将 MyClass 作为实参类型对该函数模板进行形参替换会失败,从而不会作为可行的候选函数集。因此,对于 len(my) 最终选择的函数版本为非函数模板的 len(...)
。
上述写法虽然可以使得的对 MyClass 类型调用 len 函数时,可以触发 SFINAE,但任然存在很大局限性。因为对于函数体内部调用size方法的模板函数,我们是希望其针对 STL 中的容器进行模板特化,上述的修改代码虽然使得 MyClass 类型调用 len 方法能够触发 SFINAE,但仅限于MyClass类型,若存在许多和MyClass类相似但不同的类,该写法就不成立。我们可以改进上述写法,使用模板元编程的技巧来满足所有不是STL容器的自定义类型能够正确触发 SFINAE,这里不再继续展开。
小结一下。enable_if 和 is_same 都是模板类,使用这类的模板类可以使得我们有选择的控制模板实例化,从而实现模板元编程中的 SFINAE 的特性。
最后来看一下前言中提到的如下代码。
// 当 T 被实例化为 void 类型,其对应的函数实例化为该版本
template<typename T>
static typename std::enable_if<std::is_void<T>::value, void>::type
defaultValue() {}
// 当 T 被实例化为 指针 类型,其对应的函数实例化为该版本
template<typename T>
static typename std::enable_if<std::is_pointer<T>::value, T>::type
defaultValue() {
return nullptr;
}
// 当 T 被实例化为 整型 类型,其对应的函数实例化为该版本
template<typename T>
static typename std::enable_if<std::is_integral<T>::value, T>::type
defaultValue() {
return 0;
}
理解了模板元编程中的 SFINAE 特性后和 enable_if 的用法后,上述代码的用途就很清晰了。具体见注释,不再展开。
https://en.cppreference.com/w/cpp/language/sfinae
C++新标准011:模板元编程敲门砖:搞懂SFINAE
https://en.cppreference.com/w/cpp/language/overload_resolution