包和模块

rust有4层:

  • 工作空间(workspace): Set<packages>
  • 项目(packages): 由cargo管理,这是项目层级,可以看作Set<crate>,特征是有Cargo.toml。一个package只能有一个lib crate,但是可以有多个binary crate
  • 包(crate): 这是最小编译单元。层级比go的package大,类似java的Sub-module,可以看作独立的项目,但是crate和文件树没有强对应关系,本质是一个入口(lib或binary)+依赖链的模式。同样类似的Set<mod>
  • 模块(module): 代码构成单元,对象命名空间

Tip

理解语义即可,rust的命名简直瞎搞。

package

二进制package

表示默认的crate是二进制的,也就是src/main.rs是入口文件,此时crate的名称同package名。

cargo new my-project

库package

crate是lib模式,入口文件为src/lib.rs,此时crate的名称依然是package名。

cargo new my-lib --lib

Tip

lib package可以和binary package同名,没有影响

典型package

├── Cargo.toml
├── Cargo.lock
├── src
│   ├── main.rs  # binary crate,和package同名
│   ├── lib.rs   # lib crate, 和package同名
│   └── bin
│       └── main1.rs # binary crate,名称就是文件名
│       └── main2.rs # 同上
├── tests
│   └── some_integration_tests.rs
├── benches
│   └── simple_bench.rs
└── examples
    └── simple_example.rs

模块

mod类型

  • 文件mod: 只有在其他文件中通过显式mod声明时,这个被声明的文件才变成文件mod,否则它就是一个普通的文件,rust甚至不会看见它。这种mod声明本质是建立链接。
  • mod block: 直接声明的mod块

这2个mod在使用上没有任何区别,只是为了方便理解语义。

要点:

  1. 模块必须在crate的语境下
  2. rust的模块本质是编译链,这是和其他语言最大的区别,比如go,ts,python,lua等的”模块”系统,和文件系统深度绑定。rust的模块跟随编译链走,从编译链的入口模块开始,最后形成一棵以入口模块为根的模块树(不是标准多叉树),所以广义的crate指的实际是这棵树。
  3. 目录必须转换为模块才能被找到,有2种方式:
    1. <dir>/mod.rs。对,就是python/lua的模式
    2. <dir>.rs,创建一个同级同名的rs文件
  4. 文件模块使用前,必须显式使用mod来声明(链接到树上),rust完全不会主动去文件系统中找
  5. 模块和文件不是一对一,一个文件可以定义多个mod,这个mod可以是文件mod或者mod block
  6. mod可以嵌套,当然,不然目录也没必要转换为mod了。mod block同样支持嵌套。
  7. 只有同一个作用域的对象(mod也是对象)才是相互可见的,否则必须显式pub修饰。

Tip

这里也能看出来rust不太喜欢主动替用户做决定。

use

"use" identifier{"::"identifier}[ "as" identifier]";";

很多语言都是直接从文件树上找,但是rust必须先用mod形成树,然后use才能从这棵树上找对象。

use第三方crate

当第三方依赖添加进来后,本质是它的crate暴露在了项目crate的runtime,默认暴露的还有内置crate,比如std。

所以可以总结use的含义: 从runtime中的crate和当前模块树搜索对象。

方式

  1. use的对象在当前模块的作用域中,属于当前模块了,当然也可以暴露出去,使用pub修饰即可
  2. use多个,可以使用{obj,...}表示
  3. 当self出现在use的路径中时,它表示其前面路径本身,use std::io::{self, Write};中的self即std::io
  4. *导入所有,use std::collections::*;

绝对路径和相对路径

在use中,关键字crate特指当前crate的根mod,类似/的意义。

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}
 
pub fn eat_at_restaurant() {
    // 绝对路径
    crate::front_of_house::hosting::add_to_waitlist();
 
    // 相对路径
    front_of_house::hosting::add_to_waitlist();
}

最佳实践

并没有规定和建议强制使用哪种形式,但是有个原则,局部的用相对,全局的用绝对

特殊标识

  • super: 类似../的概念,用于嵌套mod
  • self: 表示当前mod

可见性

pub定义可见,注意每一层都要显式暴露。

还可以更加细化:

  • pub 意味着可见性无任何限制
  • pub(crate) 表示在当前包可见
  • pub(self) 在当前模块可见,和什么都不修饰的private行为一致,当前和子模块中可以访问。这个主要用于宏编程的显式可见性设置。
  • pub(super) 在父模块可见
  • pub(in ) 表示在某个路径代表的模块中可见,其中 path 必须是父模块或者祖先模块

基础

变量

  1. rust的变量,默认是immutable的,必须显式的使用mut,比如let mut a : i32 = 1来声明。

  2. rust也可以多次let同一个变量名,用新变量遮蔽(shadow)前面作用域的同名变量。这和重新赋值不同,这是重新分配了一次内存(正常情况下),后面对这个变量的使用,都使用的是最新地址,而原来的那个内存不会因为被遮蔽而释放,它依然遵循常规的释放逻辑。

  3. rust支持非常方便的解构赋值

    let (a, b, c):(i32, i32, i32) = (1, 2, 3);
  4. let绑定默认分配在栈上(或寄存器)——这是语言语义。rust、go、js 的局部变量默认都在栈上,区别只在”会不会被自动挪到堆”:

    • go:逃逸分析发现局部量逃逸出当前作用域时,自动把它挪到堆;
    • js:引用变量默认在栈;对象永远在堆(语言语义,与栈根无关);引用变量被闭包捕获时整个环境记录会挪到堆;
    • rust:两者都没有,从不自动挪堆,想让数据落堆必须显式装箱(Box/Rc/Vec等)。

    同时要区分绑定本身与绑定指向的数据:let a = String::from("..")时a在栈上,但它内部指针指向的字符数据在堆上;a想整体落堆只能显式装箱,装箱后栈根依然存在。唯一的例外是编译器优化(变量被完全内联/优化掉),那是优化结果,不是语言保证。

基本类型

类型的标识有多种形式, 以下表示的含义完全相同:

let a: i32 = 1; // 1此时就是i32,如果a: i64 = 1,那1就是i64
let a: i32 = 1i32; // 显式指定
let a: i32 = 1_i32; // 显式指定
let a = 1i32; // a被推断为i32
let a = 1_i32; // a被推断为i32

数值类型

整数

可以参考c.md > chapter4的整数类型.

同样isize和usize大小,取决于CPU的位数。

Note

rust的整数表示,比如i8, i16, i32, u8, u16, u32, f32, f64等非常直观且容易记忆。

默认使用i32

溢出

这是所有静态类型语言都会遇到的问题。
有符号整数的大小: -2^(n-1) ~ 2^(n-1)-1
无符号整数的大小: 0 ~ 2^n-1
rust的溢出处理与构建模式相关:

  • debug构建(默认):溢出会直接panic,比如255u8 + 1u8会崩溃而不是变成0u8
  • release构建:默认按”补码循环溢出”逻辑,表现上就是高位丢弃,255u8 + 1u8 变成0u8

也可以显式处理,给方法加上对应的前缀:

  • 使用 wrapping_* 方法在所有模式下都按照补码循环溢出规则处理,例如 wrapping_add
  • 如果使用 checked_* 方法时发生溢出,则返回 None 值
  • 使用 overflowing_* 方法返回该值和一个指示是否存在溢出的布尔值
  • 使用 saturating_* 方法,可以限定计算后的结果不超过目标类型的最大值或低于最小值
// all is true
assert_eq!(255u8.wrapping_add(1), 0);
assert_eq!(255u8.checked_add(1), None);
assert_eq!(255u8.overflowing_add(1), (0, true));
assert_eq!(u8::MAX.saturating_add(1), u8::MAX);

浮点数

默认为f64
基于IEEE754实现,可以参考IEEE754浮点数标准,Java SE > 浮点数存储标准IEEE-754也进行了简单说明。
根据标准也可以知道,二进制是无法精确的表示十进制的浮点数的,所以计算机的浮点数都是近似的表达,书中也明确提到:

  • 避免在浮点数上测试相等性。因为2个不同的小数的浮点数位可能相同,毕竟精度会丢失。

    assert!(0.1 + 0.2 == 0.3); // assertion failed
    assert!(0.1f32 + 0.2f32 == 0.3f32); // assertion successed,32位的近似表达又是一致的
    assert!(0.1 + 0.1 == 0.2); // assertion true,0.1 + 0.1的近似表达 == 0.2的近似表达
  • 当结果在数学上可能存在未定义时,需要格外的小心

Note

基本使用IEEE754实现的,都有这个问题,包括python, java等等

