为什么应该避免在函数签名中使用std::enable_if?

180

Scott Meyers发布了他的下一本书EC++11的内容和状态。他写道,书中的一项可能是“避免在函数签名中使用std::enable_if

std::enable_if可以用作函数参数、返回类型或类模板或函数模板参数,以有条件地从重载解析中删除函数或类。

这个问题中展示了所有三种解决方案。

作为函数参数:

template<typename T>
struct Check1
{
   template<typename U = T>
   U read(typename std::enable_if<
          std::is_same<U, int>::value >::type* = 0) { return 42; }

   template<typename U = T>
   U read(typename std::enable_if<
          std::is_same<U, double>::value >::type* = 0) { return 3.14; }   
};

作为模板参数:
template<typename T>
struct Check2
{
   template<typename U = T, typename std::enable_if<
            std::is_same<U, int>::value, int>::type = 0>
   U read() { return 42; }

   template<typename U = T, typename std::enable_if<
            std::is_same<U, double>::value, int>::type = 0>
   U read() { return 3.14; }   
};

作为返回类型:
template<typename T>
struct Check3
{
   template<typename U = T>
   typename std::enable_if<std::is_same<U, int>::value, U>::type read() {
      return 42;
   }

   template<typename U = T>
   typename std::enable_if<std::is_same<U, double>::value, U>::type read() {
      return 3.14;
   }   
};
  • 哪种解决方案应该优先选择,为什么要避免其他方案?
  • 在哪些情况下,"避免在函数签名中使用 std::enable_if" 涉及作为返回类型的用法(这不是普通函数签名的一部分,而是模板特化的一部分)?
  • 成员函数和非成员函数模板有什么区别?

