返回

Rust入门学习笔记

Rust程序设计语言 简体中文版

简介

为什么要用Rust

  • Rust是一种令人兴奋的新编程语言,它可以让每个人编写可靠且高效的软件
  • 它可以用来替换C/C++,Rust和它们具有同样的性能,但是很多常见的bug在 编译时就可以被消灭
  • Rust是一种通用的编程语言,但是它更善于以下场景:
    • 需要运行时的速度
    • 需要内存安全
    • 更好的利用多处理器

与其它语言比较

  • C/C++性能非常好,但类型系统和内存都不太安全
  • Java/C#,拥有GC,能保证内存安全,也有很多优秀特性,但是性能不行
  • Rust:
    • 安全
    • 无需GC
    • 易于维护、调试,代码安全高效

RUS特别擅长的领域

  • 高性能Veb Service
  • WebAssembly
  • 命令行工具
  • 网络编程
  • 嵌入式设备
  • 系统编程

Rust与Firefox

  • Rust最初是ozilla公司的一个研究性项目。Firefox是Rust产品应用的一个重要的例子
  • Mozilla一直以来都在用Rust创建一个名为Servo的实验性浏览器引擎,其中的所有内容都是并行执行的
    • 目前Servo的部分功能已经被集成到Firefox里面了
  • Firefox原来的量子版就包含了Servo的CSS渲染引擎
    • Rust使得Firefox在这方面得到了巨大的性能改进

Rust的用户和案例

  • Google:新操作系统Fuschia,其中Rust代码量大约占30%
  • Amazon:基于Linux开发的直接可以在裸机、虚机上运行容器的操作系统
  • System76:纯Rust开发了下一代安全操作系统Redox
  • 蚂蚁金服:库操作系统Occlum
  • 斯坦福和密歇根大学:嵌入式实时操作系统,应用于Google的加密产品
  • 微软:正在使用Rust重写Windows系统中的一些低级组件
  • 微软:WinRT/Rust项目
  • Dropbox、Yelp、Coursera、LlNE、Cloudflare、Atlassian、npm、Ceph、百 度、华为、Sentry、Deno.…

Rust的优点

  • 性能
  • 安全性
  • 无所畏惧的并发

注意

  • Rust有很多独有的概念,它们和现在大多主流语言都不同
    • 所以学习Rust必须从基础概念一步一步学,否则会懵

安装Rust

Win

  1. 官网下载安装包:Rust 程序设计语言
  2. 在需要安装的目录下新建文件夹.cargo.rustup
  3. 新建用户变量CARGO_HOMERUSTUP_HOME分别为.cargo.rustup所在路径;在用户变量PATH中添加%CARGO_HOME%%RUSTUP_HOME%
  4. 换源:crates.io-index.git | Tsinghua Open Source Mirror
  5. 运行Rust安装程序,按回车开始默认安装
  6. 检测cargo是否安装成功
1
cargo --version

  1. 检测rustc编译器是否安装成功
1
rustc --verison

安装成功🎉

更新与卸载Rust

更新Rust:

1
rustup update

卸载Rust:

1
rustup self uninstall

本地文档

安装Rust的时候,还会在本地安装文档,可离线浏览,可在浏览器打开本地文档

1
rustup doc

开发工具

  • VsCode:安装Rust插件
  • Clion:安装Rust插件

Hello World

编写Rust程序

  • 程序文件后缀名:rs
  • 文件命名规范:main.rs
  • (例子)
1
2
3
fn main(){
    println!("Hello World");
}
  • main函数是每个Rust可执行程序最先运行的代码
  • Rust的缩进是 4 个空格而不是tab
  • println!是一个Rust macro(宏),如果是函数的话就没有!
  • 代码以;结尾

编译与运行Rust程序

  • 编译
1
rustc main.rs
  • 运行
    • Windowsmain
    • Linux/mac:./main

编译和运行是单独的两步

  • 运行Rust程序之前必须先编译,命令为:rustc 源文件名

  • 编译成功后,会生成一个二进制文件;在Windows上还会生成一个.pdb文件,里面包含调试信息
  • Rust是ahead-of-time编译的语言:可以先编译程序,然后把可执行文件交给别人运行(无需安装RUst)
  • rustc只适合简单的Rust程序;大型项目用Cargo

Hello Cargo

  • Cargo是Rust的构建系统和包管理工具,可以构建代码、下载依赖的库、构建这些库
  • 安装Rust的时候会安装Cargo

使用Cargo创建项目

  • 创建项目
1
cargo new hello_cargo

项目名称也是hello_cargo,会创建一个新的目录hello_cargo

  • Cargo.toml
  • src目录
    • main.rs
  • 初始化了一个新的Git仓库:.gitignore
    • 可以使用其它的VCS或不使用VCS:cargo new的时候使用--vcs这个flag

Cargo.toml

TOML(Tom’s Obvious,Minimal Language)格式,是Cargo的配置格式

[pacakge]:是一个区域标题,表示下方内容是用来配置包(package)的

  • name:项目名
  • version:项目版本
  • edition:使用的Rust版本

[dependencies]:另一个区域的开始,它会列出项目的依赖项

在Rust里面,代码的包称作crate

src/main.rs

cargo生成的main.rssrc目录下,源代码都应该在src目录下,Cargo.toml在项目顶层下

顶层目录可以放置:README、许可信息、配置文件和其它与程序源码无关的文件

如果创建项目时没有使用cargo,也可以把项目转化为使用cargo:

  • 把源代码文件移动到src
  • 创建Cargo.toml并填写相应的配置

构建Cargo项目

1
cargo build

执行命令后会创建可执行文件

  • Linux:target/debug/hello_cargo
  • Windows:target\debug\hello_cargo.exe

运行可执行文件

  • Linux:./target/debug/hello_cargo
  • Windows:.\target\debug\hello_cargo.exe

第一次运行cargo build会在顶层目录生成cargo.lock文件

  • 该文件负责追踪项目依赖的精确版本
  • 不需要手动修改该文件

构建和运行cargo项目

编译代码并执行结果

1
cargo run

如果之前编译成功过,并且源码没有改变,那么就会直接运行二进制文件

cargo check

cargo check用来检查代码,确保能通过编译,但是不产生任何可执行文件

cargo check要比cargo build快得多

编写代码的时候可以连续反复的使用cargo check检查代码,提高效率

为发布构建

1
cargo build --release

编译时会进行优化:代码会运行的更快,但是编译时间更长

编译后会在target/release生成可执行文件,而不是target/debug

两种编译配置:

  • 开发:cargo build
  • 正式发布:cargo build --release

猜数游戏

目标

  • 生成一个1到100间的随机数
  • 提示玩家输入一个猜测
  • 猜完之后,程序会提示猜测是太小了还是太大了
  • 如果猜测正确,那么打印出一个庆祝信息,程序退出

创建项目

1
cargo new guessing_game

一次猜测

1
2
3
4
5
6
7
8
9
use std::io;

fn main() {
    println!("猜数游戏");
    println!("请猜测一个数:");
    let mut guess = String::new();
    io::stdin().read_line(&mut guess).expect("读取行失败");
    println!("你猜测的数是:{}", guess);
}

运行结果:

生成神秘数字

Cargo.toml中引入第三方依赖库rand

1
2
[dependencies]
rand = "0.7.0"

执行编译命令后会自动下载依赖:

1
2
3
use rand::Rng; //trait
let secret_number = rand::thread_rng().gen_range(1, 101);
println!("神秘数字是:{}", secret_number);

运行结果:

比较数字

1
2
3
4
5
6
7
use std::cmp::Ordering;    
let guess: u32 = guess.trim().parse().expect("请输入一个数字");
match guess.cmp(&secret_number) {
    Ordering::Less => println!("太小了"),
    Ordering::Greater => println!("太大了"),
    Ordering::Equal => println!("猜对了"),
}

运行结果:

多次猜测

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use rand::Rng; //trait
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("猜数游戏");
    let secret_number = rand::thread_rng().gen_range(1, 101);
    // println!("神秘数字是:{}", secret_number);
    loop {
        println!("请猜测一个数:");
        let mut guess = String::new();
        io::stdin().read_line(&mut guess).expect("读取行失败");
        println!("你猜测的数是:{}", guess);
        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };
        match guess.cmp(&secret_number) {
            Ordering::Less => println!("太小了"),
            Ordering::Greater => println!("太大了"),
            Ordering::Equal => {
                println!("猜对了");
                break;
            }
        }
    }
}

通用的编程概念

变量与可变性

  • 声明变量使用let关键字
  • 默认情况下,变量是不可变的(Immutable)
  • 声明变量时,在变量前面加上mut,就可以使变量可变

