如果说前面的坑我们一直在用小铲子挖的话,那么今天的坑就是用挖掘机挖的。

今天要介绍的是Rust的一个核心概念:Ownership。全文将分为什么是Ownership以及Ownership的传递类型两部分。

什么是Ownership

每种编程语言都有自己的一套内存管理的方法。有些需要显式的分配和回收内存(如C),有些语言则依赖于垃圾回收器来回收不使用的内存(如Java)。而Rust不属于以上任何一种,它有一套自己的内存管理规则,叫做Ownership。

在具体介绍Ownership之前,我想要先声明一点。Rust入坑指南:常规套路一文中介绍的数据类型,其数据都是存储在栈中。而像String或一些自定义的复杂数据结构(我们以后会对它们进行详细介绍),其数据则存储在堆内存中。明确了这一点后,我们来看下Ownership的规则有哪些。

Ownership的规则

  • 在Rust中,每一个值都有对应的变量,这个变量称为值的owner

  • 一个值在某一时刻只能有一个owner

  • 当owner超出作用域后,值会被销毁

这三条规则非常重要,记住他们会帮助你更好的理解本文。

变量作用域

Ownership的规则中,有一条是owner超过范围后,值会被销毁。那么owner的范围又是如何定义的呢?在Rust中,花括号通常是变量范围作用域的标志。最常见的在一个函数中,变量s的范围从定义开始生效,直到函数结束,变量失效。

1fn main() {                      // s is not valid here, it’s not yet declared
2    let s = "hello";   // s is valid from this point forward
3
4    // do stuff with s
5}                      // this scope is now over, and s is no longer valid

这个这和其他大多数编程语言很像,对于大多数编程语言,都是从变量定义开始,为变量分配内存。而回收内存则是八仙过海各显神通。对于有依赖GC的语言来说,并不需要关心内存的回收。而有些语言则需要显式回收内存。显式回收就会存在一定的问题,比如忘记回收或者重复回收。为了对开发者更加友好,Rust使用自动回收内存的方法,即在变量超出作用域时,回收为该变量分配的内存。

Ownership的移动

前面我们提到,花括号通常是变量作用域隔离的标志(即Ownership失效)。除了花括号以外,还有其他的一些情况会使Ownership发生变化,先来看两段代码。

1let x = 5;
2let y = x;
3println!("x: {}", x);
1let s1 = String::from("hello");
2let s2 = s1;
3println!("s1: {}", s1);

作者注:双冒号是Rust中函数引用的标志,上面的意思是引用String中的from函数,这个函数通常用来构建一个字符串对象。

这两段代码看起来唯一的区别就是变量的类型,第一段使用的是整数型,第二段使用的是字符串型。而执行结果却是第一段可以正常打印x的值,第二段却报错了。这是什么原因呢?

我们来分析一下代码。对于第一段代码,首先有个整数值5,赋给了变量x,然后把x的值copy了一份,又赋值给了y。最后我们成功打印x。看起来比较符合逻辑。实际上Rust也是这么操作的。

对于第二段代码我们想象中,也可以是这样的过程,但实际上Rust并不是这样做的。先来说原因:对于较大的对象来说,这样的复制是非常浪费空间和时间的。那么Rust中实际情况是怎么样呢?

首先,我们需要了解Rust中String类型的结构:

上图中左侧是String对象的结构,包括指向内容的指针、长度和容量。这里长度和容量相同,我们暂时先不关注。后面详细介绍String类型时会提到两者的区别。这部分内容都存储在栈内存中。右侧部分是字符串的内容,这部分存储在堆内存中。

有的朋友可能想到了,既然复制内容会造成资源浪费,那我只复制结构这部分好了,内容再多,我复制的内容长度也是可控的,而且也是在栈中复制,和整数类型类似。这个方法听起来不错,我们来分析一下。按照上面这种说法,内存结构大概是这个样子。

这种会有什么问题呢?还记得Ownership的规则吗?owner超出作用域时,回收其数据所占用的内存。在这个例子中,当函数执行结束时,s1和s2同时超出作用域,那么上图中右侧这块内存就会被释放两次。这也会产生不可预知的bug。

Rust为了解决这一问题,在执行let s2 = s1;这句代码时,认为s1已经超出了作用域,即右侧的内容的owner已经变成了s2,也可以说s1的ownership转移给了s2。也就是下图所示的情况。

另一种实现:clone

如果你确实需要深度拷贝,即复制堆内存中的数据。Rust也可以做到,它提供了一个公共方法叫做clone。

