本文为阅读《Rust圣经》的第一篇笔记,内容包括《Rust基础入门》前4章节,后续章节请移步到本网站文章《Rust基础入门(二)》等,如果想查看原书请搜索“Rust圣经”或点击此链接:[Rust语言圣经(Rust Course)](https://course.rs/about-book.html)


变量绑定与解构

变量命名

Rust语言变量命名不能和关键字重复

变量绑定

将一个值绑定给一个变量,类似赋值但不完全是赋值,要注意“所有权”这个概念。

变量可变性

Rust变量默认情况下不可变:

  • 不可变变量:

    1
    2
    let x = 5;
    //此时不能再对x赋值,若有代码“x = 6;”则会报错
  • 可变变量:

    1
    2
    let mut x = 5;
    //可以对x进行再赋值

未使用的变量

如果未使用的变量开头不是下划线,则会引发warning

避免方法:

  • 变量开头使用下划线

  • 使用 #![allow(unused)] 属性,例如:

    1
    2
    3
    4
    #![allow(unused)]
    fn main() {
    let x = 1;
    }

变量解构

1
let (a, mut b): (bool,bool) = (true, false);

常量

1
const MAX_POINTS: u32 = 100_000;

变量遮蔽

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let x = 5;
/* x = 5 */
let x = x + 1;// 这句话在main函数的作用域内对之前的x进行遮蔽
/* x = 6 */
{
let x = x * 2;// 这句话在当前的花括号作用域内,对之前的x进行遮蔽
/* x = 12 */
}
/* x = 6 */
}

注意:使用变量遮蔽(let)可以改变变量的数据类型,但是不用let对可变变量进行不同类型的赋值是不允许的

基本类型

数值类型

  • 数值类型:有符号整数 (i8, i16, i32, i64, isize)、 无符号整数 (u8, u16, u32, u64, usize) 、浮点数 (f32, f64)、以及有理数、复数
  • 字符串:字符串字面量和字符串切片 &str
  • 布尔类型:true 和 false
  • 字符类型:表示单个 Unicode 字符,存储为 4 个字节
  • 单元类型:即 () ,其唯一的值也是 ()

语句

语句完成了一个具体的操作,但是并没有返回值。语句一定以“;”结尾。

表达式

表达式会进行求值,然后返回一个值。表达式结尾不会有“;”。

函数

Rust 的函数体是由一系列语句组成,最后由一个表达式来返回值

1
2
3
4
5
6
7
8
9
10
11
12
fn add_with_extra(x: i32, y: i32) -> i32 {
let x = x + 1; // 语句
let y = y + 5; // 语句
x + y // 表达式
}
//或者使用return
fn plus_or_minus(x:i32) -> i32 {
if x > 5 {
return x - 5
}
x + 5
}

当用 ! 作函数返回类型的时候,表示该函数永不返回( diverging functions ),特别的,这种语法往往用做会导致程序崩溃的函数:

1
2
3
fn dead_end() -> ! {
panic!("你已经到了穷途末路,崩溃吧!");
}

与函数有所区别的是宏调用,例如 println! 是宏调用,看起来像是函数但是它返回的是宏定义的代码块。常见宏调用如下:

1
2
3
4
5
6
7
8
9
10
11
12
println!("a + b = {}", c); // 打印
/* Rust 使用 {} 来作为格式化输出占位符, println! 会自动推导出具体的类型。 */

panic!("崩溃吧!"); // 触发不可恢复错误
/* 原理:首先打印panic消息和位置信息(如果设置了环境变量RUST_BACKTRACE=1,还会打印回溯信息)。
然后程序开始栈展开(stack unwinding),这会清理每个函数调用中的数据(调用析构函数等)。
最后,程序终止。 */

assert_eq!(a, b); // 比较
/* 如果a, b相等,则编译通过,否则程序panic并打印错误信息,一般在测试时使用。 */

dbg!(new_string); //返回其内表达式的值

所有权和借用

所有权

栈按照顺序存储值并以相反顺序取出值,这也被称作后进先出。增加数据叫做进栈,移出数据则叫做出栈。

因为上述的实现方式,栈中的所有数据都必须占用已知且固定大小的内存空间,假设数据大小是未知的,那么在取出数据时,你将无法取到你想要的数据。

与栈不同,对于大小未知或者可能变化的数据,我们需要将它存储在堆上。当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针,该过程被称为在堆上分配内存,有时简称为 “分配”(allocating)。

接着,该指针会被推入栈中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的指针,来获取数据在堆上的实际内存位置,进而访问该数据。

所有权原则

所有权的规则:

  • Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
  • 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
  • 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)

Rust语言的变量作用域和C等其他语言没有区别,例如:

