优秀的编程知识分享平台

网站首页 > 技术文章 正文

Rust高效编程:项目 1:使用类型系统来表达您的数据结构

nanyue 2024-09-23 10:40:37 技术文章 4 ℃

条目提供了 Rust 类型系统的快速概述,首先介绍编译器提供的基本类型,然后讨论值如何组合成数据结构的各种方式。


Rust 的枚举类型因此扮演了重要角色。尽管基本版本与其他语言提供的相当,但将枚举变体与数据字段结合的能力增强了灵活性和表现力。

基本类型


Rust 的类型系统的基础对于来自其他静态类型编程语言(如 C++、Go 或 Java)的人来说是相当熟悉的。它有一组具有特定大小的整数类型,包括有符号(i8、i16、i32、i64、i128)和无符号(u8、u16、u32、u64、u128)。


还有与目标系统上的指针大小匹配的有符号(isize)和无符号(usize)整数。然而,在 Rust 中,您不会进行太多指针和整数之间的转换,因此这种大小等价性并不真正相关。然而,标准集合返回其大小为 usize(来自.len()),因此集合索引意味着 usize 值非常常见——从容量的角度来看,这显然是可以的,因为内存中的集合中的项目数量不能超过系统上的内存地址数量。


整型确实给了我们第一个暗示,Rust 的世界比 C++更严格。在 Rust 中,试图将一个较大的整数类型(i32)放入一个较小的整数类型(i16)会产生编译时错误:

let x: i32 = 42;
let y: i16 = x;
error[E0308]: mismatched types
  --> src/main.rs:18:18
   |
18 |     let y: i16 = x;
   |            ---   ^ expected `i16`, found `i32`
   |            |
   |            expected due to this
   |
help: you can convert an `i32` to an `i16` and panic if the converted value
      doesn't fit
   |
18 |     let y: i16 = x.try_into().unwrap();
   |                   ++++++++++++++++++++


这让人放心:Rust 不会在程序员做一些风险操作时静静地坐着。尽管我们可以看到这个特定转换中涉及的值是完全可以的,但编译器必须考虑到转换不合适的值的可能性。

错误输出还早早表明,虽然 Rust 有更严格的规则,但它也提供了有助于遵循这些规则的编译器消息。建议的解决方案引发了一个问题,即如何处理转换必须改变值以适应的情况,我们稍后会在错误处理(项目 4)和使用 panic!(项目 18)上有更多讨论。


Rust 也不允许一些看似“安全”的操作,例如将较小整数类型的值放入较大整数类型中:

let x = 42i32; // Integer literal with type suffix
let y: i64 = x;
error[E0308]: mismatched types
  --> src/main.rs:36:18
   |
36 |     let y: i64 = x;
   |            ---   ^ expected `i64`, found `i32`
   |            |
   |            expected due to this
   |
help: you can convert an `i32` to an `i64`
   |
36 |     let y: i64 = x.into();
   |                   +++++++



在这里,建议的解决方案并没有引发错误处理的问题,但转换仍然需要明确。我们将在稍后更详细地讨论类型转换(项目 5)。


继续讨论不令人惊讶的原始类型,Rust 有一个布尔类型,浮点类型(f32,f64)和一个单元类型()(类似于 C 的 void)。


更有趣的是 char 字符类型,它保存一个 Unicode 值(类似于 Go 的 rune 类型)。尽管它在内部存储为四个字节,但同样没有对 32 位整数的静默转换。


这种类型系统的精确性迫使你明确你想要表达的内容——u32 值与 char 不同,而 char 又与 UTF-8 字节序列不同,UTF-8 字节序列又与任意字节序列不同,具体你指的是哪一种由你来指定。乔尔·斯波尔斯基著名的博客文章可以帮助你理解你需要的内容。


当然,有一些辅助方法可以让你在这些不同类型之间转换,但它们的签名迫使你处理(或明确忽略)失败的可能性。例如,Unicode 码点总是可以用 32 位表示,因此 'a' 作为 u32 是允许的,但反向转换就更棘手(因为有一些 u32 值不是有效的 Unicode 码点):


字符::从_u32