更直观的例子:
在浮点表达上,32位由于精度较差,刚好0.1 + 0.2和0.3的近似表达就是一致的,但是64位则不一样,因为它们的精度更高。

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()); // 3e99999a
println!("       0.3: {:x}", (abc.2).to_bits()); // 3e99999a
println!("xyz (f64)");
println!(" 0.1 + 0.2: {:x}", (xyz.0 + xyz.1).to_bits()); // 3fd3333333333334
println!("       0.3: {:x}", (xyz.2).to_bits()); // 3fd3333333333333
assert!(abc.0 + abc.1 == abc.2); // successed
assert!(xyz.0 + xyz.1 == xyz.2); // failed
NaN

数学上未定义的结果,被处理为NaN(not a number),NaN不能被比较。

基本运算

  1. 只有同类型的才能进行运算,没有所谓的隐式转换。
  2. 长数字,比如100,000,可以表示为100_000。

位运算

见c.md > chapter15中第4点。

Caution

移位不能超过类型的位数,比如u8, <<, >>最多只能移7位

Range

模型基本类似python的range

  • ..: 不包含右边界
  • ..=: 包含右边界

字符本质是u32(c是u8),所以也可以用于range。

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

As 类型转换

let a: i32 = 10;
let b: u16 = 100;
let c = a as f64; // 将 i32 转换为 f64
let d = b as i32; // 将 u16 转换为 i32
let e = 3.14_f32 as i32; // 将浮点数直接截断为整数,结果为 3

这个是比较粗暴的转换,如果需要更安全的可以使用try_into()

有理数和复数

本质就是非具体的数值表达,而是”结构”表达, 毕竟计算机无法表示所有数(连浮点数都无法准确表示)。结构表达可以规避一些问题比如精度丢失,长度太长等,比如1/3,根本无法准确表示,但是却可以直接用1/3表示。

Note

对于很多精度要求高的场景,比如财务,用i64,f64有时候是无法表达的,这些类型都有上限。

甚至有些数自然界都没有,比如复数,只能结构表达。

Tip

num库提供了复数类型,可以用num::complex::Complex表示。

字符、布尔、单元类型

  1. char. 不同于c的一个字节,rust中的char是一个Unicode码点(严格来说是标量值,筛选过的码点),用4个字节表示,所以可以表示任意Unicode字符,包括汉字。和go > 字符非常相似。

  2. bool. true or false, 一个字节表示

  3. 单元类型. 用()表示,且拥有唯一的值(),可以用于函数返回值,比如fn main()就是返回()。()占用0字节,对其取址得到的是无实际内存的悬垂地址,可以看作是”虚拟的”。

    Todo

    作用像None,void, 但是为什么用empty tuple?

语句和表达式

  • 语句:let a = 1;
  • 表达式:1 + 2, 如果加上;就会变成语句

表达式总是会返回值,语句则没有。

下面的都是表达式:

  • 1, 就返回1
  • { let x = 1; x + 1 }, 没错,语句块也是,所以可以把函数体也当作一个表达式。最后一个表达式的返回值就是语句块的返回值,如果块的最后不是表达式,则使用隐含的()作为表达式,也就是返回()。
  • if xxx { 1 } else { 2 }, 也是表达式
  • some_fn()

Tip

所以函数可以省略return,因为默认会返回最后一个表达式

函数

没有什么特殊的,关键字,函数名,形参列表,返回类型,函数体。

fn add(a: i32, b: i32) -> i32 {
    a + b
}

没有或显式使用()作为返回类型,表示函数返回(),该类型的唯一值。

Typescript和Python都有Never的概念,作为返回类型时,表示永不返回,也就是不会执行到return(比如异常,死循环等等),rust用!表示同样的概念(这是什么奇怪的关键字想法)。

fn dead_end() -> ! {
  panic!("你已经到了穷途末路,崩溃吧!");
}

所有权和借用

内存管理方式

  • 手动GC: c, c++
  • 自动GC: go, java, python等等
  • 通过所有权管理: rust

所有权管理的本质,依然是用户主导,只是释放内存的逻辑被融合进了语义中,不再是显式的释放,完全依靠编译器去实现,简单的说c是纯手动,rust则提供了开关。

与自动GC的本质区别

自动GC靠运行时扫描可达性找根并回收;rust的所有权是编译期静态规则:没有运行时扫描,也没有隐式逃逸。go的逃逸分析会把逃逸的局部量自动挪到堆;js则是对象永远在堆、引用变量默认在栈(存引用的那个变量本身和rust一样在栈上)。这些都和rust不同——rust中堆分配必须显式表达(Box/Rc/Vec等),“变量最终要不要上堆”,rust从不替用户决定。

内存分区

一般的程序运行时大致有3个内存区域:

  • 栈: 函数的调用都会进行入栈和出栈,栈用于管理地址跳转逻辑,变量的内存分配,返回值等等。具体查看stack;
  • 堆: 操作系统视角,就是一块内存区域,用于存储复杂数据。栈一般是比较小的。
  • 数据段: 静态数据,全局数据,一般只读,随着程序加载写入.

类型与内存分配

在不同的语言中,都有这种逻辑,根据类型,构造的对象,一般会直接分配1个或2个内存,即所谓的c > 引用类型和值类型。
rust的所有权、借用、共享等对象之间的关系,都和类型强关联,通过类型来实现。

内存对象之间的关系

这里的内存对象指运行时分配的内存地址内容,且和内存位置无关,不管栈、堆还是数据段。

内存对象之间的关系,本质是资源管理的方式具现,比如:

  • 所有权: 我拥有你,我管理你,我的周期结束,你的周期也结束
  • 借用:所有权没有转移,我们建立临时的关系,但是我的生命周期不允许超过你
  • 共享:我们都有你的所有权,通过引用计数的方式管理资源

说明

本文把分配的内存对象,其中的入口对象,称为访问入口对象

所有权

在处理2个内存对象类型的资源释放的问题时,传统的多个引用(多个所有者),释放时必须检查所有引用者的生命周期是否都结束了,否则会造成空指针,而如果有引用没有正确的释放,又会造成内存泄露。

rust引入所有权,明确管理者,且通常只有一个,这样管理就变的非常简单,我结束,你释放。

那内存对象之间怎么建立什么样的关系,谁来决定?

类型,类型构造对象(逻辑上的对象),类型决定了内存分配方式,类型决定了内存对象之间的关系。 总之类型决定语义,类型决定关系。

Tip

逻辑对象,类型构建的内存对象的统一,狭义上指它的访问入口对象。

类型构造形式对象关系代码表现
i32标量各自拥有(Copy)let b = a; // a,b 都有效
Box<T>拥有指针唯一拥有(Move)let b = a; // a 失效
&T不可变引用借用(只读)let r = &a; // 不拥有
&mut T可变引用独占借用(可修改)let r = &mut a; // 独占
Rc<T>共享指针共享拥有Rc::clone(&a) // 计数+1
Option<T>可选可能有/无Some(v) / None
Vec<T>集合拥有多个vec![a,b,c] // 拥有所有
*mut T裸指针无关系(unsafe)自己管理

根据上表的内容,不同的类型意味着不同的关系和管理模式。

  • 通常分配一个内存的,它的所有者就是自己,它的赋值总是COPY,它不管理其他资源,它的释放就是简单的释放自己
  • 通常分配两个内存的,它就有所有者了,它的赋值总是MOVE,转移了所有者,释放它,会同时释放它拥有的。

而借用和共享等的形式,也有对应的类型,它们也涉及多个内存对象:

  • 借用,给予访问权限,但是编译器不允许借用者的生命周期超过被借用者,所以这是一个局部优化措施
  • 共享,引用计数的形式,计数归0,就释放。这是妥协?

Tip

所有权的丢失,本质上就是这个地址被标记为未初始化了,不可访问,但是实际上原始内容依然在

所有权是一条责任链,不是位置映射

“栈存地址 → 对象在堆 → 出栈 → 回收”这种直觉,只是链长为2的最简特例。真正的规则:每个值都有一条”由谁负责drop”的责任链,链有多长,drop就递归多长。拥有者本身完全可以出现在堆上,不需要任何额外机制:

// 链长3:栈root → 堆Box → 堆Box → 堆数据
let root: Box<Box<Vec<u8>>> = Box::new(Box::new(vec![1, 2, 3]));
 
// 拥有者(入口字段)在堆上
struct Handle { ptr: Box<Thing> }
let h: Box<Handle> = Box::new(Handle { ptr: Box::new(Thing::new()) });

回收时从根递归:root出栈 → drop外层Box → drop内层Box → drop Vec数据。堆上的”地址”不是自己”出栈”,而是被上一级drop连带释放。

根(drop触发器)必须在哪?

所有权链必须以一个”有确定生命周期终点”的实体收尾,由它触发drop。堆对象没有自主作用域,不可能自己当触发器——否则它又在等下一个触发器,无限回归。所以触发器的落点只有三类:

触发器位置说明
作用域栈退出作用域执行drop(最典型)
全局数据段static放在数据段(.data/.bss/.rodata),程序加载时分配、进程结束才销毁;常用Box::leak得到&'static。const是编译期内联值,不占运行时内存
引用计数堆(计数器)Rc/Arc在堆上维护引用计数;每个持有者离开作用域时计数减1,归零(最后一个持有者消失)时才触发drop。触发者仍是栈上某个持有者的作用域结束