1
2
3
4
{                      // s 在这里无效,它尚未声明
let s = "hello"; // 从此处起,s 是有效的
// 使用 s
} // 此作用域已结束,s不再有效

初识String

Rust 为我们提供动态字符串类型: String,该类型被分配到堆上,因此可以动态伸缩,也就能存储在编译时大小未知的文本。

String 的用法如下:

1
2
3
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 在字符串后追加字面值
println!("{}", s); // 将打印 `hello, world!`

注:String 不是基本类型,String 类型包含了堆指针、字符串长度、字符串容量等多个复杂内容。

变量绑定后的数据交互

  • 转移所有权:将一个值的所有权进行转移,原变量名会失去这个值的所有权。

    1
    2
    let s1 = String::from("hello");
    let s2 = s1;

    此例中,s1将会失去”hello”的所有权,运行

    1
    println!("{}, world!", s1);

    会报错。

  • 深拷贝:clone,性能比较低,是在堆的基础上的拷贝。

    1
    2
    let s1 = String::from("hello");
    let s2 = s1.clone();

    此例中s1,s2都会拥有”hello”的所有权(但是是两个不同的”hello”)。

    Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都不是深拷贝。

  • 浅拷贝:浅拷贝只发生在栈上,因此性能很高。

    1
    2
    let x = 5;
    let y = x;

    此例中x和y都会赋值为5.这种可以进行直接浅拷贝的变量类型所拥有的特征叫做Copy特征。

    任何基本类型的组合可以 Copy ,不需要分配内存或某种形式资源的类型是可以 Copy 的。

函数的传值与返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
let s = String::from("hello"); // s 进入作用域

takes_ownership(s); // s 的值移动到函数里 ...
// ... 所以到这里不再有效

let x = 5; // x 进入作用域

makes_copy(x); // x 应该移动函数里,
// 但 i32 是 Copy 的,所以在后面可继续使用 x

} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
// 所以不会有特殊操作

fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

上述例子中,在 takes_ownership(s); 之后运行 println!(“{}”,s); 将会报错,因为s的”hello”所有权已经转移给some_string。但是i32型的变量是Copy的,所以在 makes_copy(x); 后运行 println!(“{}”,x); 是不会报错的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fn main() {
let s1 = gives_ownership(); // gives_ownership 将返回值
// 移给 s1

let s2 = String::from("hello"); // s2 进入作用域

let s3 = takes_and_gives_back(s2); // s2 被移动到
// takes_and_gives_back 中,
// 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
// 所以什么也不会发生。s1 移出作用域并被丢弃

fn gives_ownership() -> String { // gives_ownership 将返回值移动给
// 调用它的函数

let some_string = String::from("hello"); // some_string 进入作用域.

some_string // 返回 some_string 并移出给调用的函数
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域

a_string // 返回 a_string 并移出给调用的函数
}

同理,在 main() 函数最后, s1,s3 可以被打印, s2 不可被打印。

引用和借用

引用与解引用

  • 引用(名词):常规引用是一个指针类型,指向了对象存储的内存地址。

  • 解引用(名词):解出引用所指向的值,即引用对象存储的值

  • 借用(动词):获取变量的引用,称之为借用

  • 引用的作用域:从引用创建开始到最后一次使用的位置(旧版 Rust 的引用的作用域等于变量作用域)。对于这种编译器优化行为,Rust 专门起了一个名字 —— Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域(})结束前就不再被使用的代码位置。

  • 例子:

    1
    2
    3
    4
    5
    6
    fn main() {
    let x = 5;
    let y = &x; // y 为引用
    assert_eq!(5, x); // x 对 5 的所有权并没有消失
    assert_eq!(5, *y); // *y 为解引用
    }

不可变引用与可变引用

  • 不可变引用:变量默认不可变,所以引用指向的值也默认不可变。示例:

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

    let len = calculate_length(&s1);

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

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

    这里的 & 就是引用,这样传入引用的函数可以维持 s1 的 “hello” 所有权,不需要复杂地在函数中传入传出所有权。但是不能通过这个引用修改 s1 的值,原因: s1 是不可变变量,引用也不可变。

    下为引用示意图:

  • 可变引用:对可变变量的引用。

    1
    2
    3
    4
    5
    6
    7
    8
    fn main() {
    let mut s = String::from("hello"); // 可变
    change(&mut s); // 可变
    }

    fn change(some_string: &mut String) {
    some_string.push_str(", world"); // push_str 是在目标字符串后添加字符串的函数
    }

    这样运行就不会报错了。

    可变引用有两个很大限制:

    • 同一作用域,特定数据只能有一个可变引用
    • 可变引用与不可变引用不能同时存在

悬垂引用

