字符串与 Path

本页目录

Rust 的 String/str 维护有效 UTF-8;操作系统路径则不一定能表示成 UTF-8。先判断数据表达的是文本、原始字节还是系统路径,才能选择不会丢信息的类型与操作。

拥有与借用是第一条轴

拥有内容借用视图保证
Stringstr / &str有效 UTF-8 文本
Vec[u8] / &[u8]任意字节,不承诺文本编码
OsStringOsStr / &OsStr平台字符串,保留其可表达内容
PathBufPath / &Path平台路径组件语义

&str 可以借用 String 的一部分,不仅指向字符串字面量,也不自动具有 'static 生命周期。String 的安全 API 维护 UTF-8 不变量;使用 unsafe 构造或修改时,调用者必须继续维护它,不能说编译器会自动验证所有内存操作。String

字节、char 和可见字符不同

UTF-8 字节:6 个65 | CC 81 | E4 B8 ADUnicode 标量值:3 个U+0065、U+0301、U+4E2D扩展字素簇:通常 2 个e + 组合重音构成一个簇;中构成另一个簇同一段文本有不同计数单位:e + 组合重音 + 中Rust char 表示 Unicode 标量值;用户感知字符与终端显示宽度还需要其他规则。
fn main() {
    let text = "e\u{301}中";
    assert_eq!(text.len(), 6);
    assert_eq!(text.chars().count(), 3);
    assert_eq!(text.char_indices().collect::<Vec<_>>(),
               [(0, 'e'), (1, '\u{301}'), (3, '中')]);
    assert_eq!(text.get(1..3), Some("\u{301}"));
    assert_eq!(text.get(2..3), None); // 2 位于 UTF-8 编码中间
}

len 统计字节,chars 遍历 Unicode 标量值。Unicode 中的“码点”还包含 surrogate 等不能作为 Rust char 的值,准确讨论时应使用标量值这个概念。

用户看到的一个字符可能由多个标量值组成,emoji 序列也不必只有一个 4 字节标量。编辑器移动光标、截断用户昵称等任务,通常还需要字素簇分段规则;显示列宽又是另一问题。Unicode 文本分段

索引必须先选定单位

Rust 不提供 text[n] 返回“第 n 个字符”的接口,因为这个词可能指字节、标量值或字素簇,而成本与语义不同。

  • 读取第 n 个字节:text.as_bytes().get(n)。
  • 读取第 n 个标量值:text.chars().nth(n),通常需扫描前面的编码。
  • 按字节范围借用文本:text.get(a..b),要求范围及 UTF-8 边界都有效。
  • 需要字素簇:采用实现相应 Unicode 分段规则的库,并明确其 Unicode 版本。

直接 &text[a..b] 在范围或边界非法时会 panic;get 用 Option 表达失败。循环中反复 chars().nth(i) 可能重复扫描形成二次复杂度,通常应一次 chars/char_indices 迭代完成工作。

有效 UTF-8 也不意味着已经 Unicode 规范化:预组合的 é 和 e 加组合重音通常不按普通字符串相等比较视为相等。搜索或身份匹配需要什么规范化策略,应由业务明确决定。

限制字节预算时向前寻找合法边界

fn prefix_bytes(text: &str, budget: usize) -> &str {
    let mut end = budget.min(text.len());
    while !text.is_char_boundary(end) {
        end -= 1;
    }
    &text[..end]
}
fn main() {
    assert_eq!(prefix_bytes("中ab", 2), "");
    assert_eq!(prefix_bytes("中ab", 4), "中a");
    assert_eq!(prefix_bytes("中ab", 100), "中ab");
    assert_eq!(prefix_bytes("e\u{301}", 1), "e");
}

这满足“不超过指定 UTF-8 字节数且结果仍是合法文本”的契约,最后一个断言也暴露了它的边界:可以把组合重音从字母后截走。用于网络字段的字节上限可能合适,用于用户可见昵称则还要按字素簇规则决定截断点。返回的是原文本借用,没有新建 String;源字符串也因此不能在借用仍使用时任意修改。

