Day 1(周六 09-05):地基——从"会写几行"到"会用数据结构和函数"¶
今日目标:晚上 21:30 前,
exercises/day1/的 8 个文件全部 [OK],07_mini_project_grades.py能打印出一份成绩报表;用 VS Code 断点单步跟过至少一个函数;能不看资料说出"列表别名"和"可变默认参数"为什么会坑人。今日节奏:8 个 50+10 分钟块 + 45 分钟环境块 + 60 分钟综合块。从零写代码的时间约 4.5 小时,其余是读、预测、追踪、找 bug。钟点是参考,晚起就整体平移;宁可砍掉 Block 7 的后半段,也不要熬夜。
| 钟点 | 块 | 内容 |
|---|---|---|
| 09:00–09:15 | 准备 | 读 README.md 第 0 节 + ai-guide.md 七条规则;把今天目标抄在纸上 |
| 09:15–10:00 | Block 0 | 环境与命令行(45 分钟) |
| 10:10–11:00 | Block 1 | 基础类型、运算、f-string |
| 11:10–12:00 | Block 2 | 字符串 |
| 12:00–13:30 | 午餐 + 闭眼 20–30 分钟 | 不看屏幕 |
| 13:30–14:20 | Block 3a | 列表与元组(一):增删改查、切片、遍历 |
| 14:30–15:20 | Block 3b | 列表与元组(二):别名与拷贝(Python Tutor)、嵌套、元组解包 |
| 15:30–16:20 | Block 4 | 字典与集合 |
| 16:20–16:40 | 加餐 + 散步 | |
| 16:40–17:30 | Block 5a | 函数(一):定义、参数、返回值、拆分 |
| 17:40–18:30 | Block 5b | 函数(二):作用域、可变默认参数、*args、类型注解 |
| 18:30–19:30 | 晚餐 | |
| 19:30–20:20 | Block 6 | 调试:读报错、断点单步、找 bug |
| 20:30–21:30 | Block 7 | 综合小程序:成绩单分析(60–80 行) |
| 21:30–22:00 | 复盘 | 写学习日志(checklist.md);预览明早晨测题目范围;22:30 前睡 |
Block 0(45 分钟):环境与命令行¶
知识点:A 在终端运行脚本与 REPL;A VS Code 打开项目、选解释器、运行;B uv 与 pyproject.toml;B ruff。详细来源见 research/02。
步骤(每步有预期结果,不符合就停下来解决,不要带着坏环境往下走):
- 关闭 Windows 商店别名(5 分钟):
Win + I→ 搜索"应用执行别名" → 关掉python.exe和python3.exe。预期:以后在终端输入python不会弹出 Microsoft Store。 - 确认 uv 与 Python(5 分钟):打开 PowerShell(推荐 Windows Terminal),输入
uv --version、uv python list --only-installed。预期:看到 uv 版本号和一个 3.14.x。若提示uv不是命令:运行powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex",关掉终端重开,再uv python install 3.14。 - VS Code 与扩展(10 分钟):安装 VS Code;扩展面板搜索并安装
ms-python.python(会带上 Pylance 与ms-python.debugpy)、charliermarsh.ruff、中文语言包MS-CEINTL.vscode-language-pack-zh-hans。用 VS Code "打开文件夹" 打开learn-python。 - 初始化并运行(10 分钟):在 VS Code 终端(
Ctrl+`)中: 预期:打印 Python 版本(3.14.x)、当前目录、编码信息和一行 [OK] 环境正常。VS Code 右下角应显示解释器为.venv;若没有,Ctrl+Shift+P→ "Python: Select Interpreter" → 选.venv\Scripts\python.exe。 - REPL 五分钟:
uv run python进入交互式解释器(3.14 有彩色提示和语法高亮),逐行试:2 ** 100、"你好" * 3、len("你好")、按 ↑ 翻历史、多行输入一个for循环、输入exit退出。预期:直接输入exit(不用括号)能退出。 - 命令行三板斧(5 分钟):
cd exercises→ls(PowerShell 里等于dir)→cd ..;Tab 键补全路径;用uv run python -c "while True: pass"制造死循环,再用Ctrl+C中断。 - ruff 与断点预演(5 分钟):在
00_env_check.py里故意把一行写成import sys,os,保存(若已配置保存即格式化会自动修正),再运行uv run ruff check exercises/day1/00_env_check.py看提示。点击行号左侧打一个红点断点,按F5→ 选 "Python File" → 程序停在断点,看左侧"变量"面板;F10走一行;Shift+F5停止。
不做的事:不手动创建/激活虚拟环境(
uv run会自动处理);不装 Anaconda;不升级到 3.14.7。 常见坑速查:research/02第四节(商店别名、Activate.ps1执行策略、GBK 编码、路径反斜杠、No Python interpreter is selected)。
Block 1(50 分钟):基础类型、运算、f-string¶
知识点:A int/float/bool/str/None;A / 与 //、%、**;A input() 返回字符串与类型转换;A 比较与链式比较、and/or/not 短路;A 真值;A f-string(:.2f、:>8、:02d、{x=});B round/abs/divmod;C % 格式化。
阅读(10 分钟):廖雪峰《数据类型和变量》《字符串和编码》(只读格式化部分);官方教程 3.1.1 数字;Automate 3e Ch1 的 "Math Operators" 与 Ch1 末的 f-string。
预测(写在纸上,再到 REPL 验证):
print(7 / 2, 7 // 2, 7 % 2)
print(-7 // 2, -7 % 2) # 向下取整;余数与除数同号
print(2 ** 10, 10 ** -1)
print(int("42") + 1, str(42) + "1")
print(True + True, bool(""), bool("0"), bool([]))
print(0.1 + 0.2 == 0.3, round(0.1 + 0.2, 2) == 0.3)
x = 3.14159
print(f"{x:.2f}|{x:>10.3f}|{x:<8}|{x=}")
print(f"{7:02d}:{5:02d}", f"{1234567:,}", f"{0.256:.1%}")
练习:exercises/day1/01_types_and_fstrings.py
| 函数 | 行为 | 测试用例(自测断言) |
|---|---|---|
celsius_to_fahrenheit(c: float) -> float |
c * 9 / 5 + 32,round(..., 1) |
0→32.0、100→212.0、-40→-40.0、36.6→97.9 |
describe_number(n: int) -> str |
返回 "零"/"正奇数"/"正偶数"/"负奇数"/"负偶数" |
0→"零"、4→"正偶数"、7→"正奇数"、-3→"负奇数"、-8→"负偶数" |
seconds_to_hms(total: int) -> str |
用 // 和 % 拆成时分秒,f"{h:02d}:{m:02d}:{s:02d}" |
0→"00:00:00"、3661→"01:01:01"、86399→"23:59:59"、90061→"25:01:01" |
format_receipt(item: str, price: float, qty: int) -> str |
精确格式 f"{item:<10}{qty:>3} x {price:>8.2f} = {price * qty:>9.2f}" |
("苹果", 3.5, 4) → "苹果" + " " * 8 + " 4 x 3.50 = 14.00";("pen", 1.25, 10) → "pen 10 x 1.25 = 12.50" |
is_close(a: float, b: float, tol: float = 1e-9) -> bool |
abs(a - b) < tol |
is_close(0.1 + 0.2, 0.3) 为 True;is_close(1.0, 1.1) 为 False |
average(nums: list[float]) -> float |
空列表返回 0.0,否则 sum / len |
[1, 2, 3]→2.0、[]→0.0、[2.5]→2.5 |
陷阱反例(在文件注释里出现,你要能解释):input() 得到的 "3" + 1 报 TypeError;0.1 + 0.2 != 0.3(二进制浮点,官方教程第 15 章);/ 的结果永远是 float,10 / 2 是 5.0。
复盘问题:// 对负数为什么是 -4 而不是 -3?什么时候该用 round,什么时候该用 is_close?
Block 2(50 分钟):字符串¶
知识点:A 索引/负索引/切片/[::-1];A 不可变;A strip/split/join/replace/upper/lower/startswith/endswith/find/count/isdigit/isalpha;A 转义、r"..."、三引号;B ord/chr、Unicode 与 UTF-8;A(陷阱)方法返回新串。
阅读:廖雪峰《字符串和编码》全篇、《切片》;官方教程 3.1.2 文本;Automate 3e Ch8 前半。
预测:
s = "Python"
print(s[0], s[-1], s[1:4], s[::-1], s[::2], s[10:]) # 切片越界不报错
print(s.upper(), s) # s 本身不变
print("a,b,,c".split(","), " x ".strip(), "-".join(["a", "b"]))
print("3" * 3, "3" + "4", len("你好"), "你好".encode("utf-8"))
# s[0] = "J" → TypeError: 'str' object does not support item assignment
# s[10] → IndexError(索引越界报错,切片越界不报错)
练习:exercises/day1/02_strings.py
| 函数 | 行为 | 测试用例 |
|---|---|---|
is_palindrome(s: str) -> bool |
忽略大小写与空格 | "上海自来水来自海上"→True、"Never odd or even"→True、"python"→False、""→True |
count_vowels(s: str) -> int |
统计 a/e/i/o/u(不分大小写) | "Hello World"→3、"xyz"→0、"AEIOU"→5 |
capitalize_words(s: str) -> str |
用 split() + join(),每个词首字母大写、多余空白压成单个空格(不要用 .title()) |
"hello world"→"Hello World"、" python is fun "→"Python Is Fun"、""→"" |
mask_phone(phone: str) -> str |
11 位手机号中间 4 位换 *;长度不为 11 或含非数字 → raise ValueError |
"13812345678"→"138****5678";"123" 抛 ValueError |
caesar_shift(s: str, k: int) -> str |
只移动英文字母、保留大小写、其他字符原样(用 ord/chr 和 %) |
("abc", 1)→"bcd"、("xyz", 3)→"abc"、("Hello, World!", 13)→"Uryyb, Jbeyq!"、("abc", -1)→"zab" |
word_lengths(sentence: str) -> list[int] |
每个词的长度 | "I love Python"→[1, 4, 6]、""→[] |
extract_domain(email: str) -> str |
@ 后面的部分;没有 @ 或有多个 @ → ValueError |
"user@example.com"→"example.com";"bad.email" 抛 ValueError |
陷阱反例:s.upper() 之后 s 没变(要 s = s.upper());"abc"[3] 报错但 "abc"[3:] 是 "";len("你好") 是 2 个字符但 len("你好".encode()) 是 6 个字节。
Block 3a + 3b(2 × 50 分钟):列表与元组¶
知识点:A 创建/索引/切片/嵌套;A append/extend/insert/pop/remove/index/count;A sort() 原地返回 None vs sorted();A for / enumerate / zip / range;A 别名与拷贝;A 元组与解包;A(陷阱)遍历时修改;B [[0]*3]*3 陷阱;B 深拷贝。
阅读:廖雪峰《使用 list 和 tuple》《切片》《迭代》;官方教程 5.1、5.3;Automate 3e Ch6(尤其 "References" 一节)。
预测(3b 开头,把前三段贴到 Python Tutor 看动画):
a = [1, 2, 3]
b = a
b.append(4)
print(a) # ?
c = a[:]
c.append(5)
print(a, c) # ?
x = [3, 1, 2].sort()
print(x) # ? ← sort() 返回 None
t = (1,)
print(type(t), type((1)))
for i in range(1, 10, 3):
print(i, end=" ")
grid = [[0] * 3] * 2
grid[0][0] = 9
print(grid) # ? ← 两行是同一个列表
练习:exercises/day1/03_lists_tuples.py(3a 做前 5 题,3b 做后 4 题)
| 函数 | 行为 | 测试用例 |
|---|---|---|
running_total(nums: list[int]) -> list[int] |
前缀和 | [1, 2, 3, 4]→[1, 3, 6, 10]、[]→[] |
remove_duplicates_keep_order(items: list) -> list |
去重且保持首次出现顺序 | [3, 1, 3, 2, 1]→[3, 1, 2]、[]→[] |
rotate(lst: list, k: int) -> list |
返回新列表,右旋 k 位;k 可大于长度(用 %);空列表返回 [] |
([1, 2, 3, 4, 5], 2)→[4, 5, 1, 2, 3]、([1, 2, 3], 4)→[3, 1, 2]、([], 3)→[] |
top_n(scores: list[int], n: int) -> list[int] |
降序前 n 个,不能修改入参(用 sorted) |
([70, 95, 88, 60], 2)→[95, 88];调用后原列表仍为 [70, 95, 88, 60] |
pair_up(names: list[str], scores: list[int]) -> list[tuple[str, int]] |
zip;长度不同 → ValueError |
(["张三", "李四"], [90, 85])→[("张三", 90), ("李四", 85)] |
min_max(nums: list[int]) -> tuple[int, int] |
返回 (最小, 最大);空 → ValueError |
lo, hi = min_max([3, 1, 2]) 得 1, 3 |
safe_copy_and_append(lst: list, item) -> list |
返回追加后的新列表,原列表不变 | orig = [1, 2]; new = safe_copy_and_append(orig, 3) → new == [1, 2, 3] 且 orig == [1, 2] |
transpose(matrix: list[list[int]]) -> list[list[int]] |
行列互换 | [[1, 2, 3], [4, 5, 6]]→[[1, 4], [2, 5], [3, 6]] |
make_grid(rows: int, cols: int, fill: int = 0) -> list[list[int]] |
每行必须是独立对象 | g = make_grid(2, 2); g[0][0] = 1 后 g[1][0] == 0 |
陷阱反例:b = a 不是复制;lst.sort() 返回 None;for x in lst: lst.remove(x) 会漏删;[[0]*3]*3。
Block 4(50 分钟):字典与集合¶
知识点:A 增删改查、in、get、keys/values/items;A 计数模式与分组模式;A 键必须可哈希;A 嵌套结构(列表里装字典);B 集合运算;B Counter/defaultdict;B 字典/集合推导式。
阅读:廖雪峰《使用 dict 和 set》;官方教程 5.4–5.6;Automate 3e Ch7。
预测:
d = {"a": 1}
d["b"] = 2
print(len(d), d.get("z"), d.get("z", 0))
for k in d: print(k, d[k])
for k, v in d.items(): print(k, v)
print({1, 2, 2, 3}, len({1, 2, 2, 3}))
print({1, 2, 3} & {2, 3, 4}, {1, 2, 3} - {2})
# d["z"] → KeyError
# {[1, 2]: "x"} → TypeError: unhashable type: 'list'
练习:exercises/day1/04_dicts_sets.py
| 函数 | 行为 | 测试用例 |
|---|---|---|
word_frequency(text: str) -> dict[str, int] |
转小写、按空白切分、每个词 strip(".,!?")、用 get 计数 |
"the cat and the hat."→{"the": 2, "cat": 1, "and": 1, "hat": 1}、""→{} |
invert_dict(d: dict) -> dict |
键值互换 | {"a": 1, "b": 2}→{1: "a", 2: "b"} |
merge_scores(a: dict[str, int], b: dict[str, int]) -> dict[str, int] |
同键相加,不修改入参 | ({"x": 1, "y": 2}, {"y": 3, "z": 4})→{"x": 1, "y": 5, "z": 4} |
group_by_first_letter(words: list[str]) -> dict[str, list[str]] |
用 setdefault 或 defaultdict 分组 |
["apple", "avocado", "banana"]→{"a": ["apple", "avocado"], "b": ["banana"]} |
common_and_unique(a: list, b: list) -> tuple[set, set] |
(交集, 对称差) |
([1, 2, 3], [2, 3, 4])→({2, 3}, {1, 4}) |
top_student(grades: dict[str, list[int]]) -> str |
平均分最高者;空字典 → ValueError |
{"张三": [90, 80], "李四": [95, 85]}→"李四" |
count_by_category(records: list[dict], key: str) -> dict[str, int] |
按某字段计数(列表里装字典) | ([{"name": "苹果", "type": "水果"}, {"name": "白菜", "type": "蔬菜"}, {"name": "梨", "type": "水果"}], "type")→{"水果": 2, "蔬菜": 1} |
unique_sorted(items: list[int]) -> list[int] |
去重后升序 | [3, 1, 3, 2]→[1, 2, 3] |
陷阱反例:d["不存在"] 崩溃 vs d.get(...);列表不能当键;遍历字典时增删键会 RuntimeError。
Block 5a + 5b(2 × 50 分钟):函数与作用域¶
知识点:A def/参数/return/多值返回/docstring;A 位置与关键字参数、默认值;A 可变默认参数陷阱;A 局部 vs 全局、global 为什么要避免;A 把 50 行拆成 4–6 个函数;B 类型注解;B *args/**kwargs、仅关键字参数;B lambda、函数作为参数;C 闭包/装饰器/递归。
阅读:廖雪峰《调用函数》《定义函数》《函数的参数》;官方教程 4.8–4.9;Automate 3e Ch4。
预测(5b 开头,贴到 Python Tutor 看栈帧):
total = 0
def add(n):
total = total + n # ? ← UnboundLocalError:函数内赋值使 total 成为局部变量
return total
x = 10
def show():
print(x) # 只读全局变量没问题
show()
def f(item, lst=[]): # 默认值只在定义时创建一次!
lst.append(item)
return lst
print(f(1), f(2)) # ?
def g(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
print(g(1), g(2)) # ?
练习:exercises/day1/05_functions.py(5a 做前 4 题,5b 做后 5 题)
| 函数 | 行为 | 测试用例 |
|---|---|---|
safe_divide(a: float, b: float) -> float | None |
b == 0 返回 None |
(10, 4)→2.5、(1, 0)→None |
clamp(x: int, lo: int = 0, hi: int = 100) -> int |
夹在区间内 | 150→100、-5→0、50→50、clamp(7, lo=1, hi=5)→5 |
stats(nums: list[float]) -> tuple[float, float, float] |
(最小, 最大, 平均);空 → ValueError |
[2, 4, 6]→(2, 6, 4.0) |
apply_discount(price: float, percent: float = 10) -> float |
打折并 round(..., 2) |
100→90.0、(59.9, 15)→50.91(50.915 在二进制里略小于 50.915,所以舍到 50.91——这就是 Block 1 讲的浮点问题) |
append_item(item, items: list | None = None) -> list |
正确处理可变默认参数 | append_item(1) 和 append_item(2) 分别得到 [1] 和 [2] |
greet(name: str, *, greeting: str = "你好") -> str |
仅关键字参数;返回 f"{greeting},{name}!" |
greet("小明")→"你好,小明!"、greet("Tom", greeting="Hi")→"Hi,Tom!";greet("A", "B") 抛 TypeError |
summarize(*nums: float) -> dict[str, float] |
{"count", "total", "avg"},无参数时 avg 为 0.0 |
summarize(1, 2, 3)→{"count": 3, "total": 6, "avg": 2.0}、summarize()→{"count": 0, "total": 0, "avg": 0.0} |
apply_twice(func, x) |
func(func(x)) |
apply_twice(lambda v: v * 2, 3)→12 |
event_report(events: list[str]) -> str |
拆分练习:必须另写 count_events(events) -> dict[str, int] 和 format_line(name, n) -> str(返回 f"{name}: {n} 次"),再由 event_report 按名称升序拼成多行 |
["登录", "下单", "登录"] → "下单: 1 次\n登录: 2 次";[]→"" |
陷阱反例:可变默认参数;函数没写 return 得到 None;在函数里给全局变量赋值;把变量命名为 list、str、sum、input(遮蔽内置名)。
Block 6(50 分钟):调试——读报错、断点单步、找 bug¶
知识点:A 读 Traceback(最后一行看异常名,往上找自己文件的行号);A 常见异常含义;A print(f"{x=}");A VS Code 断点/F10/F11/变量面板;B assert。
阅读:Automate 3e Ch5 前半(Raising Exceptions 之前);官方教程 8.1–8.2;research/04 第四节的陷阱清单挑 5 条读。
练习:exercises/day1/06_debugging_bughunt.py —— 文件里有 7 个带 bug 的函数,测试是正确的。规则:每个函数先在 VS Code 里打断点单步跟到出错处,写下"现象 → 原因",再改。
| 函数 | 植入的 bug | 正确行为(测试) |
|---|---|---|
average(nums) |
空列表除零 | []→0.0、[1, 2, 3]→2.0 |
find_max(nums) |
初始值 best = 0,全负数时出错 |
[-5, -2, -9]→-2、[3, 7, 1]→7 |
remove_negatives(nums) |
遍历时 remove,漏删相邻负数 |
[1, -2, -3, 4]→[1, 4]、[-1, -1]→[] |
is_leap_year(year) |
漏了 400 年规则 | 2000→True、1900→False、2024→True、2023→False |
count_down(n) |
range 边界差一 |
3→[3, 2, 1, 0]、0→[0] |
add_student(name, roster=[]) |
可变默认参数 | 两次不传 roster 的调用各得长度 1 的列表 |
next_year_age(text) |
忘了 int(),"17" + 1 报 TypeError |
"17"→18 |
复盘问题:TypeError 和 ValueError 的区别?IndexError 和 KeyError 分别来自哪种数据结构?
Block 7(60 分钟):综合小程序——成绩单分析¶
目标:把今天所有东西串起来,写一个 60–80 行、有 7 个函数的程序。数据先用多行字符串(文件读写是明天的内容)。
练习:exercises/day1/07_mini_project_grades.py
数据(文件里已给出):
| 函数 | 行为 | 测试用例 |
|---|---|---|
parse_report(text: str) -> list[dict] |
首行是表头;每行一个字典,分数转 int;跳过空行 |
第一条为 {"姓名": "张三", "语文": 85, "数学": 92, "英语": 78};共 4 条 |
student_average(record: dict) -> float |
数值字段平均,round(..., 1) |
张三 85.0、王五 71.7 |
subject_averages(records: list[dict]) -> dict[str, float] |
各科平均,round(..., 1) |
{"语文": 83.2, "数学": 81.0, "英语": 86.0}(注意 83.25 四舍五入到 83.2 是"银行家舍入",文件注释里解释) |
letter_grade(score: float) -> str |
A≥90,B≥80,C≥70,D≥60,其余 F | 91.0→"A"、85.0→"B"、71.7→"C"、59.9→"F" |
grade_distribution(records: list[dict]) -> dict[str, int] |
按学生平均分的等级计数;A–F 五个键都要有 | {"A": 1, "B": 2, "C": 1, "D": 0, "F": 0} |
top_students(records: list[dict], n: int = 3) -> list[tuple[str, float]] |
按平均分降序 | [("李四", 91.0), ("赵六", 86.0), ("张三", 85.0)] |
build_report(records: list[dict]) -> str |
多行报表:标题行、每人一行 f"{姓名:<4}{平均:>6.1f} {等级}"、各科平均、等级分布 |
断言结果包含子串 "李四"、"91.0"、"A"、"语文"、"83.2" |
main() |
print(build_report(parse_report(RAW))) |
运行文件能看到报表 |
要求:每个函数 ≤ 15 行;不用全局变量传数据;先写 parse_report 并打印结果确认再写下一个。做完后运行 uv run ruff check exercises/day1 与 uv run ruff format exercises/day1。
复盘(30 分钟)¶
- 在
checklist.md勾掉今天完成的块,写学习日志(模板见ai-guide.md第三节)。 - 合上电脑,在纸上默写:
for k, v in d.items()、列表切片lst[a:b:c]、def f(x, lst=None)的写法、f"{x:>8.2f}"的含义。写不出来的就是明早晨测会考的。 - 明早晨测范围:Block 1–5 的"预测"代码与陷阱反例。22:30 前睡觉。
若时间不够:Block 7 只完成
parse_report、student_average、letter_grade三个函数即可,其余明天 Block 7 综合 v2 会重做一次。