包和模块

rust有4层:

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

数值类型

整数

可以参考chapter4的整数类型.

同样isizeusize大小,取决于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.20.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

位运算

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字符,包括汉字。和字符非常相似。

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

  3. 单元类型. 用()表示,且拥有唯一的值(),可以用于函数返回值,比如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
}

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

TypescriptPython都有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得到&'staticconst是编译期内联值,不占运行时内存
引用计数堆(计数器)Rc/Arc在堆上维护引用计数;每个持有者离开作用域时计数减1,归零(最后一个持有者消失)时才触发drop。触发者仍是栈上某个持有者的作用域结束

推论:

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

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

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

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

Tip

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

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

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

  • 移入地址 ≠ 复制所有权unsafe可以把栈上瘦指针的位模式移入堆上槽位(alloc+write),writemovea被移走,所有权随之转移到堆上槽位,值本身仍只有一个所有者。若用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
}
要点
  1. str 是 unsized 类型(DST):长度不在类型里,不能单独作为变量,必须通过 &str/Box<str> 使用。[u8; 5] 长度在类型里,可单独存在
  2. 切片元素是 u8 字节,不是 charchar 是 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