04.Rust 语言实战笔记 —— 基本类型

3,560 阅读9分钟

基本类型

Rust 每个值都有其确切的数据类型,总的来说可以分为两类:基本类型和复合类型。 基本类型意味着它们往往是一个最小化原子类型,无法解构为其它类型(一般意义上来说),由以下组成:

  • 数值类型: 有符号整数 (i8i16i32i64isize)、 无符号整数 (u8u16u32u64usize) 、浮点数 (f32f64)、以及有理数、复数
  • 字符串:字符串字面量和字符串切片  &str
  • 布尔类型: truefalse
  • 字符类型: 表示单个 Unicode 字符,存储为 4 个字节
  • 单元类型: 即  () ,其唯一的值也是  ()

数值类型

整数类型

整数是没有小数部分的数字。之前使用过的  i32  类型,表示有符号的 32 位 整数( i  是英文单词  integer的首字母,与之相反的是  u,代表无符号  unsigned  类型)。下表显示了 Rust 中的内置的整数类型:

长度有符号类型无符号类型
8 位i8u8
16 位i16u16
32 位i32u32
64 位i64u64
128-位i128u128
视架构而定isizeusize

类型定义的形式统一为:有无符号 + 类型大小(位数)无符号数表示数字只能取正数,而有符号则表示数字即可以取正数又可以取负数。就像在纸上写数字一样:当要强调符号时,数字前面可以带上正号或负号;然而,当很明显确定数字为正数时,就不需要加上正号了。有符号数字以补码形式存储。

每个有符号类型规定的数字范围是 -(2n - 1) ~ 2n - 1- 1,其中  n  是该定义形式的位长度。因此  i8  可存储数字范围是 -(27) ~ 27 - 1,即 -128 ~ 127。无符号类型可以存储的数字范围是 0 ~ 2n - 1,所以  u8  能够存储的数字为 0 ~ 28 - 1,即 0 ~ 255。

此外,isize  和  usize  类型取决于程序运行的计算机 CPU 类型: 若 CPU 是 32 位的,则这两个类型是 32 位的,同理,若 CPU 是 64 位,那么它们则是 64 位。

整形字面量可以用下表的形式书写:

数字字面量示例
十进制98_222
十六进制0xff
八进制0o77
二进制0b1111_0000
字节 (仅限于  u8)b'A'

这么多类型,有没有一个简单的使用准则?答案是肯定的, Rust 整形默认使用  i32,例如  let i = 1,那  i  就是  i32  类型,因此你可以首选它,同时该类型也往往是性能最好的。isize  和  usize  的主要应用场景是用作集合的索引。

整数溢出

比方说有一个  u8 ,它可以存放从 0 到 255 的值。那么当你将其修改为范围之外的值,比如 256,则会发生整型溢出。关于这一行为 Rust 有一些有趣的规则:当在 debug 模式编译时,Rust 会检查整型溢出,若存在这些问题,则使程序在编译时  panic(崩溃,Rust 使用这个术语来表明程序因错误而退出)。

在当使用  --release  参数进行 release 模式构建时,Rust 检测溢出。相反,当检测到整型溢出时,Rust 会按照补码循环溢出(two’s complement wrapping)的规则处理。简而言之,大于该类型最大值的数值会被补码转换成该类型能够支持的对应数字的最小值。比如在  u8  的情况下,256 变成 0,257 变成 1,依此类推。程序不会  panic,但是该变量的值可能不是你期望的值。依赖这种默认行为的代码都应该被认为是错误的代码。

要显式处理可能的溢出,可以使用标准库针对原始数字类型提供的这些方法:

  • 使用  wrapping_*  方法在所有模式下都按照补码循环溢出规则处理,例如  wrapping_add
  • 如果使用  checked_*  方法时发生溢出,则返回  None  值
  • 使用  overflowing_*  方法返回该值和一个指示是否存在溢出的布尔值
  • 使用  saturating_*  方法使值达到最小值或最大值

浮点类型

浮点类型数字  是带有小数点的数字,在 Rust 中浮点类型数字也有两种基本类型: f32  和  f64,分别为 32 位和 64 位大小。默认浮点类型是  f64,在现代的 CPU 中它的速度与  f32  几乎相同,但精度更高。

下面是一个演示浮点数的示例:

fn main() {
    let x = 2.0; // f64
    let y: f32 = 3.0; // f32
}

浮点数根据  IEEE-754  标准实现。f32  类型是单精度浮点型,f64  为双精度。

浮点数陷阱

