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

38

我正在学习一门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_speedbool m_rotating。在Motorcycle类中,m_speedm_direction也是double类型。)

我不太明白为什么需要清除ori的值。

如果一个Motorcycle有任何我们想要“窃取”的指针成员,那么我们必须设置ori.m_thingy = nullptr,以免例如两次delete m_thingy。但当字段包含对象本身时,这是否重要呢?

我问了一个朋友,他们让我看这个页面,上面写着:

移动构造函数通常“窃取”参数持有的资源(例如指向动态分配对象的指针、文件描述符、TCP套接字、I/O流、运行中的线程等),而不是复制它们,并将参数留在某些有效但其他方面不确定的状态。例如,从std::stringstd::vector移动可能会导致参数被清空。但是,不应依赖这种行为。

谁定义了不确定状态的含义?我不明白为什么将速度设置为0.0比不修改更加不确定。并且正如引用中的最后一句话所说-代码不应依赖于移动后的Motorcycle状态,那为什么还要清除它呢?


15
我认为构造函数主体中的三行代码没有特别的原因。而且我也看不出为什么这个类需要移动构造函数 —— 它似乎和复制构造函数完全一样。 - Igor Tandetnik
构造函数其实还有一些我没有提到的内容(主要是一些日志记录)。但是,是的,这是一个相当人为的示例类,我希望他们能选择一个更好的。 - Lynn
3
@Lynn:在构造函数中进行IO操作本来就不好,这是我的意见。 - Patrick Collins
1
@PatrickCollins 我同意。我不是很喜欢这门课程。 :( - Lynn
1
@IgorTandetnik 它并不完全做相同的事情;它移动了 wheels 数组。 - user253751
2
@immibis 所以确实如此。然而,“Wheel”类的口头描述表明移动该数组与复制它是无法区分的。 - Igor Tandetnik
7个回答

34

它们不需要被清理。类的设计者决定将移动后的对象保留为零初始化,这是个好主意。

需要注意的是,在管理在析构函数中释放的资源的对象时,它确实是有意义的。例如,指向动态分配内存的指针。如果将指针保留在移动后的对象中未修改,则意味着两个对象都管理同一资源。它们的析构函数都会尝试释放该资源。


2
你的第二段完全是多余的,因为问题中已经包含了相同的内容:“如果摩托车有任何指针成员…”等。 - Kyle Strand
1
@KyleStrand 谢谢。我使用指针作为例子,但我的陈述意图更加普遍。并非所有资源都由指针处理。 - juanchopanza
3
不,被移动的对象的析构函数仍然会被调用。 - juanchopanza
1
@Joshua,这完全是公平的!我认为对于低级语言来说,移动语义是一个非常有趣的概念,所以如果你有机会的话,应该学习更多关于它的知识。如果你更喜欢非C++的环境,移动语义是Rust的核心部分,我相信它也是其他我不知道的语言中的一个特性。 - Kyle Strand
线程的第一个评论。摩托车没有“可见”的指针并不意味着它们不能在其字段中深藏。Array<>就是这样一个带有隐藏指针的例子。 - Pawel Kraszewski
显示剩余8条评论

14

谁定义了未确定状态的含义?

这个类的作者。

如果您查看 std::unique_ptr 的文档,您会发现在以下代码行之后:

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

pi实际上处于一个非常明确定义的状态。它将已经被reset()并且pi.get()将等于nullptr


11

你非常可能是正确的,这个类的作者在构造函数中放置了不必要的操作。

即使 m_wheels 是一个堆分配的类型,比如 std::vector,也没有理由去"清空"它,因为它已经被传递给了自己的移动构造函数:

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

然而,您还没有展示出足够的类以使我们能够知道在这种特定情况下是否需要显式的“清除”操作。根据Deduplicator的回答,在“移动自”状态下,应该正确执行以下函数:

// Destruction
~Motorcycle(); // This is the REALLY important one, of course
// Assignment
operator=(const Motorcycle&);
operator=(Motorcycle&&);
因此,您必须查看每个函数的实现,以确定移动构造函数是否正确。
如果这三个函数都使用默认实现(从您展示的内容来看,这似乎是合理的),那么就没有理由手动清除已移动的对象。但是,如果任何一个函数使用m_wheels、m_speed或m_direction的值来确定如何行为,则移动构造函数可能需要清除它们以确保正确的行为。
这样的类设计是不优雅且容易出错的,因为通常我们不希望原语的值在“移动后”状态下起作用,除非原语明确用作标志,以指示是否必须在析构函数中进行清理。
最终,作为C++类中的一个示例,所示的移动构造函数在技术上并不是“错误”的,因为它不会导致不良行为,但它似乎是一个糟糕的示例,因为它很可能会引起您发布此问题的混乱。

4

有几件事情必须要保证可以安全地使用已移动的对象:

  1. 析构它。
  2. 赋值/移动给它。
  3. 任何其他明确说明如此的操作。

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

另外,为了简洁和保留平凡性,请优先选择显式缺省函数,其次是隐式定义函数。


3
我可以编写一个类,移动后就不能再次分配/移动该类对象,但这不是必须的。所有标准类型都支持这两个保证,当它们在标准容器中操作您的类型时,标准可能会假定类型支持这些保证,但这并不意味着必须这样做。 - Yakk - Adam Nevraumont
1
@Yakk 嗯,你总是可以打破规则,没错。不过这不是一个好主意。 - Deduplicator
如何维护不变量? - juanchopanza
@juanchopanza:只有与我列出的要点相关的内容需要维护,因为调用任何依赖于其余部分的内容都是非法的。 - Deduplicator

2
这没有标准行为。就像指针一样,你可以在删除它们后继续使用它们。有些人认为你不应该关心并且不重用对象,但编译器不会禁止这样做。
这是一篇关于此话题的博客文章(不是我写的),其中包含一些有趣的评论讨论:

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

and the follow up:

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

这里还有最近关于这个话题的视频聊天,其中讨论了相关的论点。

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

基本上,它是关于将被移动的对象视为已删除的指针或使其处于“可移动状态”的安全状态。

2
谁定义了不确定状态的含义?
我认为是英语语言。 "Indeterminate" 在这里有其通常的英语意义之一,即“未确定的;范围不明确的;不确定的;未确定的;未解决或未决定”。移动对象的状态除了必须对该对象类型“有效”之外,没有受到标准的限制。它不需要每次从对象中移动时都保持相同。
如果我们只谈论实现提供的类型,那么适当的语言应该是“有效但未指定的状态”。但是我们将“未指定”这个词保留给讨论C++实现的细节,而不是用户代码允许做的细节。
“有效”针对每种类型单独定义。以整数类型为例,陷阱表示不是有效的,任何代表值的东西都是有效的。
这里的标准并不是说移动构造函数必须使对象变得不确定,而仅仅是说它不需要将其置于任何确定的状态。因此,虽然您正确地指出0不比旧值“更不确定”,但无论如何,这都是无关紧要的,因为移动构造函数不需要使旧对象“尽可能不确定”。
在这种情况下,类的作者选择将旧对象放入一个特定的状态。然后完全取决于他们是否记录该状态,如果他们这样做,那么完全取决于代码的用户是否依赖它。
我通常建议不要依赖它,因为在某些情况下,你编写的代码可能被认为是移动语义,实际上却进行了复制。例如,你在赋值的右侧放置std :: move,不关心对象是否是const,因为它可以以任何方式工作,然后有人来并且认为“啊,它已经被移动了,必须已经被清零了”。不对。他们忽略了当从Motorcycle中移动时,它会被清除,但当然不能清除const Motorcycle,无论文档向他们暗示什么 :-)
如果你要设置一个状态,那么真的是投硬币决定哪个状态。你可以将它设置为“清除”状态,也许与无参构造函数相匹配(如果有的话),因为这代表了最中立的价值观。我想在许多体系结构中,0 是设置某些内容的最便宜的(或许是共同的)值。或者,你可以将其设置为一些引人注目的值,希望当有人写了一个错误时,他们会不小心从一个对象移动,并使用它的值,他们会想到自己,“什么?光速?在住宅区?哦,对了,这个类在移动后设置的值,我可能做了这件事”。

1

源对象是rvalue,可能是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 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
}

抱歉,因为使用手机打字可能会出现错别字或自动更正。


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