📖 工程视角:台架试验的数据为什么必须落盘 设备台架做一次试验要连续采集成千上万条读数。工程师从不在「程序还活着」时就宣布数据安全:程序可能中途崩溃、电脑可能被人碰掉电源、系统可能自动更新重启——任何一件都会让内存里的数据归零。所以采集程序的铁律是边采边写盘,每条读数尽快落到磁盘。凡是「下次还要用」的数据,一律当它随时会丢,尽早持久化——这是用本章第一小节的原理换来的工程习惯。
🎯 本章学习目标
- 理解「内存易失」与「磁盘持久」的区别,明白为什么需要文件。
- 掌握文件的「打开 → 读写 → 关闭」完整流程。
- 理解「异常」是什么,以及它和「程序错误」的关系。
- 掌握
try / except兜住异常的写法。 - 理解「输入输出(I/O)」在程序里的角色。
11.1内存的短暂:为什么需要持久化
回到第 4 章:变量存在内存(RAM)里。内存有个根本特点——易失性(volatile):它靠电力维持,一断电(或程序一结束),里面的数据就全部消失。
这带来一个现实问题:你辛辛苦苦用程序算出的一堆结果,程序一关就没了。这显然不行——记账本、文章、游戏存档,都得「长期保存」。
解决办法就是持久化(persistence):把内存里的数据,写到磁盘(硬盘)上。磁盘的特点是非易失——断电了数据也还在。
核心思想 变量存内存(快、会丢),文件存磁盘(慢、能留)。凡是需要「下次还能用」的数据,都要写成文件持久化。这正是你手机里 App 的「设置」「聊天记录」「游戏进度」能一直保存的原因。
内存为什么会「失忆」:电容里的电
「断电就丢」不是内存的毛病,而是它的工作原理使然。主流内存(DRAM)的每个存储位,本质是一枚微型电容:充电代表 1,放电代表 0。电容会漏电,所以内存芯片必须每隔几毫秒就把所有电容刷新一遍(重新充电),像一群得不停续水的小水杯;一旦断电,没有人再续水,杯里的电在瞬间漏光,0 和 1 的记忆随之蒸发。磁盘则不同——它靠介质的物理状态记数据(磁性方向等),写好之后不依赖电力维持,所以断电也不丢。一句话:内存用电的「现状」记数据,磁盘用介质的「烙印」记数据——现状随电而去,烙印长存。
📐 例题 1 算一次,存一次,换个程序还能用
某试验记录了 3 个温度读数 [12.5, 13.0, 11.8](单位 ℃)。写两个程序:程序 A 算出平均温度并存入文件 today.txt;程序 B 在程序 A 结束后单独运行,从文件里读回这个平均值。要求最终屏幕打印出 12.43。
思路 平均 = 总和 ÷ 个数;除法结果是一长串小数,用 round(x, 2) 保留两位(第 5 章讲过浮点显示);写入文件前要先用 str() 把数字转成字符串——write 只收字符串。
解
# 程序 A:计算并保存
temps = [12.5, 13.0, 11.8]
total = 0
for t in temps:
total = total + t
avg = round(total / len(temps), 2) # 12.43
f = open("today.txt", "w")
f.write(str(avg)) # 数字先转字符串再写入
f.close()
# 程序 B:另一个程序,重新读回
f = open("today.txt", "r")
print("程序A存的平均温度:", f.read()) # 程序A存的平均温度: 12.43
f.close()
核算:12.5 + 13.0 + 11.8 = 37.3,37.3 ÷ 3 = 12.433…,round 保留两位得 12.43。程序 A 结束时变量 avg 已随内存消失,但文件里的字符 12.43 留在磁盘上——程序 B 什么都不用重算,直接读。
答:程序 A 存入 12.43;程序 B 读出 12.43。 ⚠ 易错点:① write(12.43) 直接写数字会报 TypeError——write 只接受字符串,必须 str(avg);② 程序 B 读回的 12.43 是字符串,要参与计算还得用 float() 转回数字(第 12 章的项目会专门处理这件事:读回的每一行都要转回数字);③ 用 "w" 打开已存在的文件会清空旧内容——本章末尾思考题第 2 题还会回来抓这个坑。
辨析:持久化保存的是「数据」,不是「程序状态」 写入文件的是数据本身(那串字符),不是「程序运行到哪一行、变量都装了什么」这些运行状态。程序 B 读回 12.43 后,它并不知道自己是谁算出来的——想恢复更复杂的状态,就得把状态翻译成数据再存。这个「翻译」问题在真实软件里有一门专门的手艺(序列化),你只需先记住:文件里躺着的,永远只是字节与字符。
11.2文件:磁盘上的「持久容器」
那「文件」到底是什么?
文件(file)是磁盘上一块被命名、能读写的数据区域。一个文件有:
- 名字:如
notes.txt,用来找到它。 - 内容:一串数据(文字、数字、图片的字节……)。
- 位置:在磁盘的哪个路径下。
从程序的角度看,文件就像一个「放在磁盘上的大盒子」:你可以往里面写数据,也可以从里面读数据。写 = 存,读 = 取。
📖 文件名的「后缀」是什么? notes.txt 里的 .txt 叫扩展名,它只是提示「这个文件里存的是什么类型的数据」——.txt 是纯文本,.jpg 是图片,.mp3 是音乐。实际上,文件内容本质都是一串字节(0/1),扩展名只是告诉程序「该用什么方式解读这串字节」。
把「位置」说清楚:路径与目录树
文件三要素里的「位置」,在真实电脑上是以路径(path)表达的。磁盘上的文件夹(目录)一层套一层,像一棵倒挂的树:顶层是盘符或根目录,往下分叉出各级文件夹,树叶才是文件。data/2024/temps.txt 就是一条路径——顺着 data 进 2024 文件夹,找到 temps.txt。路径有两种写法:绝对路径从根写起(如 Windows 的 C:\data\temps.txt、Linux/Mac 的 /data/temps.txt),像寄信写全地址;相对路径从「程序当前所在的文件夹」写起,像在自家小区里说「三号楼」——省事,但换了出发点就得重说。前面例题里直接写 "today.txt",就是最短的相对路径:文件与程序在同一层。
回到文件名本身,再看一眼「名字 + 扩展名」的边界:扩展名以最后一个点为准。一个文件名里可以有好几个点,真正标记类型的是最后那个;名字里也可以一个点都没有,那就是「无扩展名」文件。这条规则完全可以用已学的字符串操作算出来。
📐 例题 2 找出扩展名与主体名
给定文件名 "archive.tar.gz":① 它的扩展名是什么?② 去掉扩展名的「主体」是什么?③ 换成 "notes" 呢?用字符串方法 rfind 解决——name.rfind(".") 返回最后一个点号的下标,找不到返回 -1。
思路 用字符串方法 rfind(r 即 reverse,反向找):它返回最后一个点号的下标,找不到返回 -1。找到点号下标 i 后,用「切片」取段——方括号里写 名字[a:b],表示从下标 a 取到下标 b 之前(含头不含尾):name[i+1:] 取点号之后的扩展名(冒号后空着=取到末尾),name[:i] 取点号之前的主体(冒号前空着=从头取)。切片规则只需记一句:含头不含尾。
解
name = "archive.tar.gz"
i = name.rfind(".") # 最后一个点的下标
print(i) # 11
print(name[i+1:]) # gz ← 扩展名
print(name[:i]) # archive.tar ← 主体
print(len(name)) # 14
name2 = "notes"
print(name2.rfind(".")) # -1 → 没有点,无扩展名
下标逐个数:archive 占 0~6,第 7 位是点,tar 占 8~10,第 11 位是最后一个点,gz 占 12~13,总长 14。所以扩展名是 gz,主体是 archive.tar——它自己仍含一个点,这完全合法。
答:① 扩展名 gz;② 主体 archive.tar;③ notes 中找不到点号(rfind 返回 -1),是无扩展名文件。 ⚠ 易错点:① 别用 find 找第一个点——那会把扩展名算成 tar.gz;② name[:i] 切到点号之前(不含点号本身),name[i+1:] 从点号之后取——如果错写成 name[i:] 会把点带进扩展名;③ rfind 返回 -1 是「没找到」的信号,用之前先检查,别直接拿来当切片下标。
📖 工程视角:数据目录要按「时间 + 主题」分层 试验数据积累几年后,如果几百个文件全堆在一个文件夹里,找一条记录如同大海捞针。工程上通行按日期分层组织:data/2024/05/temps.txt、data/2024/06/temps.txt……目录树天然按路径排序,按时间找数据一步到位。程序生成文件名时也常嵌入日期(如 result_20240512.txt),让文件自己「报户口」——这些习惯都建立在本节对路径的理解上。
辨析与衔接 扩展名是「约定」不是「保证」:把图片改名为 .txt 它依然是图片数据,只是双击时电脑会尝试用记事本打开它。程序要可靠地判断文件类型,读内容头几个字节(文件头)比看后缀更可信——这个话题以后在网络与安全课还会回来。眼下记住:扩展名是给人看的标签、给程序的第一层提示,仅此而已。
11.3打开、写入、读取、关闭
在 Python 里操作文件,有固定的四步套路:打开 → 操作(写/读)→ 关闭。
写入:把数据存到文件
f = open("notes.txt", "w") # 1. 打开文件,w = 写入模式
f.write("你好,世界") # 2. 写入内容
f.close() # 3. 关闭文件
逐行拆解:
open("notes.txt", "w"):打开(或创建)名为 notes.txt 的文件,"w"表示「写入模式」(write)。它返回一个「文件对象」f,你可以把它想象成「文件的操作手柄」。f.write("你好,世界"):通过手柄 f,把这段文字写进文件。f.close():关闭文件,释放这个手柄。
读取:把文件里的数据拿出来
f = open("notes.txt", "r") # r = 读取模式
content = f.read() # 读出全部内容
print(content) # 输出:你好,世界
f.close()
几种常见的模式:
| 模式 | 含义 | 注意 |
|---|---|---|
"r" | 读取 read | 文件必须存在,否则报错 |
"w" | 写入 write | 会清空原内容再写 |
"a" | 追加 append | 在末尾接着写,不清空 |
要点 用完一定要 close()。不关闭的话,写入的数据可能还「堵」在缓冲区没真正落到磁盘,而且会占用系统资源。养成「打开必关闭」的习惯。
被 close 遗忘的数据:缓冲区
前文说过「不关闭的话,写入的数据可能还堵在缓冲区」——现在把这句话拆开讲透。每次 f.write(...),数据并不是立刻写到磁盘,而是先存进内存里的一块「候车区」(缓冲区,buffer);攒够一批或文件关闭时,才整批真正落盘。为什么这样设计?磁盘 I/O 极慢(11.6 节会给这笔慢账算出数量级),一条一条零散地写会慢到无法忍受;攒成整批一次写,是拿「极少数慢操作」换「无数快操作」。但缓冲区也有代价:数据在没落盘前只存在于内存里,此时断电,它和普通变量一样会蒸发。这就是「写完没 close,文件是空的/少了半截」事故的根源。
于是有一个直击要害的改进:能不能让 Python 自己保证 close?能——这就是文件操作的标准姿势 with open:
with open("log.txt", "a") as f:
f.write("新一条记录\n")
# 离开 with 这两行缩进的瞬间,自动 close,缓冲区自动落盘
with ... as f 读作「打开文件并把手柄记作 f,在本块内负责」。它布置了两道防线:第一道,代码正常跑完,离开缩进块时自动关闭;第二道,块内哪怕中途抛出异常,退出前也会先关闭文件再让异常冒出去。相比之下,手写 open/close 有个致命盲区——f = open(...) 之后、f.close() 之前若出了异常,程序直接跳走,close 永远执行不到,手柄就漏在外面了。with 把「善后」从「靠人自觉」变成「语言机制保证」,这正是专业 Python 代码几乎都这样写文件的原因。
📐 例题 3 写入 → 追加 → 读回:完整生命周期
用 with open 完成三步:① 以 w 模式写入 start;② 以 a 模式追加 temp=93.1;③ 再以 a 模式追加 stop。最后一次性读出全部内容并说出共几行。每行末尾都要有换行。
思路 三步打开三次(w 一次、a 两次),with 块结束即关闭;「换行」要自己写——write 会原样写入字符串,不会自动在末尾加 \n。
解
with open("run.log", "w") as f:
f.write("start\n") # w:新建/清空,写入第 1 行
with open("run.log", "a") as f:
f.write("temp=93.1\n") # a:接着写第 2 行
with open("run.log", "a") as f:
f.write("stop\n") # a:接着写第 3 行
with open("run.log", "r") as f:
content = f.read()
print(content)
print("共", content.count("\n"), "行")
输出:
start
temp=93.1
stop
共 3 行
答:文件内容为 start、temp=93.1、stop 三行(末尾各带换行),count 统计到 3 个换行符。 ⚠ 易错点:① 三个 \n 少写任何一个,文件就会变成「starttemp=93.1stop」挤成一团;② 第一次用 w 打开是「归零重来」——日志程序每天重开一个新文件用 w,当天之内续写用 a,用反了要么丢历史、要么永远只有一条;③ content.count("\n") 数的是换行符个数,恰好等于行数(末行也带 \n 的前提下),这是文件处理里常用的简便计数。
📖 工程视角:一次忘了 close,两天白测 某自动化测试脚本要连续跑 48 小时,每轮测试打开一个配置文件读取参数。脚本用裸 open 不关——单看每轮没事,但每个没还的手柄都占着系统资源;几十小时、几万轮之后,操作系统分配的手柄数被耗尽,程序在第 40 小时突然打不开任何文件,整套测试作废。复盘教训只有一条:手柄是借来的,必须还。改用 with open 后,无论中途出什么异常都自动归还,再没出过这类事故。新手常以为「close 忘了也就是浪费一点内存」,真实系统里它是会爆炸的定时炸弹。
辨析:f.write 与 print 的两笔账 print("93.1") 往屏幕输出时自动换行;f.write("93.1") 往文件写时什么都不自动加——不换行、不加空格,字符串是什么就落盘什么。迁移经验:把 print 的习惯带到 write,是新手文件里「所有内容连成一行」的头号原因;反过来记住「write 收到什么就写什么」,你就能完全掌控文件的样子。另外,write 只接受字符串,数字要 str(x) 转一道(例题 1 踩过)。
辨析:文本模式 vs 二进制模式 open() 的默认模式是「文本模式」:你交给它字符串,它按编码约定(本章统一用 UTF-8,正好接上第 5 章)把字符翻译成字节落盘,读回来时再自动翻译回字符串——中间藏着一层翻译。模式字符串里加个 b(如 "wb"、"rb")就切到「二进制模式」:不翻译,字节进字节出,write 只接受 b"..." 字节串,read 吐回来的也是字节串。判据一句话:给人类读的文字用文本模式,给程序读的数据用二进制模式——后者涵盖图片、音视频、压缩包,以及一切「格式自定义」的工程数据文件。下面的对照实验值得亲手跑一遍:同样写「适航」二字,两个模式落盘的字节完全一样(文本模式只是替你多做了一次 encode),但读回来的形态不同——字符串 vs 字节串。
with open("t.txt", "w", encoding="utf-8") as f: # 文本模式
f.write("适航")
with open("b.bin", "wb") as f: # 二进制模式
f.write("适航".encode("utf-8"))
with open("t.txt") as f:
print(f.read()) # 适航 —— 读回字符串
with open("b.bin", "rb") as f:
print(f.read()) # b'\xe9\x80\x82\xe8\x88\xaa' —— 读回字节串
with open("t.txt", "rb") as f:
print(len(f.read())) # 6:两个文件落盘的字节一模一样
11.4异常:程序出错时怎么办
程序运行中会「出岔子」:读一个不存在的文件、除以 0、把字符串和数字相加……这些运行时错误,Python 叫 异常(exception)。
print(1 / 0) # 触发异常:ZeroDivisionError
open("不存在.txt", "r") # 触发异常:FileNotFoundError
异常一旦触发,如果没人处理,程序就会立刻中断、崩溃,后面的代码都不执行。对于一个小脚本,崩了也就崩了;但对于一个正经的程序,随便一个异常就崩溃,体验极差——用户可能会因为「文件不存在」这种小事,看到程序直接闪退。
所以我们需要一种机制:预先「兜住」可能出现的异常,出错时不让程序崩溃,而是走另一条「补救」的路。这就是异常处理。
核心思想 异常不是「坏东西」,而是程序告诉你「这里出问题了」的信号。好的程序会预期到可能出错,并提前做好应对——这叫「健壮性」(robust)。健壮的程序,遇到意外也不崩,而是优雅地处理。
读懂 Traceback:报错不可怕,不会读才可怕
异常真发生时,Python 会打印一段「天书」,例如:
Traceback (most recent call last):
File "demo.py", line 3, in <module>
x = int("abc")
ValueError: invalid literal for int() with base 10: 'abc'
这段天书叫 Traceback(回溯),阅读口诀是「从最后一行读起,再向上找位置」:最后一行先给你结论——异常类型(ValueError)和一句原因(invalid literal:没法把 abc 当数字转成整数);往上找最后一处 File "…", line N,就是出错的文件与行号;如果上面还有更多 File 行,说明错误是从更深的调用里层层传上来的。三步读完,你就知道「发生了什么、坏在哪」。把报错全文读懂再动手,是修程序的第一习惯。
底层直觉:异常是一次「计划外跳转」
CPU 逐行执行程序,靠一个叫程序计数器的「书签」记住「下一步执行哪一行」。正常情况下书签一行行往后挪;异常触发的瞬间,本质是这张书签被猛地抽走,改插到「能处理这个错误的地方」——一次不由分说的跳转。第 7 章讲过,if 分支是「你计划好的跳转」;异常则是「出错引发的计划外跳转」。如果代码里没人接手(没有 except),这张书签会一路往上交给 Python 本身:Python 的处理方式就是打印 Traceback 然后终止程序——这就是「没人兜住就崩溃」的底层含义。下一节要学的 try/except,就是在跳转的落点处安排一个接手人,把计划外跳转驯化成可控流程——下一节的图 11-4 会把这记跳转画出来。
📐 例题 4 给四行「事故代码」验明正身
下面四行代码各会触发哪种异常?先自己判断,再说出每种异常的「出错类别」。
解
print(1 / 0) # ZeroDivisionError:数学上不成立的运算
open("x9.txt") # FileNotFoundError:要读的文件不存在
int("abc") # ValueError:值的内容转不成目标类型
print("3" + 5) # TypeError:类型之间不支持这种运算
四行分属四类:除零是数学错误、文件不存在是环境错误、值不合法是数据错误、类型合不来是类型错误。注意第二行没写模式——open("x9.txt") 不给第二个参数时默认就是 "r" 读取模式,文件不在立刻报 FileNotFoundError。
答:ZeroDivisionError、FileNotFoundError、ValueError、TypeError。 ⚠ 易错点:① 分不清 ValueError 与 TypeError 时看「卡在哪一步」:字符串长得不像数字、转不动,是 ValueError;压根不打算转换、类型直接合不来(字符串加整数),是 TypeError;② int("88") 能正常得到 88——int 收「长得像整数」的字符串;但 int("88.5") 转不动,抛 ValueError,要转带小数点的串得用 float;③ 判断题口诀:先看类型对不对(TypeError),再看值能不能用(ValueError)。
📖 工程视角:老手和新手的差别,常常就在报错面前 新人看到满屏 Traceback 第一反应是心慌、截图求助;老手的第一反应是把最后一行圈出来读类型,再顺着 File 行定位到自己的代码。生产环境里,日志系统每天生成的报错成千上万,工程师的日常就是拿着异常类型分类处理:哪些是数据脏了要清洗、哪些是环境缺东西要补、哪些是代码写错了要修。读 Traceback 的速度,几乎就是调试的速度——这也是第 12 章调试方法的地基。
辨析:异常与 Bug 不是一回事 Bug 是「程序写错了」,解法是改代码;异常是「运行时撞上的意外」,解法是写应对。有些意外程序员防不胜防:文件可能被别人删掉、用户可能在要数字的地方敲了字母、网线可能被踢松——这些不是代码写错,而是世界的本来面目。所以「健壮」不是「不出意外」,而是出了意外也不崩。但也要警惕另一个极端:把 Bug 也用 except 兜住装没看见——那不是处理异常,是掩盖问题,下一节辨析里细说。
11.5try/except:兜住错误
Python 用 try / except 来兜异常。结构是:
try:
可能出错的代码
except 异常类型:
出错后执行的补救代码
它的逻辑是:先尝试(try)执行代码;如果执行中抛出了异常,就跳到 except 里执行补救代码,而不是崩溃。
看一个真实场景:读文件时,文件可能不存在。
try:
f = open("notes.txt", "r")
content = f.read()
f.close()
except FileNotFoundError:
print("文件不存在,请先创建它")
如果文件存在,正常读取;如果文件不存在,不会崩溃,而是打印一句友好的提示。
🧠 一句话记忆 try 是「试一下」,except 是「出事了这么办」。兜住异常,程序就不崩。
except 的「精确匹配」规则
try/except 最容易用错的地方是「兜的类型不对」。except ValueError: 只接 ValueError;此时若 try 块里抛的是 FileNotFoundError,这个 except 接不住,异常照样向外冒、程序照样崩——兜错类型等于白兜。所以写 except 前,先想清楚「这里最可能出什么类型的异常」:读文件想到 FileNotFoundError,转换用户输入想到 ValueError,做除法想到 ZeroDivisionError。上一节例题 4 的四类异常,就是配 except 的常用清单。还有一条新手须知:只兜预期的类型,别写不带类型的裸 except: 把一切统统吞掉——程序表面不崩了,可真正的大问题也被悄悄埋掉,排查难度翻倍。异常要「按类型精确接住、明确处理」。
再强调一遍跳转直觉:异常行之后的 try 代码不再执行。执行流在出错行被拦腰截断,直接落到 except 块;except 块跑完后,从 try/except 结构之后的代码继续——所以「兜住」之后程序是真的活着,不是假装活着。
📐 例题 5 两道防线:兜住类型转换失败
程序要用户输入一个整数。写出:① 直接 x = int("abc") 会怎样;② 用 try/except 兜住 ValueError,打印「这不是数字」;③ 证明兜住之后程序继续往下跑(在 except 之后再打印一句话)。
思路 第①问预期崩溃;第②③问把转换动作包进 try,except 指名道姓接 ValueError,块内打印补救信息;随后在 try/except 结构外面打印一句话验证「活着」。
解
# ① 不设防:直接崩
x = int("abc") # ValueError: invalid literal ... 程序在此终止
# ②③ 设防 + 续命
try:
x = int("abc")
print("转换成功") # ← 这行永远不会执行:异常行之后的 try 代码被跳过
except ValueError:
print("这不是数字") # 兜住,走补救
print("程序还活着") # try/except 之后,正常继续
实测输出只有两行:这不是数字 与 程序还活着——「转换成功」因跳转规则被跳过,except 执行补救后,控制流回到结构之后。再看文件场景的同类写法:
try:
with open("scores.txt", "r") as f:
content = f.read()
print("读到:", content)
except FileNotFoundError:
print("scores.txt 不存在,先用默认值")
content = "" # 兜底:给个能用的值,让后面代码走得下去
答:① 抛 ValueError 后崩溃;② except 打印「这不是数字」;③ 随后打印「程序还活着」。 ⚠ 易错点:① print("转换成功") 放在 try 里转换行之后,永远没机会执行——想「成功时才做的事」要紧跟在成功的那行后面,或放到 try/except 之后;② except 类型必须对得上:这里若写 except FileNotFoundError,ValueError 照样崩;③ 兜住之后要给出路:要么补救(默认值 content = "")、要么明确报告并退出,不能兜住了却什么都不做,让后续代码拿着残缺数据硬跑。
📖 工程视角:兜底 + 默认值,是系统不闪退的秘密 你有没有想过,App 偶尔「数据加载失败」却还能继续用?底层正是这套「两道防线」:某条传感器读数解析失败,except 兜住后用上一条有效值顶替,界面照样刷新;配置文件缺失,兜住后用出厂默认配置继续跑。专业系统与玩具脚本的分水岭之一就在这里——意外被就地处理,数据和日志留了痕,程序始终活着。等你写第 12 章的项目时,会在解析用户输入处用同样的手法。
辨析:except 是「处理」,不是「掩盖」 兜异常的正确姿势是三选一:补救(换个默认值重试)、报告(提示用户/记日志后体面退出)、转交(本地处理不了,抛给更上层的调用者)。错误的做法是空 except 把异常闷掉——程序「看起来」没崩,实际却在带病运转,错误数据流向下游,早晚酿成更大的错。判断标准很简单:兜住之后,你知道该干什么,并且干了,才叫处理。
辨析一套与 try/except 打对台的「上古机制」:返回错误码。C 语言的传统做法是函数出错时返回特殊值(−1、NULL),由调用者自己检查:查了就处理,忘了程序就带着坏数据继续跑——错得静悄悄,排查无从下手。异常机制反其道而行:出错点主动举手,没人接住就沿调用链层层上抛,直到有人处理;全程无人处理才让程序显式倒下。两个红利立见高下:其一「不会忘」——不写 except 就藏不住,没人能悄悄绕过;其二「不掺私货」——正常逻辑里不必每行都塞 if 判断错误码,主干代码读起来干干净净。但硬币有两面:错误码「忘了查才出事」,异常「乱兜才出事」——上一段那个空 except 反模式,正是从错误码时代平移过来的坏习惯在新机制里的变形。工程上两条底线记牢:错误码要检查,异常要有动作;在两种风格混用的老项目里,先看清每个函数说的是哪种「报错方言」再动手。
11.6输入输出的本质
最后把视角拉高,看一个贯穿始终的概念:输入输出(Input/Output,简称 I/O)。
第 1 章我们说过,计算机的本质是「输入 → 处理 → 输出」。而编程里,处处都是 I/O:
| 操作 | 属于 | 数据流向 |
|---|---|---|
| 用户敲键盘 | 输入 | 外部 → 程序 |
print() 显示到屏幕 | 输出 | 程序 → 屏幕 |
f.read() 读文件 | 输入 | 磁盘 → 程序 |
f.write() 写文件 | 输出 | 程序 → 磁盘 |
一个关键认知:I/O(尤其是磁盘读写)比纯计算慢得多。读一次磁盘,可能比做几百万次加法还慢。这就是为什么第 11.1 节说磁盘「慢」——它是程序性能的常见瓶颈。专业的程序员写程序时,会尽量减少不必要的 I/O 次数。
核心思想 I/O 是程序与外界交换数据的总称——键盘、屏幕、文件、网络,都是 I/O。它让程序不再「闭门造车」,而是能和外界互动。理解 I/O,你就理解了程序「活」起来的关键。
慢在哪里:给 I/O 算一笔数量级的账
「读一次磁盘比几百万次加法还慢」——这句话值得算清楚。做个数量级估算:CPU 做一次加法大约是纳秒级(10 的负 9 次方秒),机械硬盘读一次数据大约是毫秒级(10 的负 3 次方秒)——差了 6 个数量级。换算一下:10 毫秒 = 10,000,000 纳秒,即磁盘动一「格」,CPU 已经做完约一千万次加法。就算换成快得多的固态硬盘(SSD,约百微秒级),仍然比一次加法慢十万倍上下。所以那张 I/O 表里的每一行,都要在心里标上「贵」字:键盘、屏幕、磁盘、网络,都是比 CPU 慢几个量级的「慢亲戚」。
| 操作 | 典型耗时量级 | 相当于多少次加法 |
|---|---|---|
| 内存读一个数 | 约 100 纳秒 | 约 100 次 |
| CPU 做一次加法 | 约 1 纳秒 | 1 次(基准) |
| 固态硬盘读一次 | 约 100,000 纳秒(0.1 毫秒) | 约 10 万次 |
| 机械硬盘读一次 | 约 10,000,000 纳秒(10 毫秒) | 约 1,000 万次 |
把镜头拉远,这张表其实是一座存储金字塔:塔尖是 CPU 里又快又小的寄存器,往下一层是内存,再往下是固态硬盘、机械硬盘,越往塔基越慢、越便宜、也越大。程序的数据必须在这座塔里上下搬家:正在算的住塔腰(内存里的变量,第 4 章),要长期保管的搬到塔基(磁盘上的文件,本章)。所谓「减少 I/O」,翻译过来就是让数据尽量住在离 CPU 近的楼层,能不下塔就不下塔;而「持久化」则是把重要数据及时送到塔基存好。两个诉求拉扯,工程上的解法前面已经见过:攒小批、定期落盘。第 12 章的项目会做一次真实的搬家——把算好的成绩统计写进磁盘。
📐 例题 6 两种写法的 I/O 账单
某程序要把 30 条记录写入日志文件。方案 A:每写一条都「打开 → 写入 → 关闭」一次,假设每次打开/关闭合计 1 秒(含寻道与开关节流),写一条本身耗时忽略不计;方案 B:只打开 1 次,30 条写完,关闭 1 次。分别算总耗时,并比较快多少倍。
思路 数清 I/O 动作的次数:方案 A 是 30 次完整的打开+关闭,方案 B 只有 1 次;每条 write 本身按题意忽略。
解
n = 30
cost_per_open = 1.0 # 每次打开+关闭合计 1 秒
A = n * cost_per_open # 30 × 1 = 30 秒
B = 1 * cost_per_open # 1 × 1 = 1 秒
print(A) # 30.0
print(B) # 1.0
print(A / B) # 30.0
方案 A 花在「反复开门关门」上的时间是方案 B 的 30 倍——而两方案干的是同一件活。数据量越大,这笔账越惊人:10 万条记录时,方案 A 要 10 万次开关节流,方案 B 仍只需 1 次。
答:方案 A 约 30 秒,方案 B 约 1 秒,相差 30 倍。 ⚠ 易错点:① 别把「写一条」和「开一次文件」混为一谈——贵的不是写数据那几行字符串,是打开与关闭这套与磁盘打交道的仪式;② 现代系统有缓冲区帮忙,真实差距未必这么工整,但「减少 I/O 次数」的方向永远正确;③ 反过来也别走极端:为省 I/O 把数据全攒在内存里迟迟不落盘,一旦断电全丢——及时持久化(11.1 节)与减少 I/O(本节)是一对需要平衡的诉求,工程上常用「攒小批、定期落盘」折中。
📖 工程视角:批处理思想,无处不在 把「攒一批再处理」用到 I/O 上就是批处理:数据库不是每改一行就同步刷盘,而是先写内存、攒够一批再整批落盘;日志系统也是攒满几 KB 才真正写一次。回看第 10 章「能 append 就不 insert」——那是在数据结构上省挪动,本节是在 I/O 上省开门关门,同一个思想:把昂贵的动作攒起来,一次做一批。识别程序里最贵的那类动作,然后想办法减少它的次数,这就是性能优化的第一直觉。
辨析与衔接 至此,本章的三条主线合拢了:内存易失所以要持久化(写文件);读写文件要占资源所以要及时关闭(with open);读写文件可能出意外所以要异常处理(try/except)。下一章的项目里,这三样会原样登场:成绩存文件、用 with 打开、用 try 兜住文件缺失——章节之间的「衔接」,就是同一套零件在新场景里重新组装。
本章小结
- 内存易失(断电就丢),磁盘持久(断电还在),文件是把数据在两者间搬运的桥梁。
- 文件操作三步:open(拿手柄)→ write/read(读写)→ close(归还)。
- 文件模式:
r读、w写(清空)、a追加。 - 异常是运行时的错误信号;不处理会崩溃。
- try/except 兜住异常,出错时走补救路径,程序不崩。
- I/O 是程序与外界交换数据的总称,且比计算慢得多。
- with open(…) as f 是标准姿势:离开缩进块自动关闭,中途抛异常也会先关再走。
- write() 原样落盘、不自动换行,
\n要自己写;读回的数字是字符串,参与计算前要转换。 - 异常即跳转:出错行之后的 try 代码被跳过;except 按类型精确接住,兜住后要么补救、要么报告。
- 磁盘 I/O 比计算慢几个数量级,攒批落盘省大钱——但要与「及时持久化」相平衡。
- 读 Traceback 的口诀:先看最后一行的类型与原因,再向上找 File 行定位;bug 要改代码,异常要写应对,两回事。
读完本章,你应该能……
- 解释「变量存内存、文件存磁盘」的区别和原因。
- 写一段代码,把一个字符串写入文件,再读出来。
- 说出
"w"和"a"模式的区别。 - 用 try/except 处理「文件不存在」的情况。
- 举出程序中至少 3 种不同的输入/输出。
- 用 with open 完成「写入 → 追加 → 读回」的完整实验,并解释 w 与 a 的行为差异。
- 拿着一段 Traceback,按「最后一行 → File 行」的顺序说出异常类型与出错位置。
- 各写一个兜 FileNotFoundError 与兜 ValueError 的 try/except,并说出兜错类型会怎样。
- 给你的程序算一笔 I/O 账:哪些昂贵动作被重复了多少次,能否攒成一批。
思考题
1. 为什么程序计算的结果要「写入文件」才能长期保存?
提示:因为变量存在内存里,断电就丢;写入磁盘(文件)才能持久保存。
2. open("a.txt", "w") 和 open("a.txt", "a") 有什么不同?
提示:w 会清空原内容再写;a 是追加,在末尾接着写。
3. 如果读取文件时文件不存在,程序会怎样?如何避免崩溃?
提示:会抛 FileNotFoundError 异常导致崩溃;用 try/except 兜住,打印友好提示。
4. 为什么说「磁盘读写」比「纯计算」慢很多?这提醒我们写程序时注意什么?
提示:磁盘是机械/慢速设备;应尽量减少不必要的 I/O 次数,把数据尽量留在内存里处理。
5. with open("a.txt", "w") as f: 相比手写 open/close,有哪两大好处?
提示:① 离开缩进块自动 close,不用记;② 块内哪怕抛异常,退出前也会先关闭文件再让异常冒出去——手柄永不泄漏。
6. 连续执行 f.write("你好") 两次(不写 \n),文件里是什么样?
提示:write 原样落盘、不自动加任何字符,所以是「你好你好」挤在一起——换行必须自己写 "\n"。
7. try 块里第 3 行抛了异常,第 4、5 行还会执行吗?except 块跑完后程序去哪?
提示:异常行之后的 try 代码全部跳过,直接跳到 except;except 结束后从 try/except 结构之后继续执行。
8. 老式函数用返回 −1 表示失败、新式函数用抛异常报错,两种机制各自的头号陷阱是什么?
提示:错误码的陷阱是「忘了检查」——−1 混进正常数据被继续计算,错得静悄悄;异常的陷阱是「乱兜」——空 except 把该暴露的错误闷掉,程序带病运转。一个易漏、一个易藏;防御姿势分别是「返回值必查」和「except 里必有动作」。