Rust String 与 &str 深度解析:基础概念与实践指南
在 Rust 编程语言中,处理文本数据是日常开发的核心部分。与其他许多语言不同,Rust 对字符串的处理方式有着独特的区分,主要体现在两种基本类型上:String
和 &str
(字符串切片)。理解它们之间的差异、联系以及各自的适用场景,对于编写高效、安全且符合 Rust 哲学(尤其是所有权系统)的代码至关重要。本教程将深入探讨 String
和 &str
,从底层原理到实际应用,帮助你彻底掌握它们。
核心概念:所有权与借用
在深入字符串之前,必须先理解 Rust 的核心特性:所有权(Ownership)和借用(Borrowing)。
- 所有权:Rust 中的每个值都有一个被称为其 所有者(owner)的变量。在任何时候,一个值只能有一个所有者。当所有者离开作用域时,该值将被丢弃(dropped),其占用的资源(如内存)会被释放。
- 借用:我们可以创建对值的引用(references),这被称为 借用。借用允许我们在不获取所有权的情况下访问值。借用分为不可变借用(
&T
)和可变借用(&mut T
)。Rust 强制执行严格的借用规则:在任何给定时间,你要么只能有一个可变引用,要么可以有任意数量的不可变引用,但不能同时拥有。
String
和 &str
的区别与联系,很大程度上就是所有权和借用规则在字符串类型上的具体体现。
String
:拥有所有权的可变字符串
String
类型是 Rust 标准库提供的一个拥有所有权的、堆分配的、UTF-8 编码的、可增长的字符串类型。
特点:
- 拥有所有权(Owned):当你创建一个
String
或将String
赋值给一个变量时,该变量就成为了这个字符串数据的所有者。当变量离开作用域时,String
会自动释放其在堆上分配的内存。 - 堆分配(Heap Allocated):
String
的内容存储在内存的堆(heap)上。这意味着String
的大小可以在运行时动态增长或缩小。堆分配涉及一定的开销(分配和释放内存),但提供了灵活性。 - 可变(Mutable):你可以修改一个
String
的内容,例如追加字符、插入子串、清空等,前提是该String
变量被声明为可变的(使用mut
关键字)。 - UTF-8 编码:Rust 的
String
保证其内容始终是有效的 UTF-8 编码。这意味着它可以表示世界上几乎所有的字符。 - 结构:一个
String
实例在栈上通常存储三个信息:- 指向堆上字节序列的指针(pointer)。
- 字符串当前的长度(length),即包含的字节数。
- 字符串当前的容量(capacity),即在不重新分配内存的情况下可以容纳的总字节数。
创建 String
的常用方法:
“`rust
fn main() {
// 1. 从空字符串创建
let mut s1 = String::new();
println!(“s1 (empty): ‘{}’, len: {}, capacity: {}”, s1, s1.len(), s1.capacity());
// 2. 从字符串字面量 (&str) 创建
let data = "initial contents";
let s2 = String::from(data); // 使用 String::from()
println!("s2: '{}'", s2);
let s3 = data.to_string(); // 使用 .to_string() 方法 (更通用)
println!("s3: '{}'", s3);
let s4 = "直接字面量".to_string(); // 直接在字面量上调用 .to_string()
println!("s4: '{}'", s4);
// 3. 使用 format! 宏创建 (类似其他语言的 sprintf 或 f-string)
let name = "Alice";
let age = 30;
let s5 = format!("My name is {} and I am {} years old.", name, age);
println!("s5: '{}'", s5);
}
“`
修改 String
:
因为 String
是可变的(如果用 mut
声明),你可以修改它:
“`rust
fn main() {
let mut s = String::from(“foo”);
println!(“Original: ‘{}'”, s);
// 追加 &str
s.push_str("bar");
println!("After push_str: '{}'", s);
// 追加单个字符 char
s.push('!');
println!("After push: '{}'", s);
// 替换子串
let s_replaced = s.replace("foo", "baz"); // replace 返回新的 String,不修改原 String
println!("Original s after replace call: '{}'", s);
println!("s_replaced: '{}'", s_replaced);
// 清空 String
// s.clear();
// println!("After clear: '{}'", s);
}
“`
所有权转移:
当 String
被赋值给另一个变量或作为函数参数传递(按值传递)时,所有权会发生转移。
“`rust
fn main() {
let s1 = String::from(“hello”);
let s2 = s1; // s1 的所有权转移给 s2
// println!("s1 is: {}", s1); // 编译错误!s1 不再有效,因为所有权已转移
println!("s2 is: {}", s2); // s2 现在拥有数据
takes_ownership(s2); // s2 的所有权转移给函数 takes_ownership
// println!("s2 after function call: {}", s2); // 编译错误!s2 的所有权已被函数拿走
}
fn takes_ownership(some_string: String) { // some_string 获得所有权
println!(“Inside function: {}”, some_string);
} // 这里 some_string 离开作用域,String 的内存被释放
“`
&str
:不可变的字符串切片
&str
类型,通常称为“字符串切片”(string slice),是对某处存储的 UTF-8 编码字符串数据的不可变引用。
特点:
- 借用(Borrowed):
&str
本身并不拥有它所指向的字符串数据。它只是一个“视图”或“引用”,指向存储在其他地方(如String
、二进制文件的数据段、或其他&str
)的有效 UTF-8 字节序列。 - 不可变(Immutable by default):通过
&str
引用,你通常不能修改它所指向的字符串数据。这是 Rust 借用规则的一部分,保证了数据安全。(存在&mut str
,但相对少见且有严格限制)。 - 固定大小视图:
&str
指向的是一段固定长度的字节序列。它本身的大小在编译时是已知的(通常是一个指针和长度,占用两个usize
的空间)。 - UTF-8 编码:和
String
一样,&str
也保证指向有效的 UTF-8 数据。 - 来源多样:
&str
可以指向:- 字符串字面量:如
"hello"
。字符串字面量直接存储在程序编译后的二进制文件中,具有'static
生命周期(整个程序运行期间都有效)。 String
的一部分或全部:你可以从一个String
中创建一个或多个&str
切片。- 其他
&str
的一部分。
- 字符串字面量:如
创建 &str
:
“`rust
fn main() {
// 1. 字符串字面量本身就是 &str 类型
let literal: &str = “Hello, world!”; // 类型标注是可选的,编译器能推断
println!(“Literal: {}”, literal);
// 2. 从 String 创建 &str (借用)
let s = String::from("你好,Rust");
// 创建指向整个 String 的 &str
let s_slice_full: &str = &s; // 使用 & 操作符进行不可变借用
println!("Slice from full String: {}", s_slice_full);
// 创建指向 String 部分内容的 &str (切片)
// 注意:切片索引是基于字节的,需要小心 UTF-8 字符边界
// "你好,Rust" -> UTF-8 bytes: [E4 BD A0, E5 A5 BD, EF BC 8C, 52, 75, 73, 74]
// 你: 3 bytes, 好: 3 bytes, ,: 3 bytes, R: 1 byte, u: 1 byte, s: 1 byte, t: 1 byte
let hello_bytes_start = 0;
let hello_bytes_end = 6; // "你好" 占用 6 个字节 (3 + 3)
let s_slice_part: &str = &s[hello_bytes_start..hello_bytes_end]; // [start..end) 半开区间
println!("Slice of '你好': {}", s_slice_part);
// 安全地获取切片,避免 panic (如果索引无效)
// let invalid_slice = &s[0..1]; // 这会 panic,因为 1 不是 UTF-8 字符边界
// 使用 .get() 方法安全获取切片,返回 Option<&str>
let safe_slice = s.get(0..6);
match safe_slice {
Some(slice) => println!("Safe slice: {}", slice),
None => println!("Invalid slice index."),
}
let first_char_slice = s.get(0..3); // "你"
if let Some(slice) = first_char_slice {
println!("First char slice: {}", slice);
}
}
// 字节索引的陷阱!
// Rust 的字符串索引是基于字节的,而不是字符。对于多字节的 UTF-8 字符,
// 直接使用索引切片可能会导致程序 panic,如果切片边界落在一个字符的中间。
// 迭代字符通常更安全 (e.g., using s.chars())。
“`
&str
的生命周期:
由于 &str
是一个引用,它必须遵守 Rust 的生命周期规则。&str
不能比它所指向的数据活得更长。
- 字符串字面量 (
"hello"
) 具有'static
生命周期,意味着它们在整个程序执行期间都有效,所以对它们的&str
引用也基本上没有生命周期限制。 - 从
String
创建的&str
,其生命周期不能超过该String
的生命周期。如果String
被移动或销毁,那么指向它的&str
就会变成悬垂引用(dangling reference),Rust 的编译器会阻止这种情况发生。
“`rust
fn main() {
let s = String::from(“long string is long”);
let part: &str;
{
let s_inner = String::from("short lived");
// part = &s_inner; // 编译错误! `s_inner` 在这个作用域结束后就销毁了,但 part 仍然存活
} // s_inner 在这里被 drop
// println!("{}", part); // 如果上面那行没被注释,这里会尝试使用悬垂引用
part = &s[0..4]; // 这是合法的,因为 s 的生命周期比 part 长
println!("Part from long lived string: {}", part);
} // s 在这里被 drop
“`
String
与 &str
的关系与转换
String
和 &str
关系密切,可以方便地相互转换(或者更准确地说,是从一个生成另一个)。
1. 从 String
获取 &str
(借用)
这是最常见的操作,而且非常廉价。因为 &str
只是一个指向 String
内部数据的引用(指针+长度),不需要进行内存分配或数据复制。
“`rust
fn main() {
let my_string = String::from(“Hello”);
// 获取指向整个 String 的 &str
let s1: &str = &my_string;
// 获取指向 String 部分内容的 &str (切片)
let s2: &str = &my_string[0..3]; // "Hel"
println!("s1: {}", s1);
println!("s2: {}", s2);
// 函数通常接收 &str 参数,这样可以同时接受 String 和 &str
process_string_slice(s1);
process_string_slice(s2);
process_string_slice("I am a literal"); // 字符串字面量也是 &str
process_string_slice(&my_string); // 直接传递 &String 也可以,会自动解引用强制转换 (Deref Coercion)
}
fn process_string_slice(slice: &str) {
println!(“Processing slice: {}”, slice);
}
“`
解引用强制多态(Deref Coercion)
你可能注意到 process_string_slice(&my_string)
也能工作,即使函数期望 &str
而我们传递的是 &String
。这是因为 Rust 的 解引用强制多态(Deref Coercion)特性。String
实现了 Deref<Target=str>
trait,这意味着 &String
可以被自动、隐式地转换为 &str
。这是 Rust 设计中的一个巨大便利,使得接受 &str
的函数可以无缝地处理 String
的引用。
2. 从 &str
创建 String
(克隆/转换)
当你需要一个拥有所有权、可变的字符串副本时(例如,要修改它或将其传给需要 String
的函数),你可以从 &str
创建一个新的 String
。这个操作涉及内存分配和数据复制,相对昂贵。
“`rust
fn main() {
let my_slice: &str = “World”;
// 方法 1: 使用 .to_string()
let mut s1: String = my_slice.to_string();
s1.push_str("!");
println!("s1 from to_string(): {}", s1);
// 方法 2: 使用 String::from()
let s2: String = String::from(my_slice);
println!("s2 from String::from(): {}", s2);
}
“`
实践指南:何时使用 String
vs &str
?
理解了技术细节后,关键在于知道何时选择哪种类型。
使用 String
当:
- 你需要拥有字符串数据的所有权。例如,函数需要返回一个新生成的字符串,或者结构体需要存储字符串数据。
- 你需要在运行时构建或修改字符串。例如,从用户输入、文件读取或网络请求中动态地组合字符串。
- 字符串的生命周期需要独立于其原始来源。如果你从一个临时的
&str
创建了String
,这个String
可以活得比原始&str
更久。
使用 &str
当:
- 你只需要读取或引用字符串数据,而不需要拥有它。这是最常见的情况,尤其是在函数参数中。
- 你想表示字符串字面量。
"hello"
本身就是&'static str
。 - 你想创建一个指向
String
或其他&str
的一部分的视图(切片)。 - 性能是关键考虑因素,且你不需要修改数据或拥有所有权。传递
&str
避免了不必要的内存分配和复制。
黄金法则:函数参数优先使用 &str
除非函数确实需要获取字符串的所有权(比如要存储它或返回修改后的版本),否则函数参数应尽可能接受 &str
。
“`rust
// 不推荐:这个函数不必要地获取了所有权
fn process_string_owned(s: String) {
println!(“{}”, s);
}
// 推荐:这个函数通过借用工作,更灵活
fn process_string_borrowed(s: &str) {
println!(“{}”, s);
}
fn main() {
let s = String::from(“my data”);
let literal = “literal data”;
// process_string_owned(s); // s 的所有权被移走
// process_string_owned(literal.to_string()); // 需要创建 String,有开销
process_string_borrowed(&s); // 可以接受 &String (通过 Deref Coercion)
process_string_borrowed(literal); // 可以接受 &str
process_string_borrowed(&s[3..]); // 可以接受 String 切片
println!("s still usable here: {}", s); // s 的所有权还在
}
“`
通过接受 &str
,你的函数变得更加通用,可以处理 String
、字符串字面量以及 String
的切片,而无需调用者进行额外的转换或放弃所有权。
UTF-8 和索引
这是一个关键但有时会引起混淆的点。Rust 的 String
和 &str
内部都使用 UTF-8 编码。UTF-8 是一种变长编码,意味着一个字符可能占用 1 到 4 个字节。
'H' -> 1 byte
'e' -> 1 byte
'l' -> 1 byte
'l' -> 1 byte
'o' -> 1 byte
' ' -> 1 byte
'世' -> 3 bytes (E4 B8 96)
'界' -> 3 bytes (E7 95 8C)
由于这种变长特性:
len()
方法返回的是字节数,而不是字符数。- 直接用整数索引(如
s[i]
)访问字符是危险且不被允许的(除非是字节切片&s.as_bytes()[i]
)。因为索引i
是字节索引,它可能指向一个多字节字符的中间,这在 UTF-8 中是无效的。 - 字符串切片
&s[start..end]
的索引也是字节索引。如果start
或end
不是有效的 UTF-8 字符边界,程序会在运行时 panic。
安全处理字符的方式:
- 迭代字符: 使用
.chars()
方法,它返回一个迭代器,每次产生一个char
(Unicode 标量值)。这是处理单个字符最推荐的方式。 - 迭代字节: 使用
.bytes()
方法,它返回一个迭代器,每次产生一个u8
(字节)。 - 使用库(如
unicode-segmentation
): 如果你需要处理更复杂的 Unicode 边界(如字素簇,即用户感知的“字符”),可以使用外部库。
“`rust
fn main() {
let s = String::from(“नमस्ते”); // Hindi “Namaste”
println!("String: '{}'", s);
println!("Length (bytes): {}", s.len()); // len() 返回字节数
// 尝试字节索引 (危险!)
// let first_byte = s.as_bytes()[0];
// println!("First byte: {}", first_byte);
// let invalid_char_slice = &s[0..1]; // Panic! 1 is not a char boundary
// 正确方式:迭代字符
let mut char_count = 0;
for c in s.chars() {
println!("Char: '{}'", c);
char_count += 1;
}
println!("Character count: {}", char_count);
// 迭代字节
print!("Bytes:");
for b in s.bytes() {
print!(" {:02X}", b);
}
println!();
}
“`
总结:String
vs &str
对比
特性 | String |
&str (字符串切片) |
---|---|---|
所有权 | 拥有所有权 (Owned) | 借用 (Borrowed) |
内存位置 | 数据在堆上 (Heap allocated) | 数据在其他地方 (堆, 栈, 静态内存) |
可变性 | 可变 (Mutable, 需 mut 声明) |
默认不可变 (Immutable) |
大小 | 可增长 (Growable) | 固定大小 (Fixed size view) |
创建 | String::new() , String::from() , .to_string() , format! |
字面量 ("..." ), 切片 (&s[..] ) |
成本 | 创建/修改可能涉及堆分配/重分配 | 创建 (借用) 非常廉价 (指针复制) |
主要用途 | 存储、构建、修改字符串数据 | 引用、查看字符串数据,函数参数 |
生命周期 | 由所有者作用域决定 | 不能超过其指向数据的生命周期 |
与另一类型转换 | 通过 & 或切片廉价得到 &str |
通过 .to_string() 或 String::from() 创建 String (涉及分配和复制) |
编码 | UTF-8 | UTF-8 |
结语
掌握 String
和 &str
是精通 Rust 的关键一步。String
提供了拥有所有权、可变的堆分配字符串,适用于需要动态构建或长期存储文本的场景。&str
则是一个轻量级、不可变的字符串引用(切片),非常适合用于函数参数和临时查看字符串数据,它避免了不必要的内存分配和复制,并且通过 Deref Coercion 机制能够灵活地接受来自 String
的引用。
理解它们背后的所有权、借用和生命周期规则,以及它们与 UTF-8 编码的交互方式,将使你能够编写出既安全又高效的 Rust 代码。记住“函数参数优先使用 &str
”的原则,并在需要所有权或可变性时才选择 String
。随着实践的深入,你会越来越熟练地在两者之间做出正确的选择。