问题与目标
前四篇把环境、数据类型、流程控制、函数、文件、异常和面向对象串了一遍。每个例子单独运行时都不算难,但这也容易制造一种错觉:代码读起来没有障碍,就以为自己已经能独立写出来。
真正面对一个没有现成步骤的小需求时,问题很快暴露出来:
- 知道列表和字典,却不确定数据应该怎么组织。
- 知道要写函数,却把读取、清洗和输出全塞进一个函数。
- 正常输入能处理,空字符串和错误类型一来就报错。
- 结果虽然正确,但原始数据在不知不觉中被改了。
- 调试时只盯着最后一行报错,没有向上寻找错误输入来自哪里。
所以这篇不再增加新语法。我用一个综合练习检查前面的基础是否真的连了起来,再把错误按原因归类。
核心理解
练习的价值不是统计“做对多少题”,而是找到哪一种错误会重复出现。
我把错误分成四层:
语法错误:代码无法被解析
运行错误:类型、索引、键或文件操作失败
逻辑错误:程序能运行,但结果不符合需求
设计问题:结果暂时正确,但代码很难修改和复用
前两层通常会有明确报错,后两层更隐蔽。尤其是逻辑错误,程序可能非常自信地输出一个错误结果。
因此,验证不能只看“有没有报错”,还要提前写清输入、预期输出和边界情况。
练习范围与完成标准
练习分成五层,每层都比上一层多组合一点能力:
| 层级 | 主要内容 | 检查重点 |
|---|---|---|
| 1 | 类型与运算 | 输入转换、边界判断 |
| 2 | 条件与循环 | 分支顺序、循环结束条件 |
| 3 | 字符串与容器 | 清洗、统计、去重、顺序 |
| 4 | 函数 | 参数、返回值、职责拆分 |
| 5 | 文件、异常和对象 | 外部输入、失败处理、结构组织 |
动手实现前,先写输入和预期输出。下面给出的实现不是唯一写法;如果另一种写法更清楚、边界也正确,同样成立。
分层练习
练习一:检查模型参数
要求:接收一个温度值和最大输出长度。温度必须在 0 到 2 之间,最大输出长度必须在 1 到 8192 之间。输出所有不合法原因。
输入:
temperature = 2.5
max_tokens = 0
预期输出:
temperature 必须在 0 到 2 之间
max_tokens 必须在 1 到 8192 之间
参考实现:
temperature = 2.5
max_tokens = 0
errors = []
if not 0 <= temperature <= 2:
errors.append("temperature 必须在 0 到 2 之间")
if not 1 <= max_tokens <= 8192:
errors.append("max_tokens 必须在 1 到 8192 之间")
if errors:
for error in errors:
print(error)
else:
print("参数有效")
这里不能写成 if ... elif ...,否则第一个条件不合法后,第二个条件不会继续检查。两个规则彼此独立,应该使用两个 if。
练习二:限制重试次数
要求:模拟一次最多重试三次的模型请求。下面的状态中,前两次失败,第三次成功;成功后立即结束循环。
request_results = [False, False, True]
for attempt, success in enumerate(request_results, start=1):
print(f"第 {attempt} 次请求")
if success:
print("请求成功")
break
print("请求失败,准备重试")
else:
print("达到最大重试次数")
for 后面的 else 只在循环没有被 break 中断时执行。它不算最常用语法,但在“查找失败”或“重试耗尽”场景中表达得很直接。
把三个状态都改为 False,可以验证最大次数耗尽的分支。
练习三:统计关键词频率
要求:忽略大小写和首尾常见标点,统计一段英文文本中的词频,并按出现次数从高到低输出。
text = "Python helps AI, and Python helps automation."
punctuation = ",.!?"
counts: dict[str, int] = {}
for raw_word in text.lower().split():
word = raw_word.strip(punctuation)
if not word:
continue
counts[word] = counts.get(word, 0) + 1
sorted_counts = sorted(
counts.items(),
key=lambda item: item[1],
reverse=True,
)
for word, count in sorted_counts:
print(word, count)
预期 python 和 helps 的次数都是 2。
这不是完整的自然语言分词方案。它只适合检验字符串、字典、循环和排序是否掌握,不能直接替代中文分词器或大模型 Tokenizer。
练习四:实现带重叠的文本切分
要求:把字符串按固定长度切分,相邻片段保留指定重叠;chunk_size 必须大于 overlap,且二者不能为负数。
def split_text(text: str, chunk_size: int, overlap: int = 0) -> list[str]:
if chunk_size <= 0:
raise ValueError("chunk_size 必须大于 0")
if overlap < 0:
raise ValueError("overlap 不能小于 0")
if overlap >= chunk_size:
raise ValueError("overlap 必须小于 chunk_size")
step = chunk_size - overlap
return [
text[start:start + chunk_size]
for start in range(0, len(text), step)
]
print(split_text("ABCDEFGHIJ", chunk_size=4, overlap=1))
输出:
['ABCD', 'DEFG', 'GHIJ', 'J']
最后的 J 与前一片段完全重复,没有新增内容。更严格的实现可以在剩余内容已经被前一片段覆盖时停止:
def split_text(text: str, chunk_size: int, overlap: int = 0) -> list[str]:
if chunk_size <= 0:
raise ValueError("chunk_size 必须大于 0")
if overlap < 0 or overlap >= chunk_size:
raise ValueError("overlap 必须在 0 到 chunk_size - 1 之间")
chunks = []
start = 0
while start < len(text):
end = min(start + chunk_size, len(text))
chunks.append(text[start:end])
if end == len(text):
break
start = end - overlap
return chunks
print(split_text("ABCDEFGHIJ", chunk_size=4, overlap=1))
现在输出为:
['ABCD', 'DEFG', 'GHIJ']
这道题很适合暴露边界错误:表面上两个实现都能切分,只有专门检查末尾片段时才能发现重复。
练习五:统计目录中的文本文件
要求:统计目录下每个 UTF-8 .txt 文件的非空行数。单个文件无法读取时记录错误,不中断整个批次。
from pathlib import Path
def count_non_empty_lines(path: Path) -> int:
with path.open("r", encoding="utf-8") as file:
return sum(1 for line in file if line.strip())
def scan_text_files(directory: Path) -> tuple[dict[str, int], list[str]]:
if not directory.is_dir():
raise NotADirectoryError(directory)
counts = {}
errors = []
for path in sorted(directory.glob("*.txt")):
try:
counts[path.name] = count_non_empty_lines(path)
except (OSError, UnicodeDecodeError) as error:
errors.append(f"{path.name}: {error}")
return counts, errors
验证时应准备三个文件:正常多行文本、包含空行的文本、无法按 UTF-8 解码的文件。只准备正常文件,无法证明异常分支有效。
综合练习:整理一批知识片段
需求
程序收到一批准备写入知识库的原始记录,需要完成这些任务:
- 只保留包含
title和content的字典。 - 去掉标题和正文两端的空白。
- 丢弃标题或正文为空的记录。
- 按标题去重,保留第一次出现的记录。
- 把过长正文按固定长度切成片段。
- 为每个片段生成稳定编号。
- 输出有效文档数、片段数和错误原因。
示例输入故意包含重复、空内容和错误类型:
raw_documents = [
{"title": " Python 环境 ", "content": " venv 用于隔离项目依赖。 "},
{"title": "文件编码", "content": "UTF-8 能把 Unicode 字符编码成字节。"},
{"title": "Python 环境", "content": "重复文档不应再次进入知识库。"},
{"title": "空文档", "content": " "},
"这不是一条字典记录",
]
验收条件
运行前先明确结果:
- 有效文档是 2 篇。
- 重复标题、空正文和错误类型各产生一条错误记录。
- 片段编号应稳定,例如
doc-001-chunk-001。 chunk_size小于等于 0 时主动抛出ValueError。- 原始输入不能被函数原地修改。
这些条件相当于一份最小测试说明。没有它们,“输出了一些内容”很难证明逻辑正确。
参考实现
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class Document:
title: str
content: str
@dataclass(frozen=True)
class Chunk:
chunk_id: str
title: str
content: str
def clean_documents(raw_items: list[Any]) -> tuple[list[Document], list[str]]:
documents = []
errors = []
seen_titles = set()
for index, item in enumerate(raw_items, start=1):
if not isinstance(item, dict):
errors.append(f"第 {index} 条不是字典")
continue
title = str(item.get("title", "")).strip()
content = str(item.get("content", "")).strip()
if not title or not content:
errors.append(f"第 {index} 条标题或正文为空")
continue
if title in seen_titles:
errors.append(f"第 {index} 条标题重复:{title}")
continue
seen_titles.add(title)
documents.append(Document(title=title, content=content))
return documents, errors
def split_documents(documents: list[Document], chunk_size: int) -> list[Chunk]:
if chunk_size <= 0:
raise ValueError("chunk_size 必须大于 0")
chunks = []
for document_index, document in enumerate(documents, start=1):
parts = [
document.content[start:start + chunk_size]
for start in range(0, len(document.content), chunk_size)
]
for chunk_index, part in enumerate(parts, start=1):
chunk_id = f"doc-{document_index:03d}-chunk-{chunk_index:03d}"
chunks.append(
Chunk(
chunk_id=chunk_id,
title=document.title,
content=part,
)
)
return chunks
raw_documents = [
{"title": " Python 环境 ", "content": " venv 用于隔离项目依赖。 "},
{"title": "文件编码", "content": "UTF-8 能把 Unicode 字符编码成字节。"},
{"title": "Python 环境", "content": "重复文档不应再次进入知识库。"},
{"title": "空文档", "content": " "},
"这不是一条字典记录",
]
documents, errors = clean_documents(raw_documents)
chunks = split_documents(documents, chunk_size=12)
print(f"有效文档:{len(documents)}")
print(f"生成片段:{len(chunks)}")
for chunk in chunks:
print(chunk)
for error in errors:
print(f"跳过:{error}")
这段代码没有用到复杂算法,难点在于把需求变成一组稳定规则:什么是有效输入,谁负责清洗,谁负责切分,错误如何保留,编号如何生成。
它也把前四篇的内容串了起来:
- 列表、字典、字符串和集合负责表示数据。
- 条件和循环负责验证及批处理。
- 函数划分清洗与切分职责。
Document和Chunk固定处理后的数据结构。ValueError拒绝不合理参数。- 类型标注让输入输出更容易检查。
典型错题与修正
错题一:把循环边界写错
切分文本时,容易把 range() 的结束位置理解成“包含末尾”。实际上结束值不包含在结果中:
text = "abcdefgh"
size = 3
parts = [text[start:start + size] for start in range(0, len(text), size)]
print(parts) # ['abc', 'def', 'gh']
我的修正方法不是继续背“左闭右开”,而是用长度不能整除的短文本验证最后一个片段有没有丢。
错题二:直接访问可能不存在的字典键
写 item["title"] 时,如果键不存在,会触发 KeyError。对于来源不可靠的数据,可以先用 get() 提供默认值,再执行业务验证。
不过 get() 也不能滥用。内部已经校验过的数据如果仍然到处给默认值,真正的程序错误可能被隐藏。是否使用默认值,取决于数据边界是否可信。
错题三:修改了传入的原始数据
为了省一个变量,直接对原字典执行:
item["title"] = item["title"].strip()
程序结果可能没错,但调用方手中的原始输入也被改变了。综合练习中我改为创建新的 Document,让清洗前后的数据边界更清楚。
错题四:用集合去重后丢失顺序
直接 list(set(titles)) 可以去重,但顺序不应被当作可靠承诺。需求要求“保留第一次出现的文档”,所以我使用 seen_titles 判断,同时按原顺序向结果列表追加。
数据结构能做什么,不等于它符合当前业务规则。
错题五:函数只打印结果,没有返回结果
如果 clean_documents() 只在内部打印错误,调用方就很难统计、记录或测试这些错误。现在函数返回 (documents, errors),展示方式留给外层决定。
我由此形成了一个检查习惯:函数产生的信息,后续是否还要使用?如果要,就返回数据,不要只打印。
错题六:变量名覆盖内置能力
一种很自然但不合适的写法是:
list = []
id = "doc-001"
代码短期内可能正常,但 list() 和 id() 随后无法按原意调用。更合适的名称是 documents、chunk_id 这类带业务含义的名字。
错题七:异常处理只追求“不崩”
把所有逻辑包进 try/except Exception,再打印一句“处理失败”,程序确实没有直接退出,但错误发生在哪条记录、为什么失败,全都丢了。
综合练习把两类情况分开:
- 单条脏数据是批处理中可以预期的业务问题,记录原因并继续。
chunk_size <= 0是调用约定被破坏,直接抛出异常。
并不是所有失败都应该用同一种方式处理。
用 assert 自动检查结果
人工查看输出容易漏掉细节。assert 可以把预期结果直接写进代码;表达式为假时,程序会抛出 AssertionError:
def normalize_title(title: str) -> str:
return title.strip().lower()
assert normalize_title(" Python ") == "python"
assert normalize_title("") == ""
assert normalize_title(" RAG ") == "rag"
为前面的文本切分函数补上边界验证:
assert split_text("ABCDEFGHIJ", 4, 1) == ["ABCD", "DEFG", "GHIJ"]
assert split_text("ABC", 4, 1) == ["ABC"]
assert split_text("", 4, 1) == []
预期某次调用抛出异常时,可以明确检查:
try:
split_text("ABC", chunk_size=3, overlap=3)
except ValueError:
pass
else:
raise AssertionError("overlap 非法时应抛出 ValueError")
assert 适合练习、自测和内部不变量检查,但不能代替业务输入验证。Python 使用优化模式运行时可能跳过断言,因此用户输入、权限和数据合法性仍应使用普通判断并主动抛出异常。
综合练习也可以增加这些断言:
documents, errors = clean_documents(raw_documents)
chunks = split_documents(documents, chunk_size=12)
assert len(documents) == 2
assert len(errors) == 3
assert chunks[0].chunk_id == "doc-001-chunk-001"
assert all(chunk.content for chunk in chunks)
断言让“预期应该如此”变成程序可以重复执行的检查。以后进入正式项目,可以把这些检查迁移到 pytest 等测试框架中。
怎样检查一道 Python 练习
完成代码后,依次检查:
- 正常输入的结果是否符合预期?
- 空字符串、空列表、缺少字段时会怎样?
- 数量刚好在边界前后时会怎样?
- 函数有没有偷偷修改传入对象?
- 返回值能否被下一步继续使用?
- 名称是否表达业务含义?
- 错误是应该跳过、恢复,还是立即失败?
这七个问题比“再随机做十道相似题”更能暴露我的薄弱点。
小结
这一轮复盘让我承认一个事实:能理解一段代码,与能从空白文件写出可靠代码,中间还有一段距离。
错误并不可怕,重复犯同一种错误却没有留下修正方法,才真正浪费时间。这次复盘不只记录“错在哪里”,还整理了错误所属层次、暴露错误的边界用例,以及下一次可以复用的检查方法。
本篇检查清单:
- 每道题都能先写出明确的输入和预期输出。
- 会主动补充空值、边界值和错误类型。
- 能区分语法错误、运行异常、逻辑错误和设计问题。
- 不用“程序没报错”代替结果验证。
- 能使用
assert自动检查正常结果和边界结果。 - 知道
assert不能代替业务输入验证。 - 能判断批处理中的单条失败是否应该中断整个任务。
- 能把重复错误整理成下一次可执行的检查项。
Python 基础还剩一块容易被忽略的内容:文本为什么会乱码,整数为什么能用不同进制表示,负数在固定宽度的二进制里又是怎么保存的。它们平时不总出现,但一旦出现,单靠试参数很难真正解决。
License: CC BY-NC 4.0
Updated 3 hours ago
Was this article helpful? Give it a like.
0 comments


