你写了一个命令行程序,运行后却像卡死了一样:光标停在那里,屏幕上连“请输入名字”都没有。你按下回车,它忽然又活了。换个场景,程序说日志已经写完,进程退出后文件却少了最后几行;或者你在 macOS 上拼出来的路径很正常,一到 Windows 就找不到文件。I/O 的麻烦往往不是语法难,而是我们对“完成”这件事想得太简单。
在内存里做加法,输入确定,结果通常也确定。读键盘、写文件就不一样了。数据可能暂时还在缓冲区里,底层调用可能只处理一部分,路径可能不是合法的 UTF-8,磁盘也可能在最后一步才报告空间不足。Rust 没有假装这些事情不会发生。它把失败放进 Result,把字节数量放进返回值,又让编译器不断提醒你:这个结果还没处理。
这份严格一开始确实有点烦。尤其当你只想读个文件,却连续遇到 Result、trait 导入、可变借用和字符串编码时,很容易觉得编译器在故意加戏。换个角度看,它其实是在替将来的你追问几个很实际的问题:读到的是全部内容吗?错误应该在这里处理,还是交给调用方?这条路径真能当普通字符串吗?这些问题不在编译期回答,就很可能在用户的机器上回答。
先别急着背 API。Rust 的同步 I/O 可以先理解成一组“数据从哪里来、到哪里去”的能力。
Read 的值是读取源,你可以尝试从中拿字节。文件、标准输入、字节切片都可以是读取源。Write 的值是写入目标,你可以尝试向其中送字节。文件、标准输出、标准错误和 Vec<u8> 都可以是写入目标。BufRead 的值在 Read 之上多了一层内部缓冲,因此适合按行、按分隔符读取。Seek 的值带有可移动的字节位置,文件和内存中的 Cursor 都是常见例子。
这里最关键的词是“能力”。File 是一个具体类型,Read 是它可能拥有的能力。函数如果只需要读取,就可以接收“任何实现了 Read 的东西”,而不用把参数写死成文件。以后要把输入换成标准输入、网络流或测试用的内存数据,函数本体往往不用改。
use std::io::{self, Read};
fn read_at_most<R: Read>(reader: R, limit: u64) -> io::Result<Vec<u8>> {
let mut bytes = Vec::new();
reader.take(limit).read_to_end(&mut bytes)?;
Ok(bytes)
}
fn main() -> io::Result<()> {
let input = b"hello rust".as_slice();
let bytes = read_at_most(input, 5)?;
assert_eq!(bytes, b"hello");
Ok(())
}take(5) 没有立刻读取,它只是给原来的读取源套上“最多读 5 字节”的限制。这个小例子已经露出了 I/O 设计里很有用的一点:读取器和写入器可以被包装、组合,再交给通用函数。
不过本章使用的是 std 里的同步 I/O。同步的直觉很直接:当前线程发起操作,如果数据还没准备好,它可能就在那里等。读普通本地文件时,这通常够用;一个小工具也没必要为了“看起来高级”就引入异步运行时。但如果你要同时维持成千上万个慢连接,或者不能让界面线程被磁盘访问卡住,同步 I/O 的阻塞性质就会变成真正的约束。缓冲能减少调用次数,不能把同步调用变成异步调用。
std::io 的核心抽象面向字节,不面向“字符”或“行”。文本、行和数字格式都是建立在字节之上的解释。先记住这一层,后面遇到 UTF-8 错误、半个汉字和按字节定位时就不容易迷糊。
最熟悉的 println! 会把格式化后的内容写到标准输出,并补一个换行。print! 也写标准输出,但不补换行。eprintln! 和 eprint! 则写标准错误。
这两个输出通道不是为了让代码看起来整齐。命令行程序经常被这样使用:

cargo run --quiet > result.txt此时标准输出被重定向到 result.txt,标准错误默认仍显示在终端。如果程序把“处理失败”也用 println! 打出来,错误文字就会混进正常结果,后续脚本可能把它当数据继续处理。一个实用的约定是:能被下一段程序消费的正常结果走 stdout,诊断、警告和失败原因走 stderr。
fn main() {
let files = 3;
println!("{files}");
eprintln!("提示:已跳过不可读取的缓存目录");
}格式化宏里的 {} 使用 Display,通常面向最终用户;{:?} 使用 Debug,更适合开发时查看结构;{:#?} 会把复杂的调试结构展开。你还可以使用命名捕获、宽度、对齐、精度和进制:
fn main() {
let name = "报告";
let size = 27_u64;
let ratio = 0.875_f64;
println!("文件:{name}");
println!("大小:{size:>6} B");
println!("进度:{:.1}%", ratio * 100.0);
println!("标志:0x{size:04x} / 0b{size:08b}");
}文件:报告
大小: 27 B
进度:87.5%
标志:0x001b / 0b00011011格式字符串在编译期就会被检查。占位符数量对不上、类型不支持对应格式时,编译器会直接指出来。它在这里像一个很较真的编辑:宁可现在打断你,也不愿等程序跑到生产环境才拼出一条残缺日志。
回到开头那个“程序卡住”的场景:
use std::io;
fn main() -> io::Result<()> {
print!("请输入名字:");
let mut name = String::new();
io::stdin().read_line(&mut name)?;
println!("你好,{}", name.trim_end());
Ok
print! 没有换行,内容可能还停留在标准输出的缓冲里,程序却已经开始等待输入。用户看到的就是一片安静。修复方法不是随便多加一个换行,而是在交互边界明确刷新:
use std::io::{self, Write};
fn main() -> io::Result<()> {
print!("请输入名字:");
io::stdout().flush()?;
let mut name = String::new();
io::stdin()
注意 flush 来自 Write trait,所以需要把 Write 引入作用域。flush 的意思是把中间缓冲的数据继续推向底层目标。对终端提示来说,这通常就是我们想要的“现在显示”。对文件来说,flush 还不等于数据已经稳定落盘,后面会专门拆开这几个层次。
下面的交互实验把“提示仍在缓冲区”和“刷新后终端可见”放在同一条时间线上。试着切换是否换行、是否手动刷新,再观察程序何时进入等待输入状态。
反复调用输出宏很方便,但如果你在紧密循环里写很多行,更可控的做法是先取得标准输出句柄并锁住它,再用 writeln! 写。这样既减少反复获取锁的开销,也能让写入错误沿着 io::Result 返回。
use std::io::{self, Write};
fn write_table(mut out: impl Write) -> io::Result<()> {
for id in 1..=3 {
writeln!(out, "任务 {id:02}: 完成")?;
}
out.flush()
}
print!、println! 适合日常输出;如果输出失败必须被业务代码感知,就优先显式使用实现了 Write 的句柄。这样一来,管道的另一端提前关闭、输出设备出错之类的问题不会被你无意中当成“肯定成功”。
format!、print!、write! 使用的是同一套格式化语法,但数据去向不同。format! 在内存里构造一个新的 String;write! 和 writeln! 把格式化结果送给实现了 fmt::Write 或 io::Write 的目标;输出宏则把目标固定为控制台。若结果马上要写进文件,先 format! 出一个大字符串再 write_all 并不总是划算,直接 writeln!(writer, ...) 可以边格式化边交给写入器。
自定义类型想用 {},要实现 Display;想快速获得 {:?},通常可以派生 Debug。两者服务的对象不同,没必要为了省事把面向开发者的内部结构直接当成用户界面。
use std::fmt;
#[derive(Debug)]
struct FileSize(u64);
impl fmt::Display for FileSize {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} B", self
Display::fmt 返回 fmt::Result,因为真正的目标可能拒绝继续写。你不用在实现里自行打印错误,只要把结果返回给格式化系统。格式化精度也要看类型语义:{:.2} 对浮点数常表示小数位,对字符串则表示最多显示多少字符。遇到对齐或精度行为拿不准时,写一个最小例子验证,比凭记忆猜占位符可靠。
控制台还可能不是终端。用户可以把 stdout 接到文件、管道或另一个进程,颜色控制码和动态进度条在这些目标上反而会污染数据。标准库提供判断句柄是否连接终端的能力,但更重要的是接口意识:正常输出应尽量稳定、可重定向,花哨交互只在确认目标适合时启用。这样写出来的命令行程序才真的能参与自动化,而不是只能在人眼盯着终端时工作。
从终端读配置值时,read_line 很顺手。它把读到的内容追加到已有的 String,直到读到换行字节或文件结束,并返回本次读取的字节数。这里有三个细节经常一起坑人。

其一,换行通常还在字符串里。在 Unix 风格输入中结尾常见 \n,Windows 文本行常见 \r\n。如果你只想去掉行尾,优先用 trim_end;trim 会把开头的空格也去掉,而用户输入开头的空格有时就是数据。
其二,传入的 String 不会自动清空。循环复用同一个缓冲时,如果忘了 clear,第二行会接在第一行后面。
其三,返回 Ok(0) 表示到达输入结束。交互式终端和被重定向的文件都可能出现 EOF,不能把它和“用户输入了一行空白”混为一谈。空行通常仍然读到了换行,因此返回值大于零。
use std::io::{self, BufRead, Write};
fn main() -> io::Result<()> {
let stdin = io::stdin();
let mut input = stdin.lock();
let mut line = String::new();
这里锁住了标准输入,因此得到了实现 BufRead 的 StdinLock。如果只是读取一次,直接 io::stdin().read_line(&mut line) 足够;如果要循环读取,持有锁更自然。
String 永远要求有效 UTF-8,所以 read_line 不是“把任意输入塞进字符串”。遇到非法 UTF-8 字节时,它会返回错误。读取任意二进制内容、自定义协议或编码未知的数据时,应该先读入 Vec<u8> 或固定字节数组,再决定如何解释。
use std::io::{self, Read};
fn main() -> io::Result<()> {
let mut first = [0_u8; 8];
let n = io::stdin().read(&mut first)?;
println!("本次读到 {n} 字节:{:02x?}"
这段代码只承诺“本次最多读 8 字节”。哪怕后面还有数据,read 也允许先返回较小的 n。如果协议头必须正好是 8 字节,应使用 read_exact(&mut first)。它会不断尝试,直到填满缓冲区,或者因过早 EOF 等原因返回错误。
还有一个现实问题:read_line 会一直等到换行或 EOF。若输入来自不受信任的一端,对方可以持续发送数据却始终不发换行,让缓冲不断增长。面对外部输入时,应给长度设置上限,或者使用字节读取配合自己的分隔与上限策略。同步读取在等待期间会占住当前线程,这也是它诚实但不免费的地方。
I/O 层只负责把文本送到你手上,业务格式仍要自己检查。用户输入年龄时,read_line 成功只能证明终端读到了有效 UTF-8,不能证明内容是整数,更不能证明范围合理。把读取、去行尾、解析和范围验证分开,错误提示会清楚很多。
use std::io::{self, Write};
fn read_port() -> io::Result<u16> {
loop {
print!("端口号(1-65535):");
io::stdout().flush()?;
let mut line = String::new();
这里可以对格式错误继续询问,因为当前函数知道自己正在做交互;但 EOF 被返回给调用方,因为输入源已经结束,再循环只会重复失败。要是程序从管道接收数据,反复提示通常也没有意义。恢复策略不能只看 ErrorKind,还得看程序正在和人交互,还是在无人值守的流水线里运行。
只想把一个不大的 UTF-8 配置文件全部读进内存,可以从 fs::read_to_string 开始;只想读原始字节,用 fs::read;一次性覆盖写入,用 fs::write。这些函数替你完成打开、读写和关闭,代码短,意图也清楚。
use std::fs;
use std::io;
fn main() -> io::Result<()> {
fs::write("settings.txt", "theme=dark\n")?;
let text = fs::read_to_string("settings.txt")?;
assert_eq!(text, "theme=dark\n
便利函数不是“低级写法”。文件小、只操作一次时,它们往往就是最清楚的写法。只有在文件很大、要流式处理、需要追加、需要随机访问,或者必须控制持久化时,才有必要拿到 File。
File::open(path) 以只读方式打开已经存在的文件。它不会替你创建文件。File::create(path) 以只写方式创建文件;如果文件已存在,会先把长度截成零。这个“顺手清空”的行为很锋利,备份程序里误用一次,就能得到一个非常干净的空备份。
OpenOptions 把意图写完整当“只读”和“创建后覆盖”不够用时,用 OpenOptions 组合访问方式:
read(true) 允许读取。write(true) 允许从当前文件位置写入,但不会自动清空旧内容。append(true) 让每次写入都定位到当时的文件末尾。truncate(true) 在成功打开后把已有文件截断为零长度,需要同时允许写入。create(true) 在文件不存在时创建,已经存在则照常打开,需要写入或追加权限。create_new(true) 只在目标不存在时创建,已存在就报错;这比“先检查不存在,再创建”更可靠。
下面的实验把常见 OpenOptions 开关组合成可操作的模式面板。先预测已有文件会被保留、覆盖、截断还是拒绝,再运行一次对照实际结果。
下面是一个追加日志的函数:
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::Path;
fn append_log(path: &Path, message: &str) -> io::Result<()> {
let mut file =
追加模式保证写操作发生在当时的文件末尾,这和“先 seek 到末尾,再写”不同:后者在定位与写入之间,别的线程或进程可能先追加了内容。不过追加并不自动保证一条长消息永远不会和其他写入者交错,单次底层写入也可能只接受一部分数据。多写入者日志需要更高一层的并发设计,不能只靠 .append(true) 就宣布万事大吉。
如果你想“文件不存在时才创建”,不要这样写:
use std::fs::File;
use std::path::Path;
fn main() -> std::io::Result<()> {
let path = Path::new("job.lock");
if !path.exists() {
let _file = File::
检查与创建是两个操作,中间有空隙,另一个进程完全可能抢先创建。编译器抓不到这种时间差,因为代码的类型都对。把意图交给一个原子创建操作更合适:
use std::fs::OpenOptions;
fn main() -> std::io::Result<()> {
let _file = OpenOptions::new()
.write(true)
.create_new(true)
.open("job.lock")?;
Ok(())
write(true) 的意思只是“允许写”,不是“从头覆盖整份文件”。新打开的普通文件位置通常在开头,因此第一次写会覆盖开头那一段,旧文件后半段仍然保留。比如旧内容是 abcdef,从开头写入 XY,可能得到 XYcdef,而不是只有 XY。想彻底替换就明确使用 truncate(true),或者选择已经带有截断语义的 File::create、fs::write。
同样,create(true) 只负责在目标文件缺失时创建,不会递归创建父目录。data/2026/report.txt 的 data/2026 不存在时,打开仍会失败;需要时先调用 fs::create_dir_all,并照样处理它的错误。自动创建目录是否合适也要看业务:用户拼错路径时,静默建出一串新目录可能比直接报错更难发现。
读写同时开启也不代表有两个独立位置。同一个 File 的读取和写入围绕同一个逻辑游标工作。你刚读了 20 字节,接下来的写入就从当前位置开始,除非追加模式或显式定位改变了行为。代码里如果一会儿读、一会儿写,却没有清楚标注 seek,结果通常只能靠猜。
可以把选择压缩成一组问题:目标必须已存在吗?已存在时要保留、截断还是报错?写入要从当前位置还是末尾?是否同时读取?把这几句回答清楚,再配 OpenOptions,比先链一串 true 然后试运行靠谱得多。非法组合也会在 open 时返回 InvalidInput,例如要求截断却没有写权限。编译器能检查类型,操作系统和标准库则在打开时检查这些运行期语义。
Read 与 Write:一次调用可能只完成一部分很多“文件读了一半”的 bug,都来自一个过于乐观的假设:缓冲区有多大,read 就会填多少;切片有多长,write 就会写多少。trait 的契约没有这样保证。
Read::read(&mut buf) 返回 io::Result<usize>。成功时的 n 只说明 buf[..n] 是这次新读到的字节,n 可以小于 buf.len()。对普通文件,短读可能不常碰见;对管道、终端和网络流,它很平常。除了传入空缓冲的情况,Ok(0) 通常表示到达流的末尾。
use std::fs::File;
use std::io::{self, Read};
fn first_chunk(path: &str) -> io::Result<Vec<u8>> {
let mut file = File::open(path)?;
let
这个函数的名字故意叫 first_chunk,因为它确实只读一次。若名字叫 read_file,实现却只调用一次 read,接口就在撒谎。
需求不同,选择也不同:
read_exact。少一个字节都应该报错。read_to_end 或 fs::read。read_to_string 或 fs::read_to_string。BufReader 按行、按分隔符处理。take(limit),不要无上限地 read_to_end。Write::write(&buf) 也只承诺尽力写入一些字节并返回数量。Ok(n) 不意味着 n == buf.len(),Ok(0) 在非空输入下通常说明写入目标已经无法继续接受数据。自己写循环时还得处理 Interrupted 和零进度,稍不留神就会死循环。因此,大多数“必须完整写入”的场景直接用 write_all。
use std::fs::File;
use std::io::{self, Read, Write};
fn write_packet(path: &str, payload: &[u8]) -> io::Result<()> {
let mut file = File::create(path)?
这个例子有两个边界。read_exact 解决“实际字节不够”的问题;长度上限解决“文件头声称后面有几十 GiB,于是程序试图一次分配巨大内存”的问题。I/O 稳定性往往就藏在这种看似啰嗦的检查里。
高层方法已经替你处理了大量机械细节。有时你确实需要控制每一步,例如更新进度条或计算流式校验值。这时循环不能只写“失败就 ?,成功就前进”。Interrupted 表示这次操作被打断,通常应该重试;非空输入得到 write 的 Ok(0) 时,如果仍原地循环,程序会永远卡住。
use std::io::{self, ErrorKind, Write};
fn write_everything(
writer: &mut impl Write,
mut bytes: &[u8],
) -> io::Result<()> {
while !bytes.is_empty() {
match writer.
这正是 write_all 已经解决的问题,所以业务代码通常直接调用它。手写版本的价值在于看清契约:错误分支不一定都该立刻退出,成功分支也不一定已经完成。读取循环同理。使用固定数组时,只能消费 &buf[..n];buf[n..] 是这次没有被填入的剩余空间,可能还保留着上一次的数据。若误把整个缓冲交给解析器,文件尾部就可能凭空“长出”一段旧内容。
read_to_end 和 read_to_string 会把内容追加到现有集合,而不是先清空。复用 Vec 或 String 时,要明确你是想追加,还是应该先 clear。这种设计方便把多个来源串到同一个缓冲,却也会惩罚“我以为它会覆盖”的想当然。
如果一个函数只做复制,不需要知道两端究竟是什么,参数就可以只要求 Read 和 Write:
use std::io::{self, Read, Write};
fn copy_limited(
input: &mut impl Read,
output: &mut impl Write,
max_bytes: u64,
) -> io::Result<u64> {
let mut limited =
测试时用内存切片和 Vec<u8>,实际运行时换成文件,函数都能工作。这不是为了炫泛型,而是把“复制字节”与“打开哪个文件”拆开,让错误边界和测试边界都更清楚。
File 本身不替你做用户态缓冲。一次写一小段、连续写十万次,可能带来大量底层调用。BufReader<R> 会从底层读取较大的块放在内存里,小读取优先从缓冲拿;BufWriter<W> 会先积累小写入,再批量推给底层目标。

缓冲适合“很多次、小块、同一个目标”的模式。如果你只调用一次 fs::write 写一个大块,外面再包 BufWriter 往往没有明显收益;目标本来就是内存中的 Vec<u8>,再加一层缓冲也通常只是多绕一步。缓冲区不是越大越快,过大的缓冲会多占内存,也可能延迟错误暴露和数据可见时间。
BufReader 逐行处理大文件BufRead::lines 很方便,每一项是 io::Result<String>。它会去掉行尾的换行标记,但仍要逐项处理可能发生的读取或 UTF-8 错误。
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::Path;
fn count_nonempty_lines(path: &Path) -> io::Result<usize> {
let file = File
lines 每行会生成一个新的 String。若你在意大量分配,可以复用一个缓冲并调用 read_line:
use std::io::{self, BufRead};
fn count_keyword(mut reader: impl BufRead, keyword: &str) -> io::Result<usize> {
let mut line = String::new();
let mut count = 0;
下面的交互实验会展示底层数据如何分块进入缓冲区,以及 read_line、fill_buf 和 consume 分别改变哪一层状态。调整块大小后再观察同一行被拆成多次底层读取时,调用方为什么仍能拿到完整记录。
处理的不是 UTF-8 文本时,不要硬用 lines。read_until(b'\n', &mut Vec<u8>) 会按字节分隔并保留分隔符,更适合编码未知的记录或二进制协议。更底层的 fill_buf 与 consume 能直接查看内部缓冲,减少复制,但它们要求你严格维护“已经消费多少字节”。如果 consume 的数量超过本次可见数据,那就是调用逻辑错了。多数业务代码先用 read_line、read_until,只有测量后确认复制是瓶颈,再下探这一层。
BufWriter 必须显式 flushuse std::fs::File;
use std::io::{self, BufWriter, Write};
fn write_report() -> io::Result<()> {
let file = File::create("report.txt")?;
let mut writer = BufWriter::
BufWriter 离开作用域时会尝试刷新,但析构阶段发生的刷新错误无法正常返回给你的函数,只能被忽略。磁盘空间恰好耗尽、设备突然断开,错误可能就在最后一次刷新时出现。依赖 drop 帮你“顺便写完”,等于把最关键的一张回执扔掉。显式 flush()? 才能让错误沿着函数返回。
缓冲还会改变“什么时候看见数据”。一个进程用 BufWriter 写入后,另一个进程立刻打开文件,可能暂时看不到仍留在用户态缓冲里的尾部。刷新后数据到了底层,但其他读取者是否立刻以你期望的方式观察到,还会受到操作系统缓存和并发访问影响。不要用“我这边的 writeln! 已经返回”推导“另一边肯定看见完整一行”。进程间通信需要明确协议、刷新时机,必要时还要锁或原子替换。
默认缓冲容量是实现细节,不要把具体数值写进业务假设。BufReader::with_capacity 和 BufWriter::with_capacity 允许指定容量,但调参前最好先测量。记录平均只有几十字节、调用极其频繁时,缓冲通常很有帮助;每次本来就写几 MiB 时,加大缓冲未必更快,反而可能多一次复制。性能问题如果只靠“缓冲越大越好”来解决,最后常常只是把内存占用变大。
标准输入输出的锁也属于这一思路。锁并没有复制新的终端,它只是让一段操作稳定地持有共享句柄。循环中持有锁可以减少反复加锁;长期持有又会妨碍同进程其他线程使用同一标准流。作用域应覆盖一批连续操作,然后及时释放。缓冲、锁和同步 I/O 都是在吞吐、延迟、占用之间做取舍,没有免费的组合。
不要同时用 BufReader 和底层文件句柄交替读取同一个流,也不要绕过 BufWriter 直接写它内部的文件。缓冲器可能已经预读了数据,或暂存了尚未下推的数据;绕开它会让你看到的文件位置和真实操作顺序变得难以推断。
磁盘上没有“字符串”,只有字节。fs::read_to_string 之所以能返回 String,是因为它除了读取,还要求全部内容构成有效 UTF-8。文件是 UTF-16、GBK、损坏的 UTF-8,都会让这条路径失败。标准库不会偷偷猜编码,这反而避免了同一份数据在不同机器上被猜出不同结果。

一种稳妥的分层方式是:先按字节读取,再在协议明确的地方解码。
use std::fs;
use std::io;
use std::path::Path;
fn read_utf8(path: &Path) -> io::Result<String> {
let bytes = fs::read(path)?;
String::from_utf8(bytes).map_err
如果产品明确允许替换坏字节,可以使用 String::from_utf8_lossy。它会在非法位置放入替代字符。这个选择适合“尽量显示日志”,却不适合“校验签名”或“解析精确配置”,因为替换意味着原始数据已经改变。不要把宽容模式当成默认正确。
文本里的索引也要保持警惕。UTF-8 字符可能占多个字节,SeekFrom::Start(10) 移动到的是第 10 个字节,不是第 10 个汉字。定位恰好落在一个多字节字符中间,再从那里 read_to_string,很可能得到编码错误。需要按字符定位的文本格式,通常要从已知字符边界开始解码,或者先建立字节偏移索引。
反过来,二进制格式不应借道字符串。文件魔数、长度、校验值都应该使用 [u8; N]、整数的 to_be_bytes / from_be_bytes 或小端版本明确处理。把任意二进制数据塞进 String,不是“省一步转换”,而是在强迫数据满足一个它原本没有承诺的编码规则。
UTF-8 文本仍然可能有不同的行尾。BufRead::lines 会处理 \n,并在常见的 \r\n 情况下去掉末尾的 \r;自己使用 read_line 时,换行符会保留在缓冲里。若你要原样复制文件,就不要先按行读取再统一 writeln!,因为这会重写行尾,还可能给最后一行补上原来没有的换行。
有些 UTF-8 文件开头带 BOM。它在解码后会成为开头的 Unicode 字符,不会自动消失。配置键本来是 name,实际却可能读成带 BOM 的 name,肉眼又很难看出差别。若文件格式允许 BOM,应只在文件开头按规则剥离;若格式不允许,就把它当格式错误报告。不要对每一行随手 trim,那会顺便吞掉本来有意义的空格。
编码错误与 I/O 错误最好在概念上分开。读取失败说明字节没能拿到;UTF-8 校验失败说明字节拿到了,却不符合文本协议。都可以暂时装进 io::Error 往上传,但错误信息要把这两件事说清楚。调试“文件不存在”和调试“第 37 个字节破坏了编码”,需要的是完全不同的下一步。
“目录加斜杠再加文件名”在自己电脑上很容易工作:
fn build_path_as_text(dir: &str) -> String {
format!("{dir}/config/settings.toml")
}问题是,路径不只是带分隔符的 UTF-8 字符串。不同平台有不同的前缀、根目录和分隔规则;操作系统还可能允许不能无损转成 UTF-8 的文件名。Rust 因此提供了 Path 和 PathBuf:
Path 是借用的路径视图,关系接近 str。PathBuf 拥有并可修改路径,关系接近 String。OsStr 和 OsString,不会强迫所有路径先变成 UTF-8。
use std::path::{Path, PathBuf};
fn report_path(base: &Path, year: u32) -> PathBuf {
base.join("reports")
.join(year.to_string())
.join("summary.txt")
}
fn main
join 和 push 会按路径组件构造,不需要你判断该用 / 还是 \。PathBuf::push 适合逐步修改已有路径,Path::join 适合从一个借用路径得到新路径。还要注意:追加一个绝对路径时,原来的基础路径可能被替换。因此,若后半段来自用户输入而你要求它只能是相对路径,应先检查 is_absolute,并根据安全边界检查组件,而不是默认 base.join(user_input) 一定留在 base 之下。
&strpath.to_str() 返回 Option<&str>,因为转换可能失败。path.display() 适合给人看,它会采用可显示的方式格式化,但这种显示不保证能无损还原原路径。to_string_lossy() 也可能用替代字符换掉无法表示的部分。它们适合日志,不适合拿来当路径身份标识再存回去。
读取扩展名时,得到的是 Option<&OsStr>:
use std::ffi::OsStr;
use std::path::Path;
fn is_rust_source(path: &Path) -> bool {
path.extension() == Some(OsStr::new("rs"))
}路径解析与访问文件系统也要分开看。parent、file_name、extension、components 主要分析路径的形状,不会证明文件真的存在。canonicalize 会访问文件系统,通常还会处理符号链接并返回规范化的绝对路径;它可能因路径不存在或权限不足而失败。普通的组件遍历不会替你解析所有 ..,也不会消除符号链接带来的跳转。
一个常用的函数签名是 P: AsRef<Path>:
use std::fs;
use std::io;
use std::path::Path;
fn load_bytes(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
fs::read(path)
}调用方可以传 &str、String、&Path 或 PathBuf,函数内部仍然围绕路径抽象工作。除非函数要长期持有或修改路径,否则没有必要一上来就要求调用方交出 PathBuf 的所有权。
假设导出目录是 exports,用户提交文件名 ../../settings.toml。直接 exports.join(name) 并不会阻止它离开导出目录。路径 API 帮你理解组件,但安全策略仍要自己写。一个保守的词法检查可以拒绝根目录、平台前缀和父目录组件:
use std::io;
use std::path::{Component, Path, PathBuf};
fn safe_join(base: &Path, relative: &Path) -> io::Result<PathBuf> {
let unsafe_component = relative.components().any(
这能挡住明显的绝对路径和 ..,却还不是完整沙箱。基础目录中的符号链接可能把访问引向外部,检查之后文件系统也可能被并发修改。若安全边界很高,应使用更贴近平台文件描述符的方案,并尽量让“验证与打开”成为不可分割的操作。普通导出工具至少要明白:join 负责构造路径,不负责授权。
路径比较也不等于文件身份比较。大小写规则由文件系统决定,两个不同写法可能通过符号链接指向同一对象,同一文本在不同挂载点上又可能是不同文件。Path 的词法比较适合排序和结构处理,不要拿它替代文件系统层面的身份判断。
fs::metadata 可以拿到文件类型、字节长度、权限和时间等信息。len() 对普通文件表示字节数,不是字符数;modified() 等时间字段在某些平台或文件系统上可能不可用,因此它本身也返回 Result。
use std::fs;
use std::io;
use std::path::Path;
fn describe(path: &Path) -> io::Result<()> {
let metadata = fs::metadata(path)?;
println!("路径:{}", path.display());
metadata 通常会跟随符号链接,查询链接目标;symlink_metadata 查询链接本身。你在清理工具、备份工具里必须先想清楚要操作哪一个,否则“删除这个链接”和“检查链接指向的目录”很容易被写成一团。
read_dir 有两层失败fs::read_dir(path) 本身可能失败,例如目录不存在或没有权限。即使成功得到迭代器,遍历中的每一个条目仍然是 io::Result<DirEntry>,因为目录在遍历期间可能变化,某个条目也可能单独读取失败。
use std::ffi::OsStr;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
fn rust_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut paths
目录遍历顺序没有稳定保证。测试、打包结果或用户界面需要固定顺序时,收集后自己排序。也别把 read_dir 当成递归遍历:它只列当前目录。递归时还要决定是否跟随符号链接、如何防止环、遇到无权限子目录是终止还是跳过。这些都属于你的产品语义,不是标准库能替你猜的答案。
元数据还有一个更隐蔽的边界:它描述的是查询当时的状态。你先调用 path.exists(),下一行再打开,文件可能已经被另一个进程删除或替换。你先确认“是普通文件”,之后也不代表它永远不变。真正想读,就直接尝试打开并处理返回值;真正想创建新文件,就用 create_new 表达“必须是新文件”。检查可以改善提示,不能替代实际操作的错误处理。
批量扫描目录时,还要先决定失败策略。备份程序遇到一个不可读条目,通常应该让整次备份失败或至少明确记录缺口;桌面搜索的预览列表则可能跳过一个瞬时消失的文件,继续展示其他结果。两种策略都可以,但不能用一个随手的 .flatten() 把差异抹掉。若选择跳过,应把路径和错误写到诊断通道,并让最终结果告诉调用方“本次并不完整”。
元数据大小也不适合单独当成可信分配依据。你查询到文件长 1 MiB,真正读取前它可能增长;也可能是特殊文件,长度信息不能代表一次读取会得到多少。可以用长度做预分配或早期拒绝,但读取过程仍要设置上限并检查实际返回。这又是同一个主题:观察值适合优化,操作结果才是事实。
Path::exists、is_file 之类的便利判断会把部分访问错误折叠成 false。如果你必须区分“不存在”和“没有权限查看”,请调用返回 Result 的元数据或打开操作。
io::Result 不只是给编译器交作业io::Result<T> 只是 Result<T, io::Error> 的简写。成功分支装着值,失败分支装着 I/O 错误。io::Error 可能来自操作系统,也可能由程序自己构造;kind() 给出相对通用的错误类别,raw_os_error() 则可能带有平台相关的原始错误码。
常见类别包括:
NotFound:路径或其中某个目录不存在。PermissionDenied:权限不允许当前操作。AlreadyExists:要求新建,但目标已经存在。InvalidInput:传给操作的参数或选项组合不合法。InvalidData:读到的内容不满足你期望的数据格式,例如文本不是 UTF-8。UnexpectedEof:需要更多字节,输入却提前结束。Interrupted:操作被中断,底层操作通常可以重试。WouldBlock:当前操作若继续会阻塞,常见于非阻塞句柄。
ErrorKind 是给跨平台分支用的分类,不是所有系统错误的一比一翻译。写 match 时保留兜底分支,不要假设你列出的几种就是世界上全部可能。
配置文件不存在时采用默认值,可能很合理;配置文件存在但权限被拒绝时也静默使用默认值,通常会掩盖部署问题。
use std::fs;
use std::io::{self, ErrorKind};
use std::path::Path;
fn read_theme(path: &Path) -> io::Result<String> {
match fs::read_to_string(path) {
Ok(text) => Ok
这就是错误边界:函数在这里有足够业务信息,知道“不存在”代表第一次启动,因此可以恢复;其他失败仍交给上层。若你写成 unwrap_or_default(),权限错误、磁盘错误、非法 UTF-8 都会被揉成一个空字符串,现场线索也就没了。
? 是传播,不是处理use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
fn load_config(path: &Path) -> io::Result<String> {
let mut file = File::open
每个 ? 都在做同一件事:成功就取出里面的值继续;失败就提前返回,并在需要时通过 From 转换错误类型。它没有记录日志,没有改成默认值,也没有神奇地修复文件。传播的价值是把决定权交给上下文更多的调用方。
如果你在返回 () 的函数里直接使用 ?,编译器会拒绝,因为提前返回的错误没有地方放。把入口写成 fn main() -> io::Result<()> 是小工具里很实用的选择:
use std::fs;
use std::io;
fn main() -> io::Result<()> {
let text = fs::read_to_string("input.txt")?;
println!("{} 字节", text.len());
Ok(())
}编译器在这里不是要求你“多写个返回类型”这么简单。它是在确认失败到底能不能穿过当前函数边界。等应用变大后,底层函数继续返回结构化错误,最外层入口再决定如何写 stderr、使用哪个退出码,通常比每一层都打印一次更干净。否则同一个错误沿调用链被打印三遍,用户只会以为程序坏了三次。
为错误补充上下文时,也要保留原本有用的信息。标准库范围内可以构造新的 io::Error,说明正在操作哪个对象:
use std::fs;
use std::io;
use std::path::Path;
fn read_named(path: &Path) -> io::Result<String> {
fs::read_to_string(path).map_err(|error| {
io::Error::
这样用户能看到失败发生在哪条路径,同时调用方仍可按 kind() 做大类判断。更复杂的应用通常会定义自己的错误类型,把“解析失败”和“I/O 失败”分开保存;本章先把 io::Result 的边界掌握牢,就已经能避开大量无声失败。
看到错误就重试听上去很稳,实际很容易制造更糟的故障。Interrupted 一般表示底层调用被信号等事件打断,立即重试同一操作通常合理,高层的 read_exact、write_all 等方法已经处理了常见情况。WouldBlock 出现在非阻塞资源上,正确做法通常是等待资源就绪,而不是占满 CPU 疯狂循环。PermissionDenied 连续重试一百次也不会凭空获得权限。
对可能恢复的设备或临时文件系统错误做重试时,要考虑次数、间隔、幂等性和部分进度。一次写操作可能已经写入前半段才在后续调用失败;粗暴地从头重试,会重复数据。追加日志尤其容易中招:调用方不知道上一条记录写到了哪一步,又把整条消息追加一次。只有协议能识别记录边界、序号或事务状态时,重试才真正安全。
错误消息也不应用来做程序分支。操作系统文字可能本地化,版本变化后措辞也会不同。能按 ErrorKind 判断就按类别判断,需要平台细节时再检查原始错误码。给人看的 Display 信息负责解释,给程序看的结构化字段负责决策,别让这两条线缠在一起。
Seek 移动的是字节位置流式读取像播放磁带:从当前位置继续向后。Seek 则允许改变下一次读写的字节位置。SeekFrom::Start(n) 从开头定位,Current(delta) 相对当前位置移动,End(delta) 相对末尾移动。相对偏移可以是负数,但最终位置不能落到文件开始之前。

下面的例子写入一个简单文件头,然后回头补上正文长度:
use std::fs::OpenOptions;
use std::io::{self, Seek, SeekFrom, Write};
fn write_message(path: &str, body: &[u8]) -> io::Result<()> {
let mut file = OpenOptions::new
stream_position() 可以查询当前位置,rewind() 可以回到开头。记住位置属于流的状态:你读了 100 字节,位置就向后走了 100;接着再 read_to_end,读到的只是剩余部分。想重读就要重新打开,或者先定位。
下面的交互实验允许你从文件开头、当前位置和末尾发起定位,并立即看到下一次读写覆盖了哪些字节。先用中文文本尝试按“字符序号”定位,再对照字节游标,就能直观看到两者为什么不能混用。
Seek 很适合固定大小的文件头、索引、媒体容器和大型数据文件的局部访问。它不适合假装文本可以按字符编号随机跳转,因为偏移单位始终是字节。追加模式也有特殊语义:即使你调用 seek,后续写入仍会被放到文件末尾;若同一句柄还允许读取,写入后的位置也可能影响接下来的读取。把追加与随机覆盖混在一个句柄里,通常只会让自己难受。
BufReader 可能已经从底层文件预读了比你实际消费更多的字节,所以底层文件句柄的位置不一定等于你眼中的逻辑位置。通过 BufReader 自己的 seek 操作,它会协调并丢弃必要的缓冲数据;绕过它直接定位底层文件,则会破坏这种协调。
BufWriter 在定位前需要先处理尚未写出的缓冲,否则新位置与旧缓冲的写入顺序会冲突。实际代码里,随机小读写与大批顺序写往往值得拆成不同阶段:先通过缓冲器完成顺序部分并 flush,再取回或重新打开文件做定位。清晰的阶段边界比在同一个对象上来回切换更不容易出错。
随机访问还会碰到外部修改。你根据旧元数据算出偏移,另一个进程却在读取前截断文件,read_exact 仍然可能得到 UnexpectedEof。持有文件句柄不代表别人不能改内容,&File 也不意味着文件世界静止了。需要一致快照时,要用文件锁、应用协议或存储系统提供的事务能力;Seek 只负责移动位置,不负责冻结时间。
Rust 的 File 离开作用域时会自动关闭,通常不需要手动调用 close。所有权在这里帮了大忙:句柄归某个值所有,值被销毁,资源随之释放。但“会自动关闭”不等于“关闭时的所有错误都会送回你的业务代码”。析构不能正常返回 Result,所以关闭阶段发现的错误可能被忽略。
写文件时,可以把“完成”分成几层:
write 返回成功:本次有一部分字节被目标接受,未必是全部。write_all 返回成功:传入切片的全部字节都已经交给写入器;如果写入器有缓冲,它们可能还在内存缓冲里。flush 返回成功:写入器自己的中间缓冲已经继续推向底层目标。File::sync_data 返回成功:请求把文件内容同步到文件系统,可能不要求同步全部元数据。File::sync_all 返回成功:请求同步文件内容与相关元数据,代价通常更高。这几层解决的不是同一个问题。交互提示只需要及时 flush;临时统计日志可能允许操作系统晚些落盘;数据库提交记录或关键配置则可能需要更强的持久化保证。每写一行日志就 sync_all 会把吞吐和延迟拖得很难看,不做同步又意味着机器突然掉电时可能丢失最近写入。没有“永远正确”的统一选择,只有你愿意承受哪种失败。
下面这个保存流程先写同目录临时文件,显式刷新并同步,再重命名:
use std::fs::{self, File};
use std::io::{self, BufWriter, Write};
use std::path::Path;
fn save_report(path: &Path, content: &[u8]) -> io::Result
它比直接截断目标文件再写安全:若写临时文件失败,旧目标通常还在。drop(writer) 放在重命名前,是为了明确释放文件句柄,这在某些平台的文件操作规则下尤其重要。
但不要给这段代码贴上“绝对不会丢数据”的标签。目标已存在时,重命名覆盖语义有平台差异;临时文件名可能冲突;多个写入者会互相竞争;要把目录项本身也纳入掉电一致性,还可能需要平台相关处理。真正的事务式存储比一个 rename 复杂。这个模式提供的是更好的失败边界,不是魔法护盾。
资源通常在离开作用域时释放。若后续操作要求句柄已经关闭,可以用小作用域或显式 drop 表达时间点:
use std::fs::{self, File};
use std::io::{self, Write};
fn replace_file() -> io::Result<()> {
{
let mut file = File::create("next.txt")?;
file.write_all
这不是在和垃圾回收器斗智斗勇。Rust 的资源释放时间通常由作用域决定,你可以把这个时间点写进结构里。编译器还会阻止资源被错误地移动后继续使用。它确实严格,但这种严格对文件句柄、锁和缓冲器尤其值钱。
一个编译缓存丢了可以重建,用户刚保存的文档丢了会非常痛,转账账本丢一条则可能不可接受。三者若都采用“每写一行就 sync_all”,性能会浪费在错误的地方;若都只等 drop,风险又被压到用户身上。
常见折中是分批写入:普通日志先进入 BufWriter,达到条数、时间或容量阈值后 flush;审计记录在业务确认点额外同步;可重建缓存只要求写入完整,失败就删除重建。批量能摊薄同步成本,但会扩大故障时可能丢失的窗口。阈值不是越大越专业,它应该来自“最多允许丢多少”和“能接受多高延迟”。
sync_data 可能比 sync_all 少同步一部分元数据,但具体收益与平台实现有关。创建新文件、改变长度、替换目录项时,元数据和目录持久化可能就是恢复链条的一部分。跨平台程序如果真的承诺强一致保存,需要针对支持的平台验证崩溃恢复,而不是只在正常退出时读回一次文件就宣布成功。
还要把错误报告放在同步之后。程序先对用户说“保存成功”,再调用 flush 或 sync_all,后者失败时就只能推翻刚才的承诺。更自然的顺序是:写入、刷新、按需求同步、完成替换,全部成功后再打印成功消息。I/O 的用户体验很大一部分,就是别过早报喜。
write_all 保证的是把整段数据交给当前写入器;flush 保证的是推出该写入器的中间缓冲。它们都不能单独等价为“断电后数据一定还在”。需要持久化语义时,必须根据风险评估 sync_data、sync_all、临时文件替换及更高层协议。
我们把前面的能力组合成一个小工具:读取 UTF-8 文本,逐行统计,结果写入任意 Write。文件打开留在最外层,核心逻辑只依赖 BufRead 和 Write,测试就能完全在内存里完成。
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::Path;
#[derive(Debug, PartialEq)]
struct Stats {
lines: usize,
nonempty
这段程序的层次很清楚:
main 决定入口错误如何结束进程,并把进度写到标准错误。run 决定使用哪些文件,负责把具体文件包装成缓冲读取器和写入器。analyze 只关心“按行读”和“写统计结果”,不关心数据来自磁盘还是内存。Result,以 Ok(0) 判断 EOF。flush,因此 BufWriter 的刷新失败会返回。Cursor 和 Vec<u8>,不需要在测试目录制造临时文件。这里统计的是 UTF-8 字节数,不是 Unicode 字符数。名字故意写成 utf8_bytes,就是为了让单位留在接口上。若你要统计字符,可以对每一行调用 line.chars().count();若要统计用户眼中的字形,还会涉及更高层的 Unicode 分段规则,不能拿字节数冒充。
你也可以继续强化这个工具。比如先用 metadata 拒绝超过某个上限的普通文件,再在真正读取时使用 take 保留第二道限制;输出关键报告时采用临时文件保存流程;目录批处理时收集并排序 PathBuf。每加一层,都应该对应一个真实风险,而不是把所有 I/O 程序都堆成堡垒。
I/O 优化最容易掉进“听说某个 API 更快”的坑。真正决定选择的,通常是数据大小、调用次数、等待时间和内存上限。
一个 20 KiB 配置文件,启动时只读一次,fs::read_to_string 简单也足够快。为了避免这 20 KiB 分配,改成复杂的分块状态机,代码增加了错误路径,用户却几乎感觉不到区别。反过来,一个 40 GiB 日志如果仍用 read_to_string,问题已经不是“稍微慢一点”,而是内存根本容不下,甚至在开始处理第一行前就要等完整读取。此时 BufReader 的流式处理是资源边界,不只是性能技巧。
可以按下面几类成本思考:
flush,批量越大,错误出现得越晚。sync_all 往往比普通写入贵得多,频繁调用会把吞吐换成更小的数据丢失窗口。不要只测“循环主体用了多少毫秒”。读取测试若反复命中操作系统缓存,测到的可能是内存速度,不是冷启动磁盘速度;写入测试若没 flush,可能只测到把数据复制进缓冲的速度;不包含同步的测试也不能回答持久化延迟。基准场景要和你真正承诺的完成层级一致。
容量也不该靠猜。先用默认 BufReader 或 BufWriter,记录真实数据规模与耗时,再比较不同容量。若瓶颈在解析、压缩或终端渲染,继续放大 I/O 缓冲不会解决问题。尤其 println! 输出海量调试信息时,慢的可能是终端,不是文件读取;把正常结果写入缓冲文件、降低诊断频率,往往比折腾读取器更有效。
同步与异步也不是“旧方案”和“新方案”的关系。异步 I/O 适合大量操作经常等待、等待期间还有别的任务可推进的场景,它会带来运行时、取消、生命周期和并发控制等新问题。一个按顺序处理本地文件的命令行工具,用同步 I/O 往往更容易验证完整性。先确认阻塞真的造成了并发或响应问题,再引入异步;否则你只是把一条直路改成了立交桥。
最后给性能设上限,不只追求平均值。不受信任的输入要限制单行长度、总字节数和文件数量;目录遍历要考虑极端多条目;输出要防止单个错误生成无穷日志。稳定的 I/O 程序不是在理想文件上跑得最快,而是在糟糕输入下仍然知道何时停止、怎样报告。
read 成功了,为什么文件只有前 4 KiB因为成功只代表这次读取成功。检查返回的 n,循环到 Ok(0),或者直接使用 read_to_end。固定长度则用 read_exact。API 把数量返回给你,就是在阻止“缓冲区长度等于实际读取长度”这个误会。
write 没报错,为什么内容少了一截因为 write 可以短写。必须完整写入时使用 write_all。如果外面包了 BufWriter,结束前再显式 flush。如果你关心断电后的持久化,再评估 sync_data 或 sync_all。
没有换行的提示可能还在标准输出缓冲里。读取用户输入前调用 io::stdout().flush()?。如果提示本身算诊断,也可以考虑写到标准错误,但仍不要把“标准错误一定无缓冲”当成跨环境契约来依赖。
read_to_string 为什么把一个真实存在的文件判为失败它失败的不一定是“读取”,也可能是“这些字节不是有效 UTF-8”。先用 fs::read 获取原始字节,确认文件编码,再选择严格解码、替换非法字节或使用明确的其他编码方案。
display 和 to_string_lossy 面向显示,可能发生有损转换。业务内部保留 PathBuf 或 OsString,不要把日志里的显示文本当成可逆序列化格式。
exists 再打开,怎么还是 NotFound文件系统会变化。检查和使用之间,目标可能被删除、替换,权限也可能改变。把打开操作的 Result 当成最终事实,检查只能提供辅助信息。创建唯一文件用 create_new,不要自己拼“先查再建”。
BufWriter 离开作用域了,为什么没有收到失败析构时不能把刷新错误正常返回给调用方,所以错误可能被忽略。关键写入总是在正常控制流里调用 flush()?,必要时再同步底层 File。
seek 到第十个位置,为什么中文乱码因为它移动到第十个字节,而不是第十个字符。先确认目标偏移是有效的 UTF-8 边界,或者把随机访问设计在二进制记录、固定头部、预先构建的字节索引上。
写一个函数 read_header(path: &Path) -> io::Result<[u8; 8]>。要求文件少于 8 字节时返回错误,文件再大也只读取前 8 字节。
写一个函数读取 UTF-8 配置。文件不存在时返回 mode=safe,其他错误原样传播。
遍历给定目录,只保留普通 .txt 文件,按路径排序后写到任意 Write,并把所有 I/O 失败交给调用方。
学完这一章,你不需要把每个 I/O API 都背下来。更有用的是形成一套检查顺序:数据是文本还是字节,操作能否只完成一部分,当前层是否有资格恢复错误,缓冲什么时候必须刷新,路径有没有被误当成普通字符串,成功究竟要到“写入器接受”还是“请求同步到磁盘”。这些问题一旦问对,编译器就从拦路的人变成了那个严格但负责的搭档。
这里选择 read_exact,因为“正好 8 字节”就是格式契约。单次 read 可能短读,不能满足这个要求。
关键不在 match 的写法,而在错误语义:缺失是可以恢复的初始状态,权限拒绝和非法 UTF-8 不是同一回事。
这个练习同时碰到了三层边界:打开目录可能失败,某个条目可能失败,写结果也可能失败。全部保留为 io::Result,最外层才能决定是终止、跳过还是记录诊断。