变量与常量

常量(constant),常量在绑定值以后也是不可变的,但是它与不可变的变量有很多区别:

  • 不可以使用mut,常量永远都是不可变的
  • 声明常量使用const关键字,它的类型必须被标注
  • 常量可以在任何作用域内进行声明,包括全局作用域
  • 常量只可以绑定到常量表达式,无法绑定到函数的调用结果或只能在运行时才能计算出的值

在程序运行期间,常量在其声明的作用域内一直有效

命名规范:Rust里常量使用全大写字母,每个单词之间用下划线分开,例如:

  • MAX POINTS

例子:const MAX_POINTS: u32=100_000;

_用来增加数字可读性

Shadowing(隐藏)

可以使用相同的名字声明新的变量,新的变量就会shadow(隐藏)之前声明的同名变量

  • 在后续的代码中这个变量名代表的就是新的变量
1
2
3
4
5
6
fn main() {
    let x = 5;
    let x = x + 1;
    let x = x * 2;
    println!("The value of x is: {}", x); //12
}

shadow和把变量标记为mut是不一样的:

  • 如果不使用let关键字,那么重新给非mut的变量赋值会导致编译时错误
  • 而使用let声明的同名新变量,也是不可变的
  • 使用let声明的同名新变量,它的类型可以与之前不同
1
2
3
4
5
fn main() {
    let space = " ";
    let space = space.len();
    println!("{}", space); //1
}

数据类型

标量类型

Rust是静态编译语言,在编译时必须知道所有变量的类型

基于使用的值,编译器通常能够推断出它的具体类型

但如果可能的类型比较多(例如把String转为整数的parse方法),就必须添加类型的标注,否则编译会报错

1
2
let guess: u32 = "42".parse().expect("Not a number");
println!("{}", guess); //42

一个标量类型代表一个单个的值,Rust有四个主要的标量类型:

  • 整数类型
  • 浮点类型
  • 布尔类型
  • 字符类型

整数类型

整数类型没有小数部分,例如u32就是一个无符号的整数类型,占据32位的空间

无符号整数类型以u开头,有符号整数类型以i开头

Rust的整数类型列表如图:

  • 每种都分iu,以及固定的位数
  • 有符号范围:- (2 - 1) 到 2n-1 - 1
  • 无符号范围:0 到 2n - 1

isizeusize类型的位数由程序运行的计算机的架构所决定:

  • 如果是64位计算机,那就是64位的,……
  • 使用isizeusize的主要场景是对某种集合进行索引操作

整数字面值

除了byte类型外,所有的数值字面值都允许使用类型后缀

  • 例如:57u8

如果不太清楚应该使用那种类型,可以使用Rust相应的默认类型:

  • 整数的默认类型就是i32:总体上来说速度很快,即使在64位系统中

整数溢出

例如:u8的范围是0-255,如果把一个u8变量的值设为256,那么:

调试模式下编译:Rust会检查整数溢出,如果发生溢出,程序在运行时就会panic

发布模式下(-release)编译:Rust不会检查可能导致panic的整数溢出

  • 如果溢出发生:Rust会执行“环绕”操作:256变成0,257变成1……
  • 程序不会panic

浮点类型

1
2
let x = 2.0; // f64
let y: f32 = 3.0; // f32

Rust有两种基础的浮点类型,也就是含有小数部分的类型

  • f32,32位,单精度
  • f64,64位,双精度

Rust的浮点类型使用了IEEE-754标准来表述

f64是默认类型,因为在现代CPU上f64f32的速度差不多,而且精度更高

布尔类型

Rust的布尔类型也有两个值:truefalse

一个字节大小

符号是bool

字符类型

Rust语言中char类型被用来描述语言中最基础的单个字符

字符类型的字面值使用单引号,占用4字节大小

是Unicode标量值,可以表示比ASCII多得多的字符内容:拼音、中日韩文、零长度空白字符、emoji表情等。

  • U+0000到U+D7FF
  • U+E000到U+10FFFF

但Unicode中并没有“字符”的概念,所以直觉上认为的字符也许与Rust中的概念并不相符

复合类型

复合类型可以将多个值放在一个类型里。

Rust提供了两种基础的复合类型:元组(Tuple)、数组

Tuple

Tuple可以将多个类型的多个值放在一个类型里

Tuple的长度是固定的:一旦声明就无法改变

创建Tuple:

1
2
let tup: (i32, f64, u8) = (500, 6.4, 1);
println!("{},{},{},", tup.0, tup.1, tup.2); //500,6.4,1,
  • 在小括号里,将值用逗号分开
  • Tuple中的每个位置都对应一个类型,Tuple中各元素的类型不必相同

获取Tuple的元素值:

1
2
3
let tup: (i32, f64, u8) = (500, 6.4, 1);
let (x, y, z) = tup;
println!("{},{},{},", x, y, z); //500,6.4,1,
  • 可以使用模式匹配来解构(destructure)一个Tuple来获取元素的值

访问Tuple的元素:

1
2
let tup: (i32, f64, u8) = (500, 6.4, 1);
println!("{},{},{},", tup.0, tup.1, tup.2); //500,6.4,1,
  • tuple变量使用点标记法,后接元素的索引号

数组

  • 数组也可以将多个值放在一个类型里
  • 数组中每个元素的类型必须相同
  • 数组的长度也是固定的

声明一个数组:

1
let a = [1, 2, 3, 4, 5];
  • 在中括号里,各值用逗号分开

数组的用处:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
let months = [
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "Septemper",
    "October",
    "November",
    "December",
];
  • 如果想让数据存放在stack(栈)上而不是heap(堆)上,或者想保证有固定数量的元素,这时使用数组更有好处
  • 数组没有Vector灵活(以后再讲)
    • Vector和数组类似,它由标准库提供
    • Vector的长度可以改变
    • 如果不确定应该用数组还是Vector,那么应该用Vector

数组的类型以这种形式表示:[类型;长度]

  • 例如:let a: [i32;5] = [1,2,3,4,5];

另一种声明数组的方法:如果数组的每个元素值都相同,那么可以在中括号里指定初始值,然后是一个;,最后是数组的长度

  • 例如:let a=[3;5];,它就相当于let a = [3,3,3,3,3];

访问数组的元素

  • 数组是Stack上分配的单个块的内存
  • 可以使用索引来访问数组的元素
    • 例如:let first = months[0];
  • 如果访问的索引超出了数组的范围,那么:
    • 编译会通过
    • 运行会报错(runtime时会panic):Rust不会允许其继续访问相应地址的内存

函数

1
2
3
4
5
6
7
fn main(){
    println!("hello world");
    another_function();
}
fn another_function(){
    println!("Another function")
}

声明函数使用fn关键字

依照惯例,针对函数和变量名,Rust使用snake case命名规范:

  • 所有的字母都是小写的,单词之间使用下划线分开

函数的参数

1
2
3
4
5
6
fn main(){
    another_function(5); // 5
}
fn another_function(x: i32){
    println!("{}",x);
}
  • 在函数签名里,必须声明每个参数的类型

函数的返回值

1
2
3
4
5
6
7
fn plus_five(x: i32) -> i32 {
    x + 5
}
fn main() {
    let x = plus_five(6);
    println!("{}", x); // 11
}
  • ->符号后边声明函数返回值的类型,但是不可以为返回值命名
  • 在Rust里面,返回值就是函数体里面最后一个表达式的值
    • x+5后不能写;,否则就是一个语句,会返回一个空的元组Tuple()
  • 若想提前返回,需使用return关键字,并指定一个值
    • 大多数函数都是默认使用最后一个表达式作为返回值

控制流

if表达式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fn main() {
    let number = 6;
    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4,3 or 2");
    }
}
  • if表达式允许根据条件来执行不同的代码分支
    • 这个条件必须是bool类型
  • if表达式中,与条件相关联的代码块就叫做分支(arm)
  • 可选的,在后边可以加上一个else表达式

如果使用了多于一个else if,那么最好使用match来重构代码

循环

Rust提供了3种循环:loopwhilefor

1
2
3
4
5
6
7
8
let mut counter = 0;
let result = loop {
    counter += 1;
    if counter == 10 {
        break counter * 2;
    }
};
println!("{}", result); // 20
  • loop关键字告诉Rust反复的执行一块代码,直到喊停
  • 可以在loop循环中使用break关键字来告诉程序何时停止循环

while条件循环

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let mut number = 3;
while number != 0 {
    println!("{}", number);
    number -= 1;
}
println!("LIFTOFF!!!");
//运行结果:
//3
//2
//1
//LIFTOFF!!!
  • 每次执行循环体之前都判断一次条件