推论:

  • 没有根的堆内存不自动回收(泄漏),rust不替用户找根,这就是与GC的分水岭。
  • 所有权链必须有穷且无环——循环引用(环)无法用普通所有权表达,只能Rc计数(环会泄漏)或Weak破环。
值类型住栈,引用类型住堆:值类型上堆需封装

这和所有语言都有的值类型/引用类型逻辑一致:根据类型构造的对象,一般会直接分配1或2块内存。

  • 值类型(i32、struct、数组等):值本体直接分配在栈上(或内嵌在父对象里)。let a = 1,1就在栈上。值类型的值本体不会直接出现在堆上——想让它住堆,必须封装(Box::new(5)装箱、放进Vec等容器)。
  • 引用类型(Box/Vec/Rc/引用/指针指向的对象):分配的是堆内存,栈上只有一个指针/句柄作入口,对象本体驻留在堆上。瘦指针、胖指针指向的对象,值本体都是直接在堆上的。

如果非要让一个值类型(如i32)住进堆,又不封装(不用Box/Vec等安全容器),safe代码没有途径,只能unsafe:std::alloc的alloc分配一块堆内存,ptr::write把值写进去,得到一个*mut i32——值本体在堆上,栈上仍是裸指针作入口,且必须手工dealloc否则泄漏。

Tip

同一个值,不同的类型,有不同的逻辑表达,裸指针也是指针,但是rust并不做额外的控制,它此时就像c的指针一样,完全由用户管理。

unsafe和裸指针:移入/复制地址≠复制所有权

unsafe绕过编译器的所有权检查,做更底层的操作。但它不改变所有权责任链的本质——只是把”何时、由谁触发drop”的责任从编译器转交给程序员手工保证。关键认知:

  • 移入地址 ≠ 复制所有权:unsafe可以把栈上瘦指针的位模式移入堆上槽位(alloc+write),write是move,a被移走,所有权随之转移到堆上槽位,值本身仍只有一个所有者。若用ptr::read强制复制位模式,才会出现两个地址副本指向同一对象,但所有权仍只有一个——只能drop一次,否则双重释放;若都不drop则泄漏。谁负责,由程序员指定。
  • 裸指针不拥有:*mut T只是存地址,不承担任何drop责任,也不被编译器跟踪。堆内存必须有一个根(栈上指针/static)引用着,否则立即泄漏——rust不替无根的堆内存回收。
  • 堆上放值的访问入口:unsafe可以把任何值(包括瘦指针)搬进堆,但根无法堆化——触发drop的代码最终仍运行在某个栈调用里。unsafe搬移的本质是破坏约束、转移责任,而不是创造新的回收机制。
use std::alloc::{alloc, dealloc, Layout};
 
let a: Box<String> = Box::new(String::from("hi"));
unsafe {
    let layout = Layout::new::<Box<String>>();
    let heap_slot = alloc(layout) as *mut Box<String>; // 堆上造一个槽位
    heap_slot.write(a);   // move:把a的位模式(地址)移进槽位,a被move走
    // 之后只有堆上这份地址指向String,但仍需手工drop一次:
    drop(heap_slot.read());      // 释放String本体
    dealloc(heap_slot.cast(), layout); // 再手动释放槽位本身
}

注意这里的两步释放:对象本体一次 + 容纳它的槽位一次,漏一步就泄漏,重复一次就double free——全是程序员的责任。

引用和借用

如果只有所有权转移,那程序的表达能力将非常弱,需要大量的控制代码,心智负担极大。有些轻量使用场景,我只是想临时获取引用,这时候使用简单的“借用”就可以了。

Tip

这里也可以看出来,一个普通的逻辑行为,都做了不同的表达,实际上整个rust中有大量类似的行为,在其他语言中普通的操作,到这里都进行了细化,以便更精确的控制。理解上要困难得多,对比c,多了大量的抽象控制表达,对比go,把控制权交给了用户。

运行时数据相同,语义靠类型

Box<T>和&T在运行时都是同一个地址(thin pointer),没有任何标记区分彼此。区分它们的是编译期规则:Box拥有→必须且能够drop、可move;&T借用→不负责drop、不可move、生命周期受约束。所有权不是”栈里多存了什么”,而是一整套由类型定义、编译期强制的责任/权限划分,运行时它们可能就是同一个数。

唯一的"运行时额外信息"

  • 胖指针自带元数据:&[T]/&str是”地址+长度”,&dyn Trait是”地址+vtable”。
  • 变量可能被提前drop时,编译器可能放一个隐藏的drop flag(bool),现代编译优化通常能消除它。
    除此之外,回收所需的信息全部在编译期类型里,运行时不需要标记。
取址
fn main() {
    let x = 5;
    let y = &x;
 
    assert_eq!(5, x);
    assert_eq!(5, *y);
}

&取址操作,注意这和c,go的不太一样的是,会根据类型获取到瘦指针或胖指针。

Tip

瘦指针: 纯地址
胖指针: 地址 + 其他元信息

比如go的slice,map, string,在栈中实际都是所谓胖指针,但是go并不把它们当作指针,而在rust中它们都是指针。

胖指针的元数据是len还是vtable,类型本身没有任何标记——运行时无从分辨,全凭类型在编译期指定语义。甚至”地址+一个数”的布局,可被&[u8]和自定义的”起始地址+容量”结构共享,语义却完全不同。

&取址后的类型是对应的&<type>,这个和*<type>裸指针的区别是,它是安全的,而*<type>是不安全的,也就是不保证对应的地址有内容。但是它们2者表达的是一个内容,即指针类型(包括胖指针)。

& 指向哪:由目标类型决定(易混点)

& 字面语义和 C 一样:取被引用对象所在位置的地址。C 的 &a 永远是固定类型、无隐式转换;Rust 的引用携带类型,且可被编译器自动变形(deref coercion),变形后指向的目标变了:

let a = String::from("hello");
let b = &a;                // b: &String,瘦指针,指向【栈上 a 的证书】
let c: &str = &a;          // Deref 转换:胖指针,ptr 直接指向【堆上数据】
  • &a 是指向栈上 24 字节证书的瘦指针(和 C 的取址一致)
  • let c: &str = &a 赋值时触发 deref coercion:编译器解引用 a,把证书里的 ptr+len 复制组装成胖指针,丢弃 cap——c 的 ptr 与 a.as_ptr() 相同(已验证),访问不再经过 a
  • 因此判断”& 指向哪”看上下文要求的引用类型: &String 指向证书, &str 指向数据。
借用

通过取址,获得访问权限,就是“借用”。

fn main() {
    let s1 = String::from("hello");
 
    let len = calculate_length(&s1);
 
    println!("The length of '{}' is {}.", s1, len);
}
 
fn calculate_length(s: &String) -> usize {
    s.len() // s -> s1,操作时自动解引用
}

这看起来像间接指针,实际也是,但是表达的逻辑是借用。

可变引用(借用)
fn main() {
    let mut s = String::from("hello"); // 这里也必须是可修改的
 
    change(&mut s); // &mut 可变借用
}
 
fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

注意: 同一作用域,同一数据只能有一个可变,且可变和不可变不能同时存在。

悬垂引用

不管用什么概念来包装,此时既然是取址,那就可能存在被取址的释放了,但是它的地址还存在某处,形成空指针。
不过rust在编译阶段就阻止了这种行为,帮你避免了panic。

// 编译错误
fn main() {
	// dangle执行完,s就释放了,s的地址返回给了reference_to_nothing,它指向不存在的内存
    let reference_to_nothing = dangle();
}
 
fn dangle() -> &String {
    let s = String::from("hello");
 
    &s
}
借用的生命周期到”最后一次使用”(NLL)

借用结束于最后一次使用,不是作用域结束。

let mut v = vec![1, 2, 3];
let i = &v[0];
println!("{i}");     // i 最后一次使用 → 借用终结
v.push(4);           // ✅ 合法:i 已死
 
// 顺序换一下就报错:
let i2 = &v[0];
v.push(4);           // ❌ E0502:i2 还没用完,发生悬垂
println!("{i2}");    // ← 最后一次使用在这里

Tip

push 是否扩容是运行时决策(看 cap/len),编译器不预测——即使容量充足、push 不会迁移内存,只要存在元素引用就禁止一切可变操作(即假定一定会迁移,宁可错杀,不让悬垂发生)

切片(slice)

是什么

连续数据的只读视图,零拷贝,和原数据共享底层内存。

  • 语法:&s[0..5](.. 不含右边界),变体 &s[..5] / &s[3..] / &s[..] / &s[0..=4]
  • 字符串切片类型是 &str(胖指针:地址+长度);数组切片 &arr[1..3] 类型 &[T]
  • 字符串字面量 "hello" 本身就是 &str,指向数据段

典型用法

