Rust 字符串与切片实战

Rust 字符串与切片实战

字符串与切片是所有新手遇到的第一个门槛,不同于 Java、Python 等语言对字符串的高度封装,Rust 的字符串与切片深度绑定了所有权、借用、生命周期与 UTF-8 编码,从编译期就将乱码、内存安全等问题解决。

什么是切片(Slice)

字符串是切片的特殊场景,想要理解字符串,必须先搞懂切片。在 Rust 里,切片是描述一组属于同一类型、长度不确定的、在内存中连续存放的数据结构,用 [T] 来表述。因为长度不确定,切片属于动态大小类型(DST, Dynamically Sized Type),无法直接在栈上存储,必须通过引用(&[T],不可变切片)或可变引用(&mut [T],可变切片)来使用。

切片通过 Rust 的范围语法 [start..end] 创建,遵循左闭右开原则,支持多种简写形式:

  • [a..b]:从索引 ab-1 的切片
  • [a..]:从索引 a 到集合末尾的切片
  • [..b]:从集合开头到索引 b-1 的切片
  • [..]:覆盖整个集合的全切片

示例如下:

rust 复制代码
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 的切片从编译期就杜绝了两类经典内存问题:

  1. 越界访问:编译期会校验切片范围,运行时也会做边界检查,直接拒绝非法访问
  2. 悬垂引用:生命周期规则保证,只要切片有效,底层数据的所有者就一定不会被释放或修改

示例如下:

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 从语法层面禁止了直接通过索引访问字符串字符,只允许通过合法的方式遍历和操作。

字节与字符的正确操作

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

rust 复制代码
fn main() {
    let s = "你好";
    // 反例:运行时 panic!"你"占3个字节,[0..2]切到了字符中间
    // let sub = &s[0..2];
    
    // 正确示例:严格按字符边界切片
    let sub = &s[0..3];
    println!("{}", sub); // 输出 你
}

所以在实际开发中最佳实践 是:不要硬编码索引切片字符串,优先通过 chars().enumerate() 定位字符位置,再进行切片操作。

字符串与切片的实战常用操作

String 的修改操作(仅所有者可用)

只有持有所有权的 String 可以修改内容,常用方法如下:

rust 复制代码
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,不转移 循环追加、动态构建字符串

示例如下:

rust 复制代码
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); // 所有权未转移,可正常使用
}

字符串切片的常用工具方法

rust 复制代码
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、其他字符串切片,通用性最大。

rust 复制代码
// 函数参数使用&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[..]);
}

扩展:其他字符串相关类型

除了核心的 &strString,Rust 标准库还提供了面向特定场景的字符串类型,无需深入学习,了解其用途即可:

  • OsStr/OsString:操作系统兼容的字符串,支持非 UTF-8 的路径、系统参数,位于 std::ffi 模块
  • CStr/CString:与 C 语言交互的字符串,以 \0 结尾,符合 C 语言字符串规范,位于 std::ffi 模块
  • Path/PathBuf:路径专用类型,基于 OsStr 封装,提供路径相关的专用方法,位于 std::path 模块

总结

Rust 的字符串与切片设计,看似严苛,实则是围绕内存安全、UTF-8 原生支持和零成本抽象三个核心目标,它把其他语言中运行时才会暴露的字符串乱码、越界、悬垂引用等问题,提前到了编译期解决。

理解这些设计背后的逻辑,你就能真正掌握 Rust 字符串与切片,写出既安全又高效的代码。

相关推荐
朝阳5812 小时前
局域网聊天工具
javascript·rust
朝阳5812 小时前
我做了一个局域网传文件的小工具,记录一下
javascript·rust
Rust语言中文社区16 小时前
【Rust日报】用 Rust 重写的 Turso 是一个更好的 SQLite 吗?
开发语言·数据库·后端·rust·sqlite
小杍随笔1 天前
【Rust 半小时速成(2024 Edition 更新版)】
开发语言·后端·rust
Source.Liu1 天前
【office2pdf】office2pdf 纯 Rust 实现的 Office 转 PDF 库
rust·pdf·office2pdf
洛依尘1 天前
深入浅出 Rust 生命周期:它不是语法负担,而是借用关系的说明书
后端·rust
Rust研习社1 天前
通过示例学习 Rust 模式匹配
rust
PaytonD1 天前
基于 GPUI 实现 WebScoket 服务端之服务篇
后端·rust
Source.Liu1 天前
【Acadrust】Rust 语言的高性能 CAD 库
rust·acadrust