使用for循环遍历集合

1
2
3
4
let a = [10, 20, 30, 40, 50];
for element in a.iter() {
	println!("the value is: {}", element);
}

Range

1
2
3
4
for number in (1..4).rev() {
    println!("{}", number);
}
println!("LIFTOFF!!!");
  • 由标准库提供
  • 指定一个开始数字和一个结束数字,range可以生成它们之间的数字(不含结束)
  • rev方法可以反转Range

所有权

所有权是Rust最独特的特性,它让Rust无需GC就可以保证内存安全

Rust的核心特性就是所有权

所有程序在运行时都必须管理它们使用计算机内存的方式

  • 有些语言有垃圾收集机制,在程序运行时,它们会不断地寻找不再使用的内存(JAVA/GO)
  • 在其他语言中,程序员必须显式地分配和释放内存(C/C++)

Rust采用了第三种方式:

  • 内存是通过一个所有权系统来管理的,其中包含一组编译器在编译时检查的规则
  • 当程序运行时,所有权特性不会减慢程序的运行速度

栈内存和堆内存

在像Rust这样的系统级编程语言里,一个值是在stack上还是在heap上对语言的行为和你为什么要做某些决定是有更大的影响的

在代码运行的时候,Stack和Heap都是可用的内存,但它们的结构很不相同

存储数据

Stack按值的接收顺序来存储,按相反的顺序将它们移除(后进先出,LIFO〉

  • 添加数据叫做压入栈
  • 移除数据叫做弹出栈

所有存储在Stack上的数据必须拥有已知的固定的大小

  • 编译时大小未知的数据或运行时大小可能发生变化的数据必须存放在heap

Heap内存组织性差一些:

  • 当把数据放入heap时,会请求一定数量的空间
  • 操作系统在heap里找到一块足够大的空间,把它标记为在用,并返回一个指针,也就是这个空间的地址
  • 这个过程叫做在heap上进行分配,有时仅仅称为“分配”

把值压到stack上不叫分配

因为指针是已知固定大小的,可以把指针存放在stack

  • 但如果想要实际数据,必须使用指针来定位

把数据压到stack上要比在heap上分配快得多:

  • 因为操作系统不需要寻找用来存储新数据的空间,那个位置永远都在stack的顶端

heap上分配空间需要做更多的工作:

  • 操作系统首先需要找到一个足够大的空间来存放数据,然后要做好记录方便下次分配

访问heap中的数据要比访问stack中的数据慢,因为需要通过指针才能找到heap中的数据

  • 对于现代的处理器来说,由于缓存的缘故,如果指令在内存中跳转的次数越少,那么速度就越快

如果数据存放的距离比较近,那么处理器的处理速度就会更快一些(stack上)

如果数据之间的距离比较远,那么处理速度就会慢一些(heap上)

  • heap上分配大量的空间也是需要时间的

函数调用

当代码调用函数时,值被传入到函数(也包括指向heap的指针),函数本地的变量被压到stack上,当函数结束后,这些值会从stack上弹出

所有权存在的原因

所有权解决的问题:

  • 跟踪代码的哪些部分正在使用heap的哪些数据
  • 最小化heap上的重复数据量
  • 清理heap上未使用的数据以避免空间不足

一旦懂的了所有权,那么就不需要经常去想stack或heap了

知道管理heap数据是所有权存在的原因,就有助于解释它为什么会这样工作

所有权规则

  • 每个值都有一个变量,这个变量是该值的所有者
  • 每个值同时只能有一个所有者
  • 当所有者超出作用域(scope)时,该值将被删除

String类型

String比那些基础标量数据类型更复杂

Rust有两种种字符串类型

  • 字符串字面值:程序里写死的那些字符串值,它们是不可变的
  • String:在heap上分配,能够存储在编译时未知数量的文本

创建String类型的值:可以使用from函数从字符串字面值创建出String类型:let s = String::from("hello");

  • ::表示fromString类型下的函数
1
2
3
let mut s = String::from("Hello");
s.push_str(", world!");
println!("{}", s); //Hello, world!
  • String类型的字符串是可以被修改的
  • 为什么String类型的值可以修改,而字符串字面值却不能修改?
    • 因为它们处理内存的方式不同

内存与分配

字符串字面值,在编译时就知道它的内容了,其文本内容直接被硬编码到最终的可执行文件里

  • 速度快、高效。是因为其不可变性

String类型,为了支持可变性,需要在heap上分配内存来保存编译时未知的文本内容:

  • 操作系统必须在运行时来请求内存
    • 这步通过调用String::from来实现
  • 当用完String之后,需要使用某种方式将内存返回给操作系统
    • 这步,在拥有GC的语言中,GC会跟踪并清理不再使用的内存
    • 没有GC,就需要程序员去识别内存何时不再使用,并调用代码将它返回
      • 如果忘了,那就浪费内存
      • 如果提前做了,变量就会非法
      • 如果做了两次,也是Bug,必须一次分配对应一次释放

Rust采用了不同的方式:对于某个值来说,当拥有它的变量走出作用域时,内存会立即自动的交还给操作系统(drop函数)

变量和数据交互的方式

多个变量可以与同一个数据使用一种独特的方式来交互

1
2
let x=5;
let y=x;
  • 整数是已知且固定大小的简单的值,这两个5被压到了stack中
  • 一个String由三部分组成
    • 一个指向存放字符串内容的内存的指针
    • 一个长度
    • 一个容量
  • 上面这些东西放在stack上;
  • 存放字符串内容的部分在heap上;
  • 长度Ien就是存放字符串内容所需的字节数
  • 容量capacity是指String从操作系统总共获得内存的总字节数
  • 再次使用S1使会报错:value borrowed here after move

如果真想对heap上面的String数据进行深度拷贝,而不仅仅是stack上的数据,可以使用clone方法:

Stack上的数据:复制

  • Copy trait,可以用于像整数这样完全存放在stack上面的类型
  • 如果一个类型实现了Copy这个trait,那么旧的变量在赋值后仍然可用
  • 如果一个类型或者该类型的一部分实现了Drop trait,那么Rust不允许让它再去实现Copy trait

一些拥有Copy trait的类型

  • 任何简单标量的组合类型都可以是Copy的
  • 任何需要分配内存或某种资源的都不是Copy的
  • 一些拥有Copy trait的类型:
    • 所有的整数类型,例如u32
    • bool
    • char
    • 所有的浮点类型,例如f64
    • Tuple(元组),如果其所有的字段都是Copy的
      • (i32,i32)
      • (i32,String)不是

所有权与函数

在语义上,将值传递给函数和把值赋给变量是类似的:

  • 将值传递给函数将发生移动或复制

返回值与作用域

函数在返回值的过程中同样也会发生所有权的转移

一个变量的所有权总是遵循同样的模式:

  • 把一个值赋给其它变量时就会发生移动
  • 当一个包含heap数据的变量离开作用域时,它的值就会被drop函数清理,除非数据的所有权移动到另一个变量上了

引用的规则

在任何给定的时刻,只能满足下列条件之一:

  • 一个可变的引用
  • 任意数量不可变的引用

引用必须一直有效

Rust 也提供了一个不用获取所有权就可以使用值的功能,叫做 引用references)。

引用与借用

引用reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值。

下面是如何定义并使用一个(新的)calculate_length 函数,它以一个对象的引用作为参数而不是获取值的所有权:

文件名:src/main.rs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递 &s1calculate_length,同时在函数定义中,我们获取 &String 而不是 String。这些 & 符号就是 引用,它们允许你使用值但不获取其所有权。图 4-5 展示了一张示意图:&String s 指向 String s1

Three tables: the table for s contains only a pointer to the table for s1. The table for s1 contains the stack data for s1 and points to the string data on the heap.

注意:与使用 & 引用相反的操作是 解引用dereferencing),它使用解引用运算符,*

&s1 语法让我们创建一个 指向s1 的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。

变量 s 有效的作用域与函数参数的作用域一样,不过当 s 停止使用时并不丢弃引用指向的数据,因为 s 并没有所有权。

我们将创建一个引用的行为称为 借用borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它。

正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。

可变引用

允许我们修改一个借用的值就是 可变引用mutable reference):

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

首先,我们必须将 s 改为 mut。然后在调用 change 函数的地方创建一个可变引用 &mut s,并更新函数签名以接受一个可变引用 some_string: &mut String。这就非常清楚地表明,change 函数将改变它所借用的值。

可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个 s 的可变引用的代码会失败:

文件名:src/main.rs

1
2
3
4
5
6
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);