悬垂引用:悬垂引用也叫做悬垂指针,意思为指针指向某个值后,这个值被释放掉了,而指针仍然存在,其指向的内存可能不存在任何值或已被其它变量重新使用。Rust语言不允许悬垂指针的出现。

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

fn dangle() -> &String { // dangle 返回一个字符串的引用

let s = String::from("hello"); // s 是一个新字符串

&s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
// 危险!
// 上述代码运行会报错。

复合类型(一):字符串和数组

字符串与切片

切片

  • 对于字符串而言,切片就是对String类型中某一部分的引用,语法如下:

    1
    2
    3
    4
    let s = String::from("hello world");

    let hello = &s[0..5]; // 左闭右开
    let world = &s[6..11]; // 左闭右开

    示意图如下:

  • Rust 语言的 .. range序列与 Python 的类似,可以在索引 0 处省略 0 ,索引 len 处省略 len:

    1
    2
    3
    4
    5
    6
    let slice = &s[0..2];
    let slice = &s[..2];
    let slice = &s[4..len];
    let slice = &s[4..];
    let slice = &s[0..len];
    let slice = &s[..];
  • 区间符号:

    • .. :左闭右开。如:切片&s[0..2] 代表 “he”

    • ..= :左闭右闭,闭区间。如:切片&s[0..=2] 代表 “hel”

  • 切片索引必须在字符边界位置,这一点在UTF-8等编码的字符处理时要注意。以UTF-8为例,如果要切片纯UTF-8的中文字符串,必须满足切片的两个索引都为3的倍数(中文在 UTF-8 中占用三个字节)。

  • 字符串切片的类型标识是 &str

  • 因为切片是对集合的部分引用,因此不仅仅字符串有切片,其它集合类型也有,例如数组。

  • 字符串的字面量是切片:

    1
    2
    3
    let s = "Hello, world!";     // s 的类型是 &str
    /* 下面这个语句和上面等效 */
    let s: &str = "Hello, world!";

    s 这个切片指向了程序可执行文件中的某个点,这也是为什么字符串字面量是不可变的,因为 &str 是一个不可变引用。

  • 简单总结下切片的特点:(ps:如果这里没有看懂可以先跳过,等学完“数组——数组切片”后或许能更好地理解)

    • 切片的长度在运行时确定,并非固定不变,而是取决于创建时指定的起始和结束位置,提供了灵活的数据访问视图
    • 切片本身不拥有底层数据,它只是对原始数据(如数组、String或 &str)的一个“视图”或引用。因此创建切片的代价很小,避免了不必要的数据复制
    • 切片类型 [T] 和 str 本身是动态大小类型(DST),其大小在编译时无法确定。而更常用的切片引用类型 &[T]和 &str是胖指针,包含指向数据的指针和长度信息,具有固定的大小(在64位系统上通常为16字节),这使其能够满足Rust对固定大小数据类型的要求,因此更为实用
    • Rust的借用检查器会确保切片的生命周期不会超过其引用的底层数据,从而在提供灵活性的同时保证了内存安全

字符串

  • Rust 中的字符是 Unicode 类型,因此每个字符占据 4 个字节内存空间,但是在字符串中不一样,字符串是 UTF-8 编码,也就是字符串中的字符所占的字节数是变化的(1 - 4)

  • Rust 在语言级别,只有一种字符串类型: str,它通常是以引用类型出现 &str,也就是上文提到的字符串切片。虽然语言级别只有上述的 str 类型,但是在标准库里,还有多种不同用途的字符串类型,其中使用最广的即是 String类型。

  • str 类型是硬编码进可执行文件,也无法被修改,但是 String 则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串,当 Rust 用户提到字符串时,往往指的就是 String 类型和 &str 字符串切片类型,这两个类型都是 UTF-8 编码。

  • String 与 &str 的转换:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /* &str -> String */
    let s = String::from("hello,world")
    let s = "hello,world".to_string() // to_string 将值转换为其字符串表示形式

    /* String -> &str */
    fn main() {
    let s = String::from("hello,world!");
    say_hello(&s);
    say_hello(&s[..]);
    say_hello(s.as_str()); // as_str 获取该字符串的一个不可变切片引用(&str)
    }

    fn say_hello(s: &str) {
    println!("{}",s);
    }
  • Rust 不允许使用索引的方式访问字符串的某个字符或者子串。通过索引区间来访问字符串时,需要格外的小心。

字符串操作

由于 String 是可变字符串,下面介绍 Rust 字符串的修改,添加,删除等常用方法:

  • 追加(Push)

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

    s.push_str("rust"); //追加字符串字面量
    println!("追加字符串 push_str() -> {}", s);

    s.push('!'); //追加字符 char
    println!("追加字符 push() -> {}", s);
    }

    代码运行结果:

    1
    2
    追加字符串 push_str() -> Hello rust
    追加字符 push() -> Hello rust!
  • 插入(Insert)

    1
    2
    3
    4
    5
    6
    7
    fn main() {
    let mut s = String::from("Hello rust!");
    s.insert(5, ','); // 插入单个字符 char
    println!("插入字符 insert() -> {}", s);
    s.insert_str(6, " I like"); // 插入字符串字面量
    println!("插入字符串 insert_str() -> {}", s);
    } // insert 的第一个参数实质上是字节偏移量,而不是字符序号。

    代码运行结果:

    1
    2
    插入字符 insert() -> Hello, rust!
    插入字符串 insert_str() -> Hello, I like rust!
  • 替换(Replace)

    • replace,可适用于 String 和 &str 类型

      1
      2
      3
      4
      5
      fn main() {
      let string_replace = String::from("I like rust. Learning rust is my favorite!");
      let new_string_replace = string_replace.replace("rust", "RUST"); // replace 会返回一个新字符串,而不是直接在原字符串上进行修改。
      dbg!(new_string_replace);
      }

      代码运行结果:

      1
      new_string_replace = "I like RUST. Learning RUST is my favorite!"
    • replacen,可适用于 String 和 &str 类型

      1
      2
      3
      4
      5
      fn main() {
      let string_replace = "I like rust. Learning rust is my favorite!";
      let new_string_replacen = string_replace.replacen("rust", "RUST", 1); // 第三个参数指的是替换的个数,从头开始数。
      dbg!(new_string_replacen);
      }

      代码运行结果:

      1
      new_string_replacen = "I like RUST. Learning rust is my favorite!"
    • replace_range,仅适用于 String 类型

      1
      2
      3
      4
      5
      fn main() {
      let mut string_replace_range = String::from("I like rust!");
      string_replace_range.replace_range(7..8, "R"); // 直接操作原来的字符串,不会返回新字符串。第二个参数要是字符串。
      dbg!(string_replace_range);
      }

      代码运行结果:

      1
      string_replace_range = "I like Rust!"
  • 删除(Delete)

    下列相关方法仅适用于 String ,因为都是直接操作原来的字符串。

    • pop —— 删除并返回字符串的最后一个字符

      其返回值是一个 Option 类型,为删除掉的字符( Option 类型的特点:如果字符串为空,则返回 None)。

      1
      2
      3
      4
      5
      6
      7
      8
      fn main() {
      let mut string_pop = String::from("rust pop 中文!");
      let p1 = string_pop.pop();
      let p2 = string_pop.pop();
      dbg!(p1);
      dbg!(p2);
      dbg!(string_pop);
      }

      代码运行结果:

      1
      2
      3
      4
      5
      6
      7
      8
      p1 = Some(
      '!',
      )
      p2 = Some(
      '文',
      )
      string_pop = "rust pop 中"

    • remove —— 删除并返回字符串中指定位置的字符

      其返回值为删除位置的字符串。remove() 方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      fn main() {
      let mut string_remove = String::from("测试remove方法");
      println!(
      "string_remove 占 {} 个字节",
      std::mem::size_of_val(string_remove.as_str())
      );
      // 删除第一个汉字
      string_remove.remove(0);
      // 下面代码会发生错误
      // string_remove.remove(1);
      // 直接删除第二个汉字
      // string_remove.remove(3);
      dbg!(string_remove);
      }

      代码运行结果:

      1
      2
      string_remove 占 18 个字节
      string_remove = "试remove方法"
    • truncate —— 删除字符串中从指定位置开始到结尾的全部字符

      无返回值,它同样是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。

      1
      2
      3
      4
      5
      fn main() {
      let mut string_truncate = String::from("测试truncate");
      string_truncate.truncate(3);
      dbg!(string_truncate);
      }

      代码运行结果:

      1
      string_truncate = "测"
    • clear —— 清空字符串

      相当于 truncate() 方法参数为 0 的时候

      1
      2
      3
      4
      5
      fn main() {
      let mut string_clear = String::from("string clear");
      string_clear.clear();
      dbg!(string_clear);
      }

      代码运行结果:

      1
      string_clear = ""
  • 连接(Concatenate)

    • 使用 + 或者 += 连接字符串

      使用 + 或者 += 连接字符串,要求右边的参数必须为字符串的切片引用(Slice)类型。其实当调用 + 的操作符时,相当于调用了 std::string 标准库中的 add() 方法,这里 add() 方法的第二个参数是一个引用的类型。因此我们在使用 + 时, 必须传递切片引用类型。不能直接传递 String 类型。+ 是返回一个新的字符串,所以变量声明可以不需要 mut 关键字修饰。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      fn main() {
      let string_append = String::from("hello ");
      let string_rust = String::from("rust");
      // &string_rust会自动解引用为&str
      let result = string_append + &string_rust;
      let mut result = result + "!"; // `result + "!"` 中的 `result` 是不可变的
      result += "!!!";

      println!("连接字符串 + -> {}", result);
      // 以下代码去掉注释后会报错,因为 + 实际上是调用了 add() 方法,所有权被转移到 add() 方法里面, add() 方法调用后就被释放了,同时 string_append 也被释放了。再使用 string_append 就会发生错误。
      // println!("原字符串: {}", string_append);
      }

      代码运行结果:

      1
      连接字符串 + -> hello rust!!!!
    • format! 宏

    format! 这种方式适用于 String 和 &str 。format! 的用法与 print! 的用法类似,它会将格式化文本输出到 String 字符串。

    1
    2
    3
    4
    5
    6
    fn main() {
    let s1 = "hello";
    let s2 = String::from("rust");
    let s = format!("{} {}!", s1, s2);
    println!("{}", s);
    }

    代码运行结果

    1
    hello rust!

