Bootstrap

Rust语言基础知识详解【二】

1.变量绑定与解构

在 Rust 语言中,变量的绑定与解绑是其所有权系统的核心机制,直接关系到内存安全和资源管理。以下是详细的介绍:


变量绑定

1. 基本语法

在 Rust 中,变量通过 let 关键字绑定到值:

let x = 5;      // 不可变绑定
let mut y = 10; // 可变绑定(需显式声明 `mut`)
  • 不可变绑定(默认):变量不可修改,确保安全性和线程安全。

  • 可变绑定mut 修饰):允许修改变量的值。

2. 类型推导与显式标注

Rust 支持类型推导,也可显式标注类型:

let x: i32 = 5;          // 显式类型标注
let y = vec![1, 2, 3];   // 类型推导为 Vec<i32>

3. 变量遮蔽(Shadowing)

允许在同一作用域中重新绑定同名变量,旧变量会被“遮蔽”(不是修改):这和 mut 变量的使用是不同的,第二个 let 生成了完全不同的新变量,两个变量只是恰好拥有同样的名称,涉及一次内存对象的再分配 ,而 mut 声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好。

变量遮蔽的用处在于,如果你在某个作用域内无需再使用之前的变量(在被遮蔽后,无法再访问到之前的同名变量),就可以重复的使用变量名字,而不用绞尽脑汁去想更多的名字。

let x = 5;
let x = x + 1; // 新变量 x 的类型和值均可改变

变量的解绑

Rust 中没有显式的“解绑”操作,但通过以下机制自动或间接实现:

1. 作用域结束

当变量离开作用域时,其占用的资源会自动释放(调用 drop 方法):

{
    let s = String::from("hello"); // s 进入作用域
    // 使用 s
} // s 离开作用域,内存自动释放(解绑)

2. 所有权转移(Move 语义)

当变量赋值给其他变量或作为参数传递时,所有权可能发生转移,原变量失效:

let s1 = String::from("hello");
let s2 = s1; // s1 的所有权转移到 s2,s1 被解绑(不能再使用)
// println!("{}", s1); // 编译错误:value borrowed after move

3. 显式释放资源

通过 std::mem::drop 函数提前释放资源:

let s = String::from("data");
drop(s); // 强制释放 s 的内存
// println!("{}", s); // 错误:s 已被解绑

4. 模式匹配解构

matchif let 中解构数据时,变量会被绑定或解绑:

let tuple = (1, "hello");
let (num, text) = tuple; // 解构绑定到 num 和 tex

关键概念与示例

1. 所有权与解绑

  • 每个值有唯一的所有者。

  • 所有权转移后,原变量失效(解绑):

    let v1 = vec![1, 2, 3];
    let v2 = v1; // v1 的所有权转移给 v2
    // println!("{:?}", v1); // 错误!

2. 借用(Borrowing)

  • 通过引用(&&mut)借用值,不转移所有权:

    let s = String::from("hello");
    let len = calculate_length(&s); // s 仍有效

3. Copy 类型

  • 基本类型(如 i32)实现 Copy trait,赋值时复制值而非转移所有权:

    let a = 5;
    let b = a; // 复制值,a 仍有效

2.基本类型

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

  • 数值类型:有符号整数 (i8, i16, i32, i64, isize)、 无符号整数 (u8, u16, u32, u64, usize) 、浮点数 (f32, f64)、以及有理数、复数

  • 字符串:字符串字面量和字符串切片 &str

  • 布尔类型:truefalse

  • 字符类型:表示单个 Unicode 字符,存储为 4 个字节

  • 单元类型:即 () ,其唯一的值也是 ()

类型推导与标注

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

来看段代码:

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

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

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^ consider giving `guess` a type

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

整数类型

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

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

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

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

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

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

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

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

整型溢出

假设有一个 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_* 方法,可以限定计算后的结果不超过目标类型的最大值或低于最小值,例如:

assert_eq!(100u8.saturating_add(1), 101);
assert_eq!(u8::MAX.saturating_add(127), u8::MAX);

下面是一个演示wrapping_*方法的示例:

fn main() {
    let a : u8 = 255;
    let b = a.wrapping_add(20);
    println!("{}", b);  // 19
}

浮点类型

浮点类型数字 是带有小数点的数字,在 Rust 中浮点类型数字也有两种基本类型: f32f64,分别为 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 特征,因此这意味着你无法使用浮点数作为 HashMapKey,来存储键值对,但是作为对比,Rust 的整数类型、字符串类型、布尔类型都实现了该特征,因此可以作为 HashMapKey

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

  • 避免在浮点数上测试相等性

  • 当结果在数学上可能存在未定义时,需要格外的小心