这段代码是无效的,因为我们不能在同一时间多次将 s 作为可变变量借用。第一个可变的借入在 r1 中,并且必须持续到在 println! 中使用它,但是在那个可变引用的创建和它的使用之间,我们又尝试在 r2 中创建另一个可变引用,该引用借用与 r1 相同的数据。

这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。新 Rustacean 们经常难以适应这一点,因为大部分语言中变量任何时候都是可变的。这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争data race)类似于竞态条件,它可由这三个行为造成:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个指针被用来写入数据。
  • 没有同步数据访问的机制。

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!

一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能 同时 拥有:

1
2
3
4
5
6
7
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用

    let r2 = &mut s;

Rust 在同时使用可变与不可变引用时也采用的类似的规则。这些代码会导致一个错误:

1
2
3
4
5
6
7
    let mut s = String::from("hello");

    let r1 = &s; // 没问题
    let r2 = &s; // 没问题
    let r3 = &mut s; // 大问题

    println!("{}, {}, and {}", r1, r2, r3);

哇哦!我们 不能在拥有不可变引用的同时拥有可变引用。

不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。

注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。例如,因为最后一次使用不可变引用(println!),发生在声明可变引用之前,所以如下代码是可以编译的:

1
2
3
4
5
6
7
8
9
    let mut s = String::from("hello");

    let r1 = &s; // 没问题
    let r2 = &s; // 没问题
    println!("{} and {}", r1, r2);
    // 此位置之后 r1 和 r2 不再使用

    let r3 = &mut s; // 没问题
    println!("{}", r3);

不可变引用 r1r2 的作用域在 println! 最后一次使用之后结束,这也是创建可变引用 r3 的地方。它们的作用域没有重叠,所以代码是可以编译的。编译器可以在作用域结束之前判断不再使用的引用。

尽管这些错误有时使人沮丧,但请牢记这是 Rust 编译器在提前指出一个潜在的 bug(在编译时而不是在运行时)并精准显示问题所在。这样你就不必去跟踪为何数据并不是你想象中的那样。

悬垂引用

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

让我们尝试创建一个悬垂引用,Rust 会通过一个编译时错误来避免:

文件名:src/main.rs

1
2
3
4
5
6
7
8
9
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String,这可不对!Rust 不会允许我们这么做。

这里的解决方法是直接返回 String

1
2
3
4
5
fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

这样就没有任何错误了。所有权被移动出去,所以没有值被释放。

引用的规则

让我们概括一下之前对引用的讨论:

  • 在任意给定时间,要么 只能有一个可变引用,要么 只能有多个不可变引用。
  • 引用必须总是有效的。

切片

切片(slice)使不持有所有权的数据类型

字符串切片

字符串切片是指向字符串中一部分内容的引用

形式:[开始索引..结束索引]

  • 开始索引就是切片起始位置的索引值
  • 结束索引是切片终止位置的下一个索引值

字符串字面值是切片

  • 字符串字面值被直接存储在二进制程序中
  • let s = "Hello,World!";
  • 变量s的类型是&s,它是一个指向二进制程序特定位置的切片
    • &str是不可变引用,所以字符串字面值也是不可变的

采用&str作为参数类型,就可以同时接收String&str类型的参数

一道题,编写一个函数:

  • 它接收字符串作为参数
  • 返回它在这个字符串里找到的第一个单词
  • 如果函数没找到任何空格,那么整个字符串就被返回
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fn main() {
    let my_string = String::from("Hello world");
    let _word_index = first_word(&my_string[..]);

    let my_string_literal = "Hello world";
    let _word_index = first_word(my_string_literal);
}
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &item) in bytes.iter().enumerate() {
        // enumerate returns a tuple
        if item == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

Struct

定义和实例化

使用struct关键字,并为整个struct命名

在花括号内,为所有字段(Field)定义名称和类型,例如:

1
2
3
4
5
6
struct User{
    username:String,
    email:String,
    sign_in_count:u64,
    active:bool,
}

想要使用struct,需要创建struct的实例:

  • 为每个字段指定具体值
  • 无需按声明的顺序进行指定

例子:

1
2
3
4
5
6
let user1 = User{
    email:String::from("someone@example.com"),
    username:String::from("someusername123"),
    active:true,
    sign_in_count:1,
};

要想取得struct里面的某个值使用点标记法:

1
2
3
4
5
6
7
let mut user1 = User{
    email:String::from("someone@example.com"),
    username:String::from("someusername123"),
    active:true,
    sign_in_count:1,
};
user1.email = String::from("anotheremail@example.com");
  • 注意:一旦声明struct的实例是可变的,那么实例中所有的字段都是可变的

要想使用struct作为函数的返回值,保证该struct是函数的最后一个表达式即可

当字段名与字段值对应变量名相同时,就可以使用字段初始化简写的方式

当想基于某个struct实例来创建一个新实例的时候,可以使用struct更新语法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let user2 = User{
    email:String::from("another@example.com"),
    username:String::from("anotherusername567"),
    active:user1.active,
    sign_in_count:user1.sign_in_count,
};
let user2 = User{
    email:String::from("another@example.com"),
    username:String::from("anotherusername567"),
    ..user1 //struct更新语法
};

Turple struct

可定义类似tuple的struct,叫做tuple struct

Tuple struct整体有个名,但里面的元素没有名

适用:想给整个tuple起名,并让它不同于其它tuple,而且又不需要给每个元素起名

定义tuple struct:使用struct关键字,后边是名字,以及里面元素的类型,例子:

1
2
3
4
struct Color(i32,i32,i32);
struct Point(i32,i32,i32);
let black Color(0,0,0);
let origin Point(O,0,0);
  • black和origin是不同的类型,因为它们是不同tuple struct的实例

Unit-Like Struct(没有任何字段)

可以定义没有任何字段的struct,叫做Unit-Like struct(因为与(),单元类型类似)

适用于需要在某个类型上实现某个trait,但是在里面又没有想要存储的数据

struct数据的所有权

1
2
3
4
5
6
struct User{
    username:String,
    email:String,
    sign_in_count:u64,
    active:bool,
}

这里的字段使用了String而不是&str

  • 该struct实例拥有其所有的数据
  • 只要struct实例是有效的,那么里面的字段数据也是有效的

struct里也可以存放引用,但这需要使用生命周期(后面讲)

  • 生命周期保证只要struct实例是有效的,那么里面的引用也是有效的
  • 如果struct里面存储引用,而不使用生命周期,就会报错

struct方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#[derive(Debug)]
struct Rectangle{
    width: u32,
    length: u32,
}
impl Rectangle{
    fn area(&self) -> u32{
        self.width * self.length
    }
    fn can_hold(&self, other: &Rectangle) -> bool{
        self.width > other.width && self.length > other.length
    }
    fn square(size: u32) -> Rectangle{
        Rectangle{width: size, length: size}
    }
}
fn main() {
    let square = Rectangle::square(3);
    println!("square is {:?}", square);
    let rect1 = Rectangle{width: 30, length: 50};
    let rect2 = Rectangle{width: 10, length: 40};
    let rect3 = Rectangle{width: 60, length: 45};
    println!("rect1 is {:?}", rect1);
    println!("rect1 is {:#?}", rect1);
    println!("The area of the rectangle is {} square pixels.", rect1.area());
    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
// 运行结果
// square is Rectangle { width: 3, length: 3 }
// rect1 is Rectangle { width: 30, length: 50 }
// rect1 is Rectangle {
//     width: 30,
//     length: 50,
// }
// The area of the rectangle is 1500 square pixels.
// Can rect1 hold rect2? true
// Can rect1 hold rect3? false

方法和函数类似:fn关键字、名称、参数、返回值

方法与函数不同之处:

  • 方法是在struct(或enum、trait对象)的上下文(impl块)中定义
  • 第一个参数是&self,表示方法被调用的struct实例

方法调用的运算符:

  • Rust会自动引用或解引用
  • 在调用方法时就会发生这种行为
  • 在调用方法时,Rust根据情况自动添加&、&mUt或*,以便object可以匹配 法的签名。
  • 下面两行代码效果相同:
    • p1.distance(&p2);
    • (&p1).distance(&p2);

关联函数

可以在impl块里定义不把self作为第一个参数的函数,它们叫关联函数(不是方 法)

  • 例如:String::from()

关联函数通常用于构造器

::函数可以用于

  • 关联函数
  • 模块创建的命名空间

多个impl块

  • 每个struct允许拥有多个impl

枚举与模式匹配

定义枚举

枚举允许我们列举所有可能的值来定义一个类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#[derive(Debug)]
enum IpAddrKind {
    V4,
    V6,
}   // 定义枚举

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;
    route(four);
    route(six);

}
fn route(ip_kind: IpAddrKind) {
    println!("ip_kind is {:?}", ip_kind);
}
// 运行结果
// ip_kind is V4
// ip_kind is V6

Option枚举

定义于标准库中

在Prelude(预导入模块)中

描述了:某个值可能存在(某种类型)或不存在的情况

Rust中类似Null概念的枚举:Option<T>

标准库中的定义:

1
2
3
4
enum Option<T>{
    Some(T),
    None,
}

它包含在Prelude(预导入模块)中。可直接使用:

  • Option<T>
  • Some(T)
  • None
1
2
3
4
5
fn main(){
    let some_number = Some(5);
    let some_string = Some("a string");
    let absent_number: Option<i32> = None;
}

Option<T>Null好在哪

  • Option<T>T是不同的类型,不可以把Option<T>直接当成T
  • 若想使用Option<T>中的T,必须将它转换为T

控制流运算符match

  • 允许一个值与一系列模式进行匹配,并执行匹配的模式对应的代码
  • 模式可以是字面值、变量名、通配符……
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}
fn value_in_cents(coin: Coin) -> u8{
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        },
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}
  • match必须穷举所有可能性,不关心的可以用通配符_

