浅谈C++模板参数类型约束

C++模板是编译时泛型机制,其可以通过编译时绑定来实现类似于运行时OO多态等效果。 模板本身是图灵完备的,进而进化出来了模板元编程黑魔法,其被广泛用于STL和Boost中。

有时候,我们需要对模板参数可以接受的类型进行限制,比如让某个模板参数T仅能被实例化为int, 而不能被实例化为std::string. 在C++ 20之前,这主要是通过Substitution Failure Is Not An Error (SFINAE)等模板元编程黑魔法来实现的,而C++ 20则在语法层面提出了concept概念来约束模板参数类型。 本文接下来对这两者分别进行讨论,希望能对大家有所帮助。

1. SFINAE与std::enable_if实现原理

模板参数一般对可实例化的类型是有要求的,只是这种类型要求是​隐式​的(而不是如普通函数形参等显示指出的类型要求),而这可以被看作是一种​鸭子类型​ducking type要求。 若实例化时模板实参最终不能满足要求,那么编译器将报错。

当类模板具有偏特化时,一个模板实参的可选匹配模板将可能有多个,其中最偏特化的版本的匹配优先级最高。 此外,这些偏特化模板对模板形参的隐式要求可以是相互不同的,比如某个偏特化版本可以隐式地要求模板实参需要具有某个特定成员。 SFINAE所描述的语言机则是:​当对具有多个偏特化的类模板进行实例化时,若当前模板实参不能满足当前模板的要求,编译器不会报错,而是先看看其它相关模板类是否能可以实例化当前模板实参​。 SFINAE可被用来实现​编译时静态反射​,其被大量应用于protobuf等具有静态反射需求的项目中。 std::enableif依赖的语言基础之一就是SFINAE,其可以对某个模板可接受形参类型进行限制的,下面将讨论其实现原理。

首先,我们先举例来看看std::enableif的用法,比如用其来限制某个模板函数仅可以接受int类型入参,如下:

template <typename T>
typename std::enable_if<std::is_integral<T>::value, int>::type f1(T i) {return i;}

template <typename T, typename = typename std::enable_if<std::is_integral<T>::value>::type>
bool f2(T i) { return i; }

上面的两个函数模板f1/f2()都只能接收int/char/bool等整数类型的模板实参,其它类型的模板实参将导致编译器报错。

std::enableif是具有偏特化的一个类模板,如下:

template <bool, typename T=void> struct enable_if {};  // 隐式使用type成员将理应导致报错,而SFINAE机制则使其不报错而继续匹配偏特化版
template <typename T> struct enable_if<true, T> { using type = T; };  // 仅偏特化版才有type成员

可以看出,其依赖偏特化优先匹配的语言机制。 enableif的实现,仅偏特化版才有type成员,隐式地使用普通版的type成员将导致报错,而SFINAE机制则使其不报错而继续匹配偏特化版。 对于前面的函数模板f1/2(),若模板实参不是int/char/bool等整数类型的模板实参, 那么enableif将不会被实例化为偏特化版,因此将没有type成员,进而报错​error: no type named ‘type’ in ‘struct std::enable_if<false, void>’​.

std::enableif依赖偏特化匹配优先级高的语言特性。 此外,SFINAE相关应用还可以使用函数重载时具有精确形参的的函数匹配优先级更高的特性。 例如,可以通过下面的hastypedefiterator模板类来判断一个类型是否具有iterator成员,进而实现编译时静态反射:

template <typename T>
struct has_typedef_iterator {
   typedef char yes[1];
   typedef char no[2];
   template <typename C> static yes& test(typename C::iterator*);  // 具有iterator成员的类型将优先匹配之
   template <typename> static no& test(...);
   static const bool value = sizeof(test<T>(nullptr)) == sizeof(yes);
};

static_assert(!has_typedef_iterator<int>::value);  // 匹配test(...);
static_assert(has_typedef_iterator<std::string>::value);  // 匹配test(typename C::iterator*)

其中,int类型没有iterator成员,因此将匹配test(typename C::iterator*), 最终导致value为false, 而std::string iterator则相反。

SFINAE的一个主要问题在于当模板实参不满足模板形参的隐式要求时,报错信息可能会长达上百行,这很容易让人找不到报错的根本原因。 幸运的是,这个问题可以通过使用来staticassert显示描述模板形参要求来缓解,具体可以参考Effective Modern C++ Item 27, 此处不再赘述。 此外,随着编译器进步,报错信息也会被优化。

2. C++ 20 Concept

C++ 20提出concept以​显示约束模板参数类型​,其比基于SFINAE的std::enableif的编程体验更好。 STL也使用concept进行了重构,以显示约束算法模板函数等的参数类型。 例如,STL算法对iterator类型在C++ 20之前只是口头约定,编译器并不检测之,而C++ 20 STL则使用std::inputiterator等concept来显示约束算法模板使用的iterator类型

下面以cppreference给的示例简要说明concept用法,如下:

template<typename T>
concept Hashable = requires(T a)
{
    // std::hash<T>{}(a)语法可行,且decltype({...})满足convertible_to<size_t>约束
    { std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
};

template<Hashable T> void f(T) {}

struct meow {};
f("abc");
f(meow{});  // 失败,因为std::hash未对meow特例化

上述代码中,template<Hashable T> f(T)的函数模板实参必须满足Hashable concept要求的条件,而meow类型不能满足该条件,因此f(meow{})将报错。 下面再给一个简单示例:

template<typename T> concept Test = requires(T t) { {sizeof(T) == 1}; };
template <Test T> void f(T); // f1
void f(int); // f2
f('A');  // 匹配f1(T), 因为'A'满足sizeof(T) == 1, 且更特化

template<typename T> requires(sizeof(T) == 1)
void g(T); // 约束条件等价于f(T)
void g(int);

上述代码注释已描述得足够清楚,这里就不再赘述了。

从上述例子可以看出,concept的对模板参数的直接语法支持远比基于SFINAE的std::enableif优雅。 此外,由于是语法层面的直接支持,编译器的concept相关报错信息也将比std::enableif方案更能说明出错根本原因