从 SFINAE 到 C++20 Concepts

2023-07-08 C++

引言:支持泛型的编程语言通常都支持对类型进行约束,例如下面是 Swift 中的一个泛型函数,它要求类型 TSomeClass 或其子类,U 实现了 SomeProtocol 协议:

func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    // function body goes here
}

C++ 中通过模板进行泛型编程(Generic Programming)。本文主要介绍 C++ 中两种对模板参数进行约束的方法,第一种是传统的 SFINAE,第二种是 C++20 中新的 Concepts。前者在现有项目的代码中广泛存在,因此仍然需要了解;而后者则是新标准中提出的替代品,值得我们学习。

什么是 SFINAE?

SFINAE 是 Substitution Failure Is Not An Error 的缩写,即「替换失败不是一种错误」,通常被读作 sfee-nay

SFINAE 是 C++ 中的一种语言规则,其大意是:编译器在尝试将模版形参替换为模板实参的时候,如果替换后得到的结果不是合法的代码(替换失败),编译器不会报错,而仅仅是忽略它。

仍然不懂?没关系,请看下一节,在 SFINAE 的实际用法中理解这段话。

如何利用 SFINAE?

考虑这样一个问题:你需要实现一个 equal 函数,用来判断两个数字的值是否相同,你只需要处理 C++ 中的基本类型,如 intlong longfloat 等。

我们先使用模板实现一个最初的版本:

template<typename T>
bool equal(const T& a, const T& b) {
    return a == b;
}

这个版本看似没有问题,比如我们可以这样使用它:

std::cout << std::boolalpha << equal(3, 1 + 2) << std::endl;
// 输出 true

但如果我们让它判断两个 double 呢?

std::cout << std::boolalpha << equal(.3, .1 + .2) << std::endl;
// 输出 false

众所周知,由于浮点数的表示存在误差,我们判断浮点数是否相等的时候,不应该使用 == 运算符,而应该让二者进行减法计算,然后看差值是否足够小。

也就是说,上面的版本其实只适合 T 是整数类型,不适合浮点类型。我们如何表达“T 是整数类型”这样一个约束呢?

一种方法是给 float/double 类型编写一个 equal 的特化版本,这样当 Tfloatdouble 的时候就会使用这个特化版本。但我们这里要介绍的是 SFINAE 机制,它能让我们对当前模板进行约束,让 equal 只对某些类型生效。

直接看代码:

#include <type_traits>

// 借助 SFINAE 对 T 进行约束
template<typename T,
         typename = std::enable_if_t<std::is_integral_v<T>>
>
bool equal(const T& a, const T& b) {
    return a == b;
}

这里和之前的区别在于,模版形参多了一个无名的形参,且它拥有一个默认值,也就是这一部分:

typename = std::enable_if_t<std::is_integral_v<T>>

这个模版形参没有名字,因为我们不需要在模版内部使用它,它存在的意义就是利用 SFINAE 机制通过它的默认值对 T 类型进行约束

如果第一次看到这样的代码,可能会不知道这一坨在干什么。现在我们考虑,当你调用 equal(3, 1 + 2) 的时候会发生什么:

  1. 编译器根据函数调用的实参 31 + 2int 类型,推断出第一个模版形参 Tint;第二个模版形参不需要用户提供实参,因为它拥有一个默认值。

  2. 编译器尝试将 T 替换成 int,就得到下面完整的调用:

    equal<int, std::enable_if_t<std::is_integral_v<int>>>(3, 1 + 2);
    

    其中 std::enable_if_tstd::is_integral_v 都是 STL type_traits 头文件中定义的:

    // enable_if_t 是一个别名模板(Alias Template)
    template<bool B, class T = void>
    using enable_if_t = typename enable_if<B,T>::type;  // since C++14
    
    // is_integral_v 是一个变量模版(Variable Template)
    template<class T>
    inline constexpr bool is_integral_v = is_integral<T>::value;  // Since C++17
    

    也就是说,上面的调用等价于:

    equal<int, std::enable_if<std::is_integral<int>::value>::type>(3, 1 + 2);
    

    前面的写法只是一种简写罢了。

    什么是 Type Traits 呢?Type Traits 是一种特殊的 class templates:记录着某个类型的一些特征(traits)。例如 std::is_integral<T> 是一个 class template,它通过 value 成员类型是 true 还是 false 来记录着 T 是不是整数类型。

    现在你只需要知道:

    • std::is_integral<int>::value 会“返回” true
    • std::enable_if<true>::type 会“返回” void

    这里的“返回”加了引号,是因为它俩不是常规意义上的函数,而是模版元函数(Meta Function)。在 C++ 的世界,模版元函数是指那些能在编译期执行的函数,其输入输出一般是类型而非值。

    Type Traits 的实现通常都非常简单,实际上就是利用模版特化实现编译期的“条件分支”,定义在什么输入下会得到什么输出,例如 enable_if 类模板可以这样实现:

    template<bool B, class T = void>
    struct enable_if {};
     
    template<class T>
    struct enable_if<true, T> { typedef T type; };
    

    这样,enable_if<true> 就会使用特化版本,拥有一个类型成员 type = void;而 enable_if<false> 就使用基础版本,它没有任何成员。

    现在 equal 函数的调用在编译器眼里实际上已经变成:

    equal<int, void>(3, 1 + 2);
    

    本次替换没有发生任何问题,编译期会使用 intvoid 作为实参,实例化函数模板 equal 并调用它。

