在软件工程中,重构(refactoring)是保持代码健康的关键实践。然而,“重构”这个词常常被误解为“一次性重写一大块逻辑”,这种做法风险极高,尤其在没有充分测试覆盖或语言保障的情况下。
Rust 语言凭借其强大的编译器、类型系统和工具链,为我们提供了极佳的安全重构环境。但即便如此,安全的重构依然依赖于方法论:每次只做小的、可验证的变更(small, verifiable steps)。本文将通过一个真实的重构案例,展示如何一步步、安全地将一个职责混杂的函数拆解为两个清晰、独立的组件。
起点:问题函数 load_question_from_project
假设我们有如下初始实现(简化版):
use std::fs;
use std::path::Path;
pub fn load_question_from_project<P: AsRef<Path>>(project_path: P) -> Result<String, Box<dyn std::error::Error>> {
let content = fs::read_to_string(project_path)?;
// 解析 <file> 标签并展开(伪逻辑)
let mut expanded = String::new();
for line in content.lines() {
if line.starts_with("<file>") && line.ends_with("</file>") {
let file_path = &line[6..line.len() - 7];
let included = fs::read_to_string(file_path)?;
expanded.push_str(&included);
} else {
expanded.push_str(line);
expanded.push('\n');
}
}
Ok(expanded)
}
这个函数做了两件事:
- 从磁盘读取主问题文件
- 解析内容中的
<file>标签,并递归读取引用的文件
这违反了单一职责原则——理想情况下,每个函数应只负责一件事。但直接将其“一刀切”成两个函数,很容易引入错误,比如:
- 忘记处理换行符
- 错误传递路径上下文
- 异常处理不一致
因此,我们需要分步、渐进、可验证地重构。
第一步:提取纯逻辑部分,保留原有行为
我们的第一个目标是不改变任何外部行为,只是把标签展开的逻辑抽出来,但仍在原函数内部调用。
变更 1:提取 expand_file_tags(私有、不暴露)
// 新增辅助函数(私有)
fn expand_file_tags(content: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut expanded = String::new();
for line in content.lines() {
if line.starts_with("<file>") && line.ends_with("</file>") {
let file_path = &line[6..line.len() - 7];
let included = fs::read_to_string(file_path)?;
expanded.push_str(&included);
} else {
expanded.push_str(line);
expanded.push('\n');
}
}
Ok(expanded)
}
// 原函数改为调用新函数
pub fn load_question_from_project<P: AsRef<Path>>(project_path: P) -> Result<String, Box<dyn std::error::Error>> {
let content = fs::read_to_string(project_path)?;
expand_file_tags(&content) // ← 委托给新函数
}
✅ 为什么这是安全的?
- 外部接口完全不变
- 所有逻辑仍由原函数驱动
- 只是内部结构变化
✅ 如何验证?
- 所有现有测试应继续通过
- 可以手动运行程序,行为无差异
📌 关键原则:第一步的目标不是“设计完美”,而是“隔离逻辑”。即使新函数仍包含 I/O(读子文件),我们也先不动它。
第二步:为新函数添加单元测试
现在 expand_file_tags 是一个独立函数,我们可以为它编写专用测试,即使它还依赖文件系统。
但注意:当前它仍调用 fs::read_to_string,这使得测试需要真实文件,不够可靠。
变更 2:引入可注入的文件读取器(为测试铺路)
为了使 expand_file_tags 可测试,我们让它接受一个“文件读取回调”:
type FileReader = dyn Fn(&str) -> Result<String, Box<dyn std::error::Error>>;
fn expand_file_tags_with_reader(
content: &str,
reader: &FileReader,
) -> Result<String, Box<dyn std::error::Error>> {
let mut expanded = String::new();
for line in content.lines() {
if line.starts_with("<file>") && line.ends_with("</file>") {
let file_path = &line[6..line.len() - 7];
let included = reader(file_path)?; // ← 使用传入的 reader
expanded.push_str(&included);
} else {
expanded.push_str(line);
expanded.push('\n');
}
}
Ok(expanded)
}
// 保留兼容性包装
fn expand_file_tags(content: &str) -> Result<String, Box<dyn std::error::Error>> {
expand_file_tags_with_reader(content, &|path| {
fs::read_to_string(path).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
})
}
现在我们可以写纯净的单元测试:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_file_tags_with_mock() {
let input = "<file>dummy.txt</file>\nHello";
let mock_reader = |path: &str| {
assert_eq!(path, "dummy.txt");
Ok("MOCK_CONTENT".to_string())
};
let result = expand_file_tags_with_reader(input, &mock_reader).unwrap();
assert_eq!(result, "MOCK_CONTENT\nHello\n");
}
}
✅ 为什么这一步安全?
- 公共 API 未变
- 原有
expand_file_tags作为适配器保留 - 新逻辑可通过测试验证
第三步:分离主文件加载逻辑
现在我们聚焦第一部分:从路径加载主问题内容。
变更 3:提取 load_project_question
fn load_project_question<P: AsRef<Path>>(project_path: P) -> Result<String, Box<dyn std::error::Error>> {
fs::read_to_string(project_path).map_err(|e| {
error!("Failed to read project question from {:?}: {}", project_path.as_ref(), e);
Box::new(e) as Box<dyn std::error::Error>
})
}
然后更新主函数:
pub fn load_question_from_project<P: AsRef<Path>>(project_path: P) -> Result<String, Box<dyn std::error::Error>> {
let content = load_project_question(project_path)?;
expand_file_tags(&content)
}
✅ 验证点:
- 行为不变
- 错误日志已添加(符合要求)
- 可单独测试
load_project_question
第四步:清理与公开(如需要)
如果这两个函数确实需要被其他模块使用,我们可以将它们设为 pub,并完善文档和错误处理。
同时,考虑是否要统一错误类型(例如使用 thiserror),但这属于后续优化,不属于本次重构核心。
最终代码结构:
/// 从项目路径加载原始问题内容
pub fn load_project_question<P: AsRef<Path>>(project_path: P) -> Result<String, Box<dyn std::error::Error>> {
// ...
}
/// 展开内容中的 <file> 标签(使用默认文件系统读取)
pub fn expand_file_tags(content: &str) -> Result<String, Box<dyn std::error::Error>> {
// ...
}
/// 高层组合函数:加载并展开
pub fn load_question_from_project<P: AsRef<Path>>(project_path: P) -> Result<String, Box<dyn std::error::Error>> {
let content = load_project_question(project_path)?;
expand_file_tags(&content)
}
为什么这种“小步”方式更安全?
| 步骤 | 变更范围 | 验证方式 | 风险 |
|---|---|---|---|
| 1 | 内部函数提取 | 编译 + 现有测试 | 极低 |
| 2 | 引入依赖注入 | 新单元测试 + 现有测试 | 低 |
| 3 | 提取文件加载 | 编译 + 现有测试 + 新测试 | 低 |
| 4 | 接口调整 | 编译 + 所有测试 | 中(需确认调用方) |
每一步都:
- 可独立提交(Git commit)
- 可独立回滚
- 有明确验证手段
- 不破坏现有功能
Rust 如何赋能安全重构?
-
编译器是你的第一道防线
类型错误、所有权问题在编译期暴露,避免运行时崩溃。 -
Result和?保证错误流清晰
不会因为拆分函数而丢失错误上下文。 -
测试框架内建支持
#[cfg(test)]让单元测试与生产代码无缝集成。 -
logcrate 支持结构化日志
如我们在load_project_question中加入的error!日志,带上路径上下文,便于排查。
结语
安全的重构不是一蹴而就的魔术,而是一系列小的、受控的、可验证的步骤。Rust 的语言特性让我们能更自信地迈出每一步,但方法论才是核心。
下次当你面对一个“又长又乱”的函数时,不要想着“重写它”,而是问自己:
“我能做的最小、最安全的下一步是什么?”
答案往往就是:提取一个私有函数,保持外部行为不变,然后写一个测试。
这样,你不仅在改进代码,也在构建一种可持续演进的工程文化。
附:本文示例代码可在 Gitee查看完整可运行版本(略)。实际项目中建议结合 cargo test 和 cargo clippy 进行持续验证。

310

被折叠的 条评论
为什么被折叠?