返回一个 Option,强制调用者处理失败情况。

char::from_u32_unchecked


假设有效性,但如果该假设不成立,则可能导致未定义行为。因此,该函数被标记为不安全,迫使调用者也使用不安全(项目 16)。

聚合类型


接下来谈谈聚合类型,Rust 有多种方法来组合相关值。这些大多数是其他语言中可用的聚合机制的熟悉等价物:


数组


持有单一类型的多个实例,其中实例的数量在编译时已知。例如,[u32; 4] 是四个连续的 4 字节整数。


元组


持有多个异构类型的实例,其中元素的数量和类型在编译时已知,例如(WidgetOffset,WidgetSize,WidgetColor)。如果元组中的类型不具备区分性,例如(i32,i32,&'static str,bool),最好为每个元素命名并使用结构体。


结构体


还可以持有在编译时已知的异构类型的实例,但允许整体类型和各个字段通过名称进行引用。


Rust 还包括元组结构,它是结构体和元组的混合体:整体类型有一个名称,但单个字段没有名称——它们是通过数字来引用的:s.0、s.1,依此类推。

/// Struct with two unnamed fields.
struct TextMatch(usize, String);

// Construct by providing the contents in order.
let m = TextMatch(12, "needle".to_owned());

// Access by field number.
assert_eq!(m.0, 12);

枚举

这使我们来到了 Rust 类型系统的瑰宝——枚举。对于枚举的基本形式,很难看出有什么值得兴奋的地方。与其他语言一样,枚举允许您指定一组互斥的值,可能附带一个数值:

enum HttpResultCode {
    Ok = 200,
    NotFound = 404,
    Teapot = 418,
}

let code = HttpResultCode::NotFound;
assert_eq!(code as i32, 404);

因为每个枚举定义都会创建一个独特的类型,这可以用来提高接受布尔参数的函数的可读性和可维护性。代替:

print_page(/* both_sides= */ true, /* color= */ false);


使用一对枚举的版本:

pub enum Sides {
    Both,
    Single,
}

pub enum Output {
    BlackAndWhite,
    Color,
}

pub fn print_page(sides: Sides, color: Output) {
    // ...
}

在调用时更具类型安全性且更易于阅读:

print_page(Sides::Both, Output::BlackAndWhite);

与布尔版本不同,如果库用户不小心颠倒了参数的顺序,编译器会立即发出警告:

error[E0308]: arguments to this function are incorrect
   --> src/main.rs:104:9
    |
104 | print_page(Output::BlackAndWhite, Sides::Single);
    | ^^^^^^^^^^ ---------------------  ------------- expected `enums::Output`,
    |            |                                    found `enums::Sides`
    |            |
    |            expected `enums::Sides`, found `enums::Output`
    |
note: function defined here
   --> src/main.rs:145:12
    |
145 |     pub fn print_page(sides: Sides, color: Output) {
    |            ^^^^^^^^^^ ------------  -------------
help: swap these arguments
    |
104 | print_page(Sides::Single, Output::BlackAndWhite);
    |             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

使用 newtype 模式(见第 6 项)来包装布尔值也实现了类型安全和可维护性;如果语义始终是布尔值,通常最好使用 newtype 模式,而如果将来可能出现新的替代选项(例如,Sides::BothAlternateOrientation),则应使用枚举。


Rust 的枚举的类型安全性在 match 表达式中得以延续:

let msg = match code {
    HttpResultCode::Ok => "Ok",
    HttpResultCode::NotFound => "Not found",
    // forgot to deal with the all-important "I'm a teapot" code
};
error[E0004]: non-exhaustive patterns: `HttpResultCode::Teapot` not covered
  --> src/main.rs:44:21
   |
44 |     let msg = match code {
   |                     ^^^^ pattern `HttpResultCode::Teapot` not covered
   |
note: `HttpResultCode` defined here
  --> src/main.rs:10:5
   |
7  | enum HttpResultCode {
   |      --------------
...
10 |     Teapot = 418,
   |     ^^^^^^ not covered
   = note: the matched value is of type `HttpResultCode`
help: ensure that all possible cases are being handled by adding a match arm
      with a wildcard pattern or an explicit pattern as shown
   |
46 ~         HttpResultCode::NotFound => "Not found",
47 ~         HttpResultCode::Teapot => todo!(),
   |


编译器迫使程序员考虑枚举所表示的所有可能性,即使结果只是添加一个默认分支 _ => {}。(请注意,现代 C++ 编译器可以并且确实会对枚举缺失的 switch 分支发出警告。)

带字段的枚举


Rust 的枚举特性的真正力量在于每个变体可以携带数据,使其成为一种聚合类型,充当代数数据类型(ADT)。这对主流语言的程序员来说不太熟悉;在 C/C++ 的术语中,它就像是枚举和联合的结合——只是类型安全。


这意味着程序数据结构的不变性可以编码到 Rust 的类型系统中;不符合这些不变性的状态甚至无法编译。一个设计良好的枚举使创建者的意图对人类和编译器都清晰可见:

use std::collections::{HashMap, HashSet};

pub enum SchedulerState {
    Inert,
    Pending(HashSet<Job>),
    Running(HashMap<CpuId, Vec<Job>>),
}

仅从类型定义来看,可以合理地推测,作业在待处理状态下排队,直到调度程序完全激活,此时它们被分配到某个每个 CPU 的池中。


这突出了本条目的核心主题,即利用 Rust 的类型系统来表达与软件设计相关的概念。


一个明显的迹象表明这种情况没有发生的是一条评论,解释了某个字段或参数何时有效:


pub struct DisplayProps {
    pub x: u32,
    pub y: u32,
    pub monochrome: bool,
    // `fg_color` must be (0, 0, 0) if `monochrome` is true.
    pub fg_color: RgbColor,
}


这是一个可以用持有数据的枚举替换的主要候选者:

pub enum Color {
    Monochrome,
    Foreground(RgbColor),
}

pub struct DisplayProps {
    pub x: u32,
    pub y: u32,
    pub color: Color,
}

这个小例子说明了一个关键建议:使无效状态在你的类型中不可表达。只支持有效值组合的类型意味着整个错误类别会被编译器拒绝,从而导致更小、更安全的代码。

无处不在的枚举类型


返回到枚举的力量,有两个概念是如此常见,以至于 Rust 的标准库包含了内置的枚举类型来表达它们;这些类型在 Rust 代码中无处不在。

选项


第一个概念是选项(Option):要么有某种特定类型的值(Some(T)),要么没有(None)。始终使用选项来表示可能缺失的值;绝不要退回使用哨兵值(-1、nullptr 等)来尝试在带内表达相同的概念。


不过,有一个微妙的点需要考虑。如果你正在处理一组事物,你需要决定在集合中没有事物是否与没有集合是一样的。在大多数情况下,这种区别并不存在,你可以继续使用(比如)Vec:零个事物的计数意味着没有事物。


然而,确实存在其他罕见的情况需要用 Option> 来区分这两种情况——例如,一个加密系统可能需要区分“单独运输的有效载荷”和“提供的空有效载荷”。(这与 SQL 中列的 NULL 标记的争论有关。)


类似地,对于可能缺失的字符串,最佳选择是什么?使用""还是 None 更能表示缺失值的情况?两者都可以,但 Option清楚地传达了该值可能缺失的可能性。

结果


第二个常见概念源于错误处理:如果一个函数失败,应该如何报告该失败?历史上,使用了特殊的哨兵值(例如,Linux 系统调用中的-errno 返回值)或全局变量(POSIX 系统中的 errno)。最近,支持多重或元组返回值的语言(如 Go)可能有一种约定,即返回一个(结果,错误)对,假设在错误为非“零”时,结果存在某个合适的“零”值。


在 Rust 中,有一个枚举正是为了这个目的:始终将可能失败的操作的结果编码为 Result。T 类型保存成功的结果(在 Ok 变体中),而 E 类型在失败时保存错误详细信息(在 Err 变体中)。


使用标准类型使设计意图清晰。这也允许使用标准转换(项目 3)和错误处理(项目 4),从而使得可以使用 ? 运算符简化错误处理。

最近发表
标签列表