Rust 字符串性能:String、&str、Cow 的选择不是随便来的
一、String vs &str:所有权决定一切
这是最基础的分水岭。String 是拥有所有权的、堆上分配的、可变的字符串;&str 是借用的、不可变的字符串切片。
// String —— 拥有数据,可以修改
let mut owned: String = "hello".to_string();
owned.push_str(" world!"); // ✅ 可以修改
println!("{}", owned); // ✅ 可以自由使用,没有生命周期限制
// &str —— 借用数据,不能修改
let borrowed: &str = &owned; // 借用 String 的一部分
// borrowed.push_str("!"); // ❌ 编译错误:&str 不可变
println!("{}", borrowed); // ✅ 可以读,但受 owned 的生命周期限制
最常见的错误就是搞不清什么时候用哪个:
// ❌ 错误示范 —— 函数签名写错了
fn greet_bad(name: &str) -> String {
let mut result = name;
result.push_str(", 你好!"); // 编译错误:&str 没有 push_str 方法
result
}
// ✅ 正确写法 —— 如果不需要修改,直接返回 &str 拼接
fn greet(name: &str) -> String {
// format! 宏内部创建了一个新的 String
format!("{}, 你好!", name)
}
// ✅ 或者明确参数为 String,拿到所有权
fn greet_owned(mut name: String) -> String {
name.push_str(", 你好!");
name // 转移所有权给调用者
}
选型口诀:需要拼接/修改 → String;只需要读 → &str;静态常量 → &'static str。
二、Cow<str>:写时复制的艺术
Cow(Clone on Write)是我后来才理解的一个好东西。它的语义是:先用借用的形式持有数据,只有在真正需要修改时才克隆一份。
看一个实际的场景:写一个函数,把用户输入的 URL 统一处理成小写(URL 本来就应该大小写不敏感,但很多用户不注意):
use std::borrow::Cow;
/// 将 URL 规范化为小写 —— 如果本身就是小写,零开销返回
fn normalize_url(url: &str) -> Cow<str> {
// 先检查是否已经是全小写(大多数情况下是的)
if url.chars().any(|c| c.is_uppercase()) {
// 只有存在大写字母时才分配新内存
Cow::Owned(url.to_lowercase())
} else {
// 已经是小写,直接借用,零内存分配!
Cow::Borrowed(url)
}
}
fn main() {
let url1 = "https://github.com/rust-lang";
let url2 = "https://GitHub.com/Rust-Lang";
// url1 已经是小写 → Cow::Borrowed,零开销
let normalized1 = normalize_url(url1);
// url2 有大写 → Cow::Owned,分配新 String
let normalized2 = normalize_url(url2);
// 使用时统一解引用
println!("{}", normalized1); // 自动 Deref 到 &str
println!("{}", normalized2);
}
Cow 在 JSON 处理、模板渲染、HTTP header 规范化这些场景特别有用。很多时候数据根本不需要修改,但接口又要求返回可修改的类型,Cow 就是最优解。
// 实际例子:处理 HTTP 请求头(大小写不敏感)
fn normalize_header<'a>(key: &'a str, value: &'a str) -> Cow<'a, str> {
match key.to_lowercase().as_str() {
"content-type" => {
// content-type 需要规范化为小写
Cow::Owned(value.to_lowercase())
}
_ => {
// 其他 header 原样返回,零拷贝
Cow::Borrowed(value)
}
}
}
三、字符串拼接的性能陷阱
写 Rust 时最容易踩的性能坑之一就是循环中的字符串拼接:
// ❌ 坏写法 —— 每次 += 都可能触发 realloc
fn build_csv_bad(rows: &[Vec<String>]) -> String {
let mut csv = String::new();
for row in rows {
csv.push_str(&row.join(",")); // 每行都做一次 join(分配临时 String)
csv.push('\n');
}
csv
}
// ✅ 好写法 —— 预估容量,避免多次 realloc
fn build_csv_good(rows: &[Vec<String>]) -> String {
// 预估总容量 = 行数 × 每行平均长度
let estimated_len: usize = rows.iter().map(|r| r.join(",").len() + 1).sum();
let mut csv = String::with_capacity(estimated_len);
for row in rows {
// write! 宏直接写入 String,不分配中间临时对象
use std::fmt::Write;
let _ = write!(csv, "{}\n", row.join(","));
}
csv
}
核心原则:如果你知道最终字符串大概多长,就用 with_capacity 预分配,避免中间不断 realloc + memcpy。
生产踩坑:with_capacity 用错了反而浪费内存。 我之前在处理用户上传的 CSV 文件时,用 rows.len() * 200 粗暴估算容量,结果遇到某行包含超长备注字段,实际需要的容量远大于估算值,依然触发了多次 realloc。更糟糕的是反向情况——处理大量短行时,估算值严重偏大,一个 50 行的 CSV 分配了 10KB,实际只用 500 字节,造成了大量内存浪费。后来学乖了,先扫描一遍数据求出精确容量:
fn build_csv_safe(rows: &[Vec<String>]) -> String {
// 先精确计算每行的实际字节数
let capacity: usize = rows.iter()
.map(|r| r.join(",").len() + 1) // +1 是换行符
.sum();
let mut csv = String::with_capacity(capacity);
for row in rows {
use std::fmt::Write;
let _ = writeln!(csv, "{}", row.join(","));
}
csv
}
多一次遍历的开销是 O(n),但换来的是零 realloc 和零内存浪费,对于数据量大的场景完全值得。这个教训是:预分配的前提是你真的知道精确大小,而不是瞎估。
四、零拷贝字符串:&str 切片的妙用
这是我后来在实践中特别喜欢的一个技巧——能用 &str 切片就不用 String,因为切片的开销几乎为零:
/// 解析 HTTP 请求的第一行:"GET /api/users HTTP/1.1"
fn parse_request_line(line: &str) -> (&str, &str, &str) {
// 不分配任何新内存,全部返回原字符串的切片引用
let mut parts = line.splitn(3, ' ');
let method = parts.next().unwrap_or(""); // &str 切片
let path = parts.next().unwrap_or(""); // &str 切片
let version = parts.next().unwrap_or(""); // &str 切片
(method, path, version)
}
// 使用场景:解析大文件中的每一行
async fn parse_large_csv(path: &str) -> Result<(), Box<dyn std::error::Error>> {
let content = tokio::fs::read_to_string(path).await?;
// 只分配一次内存(读入整个文件),之后所有解析都基于 &str 切片
for line in content.lines() {
let fields: Vec<&str> = line.split(',').collect();
// fields 里的每个元素都指向 content 中的原始位置,零额外分配
process_fields(&fields);
}
Ok(())
}
边界故障提醒:&str 切片的生命周期陷阱。 &str 是零开销,但它绑定了你到源数据的生命周期。我在一个 Web 请求处理函数中踩过这样的坑:
// ❌ 这段代码编译通不过——response_str 活得不够长
fn bad_example(query: &str) -> &str {
let response = format!("处理结果: {}", query); // 创建临时 String
&response[..10] // ❌ 返回切片引用,但 response 马上就被 drop 了!
}
这类悬垂引用在编译期就会被 Rust 拦下来(borrow checker 报错),但它的变体更隐蔽:你在循环里把一个大 String 的所有 &str 切片收集起来,然后在循环外使用——这个时候 &str 指向的数据已经被下一次迭代覆盖或者 drop 了。规则很明确:&str 切片的生命不能超过被借用的源数据。如果你需要在函数外持有数据,老老实实 to_string() 复制一份。不要因为追求零拷贝而写出 UB(虽然 Rust 会在编译期保护你,减少了运行时崩溃的风险)。
五、总结
Rust 的字符串类型系统不是故意复杂,而是每种类型都对应着一种内存策略:
&str:零成本引用,能用就用。解析、匹配、切片首选。String:需要拥有所有权或修改内容时用,记得with_capacity预分配。Cow<str>:不确定要不要改的时候用,写时复制,延迟分配,性能利器。&'static str:编译期确定的常量,直接硬编码在二进制文件中。
这些概念在我刚开始学 Rust 时确实觉得繁琐,但用熟了之后才体会到:Rust 逼迫你在写代码时就思考内存布局,而这些思考换来的就是运行时的零开销和高性能。自学出身不可怕,怕的是不求甚解。
1万+



被折叠的 条评论
为什么被折叠?