字符串转义

我们可以通过转义的方式 \ 输出 ASCII 和 Unicode 字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn main() {
// 通过 \ + 字符的十六进制表示,转义输出一个字符
let byte_escape = "I'm writing \x52\x75\x73\x74!";
println!("What are you doing\x3F (\\x3F means ?) {}", byte_escape);

// \u 可以输出一个 unicode 字符
let unicode_codepoint = "\u{211D}";
let character_name = "\"DOUBLE-STRUCK CAPITAL R\"";

println!(
"Unicode character {} (U+211D) is called {}",
unicode_codepoint, character_name
);

// 换行了也会保持之前的字符串格式
// 使用\忽略换行符
let long_string = "String literals
can span multiple lines.
The linebreak and indentation here ->\
<- can be escaped too!";
println!("{}", long_string);
}

当然,在某些情况下,可能你会希望保持字符串的原样,不要转义:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
println!("{}", "hello \\x52\\x75\\x73\\x74");
let raw_str = r"Escapes don't work here: \x3F \u{211D}";
println!("{}", raw_str);

// 如果字符串包含双引号,可以在开头和结尾加 #
let quotes = r#"And then I said: "There is no escape!""#;
println!("{}", quotes);

// 如果字符串中包含 # 号,可以在开头和结尾加多个 # 号,最多加255个,只需保证与字符串中连续 # 号的个数不超过开头和结尾的 # 号的个数即可
let longer_delimiter = r###"A string with "# in it. And even "##!"###;
println!("{}", longer_delimiter);
}

