Rust 字符串与切片实战
字符串与切片是所有新手遇到的第一个门槛,不同于 Java、Python 等语言对字符串的高度封装,Rust 的字符串与切片深度绑定了所有权、借用、生命周期与 UTF-8 编码,从编译期就将乱码、内存安全等问题解决。
什么是切片(Slice)
字符串是切片的特殊场景,想要理解字符串,必须先搞懂切片。在 Rust 里,切片是描述一组属于同一类型、长度不确定的、在内存中连续存放的数据结构,用 [T] 来表述。因为长度不确定,切片属于动态大小类型(DST, Dynamically Sized Type),无法直接在栈上存储,必须通过引用(&[T],不可变切片)或可变引用(&mut [T],可变切片)来使用。
切片通过 Rust 的范围语法 [start..end] 创建,遵循左闭右开原则,支持多种简写形式:
[a..b]:从索引a到b-1的切片[a..]:从索引a到集合末尾的切片[..b]:从集合开头到索引b-1的切片[..]:覆盖整个集合的全切片
示例如下:
fn main() {
// 原数组:切片的底层数据所有者
let arr = [1, 2, 3, 4, 5];
// 创建切片:引用数组的第1到第3个元素
let slice: &[i32] = &arr[1..4];
println!("切片内容: {:?}", slice); // 输出 [2, 3, 4]
println!("切片长度: {}", slice.len()); // 输出 3
println!("切片是否为空: {}", slice.is_empty()); // 输出 false
}
Rust 的切片从编译期就杜绝了两类经典内存问题:
- 越界访问:编译期会校验切片范围,运行时也会做边界检查,直接拒绝非法访问
- 悬垂引用:生命周期规则保证,只要切片有效,底层数据的所有者就一定不会被释放或修改
示例如下:
// 获取数组中第一个非零元素的切片
fn first_non_zero(arr: &[i32]) -> &[i32] {
for (i, &num) in arr.iter().enumerate() {
if num != 0 {
return &arr[i..];
}
}
&arr[..0]
}
fn main() {
let arr = [0, 0, 3, 5, 7];
let non_zero_slice = first_non_zero(&arr);
// 编译报错:无法修改原数组,因为它已经被切片借用
// arr[2] = 0;
println!("非零切片: {:?}", non_zero_slice); // 输出 [3, 5, 7]
}
这个例子体现了 Rust 的安全哲学:在切片的生命周期内,原数据无法被修改,彻底避免了数据竞争与悬垂引用。
Rust 字符串的两大核心类型
Rust 的字符串体系看似复杂,核心只有两个类型,其他都是面向特定场景的扩展:
&str:字符串切片,无所有权,只读,是切片的 UTF-8 版本String:可拥有、可修改的字符串类型,底层是堆上分配的Vec<u8>封装
两者的关系如同 &[T] 与 Vec<T>:&str 是数据的“视图”,String 是数据的“所有者”。
Rust 字符串的核心设计:强制 UTF-8 编码
很多新手可能会困惑为什么 Python、Go 可以直接用索引 s[i] 取字符,而 Rust 却不行?原因在于:Rust 的字符串强制使用 UTF-8 编码,而 UTF-8 是变长编码。
- ASCII 字符占 1 个字节
- 中文、日文等东亚字符占 3 个字节
- Emoji 等特殊字符占 4 个字节
如果允许直接按索引访问,会带来两个致命问题:
- 索引访问的时间复杂度不再是
O(1),必须遍历字符串才能定位到对应字符 - 极易访问到字符的中间字节,生成非法 UTF-8 序列,引发未定义行为(UB)
因此 Rust 从语法层面禁止了直接通过索引访问字符串字符,只允许通过合法的方式遍历和操作。
字节与字符的正确操作
fn main() {
let s = "你好Rust";
println!("字节长度: {}", s.len()); // 输出 10(2个中文*3字节 + 4个ASCII字符=10)
println!("字符数量: {}", s.chars().count()); // 输出 6
// 正确方式1:遍历所有字符(Unicode标量值)
println!("=== 字符遍历 ===");
for c in s.chars() {
println!("字符: {}", c);
}
// 正确方式2:遍历所有字节
println!("=== 字节遍历 ===");
for b in s.bytes() {
println!("字节: {}", b);
}
}
字符串切片的致命坑:字符边界问题
Rust 编译期不会检查切片范围是否符合 UTF-8 字符边界,只有运行时会校验,一旦切到字符中间,会直接触发 panic。
fn main() {
let s = "你好";
// 反例:运行时 panic!"你"占3个字节,[0..2]切到了字符中间
// let sub = &s[0..2];
// 正确示例:严格按字符边界切片
let sub = &s[0..3];
println!("{}", sub); // 输出 你
}
所以在实际开发中最佳实践是:不要硬编码索引切片字符串,优先通过 chars().enumerate() 定位字符位置,再进行切片操作。
字符串与切片的实战常用操作
String 的修改操作(仅所有者可用)
只有持有所有权的 String 可以修改内容,常用方法如下:
fn main() {
let mut s = String::from("Hello");
// 追加字符串切片
s.push_str(" World");
// 追加单个字符
s.push('!');
println!("{}", s); // 输出 Hello World!
// 插入字符
s.insert(5, ',');
println!("{}", s); // 输出 Hello, World!
// 弹出最后一个字符
let last_char = s.pop();
println!("弹出的字符: {:?}", last_char); // 输出 Some('!')
// 清空字符串
s.clear();
println!("清空后是否为空: {}", s.is_empty()); // 输出 true
}
字符串拼接的三种方式与选型
| 方法 | 用法 | 所有权影响 | 适用场景 |
|---|---|---|---|
+ 运算符 | String + &str | 左侧 String 所有权被转移 | 简单拼接,无需保留原 String |
format! 宏 | format!("{} {}", a, b) | 不转移任何所有权 | 复杂拼接、多变量拼接,可读性优先 |
push_str 方法 | s.push_str(&str) | 仅修改原 String,不转移 | 循环追加、动态构建字符串 |
示例如下:
fn main() {
let s1 = String::from("Hello");
let s2 = "Rust";
// + 运算符:s1所有权被转移,后续无法使用
let s3 = s1 + " " + s2;
println!("+ 拼接结果: {}", s3); // 输出 Hello Rust
// format! 宏:不转移所有权,最灵活
let a = String::from("你好");
let b = String::from("世界");
let c = format!("{},{}!", a, b);
println!("format! 结果: {}", c); // 输出 你好,世界!
println!("a仍可用: {}", a); // 所有权未转移,可正常使用
}
字符串切片的常用工具方法
fn main() {
let s = " Hello Rust ";
// 去除首尾空白
println!("trim后: '{}'", s.trim()); // 输出 'Hello Rust'
// 前缀/后缀判断
let trimmed = s.trim();
println!("是否以Hello开头: {}", trimmed.starts_with("Hello")); // 输出 true
println!("是否以Rust结尾: {}", trimmed.ends_with("Rust")); // 输出 true
// 包含判断与查找
println!("是否包含Rust: {}", s.contains("Rust")); // 输出 true
println!("Rust的起始位置: {:?}", s.find("Rust")); // 输出 Some(8)
// 分割字符串
let parts: Vec<&str> = trimmed.split_whitespace().collect();
println!("分割结果: {:?}", parts); // 输出 ["Hello", "Rust"]
}
最常用的转换:String 与 & str 的互转
Rust 提供了非常便捷的转换能力,核心是 Deref 强制转换机制:&String 可以自动被编译器转换为 &str,无需手动处理。
这也是实际开发中的一个最佳实践:函数参数优先使用 &str,而非 &String。因为 &str 可以同时接受字符串字面量、&String、其他字符串切片,通用性最大。
// 函数参数使用&str,获得最大通用性
fn print_info(s: &str) {
println!("内容: {},字节长度: {}", s, s.len());
}
fn main() {
let s = String::from("Hello Rust");
// 直接传&String,自动转换为&str
print_info(&s);
// 传字符串字面量,完全兼容
print_info("你好世界");
// 手动创建全切片,效果完全一致
print_info(&s[..]);
}
扩展:其他字符串相关类型
除了核心的 &str 和 String,Rust 标准库还提供了面向特定场景的字符串类型,无需深入学习,了解其用途即可:
OsStr/OsString:操作系统兼容的字符串,支持非 UTF-8 的路径、系统参数,位于std::ffi模块CStr/CString:与 C 语言交互的字符串,以\0结尾,符合 C 语言字符串规范,位于std::ffi模块Path/PathBuf:路径专用类型,基于OsStr封装,提供路径相关的专用方法,位于std::path模块
总结
Rust 的字符串与切片设计,看似严苛,实则是围绕内存安全、UTF-8 原生支持和零成本抽象三个核心目标,它把其他语言中运行时才会暴露的字符串乱码、越界、悬垂引用等问题,提前到了编译期解决。
理解这些设计背后的逻辑,你就能真正掌握 Rust 字符串与切片,写出既安全又高效的代码。