// 经典场景:返回第一个单词(不复制,返回视图)
fn first_word(s: &str) -> &str {
    for (i, &b) in s.as_bytes().iter().enumerate() {
        if b == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}
 
fn main() {
    // 字符串切片
    let s = String::from("hello world");
    let hello = &s[..5];        // "hello"
    let world = &s[6..];        // "world"
    println!("{hello} {world}");
 
    // &str 参数:字面量和 &String 都能传(自动转换)
    println!("{}", first_word("hello world"));  // hello
    println!("{}", first_word(&s));             // hello
 
    // 数组切片 + 遍历求和
    let arr = [1, 2, 3, 4, 5];
    let sl: &[i32] = &arr[1..4];       // [2, 3, 4]
    let sum: i32 = sl.iter().sum();    // 9
 
    // 可变切片:改元素值(不能增删长度)
    let mut m = [1, 2, 3];
    let ms = &mut m[..2];
    ms[0] = 99;                        // m 变为 [99, 2, 3]
 
    // 常见 panic:越界 / 切到字符中间
    // let _ = &s[0..100];             // out of bounds
    // let _ = &"你好"[0..1];          // not a char boundary
}

要点

  1. str 是 unsized 类型(DST):长度不在类型里,不能单独作为变量,必须通过 &str/Box<str> 使用。[u8; 5] 长度在类型里,可单独存在
  2. 切片元素是 u8 字节,不是 char。char 是 Unicode 标量值,固定 4 字节,与 UTF-8 无关;UTF-8 编码 1~4 字节变长。str = 字节流 + UTF-8 合法性承诺([u8] 无此承诺)
  3. 切片是借用 → 视图存在期间原数据不可变(编译期拦截),借用结束于最后一次使用(NLL)
  4. 越界/切到字符中间 → panic。常量下标越界编译期就报错,变量下标运行时检查
  5. 函数参数用 &str 而非 &String:&String 自动转 &str(Deref),字面量直接可传
  6. 数组切片有可变版 &mut [T](改元素值,不能增删长度)

内存分布与瘦/胖指针

  • String 值本体 = 24 字节(ptr+len+cap)直接内联在栈上;&String 是瘦指针(1 个地址);&str 是胖指针(地址+长度)
  • &String → &str 是 Deref 自动转换:解引用拿到句柄,取出 ptr+len 组装胖指针,丢弃 cap,零成本。反向需复制(.to_string()),因 &str 没有 cap
  • 字面量在数据区,String::from 复制到堆

类型/值/指令三层模型(与 C 对比的核心)

信息只有两个存放位置:类型(编译期)或值(运行时数据);指令根据类型知道怎么读值。

  • 长度在类型:int (*)[5] / &[u8; 5] → 运行时是 1 个地址
  • 长度在值:&str → 运行时是 2 个 usize,len 从内存读

数组越界:

  • C:长度编译期算完偏移就扔,运行时无长度信息,越界是 UB(编译不报、运行不查,指令只有偏移量)
  • Rust &[u8; 5]:长度烧进指令(cmp i, 3),常量下标编译期报错,变量下标运行时 panic
  • Rust &str:上限是运行时 len,读内存再检查,越界 panic

控制流

if 是表达式

let n = if cond { 5 } else { 6 };   // 可赋值
// let n = if cond { 5 } else 6;    // ❌ else 分支必须是大括号块(expected `{`)
特性说明
条件必须 bool无 truthy 隐式转换。if number 报 E0308,须显式 number != 0
分支类型必须一致变量类型编译期固定,{5} else {"six"} 报错
替代三目运算符Rust 无 ?:,if 表达式就是替代品(Go 同理),代价是多层花括号

循环

无 C 风格 for (i=0;i<n;i++),只有三种:

循环特点
loop无条件循环,至少执行一次。Rust 特有
while条件循环,条件必须 bool
for遍历,社区首选

loop 的三个独特能力

// 1. break 返回值(整个 loop 作为表达式求值)
let result = loop {
    counter += 1;
    if counter == 10 { break counter * 2; }
};
 
// 2. 循环标签:break 指定跳出哪层
'outer: loop {
    loop {
        break 'outer 42;    // 标签在前,返回值在后
    }
};
 
// 3. 无条件:不 break 就不停(服务主循环惯用)
loop { /* 事件循环 */ }

无 break 的 loop 类型是 !(never type),let x = loop {}; 也能编译

while true {} 会被 rustc lint 警告(denote infinite loops with 'loop {'),死循环请用 loop

for + Range 惯用法

for i in 0..3 { ... }    // 半开区间 [0,3):0,1,2
for i in 0..=3 { ... }   // 闭区间 [0,3]:0,1,2,3
for x in (0..=i).rev() { ... }  // 反转
for _ in 0..i { ... }    // _ 通配:不需要循环变量
  • 遍历集合用 for x in arr,不用 while index < len:索引方式越界 panic 风险 + 每次迭代运行时边界检查拖慢性能;for 消除检查
  • “执行固定次数”场景也用 for + Range,而非 while(社区惯例,TRPL 3.5)

else if 链

  • “第一个命中”语义:命中后不再检查后面的分支(6 同时满足 %3 和 %2,只进第一个)
  • 超过两个 else if 就该考虑 match(TRPL 3.5,第 6 章展开)

结构体

定义与实例化

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}
 
// 实例化:字段顺序任意;字段初始化简写(参数名==字段名时省略)
fn build_user(username: String, email: String) -> User {
    User { active: true, username, email, sign_in_count: 1 }
}
 
let mut u = build_user(String::from("a"), String::from("b"));
u.email = String::from("c");   // 可变性整体声明:不能只让某个字段可变

struct 里存 String 而非 &str:存引用必须声明生命周期,否则 E0106(第 10 章展开)

结构体更新语法 ..:字段级移动

let u2 = User { email: String::from("new"), ..u1 };

按字段逐一定义移动/复制(TRPL 5.1 明说:“更新语法像 = 赋值,因为它移动了数据”):

字段类型..u1 行为u1 之后的状态
显式覆盖的字段与 u1 无关照常可用
.. 补的非 Copy 字段(String)移动该字段失效(E0382)
.. 补的 Copy 字段(bool/u64)复制不受影响

移动的内存模型

  • 移动 = 语义层面的所有权转移:运行时只做值表示的按位复制(String 即栈上 24 字节 ptr+len+cap 的 memcpy,堆数据原地不动、无深拷贝);“转移”的记账由编译器在编译期完成——源变量标为不可用(读即 E0382)、源不再 drop,运行时无所有权标记、无额外动作
  • 原变量在语义上变为不可用(MIR 层标记为未初始化),是否保留栈槽是实现/优化细节,不做承诺:
    • 任何读取 → 编译期 E0382 拦截
    • 可重新赋值(s1 = String::from("b") 合法,因为等于重新初始化)
    • 不会被 drop → 无双重释放
  • 与 C 对比:C 拷贝原变量继续有效,但浅拷贝两份指针 → 双重释放风险;Rust 移动消灭这类问题
  • C/Go 背景纠偏:C/Go 的 struct 是 copy 语义(Go 的 string/slice 还是底层共享的伪拷贝);Rust 的 struct 默认 move,必须显式 #[derive(Copy, Clone)] 才可复制(字段含 String 等有 Drop 的类型则 E0204 不可 Copy)。“存在栈上” ≠ Copy,Copy 是类型显式声明的能力,编译器不按布局推断。整体 move let b = a; 后整个 a 作废(Copy 字段也不能访问);部分移动 let x = a.name; 仅该字段失效
// 验证:移动前后堆地址不变
let s1 = String::from("hello");
let p1 = s1.as_ptr();
let s2 = s1;                       // 移动
println!("{}", p1 == s2.as_ptr()); // true:堆零拷贝,只换主人

栈 vs 堆的"释放":

  • 栈值:若有 Drop 仍会跑 drop(如 String 会 free 堆 buffer),时机跟随最后一个拥有者的作用域结束;栈帧本身随函数返回回收
  • 堆 buffer:drop 释放,时机跟随最后一个拥有者的作用域结束
  • 地址对比(两次调用 &s/as_ptr() 相同)不能当证明,受优化/ASLR/分配器影响;有效证据用 Drop 探针打印(见 panic 章 Guard)

元组结构体 / 类单元结构体

struct Color(i32, i32, i32);   // 元组结构体:有名字无字段名
struct Point(i32, i32, i32);   // 与 Color 不同源即使布局相同
struct AlwaysEqual;            // 类单元结构体:无字段
 
let black = Color(0, 0, 0);
black.0;                       // 点号+索引访问
  • 名义类型:Color/Point 都是三个 i32 但互不兼容(传错 E0308),C 的 typedef 是透明别名
  • 类单元结构体:实现 trait 但无需存数据时使用(第 10 章展开)

打印结构体:Display vs Debug

{} 需要 Display trait(面向终端用户),struct 无默认实现(格式不唯一,Rust 不猜意图)→ E0277。{:?} 用 Debug trait(面向开发者),#[derive(Debug)] 即可:

println!("{rect1:?}");    // Rectangle { width: 30, height: 50 }
println!("{rect1:#?}");   // 美化:字段换行缩进

derive 可派生 trait 不止 Debug(附录 C):Copy/Clone/PartialEq/Hash 等,都是”编译器自动生成标准实现”。

dbg! 宏 vs println!

let width = dbg!(30 * scale);   // 输出: [src/main.rs:10:16] 30 * scale = 60
dbg!(&rect1);                   // 传引用惯用,否则 rect1 被移动
dbg!println!
参数按值接收再归还(返回参数值,可嵌表达式)宏内部取引用(传值/传引用效果相同)
打印源码文本 + 结果 + 文件:行号只打印格式化结果
输出流stderrstdout

dbg! 独有的是:编译期捕获参数的源码 token 打印出来,且把参数值作为返回值交还

注:C 宏是文本层的复制粘贴——编译前先把宏名替换成字符串,再从头解析,所以不懂代码结构(#define SQUARE(x) x*x 传 i+1 会错);Rust 宏是语法层的积木拼装——编译期内直接拼接语法单元(token 树),拼好 parse 成 AST 汇入编译主线.

方法语法(impl 块)

impl Rectangle {
    fn area(&self) -> u32 { self.width * self.height }   // 方法:第一参数必是 self
    fn square(size: u32) -> Self { ... }                 // 关联函数:无 self(静态方法)
}
  • &self = self: &Self 缩写;Self 是 impl 类型别名
  • 三种接收者:&self 只读(最常见)/ &mut self 可改 / self 拿所有权(罕见,防转换后复用原实例)

自动引用/解引用(无 ->)

p1.distance(&p2);     // 自动加 &,等价 (&p1).distance(&p2)
  • 方法调用自动加 &/&mut/* 匹配接收者;因方法有明确的 self 类型,可精确推断只读/可变/所有权
  • 单向:值 → 借用自动(加 &);借用 → 值不行(E0507,借用者无移动资格,须显式 .clone())

关联函数 = 静态方法

  • 类型::函数 调用;Self 类型别名;惯例命名 new(非关键字)
  • 与 Java static/Python @staticmethod 等价,区别是挂在类型上而非类上;不能给外部类型加固有 impl/关联函数(E0116),跨 crate 扩展行为用 trait(孤儿规则 E0117 只限制“外部 trait 为外部类型实现”,第 10 章展开)

多个 impl 块

同一类型可拆多个 impl 块,效果与合并等价;第 10 章泛型/trait 场景有用(基础方法块 + trait 实现块)。

枚举

定义与携带数据

enum Message {
    Quit,                            // 无数据
    Move { x: i32, y: i32 },         // 命名字段
    Write(String),                   // 单值
    ChangeColor(i32, i32, i32),      // 多值
}
  • 变体在类型命名空间(Message::Write),变体名即构造函数
  • 不同变体可存不同形态/数量的数据——struct 做不到(字段固定)
  • 选型判据:数据同时存在 → struct(积类型);数据互斥 → enum(和类型)
  • 来自 ADT(ML/Haskell)传统,与 C/Java 常量 enum 是两个物种;enum+match 是继承体系的替代品

enum 内存布局

  • 分配时机:类型定义不分配内存(编译期概念);构造实例时才在栈上分配
    let q = Message::Quit;                          // 构造:分配(无数据变体也要构造)
    let w = Message::Write(String::from("hi"));     // 构造:分配
    // enum Message { ... }                          // 仅定义:不分配任何内存
  • 分配长度:由 tag(判别值)+ 最大变体的数据决定——小变体住大房子,用不上的空间逻辑不可达
  • 对齐:与 C/Go struct 同源,对齐到最大字段的要求,整体大小是最大对齐的倍数

值位置 vs 模式位置: Message::Quit 在表达式里是构造(分配内存),在 let/match 左侧是匹配图案(零分配)。变体名双重身份,靠位置区分

Option:无 null 世界

enum Option<T> { None, Some(T) }   // prelude 自带
 
let x: i8 = 5;
let y: Option<i8> = Some(5);
// x + y  ❌ E0277:Option<T> 与 T 是不同类型
  • 核心:Rust 无 null,“可能没有值”编码进类型,编译期强制处理;unwrap() 是显式选择 panic(None 时)
  • vs Java Optional:核心区别是 Java 不强制检查——get() 不检查直接取,编译通过、运行时才炸,检查是自愿约定;Rust 的类型隔离让”漏处理”直接是编译错误,不处理根本拿不到里面的值
  • Copy 性随 T:T: Copy 则 Option<T> 是 Copy(Option<i32> 复制后原变量可用);T 非 Copy 则 move(Option<String> 赋值后原变量失效 E0382)。与 struct 规则一致(外层 Copy 性由内层决定)

enum 的 impl

impl Message {
    fn describe(&self) -> String {
        match self {                 // 统一签名,内部 match 穷尽分发
            Message::Write(s) => format!("{s}"),
            _ => String::from("other"),
        }
    }
}
  • 方法签名统一(&self),变体差异在方法体内用 match 消化
  • 加新变体 → 无通配的穷尽 match 报 E0004,逼你更新所有行为点(class 继承做不到:新子类静默继承);带 _ 的 match 编译通过但静默落入 _(更隐蔽)
  • 表达式问题取舍:class 加形态易加行为难;enum+match 反之,但有穷尽性检查兜底
  • 提取变体值:match(穷尽)/ if let(6.3)/ let...else(失败早退)。(s) 绑定 = move,(ref s) = 借用

match 控制流

match coin {
    Coin::Penny => 1,
    Coin::Quarter(state) => { ... }   // 模式绑定:提取变体数据
    _ => (),                          // 通配兜底
}
  • 与 if 区别:匹配任意类型(if 仅 bool);N 路分支逗号分隔;分支是表达式,其值为 match 返回值;按顺序命中第一个匹配分支
  • 模式绑定:Quarter(state) 左侧是模式(6.1 的模式位置)——既比对形态又绑定数据,所有权规则适用(move 绑定,ref 借用)
  • 穷尽性检查(E0004):必须覆盖所有可能,编译器精确指出漏了哪个模式并建议补全。无 _ 的 match 加新变体 → 编译报错(逼你显式处理);有 _ 的 match 加新变体 → 编译通过但静默落入 _ 分支(潜在 bug,更隐蔽)
  • other vs _:other 是普通变量名(可换任何名),绑定剩余值供分支使用;_ 是语法占位符,丢弃值且无未使用警告。通配分支必须放最后
  • 取出 Option 值:unwrap()(None panic,断言语义)/ unwrap_or(默认)(固定兜底值,简洁首选)/ match(任意逻辑)/ if let。安全性上 unwrap_or 与 match 等价(内部就是 match)

if let / let else(简洁控制流)

// 语法糖对比:if let 是 match 的等价简写
match config_max {                              // 原版
    Some(max) => println!("{max}"),
    _ => (),
}
if let Some(max) = config_max { println!("{max}"); }   // 糖:删掉样板 _ => ()
 
// let else 是"匹配失败必须离开"的守卫(match 的等价物是 match + 早退)
let Coin::Quarter(state) = coin else {
    return None;
};
// state 已绑定到外层作用域
  • if let = 单分支 match(不匹配直接跳过),牺牲穷尽性换简洁;可匹配任意模式(整数/字符串/enum)
  • let else = 短路守卫(类似 Go if err != nil { return },但模式化):匹配 → 绑定外层;不匹配 → else 必须发散(return/break/continue)
  • 可反驳性(refutable pattern):let 绑定只接受必然匹配的模式,Some(x) 是”可能失败” → E0005。可反驳模式的三个家:match(失败走其他分支)、if let(失败跳过)、let else(失败早退)
  • 选择:多路穷尽 → match;单模式忽略其余 → if let;缺了没法继续 → let else

集合

Vec 基本用法

let v: Vec<i32> = Vec::new();    // 空 vec 需类型注解
let v = vec![1, 2, 3];           // vec! 宏,字面量推断类型
let mut v = Vec::new();
v.push(5);                       // 修改需 mut
 
let x: &i32 = &v[2];             // 索引 → &T,越界 panic
let x: Option<&i32> = v.get(2);  // get → Option<&T>,越界 None
  • v[i] 底层是 *v.index(i):索引返回引用再解引用。Copy 元素(i32)可直接 let x = v[2] 复制;非 Copy(String)let s = v[0] → E0507(不能 move 出 index,破坏堆连续布局)
  • 越界:&v[i] panic(“越界即 bug”语义)/ get None(可处理)
  • 遍历:for i in &v(i: &T,借用)/ &mut v(&mut T,改值需 *i)/ for i in v(T,move 消费 v)
  • for i in &v 是迭代器语法糖:(&v).into_iter() 把整体借用映射成元素引用,所以 i 是 &T

Vec 是”拥有堆数据的值”

  • Vec 值本体 = 栈上 24 字节(ptr+len+cap,同 String);堆 buffer 动态扩容(push 不够时分配新内存迁移元素)
  • 术语对照:C#/Java 按”本体位置”分值/引用类型,String/Vec 算”引用类型”;Rust 按所有权分——String/Vec 是拥有型值(move/Copy 语义),&T 才是引用。本体在堆 ≠ 引用

借用与扩容(E0502)

let first = &v[0];
v.push(6);              // ❌ E0502:push 可能扩容迁移 → first 悬垂
println!("{first}");
  • push 是否扩容是运行时决策(cap/len),编译器不预测——存在元素引用就禁止一切可变操作,宁可错杀
  • 借用终结于最后一次使用(NLL):println!("{i4}") 后再 push 合法,i4 已死
  • 遍历时同样禁止循环体内 push/pop(同 E0502)

为什么不能 move 出元素(E0507)

let s = v[0](非 Copy 元素)报 E0507,但 struct 字段可以 move(部分移动)。核心:编译器无法动态追踪哪个元素被移走了:

  • struct 字段数量编译期固定(字段名是常量),编译器可静态追踪每个字段的有效性 → 部分移动安全
  • Vec 元素数量运行时未知(可 2^63 个),编译器不逐元素追踪有效性 → 允许 move 会留下”空洞”,drop 时不知道哪些槽位还有值
  • 这是零成本设计:每个元素加”是否有效”标记(flag)会拖慢/撑大 Vec——默认禁止;想要这能力就用 Vec<Option<T>> + .take()(把空槽显式标记为 None)

异构存储:枚举

enum SpreadsheetCell { Int(i32), Float(f64), Text(String) }
let row = vec![SpreadsheetCell::Int(3), SpreadsheetCell::Text(...), ...];
  • Vec 只存同类型;枚举把不同形态统一为单一类型(和类型)
  • 原理:枚举每个实例占 tag + 最大变体 的固定大小(6.1 布局),Vec 才能确定元素大小
  • 运行时类型未知的场景枚举不够 → trait 对象(第 18 章)

String 字符串

String = 标准库类型(核心语言只有 str),本质是 Vec<u8> 封装 + UTF-8 合法性保证。字符串的存储(无论堆上 String 的 buffer、还是数据区里的 "hello" 字面量)底层字节都是 UTF-8 格式。

创建与更新

let s = String::from("hello");       // 关联函数
let s = "hello".to_string();         // ToString trait:任何 Display 类型都有
let mut s = String::from("foo");
s.push_str("bar");                   // 追加 &str(借用,不夺所有权)
s.push('l');                         // 追加单字符
let s = format!("{a}-{b}-{c}");      // 拼接:不夺任何参数所有权,推荐

+ 拼接的语法糖(重点)

+ 运算符是 std::ops::Add::add 的语法糖,String 的实现签名(TRPL 8.2):

fn add(self, s: &str) -> String;     // impl Add<&str> for String
let s3 = s1 + &s2;                   // s1 被移动(self 无 &),s2 借用仍可用
  • self 无 & → s1 所有权被移动进 add,之后 s1 失效
  • 参数是 &str → &s2(&String)靠 deref coercion 自动转 &str;不能两个 String 直接相加
  • s2 不被夺取所有权 → add 后 s2 仍有效

为什么不能索引 s[0](E0277)

  1. 返回类型不明确:字节?字符?字形簇?Rust 不猜
  2. UTF-8 变长编码:字节索引不对应字符边界("Здравствуйте" 12 个字母 = 24 字节)
  3. O(1) 不可能:确定第 n 个字符必须从头遍历(变长编码)

UTF-8 三视角

"我".bytes()    // 3 个字节(u8):存储层,计算机真正存的(UTF-8 编码 E6 88 91)
"我".chars()    // 1 个 char(Unicode 标量值):语义层,4 字节定长存码点数值
// 字形簇:多个 char 组合成一个有意义的识别字符(如重音字母、韩文、组合 emoji)
  • char = Unicode 标量值(码点):'我' as u32 = 0x6211(4 字节定长存编号),不是 UTF-8 编码字节(E6 88 91 是存储层表示)。
  • 字形簇 = 多个 char 组合成一个有意义的识别字符(如重音字母 é、韩文、组合 emoji)。中文表意文字每字 = 1 char,无此逻辑(少数异体字/组合 emoji 除外)
  • 字节与字符并非一一对应:chars() 按码点切,不按”字母”切

切片

let s = &hello[0..4];    // ✅ 字节 range(必须落在字符边界)
let s = &hello[0..1];    // ❌ panic:byte index 1 is not a char boundary
  • 切片单位是字节(不是字符),必须切在字符边界,否则运行时 panic

HashMap

不在 prelude,需 use std::collections::HashMap;,无内建构建宏。数据在堆上,键同类型、值同类型。

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
 
let score = scores.get(&team_name).copied().unwrap_or(0);
// get → Option<&V>;copied() 把 &i32 复制成 i32;unwrap_or 给默认值
 
for (key, value) in &scores { }    // 迭代元素 (&K, &V),同 Vec 的 & 借用机制

所有权

  • insert 非 Copy 值(String)→ 移动进 map,原变量失效;Copy 值(i32)→ 拷贝
  • &str,允许多个只读借用

entry API(惯用模式)

scores.entry(String::from("Yellow")).or_insert(50);   // 键不存在:插入
scores.entry(String::from("Blue")).or_insert(50);     // 键已存在:不动
 
// 单词计数(一次 entry 完成查+插+拿可变引用,无借用冲突)
for word in text.split_whitespace() {
    let count = map.entry(word).or_insert(0);
    *count += 1;                  // or_insert 返回 &mut V,必须 * 解引用
}
  • or_insert:不存在插入参数值;已存在返回已有值的 &mut V
  • 比手写”先 get 判断再 insert”清晰且与借用检查配合更好

哈希

  • 默认 SipHash:抗 DoS(哈希碰撞攻击),代价非最快;可换其他 BuildHasher(crates.io)
  • hash 与内容有关、与地址无关——内容相同的 String 与 &str hash 一致,所以能作为键命中

哪些类型能作 key

条件:实现 Hash + Eq(Hash 决定放哪个桶,Eq 决定桶内精确匹配,查找 = 算 hash → 定位桶 → == 确认)

  • ✅ 整数/bool/char、String/&str、元组/数组(元素实现时)、#[derive(Hash, PartialEq, Eq)] 的自定义 struct
  • ❌ f64/f32:NaN != NaN 违反 Eq 自反性,只实现 PartialEq
  • ❌ 未 derive Hash/Eq 的自定义类型

错误处理

panic! 不可恢复错误

  • panic = 不可恢复(默认约定):打印信息 → 展开栈清理(unwind)→ 当前线程退出,不返回 Result
  • 对比:Python raise 可 try/except 恢复;Rust panic 默认同线程代码不再执行(类似 Go panic),但 unwind 可被同线程 std::panic::catch_unwind 捕获(panic = "abort" 下不可)

主动 panic:显式调用

fn main() {
    panic!("crash and burn"); // 显式触发,线程直接停掉
}
  • 输出 thread 'main' panicked at src/main.rs:2:5: → 文件:行:列精确定位到 panic! 本身

被动 panic:库代码替你触发

fn main() {
    let v = vec![1, 2, 3];
    v[99]; // ❌ 越界:标准库内部 panic(index out of bounds: the len is 3 but the index is 99)
}
  • 指向的 src/main.rs:4:6 是调用点
  • 同机制:&hello[0..1] 非字符边界切片 panic;C 返回越界内存(buffer overread),Rust 直接停掉
  • 定位:RUST_BACKTRACE=1 cargo run,从上往下第一个自己的文件帧即问题起点(上为库代码,下为运行时)

退出策略:unwind vs abort

unwind(默认)abort
行为回溯栈,逐帧跑 Drop 再退出不清理,直接退出,内存丢给 OS
代价/收益二进制大,Drop 会执行二进制小,Drop 不跑
配置默认Cargo.toml 加 [profile.release] panic = "abort"
// 验证 unwind 跑 Drop:默认配置打印 dropping guard;abort 下不打印
struct Guard; // 单元结构体:无字段、零大小,仅当探针
 
impl Drop for Guard {
    fn drop(&mut self) {
        println!("dropping guard"); // 离开作用域(含 unwind)自动调用
    }
}
 
fn main() {
    let _g = Guard; // _ 只压警告,所有权照样活到作用域结束
    panic!("crash and burn");
}
  • Drop 要点:出作用域自动调(含 unwind 经过),同作用域内按声明逆序;&T 从不 Drop;move 后旧主人不 Drop

Result 可恢复错误

  • 可恢复 = 失败是值不是异常:File::open 返回 Result<File, io::Error>,失败写进签名,调用者躲不掉
  • Result/Ok/Err 在 prelude,裸写;ErrorKind 不在,变体需 ErrorKind::NotFound 形式(use 引进谁才能裸写谁)

match 穷尽处理

let greeting_file = match File::open("hello.txt") {
    Ok(file) => file,                                    // 成功:拆包拿句柄
    Err(error) => panic!("Problem opening the file: {error:?}"), // 失败:一刀切 panic
};
  • match 是表达式:值 = 命中分支 => 右边;各分支类型须一致(panic! 是 !,配平任何类型)

按 ErrorKind 分流

use std::io::ErrorKind;
 
let greeting_file = match File::open("hello.txt") {
    Ok(file) => file,
    Err(error) => match error.kind() {
        // Err 里是 io::Error 结构体;.kind() 返回 ErrorKind 枚举
        ErrorKind::NotFound => match File::create("hello.txt") {
            Ok(fc) => fc,                                  // 不存在→创建(create 也返回 Result)
            Err(e) => panic!("Problem creating the file: {e:?}"),
        },
        _ => panic!("Problem opening the file: {error:?}"), // 其他错误→panic(_ 保穷尽)
    },
};

unwrap / expect:失败就 panic 的缩写

let _f = File::open("hello.txt").unwrap(); // Err→panic(默认信息:called Result::unwrap() on an Err value…)
let _f = File::open("hello.txt").expect("hello.txt should be in this project"); // Err→panic(自定义意图打头 + 原错跟随)
  • Demo 图省事用 unwrap,生产用 expect 并写清”为何认为不会失败”(假设被推翻时就是调试线索)

? 传播:交回给调用者

// ? 脱糖(伪代码):成功拆包取值,失败整函数提前 return(经 From 转成函数签名的错误类型)
match expr {
    Ok(v) => v,
    Err(e) => return Err(From::from(e)),
}

? 的完整过程

用 Self 推断类型:期望类型(签名 E → Err 槽位)反向定死 Self,再调该类型的 from 完成构造。

use std::fs::File;
use std::io::{self, Read}; // read_to_string 住在 Read trait,需 use 才能点调用
 
fn read_username_from_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt")?; // Result<File, E> → File(包拆了,类型都变了)
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let name = read_username_from_file()?; // io::Error 经 From 自动转进 Box<dyn Error>
    println!("hello, {name}");
    Ok(())
}
  • 规则:? 只能在返回 Result/Option 的函数里用,且类型须同类(Result 的 ? 本质是 return Err(From::from(e)),Option 的 ? 本质是 return None;混用报 E0277,需 ok_or/map_err 转换);main 默认返回 (),想用就改签名
  • 选型:内部精确(io::Error,调用者可 match),边界擦除(Box<dyn Error>,以后加新错误不用改签名)
  • Box:堆指针,把大小各异的错误统一成固定大小才能进返回值;dyn:动态分发,只留行为承诺、丢具体类型;Box<dyn Error> 栈上 16 字节胖指针(64 位,可 size_of 验证)

要不要 panic

两种适合 expect 的场景(第 3 种“有害状态”归 panic,见上表)

// 你比编译器知道更多:硬编码必合法,expect + 写清假设(假设推翻时即线索)
use std::net::IpAddr;
let home: IpAddr = "127.0.0.1".parse().expect("Hardcoded IP address should be valid");
  • 示例/原型/测试占位(生产前换掉);硬编码有效值才敢 expect——同一数据若来自用户输入/文件/网络,必须走 Result

这里的主要作用更像是”要求”,而不是真的要panic。

Guess:用类型消灭检查

// 检查只做一次(new),之后处处免检;私有字段 + getter,模块外只能经 new
pub struct Guess { value: i32 }
 
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}."); // 违约 = 调用方 bug
        }
        Guess { value }
    }
    pub fn value(&self) -> i32 { self.value }
}
  • 违约 panic 还是返回 Result,取决于把越界定义成”违约”还是”意外”——定义权在 API 设计者

泛型、Trait 与生命周期

泛型数据类型

基本语法:用前先声明

  • 规则:用几个占位符,就在名字后 <> 里先声明几个(同函数形参”先声明后使用”)
  • T 只是惯例(type 缩写),大驼峰任意名都行
struct Point<T> { x: T, y: T }       // 单个 T:两字段锁死同类型
struct Point<T, U> { x: T, y: U }    // 两个占位符:各管各,可异构
 
enum Option<T> { Some(T), None }       // 早见过(6章),现在看懂定义
 
enum Result<T, E> { Ok(T), Err(E) }    // 成功值与错误值本来就异构(9章)
 
impl<T> Point<T> { fn x(&self) -> &T { &self.x } } // impl 后声明 T 才能在方法里用
impl Point<f32> { /* ... */ }          // 具体类型:只给 f32 的实例加方法
 
