切片:字符串切片与通用切片

切片是引用在连续数据上的标准形态:一段不持有所有权、只描述「从哪里到那里」的视图。这篇解决的问题是:字符串切片 &str 与通用切片 &[T] 各自是什么、怎么从 String 和 Vec 取切片、切片与生命周期的关系是什么、以及为什么 Rust 的切片比多数语言的 substring 安全。读完你能在函数间高效传递「数据的一部分」而不复制内存。

一、字符串切片:String 的视图

从 String 取一段,得到的是 &str 而不是新 String,这就是切片:

fn main() {
    let s = String::from("你好,Rust 世界");
    let hello: &str = &s[0..6];      // 按字节切前两个汉字
    // 注意边界必须落在 UTF-8 字符边界上
    println!("{}", hello);
    let whole: &str = &s[..];         // 全量切片
}

切片本体只是「指针加长度」两个机器字,取切片零分配、零拷贝,这也是它能成为最高效数据传递形态的技术前提。规则上注意两点:切片是借用的,它的生命周期不能超过源数据;区间端点使用 UTF-8 字节偏移,落在字符中间会 panic(错误信息 byte index is not a char boundary)。

语法上有三种写法:&s[a..b] 含头不含尾,&s[a..] 到末尾,&s[..b] 从头到 b,区间两端也都可以是变量。Rust 的 .. 是 Range 类型,切片索引的 [..] 语法是编译器对 Range 的特殊支持。初学时容易混淆的一点:切片区间前闭后开是铁律,想取到末尾含尾元素只能用 .. 留空或显式写 len,写 ..=len 在字符串上几乎必然踩到字符边界问题。先把这条记住,能省掉一半的 panic 调试时间。

切片之所以安全,奥秘在于它把「检查」提前到了创建时:越界或字符边界不对,创建那一刻就 panic,后续使用无需再查。相比之下 C 的 substring 返回裸指针,所有安全责任推给调用者,一次疏忽就是悬挂指针或者缓冲区读越界,两种 bug 都足够让服务器半夜报警。

二、通用切片:Vec 的视图

任何连续存储的类型都能切片,&Vec<T> 会自动得到 &[T]。函数签名收 &[T] 是最通用的选择——数组、Vec、切片全都能传:

fn sum(nums: &[i32]) -> i32 {
    let mut total = 0;
    for n in nums {
        total += n;
    }
    total
}

fn main() {
    let v = vec![1, 2, 3];
    let a = [4, 5, 6];
    println!("{} {}", sum(&v), sum(&a));
}

&[T] 相比 &Vec<T> 的优势在于它不关心数据来自堆还是栈,也不要求调用方拥有动态容器。标准库几乎所有集合方法都收切片,这是 Rust API 的一级惯例。借用转换还有个隐式便利:&Vec<T> 传给收 &[T] 的位置时会自动解引用转换,调用处完全无感,反过来则必须显式,比如用 slice.to_vec() 重新装回堆容器。三种容器的切片关系如下表。

类型 切片形态 典型来源
String &str String、字面量、切片
Vec<T> &[T] Vec、数组
[T; N] &[T] 定长数组本身
&str &str 再切还是它

三、切片与迭代器为什么是绝配

切片本身就是迭代器的载体:对 &[T] 直接 for x in &slice 拿到 &T,对 &mut [T] 得到 &mut T。方法调用上切片有 split、chunks、windows 等一批实用工具,和 &str 的 split、lines、trim 是一族:

fn main() {
    let text = "Rust\nJava\nGo";
    for (i, line) in text.lines().enumerate() {
        println!("{}: {}", i + 1, line);
    }
    let nums = [1, 2, 3, 4];
    for pair in nums.chunks(2) {
        println!("{:?}", pair);
    }
}

chunks(2) 按固定大小分块、windows(2) 滑动取窗口、split 按分隔符切割——处理日志、协议、表格文本时这三个方法出场率极高。它们全部返回迭代器,零分配完成遍历。配合 正则表达式精进 里的文本处理模式,可以写出几乎不分配内存的解析流水线,对读取 GB 级日志的场景性能收益立竿见影。

四、切片与所有权边界的实战模式

写解析器时常遇到「先校验整体、再返回片段」的模式。让函数收 &str、返回 &str,生命周期由省略规则自动补齐:

fn domain_of(url: &str) -> &str {
    let scheme_end = url.find("://").unwrap_or(0);
    let rest = &url[scheme_end + 3..];
    let slash = rest.find('/').unwrap_or(rest.len());
    &rest[..slash]
}

fn main() {
    println!("{}", domain_of("https://www.wanxiangyundang.top/books"));
}

整个函数没有一次堆分配:找到下标、切两刀、返回视图。这是 Rust 文本处理的典型形态,也是它比动不动 substring 复制一份的语言快的原因。代价是你必须想清楚每个返回片段的来源,让生命周期标注把责任写进签名。

注意这类函数在签名上把输入与输出用同一个生命周期绑定,意味着调用方拿到结果后,源数据必须仍然存活。这是借用规则的直接推论,不是额外约束。好处是签名本身就构成文档:读的人一看就知道「返回的片段是从参数里借来的」,不需要翻实现确认有没有分配新内存。养成用切片表达「这只是视图」的习惯,你的 API 会明显比满屏 String 的版本更高效也更自解释。

五、切片的坑与速查

现象 原因 解法
byte index panic 按字节切到字符中 改用 chars 边界
切片生命期编译失败 切片活得比源数据久 缩短持有或改拥有数据
想存切片进结构体 引用不能长期持有 存 String 或 Cow
多个切片重叠可变修改 借用冲突 不可变只读共享即可

第三条很常见:把解析结果暂存进结构体字段时,&str 字段的寿命约束会传染整个结构体。需要既支持借用又支持拥有的混合类型时,标准库提供了 Cow<str>:它要么持有借来的 &str,要么拥有 String,由运行时内容决定,适合「多数情况可零拷贝、偶尔需要 own」的解析场景,以官方文档为准。

六、切片的设计意义

切片把「连续数据」抽象成一种与存储无关的接口,标准库所有集合、所有 IO、所有序列化都统一在它上面,这是 Rust API 一致性的来源。你后续读任何涉及数据处理库的源码,会发现内部几乎全是 &[u8] 与 &str 的接力。掌握切片等于掌握了 Rust 最主流的数据传递语法,后续所有集合、序列化与网络处理的代码都会反复用到它。桌面应用把数据从前端传到 Rust 后端做处理时,切片也是默认接口形态,Tauri 桌面开发指南 里有完整示范。

延伸阅读

  • 下一篇「Copy 与 Clone」解释为什么切片本身可以随便复制而数据不行,两者放在一起正好补齐「什么会被复制」的全景图。
  • 写解析器时先读 正则表达式精进 的分词一章,再看本篇的标注模式会豁然开朗,两本书的示例可以直接拼起来运行。
  • 迭代器一章会把切片上的链式处理展开,chunks 与 map 的组合会反复出现,读完那里你就能独立写出完整的解析管线。