因为重载通常也很好用。如果有什么问题,可以委托给使用(专门化)类模板的实现。 - sehe
成员函数的不同之处在于重载集包括在当前重载之后声明的重载。这在进行可变参数延迟返回类型时特别重要(其中返回类型应从另一个重载中推断出)。 - sehe
1
仅凭主观感受,我必须说,虽然通常非常有用,但我不喜欢std::enable_if在我的函数签名中弄乱(特别是丑陋的额外nullptr函数参数版本),因为它总是看起来像它是一个奇怪的黑客(对于一些可能更美丽和干净的东西,如static if,使用模板黑魔法来利用一个有趣的语言特性)。这就是为什么我尽可能使用标签分派(好吧,你仍然有额外的奇怪参数,但不在公共接口中,而且也少得多丑陋和神秘)。 - Christian Rau
2
我想问一下,在 typename std::enable_if<std::is_same<U, int>::value, int>::type = 0 中的 =0 是什么意思?我找不到正确的资源来理解它。我知道在 =0 之前的第一部分,如果 Uint 相同,则具有成员类型 int。非常感谢! - astroboylrx
5
有趣,我正准备发表评论来指出这一点。基本上, "=0" 表示这是一个默认的、非类型的模板参数。采用这种方式是因为默认的类型模板参数不属于函数签名,所以无法在其上进行重载。 - Nir Friedman
1
点赞了这个问题,因为它展示了使用 enable_if 的所有方法! (; - puio
5个回答

120
将hack放入模板参数中。
在模板参数中使用enable_if方法至少有两个优点:
  • 可读性:enable_if用法和返回/参数类型不会混合成一个混乱的typename消歧别符和嵌套类型访问的块;即使使用别名模板可以减少消歧别符和嵌套类型的混杂,但仍然将两个无关的事情合并到一起。 enable_if用法与模板参数相关而不是与返回类型相关。将它们放在模板参数中意味着它们更接近于重要的东西;

  • 通用适用性:构造函数没有返回类型,某些运算符不能有额外的参数,因此其他两个选项都无法应用于所有情况。在模板参数中放置enable_if可在任何地方使用,因为您只能在模板上使用SFINAE。

对我来说,可读性方面是选择这种方法的主要动因。

4
在这里使用FUNCTION_REQUIRES链接,使得代码更易读,并且在C++03编译器中也可用,它依赖于在返回类型中使用enable_if。此外,在函数模板参数中使用enable_if会导致重载问题,因为函数签名现在不是唯一的,从而引发歧义的重载错误。 - Paul Fultz II
5
这是一个老问题,但对于仍在阅读的任何人:解决@Paul提出的问题的方法是使用带默认非类型模板参数的enable_if,从而实现重载。即使用enable_if_t<condition, int> = 0而不是typename=enable_if_t<condition> - Nir Friedman
几乎静态if的wayback链接:https://web.archive.org/web/20150726012736/http://flamingdangerzone.com/cxx11/almost-static-if/ - davidbak
@R.MartinhoFernandes,您在评论中提供的“flamingdangerzone”链接现在似乎会导致安装间谍软件的页面。我已经标记并通知管理员注意此事。 - nispio

58

std::enable_if模板参数推导期间依赖于"Substitution Failure Is Not An Error"(又称 SFINAE)原则。这是一种非常脆弱的语言特性,您需要非常小心才能正确使用。

  1. 如果您在 enable_if 内部的条件包含嵌套模板或类型定义(提示:查找 :: 标记),那么这些嵌套模板或类型的解析通常是无法推导的上下文。任何在此类无法推导的上下文中的替换失败都是一个错误
  2. 多个 enable_if 重载中的各种条件不能有任何重叠,因为重载决策会产生歧义。虽然编译器会提供良好的警告,但这是您作为作者需要自己检查的。
  3. enable_if 在重载决策期间操纵了可行函数集合,这可能会与引入其他作用域中的函数(例如通过 ADL)的函数存在意想不到的交互。这使得它不太健壮。

简而言之,当它起作用时,它工作得很好,但当它不起作用时,调试可能非常困难。一个非常好的替代方案是使用标签分发,即委托给一个实现函数(通常在 detail 命名空间或助手类中) ,该函数接收基于与您在 enable_if 中使用的相同编译时条件的虚拟参数。

template<typename T>
T fun(T arg) 
{ 
    return detail::fun(arg, typename some_template_trait<T>::type() ); 
}

namespace detail {
    template<typename T>
    fun(T arg, std::false_type /* dummy */) { }

    template<typename T>
    fun(T arg, std::true_type /* dummy */) {}
}

标签分发并不操纵重载集,而是通过编译时表达式(例如在类型特征中)提供适当的参数来帮助您精确选择所需的函数。根据我的经验,这种方法更容易调试和正确实现。如果您是高级类型特征库编写者,则可能需要某种方式使用enable_if,但对于大多数常规编译时条件的使用,不建议使用。


25
标签派发虽然有一个缺点:如果你有一个检测函数存在的特性,并且该函数是使用标签派发方式实现的,那么它总是会报告成员已经存在并产生错误而不是潜在的替换失败。SFINAE主要用于从候选集中删除重载,而标签派发是用于在两个(或更多)重载之间进行选择的技术。它们在功能上有一些重叠,但它们并不等同。 - R. Martinho Fernandes
1
@R.MartinhoFernandes 我认为单独解释这些要点的答案可能会对OP有所帮助。 :-) 顺便说一句,编写像 is_f_able 这样的特性是我认为库编写者的任务,他们当然可以在使用SFINAE时获得优势,但对于“普通”用户和给定一个特性 is_f_able,我认为标签分派更容易。 - TemplateRex
1
@hansmaad,我已经发布了一个简短的回答来解决你的问题,并且会在博客文章中讨论“使用SFINAE还是不使用SFINAE”的问题(这与本问题有些偏题)。只要我有时间完成它。 - R. Martinho Fernandes
8
SFINAE是“脆弱”的?什么意思? - Lightness Races in Orbit
由于一义规则(ODR),多个enable_if重载中的各种条件不能有任何重叠。当然,在重载分辨率上它们会产生歧义,但是ODR与此有什么关系呢? - T.C.
显示剩余14条评论

12

选项1:enable_if在模板参数中使用

哪个解决方案应该被优先选择,为什么要避免其他方案?
  • It is usable in Constructors.

  • It is usable in user-defined conversion operator.

  • It requires C++11 or later.

  • In my opinion, it is the more readable (pre-C++20).

  • It is easy to misuse and produce errors with overloads:

    template<typename T, typename = std::enable_if_t<std::is_same<T, int>::value>>
    void f() {/*...*/}
    
    template<typename T, typename = std::enable_if_t<std::is_same<T, float>::value>>
    void f() {/*...*/} // Redefinition: both are just template<typename, typename> f()
    

    Notice the use of typename = std::enable_if_t<cond> instead of the correct std::enable_if_t<cond, int>::type = 0

