所有权是 rust 语言独有的特性,它保证了在没有垃圾回收机制下的内存安全,所以理解 rust 的所有权是很有必要的。接下来,我们来讨论所有权和它的几个特性:借用、切片和内存结构。

什么是所有权

Rust 的核心特性是所有权。各种语言都有它们自己管理内存的方式,有些是使用垃圾回收机制,有些是手动管理内存,而 rust 使用的是所有权机制来管理内存。

所有权规则

所有权规则如下:

  • rust 中的每个值都有一个自己的变量。
  • rust 值在同一时间只能绑定一个变量。
  • 变量超出作用域,值会自动被销毁。

不懂没关系,跳过往后看。

变量作用域

rust 语言的变量作用域和其他语言是类似的,看例子:

{                      // 变量 s 还没有被声明,s 在这里是无效的
let s = "hello"; // 变量 s 是这里声明的,从这里开始生效 // 从这里开始,可以使用 s 做一些工作
} // 变量 s 超出作用域,s 从这里开始不再生效

可以总结两点重要特性:

  • 当变量 s 声明之后开始生效
  • 当变量 s 出了作用域失效

String 类型

在章节三中学习的数据类型都是存储在内存的栈空间中,当它们的作用域结束时清空栈空间,我们现在学习一下内存的堆空间中存储的数据是在何时被 rust 清空的。

我们在这里使用 String 类型作为例子,当然只是简单的使用,具体的内容后文介绍。

let s = "hello";

这个例子是把 hello 字符串硬编码到程序中,我们把它叫做 字符串文字 (string literals 我不知道别人是怎么翻译的,我实在想不到合适的词,先这样叫着吧),字符串文字很方便,但是它不能适用于任何场景,比如我们想要输入一个文本串的时候,原理如下:

  • 字符串文字是不可修改的
  • 编码(编译)时不确定文本串的内容 (比如保存用户的输入)

    在这种情况下,我们会使用字符串的第二种类型——String。它是存储在堆内存中的,而且允许在编译期间不知道字符串的大小,我们先使用 from 函数从字符串文字中创建一个 String 类型的字符串。
let s = String::from("hello");

这种双冒号的语法细节下个章节再说,这里先聚焦于字符串,例子中创建的字符串是可以改变的,比如:

let mut s = String::from("hello");

s.push_str(", world!"); // push_str() 在上个字符串后追加一个字符串

println!("{}", s); // 这里会打印 `hello, world!`

内存分配

字符串文字是在编译期间就有确定的字符内容,所以文本可以直接硬编码到程序中,这就是字符串文字快捷方便的原因。但是字符串文字又是不可变的,我们没办法分配内存给编译期间未知大小及变化的字符串。

字符串类型则支持字符串的修改和增长,即使编译期间未知大小,也可以在堆内存分配一块空间用于存储数据,这意味着:
* 内存必须是在运行时,从操作系统中请求 (和大多数编程语言类似,当我们调用 String::from 方法时,就完成了对内存的请求)
* 当不使用这块内存时,我们才会把它返还给操作系统 (和其它语言不同,其它语言是使用垃圾回收机制或手动释放内存)

rust 则是另一种方式:一旦变量超出作用域,程序自动返还内存,通过下面的例子来看这个概念:

{
let s = String::from("hello"); // s 从这里开始生效 // 利用 s 做一些事情
} // s 超出作用域,不再生效

当 s 超出作用域,rust 会自动帮我们调用一个特殊的函数—— drop 函数,它是用于返还内存的,当程序执行到 大括号右半块 } 的时候自动调用该函数。

变量和数据交互方式:移动

在 rust 中,可以使用不同的方式在多个变量间交互相同的数据,比如:

let x = 5; // 把 5 绑定到 x 上
let y = x; // 把 x 的值复制给 y,此时,x 和 y 的值都是 5

再比如:

let s1 = String::from("hello"); // 创建一个字符串绑定到 s1 上
let s2 = s1; // 把 s1 的值移动给 s2,此时,s1 就失效了,只有 s2 是有效的

s1 为什么失效,因为字符串是存储在堆内存中的,这里只是把栈内存中的 s1 的数据移动给 s2,堆内存不变,这种方式叫做浅克隆,也叫移动。如果想让 s1 仍然有效,可以使用深克隆。

