课程概览 · 第 5 章

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

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

函数签名表达谁拥有数据、需要什么访问权限、成功产出什么以及失败怎样返回。文件 I/O 把这些边界放到真实输入上:文件可能缺失,文本可能不是 UTF-8,写出也可能失败。本章用独立的配置与规则示例,连接参数设计、Result、? 与显式缓冲收尾。

函数访问合同、缓冲 I/O、错误传播与应用入口
图:签名表达访问权限与失败类型,缓冲层组织读写,应用入口决定最终呈现。

学习目标

前置知识:第 1–4 章的文本、切片、Result 与显式分支。学完你能:

  • 根据读取、修改或转移数据选择参数与返回类型;
  • 展开 ? 的成功取值与失败返回路径;
  • 传播配置读取错误,写出后显式 flush 并检查结果;
  • 分离 stdout 数据和 stderr 诊断,区分打印宏与可失败写入。

本章独立示例只依赖标准库,采用 Edition 2024;各自放入 binary 项目的 src/main.rs,执行 cargo +stable run。文件例只创建自己的临时目录,成功路径清理它;失败早退仍释放句柄,但不自动删除 fixture 目录。

函数签名与参数选择

普通签名形如 fn name(input: &str) -> String:参数要写类型,返回类型写在 -> 后。未写返回类型默认为 (),函数体尾表达式提供结果,早退用 return。

函数需要参数或返回类型合同
只读文本 / 序列&str / &[T]借用,不取得所有权
修改调用方值&mut T独占可变访问
保存、转发或消耗值T非 Copy 值通常移动;Copy 值复制
可能没有产出返回 Option<T>缺值属于正常结果
可能失败返回 Result<T, E>失败原因是接口数据
成功没有产出、仍可能失败返回 Result<(), E>成功为 Ok(())

只读不必要求调用方提供拥有值。类型为 T 也不意味着函数一定长期保存它,签名只表达按值传入;完整 move 与 Copy 规则在第 6 章展开。

add_tag:消耗一个值,借用另一个值

add_tag 接收 String 并复用其缓冲区追加内容,随后把拥有值交回调用方;tag 只读取,接受 &str。不必为只读标签分配一个新 String。

独立示例 05-1(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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(" "));
}

如果调用者还要保留旧名称,应先确定实际需求是原地修改、借用生成新值还是确实需要一份副本,不用 .clone() 隐藏接口设计。泛型参数和 impl Into<T> 会在第 10 章讨论,当前接口先保持清楚。

Result 与 ? 的展开

expr? 对本章的 Result 执行两件事:成功取出值继续;失败经错误转换后从当前函数提前返回。它不捕获异常,也不触发 panic 的栈展开。下面用显式 match 对照其语义;完整的 Try / residual 协议不在本章展开。

独立示例 05-2(标准库;文件:src/main.rs)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::num::ParseIntError;

fn parse_port(text: &str) -> Result<u16, ParseIntError> {
let result = text.parse::<u16>();
let value = match result {
Ok(value) => value,
Err(error) => return Err(From::from(error)),
};
Ok(value)
}

fn parse_port_short(text: &str) -> Result<u16, ParseIntError> {
let value = text.parse::<u16>()?;
Ok(value)
}

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

提前返回正常离开作用域,局部拥有型资源按析构规则释放;? 本身不会绕过清理。From::from 可以转换错误类型,第 13 章再组合多种错误来源。清理释放资源与确认数据写出是不同合同:Drop 不能把写出错误返回给调用方。

Result 可以显式匹配、用 ? 传播,也可以有意丢弃。通常直接丢弃会受到 must_use lint 诊断,但 lint 可配置,let _ = operation(); 也能明确忽略;不能宣称“类型层面无法忽略失败”。丢弃时应有清楚理由。

read_config:文件与内容边界

&Path 不把路径限定为 UTF-8 字符串;拥有路径通常用 PathBuf,拼接用 .join()。小文件可以 fs::read_to_string 一次读取,返回 io::Result<String>;读取失败原样传播,业务上全空白的配置再返回 InvalidData。

独立示例 05-3:配置主例(标准库;文件:src/main.rs)。

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
use std::{fs, io, path::Path};
use std::time::{SystemTime, UNIX_EPOCH};

fn read_config(path: &Path) -> io::Result<String> {
let text = fs::read_to_string(path)?;
if text.trim().is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "配置为空"));
}
Ok(text)
}