文本相等不自动做规范化

fn main() {
    let composed = "\u{e9}";
    let decomposed = "e\u{301}";
    assert_ne!(composed, decomposed);
    assert_eq!(composed.len(), 2);
    assert_eq!(decomposed.len(), 3);
    assert_eq!("a\nb\n".lines().collect::<Vec<_>>(), ["a", "b"]);
    assert_eq!("a\nb\n".split('\n').collect::<Vec<_>>(), ["a", "b", ""]);
}

选择 lines 还是 split 也要看协议:前者按行语义处理,后者按分隔字符保留末尾空段。需要保留空字段或精确原始换行时,不能为了简洁随意替换。规范化、大小写处理与去空白同样属于数据解释策略,应在输入边界明确,并保留需要用于展示或审计的原始表示。

字节边界与可见字符边界不是一回事

按字节预算截断有两个问题:先不能破坏 UTF-8 编码,然后还要判断是否允许拆开用户看到的一个字符。预算为 2 或 3 时落在“中”的编码内部;预算为 5 时 UTF-8 完全合法,却把 e 后的重音截走。两类边界需要不同的处理策略。

正在呈现知识画面
字节边界与可见字符边界不是一回事

有效 UTF-8 截断不会切开一个标量的编码,却仍可能拆开 e 与组合重音。

从字节得到文本,明确是否允许损失

String::from_utf8 检查拥有的字节向量,失败时可取回错误中的原始字节;str::from_utf8 对借用切片检查。from_utf8_lossy 用替换字符展示非法序列,适合某些日志,但不适合需要无损回传的协议或文件名。

String 也不保证末尾有 C 的 NUL 字节。与 C 字符串互操作用 CString/CStr,并考虑内部 NUL 与外部缓冲生命周期,参见 C ABI 与 bindgen。

第一块:41 E4 B8完整 A + “中”的前两字节第二块:AD 42补齐“中” + 完整 B组合字节:41 E4 B8 AD 42文本结果 A中BUTF-8 编码边界不一定与读取分块边界一致error_len = None 可表示尾部未完成;流结束后仍未完成就应按截断错误处理。

区分暂缺后续字节与确定的非法编码

fn main() {
    let mut bytes = vec![b'A', 0xe4, 0xb8];
    let error = std::str::from_utf8(&bytes).unwrap_err();
    assert_eq!(error.valid_up_to(), 1);
    assert_eq!(error.error_len(), None);
    bytes.extend([0xad, b'B']);
    assert_eq!(std::str::from_utf8(&bytes), Ok("A中B"));

    let bad = vec![b'A', 0xff, b'B'];
    let error = String::from_utf8(bad).unwrap_err();
    assert_eq!(error.utf8_error().valid_up_to(), 1);
    assert_eq!(error.utf8_error().error_len(), Some(1));
    assert_eq!(error.into_bytes(), [b'A', 0xff, b'B']);
}

读到第一块时,尾部 E4 B8 可能只是“中”跨块,不能立即丢弃或替换。如果协议规定 UTF-8 流,可输出已确认的前缀、保留未完成尾部,并在下一块继续检查;若消息或流已结束,则必须报告截断或执行明确的替换策略。直接对每一读取块分别 from_utf8_lossy,会把原本合法的跨块字符变成替换字符。Utf8Error

本例把全部字节保存在 Vec 中以展示边界。实际流式实现还要限制累计缓冲大小,并避免每次追加都从头重新验证已确认的长前缀。

OsStr 不是承诺某种可持久化内部编码

Unix 文件名组件可含非 UTF-8 字节,但不能包含 NUL,斜杠是路径分隔符。Windows 的系统宽字符接口使用 16 位编码单元,可能出现不能当作有效 Unicode 标量序列的内容;不能把 Windows 文件系统原生格式直接说成 WTF-8。

