跳到主要内容

String 与 &str

问题

Rust 中 String&str 有什么区别?为什么有两种字符串类型?

答案

Rust 有两种核心字符串类型,它们的关系类似 Vec<T>&[T]

类型存储位置所有权可变性大小
String堆上拥有所有权可变(mut3 个字段:ptr + len + cap(24 字节)
&str任意位置的引用借用,无所有权不可变2 个字段:ptr + len(16 字节)

两者都保证是有效的 UTF-8 编码。

内存布局

  • String:在堆上分配,拥有数据,大小可变。结构为 { ptr: *mut u8, len: usize, cap: usize }
  • &str:胖指针(fat pointer),指向 UTF-8 字节序列的切片引用。结构为 { ptr: *const u8, len: usize }

创建字符串

fn main() {
// String
let s1 = String::from("hello");
let s2 = "hello".to_string();
let s3 = String::new(); // 空字符串
let s4 = String::with_capacity(10); // 预分配容量
let s5 = format!("{} {}", "hello", "world");

// &str
let s6: &str = "hello"; // 字符串字面量,类型是 &'static str
let s7: &str = &s1[0..3]; // String 的切片 "hel"
let s8: &str = &s1; // String → &str(Deref)
}
字符串字面量的类型

字符串字面量 "hello" 的类型是 &'static str——它被编译进程序的二进制文件中,在程序运行期间始终有效。

常用操作

fn main() {
let mut s = String::from("hello");

// 追加
s.push(' '); // 追加单个字符
s.push_str("world"); // 追加字符串切片
s += "!"; // + 运算符(消耗左操作数的所有权)

// 插入
s.insert(5, ','); // 在索引处插入字符
s.insert_str(6, " "); // 在索引处插入字符串

// 替换
let new = s.replace("world", "Rust"); // 替换所有匹配(返回新 String)
let new = s.replacen("l", "L", 1); // 只替换前 n 个

// 删除
s.pop(); // 移除并返回最后一个字符
s.truncate(5); // 截断到指定长度
s.clear(); // 清空

// 长度
let s = String::from("你好");
assert_eq!(s.len(), 6); // 字节长度(UTF-8 中每个中文 3 字节)
assert_eq!(s.chars().count(), 2); // 字符数
}

UTF-8 编码与索引

Rust 的字符串是 UTF-8 编码,不支持直接索引s[0] 会编译错误):

let s = String::from("你好世界");

// s[0]; // ❌ 编译错误:String 不能直接索引

// 正确方式:
// 1. 字节切片(需确保在字符边界上)
let byte_slice = &s[0..3]; // "你"(UTF-8 中 '你' 占 3 字节)
// let bad = &s[0..2]; // ⚠️ 运行时 panic:不在字符边界

// 2. 遍历字符
for c in s.chars() {
print!("{} ", c); // 你 好 世 界
}

// 3. 遍历字节
for b in s.bytes() {
print!("{:02x} ", b); // e4 bd a0 e5 a5 bd e4 b8 96 e7 95 8c
}
为什么不支持索引?
  1. UTF-8 是变长编码:ASCII 字符 1 字节,中文 3 字节,emoji 4 字节。s[i] 无法在 O(1) 时间返回"第 i 个字符"
  2. **索引应返回什么?**字节、Unicode 标量值、还是字素簇(grapheme cluster)?不同场景需求不同
  3. Rust 选择让开发者显式选择访问方式,避免隐式的性能陷阱

字符的三个层次

let s = "नमस्ते"; // 印地语

// 字节(bytes)
s.bytes(); // [224, 164, 168, 224, 164, 174, 224, 164, 184, ...]

// Unicode 标量值(chars)
s.chars(); // ['न', 'म', 'स', '्', 'त', 'े']

// 字素簇(grapheme clusters)—— 需要 unicode-segmentation crate
// ["न", "म", "स्", "ते"] ← 人类理解的"字符"

String 与 &str 的转换

fn main() {
// String → &str(无开销,Deref)
let s = String::from("hello");
let r: &str = &s; // Deref Coercion
let r: &str = s.as_str(); // 显式方法
let r: &str = &s[..]; // 切片

// &str → String(需要堆分配)
let s: String = "hello".to_string();
let s: String = String::from("hello");
let s: String = "hello".to_owned();
}

函数参数应该用哪个?

// ✅ 推荐:接受 &str,兼容 String 和 &str
fn greet(name: &str) {
println!("Hello, {}!", name);
}

// ❌ 不推荐:只能接受 &String
fn greet_limited(name: &String) {
println!("Hello, {}!", name);
}

fn main() {
let owned = String::from("Alice");
let borrowed = "Bob";

greet(&owned); // ✅ &String → &str(Deref)
greet(borrowed); // ✅ &str 直接传

greet_limited(&owned); // ✅
// greet_limited(borrowed); // ❌ 类型不匹配
}