变量和数据交互方式:克隆

关于深克隆,我们直接看例子吧:

let s1 = String::from("hello"); // 创建一个字符串绑定到 s1 上
let s2 = s1.clone(); // 把 s1 的值克隆给 s2,此时,s1 和 s2 都是有效的 println!("s1 = {}, s2 = {}", s1, s2); // 打印 s1 和 s2

下一章节再详细介绍这种语法。

只有栈数据:复制

我们再回到前面的例子中:

let x = 5; // 把 5 绑定到 x 上
let y = x; // 把 x 的值复制给 y,此时,x 和 y 的值都是 5 println!("x = {}, y = {}", x, y); // 打印 x 和 y

这里没有使用 clone 这个方法,但是 x 和 y 都是有效的。因为 x 和 y 都是整型,整型存储在栈内存中,即使调用了 clone 方法,也是做相同的事。下面总结一下复制的类型:

  • 所有的整型,像 u32,i32
  • 布尔类型,像 bool,值是 true, false
  • 所有的浮点型,像 f32,f64
  • 字符类型,像 char
  • 元组,但是仅仅是包含前 4 种类型的元组,像 (u32, i32),但是 (u32, String) 就不是了

所有权和函数

这里直接放一个例子应该就说清楚了,如下:

fn main() {
let s = String::from("hello"); // s 进入作用域 takes_ownership(s); // s 移动到函数里 // s 从这里开始不再生效,如果还使用 s,则会在编译期报错 let x = 5; // x 进入作用域 makes_copy(x); // x 复制(移动)到函数里,
// 但由于 x 是 i32 ,属于整型,仍有效
// 使用 x 做一些事情 } // x 超出作用域,由于 s 被移动到了函数中,这里不再释放 s 的内存 fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string); } // some_string 超出作用域,然后调用 drop 函数释放堆内存 fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer); } // some_integer 超出作用域,但是整型不需要释放堆内存

返回值和作用域

这块也直接放个例子,如下:

fn main() {
let s1 = gives_ownership(); // gives_ownership 把它的返回值移动给 s1
let s2 = String::from("hello"); // s2 进入作用域 let s3 = takes_and_gives_back(s2); // s2 移动进函数,函数返回值移动给 s3 } // s3 超出作用域被删除,s2 超出作用域被删除,s1 超出作用域被删除 fn gives_ownership() -> String { // gives_ownership 将移动它的返回值给调用者 let some_string = String::from("hello"); // some_string 进入作用域 some_string // some_string 是返回值,移出调用函数
} // takes_and_gives_back 移入一个字符串,移出一个字符串
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域 a_string // a_string 是返回值,移出调用函数
}

引用和借用

前面都是把值传入传出函数,这里我们学习一下引用,看个例子:

fn main() {
let s1 = String::from("hello"); // 创建一个字符串 s1 let len = calculate_length(&s1); // 把 字符串 s1 的引用传给函数 println!("The length of '{}' is {}.", s1, len); // 这里可以继续使用 s1
} fn calculate_length(s: &String) -> usize { // 接收到 字符串 s1 的引用 s
s.len() // 这里返回函数的长度
} // s 超出作用域,但是这里没有字符串的所有权,不释放内存

&s1 语法是创建一个指向 s1 的值的引用,而不是 s1 本身,当引用超出作用域不会释放引用指向值的内存。被调用函数声明参数的时候,参数的类型也需要使用 & 来告知函数接收的参数是个引用。

修改引用

在上述例子中,如果在 calculate_length 函数中修改字符串的内容,编译器会报错,因为传入的引用在默认情况下是不可变引用,如果想要修改引用的内容,需要添加关键字 mut,看例子:

fn main() {
let mut s = String::from("hello"); change(&mut s); // mut 同意函数修改 s 的值
} fn change(some_string: &mut String) { // mut 声明函数需要修改 some_string 的值
some_string.push_str(", world"); // 追加字符串
}

可变引用有一个很大的限制:在特定作用域中针对同一引用,只能有一个可变引用。比如:

let mut s = String::from("hello");

