void_t和使用decltype的尾返回类型:它们完全可以互换吗?

16
考虑以下基于void_t的基本示例:
template<typename, typename = void_t<>>
struct S: std::false_type {};

template<typename T>
struct S<T, void_t<decltype(std::declval<T>().foo())>>: std::true_type {};

可以按照以下方式使用它:
template<typename T>
std::enable_if_t<S<T>::value> func() { }

同样的功能可以使用尾返回类型和decltype来实现:

template<typename T>
auto func() -> decltype(std::declval<T>().foo(), void()) { }

这适用于我想到的所有例子。我未能找到一种情况,在这种情况下,使用void_t或带有decltype的尾随返回类型不能使用其对应物。最复杂的情况可以通过尾随返回类型和重载的组合来解决(例如,当探测器用于在两个函数之间切换而不是用作禁用或启用某些内容的触发器时)。
这是真的吗?它们(void_t和带有必要重载的decltype作为尾随返回类型)完全可互换吗?否则,有什么情况下无法使用其中一种方法来解决约束条件,我必须使用特定的方法?

7
第一行中的 typename = void_t<> 可以改为 typename = void,这样更清晰易懂。 - vsoftco
这可能会引起您的兴趣:https://dev59.com/EZXfa4cB1Zd3GeqPZQwT - W.F.
1
@W.F. 它在特化的模板参数中使用了 decltype 代替 void_t。但我正在询问略有不同的事情。无论如何,谢谢。 - skypjack
2
@skypjack 我并没有说这是一个重复的问题,我只是觉得这可能会引起你的兴趣 :) - W.F.
阅读您的问题时,Yakk的“can_apply”立即浮现在脑海中。 - Rerito
2个回答

8

这是元编程的等价物:我应该写一个函数还是直接在代码中写。偏爱编写类型特征的原因与偏爱编写函数的原因相同:它更具自我说明性,可重用,易于调试。偏爱编写尾随 decltype 的原因与偏爱内联编写代码的原因类似:它是一次性的,不可重用,那么为什么要花费精力将其分解并想出一个合理的名称呢?

但是以下是您可能需要类型特征的原因:

重复

假设我有一个要多次检查的特征。像 fooable。如果我只编写一次类型特征,我可以将其视为概念:

template <class, class = void>
struct fooable : std::false_type {};

template <class T>
struct fooable<T, void_t<decltype(std::declval<T>().foo())>>
: std::true_type {};

现在我可以在很多地方使用同样的概念:

template <class T, std::enable_if_t<fooable<T>{}>* = nullptr>
void bar(T ) { ... }    

template <class T, std::enable_if_t<fooable<T>{}>* = nullptr>
void quux(T ) { ... }

对于检查多个表达式的概念,您不希望每次都重复它。

可组合性

与重复相伴随,组合两种不同的类型特征很容易:

template <class T>
using fooable_and_barable = std::conjunction<fooable<T>, barable<T>>;

合并两个返回类型需要编写出两个表达式的全部内容...

否定

使用类型特征轻松检查类型满足某一特征。只需使用!fooable<T>::value,即可实现。无法编写一个尾随的decltype表达式来检查某些内容是否无效。当您拥有两个不相交的重载时,可能会遇到这种情况:

template <class T, std::enable_if_t<fooable<T>::value>* = nullptr>
void bar(T ) { ... }

template <class T, std::enable_if_t<!fooable<T>::value>* = nullptr>
void bar(T ) { ... }

这很好地引出了...

标签分派

假设我们有一个短的类型特性,使用类型特性进行标签分派会更清晰:

template <class T> void bar(T , std::true_type fooable) { ... }
template <class T> void bar(T , std::false_type not_fooable) { ... }
template <class T> void bar(T v) { bar(v, fooable<T>{}); }

比起其他情况,这样会更好:

template <class T> auto bar(T v, int ) -> decltype(v.foo(), void()) { ... }
template <class T> void bar(T v, ... ) { ... }
template <class T> void bar(T v) { bar(v, 0); }

这里的 0int/... 有点奇怪,对吧?

static_assert

如果我不想在概念上进行 SFINAE,而是想要通过明确的信息来硬性失败怎么办?

template <class T>
struct requires_fooability {
    static_assert(fooable<T>{}, "T must be fooable!");
};

概念

如果我们某时(是否?)获得了概念,那么在涉及元编程的所有方面中,实际使用概念显然更加强大:

template <fooable T> void bar(T ) { ... }

那么,不存在任何情况可以使用其中一个而另一个不能吗?这只是一种“方便”的问题吗?确实有道理,感谢您详细的回答。 - skypjack
@skypjack static_assert - Barry
Touché。如果我没有至少读两遍答案,晚上不应该发表评论!抱歉。 - skypjack
@Jarod42 通过尾返回类型编写类型特征似乎是一种笨拙的方式,使用void_tcan_apply/is_detected会更简洁。 - Barry

-1

在我实现自己的Concepts Lite版本时,我使用了void_t和trailing decltype(顺便说一句,我成功了),这需要创建许多额外的类型特征,其中大部分以某种方式使用检测惯用语。我使用了void_t、trailing decltype和preceding decltype。

据我所知,这些选项在逻辑上是等价的,因此理想的、100%符合规范的编译器应该使用它们中的所有选项产生相同的结果。然而,问题在于特定的编译器可能会在不同的情况下遵循不同的实例化模式,其中一些模式可能超出内部编译器限制。例如,当我尝试使MSVC 2015 Update 23检测到相同类型的乘法存在时,唯一有效的解决方案是使用preceding decltype:

    template<typename T>
    struct has_multiplication
    {
        static no_value test_mul(...);

        template<typename U>
        static decltype(*(U*)(0) *= std::declval<U>() * std::declval<U>()) test_mul(const U&);

        static constexpr bool value = !std::is_same<no_value, decltype(test_mul(std::declval<T>())) >::value;
    };

每个其他版本都会产生内部编译器错误,尽管其中一些可以与Clang和GCC一起正常工作。 在这种特殊情况下,我还必须使用*(U*)(0)而不是declval,因为连续使用三个declval虽然完全合法,但对于编译器来说就太多了。

我的错误,我忘记了。实际上,我使用*(U*)(0)是因为declval生成类型的rvalue-ref,无法赋值,这就是我使用它的原因。但除此之外,其他所有内容仍然有效,这个版本可以运行,而其他版本则不能。

所以目前我的答案是:“只要你的编译器认为它们是相同的,它们就是相同的”。这是需要通过测试来找出的。我希望在接下来的MSVC和其他版本中,这将不再是一个问题。


declval<T&>() 会产生一个左值。(此外,许多 xvalues 可以被分配给它。) - Davis Herring

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