
在 Python 的教学节奏里第三次作业往往是最有意思的一道坎。前两次作业大家基本还在跟变量、类型、print、if、for打交道属于“语法验证”阶段到了第三次画风突然就变了——老师不再告诉你每一步调哪个函数而是丢给你一个要自己拆解、自己设计方案、还要能抗住脏数据的完整任务。我见过太多同学在这份作业上第一次体会到“明明语法都会组合起来就报错”的挫败感。这份“Python第三次作业”的核心其实不再是某个语法点而是把变量、循环、函数、文件操作、异常处理、面向对象和可视化串成一条线去解决一个有实际意义的小问题。这篇文章就把它从头到尾拆开讲作业怎么设计、代码怎么写、坑在哪里以及怎么从这份作业里偷师到一点工程经验。1. 第三次作业到底在练什么从抄语法到解决问题的第一次转折1.1 前两次作业的积累和第三次作业的分水岭在哪前两次作业的典型形态是“照着教材敲一遍”就能过的。第一次作业无非是打印个字符串、做个加法器、练练类型转换第二次作业开始写判断、循环、猜数字本质还是在验证“我会不会用if”和“我会不会用for”。这些题目最大的问题在于解题路径是唯一的老师把坑都填平了你只需要把代码填进去。第三次作业不一样。它通常会突然给你一个“开放式需求”比如“请读取某个文件的数据完成统计分析把结果展示出来”。这意味着你第一次需要自己回答几个问题数据存成什么结构程序分几步走中间出错了怎么办是先写数据处理还是先写界面这几个问题恰恰是真实项目里程序员每天都要面对的东西。所以我说第三次作业是“从读代码到组织代码”的转折点也是很多人在这个阶段第一次意识到会写语法和会写程序是两码事。1.2 一份贴近真实场景的作业要求学生成绩统计与分析系统为了让讨论不悬空我直接给一份典型的第三次作业题目后面所有内容都以它为例展开。这份题目我见过很多学校在用也经常被拿来当课程设计的入门题非常适合用来练手。题目要求大概是这样的从本地文本文件scores.txt中读取学生成绩每行格式为姓名,学号,成绩成绩可能是数字也可能出现“缺考”或乱写的非法值。使用类来组织代码至少包含一个学生类和一个管理类。统计全班总人数、有效成绩人数、平均分、最高分、最低分、及格率以及优秀、良好、中等、及格、不及格几个分数段的人数。将统计结果同时输出到控制台和一个report.txt文件中。加分项用 matplotlib 绘制成绩分布直方图。这份题目好在哪里它模拟了真实项目的最小闭环输入读文件→ 处理校验和统计→ 输出展示和落盘。文件可能不干净、数据可能有缺失这些在真实工作中太常见了。很多同学第一次写它的时候会觉得“这也太难了”但拆开看每一步的知识点你都学过只是没人帮你把它串起来。2. 核心开工前必须想清楚的三件事数据结构、异常边界、输出约定2.1 数据怎么存类、字典还是 dataclass动手写代码之前第一件要想清楚的事是“学生数据用什么结构表示”。很多新手会习惯性地把所有信息塞进字典里比如{name: 张三, id: 2024001, score: 88}。这样做不是不行但有个问题字典的键是字符串敲错了不会报错程序只会默默返回None排查起来很费劲。我给的方案是用dataclass定义一个学生类。它写起来比普通类简洁又能享受类型提示的好处from dataclasses import dataclass dataclass class Student: name: str student_id: str score: float | None # None 表示缺考 property def is_pass(self) - bool: return self.score is not None and self.score 60如果你用的 Python 版本比较老低于 3.10float | None这种写法会报错可以用Optional[float]代替也就是from typing import Optional之后写成score: Optional[float]。用dataclass最大的好处是数据结构和业务逻辑绑在一起了以后想加个“是否及格”的判断直接在类里加个属性方法就行不需要在外面写一堆到处判断的函数。这时候“字典派”和“类派”的差距就出来了——代码量差不多但可读性和扩展性差了一个量级。2.2 哪些算异常文件不存在、空行、缺考、非法分数第二件事是明确“异常边界”。很多同学的代码能处理完美数据但一遇到文件里有一行格式不对整个程序就崩了。作业批改时这一项往往是最容易拉开差距的地方。一份真实的scores.txt长什么样大概率长这样张三,2024001,88 李四,2024002,缺考 王五,2024003,abc 赵六,2024004,59.5这四行数据里后三行都有问题李四缺考、王五的成绩不是数字、赵六的成绩是小数。那么你的程序就要明确回答这么几个问题文件不存在时是崩溃还是给出友好提示空行要不要跳过“缺考”算不算有效成绩算不算总人数非法分数abc、负数、大于 100 的数按什么规则处理我建议在代码里明确做以下设计空行直接跳过缺考计入总人数但不参与平均分等统计非法分数抛出异常或者记录到错误列表里然后继续处理后面的数据。我一般会选择记录错误列表而不是直接崩溃因为真实场景里你不可能因为一条脏数据就放弃整个批次。2.3 输出约定屏幕、文件、图表用统一接口第三件事是输出。统计结果至少有三个去处控制台、report.txt、图表。很多同学的代码会写成“统计完马上print”的流水账结果后面想同时输出到文件时不得不把代码复制一遍。正确的做法是分层计算逻辑只负责算输出逻辑只负责展示。统计结果统一放到一个结构里比如字典或对象然后由不同的输出方法去消费它。stats manager.get_statistics() console_printer.print(stats) # 控制台输出 file_printer.print(stats) # 文件输出 plotter.plot(stats) # 图表输出这样做的好处是以后你想加一种新的输出形式比如导出 Excel只需要新增一个方法完全不用动统计逻辑。这个思想就是工程上常说的“单一职责原则”。作业里不一定要求但写出来之后老师看代码的体验会完全不一样。3. 代码落地全流程从定义类到逐行排查的完整实现3.1 类的设计与方法划分明确了上面的三个决策后代码的结构基本就出来了。我建议设计两个类Student负责单条学生数据ScoreManager负责整个班级的数据加载、统计和输出。ScoreManager的方法可以这样划分load_from_file(path)读取文件逐行解析构造Student列表。get_statistics()基于学生列表计算各类统计指标。output_console()打印到控制台。output_file(path)写出到文件。plot_histogram()绘制直方图。之所以把“加载”“统计”“输出”拆成不同方法是为了方便单独调试。比如你发现直方图画得不对只需要去看plot_histogram不用在几百行代码里找哪里在算平均分。这个习惯是第三次作业能带给你的最值钱的东西之一。3.2 数据加载与校验的编写顺序数据加载这部分是新手最容易翻车的地方。核心问题有两个一是编码二是字段解析。先看代码from pathlib import Path class ScoreManager: def __init__(self): self.students: list[Student] [] def load_from_file(self, path: str | Path) - None: path Path(path) if not path.exists(): raise FileNotFoundError(f找不到数据文件: {path}) with path.open(r, encodingutf-8) as f: for line_number, line in enumerate(f, start1): line line.strip() if not line: continue student self._parse_line(line, line_number) if student is not None: self.students.append(student)这里有几个细节值得掰开讲。第一用Path而不是直接拼接字符串路径跨平台更安全。第二encodingutf-8是必须显式声明的否则在 Windows 上会默认用 GBK 读文件遇到 UTF-8 编码的中文直接乱码或报错。第三enumerate(f, start1)是为了定位到具体是哪一行数据出了问题这在排错时非常有用。对应的_parse_line方法长这样def _parse_line(self, line: str, line_number: int) - Student | None: parts [p.strip() for p in line.split(,)] if len(parts) ! 3: print(f第 {line_number} 行格式错误已跳过: {line}) return None name, student_id, score_text parts if score_text 缺考: return Student(namename, student_idstudent_id, scoreNone) try: score float(score_text) except ValueError: print(f第 {line_number} 行成绩不是数字已跳过: {line}) return None if not (0 score 100): print(f第 {line_number} 行成绩超出范围已跳过: {line}) return None return Student(namename, student_idstudent_id, scorescore)这段代码把“每条数据可能怎么坏”全部枚举了一遍字段数量不对、缺考、非数字、超出范围。每一种情况都有对应的提示输出。到这一步数据加载环节才算真正写完了。3.3 统计逻辑的实现细节数据加载完之后统计逻辑其实很简单但有一个口径问题必须提前定好缺考算不算分母。我在 2.2 里已经定过口径缺考计入总人数但不算有效成绩不参与平均分计算。那么统计代码可以这样写def get_statistics(self) - dict: total len(self.students) scores [s.score for s in self.students if s.score is not None] if not scores: return { total: total, valid_count: 0, avg: 0, max: 0, min: 0, pass_rate: 0, levels: {}, } avg sum(scores) / len(scores) pass_count sum(1 for s in self.students if s.is_pass) levels { 优秀(90): sum(1 for s in scores if s 90), 良好(80-89): sum(1 for s in scores if 80 s 90), 中等(70-79): sum(1 for s in scores if 70 s 80), 及格(60-69): sum(1 for s in scores if 60 s 70), 不及格(60): sum(1 for s in scores if s 60), } return { total: total, valid_count: len(scores), avg: round(avg, 2), max: max(scores), min: min(scores), pass_rate: round(pass_count / total * 100, 2), levels: levels, }这里有个特别容易踩的坑avg sum(scores) / len(scores)里如果scores为空直接会抛ZeroDivisionError所以在计算之前必须先判空。很多同学的代码在“全班都缺考”这种极端情况下崩溃就是因为少了这个判断。另外及格率的分母到底用总人数还是有效人数一定要在注释里写清楚这也是实际项目里“统计口径不一致导致吵架”的经典案例。3.4 可视化部分直方图与元素说明可视化是加分项但也最容易让人卡住。一个最基础的成绩分布直方图代码非常简单import matplotlib.pyplot as plt def plot_histogram(self) - None: scores [s.score for s in self.students if s.score is not None] if not scores: print(没有有效成绩无法绘图) return plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False plt.hist(scores, bins10, edgecolorblack, alpha0.7) plt.xlabel(成绩) plt.ylabel(人数) plt.title(成绩分布直方图) plt.grid(axisy, linestyle--, alpha0.5) plt.show()bins10表示把 0 到 100 分成 10 个区间。edgecolorblack是为了让柱子之间有分割线看起来更清楚。alpha0.7是透明度纯属审美调整。真正容易踩坑的是那两行rcParams第一行设置中文字体第二行解决负号显示成方块的问题。如果不设置图里中文全变成小方框这是 matplotlib 的老毛病跟你的代码逻辑没关系但会直接影响作业印象分。4. 作业里最容易扣分的隐藏考点编码、路径、环境4.1 中文乱码源头在读取不在 print中文乱码这事我在批改作业时见过太多次了。很多同学的代码在自己的电脑上跑得好好的交到老师电脑上打开就乱码最后发现是文件编码不一致。问题的根源是Windows 系统默认用 GBK 编码读写文本文件而现代编辑器创建文件时默认用 UTF-8。如果你的scores.txt是 VSCode 创建的 UTF-8 文件但代码里写的是open(scores.txt)那么在 Windows 上就会用 GBK 去读 UTF-8 文件中文自然全乱。解法就是我在 3.2 里的写法读写文件时显式声明encodingutf-8。写report.txt时同样要写with output_path.open(w, encodingutf-8) as f: f.write(统计报告\n)这里还有一个容易忽略的细节report.txt如果用 Windows 自带的记事本打开UTF-8 文件如果开头没有 BOM字节序标记记事本可能还是识别成乱码。如果你确定作业要交到 Windows 环境可以退一步用encodingutf-8-sig写入这样记事本就能正确显示了。这个技巧一般文档里不会写但实际很管用。4.2 相对路径还是绝对路径让作业能换台电脑跑起来路径问题是第三次作业里另一个高频翻车点。典型场景是你在 VSCode 里运行代码scores.txt就在项目文件夹里一切正常换到 PyCharm 里运行突然报FileNotFoundError。为什么因为两个编辑器设置的“工作目录”不一样相对路径scores.txt是相对于当前工作目录解析的而工作目录变了文件自然就找不到了。稳妥的做法是不要直接写相对路径而是基于代码文件本身的目录去定位数据文件。用pathlib很容易实现BASE_DIR Path(__file__).resolve().parent DATA_FILE BASE_DIR / scores.txt__file__是当前代码文件的路径resolve()会把它转成绝对路径然后取它的父目录parent。这样无论你在哪个环境、从哪个目录启动都能找到同一个scores.txt。这个写法在真实项目里也经常用特别是写脚本和工具的时候能省掉很多“换个目录就报错”的麻烦。4.3 环境配置第三方库装不上怎么办作业一旦涉及 matplotlib就绕不开安装第三方库这件事。很多同学的第一个拦路虎不是代码而是pip install matplotlib装了几次都报超时或找不到包。在国内网络环境下默认的 PyPI 源经常很不稳定。我一般建议直接换国内镜像源最省事的方式是在安装时指定镜像pip install -i https://pypi.tuna.tsinghua.edu.cn/simple matplotlib如果只是临时使用这个源上面的命令就够了。想长期生效可以在用户目录下创建一个pip.iniWindows或.pip/pip.confmacOS/Linux配置文件把镜像地址写进去。另一个常见报错是命令提示符里输入python却提示python was not found。这通常不是没装 Python而是 Windows 的 PATH 环境变量没配好。一种快速排查方式是输入py试试Windows 的 Python 启动器一般能直接打开如果py也不行那就老老实实重新安装 Python在安装向导第一步务必勾选“Add Python to PATH”。VSCode 里如果选不到解释器可以按CtrlShiftP输入Python: Select Interpreter手动指定 Python 安装路径。这些环境问题看着跟作业内容没关系但它们消耗的时间一点不比写代码少提前处理好能省下大量精力。5. 画图横坐标太密集matplotlib刻度问题的高频解法5.1 为什么会出现“横坐标挤成一团”成绩直方图里横坐标是数值区间一般不怎么会挤。但如果你把图形换成班级对比柱状图比如横坐标是几十个班级的班号问题就来了matplotlib 默认会尽量把每个数据点都标一个刻度当类别很多、画布又不够宽时所有标签就会重叠成一团黑色。这就是搜索词里“python画图横坐标太密集”这个高频问题的来源。还有一个常见场景是横坐标是日期。如果你处理的数据跨越了好几个月默认刻度可能精细到每天或者反过来只有几个点都需要手动调整。5.2 四种解法横向对比解决“横坐标太密集”没有万能的银弹得看你的数据到底是什么类型。我把最常用的几种方案列成一张表方便对照选择。方案适用场景核心代码副作用旋转标签类别少但文字长plt.xticks(rotation45)只缓解不根治隔几个刻度显示类别多且均匀plt.xticks(range(0, len(x), step))需要手动算步长用 MaxNLocator 限制数量数值型或日期型横轴ax.xaxis.set_major_locator(MaxNLocator(nbins10))自动挑“好看”的刻度增大画布所有类型plt.figure(figsize(12, 6))治标图太大可能变形前两种方法对于作业里的字符串类别数据最常用。举个例子如果横坐标是 30 个班号你可以只显示其中的 5 个避免全部挤在一起import matplotlib.pyplot as plt from matplotlib.ticker import MaxNLocator fig, ax plt.subplots(figsize(10, 5)) ax.bar(class_names, avg_scores) ax.xaxis.set_major_locator(MaxNLocator(nbins6)) ax.tick_params(axisx, rotation30) plt.tight_layout() plt.show()这一段代码同时做了三件事把画布拉长到 10 英寸宽、让 x 轴最多出 6 个刻度、把标签旋转 30 度。配合tight_layout()自动调整布局基本能解决 90% 的“挤成一团”问题。5.3 踩坑备注旋转后标签长、截断和负号方块这里再提醒几个我实测中经常接着冒出来的后续问题。第一旋转标签后如果标签文字太长旋转 45 度或 90 度时左右两侧的标签会被截断。这时候单靠tight_layout()有时不够可以再手动加一行plt.subplots_adjust(bottom0.2)把整个绘图区域往上抬给底部标签留出空间。第二如果中文标签变成方块那就是 3.4 里提到的rcParams没设置。这个一定要放在绘图前面plt.rcParams[font.sans-serif] [SimHei] # Windows 用黑体 plt.rcParams[axes.unicode_minus] False如果你在 macOS 上SimHei不一定存在可以换成[Arial Unicode MS]或者系统里已有的中文字体。判断字体有没有生效直接看图里中文是否还显示成方块就行。第三当横坐标是日期时plt.hist不一定合适更专业的方式是用pandas做分组然后画柱状图再用DateFormatter控制日期格式。作业里大概率用不到但如果你在做个人小项目时遇到可以记住关键词是mdates.DateFormatter别自己硬拼字符串。6. 从作业到真实项目的进阶pandas、协程与多进程到底什么时候用6.1 数据量大了用pandas替换手写统计作业里的数据量撑死几百行手写统计完全没问题。但如果你以后真的要处理几千个学生、几十个班的成绩再用for循环一个个算效率就有点不够看了。这时候就该 pandas 上场了。pandas 处理这种结构化数据是降维打击。比如上面的统计需求用 pandas 只需要几行import pandas as pd df pd.read_csv(scores.txt, names[name, student_id, score]) df[score] pd.to_numeric(df[score], errorscoerce) # 非法值自动变成 NaN print(df[score].describe()) print(df[score].isin(range(60, 101)).mean()) # 及格率口径请自行调整errorscoerce这个参数尤其好用它会把所有不能转成数字的值统一变成NaN相当于把“校验非法成绩”这件事内置了。配合describe()平均值、最值、分位数一次性全出来。我并不是说作业里一定要用 pandas——恰恰相反作业的价值在于让你亲手实现一遍这些逻辑理解底层发生了什么。但作业做完之后去了解 pandas 怎么把这套流程写得更优雅是很有价值的下一步。6.2 协程和多进程作业里别硬上搜索词里“python协程”“python多进程”的热度一直很高但请听我一句在这份第三次作业里协程和多进程都属于过度设计。数据量就几百行读写文件和画图都是毫秒级你用协程不会带来任何体感上的提升反而会让代码复杂度爆炸。那它们什么时候才真正有用记住一个最粗的规则IO 密集任务比如爬虫、批量请求网络接口、读写大量小文件用协程或异步 IOCPU 密集任务比如科学计算、图像处理用多进程。给你一个最朴素的判断方法如果程序大部分时间在“等”那是 IO 密集考虑asyncio如果大部分时间在“算”那是 CPU 密集考虑ProcessPoolExecutor。以爬虫为例几十个 URL 挨个请求可能要等好几秒而用协程并发请求可以缩短到一秒内。这个场景才是协程真正的用武之地。在成绩统计这种“读取-计算-写入”的简单流程里老老实实按顺序写就是最优解。6.3 类型提示、PEP8与自测习惯比抄代码更值钱的收获最后再说一个作业本身不要求、但长远看最值钱的东西代码规范和自测习惯。我在批改这类作业时经常看到两种风格截然不同的代码。一种是把所有逻辑从头写到尾堆在main里中间穿插着几十个print另一种是像我上面写的那样拆成类和方法每个方法做一件具体的事。前者功能也能跑通但一旦中间要改个输出格式整个函数都得大动干戈。后者改起来就轻松很多因为每个模块是独立的。这也是为什么我反复强调“单一职责”——它不是面试八股而是真的能降低你的心智负担。自测习惯上推荐一个最轻量的做法把关键逻辑放进独立函数然后写几个测试数据主动调用。比如我写过_parse_line我会手动构造四五个不同情况的字符串看看它是否按预期返回Student或None。这种成本极低的自测能帮你至少提前发现一半以上的逻辑 bug。等你以后接触pytest会发现这其实就是测试的基本思路只不过现在用最土的方式执行而已。我自己在带这份作业时最常说的一句话是第三次作业的目标不是把功能跑通而是让另一个没看过需求文档的人拿到你的代码之后不问一句话也能看懂流程。能自觉做到这一点比把附加题全做完都管用。如果你现在还在为这份作业发愁别急着搜源码先按这篇的思路把数据结构、异常边界、输出方式定下来代码自然就顺了。