let r1 = &mut s; // 这是第一个借用的可变引用
let r2 = &mut s; // 这是第二个借用的可变引用,这里会编译不通过 println!("{}, {}", r1, r2);

这个限制的好处是编译器可以编译期间阻止数据竞争,数据竞争发生在如下情况:

  • 两个或多个指针同时访问相同的数据
  • 多个指针在写同一份数据
  • 没有同步数据的机制

数据竞争会造成不可预知的错误,而且在运行时修复是很困难的,rust 在编译期间就阻止了数据竞争情况的发生。但是在不同的作用域下,是可以有多个可变引用的,比如:

let mut s = String::from("hello");

{
let r1 = &mut s; } // r1 超出作用域 // 这里可以借用新的可变引用了
let r2 = &mut s;

可变引用和不可变引用放在一起,也会出错,比如:

let mut s = String::from("hello");

let r1 = &s; // 不可变引用,没问题
let r2 = &s; // 不可变引用,没问题
let r3 = &mut s; // 可变引用,会发生大问题,因为后面还在使用 r1 和 r2 println!("{}, {}, and {}", r1, r2, r3);

当存在不可变引用时,就不能再借用可变引用了。不可变引用不会修改引用的值,所以可以借用多个不可变引用。但是如果不可变引用都不使用了,就又可以借用可变引用了,比如:

let mut s = String::from("hello");

let r1 = &s; // 不可变引用,没问题
let r2 = &s; // 不可变引用,没问题
println!("{} and {}", r1, r2);
// r1 和 r2 从这里开始不再使用了 let r3 = &mut s; // 可变引用,也没问题了,因为后面没使用 r1 和 r2 的了
println!("{}", r3);

悬空引用

在一些指针语言中,很容易就会错误地创建悬空指针。悬空指针就是过早地释放了指针指向的内存,也就是说,堆内存已经释放了,而指针还指向这块堆内存。在 rust 中,编译器可以保证不会产生悬空引用:如果有引用指向数据,编译器会确保引用指向的数据不会超出作用域,比如:

fn main() {
let reference_to_nothing = dangle();
} fn dangle() -> &String { // 这里希望返回字条串的引用
let s = String::from("hello"); // 创建字符串 &s // 这里返回了字符串的引用 } // 这里会把字符串的内存释放,因为 s 在这里超出作用域

这个函数做个简单的修改就好了,看如下例子:

fn no_dangle() -> String { // 不返回字符串引用了,直接返回字符串
let s = String::from("hello"); s // 返回字符串
}

引用规则

引用的规则如下:

  • 任何时候,只能有一个可变引用或多个不可变引用
  • 引用必须总是有效的

切片类型

另一个没有所有权的数据类型是切片。切片是集合中相邻的一系列元素,而不是整个集合。

做个小小的编程题目:有一个函数,输入一个英文字符串,返回第一个单词。如果字符串没有空格,则认为整个字符串是一个单词,返回整个字符串。

我们来思考一下这个函数结构:

fn first_word(s: &String) -> ?   // 这里应该返回什么

在这个函数中,字符串引用作参数,函数没有字符串的所有权,那我们应该返回什么呢?我们不能返回字符串的一部分,那么,我们可以返回第一个单词结束位置的索引。看实现:

fn first_word(s: &String) -> usize { // 返回一个无符号整型,因为索引不会小于 0

    let bytes = s.as_bytes(); // 把字符串转换化字节类型的数组

    // iter 方法用于遍历字节数组,enumerate 方法用于返回一个元组,元组的第 0 个元素是索引,第 1 个元素是字节数组的元素
for (i, &item) in bytes.iter().enumerate() { if item == b' ' { // 如果找到了空格,就返回对应的索引
return i;
}
} s.len() // 如果没找到空格,就返回字符串的长度
}

现在,我们找到了返回字符串第一个单词的末尾索引,但是还有一个问题:函数返回的是一个无符号整型,返回值只是字符串中的一个有意义的数字。换句话说,返回值和字符串是分开的值,不能保证它永远有意义,比如:

fn main() {
let mut s = String::from("hello world"); // 创建字符串 let word = first_word(&s); // word 被赋值为 5 s.clear(); // 清空字符串,现在字符串的值是 "" // word 仍是5,但是我们不能得到单词 hello 了,这里使用 word,编译器也不会报错,但是这真的是一个 bug
}

还有一个问题,如果我们想得到第二个单词,应该怎么办?函数声明应该是:

fn second_word(s: &String) -> (usize, usize) {

如果想得到很多个单词,又应该怎么办?

字符串切片

字符串切片就是字符串一部分的引用,如下:

let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

就是取字符串 s 中的一部分组成一个新的变量,取值区间左闭右开,就是说,包括左边的索引,不包括右边的索引。

如果左边索引是0,可省略:

let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2]; // 和上一行等价

如果右边索引是字符串末尾,可省略:

let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..]; // 和上一行等价

