随机、文本与文件
Randomness, Text and Files
本讲围绕可复现的数据处理:生成随机数据,区分字符与字节,安全读写文件,并从日志中提取字段、验证数据和报告错误。
本讲结束后你应当能
- 用 random 生成各种随机数据,并用 seed 让结果可复现
- 说清楚字符、编码与字节的关系,以及乱码是怎么产生的
- 用 with 打开文件,正确读写文本和 CSV
- 用正则表达式匹配、提取和替换文本
- 处理一份真实的、不干净的数据
本页目录
程序要接触外部世界
前面已经使用过用户输入,也见过文件操作与异常处理。本讲系统处理外部数据:先控制实验输入,再解释文件中的字节,逐行解析,最后输出统计与可定位的诊断。
这一讲把三件相关的事放在一起:
- 随机:程序需要不确定性(模拟、抽样、游戏)
- 文本编码:外部数据是字节,要变回字符
- 文件与正则:把数据读进来,从里面捞出你要的部分
它们的共同要求是:明确输入规则与失败边界。文件可能不存在,编码可能不对,格式可能不规整。所以 L11 的异常处理会在本讲频繁出现。
一、随机
基本用法
import random
print(random.randint(1, 6)) # 1 到 6,两端都含
print(random.randrange(0, 10, 2)) # 从 range(0,10,2) 里挑一个
print(random.random()) # [0.0, 1.0) 之间的浮点数
print(random.uniform(1.5, 3.5)) # 右端点是否出现受浮点舍入影响
从序列里选
import random
deck = ["♠A", "♥K", "♦Q", "♣J", "♠10"]
print(random.choice(deck)) # 随机挑一个
print(random.sample(deck, 3)) # 不放回地挑 3 个
print(random.choices(deck, k=3)) # 有放回地挑 3 个,可能重复
shuffled = deck.copy()
random.shuffle(shuffled) # 原地打乱
print(shuffled)
print(deck) # 原来的没变
| 函数 | 会不会重复 | 改不改原序列 |
|---|---|---|
choice(seq) | — | 不改 |
sample(seq, k) | 不重复抽取同一位置;原序列值重复时结果仍可重复 | 不改 |
choices(seq, k=n) | 可能重复 | 不改 |
shuffle(seq) | — | 原地改 |
choices 还能带权重:
import random
random.seed(42)
outcomes = random.choices(["赢", "平", "输"], weights=[1, 2, 7], k=20)
print(outcomes)
print("输的次数:", outcomes.count("输"))
seed:让随机可复现
这一节比前面重要得多。
计算机的”随机数”其实是伪随机——由一个确定的算法从一个初始值(种子)算出来的。同一版本与算法下,从同样的种子开始,按同样的顺序调用,就能重放相同的序列。不同种子不保证每一个输出都不同。
import random
random.seed(42)
print([random.randint(1, 100) for _ in range(5)])
random.seed(42)
print([random.randint(1, 100) for _ in range(5)]) # 一模一样
random.seed(7)
print([random.randint(1, 100) for _ in range(5)]) # 不同的种子,不同的序列
为什么这很重要?
因为科学计算、机器学习、任何涉及随机的实验,都必须能复现。别人拿到你的代码,跑出来的结果得和你一样,否则没法验证、没法比较、没法调试。
import random
def simulate(n: int, seed: int | None = None) -> float:
"""模拟掷 n 次骰子,返回平均点数。"""
if n <= 0:
raise ValueError("n must be positive")
rng = random.Random(seed)
total = 0
for _ in range(n):
total += rng.randint(1, 6)
return total / n
print("不设种子,结果通常不同:")
print(f" {simulate(1000):.4f}")
print(f" {simulate(1000):.4f}")
print("设了种子,每次相同:")
print(f" {simulate(1000, seed=123):.4f}")
print(f" {simulate(1000, seed=123):.4f}")
二、文本编码
字符与字节
L2 说过:计算机只存 0 和 1,字符的意义来自约定。ASCII 是最早的约定,用 7 位表示 128 个字符。
128 个位置显然装不下世界上的文字。现代的方案是 Unicode:给几乎每个字符分配一个编号(叫码点,code point)。
print(ord('A'), ord('中'), ord('😀'))
print(chr(65), chr(20013), chr(128512))
但码点还不是字节。Unicode 只说”中”的编号是 20013,没说这个数字在文件里该怎么存。
Unicode 还规定字符属性等内容。本讲聚焦码点与存储表示的区别。len(str) 数码点,一般不等于视觉上看到的字符数。
把文本变成字节的规则叫编码(encoding)。最通用的是 UTF-8:
s = "A中😀"
utf8 = s.encode("utf-8")
print("原文:", s)
print("UTF-8 字节:", utf8)
print("字符数:", len(s), " 字节数:", len(utf8))
看清楚:3 个字符,8 个字节。UTF-8 是变长的:
| 码点范围 | UTF-8 字节数 | 例子 |
|---|---|---|
| U+0000–U+007F | 1 | A |
| U+0080–U+07FF | 2 | é |
| U+0800–U+FFFF(不含代理码点) | 3 | 中 |
| U+10000–U+10FFFF | 4 | 😀 |
常见汉字占三个 UTF-8 字节,但不是所有汉字都如此。一个视觉 emoji 也可能由多个码点组成。
for ch in "A中😀":
b = ch.encode("utf-8")
print(f"{ch} 码点 {ord(ch):>6} {len(b)} 字节 {b}")
乱码是怎么产生的
用错误的编码去解码字节,就产生乱码。
s = "你好"
b = s.encode("utf-8")
print("正确解码:", b.decode("utf-8"))
print("用 latin-1 解码:", b.decode("latin-1")) # 乱码
s = "你好"
b = s.encode("utf-8")
print(b.decode("gbk")) # 用另一种中文编码解,也是乱码
s = "你好"
b = s.encode("utf-8")
print(b.decode("ascii")) # ASCII 根本装不下,直接报错
一条实践规则
新建课程文本使用 UTF-8;读取已有文件时按其实际编码解码,并显式指定。
# 差:依赖系统默认编码,换台机器就可能坏
open("data.txt")
# 好:明确指定
open("data.txt", encoding="utf-8")
Windows 的默认编码往往不是 UTF-8,这是”在我电脑上好好的”类问题的常见来源。
三、文件
打开、读、关
# 写
with open("demo.txt", "w", encoding="utf-8") as f:
f.write("第一行\n")
f.write("第二行\n")
# 读
with open("demo.txt", encoding="utf-8") as f:
content = f.read()
print(repr(content))
with 保证文件被关闭,即使中间出了异常(L11 讲过)。不要手动 open / close。
打开模式
| 模式 | 含义 | 文件不存在时 | 文件已存在时 |
|---|---|---|---|
"r" | 读(默认) | 报错 | — |
"w" | 写 | 创建 | 清空! |
"a" | 追加 | 创建 | 在末尾添加 |
"x" | 独占创建 | 创建 | 报错 |
"rb" / "wb" | 二进制读写 | 用于图片等非文本 |
三种读法
with open("demo.txt", "w", encoding="utf-8") as f:
f.write("苹果 3\n香蕉 5\n橘子 2\n")
# 1. 一次读全部,得到一个字符串
with open("demo.txt", encoding="utf-8") as f:
print(repr(f.read()))
# 2. 读成行的列表
with open("demo.txt", encoding="utf-8") as f:
print(f.readlines())
# 3. 逐行遍历 —— 推荐
with open("demo.txt", encoding="utf-8") as f:
for line in f:
print(repr(line))
一个完整的例子
# 先造一份数据
data = """张三,数学,87
李四,数学,92
张三,物理,78
王五,数学,65
李四,物理,88
"""
with open("scores.txt", "w", encoding="utf-8") as f:
f.write(data)
def load_scores(path: str) -> dict[str, dict[str, int]]:
"""读入成绩文件,返回 {姓名: {科目: 分数}}。"""
result: dict[str, dict[str, int]] = {}
with open(path, encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
line = line.strip()
if not line: # 跳过空行
continue
try:
name, subject, score = line.split(",")
value = int(score)
if not 0 <= value <= 100:
raise ValueError("score outside 0..100")
result.setdefault(name, {})[subject] = value
except ValueError:
print(f"{path}:{lineno}: invalid score record; input={line!r}")
return result
scores = load_scores("scores.txt")
for name, subjects in scores.items():
avg = sum(subjects.values()) / len(subjects)
print(f"{name}: {subjects} 平均 {avg:.1f}")
注意这里的三个细节,它们体现了”外部数据是脏的”:
- 跳过空行——文件末尾常有一个
try/except包住解析——一行坏数据不该让整个程序崩溃- 报错时带上行号——否则你不知道去哪找问题
CSV
逗号分隔的数据看起来简单,但真实的 CSV 有陷阱:字段里可能含逗号、含换行、含引号。用标准库,别自己 split。
import csv
rows = [
["姓名", "科目", "分数"],
["张三", "数学", 87],
["李四, Jr.", "物理", 92], # 名字里有逗号
]
with open("scores.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerows(rows)
with open("scores.csv", encoding="utf-8") as f:
print(f.read())
with open("scores.csv", encoding="utf-8", newline="") as f:
for row in csv.reader(f):
print(row)
看第二行——csv 自动给含逗号的字段加了引号,读回来也能正确还原。手写 split(",") 会在这里出错。
DictReader 更好用:
import csv
with open("scores.csv", encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
print(row["姓名"], "→", row["分数"])
路径
from pathlib import Path
p = Path("scores.csv")
print("存在吗:", p.exists())
print("文件名:", p.name)
print("后缀:", p.suffix)
print("所在目录:", p.parent)
print("大小:", p.stat().st_size, "字节")
# 拼路径用 / 而不是字符串拼接
data_dir = Path(".")
print(data_dir / "sub" / "file.txt")
pathlib 比手工拼字符串好,因为它跨平台——Windows 用 \,Unix 用 /,pathlib 自动处理。
四、正则表达式
什么时候需要它
前面的 split(",") 能处理规整的数据。但真实文本往往不规整:
2026-09-14 10:23:45 ERROR 用户 alice 登录失败
2026-09-14 10:23:47 INFO 用户 bob 登录成功
2026-09-14 10:24:02 ERROR 用户 carol 登录失败
想提取所有登录失败的用户名。普通 split() 能处理连续空白;若字段格式还包含可选部分、长度规则和不同的取值约束,正则可以集中描述这些规则。固定格式的 CSV 仍使用 csv。
正则表达式是描述”文本模式”的语言。你告诉它长什么样,它去找。
基本语法
import re
text = "订单号 A12345,金额 299 元,日期 2026-09-14"
print(re.findall(r"\d+", text)) # 所有数字串
print(re.findall(r"[A-Z]\d+", text)) # 大写字母跟数字
print(re.search(r"\d{4}-\d{2}-\d{2}", text).group()) # 日期
核心记号:
| 记号 | 匹配 |
|---|---|
\d | Unicode 十进制数字;若只接受 ASCII 数字用 [0-9] |
\w | Unicode 字母数字字符或下划线;不只限于英文 |
\s | 一个空白字符 |
. | 任意一个字符(换行除外) |
[abc] | a、b、c 中的一个 |
[a-z] | 小写字母中的一个 |
[^abc] | 不是 a、b、c 的一个字符 |
数量:
| 记号 | 含义 |
|---|---|
* | 0 次或多次 |
+ | 1 次或多次 |
? | 0 次或 1 次 |
{n} | 恰好 n 次 |
{n,m} | n 到 m 次 |
位置:
| 记号 | 含义 |
|---|---|
^ | 字符串开头;MULTILINE 下也匹配行首 |
$ | 字符串末尾或末尾换行前;MULTILINE 下也匹配行尾 |
\b | 单词边界 |
四个主要函数
import re
text = "cat bat rat mat"
print(re.search(r"[bc]at", text)) # 找第一个,返回 Match 对象
print(re.search(r"[bc]at", text).group()) # 取出匹配的文本
print(re.findall(r"[bcr]at", text)) # 找所有,返回列表
print(re.sub(r"[bcr]at", "dog", text)) # 替换
print(re.split(r"\s+", text)) # 按模式切分
| 函数 | 做什么 | 返回 |
|---|---|---|
re.search(p, s) | 找第一个匹配 | Match 对象,或 None |
re.match(p, s) | 从开头匹配 | Match 对象,或 None |
re.fullmatch(p, s) | 要求整个字符串符合模式 | Match 对象,或 None |
re.findall(p, s) | 找所有匹配 | 无组时返回完整匹配;一个组返回组字符串;多个组返回元组 |
re.sub(p, r, s) | 替换 | 新字符串 |
re.split(p, s) | 切分 | 列表 |
分组:提取你要的部分
用圆括号标记你想单独取出来的部分:
import re
log = "2026-09-14 10:23:45 ERROR 用户 alice 登录失败"
m = re.search(r"(\d{4})-(\d{2})-(\d{2})", log)
if m:
print("整体:", m.group()) # 或 m.group(0)
print("年:", m.group(1))
print("月:", m.group(2))
print("日:", m.group(3))
print("全部分组:", m.groups())
命名分组更清楚:
import re
log = "2026-09-14 10:23:45 ERROR 用户 alice 登录失败"
m = re.search(r"(?P<date>\S+) (?P<time>\S+) (?P<level>\w+) 用户 (?P<user>\w+)", log)
if m:
print(m.group("date"), "|", m.group("level"), "|", m.group("user"))
print(m.groupdict())
一个完整的例子
import re
logs = """2026-09-14 10:23:45 ERROR 用户 alice 登录失败
2026-09-14 10:23:47 INFO 用户 bob 登录成功
2026-09-14 10:24:02 ERROR 用户 carol 登录失败
2026-09-14 10:24:15 ERROR 用户 alice 登录失败
格式不对的一行
2026-09-14 10:25:00 INFO 用户 dave 登录成功"""
pattern = re.compile(r"^(?P<date>\S+) (?P<time>\S+) +(?P<level>\w+) +用户 (?P<user>\w+) (?P<action>\S+)$")
failures: dict[str, int] = {}
for line in logs.split("\n"):
m = pattern.match(line)
if not m:
print(f" 跳过无法解析的行: {line!r}")
continue
if m.group("level") == "ERROR":
user = m.group("user")
failures[user] = failures.get(user, 0) + 1
print("登录失败统计:", failures)
re.compile 给模式一个名字,便于复用和测试。模块函数也会缓存最近使用的模式,因此不能一概认为显式编译就更快。用 fullmatch 验证整条记录;匹配日期外形不代表日期真实有效,还需单独验证含义。
贪婪与非贪婪
import re
html = "<b>粗体</b>和<i>斜体</i>"
print(re.findall(r"<.*>", html)) # 贪婪:尽可能长
print(re.findall(r"<.*?>", html)) # 非贪婪:尽可能短
* 和 + 默认是贪婪的——会匹配尽可能多的字符。加个 ? 变成非贪婪。
什么时候不该用正则
正则强大,但它很难读。一条实用的判断:
| 用正则 | 用字符串方法 |
|---|---|
| 模式复杂、有变化 | 固定的分隔符 |
| 需要提取多个部分 | 只是判断包不包含 |
| 格式不完全规整 | 格式严格规整 |
text = "name=Alice"
# 简单情况,字符串方法更清楚
key, value = text.split("=", 1)
print(key, value)
# 不要为了用而用
import re
print(re.match(r"(\w+)=(\w+)", text).groups()) # 同样的结果,但更难读
五、完整的处理流程
程序的成功条件是:每条输入都有明确去向,正常记录参与统计,坏记录保留诊断,输出能正确读回。
下面使用比实验更小的三字段格式。格式匹配只确定字段外形;时长还需转换并检查范围。空行和注释在此示例中也视为格式不合,不默默跳过。文件不存在、权限和解码失败属于文件级错误,留给调用者处理;代码只恢复契约允许跳过的记录级错误。
csv.DictWriter 与 DictReader 对应:指定 fieldnames 确定输出列,writeheader() 写表头。CSV 字段读回仍是字符串,因此本例保留已验证的时长字符串。所有修改统计的操作都在验证成功之后执行。
"""Complete lecture example; intentionally smaller than the Lab12 contract."""
import csv
from pathlib import Path
import re
PATTERN = re.compile(
r"(?P<level>INFO|WARN|ERROR)\s+"
r"user=(?P<user>[a-z]+)\s+"
r"ms=(?P<ms>[0-9]+)"
)
def process_log(path: str):
"""Reject malformed rows; file errors propagate to the caller."""
records, diagnostics, counts = [], [], {}
with open(path, encoding="utf-8") as f:
for lineno, raw in enumerate(f, start=1):
line = raw.rstrip("\n")
match = PATTERN.fullmatch(line)
if match is None:
diagnostics.append((lineno, line, "invalid fields"))
continue
row = match.groupdict()
try:
duration = int(row["ms"])
if not 1 <= duration <= 3000:
raise ValueError("ms outside 1..3000")
except ValueError as exc:
diagnostics.append((lineno, line, str(exc)))
continue
records.append(row)
level = row["level"]
counts[level] = counts.get(level, 0) + 1
return records, diagnostics, counts
def demo():
sample = "\n".join([
"INFO user=alice ms=42",
"ERROR user= ms=abc",
"WARN user=bob ms=0",
"ERROR user=carol ms=99",
])
Path("app.log").write_text(sample, encoding="utf-8")
records, diagnostics, counts = process_log("app.log")
for lineno, raw, reason in diagnostics:
print(f"app.log:{lineno}: {reason}; input={raw!r}")
print("counts:", counts)
fields = ["level", "user", "ms"]
with open("parsed.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
writer.writerows(records)
with open("parsed.csv", encoding="utf-8", newline="") as f:
restored = list(csv.DictReader(f))
assert restored == records
assert len(records) + len(diagnostics) == len(sample.splitlines())
print("CSV round trip:", restored == records)
if __name__ == "__main__":
demo()
实际输出:
app.log:2: invalid fields; input='ERROR user= ms=abc'
app.log:3: ms outside 1..3000; input='WARN user=bob ms=0'
counts: {'INFO': 1, 'ERROR': 1}
CSV round trip: True
参考内容
| 主题 | 需要掌握的边界 |
|---|---|
bytes / bytearray | 不可变/可变的字节序列;索引是 0–255 的整数;hex() / fromhex() 用于检查原始字节 |
| Unicode 查询 | ord / chr、\u / \U / \N、unicodedata.name / lookup;码点不是编码字节 |
| UTF-16 / UTF-32 | UTF-16 的码点使用一个或两个 16 位码元;UTF-32 使用一个 32 位码元;字节序与 BOM 需要区分 |
| 源码编码 | Python 3 源码默认 UTF-8;编码声明在前两行;与外部文件的编码独立 |
| 路径与参数 | 相对路径从当前工作目录解释;sys.argv[0] 是脚本名,之后是参数;包含空格的路径在 shell 中加引号 |
| 运行环境 | sys.path 是模块搜索路径,不是数据目录;sys.maxsize 不是 Python 整数上限;标准输入、输出、错误可连接终端、文件或管道 |
| 文件系统 | Path 可访问文件系统,PurePath 只计算路径;os 还提供环境变量和系统接口;删除练习限制在专用目录 |
| 二进制复制 | rb / wb,分块读取到 b"" 结束,不做文本解码或换行转换 |
| 文件位置 | 二进制 tell 是字节位置;文本位置应当作供 seek 使用的标记;flush 不等于设备持久化保证 |
| 正则选项 | IGNORECASE、MULTILINE、DOTALL 分别改变大小写、锚点、点号规则 |
| 匹配结果 | start/end/span 定位;findall 返回形状受捕获组影响;finditer 的迭代机制见 L13 |
| 模拟扩展 | Monte Carlo 用采样比例估计面积;生日模拟假定独立且均匀的 365 天,有限试验不保证固定阈值 |
| 性能实验 | 往年第十二周的文件型 list/dict/set 查询实验:先比较输出一致,再分别测 I/O 和查找,逐步扩大规模 |
完整接口与真实运行示例见本讲课件参考页。文件错误和记录错误应分开报告,不能统一改成空结果。上述程序里的两条诊断都包含原始输入,学生可以直接据此补测试。
小结
随机
randint(a, b)含右端,randrange不含sample对位置无放回,原总体可有重复值;choices有放回,shuffle原地改- 固定种子、版本及调用过程可重放实验;每次实验只初始化一次
- 密码学用
secrets,不要用random
编码
- Unicode 给字符编号(码点),编码规则把码点变成字节
- UTF-8 按码点使用 1–4 字节;视觉字符可以包含多个码点
- 乱码 = 用错误的编码解码字节
- 新文本使用 UTF-8;读取已有文件时显式指定实际编码
文件
- 永远用
with,它保证关闭 "w"模式会清空文件- 大文件逐行处理;若仍保存所有行或不断增加统计键,内存仍会增长
- 保留原始行用于诊断;只按数据规则删除换行与空白
- CSV 用
csv模块,别手写split(",") - 路径用
pathlib,跨平台
正则
- 永远用
r"..."写模式 \d\w\s.[...]配*+?{n,m}search找第一个、findall找所有、sub替换search找不到返回None,用之前要判断- 用命名分组提取,比数括号清楚
*+默认贪婪,加?变非贪婪- 简单情况用字符串方法,正则本身也需要调试
练习
- 写一个函数模拟掷两个骰子 10000 次,统计点数和的分布。用固定种子,确保两次运行结果一样。
"中华人民共和国"用 UTF-8 编码是多少字节?用 GBK 呢?写代码验证,并解释差别。- 写一个函数
count_lines(path: str) -> int数文件行数。要求:用with、指定 UTF-8、文件不存在时抛出有意义的异常。 - 给定一份成绩 CSV(含表头
姓名,科目,分数),用csv.DictReader读入,输出每个科目的平均分。 - 写正则提取一段文本里所有的:
- 邮箱地址
- 形如
2026-09-14的日期 - 中国大陆手机号(1 开头,共 11 位)
- 下面的正则想匹配”三位数字”,但它对
"12345"也会匹配成功。为什么?怎么改?re.search(r"\d{3}", "12345") - 做一次真实数据处理:写一个程序读入一份日志文件(格式自定,但要故意加入几行格式不对的),统计每个级别(INFO/ERROR/WARN)出现的次数,跳过并报告无法解析的行。
本讲的配套上机题在 Lab 12。个人作业 3“大整数运算”在第 12–13 周开展;实际截止日期以课程日程及提交页显示为准。