选项2:返回类型中的enable_if

  • 它不能用于构造函数(它们没有返回类型)
  • 它不能用于用户定义的转换运算符(因为它无法推导)
  • 它可以在C++11之前使用。
  • 在我看来,第二种更易读(在C++20之前)。

选项3:enable_if函数参数中

  • 它可以在C++11之前使用。
  • 它可用于构造函数。
  • 它不能用于具有固定数量参数的方法,例如一元/二元运算符 + - * 等。
  • 它不能用于用户定义的转换运算符(它们没有参数)
  • 它在继承中是安全的(见下文)。
  • 更改函数签名(您基本上有一个额外的参数作为最后一个参数 void * = nullptr );这会导致指向函数的指针行为不同等。

选项4(C++20) requires

现在有requires clauses

  • It is usable in Constructors

  • It is usable in user-defined conversion operator.

  • It requires C++20

  • IMO, the most readable

  • It is safe to use with inheritance (see below).

  • Can use directly template parameter of the class

    template <typename T>
    struct Check4
    {
       T read() requires(std::is_same<T, int>::value) { return 42; }
       T read() requires(std::is_same<T, double>::value) { return 3.14; }   
    };
    
“成员函数模板”和“非成员函数模板”有何区别?
在继承和using方面存在微妙的差异:
根据using-declarator(强调是我的):

namespace.udecl

通过执行限定名查找([basic.lookup.qual],[class.member.lookup]),可以找到 using-declarator 引入的声明集中的名称,在 using-declarator 中排除被隐藏的函数,具体描述如下。

...

当使用using声明符将基类的声明引入到派生类中时,派生类中的成员函数和成员函数模板会覆盖和/或隐藏基类中具有相同名称、参数类型列表、cv限定符和引用限定符(如果有)的成员函数和成员函数模板(而不是冲突)。这些被隐藏或覆盖的声明从using声明符引入的声明集中排除。因此,在以下情况下,方法的模板参数和返回类型都会被隐藏:
struct Base
{
    template <std::size_t I, std::enable_if_t<I == 0>* = nullptr>
    void f() {}

    template <std::size_t I>
    std::enable_if_t<I == 0> g() {}
};

struct S : Base
{
    using Base::f; // Useless, f<0> is still hidden
    using Base::g; // Useless, g<0> is still hidden
    
    template <std::size_t I, std::enable_if_t<I == 1>* = nullptr>
    void f() {}

    template <std::size_t I>
    std::enable_if_t<I == 1> g() {}
};

演示(gcc错误地找到了基础函数)。

然而,使用参数时,类似的情况可以正常工作:

struct Base
{
    template <std::size_t I>
    void h(std::enable_if_t<I == 0>* = nullptr) {}
};

struct S : Base
{
    using Base::h; // Base::h<0> is visible
    
    template <std::size_t I>
    void h(std::enable_if_t<I == 1>* = nullptr) {}
};

演示

同时也需要使用requires

struct Base
{
    template <std::size_t I>
    void f() requires(I == 0) { std::cout << "Base f 0\n";}
};

struct S : Base
{
    using Base::f;
    
    template <std::size_t I>
    void f() requires(I == 1) {}
};

演示


1
“应该优先选择哪种解决方案,为什么要避免其他方案?”
当这个问题被提出时,<type_traits> 中的 std::enable_if 是最好的工具,而其他答案在 C++17 之前也是合理的。
现在在 C++20 中,我们通过 requires 直接获得编译器支持。
#include <concepts
template<typename T>
struct Check20
{
   template<typename U = T>
   U read() requires std::same_as <U, int>
   { return 42; }
   
   template<typename U = T>
   U read() requires std::same_as <U, double>
   { return 3.14; }   
};

2
template <typename U = T> 可以甚至省略(并将 U 替换为 T ;-) ) - undefined
1
模板 <typename U = T> 可以被省略(并将 U 替换为 T ;-) )。 - Jarod42

0
我想到了另一个可读性最高且最简单的选项。
尾返回类型
template <typename T>
auto GetName() -> std::enable_if_t<std::is_integral_v<T> || std::is_enum_v<T>, const char *>
{
    return "Inter or enum";
}

丑陋的东西只是放在函数名后面
在cpp20中的工作方式类似于requires

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接