if let

  • 处理只关心一种匹配而忽略其它匹配的情况
  • 更少的代码,更少的缩进,更少的模板代码
  • 放弃了穷举的可能
  • 可以把if let看作是match的语法糖
  • 可以搭配else使用

Package,Crate,Module

rust的代码组织

代码组织主要包括:

  • 哪些细节可以暴露,哪些细节是私有的
  • 作用域内哪些名称有效

模块系统:

  • Package(包):Cargo的特性,构建、测试、共享crate
  • Crate(单元包):一个模块树,它可产生一个library或可执行文件
  • Module(模块)、Use:控制代码的组织、作用域、私有路径
  • Path(路径):为struct、function或module等项命名的方式

Package和Crate

Crate的类型:

  • binary
  • library

Crate Root:

  • 是源代码文件
  • Rust编译器从这里开始,组成Crate的根Module

一个Package:

  • 包含1个Cargo.toml,它描述了如何构建这些Crates
  • 只能包含0-1个library crate
  • 可以包含任意数量的binary crate
  • 但必须至少包含一个crate(library或binary)

Cargo的惯例

一个Package可以同时包含src/main.rssrc/lib.rs

  • 一个binary crate,一个library crate
  • 名称与package名相同

一个Package可以有多个binary crate:

  • 文件放在src/bin
  • 每个文件是单独的binary crate

Crate的作用

将相关功能组合到一个作用域内,便于在项目间进行共享

  • 防止冲突

例如rand crate,访问它的功能需要通过它的名字:rand

定义module来控制作用域和私有性

Module:

  • 在一个crate内,将代码进行分组
  • 增加可读性,易于复用
  • 控制项目(item)的私有性:public、private

建立module:

  • mod关键字
  • 可嵌套
  • 可包含其它项(struct、enum、常量、trait、函数等)的定义

路径

为了在Rust的模块中找到某个条目,需要使用路径

路径的两种形式:

  • 绝对路径:从crate root开始,使用crate名或字面值crate
  • 相对路径:从当前模块开始,使用self,super或当前模块的标识符

路径至少由一个标识符组成,标识符之间使用::

私有边界(privacy boundary)

  • 模块不仅可以组织代码,还可以定义私有边界
  • 如果想把函数或struct等设为私有,可以将它放到某个模块中
  • Rust中所有的条目(函数,方法,struct,enum,模块,常量)默认是私有的
  • 父级模块无法访问子模块中的私有条目
  • 子模块里可以使用所有祖先模块中的条目

关键字

pub关键字

  • 使用pub关键字来将某些条目标记为公共的

super关键字

  • super:用来访问父级模块路径中的内容,类似文件系统中的..

use关键字

可以使用use关键字将路径导入到作用域内

  • 仍遵循私有性规则

as关键字

  • as关键字可以为引入的路径指定本地的别名

pub use关键字

  • 将条目引入作用域
  • 该条目可以被外部代码引入到它们的作用域

使用外部包

Cargo.toml添加依赖的包(package)

use将特定条目引入作用域

下载慢的话,删除cargo目录下的.package-cache后重新执行cargo build

use嵌套

如果使用同一个包或模块下的多个条目,可使用嵌套路径在同一行内将上述条目进行引入,,用于清理大量的use语句

  • 路径相同的部分::{路径差异的部分}

如果两个Use路径之一是另一个的子路径

  • 使用self

通配符

使用*可以把路径中所有的公共条目都引入到作用域

注意:谨慎使用

应用场景:

  • 测试:将所有被测试代码引入到tests模块
  • 有时被用于预导入(prelude)模块

将模块内容移动到其它文件

模块定义时,如果模块名后边是;,而不是代码块:

  • Rust会从与模块同名的文件中加载内容
  • 模块树的结构不会变化

随着模块逐渐变大,该技术可以把模块的内容移动到其它文件中

常用集合

都在堆内存中

Vector

Vec<T>,叫做vector

  • 由标准库提供
  • 可存储多个值
  • 只能存储相同类型的数据
  • 值在内存中连续存放

创建Vector

  • Vec::new函数
  • 使用初始值创建Vec<T>,使用vec!

更新Vector

  • 向Vector添加元素,使用push方法

删除Vector

  • 与任何其它struct一样,当Vector离开作用域后
    • 它就被清理掉了
    • 它所有的元素也被清理掉了

读取Vector的元素

两种方式可以引用Vector里的值:

  • 索引
  • get方法

索引 vs get 处理访问越界

  • 索引:panic
  • get:返回None

遍历Vector中的值

1
2
3
4
let v = vec![100,32,57]
for i in &v {
    println!("{}",i);
}
  • 由标准库提供
  • 一可存储多个值
  • 只能存储相同类型的数据
  • 一值在内存中连续存放

String

Rust的核心语言层面,只有一个字符串类型:字符串切片str(或&str)

字符串切片:对存储在其它地方、UTF-8编码的字符串的引用

  • 字符串字面值:存储在二进制文件中,也是字符串切片

String类型:

  • 来自标准库而不是核心语言
  • 可增长、可修改、可拥有
  • UTF-8编码

创建String

String::new()函数

使用初始值来创建String

  • to_string()方法,可用于实现了Display trait的类型,包括字符串字面值
  • String::from()函数,从字面值创建String

更新String

push_str()方法:把一个字符串切片附加到String

push()方法:把单个字符附加到String(例子)

+:连接字符串

  • 使用了类似这个签名的方法fn add(self,s:&str)->String{...}
    • 标准库中的add方法使用了泛型
    • 只能把&str添加到String
    • 解引用强制转换(deref coercion)
    • 第一个字符串的所有权转移到ADD函数中,s1失效,后续无法继续使用

format!:连接多个字符串

  • println!()类似,但返回字符串
  • 不会获得字符串的所有权,所有变量后续可继续使用

遍历String的方法

  • 对于标量值:chars()方法
  • 对于字节:bytes()方法
  • 对于字形簇:很复杂,标准库未提供

HashMap

HashMap<K,V>

  • 键值对的形式存储数据,一个键(Key)对应一个值(Value)
  • Hash函数:决定如何在内存中存放K和V
  • 适用场景:通过K(任何类型)来寻找数据,而不是通过索引
  • HashMap用的较少,不在Prelude中
  • 标准库对其支持较少,没有内置的宏来创建HashMap
  • 数据存储在heap上
  • 同构的,一个HashMap中
    • 所有的K必须是同一种类型
    • 所有的V必须是同一种类型

创建HashMap

  • 创建空HashMap:new()函数
  • 添加数据:insert()方法

HashMap和所有权

  • 对于实现了Copy trait的类型(例如i32),值会被复制到HashMap中
  • 对于拥有所有权的值(例如String),值会被移动,所有权会转移给HashMap
  • 如果将值的引用插入到HashMap,值本身不会移动
    • 在HashMap有效的期间,被引用的值必须保持有效

访问HashMap中的值

get方法

  • 参数:K
  • 返回:Option<&V>

遍历HashMap

  • for循环

更新HashMap<K,V>

替换现有的V

  • 向HashMap插入一对KV,然后再插入同样的K,但是不同的V,那么原来的V会被替换掉

保留现有的V,忽略新的V

  • entry方法:检查指定的K是否对应一个V
    • 参数为K
    • 返回enum Entry:代表值是否存在
  • Entry的or_insert()方法
    • 如果K存在,返回到对应的V的一个可变引用
    • 如果K不存在,将方法参数作为K的新值插进去,返回到这个值的可变引用