操作 UTF-8 字符串

如果想要以 Unicode 字符的方式遍历字符串,最好的办法是使用 chars 方法,例如:

1
2
3
for c in "中国人".chars() {
println!("{}", c);
}

输出如下:

1
2
3



如果想要获取底层字节数组表现形式,例如:

1
2
3
for b in "中国人".bytes() {
println!("{}", b);
}

输出如下:

1
2
3
4
5
6
7
8
9
228
184
173
229
155
189
228
186
186

如果想获取子串:考虑尝试下这个库:utf8_slice

数组

数组的定义和分类

数组的三要素:

  • 长度固定
  • 元素必须有相同的类型
  • 依次线性排列

数组的分类:

  • 数组:array,速度快但是长度固定,数组属于基本类型(类比&str)
  • 动态数组:Vector,可动态增长,但是有性能损耗(类比String)

从上述分类可以看出,array 和 &str 属于基本类型,而 Vector 和 String 属于非基本类型,实际上,Vector 和 String 属于“集合类型”,较为复杂。

本章节重点放在 array 上。

数组的创建

一般的创建:

1
2
3
4
fn main() {
let a = [1, 2, 3, 4, 5];
let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
}

声明类型的数组:

1
2
3
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

这里的 i32 是元素类型,5 是数组长度。

重复出现某值的数组:

1
2
3
fn main() {
let a = [3; 5];
}

a 数组包含 5 个元素,这些元素的初始化值为 3(这种语法跟数组类型的声明语法其实是保持一致的)

数组元素的访问

1
2
3
4
5
6
fn main() {
let a = [9, 8, 7, 6, 5];

let first = a[0]; // 获取a数组第一个元素
let second = a[1]; // 获取第二个元素
}

索引下标是从 0 开始的

Rust语言不允许越界访问数组元素,一旦发生越界访问将会直接出现 panic。

非基础类型的数组元素

如果数组元素是非基本类型的,下列代码不被允许:

1
2
let array = [String::from("rust is good!"); 8];
println!("{:#?}", array);

