[!NOTE]
在 Rust 编程中,字符串操作是非常常见的需求。很多开发者习惯使用
format!
宏来构建格式化字符串,但你是否知道在某些场景下,使用write!
宏可以显著提升性能?本文将分享一个真实案例,展示如何通过简单替换字符串构建方法,实现高达 75% 的性能提升。
问题背景
作者在开发一个用于自定义虚拟机的 GUI 调试器时,需要实现内存查看功能,将程序内存中的每个字节以十六进制格式在网格中显示:
- 每行显示 16 个字节
- 左侧显示行索引(十六进制)
- 右侧显示内存中每个字节的十六进制值
实现这一功能的自然方式是构建两个多行字符串:一个用于左侧的行索引,一个用于右侧的十六进制字节值。
初始实现:使用 format! 宏
作者最初的实现使用了 format!
宏来创建每个字节的十六进制表示:
/// 生成给定内存缓冲区的十六进制内存视图
fn generate_memory_strings(memory: &[u8], mut range: Range<usize>) -> Option<(String, String)> {const BYTES_PER_MEMORY_ROW: usize = 16;// 验证请求的字节范围if memory.len() < range.end {range.end = memory.len();if range.start > range.end {returnNone;}}let mem_view = &memory[range.start..range.end];// 包含行索引的字符串,用换行符分隔letmut lines_str = String::new();// 包含实际内存中十六进制字节的字符串,每行用换行符分隔letmut mem_str = String::new();// 内存视图左侧显示的行索引letmut row_index: usize = range.start;// 构建字符串letmut mem_rows = mem_view.array_chunks::<BYTES_PER_MEMORY_ROW>();for full_row in mem_rows.by_ref() {for byte in &full_row[..BYTES_PER_MEMORY_ROW-1] {mem_str.push_str(format!("{:02X}", *byte).as_str());mem_str.push(' ');}mem_str.push_str(format!("{:02X}", full_row[BYTES_PER_MEMORY_ROW-1]).as_str());mem_str.push('\n');lines_str.push_str(format!("{:#X}\n", row_index).as_str());row_index += BYTES_PER_MEMORY_ROW;}let remainder_row = mem_rows.remainder();if !remainder_row.is_empty() {lines_str.push_str(format!("{:#X}\n", row_index).as_str());for byte in remainder_row {mem_str.push_str(format!("{:02X}", *byte).as_str());mem_str.push(' ');}}Some((lines_str,mem_str))
}
性能瓶颈分析
尽管这种方法能够正常工作,但作者很快注意到,生成大块内存的十六进制视图非常缓慢。即使预先分配两个字符串缓冲区以避免重新分配,性能提升也微不足道。
经过思考,作者发现主要瓶颈是 format!
宏导致的大量堆内存分配。每次调用 format!
时,Rust 都会在堆上分配一个新的 String
。在大多数情况下这没问题,但当字符串内容复制到更大的缓冲区(本例中是 mem_str
和 lines_str
)后立即丢弃时,这种堆分配就完全没有必要了。
优化方案:使用 write! 宏
write!
宏与 format!
不同,它直接将格式化的字符串写入可写流中,无需中间分配。以下是使用 write!
代替 format!
生成内存视图字符串的优化函数:
fn generate_memory_strings(memory: &[u8], mut range: Range<usize>) -> Option<(String, String)> {if memory.len() < range.end {range.end = memory.len();if range.start > range.end {returnNone;}}let mem_view = &memory[range.start..range.end];letmut lines_str = String::new();letmut mem_str = String::new();letmut row_index: usize = range.start;letmut mem_rows = mem_view.array_chunks::<BYTES_PER_MEMORY_ROW>();for full_row in mem_rows.by_ref() {for byte in &full_row[..BYTES_PER_MEMORY_ROW-1] {write!(mem_str, "{:02X} ", *byte).unwrap();}writeln!(mem_str, "{:02X}", full_row[BYTES_PER_MEMORY_ROW-1]).unwrap();writeln!(lines_str, "{:#X}", row_index).unwrap();row_index += BYTES_PER_MEMORY_ROW;}let remainder_row = mem_rows.remainder();if !remainder_row.is_empty() {writeln!(lines_str, "{:#X}", row_index).unwrap();for byte in remainder_row {write!(mem_str, "{:02X} ", *byte).unwrap();}}Some((lines_str,mem_str))
}
性能对比
使用 Rust 的 test::Bencher
进行基准测试,作者发现通过直接将格式化字符串写入最终缓冲区(而非创建大量临时字符串)实现了惊人的 75% 性能提升!
关键差异分析
让我们对比两个版本的关键差异:
-
format! 版本:
mem_str.push_str(format!("{:02X}", *byte).as_str());
这里每次格式化都会:
- 在堆上分配一个新的
String
- 格式化字节写入该
String
- 将该
String
内容复制到mem_str
- 丢弃临时字符串
- 在堆上分配一个新的
-
write! 版本:
write!(mem_str, "{:02X}", *byte).unwrap();
这里直接:将格式化的字节写入最终目标
mem_str
, 无需任何中间堆分配
当需要处理大量字节并执行大量格式化操作时,这种差异累积起来会产生显著影响。
实际应用场景
这种优化特别适用于以下场景:
- 处理大量数据(如内存查看器、十六进制编辑器)
- 生成大型报告或日志
- 执行高频字符串格式化操作
- 资源受限环境(如嵌入式系统)
扩展应用:其他可写目标
除了 String
,write!
宏还可以用于任何实现了 std::fmt::Write
trait 的类型,包括:
- 文件(通过
std::io::Write
) - 网络套接字
- 缓冲区
- 自定义输出流
总结
在 Rust 中构建字符串时,format!
和 write!
是两种常用的方法,但它们在性能上可能有显著差异:
format!
宏:创建新的格式化字符串,适合需要独立字符串的场景write!
宏:直接写入目标流,避免中间分配,性能更佳
当需要将多个格式化结果合并到同一个缓冲区时,write!
是明显更好的选择,可以提供高达 75% 的性能提升。这是一个简单但强大的优化技巧,几乎不需要重构代码就能实现显著的性能改进。
下次编写 Rust 代码时,在需要构建大量字符串的场景下,记得考虑使用 write!
代替 format!
,这可能会为你的应用程序带来可观的性能提升。
参考文章
- write! vs format! when constructing Rust strings by Nicholas Obert, Apr 2025, Medium