fn main() -> io::Result<()> {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH)
.map_err(io::Error::other)?.as_nanos();
let dir = std::env::temp_dir().join(format!(
"rust-course-ch05-config-{}-{stamp}", std::process::id()
));
fs::create_dir(&dir)?;
let path = dir.join("config.txt");
let expected = "host=127.0.0.1\nport=8080\n";
fs::write(&path, expected)?;
assert_eq!(read_config(&path)?, expected);

let error = read_config(&dir.join("missing.txt")).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
fs::write(&path, " \n \n")?;
let error = read_config(&path).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
fs::write(&path, [0xFF])?;
assert_eq!(read_config(&path).unwrap_err().kind(),
io::ErrorKind::InvalidData);
fs::remove_dir_all(&dir)?;
Ok(())
}

例中的 unwrap_err 只验证已知失败输入。实际加载流程返回 Result,不会因为缺失或非法编码就主动 panic。临时目录由进程 ID 与纳秒时间组合后使用 create_dir 创建;若路径碰撞则返回错误,不覆盖已有目录,更不删除任意固定名称的“缺失文件”。

BufRead、BufWriter 与显式 flush

需要逐行处理时,用实现 BufRead 的输入;文件可包在 BufReader 中,内存可用 Cursor。lines() 产生每行的 io::Result<String>,既可能遇到 I/O 错误,也可能遇到非法 UTF-8。逐行不代表零内存占用:本例仍把有效规则收集到 Vec<String>,且单行长度没有另设上限。

独立示例 05-4:规则读写主例(标准库;文件:src/main.rs)。

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
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, BufWriter, Cursor, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

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 stamp = SystemTime::now().duration_since(UNIX_EPOCH)
.map_err(io::Error::other)?.as_nanos();
let dir = std::env::temp_dir().join(format!(
"rust-course-ch05-rules-{}-{stamp}", std::process::id()
));
fs::create_dir(&dir)?;
let input = dir.join("in.txt");
let output = dir.join("out.txt");
fs::write(&input, "allow admin\n\n \ndeny guest \nallow dev\n")?;
let rules = load_rules(&input)?;
assert_eq!(rules, vec!["allow admin", "deny guest", "allow dev"]);

assert_eq!(load_rules(&dir.join("missing.txt")).unwrap_err().kind(),
io::ErrorKind::NotFound);
fs::write(&input, " \n\t\n")?;
assert_eq!(load_rules(&input).unwrap_err().kind(),
io::ErrorKind::InvalidData);
let invalid = Cursor::new([0xFF, b'\n']);
assert_eq!(non_empty_lines(invalid).unwrap_err().kind(),
io::ErrorKind::InvalidData);

let file = File::create(&output)?;
let mut writer = BufWriter::new(file);
for rule in &rules {
writeln!(writer, "{rule}")?;
}
writer.flush()?;
drop(writer);
assert_eq!(fs::read_to_string(&output)?,
"allow admin\ndeny guest\nallow dev\n");
fs::remove_dir_all(&dir)?;
Ok(())
}

BufWriter 将多次小写入缓冲到一起,减少底层写操作的机会;不承诺固定的系统调用次数。显式 flush() 把缓冲数据交给底层写入器,并报告错误;析构时会尽力写出剩余缓冲,但不能报告失败,因此不能替代检查 flush。flush 不等于文件 sync_all,也不保证掉电持久化。

main -> io::Result<()> 将未处理错误交给程序入口的终止协议,错误结果通常打印到 stderr 并以非零状态退出。需要自定义用户消息或退出码时,在应用边界显式匹配错误,第 13 章展开;不承诺操作系统错误文字或固定退出码。

stdout、stderr、write! 与 writeln!

stdout 通常承载数据产物,stderr 承载诊断和进度,两者都可重定向。输出到 stderr 并不意味着永远直达终端,只是默认不混入 stdout 的数据流。

宏目标返回与失败
print! / println!stdout返回 (),写失败会 panic
eprint! / eprintln!stderr返回 (),写失败会 panic
write! / writeln!指定 io::Write / fmt::Write 目标返回相应 Result
format!内存中的新文本返回 String,不返回 I/O 错误

println! 与 writeln! 分别在打印/写入内容后追加换行。Stdout 连接终端时默认行缓冲;不要将内部实现或管道行为当成跨平台公共保证。交互提示没有换行时,应显式 flush;要处理写失败,使用 write! / writeln! 而不是依赖打印宏 panic 后的固定状态码。

独立示例 05-5(标准库;文件:src/main.rs;输出示例不读交互输入)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::io::{self, Write};

