完美的转发和构造函数

Perfect forwarding and constructors

我正在尝试理解完美转发和构造函数的交互。我的例子如下:

#include <utility>
#include <iostream>


template<typename A, typename B>
using disable_if_same_or_derived =
  std::enable_if_t<
    !std::is_base_of<
      A,
      std::remove_reference_t<B>
    >::value
  >;


template<class T>
class wrapper {
  public:
    // perfect forwarding ctor in order not to copy or move if unnecessary
    template<
      class T0,
      class = disable_if_same_or_derived<wrapper,T0> // do not use this instead of the copy ctor
    > explicit
    wrapper(T0&& x)
      : x(std::forward<T0>(x))
    {}

  private:
    T x;
};


class trace {
  public:
    trace() {}
    trace(const trace&) { std::cout << "copy ctor\n"; }
    trace& operator=(const trace&) { std::cout << "copy assign\n"; return *this; }
    trace(trace&&) { std::cout << "move ctor\n"; }
    trace& operator=(trace&&) { std::cout << "move assign\n"; return *this; }
};


int main() {
  trace t1;
  wrapper<trace> w_1 {t1}; // prints "copy ctor": OK

  trace t2;
  wrapper<trace> w_2 {std::move(t2)}; // prints "move ctor": OK

  wrapper<trace> w_3 {trace()}; // prints "move ctor": why?
}

我希望我的 wrapper 完全没有开销。特别是,当像 w_3 一样将临时对象编组到包装器时,我希望直接在适当的位置创建 trace 对象,而不必调用 move ctor。但是,有一个 move ctor 调用,这让我觉得创建了一个临时文件,然后从中移动。为什么叫 move ctor?怎么不叫呢?

I would expect the trace object to be created directly in place, without having to call the move ctor.

我不知道你为什么这么期待。转发正是这样做的:移动或复制 1)。在您的示例中,您使用 trace() 创建一个临时文件,然后转发将其移至 x

如果您想就地构造一个 T 对象,那么您需要将参数传递给 T 的构造,而不是要移动或复制的 T 对象。

创建一个就地构造函数:

template <class... Args>
wrapper(std::in_place_t, Args&&... args)
    :x{std::forward<Args>(args)...}
{}

然后这样称呼它:

wrapper<trace> w_3 {std::in_place};
// or if you need to construct an `trace` object with arguments;
wrapper<trace> w_3 {std::in_place, a1, a2, a3};

解决 OP 对另一个答案的评论:

@bolov Lets forget perfect forwarding for a minute. I think the problem is that I want an object to be constructed at its final destination. Now if it is not in the constructor, it is now garanteed to happen with the garanteed copy/move elision (here move and copy are almost the same). What I don't understand is why this would not be possible in the constructor. My test case proves it does not happen according to the current standard, but I don't think this should be impossible to specify by the standard and implement by compilers. What do I miss that is so special about the ctor?

演员在这方面绝对没有什么特别之处。您可以使用一个简单的自由函数看到完全相同的行为:

template <class T>
auto simple_function(T&& a)
{
    X x = std::forward<T>(a);
    //  ^ guaranteed copy or move (depending on what kind of argument is provided
}

auto test()
{
    simple_function(X{});
}

上面的例子和你的OP类似。您可以将 simple_function 视为包装器构造函数的模拟,并将我的本地 x 变量视为 wrapper 中的数据成员的模拟。这方面的机制是一样的。

为了理解为什么你不能直接在 simple_function 的本地范围内构造对象(或者在你的情况下作为你的包装对象中的数据成员)你需要了解保证复制省略是如何工作的在 C++17 中我推荐 .

总结一下这个答案:基本上,纯右值表达式不会具体化对象,而是可以初始化对象的东西。在使用它来初始化对象之前,尽可能长时间地保留表达式(从而避免一些 copy/moves)。请参阅链接的答案以获得更深入但友好的解释。

当你的表达式被用来初始化 simple_foo 的参数(或你的构造函数的参数)时,你被迫具体化一个对象并失去你的表达式。从现在开始,您不再拥有原始的纯右值表达式,您拥有了一个创建的物化对象。而这个对象现在需要移动到你的最终目的地——我的本地x(或者你的数据成员x)。

如果我们稍微修改我的示例,我们可以看到有保证的复制省略在起作用:

auto simple_function(X a)
{
    X x = a;
    X x2 = std::move(a);
}


auto test()
{
    simple_function(X{});
}

如果没有省略,事情会是这样的:

  • X{} 创建一个临时对象作为 simple_function 的参数。让我们称之为 Temp1
  • Temp1 现在被移动(因为它是纯右值)到 simple_function
  • 的参数 a
  • a 被复制(因为 a 是左值)到 x
  • a 被移动(因为 std::movea 转换为 xvalue)到 x2

现在使用 C++17 保证复制省略

  • X{} 不再当场实体化一个对象。取而代之的是表达。
  • simple_function 的参数 a 现在可以从 X{} 表达式初始化。不涉及也不需要复制或移动。

剩下的都是一样的:

  • a被复制到x1
  • a 移入 x2

您需要了解的是:一旦您命名了某物,则该某物必须存在。一个非常简单的原因是,一旦你有了一个名字,你就可以多次引用它。请参阅我对此 的回答。您已将参数命名为 wrapper::wrapper。我将参数命名为simple_function。那是你失去你的纯右值表达式来初始化那个命名对象的那一刻。


如果你想使用 C++17 保证的复制省略并且你不喜欢你需要避免命名的就地方法:)你可以用 lambda 来做到这一点。我最常看到的习语(包括在标准中)是就地方式。由于我没有在野外见过lambda方式,我不知道我是否会推荐它。无论如何:

template<class T> class wrapper {
public:

    template <class F>
    wrapper(F initializer)
        : x{initializer()}
    {}

private:
    T x;
};

auto test()
{
    wrapper<X> w = [] { return X{};};
}

在 C++17 中,此受赠者不会移动副本 and/or,并且即使 X 已删除复制构造函数和移动构造函数,它也能正常工作。该对象将在其最终目的地构建,就像您想要的那样。


1) 我说的是转发成语,如果使用得当。 std::forward只是一个演员。

一个引用(左值引用或右值引用)必须绑定到一个对象,所以当引用参数x被初始化时,无论如何都需要物化一个临时对象。从这个意义上说,完美转发并不是"perfect".

从技术上讲,要省略此移动,编译器必须知道初始化参数和构造函数的定义。这是不可能的,因为它们可能位于不同的翻译单元中。