错误处理

Rust的可靠性:错误处理

  • 大部分情况下:在编译时提示错误,并处理

错误的分类:

  • 可恢复:例如文件未找到,可再次尝试
  • 不可恢复:bug,例如访问的索引超出范围

不可恢复

panic!

  • 打印一个错误信息
  • 展开(unwind)、清理调用栈(Stack):Rust沿着调用栈往回走,清理每个遇到的函数中的数据
  • 也可以选择立即中止调用栈:不进行清理,直接停止程序,内存稍后由操作系统进行清理
  • 退出程序
  • 想让二进制文件更小,把设置从“展开”改为“中止”
    • Cargo.toml中适当的profile部分设置panic=abort

可恢复错误

Result<T,E>

1
2
3
4
enum Result<T,E>{
	OK(T),
    Err(E),
}
  • T:操作成功情况下,Ok变体里返回的数据的类型
  • E:操作失败情况下,Er变体里返回的错误的类型

处理Result的一种方式:match表达

  • 和Option枚举一样,Result及其变体也是由prelude带入作用域

unwrap:match表达式的一个快捷方法

  • 如果Result结果是Ok,返回Ok里面的值
  • 如果Result结果是Err,调用panic!宏

expect:和unwrap类似,但可指定错误信息

传播错误

  • 在函数外处理错误:将错误返回给调用者

?运算符:传播错误的一种快捷方式

  • 如果Result是Ok:Ok中的值就是表达式的结果,然后继续执行程序
  • 如果Result是Err:Err就是整个函数的返回值,就像使用了return
  • 可以链式调用
  • 只能用于返回Result的函数
  • Box<dyn Error>:是一个trait对象,可以简单理解为“任何可能的错误类型”

泛型

泛型:提高代码复用能力

  • 处理重复代码的问题

泛型是具体类型或其它属性的抽象代替:

  • 你编写的代码不是最终的代码,而是一种模板,里面有一些“占位符”
  • 编译器在编译时将“占位符”替换为具体的类型

例如:fn largest<T>(list:&[T])->T{...}

T类型参数:

  • 很短,通常一个字母
  • CamelCase
  • T:type的缩写

Trait

Trait告诉Rust编译器:

  • 某种类型具有哪些并且可以与其它类型共享的功能

Trait:抽象的定义共享行为

Trait bounds(约束):泛型类型参数指定为实现了特定行为的类型

Trait与其它语言的接口(interface)类似,但有些区别

定义一个Trait

Trait的定义:把方法签名放在一起,定义实现某种目的所必需的一组行为

  • 关键字:trait
  • 只有方法签名,没有具体实现
  • trait可以有多个方法:每个方法签名占一行,以;结尾
  • 实现trait的类型必须提供具体的方法实现

在类型(结构体)上实现trait

  • 与为类型(结构体)实现方法类似
  • 不同之处:
    • impl Xxxx for Tweet {...}
    • 在impl的块里,需要对Trait里的方法签名进行具体的实现

实现trait的约束

可以在某个类型上实现某个trait的前提条件是:

  • 这个类型或这个trait是在本地crate里定义的

无法为外部类型来实现外部的trait:

  • 这个限制是程序属性的一部分(也就是一致性)
  • 更具体地说是孤儿规则:之所以这样命名是因为父类型不存在
  • 此规则确保其他人的代码不能破坏您的代码,反之亦然
  • 如果没有这个规则,两个crate可以为同一类型实现同一个trait,Rust就不知道应该使用哪个实现了

默认实现

  • 默认实现的方法可以调用trait中其它的方法,即使这些方法没有默认实现
  • 注意:无法从方法的重写实现里面调用默认的实现

Trait作为参数

  • impl Trait语法:适用于简单情况
  • Trait bound语法:可用于复杂情况
    • impl Trait语法是Trait bound的语法糖
  • 使用+指定多个Trait bound
  • Trait bound使用where子句
    • 在方法签名后指定where子句

实现Trait作为返回类型

  • impl Trait语法
  • 注意:impl Trait只能返回确定的同一种类型,返回可能不同类型的代码会报错

使用Trait Bound有条件的实现方法

  • 在使用泛型类型参数的impl块上使用Trait bound,可以有条件的为实现了特定Trait的类型来实现方法
  • 也可以为实现了其它Trait的任意类型有条件的实现某个Trait
  • 为满足Trait Bound的所有类型上实现Trait叫做覆盖实现(blanket implementations)

生命周期

  • Rust的每个引用都有自己的生命周期。
  • 生命周期:引用保持有效的作用域
  • 大多数情况:生命周期是隐式的、可被推断的
  • 当引用的生命周期可能以不同的方式互相关联时:手动标注生命周期

生命周期-避免悬垂引用(dangling reference)

  • 生命周期的主要目标:避免悬垂引用(dangling reference)

生命周期标注语法

  • 生命周期的标注不会改变引用的生命周期长度
  • 当指定了泛型生命周期参数,函数可以接收带有任何生命周期的引用
  • 生命周期的标注:描述了多个引用的生命周期间的关系,但不影响生命周期

生命周期标注一语法

  • 生命周期参数名:
    • '开头
    • 通常全小写且非常短
    • 很多人使用'a
  • 生命周期标注的位置:
    • 在引用的&符号后
    • 使用空格将标注和引用类型分开

函数签名中的生命周期标注

  • 泛型生命周期参数声明在:函数名和参数列表之间的<>
  • 生命周期'a的实际生命周期是:x和y两个生命周期中较小的那个

深入理解生命周期

  • 指定生命周期参数的方式依赖于函数所做的事情
  • 从函数返回引用时,返回类型的生命周期参数需要与其中一个参数的生命周期匹配:
  • 如果返回的引用没有指向任何参数,那么它只能引用函数内创建的值:
    • 这就是悬垂引用:该值在函数结束时就走出了作用域

Struct定义中的生命周期标注

Struct里可包括:

  • 自持有的类型
  • 引用:需要在每个引用上添加生命周期标注

生命周期的省略

我们知道:

  • 每个引用都有生命周期
  • 需要为使用生命周期的函数或struct指定生命周期参数

生命周期省略规则

  • 在Rust引用分析中所编入的模式称为生命周期省略规则。
    • 这些规则无需开发者来遵守
    • 它们是一些特殊情况,由编译器来考虑
    • 如果你的代码符合这些情况,那么就无需显式标注生命周期
  • 生命周期省略规则不会提供完整的推断:
    • 如果应用规则后,引用的生命周期仍然模糊不清→编译错误
    • 解决办法:添加生命周期标注,表明引用间的相互关系

输入、输出生命周期

生命周期在:

  • 函数方法的参数:输入生命周期
  • 函数/方法的返回值:输出生命周期

生命周期省略的三个规则

  • 编译器使用3个规测在没有显式标注生命周期的情况下,来确定引用的生命周期
    • 规则1应用于输入生命周期
    • 规则2、3应用于输出生命周期
    • 如果编译器应用完3个规则之后,
    • 仍然有无法确定生命周期的引用→报错
    • 这些规则适用于fn定义和impl块
  • 规则1:每个引用类型的参数都有自己的生命周期
  • 规则2:如果只有1个输入生命周期参数,那么该生命周期被赋给所有的输出生命周期参数
  • 规则3:如果有多个输入生命周期参数,但其中一个是&self&mut self(是方法),那么self的生命周期会被赋给所有的输出生命周期参数

方法定义中的生命周期标注

  • 在struct上使用生命周期实现方法,语法和泛型参数的语法一样
  • 在哪声明和使用生命周期参数,依赖于:
    • 生命周期参数是否和字段、方法的参数或返回值有关
  • struct字段的生命周期名:
    • 在impl后声明
    • 在struct名后使用
    • 这些生命周期是struct类型的一部分
  • impl块内的方法签名中:
    • 引用必须绑定于suct字段引用的生命周期,或者引用是独立的也可以
    • 生命周期省略规则经常使得方法中的生命周期标注不是必须的

静态生命周期

  • 'static是一个特殊的生命周期:整个程序的持续时间
    • 例如:所有的字符串字面值都拥有'static生命周期
      • let s:&'static str = "I have a static lifetime.";
  • 为引用指定'static生命周期前要三思:
    • 是否需要引用在程序整个生命周期内都存活

编写自动化测试

测试(函数)

  • 测试:
    • 函数
    • 验证非测试代码的功能是否和预期一致
  • 测试函数体(通常)执行的3个操作:
    • 准备数据/状态
    • 运行被测试的代码
    • 断言(Assert)结果

