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}");}运行输出:
运行输出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](移动后使用):
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 move1. 原地追加: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}看到这三行代码,背后的物理算盘一清二楚:
- 为什么
s1失效了? 因为第一个参数是按值接收的mut self。+运算符直接移走了左边s1的所有权(连同它名下那块已经分配好的堆缓冲区)。 - 为什么要移走
s1?为了原地复用堆缓冲区! 如果像其他语言那样,s1 + s2每次都在堆上新申请第三块内存、把两边都复制过去,那么连续拼接s1 + " " + &s2就会产生大量短命的临时堆分配。而 Rust 移走s1后,直接在s1原有的堆缓冲区末尾调用self.push_str(other)(只要容量cap足够,连扩容都不发生),最后把self交给新变量combined。 - 为什么右边要写
&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上。