从函数返回参数时是否可以避免复制?

Is it possible to avoid a copy when returning an argument from a function?

提问人:Maks Verver 提问时间:6/21/2022 最后编辑:Maks Verver 更新时间:6/21/2022 访问量:519

问:

假设我有带有一些就地操作的值类型。例如,像这样的东西:

using MyType = std::array<100, int>;

void Reverse(MyType &value) {
  std::reverse(value.begin(), value.end());
}

(类型和操作可能更复杂,但关键是操作就地工作,并且类型是可简单复制和可简单破坏的。请注意,MyType 足够大,可以考虑避免复制,但又足够小,以至于在堆上分配可能没有意义,并且由于它只包含基元,因此它不会从移动语义中受益。

我通常发现定义一个帮助程序函数很有帮助,该函数不会就地更改值,但会返回一个应用了操作的副本。除其他事项外,这支持如下代码:

MyType value = Reversed(SomeFunction());

考虑到就地操作,从逻辑上讲,应该在不复制结果的情况下进行计算。如何实现以避免不必要的副本?我愿意将 Reversed() 定义为标头中的内联函数,如果这是启用此优化所必需的。Reverse()valueSomeFunction()Reversed()

我可以想到两种实现此目的的方法:

inline MyType Reversed1(const MyType &value) {
  MyType result = value;
  Reverse(result);
  return result;
}

这受益于返回值优化,但只有在参数被复制到 之后。valueresult

inline MyType Reversed2(MyType value) {
  Reverse(value);
  return value;
}

这可能需要调用方复制参数,除非它已经是右值,但我认为返回值优化不是以这种方式启用的(或者是吗?),因此返回时会有一个副本。

有没有一种方法可以避免复制,最好是以最新的 C++ 标准保证的方式实现?Reversed()

C++语言

评论

2赞 HolyBlackCat 6/21/2022
最后一个选项在返回时隐式移动。这是要走的路。result
1赞 NathanOliver 6/21/2022
可以肯定的是,这就是你所需要的。MyType Reverse(MyType &value) { return {value.rbegin(), value.rend()}; }
0赞 Ted Lyngmo 6/21/2022
“t 在逻辑上应该是可以在不复制 SomeFunction() 结果的情况下计算值的” - 如何?如果对一个变量进行就地更改,并希望返回第二个变量,则该变量将是一个副本。不过,您可以返回对输入变量的引用。不相关:应该是std::array<26, int>std::array<int, 26>
0赞 Ted Lyngmo 6/21/2022
Reversed2(MyType 值) { ...;返回值;} - 我不认为返回值优化是以这种方式启用的(或者是吗?- 不,不是。它甚至被禁止。
0赞 Maks Verver 6/21/2022
@NathanOliver:这可能对这个例子很有效,但我对一般情况很感兴趣,其中 MyType 可能是一个随机结构,里面有一堆东西,而 Reverse() 可以是任何就地操作。

答:

2赞 HolyBlackCat 6/21/2022 #1

您的最后一个选择是要走的路(拼写错误除外):

MyType Reversed2(MyType value)
{
    Reverse(value);
    return value;
}

[N]RVO 不适用于 ,但至少它是隐式移动的,而不是复制的。return result;

您将有一个副本 + 一个移动,或两个移动,具体取决于参数的值类别。

评论

0赞 Maks Verver 6/21/2022
(对不起,我可能错过了错别字?您能否解释一下,如果 MyType 是一个大型的基元结构,例如 MyType = std::array<int, 10000>而不是大小为 1000 的 std::vector<int>移动有何帮助?在向量的情况下,我同意移动是要走的路。在数组的情况下,移动似乎与复制一样昂贵,我有兴趣找出一种方法来避免复制(如果可能的话)。
0赞 HolyBlackCat 6/21/2022
@MaksVerver 错别字是,应该是。对于一个大,它无济于事,是的。对于那些除了就地反转之外,我不会相信任何东西。return result;return value;std::array
0赞 Maks Verver 6/21/2022
谢谢,我修复了问题中的错别字。
0赞 David Grayson 6/21/2022
我确认这个答案给出了与我的相同性能(一次移动操作,没有副本)。也许不需要右值引用参数。但是,如果您想要并具有相同的名称,并让编译器根据上下文确定使用哪一个,那么右值引用参数似乎是要走的路。Reversed2Reverse
5赞 Ted Lyngmo 6/21/2022 #2

如果确实要就地反转字符串,以便对作为参数发送的字符串的更改在调用站点上可见,并且还希望按值返回它,则别无选择,只能复制它。它们是两个独立的实例。


一种替代方法:通过引用返回输入值。然后,它将引用您发送到函数的同一对象:

MyType& Reverse(MyType& value) {   // doesn't work with r-values
    std::reverse(std::begin(value), std::end(value));
    return value;
}

MyType Reverse(MyType&& value) {   // r-value, return a copy
    std::reverse(std::begin(value), std::end(value));
    return std::move(value);       // moving doesn't really matter for ints
}

另一种选择:创建就地返回的对象。然后,您将返回一个具有 RVO 生效的单独实例。没有移动或复制。不过,它将是一个与您发送到函数的实例不同的实例。

MyType Reverse(const MyType& value) {
    // Doesn't work with `std::array`s:
    return {std::rbegin(value), std::rend(value)}; 
}

如果可以像大多数其他容器一样从迭代器构造,则第二种选择将起作用,但它们不能。一种解决方案可能是创建一个帮助程序来确保 RVO 正常工作:std::array

using MyType = std::array<int, 26>;

namespace detail {
    template<size_t... I>
    constexpr MyType RevHelper(const MyType& value, std::index_sequence<I...>) {
        // construct the array in reverse in-place:
        return {value[sizeof...(I) - I - 1]...};    // RVO
    }
} // namespace detail

constexpr MyType Reverse(const MyType& value) {
    // get size() of array in a constexpr fashion:
    constexpr size_t asize = std::tuple_size_v<MyType>;

    // RVO:
    return detail::RevHelper(value, std::make_index_sequence<asize>{});
}

评论

1赞 Remy Lebeau 6/21/2022
第一种选择不起作用,因为左值引用无法绑定到右值。也许改用,修改温度,然后它进入最终的.Reversed(SomeFunction())MyType&&move()value
0赞 Ted Lyngmo 6/21/2022
@RemyLebeau 真的......我想我在这里还有其他问题。如果可以通过迭代器初始化第二个版本,那就没问题了。:-)从头再来。std::array
0赞 Ted Lyngmo 6/21/2022
@RemyLebeau 数组有点棘手......但我想出了“一些东西”。不确定数组大小是否为 UB。我想反对票会告诉你:-)constexpr
1赞 user17732522 6/21/2022
value.size() is somehow not constexpr, so a hack::这主要是因为您永远不能在常量表达式中使用非常量初始化的引用变量,即使没有应用左值到右值。希望这将得到解决(有一个提案)。目前,没有适用于一般范围类型的好解决方案。(见我的问题。具体来说,您可以使用 .从技术上讲,您的方法可能会因填充而失败。std::arraystd::tuple_size_v<std::remove_cvref_t<MyType>>
1赞 user17732522 6/21/2022
对不起,我先写了需要它。如果直接使用,则没有必要。decltype(value)MyType
2赞 David Grayson 6/21/2022 #3

可以使用帮助程序方法将就地操作转换为可用于 Rvalues 的操作。当我在 GCC 中测试它时,它会导致一个移动操作,但没有副本。该模式如下所示:

void Reversed(MyType & m);

MyType Reversed(MyType && m) {
  Reversed(m);
  return std::move(m);
}

以下是我用来测试此模式是否产生副本的完整代码:

#include <stdio.h>
#include <string.h>
#include <utility>

struct MyType {
  int * contents;

  MyType(int value0) {
    contents = new int[42];
    memset(contents, 0, sizeof(int) * 42);
    contents[0] = value0;
    printf("Created %p\n", this);
  }

  MyType(const MyType & other) {
    contents = new int[42];
    memcpy(contents, other.contents, sizeof(int) * 42);
    printf("Copied from %p to %p\n", &other, this);
  }

  MyType(MyType && other) {
    contents = other.contents;
    other.contents = nullptr;
    printf("Moved from %p to %p\n", &other, this);
  }

  ~MyType() {
    if (contents) { delete[] contents; }
  }
};

void Reversed(MyType & m) {
  for (int i = 0; i < 21; i++) {
    std::swap(m.contents[i], m.contents[41 - i]);
  }
}

MyType Reversed(MyType && m) {
  Reversed(m);
  return std::move(m);
}

MyType SomeFunction() {
  return MyType(7);
}

int main() {
  printf("In-place modification\n");
  MyType x = SomeFunction();
  Reversed(x);
  printf("%d\n", x.contents[41]);

  printf("RValue modification\n");
  MyType y = Reversed(SomeFunction());
  printf("%d\n", y.contents[41]);
}

我不确定标准是否保证了这种副本的缺乏,但我认为是这样,因为有些对象是不可复制的。

注意:最初的问题只是关于如何避免复制,但恐怕球门柱正在发生变化,现在我们正试图避免复制和移动。我介绍的右值函数似乎确实执行了一次移动操作。但是,如果我们不能消除移动操作,我建议 OP 重新设计他们的类,以便移动更便宜,或者放弃这种更短语法的想法。

评论

0赞 NathanOliver 6/21/2022
代码具有未定义的行为。传入并将其移动到其丢弃的返回值中。这意味着 in main 处于 moved from 状态。godbolt.org/z/M53aEaabaxReversedReversedx
0赞 David Grayson 6/21/2022
哎呀,我忘了在析构函数中使用,误用了。感谢您指出这一点,并向我展示了一种测试代码的新方法。如果您使用所有错误,则会消失,我不认为这是未定义的行为。你只需要确保处于移动状态的对象可以净地破坏,我做到了。delete[]deletedelete[]
2赞 n. m. could be an AI 6/21/2022 #4

有一个诀窍。它并不漂亮,但它有效。

Make accept not a T, but a function return T.这样调用:Reversed

MyType value = Reversed(SomeFunction); // note no `SomeFunction()`

以下是 Reversed 的完整实现:

template <class Generator>
MyType Reversed(Generator&& g)
{
  MyType t{g()};
  reverse(t);
  return t;
}

这不会产生任何副本或移动。我检查了。

如果你觉得特别讨厌,就这样做

#define Reversed(x) Reversed([](){return x;})

然后返回呼叫。同样,没有复制或移动。如果您设法通过公司代码审查来挤压它,则可以获得奖励积分。Reversed(SomeFunction())

评论

0赞 Maks Verver 6/21/2022
这是非常聪明的,我可以确认它按要求工作,但对于我来说,在实际代码中使用它可能有点太聪明了,特别是因为需要宏来支持 SomeFunction() 也可以接受一些参数的一般情况。
1赞 n. m. could be an AI 6/21/2022
参数不是问题,为此,您需要通过引用在 lambda 中捕获所有内容: 但是,如果您认为它太聪明而无法在生产代码中使用,那么您是绝对正确的。#define Reversed(x) Reversed([&](){return (x);})
0赞 Goswin von Brederlow 6/21/2022 #5

当你写

MyType value = Reversed(SomeFunction());

我看到发生了两件事:将执行 RVO,因此它直接写入并复制到参数中,或者创建一个临时对象并传递引用。无论你怎么写,都会至少有 2 个对象,你必须从一个对象反转到另一个对象。ReversedvalueSomeFunctionReversed

编译器无法执行我所说的 AVO,即参数值优化。您希望将函数的参数存储在函数的返回值中,以便可以执行就地操作。有了这个特性,编译器可以执行 RVO-AVO-RVO,并直接在最终变量中创建它的返回值。ReversedSomeFunctionvalue

但我认为你可以这样做:

MyType &&value = SomeFunctio();
reverse(value);

换个角度看:假设你确实找到了一种方法来执行就地操作,然后在Reveresed

MyType &&value = Reversed(SomeFunction());

将创建一个临时的,但编译器必须将该临时的生存期延长到 的生存期。这在直接赋值中有效,但是编译器应该如何知道这只会传递临时通过?SomeFunctionvalueReversed

0赞 Maks Verver 6/21/2022 #6

从答案和评论来看,共识是没有办法在 C++ 中实现这一点。

这是没有实现 available 的一般答案是有道理的,因为编译器不知道返回值与参数相同,因此它必然会为它们分配单独的空格。MyType Reversed(MyType)

但看起来即使有 Reversed() 的实现可用,GCC 和 Clang 都不会优化副本:https://godbolt.org/z/KW6Y3vsdf

所以我认为短篇小说是我所要求的是不可能的。如果避免复制很重要,调用方应显式写入:

MyType value = SomeFunction();
Reverse(value);
// etc.