原因:重复出现某值的数组的创建实质上是不断 Copy 实现的,而非基础类型不支持 Copy。

这里的 “{:#?}” 是一个功能强大的格式化占位符,它主要用于调试输出,并且会以更美观、易读的格式来展示数据。

正确的写法:

1
2
3
4
5
let array = [String::from("rust is good!"),String::from("rust is good!"),String::from("rust is good!")];
println!("{:#?}", array);
/* 或者 */
let array: [String; 8] = std::array::from_fn(|_i| String::from("rust is good!"));
println!("{:#?}", array);

输出结果为(看看”{:#?}”的功能):

1
2
3
4
5
6
7
8
9
10
[
"rust is good!",
"rust is good!",
"rust is good!",
"rust is good!",
"rust is good!",
"rust is good!",
"rust is good!",
"rust is good!",
]

from_fn 是标准库提供的一个函数,用于通过一个闭包(匿名函数)来动态生成数组的每个元素。它的核心价值在于,它为数组中的每个索引都调用一次这个闭包,从而创建全新的实例。这对于像 String这样没有实现 Copytrait 的类型至关重要,因为它避免了所有权问题。

闭包逻辑:|_i| String::from(“rust is good!”)

  • 这是传递给 from_fn 的闭包
  • |_i|:闭包接收一个参数 i(类型为 usize),代表当前元素的索引(从 0 到 7)。前面的下划线 _ 是一个约定,告诉 Rust 编译器我们有意不使用这个参数,以避免未使用变量的警告(见前文“变量的绑定与解构——未使用的变量”)
  • String::from(“rust is good!”):闭包的主体。它每次被调用时都会执行一次,在堆上创建一个全新的、内容为 “rust is good!”的 String。由于每次调用都会创建一个新的 String,我们最终得到了 8 个独立的、所有权明确的字符串对象。

数组切片

1
2
3
let a: [i32; 5] = [1, 2, 3, 4, 5];
let slice: &[i32] = &a[1..3];
assert_eq!(slice, &[2, 3]);

上面的数组切片 slice 的类型是&[i32],与之对比,数组的类型是[i32;5]

现在可以再看看切片的特点:(ps:可以回到“复合类型——字符串与切片——切片”看看能否更好地理解切片的特点)

  • 切片的长度可以与数组不同,并不是固定的,而是取决于你使用时指定的起始和结束位置
  • 创建切片的代价非常小,因为切片只是针对底层数组的一个引用
  • 切片类型 [T] 拥有不固定的大小,而切片引用类型 &[T] 则具有固定的大小,因为 Rust 很多时候都需要固定大小数据类型,因此 &[T] 更有用,&str 字符串切片也同理

二维数组

1
2
3
4
5
6
7
8
9
10
11
fn main() {
// 编译器自动推导出one的类型
let one = [1, 2, 3];
// 显式类型标注
let two: [u8; 3] = [1, 2, 3];
let blank1 = [0; 3];
let blank2: [u8; 3] = [0; 3];

// arrays是一个二维数组,其中每一个元素都是一个数组,元素类型是[u8; 3]
let arrays: [[u8; 3]; 4] = [one, two, blank1, blank2];
}

数组的注意事项

数组虽然很简单,但是其实还是存在几个要注意的点:

  • 数组类型容易跟数组切片混淆,[T;n] 描述了一个数组的类型,而 [T] 描述了切片的类型, 因为切片是运行期的数据结构,它的长度无法在编译期得知,因此不能用 [T;n] 的形式去描述
  • [u8; 3]和[u8; 4]是不同的类型,数组的长度也是类型的一部分
  • 在实际开发中,使用最多的是数组切片[T],我们往往通过引用的方式去使用&[T],因为后者有固定的类型大小

复合类型(二):元组、结构体和枚举

元组

元组

元组是多种类型组合一起形成的复合类型,长度和顺序都固定。

1
2
3
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
1
2
3
4
5
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("The value of y is: {}", y);
}

这些都是合法的元组创建和绑定。

元组访问与使用

访问某个元组的特定元素,使用.的访问方式:

1
2
3
4
5
6
fn main() {
let x: (i32, f64, u8) = (500, 6.4, 1);
let five_hundred = x.0;
let six_point_four = x.1;
let one = x.2;
}

元组使用示例:

1
2
3
4
5
6
7
8
9
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() 返回字符串的长度
(s, length)
}

结构体

结构体创建

一个结构体由几部分组成:

  • 通过关键字 struct 定义
  • 一个清晰明确的结构体:名称
  • 几个有名字的结构体:字段

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
} // 结构体名称是 User,拥有 4 个字段,且每个字段都有对应的字段名及类型声明
fn main() {
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
}