如果函数需要拥有字符串(例如存入结构体),接受 String

struct User {
name: String, // 拥有自己的数据
}

impl User {
// 接受 String 或可以转为 String 的类型
fn new(name: impl Into<String>) -> Self {
User { name: name.into() }
}
}

fn main() {
let u1 = User::new("Alice"); // &str → String
let u2 = User::new(String::from("Bob")); // String 直接传
}

字符串拼接

fn main() {
let s1 = String::from("hello");
let s2 = String::from(" world");

// 方式 1:+ 运算符(消耗 s1 的所有权)
let s3 = s1 + &s2; // s1 被 move,s2 被借用
// println!("{}", s1); // ❌ s1 已被 move

// 方式 2:format! 宏(不消耗任何所有权)
let s1 = String::from("hello");
let s3 = format!("{}{}", s1, s2); // ✅ s1, s2 都还在

// 方式 3:push_str(在原字符串上追加)
let mut s = String::from("hello");
s.push_str(" world");
}
大量拼接场景

频繁拼接时,format!push_str 优于 +。更好的做法是预分配容量:

let parts = vec!["hello", " ", "world", "!"];
let mut result = String::with_capacity(parts.iter().map(|s| s.len()).sum());
for part in &parts {
result.push_str(part);
}

其他字符串类型

类型用途
OsString / &OsStr操作系统原生字符串(可能不是 UTF-8)
CString / &CStrC 语言字符串(以 \0 结尾)
PathBuf / &Path文件路径(跨平台兼容)
Cow<'a, str>"写时克隆"字符串,避免不必要的分配

常见面试问题

Q1: 为什么 Rust 有 String&str 两种字符串类型?

答案

这体现了 Rust 所有权系统的设计哲学——区分"拥有"和"借用":

  • String:拥有堆上的字符串数据,可以修改、增长、传递所有权
  • &str:借用已有的字符串数据,零开销、不可变

类比 Vec<T>&[T] 的关系。这种设计让编译器精确知道谁负责释放内存、谁只是在读取。

Q2: String 能直接索引吗?为什么?

答案

不能,s[0] 会编译错误。原因:

  1. UTF-8 是变长编码,s[0] 无法 O(1) 定位到第 n 个字符
  2. "字符"的定义存在歧义(字节、Unicode 标量值、字素簇)
  3. Rust 拒绝隐藏 O(n) 复杂度在看起来是 O(1) 的操作背后

替代方案:

  • s.chars().nth(n) — 获取第 n 个 Unicode 标量值
  • &s[start..end] — 字节范围切片(必须在字符边界上)
  • s.as_bytes()[n] — 获取第 n 个字节

Q3: to_string()to_owned()String::from() 有什么区别?

答案

对于 &strString,三者效果相同,细微差异:

方法来源说明
String::from("hello")From<&str> trait最直接,语义清晰
"hello".to_string()ToString trait通用,任何实现了 Display 的类型都可用
"hello".to_owned()ToOwned trait语义"从借用创建拥有版",最符合所有权概念

推荐String::from().to_string(),选择哪个是风格偏好。

Q4: Cow<str> 是什么?什么场景使用?

答案

Cow(Clone on Write)——写时克隆,可以延迟分配

use std::borrow::Cow;

fn process(input: &str) -> Cow<str> {
if input.contains("bad") {
// 需要修改:分配新 String
Cow::Owned(input.replace("bad", "good"))
} else {
// 不需要修改:直接返回引用,零分配
Cow::Borrowed(input)
}
}

适用场景:函数大部分情况不需要修改输入,只在少数情况下需要修改。用 Cow 可以避免不必要的 clone()

Q5: + 运算符拼接字符串为什么会消耗左操作数?

答案

+ 运算符实际调用的是 fn add(self, s: &str) -> String,注意 self 不是引用——它取得左操作数的所有权:

let s1 = String::from("hello");
let s2 = String::from(" world");
let s3 = s1 + &s2;
// s1 被 move 进 add 方法,内部复用了 s1 的堆缓冲区,追加 s2 的内容
// 这比创建一个全新的 String 更高效

如果不想消耗所有权,用 format! 宏或 clone()

Q6: 如何高效处理大量字符串拼接?

答案

// 方案 1:预分配 + push_str
let mut result = String::with_capacity(1024);
for item in &items {
result.push_str(item);
}

// 方案 2:collect
let result: String = items.iter().copied().collect();

// 方案 3:join
let result = items.join(", ");

关键:预分配容量,避免多次 reallocation。String::with_capacity() 预分配足够空间后,push_str 不会触发重新分配。

相关链接