(为什么)移动构造函数或移动赋值运算符应该清除其参数?

(Why) should a move constructor or move assignment operator clear its argument?

我正在学习的 C++ 课程中的移动构造函数实现示例看起来有点像这样:

/// Move constructor
Motorcycle::Motorcycle(Motorcycle&& ori)
    : m_wheels(std::move(ori.m_wheels)),
      m_speed(ori.m_speed),
      m_direction(ori.m_direction)
{
    ori.m_wheels = array<Wheel, 2>();
    ori.m_speed      = 0.0;
    ori.m_direction  = 0.0;
}

(m_wheels 是类型 std::array<Wheel, 2> 的成员,而 Wheel 只包含一个 double m_speed 和一个 bool m_rotating。在摩托车class,m_speedm_direction也是double。)

不太明白为什么ori的值需要清空

如果 Motorcycle 有任何我们想要“窃取”的指针成员,那么当然,我们必须设置 ori.m_thingy = nullptr 以便不这样做,例如 delete m_thingy两次。但是当字段包含对象本身时有关系吗?

我问了一个朋友这件事,他们把我指向 this page,上面写着:

Move constructors typically "steal" the resources held by the argument (e.g. pointers to dynamically-allocated objects, file descriptors, TCP sockets, I/O streams, running threads, etc), rather than make copies of them, and leave the argument in some valid but otherwise indeterminate state. For example, moving from a std::string or from a std::vector may result in the argument being left empty. However, this behaviour should not be relied upon.

谁定义了不确定状态的含义?我看不出将速度设置为 0.0 比保持原样 不确定 更重要。就像引用中的最后一句话所说的那样——代码不应该依赖于从 Motorcycle 移出的状态,所以为什么要清理它呢?

它们不需要清洁。 class 的设计者认为将移出的对象保持零初始化是个好主意。

请注意,对于管理在析构函数中释放的资源的对象来说,确实有意义。例如,指向动态分配内存的指针。将移出对象中的指针保持不变意味着两个对象管理相同的资源。他们的两个析构函数都会尝试释放。

对此没有标准行为。与指针一样,您在删除它们后仍然可以使用它们。有些人认为您不应该关心并且只是不重用该对象,但编译器不会禁止这样做。

这是一篇关于此的博文(不是我写的),评论中有一些有趣的讨论:

https://foonathan.github.io/blog/2016/07/23/move-safety.html

及后续:

https://foonathan.github.io/blog/2016/08/24/move-default-ctor.html

这里还有最近关于这个话题的视频聊天,讨论了这个话题:

https://www.youtube.com/watch?v=g5RnXRGar1w

基本上,它是关于将移出的对象视为已删除的指针或使其安全 "moved-from-state"

Who defines what indeterminate state means?

class 的作者。

如果您查看 std::unique_ptr 的文档,您会在以下几行之后看到:

std::unique_ptr<int> pi = std::make_unique<int>(5);
auto pi2 = std::move(pi);

pi其实是非常明确的状态。它变成reset()并且pi.get() 等于nullptr.

对于移出的对象,必须安全地执行一些操作:

  1. 摧毁它。
  2. 分配/移动到它。
  3. 任何其他明确说明的操作。

因此,您应该尽快摆脱它们,同时提供这些少数保证,并且只有在它们有用并且您可以免费实现它们时才提供更多保证。
这通常意味着不清除源代码,而其他时候则意味着清除源代码。

作为补充,为了简洁和保留琐碎性,优先使用显式默认函数而不是用户定义的函数,并优先使用隐式定义的函数。

您很可能是正确的,class 的作者在构造函数中放置了不必要的操作。

即使 m_wheels 是堆分配类型,例如 std::vector,仍然没有理由 "clear" 它,因为它已经 被传递给它的 自己的 移动构造函数:

: m_wheels(std::move(ori.m_wheels)),   // invokes move-constructor of appropriate type

但是,您没有显示足够的 class 让我们 知道 在这种特殊情况下是否需要明确的 "clearing" 操作。根据 Deduplicator 的回答,以下函数应该在 "moved-from" 状态下正确运行:

// Destruction
~Motorcycle(); // This is the REALLY important one, of course
// Assignment
operator=(const Motorcycle&);
operator=(Motorcycle&&);

因此,您必须查看这些函数的每个的实现,以确定移动构造函数是否正确。

如果这三个都使用默认实现(从您所展示的情况来看,这似乎是合理的),那么就没有理由手动清除移出的对象。但是,如果这些函数中的任何一个 使用 m_wheelsm_speedm_direction 的值来确定如何运行,则移动构造函数可能需要清除它们以确保正确的行为。