注意:

  • 初始化实例时,每个字段都需要进行初始化
  • 初始化时的字段顺序不需要和结构体定义时的顺序一致

结构体访问

通过 . 操作符即可访问结构体实例内部的字段值,也可以修改它们

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut user1 = User {
// 注意:只能是结构体可变,字段不能单独可变
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
user1.email = String::from("anotheremail@example.com");
}

简化结构体创建

下面的函数类似一个构建函数,返回了 User 结构体的实例,两个函数等效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 1 */
fn build_user(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}
/* 2 */
fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1,
}
}

结构体更新语法

下面的函数根据已有的结构体实例,创建新的结构体实例,例如根据已有的 user1 实例来构建 user2,则两个语段等效:

1
2
3
4
5
6
7
8
9
10
11
12
/* 1 */
let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("another@example.com"),
sign_in_count: user1.sign_in_count,
};
/* 2 */
let user2 = User {
email: String::from("another@example.com"),
..user1 // 凡是没有显示声明的,统统去 user1 获取
};

结构体更新语法跟赋值语句 = 非常相像,因此在上面代码中,user1 的部分字段所有权被转移到 user2 中:username 字段发生了所有权转移,作为结果,user1 无法再被使用。

(注意,只有username是所有权转移,剩下两个从 user1 传过去的字段是 Copy 过去的)

结构体的内存排列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#[derive(Debug)]
struct File {
name: String,
data: Vec<u8>,
}

fn main() {
let f1 = File {
name: String::from("f1.txt"),
data: Vec::new(),
};

let f1_name = &f1.name;
let f1_length = &f1.data.len();

println!("{:?}", f1);
println!("{} is {} bytes long", f1_name, f1_length);
}

其中的 #[derive(Debug)] 是让编译器为你定义的类型(例如结构体或枚举)自动实现 Debug 的属性(Attribute)。此时如果想输出结构体,可以使用 {:?}(不换行) 或 {:#?}(会换行)的形式打印出来。当然用宏 dbg! 也可以。

上面定义的 File 结构体在内存中的排列如下图所示:

从图中可以清晰地看出 File 结构体两个字段 name 和 data 分别拥有底层两个 [u8] 数组的所有权(String 类型的底层也是 [u8] 数组),通过 ptr 指针指向底层数组的内存地址,这里你可以把 ptr 指针理解为 Rust 中的引用类型。

该图片也侧面印证了:把结构体中具有所有权的字段转移出去后,将无法再访问该字段,但是可以正常访问其它的字段。

元组结构体

1
2
3
4
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);

元组结构体在你希望有一个整体名称,但是又不关心里面字段的名称时将非常有用。

单元结构体

如果你定义一个类型,但是不关心该类型的内容,只关心它的行为时,就可以使用单元结构体:

1
2
3
4
5
6
struct AlwaysEqual;
let subject = AlwaysEqual;
// 我们不关心 AlwaysEqual 的字段数据,只关心它的行为,因此将它声明为单元结构体,然后再为它实现某个特征
impl SomeTrait for AlwaysEqual {

}

枚举

枚举的语法

