主要内容
这两个部分的介绍是为了给那些了解 rust 基本语法,写过一些小的 demo 代码,但却不是很清楚 ownership
和 borrowing
机制的码农看的。
我们从最简单的开始,然后一步一步逐渐复杂化,探索每一个细节。这个介绍文章假设你非常了解 let
,fn
,struct
,trait
和 impl
概念。
我们的目标是学习如果写 rust 代码,而且在写的过程中不要碰到有关 ownership
和 borrowing
的墙。
首先是 ownership 部分:
- 在简单介绍之后
- 学习
Copy Traits
,然后 - 学习 不可变(
Immutable
) - 和 可变(
Mutable
) 所有权规则 - 然后介绍一下所有权系统的强大之处
- 体现在内存管理方面
- 垃圾回收方面
- 以及并发方面
先决条件 – 你应该预先知道的
基于 作用域/栈 的内存管理很简单,因为我们已经很熟悉它的工作方式了。比如下面的代码,i
在 main
函数的最后发生了什么?
|
|
它离开了作用域然后释放了,对吗?
那如果我们把 i
传给另一个函数 foo
,它释放了几次?
|
|
是的,它释放了两次。第一次是在 foo
函数结束时候,第二次是在 main
函数结束之后。如果你修改了 foo
,那么完全不会影响 main
。
因为在调用 foo(i)
的时候值被拷贝了。
在 Rust 中,就像在 C艹 中(或者其他的语言),可以使用你的自定义类型来替代 Int。值将会被分配在当前栈,然后在离开作用域的时候被释放。
然而,Rust 编译器使用了不同的所有权规则,除非类型实现了 Copy
特质。
因此,我们先来讨论一下 Copy
特质,看看它是如何工作的。
Copy Trait
Copy
特质使类型的行为有了同样的方式:它每次赋值或者用作函数参数的时候,内存空间地址会被完整拷贝到另一个内存地址。实现这个特质允许你像使用内建 integer 一样使用你自定义类型。
内建的类型 i64
(一种类型的 integer) 实现了这个特质,还有很多类型都实现了它。
如果我们有一个 Info
结构体,我们可以通过实现 Copy
特质来让它可拷贝。
|
|
或者,使用 #[derive(Copy)]
属性实现同样的功能
|
|
没有实现 Copy
特质的类型将会移动到另一个地址并且服从所有权规则。
所有权(Ownership)
所有权规则规定:对于一个不可被拷贝的值,在任意一个地方,只能有一个所有者可以改变它。
因此,如果一个函数有责任删除一个值,它可以确认未来没有其他的所有者会访问,修改或者删除它。
抽象的概念就说这么多,来看看具体的例子!
向 Bob 问好,我们的人体模型结构体。。。
为了证明数据是如何移动的,我们创建一个新的结构体,叫做 Bob
。
|
|
在 Bob 的构造方法 new
中,我们宣布一下他的创建:
|
|
当 Bob 被销毁时(对不起啦 Bob),我们通过实现内建的 Drop::drop
特质来打印一下他的名字:
|
|
为了让 bob 的值在打印时候可以格式化,我们试下一下内建的 Show::fmt
特质方法:
|
|
来测试一下
当我们在 main
函数中创建 Bob 时,我们得到一个预期的结果:
|
|
|
|
好的,bob 挂了,但是啥时候挂的?
让我们在函数结尾插入一个 “print” 语句:
|
|
|
|
他在函数结束前就已经挂了。返回值并没有被赋值给任何东西,所以编译器调用了 drop
来在他创建的地方销毁他。
如果我们绑定返回值给一个变量呢?
|
|
|
|
有了 let
绑定,他在函数结束时才会挂,也就是在离开作用域时。所以,编译器在作用域结束时销毁绑定值。
销毁它,除非它移动了
值可以被移动到另一个地方,如果它移动了,它不会被销毁!
那么怎么移动呢?其实也就是简单地把它们作为值传给另一个函数。
让我们把我们的 bob 值传给一个叫 black_hole
的函数:
|
|
|
|
bob 在 black_hole 函数里挂了,而不是在 main
函数的结尾!
等一下,如果我们把 Bob 传给 black_hole 两次会发生什么呢?
|
|
|
|
编译器告诉我们不可以使用已经移动的值,而且做了详细的解释。
没有魔法 – 只是规则而已
为了实现 “不用垃圾回收机制的内存安全”,编译器不需要追踪你代码里的值。编译器可以通过观测函数体来确定函数需要销毁哪些值。
如果你知道规则,你也可以简单的做到这些。到目前为止,我们知道了一些规则:
- 没有使用的返回值会被销毁
- 所有被绑定到
let
的值都会在函数结尾处被销毁,除非它被移动了
现在我们知道了,内存安全实际是基于一个值只能有一个所有者。
然而,到目前为止我们只是讨论了不可变的 let
绑定 - 当我们的值可变时,规则会变得略微复杂。
所有权(可变性)
所有的值都可以被改变:我们只需要在变量名和 let
之间加入 mut
。举个栗子,我们可以改变 bob 的一些地方,比如说名字:
|
|
|
|
我们以名字 “A” 创建了他,但是以名字 “mutant” 销毁了他。
如果我们把这个值传给另一个函数 mutate
,我们同样可以把它赋值给 mut
修饰的变量:
|
|
|
|
所以,可以在任意时刻改变可变类型的值。
一些需要了解的知识点:函数参数也可以变成用 mut 修饰的,因为它也是用于绑定的关键字,就像 let 一样。所以上面的例子可以被改写成:
|
|
替换 mut 修饰的值
如果我们重写 mut
修饰的值会发生什么?来看看:
|
|
|
|
旧的值被销毁了。新的赋值会在作用域结尾处被销毁 – 除非它被移动了或者是被再次重写。
所有权(可变)规则
相对于不可变性,可变性只有一条附加规则:
- 没有使用的返回值会被销毁
- 所有被绑定到
let
的值都会在函数结尾处被销毁,除非它被移动了 - 被替换的值将被销毁
很明显了,在 Rust 中,我们可以确定一个值没有所有者或者被引用。
所有权系统的威力
刚开始这些所有权规则可能看起来有一些限制性,这仅仅是因为你使用了一套新的规则集。他们实际上并没有任何限制,只是给了我们另外一个基础设施去创建高级别架构。
这些架构在别的语言中可能很难实现安全性。即使他们做到了安全性,他们也不能保证编译期就确定安全性。
下面我们来概览一下它们,它们在一个独立的库中。
内存分配
目前为止我们只讨论了类似 integer 的值,它们存活在栈上。我们的测试人偶 Bob
就是这样一个类型的值。一些流行的语言也会把值保持在栈上(比如 C# 中的 struct,C艹中非 new 实例化出来的值),其他大部分语言都不是。
相反的,一个新的构造对象实例(在很多语言中通过 new
操作符创建的)在叫做堆内存的地方创建。
堆内存有很多优点。第一,它不受栈大小的限制。把一个大的结构放到栈上可能会马上溢出。第二,它的内存地址不会改变,不想栈地址。每次一个栈内存上面的值被移动或者拷贝,它所有的比特都会被拷贝到栈的另一个地址。当结构小的时候它是很有效率的(因为这样值会挨着嘛),不过随着结构变大就会变的很慢。
Box 解决了这个问题,处理方法是把我们创建的值移动到堆上,然后在栈上存一个指向堆地址的指针。
举个栗子,我们像这样创建 Bob
在堆内存上:
|
|
|
|
bob
的返回值类型是 Box<Bob>
。泛型类型使 Bob
的生命周期被 Box<Bob>
管理,同时当 Box
被删除时它也被随之删除。
Box
是不可拷贝的,其所有权机制跟上面提到的一样。当它在栈上的生命周期结束时,它的析构方法 drop
被调用,随后立即调用 Bob
的析构方法 drop
,同样会清理堆上的内存。
这些看似琐碎的实现其实是重大的策略。如果我们跟其他语言的解决办法比较,它们大都做了两件事情中的一件。它们或者留给你自己清理内存(用一些讨厌的 delete
语句,可能忘了调用或者调用多次),或者依赖垃圾回收机制去跟踪内存指针,当这些指针不被引用时清理内存。
在 Rust 中,所有权跟踪不会有运行时消耗,而且会在编译器确认正确性。这个简单的通过 Box
的内存处理方案,小而美,而且经常已经足够用了。
当它真的不够用时,其它的工具会来帮忙。
垃圾回收
Rust 有足够的低级别(low-level)工具来用一个库的方式实现垃圾回收。最简单的方案已经存在于 Rust 中:基于引用计数(注意:不是自动引用计数)的垃圾回收。
引用计数的解决方案很容易实现,不过它不是我们所说的真正意义上的垃圾回收。
因此在 Rust 中我们给它起了个新名字叫做:共享所有权。std::rc
库提供了在不容 Rc
处理者(handler)中共享同一个值所有权的机制。只要有一个处理者作用于这个值上,这个值就会保持存活。
举个栗子,我们可以创建一个被 Rc
处理者所管理的 bob 实例:
|
|
|
|
我们可以改变 black_hole
函数来接受一个 Rc<Bob>
然后检查是否被销毁。但是我们可以更简单的让它接受任意类型的 T
然后实现 Show
特质来方便打印。让我们来让它泛型话:
|
|
工作方式一样,不过我们以后有新的类型改变时就不需要改变这个函数啦~
现在,发送 Rc<Bob>
到 black_hole!
|
|
|
|
它在 black_hole 里存活了下来!不过它是怎么工作的?
一旦被 Rc
处理,那么其它地方只要有任何 Rc
克隆存在,bob 就会一直存活。Rc
在内部使用 Box
把值同引用计数一起放到堆内存。
每次一个新的处理者克隆被创建(通过调用 clone
或者 Rc
),引用计数就会加,当它生命周期结束时,引用计数会减。当引用计数达到 0 时,对象会被销毁,内存也会被释放。
注意:上面所说的 Rc
是不可变的。如果 Bob
的内容需要被改变,他可以被附加的用 RefCell
类型包装,这时就允许**借用(borrow)**单个 bob 实例的引用。下面的例子中它将能在 mutate
函数中被改变。
|
|
|
|
这个例子证明了不同的低级别工具是如何以最小的代价来组合实现精确的垃圾回收。
举个栗子,Rc
只能被使用在同一线程中。不过另外一个类型 Arc
可以被不同线程使用。一个可变的 Rc
可能会被多个对象互相引用。不过,Rc
可以被克隆成弱引用(Weak
) ,这样就不会参与引用计数了。更多的信息请查看官方文档。
最重要的是,更多高级的垃圾回收机制可以(即将)被实现,而且都是使用库的形式。
并发
让我们看看 Rust 是如何改变我们使用线程的方式的。默认的模式是没有竞态数据。竞态数据不会发生是因为有很多特别的安全方式作用在线程上。原则上,你可以通过这些安全特性创建你自己的线程库,简单是因为所有权模型本身是线程安全的。
考虑一下我们将一个 Bob
(可移动的) 和一个 integet
(可拷贝的)发送到一个新的 Rust 线程中:
|
|
|
|
发生了什么?首先,我们创建了两个值:bob
和 i
。然后我们通过 Thread::scoped
创建了一个新的线程并且传入一个闭包让它执行。闭包捕获了我们的两个值。
捕获对 bob
和 i
来说意味着不同的事。bob
会移动到闭包中(对外部线程不可用),i
则会被拷贝到闭包中,不过它还会对外部线程可用。
主线程会停下来等到新创建的线程执行完毕,执行完毕的标志是 guard
达到生命周期的尽头(在这个例子中也就是 main
函数的结尾)。
你可能会指出这跟你之前使用线程没啥区别 – 我们大家都知道如果没有某些同步机制是不可以随便在不同线程之间共享内存地址的。Rust 的不同之处在于它在编译期就强制了这一点。
当然,我们能获取到 guard
的返回值,也可以创建一个管道在不同线程中来发送和接受值。更多的信息请查看官方线程文档,管道文档,和这本书。
还有啥?
现在我们熟悉了 Rust 中的所有权系统,可以查看文档写安全代码啦。
不过还有一部分还没有讲到:借用系统(borrowing system)。
在这个系列的第二部分中我们将学习为什么借用机制很有用,已经怎么最好的使用它。
原文地址:http://nercury.github.io/rust/guide/2015/01/19/ownership.html