CS1602计算导论
第 12 讲Part 5 健壮性与真实世界AI Level 1

随机、文本与文件

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+007F1A
U+0080–U+07FF2é
U+0800–U+FFFF(不含代理码点)3中
U+10000–U+10FFFF4😀

常见汉字占三个 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())   # 日期

核心记号:

记号匹配
\dUnicode 十进制数字;若只接受 ASCII 数字用 [0-9]
\wUnicode 字母数字字符或下划线;不只限于英文
\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-32UTF-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,用之前要判断
  • 用命名分组提取,比数括号清楚
  • * + 默认贪婪,加 ? 变非贪婪
  • 简单情况用字符串方法,正则本身也需要调试

练习

  1. 写一个函数模拟掷两个骰子 10000 次,统计点数和的分布。用固定种子,确保两次运行结果一样。
  2. "中华人民共和国" 用 UTF-8 编码是多少字节?用 GBK 呢?写代码验证,并解释差别。
  3. 写一个函数 count_lines(path: str) -> int 数文件行数。要求:用 with、指定 UTF-8、文件不存在时抛出有意义的异常。
  4. 给定一份成绩 CSV(含表头 姓名,科目,分数),用 csv.DictReader 读入,输出每个科目的平均分。
  5. 写正则提取一段文本里所有的:
    • 邮箱地址
    • 形如 2026-09-14 的日期
    • 中国大陆手机号(1 开头,共 11 位)
  6. 下面的正则想匹配”三位数字”,但它对 "12345" 也会匹配成功。为什么?怎么改?
    re.search(r"\d{3}", "12345")
  7. 做一次真实数据处理:写一个程序读入一份日志文件(格式自定,但要故意加入几行格式不对的),统计每个级别(INFO/ERROR/WARN)出现的次数,跳过并报告无法解析的行。

本讲的配套上机题在 Lab 12。个人作业 3“大整数运算”在第 12–13 周开展;实际截止日期以课程日程及提交页显示为准。