以扑克牌花色为例:

  • 定义一个枚举类型:

    1
    2
    3
    4
    5
    6
    enum PokerSuit {
    Clubs,
    Spades,
    Diamonds,
    Hearts,
    }

    枚举类型是一个类型(PokerSuit),它会包含所有可能的枚举成员(Clubs、Spades、Diamonds、Hearts),而枚举值是该类型中的具体某个成员的实例。

  • 枚举值:用 :: 操作符来创建 PokerSuit 枚举类型的两个成员实例,

    1
    2
    let heart = PokerSuit::Hearts;
    let diamond = PokerSuit::Diamonds;

    定义一个函数使用它们:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    fn main() {
    let heart = PokerSuit::Hearts;
    let diamond = PokerSuit::Diamonds;
    print_suit(heart);
    print_suit(diamond);
    }
    fn print_suit(card: PokerSuit) {
    // 需要在定义 enum PokerSuit 的上面添加上 #[derive(Debug)],否则会报 card 没有实现 Debug
    println!("{:?}",card);
    }

    print_suit 函数的参数类型是 PokerSuit,因此我们可以把 heart 和 diamond 传给它,虽然 heart 是基于 PokerSuit 下的 Hearts 成员实例化的,但是它是货真价实的 PokerSuit 枚举类型。

  • 其他枚举的用法:

    • 以下两段代码都可以实现扑克牌花色和点数的联动:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      /* 1 */
      enum PokerSuit {
      Clubs,
      Spades,
      Diamonds,
      Hearts,
      }
      struct PokerCard {
      suit: PokerSuit,
      value: u8
      }
      fn main() {
      let c1 = PokerCard {
      suit: PokerSuit::Clubs,
      value: 1,
      };
      let c2 = PokerCard {
      suit: PokerSuit::Diamonds,
      value: 12,
      };
      }

      /* 2 */
      enum PokerCard { // 直接将数据信息关联到枚举成员上
      Clubs(u8),
      Spades(u8),
      Diamonds(u8),
      Hearts(u8),
      }
      fn main() {
      let c1 = PokerCard::Spades(5);
      let c2 = PokerCard::Diamonds(13);
      }
    • 同一枚举类型下的不同成员可以持有不同数据类型:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      enum PokerCard {
      Clubs(u8),
      Spades(u8),
      Diamonds(char),
      Hearts(char),
      }
      fn main() {
      let c1 = PokerCard::Spades(5);
      let c2 = PokerCard::Diamonds('A');
      }

      甚至可以是字符串、数值、结构体甚至另一个枚举:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      enum Message {
      Quit, // 没有任何关联数据
      Move { x: i32, y: i32 }, // 包含一个匿名结构体
      Write(String), // 包含一个 String 字符串
      ChangeColor(i32, i32, i32), // 包含三个 i32
      }
      fn main() {
      let m1 = Message::Quit;
      let m2 = Message::Move{x:1,y:1};
      let m3 = Message::ChangeColor(255,255,0);
      }
  • 由于每个结构体都有自己的类型,因此我们无法在需要同一类型的地方进行使用,例如某个函数它的功能是接受消息并进行发送,那么用枚举的方式,就可以接收不同的消息,但是用结构体,该函数无法接受 4 个不同的结构体作为参数。而且从代码规范角度来看,枚举的实现更简洁,代码内聚性更强,不像结构体的实现,分散在各个地方。

同一化类型

我们有一个这样的问题:

  • 有两种不同的网络流:TcpStreamTlsStream<TcpStream>
  • 我们希望用同一个函数处理这两种流
  • 但它们是不同的类型,Rust的强类型系统不允许直接互换

那么我们可以定义一个枚举类型:

1
2
3
4
enum WebSocket {
Tcp(WebSocket<TcpStream>),
Tls(WebSocket<native_tls::TlsStream<TcpStream>>),
}

类型名称:WebSocket(实际上是WebSocket枚举)
两个变体:

  1. Tcp:包装普通的TCP WebSocket连接
  2. Tls:包装TLS加密的WebSocket连接

利用以下代码处理这两个连接:

1
2
3
4
5
6
7
8
9
fn new (stream: TcpStream) {
let mut s = stream;
if tls {
s = negotiate_tls(stream)
}

// websocket是一个WebSocket<TcpStream>或者WebSocket<native_tls::TlsStream<TcpStream>>类型
websocket = WebSocket::from_raw_socket(s, ......)
}

Option 枚举用于处理空值

Rust 抛弃了 null,而改为使用 Option 枚举变量来表述当前时刻变量的值是缺失的这种结果。

Option 枚举包含两个成员,一个成员表示含有值:Some(T), 另一个表示没有值:None,定义如下:

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

其中 T 是泛型参数,Some(T)表示该枚举成员的数据类型是 T,换句话说,Some 可以包含任何类型的数据。

Option 枚举以及 Some 和 None 都被包含在了 prelude(prelude 属于 Rust 标准库,Rust 会将最常用的类型、函数等提前引入其中,省得我们再手动引入)之中,不需要将其显式引入作用域,甚至不需要 Option:: 前缀就可直接使用 Some 和 None 。

1
2
3
4
let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;

如果使用 None 而不是 Some,需要告诉 Rust Option 是什么类型的,因为编译器只通过 None 值无法推断出 Some 成员保存的值的类型。

Option的优势:

1
2
3
4
5
let x: i8 = 5;           // 一定有值,编译器保证不为空
let y: Option<i8> = Some(5); // 可能有值,可能为空

// 编译错误!不能直接相加
// let sum = x + y; 会报错!!

编译器强制你显式处理 None 情况:

1
2
3
4
5
// match 是模式匹配知识,可以先理解为按顺序检查每个分支的模式,并执行第一个匹配成功的分支对应的代码。
let sum = match y {
Some(value) => x + value, // 有值时才相加
None => x, // 无值时如何处理
};

为了拥有一个可能为空的值,你必须要显式的将其放入对应类型的 Option<T> 中。接着,当使用这个值时,必须明确的处理值为空的情况。只要一个值不是 Option<T> 类型,你就 可以 安全的认定它的值不为空。这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性。


本文章已完结,后续可能会有部分内容的修改和更新,以保证文章的正确性和逻辑严密性。如需要继续学习Rust基础知识请移步《Rust基础入门(二)》