Day 2(周日 09-06):进阶——写出"像 Python"的、能保存数据、有测试的程序¶
今日目标:21:30 前,
exercises/day2/全部 [OK],uv run pytest exercises/day2全绿,uv run ruff check exercises零警告;亲手复现并修复过一次UnicodeDecodeError;能用@dataclass建模、用json存档、用 pytest 写 5 个测试;选定周一的项目。今日节奏:晨测 20 分钟 → 8 个 50+10 分钟块 → 80 分钟综合块 → 复盘。早上第一件事不是学新东西,是闭卷测验——这是记忆巩固效率最高的 20 分钟。
| 钟点 | 块 | 内容 |
|---|---|---|
| 09:00–09:20 | 晨测 | Day 1 闭卷测验(下方 10 题),不开电脑、不开 AI |
| 09:20–10:10 | Block 1 | 异常处理 |
| 10:20–11:10 | Block 2a | 文件与路径(一):open/with/pathlib/UTF-8 踩坑复现 |
| 11:20–12:10 | Block 2b | 文件与路径(二):JSON、CSV |
| 12:10–13:30 | 午餐 + 闭眼 20–30 分钟 | |
| 13:30–14:20 | Block 3 | 模块与标准库:写自己的模块、random/math/datetime/collections/statistics |
| 14:30–15:20 | Block 4a | 面向对象(一):@dataclass、手写 class、__init__/self |
| 15:30–16:20 | Block 4b | 面向对象(二):__str__/__eq__、继承与 super()、组合 |
| 16:20–16:40 | 加餐 + 散步 | |
| 16:40–17:30 | Block 5 | Pythonic:推导式、sorted(key=)、生成器、match、类型注解 |
| 17:40–18:30 | Block 6 | 测试与代码质量:pytest、ruff、PEP 8 |
| 18:30–19:30 | 晚餐 | |
| 19:30–20:50 | Block 7 | 综合 v2:成绩单 CSV → dataclass → 报表文件 → pytest |
| 20:50–21:30 | 复盘 | AI 代码评审(模板 4)→ 学习日志 → 选定周一项目(读 project/README.md)→ 22:30 前睡 |
晨测(20 分钟,闭卷,写在纸上;答案见 exercises/solutions/quiz_answers.md)¶
- 写出输出:
print(7 // 2, -7 // 2, 7 % 3, 2 ** 3, 10 / 5) - 写出输出:
s = "abcdef"; print(s[1:4], s[-2:], s[::-1], s[::2]) - 下面代码打印什么?为什么?
x = [3, 1, 2].sort(); print(x)打印什么?怎么改才能得到排好序的列表?- 写一个函数
count(words: list[str]) -> dict[str, int],统计每个词出现次数(用get)。 - 下面代码有什么问题?怎么改?
- 写出输出:
d = {"a": 1}; print(d.get("b"), d.get("b", 0), "a" in d, len(d)) - 写出
f"{3.14159:.2f}|{42:>5}|{7:02d}"的结果(用|分隔,空格要数清)。 - 这段代码报什么错?为什么?
- 用一句话解释:
TypeError、ValueError、IndexError、KeyError分别在什么情况下出现。
评分:每题 1 分,≥8 分继续;6–7 分把错题对应的 Day 1 块"预测"代码重跑一遍再开始;≤5 分用 Block 1 前 20 分钟重做 Day 1 对应练习。
Block 1(50 分钟):异常处理¶
知识点:A try/except 具体异常/else/finally;A 输入校验循环;A raise ValueError("原因");A(陷阱)裸 except:;B 自定义异常;C except A, B: 不带括号(3.14 合法,我们统一带括号);C 异常组、finally 里 return 的警告。
阅读:廖雪峰《错误处理》;官方教程 8.3–8.6;Automate 3e Ch5 "Raising Exceptions"。
预测:
def f(x):
try:
print("try")
r = 10 / x
except ZeroDivisionError as e:
print("except", e)
return "错误"
else:
print("else")
return r
finally:
print("finally")
print(f(2))
print(f(0))
# int("abc") → ValueError "3" + 1 → TypeError [1][5] → IndexError {}["k"] → KeyError
练习:exercises/day2/01_exceptions.py
| 函数 / 类 | 行为 | 测试用例 |
|---|---|---|
parse_int(s: str) -> int | None |
int(s.strip()),失败返回 None |
" 42 "→42、"abc"→None、"3.5"→None |
read_positive_int(prompt: str, input_func=input) -> int |
循环调用 input_func(prompt) 直到得到正整数;非数字或 ≤0 时 print 提示后重试(input_func 参数让测试可以传假输入) |
文件里提供 make_fake_input(["abc", "-1", "5"]);调用后返回 5 |
safe_get(lst: list, index: int, default=None) |
IndexError → default |
([1, 2], 5, -1)→-1、([1, 2], 1)→2、([1, 2], -1)→2 |
class InvalidScoreError(ValueError) |
自定义异常 | issubclass(InvalidScoreError, ValueError) |
validate_score(score: int) -> int |
不在 0–100 → raise InvalidScoreError(f"分数 {score} 不在 0-100") |
88→88;101 抛 InvalidScoreError(且能被 except ValueError 捕获) |
divide_all(pairs: list[tuple[float, float]]) -> list[float | None] |
除零的位置放 None,不中断 |
[(6, 3), (1, 0), (5, 2)]→[2.0, None, 2.5] |
get_config_int(config: dict, key: str) -> int | None |
键不存在或值无法转 int → None;必须写 except (KeyError, ValueError): |
({"n": "7"}, "n")→7、({"n": "x"}, "n")→None、({}, "n")→None |
withdraw(balance: float, amount: float) -> float |
amount <= 0 → ValueError("金额必须为正");amount > balance → ValueError("余额不足") |
(100, 30)→70;两种错误的消息分别匹配 |
陷阱反例:裸 except: 会吞掉 Ctrl+C(KeyboardInterrupt)和拼写错误(NameError),让 bug 消失得无影无踪;except Exception as e: pass 同样危险。
Block 2a + 2b(2 × 50 分钟):文件、路径、JSON、CSV¶
知识点:A with open(path, encoding="utf-8")、r/w/a、逐行读、write;A Windows 默认 GBK;A pathlib.Path(/、exists、read_text、write_text、mkdir);A json.load/dump(ensure_ascii=False, indent=2);A FileNotFoundError 恢复;B glob、.name/.suffix/.stem;B csv.DictReader/DictWriter(newline="");C os.path。
阅读:廖雪峰《文件读写》《序列化》(JSON 部分);官方教程 7.2;Automate 3e Ch10;research/01 第 2.2 节(为什么 3.15 会改默认编码)。
故事线(2a 开头 10 分钟,必做):用记事本/VS Code 新建 exercises/day2/data/hello.txt,内容"你好,Python",以 UTF-8 保存。在 REPL 里:
from pathlib import Path
p = Path("exercises/day2/data/hello.txt")
print(p.read_text(encoding="utf-8")) # 正常
print(p.read_text(encoding="gbk")) # UnicodeDecodeError(或乱码)
print(open(p).read()) # 在中文 Windows 上默认就是 gbk → 同样出错;本机若已开系统 UTF-8 则不报错
encoding="utf-8"。
练习:exercises/day2/02_files_paths.py(自测在临时目录里进行,不会弄脏仓库;2a 做前 6 题,2b 做后 4 题)
| 函数 | 行为 | 测试用例 |
|---|---|---|
write_lines(path: Path, lines: list[str]) -> None |
UTF-8 写入,每行末尾 \n;父目录不存在则创建 |
写 ["a", "你好"] 后文件内容为 "a\n你好\n" |
read_lines(path: Path) -> list[str] |
UTF-8 读取,去掉行尾换行;文件不存在返回 [](捕获 FileNotFoundError) |
上一题的文件 → ["a", "你好"];不存在的路径 → [] |
count_words(path: Path) -> int |
全文按空白切分计数 | 内容 "a b\nc d e\n" → 5 |
append_log(path: Path, message: str) -> None |
追加模式 "a",一行一条 |
追加两次后 read_lines 得到两行 |
ensure_dir(path: Path) -> Path |
mkdir(parents=True, exist_ok=True),返回 path |
连续调用两次不报错,目录存在 |
demo_gbk_pitfall(folder: Path) -> str |
用 UTF-8 写入 "你好,世界",再故意用 encoding="gbk" 读,捕获 UnicodeDecodeError 并返回字符串 "UnicodeDecodeError: 请显式指定 encoding='utf-8'";若意外没报错则返回 "未触发" |
返回值以 "UnicodeDecodeError" 开头(任何机器上都能稳定复现) |
save_json(path: Path, data) -> None |
json.dump(data, f, ensure_ascii=False, indent=2),UTF-8 |
写 {"名字": "张三", "分数": [90, 80]} 后文件文本里能直接看到 "张三"(不是 \u5f20) |
load_json(path: Path, default) |
文件不存在或不是合法 JSON(json.JSONDecodeError)→ 返回 default |
读回上一题得到原字典;不存在 → default;内容为 "{bad" → default |
list_files_by_suffix(folder: Path, suffix: str) -> list[str] |
glob(f"*{suffix}") 的文件名排序列表 |
目录里有 a.txt, b.txt, c.md → (".txt")→["a.txt", "b.txt"] |
read_csv_rows(path: Path) -> list[dict[str, str]] / write_csv_rows(path: Path, rows: list[dict]) -> None |
csv.DictReader / csv.DictWriter(字段名取自第一行字典),newline="",UTF-8 |
写两行再读回,结果与原列表相等(值都是字符串) |
陷阱反例:不写 encoding;"C:\new\test.txt" 里的 \n \t 变成转义(用 r"..." 或 Path);json.dump 默认 ensure_ascii=True 中文全是 \uXXXX;CSV 不写 newline="" 在 Windows 会多出空行;JSON 不能直接存 set、date、自定义对象。
Block 3(50 分钟):模块与标准库¶
知识点:A import 三种写法、if __name__ == "__main__":;A 自己写模块并导入;A(陷阱)模块名与标准库重名、from x import *;A random、math;B datetime、collections、statistics、sys.argv;B uv add rich;C itertools/functools/re/argparse/logging。
阅读:廖雪峰《模块》《使用模块》;官方教程 6、10.1–10.6;标准库文档中文版查 random、datetime。
练习 1:新建模块 exercises/day2/shapes.py(你自己从零写):
| 函数 | 行为 |
| --- | --- |
| circle_area(r: float) -> float | math.pi * r ** 2;r < 0 → ValueError |
| rectangle_area(w: float, h: float) -> float | 负数 → ValueError |
| triangle_area(a: float, b: float, c: float) -> float | 海伦公式;三边不能构成三角形 → ValueError |
| 文件末尾 if __name__ == "__main__": | 打印三个示例;被 import 时不打印 |
练习 2:exercises/day2/03_modules_stdlib.py(开头 from shapes import circle_area, rectangle_area, triangle_area)
| 函数 | 行为 | 测试用例 |
|---|---|---|
roll_dice(n: int, sides: int = 6, seed: int | None = None) -> list[int] |
用 random.Random(seed),返回 n 个 1..sides |
长度为 n、都在范围内;同 seed 两次结果相同 |
most_common_words(text: str, n: int) -> list[tuple[str, int]] |
collections.Counter(...).most_common(n),先转小写 |
("a b a c b a", 2)→[("a", 3), ("b", 2)] |
group_by_length(words: list[str]) -> dict[int, list[str]] |
defaultdict(list) |
["hi", "yo", "hey"]→{2: ["hi", "yo"], 3: ["hey"]} |
days_between(d1: str, d2: str) -> int |
date.fromisoformat,返回绝对天数 |
("2026-09-05", "2026-10-01")→26 |
format_chinese_date(d: date) -> str |
f"{d.year}年{d.month:02d}月{d.day:02d}日" |
date(2026, 9, 5)→"2026年09月05日" |
median_and_mean(nums: list[float]) -> tuple[float, float] |
statistics.median / mean |
[1, 2, 3, 10]→(2.5, 4.0) |
lcm(a: int, b: int) -> int |
a * b // math.gcd(a, b) |
(4, 6)→12、(7, 3)→21 |
自测里调用 circle_area(1)、triangle_area(3, 4, 5) |
验证模块导入成功 | triangle_area(3, 4, 5) == 6.0;triangle_area(1, 1, 5) 抛 ValueError |
演示(5 分钟):uv add rich → 在 REPL from rich import print; print({"a": [1, 2], "b": "你好"}) 看彩色输出 → uv remove rich(本仓库不依赖它)。理解 pyproject.toml 里 dependencies 的变化。
陷阱反例:把练习文件命名为 random.py 后 import random 导入的是自己(AttributeError: module 'random' has no attribute 'randint');from math import * 污染命名空间。
Block 4a + 4b(2 × 50 分钟):面向对象与 dataclass¶
知识点:A "一切皆对象";A class、__init__、self、实例属性、方法;B @dataclass + field(default_factory=list);B __str__/__repr__/__eq__;B 继承、super().__init__()、方法重写;B 组合优先于继承;B 类属性 vs 实例属性、@property;C 多重继承/MRO/__slots__。
阅读:廖雪峰《类和实例》《访问限制》《继承和多态》;官方教程 9.3–9.5;dataclasses 官方文档中文版开头示例。
预测:
from dataclasses import dataclass, field
@dataclass
class P:
name: str
tags: list[str] = field(default_factory=list)
a = P("x"); b = P("x")
print(a == b, a is b) # ?
print(a) # dataclass 自动生成的 __repr__
class Acc:
def __init__(self, n): self.n = n
c = Acc(1); d = c; d.n = 5
print(c.n) # ? 对象也有别名
练习:exercises/day2/04_oop_dataclass.py(4a 做 Student 与 BankAccount,4b 做 SavingsAccount 与 Classroom)
| 类 / 成员 | 行为 | 测试用例 |
|---|---|---|
@dataclass class Student |
字段 name: str、scores: list[int] = field(default_factory=list) |
Student("A") == Student("A") 为 True;两个实例的 scores 不是同一个列表 |
Student.add_score(score: int) -> None |
0–100 之外 → ValueError |
add_score(101) 抛错 |
Student.average() -> float |
空 → 0.0,否则 round(mean, 1) |
[90, 80]→85.0 |
Student.best() -> int | None |
最高分;空 → None |
|
class BankAccount(手写) |
__init__(self, owner: str, balance: float = 0.0);类属性 bank_name = "Python 银行";balance 用 @property 只读(内部 _balance) |
BankAccount("张三").balance == 0.0;给 balance 赋值抛 AttributeError |
deposit(amount) / withdraw(amount) -> float |
amount <= 0 → ValueError("金额必须为正");余额不足 → ValueError("余额不足");返回新余额 |
deposit(100) 后 withdraw(30)→70.0 |
__str__ / __repr__ / __eq__ |
str(acc) == "张三: 70.00 元";repr 形如 BankAccount(owner='张三', balance=70.0);__eq__ 比较 owner 与 balance |
两个同 owner 同余额的账户 == 为 True |
class SavingsAccount(BankAccount) |
__init__(owner, balance=0.0, rate=0.02) 用 super().__init__;add_interest() -> float 把利息存入并返回利息;重写 __str__ 追加 "(利率 2.0%)" |
SavingsAccount("李四", 1000).add_interest()→20.0,之后余额 1020.0;isinstance(s, BankAccount) 为 True |
@dataclass class Classroom |
name: str、students: list[Student] = field(default_factory=list);add(student)、find(name) -> Student | None、top_student() -> Student | None、class_average() -> float(学生平均分的平均,round(..., 1),空 → 0.0) |
加入 Student("A", [90])、Student("B", [70, 80]) 后 top_student().name == "A",class_average() == 82.5 |
陷阱反例:方法忘写 self;在类体里写 scores = [] 当默认值(所有实例共享同一个列表,和可变默认参数是同一个坑);__init__ 拼成 _init_。
Block 5(50 分钟):Pythonic 写法、迭代与类型注解¶
知识点:A 列表/字典/集合推导式;A sorted(key=lambda ...)、min/max(key=);A any/all、zip/enumerate、sum(生成器表达式);B 生成器 yield、iter/next;B match;B 类型注解全套;C 海象 :=;C t-string。
阅读:廖雪峰《列表生成式》《生成器》《迭代器》;官方教程 4.6(match)、5.1.3、9.8–9.9;官方文档 typing 模块开头。
预测:
print([x * 2 for x in range(3)], {x % 3 for x in range(10)}, {w: len(w) for w in ["a", "bb"]})
g = (x * x for x in range(3))
print(next(g), next(g), next(g)) # 再 next(g) → StopIteration
print(sorted(["b", "A", "c"]), sorted(["b", "A", "c"], key=str.lower))
print(sorted([("a", 3), ("b", 1)], key=lambda t: t[1]))
print(any(x > 5 for x in [1, 7]), all(x > 5 for x in [1, 7]))
练习:exercises/day2/05_pythonic.py(全部函数必须带完整类型注解)
| 函数 | 行为 | 测试用例 |
|---|---|---|
squares_of_evens(nums: list[int]) -> list[int] |
列表推导式 | [1, 2, 3, 4]→[4, 16] |
word_length_map(words: list[str]) -> dict[str, int] |
字典推导式 | ["hi", "hey"]→{"hi": 2, "hey": 3} |
flatten(matrix: list[list[int]]) -> list[int] |
嵌套推导式一行 | [[1, 2], [3], []]→[1, 2, 3] |
sort_by_score(records: list[dict], reverse: bool = True) -> list[dict] |
sorted(key=lambda r: r["score"]),不改入参 |
[{"n": "a", "score": 1}, {"n": "b", "score": 5}] → b 在前 |
all_passed(scores: list[int], threshold: int = 60) -> bool / any_perfect(scores: list[int]) -> bool |
all / any |
[60, 70]→True、[59, 100]→False;any_perfect([99, 100])→True |
countdown(n: int) |
生成器,用 yield(或 yield from range(...))产出 n 到 0 |
list(countdown(3))→[3, 2, 1, 0] |
chunks(items: list, size: int) |
生成器,每次 yield 一段;size <= 0 → ValueError |
list(chunks([1, 2, 3, 4, 5], 2))→[[1, 2], [3, 4], [5]] |
even_sum_below(limit: int) -> int |
sum(x for x in range(limit) if x % 2 == 0) |
10→20 |
describe_command(cmd: str) -> str |
match cmd.split()::["add", a, b]→f"结果:{int(a) + int(b)}";["greet", name]→f"你好,{name}";["quit"]→"再见";[]→"空命令";其他→"未知命令" |
"add 3 4"→"结果:7"、"greet 小明"→"你好,小明"、"quit"→"再见"、""→"空命令"、"jump"→"未知命令" |
find_user(users: list[dict], name: str) -> dict | None |
找不到返回 None(用 next(..., None) 或循环) |
|
numbered(words: list[str]) -> list[tuple[int, str]] |
enumerate(start=1) |
["a", "b"]→[(1, "a"), (2, "b")] |
陷阱反例:生成器只能遍历一次;推导式里的变量不会泄漏到外面(3.x);sorted 返回新列表而 .sort() 原地;match 的 case name: 是"捕获任何值"而不是比较变量。
Block 6(50 分钟):测试与代码质量¶
知识点:B pytest:test_*.py、def test_x(): assert、uv run pytest -q、读输出 ./F/E;B pytest.raises、@pytest.mark.parametrize;A 可测试设计(算与打印分离);A PEP 8 要点;B ruff check/format。
阅读:pytest 官方 "Get Started"(英文,10 分钟);PEP 8 的"命名约定"一节;research/06 第四节。
练习:exercises/day2/06_testing/
- wordtools.py(你实现):
| 函数 | 行为 |
|---|---|
normalize(text: str) -> str |
转小写、去首尾空白、多个空白压成一个空格;传入的不是字符串 → raise TypeError("text 必须是字符串") |
count_words(text: str) -> int |
基于 normalize 后按空格计数;空串 → 0;count_words(None) 因此抛 TypeError(测试里有 pytest.raises 示例) |
longest_word(text: str) -> str |
最长的词;并列取先出现的;空串 → "" |
censor(text: str, banned: list[str]) -> str |
把 banned 中的词(不分大小写、整词匹配)替换为 "***";其余保持 normalize 后的形式 |
test_wordtools.py(已给出 8 个测试,其中含parametrize与pytest.raises示例;末尾有两个# TODO: 补充测试由你写:normalize处理制表符/换行、censor对空banned列表的行为)。- 运行:
uv run pytest exercises/day2/06_testing -q,直到全绿;再uv run ruff check exercises/day2和uv run ruff format exercises/day2。
演示:故意把 count_words 改错,看 pytest 的失败输出长什么样,学会读 assert 3 == 2 那一行的左右值。
Block 7(80 分钟):综合 v2——成绩单文件版¶
目标:把 Day 1 的成绩单程序升级为"读 CSV 文件 → dataclass 建模 → 统计 → 写报表文件 → 有 pytest 测试"的小项目,为周一的 3 小时项目预演一遍完整流程。
练习:exercises/day2/07_grades_v2/:grades_v2.py、test_grades_v2.py(已给出,含 6 个测试)、data/grades.csv(UTF-8,8 行,其中 2 行有脏数据:"缺考"与空值)。
| 成员 | 行为 |
|---|---|
@dataclass class StudentRecord |
name: str、class_name: str、scores: dict[str, int];方法 average() -> float(round(..., 1);空 → 0.0) |
load_records(path: Path) -> tuple[list[StudentRecord], list[str]] |
csv.DictReader(UTF-8);某行任一分数无法 int() → 跳过该行并在 warnings 里记一条 f"第 {行号} 行 {姓名}:分数无效,已跳过";返回 (records, warnings) |
letter_grade(score: float) -> str |
同 Day 1 |
class_averages(records) -> dict[str, float] |
每个班级"学生平均分"的平均,round(..., 1) |
subject_averages(records) -> dict[str, float] |
各科平均,round(..., 1) |
rank_students(records) -> list[tuple[int, str, float]] |
按平均分降序、同分按姓名升序;(名次, 姓名, 平均分) |
build_report(records, warnings) -> str |
多行文本:标题、排名表(每行 f"{名次:>2}. {姓名:<4}{平均:>6.1f} {等级}")、班级平均、各科平均、警告列表 |
save_report(path: Path, text: str) -> None |
UTF-8 写入 |
main() |
读 Path(__file__).parent / "data" / "grades.csv",打印报表并写到 data/report.txt |
对照数据(做完后核对):有效记录 6 条,警告 2 条(第 7 行周八、第 9 行郑十);class_averages == {"1班": 82.6, "2班": 86.7};subject_averages == {"语文": 85.0, "数学": 81.8, "英语": 87.0};排名前三:孙七 92.7、李四 91.0、赵六 86.0。
验收:uv run pytest exercises/day2/07_grades_v2 -q 全绿;uv run python exercises/day2/07_grades_v2/grades_v2.py 打印报表且生成 report.txt;ruff check 零警告;每个函数 ≤ 20 行。
复盘(40 分钟)¶
- AI 代码评审(15 分钟):把
grades_v2.py用ai-guide.md模板 4 让 AI 评审(今天唯一允许 AI 看完整代码的时刻);只改它指出的前 3 条;改完重跑 pytest。 - 学习日志(10 分钟):写进
checklist.md。 - 选定周一项目(15 分钟):读
project/README.md,选 A/B/C 之一(推荐 A),在纸上列出你打算写的 6–8 个函数名和它们的输入输出——明天开工前不用再想。 - 明早晨测范围:Day 1 + Day 2 全部"预测"代码与陷阱反例。22:30 前睡觉。
若时间不够:Block 7 只要求
load_records、rank_students、build_report三个函数通过对应测试;Block 5 的describe_command与chunks可跳过。