Rust 字符串与切片

在多数编程语言中,字符串操作往往简单直接,但 Rust 凭借其安全与性能优先的设计理念,将字符串和切片打造成了需要深入理解的核心概念。本文将结合 Rust 独特的内存管理和所有权机制,带你全面掌握字符串与切片的使用方法,避开常见陷阱。

一、先踩个坑:为什么这段代码编译失败?

先看一段看似简单的 Rust 代码,你觉得它能正常运行吗?

rust 复制代码
fn main() {
  let my_name = "Pascal";  // 字符串字面量
  greet(my_name);          // 调用 greet 函数
}

fn greet(name: String) {   // 函数参数要求 String 类型
  println!("Hello, {}!", name);
}

编译报错解析

编译器会抛出类型不匹配的错误:

复制代码
error[E0308]: mismatched types
 --> src/main.rs:3:11
  |
3 |     greet(my_name);
  |           ^^^^^^^
  |           |
  |           expected struct `std::string::String`, found `&str`
  |           help: try using a conversion method: `my_name.to_string()`

问题根源在于:my_name&str 类型 (字符串切片),而 greet 函数要求的是 String 类型(可增长的所有权字符串)。要理解这个错误,我们需要先搞清楚「切片」是什么。

二、切片(Slice):集合的部分引用

切片并非 Rust 独创(如 Go 语言也有类似概念),它的核心作用是引用集合中连续的部分元素,而不获取整个集合的所有权。这种设计既节省内存,又能避免拷贝,是 Rust 高性能的关键特性之一。

1. 字符串切片的基本用法

字符串切片是对 String 某一部分的引用,语法为 &s[开始索引..终止索引],遵循左闭右开区间规则(包含开始索引,不包含终止索引)。

rust 复制代码
fn main() {
  let s = String::from("hello world");  // 完整字符串
  let hello = &s[0..5];                 // 切片:引用 s 的第 0-4 个字节("hello")
  let world = &s[6..11];                // 切片:引用 s 的第 6-10 个字节("world")
}
  • 切片的底层结构:包含两个信息------指向原集合的指针切片长度(终止索引 - 开始索引)。
  • 例如 world 切片:指针指向 s 的第 6 个字节,长度为 5(对应 "world" 的 5 个字符)。

2. 切片语法的简化写法

Rust 提供了简洁的切片语法,可根据场景省略索引:

场景 完整写法 简化写法 说明
从索引 0 开始 &s[0..n] &s[..n] 省略起始的 0
到集合末尾结束 &s[m..len] &s[m..] 省略终止的长度值
引用整个集合 &s[0..len] &s[..] 同时省略起始和终止

示例:

rust 复制代码
fn main() {
  let s = String::from("hello");
  let len = s.len();  // 获取字符串长度(字节数)

  let slice1 = &s[0..2];  // "he"
  let slice2 = &s[..2];   // 等同于 slice1
  let slice3 = &s[3..len];// "lo"
  let slice4 = &s[3..];   // 等同于 slice3
  let slice5 = &s[0..len];// "hello"
  let slice6 = &s[..];    // 等同于 slice5
}

3. 切片的致命陷阱:UTF-8 边界问题

Rust 字符串采用 UTF-8 编码,不同字符占用的字节数不同(英文字母 1 字节,中文 3 字节,特殊符号可能 4 字节)。如果切片索引未落在字符的字节边界上,程序会直接崩溃!

反例(崩溃代码):

rust 复制代码
fn main() {
  let s = "中国人";  // 每个中文字符占 3 字节,总长度 9 字节
  let a = &s[0..2];  // 错误:索引 0-2 未覆盖完整的 "中" 字
  println!("{}", a);
}

运行后报错:

复制代码
thread 'main' panicked at 'byte index 2 is not a char boundary; it is inside '中' (bytes 0..3) of `中国人`'

解决方案 :确保切片索引是字符的完整字节边界(如中文取 0..33..6 等)。

4. 其他类型的切片

切片不仅适用于字符串,还支持数组、向量(Vec)等集合类型,语法和原理完全一致。例如数组切片:

rust 复制代码
fn main() {
  let a = [1, 2, 3, 4, 5];          // 数组
  let slice = &a[1..3];              // 数组切片:引用索引 1-2 的元素
  assert_eq!(slice, &[2, 3]);        // 断言成立,切片内容为 [2, 3]
}

数组切片的类型是 &[i32](对应数组元素类型),字符串切片则是 &str

三、字符串:Rust 中的两种核心类型

