正确地将一个`decltype(auto)`变量从函数中传递

32

这是对"是否有任何实际使用decltype(auto)变量的情况?"的跟进。

考虑以下场景 - 我想将函数f传递给另一个函数invoke_log_return,该函数将:

  1. 调用f

  2. stdout打印一些内容;

  3. 返回f的结果,避免不必要的复制/移动并允许复制省略。

请注意,如果f引发异常,则不应向stdout打印任何内容。这是我目前的代码:

template <typename F>
decltype(auto) invoke_log_return(F&& f)
{
    decltype(auto) result{std::forward<F>(f)()};
    std::printf("    ...logging here...\n");

    if constexpr(std::is_reference_v<decltype(result)>)
    {
        return decltype(result)(result);
    }
    else
    {
        return result;
    }
}

让我们考虑不同的可能性:

  • f返回一个prvalue时:

    • result将是一个对象;

    • invoke_log_return(f)将是一个prvalue(有资格进行拷贝省略)。

  • f返回一个lvaluexvalue时:

    • result将是一个引用;

    • invoke_log_return(f)将是一个lvaluexvalue

您可以在godbolt.org上查看测试应用程序。如您所见,g++对于prvalue情况执行NRVO,而clang++则不执行。

问题:

  • 这是“完美地”返回decltype(auto)变量的最短可能方式吗? 是否有更简单的方法来实现我想要的功能?

  • 是否可以将if constexpr { ... } else { ... }模式提取到单独的函数中? 提取的唯一方法似乎是使用宏。

  • clang++没有在上述prvalue情况下执行NRVO是否有任何充分的理由? 应该将其报告为潜在的增强,还是g++的NRVO优化在这里不合法?


这里是使用on_scope_success辅助工具的替代方案(如Barry Revzin所建议的):

template <typename F>
struct on_scope_success : F
{
    int _uncaught{std::uncaught_exceptions()};

    on_scope_success(F&& f) : F{std::forward<F>(f)} { }

    ~on_scope_success()
    {
        if(_uncaught == std::uncaught_exceptions()) {
            (*this)();
        }
    }
};

template <typename F>
decltype(auto) invoke_log_return_scope(F&& f)
{
    on_scope_success _{[]{ std::printf("    ...logging here...\n"); }};
    return std::forward<F>(f)();
}

invoke_log_return_scope虽然更短,但需要不同的函数行为和新抽象实现的思维模型。令人惊讶的是,g++clang++都使用此解决方案执行RVO/复制消除。

在godbolt.org上的实时示例

Ben Voigt所述,这种方法的一个主要缺点是f的返回值无法成为日志消息的一部分。


1
on_scope_success 也可以处理空返回类型。 - Jarod42
2
on scope success变量的缺点是无法记录返回值! - Ben Voigt
1
对我来说,更有趣的问题是:gcc的NRVO是否被允许?为什么clang不这样做呢? - Johannes Schaub - litb
2
@engf-010: return (result); 总是返回一个lvalue,在f产生xvalue的情况下执行复制而不是移动。 - Vittorio Romeo
@JohannesSchaub-litb:我同意,那是一个有趣的问题。你能开个后续吗? - Vittorio Romeo
显示剩余10条评论
4个回答

4

这是最简单和最清晰的编写方式:

template <typename F>
auto invoke_log_return(F&& f)
{ 
    auto result = f();
    std::printf("    ...logging here... %s\n", result.foo());    
    return result;
}

GCC 得到了预期的结果:正确(没有不必要的复制或移动)
    s()

in main

prvalue
    s()
    ...logging here... Foo!

lvalue
    s(const s&)
    ...logging here... Foo!

xvalue
    s(s&&)
    ...logging here... Foo!

如果代码很清晰,拥有相同的功能,但没有像竞争对手那样优化运行,那么这就是编译器优化失败,clang 应该解决这个问题。这种问题更适合在工具层面解决,而不是在应用程序实现中解决。

https://gcc.godbolt.org/z/50u-hT


