使用 SFINAE 定义一个要求函数不存在的函数是否可以?

for*_*818 12 c++ sfinae language-lawyer template-instantiation

这个问题中的代码基于这个答案。我有点困惑它是如何产生输出的,以及它是否都定义良好

#include <type_traits>
#include <iostream>
#include <vector>

struct bar {};
void foo(bar) {}
struct moo {};

template<class T>
struct is_fooable {
    static std::false_type test(...);
    
    template<class U>
    static auto test(const U& u) -> decltype(foo(u), std::true_type{});
    static constexpr bool value = decltype(test(std::declval<T>()))::value;
};
template<class T> inline constexpr bool is_fooable_v = is_fooable<T>::value;

template <typename T>
std::enable_if_t<!is_fooable_v<T>,void> foo(T) {}

int main() {
    std::cout << is_fooable_v<bar>;
    std::cout << is_fooable_v<moo>;
    foo(bar{});
    foo(moo{});   
}
Run Code Online (Sandbox Code Playgroud)

使用 gcc 输出(与 clang 和 msvc 相同):

10
Run Code Online (Sandbox Code Playgroud)

如果is_fooable_v<moo>false,那么 SFINAE 不会丢弃foo模板,然后moo是“fooable”,is_fooable_v<moo>尽管如此false

我发现令人困惑的是,该特征的用途有限,因为它moo在用于定义后无法判断是否是“fooable foo<T>T==moo。不管潜在的混乱如何,代码是否定义良好?

可以根据测试函数是否存在的特征来定义函数吗?

Tur*_*ght 12

太长了;博士

\n
    \n
  • 这个模式是明确定义的
  • \n
  • std::enable_if_t<!is_fooable_v<T>,void> foo(T)在初始化期间可见is_fooable<T>::value\n
      \n
    • 但模板参数替换会失败,所以is_fooable<T>::valuefalse
    • \n
    \n
  • \n
  • 您可以使用第二个特征类来检测这两个函数(例如,struct is_really_fooable具有与 相同的定义is_fooable
  • \n
\n
\n

1. 免责声明

\n

本文仅考虑 C++20 标准。
\n我没有检查以前的标准是否符合。

\n

2. 模板化foo函数的可见性

\n

模板化的 foo 函数 ( template <typename T> std::enable_if_t<!is_fooable_v<T>,void> foo(T) {}) 从内部可见is_fooable并参与重载决策。

\n

这是由于test(std::declval<T>())依赖于T- 所以名称查找需要考虑模板定义的上下文实例化点的上下文:

\n
\n

13.8.2 从属名称 [temp.dep] (2)
\n如果运算符的操作数是类型相关表达式,则该运算符还表示从属名称。
\n[注意:此类名称是未绑定的,并且会在模板定义的上下文和实例化点的上下文 ( [temp.dep.candidate ) 的模板实例化点 ( [temp.point ] ) 处查找])。\xe2\x80\x94尾注]

\n
\n
// [...]\n\ntemplate<class T>\nstruct is_fooable { // <-- Template definition\n    static std::false_type test(...);\n    \n    template<class U>\n    static auto test(const U& u) -> decltype(foo(u), std::true_type{});\n    static constexpr bool value = decltype(test(std::declval<T>()))::value;\n};\n\n// is_fooable is dependent on T in this case,\n// so the point of instantiation will be the point where is_fooable_v<T> is itself instantiated\ntemplate<class T> inline constexpr bool is_fooable_v = is_fooable<T>::value;\n\ntemplate <typename T>\n// same as for is_fooable_v - \nstd::enable_if_t<!is_fooable_v<T>,void> foo(T) {}\n\nint main() {\n    std::cout << is_fooable_v<bar>; // <-- Point of instantiation for is_fooable<bar>\n    std::cout << is_fooable_v<moo>; // <-- Point of instantiation for is_fooable<moo>\n    foo(bar{});\n    foo(moo{});\n}\n
Run Code Online (Sandbox Code Playgroud)\n

因此,模板化foo函数从模板定义中不可见,但从实例化点来看它是可见的 - 并且由于我们需要查看两者,因此将考虑在is_fooable.

\n

注意:如果表达式不依赖于模板参数,例如foo(12),那么我们只需要考虑模板定义的上下文:

\n
\n

13.8 名称解析 [temp.res] (10)
\n如果名称不依赖于模板参数(如[temp.dep]中定义),则该名称的声明(或声明集)应位于以下范围内名称出现在模板定义中的位置;该名称绑定到在该点找到的声明(或多个声明),并且此绑定不受实例化点可见的声明的影响。

