Rust入门秘籍(更新中)

这是一本Rust的入门书籍,相比官方书籍《The Rust Programming Language》,本书要更详细、更具系统性,本书也尽量追求准确性。

但本人能力有限、见识有限、时间有限,我也不敢保证所写内容完全准确,如有发现错误之处,还请在博客www.junmajinlong.com/rust/index/的评论中指出,在此先行谢过。

本书目前还在不断更新中

Rust入门第一课

注:本节暂时没有具体内容,是留在最后来补写的,目前只是列了一些todo

Rust是静态、编译、内存安全、可完全0运行时环境、可脱离操作系统、可编写操作系统的语言。同时也是非常严格的语言。学习Rust和写Rust代码都非常消耗脑力。

编译器是最好的资料、最严格的老师,程序员绝大多数时候都在和编译器对抗。它,亦师亦友亦敌。

  • main(){}
  • rustc
  • cargo run --release
  • cargo build --release
  • 注释:// ///
  • 分号结尾,表示这是一行Rust代码,Rust以行为最小单位来解析代码
  • print!()
  • println!() {} {:?} {:p}
  • assert!()、assert_eq!()

Rust是基于表达式的语言

Rust是基于表达式的语言,几乎所有代码都可以看作是表达式。

表达式计算后有返回值,例如3+4是一个表达式,它返回计算结果7。

与表达式对应的概念是语句,语句没有返回值或者不关心其返回值。例如Rust中变量赋值的代码let a=3;是语句。

在Rust中,可以在表达式结尾加上分号;来将表达式转换为【语句】。例如:

fn main(){
  3 + 4;
}

编译器发现表达式后有分号结尾时,在编译期间会自动修改代码,它会在分号的后面加上一个小括号()单独的小括号是一个特殊的值,表示什么也不做

所以,以上代码实际上等价于:

fn main(){
  3+4;()
}

带有分号表示这是一行Rust代码,Rust会先执行3+4得到7,然后忽略或丢弃该表达式的返回值7,再然后执行下一行代码,即一个单独的小括号,小括号表示什么也不做,直接跳过。

所以,代码3+4;从原本的表达式转变成了不关心返回值的【语句】。

除了在表达式尾部加分号的代码是语句之外,还有另外一种情况的代码是语句而非表达式:用于声明或定义的代码都是语句。例如let声明变量、fn定义函数、struct声明结构体等。

Rust很多地方都会结合表达式和语句来做变量赋值。例如,if结构也是一个表达式,所以它有返回值,可以将if的返回值赋值给变量,而它的返回值来自于它的大括号:当大括号最后执行的一条代码不加分号结尾时,该代码的计算结果就是if结构的返回值

例如:


#![allow(unused)]
fn main() {
let x = if true {
  println!("true");
  33     // 分支的最后一条代码计算结果赋值给x,不能分号结尾
} else {
  println!("false");
  44     // 分支的最后一条代码计算结果赋值给x,不能分号结尾
}; // 这个结尾分号表示let语句的结尾分号
}

上面的else分支不能缺少,不能缺少else的原因留待后面的章节再解释。

变量声明和函数定义

本章将介绍Rust中使用变量的细节以及定义函数的基础知识。

理解Rust中的变量赋值

Rust中使用let声明变量

fn main(){
  // 声明变量name并初始化赋值
  let name = "junmajinlong.com";
  println!("{}", name);  // println!()格式化输出数据
}

Rust会对未使用的变量发出警告信息。如果确实想保留从未被使用过的变量,可在变量名前加上_前缀。

fn main(){
  let name = "junmajinlong.com";
  println!("{}", name);

  let gender = "male";   // 警告,gender未使用
  let _age = 18;     // 加_前缀的变量不被警告
}

Rust允许声明未被初始化(即未被赋值)的变量,但不允许使用未被赋值的变量。多数情况下,都是声明的时候直接初始化的。

fn main() {
  let name;  // 只声明,未初始化
  // println!("{}", name);  // 取消该行注释,将编译错误
  
  name = "junmajinlong.com";
  println!("{}", name);
}

Rust允许重复声明同名变量,后声明的变量将遮盖(shadow)前面已声明的变量。需注意的是,遮盖不是覆盖,被遮盖的变量仍然存在,而如果是被覆盖则不再存在(也即,覆盖时,原数据会被销毁)。

fn main() {
  let name = "junmajinlong.com";
  // 注释下行,将警告:name变量未被使用
  // 因为name仍然存在,只是被遮盖了
  println!("{}", name);  
  
  let name = "gaoxiaofang.com";  // 遮盖已声明的name变量
  println!("{}", name);
}

变量遮盖示意图:

注:下图内存布局并不完全正确,此图仅为说明变量遮盖
         +---------+       +--------------------+
         |  Stack  |       |        Heap        |
         +---------+       +--------------------+
name --> | 0x56789 |  ---> | "gaoxiaofang.com"  |
         |         |       +--------------------+
name --> | 0x01234 |  ---> | "junmajinlong.com" |
         +---------+       +--------------------+

变量初始化后,默认不允许再修改该变量。注意,修改变量是直接给变量赋值,而不是再次let声明该变量,再次声明变量是允许的,它会遮盖原变量。

fn main() {
  let name = "junmajinlong.com";
  // 取消下行注释将编译错误,默认不允许修改变量
  // name = "gaoxiaofang.com";
  
  let name = "gaoxiaofang.com";  // 再次声明变量,遮盖变量
  println!("{}", name);
}

如果想要修改变量的值,需要在声明变量时加上mut标记(mutable)表示该变量是可修改的。

fn main() {
  let mut name = "junmajinlong.com";
  println!("{}", name);
  
  name = "gaoxiaofang.com";   // 修改变量
  println!("{}", name);
}

Rust不仅对未被使用过的变量发出警告,还对赋值过但未被使用过的值发出警告。比如变量赋值后,尚未读取该变量,就重新赋值了。

fn main() {
  let mut name = "junmajinlong.com"; // 警告值未被使用过
  name = "gaoxiaofang.com"; 
  println!("{}", name);
}

Rust是静态语言,声明变量时需指定该变量将要保存的值的数据类型,这样编译器编译时才知道为该变量将要保存的数据分配多少内存、允许存放什么类型的数据以及如何存放数据。但Rust编译器会根据所保存的值来推导变量的数据类型,推导得到确定的数据类型之后(比如第一次为该变量赋值之后),就不再允许存放其他类型的数据。

fn main() {
  // 根据保存的值推导数据类型
  // 推导结果:变量name为 &str 数据类型
  let mut name = "junmajinlong.com"; 
  //name = 32;  // 再让name保存i32类型的数据,报错
}

当Rust无法推导类型时,或者声明变量时就明确知道该变量要保存声明类型的数据时,可明确指定该变量的数据类型

fn main() {
  // 指定变量数据类型的语法:在变量名后加": TYPE"
  let age: i32 = 32;  // 明确指定age为i32类型
  println!("{}", name);
  
  // i32类型的变量想存储u8类型数据,不允许
  // age = 23_u8;
}

虽然Rust是基于表达式的语言,但变量声明的let代码是语句而非表达式。这意味着let操作没有返回值,因此无法使用let来连续赋值

fn main(){
  let a = (let b = 1);  // 错误
}

可以使用tuple的方式同时为多个变量赋值,并且可以使用下划线_占位表示忽略某个变量的赋值过程


#![allow(unused)]
fn main() {
// x = 11, y = 22, 忽略33
let (x, y, _) = (11, 22, 33);
}

事实上,_占位符比想象中还更会【偷懒】,其他语言中_表达的含义可能是丢弃其赋值结果(甚至不丢弃),但Rust中的_会直接忽略变量赋值的过程。这导致了这样一种看似奇怪的现象:使用普通变量名会导致报错的变量赋值行为,使用_却不会报错

例如,下面(1)不会报错,而(2)会报错。这里涉及到了后面所有权转移的内容,如果看不懂请先跳过,只需记住结论:_会直接忽略赋值的过程。


#![allow(unused)]
fn main() {
// (1)
let s1 = "junmajinlong.com".to_string();
let _ = s1;
println!("{}", s1); // 不会报错

// (2)
let s2 = "junmajinlong.com".to_string();
let ss = s2;
println!("{}", s2); // 报错
}

最后要说明的是,Rust中变量赋值操作实际上是Rust中的一种模式匹配,在后面的章节中将更系统、更详细地介绍Rust模式匹配功能。

Rust中定义函数

Rust中使用fn关键字定义函数,定义函数时需指定参数的数据类型,如果有返回值,则需要指明返回值的数据类型。

fn关键字、函数名、函数参数及其类型、返回值类型组成函数签名。例如fn fname(a: i32, b: i32)->i32是一个函数签名。

定义函数参见如下几个简单的示例:

// 没有参数、没有返回值
fn f0(){
  println!("first function_0");
  println!("first function_1");
}

// 有参数,没有返回值
fn f1(a: i32, b: i32) {
  println!("a: {}, b: {}", a, b);
}

// 有参数,有返回值
fn f2(a: i32, b: i32) -> i32 {
  return a + b;
}

// 调用函数
fn main(){
  f0();
  f1(1,2);
  f2(3,4);
}

函数也可以直接定义在函数内部。例如在函数a中定义函数b,这样函数b就只能在函数a中访问或调用:

fn f0(){
  println!("first function_0");
  println!("first function_1");
  
  fn f1(a: i32, b: i32) {
    println!("a: {}, b: {}", a, b);
  }
  
  f1(2,3);
}

fn main(){
  f0();
}

Rust有两种方式指定函数返回值:

  • 使用return来指定返回值,此时return后要加上分号结尾,使得return成为一个语句
    • return关键字不指定返回值时,默认返回()
  • 不使用return,将返回最后一条执行的表达式计算结果,该表达式尾部不能带分号
    • 不使用return,但如果最后一条执行的是一个分号结尾的语句,则返回()

参考如下函数定义:


#![allow(unused)]
fn main() {
fn f0(a: i32) -> i32{
  if a > 0 {
    // 使用return来返回,结尾处必须不能缺少分号
    return a * 2;
  }
  
  // 最后执行的一条代码,使用表达式的结果作为函数返回值
  // 结尾必须不能带分号
  a * 2
}
}

Rust原始数据类型

官方手册:https://doc.rust-lang.org/beta/std/index.html#primitives

理解什么是原始数据类型(primitive type)

有些数据就是简简单单的,比如数字3,它就是一个数值3,编译器或解释器不需要任何其他信息来识别它,只要看到3就知道它是一个数值类型。

但是有些数据类型稍微复杂一点,除了要存储数据本身之外,编译器或解释器还需要再多保存一点关于该数据的元数据信息。比如数组类型,除了存储数组中各元素数据之外,还需要额外存储数组的长度信息,这样编译器或解释器才知道数组到哪里结束,这里数组的长度就是数组类型的元数据。

所谓原始数据类型,就是该类型的数据只需要数据本身即可,没有额外元数据。

Rust有很多种原始数据类型(primitive type),这些原始数据类型都是Rust内置的类型(在核心库core中定义而非标准库std中定义的类型)。包括数据大小固定的机器类型(Machine Type)、某些组合类型和其他一些Rust语言必要的内置类型。

包括:

  • 机器类型(大小是固定的)
    • bool
    • u8、u16、u32、u64、u128、usize
    • i8、i16、i32、i64、i128、isize
    • f32、f64
    • char
  • 组合类型
    • Tuple
    • Array
  • 其他语言必要类型
    • Slice,即切片类型
    • str,即字符串切片类型
    • !,即never类型
    • (),即Unit类型
    • reference,即引用类型
    • pointer,即裸指针类型
    • fn,即函数指针类型

本章会介绍其中一些原始数据类型,还会额外简单地介绍一个非原始数据类型:String类型。

数值类型

Rust的数值类型包括整数和浮点数。有如下几种类型:

长度有符号无符号浮点数
8-biti8u8
16-biti16u16
32-biti32(默认)u32f32
64-biti64u64f64(默认)
128-biti128u128
wordisizeusize

注: word表示一个机器字长,通常是一个指针的大小,大小和机器有关。64位机器的word是64-bit,32位机器的word是32-bit。

可以在数值字面量后加上类型来表示该类型的数值。例如:

fn main(){
  let _a = 33i32;    // 直接加类型后缀
  let _b = 33_i32;   // 使用_分隔数值和类型
  let _c = 33_isize;
  let _d = 33_f32;
}

如果数值较长,可以在任意位置处使用下划线_划分数值,增加可读性。

fn main(){
  let _a = 33_333_33_i32;
  let _b = 3_333_333_i32;
  let _c = 3_333_333f32;
}

当不明确指定变量的类型,也不明确指定数值字面量的类型后缀,Rust默认将整数当作i32类型,浮点数当作f64类型

fn main(){
  // 等价于 let _a: i32 = 33_i32;
  let _a = 33;
  
  // 等价于let _b: f64 = 64.123_f64;
  let _b = 64.123;
}

每种数值类型都有所能存储的最大数值和最小数值。当超出类型的有效范围时,Rust将报错(panic)。例如u8类型的范围是0-255,它无法存储256。

fn main() {
  let n: i32 = std::i32::MAX;  // i32类型的最大值
  println!("{}", n + 1);     // 编译错误,溢出
}

Rust允许使用0b 0o 0x来表示二进制、八进制和十六进制的整数

fn main(){
  let a = 0b101_i32;  // 二进制整数,i32类型
  let b = 0o17;       // 八进制整数,i32类型
  let c = 0xac;       // 十六进制整数,i32类型
  println!("{}, {}, {}", a, b, c);  // 5, 15, 172
}

数值类型之间默认不会隐式转换,如果需要转换数值类型,可手动使用as进行转换(as主要用于原始数据类型间的类型转换)。例如3_i32 as u8表示将i32类型的3转换为u8类型。需注意,宽类型数值转为窄类型数值时,如果溢出,则从高位截断。

fn main(){
  assert_eq!(10_i8 as u16, 10_u16);
  assert_eq!(2525_u16 as i16, 2525_i16);
  
  // 有符号类型->有符号类型
  assert_eq!(-1_i16 as i32, -1_i32);
  // 有符号到无符号类型
  assert_eq!(-1_i32 as u8, 255_u8);
  
  // 范围溢出,截断
  assert_eq!(1000_i16 as u8, 232_u8);
  
  // 浮点数转整数,小数部分被丢弃
  assert_eq!(33.33_f32 as u8, 33_u8);
}

Rust数值是一种类型的值,每种类型有自己的方法,因此数值也可以调用它们具有的方法。

fn main(){
  // 需注意,下面的数值都加上了类型后缀。
  // 这是因为在调用方法的时候,需要知道值的
  // 所属类型才能找到这种类型具有的方法
  println!("{}", 3_u8.pow(2));      // 9
  println!("{}", (-3_i32).abs());   // 3
  // 4,计算45的二进制中有多少个1
  println!("{}", 45i32.count_ones());  // 4
}

Rust将字节字面量存储为u8类型,字节字面量的表示方式为b'X'(b后面使用单引号包围单个ASCII字符)。

例如A的ASCII码为65,那么b'A'完全等价于65u8

fn main(){
  let a = b'A';     // a的类型自动推导为u8
  let b = a - 65;   // b的类型也自动推导为u8
  println!("{}, {}", a, b);  // 65, 0
}

需注意,某些特殊ASCII字符需要使用反斜线转义,例如b'\n', b'\'', b'\\'。有些控制类的字符无法直接写出来,此时可以使用十六进制法来表示,例如b'\x1b'表示ESC按键的控制符。

布尔类型

Rust中的Boolean类型有两个值:true和false。

类似于if、while等的控制语句以及逻辑运算符|| && !都需要进行条件判断,Rust只允许在条件判断处使用布尔类型。

例如,要判断x是否等于0,在其他语言中可能允许如下写法:


#![allow(unused)]
fn main() {
if x {
  ...
}
}

但在Rust中,不允许上面的写法(除非x的值自身就是true或false)。

Rust中必须得在条件判断处写返回值为true/false的表达式。例如写成如下形式:


#![allow(unused)]
fn main() {
if x == 0 {
  ...
}
}

Rust的布尔值可以使用as操作符转换为各种数值类型,false对应0,true对应1。但数值类型不允许转换为bool值。再次提醒,as操作符常用于原始数据类型之间的类型转换。

fn main() {
  println!("{}", true as u32);
  println!("{}", false as u8);
  // println!("{}", 1_u8 as bool);  // 编译错误
}

char类型

char官方手册:https://doc.rust-lang.org/beta/std/primitive.char.html

char类型是Rust的一种基本数据类型,用于存放单个unicode字符,占用4字节空间(32bit)。

在存储char类型数据时,会将其转换为UTF-8编码的数据(即Unicode代码点)进行存储。

char字面量是单引号包围的任意单个字符,例如'a'、'我'。注意:char和单字符的字符串String是不同的类型。

允许使用反斜线对某些特殊字符转义:

字符名      字节字面量
--------------------
单引号      '\''
反斜线      '\\'
换行符      '\n'
换页符      '\r'
制表符      '\t'

Rust不会自动将char类型转换为其他类型,但可以进行显式转换:

  • 可使用as将char转为各种整数类型,目标类型小于4字节时,将从高位截断
  • 可使用as将u8类型转char
    • 之所以不支持其他整数类型,是因为其他整数类型的值可能无法转换为char(即不在UTF-8编码表范围的整数值)
  • 可使用std::char::from_u32将u32整数类型转char,返回值Option<char>
    • 如果传递的u32数值不是有效的Unicode代码点,则from_u32返回None
    • 否则返回Some(c),c就是char类型的字符
  • 可使用std::char::from_digit(INT, BASE)将十进制的INT转换为BASE进制的char
    • 如果INT参数不是有效的进制数,返回None
    • 如果BASE超出进制数的合理范围[1,36],将panic
    • 否则返回Some(c),c就是char类型的字符

例如:


#![allow(unused)]
fn main() {
// char -> Integer
println!("{}", '我' as i32);     // 25105
println!("{}", '是' as u16);     // 26159
println!("{}", '是' as u8);      // 47,被截断了

// u8 -> char
println!("{}", 97u8 as char);    // a

// std::char
use std::char;

println!("{}", char::from_u32(0x2764).unwrap());  // ❤
assert_eq!(char::from_u32(0x110000), None);  // true

println!("{}", char::from_digit(4,10).unwrap());  // '4'
println!("{}", char::from_digit(11,16).unwrap()); // 'b'
assert_eq!(char::from_digit(11,10),None); // true
}

字符串

Rust中的字符串是一个难点,此处先简单介绍关于字符串的一部分内容,更多细节和用法留到后面再单独解释。

Rust有两种字符串类型:str和String。其中str是String的切片类型,也就是说,str类型的字符串值是String类型的字符串值的一部分或全部。

字符串字面量

字符串字面量使用双引号包围。

fn main(){
  let s = "junmajinlong.com";
  println!("{}", s);
}

上面赋值变量时进行了变量推导,推导出的变量数据类型为&str。因此,上述代码等价于:

fn main(){
  let s: &str = "junmajinlong.com";
  println!("{}", s);
}

实际上,字符串字面量的数据类型均为&str,其中str表示str类型,&表示该类型的引用,即一个指针。因此,&str表示的是一个指向内存中str类型数据的指针,该指针所指向的内存位置处保存了字符串数据"junmajinlong.com"

至于为什么字符串字面量的类型是&str而不是str,后文再解释。

String类型的字符串

String类型的字符串没有对应的字面量构建方式,只能通过Rust提供的方法来构建。

例如,可以通过字符串字面量(即&str类型的字符串)来构建。

fn main(){
  // 类型自动推导为: String
  let s = String::from("junmajinlong.com");
  let s1 = "junmajinlong".to_string();
  println!("{},{}", s, s1);
}

String类型的字符串可以原地修改。例如:

fn main(){
  let mut s = String::from("junmajinlong");
  s.push('.');        // push()可追加单个char字符类型
  s.push_str("com");  // push_str()可追加&str类型的字符串
  println!("{}", s);  // 输出:junmajinlong.com
}

理解str和String的联系和区别

注:这部分内容对刚接触Rust的人来说较难理解,可先跳过,等阅读了后面一些章节再回来看。

str类型的字符串和String类型的字符串是有联系的:str字符串是String类型字符串的切片(slice)类型。关于切片类型,参考Slice类型

例如,变量s保存了String类型的字符串junma,那么s[0..1]就是str类型的字符串js[0..3]就是str类型的字符串jun

例如:

fn main(){
  let s = String::from("junmajinlong.com");
  
  // 自动推导数据类型为&str
  //   s[0..3]的类型为str
  //  &s[0..3]的类型为&str
  let s_str = &s[0..3];  // 等价于&(s[0..3])而不是(&s)[0..3]
  // 现在s_str通过胖指针引用了源String字符串中的局部数据
  
  println!("{}", s_str);  // 输出:jun
}

前面说过,字符串字面量的类型是&str类型。也就是说,字符串字面量实际上是字符串切片类型的引用类型。

fn main(){
  // IDE中可看到下面的变量推导出的数据类型为&str
  let s = "hello";
}

那么字符串字面量是如何存储的呢?

对于字面量"hello"来说,并不是先在内存中以String类型的方式存储"hello",然后再创建该String数据的引用来得到了一个&str的。

