课程概览 · 第 2 章

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

下一篇:数组、切片和范围:固定长度、借用视图与区间边界

文本接口有两个独立的问题:谁拥有数据,以及按什么单位处理数据。String 拥有可增长的 UTF-8 文本,&str 借用文本;字节、Unicode 标量值和用户眼中的完整字符又不总是一一对应。本章先选所有权,再确定切片与截断边界。

String 描述符示意、堆缓冲区与 UTF-8 借用视图
图:String 拥有缓冲区,&str 借用其中一段;描述符字段顺序不作为 ABI 保证,切分点必须满足字符边界。

学习目标

前置知识:第 1 章的绑定、引用与 Option。学完你能:

  • 按读取、保存或返回新文本选择 &str 与 String;
  • 区分字节数、Unicode 标量值数、字素簇和显示宽度;
  • 用 get 处理外部字节范围,用 char_indices 截取合法前缀;
  • 判断文本操作是否借用、创建拥有值或消耗旧值;
  • 根据输入类型与输出需求选择拼接方法,理解 extend 与 collect 的区别。

本章独立示例只依赖标准库,采用 Edition 2024;每块单独写入 binary 项目的 src/main.rs,执行 cargo +stable run。

String 与 &str:拥有还是借用

String 拥有 UTF-8 缓冲区,可以增长并在需要时重新分配。&str 是对合法 UTF-8 文本的借用视图。只读参数优先接受 &str,既能传字面量,也能传 &String;返回值要独立存活时返回 String,返回输入的一段时可以借用输入。结构体需要自己保存文本时通常用 String,借用字段的生命周期在第 6 章展开。

独立示例 02-1(标准库;文件:src/main.rs)。

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

fn main() {
let owned = String::from("release checklist");
let literal = "release checklist";
assert_eq!(title_bytes(&owned), 17);
assert_eq!(title_bytes(literal), 17);
assert_eq!(owned, literal); // 参数只借用,没有拿走 owned。
}

String 实现 Deref<Target = str>,参数位置可以把 &String 强制转换为 &str,方法调用也可以使用 str 的接口。这种桥接不复制文本,不是任意类型间的隐式转换。切出的结果是 &str,需要独立保存时才用 to_owned() 等创建拥有值。

UTF-8:字节、标量值与字素簇

Rust 的 str 保证合法 UTF-8。一个 Unicode 标量值编码为 1–4 字节,本例的“修”“复”各占 3 字节;不能推广为所有汉字都固定 3 字节。chars() 迭代标量值,未必对应用户眼中的完整字符:组合重音、部分 emoji 序列会由多个标量值组成一个字素簇。标准库不提供通用的字素簇分段或终端显示宽度算法。

独立示例 02-2(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let text = "修复 login";
assert_eq!(text.len(), 12);
assert_eq!(text.chars().count(), 8);
assert_eq!(text.chars().next(), Some('修'));
assert_eq!(text.as_bytes()[0], 0xE4);

let combined = "e\u{301}";
assert_eq!(combined.chars().count(), 2);
assert_eq!(combined.len(), 3);
}
计量API 或前提"修复 login" 的结果
字节数len() / as_bytes().len()12
Unicode 标量值数chars().count()8
简化显示列数假定两个汉字各 2 列、六个 ASCII 各 1 列10

10 列只是在上述简化假设下的估算,不是跨终端、字体、Unicode 序列的通用结论。按字节处理协议或哈希用 as_bytes();按标量值处理用 chars();按用户可见字符截断应采用合适的 Unicode 分段方案,而不是把 chars() 当成字素簇迭代器。

字节切片与 get

str 是 UTF-8 字节序列的切片类型,不等同于任意 [u8]。&s[a..b] 使用字节下标,要求 a <= b <= s.len() 且两端都是字符边界。String 的切片与 get 使用相同规则。已证明的内部边界可以直接索引;来自外部、尚未验证的范围优先用 get,失败返回 None。

