Rust 字符串
字符串是任何程序都离不开的数据类型,但 Rust 在这里给新手下了一个"马威"——它有两种字符串类型:String 和 &str。理解它们的区别是写出地道 Rust 代码的关键,也会顺带巩固上一篇学的所有权和借用。这一篇我们彻底讲透。
1. 两种字符串:String 和 &str
这是新手最大的困惑来源,但理解了所有权之后并不难。Rust 把"堆上可变的字符串"和"只读的字符串切片"分成两个类型:
fn main() {
// Rust 有两种字符串类型,这是新手最大的困惑点
// 1. &str —— 字符串切片(只读,借用)
// "胖指针":指向某段 UTF-8 数据 + 长度
let literal: &str = "我是字面量"; // 指向程序只读区
let s = String::from("hello");
let slice: &str = &s[..3]; // 指向 s 的前 3 字节
// 2. String —— 堆上可变字符串(拥有所有权)
// 可增长、可修改
let mut owned: String = String::from("hi");
owned.push_str(" world"); // 可变
// 关键区别:
// &str 不可变,不拥有数据,轻量(就是指针+长度)
// String 可变,拥有数据,在堆上分配
// String 可以"降级"为 &str(免费,&s)
// &str 可以"升级"为 String(to_string())
}一句话区分:
- String:堆上,可变,拥有所有权。适合需要修改、长期持有的场景。
- &str:只读切片,借用,不拥有数据。适合读、传参、字面量的场景。
其他语言(如 Python、JS)只有一种字符串类型,而 Rust 分两种是为了"所有权清晰 + 零开销"——这是性能和安全的代价。
2. 创建 String
创建 String 有几种常用方式,最常用的是 String 的 from 方法和 to_string 方法:
fn main() {
// ===== 创建 String 的几种方式 =====
// 1. 从字面量创建(最常见)
let s1 = String::from("hello");
// 2. to_string()(等价于 String::from)
let s2 = "world".to_string();
// 3. String::new() 空字符串,然后逐步填充
let mut s3 = String::new();
s3.push_str("逐步");
s3.push_str("填充");
// 4. 从字节构造(注意必须是合法 UTF-8)
let bytes = vec![104, 105]; // 'h' 'i' 的字节
let s4 = String::from_utf8(bytes).unwrap();
// 5. 重复字符
let dots = ".".repeat(3); // "..."
// 6. format! 宏拼接
let name = "小明";
let age = 20;
let info = format!("{}今年{}岁", name, age);
println!("{} {} {} {} {} {}", s1, s2, s3, s4, dots, info);
}注意 String 必须是合法 UTF-8。如果从字节构造,字节必须是有效的 UTF-8 序列,否则会返回 Err。
3. 修改与拼接
String 是可变的,可以用 push_str、push、+、format! 等方式修改和拼接。但拼接时所有权规则要特别注意:
fn main() {
let mut s = String::from("hello");
// ===== 修改 String =====
// push_str:追加字符串切片
s.push_str(" world");
// push:追加单个字符
s.push('!');
println!("{}", s); // hello world!
// + 运算符拼接(注意:左边必须是 String,右边是 &str)
let s1 = String::from("Hello, ");
let s2 = String::from("Rust!");
let s3 = s1 + &s2; // s1 的所有权被消耗,s2 借用
// println!("{}", s1); // 编译错误!s1 已被 + 消耗
println!("{}", s3); // Hello, Rust!
println!("{}", s2); // OK,s2 还能用
// 多个字符串拼接用 format! 更清晰
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2, s3);
println!("{}", s); // tic-tac-toe
// format! 不消耗任何参数的所有权,s1/s2/s3 仍可用
}几个易错点:
- + 运算符:左边必须是 String,右边必须是 &str。左边所有权被消耗,右边借用。所以
s1 + &s2后 s1 失效。 - 多个拼接用 format!:不消耗任何参数的所有权,代码更清晰。这是推荐写法。
- push_str vs push:前者追加字符串,后者追加单个 char。
4. 切片与 UTF-8 编码(重要!)
这是 Rust 字符串最容易踩坑的地方。字符串切片按字节索引,不是按字符。因为 Rust 字符串底层是 UTF-8 编码,每个字符占 1~4 字节:
fn main() {
let s = String::from("Hello, Rust!");
let literal = "你好,世界";
// ===== 切片 =====
// 注意:Rust 字符串切片按【字节】索引,不是字符!
// 因为底层是 UTF-8,一个中文占 3 字节
let hello = &s[0..5]; // "Hello",ASCII 1 字节
println!("{}", hello);
// let bad = &literal[0..1]; // 运行时 panic!
// 因为"你"占 3 字节,[0..1] 切到半个字符,非法
// 中文必须按字符边界切:
let first_char = &literal[0..3]; // "你",前 3 字节
println!("{}", first_char);
// ===== 长度 =====
println!("字节数:{}", s.len()); // 12(字节)
println!("字节数:{}", literal.len()); // 15(5 个中文 * 3 字节)
// 字符数(注意不是 len):
println!("字符数:{}", literal.chars().count()); // 5
}关键警告:
- 中文占 3 字节:切到半个字符会运行时 panic!必须按字符边界切片。
- len() 返回字节数,不是字符数。中文字符串的 len 是字符数 ×3。
- 字符数用 chars().count()。但这不是 O(1) 操作(UTF-8 需要扫描)。
这个设计听起来反人类,但它换来了O(1) 切片、零开销存储。Rust 选择"UTF-8 + 字节索引"是为了性能。
5. 遍历与常用方法
Rust 字符串提供了丰富的方法,但要按字符操作时必须显式用 chars() 迭代器:
fn main() {
let s = "你好Rust";
// ===== 遍历字符 =====
// .chars() 按 Unicode 标量值遍历
for c in s.chars() {
print!("[{}] ", c); // [你] [好] [R] [u] [s] [t]
}
println!();
// .bytes() 按字节遍历(通常不需要)
for b in s.bytes() {
print!("{} ", b);
}
println!();
// .char_indices() 同时拿字符索引和字符
for (i, c) in s.chars().enumerate() {
println!("第 {} 个字符: {}", i, c);
}
// ===== 常用方法 =====
let text = "Hello World";
println!("大写: {}", text.to_uppercase()); // HELLO WORLD
println!("小写: {}", text.to_lowercase()); // hello world
println!("是否为空: {}", text.is_empty()); // false
println!("包含 World: {}", text.contains("World")); // true
println!("以 Hello 开头: {}", text.starts_with("Hello")); // true
println!("替换: {}", text.replace("World", "Rust")); // Hello Rust
}几个要点:
- chars():按 Unicode 字符遍历。处理中文、emoji 必须用它。
- bytes():按字节遍历。通常不需要。
- 常用方法:to_uppercase、to_lowercase、contains、starts_with、replace、is_empty、trim 等,大多数语言有的 Rust 都有。
- 注意大小写转换的 Unicode 复杂性:某些语言大小写转换不一一对应(如德语 ß),Rust 会处理,但结果长度可能变化。
6. String 和 &str 的转换
两种类型经常需要互转。规则:&str 转 String 要分配堆(有开销),String 转 &str 几乎免费(借用):
fn main() {
// ===== String 和 &str 的相互转换 =====
// &str -> String(三种等价方式)
let s1: String = "hello".to_string();
let s2: String = String::from("hello");
let s3: String = "hello".to_owned();
// String -> &str(免费,自动)
let owned = String::from("hi");
let borrowed: &str = &owned; // 自动 deref
// 函数参数接收 &str 时,可以直接传 String
show(&owned); // 自动转 &str
// 函数参数推荐接收 &str(更通用)
// 因为 String 和 &str 都能传进去
fn show(s: &str) {
println!("{}", s);
}
// 拥有 vs 借用的选择:
// - 函数只是"读"字符串 -> 用 &str 参数
// - 函数需要"长期持有"字符串 -> 用 String 参数(转移所有权)
// - 函数需要修改 -> 用 &mut String 参数
// - 返回字符串 -> 通常返回 String(拥有)
}一个非常重要的最佳实践:函数参数优先用 &str 而不是 String。因为 &str 既能接收 String(自动 deref),也能接收字面量,最通用。而 String 参数会强制调用者转移所有权,不灵活。
7. 为什么有两种类型?
这是设计上的深思熟虑,不是历史包袱:
// 为什么 Rust 要分两种字符串类型?
// 1. 性能:&str 是"胖指针"(指针+长度),复制成本极低
// 传 &str 比传 String 快得多(不需要堆分配)
// 2. 灵活性:&str 可以指向任何 UTF-8 数据:
// - 字符串字面量(程序只读区)
// - String 的一段
// - 文件读进来的字节
// 统一接口,不关心数据来源
// 3. 所有权清晰:String 明确"我拥有这块堆内存",
// &str 明确"我借用别人的"。编译器据此保证安全。
// 实际经验:
// - 结构体字段、长期持有 -> String
// - 函数参数 -> &str(最通用)
// - 局部变量、临时拼接 -> String
// - 函数返回 -> String(避免生命周期问题)
fn greet(name: &str) -> String { // 借进 &str,返回 String
format!("你好, {}!", name)
}记一个简单规则:
- 结构体字段、需要长期持有 → String
- 函数参数 → &str(最通用)
- 局部变量、临时拼接 → String
- 函数返回 → String(避免生命周期问题)
8. 字符串与所有权综合
字符串是把所有权和借用最具体地展现出来的场景。回顾几个典型情况:
- 把 String 传给函数:默认转移所有权,原变量失效。除非函数声明为 &String 或 &str 参数。
- 从函数返回 String:所有权转移给调用者,完全合法。
- 把 String 存进结构体:结构体接管所有权,String 离开结构体时才释放。
- 多个地方读同一个 String:传 &str(只读共享),零拷贝。
9. 实战:解析字符串
Rust 字符串处理常用方法还包括 split、parse 等。处理命令行输入、配置文件时很常用:
- split:按分隔符切分,返回迭代器。
- parse:把字符串解析成数字等类型,返回 Result(可能失败)。
- trim:去除首尾空白,处理用户输入时必备。
- lines:按行分割,处理多行文本。
下一篇讲结构体时我们会综合运用这些。
小结
这一篇你彻底搞懂了 Rust 两种字符串:String(堆、可变、拥有)和 &str(切片、只读、借用)。关键要点:切片按字节(中文要小心)、函数参数优先 &str、len 返回字节数、chars().count() 返回字符数。字符串是把所有权和借用最具体地展现的场景,理解了它,你对 Rust 的核心理念就有了实战感。
下一篇我们看结构体——Rust 用 struct + impl 替代了传统面向对象的"类",是组织数据的核心工具。
← 上一篇 Rust 借用与引用
下一篇 Rust 结构体 →