阶段一 · 字符串与文本

字符串操作与格式化

一句话总结

字符串的日常操作--拼接,查找,替换,解析--在 Rust 里都围绕"是否需要新分配内存"展开.
format! 宏与 Display/Debug 两个特征,则决定了一个类型如何变成人类可读的文本.

前置回顾

前两篇建立了基础:第 01 篇讲 String&str 的所有权分工,第 02 篇讲 UTF-8 变长编码和遍历方式.本篇在此之上,展开字符串的日常操作 API 和格式化体系.

拼接:四种方式与各自代价

把两个字符串接起来,Rust 提供了若干写法,差别主要在所有权分配次数上.

let a = String::from("Hello, ");
let b = String::from("world");

// 方式一:+ 运算符.注意它会"吃掉"左操作数的所有权
let s1 = a + &b; // a 被移动,之后不能再用 a;b 用引用

// 方式二:push_str 追加 &str,原地修改,不产生新 String
let mut s2 = String::from("Hello, ");
s2.push_str("world");
s2.push('!'); // push 追加单个 char

// 方式三:format! 宏,不夺走任何参数的所有权,可读性最好
let s3 = format!("{}{}", "Hello, ", "world");

// 方式四:拼接集合,用 concat / join
let parts = vec!["a", "b", "c"];
let s4 = parts.join("-"); // "a-b-c"

+ 运算符的所有权陷阱

a + &b 的真实签名是 fn add(self, &str) -> String:它消耗左侧 String 的所有权,复用其堆缓冲区,再追加右侧.所以拼接后 a 不可再用,而 b 因为是借用仍然可用.

这种不对称常让新手困惑--需要保留两个原串时,用 format! 更省心.

循环里拼接要预留容量

在循环中反复 push_str 时,String 可能多次重新分配并搬迁堆数据.若能预估最终大小,先用 String::with_capacity(n) 一次性申请,可避免中途扩容,是常见的性能优化手段.

查找与判断

这一类方法都是只读的,不改变原字符串,因此都作用在 &str 上.

let s = "the quick brown fox";

s.contains("quick"); // true
s.starts_with("the"); // true
s.ends_with("fox"); // true
s.find("brown"); // Some(10),返回起始字节索引
s.find("cat"); // None

find 返回的是字节索引

与上一篇呼应:find 给出的位置是字节索引,可直接用于切片.但若字符串含多字节字符,这个索引不等于"第几个字符".需要字符位置时,仍要配合 char_indices() 自行换算.

替换,分割与清理

let s = "a,b,,c";

// 替换:返回新 String,原串不变
let r = s.replace(",", "-"); // "a-b--c"

// 分割:产出迭代器,惰性
let parts: Vec<&str> = s.split(',').collect(); // ["a", "b", "", "c"]

// 去除首尾空白:返回 &str 切片,零分配
let trimmed = " hi ".trim(); // "hi"

// 大小写转换:涉及 Unicode,返回新 String
let upper = "café".to_uppercase(); // "CAFÉ"

注意 split 返回的子串是借用原字符串的 &str,不复制数据;而 replaceto_uppercase 必须产生新内容,因此返回 String.这种"能借用就借用,必须新建才分配"的取舍贯穿整个字符串 API.

split 与连续分隔符

"a,b,,c"split(',') 会得到一个空串元素 "".若想跳过空段,可用 split_terminator 或在迭代时 filter(|s| !s.is_empty()).这类细节在解析 CSV,路径等场景中很容易踩坑.

format! 与格式化语法

format! 是构造复杂字符串的主力.它和 println!,write! 共享同一套格式化语法,值得系统掌握.

let name = "Rust";
let n = 42;

// 位置与具名参数
format!("{name} is {n}"); // 直接捕获同名变量(2021 edition 起)
format!("{0} {1} {0}", "a", "b"); // "a b a"

// 数字格式化
format!("{:05}", n); // "00042",补零到 5 位
format!("{:.2}", 3.14159); // "3.14",保留两位小数
format!("{:x}", 255); // "ff",十六进制
format!("{:b}", 5); // "101",二进制

// 对齐与填充
format!("{:>8}", "hi"); // " hi",右对齐到宽度 8
format!("{:*<8}", "hi"); // "hi******",左对齐,用 * 填充

格式化占位符里的 {} 像一张"排版要求单":冒号左边写"用哪个参数",右边写"怎么排版"--补零,对齐,进制,精度,都是排版指令.

Display 与 Debug:两种"变成文本"

为什么有的类型能用 {} 打印,有的却必须用 {:?}?这背后是两个不同的特征.

特征 占位符 面向对象 能否自动派生
Display {} 终端用户,要求"优雅可读" 否,必须手写
Debug {:?} 开发者,用于调试 是,#[derive(Debug)]
use std::fmt;

struct Point { x: i32, y: i32 }

// 手动实现 Display:决定面向用户的呈现
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}

let p = Point { x: 1, y: 2 };
println!("{p}"); // (1, 2),走 Display

Debug 几乎总是用派生宏自动生成,无需手写:

#[derive(Debug)]
struct Config { retries: u32, verbose: bool }

let c = Config { retries: 3, verbose: true };
println!("{c:?}"); // Config { retries: 3, verbose: true }
println!("{c:#?}"); // 带换行缩进的"美化"版本

为什么 Display 不能自动派生

因为"对人友好的展示形式"没有唯一标准答案:一个 Point 应该显示成 (1, 2) 还是 x=1 y=2?编译器无从猜测,只能由开发者定义.Debug 则有公认的"结构体字段一字排开"的默认格式,所以能派生.

一个经验法则:给开发者看用 Debug,给用户看用 Display.

parse:从字符串解析出类型

format! 把类型变成字符串,parse 则是反方向--把字符串解析成目标类型.它返回 Result,因为解析可能失败.

let n: i32 = "42".parse().unwrap();         // 42
let f = "3.14".parse::<f64>().unwrap(); // 用 turbofish 指定目标类型

// 解析失败返回 Err,应当用 ? 或 match 处理,而非 unwrap
let bad: Result<i32, _> = "abc".parse();
assert!(bad.is_err());

parse 靠类型标注推断目标

parse 怎么知道要解析成 i32 还是 f64?答案是靠类型推断:要么写出变量类型标注 let n: i32,要么用 turbofish 语法 parse::<f64>().任何实现了 FromStr 特征的类型都能被 parse 出来--这与下一篇要讲的类型转换体系直接相连.

快速回顾

  • 拼接看代价:push_str 原地修改,+ 消耗左操作数,format! 不夺所有权但有分配
  • 只读操作返回 &str,新建操作返回 String,区分能否零拷贝
  • find 返回字节索引,split 子串借用原数据
  • format! 语法:冒号左选参数,右定排版(补零,对齐,进制,精度)
  • Display 手写给用户,Debug 派生给开发者
  • parse 是 format 的逆向,返回 Result,靠类型标注确定目标

动手练习

  1. 实现 Display:给一个 struct Temperature { celsius: f64 } 实现 Display,让 println!("{t}") 输出形如 25.0°C,再加 #[derive(Debug)] 对比两种打印效果.
  2. split + parse:写一个函数解析 "1,2,3,4" 这样的字符串为 Vec<i32>,用 split + parse 实现,并妥善处理解析失败的情形(返回 Result).
  3. 格式化表格:用 format! 把数字 255 同时输出为十进制,十六进制,二进制三种形式,宽度对齐成一张表格.