C00 · 第 2 章 · 第一编 程序与数据
集合数据、文件与错误处理
按序列、映射与集合的语义和成本组织数据,区分可变对象与别名,在明确文本编码下读取 CSV、JSON 等结构化输入,并以模式验证、异常传播和上下文管理覆盖失败与资源清理。
报告页面错误本章目标
- 按顺序、重复、键访问和成员关系选择序列、映射或集合,并分析主要操作的渐近成本。
- 解释可变对象、不可变对象、别名和浅拷贝,避免函数意外修改调用者状态。
- 安全迭代容器,明确遍历顺序、空容器、重复键和迭代中修改的行为。
- 区分文本与字节,显式选择编码并使用上下文管理保证文件句柄清理。
- 对 CSV、JSON 等输入先解析再做模式与语义验证,以带上下文异常覆盖失败路径。
本页目录
先按语义选择容器
容器不只是“能装很多值”。不同容器承诺不同语义:
- 序列保留位置和顺序,允许重复,适合按索引或先后处理;
- 映射把唯一键关联到值,适合按标识符查找记录;
- 集合只关心成员是否存在和去重,不表达重复次数;
- 不可变序列适合表示创建后不应改变的固定记录。
用列表模拟所有结构会让意图不清。例如检查一百万次“某编号是否出现过”时,列表成员检查每次最坏要扫描线性长度;哈希集合在良好散列条件下具有期望常数成员查询,但最坏情况仍可退化。这里的渐近结论描述随数据规模增长的趋势,不是对具体机器耗时的承诺。
常见操作成本也与位置有关。动态数组式序列按索引访问通常为 ,末尾追加常为摊还 ,在开头插入需移动其后元素,通常为 。映射和集合的插入、查找在常见哈希实现中期望 ;若需要排序输出,还要额外支付比较排序的 成本。应从正确语义开始,再用真实规模测量。
可变性、别名与拷贝
列表、映射和集合通常可变;数字、文本和元组式固定序列通常不可变。赋值一个可变对象不会自动复制内容:
measurements = [10.0, 11.0]
alias = measurements
alias.append(12.0)
此时两个名字指向同一列表,调用者会看到新元素。若函数契约声称不修改输入,就应创建新容器或只读遍历。浅拷贝只复制最外层;若元素本身是列表或映射,内层仍可能共享。深拷贝虽然分离嵌套对象,却可能昂贵,也未必适合文件句柄等外部资源。
不可变对象有助于建立表示不变量。例如把二维网格形状表示为固定二元组,可防止中途追加第三个维度。不可变不等于内容绝对安全:固定序列中若包含可变列表,列表仍能变化。判断时要沿对象图追踪实际共享。
要求输入是一列非空文本编号,输出保留每个编号第一次出现的顺序。集合负责成员查询,列表负责顺序。
def unique_ids(ids):
seen = set()
result = []
for position, item in enumerate(ids):
if not is_nonempty_text(item):
raise ValueError("invalid id at position " + str(position))
if item not in seen:
seen.add(item)
result.append(item)
return result
输入 ["b", "a", "b", "c"] 返回 ["b", "a", "c"];空序列返回空序列;含空文本时立即失败并报告位置。若只有集合而直接输出其遍历结果,接口就没有明确保留首次顺序的保证。平均情况下循环是 ,额外空间至多存放全部不同编号。
迭代时保持容器结构稳定
遍历序列时可同时取得位置和值;遍历映射时应明确需要键、值还是键值对。若结果顺序影响文件或测试,应显式排序或遵守输入序列,而不是依赖未写进契约的偶然顺序。
不要在遍历映射或集合时同时增删其键,容器大小变化可能使迭代失效或遗漏元素。安全策略是先收集待删除键,再在第二个循环修改;或遍历键的快照。修改列表元素的值和改变列表长度也要区分,后者容易造成索引跳过。
空容器是正常边界。求和可定义空和为零,但平均值需要分母,空输入应抛出错误或返回明确缺失结果。最大值、首元素和随机抽样也没有自然空结果,不能让索引异常偶然充当接口。
文本、字节与编码
文件存储的是字节,文本是按编码解释后的 Unicode 字符。写入和读取应显式指定同一编码,例如 UTF-8:
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
若字节不符合编码,会产生解码错误;这比悄悄替换未知字符更适合科学数据,因为替换可能改变样本编号和单位。二进制图像、压缩包和数值块不应先解码成文本。
视觉上相同的字符可能有不同 Unicode 组合。若标识符需要规范化,应在契约中指定规范形式,并保留原始字段用于追踪。大小写折叠、去空格和全角半角转换都可能把原本不同的样本合并,不能作为“清洗”默认执行。
换行也由文本层处理。CSV 读取应使用专用解析器和推荐的换行模式,不要逐行用逗号切分,因为带引号字段可以合法包含逗号和换行。
文件句柄与上下文管理
打开文件会占用操作系统资源。上下文管理块无论正常返回还是中途异常都会关闭句柄:
with open(path, "r", encoding="utf-8") as handle:
data = parse(handle)
关闭句柄不等于写入成功。磁盘空间不足、权限错误或设备中断都可在写入或关闭时发生。调用者应让异常传播,只有在能恢复或能增加上下文时才捕获。对重要产物,可先写同目录临时文件,完整关闭并校验后再替换目标,以减少留下半文件的风险;仍需处理替换失败和临时文件清理。
CSV:解析、模式和语义是三层
CSV 解析器负责引号、分隔符和换行,但解析成功只说明语法可读。模式验证继续检查列名、必填字段和重复键;语义验证再检查数值有限、单位、范围和跨字段关系。
文件契约要求三列 sample_id、time_s、temperature_K。编号非空且唯一,时间有限且非负,温度有限且不低于零开尔文。只有全部记录通过后才返回。
def read_temperatures(path):
rows = []
seen_ids = set()
try:
with open(path, "r", encoding="utf-8", newline="") as handle:
reader = csv_dict_reader(handle)
require_exact_columns(
reader.fieldnames,
["sample_id", "time_s", "temperature_K"],
)
for line_number, raw in enumerate(reader, start=2):
sample_id = raw["sample_id"].strip()
if sample_id == "":
raise DataError("empty sample_id", line_number)
if sample_id in seen_ids:
raise DataError("duplicate sample_id", line_number)
time_s = parse_finite_float(raw["time_s"], line_number)
temperature_K = parse_finite_float(
raw["temperature_K"], line_number
)
if time_s < 0:
raise DataError("negative time", line_number)
if temperature_K < 0:
raise DataError("temperature below 0 K", line_number)
seen_ids.add(sample_id)
rows.append((sample_id, time_s, temperature_K))
except UnicodeError as error:
raise InputError("file is not valid UTF-8: " + str(path)) from error
except CsvSyntaxError as error:
raise InputError("invalid CSV in " + str(path)) from error
except OSError as error:
raise InputError("cannot read " + str(path)) from error
return rows
若文件不存在,外层异常保留原始系统原因;若第三行温度为 "hot",转换函数报告行号;若第十行重复编号,函数失败而不返回前九行的“部分成功”。上下文块在所有路径关闭文件。是否允许额外列是模式政策,本例选择严格拒绝以发现拼写错误。
直接捕获所有异常并返回空列表很危险:调用者无法区分“合法空文件”“路径错误”和“数据损坏”。只有明确可恢复的异常才应转换;程序错误应继续暴露。
JSON:树结构仍需要模式
JSON 可以表达对象、数组、文本、数字、布尔和空值,但它不会知道某个数字是秒还是米,也不会自动要求字段存在。解析后必须检查顶层类型、字段集合、每个值类型、范围和字段间约束。
约定配置只含 time_step_s、steps、output_path。时间步是有限正数,步数是正整数,路径是非空文本。
def load_config(path):
try:
with open(path, "r", encoding="utf-8") as handle:
raw = json_load(handle)
except JsonSyntaxError as error:
raise InputError("invalid JSON in " + str(path)) from error
except UnicodeError as error:
raise InputError("file is not valid UTF-8: " + str(path)) from error
except OSError as error:
raise InputError("cannot read " + str(path)) from error
if not is_mapping(raw):
raise SchemaError("top level must be an object")
require_exact_keys(raw, {"time_step_s", "steps", "output_path"})
dt = raw["time_step_s"]
steps = raw["steps"]
output_path = raw["output_path"]
if not is_finite_number(dt) or dt <= 0:
raise SchemaError("time_step_s must be positive and finite")
if not is_integer(steps) or steps < 1:
raise SchemaError("steps must be a positive integer")
if not is_nonempty_text(output_path):
raise SchemaError("output_path must be nonempty text")
return Config(dt, steps, output_path)
缺失 steps、把 steps 写成文本 "1000"、给时间步 null 或增加拼错字段 time_steps_s 都会失败。严格模式避免程序悄悄使用默认值跑出另一组实验。若产品需要向后兼容,应给模式版本和显式迁移,而不是对所有未知字段静默忽略。
本例中的 is_integer 应明确排除布尔值,因为“真假开关”和“步数”在业务语义上不同,即使某些语言允许它们参与相同算术。验证函数应检查领域类型,而不只检查值能否被乘除。
JSON 数字没有单位标签,本例把单位编码进字段名。更复杂数据可用对象同时保存 value 和 unit,再验证单位集合与换算。语法解析和物理量验证必须分层,解析成功不表示科学含义正确。
映射聚合与重复键
映射适合按组累计,但要定义重复的含义。若同一 sample_id 出现两次,是更新、求和还是错误,不能由赋值覆盖偶然决定。
输入记录为实验组名和有限测量值。先累计总和与计数,再构造新映射:
def group_means(records):
totals = {}
counts = {}
for position, record in enumerate(records):
group, value = validate_record(record, position)
totals[group] = totals.get(group, 0.0) + value
counts[group] = counts.get(group, 0) + 1
result = {}
for group in totals:
result[group] = totals[group] / counts[group]
return result
空输入返回空映射,不发生除零,因为没有创建空组。非法记录在加入累计前失败,避免总和已改而计数未改。若需要稳定输出顺序,写文件时显式排序组名;计算映射本身只表达组到平均值的关系。
大量浮点值直接按任意顺序求和可能积累舍入误差。可使用更稳定求和算法或固定归约顺序,但这属于数值精度策略;容器选择只决定数据如何组织,不能自动保证浮点准确。
异常传播与资源清理
异常表示当前函数无法履行成功后置条件。底层函数应抛出具体异常;边界层可增加路径、行号、字段名等上下文,并用异常链保留原原因。捕获后仅打印再继续,常让程序产生缺字段或半成品。
finally 块适合必须执行的清理,但文件通常优先用上下文管理。若同时发生主操作错误和清理错误,应保留可诊断信息,不要让宽泛捕获把两者都变成“未知失败”。资源清理包括关闭文件、释放锁、删除临时文件和回滚未提交事务;普通列表无需手动释放。
输出函数也要定义失败原子性。直接覆盖唯一结果文件时,进程中断可能同时失去旧文件和新文件。临时文件、完整校验和替换可降低风险,但替换是否原子取决于文件系统和路径位置,不能作无条件跨平台承诺。
常见误区
“赋值另一个变量就复制了列表。”普通赋值只增加别名;修改任一名字指向的对象,另一方也能看到。
“CSV 或 JSON 能解析就表示数据有效。”解析只确认语法,字段、类型、单位、范围和跨字段关系仍需验证。
“捕获所有异常并返回空值最稳健。”这会混淆合法空输入、外部故障和程序错误,使下游继续处理错误状态。
练习:容器、模式与失败路径
为时间序列、样本编号查记录、已访问编号和固定二维形状选择容器并说明理由。
查看提示
查看解答
解释列表赋值、浅拷贝和嵌套列表修改的差别。
查看提示
查看解答
设计删除映射中所有非法记录的安全两阶段算法。
查看提示
查看解答
说明读取 UTF-8 文本时解码失败如何传播,以及为什么仍能关闭文件。
查看提示
查看解答
为含 start_time_s、end_time_s 和 label 的 JSON 记录列出完整验证顺序。
查看提示
查看解答
end_time≥start_time。任一步失败都给字段上下文且不启动计算。设计避免把半个结果当成功的写文件流程,并说明无法保证的边界。
查看提示
查看解答
知识连接与资源
- 程序、类型、控制流与函数 提供循环、函数契约和失败传播基础。
- 集合与映射 给出成员关系、键值关联和数学语义。
- 数组、向量化与数值精度 将序列扩展为带形状、轴和批量运算的数值结构。
- 测试、版本控制与环境管理 用坏输入、临时文件和边界容器验证失败路径。
- 编程与科学计算综合项目 综合读取、验证、计算和产物交付。
Introduction to Computer Science and Programming in Python
Ana Bell, Eric Grimson, John Guttag
用于核对 C00 的基础程序语义、函数分解、集合与文件处理、异常、测试和可执行例题。
打开官方来源Computational Methods of Scientific Programming
Thomas Herring, Chris Hill
用于核对 P11 的程序验证、并行性能测量、环境记录、结果传播和可复现计算实验要求。
打开官方来源MIT OpenCourseWare 6.0001 覆盖 Python 集合、异常和文件处理;12.010 提供科学程序的数据、验证与可复算工作流背景。示例使用稳定的基础语义和标准结构,不依赖特定小版本的冷门语法,也不把渐近复杂度冒充具体性能测量。