Skip to main content

Rust程序设计知识点

鱼雪

Rust 是一种由 Mozilla 领导开发的系统编程语言。它设计的目标是提供内存安全、并发性和高性能。Rust 具有以下一些主要特性:

  • 内存安全
  • 所有权系统
  • 并发性
  • 零成本抽象
  • 模式匹配
  • 没有运行时开销
  • 活跃的社区

基本数据类型

  • 很大程度上,Rust是围绕其类型设计的。
  • Rust对高性能代码的支持,源自它能让开发者原则最适合当前场景的数据表示法,在简单性与成本之间进行合理的权衡。
  • Rust的内存和线程安全保障也依赖于Rust类型系统的健全性。
  • Rust的灵活性则源自于其泛型类型和特型。

Rust有以下两个特性

  • 基于已明确写出的类型,Rust的类型推断会帮你推断出剩下的大部分类型
  • 函数可以是泛型的:单个函数就可以处理许多不同类型的值

固定宽度的数值类型

  1. Rust类型系统的根基是一组固定宽度的数值类型,选用这些类型是为了匹配几乎所有现代处理器都已直接在硬件中实现的类型。 固定宽度的数值类型可能会溢出或丢失精度。
  2. char既不是u8,也不是u32(尽管它确实有32位长)
  3. usize类型和isize类型类似于CC++中的size_tptrdiff_t。它们的精度与目标机器上地址空间的大小保持一致, 即在32位架构上是32位长,在64位架构上则是64位长。
  4. Rust要求数组索引是usize值。
  5. 可以使用as运算符将一种整型转换成另一种整型。
  6. Rust几乎不会执行任何隐式的数值转换,随时可以使用as运算符写出显式转换。

布尔类型

  • Rust非常严格
    • ifwhile这样的控制结构要求它们的条件必须是bool表达式
    • 短路逻辑运算符&&||也是如此

字符

  1. Rust的字符类型char会以32位值表示单个Unicode字符
  2. Rust会对单独的字符使用char类型,但对字符串文本流使用UTF-8编码
  3. u8是唯一能通过as运算符转换为char的类型

指针类型

  1. Rust有多种表示内存地址的类型,这是Rust和大多数具有垃圾回收功能的语言之间一个重大差异。
  2. Rust语言旨在帮你将内存分配保持在最低限度。

引用

  1. 将引用视为Rust中的基本指针类型
  2. Rust引用有两种形式
    • &T: 一个不可变的共享引用
    • &mut T: 一个可变的、独占的引用
  3. Rust利用共享引用和可变引用之间的"二选一"机制来强制执行"单个写入者或多个读取者"规则
  4. 你独占读写一个值,或者让任意数量的读取者共享,但二者只能选择其一
  5. 这种由编译期检查强制执行的"二选一"规则是Rust安全保障的核心

数组、向量和切片

  1. Rust用3种类型来表示内存中的值序列

    • 类型[T;N]表示N个值的数组,每个值的类型为TN在编译期已经确定,不能追加新元素或缩小数组
    • 类型Vec<T>可称为T的向量,它是一个动态分配且可增长的T类型的值序列
    • 类型&[T]&mut [T]可称为T的共享切片和T的可变切片
  2. 在数组上看到的那些使用方法都是作为切片而非数组的方法提供的

    • 包括:遍历、搜索、排序、填充、过滤等
    • Rust在搜索各种方法时会隐式地将对数组的引用转换为切片,因此可以直接在数组上调用任何切片的方法
    • sort方法实际上是在切片上定义的
    • len方法也是切片的方法之一
  3. 向量

    • 使用collect时,通常要指定类型,因为它可以构建出不同种类的集合,而不仅仅是向量
    • 与数组一样,可以对向量使用切片的方法
      • reverse方法实际上是在切片上定义的,此调用会隐式地从向量中借用一个&mut [&str]切片并在其上调用reverse
    • Vec是Rust的基本数据类型,它几乎可以用在任何需要动态大小的列表的地方
    • 如果事先知道向量所需的元素数量,就可以调用Vec::with_capacity而不是Vec::new来创建一个向量,它的缓冲区足够大,可以从一开始就容纳所有元素
    • 许多库函数会寻求使用Vec::with_capacity而非Vec::new的机会
  4. 切片

    • 切片数组向量中的一个区域
    • 对切片的引用是一个胖指针
      • 一个双字值
      • 包括指向切片第一个元素的指针切片中元素的数量

