Rust 入坑记

引言

谢天谢地,总算赶在 2018 年结束前完成了 Rust 之旅(当然还是入门级别)。之所以想要入坑这门语言,也是想要在研究 TiKV 时候不要被语言卡主。另外,学习新的语言也是为了开阔视野,学习新的思路~

正如很多前辈所言,Rust 的门槛其实挺高的,所以绝对不适合新手作为入门语言!幸运的是,早些年就接触并学习了 C, Java, C#, Python, Go 等语言,所以对于其中某些类似的概念理解起来就比较轻松(如 trait, struct 和一些面向对象的模式等)。但是,这门语言还是有很多比较新的概念非常与众不同:

  • 所有权和生命周期、生命周期注解
  • TraitTrait Objects
  • 声明宏过程宏
  • 智能指针(我没了解过 C++ 的智能指针,不好对比)
  • 非常强大的模式匹配(花样非常多)
  • 非常可爱的错误处理方式(对比下 Go 呆板处理方式就知道了)

关于语法方面,倒是觉得可以「忍受」,毕竟习惯了也就可以了。但是由于概念很多,包括使用的符号太多(键盘上你能看到的符号基本都用上了),还是需要强大的理解力和记忆力才可以把持住吧,至少像我这样的老人家表示看完一遍根本不行,不管怎么样,先入坑~

入坑时学习的是官方推荐的 Rust Book,对应的中文翻译版本可以参考 Rust 程序设计语言。但是需要注意的是,中文译本有些翻译不太通顺的地方,所以可以对照着去看英文版~

前段时间 Rust 1.31.0 发布了,所以也跟着升级了下。由于书中的例子应该是以 Rust 2015 版本为主的,所以有极少部分示例不能在 Rust 2018 中使用,可以参照提示,使用 cargo fix 一下即可完成迁移~

学习过程中敲了些代码,参见 Learning Rust 仓库,有兴趣也可以去看,代码中添加很多注释,应该可以帮助理解~

下面把学习期间做的笔记做个汇总,方便老人家回顾~

变量

  1. Rust 中变量分为两种:

    1. 不可变的变量(只读)
    2. 可变的变量(mut 声明)
  2. 声明的同名变量具有 shadowing 效果

  3. 可以定义常量,使用关键词 const 定义
  4. 变量与常量的区别:
    1. 变量的赋值可以来自某个函数(运行时确定)
    2. 常量的赋值则需要则只能来自简单的表达式
    3. 作用域不同,常量的作用域全局
    4. 声明方式不同,常量在定义时需要指定类型,无法使用类型推断

所有权

Rust 的所有权机制相对其它语言还是比较独特的。可以看到它是如何借助所有权来管理所有在堆上申请的内存空间的。与其它拥有 GC 或者需要手动管理内存的语言来说,通过所有权管理内存还是挺特别的。

Rust 编译器能够在编译时提供很多安全检查,比如存在数据写入竞争的程序都无法通过编译,这样就大大减少了运行时排查错误的可能。

规则

  1. Rust 中每一个值都有一个被称为其所有者 Owner 的变量
  2. 值有且只能有一个所有者
  3. 当所有者(变量)离开作用域,值会被抛弃(内存回收)

注意点

  1. Rust 中变量的赋值,和传统语言不通,它做的是所有权转移的工作,也就是之前的变量就会被失效了
  2. 如果一个类型有 Copy trait,一个旧的变量在将其赋值给其它变量后仍然可用(如在栈上分配的整数)
  3. Rust 永远不会自动创建数据的「深拷贝」,所以任何自动的复制,可以认为对运行时的性能影响较小

引用和借用

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

Slice 切片

  1. 没有所有权的数据类型,允许引用集合中一段连续的元素序列,而不用引用整个集合
  2. 切片语法(感觉和 Python 或者 Go 类似):

    • start..end 对应的是 [start, end) 这样的区间
    • start..=end 则表示 [start, end] 区间
    • start.. 表示从 start 开始到结尾
    • .. 则表示从头到尾
  3. 字符串字面值就是 Slice &str

结构体

  1. struct 可以用来自定义数据类型,类似面向对象语言中的数据属性
  2. 结构体初始化,每个字段都需要显式赋值,字段初始化顺序可以不用关心
  3. mutimut 是针对整个结构体而言的,不允许结构体部分字段设置为 mut
  4. 变量与字段同名时,可以简化结构体初始化写法
  5. 元组结构体,相当于给元组类型命名了,这样可以有别于其他类型元组,只需要指定类型,不需要命名字段
  6. 类单元结构体(unit-like structs)
    • 没有任何字段的结构体
    • 类似 ()
    • 适用于在某个类型上实现 trait,但不需要存储数据
  7. 生命周期会保证结构体引用的数据有效性和结构体保持一致
  8. 很多基本类型都实现了 Display trait,所以在使用 println!("{}") 时可以正常打印;但 rust 默认不会打印出结构体的展示信息,使用 {:?} 会使用 Debug trait,从而打印出结构体的调试信息。使用 derive 注解,可以使用 trait。
  9. Rust 有自动引用和解引用的功能,会自动为 object 添加 &, &mut*,从而和方法的签名匹配
  10. Rust 中的关联函数(associated functions)是放在 impl 块中,

    • 和结构体本身有较大的相关性,但不是方法
    • 第一个参数也不是 self
    • 依然是函数
    • 类似其它语言中的类的静态方法
  11. 每个结构体都允许拥有多个 impl

  12. 结构体并非创建自定义类型的唯一方法!

