【Fitz】Rust 复合类型
说明:本文主要是Rust语言圣经相关章节的学习笔记,大部分与其内容相同,欢迎阅读原文。
复合类型是由其他类型组合而成的,最典型的就是结构体 struct 和枚举 enum。
原型设计:有的方法只提供 API 接口,但是不提供具体实现。下面的学习比较类似原型设计。为了使得编译器不对声明后未使用的变量提示警告,可以引入 #![allow(unused_variables)] 属性标记,该标记会告诉编译器忽略未使用的变量而不抛出警告。常见的编译器属性可以 官网的Rust参考手册 或 Rust参考手册中文版 中查看。
unimplemented!() 标记通常意味着我们期望快速完成主要代码,回头再通过搜索这些标记来完成次要代码,类似的标记还有 todo!(),当代码执行到这种未实现的地方时,程序会直接报错。
字符串
切片(slice)
切片不是 Rust 独有的概念,它允许你引用集合中部分连续的元素序列,而不是引用整个集合。
对字符串而言,切片就是对 String 类型中某一部分的引用,如 let substr = &s[0..5];,substr 并没有引用整个 String s,而是引用了 s 的一部分内容,通过 [0..5] 的方式来指定。
这就是创建切片的语法,使用方括号包括的一个序列:[开始索引..终止索引](左闭右开)。在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引 - 开始索引 的方式计算的。想要截取完整的 String 切片可以使用 let str = &s[..];,在使用 range 序列语法时,省略开始索引表示从下标0开始,省略终止索引表示索引到最后一个元素。字符串切片的类型标识是 &str。
对字符串使用切片语法时需要格外小心,切片的索引必须落在字符之间的边界位置,也就是 UTF-8 字符的边界。假如字符串由汉字组成,每个汉字占用三个字节,当切片索引没在字符的边界处时,程序会直接崩溃退出,如果在字符边界处时可以通过编译。
某些数据结构的方法参数中包含自身的可变引用 &mut self,这种操作有可能导致代码违反引用规则而报错。
其它切片
切片是对集合的部分引用,不仅仅字符串有切片,其他集合类型也有,如对于数组 let a = [1, 2, 3, 4, 5];,切片 let slice = &a[1..3]; 的类型就是 &[i32]。数组切片和字符串切片的工作方式相同,如持有一个引用指向原始数字的某个元素和长度。
字符串字面量是切片
字符串字面量的类型是字符串切片 &str。使用 let s: &str = "Hello world"; 也可以声明一个切片,该切片指向了程序可执行文件中的某个点。
什么是字符串?
字符串是由字符组成的连续集合。Rust 中的字符是 Unicode 类型,因此每个字符占据 4 个字节的内存空间,但是在字符串中不一样,字符串是 UTF-8 编码,也就是字符串中的字符所占的字节数是变化的(1-4),这样有助于大幅降低字符串所占用的内存空间。
Rust 在语言级别,只有一种字符串类型 str,通常以引用类型出现 &str,即字符串切片。同时在标准库中,还有多种不同用途的字符串类型,使用最广的即是 String 类型。
str 类型是硬编码进可执行文件中,无法修改,但 String 是一个可增长、可改变且具有所有权的 UTF-8 编码字符串。String 类型和 &str 字符串切片类型都是 UTF-8 编码。
除了 String 类型的字符串,Rust 的标准库还提供了其他类型的字符串,例如 OsString, OsStr, CsString 和CsStr 等,其中名字以 String 或者 Str 结尾分别对应具有所有权和被借用的变量。
操作字符串
下面代码示例汇总了 String 变量相关的操作方式:
// 创建一个空String
let mut s = String::new();
// 将&str类型的"hello,world"添加到s中
s.push_str("hello,world");
// 将字符'!'推入s中
s.push('!');
// 最后s的内容是"hello,world!"
assert_eq!(s,"hello,world!");
// 从现有的&str切片创建String类型
let mut s = "hello,world".to_string();
// 将字符'!'推入s中
s.push('!');
// 最后s的内容是"hello,world!"
assert_eq!(s,"hello,world!");
// 从现有的&str切片创建String类型
// String与&str都是UTF-8编码,因此支持中文
let mut s = String::from("你好,世界");
// 将字符'!'推入s中
s.push('!');
// 最后s的内容是"你好,世界!"
assert_eq!(s,"你好,世界!");
let s1 = String::from("hello,");
let s2 = String::from("world!");
// 在下句中,s1的所有权被转移走了,因此后面不能再使用s1
let s3 = s1 + &s2;
assert_eq!(s3,"hello,world!");
// 下面的语句如果去掉注释,就会报错
// println!("{}",s1);
代码中使用 + 来对字符串进行相加操作,之所以使用 s1 + &s2 的形式,是因为 + 使用了 add 方法,该方法的定义类似:fn add(self, s: &str) -> String {,该方法将 &str 类型的参数添加到 String 类型的参数上然后返回一个新的 String 参数。
String 与 &str 的转换
取 String 类型的引用即可将其转为 &str 类型,如 &s、&s[..] 或 s.as_str() 都可以将 String 类型转换为 &str 类型。这种灵活用法是因为 deref 隐式强制转换。
字符串索引
Rust 中不能直接通过索引的方式访问字符串的字符或子串。
深入字符串内部
字符串的底层的数据存储格式实际上是 [u8],即一个字节数组。如 let s = String::from("Hello"); 中 s 的长度是4个字节,因为每个字母在 UTF-8 编码中仅占用1个字节。而 let s = String::from("中国人"); 中 s 的长度就是9个字节,因为大部分常用汉字在 UTF-8 中的长度是3个字节。
字符串的不同表现形式
Rust 提供了不同的字符串展现方式,这样程序可以挑选自己想要的方式去使用,而无需去管字符串从人类语言角度看长什么样,可以在String in std::string中文标准库中了解 String 类型的各种方法。
另一个导致 Rust 不允许索引字符串的原因是:对于索引操作,我们总希望其时间复杂度为O(1),但对于 String 来说,可能需要从0开始遍历来定位合法字符,因此无法保证O(1)的时间复杂度。
字符串切片
字符串切片是非常危险的操作,在通过索引区间来访问字符串时,需要格外的小心,因为一不注意就可能导致程序的崩溃。
操作 UTF-8 字符串
字符
通过 chars 方法,可以遍历访问字符串的 Unicode 字符,字符串类型和字符串切片类型都可以使用该方法,该方法返回一个 Unicode 字符的迭代器。
字节
通过 bytes 方法,可以遍历访问字符串的底层字节数组,同样字符串类型和字符串切片类型都可以使用该方法,该方法返回一个字节的迭代器。
for c in "中国人".chars() {
println!("{}", c);
}
for b in "中国人".bytes() {
println!("{}", b);
}
获取子串
想要准确从 UTF-8 字符串中获取子串使用标准库是无法做到的,可以在 crates.io 中搜索 utf8 来寻找实现了想要功能的库。
字符串剖析
字符串字面值是被直接硬编码进可执行文件中,因此字符串字面值快速且高效,这主要得益于字符串字面值的不可变性。
对于 String 类型,需要在堆上分配一块内存空间来存放内容,使用时申请,用完将内存释放。
为了实现更好的性能,Rust 变量在离开作用域后会自动释放其占用的内存,即在作用域结尾的 } 处自动调用 drop 方法。
元组
元组是由多种类型组合到一起形成的,因此是复合类型,元组的长度是固定的,元组中元素的顺序也是固定的。
可以通过 let tup: (i32, f64, u8) = (500, 1.0, 1); 来创建一个元组,将元组值 (500, 1.0, 1) 绑定给了变量 tup。元组是用括号将多个类型组合到一起。
使用模式匹配或 . 操作符可以获取元组中的值。通过模式匹配可以用同样的形式把一个复杂对象中的值匹配出来,即解构。
let tup: (i32, f64, u8) = (500, 1.0, 1);
let (x, y, z) = tup; //模式匹配,元组中对应的值会绑定到变量x、y、z上
let five_hundred = tup.0; //使用 . 操作符访问元组的值
元组在函数返回值场景中很常见,通过元组可以使函数返回多个值。
结构体
结构体和元组有点像:都是由多种类型组合而成。但是结构体可以为内部的每个字段起一个富有含义的名称,也无需依赖字段的顺序来访问和解析它们。
结构体语法
定义结构体
一个结构体有几部分组成:
- 通过关键字
struct定义 - 一个清晰明确的结构体
名称 - 几个有名字的结构体
字段
例如:
struct User {
active: bool,
username: String,
number: u64,
email: String,
}
创建结构体实例
let user1 = User {
number: 25,
active: true,
username: String::from("Fitz"),
email: String::from("xxx@xx.com"),
};
注意:初始化实例时,每个字段都需要进行初始化,初始化时字段顺序不需要和结构体定义时的顺序一致,let 语句最后加分号。
访问结构体字段
通过 . 操作符即可访问结构体实例内部的字段值,也可以修改。但修改前必须将结构体实例声明为可变的。Rust 不支持将某个结构体某个字段标记为可变。
简化结构体创建
可以为结构体定义构建函数,构建函数接收传入参数,然后创建一个结构体实例并返回。当函数参数和结构体字段同名时,可以直接使用缩略的方式进行初始化。
fn bulid_user(username: String, number: u64) -> User {
User {
username,
active: false,
number,
email: String::from("xxx@xx.com"),
}
}
结构体更新语法
实际场景中,根据已有的结构体实例创建新的结构体实例是比较常见的。如根据已有的 user1 实例创建 user2 实例。当 user2 和 user1 只有部分字段不同需要更新时,可以使用 .. 对字段赋值进行省略:
let user2 = User {
username: String::from("name2"),
..user1
};
.. 语法表明凡是没有显示声明的字段,全部从 user1 中自动获取,注意 ..user1 必须在结构体的尾部使用。
在上面代码中,user1 的部分字段所有权其实是被转移到了 user2 中,即 email 字段发生了所有权转移,因此 user1 无法再被使用。但 user1 内部的其他字段仍能被继续使用。
结构体的内存排列
struct File {
name: String,
data: Vec<u8>,
}
代码中定义的 File 结构体,其两个字段分别有字段名称、字段数据类型以及内存表示,每个字段的内存表示中分别有 ptr 指针指向存储数据的内存地址。
元组结构体(Tuple Struct)
结构体必须有名称,但是结构体的字段可以没有名称,这种结构体长得很像元组,因此被称为元组结构体,例如:struct Point(i32, i32, i32);。元组结构体在你希望有一个整体名称,但又不关心里面字段的名称时非常有用。
单元结构体(Unit-like Struct)
单元结构体跟单元类型 () 很像,没有任何字段和属性。当定义一个类型,但是不关心该类型的内容,只关心它的行为时,就可以使用 单元结构体,然后为它实现某个 trait。
结构体数据的所有权
前面的 User 定义中,使用了自身拥有所有权的 String 类型而不是字符串切片类型,因为我们想要结构体拥有它的所有数据。
也可以让结构体从其他对象借用数据,就需要使用生命周期,来确保结构体的作用范围要比其所借用的数据的作用范围小。
使用 #[derive(Debug)] 来打印结构体的信息
如果我们使用 {} 来进行格式化输出,对应的类型就必须实现 Display trait,基本类型都默认实现了该 trait。
结构体较为复杂,这时 Rust 不希望猜测我们想要显示的是什么,而是把选择权交给我们自己实现:如果要用 {} 的方式打印结构体,就自己实现 Display trait。
如果使用 {:?} 来进行格式化输出,编译期会提醒需要实现 Debug trait,这时有两种选择:手动实现 or 使用 derive 派生实现。后者简单得多,但是也有限制,具体见派生特征trait。
当结构体较大时,可能希望有更好的输出表现,可以使用 {:#?} 来代替 {:?}。
还有一个简单的输出 debug 信息的方法,那就是使用 dbg! 宏,它会拿走表达式的所有权,然后打印出相应的文件名、行号等 debug 信息,当然还有我们需要的表达式的求值结果。除此之外,它最终还会把表达式值的所有权返回!
dbg!输出到标准错误输出stderr,而println!输出到标准输出stdout
此时会给出我们想要的几乎所有 debug 信息:代码所在文件名、行号、表达式以及表达式的值。
枚举
枚举(enum 或 enumeration)允许通过列举可能的成员来定义一个枚举类型。如果之前没有在其他语言中使用过枚举,那么可能需要花费一些时间来理解概念,而一旦上手,就会发现枚举的强大。枚举值只可能是其中某一个成员,当函数处理枚举值时,可以把它们当作相同的类型进行传参。枚举类型是一个类型,它会包含所有可能的枚举成员,而枚举值是该类型中的具体某个成员的实例。
示例代码中定义了一个枚举类型:
enum PokerSuit {
Clubs,
Spades,
Diamonds,
Hearts,
}
枚举值
创建 PokerSuit 枚举类型的两个成员实例:
let heart = PokerSuit::Hearts;
let diamond = PokerSuit::Diamonds;
通过 :: 操作符来访问 PokerSuit 下的具体成员。定义的两个变量是基于 PokerSuit 枚举类型实例化得到的。我们可以直接将数据信息关联到枚举成员上,同一枚举类型下不同成员可以持有不同的数据类型,并且持有的数据类型可以是一个更为复杂的结构体。例如:
enum PokerCard {
Clubs(u8),
Spades(u8),
Diamonds(char),
Hearts(String),
}
任何类型的数据都可以放入枚举成员中:例如数值、字符串、结构体甚至另一个枚举。
代码中的每一个枚举值确实都可以使用 Struct 实现,但是每个结构体都有自己的类型,因此无法在需要同一个类型的地方使用,而使用枚举类型就可以实现。从代码规范角度来看,枚举的实现更简洁且代码内聚性更强。
Option 枚举用于处理空值
其他编程语言中,往往有 null 关键字,该关键字用于表明一个变量当前的值为空,即不存在值。当对 null 值进行操作时,就会直接抛出 null 异常,导致程序崩溃。Rust 吸取了众多教训,决定抛弃 null,改为使用 Option 枚举类型来表述这种结果。
Option 枚举包含两个成员,一个成员表示含有值:Some(T),另一个表示没有值:None,定义:
enum Option<T> {
Some(T),
None,
}
其中 T 是泛型参数,Some(T) 表示该枚举成员的数据类型是 T,即 Some 可以包含任何类型的数据。
Option<T> 枚举包含在 prelude 中,在使用时不需要显式引入作用域。其成员 Some 和 None 也是如此,无需使用 Option:: 前缀就可以直接使用 Some 和 None。当声明 None 值时,需要手动告诉编译器 Option<T> 是什么类型的,因为编译器无法推断出其类型。
在对 Option<T> 进行 T 的运算之前必须将其转换为 T,这通常能帮助我们捕获空值最常见的问题之一:期望某值不为空但实际上为空的情况。
不再担心会错误的使用一个空值,会让你对代码更加有信心。为了拥有一个可能为空的值,你必须要显式的将其放入对应类型的 Option<T> 中。接着,当使用这个值时,必须明确的处理值为空的情况。只要一个值不是 Option<T> 类型,你就 可以 安全的认定它的值不为空。这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性。
那么当有一个 Option<T> 的值时,如何从 Some 成员中取出 T 的值来使用它呢?Option<T> 枚举拥有大量用于各种情况的方法,可以通过查看文档了解。熟悉 Option<T> 的方法将对 Rust 的学习使用有很大帮助。
match 表达式就是一个可以处理枚举的控制流结构:在枚举值不同时执行不同的操作,例如:
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
数组
Rust 中最常用的数组有两种:一是速度快很快但长度固定的 array,另一种是可动态增长但有性能损耗的 Vector,这里将 array 称为数组,将 Vector 称为动态数组。这里要学习的是数组 array。
根据数组的定义:多个类型相同的元素以此组合在一起,可以得出数组三要素:
- 长度固定
- 元素必须有相同的类型
- 依次线性排列
创建数组
通过类似 let a = [1, 2, 3, 4, 5]; 的语法可以创建数组。Rust 中的数组存储在栈上,性能非常优秀。
也可以通过 let a: [i32; 5] = [1, 2, 3, 4, 5]; 的语句来为数组声明类型,数据类型通过方括号语法声明,i32 是元素类型,分号后数字 5 是数组长度。
使用 let a = [3; 5]; 来初始化一个某个值重复出现 N 次的数组,这种语法跟数组类型的声明语法保持一致:[3; 5] 和 [类型; 长度]。
访问数组元素
数组是连续存放元素的,因此可以通过索引的方式来访问存放其中的元素。数组的下标从0开始。
越界访问
当数组访问越界时,会访问数组中不存在的元素,导致 Rust 运行时错误。程序会因此退出并显示错误消息。当尝试使用索引访问元素时,Rust 会在运行时检查使用的索引值是否小于数组长度,如果索引超出长度程序就会 panic。但是 Rust 只能在运行时检查是否越界。
数组切片
切片 允许引用集合中的部分连续片段,而不是整个集合,对于数组也成立。类似于 let slice: &[i32] = &a[1..]; 的数组切片允许我们引用数组的一部分,其中 slice 的类型是 &[i32],而数组类型是 [i32; n],从编译期的类型推导也可以看出。
切片的特点:
- 切片的长度可以与数组不同,并不是固定的,而是取决于你使用时指定的起始和结束位置
- 创建切片的代价非常小,因为切片只是针对底层数组的一个引用
- 切片类型 [T] 拥有不固定的大小,而切片引用类型 &[T] 则具有固定的大小,因为 Rust 很多时候都需要固定大小数据类型,因此 &[T] 更有用,
&str字符串切片也同理
几个需要注意的点:
- 数组类型容易跟数组切片混淆 ,[T; n] 描述了一个数组的类型,而 [T] 描述了切片的类型, 因为切片是运行期的数据结构,它的长度无法在编译器得知,因此不能用 [T; n] 的形式去描述;
[u8; 3]和[u8; 4]是不同的类型,数组的长度也是类型的一部分- 在实际开发中,使用最多的是数组切片 [T] ,我们往往通过引用的方式去使用
&[T],因为后者有固定的类型大小

浙公网安备 33010602011771号