跳转到内容

B.2 字符串转换、拼接与高频操作机制

关联主线:本篇是字符串:str、&str 与 String的实战配套附录。当你在编写或审查代码,需要核对字符串转换、拼接与日常 API 的底层成本时,可随时查阅本页。

在主线中我们已经明确:&str(16 字节借用胖指针)与 String(24 字节堆所有者)是两个平行独立的类型实体。下面我们直接拆解日常写代码最高频的三类操作:双向转换、动态拼接与方法查找。


一、&str 与 String 的双向转换全解

Section titled “一、&str 与 String 的双向转换全解”

在日常编码中,在这两个平行类型之间来回跨越,两个方向的运行时代价截然不同。

1. 从 &str 到 String:申请堆内存并复制字节(有成本)

Section titled “1. 从 &str 到 String:申请堆内存并复制字节(有成本)”

当你手头只有一个只读视图 &str(比如字面量 "hello" 或函数传进来的切片),需要长期持有或修改它时,必须在堆上开辟一块新缓冲区并把字节复制过去。

在工程代码(以及 AI 生成的代码)里,你会高频见到以下三种写法。它们编译出的机器码完全相同,但接口来源与工程表意不同:

写法 底层接口来源 工程惯用场景(Idioms)
String::from("hello") From<&str> 转换接口 最直白、最推荐的构造写法。一眼就能看出正在从字面量或切片显式构造一个 String 实例。
"hello".to_owned() ToOwned 借用转拥有接口 借用转所有者的最准确表达。强调「把借用的切片 &str 复制成对应的拥有者类型 String」(同理,&[i32] 调 .to_owned() 会变成 Vec<i32>)。
"hello".to_string() Display 格式化打印接口 原本用于把任何可打印的值(如数字 42.to_string())格式化成字符串。标准库对 &str 做了特化优化,跳过了格式化器开销,性能与前两者完全一致。

代码审查(Code Review)提示:

  • 这三种写法在性能上没有高下之分,不必为了把 .to_string() 改成 .to_owned() 而吹毛求疵;
  • 真正需要警惕的是不必要的转换:如果下游函数只需要读取文本(参数可以写 &str),却在调用处写了 s.to_owned() 或 s.clone(),那就是白白浪费了一次堆内存分配与字节拷贝。

2. 从 String 到 &str:零成本提取胖指针(无堆分配)

Section titled “2. 从 String 到 &str:零成本提取胖指针(无堆分配)”

反过来,手头有一个 String,怎么变成 &str?

  • 函数传参时直接写 &s:得益于解引用强制转换(Deref Coercion),当函数要求 &str 而你传入 &String 时,编译器会自动从 String 的 24 字节控制头里取出 ptr 和 len,在栈上拼出 16 字节的 &str 胖指针。
  • 需要显式类型时写 s.as_str()(或 &s[..]):当编译器无法自动推断目标类型(例如配合泛型或闭包)时,调用 .as_str() 可以显式提取出 &str 胖指针。这个过程只复制栈上的两个机器字(16 字节),零堆分配、零字节复制。

二、追加与拼接:为什么 s1 + &s2 会把 s1 吞掉?

Section titled “二、追加与拼接:为什么 s1 + &s2 会把 s1 吞掉?”

当我们需要动态组装字符串时,Rust 提供了三种常见方式:push_str、+ 运算符与 format! 宏。用所有权与缓冲区模型一看,就能明白它们各自的设计意图。

运行下面这段代码验证:

源文件:string-concat.rs
// Uncomment the marked line to trigger error[E0382].
fn main() {
let mut message = String::from("hello");
message.push_str(" world");
message.push('!');
println!("mutated: {message}");
let s1 = String::from("Rust");
let s2 = String::from("Root");
// `format!` borrows both strings without moving either.
let formatted = format!("{s1}-{s2}");
println!("formatted: {formatted} (s1 is still valid: {s1})");
// `+` calls `fn add(self, s: &str) -> String`:
// it moves `s1`, reuses its heap buffer, and borrows `s2`.
let combined = s1 + " " + &s2;
println!("combined: {combined} (s2 is still valid: {s2})");
// println!("s1 after +: {s1}");
}
在 Rust Playground 中运行

运行输出:

运行输出
mutated: hello world!
formatted: Rust-Root (s1 is still valid: Rust)
combined: Rust Root (s2 is still valid: Root)

如果取消最后一行 // println!("s1 after +: {s1}"); 的注释,编译器会立刻报出 error[E0382](移动后使用):

编译器诊断摘要:取消 string-concat.rs 中最后一行的注释后
error[E0382]: borrow of moved value: `s1`
--> src/bin/string-concat.rs:20:28
|
8 | let s1 = String::from("Rust");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
...
17 | let combined = s1 + " " + &s2;
| -------- `s1` moved due to usage in operator
...
20 | println!("s1 after +: {s1}");
| ^^ value borrowed here after move

1. 原地追加:push_str(&str) 与 push(char)

Section titled “1. 原地追加:push_str(&str) 与 push(char)”

当变量声明为 let mut message 时,可以通过 message.push_str(" world") 追加一段字符串切片,或者通过 message.push('!') 追加单个字符。

注意看 push_str 的签名:pub fn push_str(&mut self, string: &str)。 为什么它的第二个参数设计成只读切片 &str?因为 push_str 只需要把右边那段字节按字节复制进 message 自己的堆缓冲区末尾,完全不需要夺走右边参数的所有权。

2. + 运算符的底层签名:原地复用左边的堆内存

Section titled “2. + 运算符的底层签名:原地复用左边的堆内存”

很多初学者觉得 let combined = s1 + &s2; 的规则很难记:为什么左边的 s1 必须是拥有所有权的 String(而且加完就失效),右边的 s2 却必须写成借用 &str(加完依然有效)?

直接看标准库中字符串 + 运算符(Add 接口)的真实实现源码:

标准库签名:String 的 Add 实现
fn add(mut self, other: &str) -> String {
self.push_str(other);
self
}
在 Rust Playground 中运行

看到这三行代码,背后的物理算盘一清二楚:

  1. 为什么 s1 失效了? 因为第一个参数是按值接收的 mut self。+ 运算符直接移走了左边 s1 的所有权(连同它名下那块已经分配好的堆缓冲区)。
  2. 为什么要移走 s1?为了原地复用堆缓冲区! 如果像其他语言那样,s1 + s2 每次都在堆上新申请第三块内存、把两边都复制过去,那么连续拼接 s1 + " " + &s2 就会产生大量短命的临时堆分配。而 Rust 移走 s1 后,直接在 s1 原有的堆缓冲区末尾调用 self.push_str(other)(只要容量 cap 足够,连扩容都不发生),最后把 self 交给新变量 combined。
  3. 为什么右边要写 &s2? 因为第二个参数要求 other: &str,传入 &s2(&String)会通过解引用强制转换自动变成 &str。

3. 不想移走所有权时:用 format! 宏

Section titled “3. 不想移走所有权时:用 format! 宏”

如果手头的 s1 和 s2 在拼接后还要继续使用,或者你要把多个变量按模板组装起来,直接使用 format!("{s1}-{s2}")。

format! 在底层只对所有参数做只读借用,它会在堆上新申请一块 String 缓冲区把格式化结果写进去,因此原变量的所有权不会发生任何转移。


三、查阅标准库文档的窍门:为什么 String 的方法都在 str 里?

Section titled “三、查阅标准库文档的窍门:为什么 String 的方法都在 str 里?”

如果你去翻阅标准库中 String 的文档,会发现一个现象:你平时在 String 上高频调用的 .trim()、.split()、.contains()、.starts_with()、.lines()、.replace() 等几十个查询与切分方法,在 String 自己的固有方法(impl String)列表里一个都找不到。

它们全部定义在切片本体 str 上(位于标准库文档 Methods from Deref<Target = str> 这一栏下)。

当你写下 my_string.trim() 时,Rust 的方法查找机制会自动触发解引用(Deref),把 String 当场借用为 &str,然后直接调用 str 上的只读方法。

记住这条文档分工规律,以后查字符串 API 就能直奔主题:

  • 凡是不需要修改原堆内存容量、只需要读取或切分文本的通用能力(查找、前后缀判断、空白裁剪、按分隔符切分、大小写转换生成新串),全部定义在 str 上;
  • 只有涉及堆缓冲区原地增删、容量管理的方法(push_str、insert、clear、reserve、shrink_to_fit),才定义在 String 上。