解剖测试函数

测试函数需要使用test属性(attribute)进行标注

  • Attribute就是一段Rust代码的元数据
  • 在函数上加#[test],可把函数变成测试函数

运行测试

  • 使用cargo test命令运行所有测试函数
    • Rust会构建一个Test Runner可执行文件
      • 它会运行标注了test的函数,并报告其运行是否成功
  • 当使用cargo创建library项目的时候,会生成一个test module,里面有一个test函数
    • 你可以添加任意数量的test module或函数

测试失败

  • 测试函数panic就表示失败
  • 每个测试运行在一个新线程。
  • 当主线程看见某个测试线程挂掉了,那个测试标记为失败了。

使用assert!宏检查测试结果

assert!宏,来自标准库,用来确定某个状态是否为true

  • true:测试通过
  • false:调用panic!,测试失败

使用assert_eg!和assert_ne!测试相等性

  • 都来自标准库
  • 判断两个参数是否相等或不等
  • 实际上,它们使用的就是==!=运算符
  • 断言失败:自动打印出两个参数的值
    • 使用debug格式打印参数
      • 要求参数实现了PartialEq和Debug Traits(所有的基本类型和标准库里大部分类型都实现了)

添加自定义错误消息

可以向assert!assert_eq!assert_ne!添加可选的自定义消息

  • 这些自定义消息和失败消息都会打印出来
  • assert!:第1参数必填,自定义消息作为第2个参数。
  • assert_eq!assert_ne!:前2个参数必填,自定义消息作为第3个参数。
  • 自定义消息参数会被传递给format!宏,可以使用{}占位符

验证错误处理的情况

  • 测试除了验证代码的返回值是否正确,还需验证代码是否如预期的处理了发生错误的情况
  • 可验证代码在特定情况下是否发生了panic
  • should_panic属性(attribute):
    • 函数panic:测试通过
    • 函数没有panic:测试失败

让should panic更精确

  • 为should_panic属性添加一个可选的expected参数:
    • 将检查失败消息中是否包含所指定的文字

在测试中使用Result<T,E>

无需panic,可使用Result<T,E>作为返回类型编写测试:

  • 返回OK:测试通过
  • 返回Err:测试失败

注意:不要在使用Result<T,E>编写的测试上标注#[should_panic]

控制测试如何运行

  • 改变cargo test的行为:添加命令行参数
  • 默认行为:
    • 并行运行
    • 所有测试
    • 捕获(不显示)所有输出,使读取与测试结果相关的输出更容易
  • 命令行参数
    • 针对cargo test的参数:紧跟cargo test后
    • 针对测试可执行程序:放在--之后
  • cargo test --help
  • cargo test -- --help

并行运行测试

  • 运行多个测试:默认使用多个线程并行运行。
    • 运行快
  • 确保测试之间:
    • 不会互相依赖
    • 不依赖于某个共享状态(环境、工作目录、环境变量等等)

–test-threads参数

  • 传递给二进制文件
  • 不想以并行方式运行测试,或想对线程数进行细粒度控制
  • 可以使用--test-threads参数,后边跟着线程的数量
  • 例如:cargo test -- --test-threads=1

显式函数输出

  • 默认,如测试通过,Rust的test库会捕获所有打印到标准输出的内容。
  • 例如,如果被测试代码中用到了println!:
    • 如果测试通过:不会在终端看到println!打印的内容
    • 如果测试失败:会看到println!打印的内容和失败信息
  • 如果想在成功的测试中看到打印的内容:--show-output

按名称运行测试的子集

  • 选择运行的测试:将测试的名称(一个或多个)作为cargo test的参数
  • 运行单个测试:指定测试名
  • 运行多个测试:指定测试名的一部分(模块名也可以)

忽略某些测试,运行剩余测试

  • ignore属性(attribute)

测试的分类

  • Rust对测试的分类:
    • 单元测试
    • 集成测试
  • 单元测试:
    • 小、专注
    • 一次对一个模块进行隔离的测试
    • 可测试private接口
  • 集成测试:
    • 在库外部。和其它外部代码一样使用你的代码
    • 只能使用public接口
    • 可能在每个测试中使用到多个模块

单元测试

#[cfg(test)]标注

  • tests模块上的#[cfg(test)]标注:
    • 只有运行cargo test才编译和运行代码
    • 运行cargo build则不会
  • 集成测试在不同的目录,它不需要#[cfg(test)]标注

cfg:configuration(配置)

  • 告诉Rust下面的条目只有在指定的配置选项下才被包含
  • 配置选项test:由Rust提供,用来编译和运行测试
  • 只有cargo test才会编译代码,包括模块中的helper函数和#[test]标注的函数

测试私有函数

  • Rust允许测试私有函数

集成测试

  • 在Rust里,集成测试完全位于被测试库的外部
  • 目的:是测试被测试库的多个部分是否能正确的一起工作
  • 集成测试的覆盖率很重要

tests目录

  • 创建集成测试:tests日录
  • tests目录下的每个测试文件都是单独的一个crate
    • 需要将被测试库导入
  • 无需标注#[cfg(test)],tests目录被特殊对待
    • 只有cargo test,才会编译tests目录下的文件

运行指定的集成测试

  • 运行一个特定的集成测试:cargo test 函数名
  • 运行某个测试文件内的所有测试:cargo test --test 文件名

集成测试中的子模块

  • tests目录下每个文件被编译成单独的crate
    • 这些文件不共享行为(与src下的文件规则不同)

闭包

什么是闭包(closure)

闭包:可以捕获其所在环境的匿名函数。

  • 是匿名函数
  • 保存为变量、作为参数
  • 可在一个地方创建闭包,然后在另一个上下文中调用闭包来完成运算
  • 可从其定义的作用域捕获值

闭包的类型推断

  • 注意:闭包的定义最终只会为参数/返回值推断出唯一具体的类型

记忆化

创建一个struct,它持有闭包及其调用结果。

  • 只会在需要结果时才执行该闭包
  • 可缓存结果
  • 这个模式通常叫做记忆化(memoization)或延迟计算(lazy evaluation)

如何让struct持有闭包

struct的定义需要知道所有字段的类型

  • 需要指明闭包的类型

每个闭包实例都有自己唯一的匿名类型,即使两个闭包签名完全一样。

所以需要使用:泛型和Trait Bound

Fn trait

  • Fn traits由标准库提供
  • 所有的闭包都至少实现了以下trait之一:
    • Fn
    • FnMut
    • FnOnce

使用缓存器(Cacher)实现的限制

  • Cacher实例假定针对不同的参数arg,value方法总会得到同样的值。
    • 可以使用HashMap代替单个值:
      • key:arg参数
      • value:执行闭包的结果
  • 只能接收一个U32类型的参数和U32类型的返回值

闭包可以捕获他们所在的环境

  • 闭包可以访问定义它的作用域内的变量,而普通函数则不能。
  • 会产生内存开销

闭包从所在环境捕获值的方式

与函数获得参数的三种方式一样:

  • 取得所有权:FnOnce
  • 可变借用:FnMut
  • 不可变借用:Fn

创建闭包时,通过闭包对环境值的使用,RUst推断出具体使用哪个trait:

  • 所有的闭包都实现了FnOnce
  • 没有移动捕获变量的实现了FnMut
  • 无需可变访问捕获变量的闭包实现了Fn

move关键字

  • 在参数列表前使用move关键字,可以强制闭包取得它所使用的环境值的所有权
    • 当将闭包传递给新线程以移动数据使其归新线程所有时,此技术最为有用

最佳实践

  • 当指定Fn trait bound之一时,首先用Fn,基于闭包体里的情况,如果需要FnOnce或FnMut,编译器会再告诉你

迭代器

什么是迭代器

  • 迭代器模式:对一系列项执行某些任务
  • 迭代器负责:
    • 遍历每个项
    • 确定序列(遍历)何时完成
  • Rust的迭代器:
    • 懒惰的:除非调用消费迭代器的方法,否则迭代器本身没有任何效果

Iterator trait

所有迭代器都实现了Iterator trait

Iterator trait定义于标准库,定义大致如下:

1
2
3
4
5
pub trait Iterator{
    type ltem;
    fn next(&mut self)->Option<Self::Item>;
    //methods with default implementations elided
}
  • type ItemSelf::ltem定义了与此该trait关联的类型
    • 实现Iterator trait需要你定义一个item类型,它用于next方法的返回类型(迭代器的返回类型)

Iterator trait

  • Iterator trait仅要求实现一个方法:next
  • next:
    • 每次返回迭代器中的一项
    • 返回结果包裹在Some里
    • 迭代结束,返回None
  • 可直接在迭代器上调用next方法

