随机、文本与文件
Random, Text and File
到目前为止你的数据都是代码里写死的。这一讲让程序接触外部世界——生成随机数、读写文件、处理编码,以及用正则表达式从脏数据里捞出你要的东西。
本讲结束后你应当能
- 用 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)) # [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 seed is not None:
random.seed(seed)
return sum(random.randint(1, 6) for _ in range(n)) / 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))
print(f"Unicode 现在收录了超过 15 万个字符")
但码点还不是字节。Unicode 只说”中”的编号是 20013,没说这个数字在文件里该怎么存。
把码点变成字节的规则叫编码(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 字节数 |
|---|---|---|
ASCII(A、0、空格) | 0–127 | 1 |
| 拉丁扩展、希腊、西里尔 | 128–2047 | 2 |
| 中日韩文字 | 2048–65535 | 3 |
| emoji、罕见字 | 65536 以上 | 4 |
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("/tmp/demo.txt", "w", encoding="utf-8") as f:
f.write("第一行\n")
f.write("第二行\n")
# 读
with open("/tmp/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("/tmp/demo.txt", "w", encoding="utf-8") as f:
f.write("苹果 3\n香蕉 5\n橘子 2\n")
# 1. 一次读全部,得到一个字符串
with open("/tmp/demo.txt", encoding="utf-8") as f:
print(repr(f.read()))
# 2. 读成行的列表
with open("/tmp/demo.txt", encoding="utf-8") as f:
print(f.readlines())
# 3. 逐行遍历 —— 推荐
with open("/tmp/demo.txt", encoding="utf-8") as f:
for line in f:
print(repr(line))
一个完整的例子
# 先造一份数据
data = """张三,数学,87
李四,数学,92
张三,物理,78
王五,数学,65
李四,物理,88
"""
with open("/tmp/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(",")
result.setdefault(name, {})[subject] = int(score)
except ValueError:
print(f" 第 {lineno} 行格式不对,跳过: {line!r}")
return result
scores = load_scores("/tmp/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("/tmp/scores.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerows(rows)
with open("/tmp/scores.csv", encoding="utf-8") as f:
print(f.read())
with open("/tmp/scores.csv", encoding="utf-8", newline="") as f:
for row in csv.reader(f):
print(row)
看第二行——csv 自动给含逗号的字段加了引号,读回来也能正确还原。手写 split(",") 会在这里出错。
DictReader 更好用:
import csv
with open("/tmp/scores.csv", encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
print(row["姓名"], "→", row["分数"])
路径
from pathlib import Path
p = Path("/tmp/scores.csv")
print("存在吗:", p.exists())
print("文件名:", p.name)
print("后缀:", p.suffix)
print("所在目录:", p.parent)
print("大小:", p.stat().st_size, "字节")
# 拼路径用 / 而不是字符串拼接
data_dir = Path("/tmp")
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 能做,但很脆弱——多一个空格就崩。
正则表达式是描述”文本模式”的语言。你告诉它长什么样,它去找。
基本语法
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 | 一个数字 |
\w | 一个字母、数字或下划线 |
\s | 一个空白字符 |
. | 任意一个字符(换行除外) |
[abc] | a、b、c 中的一个 |
[a-z] | 小写字母中的一个 |
[^abc] | 不是 a、b、c 的一个字符 |
数量:
| 记号 | 含义 |
|---|---|
* | 0 次或多次 |
+ | 1 次或多次 |
? | 0 次或 1 次 |
{n} | 恰好 n 次 |
{n,m} | n 到 m 次 |
位置:
| 记号 | 含义 |
|---|---|
^ | 行首 |
$ | 行尾 |
\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.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 把模式预编译一次,在循环里反复使用时更快。
贪婪与非贪婪
import re
html = "<b>粗体</b>和<i>斜体</i>"
print(re.findall(r"<.*>", html)) # 贪婪:尽可能长
print(re.findall(r"<.*?>", html)) # 非贪婪:尽可能短
* 和 + 默认是贪婪的——会匹配尽可能多的字符。加个 ? 变成非贪婪。
什么时候不该用正则
正则强大,但它很难读。一条实用的判断:
| 用正则 | 用字符串方法 |
|---|---|
| 模式复杂、有变化 | 固定的分隔符 |
| 需要提取多个部分 | 只是判断包不包含 |
| 格式不完全规整 | 格式严格规整 |
text = "name=Alice"
# 简单情况,字符串方法更清楚
key, value = text.split("=")
print(key, value)
# 不要为了用而用
import re
print(re.match(r"(\w+)=(\w+)", text).groups()) # 同样的结果,但更难读
小结
随机
randint(a, b)含右端,randrange不含sample不重复、choices可重复、shuffle原地改seed()让结果可复现——任何用到随机的程序都该让种子可设- 密码学用
secrets,不要用random
编码
- Unicode 给字符编号(码点),编码规则把码点变成字节
- UTF-8 是变长的:ASCII 1 字节,中文 3 字节,emoji 4 字节
- 乱码 = 用错误的编码解码字节
- 永远显式写
encoding="utf-8"
文件
- 永远用
with,它保证关闭 "w"模式会清空文件- 大文件用
for line in f逐行读,内存占用与文件大小无关 - 每行末尾带
\n,几乎总要.strip() - 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。个人项目 B 本周截止。