在多数编程语言中,字符串操作往往简单直接,但 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..3、3..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:rustfn main() { let s = "Hello, world!"; // 隐式类型:&str let s_explicit: &str = "Hello!"; // 显式声明类型 } -
字符串字面量存储在程序的可执行文件中 ,运行时不可修改,因此
&str是不可变引用。
-
2. 可增长字符串 String:有所有权的动态类型
- 本质:在堆上分配内存的动态字符串,支持修改、增长和收缩。
- 特性 :
-
有所有权:负责管理堆内存的分配与释放(离开作用域时自动释放)。
-
可变性:需用
mut关键字修饰才能修改。 -
创建方式:
rustlet s1 = String::from("hello"); // 从 &str 创建 let s2 = "world".to_string(); // 从字符串字面量转换 let s3 = String::new(); // 创建空字符串,后续可填充
-
3. String 与 &str 的转换
开发中经常需要在两种字符串类型间转换,方法如下:
| 转换方向 | 方法 | 示例 |
|---|---|---|
&str → String |
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}`
深层原因
-
UTF-8 编码的不确定性 :
字符串索引本质是「按字节访问」,但 UTF-8 字符的字节数不固定(如中文 3 字节)。若允许
s[0],返回的只是第一个字节,而非完整字符,容易导致逻辑错误。 -
性能保证 :
索引操作通常期望 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由三部分组成(存储在栈上):ptr:指向堆内存中 UTF-8 字节数组的指针。len:当前字符串的字节长度。capacity:堆内存的总容量(避免频繁扩容)。
- 当
String离开作用域时,Rust 自动调用drop函数释放堆内存,无需手动管理(类似 C++ 的 RAII 模式)。
示例:
rust
fn main() {
{
let s = String::from("hello"); // 栈上存储 ptr/len/capacity,堆上存储 "hello" 字节
// 使用 s
} // s 离开作用域,堆内存自动释放
}
八、总结与最佳实践
-
优先使用
&str作为函数参数 :函数参数用
&str可同时接收String(通过&String隐式转换)和&str,灵活性更高,避免不必要的所有权转移。 -
谨慎处理 UTF-8 边界 :
切片、删除、插入等操作的索引必须落在字符的完整字节边界上,尤其是中文、日文等多字节字符,否则会导致程序崩溃。
-
选择合适的字符串类型:
- 若字符串长度固定且无需修改,用
&str(如字符串字面量)。 - 若需动态修改、增长字符串,用
String(需mut修饰)。
- 若字符串长度固定且无需修改,用
-
连接字符串优先用
format!:多字符串拼接时,
format!比+更简洁,且不转移所有权,避免潜在的生命周期问题。