- `Point { x: 5, y: 4.0 }` 在单 `T` 下报 E0308(`x: 5` 定死 `T = i32`,`y` 对不上);改 `<T, U>` 即合法
- `#[derive(Debug)]` 的实现在死代码分析时被故意无视(`has a derived impl … intentionally ignored`):只有手写代码的显式读取(`p.x` / 方法内 `&self.x`)才算用过,否则照报 `dead_code`;demo 可 `#[allow(dead_code)]` 消音

边界:泛型管复用,trait 管划界

// 函数体必须对 T 的所有可能取值都合法(定义处检查,不是调用处)
fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest { largest = item; } // 无约束时此处 E0369:`>` 不对所有 T 成立
    }
    largest
}
  • 裸 T 被拒 vs Python 鸭子类型运行时才炸:Rust 编译时就拦
  • T: PartialOrd = 只接”能比大小”的类型(i32/char 已实现,直接过);trait 细则见本章 Trait 节
  • 约束写法只有 T: Trait 一种,语义是“要求 T 有该 trait 的实现”(能力门槛,非父子关系);没有 extends / super 的概念——那是子类型体系下的上下界,Rust 类型间无子类型(除 lifetime 外),故无对应物;

单态化:编译时膨胀,零运行时开销

  • let a = Some(5); let b = Some(5.0); → 编译器约等于生成 Option_i32 / Option_f64 两份定义,各走各的机器码
  • 验证:size_of::<Point<i32,i32>>() 与手写一致(无盒子、无虚表);代价是编译变慢、二进制变大——空间换时间

