课程概览 · 第 5 章

上一篇:表达式与控制流:让分支、循环和返回值清楚可审查

下一篇:所有权、借用与生命周期:用数据流理解编译器

函数签名是 Rust 最重要的设计文档:参数类型说明谁拥有数据、要不要还,返回类型说明成功产出什么、失败怎么办。文件 I/O 把这两件事逼到真实世界–文件会缺失、配置会为空、读取会中途失败。本章把所有权、错误与资源边界写进签名,用 ? 把失败路径组织成直线。

函数签名的所有权合同、缓冲 I/O 管道、问号错误传播和应用边界策略
图:函数签名、缓冲层、Result 与应用入口分别负责资源、性能、失败和最终体验。

学习目标与默认选择

学完本章你能:

  • 为常见需求直接写出正确的参数/返回值类型组合;
  • ? 组织 Result 传播,并解释它与异常的差别;
  • Path / PathBuffs::read_to_stringBufReader / BufWriter 完成典型文件 I/O;
  • 显式处理文件缺失、空/无效配置、逐行读取错误三类失败。

参数选择表(本章的核心速查):

函数需要什么参数写法调用后的原值
读取文本&str仍可使用
读取序列&[T]仍可使用
修改调用方数据&mut T仍可使用(借用期间独占)
保存或转发数据T(owned)所有权转入函数,调用方不可再用
可能没有结果返回 Option<T>
可能失败返回 Result<T, E>
成功无产出返回 Result<(), E>

返回值规则:可能失败就 Result,可能没有就 Option,两者都没有才返回裸 T。外部输入、文件、网络一律 Result

参数选择:修正版 add_tag

一个函数同时演示 owned 参数与借用参数的分工。add_tag 拿走 String(它要基于原值构造新结果并复用缓冲),tag 只借用(读取后就用完):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/// 给任务名追加一个标签,返回新的拥有值。
/// name: String —— 函数要消费并重建它,调用方之后不再用旧值。
/// tag: &str —— 只读取,不拿走调用方的数据。
fn add_tag(mut name: String, tag: &str) -> String {
name.push_str(" [");
name.push_str(tag);
name.push(']');
name
}

/// 读取型参数:只判断,不需要拥有。
fn is_valid_name(name: &str) -> bool {
!name.trim().is_empty()
}

fn main() {
let result = add_tag(String::from("fix-login"), "urgent");
assert_eq!(result, "fix-login [urgent]");

// 标签不同,追加的内容真的不同——参数被实际使用。
assert_eq!(
add_tag(String::from("fix-login"), "later"),
"fix-login [later]"
);

assert!(is_valid_name(" ship "));
assert!(!is_valid_name(" "));
}

评审时看两点:owned 参数 T 意味着"函数之后原值归函数",调用方再使用会报 use-after-move(第 6 章);&str 参数同时接受 String 与字面量,是最宽的文本输入。不要为了"通用"把每个参数写成泛型或 impl Into<T>–公共接口先做到容易理解,确有多种输入类型时再收窄。

返回值与 ?:读取配置文件

? 把"失败就提前返回"压成一个字符,主流程保持直线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
use std::{fs, io, path::Path};

/// 读取整个配置文件,拒绝空白内容。
fn read_config(path: &Path) -> Result<String, io::Error> {
let text = fs::read_to_string(path)?;
if text.trim().is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "配置为空"));
}
Ok(text)
}

fn main() {
// 用系统临时目录做真实文件,验证后清理。
let dir = std::env::temp_dir();
let path = dir.join("rust-course-ch04-config-demo.txt");

// 场景 1:正常配置。
fs::write(&path, "host=127.0.0.1\nport=8080\n").unwrap();
let config = read_config(&path).unwrap();
assert!(config.starts_with("host="));

// 场景 2:文件缺失。
let missing = dir.join("rust-course-ch04-no-such-file.txt");
let _ = fs::remove_file(&missing); // 保证不存在
let err = read_config(&missing).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);

// 场景 3:内容为空(无效配置)。
fs::write(&path, " \n \n").unwrap();
let err = read_config(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);

// 清理临时文件。
fs::remove_file(&path).unwrap();
}

fs::read_to_string(path)? 一行完成三件事:读文件、判断结果、失败时返回 io::Error。空内容走守卫式早返回,错误种类(ErrorKind::InvalidData)让调用方能按失败性质分支,而不是只拿到一段消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::num::ParseIntError;

/// `expr?` 的等价展开:取出值继续,或经 `From` 转换错误类型后提前返回。
fn parse_port(text: &str) -> Result<u16, ParseIntError> {
let expr: Result<u16, ParseIntError> = text.parse::<u16>();
let value = match expr {
Ok(value) => value, // ? 的成功分支:取出值,主流程继续
Err(error) => return Err(From::from(error)), // ? 的失败分支:转换并提前返回
};
Ok(value)
}

fn main() {
assert_eq!(parse_port("8080"), Ok(8080));
assert!(parse_port("not-a-port").is_err());
}

它不捕获错误、没有栈展开语义,就是一次"取出值或提前返回"。提前返回经过正常的离开作用域流程:局部变量按声明逆序执行 Drop–打开的文件句柄、缓冲区都在这条路径上正常释放,不会被跳过。这与 Java/C++ 异常的关键差别是:清理路径与正常返回是同一条,不存在"异常绕过析构"的问题。From::from? 顺手完成错误类型转换(本章错误类型统一是 io::Error,第 13 章会展开多来源错误的合并)。

逐行处理:BufReadBufWriter