字符串类型

  1. 字符串字面量
    • 原始字符串用小写字母r进行标记,原始字符串不识别任何转义序列
    • 可以再原始字符串的开头和结尾添加#做标记
  2. 字符串
    • 带有b前缀的字符串字面量都是字节串,这样的字节串是u8值(字节)的切片而不是Unicode文本

      let method = b"POST";  // method类型是&[u8; 4]
      assert_eq!(method, &[b'P', b'O', b'S', b'T']);
    • 字节串不能包含任意Unicode字符,它们只能使用ASCII\xHH转义序列

  3. 内存中的字符串
    • Rust字符串是Unicode字符序列,但并不以char数组的形式存储在内存中,而是用了UTF-8形式(可变宽度编码)
    • String&str.len()方法会返回其长度
    • &mut str类型确实存在,但它没什么用
    • &mut str上唯一可用的操作是make_ascii_uppercasemake_ascii_lowercase,根据定义,它们会就地修改文本并且只影响单字节字符
  4. String
    • &str非常像&[T],是指向某些数据的胖指针
    • String则类似于Vec<T>
    • String有几种方法
      • .to_string()方法会将&str转换为String,这会复制此字符串
      • .to_owned()方法会做同样的事情,也会以同样的方式使用
      • format!()宏的工作方式与println!()类似,但它会返回一个新的String
      • .concat().json(sep),字符串数组、切片、向量都有这两个方法,它们会从许多字符串中形成一个新的String
  5. 其它类似字符串的类型
    • 对于Unicode文本,坚持使用String&str
    • 当使用文件名,请改用std::path::PathBuf&Path
    • 当处理根本不是UTF-8编码的二进制数据时,请使用Vec<u8>&[u8]
    • 当使用操作系统提供的原生形式环境变量名命令行参数时,请使用OsString&OsStr
    • 当和使用null结尾字符串的C语言库进行互操作时,请使用std::ffi::CString&CStr

