包和模块
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数值类型
整数
可以参考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浮点数标准,浮点数存储标准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。
位运算
见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字符,包括汉字。和字符非常相似。
-
bool. true or false, 一个字节表示
-
单元类型. 用
()表示,且拥有唯一的值(),可以用于函数返回值,比如fn main()就是返回()。()占用0字节,对其取址得到的是无实际内存的悬垂地址,可以看作是”虚拟的”。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个内存,即所谓的引用类型和值类型。 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,就释放。这是妥协?
所有权是一条责任链,不是位置映射
“栈存地址 → 对象在堆 → 出栈 → 回收”这种直觉,只是链长为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者表达的是一个内容,即指针类型(包括胖指针)。
借用
通过取址,获得访问权限,就是“借用”。
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
}切片(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