这样的 class 设计将是不优雅且容易出错的,因为通常我们不希望基元的值在 "moved-from" 状态下重要,除非基元被明确用作标志以指示是否必须在析构函数中进行清理。

最终,作为在 C++ class 中使用的 示例,所示的移动构造函数在技术上并不 "wrong"行为,但这似乎是一个糟糕的例子,因为它很可能会引起混淆,导致你 post 这个问题。

Who defines what indeterminate state means?

英语,我想。 "Indeterminate" 这里有其通常的英语含义之一,"Not determinate; not precisely fixed in extent; indefinite; uncertain. Not established. Not settled or decided"。移出对象的状态不受标准约束,除了对于该对象类型它必须是 "valid" 之外。每次移动对象时都不需要相同。

如果我们只讨论实现提供的类型,那么合适的语言应该是 "valid but otherwise unspecified state"。但是我们保留这个词 "unspecified" 来讨论 C++ 实现的细节,而不是允许用户代码做什么的细节。

"Valid" 是为每种类型单独定义的。以整数类型为例,陷阱表示是无效的,任何表示值的东西都是有效的。

这里的标准并不是说移动构造函数必须使对象是不确定的,只是它不需要将它置于任何确定的状态。因此,尽管您认为 0 不是 "more indeterminate" 比旧值是正确的,但它在任何情况下都没有实际意义,因为移动构造函数不需要使旧对象成为 "as indeterminate as possible".

在这种情况下,class 的作者选择 将旧对象置于一种特定状态。然后,他们是否记录该状态完全取决于他们,如果他们记录了,则完全取决于代码的用户是否依赖它。

我通常建议 不要 依赖它,因为在某些情况下,您编写的代码认为它在语义上是一个移动,实际上是一个副本。例如,您将 std::move 放在赋值的右侧,而不关心对象是否为 const 因为无论哪种方式它都有效,然后其他人出现并认为 "ah, it's been moved from, it must have been cleared to zeros".没有。他们忽略了 Motorcycle 在移动时被清除,但是 const Motorcycle 当然不是,无论文档可能向他们建议什么:-)

如果您要设置一个状态,那么它实际上是一个掷硬币的状态。您可以将其设置为 "clear" 状态,也许与无参数构造函数(如果有的话)的作用相匹配,因为这代表了最中性的值。我想在许多架构上 0 是(可能是联合)最便宜的设置值。或者你可以将它设置为一些引人注目的值,希望当有人写了一个他们不小心从一个对象移动然后使用它的值的错误时,他们会自己思考 "What? The speed of light? In a residential street? Oh yeah, I remember, that's the value this class sets when moved-from, I probably did that".

源对象是一个右值,可能是一个 xvalue。所以我们需要关心的是那个物体即将被摧毁。

资源句柄或指针是区分移动和复制的最重要的项目:在操作之后,假定该句柄的所有权已转移。

很明显,正如您在问题中提到的,在移动过程中我们需要影响源对象,以便它不再将自己标识为已转移对象的所有者。

Thing::Thing(Thing&& rhs)
     : unqptr_(move(rhs.unqptr_))
     , rawptr_(rhs.rawptr_)
     , ownsraw_(rhs.ownsraw_)
{
    the.ownsraw_ = false;
}

请注意,我没有清除 rawptr_

这里做出了一个设计决定,只要所有权标志仅对一个对象为真,我们就不会关心是否存在悬挂指针。

但是,另一位工程师可能会决定应该清除指针,而不是随机 ub,以下错误会导致 nullptr 访问:

void MyClass::f(Thing&& t) {
    t_ = move(t);
    // ...
    cout << t;
}

在问题中显示的变量无害的情况下,可能不需要清除它们,但这取决于 class 设计。

考虑:

class Motorcycle
{
    float speed_ = 0.;
    static size_t s_inMotion = 0;
public:
    Motorcycle() = default;
    Motorcycle(Motorcycle&& rhs);
    Motorcycle& operator=(Motorcycle&& rhs);

    void setSpeed(float speed)
    {
        if (speed && !speed_)
            s_inMotion++;
        else if (!speed && speed_)
            s_inMotion--;
        speed_ = speed;
    }

    ~Motorcycle()
    {
        setSpeed(0);
    }
};

这是一个相当人为的证明,表明所有权不一定是简单的指针或布尔值,但可能是内部一致性问题。

移动运算符可以使用 setSpeed 来填充自身,或者它可以只做

Motorcycle::Motorcycle(Motorcycle&& rhs)
    : speed_(rhs.speed_)
{
    rhs.speed_ = 0;  // without this, s_inMotion gets confused
}

(在我的 phone 上输入的错别字或自动更正表示歉意)