包和模块
rust有4层:
- 工作空间(workspace):
Set<packages> - 项目(packages): 由cargo管理,这是项目层级,可以看作
Set<crate>,特征是有Cargo.toml。一个package只能有一个libcrate,但是可以有多个binarycrate - 包(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 --libTip
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在使用上没有任何区别,只是为了方便理解语义。
要点:
- 模块必须在crate的语境下
- rust的模块本质是编译链,这是和其他语言最大的区别,比如go,ts,python,lua等的”模块”系统,和文件系统深度绑定。rust的模块跟随编译链走,从编译链的入口模块开始,最后形成一棵以入口模块为根的模块树(不是标准多叉树),所以广义的crate指的实际是这棵树。
- 目录必须转换为模块才能被找到,有2种方式:
<dir>/mod.rs。对,就是python/lua的模式<dir>.rs,创建一个同级同名的rs文件
- 文件模块使用前,必须显式使用
mod来声明(链接到树上),rust完全不会主动去文件系统中找 - 模块和文件不是一对一,一个文件可以定义多个
mod,这个mod可以是文件mod或者mod block mod可以嵌套,当然,不然目录也没必要转换为mod了。mod block同样支持嵌套。- 只有同一个作用域的对象(
mod也是对象)才是相互可见的,否则必须显式pub修饰。
Tip
这里也能看出来rust不太喜欢主动替用户做决定。
use
"use" identifier{"::"identifier}[ "as" identifier]";";很多语言都是直接从文件树上找,但是rust必须先用mod形成树,然后use才能从这棵树上找对象。
use第三方crate
当第三方依赖添加进来后,本质是它的crate暴露在了项目crate的runtime,默认暴露的还有内置crate,比如std。
所以可以总结use的含义: 从runtime中的crate和当前模块树搜索对象。
方式
use的对象在当前模块的作用域中,属于当前模块了,当然也可以暴露出去,使用pub修饰即可use多个,可以使用{obj,...}表示- 当
self出现在use的路径中时,它表示其前面路径本身,use std::io::{self, Write};中的self即std::io *导入所有,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: 类似../的概念,用于嵌套modself: 表示当前mod
可见性
pub定义可见,注意每一层都要显式暴露。
还可以更加细化:
pub意味着可见性无任何限制pub(crate)表示在当前包可见pub(self)在当前模块可见,和什么都不修饰的private行为一致,当前和子模块中可以访问。这个主要用于宏编程的显式可见性设置。pub(super)在父模块可见pub(in )表示在某个路径代表的模块中可见,其中path必须是父模块或者祖先模块
基础
变量
-
rust的变量,默认是immutable的,必须显式的使用
mut,比如let mut a : i32 = 1来声明。 -
rust也可以多次
let同一个变量名,用新变量遮蔽(shadow)前面作用域的同名变量。这和重新赋值不同,这是重新分配了一次内存(正常情况下),后面对这个变量的使用,都使用的是最新地址,而原来的那个内存不会因为被遮蔽而释放,它依然遵循常规的释放逻辑。 -
rust支持非常方便的解构赋值
let (a, b, c):(i32, i32, i32) = (1, 2, 3); -
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); // failedNaN
数学上未定义的结果,被处理为NaN(not a number),NaN不能被比较。
基本运算
- 只有同类型的才能进行运算,没有所谓的隐式转换。
- 长数字,比如
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表示。
字符、布尔、单元类型
-
char. 不同于c的一个字节,rust中的char是一个Unicode码点(严格来说是标量值,筛选过的码点),用4个字节表示,所以可以表示任意Unicode字符,包括汉字。和go > 字符非常相似。
-
bool. true or false, 一个字节表示
-
单元类型. 用
()表示,且拥有唯一的值(),可以用于函数返回值,比如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
}要点
str是 unsized 类型(DST):长度不在类型里,不能单独作为变量,必须通过&str/Box<str>使用。[u8; 5]长度在类型里,可单独存在- 切片元素是
u8字节,不是char。char是 Unicode 标量值,固定 4 字节,与 UTF-8 无关;UTF-8 编码 1~4 字节变长。str= 字节流 + UTF-8 合法性承诺([u8]无此承诺) - 切片是借用 → 视图存在期间原数据不可变(编译期拦截),借用结束于最后一次使用(NLL)
- 越界/切到字符中间 → panic。常量下标越界编译期就报错,变量下标运行时检查
- 函数参数用
&str而非&String:&String自动转&str(Deref),字面量直接可传 - 数组切片有可变版
&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 是类型显式声明的能力,编译器不按布局推断。整体 movelet 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! | |
|---|---|---|
| 参数 | 按值接收再归还(返回参数值,可嵌表达式) | 宏内部取引用(传值/传引用效果相同) |
| 打印 | 源码文本 + 结果 + 文件:行号 | 只打印格式化结果 |
| 输出流 | stderr | stdout |
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,更隐蔽) othervs_: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>,越界 Nonev[i]底层是*v.index(i):索引返回引用再解引用。Copy 元素(i32)可直接let x = v[2]复制;非 Copy(String)let s = v[0]→ E0507(不能 move 出 index,破坏堆连续布局)- 越界:
&v[i]panic(“越界即 bug”语义)/getNone(可处理) - 遍历:
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)
- 返回类型不明确:字节?字符?字形簇?Rust 不猜
- UTF-8 变长编码:字节索引不对应字符边界(
"Здравствуйте"12 个字母 = 24 字节) - 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 默认同线程代码不再执行(类似 Gopanic),但 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
- 默认
Result(liberal contract):把选择权给调用者;panic是替调用者判死刑 - RFC 236 三分法(https://rust-lang.github.io/rfcs/0236-error-conventions.html):Obstructions(坏输入/文件缺失/限流)→
Result;Contract violations(越界/坏编码/违前置条件)→panic;Catastrophic(OOM)→ abort - 拿不准 →
Result+ 文档;panic条件写进# Panics(clippymissing_panics_doc会查) - 社区:生产库用 restriction lint 禁裸
unwrap;expect必须写理由
两种适合 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 够格才有方法;blanketimpl<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声明
三条规则(输出跟谁,只看输入):
-
每个引用入参各分一个 lifetime;
-
恰好一个输入 lifetime,输出跟它(如
first_word);fn first_word(s: &str) -> &str { /* ... */ } // 合法:单入单出,输出只能借 s -
方法且有
&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。