C++11 SFINAE and enable_if (译)

There’s an interesting issue one has to consider when mixing function overloading with templates in C++. The problem with templates is that they are usually overly inclusive, and when mixed with overloading, the result may be surprising:

  • 在C++ 中模板与函数重载混合时,必须考虑一个有趣的问题。模板的问题是它们通常过于包容,当与重载混合时,结果可能会令人惊讶:
    void foo(unsigned i) {
      std::cout << "unsigned " << i << "\n";
    }
    
    template <typename T>
    void foo(const T& t) {
      std::cout << "template " << t << "\n";
    }
    

What do you think a call to foo(42) would print? The answer is “template 42”, and the reason for this is that integer literals are signed by default (they only become unsigned with the U suffix). When the compiler examines the overload candidates to choose from for this call, it sees that the first function needs a conversion, while the second one matches perfectly, so that is the one it picks [1].

When the compiler looks at overload candidates that are templates, it has to actually perform substitution of explicitly specified or deduced types into the template arguments. This doesn’t always result in sensical code, as the following example demonstrates; while artificial, it’s representative of a lot of generic code written in modern C++:

  • 你觉得 foo(42) 会打印什么? 答案是 “template 42”, 原因是整型字面量默认是有符号的(只会在有 U 后缀时才是无符号的)。当编译器检查选择调用的重载候选者时,它发现第一个函数需要转换,而第二个函数完全匹配,所以它选择了第二个[1]。
  • 当编译器查看作为模板的重载候选者时,它必须实际执行显式指定或推导类型到模板参数的替换。这并不总是导致有意义的代码,如接下来的例子所示;虽然是人为的,但它代表了许多在现代 C++ 中编写的通用代码:
    int negate(int i) {
      return -i;
    }
    
    template <typename T>
    typename T::value_type negate(const T& t) {
      return -T(t);
    }
    

Consider a call to negate(42). It will pick up the first overload and return -42. However, while looking for the best overload, all candidates have to be considered. When the compiler considers the templated negate, it substitutes the deduced argument type of the call (int in this case) into the template, and comes up with the declaration:

  • 考虑 negate(42),它将选择第一个重载并且返回 -42。但是,当需要最佳的重载时,需要考虑所有的候选者。当编译器考虑模板化的 negate,它将该调用的推导参数类型(本例中是 int)替换到模板中,并得到如下声明:
    int::value_type negate(const int& t);
    

This code is invalid, of course, since int has no member named value_type. So one could ask - should the compiler fail and emit an error message in this case? Well, no. If it did, writing generic code in C++ would be very difficult. In fact, the C++ standard has a special clause for such cases, explaining exactly how a compiler should behave.

  • 当然,该代码无效,因为 int 没有名为 value_type 的成员。所以有人会问 - 这种情况编译器是否应该失败并发出错误消息?好吧,不。如果这样,在 C++ 中编写通用代码将会很困难。实际上,C++ 标准有一个特殊的条款用来明确解释此种情况编译器应该如何操作。
SFINAE

In the latest draft of the C++11 standard, the relevant section is 14.8.2; it states that when a substitution failure, such as the one shown above, occurs, type deduction for this particular type fails. That’s it. There’s no error involved. The compiler simply ignores this candidate and looks at the others.

In the C++ folklore, this rule was dubbed “Substitution Failure Is Not An Error”, or SFINAE.

The standard states:
If a substitution results in an invalid type or expression, type deduction fails. An invalid type or expression is one that would be ill-formed if written using the substituted arguments. Only invalid types and expressions in the immediate context of the function type and its template parameter types can result in a deduction failure.

And then goes on to list the possible scenarios that are deemed invalid, such as using a type that is not a class or enumeration type in a qualified name, attempting to create a reference to void, and so on.

