C++编译器何时进行移动/复制操作?

3

我正在努力更好地理解C++中的移动方式。编译器如何知道何时进行移动操作,何时不进行移动操作?

#include <iostream>

template <typename T>
struct Container {
    T value;
    Container (T value): value(value) {}
    Container (const Container & i): value(i.value) {
        std::cout << "copying" << std::endl;
    }
    Container (Container && i): value(std::move(i.value)) {
        std::cout << "moving" << std::endl;
    }
};

void increment (Container<int> n) {
    std::cout << "incremented to " << ++n.value << std::endl;
}

int main () {
    Container<int> n (5);
    increment(std::move(n));
    std::cout << n.value << std::endl;
}

这个例子打印输出。
moving
incremented to 6
5

我期望int已经被移动,但之后我不应该能够继续使用它(并获得原始值)。

好的,也许int被复制了,因为value(std::move(i.value))在移动构造函数中复制了它。但我仍然不理解为什么Container<int> n在明确被移动之后还存在。


1
请注意,通过优化,由于复制构造函数(我相信也包括移动构造函数)中的副作用可能会被忽略,因此您的输出语句可能永远不会显示。 - rubenvb
所以我期望int已经被移动了,但之后我不应该还能继续使用它。为什么不能呢?编译器应该用垃圾数据来清除它,以及每个其他被std::move的基本类型,只是为了符合您错误的移动语义定义吗?那将代表一种反优化。移动存在是为了实现资源的最佳传输。按照定义,基本类型没有任何资源。 - underscore_d
@rubenvb 这是正确的,但并非在所有情况下都是如此。例如,在初始化和函数返回时允许复制省略。但是,我不认为它会在 OP 的示例中被允许。 - davmac
5个回答

5

std::move实际上只是将一个值更改为rvalue,可以进行移动操作。如果应用到int,它没有实际效果,因为“移动”int值没有任何意义。(虽然该值仍然是rvalue;但是,对int的rvalue引用通常与对int的任何其他类型的引用一样无用)。

这是因为“移动”是指将资源从一个对象转移到另一个对象,以避免需要复制这些资源(通过复制它们),因为这样的复制可能是非平凡的;例如,它可能需要动态内存分配。复制int值是平凡的,因此不需要特殊的移动语义。

因此,应用于您的示例中,移动Container<int>与复制它完全相同,当然输出不同(“移动”vs“复制”)。

(请注意,即使移动也要求源对象在操作完成后保持有效状态-它不会销毁源对象,就像您似乎认为它可能应该一样)。

至于编译器如何知道何时可以移动而不是复制,则取决于类型类别。您特定使用std::move将该值的类型类别更改为rvalue(或更具体地说是xvalue),这种类型的值可以与您移动构造函数中的rvalue引用参数匹配。一般来说,带有rvalue引用参数的重载优先于带有非rvalue引用参数的重载(确切规则很复杂)。

产生rvalue的另一种常见方法是作为未命名的临时对象-通过对对象或值执行某些操作而不将结果绑定到变量(a + b,其中ab都是类型为int的简单示例-结果是一个临时对象;它不存在于自己的变量中)。当更复杂的对象是临时的时,将其移动到最终目的地可能比复制它更有效,并且是安全的,因为不能在已移动的源对象的不确定状态之后使用它。因此,这样的值也是rvalue,并且将绑定到rvalue引用(并且可能被移动)。


措辞上有争议。它并不会将引用变为右值引用,而是将左值变为xvalue,仅保留prvalue的特性。 - Barry
@Barry std::move 相当于使用 static_cast 转换为右值引用类型,因此在这个意义上,它会改变(或者更确切地说,我认为是转换)一个引用为右值引用。在我的看法中,特别是针对这个例子,重要的事情是结果是一个右值引用,并且将绑定到一个右值引用参数(例如移动构造函数)。它是否是 xvalue 并不重要。 - davmac
1
@Barry(不过,我已经更新了答案 - 因为你是对的,更正确的说法是“变成rvalue”而不是“rvalue引用”)。 - davmac

3

所以我希望 int 已经被移动了

int 这样的原始类型没有移动构造函数,它们只是被复制。

但之后我仍然能使用它,这不应该是移动的效果

这不是移动的工作方式。您仍然可以正常使用已经移动的对象 - 它将处于有效状态。但是,该状态是不确定的,因此您不能指望对象具有任何特定的值,也不能使用具有前提条件的操作。据我所知,对于 int 对象没有任何具有前提条件的操作。

好吧,那么可能是 int 被复制了,因为 value(std::move(i.value)) 在移动构造函数中复制了它。

是的,它被复制了。