独立示例 02-3(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
fn main() {
let title = String::from("修复 login");
assert_eq!(&title[..3], "修");
assert_eq!(&title[7..], "login");
assert!(title.is_char_boundary(3));
assert!(!title.is_char_boundary(2));
assert_eq!(title.get(..2), None);
assert_eq!(title.get(..3), Some("修"));
assert_eq!(title.get(..100), None);
}

get 既检查数值边界,也检查 UTF-8 字符边界;它不只是在防止“半个字符”。两种失败需要分开理解:title[0] 请求单个整数下标,str 未提供这种索引,所以编译失败;范围索引可以编译,但非法字符边界会在运行期 panic。

故意失败:编译错误 02-4(标准库;文件:src/main.rs;str 不支持 usize 单元素索引)。

1
2
3
4
fn main() {
let title = "修复 login";
let _ = title[0];
}

故意失败:运行期 panic 02-5(标准库;文件:src/main.rs;范围终点 2 不在字符边界)。

1
2
3
4
fn main() {
let title = "修复 login";
let _broken = &title[..2];
}

char_indices 与合法前缀

“取前 N 个标量值”不能直接使用 &text[..n],因为 n 不是字节位置。char_indices() 每步给出 (字节起点, 标量值),先找到截断处,再切片,便不会切断 UTF-8 编码。

独立示例 02-6:前缀主例(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fn prefix_chars(text: &str, max_chars: usize) -> &str {
for (offset, (index, _)) in text.char_indices().enumerate() {
if offset == max_chars {
return &text[..index];
}
}
text
}

fn main() {
let title = "修复 login 权限";
assert_eq!(prefix_chars(title, 0), "");
assert_eq!(prefix_chars(title, 2), "修复");
assert_eq!(prefix_chars(title, 5), "修复 lo");
assert_eq!(prefix_chars(title, 100), title);
assert_eq!(prefix_chars("abcdef", 3), "abc");
assert_eq!(prefix_chars("", 3), "");

let text = "修复 login";
match text.find(' ') {
Some(index) => assert_eq!(&text[..index], "修复"),
None => panic!("known text contains a space"),
}
assert_eq!("release checklist".split_once(' '),
Some(("release", "checklist")));
}

find() 成功返回匹配起点的字节下标,可以作为切片边界。返回独立拥有的前缀可用 text.chars().take(n).collect::<String>(),但这是新结果;两种写法都按标量值而非字素簇截断。

拼接与常用操作

拼接先看目标:是追加到已有 String,还是生成新结果;再根据输入选择接口。没有对所有场景都最快的写法,通常应优先复用缓冲区、减少临时字符串和重复复制。

常见写法怎么选

需求推荐写法所有权与性能要点
追加单个字符s.push(ch)修改原字符串,写入 1–4 个 UTF-8 字节
追加文本、循环构建s.push_str(part) / s += part复用缓冲区;容量足够时不分配,只复制追加片段
简短的串联表达式s + part消耗左侧 String,复用其缓冲区;追加成本与 push_str 相近
固定模板、数字或格式控制format!(...)创建新 String,适合一次生成完整结果
向已有字符串写入格式化内容write!(&mut s, ...)直接写入,避免 format! 产生的临时字符串
拼接数组或切片parts.join(sep) / parts.concat()创建新结果;文本切片可先计算总长度,减少扩容
拼接迭代器输出s.extend(iter) / iter.collect::<String>()前者追加到已有字符串,后者构建结果;都不自动加分隔符
连接固定字面量concat!(...)编译期生成 &'static str,无运行期堆分配

记住这几条性能差异

  • 纯文本优先直接追加:push_str、+=、+ 的核心追加工作接近,不必按语法猜谁更快。已有文本片段不必先 chars() 再逐字符 push,那会增加 UTF-8 解码和编码工作。
  • 已知长度时预留容量:String::with_capacity(total_bytes) 或 reserve(additional_bytes) 能减少扩容;容量按字节计算。未知长度时直接追加即可,不必强行扫描输入两遍。
  • 循环格式化用 write!:比 push_str(&format!(...)) 少一个临时字符串;单次固定模板仍优先用清楚易读的 format!。
  • 避免循环重建旧结果:s = format!("{s}{part}") 每轮都会复制累计文本;固定长度片段反复拼接时,总复制量会随片段数平方增长。

仓库的真实文章文本 release 基准中,预留容量的 push_str 与 concat() / join("") 属于较快的一组,普通 push_str、+=、extend、collect 的耗时接近。具体差距依赖输入和运行环境,不应当作固定排名;基准程序与原始结果保留在 tools/rust-string-bench/,性能关键路径应使用实际输入测量。

push 与 push_str:向已有字符串追加

push(char) 追加一个 Unicode 标量值,push_str(&str) 追加一段文本;两者都修改原字符串,返回 ()。

独立示例 02-7a(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
let parts = ["修复", "login"];
let bytes = parts.iter().map(|part| part.len()).sum::<usize>();
let mut report = String::with_capacity(bytes + 1);
report.push_str(parts[0]);
report.push(' ');
report.push_str(parts[1]);
assert_eq!(report, "修复 login");

let mut tasks = String::new();
for part in ["task-1; ", "task-2; "] {
tasks.push_str(part);
}
assert_eq!(tasks, "task-1; task-2; ");
}

with_capacity(total_bytes) 为新字符串预留总容量;reserve(additional_bytes) 为已有字符串预留当前长度之外的额外容量。

+ 与 +=:注意左侧所有权

String + &str 消耗左侧 String,追加右侧文本后返回结果;右侧只被借用,&String 可以强制转换为 &str。+= 修改原字符串,原绑定仍可用。两个 &str 不能直接相加。

独立示例 02-7b(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let prefix = String::from("hello, ");
let name = String::from("login");
let mut greeting = prefix + &name;
// prefix 已被移动,不能再使用。
assert_eq!(name, "login");

greeting += "!";
greeting += " ready";
assert_eq!(greeting, "hello, login! ready");

let full = String::from("fix") + ":" + "login";
assert_eq!(full, "fix:login");
}

format! 与 write!:拼接需要格式化的值

format! 返回新的 String,格式化参数不会因此被移动。已有输出缓冲区时用 write!,需将 std::fmt::Write trait 引入作用域。

独立示例 02-7c(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::fmt::Write;

fn main() {
let name = String::from("login");
let attempt = 3;
let greeting = format!("hello, {name}! attempt={attempt:02}");
assert_eq!(greeting, "hello, login! attempt=03");
assert_eq!(name, "login");

let mut report = String::from("tasks:");
for id in 1..=2 {
write!(&mut report, " task-{id};")
.expect("writing these values to String cannot fail");
}
assert_eq!(report, "tasks: task-1; task-2;");
}

write! 返回 std::fmt::Result。此例写入 String 且只格式化标准整数,用 expect 表达不会返回格式化错误的不变量;自定义格式化实现仍需处理可能的错误。writeln! 会额外追加换行。

join 与 concat:拼接一组文本片段

数组或切片中的文本片段,可以用 join(separator) 在相邻元素之间插入分隔符,或用 concat() 直接连接。两者都创建新的 String,不消耗原集合;join 不在结果两端额外加分隔符,也不会跳过空片段。

独立示例 02-7d(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let parts = ["fix", "login", "权限"];
assert_eq!(parts.join(":"), "fix:login:权限");
assert_eq!(parts.concat(), "fixlogin权限");
assert_eq!(parts[0], "fix");

let empty: [&str; 0] = [];
assert_eq!(empty.join(","), "");
assert_eq!(["solo"].join(","), "solo");
assert_eq!(["fix", "", "login"].join(","), "fix,,login");
}

如果手里只有迭代器且要直接追加到现有字符串,可以自行循环控制分隔符;不必仅为调用 join 先收集一份临时 Vec。

extend 与 collect:从迭代器拼接

extend 将迭代器中的字符或文本片段追加到已有字符串;collect::<String>() 则构建一个结果。两者都不会自动插入分隔符,适合直接接收 filter、map 等操作的输出。

独立示例 02-7e(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
let mut report = String::from("任务:");
report.extend([' ', '修', '复']);
assert_eq!(report, "任务: 修复");

let parts = [" ", "login", " 权限"];
report.extend(parts.iter().copied());
assert_eq!(report, "任务: 修复 login 权限");
assert_eq!(parts[1], "login");

let compact: String = "修 复 login".chars()
.filter(|ch| !ch.is_whitespace())
.collect();
assert_eq!(compact, "修复login");
let merged: String = ["fix", ":", "login"].into_iter().collect();
assert_eq!(merged, "fix:login");
}

parts.iter() 产生 &&str,.copied() 将其变为 extend 支持的 &str,只复制引用;追加时仍需把文本字节写入目标缓冲区。

concat!:编译期连接字面量

concat! 在编译期连接字面量,返回 &'static str,不在运行期分配 String。它与 .concat() 方法不同,不能接收运行期变量或 const 名称,也不提供 format! 的格式控制。

独立示例 02-7f(标准库;文件:src/main.rs)。

1
2
3
4
fn main() {
let route: &'static str = concat!("/api/", "v", 1, "/tasks");
assert_eq!(route, "/api/v1/tasks");
}

其他常用操作

独立示例 02-7g(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
fn main() {
let raw = " Fix Login Bug ";
assert_eq!(raw.trim().to_lowercase(), "fix login bug");
let tags = "fix:login,review";
let count = tags.split([':', ',']).filter(|p| !p.is_empty()).count();
assert_eq!(count, 3);
let truncated: String = "重构任务队列的调度逻辑".chars().take(7).collect();
assert_eq!(truncated, "重构任务队列的");
assert_eq!(truncated.len(), 21);
}
需求写法返回
去首尾空白trim()&str
按分隔符拆分split(',')产生 &str 的迭代器
按第一个分隔符拆分split_once(':')Option<(&str, &str)>
查找find("lo")Option<usize>,字节下标
大小写转换to_lowercase()String,可能改变字节长度
判断包含、前后缀contains / starts_with / ends_withbool
拥有一份文本to_owned() / to_string()String
重复填充"-".repeat(10)String

返回类型由接口语义决定,不能概括为“长度不变就一定返回借用”。to_owned() 即使内容长度相同也创建拥有值。

临时值生命周期延长

let s = &input.to_lowercase(); 是合法写法:这种 let 初始化表达式中的借用会延长临时 String 的生命周期,使它在相应作用域内可供引用使用。先绑定拥有值再借用可以更清楚地表达所有权,但不是修复必然悬垂引用所需的操作。临时值生命周期延长

独立示例 02-8(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
fn main() {
let input = "LOGIN";
let s = &input.to_lowercase();
assert_eq!(s, "login");
let owned = input.to_lowercase();
let borrowed = owned.as_str();
assert_eq!(borrowed, "login");
}

延长不是让函数局部值活到调用方。要返回新文本应返回 String,不能从函数返回指向本地临时值的引用。

故意失败:编译错误 02-9(标准库;文件:src/main.rs;返回引用指向函数局部临时值)。

1
2
3
4
5
6
7
fn lower(input: &str) -> &str {
&input.to_lowercase()
}

fn main() {
let _ = lower("LOGIN");
}

深入阅读:布局补充

String 管理指针、字节长度和容量,但图中的排列只作示意,不保证字段顺序或稳定 ABI;描述符也不一定放在栈上。非空文本的字节缓冲区由它管理,释放拥有值会按资源规则释放缓冲区。&str 概念上携带地址与字节长度,不拥有数据;传递引用不复制文本。容量不足时增长可能重新分配,所以借用有效期间不能任意修改或移动所有者,见第 6 章。

String::with_capacity(n) 请求至少容纳 n 个字节,不是 n 个标量值;实际容量可以更大。图中的容量 16 是示意值,不是 String::from("Rust") 的默认容量承诺。

自测

  1. 只读参数、独立返回值和输入子串各适合哪种文本类型?
  2. "修复 login" 有多少字节和标量值?能否据此确定真实显示宽度?
  3. get(..2) 和 get(..3) 分别返回什么?title[0] 又属于哪类失败?
  4. prefix_chars 会保留完整字素簇吗?为什么?
  5. let s = &input.to_lowercase(); 为什么合法,而 lower 反例不合法?
  6. prefix + &name、report += &name、format!("{name}") 对原字符串的所有权分别有什么影响?
  7. extend 与 collect::<String>() 有什么区别?为什么 &str 数组的 .iter() 常需要接 .copied()?

参考答案

  1. 分别是 &str、String、借用输入的 &str;最后一种不能比输入活得更久。
  2. 12 字节、8 个标量值;简化全角假设下为 10 列,真实显示宽度仍依赖环境与 Unicode 序列。
  3. 分别是 None、Some("修");单整数索引不受支持,是编译错误。
  4. 不保证。它按 Unicode 标量值计数,可能拆开组合字符或 emoji 序列。
  5. let 借用触发临时值生命周期延长;返回引用不能让函数内部新建的临时值活到调用方。
  6. + 消耗 prefix、借用 name;+= 修改 report、借用 name;format! 借用 name 并返回新的 String。
  7. extend 追加到已有字符串,collect 构建一个结果;两者都不自动加分隔符。&str 数组的 .iter() 产生 &&str,.copied() 将其变为受支持的 &str 元素,只复制引用。