浮点数由于底层格式的特殊性,导致了如果在使用浮点数时不够谨慎,就可能造成危险,有两个原因:

  1. 浮点数往往是你想要数字的近似表达  浮点数类型是基于二进制实现的,但是我们想要计算的数字往往是基于十进制,例如  0.1  在二进制上并不存在精确的表达形式,但是在十进制上就存在。这种不匹配性导致一定的歧义性,更多的,虽然浮点数能代表真实的数值,但是由于底层格式问题,它往往受限于定长的浮点数精度,如果你想要表达完全精准的真实数字,只有使用无限精度的浮点数才行

  2. 浮点数在某些特性上是反直觉的  例如大家都会觉得浮点数可以进行比较,对吧?是的,它们确实可以使用  > , >=  等进行比较,但是在某些场景下,这种直觉上的比较特性反而会害了你。因为  f32f64  上的比较运算实现的是  std::cmp::PartialEq  特征(类似其他语言的接口),但是并没有实现  std::cmp::Eq  特征,但是后者在其它数值类型上都有定义,说了这么多,可能大家还是云里雾里,用一个例子来举例:

    Rust 的  HashMap  数据结构,是一个 KV 类型的 Hash Map 实现,它对于  K  没有特定类型的限制,但是要求能用作  K  的类型必须实现了  std::cmp::Eq  特征,因此这意味着你无法使用浮点数作为  HashMap  的  Key,来存储键值对,但是作为对比,Rust 的整数类型、字符串类型、布尔类型都实现了该特征,因此可以作为  HashMap  的  Key

    为了避免上面说的两个陷阱,你需要遵守以下准则:

    • 避免在浮点数上测试相等性
    • 当结果在数学上可能存在未定义时,需要格外的小心

    来看个小例子:

    fn main() {
       // 断言0.1 + 0.2与0.3相等
       assert!(0.1 + 0.2 == 0.3);
    }
    

你可能以为,这段代码没啥问题吧,实际上它会  panic(程序崩溃,抛出异常),因为二进制精度问题,导致了 0.1 + 0.2 并不严格等于 0.3,它们可能在小数点 N 位后存在误差。

那如果非要进行比较呢?可以考虑用这种方式  (0.1_f64 + 0.2 - 0.3).abs() < 0.00001 ,具体小于多少,取决于你对精度的需求。

讲到这里,相信大家基本已经明白了,为什么操作浮点数时要格外的小心,但是还不够,下面再来一段代码,直接震撼你的灵魂:

fn main() {
    let abc: (f32, f32, f32) = (0.1, 0.2, 0.3);
    let xyz: (f64, f64, f64) = (0.1, 0.2, 0.3);


    println!("abc (f32)");
    println!("   0.1 + 0.2: {:x}", (abc.0 + abc.1).to_bits());
    println!("         0.3: {:x}", (abc.2).to_bits());
    println!();


    println!("xyz (f64)");
    println!("   0.1 + 0.2: {:x}", (xyz.0 + xyz.1).to_bits());
    println!("         0.3: {:x}", (xyz.2).to_bits());
    println!();


    assert!(abc.0 + abc.1 == abc.2);
    assert!(xyz.0 + xyz.1 == xyz.2)

运行该程序,输出如下:

abc (f32)
   0.1 + 0.2: 3e99999a
         0.3: 3e99999a

xyz (f64)
   0.1 + 0.2: 3fd3333333333334
         0.3: 3fd3333333333333

thread 'main' panicked at 'assertion failed: xyz.0 + xyz.1 == xyz.2',
➥ch2-add-floats.rs.rs:14:5
note: run with `RUST_BACKTRACE=1` environment variable to display
➥a backtrace

仔细看,对  f32  类型做加法时,0.1 + 0.2  的结果是  3e99999a0.3  也是  3e99999a,因此  f32  下的  0.1 + 0.2 == 0.3  通过测试,但是到了  f64  类型时,结果就不一样了,因为  f64  精度高很多,因此在小数点非常后面发生了一点微小的变化,0.1 + 0.2  以  4  结尾,但是  0.3  以3结尾,这个细微区别导致  f64  下的测试失败了,并且抛出了异常。

NaN

对于数学上未定义的结果,例如对负数取平方根  -42.1.sqrt() ,会产生一个特殊的结果:Rust 的浮点数类型使用  NaN (not a number)来处理这些情况。

所有跟 NaN 交互的操作,都会返回一个 NaN,而且  NaN  不能用来比较,下面的代码会崩溃:

fn main() {
  let x = (-42.0_f32).sqrt();
  assert_eq!(x, x);
}

出于防御性编程的考虑,可以使用  is_nan()  等方法,可以用来判断一个数值是否是  NaN :

fn main() {
    let x = (-42.0_f32).sqrt();
    if x.is_nan() {
        println!("未定义的数学行为")
    }
}

序列(Range)

Rust 提供了一个非常简洁的方式,用来生成连续的数值,例如  1..5 ,生成从 1 到 4 的连续数字,不包含 5 ; 1..=5 ,生成从 1 到 5 的连续数字,包含 5 ,它的用途很简单,常常用于循环中:

for i in 1..=5 {
    println!("{}",i);
}

最终程序输出:

1
2
3
4
5

序列只允许用于数字或字符类型,原因是:它们可以连续,同时编译器在编译期可以检查该序列是否为空,字符和数字值是 Rust 中仅有的可以用于判断是否为空的类型。如下是一个使用字符类型序列的例子:

for i in 'a'..='z' {
    println!("{}",i

有理数和复数

Rust 的标准库相比其它语言,准入门槛较高,因此有理数和复数并未包含在标准库中:

  • 有理数和复数
  • 任意大小的整数和任意精度的浮点数
  • 固定精度的十进制小数,常用于货币相关的场景

好在社区已经开发出高质量的 Rust 数值库:num。按照以下步骤来引入  num  库:

  1. 创建新工程  cargo new complex-num && cd complex-num
  2. 在  Cargo.toml  中的  [dependencies]  下添加一行  num = "0.4.0"
  3. 将  src/main.rs  文件中的  main  函数替换为下面的代码
  4. 运行  cargo run
use num::complex::Complex;

fn main() {
   let a = Complex { re: 2.1, im: -1.2 };
   let b = Complex::new(11.1, 22.2);
   let result = a + b;

   println!("{} + {}i", result.re, result.im)
}

字符类型(char)

字符,对于没有其它编程经验的新手来说可能不太好理解(没有编程经验敢来学 Rust 的绝对是好汉),但是你可以把它理解为英文中的字母,中文中的汉字。

下面的代码展示了几个颇具异域风情的字符:

fn main() {
    let c = 'z';
    let z = 'ℤ';
    let g = '国';
    let heart_eyed_cat = '😻';
}

如果大家是从有年代感的编程语言过来,可能会大喊一声:这 XX 叫字符?是的,在 Rust 语言中这些都是字符,Rust 的字符不仅仅是  ASCII,所有的  Unicode  值都可以作为 Rust 字符,包括单个的中文、日文、韩文、emoji 表情符号等等,都是合法的字符类型。Unicode  值的范围从  U+0000~U+D7FF  和  U+E000~U+10FFFF。不过“字符”并不是  Unicode  中的一个概念,所以人在直觉上对“字符”的理解和 Rust 的字符概念并不一致。

由于  Unicode  都是 4 个字节编码,因此字符类型也是占用 4 个字节:

fn main() {
    let x = '中';
    println!("字符'中'占用了{}字节的内存大小", std::mem::size_of_val(&x));
}

输出如下:

$ cargo run
   Compiling ...

字符'中'占用了4字节的内存大小

注意,我们还没开始讲字符串,但是这里提前说一下,和一些语言不同,Rust 的字符只能用  ''  来表示, ""  是留给字符串的

布尔(bool)

Rust 中的布尔类型有两个可能的值:true  和  false, 布尔值占用内存的大小为  1  个字节:

fn main() {
    let t = true;

    let f: bool = false; // 使用类型标注,显式指定f的类型if f {
        println!("这是段毫无意义的代码");
    }
}

使用布尔类型的场景主要在于流程控制,例如上述代码的中的  if  就是其中之一。

单元类型

单元类型就是  () ,对,你没看错,就是  () ,唯一的值也是  () ,一些读者读到这里可能就不愿意了,你也太敷衍了吧,管这叫类型?

只能说,再不起眼的东西,都有其用途,在目前为止的学习过程中,大家已经看到过很多次  fn main()  函数的使用吧?那么这个函数返回什么呢?

没错, main  函数就返回这个单元类型  (),你不能说  main  函数无返回值,因为没有返回值的函数在 Rust 中是有单独的定义的:发散函数,顾名思义,无法收敛的函数。

例如常见的  println!()  的返回值也是单元类型  ()

再比如,你可以用  ()  作为  map  的值,表示我们不关注具体的值,只关注  key。 这种用法和 Go 语言的  struct{} **  类似,可以作为一个值用来占位,但是完全不占用**任何内存。

类型推导与标注

与  Python、 Javascript  等动态语言不同,Rust 是一门静态类型语言,也就是编译器必须在编译期知道我们所有变量的类型,但这不意味着你需要为每个变量指定类型,因为Rust 编译器很聪明,它可以根据变量的值和上下文中的使用方式来自动推导出变量的类型,同时编译器也不够聪明,在某些情况下,它无法推导出变量类型,需要手动去给予一个类型标注,关于这一点在 Rust 语言初印象中有过展示。

来看段代码:

let guess = "42".parse().expect("Not a number!");

先忽略  .parse().expect..  部分,这段代码的目的是将字符串  "42"  进行解析,而编译器在这里无法推导出我们想要的类型:整数?浮点数?字符串?因此编译器会报错:

Compiling playground v0.0.1 (/playground)
error[E0282]: type annotations needed
 --> src/main.rs:4:5
  |
4 | let guess = "42".parse().expect("Not a number!");
  |     ^^^^^ consider giving `guess` a type

For more information about this error, try `rustc --explain E0282`.
error: could not compile `playground` due to previous erro

因此我们需要提供给编译器更多的信息,例如给  guess  变量一个显式的类型标注: let guess: i32 = ...  或者  "42".parse::<i32>() 。