But wait, what does it mean by the last sentence about “immediate context”? Consider this (non-sensical) example:

  • 在 C++11 最新草案中,相关章节是14.8.2; 它指出当一个替换失败,例如像上面所示,对于这种特定类型的类型推导失败,没有任何错误。编译器只会忽略该候选者然后查看其它候选者。

  • C++ 中该规则被称为 “Substitution Failure Is Not An Error” 或者 SFINAE。

  • 标准规定:

    如果替换导致无效类型或者表达式,则类型推导失败。无效类型或者表达式是指使用替换参数编写的格式错误的类型或表达式。只有在函数类型及其模板参数类型的直接语境中的无效类型和表达式才能导致推导失败。

  • 然后继续列出可能被视为无效的场景,例如在限定名中使用不是类或枚举类型的类型,尝试创建对 void 的引用等等。

  • 但是等等,最后一句关于"直接语境"是什么含义?考虑下面(无意义的)例子:

    template <typename T>
    void negate(const T& t) {
      typename T::value_type n = -t();
    }
    

If type deduction matches this overload for some fundamental type, we’ll actually get a compile error due to the T::value_type inside the function body. This is outside of the “immediate context of the function type and its template parameter types” mentioned by the standard. The lesson here is that if we want to write a template that only makes sense for some types, we must make it fail deduction for invalid types right in the declaration, to cause substitution failure. If the invalid type sneaks past the overload candidate selection phase, the program won’t compile.

  • 如果类型推导与某个基本类型的重载匹配,我们实际上会得到一个编译错误,这是因为函数体中的 T::value_type。这超出了标准中提到的“函数类型及其模板参数类型的直接语境”。这里的教训是,如果我们想编写一个只对某些类型有意义的模板,就必须在声明中对无效类型推导失败,从而导致替换失败。如果无效类型潜入重载候选选择阶段,程序将无法编译。
enable_if - a compile-time switch for templates (模板编译时开关)

SFINAE has proved so useful that programmers started to explicitly rely on it very early on in the history of C++. One of the most notable tools used for this purpose is enable_if. It can be defined as follows:

  • SFINAE 已经被证明非常有用,在C++历史上程序员很早就开始明确地依赖它。其中一个最值得注意的工具是 enable_if。它可能如下定义:

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

And now we can do things like [2]:

  • 然后我们可以这样做[2]:

    template <class T,
             typename std::enable_if<std::is_integral<T>::value,
                                     T>::type* = nullptr>
    void do_stuff(T& t) {
      std::cout << "do_stuff integral\n";
        // an implementation for integral types (int, char, unsigned, etc.)
    }
    
    template <class T,
              typename std::enable_if<std::is_class<T>::value,
                                      T>::type* = nullptr>
    void do_stuff(T& t) {
        // an implementation for class types
    }
    

Note SFINAE at work here. When we make the call do_stuff(), the compiler selects the first overload: since the condition std::is_integral is true, the specialization of struct enable_if for true is used, and its internal type is set to int. The second overload is omitted because without the true specialization (std::is_class is false) the general form of struct enable_if is selected, and it doesn’t have a type, so the type of the argument results in a substitution failure.

enable_if has been part of Boost for many years, and since C++ 11 it’s also in the standard C++ library as std::enable_if. Its usage is somewhat verbose though, so C++14 adds this type alias for convenience:

  • 注意 SFINAE 这里起作用。当我们调用 do_stuff(),编译器选择第一个重载:由于条件 std::is_integral 为真,enable_if 第一个模板参数为true的偏特化被使用,整数类型被设置为 int。第二个重载被忽略,因为没有true的偏特化时(std::is_class 为假)选择 enable_if 的一般形式,它并没有一个type,从而导致替换失败。

  • enable_if 多年来是 Boost 的一部分,从 C++ 11 开始它也在标准 C++ 库中作为 std::enable_if。它的使用有些冗长,因此 C++14 中为方便添加了以下类型:

    template <bool B, typename T = void>
    using enable_if_t = typename enable_if<B, T>::type;
    

With this, the examples above can be rewritten a bit more succinctly:

  • 有了 enable_if_t, 上面的例子简洁地重写如下:

    template <class T,
             typename std::enable_if_t<std::is_integral<T>::value>* = nullptr>
    void do_stuff(T& t) {
        // an implementation for integral types (int, char, unsigned, etc.)
    }
    
    template <class T,
              typename std::enable_if_t<std::is_class<T>::value>* = nullptr>
    void do_stuff(T& t) {
        // an implementation for class types
    }
    
Uses of enable_if