Rust 语言级别仅定义了 str 类型,但实际开发中常用两种字符串:&str(字符串切片)和 String(可增长字符串)。二者均遵循 UTF-8 编码,但在所有权、可变性上差异巨大。

1. 字符串切片 &str:不可变的引用

  • 本质 :对字符串数据的引用(可能是 String 的切片,或程序编译期硬编码的字面量)。
  • 特性
    • 不可变:无法修改切片指向的内容。

    • 无所有权:不负责内存释放,其生命周期依赖于原数据。

    • 字符串字面量的类型就是 &str

      rust 复制代码
      fn main() {
        let s = "Hello, world!";          // 隐式类型:&str
        let s_explicit: &str = "Hello!";  // 显式声明类型
      }
    • 字符串字面量存储在程序的可执行文件中 ,运行时不可修改,因此 &str 是不可变引用。

2. 可增长字符串 String:有所有权的动态类型

  • 本质:在堆上分配内存的动态字符串,支持修改、增长和收缩。
  • 特性
    • 有所有权:负责管理堆内存的分配与释放(离开作用域时自动释放)。

    • 可变性:需用 mut 关键字修饰才能修改。

    • 创建方式:

      rust 复制代码
      let s1 = String::from("hello");  // 从 &str 创建
      let s2 = "world".to_string();    // 从字符串字面量转换
      let s3 = String::new();          // 创建空字符串,后续可填充

3. String&str 的转换

开发中经常需要在两种字符串类型间转换,方法如下:

转换方向 方法 示例
&strString String::from(&str)&str.to_string() let s = String::from("hello");
String&str 取引用 &String、切片 &String[..]String.as_str() let s_str = &s;s.as_str();

示例:

rust 复制代码
fn main() {
  // &str -> String
  let str_slice = "hello";
  let string1 = String::from(str_slice);
  let string2 = str_slice.to_string();

  // String -> &str
  let string3 = String::from("world");
  let str_slice1 = &string3;       // 隐式转换
  let str_slice2 = &string3[..];   // 切片语法
  let str_slice3 = string3.as_str();// 显式调用方法

  // 函数参数兼容:&str 可接收两种类型
  say_hello(str_slice);
  say_hello(&string3);
}

fn say_hello(s: &str) {  // 函数参数用 &str 更灵活
  println!("Hello, {}", s);
}

四、为什么 Rust 不支持字符串索引?

在 Python、Java 中,用 s[0] 获取字符串第一个字符很常见,但 Rust 中直接索引 String 会报错:

rust 复制代码
fn main() {
  let s1 = String::from("hello");
  let h = s1[0];  // 编译错误!
}

报错信息:

复制代码
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`

深层原因

  1. UTF-8 编码的不确定性

    字符串索引本质是「按字节访问」,但 UTF-8 字符的字节数不固定(如中文 3 字节)。若允许 s[0],返回的只是第一个字节,而非完整字符,容易导致逻辑错误。

  2. 性能保证

    索引操作通常期望 O(1) 时间复杂度,但 Rust 要获取完整字符,需从字符串开头遍历找到合法的 UTF-8 边界,时间复杂度为 O(n),不符合索引的性能预期。

替代方案:遍历字符或字节

若需访问字符串的每个元素,可使用 chars()(遍历 Unicode 字符)或 bytes()(遍历字节)方法:

rust 复制代码
fn main() {
  let s = "中国人";
  
  // 遍历 Unicode 字符(推荐)
  for c in s.chars() {
    println!("{}", c);  // 输出:中、国、人
  }

  // 遍历字节(底层存储)
  for b in s.bytes() {
    println!("{}", b);  // 输出每个字符的 UTF-8 字节(共 9 个)
  }
}

五、String 操作实战:修改、删除与连接

String 是可变字符串,支持追加、插入、替换、删除等操作,但需注意:所有修改操作都需 mut 关键字修饰,且需确保索引落在 UTF-8 边界上。

1. 追加(Push)

  • push(char):追加单个 Unicode 字符。
  • push_str(&str):追加字符串切片(避免所有权转移)。
rust 复制代码
fn main() {
  let mut s = String::from("Hello ");  // 必须加 mut 才可变
  
  s.push_str("rust");  // 追加字符串切片
  println!("{}", s);   // 输出:Hello rust
  
  s.push('!');         // 追加单个字符
  println!("{}", s);   // 输出:Hello rust!
}

2. 插入(Insert)

  • insert(index, char):在指定索引插入单个字符。
  • insert_str(index, &str):在指定索引插入字符串切片。
rust 复制代码
fn main() {
  let mut s = String::from("Hello rust!");
  
  s.insert(5, ',');       // 在索引 5 插入 ','
  println!("{}", s);      // 输出:Hello, rust!
  
  s.insert_str(6, " I like");  // 在索引 6 插入字符串
  println!("{}", s);      // 输出:Hello, I like rust!
}

3. 替换(Replace)

Rust 提供三种替换方法,适用于不同场景:

方法 适用类型 特性 示例
replace(&old, &new) String/&str 替换所有匹配项,返回新字符串(不修改原字符串) s.replace("rust", "RUST")
replacen(&old, &new, n) String/&str 替换前 n 个匹配项,返回新字符串 s.replacen("rust", "RUST", 1)
replace_range(range, &new) String 替换指定范围的内容,直接修改原字符串(需 mut) s.replace_range(7..8, "R")

示例:

rust 复制代码
fn main() {
  // replace:替换所有
  let s1 = String::from("I like rust. Learning rust is fun!");
  let s1_new = s1.replace("rust", "RUST");
  println!("{}", s1_new);  // 输出:I like RUST. Learning RUST is fun!

  // replacen:替换前 1 个
  let s2 = "I like rust. Learning rust is fun!";
  let s2_new = s2.replacen("rust", "RUST", 1);
  println!("{}", s2_new);  // 输出:I like RUST. Learning rust is fun!

  // replace_range:修改原字符串
  let mut s3 = String::from("I like rust!");
  s3.replace_range(7..8, "R");
  println!("{}", s3);      // 输出:I like Rust!
}

4. 删除(Delete)

四种删除方法,需注意索引边界问题:

方法 功能 返回值 注意事项
pop() 删除最后一个字符 Option<char>(空字符串返回 None 无需索引,安全
remove(index) 删除指定索引的字符 被删除的 char 索引需在 UTF-8 边界上,否则崩溃
truncate(len) 删除从 len 到末尾的内容 len 需在 UTF-8 边界上,否则崩溃
clear() 清空字符串 等同于 truncate(0)

示例:

rust 复制代码
fn main() {
  // pop():删除最后一个字符
  let mut s1 = String::from("rust!");
  let last_char = s1.pop();  // Some('!')
  println!("{}", s1);        // 输出:rust

  // remove():删除指定索引(中文需注意边界)
  let mut s2 = String::from("测试remove");
  s2.remove(0);  // 删除第一个中文字符(索引 0,占 3 字节)
  println!("{}", s2);        // 输出:试remove

  // truncate():截取前 3 字节(一个中文字符)
  let mut s3 = String::from("测试truncate");
  s3.truncate(3);
  println!("{}", s3);        // 输出:测

  // clear():清空
  let mut s4 = String::from("hello");
  s4.clear();
  println!("{}", s4);        // 输出空字符串
}

5. 连接(Concatenate)

两种常用连接方式,各有适用场景:

(1)使用 ++=
  • 规则:+ 左侧必须是 String 类型,右侧必须是 &str 类型(避免所有权转移)。
  • 本质:调用 add(self, s: &str) -> String 方法,左侧 String 的所有权会转移,返回新字符串。
rust 复制代码
fn main() {
  let s1 = String::from("Hello ");
  let s2 = String::from("rust");
  
  // s1 所有权转移,后续不可再使用
  let s3 = s1 + &s2;  // 右侧必须是 &str(&s2 自动解引用为 &str)
  println!("{}", s3); // 输出:Hello rust

  // += 语法:修改原字符串(需 mut)
  let mut s4 = String::from("Hello ");
  s4 += &s2;          // 等同于 s4 = s4 + &s2
  s4 += "!";          // 直接加字符串字面量(&str 类型)
  println!("{}", s4); // 输出:Hello rust!
}
(2)使用 format!
  • 优势:支持多个字符串(String&str)连接,不转移所有权,语法灵活(类似 print!)。
  • 适用场景:多字符串拼接,或避免所有权转移。
rust 复制代码
fn main() {
  let s1 = "Hello";
  let s2 = String::from("rust");
  let s3 = "!";
  
  // 连接多个字符串,返回新 String
  let result = format!("{} {} {}", s1, s2, s3);
  println!("{}", result);  // 输出:Hello rust !
}

六、字符串转义与原始字符串

Rust 支持字符串转义(如 ASCII、Unicode),也支持原始字符串(不解析转义字符),满足不同场景需求。

1. 字符串转义

使用 \ 转义特殊字符,支持 ASCII(\x 后跟两位十六进制)和 Unicode(\u{} 后跟十六进制):

rust 复制代码
fn main() {
  // ASCII 转义(\x52 是 'R',\x3F 是 '?')
  let ascii_escape = "I'm writing \x52\x75\x73\x74!";
  println!("{}", ascii_escape);  // 输出:I'm writing Rust!

  // Unicode 转义(\u{211D} 是数学符号 ℝ)
  let unicode_escape = "\u{211D}";
  println!("{}", unicode_escape);  // 输出:ℝ

  // 忽略换行符(\ 连接多行)
  let multi_line = "This is a \
  multi-line string.";
  println!("{}", multi_line);  // 输出:This is a multi-line string.
}

2. 原始字符串

若需避免转义(如正则表达式、文件路径),可使用原始字符串,语法为 r"内容",支持嵌套双引号(用 # 分隔):

rust 复制代码
fn main() {
  // 原始字符串:不解析转义
  let raw1 = r"Escapes don't work here: \x3F \u{211D}";
  println!("{}", raw1);  // 输出:Escapes don't work here: \x3F \u{211D}

  // 包含双引号:用 # 包裹
  let raw2 = r#"He said: "Rust is awesome!""#;
  println!("{}", raw2);  // 输出:He said: "Rust is awesome!"

  // 包含 # 号:用多个 # 包裹(最多 255 个)
  let raw3 = r###"Contains "# and "##!"###;
  println!("{}", raw3);  // 输出:Contains "# and "##!
}

七、核心原理:String 的内存管理

为什么 String 可变而 &str 不可变?关键在于二者的内存存储方式和所有权机制。

1. &str 的内存布局

  • 字符串字面量(如 "hello")存储在程序的只读数据段 (编译时确定),&str 是指向该数据段的不可变引用。
  • 由于数据段只读,&str 无法修改内容,且生命周期与程序一致('static)。

2. String 的内存布局

  • String 由三部分组成(存储在栈上):
    1. ptr:指向堆内存中 UTF-8 字节数组的指针。
    2. len:当前字符串的字节长度。
    3. capacity:堆内存的总容量(避免频繁扩容)。
  • String 离开作用域时,Rust 自动调用 drop 函数释放堆内存,无需手动管理(类似 C++ 的 RAII 模式)。

示例:

rust 复制代码
fn main() {
  {
    let s = String::from("hello");  // 栈上存储 ptr/len/capacity,堆上存储 "hello" 字节
    // 使用 s
  }  // s 离开作用域,堆内存自动释放
}

八、总结与最佳实践

  1. 优先使用 &str 作为函数参数

    函数参数用 &str 可同时接收 String(通过 &String 隐式转换)和 &str,灵活性更高,避免不必要的所有权转移。

  2. 谨慎处理 UTF-8 边界

    切片、删除、插入等操作的索引必须落在字符的完整字节边界上,尤其是中文、日文等多字节字符,否则会导致程序崩溃。

  3. 选择合适的字符串类型

    • 若字符串长度固定且无需修改,用 &str(如字符串字面量)。
    • 若需动态修改、增长字符串,用 String(需 mut 修饰)。
  4. 连接字符串优先用 format!

    多字符串拼接时,format!+ 更简洁,且不转移所有权,避免潜在的生命周期问题。

相关推荐
_OP_CHEN2 小时前
【从零开始的Qt开发指南】(二十二)Qt 音视频开发宝典:从音频播放到视频播放器的实战全攻略
开发语言·c++·qt·音视频·前端开发·客户端开发·gui开发
oioihoii2 小时前
从C++到C#的转型完全指南
开发语言·c++·c#
Java水解2 小时前
Nginx 配置文件完全指南
后端·nginx
Ashley_Amanda2 小时前
Python入门知识点梳理
开发语言·windows·python
好想来前端2 小时前
私有化部署 LLM 时,别再用 Nginx 硬扛流式请求了 —— 推荐一个专为 vLLM/TGI 设计的高性能网关
后端·架构·github
区区一散修2 小时前
Java进阶 6. 集合
java·开发语言
OpenTiny社区2 小时前
TinyPro v1.4.0 正式发布:支持 Spring Boot、移动端适配、新增卡片列表和高级表单页面
java·前端·spring boot·后端·开源·opentiny
Java编程爱好者2 小时前
如何使用SpringAI来实现一个RAG应用系统
后端
明天有专业课2 小时前
穿搭式的设计模式-装饰者
后端