所有权与移动

  1. 谈及内存管理,我们希望编程语言具备两个特点

    • 能在我们选定的时机及时释放,这使得我们能控制程序的内存消耗
    • 对象被释放后,绝不希望继续使用使用指向它的指针,这是未定义行为,会导致崩溃和安全漏洞
  2. 几乎所有主流编程语言都只能在两个阵营"二选一"

    • 安全优先:通过垃圾回收机制,在所有指向对象的可达指针都消失后,自动释放对象
    • 控制优先:让开发者自己负责释放内存,程序的内存消耗完全掌握在开发者受众。
  3. Rust通过限制程序使用指针的方式出人意料的打破了这种困局

    • 在运行期,指针仅仅是内存中的地址,和在C与C++中一样
    • 而不一样的是,Rust编译器已然证明你的代码在安全地使用它们
  4. 所有权

    • 每个值都有唯一的拥有者
    • 拥有者及其拥有的哪些值形成了一棵
      • 值的拥有者是值的父节点
      • 值拥有的值是子节点
      • 每棵树的总根都是一个变量
      • 当该变量超出作用域时,整棵树都将随之消失
    • Rust的单一拥有者规则将禁止任何可能让它们排列得比树结构更复杂的可能性
    • Rust程序中的每一个值都是某棵树的成员,树根是某个变量
  5. 为了处理某些场景,Rust从几个方面扩展了这种简单的思想

    • 可以将值从一个拥有者转移给另一个拥有者,
      • 允许你构建、重新排列和拆除树形结构
    • 像整型、浮点数和字符这样的非常简单的类型,不受所有权规则的约束
      • 这些称为Copy类型
    • 标准库提供了引用计数指针类型RcArc,它们允许在某些限制下有多个拥有者
    • 可以对值进行"借用"(borrow),以获得值的引用。
      • 这种引用是非拥有型指针,有着受限的生命周期
  6. 移动

    • 对大多数类型来说,像为变量复制、将其传给函数或从函数返回的操作都不会复制值,而是移动值。
    • 源会把值的所有权转移给目标并变回未初始化状态,改由目标变量来控制值的生命周期
      • 将参数传给函数会将所有权转移给函数的参数
      • 从函数返回一个值会将所有权转给调用者
      • 构建元组会将值转给元组
    • 值移动涉及字符串、向量和其他占用大量内存且复制成本高昂的类型
    • 移动让这些类型的所有权清晰且赋值开销极低
  7. Copy类型:关于移动的例外情况

    • 对于Copy类型的值进行赋值会复制这个值,而不会移动它
    • 赋值的源仍会保持已初始化和可用状态,并且具有与之前相同的值
    • 标准的Copy类型包括:
      • 整数类型、浮点类型、char类型、bool类型,以及其他类型
    • 任何在丢弃值时需要做一些特殊操作的类型都不能是Copy类型
    • 如果结构体的所有字段都是Copy类型,那么可以通过属性#[derive(Copy,Clone)]放置在此定义之上来创建Copy类型
    • Rust的一个原则是:
      • 各种开销对程序员来说应该是显而易见的
      • 基本操作必须保持简单,而潜在的昂贵操作应该是显式的
  8. RcArc:共享所有权

    • RcArc非常相似,唯一的区别是Arc可以安全地在线程之间直接共享
    • 普通Rc会使用更快的非线程安全代码来更新引用计数
    • 克隆一个Rc<T>值并不会复制T,相反,它只会创建另一个指向它的指针并通过递增引用计数
    • 通常的所有权规则适用于Rc指针本身,当丢弃最后一个Rc时,Rust也会丢弃T
      • 资源交给Rc管理,Rc不存在后则一切都不存在了
    • Rust的内存和线程安全保证的基石是:
      • 确保不会有任何值时既共享可变
      • Rust假Rc指针的引用目标通常都可以共享,因此就不能是可变的
    • 弱引用指针std::rc::Weak来避免建立Rc指针循环。
  9. 小结

    • 所有权:每个值有唯一的所有者
    • 移动:涉及特殊操作的赋值会发生所有权转移
    • Copy:简单类型的赋值操作会复制值
    • Rc和Arc:共享值的所有权
    • 移动和引用计数指针是缓解所有权严格性问题的两种途径
    • 第三种途径是借用对值的引用