fn main() -> io::Result<()> {
let mut out = io::stdout().lock();
write!(out, "> ")?;
out.flush()?;
for index in 0..3 {
writeln!(out, "rule-{index}")?;
}
out.flush()?;
let mut err = io::stderr().lock();
writeln!(err, "warning: using default config")?;
err.flush()
}

锁定 stdout 可以避免每次写入重新获取同一个全局锁,但输出仍可能失败。断管、设备错误和文件系统问题的具体错误及退出表现受平台与应用处理策略影响,不逐字承诺。stderr 可单独重定向,例如 shell 的 2> diagnostics.txt;stdout 的 > data.txt 不自动重定向 stderr。

格式化宏在编译期检查格式串与参数,支持位置参数、内联变量名及不同数量的实参。{name} 只捕获变量名,不是任意表达式;name.len() 应作为单独实参传入。宏的展开机制在第 16 章讨论。

故意失败:编译错误 05-6(标准库;文件:src/main.rs;格式串存在没有对应实参的占位符)。

1
2
3
4
fn main() {
let count = 3;
println!("{count} items: {}");
}

深入阅读:格式占位符与 Debug / Display

{} 要求 Display,适合用户可读表示;{:?} 要求 Debug,适合开发诊断;{:#?} 是 pretty Debug。基本类型通常已有实现,自定义类型可 derive Debug,Display 通常需自行实现。宽度按格式化规则计算,不是第 2 章讨论的通用终端显示列算法。标准库格式语法

记号用途
{}、{0}、{name}顺序、位置索引、变量捕获
{{、}}字面量花括号
{:?}、{:#?}Debug、pretty Debug
{:>8}、{:<8}、{:^8}右、左、居中对齐
{:08}、{:+}数值补零、显示正号
{:.2}浮点精度
{:x}、{:X}、{:b}、{:o}进制表示
{:#06x}前缀、宽度和补零组合
{:e}、{:E}、{:p}科学计数法、指针表示

独立示例 05-7(标准库;文件:src/main.rs;此块的 fmt::Write 不等于文件例的 io::Write)。

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
use std::fmt::Write as _;

fn main() {
let title = "login";
let id = 42;
assert_eq!(format!("{title:?}"), "\"login\"");
assert_eq!(format!("{id:>8}"), " 42");
assert_eq!(format!("{title:<8}|"), "login |");
assert_eq!(format!("{id:08}"), "00000042");
assert_eq!(format!("{id:#06x}"), "0x002a");
let ratio = 0.71828;
assert_eq!(format!("{ratio:.2}"), "0.72");
assert_eq!(format!("{{{title}}}"), "{login}");

let host = String::from("127.0.0.1");
assert_eq!(format!("endpoint: {host}:8080"),
"endpoint: 127.0.0.1:8080");
assert_eq!(host, "127.0.0.1");
let mut report = String::new();
write!(report, "{title}: 3 retries").expect("String formatting");
assert_eq!(report, "login: 3 retries");
let details = (id, title);
println!("{details:#?}");
assert_eq!(dbg!(id * 2), 84);
}

format! 不碰 I/O,返回拥有的 String,不报告 io::Error;不能据此保证任何自定义格式化实现都不 panic。write! 目标为 String 时使用 fmt::Write,目标为文件或 stdout 时使用 io::Write。dbg! 写表达式、源码位置和值到 stderr,并把值返回;按值传入非 Copy 值可能移动它,想保留原值可借用。诊断时不要打印敏感原始输入,仅记录必要的非敏感信息。

自测

  1. add_tag 为什么消耗 name,却只借用 tag?
  2. ? 遇到 Err 做什么?是否绕过局部资源清理?
  3. 缺失文件、空白配置与非法 UTF-8 分别如何报告?
  4. BufWriter 的 flush 能证明掉电持久化吗?为什么不只依赖 Drop?
  5. stderr 是否不可重定向?怎样让 stdout 写失败进入 Result?

参考答案

  1. 函数复用名称缓冲区并交回拥有值,标签只在追加期间读取,不需接收其所有权。
  2. 转换错误后从当前函数提前返回,正常执行相应作用域的析构;不会自动删除临时目录等外部副作用。
  3. 文件缺失为 NotFound;本例空白校验与非法 UTF-8 为 InvalidData,通过 Result 返回或传播。
  4. 不能,flush 只把缓冲交给底层写入器;析构不能报告写出错误,需要显式检查。
  5. 可以重定向。用锁定 stdout 的 write! / writeln! 并处理其 io::Result,交互提示还需显式 flush。