Rust如何提供移动语义?

75
Rust语言网站声称移动语义是该语言的特性之一。但我看不出Rust如何实现移动语义。
在Rust中,只有Rust boxes才使用移动语义。
let x = Box::new(5);
let y: Box<i32> = x; // x is 'moved'

上述的Rust代码可以用C++来编写。
auto x = std::make_unique<int>(5);
auto y = std::move(x); // Note the explicit move

据我所知(如果我错了,请纠正我),
Rust根本没有构造函数,更不用说移动构造函数了。
没有对右值引用的支持。
没有办法使用右值参数创建函数重载。
那么Rust是如何提供移动语义的呢?

17
大多数情况下,C++会隐式复制数据,而Rust会隐式移动数据。这不仅适用于盒子(boxes)。 - user1804599
9
“这种语言没有C++那样支持move的复杂且容易出错的技巧!”你说得没错...;-) - Jason Orendorff
1
可以说是C++没有构造函数。C++所谓的“构造函数”实际上是初始化器,如果“初始化器”这个术语没有被绑定初始化语法占用的话,它们可能会被称为那个。真正的构造函数应该返回新创建的值,而C++的“构造函数”则在原地改变对象。(比较一下函数式编程语言中的构造函数,例如Haskell或Coq。) - user3840170
6个回答

68

我认为这是从C++过来时非常常见的问题。在C++中,当涉及到复制和移动时,你需要显式地处理所有事情。该语言围绕着复制和引用进行设计。随着C++11的推出,“移动”能力被添加到了该系统中。然而,Rust则采取了全新的方法。


Rust根本没有构造函数,更不用说移动构造函数了。

你不需要移动构造函数。Rust会将"没有拷贝构造函数",也就是"没有实现Copy特性"的所有东西都移动起来。

struct A;

fn test() {
    let a = A;
    let b = a;
    let c = a; // error, a is moved
}

Rust 的默认构造函数(按照约定)是一个称为 new 的关联函数:

struct A(i32);
impl A {
    fn new() -> A {
        A(5)
    }
}

更复杂的构造函数应该有更具表现力的名称。这是C ++中的命名构造函数惯用语。


不支持右值引用。

一直以来,这都是一个被请求的功能,详情请参见RFC问题998,但最可能的是您正在请求不同的功能:将东西移动到函数中:

struct A;

fn move_to(a: A) {
    // a is moved into here, you own it now.
}

fn test() {
    let a = A;
    move_to(a);
    let c = a; // error, a is moved
}

无法使用rvalue参数创建函数重载。

可以使用特性进行操作。

trait Ref {
    fn test(&self);
}

trait Move {
    fn test(self);
}

struct A;
impl Ref for A {
    fn test(&self) {
        println!("by ref");
    }
}
impl Move for A {
    fn test(self) {
        println!("by value");
    }
}
fn main() {
    let a = A;
    (&a).test(); // prints "by ref"
    a.test(); // prints "by value"
}

6
你是不是实际上在C++中缺少了一个功能,而Rust只是以不同的方式实现了它? - oli_obk
11
在Rust中,与明确移动物体不同的是,创建引用是显式的:let x = &a; 创建一个(const)指向a的引用,名称为 x。此外,如果您担心隐式移动会导致性能损失,在优化方面应该信任编译器。由于移动语义内置于编译器中,编译器可以进行许多优化。 - oli_obk
9
另外,Rust 仍然具有隐式复制。您只需要为您的类型实现 "Copy" trait,它就会从现在开始被复制。对于 POD(Plain Old Data)类型,您甚至可以告诉编译器自动生成 "Copy" trait 实现。 - oli_obk
17
@TheParamagneticCroissant:Rust不需要移动构造函数来“删除”先前的位置,因为一旦你从某个东西中移出,就会设置一个标志,表明该对象不应调用Drop::drop。将来,改进的分析将确保我们不再需要这样的标志。我不确定其中有多少已经实现了。 - oli_obk
6
一旦实现了“复制”(Copy),就不能强制移动Rust中的对象、类或其他东西了? - rubenvb
显示剩余10条评论

