字符串与切片:String、&str 与 UTF-8 边界
课程概览 · 第 2 章
字符串是 Rust 里被使用得最多、也最容易选错的一对类型:String 拥有数据,&str 只借用数据。更深的坑在 UTF-8–中文字符占 3 个字节,字节下标随时可能落进字符中间。本章把这两个层面一次讲清:所有权层面的 String vs &str 接口选择,以及字节层面的字符串切片与字符边界。
学习目标与默认选择
学完本章你能:
- 解释
String与&str的所有权差异与各自的接口角色; - 在函数参数、返回值、结构体字段三个位置做出正确选择;
- 解释 UTF-8 下"字符数"与"字节数"的差异,并写出不会 panic 的字符串处理;
- 用字节下标安全地切出字符串切片,或按字符数/显示宽度截断文本。
默认写法:
| 需求 | 写法 | 说明 |
|---|---|---|
| 只读文本参数 | &str | 同时接受 String 与字面量,调用方零成本 |
| 返回新文本 | String | 结果独立存活,与输入解耦 |
| 返回输入的一段 | &str(从入参切片) | 零分配,但生命周期绑定输入(第 6 章) |
| 结构体字段保存文本 | String | 结构体拥有自己的数据 |
| 拼接/格式化 | format!、push_str | format! 只借用操作数 |
| 按字符处理 | chars() | 用户可见文本的正确迭代单位 |
| 按字节处理 | as_bytes() | 协议帧、哈希、明确 ASCII 的数据 |
| 按字节切片 | &s[a..b] / s.get(a..b) | 下标必须落在字符边界上 |
String 与 &str:拥有还是借用
String 拥有可增长的堆上 UTF-8 缓冲区;&str 是对一段合法 UTF-8 字节的借用视图。函数参数优先 &str–它同时接受 String(自动解引用)和字符串字面量;返回值需要独立存活时才返回 String。
1 | |
为什么可行:内存布局
String 的栈上部分是(指针、长度、容量)三元组,字符数据在堆上,离开作用域时由 Drop 释放。&str 只是(地址、字节长度),它借用而不拥有,传递时只复制两个机器字。String 可以随时 push_str 增长(容量不足时重新分配),&str 是定长视图,想要"可增长的文本"就必须拥有 String。
UTF-8:字符不是字节
Rust 字符串保证合法 UTF-8,一个 char 可能占 1–4 个字节(汉字占 3)。text[0] 没有定义–它可能落进多字节字符中间。处理用户可见文本用 chars();处理协议帧、哈希或明确 ASCII 的数据用 as_bytes();两者不要混用。
1 | |
三层计量不要混淆:
| 计量 | API | “修复 login” 的结果 |
|---|---|---|
| 字节数 | len() / as_bytes().len() | 14 |
| 字符数(Unicode 标量值) | chars().count() | 6 |
| 显示宽度 | 无标准库 API,自行按码点估算 | 8(两个汉字各 2 格 + 6 个 ASCII) |
"修复" 的宽度是 4 不是 2:终端里一个汉字占两列。对齐表格、截断标题时要按宽度算,不是按字符数。
字符串切片:字节下标 + 字符边界
&str 本质是字节切片。&s[a..b] 取出第 a 到 b-1 个字节组成的子串–前提是 a 和 b 都落在字符边界上:
1 | |
两种失败方式,性质不同:
1 | |
这与第 3 章数组切片的规则同源:越界/非法时 [] panic、get 返回 None。切分点来自内部逻辑(char_indices 算出来的)用 [];来自外部输入(用户给的偏移量)用 get。get 拦下的不是"越界",而是"切在字符中间会产生非法 UTF-8"–语言不可能给你半个字符。
按字符处理:chars() 与 char_indices()
按字符数截断(“取前 N 个字符”)不能直接 &text[..n]–n 是字符数不是字节数。标准写法是先从 char_indices() 得到每个字符的起始字节位置:
1 | |
char_indices() 每步产出 (字节位置, 字符),它是"字节世界"与"字符世界"的翻译器。find() 也返回字节下标,可以直接喂给切片:
1 | |
返回 String 的字符截断用 chars().take().collect(),适合"截断后要独立保存"的场景:
1 | |
拼接与格式化:format!、push_str 与 +
1 | |
工程默认顺序:format! > push_str > +。format! 语义最清晰(不转移操作数所有权);循环里追加用 push_str,能预估长度时先 String::with_capacity(n) 减少重新分配;+ 会消耗左侧的 String,链式拼接时容易踩所有权。
常用操作速查
| 需求 | 写法 | 返回 |
|---|---|---|
| 去首尾空白 | input.trim() | &str |
| 按分隔符拆分 | tags.split(',') | 迭代器,元素 &str |
| 按第一个分隔符切两半 | line.split_once(':') | Option<(&str, &str)> |
| 查找子串/字符 | s.find("lo") / s.find('l') | Option<usize>(字节下标) |
| 大小写转换 | s.to_lowercase() | String(可能变长) |
| 包含/前缀/后缀 | s.contains / starts_with / ends_with | bool |
| 字符数 | s.chars().count() | usize |
| 拥有一份拷贝 | s.to_string() / s.to_owned() | String |
| 重复填充 | "-".repeat(10) | String |
注意返回类型的规律:不改变长度的操作返回 &str(借用视图),可能改变长度的操作返回 String(需要新分配)。to_lowercase() 返回 String 是因为某些字符的大小写形式字节长度不同。
1 | |
常见误区
- 为了"方便"把所有参数写成
String:调用方被迫转移或分配;只读就用&str。 - 用字节下标切多字节文本:
&title[..2]会 panic 或get出None;按字符截断先char_indices(),或chars().take(n).collect()。 - 假设"字符数 == 字节数":任何非 ASCII 输入都会让这个假设破产;需要字符就
chars(),需要字节就as_bytes()。 - 假设"字符数 == 显示宽度":汉字在终端占 2 列,对齐/截断按宽度算。
let s = &input.to_lowercase();拿临时值的引用:to_lowercase()的结果没人拥有,借用立刻悬垂(编译期报错);先let owned = ...绑定再借用。- 循环里
s = format!("{s}{chunk}")反复重建:每轮都分配新的String;用push_str增量追加。
自测
- 函数要"读一段文本"和"保存一段文本",参数/返回值分别怎么选?–答方向:读用
&str参数;保存返回String,让所有权离开函数。 "修复 login"的len()、chars().count()、显示宽度分别是多少?–答方向:14(2 汉字 × 3 + 5 ASCII + 1 空格)、6、8;三种计量三个答案。title.get(..2)对"修复 login"返回什么,为什么不是 panic?–答方向:None;get把"切在字符中间"表达为Option,拦下的是非法 UTF-8。find()返回的下标可以直接用于&s[..i]吗?–答方向:可以;find返回的字符/子串起点必然是字符边界。"任务"的to_lowercase()返回什么类型?–答方向:String;大小写转换可能改变字节长度,必须新分配。
下一章把"只借用需要的视图"推广到数组和 Vec:函数应接收它需要的切片,而不是某个具体容器。






