Rust 字符串与切片实战

0 阅读7分钟

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 的切片
  • [..]:覆盖整个集合的全切片

示例如下:

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. 悬垂引用:生命周期规则保证,只要切片有效,底层数据的所有者就一定不会被释放或修改

示例如下:

// 获取数组中第一个非零元素的切片
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[..]);
}

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

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

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

总结

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

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