54
Rust的移动和复制语义与C++非常不同。我将采用一种不同的方法来解释它们,而不是现有的答案。
在C++中,复制是一个可以任意复杂的操作,这是由于自定义的复制构造函数。Rust不希望简单赋值或参数传递具有自定义语义,因此采取了不同的方法。
首先,在Rust中,赋值或参数传递始终只是一个简单的内存复制。
let foo = bar; // copies the bytes of bar to the location of foo (might be elided)

function(foo); // copies the bytes of foo to the parameter location (might be elided)

但是如果对象控制一些资源呢?假设我们正在处理一个简单的智能指针,Box
let b1 = Box::new(42);
let b2 = b1;

现在,如果只是简单地复制字节,那么析构函数(Rust中的drop)不会被调用两次,从而导致未定义的行为吗?

答案是Rust默认情况下是移动操作。这意味着它将字节复制到新位置,旧对象就消失了。在上面的第二行之后访问b1将导致编译错误。并且它的析构函数也不会被调用。该值已经移动到b2b1实际上可能已经不存在了。

这就是Rust中移动语义的工作方式。字节被复制过去,旧对象就消失了。

在一些关于C++的移动语义的讨论中,Rust的方式被称为“破坏性移动”。有人提出了向C++添加“移动析构函数”或类似功能的建议,以使其具有相同的语义。但是C++中实现的移动语义并不是这样的。旧对象被留下,它的析构函数仍然会被调用。因此,您需要一个移动构造函数来处理移动操作所需的自定义逻辑。移动只是一种特殊的构造函数/赋值运算符,它被期望以特定的方式行为。


默认情况下,Rust的赋值操作会移动对象,使旧位置无效。但是许多类型(整数、浮点数、共享引用)具有复制字节的语义,这是一种创建真正副本的有效方式,不需要忽略旧对象。这些类型应该实现Copy特性,编译器可以自动派生它们。
#[derive(Clone, Copy)]
struct JustTwoInts {
  one: i32,
  two: i32,
}

这会向编译器发出信号,表明赋值和参数传递不会使旧对象失效。
let j1 = JustTwoInts { one: 1, two: 2 };
let j2 = j1;
println!("Still allowed: {}", j1.one);

请注意,简单的复制和销毁的需求是互斥的;一个被标记为Copy的类型不能同时被标记为Drop
现在,如果你想复制一些东西,仅仅复制字节是不够的,比如一个向量,该怎么办呢?对于这个问题,语言本身没有提供特定的功能。从技术上讲,类型只需要一个返回以正确方式创建的新对象的函数即可。但是按照约定,我们通过实现“Clone”特质及其“clone”函数来实现这一目的。事实上,编译器还支持自动派生“Clone”,它会简单地克隆每个字段。
#[derive(Clone)]
struct JustTwoVecs {
  one: Vec<i32>,
  two: Vec<i32>,
}

let j1 = JustTwoVecs { one: vec![1], two: vec![2, 2] };
let j2 = j1.clone();

每当你派生`Copy`时,你也应该派生`Clone`,因为像`Vec`这样的容器在自身被克隆时会内部使用它。
#[derive(Copy, Clone)]
struct JustTwoInts { /* as before */ }

现在,这个有没有什么不好的地方呢?实际上,有一个相当大的缺点:因为将一个对象移动到另一个内存位置只是通过复制字节来完成,而没有自定义逻辑,所以一个类型不能有对自身的引用。事实上,Rust的生命周期系统使得构造这样的类型变得不安全成为不可能。
但是在我看来,这种权衡是值得的。

1
移动位于堆栈上的内存是否有意义?例如:let i: i32 = 12; let obj = MyStruct(i);在堆栈上为两个i32变量分配空间 - 即8个字节。但是在第二行移动后实际上只需要一个。 - Matthias
1
@Matthias 编译器可能会进行这种优化;但它很可能是在 LLVM 层面上进行的,而不涉及 Rust 的语义。 - Sebastian Redl
1
@SebastianRedl 所以在Rust中,_move_和_copy_都是使用memcpy,其中_move_禁止使用原始数据。智能的深度复制由Clone特征委托给类型作者。我的理解正确吗?感谢您的回答,您的回答解释了底层发生的情况! - legends2k
谢谢!Copy trait 与我的总结相符,我在这里只是为了未来的读者。 - legends2k
这是最好的答案。它也解决了我对Rust和C++ move之间差异的困惑。Rust在移动时进行位拷贝,而C++则重用相同的内存。截至Rust 1.51.0,编译器在我运行的测试中至少没有进行此优化。 - imaliazhar
显示剩余3条评论