现在考虑,如果我们调用 equal(.3, .1 + .2) 时,会发生什么:

  1. 编译器根据函数调用的实参 .3.1 + .2double 类型,推断出第一个模版形参 Tdouble;第二个模版形参不需要用户提供实参,因为它拥有一个默认值。

  2. 编译器尝试将 T 替换成 double,就得到下面完整的调用:

    equal<int, std::enable_if_t<std::is_integral_v<double>>>(.3, .1 + .2);
    

    等价于

    equal<int, std::enable_if<std::is_integral<double>::value>::type>(.3, .1 + .2);
    

    此时已经有问题了:std::is_integral<double>::value 返回 false,那么 std::enable_if<false>::type 这个表达式就是无意义的,因为 type 成员类型不存在。此时,编译器会抛弃掉这次匹配。

  3. 此时,已经没有别的 equal 重载版本能够被使用了,编译期没有找到合适的版本,报错如下:

    报错截图

可以看到,借助 SFINAE 机制,我们让 equal 函数模板对 T 进行了约束,只有当它满足整数类型的时候,才是合法的可选项。当然,这里还借助了标准库中定义的一些 Type Traits,它们能够帮助我们对类型进行一些判断,通过查询 ::value::type 来判断该类型是否满足一些条件。

SFINAE 的替代品:C++20 Concepts

使用 SFINAE + Type Traits 虽然能够满足需求,但其写法较为复杂,报错信息也不容易看懂。因此,C++20 从语言上引入 Concepts 特性,专门用于弥补 C++ 语言无法对模板形参进行约束的这个缺陷。

用 C++20 Concepts 来实现对 T 的约束,可以这样写:

template<typename T>
requires std::is_integral_v<T>
bool equal(const T& a, const T& b) {
    return a == b;
}

这里就多了一行 requires std::is_integral_v<T>,用于表明 T 需要满足约束 std::is_integral_v<T>

约束是一个 bool 类型的编译期常量,

此时,如果你调用 equal(.3, .1 + .2),编译器就会明确告诉你,约束不满足,而不是前面 SFINAE 中的 type 成员不存在。

我们也可以单独定义一个表示整数类型的 concept Integral,然后它可以替换 typename 关键字,直接表达对模版形参的约束:

template<typename T>
concept Integral = std::is_integral_v<T>;

template<Integral T>
bool equal(const T& a, const T& b) {
    return a == b;
}

由于 C++20 允许我们用 auto 作为函数形参来定义模版:

// 这也是一个模版,此时被称为 Abbreviated function template
// 参考:https://en.cppreference.com/w/cpp/language/function_template#Abbreviated_function_template
bool equal(const auto& a, const auto& b) {
    return a == b;
}

那我们甚至这样写:

template<typename T>
concept Integral = std::is_integral_v<T>;

bool equal(const Integral auto& a, const Integral auto& b) {
    return a == b;
}

总结

本文首先介绍了 SFINAE 及其作用,它就是利用 C++ 语言中的关于模板参数替换的一个规则来对模板参数进行约束的技巧。SFINAE 通常都会结合 Type Traits 一起使用。

然后介绍了 C++20 引入的 Concepts 语言特性,它能直接对模版参数进行约束,也能单独定义约束,然后用到多个模板中。

个人认为,Concepts 弥补了之前 C++ 模板约束的缺失,让 C++ 模板元编程体系更加完善了。SFINAE 是这个语言缺陷下的一种妥协手段,非常不优雅,为了实现一个简单的约束,需要编写又臭又长的代码,还需要理解编译器对模板的处理过程,进行上文中一大坨复杂的分析。我非常希望看到新的 C++ 项目能摒弃 SFINAE,拥抱 Concepts,让现代 C++ 造福程序员。

参考