补充:主流语言的泛型策略对比(TRPL 之外,记住取舍即可)

本质分两派:特化派(Rust / C++:每种用到的类型各一份代码,零开销,代价膨胀)vs 擦除派(Java / TS:一份代码跑所有类型,有运行时成本或零残留)。

语言策略运行时后果
Rust单态化:每具体类型一份机器码零开销二进制大、编译慢;size_of 与手写一致
C++模板实例化:用到才生,每种类型一份代码;非依赖名定义处查、依赖操作实例化时才查,concepts 后可写上界零开销抽象同样膨胀 + 编译慢;坏类型在调用点逐份报错(长展开栈),Rust 缺界在定义处报 E0369
Java类型擦除:一份字节码,擦成 Object/上界 + 编译器插强转装箱 + checkcast(checkcast/内联 JIT 多能优化,装箱成本一般仍在)编译器不兜底、开发者兜底:仅报 unchecked 警告(不阻止写入)、远处读时才 CCE;类型参数须为 Object 子类(List<int> 非法);运行时只剩 List.class 这一个类,instanceof List<String> / new T() 非法
TS类型擦除:类型(含 extends 上界约束)只活在编译时,JS 无残留对运行时零影响(同一份 JS)无特化、无运行时检查,any/断言可绕过,安全只在编译时

Trait:定义共同行为

  • 本质就是接口(Java / Go 的 interface 类比):只定义行为规范,不管实现;trait 定方法签名,impl Trait for Type 给具体类型写行为;不实现就没这个方法(no method named,编译器逐类型检查)
  • 默认实现:trait 内直接给方法体,可保留(空 impl 块全用默认)可重载;重载体内调同名默认不行(无 super,写同名就是递归调自己)
// 默认方法可调同 trait 无默认的方法:实现者只写最小集,白送其余
trait Summary {
    fn summarize_author(&self) -> String; // 必干的:无默认,必须写
    fn summarize(&self) -> String {       // 白送的:默认实现调上面那个
        format!("(更多来自 {}……)", self.summarize_author())
    }
}
// 只实现 summarize_author,summarize 自动就有(单态化后直调实现者的版本,链条永闭合)
  • 孤儿规则:trait / 类型至少一边在当前 crate,否则 E0117(防两 crate 给同类型实现同 trait 撞车)
  • 参数写法一回事:都是约束“实现该接口的类型”,&impl Trait 是短糖,<T: Trait> 是本体(单 T 锁死同类型),where 只是把长约束挪到签名后;多约束用 +
  • 返回 -> impl Trait:只藏一种具体类型,多分支返回异构不行(多态返回等 trait object);借用版 -> &impl Trait 合法,但须挂 lifetime(见生命周期节)。为何强制单一:单态化复制代码不复制运行时——入参类型是编译期证据(写在调用点,可枚举可生代码),返回分支是运行时事实(值在运行时),实际返回类型与尺寸皆不可知,所以只能规定单一类型(不是返回类型要定义为具体类型,而是你的运行时只能返回一种具体类型)
  • 条件实现 impl<T: Display + PartialOrd> Pair<T>:T 够格才有方法;blanket impl<T: Display> ToString for T
  • 裸 Trait 是 unsized(同 str / [T] 一家),站不了值位置(他是规范);&impl 靠单态化定尺寸(静态分发,直调),&dyn 靠胖指针定尺寸(16 字节 + 虚表,动态分发,查方法地址而非类型)

