明确默认的移动构造函数有什么作用?

What does explicitly-defaulted move constructor do?

我是 C++ 的新手,需要一些关于移动构造函数的帮助。我有一些只能移动的对象,每个对象都有不同的行为,但它们都有一个句柄 int id,所以我尝试使用继承对它们进行建模,here 的代码

#include <iostream>
#include <vector>

class Base {
  protected:
    int id;

    Base() : id(0) { std::cout << "Base() called " << id << std::endl; }
    virtual ~Base() {}
    Base(const Base&) = delete;
    Base& operator=(const Base&) = delete;
    Base(Base&& other) noexcept = default;
    Base& operator=(Base&& other) noexcept = default;
};

class Foo : public Base {
  public:
    Foo(int id) {
        this->id = id;
        std::cout << "Foo() called " << id << std::endl;
    }
    ~Foo() { std::cout << "~Foo() called " << id << std::endl; }
    
    Foo(const Foo&) = delete;
    Foo& operator=(const Foo&) = delete;
    Foo(Foo&& other) noexcept = default;
    Foo& operator=(Foo&& other) noexcept = default;
};

int main() {
    std::vector<Foo> foos;

    for (int i = 33; i < 35; i++) {
        auto& foo = foos.emplace_back(i);
    }

    std::cout << "----------------------------" << std::endl;
    return 0;
}

每个派生 class 都有一个使用 id 销毁对象的特定析构函数(如果 id 为 0,它什么都不做),我需要为每个派生类型定义它。在这种情况下,编译器不会为我生成隐式声明的 copy/move ctors,所以我必须明确地让它只移动以遵循五的规则,但我不明白 =default 移动 ctor。

构造第二个 foo(34) 时,向量 foos 重新分配内存并将第一个 foo(33) 移动到新分配,但是,我看到这个的源和目标移动操作的 id 为 33,因此移动后,foo(33) 被销毁,在向量中留下无效的 foo 对象。在下面的输出中,我也没有看到第三个 ctor 调用,那么 foo(33) 到底被交换了什么?一个 ID 为 33 的空对象?那个 33 从哪里来,来自副本?但我已经明确删除了复制构造函数。

Base() called 0
Foo() called 33
Base() called 0
Foo() called 34
~Foo() called 33  <---- why 33?
----------------------------
~Foo() called 33
~Foo() called 34

现在,如果我改为手动定义移动构造函数:

class Foo : public Base {
  public:
    ......

    // Foo(Foo&& other) noexcept = default;
    // Foo& operator=(Foo&& other) noexcept = default;

    Foo(Foo&& other) noexcept { *this = std::move(other); }
    Foo& operator=(Foo&& other) noexcept {
        if (this != &other) {
            std::swap(id, other.id);
        }
        return *this;
    }
}
Base() called 0
Foo() called 33
Base() called 0
Foo() called 34
Base() called 0  <-- base call
~Foo() called 0  <-- now it's 0
----------------------------
~Foo() called 33
~Foo() called 34

这次明明是用base(0)对象交换foo(33),在id 0销毁后,我的foo对象仍然有效。那么默认的move ctor和我自己的move ctor有什么区别呢?

据我所知,我几乎不需要手动定义我的移动构造函数主体和移动赋值运算符,除非我直接在堆上分配内存。大多数时候我只会使用原始数据类型,例如 intfloat 或智能指针和本机支持 std::swap 的 STL 容器,所以我认为我会很好使用 =default 将 ctors 移动到各处并让编译器按成员进行交换,这似乎是错误的?也许我应该总是为每个 class 定义我自己的 move ctor?如何确保交换的对象处于可以安全销毁的干净空状态?

What does explicitly-defaulted move constructor do?

编译器生成的移动构造函数移动每个子对象。

so after the move, foo(33) is destroyed, leaving an invalid foo object in the vector.

销毁moved-from“foo(33)”对向量中保留的moved-to“foo(33)”对象没有影响;显示的析构函数不会执行任何会导致剩余对象变为“无效”的操作。

a null object that somehow has an id of 33?

'Null'是一个指针的状态。它对 class 实例没有概念意义 - 除非 class 模拟指针包装器,在这种情况下,您可以认为 class 实例在概念上为 null它包装为空;但这不适用于示例 class.

Where does that 33 come from, from copy?

你用 33 初始化了 int;这就是 33 的来源。 moved-to 对象也有 33,因为它是从值为 33 的整数中移出的。

In the output below, I also didn't see a third ctor call,

您没有看到移动构造函数的输出,因为默认移动构造函数不输出任何内容。

So what's the difference between the defaulted move ctor and my own move ctor?

默认移动构造函数通过从源的每个子对象移动来初始化所有子对象。相比之下,您的移动构造函数默认初始化子对象,然后委托给交换 id .

的用户定义的移动赋值

为了理解差异,如果手动编写默认移动构造函数可能会有所帮助:

Foo(Foo&& other) noexcept
    : id{std::move(other.id)} {}

Perhaps I should always define my own move ctor for every single class?

没有。大多数时候你应该定义 none 的特殊成员函数。此经验法则称为 0 规则。

但请记住,大部分时间与所有时间并不相同。有时——尽管很少——你确实需要一个用户定义的析构函数,在那些情况下,大多数时候你还需要用户定义的复制和移动构造函数和赋值运算符。这被称为 5 法则(在语言中引入移动语义之前也称为 3 法则)。

How can I ensure the swapped object is in a clean null state that can be safely destructed?

第一步是指定“干净的空状态”的含义。

如果存在不能被销毁的状态,那么通常最好指定一个class不变量,作为所有成员函数的post条件始终成立,时刻保证安全.保持这种不变性通常需要用户定义的特殊成员函数。

What does explicitly-defaulted move constructor do?

在自己的实现中将moved from对象中的相应成员变量变为xvalues. This is the same as if you use std::move后,会初始化其成员变量:

Base(Base&& other) noexcept : id(std::move(other.id)) {}

请注意,对于 int 等基本类型,这与复制值没有区别。如果您希望移动的源对象将其 id 设置为 0,请使用 std::exchange:

Base(Base&& other) noexcept : id(std::exchange(other.id, 0)) {}

Base& operator=(Base&& other) noexcept {
    if(this != &other) id = std::exchange(other.id, 0);
    return *this;
}

这样,Foo 移动构造函数和移动赋值运算符就可以 default 编辑并“做正确的事”。


建议:我认为 Base 会受益于可以直接初始化 id 的构造函数。示例:

Base(int Id) : id(Id) {
    std::cout << "Base() called " << id << std::endl;
}

Base() : Base(0) {} // delegate to the above

现在,以 id 作为参数的 Foo 构造函数可能如下所示:

Foo(int Id) : Base(Id) {
    std::cout << "Foo() called " << id << std::endl;
}