字符串与切片:String、&str 与 UTF-8 边界
课程概览 · 第 2 章
文本接口有两个独立的问题:谁拥有数据,以及按什么单位处理数据。String 拥有可增长的 UTF-8 文本,&str 借用文本;字节、Unicode 标量值和用户眼中的完整字符又不总是一一对应。本章先选所有权,再确定切片与截断边界。
学习目标
前置知识:第 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 | |
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 | |
| 计量 | 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 | |
get 既检查数值边界,也检查 UTF-8 字符边界;它不只是在防止“半个字符”。两种失败需要分开理解:title[0] 请求单个整数下标,str 未提供这种索引,所以编译失败;范围索引可以编译,但非法字符边界会在运行期 panic。
故意失败:编译错误 02-4(标准库;文件:src/main.rs;str 不支持 usize 单元素索引)。
1 | |
故意失败:运行期 panic 02-5(标准库;文件:src/main.rs;范围终点 2 不在字符边界)。
1 | |
char_indices 与合法前缀
“取前 N 个标量值”不能直接使用 &text[..n],因为 n 不是字节位置。char_indices() 每步给出 (字节起点, 标量值),先找到截断处,再切片,便不会切断 UTF-8 编码。
独立示例 02-6:前缀主例(标准库;文件:src/main.rs)。
1 | |
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 | |
with_capacity(total_bytes) 为新字符串预留总容量;reserve(additional_bytes) 为已有字符串预留当前长度之外的额外容量。
+ 与 +=:注意左侧所有权
String + &str 消耗左侧 String,追加右侧文本后返回结果;右侧只被借用,&String 可以强制转换为 &str。+= 修改原字符串,原绑定仍可用。两个 &str 不能直接相加。
独立示例 02-7b(标准库;文件:src/main.rs)。
1 | |
format! 与 write!:拼接需要格式化的值
format! 返回新的 String,格式化参数不会因此被移动。已有输出缓冲区时用 write!,需将 std::fmt::Write trait 引入作用域。
独立示例 02-7c(标准库;文件:src/main.rs)。
1 | |
write! 返回 std::fmt::Result。此例写入 String 且只格式化标准整数,用 expect 表达不会返回格式化错误的不变量;自定义格式化实现仍需处理可能的错误。writeln! 会额外追加换行。
join 与 concat:拼接一组文本片段
数组或切片中的文本片段,可以用 join(separator) 在相邻元素之间插入分隔符,或用 concat() 直接连接。两者都创建新的 String,不消耗原集合;join 不在结果两端额外加分隔符,也不会跳过空片段。
独立示例 02-7d(标准库;文件:src/main.rs)。
1 | |
如果手里只有迭代器且要直接追加到现有字符串,可以自行循环控制分隔符;不必仅为调用 join 先收集一份临时 Vec。
extend 与 collect:从迭代器拼接
extend 将迭代器中的字符或文本片段追加到已有字符串;collect::<String>() 则构建一个结果。两者都不会自动插入分隔符,适合直接接收 filter、map 等操作的输出。
独立示例 02-7e(标准库;文件:src/main.rs)。
1 | |
parts.iter() 产生 &&str,.copied() 将其变为 extend 支持的 &str,只复制引用;追加时仍需把文本字节写入目标缓冲区。
concat!:编译期连接字面量
concat! 在编译期连接字面量,返回 &'static str,不在运行期分配 String。它与 .concat() 方法不同,不能接收运行期变量或 const 名称,也不提供 format! 的格式控制。
独立示例 02-7f(标准库;文件:src/main.rs)。
1 | |
其他常用操作
独立示例 02-7g(标准库;文件:src/main.rs)。
1 | |
| 需求 | 写法 | 返回 |
|---|---|---|
| 去首尾空白 | trim() | &str |
| 按分隔符拆分 | split(',') | 产生 &str 的迭代器 |
| 按第一个分隔符拆分 | split_once(':') | Option<(&str, &str)> |
| 查找 | find("lo") | Option<usize>,字节下标 |
| 大小写转换 | to_lowercase() | String,可能改变字节长度 |
| 判断包含、前后缀 | contains / starts_with / ends_with | bool |
| 拥有一份文本 | to_owned() / to_string() | String |
| 重复填充 | "-".repeat(10) | String |
返回类型由接口语义决定,不能概括为“长度不变就一定返回借用”。to_owned() 即使内容长度相同也创建拥有值。
临时值生命周期延长
let s = &input.to_lowercase(); 是合法写法:这种 let 初始化表达式中的借用会延长临时 String 的生命周期,使它在相应作用域内可供引用使用。先绑定拥有值再借用可以更清楚地表达所有权,但不是修复必然悬垂引用所需的操作。临时值生命周期延长
独立示例 02-8(标准库;文件:src/main.rs)。
1 | |
延长不是让函数局部值活到调用方。要返回新文本应返回 String,不能从函数返回指向本地临时值的引用。
故意失败:编译错误 02-9(标准库;文件:src/main.rs;返回引用指向函数局部临时值)。
1 | |
深入阅读:布局补充
String 管理指针、字节长度和容量,但图中的排列只作示意,不保证字段顺序或稳定 ABI;描述符也不一定放在栈上。非空文本的字节缓冲区由它管理,释放拥有值会按资源规则释放缓冲区。&str 概念上携带地址与字节长度,不拥有数据;传递引用不复制文本。容量不足时增长可能重新分配,所以借用有效期间不能任意修改或移动所有者,见第 6 章。
String::with_capacity(n) 请求至少容纳 n 个字节,不是 n 个标量值;实际容量可以更大。图中的容量 16 是示意值,不是 String::from("Rust") 的默认容量承诺。
自测
- 只读参数、独立返回值和输入子串各适合哪种文本类型?
"修复 login"有多少字节和标量值?能否据此确定真实显示宽度?get(..2)和get(..3)分别返回什么?title[0]又属于哪类失败?prefix_chars会保留完整字素簇吗?为什么?let s = &input.to_lowercase();为什么合法,而lower反例不合法?prefix + &name、report += &name、format!("{name}")对原字符串的所有权分别有什么影响?extend与collect::<String>()有什么区别?为什么&str数组的.iter()常需要接.copied()?
参考答案
- 分别是
&str、String、借用输入的&str;最后一种不能比输入活得更久。 - 12 字节、8 个标量值;简化全角假设下为 10 列,真实显示宽度仍依赖环境与 Unicode 序列。
- 分别是
None、Some("修");单整数索引不受支持,是编译错误。 - 不保证。它按 Unicode 标量值计数,可能拆开组合字符或 emoji 序列。
let借用触发临时值生命周期延长;返回引用不能让函数内部新建的临时值活到调用方。+消耗prefix、借用name;+=修改report、借用name;format!借用name并返回新的String。extend追加到已有字符串,collect构建一个结果;两者都不自动加分隔符。&str数组的.iter()产生&&str,.copied()将其变为受支持的&str元素,只复制引用。