枚举与模式匹配

  1. Rust 中的枚举类似于 F#, OCaml 和 Haskell 这种函数式编程语言中的**代数数据类型(algebraic data types)
  2. 枚举还可以存储数据(自定义的那种,类似的概念可以用结构体来表达,但是比较麻烦)
  3. 枚举的每个成员可以处理不同类型和数量的数据(可以将任意类型数据放入枚举成员中)
  4. Rust 枚举实在太强大了,甚至可以实现方法,远不是 Python/Go 中那种简单的枚举可比的
  5. 一个常用的标准库枚举类型 Option,应对的场景:一个值要么有值要么没值(WTF?)
  6. Rust 中没有空值(Null)的功能:空值和非空值
  7. 只要一个值不是 Option<T> 类型,就可以安全的认为它的值不为空
  8. match 是非常强大的控制流运算符,可以将一个值和一系列的模式相比较,并根据相匹配的模式执行相应的代码
  9. Rust 不允许 match 没有匹配处理各种情况,Rust 中的匹配是穷尽的
  10. () 是一个 unit
  11. if let 适合匹配一种情况的时候替代 match,减少样板代码编写,match 的语法糖

模块与代码复用

  1. 模块(module)是一个包含函数或类型定义的命名空间

    1. mod 声明新模块,模块中的代码要么直接位于声明后的大括号中,要么位于另一个文件
    2. 函数、类型、常量和模块默认都是私有的,pub 可以控制其在模块外可见
    3. use 关键字将模块或者模块中的定义引入到作用域中以便于引用它们
  2. Rust 中多个模块可以位于一个文件中,且模块可以嵌套,以构成更符合逻辑的结构

  3. Rust 默认只知道 lib.rs 内容,所以模块的声明或者定义都需要放在该文件
  4. 模块文件系统的规则:

    1. 如果 foo 模块没有子模块,应该将 foo 的声明放在 foo.rs 的文件中
    2. 如果 foo 模块有子模块,应该将 foo 的声明放在 foo/mod.rs 的文件中
  5. Rust 中模块和函数默认均为私有的,必须要同时设置为公有,才能在外部访问模块中的函数

  6. 私有性规则:

    1. 如果一个项是公有的,它能被任何父模块访问
    2. 如果一个项是私有的,它能被直接父级模块及其任何子模块访问
  7. use 只将指定的模块引入作用域,但不会将其子模块也引入