编译器对字符串字面量做了特殊处理:编译器编译的时候直接将字符串字面量以硬编码的方式写入程序二进制文件中,当程序被加载时,字符串字面量被放在内存的某个位置(不在堆中也不在栈中,而是在类似于静态数据区的全局字面量区)。当程序执行到let s="hello";准备将其赋值给变量s时(注:s在栈上),直接将字面量内存区的该数据地址保存到&str类型的s中。

理解了这一点,再理解let s = String::from("hello");这样的代码就很容易了。编译器将"hello"硬编码写入程序二进制文件,程序加载期间字符串字面量被放入字面量内存区,当程序运行到let s = String::from()操作时,从字面量内存区将其拷贝到堆内存中,然后将堆内存中该数据的地址保存到栈内变量s中。

tuple类型

Rust的tuple类型可以存放0个、1个或多个任意数据类型的数据。使用tup.N的方式可以访问索引为N的元素。


#![allow(unused)]
fn main() {
let n = (11, 22, 33);
println!("{}", n.0);  // 11
println!("{}", n.1);  // 22
println!("{}", n.2);  // 33
}

注意,访问tuple元素的索引必须是编译期间就能确定的数值,而不能是变量。


#![allow(unused)]
fn main() {
let n = (11, 22, 33);
let a: usize = 2;
println!("{}", n.a);  // 错误
}

实际上,n.a会被Rust解析为对Struct类型的变量n的a字段的访问。

tuple通常用来作为简单的数据组合体。

例如:

fn main(){
  // 表示一个人的name和age
  let p_name = "junmajinlong";
  let p_age = 23;
  println!("{}, {}", p_name, p_age);
  
  // 与其将有关联的数据分开保存到多个变量中,
  // 不如保存在一个结构中
  let p = ("junmajinlong", 23); // 同时存放&str和i32类型的数据
  println!("{}, {}", p.0, p.1);
}

Rust中经常会将tuple类型的各元素赋值给各变量,方式如下:

fn main(){
  let p = ("junmajinlong", 23);
  
  // 也可以类型推导:let (name,age) = p;
  let (name, age): (&str, i32) = p;
  // 比 let name = p.0; let age = p.1; 更简洁
  println!("{}, {}", name, age);
}

有时候tuple里只会保存一个元素,此时必须不能省略最后的逗号:


#![allow(unused)]
fn main() {
let p = ("junmajinlong",);
}

unit类型

不保存任何数据的tuple表示为()。在Rust中,它是特殊的,它有自己的类型:unit。

unit类型的写法为(),该类型也只有一个值,写法仍然是()。参考下面的写法应该能搞清楚。


#![allow(unused)]
fn main() {
// 将值()保存到类型为()的变量x中
//    类型    值
let x: ()  =  ();
}

unit类型通常用在那些不关心返回值的函数中。在其他语言中,那些不写return语句或return不指定返回内容的的函数,一般表示不关心返回值。在Rust中可将这种需求写为return ()

Array类型

Rust中的数组和其他语言中的数组不太一样,Rust数组长度固定、元素类型相同。

数组的数据类型表示方式为[Type; N],其中:

  • Type是该数组要存储什么类型的数据,数组中的所有元素类型都必须是Type
  • N是数组的长度,Rust不会自动伸缩数组的长度

数组字面量使用中括号[]表示,例如[1,2,3]。还有一种特殊的表示数组字面量的方式是[val; N],这有点像数组类型的描述方式[Type; N],不过这里表示的是该数组长度为N,并且这N个元素的值都初始化为val。

例如:

fn main(){
  // 自动推导类型为:[i32; 4]
  let _arr = [11,22,33,44];
  
  let _arr1: [&str; 3] = ["junma", "jinlong", "gaoxiao"];
  
  // 自动推导类型为:[u8; 1024]
  // 该数组初始化为1024个u8类型的0
  // 可将之当作以0填充的1K的buf空间
  let _arr2 = [0_u8; 1024]; 
}

注意,[Type; N]是用来描述数据类型的,所以其中的N必须在编译期间就能确认,因此N不能是一个变量。

fn main(){
  let n = 3;
  // 编译错误,提示n不是常量值
  let _arr1: [&str; n] = ["junma", "jinlong", "gaoxiao"];
}

可以迭代数组,不过不能直接for i in arr{},而是for i in &arr{}或者for i in arr.iter(){}。例如:

fn main(){
  let arr = [11,22,33,44];
  for i in arr.iter() {
    println!("{}", i);
  }
}

数组有很多方法可以使用,例如len()方法可以获取数组的长度。

fn main(){
  let arr = [11,22,33,44];
  println!("{}", arr.len());    // 4
}

实际上,数组的方法都来自Slice类型。Slice类型后面会详细介绍。

Rust中的引用类型

本节简单介绍Rust中的引用,混个脸熟,后面会专门详细介绍引用。

Rust中,使用&T表示类型T的引用类型(reference type)。

例如,&String表示String的引用类型,&i32表示i32的引用类型,&&i32表示i32引用的引用类型。

引用类型是一种数据类型,它表示其所保存的值是一个引用

值的引用写法和引用类型的写法类似。例如&33表示的是33这个值的引用。

引用,通常来说是指向其他数据的一个指针或一个胖指针(有额外元数据的指针)。例如&33表示的是一个指向数据值33的一个指针。

因此,引用类型保存值的引用

例如:


#![allow(unused)]
fn main() {
let n: &i32 = &33_i32;
}

这里变量n的类型是引用类型&i32,它所保存的值必须是i32类型数据的引用,例如上面的&33_i32就是33_i32的引用。

可以将保存了引用的变量赋值给其他变量,这样就有多个变量拥有同一份数据的引用。

fn main(){
  let n = 33;
  let n_ref1 = &n;     // n_ref1指向33
  let n_ref2 = n_ref1; // n_ref2也指向33
}

可以使用std::ptr::eq()来判断两个引用是否指向同一个地址,即判断所指向的数据是否是同一份数据。

fn main(){
  let n = 33;
  let n_ref1 = &n;
  let n_ref2 = n_ref1;
  println!("{}", std::ptr::eq(n_ref1, n_ref2)); // true
}

可变引用

直接使用&创建出来的引用是只读的,这意味着可以通过该引用去读取其指向的数据,但是不能通过引用去修改指向的数据。

如果想要通过引用去修改源数据,需要使用&mut v来创建可修改源数据v的可变引用

注意,想要通过&mut引用去修改源数据,要求原变量是可变的。这很容易理解,&mut是一个对源数据的引用,如果源数据本身就不允许修改,当然也无法通过&mut去修改这份数据。

因此,使用&mut的步骤大致如下:


#![allow(unused)]
fn main() {
let mut x = xxxx;
let x_ref = &mut x;
}

例如,下面声明的变量n是不可变的,即使创建&mut n,也无法修改原始数据。实际上,这会导致编译错误。

fn main(){
  let n = 33;
  let n_ref = &mut n;   // 编译错误
}

因此,改为如下代码可编译通过:

fn main(){
  let mut n = 33;
  let n_ref = &mut n;
}

解引用

解引用表示解除引用,即通过引用获取到该引用所指向的原始值

解引用使用*T表示,其中T是一个引用(如&i32)。

例如:

fn main(){
  let s = String::from("junma");
  let s_ref = &s;   // s_ref是指向"junma"的一个引用
  
  // *s_ref表示通过引用s_ref获取其指向的"junma"
  // 因此s和*s_ref都指向同一个"junma",它们是同一个东西
  assert_eq!(s, *s_ref);  // true
}

再例如:

fn main(){
  let mut n = 33;
  let n_ref = &mut n;
  n = *n_ref + 1;
  println!("{}", n);
}

Rust绝大多数时候不会自动地解除引用。但在某些环境下,Rust会自动进行解引用。

自动解引用的情况有(结论先总结在此,混脸熟,以后涉及到时再来):

  • (1).使用.操作符时(包括取属性值和方法调用),会隐式地尽可能解除或创建多层引用
  • (2).使用比较操作符时,若比较的两边是相同类型的引用,则会自动解除引用到它们的值然后比较

对于(1),Rust会自动分析func()的参数,并在需要的时候自动创建或自动解除引用。例如以abc.func()有可能会自动转换为&abc.func(),反之,&abc.func()也有可能会自动转换为abc.func()

对于(2),例如有引用类型的变量n,那么n > &30*n > 30的效果是一样的。

Slice类型

Slice类型通常翻译为切片,它表示从某个包含多个元素的容器中取得局部数据,这个过程称为切片操作。不同语言对切片的支持有所不同,比如有些语言只允许取得连续的局部元素,而有些语言可以取得离散元素,甚至有些语言可以对hash结构进行切片操作。

Rust也支持Slice操作,Rust中的切片操作只允许获取一段连续的局部数据,切片操作获取到的数据称为切片数据

Rust常见的数据类型中,有三种类型已支持Slice操作:String类型、Array类型和Vec类型(本文介绍的Slice类型自身也支持切片操作)。实际上,用户自定义的类型也可以支持Slice操作,只要自定义的类型满足一些条件即可,相关内容以后再介绍。

slice操作

有以下几种切片方式:假设s是可被切片的数据

  • s[n1..n2]:获取s中index=n1到index=n2(不包括n2)之间的所有元素
  • s[n1..]:获取s中index=n1到最后一个元素之间的所有元素
  • s[..n2]:获取s中第一个元素到index=n2(不包括n2)之间的所有元素
  • s[..]:获取s中所有元素
  • 其他表示包含范围的方式,如s[n1..=n2]表示取index=n1到index=n2(包括n2)之间的所有元素

例如,从数据s中取第一个元素和取前三个元素的切片示意图如下:

切片操作允许使用usize类型的变量作为切片的边界。例如,n是一个usize类型的变量,那么s[..n]是允许的切片操作。

slice作为数据类型

和其他语言的Slice不同,Rust除了支持切片操作,还将Slice上升为一种原始数据类型(primitive type),切片数据的数据类型就是Slice类型。

Slice类型是一个胖指针,它包含两份元数据:

  • 第一份元数据是指向源数据中切片起点元素的指针
  • 第二份元数据是切片数据中包含的元素数量,即切片的长度

例如,对于切片操作s[3..5],其起点指针指向s中index=3处的元素,切片长度为2。

Slice类型的描述方式为[T],其中T为切片数据的数据类型。例如对存放了i32类型的数组进行切片,切片数据的类型为[i32]

由于切片数据的长度无法在编译期间得到确认(比如切片操作的边界是变量时s[..n]),而编译器是不允许使用大小不定的数据类型的,因此无法直接去使用切片数据(比如无法直接将它赋值给变量)。

fn main(){
  let arr = [11,22,33,44,55];
  let n: usize = 3;

  // 编译错误,无法直接使用切片类型
  let arr_s = arr[0..n];
}

也因此,在Rust中几乎总是使用切片数据的引用。切片数据的引用对应的数据类型描述为&[T]&mut [T],前者不可通过Slice引用来修改源数据,后者可修改源数据。

注意区分Slice类型和数组类型的描述方式。

数组类型表示为[T; N],数组的引用类型表示为&[T; N],Slice类型表示为[T],Slice的引用类型表示为&[T]

例如,对一个数组arr做切片操作,取得它的不可变引用arr_slice1和可变引用arr_slice2,然后通过可变引用去修改原数组的元素。

fn main(){
  let mut arr = [11,22,33,44];

  // 不可变slice
  let arr_slice1 = &arr[..=1];
  println!("{:?}", arr_slice1); // [11,22];

  // 可变slice
  let arr_slice2 = &mut arr[..=1];  
  arr_slice2[0] = 1111;
  println!("{:?}", arr_slice2);// [1111,22];
  println!("{:?}", arr);// [1111,22,33,44];
}

需要说明的一点是,虽然[T]类型和&[T]类型是有区别的,前者是切片类型,后者是切片类型的引用类型,但因为几乎总是通过切片类型的引用来使用切片数据,所以通常会去混用这两种类型(包括一些书籍也如此),无论是[T]还是&[T]都可以看作是切片类型。

特殊对待的str切片类型

需要特别注意的是,String的切片和普通的切片有些不同

一方面,String的切片类型是str,而非[String],String切片的引用是&str而非&[String]

另一方面,Rust为了保证字符串总是有效的Unicode字符,它不允许用户直接修改字符串中的字符,所以也无法通过切片引用来修改源字符串,除非那是ASCII字符(ASCII字符总是有效的unicode字符)。

事实上,Rust只为&str提供了两个转换ASCII大小写的方法来修改源字符串,除此之外,没有为字符串切片类型提供任何其他原地修改字符串的方法。

fn main(){
  let mut s = String::from("HELLO");
  let ss = &mut s[..];

  // make_ascii_lowercase()
  // make_ascii_uppercase()
  ss.make_ascii_lowercase();
  println!("{}", s);  // hello
}

Array类型自动转换为Slice类型

在Slice的官方手册中,经常会看到将Array的引用&[T;n]当作Slice来使用。

例如:


#![allow(unused)]
fn main() {
let arr = [11,22,33,44];
let slice = &arr;   // &arr将自动转换为slice类型

// 调用slice类型的方法first()返回slice的第一个元素
println!("{}", slice.first().unwrap());  // 11
}

所以,可以直接将数组的引用当成slice来使用。即&arr&mut arr当作不可变slice和可变slice来使用。

另外,在调用方法的时候,由于.操作符会自动创建引用或解除引用,因此Array可以直接调用Slice的所有方法

例如:


#![allow(unused)]
fn main() {
let arr = [11, 22, 33, 44];

// 点运算符会自动将arr.first()转换为&arr.first()
// 而&arr又会自动转换为slice类型
println!("{}", arr.first().unwrap());
}

这里需要记住这个用法,但目前请忽略以上自动转换行为的内部原因,其涉及到尚未介绍的类型转换机制。

Slice类型支持的方法

Slice支持很多方法,这里介绍几个比较常用的方法,更多方法可参考官方手册:https://doc.rust-lang.org/std/primitive.slice.html#impl

注:这些方法都不适用于String Slice,String Slice可用的方法较少,上面给出官方手册中,除了方法名中有"ascii"的方法(如is_ascii()方法)是String Slice可使用的方法外,其他方法都不能被String Slice调用。

一些常见方法

  • len():取slice元素个数
  • is_empty():判断slice是否为空
  • contains():判断是否包含某个元素
  • repeat():重复slice指定次数
  • reverse():反转slice
  • join():将各元素压平(flatten)并通过指定的分隔符连接起来
  • swap():交换两个索引处的元素,如s.swap(1,3)
  • windows():以指定大小的窗口进行滚动迭代
  • starts_with():判断slice是否以某个slice开头

例如:


#![allow(unused)]
fn main() {
let arr = [11,22,33];
println!("{}",   arr.len());  // 3
println!("{:?}", arr.repeat(2)); // [11, 22, 33, 11, 22, 33]
println!("{:?}", arr.contains(&22)); // true

// reverse()
let mut arr = [11,22,33];
arr.reverse();
println!("{:?}",arr); // [33,22,11]

// join()
println!("{}", ["junma","jinlong"].join(" ")); // junma jinlong
println!("{:?}", [[1,2],[3,4]].join(&0)); // [1,2,0,3,4]

// swap()
let mut arr = [1,2,3,4];
arr.swap(1,2);
println!("{:?}", arr); // [1,3,2,4]

// windows()
let arr = [10, 20, 30, 40];
for i in arr.windows(2) {
  println!("{:?}", i); // [10,20], [20,30], [30,40]
}

// starts_with(),相关的方法还有ens_with()
let arr = [10, 20, 30, 40];
println!("{}", arr.starts_with(&[10]));  // true
println!("{}", arr.starts_with(&[10, 20])); // true
println!("{}", arr.starts_with(&[30]));  // false
}

Rust操作符和流程控制语句

本章将介绍Rust中的一些操作符以及流程控制结构。

Rust操作符

操作符(Operator)通常是由一个或多个特殊的符号组成(也有非特殊符号的操作符,如as),比如+ - * / % & *等等,每个操作符都代表一种动作(或操作),这种动作作用于操作数之上。简单来说,就是对操作数执行某种操作,然后返回操作后得到的结果。

比如加法操作3 + 2,这里的+是操作符,加号两边的3和2是操作数,加法符号的作用是对操作数3加上操作数2,得到计算结果5,然后返回5。

此处仅列出一部分操作符并给出它们的含义,剩下其他的操作符将在后面章节涉及到的时候再介绍。