如果取整个字符串,可以把两边都省略:

let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..]; // 和上一行等价

我们现在看一下一开始讨论的题目应该怎么做:

fn first_word(s: &String) -> &str {
let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
} &s[..]
}

这样就直接返回了第一个单词的内容。如果要返回第二个单词,可写成:

fn second_word(s: &String) -> &str {

我们再来看一下字符串清空的问题是否还存在:

fn main() {
let mut s = String::from("hello world"); let word = first_word(&s); // 不可变借用在这里声明 s.clear(); // 这里会报错,因为这里在修改 s 的内容 println!("the first word is: {}", word); // 不可变借用在这里使用
}

不可变借用的声明和使用之间是不能使用可变借用的。

字符串文字是切片

前面我们讨论过字符串文字的存储问题,现在我们学习了切片,我们可以理解字符串文字了:

let s = "Hello, world!";

s 的类型是 &str,这是一个指向了二进制程序特殊的位置的切片,这就是字符串文字不可变的原是,&str 是一个不可变引用

字符串切片作为参数

前面学习了字符串文字切片和字符串类型切片,我们来提高 first_word 函数的质量。有经验的 rust 开发者会把函数参数类型写成 &str,因为这样可以使得 &String 和 &str 使用相同的函数。好像不太好理解,直接上例子:

fn main() {
let my_string = String::from("hello world"); // 创建字符串 // first_word 使用 &String切片
let word = first_word(&my_string[..]); let my_string_literal = "hello world"; // 创建字符串 // first_word 使用 &str切片
let word = first_word(&my_string_literal[..]); // 因为字符串文字已经是字符串切片了,可以不使用切片语法
let word = first_word(my_string_literal);
}

其它切片

我们只举一个 i32 类型切片的例子吧:

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

let slice = &a[1..3];  // 值是: [2, 3]

rust 使用了所有权、借用、切片,在编译期确保程序的内存安全。rust 语言提供了和其他编程语言相同的方式来控制内存,而不需要我们编写额外代码来手动管理内存,当数据超出所有者的作用域就会被自动清理。

欢迎阅读单鹏飞的学习笔记

Rust 入门 (四)的更多相关文章

  1. 【原创】NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战

    概述 本文演示的是一个Android客户端程序,通过UDP协议与两个典型的NIO框架服务端,实现跨平台双向通信的完整Demo. 当前由于NIO框架的流行,使得开发大并发.高性能的互联网服务端成为可能. ...

  2. Rust入门篇 (1)

    Rust入门篇 声明: 本文是在参考 The Rust Programming Language 和 Rust官方教程 中文版 写的. 个人学习用 再PS. 目录这东东果然是必须的... 找个时间生成 ...

  3. python学习笔记--Django入门四 管理站点--二

    接上一节  python学习笔记--Django入门四 管理站点 设置字段可选 编辑Book模块在email字段上加上blank=True,指定email字段为可选,代码如下: class Autho ...

  4. Swift语法基础入门四(构造函数, 懒加载)

    Swift语法基础入门四(构造函数, 懒加载) 存储属性 具备存储功能, 和OC中普通属性一样 // Swfit要求我们在创建对象时必须给所有的属性初始化 // 如果没办法保证在构造方法中初始化属性, ...

  5. Thinkphp入门 四 —布局、缓存、系统变量 (48)

    原文:Thinkphp入门 四 -布局.缓存.系统变量 (48) [控制器操作方法参数设置] http://网址/index.php/控制器/操作方法 [页面跳转] [变量调节器] Smarty变量调 ...

  6. DevExpress XtraReports 入门四 创建 Web 报表

    原文:DevExpress XtraReports 入门四 创建 Web 报表 本文只是为了帮助初次接触或是需要DevExpress XtraReports报表的人群使用的,为了帮助更多的人不会像我这 ...

  7. 微服务(入门四):identityServer的简单使用(客户端授权)

    IdentityServer简介(摘自Identity官网) IdentityServer是将符合规范的OpenID Connect和OAuth 2.0端点添加到任意ASP.NET核心应用程序的中间件 ...

  8. Spring Boot入门(四):开发Web Api接口常用注解总结

    本系列博客记录自己学习Spring Boot的历程,如帮助到你,不胜荣幸,如有错误,欢迎指正! 在程序员的日常工作中,Web开发应该是占比很重的一部分,至少我工作以来,开发的系统基本都是Web端访问的 ...

  9. 脑残式网络编程入门(四):快速理解HTTP/2的服务器推送(Server Push)

    本文原作者阮一峰,作者博客:ruanyifeng.com. 1.前言 新一代HTTP/2 协议的主要目的是为了提高网页性能(有关HTTP/2的介绍,请见<从HTTP/0.9到HTTP/2:一文读 ...

随机推荐

  1. spring cloud 2.x版本 Eureka Server服务注册中心教程

    本文采用Spring cloud本文为2.1.8RELEASE,version=Greenwich.SR3 1.创建服务注册中心 1.1 新建Spring boot工程:eureka-server 1 ...

  2. vue中改变数组的值视图无变化

    今天开发的时候遇到一个多选取消点击状态的,渲染的时候先默认都选中,然后可以取消选中,自建了一个全为true的数组,点击时对应下标的arr[index]改为false,数据改变了状态没更新,突然想起来单 ...

  3. Okhttp 请求流程梳理

    最近在看 Okhttp 的源码.不得不说源码设计的很巧妙,从中能学到很多.其实网上关于 Okhttp 的文章已经很多了,自己也看了很多.但是俗话说得好,好记性不如烂笔头,当你动手的时候,你会发现你在看 ...

  4. Pycharm 专业版激活码(转) 有效期到2020/06月

    亲测有效!!! 有效期截止为2020年06月,多谢大家支持与讨论! K6IXATEF43-eyJsaWNlbnNlSWQiOiJLNklYQVRFRjQzIiwibGljZW5zZWVOYW1lIjo ...

  5. 数据存储之非关系型数据库存储----MongoDB存储

    MongoDB存储----文档型数据库 利用pymongo连接MongoDB import pymongo client = pymongo.MongoClient(host='localhost', ...

  6. ssh WARNING:REMOTE HOST IDENTIFICATION HAS CHANGED(警告:远程主机标识已更改)

    ssh 192.168.1.88 出现以下警告: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ WARNING: REMOT ...

  7. NOI1995 石子合并 [Luogu P1880]

    一道区间dp的模板题,这里主要记一下dp时环形数据的处理. 简略版:方法一:枚举分开的位置,将圈化为链,因此要做n次. 方法二:将链重复两次,即做一个2n-1长度的链,其中第i(i<=n)堆石子 ...

  8. Spring Boot2 系列教程(二十一)整合 MyBatis

    前面两篇文章和读者聊了 Spring Boot 中最简单的数据持久化方案 JdbcTemplate,JdbcTemplate 虽然简单,但是用的并不多,因为它没有 MyBatis 方便,在 Sprin ...

  9. .NET Core 3.0 部署在docker上运行

    自从.NET Core3.0发布之后,写了几篇关于.NET Core 3.0的文章,有助于你快速入门.NET Core3.0. 本篇文章主要讲解如何一步步创建一个mvc项目,然后发布并部署在Docke ...

  10. 接口测试专题(Java & jmeter & Linux基础)

    以下是我和两个朋友原创文章合集,主题是接口测试,有Java接口测试案例和jmeter的案例,还有接口测试相关服务器操作基础.欢迎点赞.关注和转发. 接口测试 httpclient处理多用户同时在线 h ...