从 enable_if 了解模板元编程

前言

在阅读学习 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) 实例化选择了第三个函数。这涉及函数模板的重载决议,稍后会简单介绍,这里先暂时跳过。
  • 上述程序中的模板元编程体现在第一个和第二个函数模板,以第一个函数模板为例,编写了一个接收任意类型的原始数组作为参数的函数模板,在程序编译过程中,会根据传入 len 函数的变量来推导出形参类型,从而生成对应类型的代码。注意这一步是在程序编译过程中进行的。像这种编写的简单的函数模板,就是一种简单的模板元编程,即编写的源程序是用于编译器在编译阶段执行。当然这个示例只展示了一种最简单的模板元编程,在实际的模板元编程中,可以在编译阶段做很多事情,例如根据类型的不同进行不同的编译。本文介绍的 enable_if 就是C++标准库中提供的一种模板元编程的模板类,下文会详细介绍这个模板类。

下面对上述示例进行一些变化,来引出 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 函数调用的候选函数集。
  • 确定候选集后,编译器通过实参类型推导,将模板参数 T 进行实例化,发现第一个函数模板是不匹配,因为第一个函数模板接收的参数类型为数组类型。这里需要注意一点:在确定可行的候选函数这一步,对于函数模板,只对函数的声明进行匹配,对其函数体中的定义不进行匹配展开。因此这一步中,确定的可行的候选函数为第二函数模板和第三个非模板函数。
  • 确定了可行的候选函数后,需要进一步分析候选集中的函数,以确定最佳匹配函数。以上述为例,可行的候选函数为第二个模板函数和第三个非模板函数,这两个函数都可以作为 len(my) 函数语句进行调用,但编译器需要选择最合适的一个进行调用。重点来了,编译器选择最合适函数的依据是什么?答:编译器通过函数的形参类型进行最佳匹配,而与函数体中的定义无关。最佳匹配的细节不在本文展开,具体可查阅相关资料。在上述例子中,第二模板函数实例化推导后的形参类型完全匹配,因此最终编译器对 len(my) 函数调用的最佳匹配函数为第二个模板函数的实例化版本。
  • 因为确定的最佳匹配函数是第二个模板函数,然后编译过程中对其进行实例化展开时,发现 MyClass 类中没有定义 size 成员函数,最终导致了编译不通过。

以上就是结合本例,当遇到函数模板时,对重载决议过程的分析。

我们进一步分析,会发现,上述编译失败是因为在模板函数中,实参替换形参后,被选择为了最佳匹配函数,而这种替换没有考虑函数体中的定义,导致了最终的编译失败。那一个自然的疑问是,既然这种替换失败会导致编译错误,那有没有说明方法让这种模板替换失败不作为一种错误,而是将这替换失败的函数模板从函数候选集中剔除出去。答案是,有的。这就引出了模板元编程中的 SFINAE 概念。下面就来了解 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 的。

  • 首先,最核心的修改为 len 函数的返回类型的定义,使用 decltype 类型推导表达式替换了 size_t 。
  • decltype 表达式中使用逗号运算符执行了两条语句,而逗号运算符用于将多个表达式组合在一起,它按照从左到右的顺序依次计算这些表达式,并返回最后一个表达式的值。因此上述示例中,decltype 推导的结果等于 typename T::size_type
  • 因此,修改后的len函数模板和修改前在函数声明上其实是等价的。不同之处在于,在上述的 decltype 表达式中,还执行了 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

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::value 该表达式为 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 的用法后,上述代码的用途就很清晰了。具体见注释,不再展开。

总结

  • 模板元编程即编写特定于模板的程序,让编译器在编译阶段生成特化后的程序。一种常见的模板元编程的场景是,根据模板参数实例化的类型,有选择的生成对应的实例化程序。更多模板元编程的工具类,在 type_traits 头文件中。
  • SFINAE 是模板元编程中的一种特性,当涉及模板重载时,模板实例化后的函数声明不一定是可以的,通过 enable_if 等模板类,可以在模板实例化后”提前“知道实例化后的函数是否正确,从而将不可行的函数剔除出函数重载过程中的候选函数集,避免编译失败的情况。

参考资料

https://en.cppreference.com/w/cpp/language/sfinae
C++新标准011:模板元编程敲门砖:搞懂SFINAE
https://en.cppreference.com/w/cpp/language/overload_resolution

你可能感兴趣的:(c++,开发语言)