课程概览 · 第 5 章
上一篇:表达式与控制流:让分支、循环和返回值清楚可审查
下一篇:所有权、借用与生命周期:用数据流理解编译器
函数签名是 Rust 最重要的设计文档:参数类型说明谁拥有数据、要不要还,返回类型说明成功产出什么、失败怎么办。文件 I/O 把这两件事逼到真实世界–文件会缺失、配置会为空、读取会中途失败。本章把所有权、错误与资源边界写进签名,用 ? 把失败路径组织成直线。
图:函数签名、缓冲层、Result 与应用入口分别负责资源、性能、失败和最终体验。学习目标与默认选择
学完本章你能:
- 为常见需求直接写出正确的参数/返回值类型组合;
- 用
? 组织 Result 传播,并解释它与异常的差别; - 用
Path / PathBuf、fs::read_to_string、BufReader / 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
|
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");
fs::write(&path, "host=127.0.0.1\nport=8080\n").unwrap(); let config = read_config(&path).unwrap(); assert!(config.starts_with("host="));
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);
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;
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 章会展开多来源错误的合并)。
逐行处理:BufRead 与 BufWriter
小文件一次读完;大文件或流式数据用 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?; 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)?; 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");
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"]);
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);
fs::write(&in_path, " \n\t\n")?; let err = load_rules(&in_path).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData);
let file = File::create(&out_path)?; let mut writer = BufWriter::new(file); for rule in &rules { writeln!(writer, "{rule}")?; } writer.flush()?; 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::Error。main 返回 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()?。- 把
Result 的 Err 丢弃:let _ = risky(); 让失败静默;至少用 ? 或显式分支。 - 为了省一次借用把 owned
T 当输入:fn len(s: String) -> usize 迫使调用方放弃字符串;只读就用 &str。
自测
fn add_tag(name: String, tag: &str) -> String 里两个参数为什么一个 owned 一个借用?–答方向:函数要消费并重建 name(复用缓冲构造新值),tag 读取即弃不需要拥有。? 在 Err 时做了哪两件事?它与异常的区别是什么?–答方向:经 From 转换错误类型后从当前函数提前返回;它不捕获、不栈展开,提前返回走正常 Drop 清理路径。- 读取配置要覆盖哪三类失败?各用什么错误表达?–答方向:文件缺失(
NotFound)、空/无效内容(业务校验,如 InvalidData)、逐行读取失败(传播 io::Error)。 - 什么时候用
fs::read_to_string,什么时候用 BufReader?–答方向:小文件整读;大小不可控或需要流式处理时逐行。 main 返回 io::Result<()> 意味着什么?–答方向:出错时进程非零退出并打印错误;小工具把"失败"交给进程边界表达。