来看个小例子:

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.24 结尾,但是 0.33结尾,这个细微区别导致 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!("未定义的数学行为")
    }
}

🐟:这里介绍一下rust中的断言宏:

在 Rust 中,断言(Assertion)是通过宏来实现的,主要用于开发阶段的调试和测试。以下是 Rust 中常见断言宏的详细用法:


  1. 基础断言 assert!

  • 作用: 检查一个布尔条件是否为 true,若为 false 则触发 panic。

  • 语法:

    assert!(condition);
    assert!(condition, "错误信息: {}", value); // 支持格式化输出
  • 示例:

    fn main() {
        let x = 10;
        assert!(x > 5); // 通过,无输出
        assert!(x % 2 == 0, "{} 不是偶数", x); // 通过
        // assert!(x < 5); // 触发 panic,报错并终止程序
    }
  1. 相等性断言 assert_eq!assert_ne!

  • 作用: 比较两个值是否相等(assert_eq!)或不相等(assert_ne!)。

  • 语法:

    assert_eq!(a, b);
    assert_ne!(a, b);
    // 支持自定义错误信息
    assert_eq!(a, b, "错误信息: {}", value);
  • 示例:

    fn add(a: i32, b: i32) -> i32 {
        a + b
    }
    ​
    #[test]
    fn test_add() {
        assert_eq!(add(2, 2), 4); // 通过
        assert_ne!(add(2, 2), 5); // 通过
        // assert_eq!(add(2, 2), 5); // 触发 panic,显示左右值
    }

数字运算

Rust 支持所有数字类型的基本数学运算:加法、减法、乘法、除法和取模运算。下面代码各使用一条 let 语句来说明相应运算的用法:

fn main() {
    // 加法
    let sum = 5 + 10;
​
    // 减法
    let difference = 95.5 - 4.3;
​
    // 乘法
    let product = 4 * 30;
​
    // 除法
    let quotient = 56.7 / 32.2;
​
    // 求余
    let remainder = 43 % 5;
}

这些语句中的每个表达式都使用了数学运算符,并且计算结果为一个值,然后绑定到一个变量上。

位运算

Rust 的位运算基本上和其他语言一样

运算符说明
& 位与相同位置均为1时则为1,否则为0
| 位或相同位置只要有1时则为1,否则为0
^ 异或相同位置不相同则为1,相同则为0
! 位非把位中的0和1相互取反,即0置为1,1置为0
<< 左移所有位向左移动指定位数,右位补0
>> 右移所有位向右移动指定位数,带符号移动(正数补0,负数补1)
fn main() {
    // 无符号8位整数,二进制为00000010
    let a: u8 = 2; // 也可以写 let a: u8 = 0b_0000_0010;
​
    // 二进制为00000011
    let b: u8 = 3;
​
    // {:08b}:左高右低输出二进制01,不足8位则高位补0
    println!("a value is        {:08b}", a);
​
    println!("b value is        {:08b}", b);
​
    println!("(a & b) value is  {:08b}", a & b);
​
    println!("(a | b) value is  {:08b}", a | b);
​
    println!("(a ^ b) value is  {:08b}", a ^ b);
​
    println!("(!b) value is     {:08b}", !b);
​
    println!("(a << b) value is {:08b}", a << b);
​
    println!("(a >> b) value is {:08b}", a >> b);
​
    let mut a = a;
    // 注意这些计算符除了!之外都可以加上=进行赋值 (因为!=要用来判断不等于)
    a <<= b;
    println!("(a << b) value is {:08b}", a);
}

序列(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);
}

3.字符、布尔、单元类型

字符类型(char)

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

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

如果大家是从有年代感的编程语言过来,可能会大喊一声:这 XX 叫字符?是的,在 Rust 语言中这些都是字符,Rust 的字符不仅仅是 ASCII,所有的 Unicode 值都可以作为 Rust 字符,包括单个的中文、日文、韩文、emoji 表情符号等等,都是合法的字符类型。Unicode 值的范围从 U+0000 ~ U+D7FFU+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 中的布尔类型有两个可能的值:truefalse,布尔值占用内存的大小为 1 个字节:

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

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

单元类型

单元类型就是 () ,只能说,再不起眼的东西,都有其用途,那么这个函数返回什么呢?

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

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

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

;