引用

  • 迄今为止,我们所看到的所有指针类型都是拥有型指针,意味着当拥有者被丢弃时,它的引用目标也会随之消失
    • 如:简单的Box<T>堆指针,String值,Vec值内部的指针
  • Rust还有一种名为引用(reference)非拥有型指针,这种指针对引用目标生命周期毫无影响
  • 引用生命周期决不能超出其引用目标
  • Rust把创建对某个值的引用的操作称为**借用(borrow)**那个值:凡是借用,终须归还
  • 引用本身确实没什么特别之处-说到底,它们只是地址而已
    • 但用以让引用保持安全的规则,对Rust来说是一种创新
  1. 对值的引用

    • 引用能让在不影响其所有权的情况下访问值
      • 共享引用:允许你读取但不能修改其引用目标,可以同时拥有任意数量对特定值的共享引用。
        • 类型写成:&T
        • 共享引用是Copy类型
      • 可变引用:允许你读取和修改值,一旦一个值拥有了可变引用,就无法对该值创建其它任何引用。
        • 类型写成: &mut T
        • 可变引用不是Copy类型
    • 可以将共享引用和可变引用之间的区别视为在编译期强制执行多重读取单一写入
      • 这条规则不仅适用于引用,也适用于所引用值的拥有者
      • 只要存在一个值的共享引用,即使是它的拥有者也不能修改它,该值会被锁定
    • 事实证明:让共享和修改保证完全分离对内存安全至关重要
  2. 由于引用在Rust中随处可见,因此**.运算符**就会按需对其左操作数隐式解引用

    struct Anime { name: &'static str, bechdel_pass: bool};
    let aria = Anime { name: "Aria: The Animation", bechdel_pass: true};
    let anime_ref = &aria;
    assert_eq!(anime_ref.name, "Aria: The Animation");
    • 在Rust中使用&运算符*运算符来创建引用(借用)和追踪引用(解引用),不过.运算符不需要做这种转换,它会隐式借用和解引用
  3. 对引用进行引用

    struct Point {x: i32, y: i32};
    let point = Point {x: 1000, y: 729};
    let r: &Point = &point;
    let rr: &&Point = &r;
    let rrr: &&&Point = &rr;
    • .运算符会追踪尽可能多层次的引用来找到它的目标
  4. 比较引用

    • 就像.运算符一样,Rust的比较运算符也能看穿任意数量的引用
    • 比较运算符操作数(包括引用型操作数)必须是完全相同类型
      • 比如如上代码中rrr类型不匹配
    • 两个引用是否指向同一块内存,可以使用std::ptr::eq,会对两者作为地址进行比较
  5. 引用永不为空

    • 在Rust中,如果需要用一个值来表示对某个可能不存在事物的引用,请使用Option<&T>
    • 在机器级别,Rust会将None表示为空指针,将Some(r)表示为非零地址,因此Option<&T>与C/C++中的可空指针一样高效,但更安全
      • 它的类型要求你在使用之前必须检查它是否为None
  6. 对切片和特型对象的引用

    • Rust还包括两种胖指针,即携带某个值地址的双字值,以及要正确使用该值所需的某些额外信息
      • 对切片的引用就是一个胖指针,携带着此切片的起始地址及其长度
      • 另一种胖指针是特型对象,即对实现了指定型的值的引用
  7. Rust的可变与共享规则

    • 共享访问是只读访问
      • 对于共享借用,这条路径是只读的
    • 可变访问是独占访问
      • 对于可变借用,这条路径是完全不可访问的
      • 通过要求可变访问必须是独占的,Rust避免了一大类日常错误
    • 在编写并发代码时,共享引用和可变引用的互斥性确实证明了其价值

表达式

  • Rust中完全面向表达式的控制流
    • if表达式可用于初始化变量
    • match表达式可以作为参数传给函数或宏
    • 块是一种最通用的表达式,一个块生成一个值
    • if let表达式其实只有一个模式的match表达式的简写形式
    • match表达式的所有分支都必须具有相同的类型
    • while循环for循环的值总是()
    • loop表达式就能生成一个值
      • loop中所有break表达式也必须生成具有相同类型的值,这样该类型就会成为这个loop本身的类型
    • ..运算符会生成一个范围(range),即具有两个字段(start和end)的简单结构体
    • break表达式会退出所在循环
  • 几个重要的自动转换
    • &String类型的值会自动转换为&str类型,无须强制转换
    • &Vec<i32>类型的值会自动转换为&[i32]
    • &Box<Chessboard>类型的值自动转换为&Chessboard
    • 以上这些被称为隐式解引用,因为它们适用于所有实现了内置特型Deref的类型
      • Deref隐式转换的目的是使智能指针类型的行为尽可能像其底层值
      • 多亏了DerefBox<Chessboard>的用法基本上和普通Chessboard的用法一样

crate与模块

  • crate
    • 每个crate都是既完整又内聚的单元
    • 包含单个库或可执行程序的所有源代码
  • crate是关于项目间代码共享的,而模块是关于项目内代码组织的
    • 它们(crate与module)扮演着Rust命名空间的角色,是构成Rust程序或库的函数、类型、常量等的容器
  • 函数标记为pub(crate)
    • 那么就意味着它可以再这个crate的任何地方使用
    • 但不会作为外部接口的一部分公开
  • 预导入(prelude)
    • 它们通过收集几乎所有用户都需要的常用导入,减少了样板代码的编写
    • 把一个模块命名为prelude只是一种约定,旨在告诉用户应该使用*导入它
  • 模块
    • 目录可以作为一个模块,目录名为模块名
      • 目录中包含mod.rs,表示是一个模块,类似Python语言的__init__.py
    • 文件可以作为一个模块,文件名为模块名
    • 自定义文件中的模块
      • 使用mod关键字
    • ::运算符用于访问模块中的各项特型
    • 关键字supercrate在路径中有着特殊的含义
      • super指的是父模块
      • crate指的是当前模块所在的crate
    • as关键字可以给导入的内容重命名
    • 绝对路径
      • ::开头,总会引用外部crate
    • src/lib.rs中代码构成了库的根模块

结构体

  • Rust有三种结构体类型

    • 具名字段型结构体
    • 元组型结构体
    • 单元型结构体
  • 具名字段型结构体

    • Rust中的约定是
      • 所有类型(包括结构体)的名称都将每个单词的第一个字母大写,称为大驼峰式
      • 字段和方法是小写的,单词之间用下划线分隔,称为蛇形格式
    • 结构体默认情况下是私有的
      • 仅在声明它们的模块及其子模块中可见
      • 即使一个结构体声明为pub,它的字段也可以是私有的
      • 其它模块可以使用此结构体及其任何公共的关联函数,但不能按名称访问私有字段或使用结构体表达式创建新值
  • 用impl定义方法

    • impl块只是fn定义的集合,每个定义都会成为块顶部命名的结构体类型上的一个方法
    • impl块中定义的函数称为关联函数,因为它们是与特定类型相关联的
    • Rust会将调用关联函数的结构体值作为第一个参数传给方法
      • 该参数必须具有特殊名称self
      • 由于self的类型显然就是在impl块顶部命名的类型或对该类型的引用
      • self的不同形式
        • self
        • &self
        • &mut self
  • BoxRcArc形式传入self

    • 方法的self参数也可以是Box<Self>类型,Rc<Self>类型或Arc<Self>类型
    • Rust会自动从BoxRcArc等指针类型中借入引用
      • 因此&self&mut self几乎总是方法签名里的正确选择
  • 类型关联函数

    • impl块还可以定义根本不以self参数的函数
    • 这些函数仍然是关联函数,因为它们在impl块中
    • 但它们不是方法,因为它们不接受self参数
    • 为了将它们与方法区分开来,我们称其为类型关联函数
  • 泛型结构体

    • Rust结构体可以是泛型的
      • 在泛型结构体定义中,尖括号(<>)中的类型名称叫作类型参数
      • Self参数定义为我们要为其添加方法的任意类型。
      • 调用关联函数时,使用::<>(比目鱼)表示法显式地提供类型参数
  • 带生命周期参数的泛型结构体

    • 如果结构体类型包括引用,则必须为这些引用的生命周期命名
  • 带常量参数的泛型结构体

    • 常量泛型参数可以是任意整数类型charbool
    • 不允许使用浮点数、枚举和其他类型
    • 如果结构体还接受其他种类的泛型参数
      • 生命周期参数必须排在第一位,然后是类型,接下来是任何const值
  • 内部可变性

    • 我们需要一个不可变值中的一丁点儿可变数据,称为内部可变性
    • Cell<T>是一个包含类型T的单个私有值的结构体
    • Cell唯一的特殊之处在,即使你对Cell本身没有mut访问权限,也可以获取和设置这个私有字段
    • Cell只是改变不变性规则的一种安全方式
      • 一丝不多,一毫不少
      • Cell不允许在共享值上调用mut方法
      • .get()方法会返回Cell中值的副本,因此它仅在T实现了Copy特型时才有效
    • Cell很容易使用
      • 虽然不得不调用.get().set().borrow().borrow_mut()略显尴尬
      • 但这就是我们为违反规则而付出的代价
    • Cell不是线程安全的
    • Cell不用,RefCell支持借用对其T值的引用

枚举与模式

  • Rust模式有点像针对所有数据的正则表达式
  • 枚举可以是泛型的,如Option<T>,Result<T>
  • 只能用一种安全的方式来访问枚举中的数据,即使用模式
  • match表达式中,模式就是出现在=>符号前面的部分
  • 表达式会生成值,模式会消耗值
  • 模式匹配可以和枚举协同工作,甚至可以测试它们包含的数据,这让match成了C的switch语句的强大而灵活的替代品
  • match也可用来匹配其他类型
    • 字面量、变量、通配符等
    • 字面量也可以用作模式,包括布尔值、字符,甚至字符串
    • 不关心匹配的值,那么可以用单个下划线_作为模式,这就是通配符模式
  • 元组型模式匹配元组
  • 结构体型模式使用花括号,就像结构体表达式一样
  • 数组型模式与切片模式
    • 切片模式不仅匹配值,还匹配长度
  • 引用型模式
    • ref模式会借用己匹配值的一部分
    • &模式会匹配引用`
    • 匹配不可复制的值会移动该值

特型与泛型

  • Rust通过两个相关联的特性来支持多态
    • 特型泛型
  • 特型
    • 是Rust体系中的接口抽象基类
    • 是一种语言特性,我们可以说某类型支持或不支持某个特型
    • 大多数情况下,特型代表着一种能力,即一个类型能做什么
    • 特型本身必须在作用域内
      • CloneIterator的各个方法没有导入就能工作,因为默认它们始终在作用域中,是标准库一部分
      • 预导入主要就是一些精心挑选的特型
  • 泛型
    • 是Rust中多态的另一种形式
  • 界限
    • 对类型T可能的类型范围做了限制
  • 泛型和特型的紧密相关
    • 泛型函数会在限界中使用特型来阐明它能针对哪些类型的参数进行调用
  1. 为什么向类型添加特型不需要额外的内存,以及如何在不需要虚方法调用开销的情况下使用特型

  2. 特型对象

    • 在Rust中使用特型编写多态代码有两种方式:
      • 特型对象
      • 泛型
    • 特型类型引用叫作特型对象
    • Rust通常无法在编译期间知道引用目标的类型,因此特性对象要包含一些关于引用目标类型的额外信息
    • 特型对象的内存布局
      • 特型对象是一个胖指针
      • 指向值的指针指向表示该值类型虚表的指针组成
      • 在Rust中,虚表只会在编译期生成一次,并由同一类型的所有对象共享
      • 当调用特型对象的方法时,该语言会自动使用虚表来确定调用哪个实现
      • 在C++中,虚表指针vptr是作为结构体的一部分存储,而Rust是胖指针方案
      • Rust中,结构体本身只包含自己的字段
        • 这样一来,每个结构体就可以实现几十个特型而不必包含几十个vptr
      • Rust在需要时会自动将普通引用转换为特型对象
      • 创建特型对象的唯一方法
        • &mut dyn train_name
        • &mut dyn WriteBox<dyn Write>也是一个胖指针,包含写入器本身的地址和虚表的地址
  3. 特型对象还是泛型代码的选择相当微妙

    • 由于这两个特性都基于特型
    • 当需要一些混合类型值的集合时,特型对象是正确的选择
  4. 特型对象相比,泛型具有三个重要优势

    • 第一个优势是速度,没有dyn则不会有动态派发,没有运行期开销
    • 第二个优势在于并不是每个特性都能支持特型对象
    • 第三个优势是它很容易同时指定具有多个特型和泛型参数限界,特型对象不能这样
  5. 定义与实现特型

    • 定义特型很简单,给它一个名字并列出特型方法的类型签名即可
    • 要实现特型,请使用语法impl TraitName for Type
    • 特型impl代码中定义一切都必须是真正属于此特型的
    • 自定义函数不能放在特型的impl代码中,只能包含在单独的impl块
    • 特型的impl代码中的一切都必须是真正属于此特型
    • 特型中有未实现的函数,也有已实现的函数
  6. 特型中的Self

    • 特型可以用关键字Self作为类型
    • 使用了Self类型的特型特型对象不兼容
    • 特型对象实际上是为最简单的特型类型而设计的
    • 特型的高级特性很有用,但它们不能与特型对象共存
      • 因为一旦有了特型对象,就会失去Rust对你的程序进行类型检查时所必须的类型信息
  7. impl Trait

    • 是一种静态派发形式
    • Rust不允许特型方法使用impl Trait作为返回值
    • 只有最简单的泛型函数中才能把impl Trait参数用作类型,参数的类型之间不能存在关系
  8. 类型限界的另一个优点

    • 当遇到编译期错误时,至少编译期可以告诉你问题出在哪里
    • 限界就这么写在代码和文档中,你可以查看Rust中泛型函数的签名
    • 并准确了解它能接受的参数类型
    • 而使用模版则做不到这些
  9. 以特型为基础

    • 特型泛型在所有这些主题中都扮演着核心角色