17. Strings & Text — 字符串与文本
字符串处理是 Rust 中最容易让初学者困惑的领域之一——不是因为 Rust 做得不好,而是因为它对 Unicode 和内存安全的要求比其他语言更高。Rust 的所有权系统在字符串上同样严格:你无法用无效索引截断多字节字符,也无法不小心将非 UTF-8 数据注入 String。本章从 Unicode 底层原理出发,系统覆盖 Rust 中所有字符串相关类型和操作。
Unicode 与 UTF-8 内部原理
要理解 Rust 的字符串,必须先理解 Unicode 的三个层次。
代码点、标量值和字形簇
- 代码点(Code Point):Unicode 给每个“字符”分配的数字,范围
U+0000~U+10FFFF(约 110 万个)。 - 标量值(Scalar Value):有效的 Unicode 代码点,排除代理对(Surrogate,
U+D800~U+DFFF)——共约 110 万个。Rust 的char类型代表一个 Unicode 标量值。 - 字形簇(Grapheme Cluster):用户感知的“一个字符”——可能由多个标量值组成。例如
é可以是单个标量值U+00E9,也可以是e+ 组合重音符U+0301(两个标量值)。
// 同一个"字符"可能有多种 Unicode 表示
let single = "\u{00E9}"; // é 作为一个标量值
let combined = "e\u{0301}"; // é 作为 e + 组合重音符
println!("{single} == {combined}"); // 视觉效果相同
assert_ne!(single, combined); // 但字节序列不同!
assert_eq!(single.len(), 2); // U+00E9 的 UTF-8 编码占 2 字节
assert_eq!(combined.len(), 3); // e(1字节) + U+0301(2字节) = 3 字节UTF-8 编码
Rust 的 String 和 &str 始终是有效的 UTF-8 编码。UTF-8 是变长编码:
| 代码点范围 | UTF-8 字节数 | 示例 |
|---|---|---|
| U+0000 ~ U+007F | 1 字节 | A = 0x41 |
| U+0080 ~ U+07FF | 2 字节 | é (U+00E9) = 0xC3 0xA9 |
| U+0800 ~ U+FFFF | 3 字节 | 中 (U+4E2D) = 0xE4 0xB8 0xAD |
| U+10000 ~ U+10FFFF | 4 字节 | 🦀 (U+1F980) = 0xF0 0x9F 0xA6 0x80 |
这意味着 UTF-8 的关键特性:
- ASCII 字符(0-127)与 UTF-8 完全兼容——一个字节
- 不能以字节索引直接定位字符——
&s[3]可能得到多字节字符的中间字节 - 不能假定
s.len()等于字符数量
String 与 &str:内存模型
Rust 的核心字符串类型分为借用和拥有两级,类似于 [T] 和 Vec<T> 的关系:
// &str — 字符串切片(借用,大小在编译时未知)
// ┌──────────┐
// │ pointer │ ──→ 堆上或静态内存中的 UTF-8 字节序列
// │ length │
// └──────────┘
// 16 字节(64 位系统)——这是“胖指针”(fat pointer)
// String — 拥有所有权的字符串(拥有,大小在编译时已知)
// ┌──────────┐
// │ pointer │ ──→ 堆上的 UTF-8 字节序列
// │ length │ (当前使用的字节数)
// │ capacity │ (分配的容量)
// └──────────┘
// 24 字节(64 位系统)
// str — 不定长类型(不能直接声明 str 类型的变量)
// 只能以 &str 形式存在内存布局示意:
String s = "你好"
堆内存: [228, 189, 160, 229, 165, 189, ...]
└─ 你 ─┘ └─ 好 ─┘
length = 6
capacity >= 6创建字符串
// 从字面量
let s = "hello"; // &str,静态生命周期
let s = "hello".to_string(); // String
let s = String::from("hello"); // String
// 从 char
let c = '世';
let s: String = c.to_string();
// format! 宏——最灵活的构造方式
let name = "Alice";
let age = 30;
let s = format!("我的名字是 {name},今年 {age} 岁");
// to_owned——从引用创建拥有值
let slice: &str = "hello";
let owned: String = slice.to_owned();
// into——类型转换
let s: String = "hello".into();
// 从 Vec<u8>
let bytes = vec![228, 189, 160, 229, 165, 189];
let s = String::from_utf8(bytes).expect("必须是有效 UTF-8");
// 从 OsString / PathBuf
use std::ffi::OsStr;
let os_str = OsStr::new("hello");
let s = os_str.to_string_lossy().into_owned();
// String 的收集
let chars = vec!['h', 'e', 'l', 'l', 'o'];
let s: String = chars.iter().collect();
// repeat
let s = "ha".repeat(5); // "hahahahaha"常用操作
修改字符串
let mut s = String::from("Hello");
// 追加
s.push(' '); // 追加一个 char
s.push_str("World!"); // 追加 &str
// 插入
s.insert(0, '>'); // 在字节位置 0 插入
s.insert_str(0, ">>"); // 在字节位置 0 插入字符串
// 移除
let last = s.pop(); // 移除并返回最后一个字符(Option<char>)
s.remove(0); // 移除指定字节位置的字符——O(n)
// 替换
let new_s = s.replace("World", "Rust"); // 替换所有出现
// 清空
s.clear(); // 删除所有字符,保留已分配的容量字节索引切片(危险操作)
String 和 &str 的切片操作使用字节索引而非字符索引——在多字节字符的中间切片会 panic:
let s = "你好世界";
// 安全——在 UTF-8 字符边界切片
assert_eq!(&s[0..3], "你"); // 你在 UTF-8 中占 3 字节
assert_eq!(&s[3..6], "好"); // 好在 UTF-8 中占 3 字节
// 危险——在字符中间切片将 panic
// let bad = &s[0..2]; // panic! byte index 2 is not a char boundary
// 安全检查
let end = s.char_indices().nth(2).map(|(i, _)| i).unwrap_or(s.len());
let safe_slice = &s[0..end];// 更安全的字符切片方法
fn safe_slice(s: &str, start_char: usize, end_char: usize) -> &str {
let start_byte = s.char_indices()
.nth(start_char)
.map(|(i, _)| i)
.unwrap_or(s.len());
let end_byte = s.char_indices()
.nth(end_char)
.map(|(i, _)| i)
.unwrap_or(s.len());
&s[start_byte..end_byte]
}
let s = "你好世界";
assert_eq!(safe_slice(s, 0, 2), "你好");迭代字符串
let s = "Hello 你好";
// chars()——Unicode 标量值迭代器
let chars: Vec<char> = s.chars().collect();
// ['H','e','l','l','o',' ','你','好']
// char_indices()——(字节位置, char) 迭代器
for (byte_pos, ch) in s.char_indices() {
println!("{ch} 从字节位置 {byte_pos} 开始");
}
// bytes()——原始字节迭代器
let bytes: Vec<u8> = s.bytes().collect();
// [72, 101, 108, 108, 111, 32, 228, 189, ...]
// lines()——按行分割(\n 或 \r\n)
let multiline = "第一行\n第二行\n第三行";
for line in multiline.lines() {
println!("{line}");
}
// split_whitespace()——按空白字符分割
let words: Vec<&str> = "hello world\tfoo".split_whitespace().collect();
assert_eq!(words, vec!["hello", "world", "foo"]);搜索与查找
let s = "Hello, World!";
// 包含检查
assert!(s.contains("World"));
assert!(s.contains('H'));
// 搜索——返回字节索引
assert_eq!(s.find('o'), Some(4)); // 第一个 'o' 的字节位置
assert_eq!(s.rfind('o'), Some(8)); // 最后一个 'o'
assert_eq!(s.find("World"), Some(7));
assert_eq!(s.find('x'), None);
// 前缀/后缀
assert!(s.starts_with("Hello"));
assert!(s.ends_with('!'));
assert!(s.ends_with("World!"));
// 模式匹配(find 接受多种模式类型)
// char, &str, &String, FnMut(char) -> bool 等
let pos = s.find(|c: char| c.is_uppercase());
assert_eq!(pos, Some(0)); // 'H'
// match_indices——所有匹配的位置和内容
for (pos, matched) in s.match_indices("l") {
println!("在位置 {pos} 找到 '{matched}'");
}分割与提取
let s = "apple,banana,orange,grape";
// 按分隔符分割
let parts: Vec<&str> = s.split(',').collect();
assert_eq!(parts, vec!["apple", "banana", "orange", "grape"]);
// 限制分割次数
let parts: Vec<&str> = s.splitn(2, ',').collect();
assert_eq!(parts, vec!["apple", "banana,orange,grape"]);
// rsplit——从右开始分割
let parts: Vec<&str> = s.rsplitn(2, ',').collect();
assert_eq!(parts, vec!["grape", "apple,banana,orange"]);
// split_inclusive——保留分隔符在结果中
let parts: Vec<&str> = "a,b,c".split_inclusive(',').collect();
assert_eq!(parts, vec!["a,", "b,", "c"]);
// split_terminator——忽略尾部的空段
let parts: Vec<&str> = "a,b,".split_terminator(',').collect();
assert_eq!(parts, vec!["a", "b"]); // 没有尾部空串
// 按空白分割
let parts: Vec<&str> = " hello world ".split_whitespace().collect();
assert_eq!(parts, vec!["hello", "world"]);修剪与去空格
let s = " \t hello world \n \r\n";
// 去除两端空白
assert_eq!(s.trim(), "hello world");
// 只去除开头或结尾
assert_eq!(s.trim_start(), "hello world \n \r\n");
assert_eq!(s.trim_end(), " \t hello world");
// trim_matches——自定义修剪字符
let s = "===hello===";
assert_eq!(s.trim_matches('='), "hello");
let s = ">>>hello<<<";
assert_eq!(s.trim_matches(|c| c == '>' || c == '<'), "hello");
// strip_prefix / strip_suffix
let s = "https://example.com";
assert_eq!(s.strip_prefix("https://"), Some("example.com"));
assert_eq!(s.strip_suffix(".org"), None);大小写转换
let s = "Hello, World!";
// 基础转换
assert_eq!(s.to_lowercase(), "hello, world!");
assert_eq!(s.to_uppercase(), "HELLO, WORLD!");
// 注意:大小写转换可能改变字符串长度
let german = "Straße"; // 德国街道——ß 的小写
let upper = german.to_uppercase(); // "STRASSE"——变长了!
assert_eq!(upper.len(), 7); // ß 的大写是 SS(两个字符)
let turkish_i = "i".to_uppercase(); // 默认是 'I'
// 注意:to_lowercase/to_uppercase 不考虑区域设置
// 对于土耳其语 İ/i 的特殊转换需要 icu 等专业库大小写转换返回新的 String——原字符串不修改。
格式化字符串
format! 宏
// 基本占位符
let s = format!("{}-{}", "hello", "world");
// 位置参数
let s = format!("{1} {0}", "world", "hello"); // "hello world"
// 命名参数
let s = format!("{name} is {age} years old",
name = "Alice",
age = 30,
);
// 格式化说明符
format!("{:>8}", "hi"); // " hi"(右对齐,宽度8)
format!("{:<8}", "hi"); // "hi "(左对齐)
format!("{:^8}", "hi"); // " hi "(居中)
format!("{:0>5}", 42); // "00042"(零填充)
format!("{:.2}", 3.14159); // "3.14"(精度)
format!("{:b}", 42); // "101010"(二进制)
format!("{:x}", 255); // "ff"(十六进制小写)
format!("{:X}", 255); // "FF"(十六进制大写)
format!("{:#x}", 255); // "0xff"(带前缀)
format!("{:?}", vec![1,2]); // "[1, 2]"(Debug)
format!("{:#?}", vec![1,2]); // 带缩进的多行 DebugDisplay 和 Debug trait
use std::fmt;
struct Point { x: f64, y: f64 }
// Debug——自动派生或手动实现(用于 {:?})
impl fmt::Debug for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Point({:.2}, {:.2})", self.x, self.y)
}
}
// Display——手动实现(用于 {})
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// 尊重 Formatter 的格式化参数
write!(f, "({:.prec$}, {:.prec$})", self.x, self.y, prec = f.precision().unwrap_or(2))
}
}
let p = Point { x: 3.14159, y: 2.71828 };
println!("Debug: {:?}", p); // Point(3.14, 2.72)
println!("Display: {p}"); // (3.14, 2.72)
println!("Display: {:.4}", p); // (3.1416, 2.7183)write! 宏
write! 和 writeln! 宏将格式化文本写入实现了 fmt::Write 或 io::Write 的对象:
use std::fmt::Write;
let mut buf = String::new();
// write! 写入 String(实现了 fmt::Write)
write!(&mut buf, "{} + {} = {}", 2, 3, 5).unwrap();
assert_eq!(buf, "2 + 3 = 5");
use std::io::Write as IoWrite;
// write! 写入文件(实现了 io::Write)
let mut file = std::fs::File::create("output.txt")?;
writeln!(file, "Hello, {}", "world")?;正则表达式
Rust 标准库不含正则表达式支持,需使用 regex crate:
// Cargo.toml: regex = "1"
use regex::Regex;
// 编译正则(编译一次,多次复用)
let re = Regex::new(r"(\d{4})-(\d{2})-(\d{2})").unwrap();
// is_match——检查是否匹配
assert!(re.is_match("2024-01-15"));
assert!(!re.is_match("2024/01/15"));
// find——查找第一个匹配
let text = "Events on 2024-01-15 and 2024-02-20";
let m = re.find(text).unwrap();
assert_eq!(m.as_str(), "2024-01-15");
assert_eq!(m.range(), 10..20);
// captures——捕获组
let caps = re.captures("2024-01-15").unwrap();
assert_eq!(&caps[1], "2024"); // 年
assert_eq!(&caps[2], "01"); // 月
assert_eq!(&caps[3], "15"); // 日
// 命名捕获组
let re = Regex::new(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})").unwrap();
let caps = re.captures("2024-01-15").unwrap();
assert_eq!(caps.name("year").unwrap().as_str(), "2024");
// replace——替换
let result = re.replace_all("2024-01-15", "${month}/${day}/${year}");
assert_eq!(result, "01/15/2024");
// find_iter——遍历所有匹配
for m in re.find_iter(text) {
println!("找到日期:{}", m.as_str());
}
// split——按模式分割
let parts: Vec<&str> = re.split("2024-01-15 2024-02-20").collect();
assert_eq!(parts, vec!["", " ", ""]);性能建议:在循环外部编译 Regex——编译成本高,匹配极快。
其他字符串类型
OsString 和 OsStr
操作系统文件名、环境变量和命令行参数不保证是有效 UTF-8。Linux 允许文件名包含任意字节(除 NUL 和 /)。OsStr/OsString 可以容纳非 UTF-8 数据:
use std::ffi::OsString;
use std::path::PathBuf;
// String <-> OsString
let s = String::from("hello");
let os: OsString = s.into(); // String -> OsString
let s2: Result<String, _> = os.into_string(); // 如果非 UTF-8 则失败
// 文件路径
let path = PathBuf::from("/home/user/document.txt");
let file_name = path.file_name(); // Option<&OsStr>Path 和 PathBuf
Path 类似于 OsStr 但增加了路径相关方法,PathBuf 是所有权版本:
use std::path::{Path, PathBuf};
let path = Path::new("/usr/local/bin");
assert_eq!(path.parent(), Some(Path::new("/usr/local")));
assert_eq!(path.file_name(), Some(OsStr::new("bin")));
// join 连接路径
let full_path = path.join("rustc");
assert_eq!(full_path, PathBuf::from("/usr/local/bin/rustc"));CString 和 CStr
与 C 语言 FFI 交互时,需要以 NUL 结尾的字符串:
use std::ffi::{CString, CStr};
use std::os::raw::c_char;
// Rust -> C(自动添加 NUL 终止符)
let rust_string = String::from("hello");
let c_string = CString::new(rust_string).unwrap(); // 拒绝包含内部 NUL 的字符串
let ptr: *const c_char = c_string.as_ptr();
// C -> Rust
unsafe {
let c_str = CStr::from_ptr(ptr);
let rust_str = c_str.to_str().unwrap();
}Cow<str>:惰性克隆
Cow<'a, str>(Clone-on-Write)在不需要修改时避免分配:
use std::borrow::Cow;
fn sanitize(input: &str) -> Cow<'_, str> {
if input.contains('\0') {
Cow::Owned(input.replace('\0', "")) // 需要修改——分配
} else {
Cow::Borrowed(input) // 不需要修改——零分配
}
}
let clean = sanitize("hello"); // Borrowed——无分配
let sanitized = sanitize("hel\0lo"); // Owned——有分配Unicode 规范化
同一字形有多种 Unicode 表示方式。规范化将等价表示统一:
// Cargo.toml: unicode-normalization = "0.1"
use unicode_normalization::UnicodeNormalization;
let composed = "\u{00E9}"; // é 的预组合形式(NFC)
let decomposed = "e\u{0301}"; // e + 组合重音符(NFD)
// 这两者视觉相同但字节不同
assert_ne!(composed, decomposed);
// NFC 规范化——组合字符
let nfc: String = decomposed.nfc().collect();
assert_eq!(nfc, "\u{00E9}");
// NFD 规范化——分解字符
let nfd: String = composed.nfd().collect();
assert_eq!(nfd, "e\u{0301}");何时需要规范化:
- 比较用户输入(例如搜索功能)
- 跨系统数据交换
- 文件名规范化
字符串的性能陷阱
// 陷阱 1:循环中拼接字符——每个 + 都分配新 String
let mut result = String::new();
for i in 0..1000 {
result = result + &i.to_string() + ", "; // 每次重新分配!
}
// 更优做法:用 push_str 或 write!
let mut result = String::new();
for i in 0..1000 {
use std::fmt::Write;
write!(&mut result, "{i}, ").unwrap();
}
// 陷阱 2:大量小字符串分配
// 使用第三方库(如 string-builder、arrayvec 中的 ArrayString)
// 陷阱 3:to_string() 每次分配
// 函数签名中用 &str 代替 &String
fn process(name: &str) { /* ... */ } // 好
// fn process(name: &String) { /* ... */ } // 差——调用者被迫传 &String字符串相关 trait 总览
| Trait | 用途 | 实现者 |
|---|---|---|
AsRef<str> | 廉价转换为 &str | String, &str, Cow<str>, Box<str> |
Borrow<str> | 哈希一致借用 | String |
ToOwned | 引用 → String | &str → String |
From<&str> | 复制构造 String | String |
fmt::Display | 面向用户的格式化 {} | 大多数类型 |
fmt::Debug | 调试格式化 {:?} | 几乎所有类型 |
FromStr | 从字符串解析 | i32, f64, bool 等 |
ToString | 转换为 String | 所有实现 Display 的类型,但优先用 to_owned() 避免调用开销 |
AsRef<Path> | 作为路径参数 | String, &str, OsString, PathBuf |
小结
- Rust 的
String和&str始终是有效 UTF-8。UTF-8 是变长编码——ASCII 字符 1 字节,中文字符 3 字节,emoji 4 字节。不能用简单字节索引定位字符。 &str是胖指针(指针 + 长度),String是三字结构(指针 + 长度 + 容量)。两者都保证指向有效的 UTF-8 数据。- 字符串切片使用字节索引——在字符中间切片会 panic。用
char_indices()安全定位字符边界。 chars()迭代 Unicode 标量值,bytes()迭代原始字节,lines()按行分割。find/split/trim等方法支持多种模式类型(char,&str, 闭包等)。- 正则表达式通过
regexcrate 提供——编译一次、多次复用。捕获组支持命名和索引两种访问方式。 - OsStr/OsString 封装非 UTF-8 文件名和系统数据。Path/PathBuf 在此基础上添加路径操作。CStr/CString 用于 FFI 的 NUL 终止字符串。
Cow<str>实现惰性分配。