Rust 的 Windows 内部表示与 WTF-8 有实现关联,但跨平台 OsStr 抽象不让应用依赖某个内部编码作为持久化协议。Unix 可用 OsStrExt::as_bytes 等平台接口无损访问,Windows 可用 encode_wide 得到宽字符单元。OsStr、Windows OsStrExt

#[cfg(unix)]
fn main() {
    use std::ffi::OsString;
    use std::os::unix::ffi::OsStringExt;
    use std::path::PathBuf;
    let path = PathBuf::from(OsString::from_vec(vec![b'f', 0xff]));
    assert!(path.to_str().is_none());
    assert_eq!(path.into_os_string().into_vec(), [b'f', 0xff]);
}
#[cfg(not(unix))]
fn main() {}

Path::display 与 to_string_lossy 适合人类阅读,不应把显示结果再当成原始路径身份。文件操作尽量一直传 &Path/PathBuf,避免 UTF-8 转换往返。

Path 操作不自动访问或隔离文件系统

join、parent、extension、components 等主要按平台路径语法处理。join 的右侧如果是绝对路径,会替换前面的基路径;Windows 的前缀与根路径还存在平台特有规则。Path

use std::path::Path;
fn main() {
    let path = Path::new("reports").join("2026").join("summary.txt");
    assert_eq!(path.file_name().unwrap(), "summary.txt");
    assert_eq!(path.extension().unwrap(), "txt");
}

components 不会任意折叠 a/../b,因为符号链接可能改变含义。canonicalize 会访问文件系统并解析相应路径,但结果也不证明之后访问时目录和链接没有改变。

因此 PathBuf::join 是正确表达路径拼接的工具,却不是“保证用户输入始终落在某个目录里”的安全边界。若任务需要限定访问根目录,还要按目标系统使用适当的目录句柄与解析约束,不能只做一次字符串前缀检查。

路径前缀按组件比较,仍不代表文件系统包含关系

use std::path::{Component, Path};
fn normal_relative(path: &Path) -> bool {
    !path.as_os_str().is_empty()
        && path.components().all(|c| matches!(c, Component::Normal(_)))
}
fn main() {
    assert!(normal_relative(Path::new("reports/summary.txt")));
    assert!(!normal_relative(Path::new("../outside")));
    assert!(!normal_relative(Path::new("")));
    assert!(!Path::new("reports-old/a").starts_with("reports"));
    assert!(Path::new("reports/../outside").starts_with("reports"));
    assert!(!normal_relative(Path::new("reports/../outside")));
}

函数只表达一个词法输入策略:非空,并且解析后的组件都为 Normal。components 会做有限的语法规范化,重复分隔符等原始拼写信息不一定保留;不同平台还会用各自的分隔符与前缀规则。它不检查路径是否存在,也无法防止 reports 内的符号链接指向外部。

Path::starts_with 比字符串前缀更符合组件语义,因此 reports-old 不算 reports 的子路径;但带 .. 的路径仍可能通过这个组件前缀判断。即使拒绝 ..,符号链接和并发目录变化仍需在实际打开阶段处理,不能把上面的 normal_relative 当成完整的文件访问隔离方案。Path 组件与前缀

把展示、存储与操作接口分开

用途合适的表示转换时保留什么
文本协议字段&str / String编码错误、长度单位与规范化策略
未知编码输入&[u8] / Vec原始字节,确认格式后再解码
当前平台文件操作&Path / PathBuf原始 OsStr 内容与平台组件语义
日志中的文件名display 或明确转义表示展示可能有损,不再用于定位原文件
跨平台保存路径标识明确设计的格式平台、根/前缀规则及不可表示内容的处理

文件 API 接收路径时,优先让调用者传 AsRef 或 &Path,内部直接进行路径操作;如果强制接收 String,就在入口排除了部分合法系统路径。相反,网络协议里的资源名即便长得像路径,也应先按协议规定解释,不能直接套用当前操作系统的 Path 规则。