enable_if is an extremely useful tool. There are hundreds of references to it in the C++11 standard template library. It’s so useful because it’s a key part in using type traits, a way to restrict templates to types that have certain properties. Without enable_if, templates are a rather blunt “catch-all” tool. If we define a function with a template argument, this function will be invoked on all possible types. Type traits and enable_if let us create different functions that act on different kinds of types, while still remaining generic [3].

One usage example I like is the two-argument constructor of std::vector:

  • enable_if 是一个非常有用的工具。在 C++11 标准模板库中有数百个引用。它之所以如此有用是因为它是使用 type_traits 的关键部分,type_traits 是一种将模板限制为具有特定属性类型的方法。没有 enable_if,模板是一个相当生硬的“全匹配”工具。如果我们用模板参数定义一个函数,该函数将在所有可能的类型上被调用。 Type traits 和 enable_if 让我们创建作用于不同类型的不同函数,同时仍然保持泛型[3]。
  • 一个我喜欢的例子是 std::vector 双参数的构造函数:
    // Create the vector {8, 8, 8, 8}
    std::vector<int> v1(4, 8);
    
    // Create another vector {8, 8, 8, 8}
    std::vector<int> v2(std::begin(v1), std::end(v1));
    
    // Create the vector {1, 2, 3, 4}
    int arr[] = {1, 2, 3, 4, 5, 6, 7};
    std::vector<int> v3(arr, arr + 4);
    

There are two forms of the two-argument constructor used here. Ignoring allocators, this is how these constructors could be declared:

  • 这里使用两种形式的双参数构造函数。忽略分配器,可以这样声明这些构造函数:
template <typename T>
class vector {
    vector(size_type n, const T val);

    template <class InputIterator>
    vector(InputIterator first, InputIterator last);

    ...
}

Both constructors take two arguments, but the second one has the catch-all property of templates. Even though the template argument InputIterator has a descriptive name, it has no semantic meaning - the compiler wouldn’t mind if it was called ARG42 or T. The problem here is that even for v1, the second constructor would be invoked if we didn’t do something special. This is because the type of 4 is int rather than size_t. So to invoke the first constructor, the compiler would have to perform a type conversion. The second constructor would fit perfectly though.

So how does the library implementor avoid this problem and make sure that the second constructor is only called for iterators? By now we know the answer - with enable_if.

Here is how the second constructor is really defined:

  • 两个构造函数都有两个参数,但是第二个具有模板全匹配属性。尽管模板参数 InputIterator 有一个描述性的名称,但它没有语义意义 - 甚至编译器不介意它被称为 ARG42 或者 T。这里的问题是,即使对于 v1,如果我们不做特殊的事情,第二个构造函数将被调用。因为 4 的类型是 int 而不是 size_t,所以如果要调用第一个构造函数,编译器必须执行类型转换,而第二个构造函数非常合适。

  • 那么库的实现着如何避免该问题并且确保第二个构造函数只为迭代器所用呢?现在我们知道答案了-通过 enable_if。

  • 下面是第二个构造函数的实际定义:

    template <class _InputIterator>
    vector(_InputIterator __first,
           typename enable_if<__is_input_iterator<_InputIterator>::value &&
                              !__is_forward_iterator<_InputIterator>::value &&
                              ... more conditions ...
                              _InputIterator>::type __last);
    

It uses enable_if to only enable this overload for types that are input iterators, though not forward iterators. For forward iterators, there’s a separate overload, because the constructors for these can be implemented more efficiently.

As I mentioned, there are many uses of enable_if in the C++11 standard library. The string::append method has a very similar use to the above, since it has several overloads that take two arguments and a template overload for iterators.

A somewhat different example is std::signbit, which is supposed to be defined for all arithmetic types (integer or floating point). Here’s a simplified version of its declaration in the cmath header:

  • 它使用 enable_if 仅为 输入迭代器类型(而不是前向迭代器)启用该重载。对于前向迭代器,有一个单独的重载,因为这些迭代器的构造函数可以更有效地实现。
  • 正如我提到的,在C++ 11标准库中,enable_if 有很多用途。std::append 方法的用法与上面的方法非常相似,因为它有几个重载,这些重载接受两个参数,还有一个用于迭代器的模板重载。
  • 一个稍微不同的例子是 std::signbit,它应该为所有算术类型(整数或浮点)定义。下面是它在 cmath 头中声明的简化版本:
    template <class T>
    typename std::enable_if<std::is_arithmetic<T>, bool>::type
    signbit(T x)
    {
        // implementation
    }
    

