完美转发和构造函数

3
我正在尝试理解完美转发和构造函数之间的交互。我的例子如下:
#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对象直接就地创建,而不必调用移动构造函数。然而,实际上存在一个移动构造函数调用,这让我想到可能创建了一个临时变量并进行了移动。为什么会调用移动构造函数?如何避免调用它?


你似乎期望编译器在&w_3.x的位置神奇地构造临时对象。我认为这是不可能的。虽然有返回值优化,但没有参数优化。换句话说,在wrapper构造函数中,&x != &(this->x)。你可以像std::vector::emplace一样做-拥有一个接受任意参数的构造函数,并将它们传递给x的构造函数。 - Igor Tandetnik
@IgorTandetnik 是的,我也会期望这样,而且我并不完全看得出与 RVO 的区别。但即使没有优化,我也不明白为什么需要一个移动构造函数。对我来说,移动构造函数将一个左值从一个地方移动到另一个地方。而在这里,我只有一个右值,并且我小心地通过 std::forward 保持了右值特性。发生了什么? - Bérenger
1
参数是一个 rvalue - 但最终的目标 w_3.x 是一个 lvalue。在这两者之间,必须发生某种形式的复制或移动。 - Igor Tandetnik
“对我来说,移动构造函数将左值从一个位置移动到另一个位置。” 这是错误的。复制构造函数复制左值;移动构造函数从右值移动。 - D Drmmr
2个回答

6
我希望能直接创建追踪对象,而不必调用移动构造函数。
我不知道你为什么会有这样的期望。转发正好可以实现此功能:移动或复制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让我们暂时忘记完美转发。我认为问题在于我希望一个对象在其最终目的地被构造。现在,如果它不在构造函数中,那么它现在可以通过保证的复制/移动省略(这里移动和复制几乎相同)来实现。我不明白的是为什么这在构造函数中不可能实现。我的测试用例证明了根据当前标准它不会发生,但我认为这不应该是标准规定和编译器实现不了的。我错过了什么关于构造函数如此特殊的东西吗?

在这方面,构造函数绝对没有任何特殊之处。您可以看到一个简单的自由函数具有完全相同的行为:

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中保证的复制省略如何工作,我推荐this 优秀的答案
总之,基本上prvalue表达式不会实现一个对象,而是可以初始化一个对象的东西。在使用它初始化对象之前尽可能长时间地持有表达式(从而避免一些复制/移动)。请参考链接的答案以获得更深入但友好的解释。
在初始化simple_foo的参数(或者你构造函数的参数)时使用你的表达式,就会强制要求将对象实例化并失去你的表达式。从现在开始,你不再拥有原始prvalue表达式,而是得到了一个创建的物化对象。这个对象现在需要被移动到你的最终目的地——我的本地变量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 现在被移动(因为它是 prvalue)到 simple_function 的参数 a
  • a 被复制(因为 a 是 lvalue)到 x
  • a 被移动(因为 std::movea 强制转换为 xvalue)到 x2

现在有了 C++17 保证的复制省略

  • X{} 不再即时生成对象。相反,表达式会被保存。
  • simple_function 的参数 a 现在可以从 X{} 表达式中初始化。不需要任何复制或移动。

其余部分仍然相同:

  • a 复制到 x1
  • a 移动到 x2

需要理解的是:一旦你给某个东西命名,那么这个东西必须存在。这个惊人简单的原因是,一旦你为某个东西命名,你可以多次引用它。请参见我在其他问题上的回答。你已经为 wrapper::wrapper 的参数命名了。我已经为 simple_function 的参数命名了。那就是你失去了 prvalue 表达式来初始化该命名对象的时刻。


如果你想使用C++17的拷贝省略保证,而又不喜欢原地方法,那么你需要避免命名。你可以通过lambda实现这一点。我经常看到的惯用法,包括在标准中,是原地方式。由于我还没有看到过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中,这保证没有复制和/或移动,并且即使X已删除复制构造函数和移动构造函数,它也可以工作。对象将在其最终目的地构造,就像您想要的那样。

1) 我在谈论正常使用时的转发惯用语。 std::forward 只是一种类型转换。


转发正是这样做的:移动或复制。这就是我不理解的地方。你是在一般情况下说还是在这个上下文中说?因为对我来说,除了在构造函数中,它既不复制也不移动,而是转发参数。现在我对构造函数感到困惑:为什么它不同呢?为什么对象没有被转发到&w_3.x? - Bérenger
@Bérenger,“转发到&w_3.x”是什么意思?您是否期望某种完美的传输方式,通过这种方式对象可以在内存中神奇地移动并更改其地址,而无需复制或移动? - Igor Tandetnik
@IgorTandetnik 好的,我想我通过你对OP的评论明白了。有点棘手的是,你谈论的是临时变量的地址。但是临时变量没有地址,对吗?所以我本来期望编译器在看到临时变量时不会构造它,直到它被移动到左值为止。然而,这似乎并非事实?:编译器不知道何时将其移动,因此必须在某个地方创建它,然后稍后移动它,例如在被构造函数要求时。 - Bérenger
@Bérenger 技术上说,st::forward 是一个 lvaluexvalue 的转换。如果结果是 lvalue,则可以从中复制结果;如果结果是 xvalue,则可以从中移动结果。 - bolov
1
@Bérenger,我不理解你的回答。int&& r = 1+2;是完全有效的。我不确定“将引用绑定到表达式”是什么意思,也不知道它与手头的问题有什么关系,也不知道引用DR2327与任何事情有关。 - Igor Tandetnik
显示剩余11条评论

0

引用(无论是左值引用还是右值引用)必须绑定到一个对象上,因此当初始化引用参数x时,需要创建一个临时对象。从这个意义上讲,完美转发并不是那么“完美”。

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


嗯...如果我说c++17引入的保证复制省略应该和可以扩展到构造函数(这将解决我的不必要的移动问题),那么我是正确的,而它还没有被实现的原因是委员会必须找出如何适当地实现它,而不会有更多的限制。 - Bérenger
@Bérenger 对的。 - xskxzr
好的,我会接受这个答案。如果有人在5年后遇到它,请更新标准的状态。 - Bérenger
1
转发无法改进,因为它完全按照广告所述的方式工作。它解决了命名左值引用、命名右值引用和命名转发引用都是左值的问题,并将命名左值引用强制转换为左值,将命名右值引用(它是一个左值)转换为 xvalue,以便进一步的代码可以区分这些类别。你需要完全不同的概念来实现你期望的功能。 - bolov
@Bérenger 我编辑了我的答案。希望现在它有意义了。 - xskxzr
显示剩余8条评论

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