\n
\n

13.8.5.1 实例化点 [temp.point] (7)在这种情况下不适用 - 我们只有一个翻译单元,并且每个单元只有一个实例化点is_fooable<T>- 因此不会违反 odr 规则。

\n

注意:如果您在多个翻译单元中使用它,您仍然需要小心(但这基本上适用于任何类似特征的模板)。这将违反 ODR 规则(格式错误,ndr):

\n
// Translation unit 1\nstruct bar{};\nvoid foo(bar) {}\n\ntemplate<class T> struct is_fooable { /* ... */ };\n\n// would print 1\nvoid test1() { std::cout << is_fooable<bar>::value << std::endl; }\n\n// Translation unit 2\nstruct bar{};\n// foo(bar) not defined before test2\n\ntemplate<class T> struct is_fooable { /* ... */ };\n\n// would print 0\nvoid test2() { std::cout << is_fooable<bar>::value << std::endl; }\n\n// -> different definitions of is_fooable<bar>::value in different translation units\n// -> ill-formed, ndr\n
Run Code Online (Sandbox Code Playgroud)\n
\n

3.is_fooable<moo>::value最终结果如何false

\n

本质上,这是常量表达式与 SFINAE 结合的有趣应用。

\n

首先我们需要了解一些基本规则:

\n
    \n
  • 在变量自身初始化期间访问变量是未定义的行为。(例如int x = x;
    \n这是由于以下两个规则:(强调我的)

    \n
    \n

    6.7.3 生命周期 [basic.life] (1)
    \n[...] 类型 T 的对象的生命周期开始于:

    \n
      \n
    • 获得类型 T 具有适当对齐和大小的存储,并且
    • \n
    • 其初始化(如果有)已完成[...]
    • \n
    \n
    \n
    \n

    6.7.3 生命周期 [basic.life] (7)
    \n[...] 在对象的生命周期开始之前,但在分配该对象将占用的存储空间之后 [...],引用的任何泛左值原始对象可以被使用,但只能以有限的方式使用。[...] 如果出现以下情况,则程序具有未定义的行为:

    \n
      \n
    • 左值用于访问对象[...]
    • \n
    \n
    \n
  • \n
  • 非类型模板参数必须转换为常量表达式

    \n
    \n

    13.4.2 模板非类型参数[temp.arg.nontype] (2)
    \n非类型模板参数的模板参数应为模板类型的转换常量表达式 ( [expr.const] ) -范围

    \n
    \n
      \n
    • 转换后的常量表达式必须是常量表达式\n
      \n

      7.7 常量表达式 [expr.const] (10)
      \nT类型的转换常量表达式是隐式转换为 T 类型的表达式,其中转换后的表达式是常量表达式 [...]

      \n
      \n
    • \n
    • 常量表达式是核心常量表达式:\n
      \n

      7.7 常量表达式 [expr.const] (11)
      \n常量表达式可以是泛左值核心常量表达式,它引用作为常量表达式(如下定义)允许的结果的实体,也可以是纯右值核心常量表达式 [。 ..]

      \n
      \n
    • \n
    • 因此,要包装它,非类型模板参数必须具有一个核心常量表达式的值(忽略转换部分)
    • \n
    \n
  • \n
\n

现在我们可以把它拼凑起来:

\n
    \n
  • 让我们从以下开始std::cout << is_fooable_v<moo>;: 这将实例化is_fooable_v<moo>,进而实例化is_fooable<moo>::value
  • \n
  • 这样初始化就is_fooable<moo>::value开始了。\n
      \n
    • 的重载决策test()以两个函数作为候选函数进行test\n
        \n
      • test(...)很简单,并且始终是一个可行的功能(优先级较低)
      • \n
      • test(const U& u)将是可行的并将被实例化\n
          \n
        • 这反过来将导致 的重载解析foo(u),它也有 2 个潜在的候选函数:foo(bar)foo(T)\n
            \n
          • foo(bar)不可行,因为moo不能转换为bar
          • \n
          • foo(T)将是可行的并将被实例化\n
              \n
            • 在参数替换期间,foo(T)我们会遇到一个问题:foo(T)访问is_fooable<moo>::value- 仍未初始化(我们当前正在尝试初始化它)
            • \n
            • 这通常是未定义的行为 - 但因为我们处于不断评估的上下文中(非类型模板参数,例如需要std::enable_if_t转换的常量表达式),因此适用特殊规则:(强调我的)\n
              \n

              7.7 常量表达式 [expr.const] (5)
              \n表达式 E 是核心常量表达式,除非对 E 的求值遵循抽象机 ( [intro.execution] )的规则,将求值下列之一:
              \n [...]

              \n\n
              \n
                \n
              • 6.7.3 生命周期 [basic.life]介于4 个介绍 [intro]15 个预处理指令 [cpp]之间,因此此规则适用于访问其生命周期之外的变量。
              • \n
              • 因此,is_fooable_v<T>insidestd::enable_if_t<!is_fooable_v<T>,void>不是核心常量表达式,这是标准对非类型模板参数的要求
              • \n
              • 所以这个实例化foo(T)将是不正确的(而不是未定义的行为)
              • \n
              \n
            • \n
            • 因此模板参数替换foo(T)失败并且不会是一个可行的函数
            • \n
            \n
          • \n
          • 没有可行的功能foo(u)可以匹配
          • \n
          \n
        • \n
        • 由于没有可以调用的可行函数,Uin 的模板参数替换失败test(const U& u)foo(u)
        • \n
        \n
      • \n
      • test(const U& u)由于foo(u)格式不正确而不再可行 - 但test(...)仍然可行
      • \n
      • test(...)将是最好的可行功能(并且错误test(const U& u)将被 SFINAE 吞没)
      • \n
      \n
    • \n
    • test(...)在重载决策期间选择的,因此is_fooable<moo>::value将被初始化为false
    • \n
    \n
  • \n
  • 初始化is_fooable<moo>::value完成
  • \n
\n

因此,这是完全符合标准的,因为常量表达式中不允许未定义的行为(因此foo(T)在初始化期间总是会导致替换失败is_fooable<T>::value

\n

这全部包含在is_fooable结构中,因此即使您第一次调用foo(moo{});也会得到相同的结果,例如:

\n
int main() {\n  foo(moo{});\n  std::cout << is_fooable_v<moo>; // will still be false\n}\n
Run Code Online (Sandbox Code Playgroud)\n

它本质上与上面的顺序相同,只是您从函数 开始foo(T),然后导致 的实例化is_fooable_v<T>

\n
    \n
  • (有关事件发生的顺序,请参阅上文)
  • \n
  • is_fooable_v<T>被初始化为false
  • \n
  • 参数替换foo(T)成功\n->foo<moo>(moo{})将被调用
  • \n
\n

注意:如果您注释掉该test(...)函数(因此 SFINAE 将无法抑制 的替换失败test(const U& u)),那么您的编译器应该报告此替换错误(它的格式不正确,因此应该有一条诊断消息)。
\n这是 gcc 12.1 的结果:(仅有趣的部分)
\n godbolt

\n
In instantiation of \'constexpr const bool is_fooable<moo>::value\':\nerror: no matching function for call to \'is_fooable<moo>::test(moo)\'\nerror: no matching function for call to \'foo(const moo&)\'\nnote:  candidate: \'template<class T> std::enable_if_t<(! is_fooable_v<T>), void> foo(T)\'\nnote:  template argument deduction/substitution failed:\nerror: the value of \'is_fooable_v<moo>\' is not usable in a constant expression\nnote:  \'is_fooable_v<moo>\' used in its own initializer\nnote:  in template argument for type \'bool\'\n
Run Code Online (Sandbox Code Playgroud)\n
\n

四、备注

\n

is_fooable如果您使用 C++20 require 子句,则可以缩短您的特征,例如:

\n
template<class T>\nconstexpr bool is_fooable_v = requires(T const& t) { foo(t); };\n
Run Code Online (Sandbox Code Playgroud)\n

请注意,您不能使用概念,因为概念永远不会被实例化。

\n

如果您还想能够检测到,foo(T)可以通过定义第二个特征来实现。
\n第二个特征不会参与is_fooable使用的初始化恶作剧,因此能够检测到foo(T)过载:\n godbolt

\n
struct bar {};\nvoid foo(bar) {}\nstruct moo {};\n\ntemplate<class T>\nconstexpr bool is_fooable_v = requires(T const& t) { foo(t); };\n\ntemplate<class T>\nconstexpr bool is_really_fooable_v = requires(T const& t) { foo(t); };\n\ntemplate <typename T>\nstd::enable_if_t<!is_fooable_v<T>,void> foo(T) {}\n\nint main() {\n    foo(moo{});\n    std::cout << is_fooable_v<moo>; // 0\n    std::cout << is_really_fooable_v<moo>; // 1\n}\n
Run Code Online (Sandbox Code Playgroud)\n

是的,如果您愿意,您可以将这些特征叠加在一起,例如:
\n godbolt

\n
struct a {};\nstruct b {};\nstruct c {};\n\n\nvoid foo(a) { std::cout << "foo1" << std::endl; }\n\ntemplate<class T> inline constexpr bool is_fooable_v = requires(T const& t) { foo(t); };\ntemplate<class T> inline constexpr bool is_really_fooable_v = requires(T const& t) { foo(t); };\ntemplate<class T> inline constexpr bool is_really_really_fooable_v = requires(T const& t) { foo(t); };\n\n\ntemplate <class T, class = std::enable_if_t<std::is_same_v<T, b>>>\nstd::enable_if_t<!is_fooable_v<T>,void> foo(T) { std::cout << "foo2" << std::endl; }\n\ntemplate <class T>\nstd::enable_if_t<!is_really_fooable_v<T>,void> foo(T) { std::cout << "foo3" << std::endl; }\n\nint main() {\n    foo(a{});\n    foo(b{});\n    foo(c{});\n    std::cout << "a: "\n              << is_fooable_v<a> << " "\n              << is_really_fooable_v<a> << " " \n              << is_really_really_fooable_v<a> << std::endl;\n    std::cout << "b: "\n              << is_fooable_v<b> << " "\n              << is_really_fooable_v<b> << " " \n              << is_really_really_fooable_v<b> << std::endl;\n    std::cout << "c: "\n              << is_fooable_v<c> << " "\n              << is_really_fooable_v<c> << " " \n              << is_really_really_fooable_v<c> << std::endl;\n    /* Output:\n       foo1\n       foo2\n       foo3\n       a: 1 1 1\n       b: 0 1 1\n       c: 1 0 1\n    */\n}\n
Run Code Online (Sandbox Code Playgroud)\n

但这会变得非常非常混乱,所以我不会推荐它。

\n


Fel*_*leg -2

在给出答案之前,让我回顾一下代码为什么会这样工作:


“问题”就出在这两行:

template<class T> inline constexpr bool is_fooable_v = is_fooable<T>::value;

template <typename T> std::enable_if_t<!is_fooable_v<T>,void> foo(T) {}
Run Code Online (Sandbox Code Playgroud)

现在想象一下foo(moo)第二行正在创建。它必须执行,std::enable_if<!is_fooable_v<T>, void>而它又必须执行!is_fooable_v<T>。这样的表达式有什么价值T = moo?当然,为了找出答案,我们必须实例化is_fooable_v<moo>。经过一些计算后,编译器将此布尔值实例化为false.

就是这样:现在,is_fooable_v<moo> 因为该点false 在该表达式的所有其他计算中都相等(temp.inst#7)。所以当你在main()函数中使用它时:

std::cout << is_fooable_v<moo>;
Run Code Online (Sandbox Code Playgroud)

它变得“固定”为该值。

为了证明这一点,如果您在这两行中添加额外的一行,会发生什么:

template<> inline constexpr bool is_fooable_v<moo> = true;
Run Code Online (Sandbox Code Playgroud)

这当然会使该foo(moo{});行无法编译,但它也会使两行都带有std::cout打印行。

你自己找吧


至于问题:

可以根据测试函数是否存在的特征来定义函数吗?

好吧,答案取决于你是否真的能找到这个“技巧”的用处。

例如,如果您正在创建一个库,那么使用这种技术,您可以根据您的库的用户是否实际提供他/她自己的库来创建函数的默认实现。当然,有一个问题,我想在上面的回顾中展示:您不能is_fooable_v<>在代码中使用模板来测试此类用户提供的函数是否存在。

  • 也许你误解了这个问题。我承认它可以用更好的措辞来表达,尽管“不考虑潜在的混乱,代码定义是否明确?” 意思是:我知道该特征的计算结果为“false”,这会导致 SFINAEd 函数被定义。我还知道,人们可能期望(包括我自己)一旦定义了函数,该特征就会评估为“true”。不过,我可以接受这种混乱,所以让我们把它放在一边。问题是:代码合法吗?“语言律师”标签意味着:你能给我指出标准中相关的一些部分吗? (5认同)