课程概览 · 第 2 章

上一篇:Rust 基础:变量、类型与转换的工程选择

下一篇:切片:为函数设计稳定、低耦合的输入

字符串是 Rust 里被使用得最多、也最容易选错的一对类型:String 拥有数据,&str 只借用数据。更深的坑在 UTF-8–中文字符占 3 个字节,字节下标随时可能落进字符中间。本章把这两个层面一次讲清:所有权层面的 String vs &str 接口选择,以及字节层面的字符串切片与字符边界。

String 的堆缓冲区、&str 胖指针借用视图,以及 UTF-8 多字节字符的合法切分边界
图:&str 是(地址、字节长度)胖指针;切分点必须落在字符边界上,否则操作被拒绝。

学习目标与默认选择

学完本章你能:

  • 解释 String&str 的所有权差异与各自的接口角色;
  • 在函数参数、返回值、结构体字段三个位置做出正确选择;
  • 解释 UTF-8 下"字符数"与"字节数"的差异,并写出不会 panic 的字符串处理;
  • 用字节下标安全地切出字符串切片,或按字符数/显示宽度截断文本。

默认写法:

需求写法说明
只读文本参数&str同时接受 String 与字面量,调用方零成本
返回新文本String结果独立存活,与输入解耦
返回输入的一段&str(从入参切片)零分配,但生命周期绑定输入(第 6 章)
结构体字段保存文本String结构体拥有自己的数据
拼接/格式化format!push_strformat! 只借用操作数
按字符处理chars()用户可见文本的正确迭代单位
按字节处理as_bytes()协议帧、哈希、明确 ASCII 的数据
按字节切片&s[a..b] / s.get(a..b)下标必须落在字符边界上

String&str:拥有还是借用

String 拥有可增长的堆上 UTF-8 缓冲区;&str 是对一段合法 UTF-8 字节的借用视图。函数参数优先 &str–它同时接受 String(自动解引用)和字符串字面量;返回值需要独立存活时才返回 String

1
2
3
4
5
6
7
8
9
10
11
12
fn title_bytes(title: &str) -> usize {
title.len() // 字节数
}

fn main() {
let owned = String::from("release checklist");
let literal = "release checklist";

// 同一个 &str 参数,两种来源都能传入。
assert_eq!(title_bytes(&owned), 17);
assert_eq!(title_bytes(literal), 17);
}

为什么可行:内存布局

String 的栈上部分是(指针、长度、容量)三元组,字符数据在堆上,离开作用域时由 Drop 释放。&str 只是(地址、字节长度),它借用而不拥有,传递时只复制两个机器字。String 可以随时 push_str 增长(容量不足时重新分配),&str 是定长视图,想要"可增长的文本"就必须拥有 String

UTF-8:字符不是字节

Rust 字符串保证合法 UTF-8,一个 char 可能占 1–4 个字节(汉字占 3)。text[0] 没有定义–它可能落进多字节字符中间。处理用户可见文本用 chars();处理协议帧、哈希或明确 ASCII 的数据用 as_bytes();两者不要混用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let text = "修复 login";

let char_count = text.chars().count();
let byte_count = text.len();
assert!(byte_count > char_count); // 6 个字符,14 个字节

// 第一个字符是完整的 '修',不是第一个字节。
let first_char = text.chars().next();
assert_eq!(first_char, Some('修'));

// 字节视图:协议/哈希场景,元素是 u8。
assert_eq!(text.as_bytes()[0], 0xE4); // '修' 的 UTF-8 首字节
}

三层计量不要混淆:

计量API“修复 login” 的结果
字节数len() / as_bytes().len()14
字符数(Unicode 标量值)chars().count()6
显示宽度无标准库 API,自行按码点估算8(两个汉字各 2 格 + 6 个 ASCII)

"修复" 的宽度是 4 不是 2:终端里一个汉字占两列。对齐表格、截断标题时要按宽度算,不是按字符数。

字符串切片:字节下标 + 字符边界

&str 本质是字节切片。&s[a..b] 取出第 ab-1 个字节组成的子串–前提是 ab 都落在字符边界上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
let title = "修复 login";

// "修" "复" 各占 3 字节,空格占第 6 字节,login 从第 7 字节开始。
let hanzi = &title[..3];
assert_eq!(hanzi, "修");

let ascii_part = &title[7..];
assert_eq!(ascii_part, "login");

// is_char_boundary 判断某个字节下标是否是字符边界。
assert!(title.is_char_boundary(0));
assert!(title.is_char_boundary(3)); // "修" 之后的边界
assert!(!title.is_char_boundary(1)); // "修" 的中间字节
}

两种失败方式,性质不同:

1
2
3
4
5
6
7
8
9
10
fn main() {
let title = "修复 login";

// 方式 1:直接索引落在字符中间 -> panic(内部逻辑错误)。
// let broken = &title[..2]; // panics: byte index 2 is not a char boundary

// 方式 2:get 返回 Option -> 可分支处理(外部输入)。
assert_eq!(title.get(..2), None); // 2 不是字符边界
assert_eq!(title.get(..3), Some("修")); // 3 是边界
}