通用集合类型

  1. 集合 是一些列有用的数据结构体,不同于内建数组和元组,这些集合指向的数据是存储在堆上的
  2. 三种广泛使用的集合:

    1. vector:变长,连续存储一系列值(类似 Python 的 list
    2. 字符串:字符集合
    3. 哈希:就是 map
  3. vector 只能存储相同类型的值,其存储的值在内存中彼此相邻排列

  4. vector 的结尾在新增元素时,如果没有足够空间将所有元素依次相邻存放的情况下,可能会要求分配内存,并将老的元素拷贝到新的空间。所以 Rust 是不允许在引用的某个元素时,不可以进行修改
  5. 可以借助 enum 实现在 vector 中存储多种类型元素目标
  6. Rust 的核心语言中只有一种字符串类型:str,即字符串 slice,以借用的形式出现 &str,UTF-8 编码
  7. String 类型是由标准库提供的,可变长、有所有权、UTF-8 编码的字符串类型
  8. Rust 标准库还提供了其他字符串类型:OsString, OsStr, CString, CStr,它们和 String 或者 str 拥有不同的编码或内存表现形式,各自拥有一些使用场景
  9. String 支持 + 运算符、format! 拼接字符串
  10. format!print! 宏类似,并且不会获取参数的所有权
  11. Rust 中,String 或者 str 类型是不支持索引获取的:

    1. String 在底层使用 Vec<u8> 存储字符字节,由于不同的语言使用 UTF-8 编码时,需要的字节是不一样的,为了避免返回无效的(无意义)字节值,Rust 选择不编译这种索引访问的情况
    2. 由于索引操作预期是常数时间 O(1),但 String 中实现索引不能保证这样的性能
  12. 使用字符串 Slice 时需要谨慎,防止 &s[n..m] 操作取出来的是非法的字符(编译时会让通过),当然 Rust 会在运行时直接 panic

  13. 可以对 &String.chars() 遍历获得每个字符,对 &String.bytes() 遍历获得每个字节
  14. HashMap<k, v> 类似其它语言中的字典(map)。其并不在 preclude 中,所以在使用时,需要 use 导入到作用域

错误处理

  1. Rust 中将错误分为两大类:可恢复错误(recoverable)不可恢复错误(unrecoverable)。可恢复的错误通常代表向用户报告错误和重试操作是否合理的情况,如文件不存在。而不可恢复的错误则通常是很严重的 bug
  2. 可恢复的错误是 Result<T, E>,而 panic! 会在遇到不可恢复的错误时终止执行。注意这个和其它语言中的异常机制还是不太一样的
  3. panic 时,默认会开始展开(unwinding),从而回溯栈并清理遇到的每个函数的数据,但这个回溯并清理的过程有很多工作。你可以选择直接终止(abort),让操作系统来清理内存。
  4. 如果希望二进制文件越小越好,可以在 Cargo.toml 中添加下面的配置,这样在 panic 时直接终止执行:

    1
    2
    [profile.release]
    panic = 'abort'
  5. 我们使用 Result 类型来作为返回,用于应付一些常规错误(如文件不存在),定义如下:

    1
    2
    3
    4
    enum Result<T, E> {
    Ok(T),
    Err(E),
    }
  6. 可以使用闭包(closure)来减少因错误处理而嵌套多层 match 的问题

  7. 当然,还可以使用 unwrapexpect 处理错误
  8. 错误传递在 Rust 中很常见,所以有个 ? 操作符语法糖可以替代繁琐的 match 写法。此外,? 还将错误值传递给 from 函数(定义于 From trait` 中),用于将一种错误类型转换为另一种类型
  9. ? 操作符只能被用于返回 Result 类型的函数中
  10. 关于数据校验,可以定义一个新的类型,把校验逻辑收敛到 new 方法,这样所有函数均使用该类型,从而让各个依赖该参数的函数不用编写繁琐的校验逻辑

要不要 panic!

这里给出一些指导性原则,来帮助你在不同场景下,如何更好的处理错误,决定是否需要 panic! 还是传递错误到上层。

  1. 在编写示例、原型代码或者测试时,建议用 unwrap 或者 expect,简单处理错误的情况(直接 panic! 掉),把焦点放在核心功能实现上,然后再处理具体的错误
  2. 遇到非常规可预期的错误状态时,建议 panic!

泛型、Trait 和生命周期

  1. 很多语言都有处理重复概念的工具,Rust 中的工具之一就是泛型(generics)
  2. 泛型可以认为是具体类型的高层抽象,我们可以基于泛型去实现一些通用的关联方法,而不需要知道 concrete type。想想 Go 没有泛型,就知道多惨了
  3. trait 可以与泛型结合,将泛型限制为拥有特定行为的类型,而不是任意类型
  4. 任何字符都可以作为类型参数名,之所以用 T 是 Rust 的习惯用法,T 作为 type 的缩写也很合适
  5. 使用 <> 来定义泛型
  6. Rust 中使用泛型实现的代码和使用具体类型实现的代码,在性能上不会有任何损失。Rust 通过在编译时进行泛型代码单态化(monomorphization)来保证效率。单态化是指通过填充编译时使用的具体类型,将通用代码转为特定代码的过程
  7. trait 以一种抽象的方式定义共享行为,可以使用 trait bounds 指定泛型是任何拥有特定行为的类型
  8. trait 定义是一种将方法签名组合起来的方法,目的是定义一个实现某些目的所必须的行为集合
  9. 注意:只有当 trait 或者要实现 trait 的类型位于 crate 的本地作用域时,才能为该类型实现 trait
  10. trait 中定义的方法是可以有默认实现的,具体类型在实现 trait 时可以选择重载方法,但不支持调用默认方法(Oops)
  11. trait 可以作为参赛(类似 Go 里面的函数接收一个 interface)
  12. 注意下面两种是不同的:

    1
    2
    3
    4
    5
    6
    // 这里允许 item1 和 item2 是不同的类型,只要都实现了 `SomeTrait`
    pub fn notify(item1: impl SomeTrait, item2: impl SomeTrait);

    // 这里就会要求 item1 和 item2 是不同的类型,也就是只有 trait bound 这种写法
    // 才可以做到这样的限制
    pub fn notify<T: SomeTrait>(item1: T, item2: T);
  13. 与 Go 的函数可以返回 interface 类似,Rust 支持返回 trait,但是不同的是,只能返回一种实现了该 trait 的类型。不过依然会有更高级的方法做到,请继续往后学习!

  14. 任何满足特定 trait bound 的类型实现 trait 被称为 blanket implementations,广泛应用于 Rust 标准中。如标准库为任何实现了 Display trait 的类型实现了 ToString 方法

生命周期和引用有效性

  1. 生命周期的主要目标是避免悬垂引用
  2. Rust 编译器中的借用检查器(borrow checker)是用来比较作用域,从而确保所有的借用都是有效的
  3. 生命周期注释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    let r; // ---------+-- 'a
    // |
    { // |
    let x = 5; // -+-- 'b |
    r = &x; // | |
    } // -+ |
    // |
    println!("r: {}", r); // |
    } // ---------+
  4. 生命周期的注解并不会改变任何引用的生命周期长短。当函数指定了泛型生命周期后,可以接受任何生命周期的引用。生命周期注解描述了多个引用生命周期的相互关系,而不影响其生命周期。生命周期注解在借用检查器的帮助下,会指出不遵守协议的入参。

  5. 泛型生命周期 'a 的具体生命周期等同于多个函数参数中生命周期较小的那个

  6. 生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的,一旦它们形成了某种关联,Rust 就有足够的信息来允许内存安全的操作并阻止产生悬垂指针或违反内存安全的行为
  7. Rust 存在生命周期省略规则(lifetime elision rules),这些规则是一系列特定场景,如果符合规则,无需手动指定生命周期
  8. 生命周期省略规则

    1. 函数或方法的参数生命周期叫做输入生命周期
    2. 返回值的生命周期叫做输出生命周期
    3. 规则适用于 fnimpl
    4. 规则一:每一个是引用的生命周期都有其自己的生命周期参数(各个生命周期参数是不同的)
    5. 规则二:如果只有一个输入生命周期参数,则它会被赋予所有输出生命周期参数
    6. 规则三:如果方法有多个生命周期参数(&self&mut self),则 &self 的生命周期被赋予给所有输出生命周期参数
  9. 静态生命周期:

    1. 'static,生命周期存活于整个程序运行期间
    2. 所有字符串字面值都有 'static 生命周期
    3. 如果编译器提示加 'static 生命周期建议时,应该看看是不是自己的实现方式有问题,而不是盲目添加 'static 生命周期

单元测试与集成测试

  1. Edsger W. Dijkstra: Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.
  2. Rust 中的测试函数是用于验证非测试代码是否按照预期方式运行的。测试函数体通常要执行的操作如下:

    1. 设置任何所需的数据或状态
    2. 运行需要测试的代码
    3. 断言其结果是否为期望的
  3. Rust 中的测试是一个带有 test 属性注解的函数。属性是 Rust 代码片段的元数据,为了将一个函数变成测试函数,需要在 fn 之前加上 #[test]cargo test 命令执行时,Rust 会构建一个测试执行程序调用标记了 test 属性的函数,并报告测试通过与否

  4. Rust 会编译在 API 文档中的代码示例,所以也可以进行文档测试 Doc-tests,这样可以保证稳定和代码同步
  5. 每个测试都在一个新线程中运行,当主线程发现测试异常,就将对应测试标记为失败
  6. 测试相关的宏,如果测试失败,会打印出详细的断言失败原因,便于排查:
    • assert!: 是否为 True
    • assert_ne!: 是否不等于
    • assert_eq!: 是否等于
  7. 需要注意的是,assert_ne!assert_eq! 在底层使用了 ==!=,意味着被比较的值必须实现 PartialEqDebug trait。所有的基本类型和大部分的标准类型都实现了这些 trait,对于自定义的结构体和枚举类型,通常可以在结构体上加 #[derive(PartialEq, Debug)] 注解解决
  8. 对于上述三个断言用的宏,任何其它参数都会被传入 format 宏,所以可以借助这个特性自定义错误提示
  9. 使用 #[should_panic] 属性来测试某些预期会 panic 的场景;对于希望检查 panic 的文本中是否含有指定文本的情况,可以加个 expected 参数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "message to be contained")]
    fn should_panic_with_msg() {
    foo_will_panic();
    }
    }
  10. 测试函数可以返回 Result<T, E>,和断言失败直接 panic 不同,这里是通过 Result<T, E> 结果来判断测试结果。此外也不能在这样的测试函数上加 #[should_panic] 属性,否则提示:

    1
    error: functions using `#[should_panic]` must return `()`
  11. cargo test 默认行为是采用并行运行测试的方式,并且会截获测试运行中的输出,阻止显示,从而方便阅读测试结果

  12. 测试常用的控制参数:

    1. 指定并行执行测试的线程数:cargo test -- --test-threads=1
    2. 测试时显示函数打印的信息:cargo test -- --nocapture,由于测试时并行的,为了方便查看输出,所以你可能需要指定使用单线程运行测试
    3. 运行指定一个或多个测试(直接指定测试函数全名或部分字符串用于匹配):cargo test name
    4. 使用 #[ignore] 属性过滤不希望运行的测试
    5. 只运行被忽略的测试:cargo test -- --ignored
  13. 关于 cargo test 参数需要注意的点:

    1. 完整使用方式 cargo test [OPTIONS] [TESTNAME] [-- <args>...]
    2. -- 前的参数传递给 cargo
    3. -- 后的参数传递给二进制测试程序(test binaries),进而传递给 libtest(这个是 Rust 内建的单元测试和 micro-benchmark 框架),可以使用 cargo -- --help 了解详细参数
  14. 单元测试(unit tests) 侧重于小而集中,在隔离环境中一次测试一个模块,或者私有接口:

    1. 单元测试与要测试的代码同在 src 目录下相同的文件中
    2. 规范是在每个文件中创建包含测试函数的 tests 模块,并使用 cfg(test) 注解
  15. 集成测试(integrated tests) 则是在外部测试你的库,只测试公有接口,且每个测试可能会测试多个模块:

    1. 使用单独的 tests 目录和 src 同级别
    2. cargo 会将每个文件当做单独的 crate 编译
    3. tests 目录很特殊,里面的测试不需要 #[cfg(test)] 注解,并且只会在 cargo test 时编译并执行 tests 目录中的文件
    4. cargo test --test <filename> 运行某个特定的集成测试文件中的所有测试
    5. cargo test --test <filename> <TESTNAME> 运行某个集成测试文件中某个 case
    6. tests 目录中的子目录不会被当作单独的 crate 编译或作为一个测试结果部分出现在测试输出中,所以可以将一些公用的帮助函数放到子目录中实现(新增一个模块)

命令行小工具:rgrep

  1. std::env::args 在其任何参数包括无效 Unicode 时会报错。如果需要处理这种情况,可以使用 std::env::args_os,该函数返回一个 OsString,而非 String,但细节处理起来还是比较麻烦
  2. 二进制程序关注点分离基本步骤:

    1. 将程序拆分成 main.rslib.rs,并将程序的逻辑放入 lib.rs
    2. 当命令行解析逻辑比较小时,可以保留在 main.rs
    3. 当命令行解析开始变得复杂时,也同样将其从 main.rs 提取到 lib.rs
    4. 经过这些后,保留在 main 函数的责任应该被限制为:
      1. 使用参数值调用命令行解析逻辑
      2. 设置任何其它的配置
      3. 调用 lib.rsrun 函数
      4. 如果 run 返回错误,则处理这个错误
  3. 错误输出到 stderr,使用 eprint!eprintln!

迭代器和闭包

  1. Rust 的 闭包(closures) 是可以保存进变量或作为参数传递给其它函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算,闭包运行捕获调用者作用域的值
  2. 闭包不要求像 fn 函数那样在参数和返回值上注明类型,因为它不会被暴露给库的用户调用
  3. 闭包通常很短,并且只用于有限的上下文中,且编译器能可靠地推断参数和返回值类型
  4. 每个闭包实例拥有自己独有的匿名类型,即便签名相同,它们的类型依然可以被认为是不同的
  5. 所有的闭包和函数都实现了 trait Fn, FnMutFnOnce 中的一个
  6. 当闭包从环境中捕获一个值,闭包会在闭包体中存储这个值以供使用
  7. 闭包获取环境的三种方式(对应参数获取的方式:所有权、可变借用和不可变借用):
    • FnOnce闭包周围的作用域叫做环境Once 表示闭包不能多次获取相同变量的所有权,也只能被调用一次。由于所有闭包都至少被调用一次,所有都实现了 FnOnce
    • FnMut:获取可变借用值,可以改变环境。对于没有移动被捕获变量的所有权到闭包内的闭包也实现了 FnMut
    • Fn:获取不可变借用。不需要对被捕获的变量进行可变访问的闭包实现了 Fn

1.迭代器模式允许你对一个项的序列进行某些处理,迭代器(iterator) 负责遍历序列中的每一项和决定序何时结束

  1. 迭代器是惰性的,联想下 Python 的生成器、迭代器就好了
  2. 迭代器 trait 中 type ItemSelf::Item 定义了 trait 的关联类型
  3. iter 方法生成一个不可变应用的迭代器;into_iter 则返回拥有所有权的迭代器;如果需要迭代可变引用,则使用 iter_mut
  4. 迭代器 是 Rust 零开销抽象之一,它意味着抽象并不会引入运行时开销
  5. 展开是一种移除循环控制代码的开销,并替换为每个迭代中的重复代码的优化
  6. 开起来 Rust 是比较推荐使用迭代器和闭包的,而且对函数编程风格更是偏爱~

Cargo 和 crates.io

  1. 文档注释使用的是 ///,并且支持 Markdown 注解
  2. 使用 cargo doc 可以生成 HTML 文档到 target/doc 中,使用 cargo doc --open 可以构建后打开文档
  3. 文档中还可以包含 Panics, ErrorsSafety
  4. //!crate 根文件(`src/lib.rs)或模块根文件提供一个概要性的介绍
  5. 使用 pub use 可以重导出,使共有结构不同于私有结构,方便用户使用(其实在 Python 中也有很多库是这么干的)
  6. 工作空间是一系列共享同样的 Cargo.lock 和输出目录的包
  7. 使用 cargo install <name> 可以安装来自 creates.io 的二进制文件

智能指针

  1. 指针 是一个包含内存地址的变量的通用概念。Rust 中最常见的指针是 引用,使用 & 表示,除引用数据没有任何其它特殊功能,没有任何额外开销
  2. 智能指针 是一类数据结构,表现类似指针,但是拥有额外的元数据和功能
  3. Rust 中,普通引用和智能指针的一个额外区别是 引用是一类只借用数据的指针;大部分情况,智能指针拥有它们指向的数据
  4. 智能指针通常使用结构体实现,区别于常规结构体,它实现了 DerefDrop trait:

    1. Deref 运行智能指针结构体实例表现得像引用
    2. Drop 则允许自定义当智能指针离开作用域时运行的逻辑
  5. 标准库中常用的智能指针(你也可以实现自定义的智能指针):

    • Box<T> 用于在堆上分配值
    • Rc<T> 引用计数类型,数据可以拥有多个所有者
    • Ref<T>RefMut<T>,通过 RefCell<T> 访问,在运行时而非编译时执行借用规则的类型

Box

  1. 使用场景:

    1. 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用该类型时
    2. 当有大量数据且希望在确保数据不被拷贝的情况下转移所有权时
    3. 当希望拥有一个值,且只关心它的类型是否实现了特定 trait 而不是其具体类型时
  2. 递归类型 是在编译器无法知道大小的类型,也是 Box<T> 适用的场景

Deref

  1. 实现 Deref trait 允许重载「解引用运算符(dereference operator)*」,这样智能指针就可以被当作常规引用对待
  2. 实现了 deref 方法后,就是向编译器提供了获取任何实现了 Deref trait 类型的值,提供了解引用的能力
  3. let z = Box::new(10) 为例,*z 在底层其实是 *(z.deref()) 操作
  4. 一般所有权依然要保留在智能指针结构体中,不用把所有权转移出去
  5. 解引用强制多态(deref coercions) 是 Rust 表现在函数或方法传参上的一种便利。当所涉及的类型定义了 Deref trait,Rust 会分析这些类型并使用任意多次 Deref::deref 调用获得匹配参数的类型(都发生在编译时),而且也让代码可读性更好:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    fn main() {
    let m = MyBox::new(String::from("world"));

    // 正式因为「解引用强制多态」机制存在,这种传参是支持的,会自动做类型转换
    // 第一次 deref 拿到 &String (针对 Box 类型)
    // 第二次 deref 拿到 &str(针对 &String 类型)
    print_box(&m);

    // 否则,你可能要这样写
    print_box(&(*m));

    // 甚至这样
    print_box(&(*m)[..])
    }

    fn print_box(s: &str) {
    println!("hello, {}", s);
    }
  6. 解引用强制多态生效情况:

    • T: Deref<Target=U> 时,&T -> &U
    • T: DerefMut<Target=U> 时,&mut T -> &mut U
    • T: Deref<Target=U> 时,&mut T -> &U
  7. Rust 可以将可变引用强制变成不可变引用(反过来不行,考虑下为什么?)

Drop

  1. 当值要离开作用域时,可以为该类型实现 Drop trait,这样可以在离开时做一些自定义的清理工作:网络资源、文件或者内存空间释放等
  2. 我们不能直截了当地禁用自动 drop 功能,通常也不需要。Drop trait 存在的意义就是它会被自动处理
  3. 如果需要提前执行 drop,可以使用 std::mem::drop 函数,但不能直接调用值的 drop 方法,因为离开作用域后 Rust 会自动调用值的 drop 方法,从而导致 double free 错误

Rc

  1. 多数情况下,所有权是很清晰的。但有时候某个值却可以有多个所有者,比如图数据结构中,多条边可能指向相同的节点
  2. Rc<T> 会追踪值的引用计数,保证没有引用时,才可以被清空
  3. Rc<T> 用于当我们希望在堆上分配内存供多个部分读取,但又不能在编译时确定哪个部分会在最后结束使用它。
  4. Rc<T> 只能用于单线程场景
  5. Rc::newRc::clone 方法比较常用,Rc::clone 做的是浅拷贝,并且每次调用只会给引用计数加 1
  6. Rc::strong_count 可以查看引用计数(注意还有个 Rc::weak_count,避免循环引用使用)

RefCell 和内部可变模式

  1. 内部可变性(Interior mutability) 是 Rust 中的一个设计模式,允许你在有不可变引用时也能改变数据。使用 unsafe 来模糊 Rust 的常规的可变性和借用规则
  2. RefCell<T> 代表其管理的数据的唯一所有权
  3. 借用规则:

    1. 在任意给定时间,只能拥有一个可变引用或任意数量的不可变引用之一
    2. 引用必须总是有效的
  4. 对于 RefCell<T>,不可变性作用于 运行时;如果在运行时违反借用规则,程序会 panic

  5. RefCell<T> 用于你确信代码遵守借用规则,但编译器这个大笨蛋不能理解和确定的时候
  6. 只能用于单线程场景
  7. 在不可变值内部改变值就是 内部可变性 模式,一个典型的应用场景是 mock 对象
  8. 测试替身(test double) 是一个通用编程概念,它代表一个在测试中替代某个类型的类型
  9. borrow 方法返回 Refborrow_mut 返回 RefMut
  10. Rc<T>RefCell<T> 结合,可以做到值有多个所有者并且可以被修改

循环引用

  1. Rust 的内存安全机制使得内存泄漏更加少见,除非你刻意创建类似循环引用这种数据结构
  2. Rc::clone 增加实例的 strong_count
  3. Rc::downgrade 创建值的 弱引用(weak ref) ,会得到 Weak<T> 类型的智能指针,并会将 weak_count 增加。weak_count 无需计数器为 0 就能使 Rc 实例被清理
  4. 强引用代表如何共享 Rc<T> 实例所有权,弱引用不代表所有权关系
  5. Weak<T> 实例的 upgrade 方法返回 Option<Rc<T>>,需要检查指向的值是否被清理了

并发

线程

  1. 并发编程 代表程序的不同部分相互独立执行;并行编程 代表程序不同部分同时执行
  2. 多线程环境下编程其实还是要小心点,但是不要害怕,需要的时候就大胆用
  3. Rust 标准库使用 1:1 线程模型实现,足够底层
  4. thread::spawn 可以启动新的线程
  5. 如果要在别的线程中使用主线程中的值,需要使用 move 关键字

消息传递

  1. 使用 消息传递 的方式在多线程中通信
  2. Do not communicate by sharing memory; instead, share memory by communicating
  3. Rust 中实现消息传递的主要工具是 通道(channel)

    1. 发送方:transmitter/producer
    2. 接收方:receiver/consumer
    3. 当发送方或接收方任意一个被 drop,都可以认为通道被关闭了(不像 Go 中的显式 close(chan)
  4. mpsc::channel,这里 mpsc 全称是 多生产者,单消费者(multiple producer, single consumer)

  5. recv 是阻塞接收,try_recv 则会立即返回
  6. 可以对 receiver 进行迭代,消费 channel 中的消息(注意,这里的 channel 是带缓冲的)
  7. mpsc::Sender::clone 可以用来克隆生成多个生产者

状态共享:Mutex 与 Arc

  1. 基于 channel 的机制类似于单所有权,一旦值被发送出去,发送方将失去所有权
  2. 共享内存模式则类似多所有权,多个线程可以同时访问相同的内存地址
  3. 互斥信号量(mutex, mutual exclusion),只允许一个线程访问某些数据
  4. Arc<T> 类似于 Rc<T>A 表示 Atomic,即原子的。引用计数在多线程环境下是安全的,但因为需要并发原语加持,所以相对 Rc<T> 会存在性能损耗
  5. Mutex<T> 提供了内部可变性
  6. RefCell<T>Mutex<T> 具有相似的作用
  7. Rc<T>Arc<T> 也是类似

Sync 和 Send

  1. Rust 语言本身对并发知之甚少,前面提到的只是标准库实现方案,但并发方案不受标准库或语言所限
  2. std::markerSendSync trait:

    1. Send 允许在多线程之间转移所有权
    2. 几乎所有的 Rust 类型都是可 Send 的,任何由 Send 类型组成的复合类型也是 Send
    3. Sync 允许多线程访问:实现了 Sync 的类型可以安全地在多个线程中拥有其值的引用
    4. 对于任意类型 T,如果 &TSend 的,那么 T 就是 Sync
    5. 完全由 Sync 的类型组成的类型也是 Sync
  3. 手动实现 SendSync 是不安全的,它们是标记 trait,甚至都不需要实现任何方法,用于加强并发相关性的不可变性

Rust 面向对象特征

面向对象语言特点

  1. Design Patterns: Elements of Resuable Object-Oriented Software 中对于面向对象的定义:

    1
    2
    Object-oriented programs are made up of objects. An object packages both data and the procedures that
    people on that data. The procedures are typically called methods or operations.
  2. 面向对象编程语言的共享特性:对象封装 和多态

  3. Rust 的结构体和 Enum 是符合对象包含数据和行为定义的;另外,通过 pub 关键字可以控制是否对外提供访问权限,对于细节可以私有化,不用对外暴露
  4. 继承:复用代码;子类型可以替代父类型被使用的地方(即多态)。继承存在的一个问题是常常会共享多于需要的代码(有这种风险)
  5. Rust 使用 trait 对象替代继承,实现多态(一种可用于多种类型代码的广泛概念)

Trait Object

  1. trait 对象指向一个实现了指定 trait 的类型实例,通过指定某些指针(&Box<T> 等),接着指定相关 trait
  2. 不能向 trait 对象中增加数据,没有那么通用:具体作用是允许对通用行为进行抽象
  3. 与定义了使用 trait bound 的泛型类型参数不同的结构体不同,trait 对象允许运行时替代多种具体类型,而泛型参数则一次只能替代一个具体类型
  4. 类似动态语言中的 鸭子类型 概念,我们不需要知道组件的具体类型,只需要确定是否实现了指定的 trait 即可执行期望的操作
  5. 使用 trait 对象和 Rust 类型系统进行类似鸭子类型操作的优势是无需在 运行时 检查一个值是否实现了特定的方法或担心调用时因为值没有实现方法而产生错误
  6. trait 对象执行动态分发(编译器会生成在运行时确定调用什么方法的代码),动态优化会导致编译器禁用一些优化
  7. 只有对象安全(object safe)的 trait 才可以组成 trait 对象。如果一个 trait 中的方法有如下属性时,则该 trait 是对象安全的:
    1. 返回值类型不是 Self
    2. 方法没有任何泛型类型参数

状态模式实现

  1. 状态模式 是一个面向对象设计模式:一个值拥有某些内部状态(体现为一系列 状态对象),同时值的行为会随着内部状态而改变。每一个状态对象代表负责自身的行为和当需要改变为另一个状态时的规则状态。持有任何一个这种状态的值不同于状态的行为及何时状态转移毫不知情
  2. 优点:易于扩展和维护
  3. 缺点:
    1. 因为需要实现状态之间的转换,产生相互联系
    2. 存在重复逻辑

模式匹配

  1. 模式可由下面的内容组合而成:

    1. 字面值
    2. 解构的数组、枚举、结构体或元组
    3. 变量
    4. 通配符
    5. 占位符
  2. match 表达时必须是穷尽的(exhaustive)

  3. if let, else if, else if let 可以混合使用的条件表达式
  4. while let 条件循环
  5. for (x, y) in 循环
  6. let 语句
  7. 如果希望忽略元组中的一个或多个值,可以使用 _..
  8. 函数参数也可以是模式
  9. Refutable(可反驳的):能够匹配任何传递的可能值的模式

    1. if let 表达式
    2. while let 表达式
  10. Irrefutable(不可反驳的):对某些可能的值进行匹配会失败的模式

    1. let 语句
    2. 函数参数
    3. for 循环
  11. charnumeric 值是 Rust 可以知道范围是否为空的类型

  12. 匹配守卫(match guard) 是一个在 match 分支后指定的额外的 if 条件,当匹配到该分支时,额外的 if 条件也必须满足才可以。匹配守卫对于表达非常复杂的匹配条件时很有帮助

Rust 高级特性(难)

Unsafe Rust

  1. 可以提供 Super Power:

    1. 解引用裸指针
    2. 调用不安全的函数或方法
    3. 访问或修改可变静态变量
    4. 实现不安全的 trait:当至少有一个方法中包含编译器无法验证的变量时 trait 是不安全的
  2. 之所以需要 Unsafe Rust,是因为:

    1. 静态分析(编译器)本质上是保守的,有时候程序可能是有效的,但被拒绝编译
    2. 底层计算机硬件固有的不安全性
  3. unsafe 并不会关闭 borrow-checker 或者禁用其它 Rust 安全检查

  4. unsafe 不代表代码块真的很危险或者必然导致内存安全问题,真正意图是程序员会确保 unsafe block 中的代码是以有效的方式访问内存
  5. 可以为不安全的代码提供安全的抽象接口
  6. 裸指针(raw pointers) 类型:*const T*mut T,区别于引用和智能指针的地方:

    1. 允许忽略借用规则,可同时拥有不可变和可变的指针,或多个指向相同位置的可变指针
    2. 不保证指向有效的内存
    3. 允许为空
    4. 不能实现任何自动清理功能
  7. 可以在安全的代码中创建裸指针,但不能直接 解引用 裸指针(必须要在 unsafe 环境下)

  8. 裸指针的一个常见的应用场景就是调用 C 接口
  9. extern 关键字可以创建和使用 外部函数接口(Foreign Function Interface, FFI)
  10. 常量与静态变量:
    1. 静态变量的内存地址总是固定的,且生命周期为 'static,使用静态变量值总会访问相同的地址
    2. 常量允许在任何被用到的时候复制其数据
    3. 静态变量是可变的,访问和修改可变静态变量都是 不安全

高级生命周期

  1. 涉及的主题:

    1. 生命周期子类型(lifetime subtyping):确保某个生命周期长于另一个生命周期的方式
    2. 生命周期绑定(lifetime bounds):用于指定泛型引用的生命周期
    3. trait 对象生命周期(trait object lifetimes):如何推断,何时需要绑定
    4. 匿名生命周期:省略写法
  2. trait bound 可以帮助 Rust 验证泛型的引用不会存在的比其引用的数据更久:如 T: 'a'

  3. '_ 匿名生命周期,简化写法

高级 trait

  1. 关联类型(associated types) 是一个将类型占位符与 trait 相关联的方式,这样 trait 的方法签名中就可以使用这些占位符
  2. 为何要使用关联类型而非泛型呢:

    1. 泛型的话,每次实现 trait 都需要注明类型;支持多次实现 trait
    2. 关联类型,则只能针对一种类型实现一次,且无需注明类型
  3. 当使用泛型参数时,可以为泛型指定默认的具体类型。为泛型指定默认类型的语法是在声明泛型类型时使用:<PlaceholderType=ConcreteType>

  4. std::ops 中列出的运算符和 trait 可以重载
  5. RHS 全称是 right hand side
  6. RHS=Self:默认类型参数(default parameters)
  7. Rust 不会阻止你为一个类型实现某个方法,再去实现一个或多个具有同名的 trait 方法。但是在调用时,默认会调用直接实现在类型上的方法。要想调用指定 trait 的某个方法,可以使用类似 TraitName::method(&obj) 这种写法
  8. 完全限定语法消除同名关联函数歧义示例:<Dog as Animal>::baby_name(),完整语法:

    1
    <Type as Trait>::function(receiver_if_method, next_arg, ...);
  9. 可以选择在任何函数或方法调用处使用完全限定语法

高级类型

  1. 必须将动态大小类型的值置于某种指针之后
  2. 动态大小类型 (DST,dynamically sized types),只有在运行时才可以知道大小的类型
  3. Sized trait,决定一个类型的大小是否在编译时可知,自动为编译器在编译时就知道大小的类型实现
  4. Rust 隐式地位每个泛型函数增加了 Sized bound

    1
    2
    // 默认添加了 `Sized` 绑定
    fn generic<T: Sized> (t: T) {}
  5. ?Sized 可以放宽限制,但是需要使用 &T,保证参数是类型是 Sized,但 T 可以不用是 Sized 的:

    1
    fn generic<T: ?Sized> (t: &T) {}

高级函数和闭包

  1. 除了可以向函数传递闭包外,还可以传递常规函数。通过函数指针允许使用函数作为另一个函数的参数
  2. fn 叫做 函数指针(function pointer)
  3. 函数指针实现了三个闭包 traitFn, FnMutFnOnce),总是可以在调用期望闭包的函数时传递函数指针作为参数

  1. 宏(Macro) 是 Rust 中一些列的功能:

    1. 声明(Declarative)宏:使用 macro_rules!
    2. 过程(Procedural) 宏
      1. 自定义 #[derive]
      2. 类属性(Attribute)宏
      3. 类函数宏
  2. 宏是一种为其它代码而写代码的方式,即所谓的 **元编程(meta programming)

  3. 元编程对于减少大量编写和维护的代码非常有用
  4. 宏只接受一个可变参数
  5. 宏可以在编译器翻译代码前展开
  6. 宏比普通函数更加难以阅读、理解和维护
  7. 在调用宏 之前 必须定义并引入到作用域,而函数则可以在任何地方定义和调用
  8. 宏模式所匹配的是 Rust 代码结构而非值,所以和 match 模式匹配不同
  9. 过程宏更像函数(一种过程类型),接收 Rust 代码作为输入,在这些代码上进行操作,再产生另一些代码作为输出,而非像声明式宏那样匹配模式,再用另外一部分代码替换当前代码
  10. #[derive(xx)] 属性可以生成代码
  11. 类属性宏则允许创建新的属性 #[some_attribute]
  12. derive 只能用于结构体和枚举;属性则可以用于其它的项,如函数
  13. 类函数宏定义看起来像函数调用的宏,如 let sql = sql!(SELECT * FROM users)

参考

  1. The Rust Book
  2. Rust 程序设计语言
  3. self, Self in Rust
  4. Paradigms of Rust for the Go developer
0%