几个迭代方法

  • iter方法:在不可变引用上创建迭代器
  • into_iter方法:创建的迭代器会获得所有权
  • iter_mut方法:迭代可变的引用

消耗迭代器的方法

  • 在标准库中,Iterator trait有一些带默认实现的方法
  • 其中有一些方法会调用next方法
    • 实现Iterator trait时必须实现next方法的原因之一
  • 调用next的方法叫做“消耗型适配器”
    • 因为调用它们会把迭代器消耗尽
  • 例如:sum方法(就会耗尽迭代器)
    • 取得迭代器的所有权
    • 通过反复调用next,遍历所有元素
    • 每次迭代,把当前元素添加到一个总和里,迭代结束,返回总和

产生其它迭代器的方法

  • 定义在Iterator trait上的另外一些方法叫做“迭代器适配器”

    • 把迭代器转换为不同种类的迭代器
  • 可以通过链式调用使用多个迭代器适配器来执行复杂的操作,这种调用可读性较高。

  • 例如:map

    • 接收一个闭包,闭包作用于每个元素
    • 产生一个新的迭代器
  • collect方法:消耗型适配器,把结果收集到一个集合类型中

使用闭包捕获环境

filter方法:

  • 接收一个闭包
  • 这个闭包在遍历迭代器的每个元素时,返回bool类型
  • 如果闭包返回true:当前元素将会包含在filter产生的迭代器中
  • 如果闭包返回fase:当前元素将不会包含在filter产生的迭代器中

使用Iterator trait来创建自定义迭代器

  • 实现next方法

Cargo

release profile

release profile:

  • 是预定义的
  • 可自定义:可使用不同的配置,对代码编译拥有更多的控制

每个profile的配置都独立于其它的profile

Cargo主要的两个profile:

  • dev profile:适用于开发,cargo build
  • release profile:适用于发布,cargo build –release

自定义profile

  • 针对每个profile,Cargo都提供了默认的配置
  • 如果想自定义xxx profile的配置:
    • 可以在Cargo.toml里添加[profile.xxxx]区域,在里面覆盖默认配置的子集
  • 对于每个配置的默认值和完整选项,请参见:https://doc.rust-lang.org/cargo/

crates.io

  • 可以通过发布包来共享你的代码
  • crate的注册表在https://crates.io
    • 它会分发已注册的包的源代码
    • 主要托管开源的代码

Cargo工作空间(Workspaces)

  • cargo工作空间:帮助管理多个相互关联且需要协同开发的crate
  • cargo工作空间是一套共享同一个Cargo.lock和输出文件夹的包

创建工作空间

  • 有多种方式来组建工作空间例:1个二进制crate,2个库crate
    • 二进制crate:main函数,依赖于其它2个库crate
    • 其中1个库crate提供add_one函数
    • 另外1个库crate提供add_two函数

在工作空间中依赖外部crate

  • 工作空间只有一个Cargo.lock文件,在工作空间的顶层目录
    • 保证工作空间内所有crate使用的依赖的版本都相同
    • 工作空间内所有crate相互兼容

从CRATES.IO安装二进制crate

  • 命令:cargo install
  • 来源:https://crates.io
  • 限制:只能安装具有二进制目标(binary target)的crate
  • 二进制目标binary target:是一个可运行程序
    • 由拥有src/main.rs或其它被指定为二进制文件的crate生成
  • 通常:README里有关于crate的描述:
    • 拥有library target
    • 拥有binary target

cargo install

  • cargo install安装的二进制存放在根目录的bin文件夹
  • 如果你用rustup安装的Rust,没有任何自定义配置,那么二进制存放目录是$HOME/.cargo/bin
    • 要确保该目录在环境变量$PATH

使用自定义命令扩展cargo

  • cargo被设计成可以使用子命令来扩展
    • 例:如果$PATH中的某个二进制是cargo –something,你可以像子命令一样运行:
      • cargo something
  • 类似这样的自定义命令可以通过该命令列出:cargo –list
  • 优点:可使用cargo install来安装扩展,像内置工具一样来运行

文档注释

  • 文档注释:用于生成文档
    • 生成HTML文档
    • 显式公共API的文档注释:如何使用API
    • 使用///
    • 支持Markdown
    • 放置在被说明条目之前

生成HTML文档的命令

  • cargo doc
    • 它会运行rustdoc工具(Rust安装包自带)
    • 把生成的HTML文档放在target/doc目录下
  • cargo doc-open:
    • 构建当前crate的文档(也包含crate依赖项的文档)
    • 在浏览器打开文档

常用章节

  • #Examples
  • 其它常用的章节:
    • Panics:.函数可能发生panic的场景
    • Errors:如果函数返回Result,描述可能的错误种类,以及可导致错误的条件
    • Safety:如果函数处于unsafe调用,就应该解释函数unsafe的原因,以及调用者确保 的使用前提

文档注释作为测试

  • 示例代码块的附加值:
    • 运行cargo test:将把文档注释中的示例代码作为测试来运行

为包含注释的项添加文档注释

  • 符号://!
  • 这类注释通常用描述crate和模块:
    • crate root(按惯例src/lib.rs)
    • 一个模块内,将crate或模块作为一个整体进行记录

pub use

使用pub use导出方便使用的公共API

  • 问题:crae的程序结构在开发时对于开发者很合理,但对于它的使用者不够方便
    • 开发者会把程序结构分为很多层,使用者想找到这种深层结构中的某个类型很费劲
  • 例如:
    • 麻烦:my_crate::some_module::another_module::UsefulType;
    • 方便:my_crate::UsefulType;
  • 解决办法:
    • 不需要重新组织内部代码结构
    • 使用pub use:可以重新导出,创建一个与内部私有结构不同的对外公共结构

智能指针

相关的概念

指针:一个变量在内存中包含的是一个地址(指向其它数据)

Rust中最常见的指针就是“引用”

引用:

  • 使用&
  • 借用它指向的值
  • 没有其余开销
  • 最常见的指针类型

智能指针

智能指针是这样一些数据结构:

  • 行为和指针相似
  • 有额外的元数据和功能

引用计数(reference counting)智能指针类型

  • 通过记录所有者的数量,使一份数据被多个所有者同时持有
  • 并在没有任何所有者时自动清理数据

引用和智能指针的其它不同

  • 引用:只借用数据
  • 智能指针:很多时候都拥有它所指向的数据

智能指针的例子

  • StringVec<T>
  • 都拥有一片内存区域,且允许用户对其操作
  • 还拥有元数据(例如容量等)
  • 提供额外的功能或保障(String保障其数据是合法的UTF-8编码)

智能指针的实现

智能指针通常使用struct实现,并且实现了:

  • DerefDrop这两个trait

Deref trait:允许智能指针struct的实例像引用一样使用

Box

Box<T>是最简单的智能指针:

  • 允许你在heap上存储数据(而不是stack)
  • stack上是指向heap数据的指针
  • 没有性能开销
  • 没有其它额外功能
  • 实现了Deref trait和Drop trait

Box的常用场景

  • 在编译时,某类型的大小无法确定。但使用该类型时,上下文却需要知道它的确切大小。
  • 当你有大量数据,想移交所有权,但需要确保在操作时数据不会被复制。
  • 使用某个值时,你只关心它是否实现了特定的trait,而不关心它的具体类型。

使用Box赋能递归类型

  • 在编译时,Rust需要知道一个类型所占的空间大小。
  • 而递归类型的大小无法在编译时确定。
  • 但Box类型的大小确定
  • 在递归类型中使用Box就可解决上述问题。
  • 函数式语言中的Cons List

关于Cons List

  • Cons List是来自Lisp语言的一种数据结构。
  • Cons List里每个成员由两个元素组成。
    • 当前项的值
    • 下一个元素
  • Cons List里最后一个成员只包含一个Nil值,没有下一个元素。
  • Cons List并不是Rust的常用集合
    • 通常情况下,Vec<T>是更好的选择

使用BOX来获得确定大小的递归类型

Box<T>是一个指针,Rust知道它需要多少空间,因为:

  • 指针的大小不会基于它指向的数据的大小变化而变化
  • 只提供了“间接”存储和heap内存分配的功能
  • 没有其它额外功能
  • 没有性能开销
  • 适用于需要“间接”存储的场景,例如Cons List
  • 实现了Deref trait和Drop trait

Deref Trait

  • 实现Deref Trait使我们可以自定义解引用运算符*的行为。
  • 通过实现Deref,智能指针可像常规引用一样来处理
Built with Hugo
Theme Stack designed by Jimmy