Without using enable_if, think about the options the library implementors would have. One would be to overload the function for each of the known arithmetic type. That’s very verbose. Another would be to just use an unrestricted template. But then, had we actually passed a wrong type into it, say std::string, we’d most likely get a fairly obscure error at the point of use. With enable_if, we neither have to write boilerplate, nor to produce bad error messages. If we invoke std::signbit as defined above with a bad type we’ll get a fairly helpful error saying that a suitable function cannot be found.

  • 如果不使用enable_if,请考虑库实现者将拥有的选项。一种方法是为每个已知的算术类型重载函数。太啰嗦了。另一种方法是使用不受限制的模板。但是,如果我们实际上向它传递了一个错误的类型,比如 std::string,我们很可能在使用时得到一个相当晦涩的错误。使用enable_if,我们既不必编写样板,也不必生成错误消息。如果我们使用错误类型调用上面定义的 std::signbit,我们将得到一个非常有用的错误,即找不到合适的函数。
A more advanced version of enable_if (更高级版本 enable_if)

Admittedly, std::enable_if is clumsy, and even enable_if_t doesn’t help much, though it’s a bit less verbose. You still have to mix it into the declaration of a function in a way that often obscures the return type or an argument type. This is why some sources online suggest crafting more advanced versions that “get out of the way”. Personally, I think this is the wrong tradeoff to make.

std::enable_if is a rarely used construct. So making it less verbose doesn’t buy us much. On the other hand, making it more mysterious is detrimental, because every time we see it we have to think about how it works. The implementation shown here is fairly simple, and I’d keep it this way. Finally I’ll note that the C++ standard library uses the verbose, “clumsy” version of std::enable_if without defining more complex versions. I think that’s the right decision.

  • 诚然,std::enable_if 是笨拙的,甚至 enable_if_t 也没有多大帮助,尽管它没有那么冗长。您仍然必须将其混合到函数声明中,这种方式通常会模糊返回类型或参数类型。这就是为什么一些在线资源建议制作更高级的版本“让开”。就我个人而言,我认为这是一个错误的权衡。
  • std::enable_if 是一个很少使用的构造。所以让它不那么冗长对我们没什么好处。另一方面,让它更神秘是有害的,因为每次我们看到它,我们都要思考它是如何运作的。这里显示的实现非常简单,我将保持这种方式。最后,我将注意到,C++ 标准库使用的是冗长、“笨拙”的 std::enable_if 版本,而不是定义更复杂的版本。我认为这是正确的决定。

[1] If we had an overload for int, however, this is the one that would be picked, because in overload resolution non-templates are preferred over templates.
[2] Update 2018-07-05: Previously I had a version here which, while supported by earlier compilers, wasn’t entirely standards-compliant. I’ve modified it to a slightly more complicated version that works with modern gcc and Clang. The trickiness here is due to do_stuff having the exact same signature in both cases; in this scenario we have to be careful about ensuring the compiler only infers a single version.
[3] Think of it as a mid-way between overloading and templates. C++ has another tool to implement something similar - runtime polymorphism. Type traits let us do that at compile time, without incurring any runtime cost.

  • [1] 如果我们有一个 int 重载,我们应该选择这个重载,因为在重载解析中,非模板优于模板。
  • [2] 2018-07-05更新:之前我在这里有一个版本,虽然得到了早期编译器的支持,但并不完全符合标准。我已经将它修改为一个稍微复杂的版本,可以与现代 gcc 和 Clang 一起使用。这里的技巧是由于 do_stuff 在两种情况下都具有完全相同的签名;在这种情况下,我们必须小心确保编译器只推断出一个版本。
  • [3] 可以把它看作是重载和模板之间的一种中间方法。C++ 有另一种工具来实现类似的 - 运行时多态性。Type traits 让我们在编译时进行而不招致任何运行时消耗。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值