小文件一次读完;大文件或流式数据用 BufReader 逐行处理,每行的读取失败单独传播。下面的程序把三类失败全部显式覆盖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::Path;

/// 校验后的非空配置行;任何一行读取失败都会终止并向上传播。
fn non_empty_lines(reader: impl BufRead) -> io::Result<Vec<String>> {
let mut result = Vec::new();
for line in reader.lines() {
let line = line?; // 单行读取失败:提前返回 io::Error
let trimmed = line.trim();
if !trimmed.is_empty() {
result.push(trimmed.to_string());
}
}
Ok(result)
}

/// 从配置文件读出非空行,内容全空视为无效配置。
fn load_rules(path: &Path) -> io::Result<Vec<String>> {
let file = File::open(path)?; // 文件缺失在这里变成 NotFound 错误
let lines = non_empty_lines(BufReader::new(file))?;
if lines.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "配置没有有效行"));
}
Ok(lines)
}

fn main() -> io::Result<()> {
let dir = std::env::temp_dir();
let in_path = dir.join("rust-course-ch04-rules-in.txt");
let out_path = dir.join("rust-course-ch04-rules-out.txt");

// 场景 1:正常输入,含空行与首尾空白。
fs::write(&in_path, "allow admin\n\n \ndeny guest \nallow dev\n")?;
let rules = load_rules(&in_path)?;
assert_eq!(rules, vec!["allow admin", "deny guest", "allow dev"]);

// 场景 2:文件缺失。
let missing = dir.join("rust-course-ch04-rules-missing.txt");
let _ = fs::remove_file(&missing);
let err = load_rules(&missing).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);

// 场景 3:全空白内容 —— 无效配置。
fs::write(&in_path, " \n\t\n")?;
let err = load_rules(&in_path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);

// BufWriter:多次写出合并为少量系统调用,显式 flush。
let file = File::create(&out_path)?;
let mut writer = BufWriter::new(file);
for rule in &rules {
writeln!(writer, "{rule}")?;
}
writer.flush()?; // 缓冲必须显式落盘(Drop 时也会尝试,但失败不可见)
drop(writer);

let written = fs::read_to_string(&out_path)?;
assert_eq!(written, "allow admin\ndeny guest\nallow dev\n");

// 清理临时文件。
fs::remove_file(&in_path)?;
fs::remove_file(&out_path)?;
Ok(())
}

三个失败路径的行为:文件缺失 -> File::open 返回 NotFound;全空白 -> InvalidData(业务校验);某行读取失败(磁盘、编码问题)-> line? 原样传播 io::Errormain 返回 io::Result<()>,出错时进程以非零码退出并打印错误–小工具的标准写法。

BufWriter 的资源边界同理:数据先进缓冲区,flush() 把它推到底层文件。缓冲区离开作用域时 Drop 会尝试 flush,但失败被吞掉;对正确性敏感的写出要显式 flush()?,让错误可见。

为什么可行:? 是控制流,不是异常

expr? 展开成一个 match:成功取出值继续,失败经 From::from 转换错误类型后从当前函数提前返回。它不捕获错误、没有栈展开语义,提前返回走的是正常的离开作用域流程–局部变量按声明逆序执行 Drop,打开的文件句柄、BufWriter 的缓冲区都在这条路径上正常清理。与异常的关键差别是:清理路径与正常返回是同一条,不存在"异常绕过析构"。同时,签名里的 Result<T, E> 让"这个函数会失败、失败长什么样"成为接口的一部分,调用方在类型层面就无法忽略它。

文件 I/O 默认方式

场景选择
一次读取小文件fs::read_to_string / fs::read
大文件或逐行处理BufReader + lines()
一次写出fs::write
多次写出BufWriter + 显式 flush
路径拼接与检查Path / PathBuf,不把路径限制为 String

常见误区

  • 在可预期失败路径上 unwrap():文件缺失、配置为空是正常业务输入,unwrap 把它们变成程序崩溃;用 ? 或显式分支。
  • 路径参数写成 String:调用方持 PathBuf&Path 时被迫转换;统一用 &Path 接受。
  • 大文件一次性读入read_to_string 会把整个文件放进内存;大小上限不明确就 BufReader 逐行。
  • BufWriter 不 flush 就退出Drop 的兜底 flush 吞掉错误;关键写出显式 flush()?
  • ResultErr 丢弃let _ = risky(); 让失败静默;至少用 ? 或显式分支。
  • 为了省一次借用把 owned T 当输入fn len(s: String) -> usize 迫使调用方放弃字符串;只读就用 &str

自测

  1. fn add_tag(name: String, tag: &str) -> String 里两个参数为什么一个 owned 一个借用?–答方向:函数要消费并重建 name(复用缓冲构造新值),tag 读取即弃不需要拥有。
  2. ?Err 时做了哪两件事?它与异常的区别是什么?–答方向:经 From 转换错误类型后从当前函数提前返回;它不捕获、不栈展开,提前返回走正常 Drop 清理路径。
  3. 读取配置要覆盖哪三类失败?各用什么错误表达?–答方向:文件缺失(NotFound)、空/无效内容(业务校验,如 InvalidData)、逐行读取失败(传播 io::Error)。
  4. 什么时候用 fs::read_to_string,什么时候用 BufReader?–答方向:小文件整读;大小不可控或需要流式处理时逐行。
  5. main 返回 io::Result<()> 意味着什么?–答方向:出错时进程非零退出并打印错误;小工具把"失败"交给进程边界表达。