Rust 字符串性能:String、str、Cow 的选择不是随便来的

Rust 字符串性能:String、str、Cow 的选择不是随便来的
Rust 字符串性能String、str、Cow 的选择不是随便来的一、Stringvsstr所有权决定一切这是最基础的分水岭。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。二、Cowstr写时复制的艺术CowClone on Write是我后来才理解的一个好东西。它的语义是先用借用的形式持有数据只有在真正需要修改时才克隆一份。看一个实际的场景写一个函数把用户输入的 URL 统一处理成小写URL 本来就应该大小写不敏感但很多用户不注意use std::borrow::Cow; /// 将 URL 规范化为小写 —— 如果本身就是小写零开销返回 fn normalize_url(url: str) - Cowstr { // 先检查是否已经是全小写大多数情况下是的 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_headera(key: a str, value: a str) - Cowa, 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: [VecString]) - 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: [VecString]) - 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: [VecString]) - 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(), Boxdyn std::error::Error { let content tokio::fs::read_to_string(path).await?; // 只分配一次内存读入整个文件之后所有解析都基于 str 切片 for line in content.lines() { let fields: Vecstr 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预分配。Cowstr不确定要不要改的时候用写时复制延迟分配性能利器。static str编译期确定的常量直接硬编码在二进制文件中。这些概念在我刚开始学 Rust 时确实觉得繁琐但用熟了之后才体会到Rust 逼迫你在写代码时就思考内存布局而这些思考换来的就是运行时的零开销和高性能。自学出身不可怕怕的是不求甚解。