但我仍然不明白为什么 Container n 在肯定被移动之后仍然存在。

被移动后的对象并不会消失。通常情况下,它们会被保留在有效但不确定的状态中。你编写的这个特定的移动构造函数恰好会将对象留在被移动前完全相同的状态下。


C++ 中编译器何时会移动...

当使用非 const 右值参数进行复制初始化 并且 对象的类型具有移动构造函数时,或者当对象被分配非 const 右值操作数,并且对象的类型具有移动赋值运算符时。

编译器如何知道何时移动和何时不移动?

编译器了解所有表达式的类型。特别地,它知道一个表达式的类型是否是非 const 右值。编译器还知道您可以移动的所有类型的定义。基于定义,编译器知道一个类型是否具有移动构造函数。


1
移动操作只对具有非平凡状态且复制成本高昂的对象有意义。在移动结束时,原始值和新值都应处于有效状态。新值应等同于旧值,通常您不知道旧值的状态除了有效之外。
例如,在向量中,将内容从一个向量复制到另一个向量是昂贵的,因此移动操作只会交换内容。这更加高效,并且有一个额外的好处,即它不会失败。移动操作后,您仍然可以使用旧向量,但它将不再处于移动前的相同状态。

1
我希望int已经被移动,但是之后我不应该还能使用它(并获得原始值)。
在C++中,“move”实际上并不移动内存。对于像字符串和向量这样的复杂类型,“move”意味着交换内部指针而不是复制所有指向数据的指针。但是对于一个int,没有可以执行的快捷方式,所以数据只是被复制。这就是为什么您的原始值仍然存在,并且您仍然可以读取它。
C++编译器如何知道何时移动和何时不移动?
它不知道!
可移动类的作者知道。通常,将此“指针交换”逻辑放入“移动构造函数”中,这是一个接受rvalue引用的构造函数的花哨名称。自C++11以来的重载解析规则旨在每当您传递临时对象(或传递通过作弊获得的rvalue,例如编写std :: move(它的名称非常糟糕,因为它不会移动任何东西!))时调用此构造函数,因为这些是您通常想要“移出”的对象。
但是如果你没有在移动构造函数中编写任何“移动”代码,那么编译器就不会为你发明一些代码。

但我仍然不明白为什么Container<int> n在明确移动后仍然存在。

你没有编写任何代码,在将其传递给移动构造函数之后,它会使原始的Container<int>处于任何不同的状态。

同样,移动构造函数并不会自动执行任何类似的操作。它只是一个让放置你的移动逻辑的地方!

这是Container的另一种替代版本,移动被观察到:

template <typename T>
struct Container
{
    T* ptr;
    Container(T value) : ptr(new T(value)) {}
    Container(const Container& i): ptr(new T(*i.ptr)) {
        std::cout << "copying" << std::endl;
    }
    Container(Container&& i) {
        ptr = i.ptr;
        i.ptr = nullptr;
        // no data copied - we just "steal" the data by swapping pointers!
        std::cout << "moving" << std::endl;
    }
    ~Container() { delete ptr; }
};

我为了尽可能清楚地说明情况,使用原始指针;实际上,你可以使用std::unique_ptr,它在自己的移动构造函数中为你完成相同的工作!(保留HTML标签)

0

一旦对象被移动,标准中没有任何规定说您不能在之后使用它。像Sean Parent这样的顶级C++开发人员对此提出了抱怨,并希望标准强制执行更好的内容。移动对象旨在进行优化,可以将移动的对象留在任何状态。因此,编译器可以将原始值保持不变的情况下,将基元的移动实现为直接复制。

要更好地了解如何移动对象而不编写一些复杂的对象,请使用填充有几个值的std::vector。在第一次调用中,我们正在建立一个基本情况。向函数传递向量的引用以使该函数的参数调用复制构造函数。在第二次调用中,向量作为右值引用传递,因为我们在调用std::move时将其转换为这样的引用。在这种情况下,将调用向量的移动构造函数。在内部,新值(函数的参数)正在为自己获取传入的右值引用的内部指针。然后,它将传入向量的内部指针设置为类似于空向量的东西。我们在第三次调用中看到了这一点,我们进行了另一个副本。但是,向量在第二次调用中被移动,因此在第三次调用的副本中没有元素。

#include <iostream>
#include <vector>

void print_size(std::vector<int> value) {
    std::cout << value.size() << std::endl;
}

int main() {
    std::vector<int> v = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    std::cout << "Pass by reference to const: ";
    print_size(v);
    std::cout << "Pass by rvalue reference: ";
    print_size(std::move(v));
    std::cout << "Pass by reference to const: ";
    print_size(v);
    return 0;
}

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