生命周期

问题:悬垂引用

引用比被引用的数据活得久,数据释放后引用再被使用,就是悬垂引用:

fn main() {
    let r;
    { let x = 5; r = &x; } // x 在这里释放
    println!("{r}");        // r 还在用,悬垂,报 E0597(borrowed value does not live long enough)
}

let r = &x 能直接看出 r 借用了 x。但函数调用把这层关系藏起来了:

let r = longest(&a, &b); // r 借用了谁?a 还是 b?看不出来

编译器无法判断 r 的有效期该和哪块数据比,也就无法检验悬垂引用。

核心:所有借用(含结构体、Vec 等复合对象里的借用)都必须早于被借用者释放,即早于所持全部借用中最短的那个。

方法:在签名中声明借用关系

用 'a(生命周期泛型参数,名字可换)表示lifetime, 用来修饰和被借用对象的关系, 相对于量化了生命周期逻辑

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

上面的例子,实参给到x后,'a获得这个实参的lifetime关联, 但是y也关联到'a,此时'a取的是他们的交集,也就是最短的那个.

编译器用它检验悬垂

签名给出约束后,编译器在调用点检查,此时返回值的借用是可以推断出来的,悬垂引用也就能检查了

let string1 = String::from("long string is long");
let r;
{
    let string2 = String::from("xyz");
    r = longest(&string1, &string2); // 'a 取较短的 string2 的作用域
}
println!("{r}"); // r超出了 string2,悬垂,报 E0597(borrowed value does not live long enough)

分别命名

fn first<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { x }

返回值只和其中一个入参有关时,为无关的那个入参另起一个生命周期参数,函数体只能返回标注为 'a 的那一个入参(或由它派生出的值);编译器不管运行时,他会考虑所有返回的可能性, 所以此时的返回值必须是x

带lifetime声明的返回值

返回值带 lifetime 声明(如 let r: &'static str)时,此时函数的lifetime声明需要能满足返回值的声明

'static

'static 表示整个程序运行期间都有效,不只指字面量, 这更像是一种对被借用对象的要求, 你必须是可以static的:

let a: &'static str = "字面量";            // 存数据区,随二进制
let b: &'static i32 = &42;                // 编译期提升为静态内存, 提升的是42, 这里表示b借用了一个数据区的42,因为static,所以42必须提升,否则无法实现
let c: &'static mut String = Box::leak(Box::new(String::from("x"))); // 主动泄漏,换全程有效, leak表示永不释放, 所以才敢static

<'a, T> 的写法

<'a, T> 写在同一对尖括号里,生命周期在前、类型在后,两类参数各管各。
T的lifetime写法为T: 'a:表示 T 里面如果带有引用,该引用的生命周期不短于 'a(‘a由其他参数推断出来)

复合对象里的借用(Vec/struct)

以struct为例,如果是当前作用域使用,看起来不需要lifetime,因为实例天然的和所有借用产生了关联,实例必须在所有借用之前释放,那为什么一定要有lifetime呢,否则编译器直接报错!
因为struct不是一个简单对象,他是复合对象, 他和所持有的借用,一但跨作用域,直接联系可能丢失,必须显示声明关联,编译器才能处理悬垂。

比如:

fn get_a(foo: Foo) -> &str // 返回的&和foo里的哪个字段同命?说不清
fn make(x: &str) -> Foo    // Foo借了x还是悬空?说不清

所有复合对象,包括数组,vec都有同样的问题,跨作用域时都必须显示应用lifetime:

struct Pair<'a> { x: &'a str, y: &'a str } // 'a 取两字段所借对象中较短的
let v: Vec<&'a str> = vec![&a, &b];        // 'a 取全部元素中较短的

要应用lifetime,就要在定义类型时先声明lifetime模板,struct是新类型,必须设置,数组和Vec都是内置类型,已经定义好了。
应用时,编译器可以自动推导的,此时可以省略lifetime,比如:

let s1 = String::from("s1");
let s2 = String::from("s2");
let foo = vec![&s1, &s2]; //不需要 foo: Vec<&'a str>,实际上foo的类型此时为: Vec<&'_ str>

Tip

泛型本身就支持lifetime,或者说lifetime本身就是类型的一部分

省略lifetime声明

三条规则(输出跟谁,只看输入):

  1. 每个引用入参各分一个 lifetime;

  2. 恰好一个输入 lifetime,输出跟它(如 first_word);

    fn first_word(s: &str) -> &str { /* ... */ } // 合法:单入单出,输出只能借 s
  3. 方法且有 &self,输出跟 self(如 fn get(&self, x: &str) -> &str 返回 self.part,x 不参与)。

编写测试

断言与测试写法

#[test] 标记的函数才会被 cargo test 跑到;assert_* 失败时内部会 panic,然后标记为 FAILED。

写法测什么失败时看到什么
assert!(bool)布尔条件只说条件是 false,看不到两边的值
assert_eq! / assert_ne!相等 / 不等自动打印 left / right 两边的值(类型得实现 Debug),调测试最省时间
#[should_panic(expected = "...")]必须 panic 的代码(不可恢复那类)没 panic 反而记 FAILED;expected 是子串包含,消息里任意位置含它就算过
测试返回 Result<(), E>可恢复错误那类,返回 Ok 算过、Err 算挂失败信息就是那个 Err 值
#[test]
fn ex1_add_works() {
    assert_eq!(add(2, 3), 5);
}
 
#[test]
#[should_panic(expected = "insufficient balance")]
fn ex2_withdraw_panics() {
    withdraw(100, 200);
}
 
#[test]
fn ex4_divide_by_zero() -> Result<(), String> {
    assert_eq!(divide(6, 0), None);
    Ok(())
}

Caution

#[should_panic] 不写 expected 等于没锁:任何 panic 都算过。永远带上能唯一标识原因的一小段,别写太短。

控制测试运行

cargo test = 编译出测试二进制(target/debug/deps/<包>-<hash>,可直接运行)+ 执行它;-- 前是 cargo 的参数,后是测试二进制(libtest)的参数。

Note

默认输出捕获:每个测试的 stdout 先重定向到一块专属内存 buffer,跑完再决定怎么print——失败的必print(加不加 --show-output 都打印),成功的默认丢掉,--show-output则会把成功的也打印。

需求命令
看测试二进制认识哪些参数cargo test -- --help
串行跑(默认并行)cargo test -- --test-threads=1
过了的测试也显示 println!cargo test -- --show-output(跑完统一打印出来,有序、带小标题)
实时看输出(不捕获进专属buffer)cargo test -- --no-capture(紧凑,并行时会交错)
只跑名字含 xx 的cargo test xx(子串匹配;精确加 -- --exact;排除用 -- --skip)
忽略某些test#[ignore];只跑被忽略的 -- --ignored,全跑 -- --include-ignored

Tip

日常 cargo test 秒级反馈(含 #[ignore] 摘掉慢测试),CI 再 -- --include-ignored 全量。

Caution

默认全并行:共享文件/环境变量/全局 static 的测试会互相踩,时过时不过。无共享的纯函数随便并行,有共享的上 --test-threads=1。

单元测试与集成测试

  • 单元测试:与被测源码同文件,置于 #[cfg(test)] mod tests 中;子模块可见父模块的私有项,因此可测试私有函数。#[cfg(test)] 是编译期条件,仅 cargo test 时成立——cargo build 会忽略整个模块,即使其中含有错误代码也不报错;
  • libtest 会收集整个 crate 中的全部 #[test] 函数,不受模块嵌套深度影响。
  • 集成测试:tests/ 目录顶层的每个 .rs 文件会被编译为独立的 crate;同包的库名由 cargo 自动以 --extern 提供给集成测试,可直接use,无需额外声明;第三方依赖须先写入 Cargo.toml [dependencies]。
  • 共享的测试辅助代码可置于 tests/common/mod.rs 等子目录中:子目录文件不会被作为独立的测试目标编译,需由测试文件以 mod common; 声明引入。
  • cargo test 输出自上而下依次为:单元测试、各集成测试文件、Doc-tests。

Tip

单模块的聚焦测试(含私有函数)使用单元测试;跨模块协作与公开 API 使用集成测试。

Caution

bin-only 包不会被编译为可链接的库,无法编写集成测试——需先拆分出 src/lib.rs。