3
但重点是原始版本不会制作静态实例的不必要副本,因此还支持既不可复制也不可移动的类型。 - StoryTeller - Unslander Monica
看一下GCC编译的输出执行,没有不必要的复制。而原始版本在日志部分的执行顺序上存在一些问题。 - David Kennedy

3
我们可以使用修改过的std::forward版本(避免使用"forward"名称以避免ADL问题)。
template <typename T>
T my_forward(std::remove_reference_t<T>& arg)
{
    return std::forward<T>(arg);
}

这个函数模板用于转发一个 decltype(auto) 变量。可以像这样使用:

template <typename F>
decltype(auto) invoke_log_return(F&& f)
{
    decltype(auto) result{std::forward<F>(f)()};
    std::printf("    ...logging here...\n");
    return my_forward<decltype(result)>(result);
}

如果 std::forward<F>(f)() 返回:

  • 一个 prvalue,则 result 是一个非引用类型,invoke_log_return 返回一个非引用类型;

  • 一个 lvalue,则 result 是一个左值引用,invoke_log_return 返回一个左值引用类型;

  • 一个 xvalue,则 result 是一个右值引用,invoke_log_return 返回一个右值引用类型。

(本质上是从我的https://dev59.com/zVMH5IYBdhLWcg3w9mF9#57440814复制而来)


3
这不允许进行复制省略。返回语句的操作数不是命名函数局部变量的 id-expression。因此,这是一种不优化的方式。这也是一个相当冗长的方法,用于实现更简单的无条件 return decltype(result)(result); - StoryTeller - Unslander Monica
@StoryTeller return decltype(result)(result); 也不允许复制省略。因此,我认为除了将其作为单独的分支之外,没有简单的方法来尝试复制省略... - L. F.

3

问题1: "这是否是以最短的方式“完美地”从函数中返回decltype(auto)变量?有没有更简单的方法来实现我想要的结果?"

证明最优性总是很困难的,但你的第一个解决方案已经非常简短了。实际上,你唯一可以希望删除的是if constexpr - 其他所有内容都是必需的(而不改变问题的重点)。

你的第二个解决方案通过牺牲一些额外的心智扭曲和无法在日志语句内使用变量来解决此问题,或者更普遍地说,它只能让你执行与你的结果无关的操作。

@david-kennedy的简单解决方案以巧妙的方式创建了一个prvalue,然后将其复制省略到其最终存储位置。如果你的用例支持此模型并且使用GCC,则几乎是最好的解决方案:

template <typename F>
auto invoke_log_return(F&& f)
{ 
    auto result = f();
    std::printf("    ...logging here...\n");    
    return result;
}

然而,这种解决方案根本没有实现完美转发,因为它的返回值类型与包装函数的返回值类型不同(它会去掉引用)。除了可能成为潜在错误源(int& a = f(); vs. int& a = wrapper(f);),这还会导致至少执行一次复制操作。

为了证明这一点,我已经修改了测试工具,使其不执行任何复制操作。因此,GCC输出显示了包装器本身执行的复制操作(clang执行更多的复制/移动操作):

    s()
in main

prvalue
    s()
    ...logging here...

lvalue
    s(const s&)
    ...logging here...

xvalue
    s(s&&)
    ...logging here...

https://gcc.godbolt.org/z/dfrYT8

然而,在GCC和clang上可以创建一种解决方案,它执行零复制/移动操作,通过摆脱if constexpr并将不同的实现移动到两个函数中,这些函数通过enable_if进行区分:

template <typename F>
auto invoke_log_return(F&& f)
    -> std::enable_if_t<
        std::is_reference_v<decltype(std::forward<F>(f)())>,
        decltype(std::forward<F>(f)())
    >
{
    decltype(auto) result{std::forward<F>(f)()};
    std::printf("    ...logging glvalue...\n");
    return decltype(result)(result);
}

template <typename F>
auto invoke_log_return(F&& f)
    -> std::enable_if_t<
        !std::is_reference_v<decltype(std::forward<F>(f)())>,
        decltype(std::forward<F>(f)())
    >
{
    decltype(auto) result{std::forward<F>(f)()};
    std::printf("    ...logging prvalue...\n");
    return result;
}

零拷贝:

    s()
in main

prvalue
    s()
    ...logging prvalue...

lvalue
    ...logging glvalue...

xvalue
    ...logging glvalue...

https://gcc.godbolt.org/z/YKrhbs

现在,当然,这会增加代码行数,尽管它以“更完美”的方式返回变量(因为两个编译器都执行了NRVO)。将功能提取到实用程序函数中会导致您的第二个问题。

Q2:“是否可以将if constexpr { ... } else { ... }模式提取到单独的函数中?唯一提取它的方法似乎是使用宏。”

不行,因为您无法省略将prvalue传递到函数中,这意味着将result传递到函数中将导致复制/移动。对于glvalues,这不是问题(如std :: forward所示)。

但是,可以稍微改变先前解决方案的控制流,以便它本身可以用作库函数:

template <typename F>
decltype(auto) invoke_log_return(F&& f) {
    return invoke_return(std::forward<F>(f), [](auto&& s) {
        std::printf("    ...logging value at %p...", static_cast<void*>(&s));
    });
}

https://gcc.godbolt.org/z/c5q93c

这个想法是使用enable_if解决方案,提供一个函数,该函数接受一个生成器函数和一个额外的函数,然后可以对临时值(prvalue、xvalue或lvalue)进行操作。库函数可能看起来像这样:

template <typename F, typename G>
auto invoke_return(F&& f, G&& g)
    -> std::enable_if_t<
        std::is_reference_v<decltype(std::forward<F>(f)())>,
        decltype(std::forward<F>(f)())
    >
{
    decltype(auto) result{std::forward<F>(f)()};
    std::forward<G>(g)(decltype(result)(result));
    return decltype(result)(result);
}

template <typename F, typename G>
auto invoke_return(F&& f, G&& g)
    -> std::enable_if_t<
        !std::is_reference_v<decltype(std::forward<F>(f)())>,
        decltype(std::forward<F>(f)())
    >
{
    decltype(auto) result{std::forward<F>(f)()};
    std::forward<G>(g)(result);
    return result;
}

问题三:“为什么clang++在上述prvalue情况下不执行NRVO,有没有好的理由?这应该报告为潜在增强,还是g++的NRVO优化在这里不合法?”

查看我的C++2a草案(N4835 §11.10.5/1.1 [class.copy.elision]),NRVO非常简单:

  • 在一个函数中[检查],返回类型为类[检查]时,在一个return语句[检查]中,当表达式是非易失性[检查]自动[检查]对象的名称(除了函数参数或由* handler *的异常声明(14.4)[检查]引入的变量),其类型与函数返回类型相同(忽略cv限定符),可以通过直接将自动对象构造到函数调用的返回对象中来省略复制/移动操作。

我不知道这应该无效的其他原因。


1

自从 C++23 接受了P2266R3,现在它变得非常简单:

template <typename F>
decltype(auto) invoke_log_return(F&& f)
{
    decltype(auto) result(std::forward<F>(f)());
    std::printf("    ...logging here...\n");
    return result;
}

将根据情况返回lvalue、xvalue或prvalue。

至于为什么clang表现不正常,我观察到autodecltype(auto)函数没有执行NRVO。它似乎也不喜欢constexpr if。这是一个clang实现质量问题。以下是在clang(C++23)中显示所需省略的内容:

template <typename F>
decltype(std::declval<F>()()) invoke_log_return(F&& f)
{
    decltype(auto) result(std::forward<F>(f)());
    std::printf("    ...logging here...\n");
    return result;
}

https://gcc.godbolt.org/z/sKv3vcGbh

查看此优秀答案https://dev59.com/UlMH5IYBdhLWcg3w9mF9#63320152,适用于C++17/20。他们的invoke_return“修复”了clang中的NRVO,因为它不使用decltype(auto)


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