Skip to content
Published at:

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(两个标量值)。
rust
// 同一个"字符"可能有多种 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+007F1 字节A = 0x41
U+0080 ~ U+07FF2 字节é (U+00E9) = 0xC3 0xA9
U+0800 ~ U+FFFF3 字节 (U+4E2D) = 0xE4 0xB8 0xAD
U+10000 ~ U+10FFFF4 字节🦀 (U+1F980) = 0xF0 0x9F 0xA6 0x80

这意味着 UTF-8 的关键特性:

  • ASCII 字符(0-127)与 UTF-8 完全兼容——一个字节
  • 不能以字节索引直接定位字符——&s[3] 可能得到多字节字符的中间字节
  • 不能假定 s.len() 等于字符数量

String 与 &str:内存模型

Rust 的核心字符串类型分为借用拥有两级,类似于 [T]Vec<T> 的关系:

rust
// &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

创建字符串

rust
// 从字面量
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"

常用操作

修改字符串

rust
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:

rust
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];
rust
// 更安全的字符切片方法
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), "你好");

迭代字符串

rust
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"]);

搜索与查找

rust
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}'");
}

分割与提取

rust
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"]);

修剪与去空格

rust
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);

大小写转换

rust
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! 宏

rust
// 基本占位符
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]); // 带缩进的多行 Debug

Display 和 Debug trait

rust
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::Writeio::Write 的对象:

rust
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:

rust
// 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 数据:

rust
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 是所有权版本:

rust
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 结尾的字符串:

rust
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)在不需要修改时避免分配:

rust
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 表示方式。规范化将等价表示统一:

rust
// 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}");

何时需要规范化:

  • 比较用户输入(例如搜索功能)
  • 跨系统数据交换
  • 文件名规范化

字符串的性能陷阱

rust
// 陷阱 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>廉价转换为 &strString, &str, Cow<str>, Box<str>
Borrow<str>哈希一致借用String
ToOwned引用 → String&strString
From<&str>复制构造 StringString
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, 闭包等)。
  • 正则表达式通过 regex crate 提供——编译一次、多次复用。捕获组支持命名和索引两种访问方式。
  • OsStr/OsString 封装非 UTF-8 文件名和系统数据。Path/PathBuf 在此基础上添加路径操作。CStr/CString 用于 FFI 的 NUL 终止字符串。Cow<str> 实现惰性分配。