移动语义将如何改进 "my way"?

How would move semantics improve "my way"?

背景

今天早些时候看了下面的回答,感觉像重新学习C++,乱七八糟的。

What are move semantics?

What is the copy-and-swap idiom?

然后我想知道我是否应该改变我的 "ways" 以使用这些令人兴奋的功能;我主要关心的是代码效率和清晰度(对我来说前者比后者更重要)。这让我想到了 post:

Why have move semantics?

我非常不同意(我同意这个答案,即);我不认为巧妙地使用指针可以使移动语义变得多余,无论是在效率还是清晰度方面。

问题

目前,每当我实现一个非平凡的对象时,我大致会这样做:

struct Y
{
    // Implement
    Y();
    void clear();
    Y& operator= ( const& Y );

    // Dependent
    ~Y() { clear(); }

    Y( const Y& that )
        : Y()
    {
        operator=(that);
    }

    // Y(Y&&): no need, use Y(const Y&)
    // Y& operator=(Y&&): no need, use Y& operator=(const Y&)
};

根据我今天读到的前两个 post 的理解,我想知道改为这样是否有益:

struct X
{
    // Implement
    X();
    X( const X& );

    void clear();
    void swap( X& );

    // Dependent
    ~X() { clear(); }

    X( X&& that )
        : X()
    {
        swap(that);
        // now: that <=> X()
        // and that.~X() will be called shortly
    }

    X& operator= ( X that ) // uses either X( X&& ) or X( const X& )
    { 
        swap(that); 
        return *this; 
        // now: that.~X() is called
    }

    // X& operator=(X&&): no need, use X& operator=(X)
};

现在,除了稍微复杂和冗长之外,我看不到第二个 (struct X) 会产生性能改进的情况,而且我发现它的可读性也较差。假设我的第二个代码正确使用了移动语义,它将如何改进我当前的 "way" 做事 (struct Y)?


注1:我认为唯一使后者更清晰的情况是"moving out of function"

X foo()
{
    X automatic_var;
    // do things
    return automatic_var;
}
// ...
X obj( foo() );

我认为使用 std::shared_ptr, and std::reference_wrapper if I get tired of get()

的替代方案
std::shared_ptr<Y> foo()
{
    std::shared_ptr<Y> sptr( new Y() );
    // do things
    return sptr;
}
// ...
auto sptr = foo();
std::reference_wrapper<Y> ref( *ptr.get() );

只是不太清楚,但同样有效。

注2:我真的努力让这个问题变得准确和可回答,而不是讨论; 想清楚,不要解释成"Why are move-semantics useful",这不是我问的

一个显着的改进是当函数 'consumes' 或以其他方式使用参数时(即:按值而不是按引用获取)。在那种情况下,如果没有移动语义,将一些左值作为参数传递将强制复制。

移动语义让调用者可以选择让函数复制值(尽管可能代价高昂)或'move'将值复制到函数中,因为知道在这次调用之后它不再使用左值这是传入的。

此外,如果函数使用 shared_ptr(或其他需要分配的包装器)只是为了能够将 return 值移出,则使用移动语义即可摆脱那个分配。所以这不仅仅是一个细节,也是一个性能提升。

虽然编写不使用移动语义并保持效率的代码非常容易,但使用移动语义允许支持它们的类型的用户拥有更简单的接口和用例(通过基本上始终使用值语义)。

编辑:

由于 OP 特别强调效率,值得补充的是,移动语义最多可以达到与没有它们时所能达到的相同效率。

据我所知,对于任何给定的代码片段,添加 std::move 与不添加相比会带来性能优势,只需重新编写代码即可达到移动甚至击败的最佳效率它。

我最近写的一段代码是一个生产者,它迭代一些数据,选择和转换以填充消费者的缓冲区。我写了一些通用的噱头,抽象了捕获数据并将其分流给消费者。

消费者要么是同线程(即:在生产者通过管道处理时处理),要么是另一个线程(标准单线程消费者交易),要么是 PC 平台的多线程。

在这种情况下,生产者代码看起来相当整洁和简单,因为它填充了一个容器,然后通过上述为每个平台定义的机制std::move将其送入消费者。

如果有人建议可以在不使用移动语义的情况下达到同样的效果,那是非常正确的。它只是让我拥有 - 我认为 - 更简单的代码。通常我支付的价格在对象的定义中更加笨拙,但在这种情况下它是一个 std 容器所以其他人做了那个工作;)