这与第 3 章数组切片的规则同源:越界/非法时 [] panic、get 返回 None。切分点来自内部逻辑(char_indices 算出来的)用 [];来自外部输入(用户给的偏移量)用 getget 拦下的不是"越界",而是"切在字符中间会产生非法 UTF-8"–语言不可能给你半个字符。

按字符处理:chars()char_indices()

按字符数截断(“取前 N 个字符”)不能直接 &text[..n]n 是字符数不是字节数。标准写法是先从 char_indices() 得到每个字符的起始字节位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// 取文本的前 max_chars 个字符。多字节字符不会被切断;
/// 请求长度超出时安全返回整段文本。
fn prefix_chars(text: &str, max_chars: usize) -> &str {
for (offset, (index, ch)) in text.char_indices().enumerate() {
if offset == max_chars {
// 第 max_chars 个字符从字节 index 开始:这里就是合法边界。
return &text[..index];
}
let _ = ch;
}
text // 字符数不足 max_chars:整段都是合法前缀
}

fn main() {
let title = "修复 login 权限";

// 按字符截断:每个汉字 3 字节,前 2 个字符 = 前 6 个字节。
assert_eq!(prefix_chars(title, 2), "修复");
assert_eq!(prefix_chars(title, 5), "修复 lo"); // 第 5 个字符是 "o"
assert_eq!(prefix_chars(title, 100), title); // 超出:返回整段

// 纯 ASCII:字符数 == 字节数。
assert_eq!(prefix_chars("abcdef", 3), "abc");
}

char_indices() 每步产出 (字节位置, 字符),它是"字节世界"与"字符世界"的翻译器。find() 也返回字节下标,可以直接喂给切片:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let title = "修复 login";

// find 返回字节下标 6,正好是字符边界。
let head = &title[..title.find(' ').unwrap()];
assert_eq!(head, "修复");

// split_once 按第一个分隔符切两半,天然安全。
let (a, b) = "release checklist".split_once(' ').unwrap();
assert_eq!((a, b), ("release", "checklist"));
}

返回 String 的字符截断用 chars().take().collect(),适合"截断后要独立保存"的场景:

1
2
3
4
5
6
fn main() {
let title = "重构任务队列的调度逻辑";
let truncated: String = title.chars().take(7).collect();
assert_eq!(truncated, "重构任务队列的");
assert_eq!(truncated.len(), 21); // 7 个汉字,21 字节
}

拼接与格式化:format!push_str+

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
let mut report = String::new();
report.push_str("task-1; ");
report.push_str("task-2; ");
assert_eq!(report, "task-1; task-2; ");

// format!:一次分配,只借用操作数,不夺走所有权。
let id = 42;
let label = format!("task-{id}");
assert_eq!(label, "task-42");

let name = String::from("login");
let greeting = format!("hello, {name}!");
assert_eq!(greeting, "hello, login!");
// format! 只借用 name,name 仍可使用。
assert_eq!(name, "login");

// + 拼接:左侧 String 被消耗(move),右侧可以是 &str。
let full = label + "!";
assert_eq!(full, "task-42!");
}

工程默认顺序: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_withbool
字符数s.chars().count()usize
拥有一份拷贝s.to_string() / s.to_owned()String
重复填充"-".repeat(10)String

注意返回类型的规律:不改变长度的操作返回 &str(借用视图),可能改变长度的操作返回 String(需要新分配)to_lowercase() 返回 String 是因为某些字符的大小写形式字节长度不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let raw = " Fix Login Bug ";

// 归一化管道:trim 借用,to_lowercase 分配。
let normalized = raw.trim().to_lowercase();
assert_eq!(normalized, "fix login bug");

// split + 计数:全部零分配。
let tags = "fix:login,review";
let count = tags.split(|c| c == ':' || c == ',')
.filter(|p| !p.is_empty())
.count();
assert_eq!(count, 3);
}

常见误区

  • 为了"方便"把所有参数写成 String:调用方被迫转移或分配;只读就用 &str
  • 用字节下标切多字节文本&title[..2] 会 panic 或 getNone;按字符截断先 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 增量追加。

自测

  1. 函数要"读一段文本"和"保存一段文本",参数/返回值分别怎么选?–答方向:读用 &str 参数;保存返回 String,让所有权离开函数。
  2. "修复 login"len()chars().count()、显示宽度分别是多少?–答方向:14(2 汉字 × 3 + 5 ASCII + 1 空格)、6、8;三种计量三个答案。
  3. title.get(..2)"修复 login" 返回什么,为什么不是 panic?–答方向:Noneget 把"切在字符中间"表达为 Option,拦下的是非法 UTF-8。
  4. find() 返回的下标可以直接用于 &s[..i] 吗?–答方向:可以;find 返回的字符/子串起点必然是字符边界。
  5. "任务"to_lowercase() 返回什么类型?–答方向:String;大小写转换可能改变字节长度,必须新分配。

下一章把"只借用需要的视图"推广到数组和 Vec:函数应接收它需要的切片,而不是某个具体容器。