16

Rust支持移动语义,具有以下功能:

  • 所有类型都是可移动的。

  • 默认情况下,在整个语言中将值发送到其他位置是一次移动操作。 对于非Copy类型,如Vec,在Rust中以下操作都属于移动操作:按值传递参数、返回值、赋值、按值进行模式匹配。

    Rust中没有std::move,因为它是默认的。你实际上一直在使用移动操作。

  • Rust知道移动后的值不能被使用。 如果你有一个值x: String并执行channel.send(x),将该值发送到另一个线程,编译器会知道x已经被移动。试图在移动后使用它会导致编译时错误,即“使用了已移动的值”。如果有任何人持有该值的引用(悬空指针),那么就不能移动该值。

  • Rust知道不要对移动后的值调用析构函数。 移动值会转让所有权,包括清理的责任。类型不必能够表示特殊的“值已经移动”状态。

  • 移动操作廉价并且性能可预测。 它基本上是memcpy。返回一个巨大的Vec始终很快——你只是在复制三个字。

  • Rust标准库在各处都使用和支持move语义。 我已经提到了通道,它们使用移动语义来安全地在线程之间传递值的所有权。其他亮点:在Rust中,所有类型都支持无副本std::mem::swap()IntoFrom标准转换特性是按值传递的;Vec 和其他集合有.drain().into_iter()方法,因此您可以轻松地砸碎一个数据结构,将其中所有值移出,并使用这些值构建一个新的数据结构。

  • Rust没有移动引用,但移动是Rust中强大且核心的概念,为程序提供了与C++一样的很多性能优势以及其他一些好处。


    4
    let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
    

    这是在内存中的表示方式:

    enter image description here

    然后我们将s赋值给t。

     let t = s;
    

    以下是发生的事情:

    enter image description here

    let t = s 将向量的三个头字段从s移动到t,现在t是该向量的所有者。 向量的元素仍然留在原地,字符串也没有发生任何变化。每个值仍然只有一个所有者。

    现在,如果我写下这个:

      let u = s
    

    我遇到了错误:"use of moved value: s"

    Rust 几乎对值的所有使用都应用移动语义(除了Copy类型)。将参数传递给函数会将所有权移交给函数的参数;从函数返回值会将所有权移交给调用者。构建元组会将值移动到元组中,等等。

    示例参考:Jim Blandy, Jason Orendorff, Leonora F. S. Tindall 的《Programming Rust》

    原始类型不能是空的且大小固定,而非原始类型可以增长并且可以为空。由于原始类型不能是空的且大小固定,因此分配内存来存储它们并处理它们相对容易。然而,处理非原始类型涉及计算它们将占据多少内存以及其他昂贵的操作。对于原始类型,Rust 会进行复制,而对于非原始类型,Rust 则会进行移动。

    fn main(){
        // this variable is stored in stack. primitive types are fixed size, we can store them on stack
        let x:i32=10;
        // s1 is stored in heap. os will assign memory for this. pointer of this memory will be stored inside stack. 
        // s1 is the owner of memory space in heap which stores "my name"
        // if we dont clear this memory, os will have no access to this memory. rust uses ownership to free the memory
        let s1=String::from("my name");
        // s1 will be cleared from the stack, s2 will be added to the stack poniting the same heap memory location
        // making new copy of this string will create extra overhead, so we MOVED the ownership of s1 into s2
        let s2=s1;
        // s3 is the pointer to s2 which points to heap memory. we Borrowed the ownership
        // Borrowing is similar borrowing in real life, you borrow a car from your friend, but its ownership does not change
        let s3=&s2;
        // this is creating new "my name" in heap and s4 stored as the pointer of this memory location on the heap
        let s4=s2.clone()
    }
    

    当我们将原始类型或非原始类型参数传递给函数时,同样的原理适用:
    fn main(){
        // since this is primitive stack_function will make copy of it so this will remain unchanged
        let stack_num=50;
        let mut heap_vec=vec![2,3,4];
        // when we pass a stack variable to a function, function will make a copy of that and will use the copy. "move" does not occur here
        stack_var_fn(stack_num);
        println!("The stack_num inside the main fn did not change:{}",stack_num);
        // the owner of heap_vec moved here and when function gets executed, it goes out of scope so the variable will be dropped
        // we can pass a reference to reach the value in heap. so we use the pointer of heap_vec
        // we use "&"" operator to indicate that we are passing a reference
        heap_var_fn(&heap_vec);
        println!("the heap_vec inside main is:{:?}",heap_vec);
    }
    // this fn that we pass an argument stored in stack
    fn stack_var_fn(mut var:i32){
        // we are changing the arguments value
        var=56;
        println!("Var inside stack_var_fn is :{}",var);
    }
    // this fn that we pass an arg that stored in heap
    fn heap_var_fn(var:&Vec<i32>){
        println!("Var:{:?}",var);
    }
    

    1
    如果您喜欢这个例子,请考虑购买一本带有螃蟹图案的 O'Reilly 书籍《Programming Rust》。该书是这里展示的图表的来源。第三章包含了 Rust 中移动语义的非常好的解释。(完全透明:我是其中的合著者,但我没有写那一章。) - Jason Orendorff
    2
    如果你担心自己购买一本完整的克隆书太贵,你也可以尝试将朋友的书复制到你的书架上或从图书馆借阅。 - Jason Orendorff
    @JasonOrendorff 我放了参考链接,这样够了吗?还是我需要删除答案?我是 Rust 的新手,只是想保留一个参考链接以便继续阅读。 - Yilmaz
    1
    哦,谢谢,Yilmaz!我不是要抱怨,但链接很感激。 - Jason Orendorff
    1
    @JasonOrendorff,我刚意识到你是这本书的作者 :) 我知道你的名字很熟悉,但一直想不起来 :) - Yilmaz

    1
    我想补充一点,不必使用memcpy进行移动。如果栈上的对象足够大,Rust编译器可能会选择传递对象的指针。

    从技术上讲,它传递了一个指针,但由于限制条件,也使用了memcpy...也许在将来的某些情况下,这种情况会得到改善。 - Chayim Friedman

    -4
    在C++中,类和结构的默认赋值是浅拷贝。值被复制,但指针引用的数据不会被复制。因此,修改一个实例会改变所有副本所引用的数据。其他实例中的值(例如用于管理)保持不变,可能导致不一致的状态。移动语义可以避免这种情况。以下是使用移动语义实现内存管理容器的C++示例:
    template <typename T>
    class object
    {
        T *p;
    public:
        object()
        {
            p=new T;
        }
        ~object()
        {
            if (p != (T *)0) delete p;
        }
        template <typename V> //type V is used to allow for conversions between reference and value
        object(object<V> &v)      //copy constructor with move semantic
        {
            p = v.p;      //move ownership
            v.p = (T *)0; //make sure it does not get deleted
        }
        object &operator=(object<T> &v) //move assignment
        {
            delete p;
            p = v.p;
            v.p = (T *)0;
            return *this;
        }
        T &operator*() { return *p; } //reference to object  *d
        T *operator->() { return p; } //pointer to object data  d->
    };
    

    这样的对象会自动进行垃圾回收,并且可以从函数中返回给调用程序。它非常高效,与Rust所做的相同:

    object<somestruct> somefn() //function returning an object
    {
       object<somestruct> a;
       auto b=a;  //move semantic; b becomes invalid
       return b;  //this moves the object to the caller
    }
    
    auto c=somefn();
    
    //now c owns the data; memory is freed after leaving the scope
    

    2
    这似乎没有回答OP提出的问题:Rust如何提供移动语义?。相反,这个答案似乎讨论了C++如何做类似的事情。 - Shepmaster

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