Currently, whenever I implement a non-trivial object, I roughly do this...

我相信你会在有更复杂的数据成员时放弃它——例如在默认构造期间执行某些校准、数据生成、文件 I/O 或资源获取的类型,只会在(重新)分配时被丢弃/释放。

I don't see a situation in which the second (struct X) would yield a performance improvement.

那你还不明白移动语义。我向你保证,这种情况是存在的。但是考虑到 '"Why are move-semantics useful",这不是我要问的。' 我不会在这里在您自己的代码的上下文中再次向您解释它们......自己去"please think it through"。如果您的想法再次失败,请尝试将 std::vector<> 添加到许多 MB 的数据和基准中。

std::shared_ptr 将您的数据存储在免费存储区(运行时开销),并具有线程安全原子 increment/decrement(运行时开销),并且可以为空(忽略它并获得错误,或者不断检查它的运行时和程序员时间开销),并且有一个非常重要的预测对象的生命周期(程序员开销)。

它在任何方面、形状或形式上都不如 move

移动在 NRVO 和其他形式的省略失败时发生,因此如果您有一个使用对象作为值的廉价移动意味着您可以依赖省略。没有便宜的举动,依靠省略是危险的:省略在实践中既脆弱又不受标准保证。

拥有高效的移动也使得对象的容器高效,而无需存储智能指针的容器。

唯一指针解决了共享指针的一些问题,除了强制自由存储和可空性,它还阻止了复制构造的简单使用。

顺便说一句,您计划的可移动模式存在问题。

首先,您在移动构造之前不必要地默认构造。有时默认构造不是免费的。

第二个 operator=(X) 标准中存在一些缺陷,效果不佳。我忘了为什么——组合或继承问题? -- 我会尽量记得回来编辑的。

如果默认构造几乎是免费的,并且交换是按元素进行的,那么这是一个 C++14 方法:

struct X{
  auto as_tie(){
    return std::tie( /* data fields of X here with commas */ );
  }
  friend void swap(X& lhs, X& rhs){
    std::swap(lhs.as_tie(), rhs.as_tie());
  }
  X();// implement
  X(X const&o):X(){
    as_tie()=o.as_tie();
  }
  X(X&&o):X(){
    as_tie()=std::move(o.as_tie());
  }
  X&operator=(X&&o)&{// note: lvalue this only
    X tmp{std::move(o)};
    swap(*this,o);
    return *this;
  }
  X&operator=(X const&o)&{// note: lvalue this only
    X tmp{o};
    swap(*this,o);
    return *this;
  }
};

现在,如果您有需要手动复制的组件(如 unique_ptr),上述方法将不起作用。我只是自己写了一个 value_ptr(告诉我如何复制)来让数据消费者远离这些细节。

as_tie 函数还使 ==<(以及相关的)易于编写。

如果X()不是平凡的,那么X(X&&)X(X const&)都可以手动写,效率会恢复。而且operator=(X&&)这么短,有两个也不错。

替代方案:

X& operator=(X&&o)&{
  as_tie()=std::move(o.as_tie());
  return *this;
}

= 的另一种实现,它有其优点(const& 也是如此)。它在某些情况下可能更有效,但异常安全性较差。它还消除了对 swap 的需要,但无论如何我都会保留 swap:逐元素交换是值得的。

补充一下其他人所说的:

您不应通过调用 operator= 来实现构造函数。你这样做:

Y( const Y& that ) : Y()
{
    operator=(that);
}

避免这种情况的原因是它需要默认构造一个 Y(如果 Y 没有默认构造函数,它甚至不起作用);而且它会毫无意义地创建和销毁任何可能由默认构造函数分配的资源。

您使用复制和交换习语为 X 正确修复了此问题,但随后您引入了类似的错误:

X( X&& that ) : X()
{
    swap(that);

移动构造函数应该构造,而不是交换。再次出现毫无意义的默认构造,然后破坏默认构造函数可能分配的任何资源。

您必须实际编写移动构造函数来移动每个成员。它必须是正确的,这样你的统一 copy/move-assignment 才能工作。


一个更一般的评论:你应该很少做所有这些。这就是为某些东西创建 RAII 包装器的方式;您的更复杂的对象应该由 RAII 兼容的子对象组成,以便它们可以遵循零规则。在大多数情况下,有预先存在的包装器,例如 shared_ptr,因此您不必编写自己的包装器。