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

随机、文本与文件

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–1271
拉丁扩展、希腊、西里尔128–20472
中日韩文字2048–655353
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,用之前要判断
  • 用命名分组提取,比数括号清楚
  • * + 默认贪婪,加 ? 变非贪婪
  • 简单情况用字符串方法,正则本身也需要调试

练习

  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。个人项目 B 本周截止。