操作符类别操作符及描述示例
一元运算符-:取负(加负号)-x
!:对整数值是位取反,对布尔值是逻辑取反!x
算术运算符+ - * / %:加、减、乘、除、取模x + y
位运算符`&^ ! << >>`:位与、位或、位异或、位取反、左移、右移
逻辑运算符`& &&
赋值操作符=x = y
复合赋值操作符`+= -= *= /= %= &== ^= <<= >>=`
等值比较运算符== !=:相等和不等x == y
大小比较运算符< <= > >=:小于、小于等于、大于、大于等于x > y

以上操作符有几点需要说明:

  • 各种运算符有优先级,可使用小括号()来强制改变多个运算符运算时的优先级,如(x + y) * z

  • ! & |操作符有两种意思,根据上下文决定:

    • 操作数是整数值时:按位取反、按位与、按位或
    • 操作数是布尔值时:逻辑取反、逻辑与、逻辑或
  • & &&都表示逻辑与,但后者会短路计算。同理| ||都表示逻辑或,但后者会短路计算

    例如,false & true在知道左边的操作数是false后,仍然会计算右边的操作数,而false && true知道左边是false后,已经能够确定整个表达式的结果是false,它会直接返回false,而不会再计算右边的操作数。

    
    #![allow(unused)]
    fn main() {
    // 不会panic报错退出,因为不会评估 || 运算符右边的操作数
    if true || panic!("not bang!!!") {}
    // 会panic报错退出,因为会评估 | 运算符右边的操作数
    if true | panic!("bang!!!") {}
    }
    

范围(Range)表达式

Rust支持范围操作符,有以下几种表示范围的操作符:

范围表达式类型表示的范围
start..endstd::ops::Rangestart ≤ x < end
start..std::ops::RangeFromstart ≤ x
..endstd::ops::RangeTox < end
..std::ops::RangeFull-
start..=endstd::ops::RangeInclusivestart ≤ x ≤ end
..=endstd::ops::RangeToInclusivex ≤ end

例如,1..5表示1、2、3、4共四个整数,1..=5表示1、2、3、4、5共五个整数。

需注意的是其中表示全范围的表达式..,它表示可以尽可能地生成下一个数,直到无法生成为止。

在生成Slice的时候,需要使用到范围表达式。例如,从数组生成Slice:


#![allow(unused)]
fn main() {
let arr = [11, 22, 33, 44, 55];
let s1 = &arr[0..3];    // [11,22,33]
let s2 = &arr[1..=3];   // [22, 33, 44]
let s3 = &arr[..];      // [11, 22, 33, 44, 55]
}

范围表达式也常被用于迭代操作。例如for语句:


#![allow(unused)]
fn main() {
for i in 1..5 {
  println!("{}", i);  // 1 2 3 4
}
}

另外,范围表达式和对应类型的实例是等价的。例如,下面两个表示范围的方式是等价的:


#![allow(unused)]
fn main() {
let x = 0..5;
let y = std::ops::Range {start: 0, end: 5};
}

流程控制结构

流程控制结构包括:

  • if条件判断结构
  • loop循环
  • while循环
  • for..in迭代

除此之外,还有其他几种本节暂不介绍的控制结构。

需要说明的是,Rust中这些结构都是表达式,它们都有默认的返回值(),且if结构和loop循环结构可以指定返回值。

注:【这些结构的默认返回值是()】的说法是不严谨的

之所以可以看作是默认返回(),是因为Rust会在每个分号结尾的语句后自动加上小括号(),使得语句看上去也有了返回值。

为了行文简洁,下文将直接描述为默认返回值。

if..else

if语句的语法如下:


#![allow(unused)]
fn main() {
if COND1 {
  ...
} else if COND2 {
  ...
} else {
  ...
}
}

其中,条件表达式COND不需要加括号,且COND部分只能是布尔值类型。另外,else if分支是可选的,且可以有多个,else分支也是可选的,但最多只能有一个。

由于if结构是表达式,它有返回值,所以可以将if结构赋值给一个变量(或者其他需要值的地方)。

但是要注意,if结构默认返回Unit类型的(),这个返回值是没有意义的。如果要指定为其他有意义的返回值,要求:

  • 分支最后执行的那一行代码不使用分号结尾,这表示将最后执行的这行代码的返回值作为if结构的返回值
  • 每个分支的返回值类型相同,这意味着每个分支最后执行的代码都不能使用分号结尾
  • 必须要有else分支,否则会因为所有分支条件判断都不通过而直接返回if的默认返回值()

下面用几个示例来演示这几个要求。

首先是一段正确的代码片段:


#![allow(unused)]
fn main() {
let x = 33;

// 将if结构赋值给变量a
// 下面if的每个分支,其返回值类型都是i32类型
let a = if x < 20 {
  // println!()不是该分支最后一条语句,要加结尾分号
  println!("x < 20");
  // x+10是该分支最后一条语句,
  // 不加分号表示将其计算结果返回,返回类型为i32
  x + 10  
} else if x < 30 {
  println!("x < 30");
  x + 5   // 返回x + 5的计算结果,返回类型为i32
} else {
  println!("x >= 30");
  x     // 直接返回x,返回类型为i32
};  // if最后一个闭大括号后要加分号,这是let的分号
}

下面是一段将if默认返回值()赋值给变量的代码片段:


#![allow(unused)]
fn main() {
let x = 33;

// a被赋值为`()`
let a = if x < 20 {
  println!("x < 20");
};
println!("{:?}", a);  // ()
}

下面不指定else分支,将报错:


#![allow(unused)]
fn main() {
let x = 33;

// if分支返回i32类型的值
// 但如果没有执行if分支,则返回默认值`()`
// 这使得a的类型不是确定的,因此报错
let a = if x < 20 {
  x + 3   // 该分支返回i32类型
};
}

下面if分支和else if分支返回不同类型的值,将报错:


#![allow(unused)]
fn main() {
let x = 33;

let a = if x < 20 {
  x + 3      // i32类型
} else if x < 30 {
  "hello".to_string()  // String类型
} else {
  x   // i32类型
};
}

由于if的条件表达式COND部分要求必须是布尔值类型,因此不能像其他语言一样编写类似于if "abc" {}这样的代码。但是,却可以在COND部分加入其他语句,只要保证COND部分的返回值是bool类型即可。

例如下面的代码。注意下面使用大括号{}语句块包围了if的COND部分,使得可以先执行其他语句,在语句块的最后才返回bool值作为if的分支判断条件。


#![allow(unused)]
fn main() {
let mut x = 0;
if {x += 1; x < 3} {
  println!("{}", x);
}
}

这种用法在if结构上完全是多此一举的,但COND的这种用法也适用于while循环,有时候会有点用处。

while循环

while循环的语法很简单:


#![allow(unused)]
fn main() {
while COND {
  ...
}
}

其中,条件表达式COND和if结构的条件表达式规则完全一致。

如果要中途退出循环,可使用break关键字,如果要立即进入下一轮循环,可使用continue关键字。

例如:


#![allow(unused)]
fn main() {
let mut x = 0;

while x < 5 {
  x += 1;
  println!("{}", x);
  if x % 2 == 0 {
    continue;
  }
}
}

根据前文对if的条件表达式COND的描述,COND部分允许加入其他语句,只要COND部分最后返回bool类型即可。例如:


#![allow(unused)]
fn main() {
let mut x = 0;

// 相当于do..while
while {println!("{}", x);x < 5} {
  x += 1;
  if x % 2 == 0 {
    continue;
  }
}
}

最后,while虽然有默认返回值(),但()作为返回值是没有意义的。因此,不考虑while的返回值问题。

loop循环

loop表达式是一个无限循环结构。只有在loop循环体内部使用break才能终止循环。另外,也使用continue可以直接跳入下一轮循环。

例如,下面的循环结构将输出1、3。


#![allow(unused)]
fn main() {
let mut x = 0;
loop {
  x += 1;
  if x == 5 {
    break;
  }
  if x % 2 == 0 {
    continue;
  }
  println!("{}", x);
}
}

loop也有默认返回值(),可以将其赋值给变量。例如,直接将上例的loop结构赋值给变量a:


#![allow(unused)]
fn main() {
let mut x = 0;
let a = loop {
  ...
};

println!("{:?}", a);   // ()
}

作为一种特殊情况,当在loop中使用break时,break可以指定一个loop的返回值


#![allow(unused)]
fn main() {
let mut x = 0;
let a = loop {
  x += 1;
  if x == 5 {
    break x;    // 返回跳出循环时的x,并赋值给变量a
  }
  if x % 2 == 0 {
    continue;
  }
  println!("{}", x);
};
println!("var a: {:?}", a); // 输出 var a: 5
}

注意,只有loop中的break才能指定返回值,在while结构或for迭代结构中使用的break不具备该功能。

for迭代

Rust中的for只具备迭代功能。迭代是一种特殊的循环,每次从数据的集合中取出一个元素是一次迭代过程,直到取完所有元素,才终止迭代。

例如,Range类型是支持迭代的数据集合,Slice类型也是支持迭代的数据集合。

但和其他语言不一样,Rust数组不支持迭代,要迭代数组各元素,需将数组转换为Slice再进行迭代。


#![allow(unused)]
fn main() {
// 迭代Range类型:1..5
for i in 1..5 {
  println!("{}", i);
}

let arr = [11, 22, 33, 44];
// arr是数组,&arr转换为Slice,Slice可迭代
for i in &arr {
  println!("{}", i);
}
}

标签label

可以为loop结构、while结构、for结构指定标签,break和continue都可以指定标签来确定要跳出哪一个层次的循环结构。

例如:


#![allow(unused)]
fn main() {
// 'outer和'inner是标签名
'outer: loop {
  'inner: while true {
    break 'outer;  // 跳出外层循环
  }
}
}

需注意,loop结构中的break可以同时指定标签和返回值,语法为break 'label RETURN_VALUE

例如:


#![allow(unused)]
fn main() {
let x = 'outer: loop {
  'inner: while true {
    break 'outer 3;
  }
};

println!("{}", x);   // 3
}

理解Rust内存管理

Rust是内存安全、没有GC(垃圾回收)的高效语言。使用Rust,需要正确理解Rust管理内存的方式。

本章简单介绍一些有关于Rust内存的内容,更多细节则分散在其他各知识点中。

Rust没有严格定义其使用的内存模型(即没有相关规范说明),但可以粗略理解为使用下图内存布局:

堆空间和栈空间

Rust语言区分堆空间和栈空间,虽然它们都是内存中的空间,但使用堆和栈的方式不一样,这也使得使用堆和栈的效率有所区别。

栈空间和栈帧

栈空间和栈帧都是属于操作系统的概念,操作系统负责管理栈空间,负责创建、释放栈帧。

栈空间采用后进先出的方式存放数据(就像叠盘子)。每次调用函数,都会在栈的顶端创建一个栈帧(stack frame),用来保存该函数的上下文数据。比如该函数内部声明的局部变量通常会保存在栈帧中。当该函数返回时,函数返回值也保留在该栈帧中。当函数调用者从栈帧中取得该函数返回值后,该栈帧被释放(实际上不会真的释放栈帧的空间,无效的栈帧可以被复用)。

实际上,有一个ESP寄存器专门用来跟踪栈帧,该寄存器中保存了当前最顶端的栈帧地址。当调用函数创建新的栈帧时(栈帧总是在栈顶创建),ESP寄存器的值更新为此栈帧的地址,当函数返回且返回值已被读取后,该函数栈帧被移除出栈,出栈的方式很简单,只需更新ESP寄存器使其指向上一个栈帧的地址即可。

不仅栈空间中的栈帧是后进先出的,栈帧内部的数据也是后进先出的。比如函数内先创建的局部变量在栈帧的底部,后创建的局部变量在栈帧的顶部。当然,上下顺序并非一定会如此,这和编译器有关,但编写程序时可如此理解。

实际上,有一个EBP寄存器专门用来跟踪调用者栈帧的位置。当在函数a中调用函数b时,首先创建函数a的栈帧,当开始调用函数b时,将在栈顶创建函数b的栈帧,并拷贝上一个ESP的值到EBP,这样EBP寄存器就保存了函数a的栈帧地址,当函数b返回时通过EBP就可以回到函数a的栈帧。

在编写代码的时候,通常不考虑属于操作系统的栈空间和栈帧的概念,而是这样思考:有一块内存,这块内存中存放数据的方式是后进先出。比如,调用函数时,函数内部的局部变量可以说成【存放在栈中或栈空间中】,而不将其具体到【存放在该函数的栈帧中】。也就是说,此时可以混用栈和栈空间的说法,且重在描述(主要是为了将栈和堆区分开来)而不是侧重于其准确性。后文也都如此混用栈和栈空间。

堆内存

不同于栈空间由操作系统跟踪管理,堆内存是一片无人管理的自由内存区,需要时要手动申请,不需要时要手动释放,如果不释放已经无用的堆内存,将导致内存泄漏,内存泄漏过多(比如在某个循环内不断泄漏),可能会耗尽内存。

手动申请、手动释放堆内存是一件非常难的事,特别是程序较大时,判断在何处编写释放内存的代码更是难上加难。所以有一些语言提供了垃圾回收器(GC)来自动管理堆内存的回收。

Rust没有提供GC,也无需手动申请和手动释放堆内存,但Rust是内存安全的。这是因为Rust使用了自己的一套内存管理机制,只要能够编译通过,多数情况下可以保证程序没有内存问题。

其中机制之一是作用域:Rust中所有的大括号都是一个独立的作用域,作用域内的变量在离开作用域时会失效,而变量绑定的数据(无论绑定的是堆内数据还是栈中数据)则自动被释放

fn main(){
  {   // 大括号,一个独立的作用域
    let n = 33;
    println!("{}", n);
  }  // 变量n在此失效,其绑定的数据33被释放
  // 此处无法再使用变量n
  // println!("{}", n);  // 编译错误
}

关于Rust更多的内存管理机制(如所有权系统、生命周期等),放在后面的章节再解释。

Rust如何使用堆和栈

有些数据适合存放于堆,有些数据适合存放于栈。

(1).栈适合存放存活时间短的数据

比如函数内部的局部变量适合存放在栈中,因为函数返回后,该函数中声明的局部变量就没有意义了,随着函数栈帧的释放,该栈中的所有数据也随之消失。

与之对应的,存活时间长的数据通常应该存放在堆空间中。比如多个函数(有不同栈帧)共用的数据应该存放在堆中,这样即使一个函数返回也不会销毁这份数据。

(2).数据要存放于栈中,要求数据所属数据类型的大小是已知的。因为只有这样,Rust编译器才知道在栈中为该数据分配多少内存。

与之对应的,如果无法在编译期间得知数据类型的大小,该数据将不允许存放在栈中,只能存放在堆中。

例如,i32类型的数据存放在栈中,因为i32类型的大小是固定的,无论对它做什么操作,只要它仍然是i32类型,那么它的大小就一定是4字节。而String类型的数据是存放在堆中的,因为String类型的字符串是可变而非固定大小的,最初初始化的时候可能是空字符串,但可以在后期向此空字符串中加入任意长度的字符串,编译器显然无法在编译期间就得知字符串的长度。

(3).使用栈的效率要高于使用堆

将数据存放于栈中时,因为编译器已经知道将要存放于栈中数据的大小,所以编译器总是在栈帧中分配合适大小的内存来存放数据。另一方面,栈中数据的存放方式是后进先出。这相当于编译器总是找好各种大小合适的盒子来存放数据并将盒子放在栈的顶部,而释放栈中数据的方式则是从栈顶拿走盒子

与之对应的是将数据存放于堆中时,当程序运行时会向操作系统申请一片空闲的堆内存空间,然后将数据存放进去。但是堆内存空间是无人管理的自由内存区,操作系统想要从堆中找到空闲空间需要做一些额外操作。更严重的是堆中有大量碎片内存的情况,操作系统可能会将多份小的碎片空闲内存通过链表的方式连接起来组成一个大的空闲空间分配给程序,这样的效率是非常低的。

对比堆和栈的使用方式,显然以【盒子】为操作单位且总是跟踪栈顶的栈内存管理方式的效率要远高于堆

其实,可以将栈理解为将物品放进大小合适的纸箱并将纸箱按规律放进储物间,将堆理解为在储物间随便找一个空位置来放置物品。显然,以纸箱为单位来存取物品的效率要高的多,而直接将物品放进凌乱的储物间的效率要低的多,而且储物间随意堆放的东西越多,空闲位置就越零碎,存取物品的效率就越低,且空间利用率就越低。

用一张图来描述它们:

(4).Rust将哪些数据存放于栈中

数据要存放在栈中,要求其数据类型的大小已知。但数据类型大小已知的数据并不一定存放在栈中。

Rust中,有如下类型的数据存放在栈中:

  • 裸指针(一个机器字长)、普通引用(一个机器字长)、胖指针(除了指针外还包含其他元数据信息)
  • 布尔值
  • char
  • 各种整数、浮点数
  • 数组(Rust数组的元素数据类型和数组长度都是固定不变的)
  • 元组

以上分类需要注意几点:

  • 将栈中数据赋值给变量时,数据直接存放在栈中。比如i32类型的33,33直接存放在栈内,而不是在堆中存放33并在栈中存放指向33的引用
  • 引用、胖指针包含了很多种可能:
    • 对于let s = String::from("hello"),字符串数据存放在堆中,但在栈中存放了指向该字符串的胖指针
    • 因此,将堆中数据赋值给变量时,总是将指向该堆数据的指针保存在栈中
    • 切片类型的引用(如&str)是胖指针,存放在栈中
    • 函数指针(fn类型)存放在栈中
  • 有些数据是0字节的,不需要占用空间,比如()
  • 尽管数组和元组是可以存放任意数据的容器,但保存在数组、元组中的要么是栈中值,要么是指向堆中数据的引用,所以这两种类型的值也在栈中

(5).Rust除了使用堆栈,还使用全局内存区(静态变量区和字面量区)

Rust编译器会将全局内存区的数据直接嵌入在二进制程序文件中,当启动并加载程序时,嵌入在全局内存区的数据被放入内存的某个位置。

全局内存区的数据是编译期间就可确定的,且存活于整个程序运行期间。

字符串字面量、static定义的静态变量(相当于全局变量)都会硬编码嵌入到二进制程序的全局内存区

例如:

fn main(){
  let _s = "hello";     // (1)
  let _ss = String::from("hello"); // (2)
  let _arr = ["hello";3];    // (3)
  let _tuple = ("hello",);   // (4)
  // ...
}

上面代码中的几个变量都使用了字符串字面量,且使用的都是相同的字面量"hello",在编译期间,它们会共用同一个"hello",该"hello"会硬编码到二进制程序文件中。当程序被加载到内存时,该被放入到全局内存区,它在全局内存区有自己的内存地址,当运行到以上各行代码时:

  • 代码(1)、(3)、(4),将根据地址取得其引用,并分别保存到变量_s_arr各元素、_tuple元素中
  • 代码(2),将根据地址取得数据并将其拷贝到堆中

(6).Rust中允许使用const定义常量。常量将在编译期间直接以硬编码的方式内联(inline)插入到使用常量的地方

所谓内联,即将它代表的值直接替换到使用它的地方。

比如,定义了常量ABC=33,在第100行和第300行处都使用了常量ABC,那么在编译期间,会将33硬编码到第100行和第300行处。

Rust中,除了const定义的常量会被内联,某些函数也可以被内联。将函数进行内联,表示将该函数对应的代码体直接展开并插入到调用该函数的地方,这样就没有函数调用的开销(比如没有调用函数时申请栈帧、在寄存器保存某些变量等的行为),效率会更高一些。但只有那些频繁调用的短函数才适合被内联,并且内联会导致程序的代码膨胀。

理解Rust的所有权和borrow规则

Rust的所有权系统是保证Rust内存安全最关键的手段之一,例如它使得Rust无需GC也无需手动释放内存。

所有权系统影响整个Rust,它也使得Rust的很多编码方式和其他语言不太一样。因此,需要掌握好Rust的所有权规则,才能写出可运行的、正确的Rust代码,并且越熟悉所有权规则,在编码过程中就越少犯错。

Rust编译器无论在哪方面都是最好且最严格的老师,编译器的borrow checker组件会给出和所有权相关的所有错误。了解所有权规则后,只需跟着编译器的报错,就能知道错在何处,以及如何改正错误。

理解Rust的变量作用域

Rust的所有权系统和作用域息息相关,因此有必要先理解Rust的作用域规则。

在Rust中,任何一个可用来包含代码的大括号都是一个单独的作用域。类似于Struct{}这样用来定义数据类型的大括号,不在该讨论范围之内,本文后面所说的大括号也都不考虑这种大括号。

包括且不限于以下几种结构中的大括号都有自己的作用域:

  • if、while等流程控制语句中的大括号
  • match模式匹配的大括号
  • 单独的大括号
  • 函数定义的大括号
  • mod定义模块的大括号

例如,可以单独使用一个大括号来开启一个作用域:


#![allow(unused)]
fn main() {
{                    // s 在这里无效, 它尚未声明
  let s = "hello";   // 从此处起,s是有效的
  println!("{}", s); // 使用 s
}                    // 此作用域已结束,s不再有效
}

上面的代码中,变量s绑定了字符串字面值,在跳出作用域后,变量s失效,变量s所绑定的值会自动被销毁。

注:上文【变量s绑定的值会被销毁】的说法是错误的

实际上,变量跳出作用域失效时,会自动调用Drop Trait的drop函数来销毁该变量绑定在内存中的数据,这里特指销毁堆和栈上的数据,而字符串字面量是存放在全局内存中的,它会在程序启动到程序终止期间一直存在,不会被销毁。可通过如下代码验证:

fn main(){
    {
      let s = "hello";
      println!("{:p}", s);  // 0x7ff6ce0cd3f8
    }

    let s = "hello";
    println!("{:p}", s);  // 0x7ff6ce0cd3f8
}

因此,上面的示例中只是让变量s失效了,仅此而已,并没有销毁s所绑定的字符串字面量。

但一般情况下不考虑这些细节,而是照常描述为【跳出作用域时,会自动销毁变量所绑定的值】。

任意大括号之间都可以嵌套。例如可以在函数定义的内部再定义函数,在函数内部使用单独的大括号,在函数内部使用mod定义模块,等等。

fn main(){
  fn ff(){
    println!("hello world");
  }
  ff();

  let mut a = 33;
  {
    a += 1;
  }
  println!("{}", a);  // 34
}

虽然任何一种大括号都有自己的作用域,但函数作用域比较特别。函数作用域内,无法访问函数外部的变量,而其他大括号的作用域,可以访问大括号外部的变量

fn main() {
  let x = 32;
  fn f(){
    // 编译错误,不能访问函数外面的变量x和y
    // println!("{}, {}", x, y);  
  }
  let y = 33;
  f();

  let mut a = 33;
  {
    // 可以访问大括号外面的变量a
    a += 1;
  }
  println!("{}", a);
}

在Rust中,能否访问外部变量称为【捕获环境】。比如函数是不能捕获环境的,而大括号可以捕获环境。

对于可捕获环境的大括号作用域,要注意Rust的变量遮盖行为。

分析下面的代码:

fn main(){
  let mut a = 33;
  {
    a += 1;   // 访问并修改的是外部变量a的值

    // 又声明变量a,这会发生变量遮盖现象
    // 从此开始,大括号内访问的变量a都是该变量
    let mut a = 44; 
    a += 2;
    println!("{}", a);  // 输出46
  }    // 大括号内声明的变量a失效
  println!("{}", a);   // 输出34
}

这种行为和其他语言不太一样,因此这种行为需要引起注意。

悬垂引用

在支持指针操作的语言中,一不小心就会因为释放内存而导致指向该数据的指针变成悬垂指针(dangling pointer)。

Rust的编译器保证永远不会出现悬垂引用:引用必须总是有效。即引用必须在数据被销毁之前先失效,而不能销毁数据后仍继续持有该数据的引用。

例如,下面的代码不会通过编译:

fn main(){
  let sf = f();  // f()返回值是一个无效引用
}

fn f() -> &String {
  let s = String::from("hello");
  &s  // 返回s的引用
}   // s跳出作用域,堆中String字符串被释放

该示例报错的原因很明显,函数的返回值&s是一个指向堆中字符串数据的引用(注意,引用是一个实实在在的数据),当函数结束后,s跳出作用域,其保存的字符串数据被销毁,这使得返回值&s变成了一个无效的引用。

这里的悬垂指针非常明显,但很多时候会在非常隐晦的情况下导致悬垂指针,幸好Rust保证了绝不出现悬垂指针的问题。

Rust所有权规则概述

Rust的所有权(ownership)规则贯穿整个语言,几乎每行代码都涉及到所有权规则,因此需要对所有权规则非常熟悉才能更好地使用Rust。

Rust所有权规则可以总结为如下几句话:

  • Rust中的每个值都有一个被称为其所有者的变量(即:值的所有者是某个变量)
  • 值在任一时刻有且只有一个所有者
  • 当所有者(变量)离开作用域,这个值将被销毁

这里对第三点做一些补充性的解释,所有者离开作用域会导致值被销毁,这个过程实际上是调用一个名为drop的函数来销毁数据释放内存。在前文解释作用域规则时曾提到过,销毁的数据特指堆栈中的数据,如果变量绑定的值是全局内存区内的数据,则数据不会被销毁。

例如:

fn main(){
  {
    // 堆中String类型的字符串数据的所有者是变量s
    // 换句话说,变量s拥有堆中String字符串的所有权
    let mut s = String::from("hello");
  } // 跳出作用域,栈中的变量s将被销毁,其指向的堆
    // 中数据也被销毁,但全局内存区的字符串字面量仍被保留
}

Rust中数据的移动

在其他语言中,有深拷贝和浅拷贝的概念,浅拷贝描述的是只拷贝数据对象的引用,深拷贝描述的是根据引用递归到最终的数据并拷贝数据。

在Rust中没有深浅拷贝的概念,但有移动(move)、拷贝(copy)和克隆(clone)的概念。

看下面的赋值操作,在其他语言中这样赋值是正确的,但在Rust中这样的赋值很可能会报错。

fn main(){
  let s1 = String::from("hello");
  let s2 = s1;

  // 将报错error: borrow of moved value: `s1`
  println!("{},{}", s1, s2); 
}

上面的示例中,变量s1绑定了堆中String字符串数据,此时该数据的所有者是s1。

当执行let s2 = s1;时,将不会拷贝堆中数据赋值给s2,也不会像其他语言一样让变量s2也绑定堆中数据(即,不会拷贝堆数据的引用赋值给s2)。

因此,下图的内存引用方式不适用于Rust。

如果Rust采用这种内存引用方式,按照Rust的所有权规则,变量在跳出作用域后就销毁堆中对应数据,那么在s1和s2离开作用域时会导致二次释放同一段堆内存,这会导致内存污染。

Rust采用非常直接的方式,当执行let s2 = s1;时,直接让s1无效(s1仍然存在,只是变成未初始化变量,Rust不允许使用未初始化变量,可重新为其赋值),而是只让s2绑定堆内存的数据。也就是将s1移动到s2,也称为堆数据的所有权从s1移给s2

如图(注意,这个图是错的,但便于此处对移动这个概念的理解,稍后解释为什么):

所有权移动后修改数据

定义变量的时候,加上mut表示变量可修改。当发生所有权转移时,后拥有所有权的变量也可以加上mut


#![allow(unused)]
fn main() {
let mut x = String::from("hello");

// x将所有权转移给y,但y无法修改字符串
let y = x;   
// y.push('C');  // 本行报错

let a = String::from("hello");
// 虽然a无法修改字符串,但转移所有权后,b可修改字符串
let mut b = a; 
b.push('C');   // 本行不报错
}

移动真的只是移动吗?

很多人会误解【移动】这个词,认为移动的效率比拷贝的效率更高。

比如下面的示例:


#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1;
}

上面已经分析过,堆中字符串数据的所有权会从变量s1转移到变量s2,很多人会认为这个过程是类似于按引用转移的过程,即将栈中该字符串数据的胖指针从s1转移到了s2,从而使得s1丢失指针,而s2拥有堆中字符串数据的所有权。

尽管这样的行为才是更高效的行为,但Rust并不是这样做的。

在发生所有权转移时,Rust会拷贝数据(直接内存拷贝),然后将拷贝得到的数据绑定到新变量。原始数据将被销毁,原始变量将回到未初始化状态,但注意,转移所有权时销毁原始数据时,不会调用用于析构的drop方法。

比如上面的示例中,当执行let s2 = s1;时,会将堆中字符串重新拷贝一次,然后将拷贝得到的新字符串赋值给变量s2,同时会销毁原始字符串数据,使得变量s1回到未初始化状态。

可通过如下代码验证该结论:

fn main(){
  let s1 = String::from("junmajinlong");
  println!("{:p}", &s1);  // 0x64600ff898

  // 所有权转移,但地址也发生了改变
  let s2 = s1;
  println!("{:p}", &s2);  // 0x64600ff900
}

因此,所有权的移动,不仅仅是所有权的移动,也是数据的移动,理解这一点很重要。例如,所有权移动后,由于数据也从原来的内存位置处被移走,使得原来指向它的引用将指向一个被释放的内存地址,即这个引用将变成悬垂指针。

Move不仅发生在变量赋值过程中,在函数传参、函数返回数据时也会Move,因此,如果将一个大数据量的变量作为参数传递给函数,是否会让效率很低下?

按照上面的结论来说,确实如此。但Rust编译器会对Move语义的行为做出一些优化,简单来说,当数据量较大且不会引起程序正确性问题时,它会传递数据的指针而非内存拷贝。

另外,对于胖指针类型的变量,即使发生了拷贝,其性能也不差,因为拷贝的只是它的胖指针部分。

总之,Move虽然发生了内存【拷贝】,但它的性能并不会太受影响。

此处部分结论参考:https://stackoverflow.com/questions/30288782/what-are-move-semantics-in-rust

克隆数据

虽然所有权转移时,Rust也会拷贝实际的数据,但所有权转移会导致原始变量变回未初始化状态而无法使用。

可以使用clone()方法手动拷贝变量绑定的数据,同时不会让原始变量变回未初始化状态。

fn main(){
  let s1 = String::from("hello");
  // 克隆s1,克隆之后,变量s1仍然绑定原始数据
  let s2 = s1.clone();
  println!("{},{}", s1, s2);
}

但不是所有数据类型都可以进行克隆,只有那些实现了Clone Trait的类型才可以进行克隆(Trait类似于面向对象语言中的接口,如果不了解可先不管Trait是什么),常见的数据类型都已经实现了Clone,因此它们可以直接使用clone()来克隆。

对于那些没有实现Clone Trait的自定义类型,需要手动实现Clone Trait。在自定义类型之前加上#[derive(Copy, Clone)]即可。例如:


#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
struct Abc(i32, i32)
}

这样Abc类型的值就可以使用clone()方法进行克隆。

Rust栈中数据的拷贝

前面介绍了所有权的转移,所有权转移后,原始变量会变回未初始化状态而无法使用。一般情况下,所有权转移是针对堆中数据的,比如String类型的数据存放在堆中(但它的胖指针存放在栈中)。

于栈而言,其保存的都是原始的数据值或各种类型的指针,它们的大小都是固定的。当栈中数据的所有权发生转移时,情况有所不同:直接拷贝栈中数据,且不会让原始变量丢失原数据的所有权

分析如下代码:

fn main(){
  // s1绑定了栈中的3,注意数值3是直接存储在栈中的
  let s1 = 3;
  let s2 = s1; // 拷贝s1的值给s2,现在栈中有两个3

  println!("{}, {}", s1, s2); // 3, 3
  println!("{:p}, {:p}", &s1, &s2);
  // 0xb36b37fa80, 0xb36b37fa84
}

变量s1绑定了栈中的数据3,然后拷贝了s1并赋值给s2,因为s1绑定的是栈中数据,所以所有权转移时实际上是拷贝了栈中的3,使得栈中有两个3,并且s1和s2都保存各自的3。

因此,如果变量s1是保存在栈中数据,那么let s2 = s1;等价于let s2 = s1.clone();

为什么栈中的数据在发生所有权转移时不会让原始变量变回未初始化状态?这是因为保存在栈中的数据类型实现了Copy这个Trait

比如所有整数类型、浮点数类型、指针类型等都实现了Copy Trait,它们在发生所有权转移时,都不会让原始变量丢失原始数据的所有权,相当于是克隆了栈中数据。

实际上,即使是保存在堆中的数据类型,只要它实现了Copy Trait,那么它的值在发生所有权转移时,也一样不会让原变量变回未初始化状态。

那么什么类型是Copy的呢?这需要查看该类型的文档来确认。不过作为通用的规则,保存在栈中的数据类型都是实现了Copy Trait的。包括但不限于:

  • 所有整数类型,比如u32
  • 所有浮点数类型,比如f64
  • 布尔类型,bool,它的值是true和false
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都是Copy的时候。比如(i32, i32)是Copy的,但(i32, String)不是
  • 指针类型或引用类型

对于那些没有实现Copy的自定义类型,可以手动去实现Copy,方式很简单:


#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
struct Abc(i32, i32)
}

函数参数和返回值的所有权移动

函数参数类似于变量赋值,在调用函数时,会将所有权移动给函数参数

函数返回时,返回值的所有权从函数内移动到函数外变量

例如:

fn main(){
  let s1 = String::from("hello");
  
  // 所有权从s1移动到f1的参数
  // 然后f1返回值的所有权移动给s2
  let s2 = f1(s1); 
  // 注意,println!()不会转移参数s2的所有权
  println!("{}", s2);

  let x = 4; // x绑定的是栈中数据(是Copy的)
  f2(x);     // 没有移动所有权,而是拷贝一份给f2参数
}  // 首先x跳出作用域,其对应栈数据出栈,
   // 然后s2跳出作用域,并释放对应堆内存数据,
   // 最后s1跳出作用域,s1没有所有权,所以没有任何其他影响

fn f1(s: String) -> String {
  let ss = String::from("world"); 
  println!("{},{}", s,ss);
  s  // 返回值s绑定的堆数据的所有权移动到函数外
}    // ss跳出作用域,其对应堆中数据被释放

fn f2(i: i32){
  println!("{}",i);
}   // i跳出作用域,i绑定的栈数据出栈

很多时候,变量传参之后丢失所有权是非常不方便的,这意味着函数调用之后,原变量就不可用了。另一方面,所有权转移时也拷贝了数据,因此传递大量数据时的效率也比较低。

为了解决这个问题,可以将变量的引用传递给参数。引用是保存在栈中的,它实现了Copy Trait,因此在传递引用时,所有权转移的过程实际上是拷贝了引用,这样不会丢失原变量的所有权,效率也更高。

引用和所有权借用

所有权不仅可以转移(原变量会丢失数据的所有权),还可以通过引用的方式来借用数据的所有权(borrow ownership)。

使用引用借用变量所有权时,【借完】之后会自动交还所有权,从而使得原变量不丢失所有权。至于什么时候【借完】,尚无法在此深究。

例如:

fn main(){
  {
    let s = String::from("hello");
    let sf1 = &s; // 借用
    let sf2 = &s; // 再次借用
    println!("{}, {}, {}",s, sf1, sf2);
  }  // sf2离开,sf1离开,s离开
}

注意,&s表示创建变量s所指向数据的引用,为某个数据创建引用的过程不会转移该数据的所有权。

引用保存在栈中,其实现了Copy Trait,因此下面的代码是等价的:


#![allow(unused)]
fn main() {
// 多次创建s所指向数据的引用,
// 并将它们赋值给不同变量
let sf1 = &s;
let sf2 = &s;

// 拷贝sf1,使得sf2也引用s,
// 但sf1是引用,是可Copy的,因此sf1仍然有效,即仍然指向数据
let sf1 = &s;
let sf2 = sf1;
}

还可以将变量的引用传递给函数的参数,从而保证在调用函数时变量不会丢失所有权。

fn main(){
  let s = String::from("hello");
  let s1 = s.clone();

  // s1丢失所有权,s1将回到未初始化状态
  f1(s1); 
  // println!("{}", s1);

  // 传递s的引用,借用s所有权 
  let l = f2(&s);
              // 交还所有权
  // s仍然可用
  println!("{} size: {}", s, l);
}

fn f1(s: String){
  println!("{}", s);
}

fn f2(s: &String)->usize{
  s.len()   // len()返回值类型是usize
}

可变引用和不可变引用的所有权规则

变量的引用分为可变引用&mut var和不可变引用&var,站在所有权借用的角度来看,可变引用表示的是可变借用,不可变引用表示的是不可变借用。

  • 不可变借用:借用只读权,不允许修改其引用的数据
  • 可变引用:借用可写权(包括可读权),允许修改其引用的数据
  • 多个不可变引用可共存(可同时读)
  • 可变引用具有排他性,在有可变引用时,不允许存在该数据的其他可变和不可变引用
    • 这样的说法不准确,短短几句话也无法描述清楚,因此留在后面再详细解释

前面示例中f2(&s)传递的是变量s的不可变引用&s,即借用了数据的只读权,因此无法在函数内部修改其引用的数据值。

如要使用可变引用去修改数据值,要求:

  • var的变量可变,即let mut var = xxx
  • var的引用可变,即let varf = &mut var

例如:

fn main(){
  let mut x = String::from("junmajinlong");
  let x_ref = &mut x;  // 借用s的可写权
  x_ref.push_str(".com");
  println!("{}", x);

  let mut s = String::from("hello");
  f1(&mut s);   // 借用s的可写权
  println!("{}", s);
}

fn f1(s: &mut String){
  s.push_str("world");
}

容器集合类型的所有权规则

前面所介绍的都是标量类型的所有权规则,此处再简单解释一下容器类型(比如tuple/array/vec/struct/enum等)的所有权。

容器类型中可能包含栈中数据值(特指实现了Copy的类型),也可能包含堆中数据值(特指未实现Copy的类型)。例如:


#![allow(unused)]
fn main() {
let tup = (5, String::from("hello"));
}

容器变量拥有容器中所有元素值的所有权

因此,当上面tup的第二个元素的所有权转移之后,tup将不再拥有它的所有权,这个元素将不可使用,tup自身也不可使用,但仍然可以使用tup的第一个元素。


#![allow(unused)]
fn main() {
let tup = (5, String::from("hello"));

// 5拷贝后赋值给x,tup仍有该元素的所有权
// 字符串所有权转移给y,tup丢失该元素所有权
let (x, y) = tup;    
println!("{},{}", x, y);   // 正确
println!("{}", tup.0);     // 正确
println!("{}", tup.1);  // 错误
println!("{:?}", tup);  // 错误
}

如果想要让原始容器变量继续可用,要么忽略那些没有实现Copy的堆中数据,要么clone()拷贝堆中数据后再borrow,又或者可以引用该元素。


#![allow(unused)]
fn main() {
// 方式一:忽略
let (x, _) = tup;
println!("{}", tup.1);  //  正确

// 方式二:clone
let (x, y) = tup.clone();
println!("{}", tup.1);  //  正确

// 方式三:引用
let (x, ref y) = tup;
println!("{}", tup.1);  //  正确
}

理解可变引用的排他性

本节内容完全属于我个人推理,完全用我个人的理解来解释结论,我不知道官方有没有相关的术语,如果有,盼请告知。另外,如果结论错误,也盼请指正。

不可变引用可以共存,表示允许同时有多个不可变引用来访问数据,这不难理解。

fn main(){
  let x = String::from("junmajinlong");
  let _x1 = &x;
  let _x2 = &x;
  let _x3 = &x;
}

可变引用具有排他性,某数据在某一时刻只允许有一个可变引用,此时不允许有其他任何引用。这看上去似乎这也不难理解。

例如,下面的代码会报错:cannot borrow x as mutable more than once at a time。


#![allow(unused)]
fn main() {
let mut x = String::from("junmajinlong");
let x_mut1 = &mut x;    // (1)
let x_mut2 = &mut x;    // (2)
println!("{}", x_mut1); // (3)
println!("{}", x_mut2); // (4)
}

绝大多数Rust书籍都只是像上面示例一样对【可变引用具有排他性】的结论粗浅地验证一遍。

但真相可能没有这么简单。比如,去掉上面的代码(3)或者同时去掉代码(3)和(4),又或者将代码(3)移到代码(2)之前,得到的代码都是可以正确执行的代码:


#![allow(unused)]
fn main() {
// 可以正确执行
let mut x = String::from("junmajinlong");
let x_mut1 = &mut x;
let x_mut2 = &mut x;
println!("{}", x_mut2);

// 也可以正确执行
let mut x = String::from("junmajinlong");
let x_mut1 = &mut x;
let x_mut2 = &mut x;

// 也可以正确执行
let mut x = String::from("junmajinlong");
let x_mut1 = &mut x;
println!("{}", x_mut1);
let x_mut2 = &mut x;
println!("{}", x_mut2);
}

从上面的测试来看,同一份数据的多个可变引用是可以共存的,可变引用具有排他性的结论不攻自破。实际上,可变引用具有排他性的【排他性】,其含义体现在更深层次。

可以将可变引用看作是对内存数据的一把独占锁。在当前作用域内,从第一次使用可变引用开始创建这把独占锁,之后无论使用原始变量、可变引用还是不可变引用都会抢占这把独占锁,以保证只有一方可以访问数据,每次抢得独占锁后,都会将之前所有引用变量给锁住(原始变量依然可用),使它们变成不可用状态。当离开当前作用域时,当前作用域内的所有独占锁都被释放。

因此,可变引用是抢占且排他的,将其称为抢占式独占锁更为合适。

换个角度来理解,自从第一次使用可变引用导致独占锁出现后,可以随时使用原始变量、可变引用或不可变引用来抢独占锁,但抢锁后以前的引用变量就不能再用,且当前持有的锁也可以随时被抢走。一切都由程序员控制,程序员可以在任意代码位置通过原始变量或引用来抢锁。

下面通过示例来分析上述规则。

fn main(){
  let mut a = String::from("junmajinlong");

  // 创建两个不可变引用,不可变引用可以共存
  // 此时还没有独占锁
  let a_non_ref1 = &a;
  let a_non_ref2 = &a;
  // 可直接使用不可变引用
  println!("{}", a_non_ref1);
  println!("{}", a_non_ref2);

  // 第一次使用可变引用,将出现独占锁,a_ref1拥有独占锁
  let a_ref1 = &mut a;
  // 抢占独占锁后,前面两个不可变引用变量将不能使用
  // 因此下面两行代码报错
  //   println!("{}", a_non_ref1);
  //   println!("{}", a_non_ref2);

  // 再次使用不可变引用,a_non_ref3将获得独占锁
  let a_non_ref3 = &a;
  // 抢占独占锁后,前面所有引用变量都不能使用
  // 因此下面代码会报错
  //   println!("{}", a_ref1);
  //   println!("{}", a_non_ref1);

  // 再次使用可变引用,a_ref2将获得独占锁
  // 抢占后前面所有该数据的引用都不可用
  let a_ref2 = &mut a;
  // 但a_ref2是可用的
  println!("{}", a_ref2);

  // 任何时候使用原始变量a,也会抢占独占锁
  // 原始变量抢得独占锁后,前面所有引用变量将不能使用
  println!("{}", a);
  // 因此下面的代码会报错
  //   println!("{}", a_ref2);
}

理解上面的分析后,再分析代码是否错误以及为什么将非常轻松。

例如,下面第一段代码为什么不报错,而第二段代码是错误的:

fn main(){
  let mut x = String::from("junmajinlong");

  // (1).下面这段代码是正确的
  let x1 = &mut x;     // 独占锁出现,x1拥有独占锁
  println!("{}", x1); // x1是可用的变量
  let x2 = &mut x;    // x2抢占独占锁,x1不可用
  println!("{}", x2); // x2是可用的变量

  // (2).下面这段代码是错误的
  let x3 = &mut x;    // x3抢占独占锁
  ff(&x);  // &x抢占独占锁,参数s获得锁,使得x3不可用
  println!("{}", x3); // 使用了x3,导致报错,注释本行将正确
}

fn ff(s: &String){
  println!("{}", s);
}

再看下面这段代码:

fn main(){
  let mut x = 33;
  let y = &mut x; // y获得独占锁
  x = *y + 1;     // 使用y获取数据后,x重新抢得独占锁
                  // 赋值之后,x有效,y将失效
  println!("{}", x);     // 正确
  // println!("{}", y);  // 错误
}

解引用的所有权转移问题

前面对Move、Copy和所有权相关的内容做了详细的解释,相信变量赋值、函数传参时的所有权问题应该不再难理解。

但是,所有权的转移并不仅仅只发生在这两种相对比较明显的情况下。例如,解引用操作有时候也需要转移所有权。

事实上,所有需要使用值的地方,且这个值来自于对某个变量的操作时,都可能发生所有权转移:通过对某个变量的操作,将其值转移或拷贝出来,以便执行下一步操作。

显然上述解释很难理解,因此这里举个例子。如下代码所示:


#![allow(unused)]
fn main() {
let x = &y;
if *x < 33 {}
}

假如y是某个具体值(比如数值100),x是它的引用,当评估计算表达式*x < 33时,必须获取到*x的值才能和值33进行比较。也就是说,小于号左右两边是需要值的地方,右边已经提供了具体的值33,而左边的值来自于对变量x的解引用操作*x,所以*x必然需要将其存储的值转移或拷贝出来,才能继续执行下一步的比较操作。

此外,赋值操作符的右边也是需要值的地方:


#![allow(unused)]
fn main() {
let x = 3;   // 右边提供了值3
let y = &x;  // 右边提供了值:引用值&x
             // &x引用也需要值:值由x提供
let z = *y;  // 右边提供了值:解引用y得到的值
}

那么,哪些地方需要值呢?

在官方手册The Rust Reference: Expressions中,将表达式分为两类:位置表达式(place expression)和值表达式(value expression)。位置表达式用于描述内存位置,比如变量、引用等;值表达式用于描述实际的数据,除位置表达式外的都是值表达式。所有的位置表达式都需要结合值表达式,例如变量(位置表达式)需要绑定一个值,引用则需要对一个值进行引用。

因此,除了上述官方手册中列出的表示位置表达式的几种情况外,其余涉及数据的地方,都需要值。一般来说,根据代码上下文自行分析哪里需要值,这并非难事。不过,有些情况下并不那么明显。

下面通过一些比较典型的示例来分析解引用操作涉及到的所有权转移问题。

理解Rust的解引用操作

对于如下代码:


#![allow(unused)]
fn main() {
let a = &"junmajinlong.com".to_string();
// let b = *a;         // (1).取消注释将报错
let c = (*a).clone();  // (2).正确
let d = &*a;           // (3).正确

let x = &3;
let y = *x;      // (4).正确
}

上面的变量a是String字符串的引用。如果取消代码行(1)的注释,将编译错误,代码行(2)(3)(4)则是正确不报错的。为什么let b = *a;这个看似简单且正确的语句会报错呢?

实际上,解引用操作符*的作用并不是直接去获取其指向的值,解引用操作符*的行为类似于导航,只是导航到数据处,导航之后,根据上下文来决定如何处理该数据:取走(Move)或拷贝(Copy)或其他处理方式。

根据前文结论,变量b、c、d均需要绑定一个值,这个值来自于赋值操作符右边的计算结果,这三个赋值操作符的右边都涉及到对变量a的解引用操作*a,但不同处理方式会导致不同行为结果。

代码行(1)let b = *a;的上下文是b需要绑定一个值,这个值来自于a指向的数据,因此,通过解引用操作*a导航到数据后,需要消费该数据将其绑定到变量b上。也就是需要Move或Copy该数据给变量b。由于String类型没有实现Copy Trait,因此只能Move,但这里却不允许Move,因为Move后会使得引用类型的变量a变成悬垂指针。因此整个代码行(1)是错误的代码。而i32类型实现了Copy,因此代码行(4)是正确的代码。

从另一个角度来看待代码行(1)也许更好理解,变量b需要绑定一个来自于变量a的值,显然这个值要么从变量a处Move,要么从变量a处Copy。而String类型没有实现Copy,因此只能Move。

代码行(2)let c = (*a).clone();的上下文是c需要绑定一个值,这个值来自于a所指向数据的克隆,因此通过解引用操作*a导航到数据后,无需消费该数据,而是克隆一份数据后绑定到变量c上,原数据仍然保留。因此,代码行(2)是正确的代码。

代码行(3)let d = &*a;和代码行(2)是类似的,其上下文是d需要绑定一个值,这个值是对a所指向数据的引用,因此通过解引用操作*a导航到数据后,无需消费该数据,直接创建一个指向该数据的引用绑定到变量d上,原数据仍然保留在内存中。因此,代码行(3)是正确的代码。

可以通过一个类比来帮助理解这里的行为。假如A给了B一张藏宝图(引用看作是藏宝图),要求B去藏宝地取一把宝剑,B根据藏宝图找到藏宝地(即解引用操作)。当B取走宝剑交给A后(即Move),这张藏宝图将变成一张没用任何价值的地图(即,引用变成悬垂指针),但如果B发现藏宝地的这把宝剑可以无限复制(即实现了Copy),B会复制一把宝剑交给A,这时藏宝图仍然是有价值的,因为B会留着藏宝图以备后用。另外,如果B发现藏宝地除了宝剑之外,还有其他宝物,B也会留着藏宝图以备后用(即对数据的其他处理方式,如clone())。

举一反三

注:本节涉及Struct、Enum相关内容,如不了解可先跳过,可了解Struct、Enum后再回来阅读。

假如定义了一个Enum类型的Vip:


#![allow(unused)]
fn main() {
enum Vip {
  Vip0 = 0,
  Vip1,
  Vip2,
  Vip3,
}
}

下面为Vip类型定义如下方法is_vip时,将报错:


#![allow(unused)]
fn main() {
impl Vip {
  fn is_vip(&self) -> bool {
    // 下面的*self会导致错误
    if (*self as u8) < 1 {
      return false;
    }
    true
  }
}
}

分析一下这里报错的原因。当调用is_vip()时,传递给self参数的是Vip实例对象的引用,这里想要通过*self来解引用从而获取其指向的数据,然后将其转换为u8类型。

根据前文的解释,*self as u8需要*self提供一个值才能进一步将其转换为u8类型,这个值要么来自于对self所指向数据的Move操作,要么来自于对self所指向数据的Copy操作。Vip没有实现Copy,因此只能Move,但Move之后,会让self变成悬垂指针。因此,代码是错误的。

再看一个类似的Struct类型的示例。


#![allow(unused)]
fn main() {
struct P {
  vip: u8,
}

impl P {
  fn is_vip(&self) -> bool {
    // 不会因为*self而报错
    if (*self).vip < 1 {
      return false;
    }
    true
  }
}
}

上面的代码是正确的,因为(*self).vip < 1中,比较操作符的左边需要一个值,值来自于对self所指向数据的vip字段,这里的解引用操作*self导航到self所指向数据后,并不会直接消费整个Struct的实例对象,而是从实例对象中取vip字段的数据,vip字段是u8类型,实现了Copy Trait,因此取vip字段数据时是Copy而不是Move,这不会导致引用类型的self变量失效。因此,上述代码可正确编译。

但如果vip字段不是u8类型而是String类型,则一样会报错,因为String类型没有实现Copy。


#![allow(unused)]
fn main() {
struct P {
  vip: String,
}

impl P {
  fn is_vip(&self) -> bool {
    // *self报错
    if (*self).vip < "xyz".to_string() {
      return false;
    }
    true
  }
}
}

发生所有权转移的几种情况

Rust中的所有权转移比较复杂,官方并没有很明确地解释哪些情况下会发生所有权转移。但所有权转移问题是必须要理解的,它贯穿整个Rust。下面是一些个人理解和总结。

可以将Rust中的所有权转移问题用其他语言的按值拷贝和按引用拷贝来理解(其实,Rust的Copy和Move是特殊的值拷贝、引用拷贝):

  • 需要一个值时,是按值拷贝,该值会被绑定到目标变量

    • 注意,被转移的值不一定是原始值,比如是克隆或拷贝后形成的新值,此时原始值的所有权不会被转移

    • 如果这个值的类型可Copy或该值被克隆,则绑定到目标变量的是Copy后的新值

    • 如果这个值的类型是不可Copy的,则该值的所有权被转移

      
      #![allow(unused)]
      fn main() {
      let a = &"hello".to_string();
      // b需要值,拷贝给b的值不是a指向的值,而是它的克隆值
      // 因此,a仍然可用
      let b = (*a).clone();
      }
      
  • 需要一个引用时,是按引用拷贝,该值的所有权不会被转移

    • 引用类型都是可Copy的
    • 从更本质的角度来看待,为某个值创建引用时,该值的所有权不会发生转移(如&(*refA)不会转移refA所指向数据的所有权)

总结来说,有以下几种上下文可能会发生值的所有权转移。之所以是可能转移所有权,是因为这些上下文本身不会转移所有权,而是这些上下文中可能出现了需要值的地方,转移所有权总是发生在需要值且数据类型不可Copy的地方

  • 将变量赋值给let声明的新变量
  • 进入新的词法作用域,包括:
    • 大括号、match、if、for in、loop、while、if let、while let、函数、闭包等
    • 需注意,上面这些结构都有自己的作用域,在作用域内会发生所有权转移
    • 这些解结构中有些有条件表达式(如if/while),在条件表达式中很可能也会出现所有权转移的情况
  • 某些情况下解引用变量时(解引用操作本身不会转移所有权,而是在使用解引用后的数据时很可能会发生所有权转移)
  • 其他需要值的时候(比如大小比较时两端都需要值,比如as进行类型转换时也需要值)

下面给一些简单的示例来说明。

let声明变量(绑定值)时,会转移所有权:

fn main(){
  let a = "hello".to_string();
  let b = a;  // a所绑定值的所有权被转移给b
  // println!("{}", a);
}

大括号创建的作用域内使用变量,会转移所有权:

fn main(){
  let a = "hello".to_string();
  {
    a;    // 大括号作用域内使用a,a所绑定的值的所有权被转移
          // 不要使用println!("{}", a);来测试,println!取的是a的引用
  }
  println!("{}", a);
}

if/for/while/loop等创建的作用域内使用变量,会转移值的所有权:

fn main(){
  let a = vec![11,22,33];
  let b = "hello".to_string();
  for i in a {  // a的所有权在调用into_iter的时候被转移
    // println!("{}", a);  // 作用域内不可使用a
    b;   // 作用域内使用b,b的所有权被转移
  }
  // println!("{}", b);
  
  let c = 33;
  if(c > 32){  // 比较操作需要值,因此会发生所有权转移,但33可Copy
               // Copy后的值将在跳出if语句块作用域时被销毁
    ...
  }
}

解引用时:

fn main(){
  let a = &"hello".to_string();
  let b = *a;   // b需要值,解引用*a会转移a所指向数据的所有权给b
  // println("{}", a);
  
  let a = &"hello".to_string();
  let b = &*a;  // b需要引用而非需要值,并且在创建a所指向数据的引用时不会转移所有权
}

函数参数:

// foo参数需要值,换句话说,需要按值拷贝该参数对应的数据
fn foo(var: String) -> String {
  ...
}

fn main(){
  let a = "hello".to_string();
  foo(a);   // a的所有权被转移
  // println!("{}", a);
}

Vec类型

Rust中数组的长度不可变,这是很受限制的。

Rust在标准库中提供了Vector类型(向量)。Vec类型和数组类型的区别在于前者的长度动态可变。

Vec的数据类型描述方式为Vec<T>,其中T代表vec中所存放元素的类型。例如,存放i32类型的vec,它的数据类型为Vec<i32>

vec的基本使用

创建向量有几种方式:

  • Vec::new()创建空的vec
  • Vec::with_capacity()创建空的vec,并将其容量设置为指定的数量
  • vec![]宏创建并初始化vec(中括号可以换为小括号或大括号)
  • vec![v;n]创建并初始化vec,共n个元素,每个元素都初始化为v
fn main(){
  let mut v1 = Vec::new();
  // 追加元素时,将根据所追加的元素推导v1的数据类型Vec<i32>
  v1.push(1);  // push()向vec尾部追加元素
  v1.push(2);
  v1.push(3);
  v1.push(4);
  assert_eq!(v1, [1,2,3,4]) // vec可以直接和数组进行比较

  // v2的类型推导为:Vec<i32>
  let v2 = vec![1,2,3,4];
  assert_eq!(v2, [1,2,3,4]);
  
  let v3 = vec!(3;4);  // 等价于vec![3,3,3,3]
  assert_eq!(v3, [3,3,3,3]);
  
  // 创建容量为10的空vec
  let mut v4 = Vec::with_capacity(10);
  v4.push(33);
}

访问和遍历vec

可以使用索引来访问vec中的元素。索引越界访问时,将在运行时panic报错。

索引是usize类型的值,因此不接受负数索引。

fn main(){
  let v = vec![11,22,33,44];
  let n: usize = 3;
  println!("{},{}", v[0], v[n]);
  
  // 越界,报错
  // 运行错误而非编译错误,因为运行期间才知道vec长度
  // println!("{}", v[9]);
}

如果不想要在越界访问vec时panic中断程序,可使用:

  • get()来获取指定索引处的元素引用或范围内元素的引用,如果索引越界,返回None
  • get_mut()来获取元素的可变引用或范围内元素的可变引用,如果索引越界,返回None

这两个方法的返回值可能是所取元素的引用,也可能是None,此处不对None展开介绍,相关的细节要留到Option类型中介绍。这里只需要知道,当所调用函数的返回值可能是一个具体值,也可能是None时,需要对这两种可能的返回值进行处理。比较简单的一种处理方式是在该函数返回结果上使用unwrap()方法:当成功返回具体值时,unwrap()将返回该值,当返回None时, unwrap()将panic报错退出。

例如:

fn main(){
  let v = [11,22,33,44];
  // 取得index=3处元素,成功,于是unwrap()提取得到44
  let n = v.get(3).unwrap();
  println!("{}", n);
  
  // 取得index=4处元素,失败,于是panic报错
  // let nn = v.get(4).unwrap(); 
}

另外,Vec是可迭代的,可以直接使用for x in vec {}来遍历vec。


#![allow(unused)]
fn main() {
let v = vec![11,22,33,44];
for i in v {
  println!("{}", i);
}
}

Vec的内存布局

Vec所存储的数据部分在堆内存中,同时在栈空间中存放了该vec的胖指针。胖指针包括三部分元数据:

  • 指向堆的指针(一个机器字长)
  • 当前vec元素数量(即长度,usize,一个机器字长)
  • vec的容量(即当前vec最多可存放多少元素,usize,一个机器字长)

因此,vec的内存布局大致如下:

vec扩容:重新分配内存

当向vec插入新元素时,如果没有空闲容量,则会重新申请一块内存,大小为原来vec内存大小的两倍(官方手册指明目前Rust并没有确定扩容的策略,以后可能会改变),然后将原vec中的元素拷贝到新内存位置处,同时更新vec的胖指针中的元数据。

例如,有一个容量为10、长度为0的空vec,向该vec中插入前10个元素时不会重新分配内存,但在插入第11个元素时,因容量不够,会重新申请一块内存,容量为20,然后将前10个元素拷贝到新内存位置并将第11个元素放入其中。

通过vec的len()方法可获取该vec当前的元素数量,capacity()方法可获取该vec当前的容量大小。

fn main(){
  let mut v1 = vec![11,22,33];
  // len: 3, cap: 3
  println!("len: {}, cap: {}", v1.len(), v1.capacity());
  
  // push()向vec中插入一个元素,将导致扩容,
  // 扩容将导致重新分配vec的内存
  v1.push(44);
  // len: 4, cap: 6
  println!("len: {}, cap: {}", v1.len(), v1.capacity());
}

显然,当频繁扩容或者当元素数量较多且需要扩容时,大量的内存拷贝会降低程序的性能

因此,如果可以的话,可以采取如下方式:

  • 在创建vec的时候使用Vec::with_capacity()指定一个足够大的容量值,以此来尽量减少可能的内存拷贝。
  • 通过reserve()方法来调整已存在的vec容量,使之至少有指定的空闲容量数,以此来尽量减少可能的内存拷贝。

例如:

fn main(){
  // 创建一个容量为3的空vec
  let mut v1 = Vec::with_capacity(3);
  v1.push(11);
  v1.push(22);
  v1.push(33);
  // len: 3, cap: 3
  println!("len: {}, cap: {}", v1.len(), v1.capacity());

  // 调整v1,使其至少要有10个空闲位置
  v1.reserve(10);
  // len: 3, cap: 13
  println!("len: {}, cap: {}", v1.len(), v1.capacity());
  
  // 当空闲容量足够时,reserve()什么也不做
  v1.reserve(5);
  println!("len: {}, cap: {}", v1.len(), v1.capacity());
}

另外,可以使用shrink_to_fit()方法来释放剩余的容量。一般情况下,不会主动去释放容量。

vec的常用方法

vec自身有很多方法,另外vec还可以调用所有Slice类型的方法。

下面是vec自身提供的一些常见的方法,更多方法和它们更详细的用法,参考官方手册:https://doc.rust-lang.org/std/vec/struct.Vec.html

  • len():返回vec的长度(元素数量)
  • is_empty():vec是否为空
  • push():在vec尾部插入元素
  • pop():删除并返回vec尾部的元素,vec为空则返回None
  • insert():在指定索引处插入元素
  • remove():删除指定索引处的元素并返回被删除的元素,索引越界将panic报错退出
  • clear():清空vec
  • append():将另一个vec中的所有元素追加移入vec中,移动后另一个vec变为空vec
  • truncate():将vec截断到指定长度,多余的元素被删除
  • retain():保留满足条件的元素,即删除不满足条件的元素
  • drain():删除vec中指定范围的元素,同时返回一个迭代该范围所有元素的迭代器
  • split_off():将vec从指定索引处切分成两个vec,索引左边(不包括索引位置处)的元素保留在原vec中,索引右边(包括索引位置处)的元素在返回的vec中

这些方法的用法都非常简单,下面举一些示例来演示它们。

len()和is_empty():


#![allow(unused)]
fn main() {
let v = vec![11,22,33];
assert_eq!(v.len(), 3);
assert!(!v.is_empty());
}

push()、pop()、insert()、remove()和clear():


#![allow(unused)]
fn main() {
let mut v = vec![11,22];

v.push(33);      // [11,22,33]

assert_eq!(v.pop(), Some(33));
assert_eq!(v.pop(), Some(22));
assert_eq!(v.pop(), Some(11));
assert_eq!(v.pop(), None);

v.insert(0, 111); // [111]
v.insert(1, 222); // [111,222]
v.insert(2, 333); // [111,222,333]
assert_eq!(v.remove(1), 222);

v.clear();  // []
}

append():


#![allow(unused)]
fn main() {
let mut v = vec![11,22];
let mut vv = [33,44,55].to_vec();

v.append(&mut vv);
println!("{:?}", v);   // [11,22,33,44,55]
println!("{:?}", vv);  // []
}

truncate():截断到指定长度,多余的元素被删除,如果目标长度大于当前长度,则不做任何事


#![allow(unused)]
fn main() {
let mut v = vec![11,22,33,44];
v.truncate(2);
println!("{:?}", v); // [11, 22]

v.truncate(5);  // 不做任何事
}

retain():


#![allow(unused)]
fn main() {
let mut v = vec![11, 22, 33, 44];

v.retain(|x| *x > 20);
println!("{:?}", v);      // [22,33,44]
}

drain():删除指定范围的元素,同时返回该范围所有元素的迭代器。如果删除迭代器,则丢弃迭代器中剩余的元素


#![allow(unused)]
fn main() {
let mut v = vec![11, 22, 33, 44, 55];
let mut vv = v.clone();

// 删除中间3个元素,同时获取到这些元素的迭代器
// 直接丢弃迭代器,所以迭代器中的元素也直接被丢弃
// 这相当于直接删除指定范围的元素
v.drain(1..=3);
println!("{:?}", v);  // [11, 55]

// 将迭代器中的元素转换为Vec<i32>
let a: Vec<i32> = vv.drain(1..=3).collect();
println!("{:?}", a);  // [22, 33, 44]
println!("{:?}", vv); // [11, 55]
}

split_off():


#![allow(unused)]
fn main() {
let mut v = vec![11, 22, 33, 44, 55];
let vv = v.split_off(2);
println!("{:?}", v);   // [11, 22]
println!("{:?}", vv);  // [33, 44, 55]
}

Struct类型

Struct是Rust中非常重要的一种数据类型,它可以容纳各种类型的数据,并且在存放数据的基本功能上之外还提供一些其他功能,比如可以为Struct类型定义方法。

实际上,Struct类型类似于面向对象的类,Struct的实例则类似于对象。Struct的实例和面向对象中的对象都可以看作是使用key-value模式的hash结构去存储数据,同时附带一些其他功能。

Struct的基本使用

使用struct关键字定义Struct类型。

具名Struct

具名Struct(named Struct)表示有字段名称的Struct。Struct的字段(Field)也可以称为Struct的属性(Attribute)。

例如,定义一个名为Person的Struct结构体,Person包含三个属性,分别是name、age和email,每个属性都需要指定数据类型,这样可以限制各属性允许存放什么类型的数据。


#![allow(unused)]
fn main() {
struct Person{
  name: String,
  age: u32,
  email: String, // 最后一个字段的逗号可省略,但建议保留
}
}

定义Struct后,可创建Struct的实例对象,为其各个属性指定对应的值即可。

例如,构造Person结构体的实例对象user1,


#![allow(unused)]
fn main() {
let user1 = Person {
  name: String::from("junmajinlong"),
  email: String::from("[email protected]"),
  age: 23,
};
}

创建user1实例对象后,可以通过user1.name访问它的name字段的值,user1.age访问它的age字段的值。

以下是一段完整的代码:

struct Person{
  name: String,
  age: u32,
  email: String,
}

fn main(){
  let user1 = Person{
    name: String::from("junmajinlong"),
    email: String::from("[email protected]"),
    age: 23,
  };
  // 访问user1实例name字段、age字段和email字段的值
  println!(
    "name: {}, age: {}, email: {}",
    user1.name, user1.age, user1.email
  );
}

构造struct的简写方式

当要构造的Struct实例的字段值来自于变量,且这个变量名和字段名相同,则可以简写该字段。

struct Person{
  name: String,
  age: u32,
  email: String,
}

fn main(){
  let name = String::from("junmajinlong");
  let email = String::from("[email protected]");

  let user1 = Person{
    name,      // 简写,等价于name: name
    email,     // 简写,等价于email: email
    age: 23,
  };
}

有时候会基于一个Struct实例构造另一个Struct实例,Rust允许通过..xx的方式来简化构造struct实例的写法:


#![allow(unused)]
fn main() {
let name = String::from("junmajinlong");
let email = String::from("[email protected]");
let user1 = Person{
  name,
  email,
  age: 23,
};

let mut user2 = Person{
  name: String::from("gaoxiaofang"),
  email: String::from("[email protected]"),
  ..user1
};
}

上面的..user1表示让user2借用或拷贝user1的某些字段值,由于user2中已经手动定义了name和email字段,因此..user1只借用了user1的age字段,即user2.age也是23。

注意,如果..base借用于base的字段是可Copy的,那么在借用时会自动Copy,这样在借用字段之后,base中的字段仍然有效。但如果借用的字段不是Copy的,那么在借用时会将base中字段的所有权转移走,使得base中的该字段无效。

例如,同时借用user1中的age字段和email字段,由于age是i32类型,是Copy的,所以user1.age仍然可用,但由于String类型不是Copy的,所以user1.email不可用。


#![allow(unused)]
fn main() {
let name = String::from("junmajinlong");
let email = String::from("[email protected]");
let user1 = Person{
  name,
  email,
  age: 23,
};

let mut user2 = Person{
  name: String::from("gaoxiaofang"),
  ..user1
};

// 报错,user1.email字段值的所有权已借给user2
// println!("{}", user1.email);
// println!("{}", user1);       // 报错
println!("{}", user1.name);     // 正确
println!("{}", user1.age);      // 正确
}

如果确实要借用user1的email属性,可以使用..user1.clone()先拷贝堆内存中的user1,这样就不会借用原始的user1中的email所有权。


#![allow(unused)]
fn main() {
let user2 = Person{
  name: String::from("ggg"),
  ..user1.clone()
}
}

tuple struct

除了named struct外,Rust还支持没有字段名的struct结构体,称为元组结构体(tuple struct)。

例如:


#![allow(unused)]
fn main() {
struct Color(i32, i32, i32); 
struct Point(i32, i32, i32); 

let black = Color(0, 0, 0); 
let origin = Point(0, 0, 0);
}

black和origin值的类型不同,因为它们是不同的结构体的实例。在其他方面,元组结构体实例类似于元组:可以将其解构,也可以使用.后跟索引来访问单独的值,等等。

unit-like struct

类单元结构体(unit-like struct)是没有任何字段的空struct。


#![allow(unused)]
fn main() {
struct St;
}

调试输出Struct

在开发过程中,很多时候会想要查看某个struct实例中的数据,但直接输出是不行的:

struct Person{
  name: String,
  age: i32,
}

fn main(){
  let p = Person{
    name: String::from("junmajinlong"),
    age: 23,
  };

  // 直接输出p会报错
  println!("{}", p);
}

这时需要在struct Person前加上#[derive(Debug)],然后使用{:?}{:#?}进行调试输出。

#[derive(Debug)]
struct Person{
  name: String,
  age: i32,
}

fn main(){
  let p = Person{
    name: String::from("junmajinlong"),
    age: 23,
  };

  println!("{:?}", p);
  println!("{:#?}", p);
}

输出结果:

Person { name: "junmajinlong", age: 23 }
Person {
    name: "junmajinlong",
    age: 23,
}

定义Struct的方法

Struct就像面向对象的类一样,Rust允许为Struct定义实例方法和关联方法,实例方法可被所有实例对象访问调用,关联方法类似于其他语言中的类方法或静态方法。

定义Struct的方法的语法为impl Struct_Name {},所有方法定义在大括号中。

定义Struct的实例方法

实例方法是所有实例对象可访问、调用的方法。

例如:

struct Rectangle{
  width: u32,
  height: u32,
}

impl Rectangle {
  fn area(&self) -> u32 {
    self.width * self.height
  }

  fn perimeter(&self) -> u32 {
    (self.width + self.height) * 2
  }
}

fn main() {
  let rect1 = Rectangle{width: 30, height: 50};
  println!("{},{}", rect1.area(), rect1.perimeter());
}

也可以将方法定义在多个impl Struct_Name {}中。如下:


#![allow(unused)]
fn main() {
impl Rectangle {
  fn area(&self) -> u32 {
    self.width * self.height
  }

  fn perimeter(&self) -> u32 {
    (self.width + self.height) * 2
  }
}

impl Rectangle {
  fn include(&self, other: &Rectangle) -> bool {
    self.width > other.width && self.height > other.height
  }
}
}

所有Struct的实例方法的第一个参数都是self(的不同形式)。self表示调用方法时的Struct实例对象(如rect1.area()时,self就是rect1)。有如下几种self形式:

  • fn f(self):当obj.f()时,转移obj的所有权,调用f方法之后,obj将无效
  • fn f(&self):当obj.f()时,借用而非转移obj的只读权,方法内部不可修改obj的属性,调用f方法之后,obj依然可用
  • fn f(&mut self):当obj.f()时,借用obj的可写权,方法内部可修改obj的属性,调用f方法之后,obj依然可用

定义方法时很少使用第一种形式fn f(self),因为这会使得调用方法后对象立即消失。但有时候也能派上场,例如可用于替换对象:调用方法后原对象消失,但返回另一个替换后的对象。

如果仔细观察的话,会发现方法的第一个参数self(或其他形式)没有指定类型。实际上,在方法的定义中,self的类型为Self(首字母大写)。例如,为Rectangle定义方法时,Self类型就是Rectangle类型。因此,下面几种定义方法的方式是等价的:


#![allow(unused)]
fn main() {
fn f(self)
fn f(self: Self)

fn f(&self)
fn f(self: &Self)

fn f(&mut self)
fn f(self: &mut Self)
}

Rust的自动引用和解引用

在C/C++语言中,有两个不同的运算符来调用方法:.直接在对象上调用方法,->在一个对象指针上调用方法,这时需要先解引用(dereference)指针。

换句话说,如果obj是一个指针,那么obj->something()就像(*obj).something()一样。更典型的是Perl,Perl的对象总是引用类型,因此它调用方法时总是使用obj->m()形式。

Rust不会自动引用或自动解除引用,但有例外:当使用.运算符和比较操作符(如= > >=)时,Rust会自动创建引用和解引用,并且会尽可能地解除多层引用:

  • (1).方法调用v.f()会自动解除引用或创建引用
  • (2).属性访问p.namep.0会自动解除引用
  • (3).比较操作符的两端如果都是引用类型,则自动解除引用
  • (4).能自动解除的引用包括普通引用&xBox<T>Rc<T>

对于(1),方法调用时的自动引用和自动解除引用,它是这样工作的:当使用ob.something()调用方法时,Rust会根据所调用方法的签名进行推断(即根据方法的接收者self参数的形式进行推断),然后自动为object添加&, &mut来创建引用或添加*来自动解除引用,其目的是让obj与方法签名相匹配。

也就是说,当distance方法的第一个形参是&self&mut self时,下面代码是等价的,但第一行看起来简洁的多:


#![allow(unused)]
fn main() {
p1.distance(&p2); 
(&p1).distance(&p2);
}

关联函数(associate functions)

关联函数是指第一个参数不是self(的各种形式)但和Struct有关联关系的函数。关联方法类似于其他语言中类方法或静态方法的概念。

调用关联方法的语法StructName::func()。例如,String::from()就是在调用String的关联方法from()。

例如,可以定义一个专门用于构造实例对象的关联函数new。

struct Rectangle {
  width: u32,
  height: u32,
}

impl Rectangle {
  // 关联方法new:构造Rectangle的实例对象
  fn new(width: u32, height: u32) -> Rectangle {
    Rectangle { width, height }
  }
}

impl Rectangle {
  fn area(&self) -> u32 { self.width * self.height }
}

fn main() {
  // 调用关联方法
  let rect1 = Rectangle::new(30, 50);
  let rect2 = Rectangle::new(20, 50);
  println!("{}", rect1.area());
  println!("{}", rect2.area());
}

实际上,实例方法也属于关联方法,也可以采用关联方法的形式去调用,只不过这时需要手动传递第一个self参数。例如:


#![allow(unused)]
fn main() {
// 调用Rectangle的area方法,并传递参数&self
Rectangle::area(  &rect1  );
}

Enum类型

枚举(Enum)类型通常用来归纳多种可穷举的具体事物。简单点说,枚举是一种包含零个、一个或多个具体值的数据类型。

比如,下面列出的几种情况都可以定义为枚举类型:

  • 【性别】包含男和女
  • 【月份】包含一月、二月、......、十二月
  • 【星期】包含星期一、星期二、......、星期日
  • 【会员】包含免费会员、vip1、vip2、vip3
  • 【方向键】包含上、下、左、右
  • 【方向】包含东、南、西、北

但枚举类型不能用来描述无法穷举的事物。例如【整数】虽然包含0、1、2、......,但这样的值无穷无尽,此时不应该直接用枚举类型,而应该使用具有概括性的方式去描述它们,比如枚举正整数、0、负整数这三种情况,也可以枚举所需的1、2、3后,再用一个额外的Other来通配所有其他情况。

Rust支持枚举类型,且Rust的枚举类型比其他语言的枚举类型更为强大。

Enum的基本使用

Rust使用enum关键字定义枚举类型(Enum)。

例如,定义一个用来描述性别的枚举类型,名为Gender,它只枚举两种值:Male(表示男),Female(表示女)。


#![allow(unused)]
fn main() {
enum Gender {
  Male,   // 男
  Female, // 女
}
}

Enum作为一种数据类型,可以用来限制允许存放的数据。比如某变量的数据类型是Gender类型,那么该变量只允许存放指定的两种值:Male或Female,不允许存放其他任何值。也就是说,枚举类型的每个实例都是从枚举类型中进行多选一的值。


#![allow(unused)]
fn main() {
let g1: Gender = Gender::Male;
let g2: Gender = Gender::Female;
// let g3: Gender = "male";  // 不允许
}

注意上面变量的类型为Gender,引用Enum内部值的方式为Gender::Male

Gender类型内部的Male和Female称为枚举类型的值或者枚举类型的成员,还可以称为是枚举类型的实例。反过来思考,不管是Male成员还是Female成员,它们都属于Gender类型,是Gender类型的一种值。就像12_u8是u8类型的其中一个值,属于u8类型。

例如:

enum Gender {
  Male,
  Female,
}

// 参数类型为Gender
fn is_male(g: Gender) -> bool {
  // ...some code...
}

fn main() {
  // 可传递Gender已枚举的值作为参数
  assert!(is_male(Gender::Male));
  assert!(is_male(Gender::Female));
}

再比如,定义一个Choice枚举类型,用来枚举由用户所作出的所有可能选择。


#![allow(unused)]
fn main() {
enum Choice {
  One,
  Two,
  Three,
  Other,
}
}

Choice枚举四种可能的值,其中第四种Other表示除前三种选择之外的所有其他选择行为,包括错误的选择(比如通过某种手段选择了不存在的选项)。

Rust中经常会看到类似于Choice的这种用法,在枚举类型中额外使用一个可以归纳剩余所有可能的成员,正如上面的Other归纳了所有其他可能的选择。

其实,前文定义的枚举类型,其每个成员都有对应的数值。默认第一个成员对应的数值为0,第二个成员的对应的数值为1,后一个成员的数值总是比其前一个数值大1。并且,可以使用=为成员指定数值,但指定值时需注意,不同成员对应的数值不能相同。

例如:


#![allow(unused)]
fn main() {
enum E {
  A,       // 对应数值0
  B,       // 自动加1,对应1
  C = 33,  // 对应33
  D,       // 自动加1,对应34
}
}

定义之后,可使用as将enum成员转换为对应的数值。

例如,定义英文的星期和数值相对应的枚举。

enum Week {
  Monday = 1, // 1
  Tuesday,    // 2
  Wednesday,  // 3
  Thursday,   // 4
  Friday,     // 5
  Saturday,   // 6
  Sunday,     // 7
}

fn main(){
  // mon等于1
  let mon = Week::Monday as i32;
}

可在enum定义枚举类型的前面使用#[repr]来指定枚举成员的数值范围,超出范围后将编译错误。当不指定类型限制时,Rust尽量以可容纳数据大小的最小类型。例如,最大成员值为100,则用一个字节的u8类型,最大成员值为500,则用两个字节的u16。


#![allow(unused)]
fn main() {
// 最大数值不能超过255
#[repr(u8)]  // 限定范围为`0..=255`
enum E {
  A,
  B = 254,
  C,
  D,        // 256,超过255,编译报错
}
}

定义Enum的完整语法

enum创建枚举类型有多种方式,其每个成员的定义都类似于创建Struct结构的语法。

例如:


#![allow(unused)]
fn main() {
enum E {
  F1,             // 该成员类似于unit-like struct
  F2(i32, u64),   // 该成员类似于tuple struct
  F3{x: i32, y: u64}, // 该成员类似于named struct
}
}

F1成员这种定义方式自无需再多做介绍,前文定义的枚举类型都是这种类型的成员。

F2成员的定义类似于tuple struct,F2成员包含两个字段,这两个字段类型分别是i32和u64。也就是说,枚举类型E的F2成员,是一个包含了具体数据的成员。

F3成员的定义类似于named struct,F3成员包含x和y两个字段,字段类型分别是i32和u64。也就是说,枚举类型E的F3成员,也是一个包含了具体数据的成员。

正是因为枚举类型允许定义F2和F3这种包含数据的成员,使得枚举类型在Rust中扮演的角色变得更为重要。

例如,Rust要实现一个Json解析工具,只需定义一个枚举类型去枚举Json允许的数据类型,参考如下代码。


#![allow(unused)]
fn main() {
enum Json {
  Null,
  Boolean(bool),
  Number(f64),
  String(String),
  Array(Vec<Json>),
  Object(Box<HashMap<String, Json>>),
}
}

不可否认,Rust语言的表达能力很强。例如这里的枚举类型,仅仅这样一个简单的数据结构就表达出很多内容,而在其它语言中,这可能需要定义很多方法来表达出这些内容。

为枚举类型定义方法

和Struct类型一样,也可以使用impl关键字为枚举类型定义方法。

例如,定义包含星期一到星期日的枚举类型Week,然后定义一个方法来判断给定的某一天是否是周末。

#[derive(Copy, Clone)]
enum Week {
  Monday = 1,
  Tuesday,
  Wednesday,
  Thursday,
  Friday,
  Saturday,
  Sunday,
}

impl Week {
  fn is_weekend(&self) -> bool {
    if (*self as u8) > 5 {
      return true;
    }
    false
  }
}

fn main(){
  let d = Week::Thursday;
  println!("{}", d.is_weekend());
}

模式匹配

模式匹配官方手册参考:https://doc.rust-lang.org/reference/patterns.html

Rust中经常使用到的一个强大功能是模式匹配(pattern match),例如let变量赋值本质上就是在进行模式匹配。得益于Rust模式匹配功能的强大,使用模式匹配比不使用模式匹配,往往会减少很多代码。

模式匹配的基本使用

可在如下几种情况下使用模式匹配:

  • let变量赋值
  • 函数参数传值时的模式匹配
  • match分支
  • if let
  • while let
  • for迭代的模式匹配

let变量赋值时的模式匹配

let变量赋值时的模式匹配:


#![allow(unused)]
fn main() {
let PATTERN = EXPRESSION;
}

变量是一种最简单的模式,变量名位于Pattern位置,赋值时的过程:将表达式与模式进行比较匹配,并将任何模式中找到的变量名进行对应的赋值

例如:


#![allow(unused)]
fn main() {
let x = 5;
let (x, y) = (1, 2);
}

第一条语句,变量x是一个模式,在执行该语句时,将表达式5赋值给找到的变量名x。变量赋值总是可以匹配成功。

第二条语句,将表达式(1,2)和模式(x,y)进行匹配,匹配成功,于是为找到的变量x和y进行赋值:x=1,y=2

如果模式中的元素数量和表达式中返回的元素数量不同,则匹配失败,编译将无法通过。


#![allow(unused)]
fn main() {
let (x,y,z) = (1,2);  // 失败
}

函数参数传值时的模式匹配

为函数参数传值和使用let变量赋值是类似的,本质都是在做模式匹配的操作。

例如:


#![allow(unused)]
fn main() {
fn f1(i: i32){
  // xxx
}

fn f2(&(x, y): &(i32, i32)){
  // yyy
}
}

函数f1的参数i就是模式,当调用f1(88)时,88是表达式,将赋值给找到的变量名i。

函数f2的参数&(x,y)是模式,调用f2( &(2,8) )时,将表达式&(2,8)与模式&(x,y)进行匹配,并为找到的变量名x和y进行赋值:x=2,y=8

match分支匹配

match分支匹配的用法非常灵活,此处只做基本的用法介绍,后文还会继续深入其用法。

它的语法为:


#![allow(unused)]
fn main() {
match VALUE {
  PATTERN1 => EXPRESSION1,
  PATTERN2 => EXPRESSION2,
  PATTERN3 => EXPRESSION3,
}
}

其中=>左边的是各分支的模式,VALUE将与这些分支逐一进行匹配,=>右边的是各分支匹配成功后执行的代码。每个分支后使用逗号分隔各分支,最后一个分支的结尾逗号可以省略(但建议加上)。

match会从前先后匹配各分支,一旦匹配成功则不再继续向下匹配

例如:


#![allow(unused)]
fn main() {
let x = (11, 22);
match x {
  (22, a) => println!("(22, {})", a),   // 匹配失败
  (a, b) => println!("({}, {})", a, b), // 匹配成功,停止匹配
  (a, 11) => println!("({}, 11)", a),   // 匹配失败
}
}

如果某分支对应的要执行的代码只有一行,则直接编写该行代码,如果要执行的代码有多行,则需加上大括号包围这些代码。无论加不加大括号,每个分支都是一个独立的作用域

因此,上述match的语法可衍生为如下两种语法:


#![allow(unused)]
fn main() {
match VALUE {
  PATTERN1 => code1,
  PATTERN2 => code2,
  PATTERN3 => code3,
}

match VALUE {
  PATTERN1 => { 
    code line 1
    clod line 2
  },
  PATTERN2 => { 
    code line 1
    clod line 2
  },
  PATTERN3 => code1,
}
}

另外,match结构自身也是表达式,它有返回值,且可以赋值给变量。match的返回值由每个分支最后执行的那行代码决定。Rust要求match的每个分支返回值类型必须相同,且如果是一个单独的match表达式而不是赋值给变量时,每个分支必须返回()类型。

例如:


#![allow(unused)]
fn main() {
let x = (11,22);

// 正确,match没有赋值给变量,分支必须返回Unit值()
match x {
  (a, b) => println!("{}, {}", a, b), // 返回Unit值()
  // 其他正确写法:{println!("{}, {}", a, b);}, 
  // 错误写法:     println!("{}, {}", a, b);, 
}

// 正确,每个分支都返回Unit值()
match x {
  (a,11) => println!("{}", a),  // 该分支匹配失败
  (a,b) => println!("{}, {}", a, b), // 将匹配该分支
}

// match返回值赋值给变量,每个分支必须返回相同的类型:i32
let y = match x {
  (a,11) => {
    println!("{}", a);
    a      // 该分支的返回值:i32类型
  },
  (a,b) => {
    println!("{}, {}", a, b);
    a + b  // 该分支的返回值:i32类型
  },
};
}

match也经常用来穷举Enum类型的所有成员。此时要求穷尽所有成员,如果有遗漏成员,编译将失败。可以将_作为最后一个分支的PATTERN,它将匹配剩余所有成员。

enum Direction {
  Up,
  Down,
  Left,
  Right,
}
fn main(){
  let dir = match Direction::Down {
    Direction::Up => 1,
    Direction::Down => 2,
    Direction::Right => 3,
    _ => 4,
  };
  println!("{}", dir);
}

if let

if let是match的一种特殊情况的语法糖:当只关心一个match分支,其余情况全部由_负责匹配时,可以将其改写为更精简if let语法。


#![allow(unused)]
fn main() {
if let PATTERN = EXPRESSION {
  // xxx
}
}

这表示将EXPRESSION的返回值与PATTERN模式进行匹配,如果匹配成功,则为找到的变量进行赋值,这些变量在大括号作用域内有效。如果匹配失败,则不执行大括号中的代码。

例如:


#![allow(unused)]
fn main() {
let x = (11, 22);

// 匹配成功,因此执行大括号内的代码
// if let是独立作用域,变量a b只在大括号中有效
if let (a, b) = x {
  println!("{},{}", a, b);
}

// 等价于如下代码
let x = (11, 22);
match x {
  (a, b) => println!("{},{}", a, b),
  _ => (),
}
}

if let可以结合else ifelse if letelse一起使用。


#![allow(unused)]
fn main() {
if let PATTERN = EXPRESSION {
  // XXX
} else if {
  // YYY
} else if let PATTERN = EXPRESSION {
  // zzz
} else {
  // zzzzz
}
}

这时候它们和match多分支类似。但实际上有很大的不同:使用match分支匹配时,要求分支之间是有关联(例如枚举类型的各个成员)且穷尽的,但Rust编译器不会检查if let的模式之间是否有关联关系,也不检查if let是否穷尽所有可能情况,因此,即使在逻辑上有错误,Rust也不会给出编译错误提醒。

例如,下面是一个使用了if let..else if let的示例,该示例穷举了Enum类型的所有成员,还包括该枚举类型之外的情况,但即使去掉任何一个分支,也都不会报错。

enum Direction {
  Up,
  Down,
  Left,
  Right,
}

fn main() {
  let dir = Direction::Down;

  if let Direction::Left = dir {
    println!("Left");
  } else if let Direction::Right = dir {
    println!("Right");
  } else {
    println!("Up or Down or wrong");
  }
}

while let

只要while let的模式匹配成功,就会一直执行while循环内的代码。

例如:


#![allow(unused)]
fn main() {
let mut stack = Vec::new();
stack.push(1);
stack.push(2);
stack.push(3);

while let Some(top) = stack.pop() {
  println!("{}", top);
}
}

stack.pop成功时,将匹配Some(top)成功,并将pop返回的值赋值给top,当没有元素可pop时,返回None,匹配失败,于是while循环退出。

for迭代

for迭代也有模式匹配的过程:为控制变量赋值。例如:


#![allow(unused)]
fn main() {
let v = vec!['a','b','c'];
for (idx, value) in v.iter().enumerate(){
  println!("{}: {}", idx, value);
}
}

模式的两种形式:refutable和irrefutable

从前文介绍的几种模式匹配可知,模式匹配的方式不唯一:

  • (1).模式匹配必须匹配成功,匹配失败就报错,主要是变量赋值型的(let/for/函数传参)模式匹配
  • (2).模式匹配可以匹配失败,匹配失败时不执行相关代码

Rust中为这两种匹配模式定义了专门的称呼:

  • 不可反驳的模式(irrefutable):一定会匹配成功,否则编译错误
  • 可反驳的的模式(refutable):可以匹配成功,也可以匹配失败,匹配失败的结果是不执行对应分支的代码

let变量赋值、for迭代、函数传参这三种模式匹配只接受不可反驳模式。if let和while let只接受可反驳模式。

match则支持两种模式:

  • 当明确给出分支的Pattern时,必须是可反驳模式,这些模式允许匹配失败
  • 使用_作为最后一个分支时,是不可反驳模式,它一定会匹配成功
  • 如果只有一个Pattern分支,则可以是不可反驳模式,也可以是可反驳模式

当模式匹配处使用了不接受的模式时,将会编译错误或给出警告。


#![allow(unused)]
fn main() {
// let变量赋值时使用可反驳的模式(允许匹配失败),编译失败
let Some(x) = some_value;

// if let处使用了不可反驳模式,没有意义(一定会匹配成功),给出警告
if let x = 5 {
  // xxx
}
}

对于match来说,以下几个示例可说明它的使用方式:


#![allow(unused)]
fn main() {
match value {
  Some(5) => (),  // 允许匹配失败,是可反驳模式
  Some(50) => (), 
  _ => (),  // 一定会匹配成功,是不可反驳模式
}

match value {
  // 当只有一个Pattern分支时,可以是不可反驳模式
  x => println!("{}", x), 
  _ => (),
}
}

完整的模式语法

下面系统性地介绍Rust中的Pattern语法。

字面量模式

模式部分可以是字面量:


#![allow(unused)]
fn main() {
let x = 1;
match x {
  1 => println!("one"),
  2 => println!("two"),
  _ => println!("anything"),
}
}

模式带有变量名

例如:

fn main() {
  let x = (11, 22);
  let y = 10;
  match x { 
    (22, y) => println!("Got: (22, {})", y), 
    (11, y) => println!("y = {}", y),    // 匹配成功,输出22
    _ => println!("Default case, x = {:?}", x), 
  }
  println!("y = {}", y);   // y = 10
}

上面的match会匹配第二个分支,同时为找到的变量y进行赋值,即y=22。这个y只在第二个分支对应的代码部分有效,跳出作用域后,y恢复为y=10

多选一模式

使用|可组合多个模式,表示逻辑或(or)的意思。


#![allow(unused)]
fn main() {
let x = 1;
match x {
  1 | 2 => println!("one or two"),
  3 => println!("three"), 
  _ => println!("anything"),
}
}

范围匹配模式

Rust支持数值和字符的范围,有如下几种范围表达式:

ProductionSyntaxTypeRange
RangeExprstart..endstd::ops::Rangestart ≤ x < end
RangeFromExprstart..std::ops::RangeFromstart ≤ x
RangeToExpr..endstd::ops::RangeTox < end
RangeFullExpr..std::ops::RangeFull-
RangeInclusiveExprstart..=endstd::ops::RangeInclusivestart ≤ x ≤ end
RangeToInclusiveExpr..=endstd::ops::RangeToInclusivex ≤ end

但范围作为模式匹配的Pattern时,只允许使用全闭合的..=范围语法,其他类型的范围类型都会报错。

例如:


#![allow(unused)]
fn main() {
// 数值范围
let x = 79;
match x {
  0..=59 => println!("不及格"),
  60..=89 => println!("良好"),
  90..=100 => println!("优秀"),
  _ => println!("error"),
}

// 字符范围
let y = 'c';
match y {
  'a'..='j' => println!("a..j"),
  'k'..='z' => println!("k..z"),
  _ => (),
}
}

模式解构赋值

模式匹配时可用于解构赋值,可解构的类型包括struct、enum、tuple、slice等等。

解构赋值时,可使用_作为某个变量的占位符,使用..作为剩余所有变量的占位符(使用..时不能产生歧义,例如(..,x,..)是有歧义的)。当解构的类型包含了命名字段时,可使用fieldname简化fieldname: fieldname的书写。

解构struct

解构Struct时,会将待解构的struct各个字段和Pattern中的各个字段进行匹配,并为找到的字段变量进行赋值。

当Pattern中的字段名和字段变量同名时,可简写。例如P{name: name, age: age}P{name, age}是等价的Pattern。

struct Point2 {
  x: i32,
  y: i32,
}

struct Point3 {
  x: i32,
  y: i32,
  z: i32,
}

fn main(){
  let p = Point2{x: 0, y: 7};
  
  // 等价于 let Point2{x: x, y: y} = p;
  let Point2{x, y} = p;
  println!("x: {}, y: {}", x, y);
  // 解构时可修改字段变量名: let Point2{x: a, y: b} = p;
  // 此时,变量a和b将被赋值
   
  let ori = Point{x: 0, y: 0, z: 0};
  match ori{
    // 使用..忽略解构后剩余的字段
    Point3 {x, ..} => println!("{}", x),
  }
}

解构enum

例如:

enum IPAddr {
  IPAddr4(u8,u8,u8,u8),
  IPAddr6(String),
}

fn main(){
  let ipv4 = IPAddr::IPAddr4(127,0,0,1);
  match ipv4 {
    // 丢弃解构后的第四个值
    IPAddr(a,b,c,_) => println!("{},{},{}", a,b,c),
    IPAddr(s) => println!("{}", s),
  }
}

解构元组


#![allow(unused)]
fn main() {
let ((feet, inches), Point {x, y}) = ((3, 1), Point { x: 3, y: -1 });
}

@绑定变量名

当解构后进行模式匹配时,如果某个值没有对应的变量名,则可以使用@手动绑定一个变量名。

例如:


#![allow(unused)]
fn main() {
struct S(i32, i32);

match S(1, 2) {
  // 如果匹配1成功,将其赋值给变量z
  // 如果匹配2成功,也将其赋值给变量z
    S(z @ 1, _) | S(_, z @ 2) => assert_eq!(z, 1),
    _ => panic!(),
}
}

再例如,匹配并解构一个数组:


#![allow(unused)]
fn main() {
let arr = ["x", "y", "z"];
match arr {
  [.., "!"] => println!("!!!"),
  // 匹配成功,start = ["x", "y"]
  [start @ .., "z"] => println!("starts: {:?}", start),
  ["a", end @ ..] => println!("ends: {:?}", end),
  rest => println!("{:?}", rest),
}
}

ref和mut修饰模式中的变量

当进行解构赋值时,很可能会将变量拥有的所有权转移出去,从而使得原始变量变得不完整或直接失效。

struct Person{
  name: String,
  age: i32,
}

fn main(){
  let p = Person{name: String::from("junmajinlong"), age: 23};
  let Person{name, age} = p;
  
  println!("{}", name);
  println!("{}", age);
  println!("{}", p.name);  // 错误,name字段所有权已转移
}

如果想要在解构赋值时不丢失所有权,有以下几种方式:


#![allow(unused)]
fn main() {
// 方式一:解构表达式的引用
let Person{name, age} = &p;

// 方式二:解构表达式的克隆,适用于可调用clone()方法的类型
// 但Person struct没有clone()方法

// 方式三:在模式的某些字段或元素上使用ref关键字修饰变量
let Person{ref name, age} = p;
let Person{name: ref n, age} = p;
}

在模式中使用ref修饰变量名相当于对被解构的字段或元素上使用&进行引用。


#![allow(unused)]
fn main() {
let x = 5_i32;         // x的类型:i32
let x = &5_i32;        // x的类型:&i32
let ref x = 5_i32;     // x的类型:&i32
let ref x = &5_i32;    // x的类型:&&i32
}

因此,使用ref修饰了模式中的变量后,解构赋值时对应值的所有权就不会发生转移,而是以只读的方式借用给该变量。

如果想要对解构赋值的变量具有数据的修改权,需要使用mut关键字修饰模式中的变量,但这样会转移原值的所有权,此时可不要求原变量是可变的。

#[derive(Debug)]
struct Person {
  name: String,
  age: i32,
}

fn main() {
  let p = Person {
    name: String::from("junma"),
    age: 23,
  };
  match p {
    Person { mut name, age } => {
      name.push_str("jinlong");
      println!("name: {}, age: {}", name, age)
    },
  }
  //println!("{:?}", p);    // 错误
}

如果不想在可修改数据时丢失所有权,可在mut的基础上加上ref关键字,就像&mut xxx一样。

#[derive(Debug)]
struct Person {
  name: String,
  age: i32,
}

fn main() {
  let mut p = Person {   // 这里要改为mut p
    name: String::from("junma"),
    age: 23,
  };
  match p {
    // 这里要改为ref mut name
    Person { ref mut name, age } => {
      name.push_str("jinlong");
      println!("name: {}, age: {}", name, age)
    },
  }
  println!("{:?}", p);
}

注意,使用ref修饰变量只是借用了被解构表达式的一部分值,而不是借用整个值。如果要匹配的是一个引用,则使用&


#![allow(unused)]
fn main() {
let a = &(1,2,3);       // a是一个引用
let (t1,t2,t3) = a;     // t1,t2,t3都是引用类型&i32
let &(x,y,z) = a;       // x,y,z都是i32类型
let &(ref xx,yy,zz) = a;  // xx是&i32类型,yy,zz是i32类型
}

最后,也可以将match value{}的value进行修饰,例如match &mut value {},这样就不需要在模式中去加ref和mut了。这对于有多个分支需要解构赋值,且每个模式中都需要ref/mut修饰变量的match非常有用。

fn main() {
  let mut s = "hello".to_string();
  match &mut s {   // 对可变引用进行匹配
    // 匹配成功时,变量也是对原数据的可变引用
    x => x.push_str("world"),
  }
  println!("{}", s);
}

匹配守卫(match guard)

匹配守卫允许匹配分支添加额外的后置条件:当匹配了某分支的模式后,再检查该分支的守卫后置条件,如果守卫条件也通过,则成功匹配该分支。


#![allow(unused)]
fn main() {
let x = 33;
match x {
  // 先范围匹配,范围匹配成功后,再检查是否是偶数
  // 如果范围匹配没有成功,则不会检查后置条件
  0..=50 if x % 2 == 0 => {
    println!("x in [0, 50], and it is an even");
  },
  0..=50 => println!("x in [0, 50], but it is not an even"),
  _ => (),
}
}

注意,后置条件的优先级很低。例如:


#![allow(unused)]
fn main() {
// 下面两个分支的写法等价
4 | 5 | 6 if bool_expr => println!("yes"),
(4 | 5 | 6) if bool_expr => println!("yes"),
}

注意(1):对引用进行解构赋值时

在解构赋值时,如果解构的是一个引用,则被匹配的变量也将被赋值为对应元素的引用


#![allow(unused)]
fn main() {
let t = &(1,2,3);    // t是一个引用
let (t0,t1,t2) = t;  // t0,t1,t2的类型都是&i32
let t0 = t.0;   // t0的类型是i32而不是&i32,因为t.0等价于(*t).0
let t0 = &t.0;  // t0的类型是&i32而不是i32,&t.0等价于&(t.0)而非(&t).0
}

因此,当使用模式匹配语法for i in t进行迭代时:

  • 如果t不是一个引用,则t的每一个元素都会move给i
  • 如果t是一个引用,则i将是每一个元素的引用
  • 同理,for i in &mut tfor i in mut t也一样

注意(2):对解引用进行匹配时

match VALUE的VALUE是一个解引用*xyz时(因此,xyz是一个引用),可能会发生所有权的转移,此时可使用xyz&*xyz来代替*xyz。具体原因请参考:解引用(deref)的所有权转移问题

下面是一个示例:

fn main() {
  // p是一个Person实例的引用
  let p = &Person {
    name: "junmajinlong".to_string(),
    age: 23,
  };
  
  // 使用&*p或p进行匹配,而不是*p
  // 使用*p将报错,因为会转移所有权
  match &*p {
    Person {name, age} =>{
      println!("{}, {}",name, age);
    },
    _ => (),
  }
}

struct Person {
  name: String,
  age: u8,
}

Trait和Trait Object

从多种数据类型中抽取出这些类型之间可通用的方法或属性,并将它们放进另一个相对更抽象的类型中,是一种很好的代码复用方式,也是多态的一种体现方式。

在面向对象语言中,这种功能一般通过接口(interface)实现。在Rust中,这种功能通过Trait实现。Trait类似于其他语言中接口的概念。例如,Trait可以被其他具体的类型实现(implement),也可以在Trait中定义一些方法,实现该Trait的类型都必须实现这些方法。

严格来说,Rust中Trait的作用主要体现在两方面:

  • Trait类型:用于定义抽象行为,抽取那些共性的属性,主要表现是作为泛型的数据类型(对泛型进行限制)
  • Trait对象:即Trait Object,能用于多态

总之,Trait很重要,说是Rust的基石也不为过,它贯穿于整个Rust。本章介绍Trait最基本的内容,更多内容将在后面的泛型章节中展开。

Trait通常翻译为【特性】、【特征】、【特质】,但这些翻译都很尴尬,特别是将特性或特质等这种名词写进文章时,更显别扭。

因此对于Trait这种重要的术语,我不打算做任何转换,直接在文中使用英文原单词。

Trait的基本用法

Trait最基本的作用是从多种类型中抽取出共性的属性或方法,并定义这些方法的规范(即方法签名)。

例如,对于Audio类型和Video类型,它们有几个具有共性的方法:

  • play方法用于播放
  • pause方法用于暂停
  • get_duration方法用于显示媒体的总时长

为了抽取这些共性方法,可定义一个名为Playable的Trait,并在其中规范好这些方法的签名。

自定义Trait类型时,使用trait关键字。如:


#![allow(unused)]
fn main() {
trait Playable {
  fn play(&self);
  fn pause(&self) {
    println!("pause");
  }
  fn get_duration(&self) -> f32;
}
}

注意上面的play方法和get_duration方法都仅仅只规范了它们的方法签名,并没有为它们定义方法体,而pause方法则指定了函数签名且定义了方法体,这个方法体是pause方法的默认方法体。

定义好Playable Trait后,先让Audio类型去实现Playable:


#![allow(unused)]
fn main() {
struct Audio {
  name: String,
  duration: f32,
}

impl Playable for Audio {
  fn play(&self) {
    println!("listening audio: {}", self.name);
  }
  fn get_duration(&self) -> f32 {
    self.duration
  }
}
}

注意,上面impl Playable for Audio表示为Audio类型实现Playable Trait。Audio实现Playable Trait时,Trait中的所有没有提供默认方法体的方法(即play方法和get_duration方法)都需要实现。对于提供了默认方法体的方法,可实现可不实现,如果实现了则覆盖默认方法体,如果没有实现,则使用默认方法体。

下面再为Video类型实现Playable Trait,这里也实现了有默认方法体的pause方法:


#![allow(unused)]
fn main() {
struct Video {
  name: String,
  duration: f32,
}

impl Playable for Video {
  fn play(&self) {
    println!("watching video: {}", self.name);
  }
  fn pause(&self) {
    println!("video paused");
  }
  fn get_duration(&self) -> f32 {
    self.duration
  }
}
}

当Audio类型和Video类型实现了Playable Trait后,这两个类型的实例对象自然可以去调用它们各自定义的方法。而对于Audio没有定义的pause方法,则会从其所实现的Trait中寻找。

fn main() {
  let audio = Audio{
    name: "telephone.mp3".to_string(),
    duration: 4.32,
  };
  audio.play();
  audio.pause();
  println!("{}", audio.get_duration());

  let video = Video {
    name: "Yui Hatano.mp4".to_string(),
    duration: 59.59,
  };
  video.play();
  video.pause();
  println!("{}", video.get_duration());
}

再多理解一点Trait

从前面示例来看,某类型实现某Trait时,需要定义该Trait中指定的所有方法,定义之后,该类型也会拥有这些方法,似乎看上去和直接为各类型定义这些方法没什么区别。

但是Trait是对多种类型之间的共性进行的抽象,它只规定实现它的类型要定义哪些方法以及这些方法的签名,至于方法体的逻辑则不关心。

也可以换个角度来看待Trait。Trait描述了一种通用功能,这种通用功能要求具有某些行为,这种通用功能可以被很多种类型实现,每个实现了这种通用功能的类型,都可以被称之为是【具有该功能的类型】

例如,Clone Trait是一种通用功能,描述可克隆的行为,i32类型、i64类型、Vec类型都实现了Clone Trait,那么就可以说i32类型、i64类型、Vec类型具有Clone的功能,可以调用clone()方法。

甚至,数值类型(包括i32、u32、f32等等)的加减乘除功能,也都是通过实现各种对应的Trait而来的。比如,为了支持加法操作+,这些数值类型都实现了std::ops::Add这个Trait。可以这样理解,std::ops::Add Trait是一种通用功能,只要某个类型(包括自定义类型)实现了std::ops::Add这个Trait,这个类型的实例对象就可以使用加法操作。同理,对减法、除法、乘法、取模等等操作,也都如此。

一个类型可以实现很多种Trait,使得这个类型具有很多种功能,可以调用这些Trait的方法。比如,原始数据类型、Vec类型、HashMap类型等等已经定义好可直接使用的类型,都已经实现好了各种各样的Trait(具体实现了哪些Trait需查各自的文档),可以调用这些Trait中的方法。

例如,查看i32类型的官方文档,会发现i32类型实现了非常非常多的Trait,下面截图是i32类型所实现的一部分Trait。

i32类型的绝大多数功能都来自于其实现的各种Trait,用术语来说,那就是i32类型的大多数功能是组合(composite)其他各种Trait而来的(组合优于继承的组合)。

因此,Rust是一门支持组合的语言:通过实现Trait而具备相应的功能,是组合而非继承

derive Traits

对于Struct类型、Enum类型,需要自己手动去实现各Trait。

但对于一些常见的Trait,可在Struct类型或Enum类型前使用#[derive()]简单方便地实现这些Trait,Rust会自动为Struct类型和Enum类型定义好这些Trait所要求实现的方法。

例如,为下面的Struct类型、Enum类型实现Copy Trait、Clone Trait。


#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
struct Person {
  name: String,
  age: u8,
}

#[derive(Copy, Clone)]
enum Direction {
  Up,
  Down,
  Left,
  Right,
}
}

现在,Person类型和Direction类型就都实现了Copy Trait和Clone Trait,具备了这两个Trait的功能:所有权转移时是可拷贝的、可克隆的。

trait作用域

Rust允许在任何时候为任何类型实现任何Trait。例如,在自己的代码中为标准库Vec类型实现trait A。


#![allow(unused)]
fn main() {
// 伪代码
impl A for Vec {
  fn ff(&self){...}
}
}

这使得编程人员可以非常方便地为某类型添加功能,无论这个功能来自自定义的Trait还是Rust中已存在的Trait,也无论这个类型是自定义类型还是Rust内置类型。

这和Ruby的一些功能有些相似,Ruby可以在任意位置处使用include添加代表功能的模块,可以在任意位置重新打开类、重新打开对象来定义临时方法。

但对于Rust而言,当类型A实现了Trait T时,想要通过A的实例对象来调用来自于T的方法时,要求Trait T必须在当前作用域内,否则报错。例如:


#![allow(unused)]
fn main() {
// Vec类型已经实现了std::io::Write
let mut buf: Vec<u8> = vec![];
buf.write_all(b"hello")?;      // 报错:未找到write_all方法
}

上面的代码报错是因为Vec虽然实现了Trait Write,但Write并未在作用域内,因此调用来自Write的方法write_all会查找不到该方法。

根据编译错误提示,加上use std::io::Write即可:


#![allow(unused)]
fn main() {
use std::io::Write;
let mut buf: Vec<u8> = vec![];
buf.write_all(b"hello")?;  
}

为什么Rust要做如此要求呢?这可以避免冲突。比如张三可以在他的代码中为u8类型实现Trait A,并定义了实现A所需的方法f,张三导入使用的第三方包中可能也为u8类型实现了Trait A,毕竟Rust允许在任何位置为某类型实现某Trait。因此,张三执行(3_u8).f()的时候,Rust必须要能够区分调用的这个f方法来自于何处。

Trait继承

通过让某个类型去实现某个Trait,使得该类型具备该Trait的功能,是组合(composite)的方式。

经常和组合放在一起讨论的是继承(inheritance)。继承通常用来描述属于同种性质的父子关系(is a),而组合用来描述具有某功能(has a)

例如,支持继承的语言,可以让轿车类型(Car)继承交通工具类型(Vehicle),表明轿车是一种(is a)交通工具,它们是同一种性质的东西。而如果是支持组合的语言,可以定义可驾驶功能Drivable,然后将Driveable组合到轿车类型、轮船类型、飞机类型、卡车类型、玩具车类型,等等,表明这些类型具有(has a)驾驶功能。

Rust除了支持组合,还支持继承。但Rust只支持Trait之间的继承,比如Trait A继承Trait B。实现继承的方式很简单,在定义Trait A时使用冒号加上Trait B即可。

例如:


#![allow(unused)]
fn main() {
trait B{}
trait A: B{}
}

如果Trait A继承Trait B,当类型C想要实现Trait A时,将要求同时也要去实现B。


#![allow(unused)]
fn main() {
trait B{
  fn func_in_b(&self);
}

// Trait A继承Trait B
trait A: B{
  fn func_in_a(&self);
}

struct C{}
// C实现Trait A
impl A for C {
  fn func_in_a(&self){
    println!("impl: func_in_a");
  }
}
// C还要实现Trait B
impl B for C {
  fn func_in_b(&self){
    println!("impl: func_in_b");
  }
}
}

现在,C的实例对象将可以调用func_in_a()func_in_b()

fn main(){
  let c = C{};
  c.func_in_a();
  c.func_in_b();
}

理解Trait Object和vtable

Trait的另一个作用是Trait Object。

理解Trait Object也简单:当Car、Boat、Bus实现了Trait Drivable后,在需要Drivable类型的地方,都可以使用实现了Drivable的任意类型,如Car、Boat、Bus。从场景需求来说,需要Drivable的地方,其要求的是具有可驾驶功能,而实现了Drivable的Car、Bus等类型都具有可驾驶功能。

所以,只要能保护唐僧去西天取经,是选孙悟空还是选六耳猕猴,这是无关紧要的,重要的是要求具有保护唐僧的能力。

这和鸭子模型(Duck Typing)有点类似,只要叫起来像鸭子,它就可以当成鸭子来使用。也就是说,真正需要的不是鸭子,而是鸭子的叫声。

再看Rust的Trait Object。按照上面的说法,当B、C、D类型实现了Trait A后,就可以将类型B、C、D当作Trait A来使用。这在概念上来说似乎是正确的,但根据Rust语言的特性,Rust没有直接实现这样的用法。原因之一是,Rust中不能直接将Trait当作数据类型来使用

例如,Audio类型实现了Trait Playable,在创建Audio实例对象时不能将数据类型指定为Trait Playable。


#![allow(unused)]
fn main() {
// Trait Playable不能作为数据类型
let x: Playable = Audio{
  name: "telephone.mp3".to_string(),
  duration: 3.42,
};
}

这很容易理解,因为一种类型可能实现了很多种Trait,将其实现的其中一种Trait作为数据类型,显然无法代表该类型。

Rust真正支持的用法是:虽然Trait自身不能当作数据类型来使用,但Trait Object可以当作数据类型来使用。因此,可以将实现了Trait A的类型B、C、D当作Trait A的Trait Object来使用。也就是说,Trait Object是Rust支持的一种数据类型,它可以有自己的实例数据,就像Struct类型有自己的实例对象一样。

可以将Trait Object和Slice做对比,它们在不少方面有相似之处。

  • 对于类型T,写法[T]表示类型T的Slice类型,由于Slice的大小不固定,因此几乎总是使用Slice的引用方式&[T],Slice保存在栈中,包含两份数据:Slice所指向数据的起始指针和Slice的长度。

  • 对于Trait A,写法dyn A表示Trait A的Trait Object类型,由于Trait Object的大小不固定,因此几乎总是使用Trait Object的引用方式&dyn A,Trait Object保存在栈中,包含两份数据:Trait Object所指向数据的指针和指向一个虚表vtable的指针。

上面所描述的Trait Object,还有几点需要解释:

  • Trait Object大小不固定:这是因为,对于Trait A,类型B可以实现Trait A,类型C也可以实现Trait A,因此Trait Object没有固定大小
  • 几乎总是使用Trait Object的引用方式:
    • 虽然Trait Object没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成,因此占用两个指针大小,即两个机器字长
    • 一个指针指向实现了Trait A的具体类型的实例,也就是当作Trait A来用的类型的实例,比如B类型的实例、C类型的实例等
    • 另一个指针指向一个虚表vtable,vtable中保存了B或C类型的实例对于可以调用的实现于A的方法。当调用方法时,直接从vtable中找到方法并调用。之所以要使用一个vtable来保存各实例的方法,是因为实现了Trait A的类型有多种,这些类型拥有的方法各不相同,当将这些类型的实例都当作Trait A来使用时(此时,它们全都看作是Trait A类型的实例),有必要区分这些实例各自有哪些方法可调用
    • Trait Object的引用方式有多种。例如对于Trait A,其Trait Object类型的引用可以是&dyn ABox<dyn A>Rc<dyn A>

简而言之,当类型B实现了Trait A时,类型B的实例对象b可以当作A的Trait Object类型来使用,b中保存了作为Trait Object对象的数据指针(指向B类型的实例数据)和行为指针(指向vtable)

一定要注意,此时的b被当作A的Trait Object的实例数据,而不再是B的实例对象,而且,b的vtable只包含了实现自Trait A的那些方法,因此b只能调用实现于Trait A的方法,而不能调用类型B本身实现的方法和B实现于其他Trait的方法。也就是说,当作哪个Trait Object来用,它的vtable中就包含哪个Trait的方法。

其实,可以对比着来理解Trait Object,比如v是包含i32类型数据的Vec,v的类型是Vec而不是i32,但v中保存了i32类型的实例数据,另外v也只能调用Vec部分的方法,而不能调用i32相关的方法。

例如:

trait A{
  fn a(&self){println!("from A");}
}

trait X{
  fn x(&self){println!("from X");}
}

// 类型B同时实现trait A和trait X
// 类型B还定义自己的方法b
struct B{}
impl B {fn b(&self){println!("from B");}}
impl A for B{}
impl X for B{}

fn main(){
  // bb是A的Trait Object实例,
  // bb保存了指向类型B实例数据的指针和指向vtable的指针
  let bb: &dyn A = &B{};
  bb.a();  // 正确,bb可调用实现自Trait A的方法a()
  bb.x();  // 错误,bb不可调用实现自Trait X的方法x()
  bb.b();  // 错误,bb不可调用自身实现的方法b()
}

使用Trait Object类型

了解Trait Object之后,使用它就不再难了,它也只是一种数据类型罢了。

例如,前文的Audio类型和Video类型都实现Trait Playable:


#![allow(unused)]
fn main() {
// 为了排版,调整了代码格式
trait Playable {
  fn play(&self);
  fn pause(&self) {println!("pause");}
  fn get_duration(&self) -> f32;
}

// Audio类型,实现Trait Playable
struct Audio {name: String, duration: f32}
impl Playable for Audio {
  fn play(&self) {println!("listening audio: {}", self.name);}
  fn get_duration(&self) -> f32 {self.duration}
}

// Video类型,实现Trait Playable
struct Video {name: String, duration: f32}
impl Playable for Video {
  fn play(&self) {println!("watching video: {}", self.name);}
  fn pause(&self) {println!("video paused");}
  fn get_duration(&self) -> f32 {self.duration}
}
}

现在,将Audio的实例或Video的实例当作Playable的Trait Object来使用:

fn main() {
  let x: &dyn Playable = &Audio{
    name: "telephone.mp3".to_string(),
    duration: 3.42,
  };
  x.play();
  
  let y: &dyn Playable = &Video{
    name: "Yui Hatano.mp4".to_string(),
    duration: 59.59,
  };
  y.play();
}

此时,x的数据类型是Playable的Trait Object类型的引用,它在栈中保存了一个指向Audio实例数据的指针,还保存了一个指向包含了它可调用方法的vtable的指针。同理,y也一样。

再比如,有一个Playable的Trait Object类型的数组,在这个数组中可以存放所有实现了Playable的实例对象数据:

use std::fmt::Debug;

fn main() {
  let a:&dyn Playable = &Audio{
    name: "telephone.mp3".to_string(),
    duration: 3.42,
  };
  
  let b: &dyn Playable = &Video {
    name: "Yui Hatano.mp4".to_string(),
    duration: 59.59,
  };
  
  let arr: [&dyn Playable;2] = [a, b];
  println!("{:#?}", arr);
}

trait Playable: Debug {}

#[derive(Debug)]
struct Audio {}
impl Playable for Audio {}

#[derive(Debug)]
struct Video {...}
impl Playable for Video {...}

注意,上面为了使用println!的调试输出格式{:#?},要让Playable实现名为std::fmt::Debug的Trait,因为Playable自身也是一个Trait,所以使用Trait继承的方式来继承Debug。继承Debug后,要求实现Playable Trait的类型也都要实现Debug Trait,因此在Audio和Video之前使用#[derive(Debug)]来实现Debug Trait。

上面实例的输出结果:


#![allow(unused)]
fn main() {
[
    Audio {
        name: "telephone.mp3",
        duration: 3.42,
    },
    Video {
        name: "Yui Hatano.mp4",
        duration: 59.59,
    },
]
}

泛型

在编程语言中,变量名是对编程人员友好的名称,在编译期间,变量名会被转换为可被机器识别的内存地址,变量保存了什么数据,变量名就被替换为该数据的内存地址。也就是说,有了变量,编程人员可以使用更友好的变量名而不是使用内存地址来操作内存中的数据。

也可以将变量理解为是对内存中数据的抽象,无论是什么数据值,在编写代码的阶段,都可以用变量来表示这些数据,而在编译阶段,变量则会被替换为它所代表的内存数据。

除了可以使用变量来代表数据,在支持泛型(Generic)的编程语言中,还可以使用泛型来代表各种各样可能的数据类型。泛型之于数据类型,和变量之于内存数据,是类似的。在编写代码阶段,泛型可以表示各种各样的数据类型,(对于Rust来说)在编译阶段,泛型会被替换为它所代表的数据类型。

本章将介绍泛型相关的内容。

泛型的基本使用

通过泛型系统,可以减少很多冗余代码。

例如,不使用泛型时,定义一个参数允许为u8、i8、u16、i16、u32、i32......等类型的double函数时:

fn double_u8(i: u8) -> u8 { i + i }
fn double_i8(i: i8) -> i8 { i + i }
fn double_u16(i: u16) -> u16 { i + i }
fn double_i16(i: i16) -> i16 { i + i }
fn double_u32(i: u32) -> u32 { i + i }
fn double_i32(i: i32) -> i32 { i + i }
fn double_u64(i: u64) -> u64 { i + i }
fn double_i64(i: i64) -> i64 { i + i }

fn main(){
  println!("{}",double_u8(3_u8));
  println!("{}",double_i16(3_i16));
}

上面定义了一堆double函数,函数的逻辑部分是完全一致的,仅在于类型的不同。

泛型可以用于解决这样因类型而代码冗余的问题。使用泛型时:

use std::ops::Add;
fn double<T>(i: T) -> T
  where T: Add<Output=T> + Clone + Copy {
  i + i
}

fn main(){
  println!("{}",double(3_i16));
  println!("{}",double(3_i32));
}

上面的字母T就是泛型(和变量x的含义是相同的),它用来代表各种可能的数据类型。多数时候泛型使用单个大写字母来表示,但也可以使用多个字母来表示。

对于double函数签名的前一部分:


#![allow(unused)]
fn main() {
fn double<T>(i: T) -> T 
}

函数名称后面的<T>表示在函数作用域内定义一个泛型T,这个泛型只能在函数签名和函数体内使用,就跟在一个作用域内定义一个变量,这个变量只能在该作用域内使用是一样的。而且,泛型本就是代表各种数据类型的变量。

参数部分i: T表示参数i的类型是泛型T。

返回值部分-> T表示该函数的返回值类型是泛型T。

因此,上面这部分函数签名表达的含义是:传入某种数据类型的参数,也返回这种数据类型的返回值,且这种数据类型可以是任意的类型。

对于第一次接触泛型的人来说,这可能很难理解。但是,换成类似的使用普通变量的代码,可能就容易理解了:

# 伪代码:传入一个数据,返回这个数据
function f(x) {return x}

对泛型进行限制

但注意,double函数期待的是对数值进行加法操作,但泛型却可以代表各种类型,因此,还需要对泛型T进行限制,否则在调用double函数时就允许传递字符串类型、Vec类型、Person类型等值作为函数参数,这偏离了期待。

例如,在double的函数体内需要对泛型T的值i进行加法操作,但只有实现了std::ops::Add Trait的类型才能使用+进行加法操作。因此要限制泛型T是那些实现了std::ops::Add的数据类型。

限制泛型也叫做Trait绑定(Trait Bound),其语法有两种:

  • 在定义泛型类型T时,使用类似于T: Trait_Name这种语法进行限制
  • 在返回值后面、大括号前面使用where关键字,如where T: Trait_Name

因此,下面两种写法是等价的:


#![allow(unused)]
fn main() {
fn f<T: Clone + Copy>(i: T) -> T{}

fn f<T>(i: T) -> T
  where T: Clone + Copy {}

// 更复杂的示例:
fn query<M: Mapper + Serialize, R: Reducer + Serialize>(
    data: &DataSet, map: M, reduce: R) -> Results 
{
    ...
}

// 此时,下面写法更友好、可读性更高
fn query<M, R>(data: &DataSet, map: M, reduce: R) -> Results 
    where M: Mapper + Serialize,
          R: Reducer + Serialize
{
    ...
}
}

其中,T: Trait_Name表示将泛型T限制为那些实现了Trait_Name Trait的数据类型。因此T: std::ops::Add表示泛型T只能代表那些实现了std::ops::Add Trait的数据类型,比如各种数值类型都实现了Add Trait,因此T可以代表数值类型,而Vec类型没有实现Add Trait,因此T不能代表Vec类型。

观察指定变量数据类型的写法i: i32和限制泛型的写法T: Trait_Name,由此可知,Trait其实是泛型的数据类型,Trait限制了泛型所能代表的类型,正如数据类型限制了变量所能存放的数据。

有时候需要对泛型做多重限制,这时使用+即可。例如T: Add<Output=T>+Copy+Clone,表示限制泛型T只能代表那些同时实现了Add、Copy、Clone这三种Trait的数据类型。

之所以要做多重限制,是因为有时候限制少了,泛型所能代表的类型不够精确或者缺失某种功能。比如,只限制泛型T是实现了std::ops::Add Trait的类型还不够,还要限制它实现了Copy Trait以便函数体内的参数i被转移所有权时会自动进行Copy,但Copy Trait是Clone Trait的子Trait,即Copy依赖于Clone,因此限制泛型T实现Copy的同时,还要限制泛型T同时实现Clone Trait。

简而言之,要对泛型做限制,一方面的原因是函数体内需要某种Trait提供的功能(比如函数体内要对i执行加法操作,需要的是std::ops::Add的功能),另一方面的原因是要让泛型T所能代表的数据类型足够精确化(如果不做任何限制,泛型将能代表任意数据类型)。

泛型的引用类型

如果参数是一个引用,且又使用泛型,则需要使用泛型的引用&T&mut T

例如:


#![allow(unused)]
fn main() {
use std::fmt::Display;

fn f<T: Display>(i: &T) {
  println!("{}", *i);
}
}

零运行开销的泛型:泛型代码膨胀

rustc在编译代码时,会将所有的泛型替换成它所代表的具体数据类型,就像编译期间会将变量名替换成它所代表数据的内存地址一样。

例如,对于下面这个泛型函数:

use std::ops::Add;
fn double_me<T>(i: T) -> T
  where T: Add<Output=T> + Clone + Copy {
  i + i
}

fn main() {
  println!("{}", double_me(3u32));
  println!("{}", double_me(3u8));
  println!("{}", double_me(3i8));
}

在编译期间,rustc会根据调用double_me()时传递的具体数据类型进行替换。上面示例使用了u32、u8和i8三种类型的值传递给泛型参数,那么编译期间,编译器会对应生成三个double_me()函数,它们的参数类型分别是u32、u8和i8。

$ rustc src/main.rs
$ strings main | grep "double_me"
_ZN4main9double_me17h6d861a9e8ab36c42E
_ZN4main9double_me17ha214a9977249a1bfE
_ZN4main9double_me17hbc458c5fab68c203E

由于编译期间,编译器会对泛型类型进行替换,这会导致泛型代码膨胀(code bloat),从一个函数膨胀为零个、一个或多个具体数据类型的函数。有时候这种膨胀会导致编译后的程序文件变大很多。不过,多数情况下,代码膨胀的问题都不是大问题。

另一方面,由于编译期间已经将泛型替换成了具体的数据类型,因此,在程序运行期间,直接调用对应类型的函数即可,不需要再消耗任何额外的资源去计算泛型所代表的具体类型。因此,Rust的泛型是零运行时开销的。

使用泛型的位置

不仅仅是函数的参数可以指定泛型,任何需要指定数据类型的地方,都可以使用泛型来替代具体的数据类型,以此来表示此处可以使用比某种具体类型更为通用的数据类型。

而且,可以同时使用多个泛型,只要将不同的泛型定义为不同的名称即可。例如,HashMap类型是保存键值对的类型,它的key是一种泛型类型,它的值也是一种泛型类型。它的定义如下:


#![allow(unused)]
fn main() {
// 使用了三个泛型,分别是K、V、S,并且泛型S的默认类型是RandomState
struct HashMap<K, V, S = RandomState> {
  base: base::HashMap<K, V, S>,
}
}

实际上,Struct、Enum、impl、Trait等地方都可以使用泛型,仍然要注意的是,需要在类型的名称后或者impl后先声明泛型,才能使用已声明的泛型。

下面是一些简单的示例。

Struct使用泛型:


#![allow(unused)]
fn main() {
struct Container_tuple<T> (T)
struct Container_named<T: std::fmt::Display> {
  field: T,
}
}

例如,Vec类型就是泛型的Struct类型,其官方定义如下:


#![allow(unused)]
fn main() {
pub struct Vec<T> {
    buf: RawVec<T>,
    len: usize,
}
}

Enum使用泛型:


#![allow(unused)]
fn main() {
enum Option<T> {
  Some(T),
  None,
}
}

impl实现类型的方法时使用泛型:


#![allow(unused)]
fn main() {
struct Container<T>{
  item: T,
}

// impl后的T是声明泛型T
// Container<T>的T对应Struct类型Container<T>
impl<T> Container<T> {
  fn new(item: T) -> Self {
    Container {item}
  }
}
}

Trait使用泛型:


#![allow(unused)]
fn main() {
// 表示将某种类型T转换为当前类型
trait From<T> { 
  fn from(T) -> Self; 
}
}

某数据类型impl实现Trait时使用泛型:


#![allow(unused)]
fn main() {
use std::fmt::Debug;

trait Eatable {
  fn eat_me(&self);
}

#[derive(Debug)]
struct Food<T>(T);

impl<T: Debug> Eatable for Food<T> {
  fn eat_me(&self) {
    println!("Eating: {:?}", self);
  }
}
}

注意,上面impl时指定了T: Debug,它表示了Food<T>类型的T必须实现了Debug。为什么不直接在定义Struct时,将Food定义为struct Food<T: Debug>而是在impl Food时才限制泛型T呢?

通常,应当尽量不在定义类型时限制泛型的范围,除非确实有必要去限制。这是因为,泛型本就是为了描述更为抽象、更为通用的类型而存在的,限制泛型将使得类型变得更具体化,适用场景也更窄。但是在impl类型时,应当去限制泛型,并且遵守缺失什么功能就添加什么限制的规范,这样可以使得所定义的方法不会过度泛化,也不会过度具体化。

简单来说,尽量不去限制类型是什么,而是限制类型能做什么。

另一方面,即使定义struct Food<T: Debug>,在impl Food<T>时,也仍然要在impl时指定泛型的限制,否则将编译错误


#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Food<T: Debug>(T);
impl<T: Debug> Eatable for Food<T> {}
}

也就是说,如果某个泛型类型有对应的impl,那么在定义类型时指定的泛型限制很可能是多余的。但如果没有对应的impl,那么可能有必要在定义泛型类型时加上泛型限制

Trait对象和泛型

对比一下Trait对象和泛型:

  • Trait对象可以被看作一种数据类型,它总是以引用的方式被使用,在运行期间,它在栈中保存了具体类型的实例数据和实现自该Trait的方法。
  • 泛型不是一种数据类型,它可被看作是数据类型的参数形式或抽象形式,在编译期间会被替换为具体的数据类型

Trait Objecct方式也称为动态分派(dynamic dispatch),它在程序运行期间动态地决定具体类型。而Rust泛型是静态分派,它在编译期间会代码膨胀,将泛型参数转变为使用到的每种具体类型。

例如,类型Square和类型Rectangle都实现了Trait Area以及方法get_area,现在要创建一个vec,这个vec中包含了任意能够调用get_area方法的类型实例。这种需求建议采用Trait Object方式:

fn main(){
  let mut sharps: Vec<&dyn Area> = vec![];
  sharps.push(&Square(3.0));
  sharps.push(&Rectangle(3.0, 2.0));
  println!("{}", sharps[0].get_area());
  println!("{}", sharps[1].get_area());
}

trait Area{
  fn get_area(&self)->f64;
}

struct Square(f64);
struct Rectangle(f64, f64);
impl Area for Square{
  fn get_area(&self) -> f64 {self.0 * self.0}
}
impl Area for Rectangle{
  fn get_area(&self) -> f64 {self.0 * self.1}
}

在上面的示例中,Vec sharps用于保存多种不同类型的数据,只要能调用get_area方法的数据都能存放在此,而调用get_area方法的能力,来自于Area Trait。因此,使用动态的类型dyn Area来描述所有这类数据。当sharps中任意一个数据要调用get_area方法时,都会从它的vtable中查找该方法,然后调用。

但如果改一下上面示例的需求,不仅要为f64实现上述功能,还要为i32、f32、u8等类型实现上述功能,这时候使用Trait Object就很冗余了,要为每一个数值类型都实现一次。

使用泛型则可以解决这类因数据类型而导致的冗余问题。

fn main(){
  let sharps: Vec<Sharp<_>> = vec![
    Sharp::Square(3.0_f64),
    Sharp::Rectangle(3.0_f64, 2.0_f64),
  ];
  sharps[0].get_area();
}

trait Area<T> {
  fn get_area(&self) -> T;
}

enum Sharp<T>{
  Square(T),
  Rectangle(T, T),
}

impl<T> Area<T> for Sharp<T>
  where T: Mul<Output=T> + Clone + Copy
{
  fn get_area(&self) -> T {
    match *self {
      Sharp::Rectangle(a, b) => return a * b,
      Sharp::Square(a) => return a * a,
    }
  }
}

上面使用了泛型枚举,在这个枚举类型上实现Area Trait,就可以让泛型枚举统一各种类型,使得这些类型的数据都具有get_area方法。