1let s1 = String::from("hello");
2let s2 = s1.clone();
3
4println!("s1 = {}, s2 = {}", s1, s2);

clone的方法执行后,内存结构如下图:

函数间转移

前面我们聊到的是Ownership在String之间转移,在函数间也是一样的。

 1fn main() {2    let s = String::from("hello");  // s 作用域开始34    takes_ownership(s);             // s's 的值进入函数5                                    // ... s在这里已经无效67} // s在这之前已经失效8fn takes_ownership(some_string: String) { // some_string 作用域开始9    println!("{}", some_string);
10} // some_string 超出作用域并调用了drop函数
11  // 内存被释放

那有没有办法在执行takes_ownership函数后使s继续生效呢?一般我们会想到在函数中将ownership还回来。然后很自然的就想到我们之前介绍的函数的返回值。既然传参可以转移ownership,那么返回值应该也可以。于是我们可以这样操作:

 1fn main() {2    let s1 = String::from("hello");     // s2 comes into scope34    let s2 = takes_and_gives_back(s1);  // s1 被转移到函数中5                                        // takes_and_gives_back,6                                        // 将ownership还给s27} // s2超出作用域,内存被回收,s1在之前已经失效89
10// takes_and_gives_back 接收一个字符串然后返回一个
11fn takes_and_gives_back(a_string: String) -> String { // a_string 开始作用域
12
13    a_string  // a_string 被返回,ownership转移到函数外
14}

这样做是可以实现我们的需求,但是有点太麻烦了,幸好Rust也觉得这样很麻烦。它为我们提供了另一种方法:引用(references)。

引用和借用

引用的方法很简单,只需要加一个&符。

 1fn main() {2    let s1 = String::from("hello");34    let len = calculate_length(&s1);56    println!("The length of '{}' is {}.", s1, len);7}89fn calculate_length(s: &String) -> usize {
10    s.len()
11}

这种形式可以在没有ownership的情况下访问某个值。其原理如下图:

这个例子和我们在前面写的例子很相似。仔细观察会发现一些端倪。主要有两点不同:

  1. 在传入参数的时候,s1前面加了&符。这意味着我们创建了一个s1的引用,它并不是数据的owner,因此在它超出作用域时也不会销毁数据。

  2. 函数在接收参数时,变量类型String前也加了&符。这表示参数要接收的是一个字符串的引用对象。

我们把函数中接收引用的参数称为借用。就像实际生活中我写完了作业,可以借给你抄一下,但它不属于你,抄完你还要还给我。(友情提示:非紧急情况不要抄作业)

另外还需要注意,我的作业可以借给你抄,但是你不能改我写的作业,我本来写对了你给我改错了,以后我还怎么借给你?所以,在calculate_length中,s是不可以修改的。

可修改引用

如果我发现我写错了,让你帮我改一下怎么办?我授权给你,让你帮忙修改,你也需要表示能帮我修改就可以了。Rust也有办法。还记得我们前面介绍的可变变量和不可变变量吗?引用也是类似,我们可以使用mut关键字使引用可修改。

1fn main() {
2    let mut s = String::from("hello");
3
4    change(&mut s);
5}
6
7fn change(some_string: &mut String) {
8    some_string.push_str(", world");
9}

这样,我们就能在函数中对引用的值进行修改了。不过这里还要注意一点,在同一作用域内,对于同一个值,只能有一个可修改的引用。这也是因为Rust不想有并发修改数据的情况出现。

如果需要使用多个可修改引用,我们可以自己创建新的作用域:

1let mut s = String::from("hello");
2
3{
4    let r1 = &mut s;
5
6} // r1 超出作用域
7
8let r2 = &mut s;

另一个冲突就是“读写冲突”,即不可变引用和可变引用之间的限制。

1let mut s = String::from("hello");
2
3let r1 = &s; // no problem
4let r2 = &s; // no problem
5let r3 = &mut s; // BIG PROBLEM
6
7println!("{}, {}, and {}", r1, r2, r3);

这样的代码在编译时也会报错。这是因为不可变引用不希望在被使用之前,其指向的值被修改。这里只要稍微处理一下就可以了:

1let mut s = String::from("hello");
2
3let r1 = &s; // no problem
4let r2 = &s; // no problem
5println!("{} and {}", r1, r2);
6// r1 和 r2 不再使用
7
8let r3 = &mut s; // no problem
9println!("{}", r3);

Rust编译器会在第一个print语句之后判断出r1和r2不会再被使用,此时r3还没有创建,它们的作用域不会有交集。所以这段代码是合法的。

空指针

对于可操作指针的编程语言来讲,最令人头疼的问题也许就是空指针了。通常情况是,在回收内存以后,又使用了指向这块内存的指针。而Rust的编译器帮助我们避免了这个问题(再次感谢Rust编译器)。

1fn main() {
2    let reference_to_nothing = dangle();
3}
4
5fn dangle() -> &String {
6    let s = String::from("hello");
7
8    &s
9}

来看一下上面这个例子。在dangle函数中,返回值是字符串s的引用。但是在函数结束时,s的内存已经被回收了。所以s的引用就成了空指针。此时就会报expected lifetime parameter的编译错误。

另一种引用:Slice

除了引用之外,还有另一种没有ownership的数据类型叫做Slice。Slice是一种使用集合中一段序列的引用。

这里通过一个简单的例子来说明Slice的使用方法。假设我们需要得到给你字符串中的第一个单词。你会怎么做?其实很简单,遍历每个字符,如果遇到空格,就返回之前遍历过的字符的集合。

对字符串的遍历方法我来剧透一下,as_bytes函数可以把字符串分解成字节数组,iter是返回集合中每个元素的方法,enumerate是提取这些元素,并且返回(元素位置,元素值)这样的二元组的方法。这样是不是可以写出来了。

 1fn first_word(s: &String) -> usize {2    let bytes = s.as_bytes();34    for (i, &item) in bytes.iter().enumerate() {5        if item == b' ' {6            return i;7        }8    }9
10    s.len()
11}

来,感受下这个例子,虽然它返回的是第一个空格的位置,但是只要会字符串截取,还是可以达到目的的。不过不能剧透字符串截取了,不然暴露不出问题。

这么写的问题在哪呢?来看一下main函数。

1fn main() {
2    let mut s = String::from("hello world");
3
4    let word = first_word(&s);
5
6    s.clear();
7}

这里在获取空格位置后,对字符串s做了一个clear操作,也就是把s清空了。但word仍然是5,此时我们再去对截取s的前5个字符就会出问题。可能有人认为自己不会这么蠢,但是你愿意相信你的好(zhu)伙(dui)伴(you)也不会这么做吗?我是不相信的。那怎么办呢?这时候slice就要登场了。

使用slice可以获取字符串的一段字符序列。例如&s[0..5]可以获取字符串s的前5个字符。其中0为起始字符的位置下标,5是结束字符位置的下标加1。也就是说slice的区间是一个左闭右开区间。

slice还有一些规则:

  • 如果起始位置是0,则可以省略。也就是说&s[0..2]&s[..2]等价

  • 如果起始位置是集合序列末尾位置,也可以省略。即&s[3..len]&s[3..]等价

  • 根据以上两条,我们还可以得出&s[0..len]&s[..]等价

这里需要注意的是,我们截取字符串时,其边界必须是UTF-8字符。

有了slice,就可以解决我们的问题了

 1fn first_word(s: &String) -> &str {2    let bytes = s.as_bytes();34    for (i, &item) in bytes.iter().enumerate() {5        if item == b' ' {6            return &s[0..i];7        }8    }9
10    &s[..]
11}

现在我们在main函数中对s执行clear操作时,编译器就不同意了。没错,又是万能的编译器。

除了slice除了可以作用于字符串以外,还可以作用于其他集合,例如:

1let a = [1, 2, 3, 4, 5];
2
3let slice = &a[1..3];

关于集合,我们以后会有更加详细的介绍。

总结

本文介绍的Ownership特性对于理解Rust来讲非常重要。我们介绍了什么是Ownership,Ownership的转移,以及不占用Ownership的数据类型Reference和Slice。

怎么样?是不是感觉今天的坑非常给力?如果之前在地下一层的话,那现在已经到地下三层了。所以请各位注意安全,有序降落。

没有入坑的小伙伴如果想入坑可以从Rust入坑指南:坑主驾到

扫码关注

Rust入坑指南:核心概念相关推荐

  1. Rust 入坑指南:鳞次栉比 | CSDN 博文精选

    作者 | Jackyzhe 责编 | 屠敏 出品 | CSDN(ID:CSDNnews) 很久没有挖Rust的坑啦,今天来挖一些排列整齐的坑.没错,就是要介绍一些集合类型的数据类型."鳞次栉 ...

  2. Rust入坑指南:朝生暮死

    今天想和大家一起把我们之前挖的坑再刨深一些.在Java中,一个对象能存活多久全靠JVM来决定,程序员并不需要去关心对象的生命周期,但是在Rust中就大不相同,一个对象从生到死我们都需要掌握的很清楚. ...

  3. Rust入坑指南:鳞次栉比

    很久没有挖Rust的坑啦,今天来挖一些排列整齐的坑.没错,就是要介绍一些集合类型的数据类型."鳞次栉比"这个标题是不是显得很有文化? 在Rust入坑指南:常规套路一文中我们已经介绍 ...

  4. Rust入坑指南:齐头并进(上)

    我们知道,如今CPU的计算能力已经非常强大,其速度比内存要高出许多个数量级.为了充分利用CPU资源,多数编程语言都提供了并发编程的能力,Rust也不例外. 聊到并发,就离不开多进程和多线程这两个概念. ...

  5. Rust入坑指南:亡羊补牢

    如果你已经开始学习Rust,相信你已经体会过Rust编译器的强大.它可以帮助你避免程序中的大部分错误,但是编译器也不是万能的,如果程序写的不恰当,还是会发生错误,让程序崩溃.所以今天我们就来聊一聊Ru ...

  6. Rust入坑指南:常规套路

    搭建好了开发环境之后,就算是正式跳进Rust的坑了,今天我就要开始继续向下挖了. 由于我们初来乍到 ,对Rust还不熟悉,所以我决定先走一遍常规套路. 变不变的变量 学习一门语言第一个要了解的当然就是 ...

  7. Rust入坑指南:千人千构

    坑越来越深了,在坑里的同学让我看到你们的双手! 前面我们聊过了Rust最基本的几种数据类型.不知道你还记不记得,如果不记得可以先复习一下.上一个坑挖好以后,有同学私信我说坑太深了,下来的时候差点崴了脚 ...

  8. Rust入坑指南:坑主驾到

    欢迎大家和我一起入坑Rust,以后我就是坑主,我主要负责在前面挖坑,各位可以在上面看,有手痒的也可以和我一起挖.这个坑到底有多深?我也不知道,我是抱着有多深就挖多深的心态来的,下面我先跳了,各位请随意 ...

  9. Rust 入坑指南 | CSDN 博文精选

    作者 | Jackyzhe 责编 | 屠敏 出品 | CSDN(ID:CSDNnews) 随着我们的坑越来越多,越来越大,我们必须要对各种坑进行管理了.Rust为我们提供了一套坑务管理系统,方便大家有 ...

最新文章

  1. 如何理解Android中的xmlns
  2. 10款最好的免费在线工具进行网站设计与开发
  3. oracle中的赋权
  4. Christmas Present
  5. php中数据类型、数组排序、循环语句、混编、操作本地文件流程、常用API、函数、魔术常量
  6. oracle 配置监听和TNS常见的问题
  7. 宝塔下php安装mongodb扩展_PHP安装MongoDB扩展
  8. Git 分支及bug分支合并
  9. golang基础1-0:golang环境配置及第一行代码Hello World
  10. java计算机毕业设计影院资源管理系统演示录像2020源程序+mysql+系统+lw文档+远程调试
  11. 百度地图api之路线规划
  12. magicbook java开发,作为一名IT编辑 荣耀MagicBook这三点打动了我
  13. 硬盘发展史之机械硬盘
  14. 太飒了!这届乘风破浪的 IT 女神写得了代码、撕得掉年龄、跨得过行业!
  15. 无法登录学校网站的解决办法
  16. 汶川地震纪念感人mv《孩子快抓紧妈妈的手》
  17. 基于 ESP8266 的智能家居开源解决方案 【文章结尾有资料】
  18. 智能机器人为什么连接不上服务器,战争机器人无法连接服务器是什么原因
  19. 坦克世界组装电脑配置推荐2022 适合玩坦克世界游戏电脑清单
  20. 【玩转rom助手】专属刷机的小帮手

热门文章

  1. 使用pyinotify监控文件系统的变化
  2. BWT(Burrows-Wheelter Transform)算法
  3. 滴滴外卖再扩张九城,美团这是自找苦吃?
  4. 【日语口语词典学习】第0001页
  5. 手机开发实战35——SIM卡应用2
  6. 凭支付宝信用 1 成首付购车!弹个车买车划算不?
  7. 网易UU加速盒WiFi短信验证如何使用?
  8. 《core Java for the impatient》阅读笔记(1) 基本编程结构
  9. 好用的考勤打卡APP
  10. 【教程】如何创建属于自己的域名邮箱(附阿里邮箱个人版pop设置)