C00 · 第 2 章 · 第一编 程序与数据

集合数据、文件与错误处理

按序列、映射与集合的语义和成本组织数据,区分可变对象与别名,在明确文本编码下读取 CSV、JSON 等结构化输入,并以模式验证、异常传播和上下文管理覆盖失败与资源清理。

报告页面错误
预备知识程序、类型、控制流与函数集合与映射

本章目标

  1. 按顺序、重复、键访问和成员关系选择序列、映射或集合,并分析主要操作的渐近成本。
  2. 解释可变对象、不可变对象、别名和浅拷贝,避免函数意外修改调用者状态。
  3. 安全迭代容器,明确遍历顺序、空容器、重复键和迭代中修改的行为。
  4. 区分文本与字节,显式选择编码并使用上下文管理保证文件句柄清理。
  5. 对 CSV、JSON 等输入先解析再做模式与语义验证,以带上下文异常覆盖失败路径。
页面阅读位置0% · 仅保存在此浏览器
章节未开始
本册完成进度0/6 章 · 0%
本页目录

先按语义选择容器

容器不只是“能装很多值”。不同容器承诺不同语义:

  • 序列保留位置和顺序,允许重复,适合按索引或先后处理;
  • 映射把唯一键关联到值,适合按标识符查找记录;
  • 集合只关心成员是否存在和去重,不表达重复次数;
  • 不可变序列适合表示创建后不应改变的固定记录。

用列表模拟所有结构会让意图不清。例如检查一百万次“某编号是否出现过”时,列表成员检查每次最坏要扫描线性长度;哈希集合在良好散列条件下具有期望常数成员查询,但最坏情况仍可退化。这里的渐近结论描述随数据规模增长的趋势,不是对具体机器耗时的承诺。

常见操作成本也与位置有关。动态数组式序列按索引访问通常为 O(1)O(1),末尾追加常为摊还 O(1)O(1),在开头插入需移动其后元素,通常为 O(n)O(n)。映射和集合的插入、查找在常见哈希实现中期望 O(1)O(1);若需要排序输出,还要额外支付比较排序的 O(nlogn)O(n\log n) 成本。应从正确语义开始,再用真实规模测量。

可变性、别名与拷贝

列表、映射和集合通常可变;数字、文本和元组式固定序列通常不可变。赋值一个可变对象不会自动复制内容:

measurements = [10.0, 11.0]
alias = measurements
alias.append(12.0)

此时两个名字指向同一列表,调用者会看到新元素。若函数契约声称不修改输入,就应创建新容器或只读遍历。浅拷贝只复制最外层;若元素本身是列表或映射,内层仍可能共享。深拷贝虽然分离嵌套对象,却可能昂贵,也未必适合文件句柄等外部资源。

不可变对象有助于建立表示不变量。例如把二维网格形状表示为固定二元组,可防止中途追加第三个维度。不可变不等于内容绝对安全:固定序列中若包含可变列表,列表仍能变化。判断时要沿对象图追踪实际共享。

例 1:保留首次顺序的编号去重

要求输入是一列非空文本编号,输出保留每个编号第一次出现的顺序。集合负责成员查询,列表负责顺序。

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"];空序列返回空序列;含空文本时立即失败并报告位置。若只有集合而直接输出其遍历结果,接口就没有明确保留首次顺序的保证。平均情况下循环是 O(n)O(n),额外空间至多存放全部不同编号。

迭代时保持容器结构稳定

遍历序列时可同时取得位置和值;遍历映射时应明确需要键、值还是键值对。若结果顺序影响文件或测试,应显式排序或遵守输入序列,而不是依赖未写进契约的偶然顺序。

不要在遍历映射或集合时同时增删其键,容器大小变化可能使迭代失效或遗漏元素。安全策略是先收集待删除键,再在第二个循环修改;或遍历键的快照。修改列表元素的值和改变列表长度也要区分,后者容易造成索引跳过。

空容器是正常边界。求和可定义空和为零,但平均值需要分母,空输入应抛出错误或返回明确缺失结果。最大值、首元素和随机抽样也没有自然空结果,不能让索引异常偶然充当接口。

文本、字节与编码

文件存储的是字节,文本是按编码解释后的 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 解析器负责引号、分隔符和换行,但解析成功只说明语法可读。模式验证继续检查列名、必填字段和重复键;语义验证再检查数值有限、单位、范围和跨字段关系。

例 2:读取并验证温度 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 可以表达对象、数组、文本、数字、布尔和空值,但它不会知道某个数字是秒还是米,也不会自动要求字段存在。解析后必须检查顶层类型、字段集合、每个值类型、范围和字段间约束。

例 3:验证计算配置而不静默填错

约定配置只含 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 出现两次,是更新、求和还是错误,不能由赋值覆盖偶然决定。

例 4:分组平均的空组与非法值

输入记录为实验组名和有限测量值。先累计总和与计数,再构造新映射:

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 能解析就表示数据有效。”解析只确认语法,字段、类型、单位、范围和跨字段关系仍需验证。

常见误区

“捕获所有异常并返回空值最稳健。”这会混淆合法空输入、外部故障和程序错误,使下游继续处理错误状态。

练习:容器、模式与失败路径

练习

为时间序列、样本编号查记录、已访问编号和固定二维形状选择容器并说明理由。

查看提示
分别判断是否需要顺序、重复、按键查找或快速成员测试。
查看解答
时间序列用序列;样本编号到记录用映射;已访问编号用集合;固定二维形状可用不可变二元序列。一个程序可组合多种容器。
练习

解释列表赋值、浅拷贝和嵌套列表修改的差别。

查看提示
画出两个名字是否指向同一个可变对象,再区分浅拷贝和嵌套元素。
查看解答
b=a 后两名同指一个列表,b.append 会改变 a 所见内容;浅拷贝分离外层,但嵌套列表仍共享。需根据契约构造新内层或使用不可变记录。
练习

设计删除映射中所有非法记录的安全两阶段算法。

查看提示
先遍历收集待删除键,再在第二阶段修改。
查看解答
遍历映射大小同时删键可能使迭代失效或遗漏。可先建立待删除键列表,循环结束后逐个删除,或构造只含保留项的新映射。
练习

说明读取 UTF-8 文本时解码失败如何传播,以及为什么仍能关闭文件。

查看提示
区分字节解码、文本解析和上下文清理三个阶段。
查看解答
以显式 UTF-8 在 with 块中打开;解码失败应带路径传播;文本得到后再解析。无论解析成功或异常,with 都关闭句柄。二进制文件不应先当文本解码。
练习

为含 start_time_s、end_time_s 和 label 的 JSON 记录列出完整验证顺序。

查看提示
依次检查顶层结构、字段集合、类型、有限性、范围和跨字段关系。
查看解答
JSON 解析成功后确认顶层为对象、必填键存在且未知键政策明确,再检查每字段类型和单位范围,最后检查如 end_time≥start_time。任一步失败都给字段上下文且不启动计算。
练习

设计避免把半个结果当成功的写文件流程,并说明无法保证的边界。

查看提示
只捕获能增加上下文或恢复的异常,并把计算与写文件分离。
查看解答
纯函数先产生完整结果;写出层在同目录临时文件中完成并关闭校验,再尝试替换目标。失败时传播带路径异常并清理临时文件;不返回成功,也不无条件承诺所有文件系统替换都原子。

知识连接与资源

课程 · 2016

Introduction to Computer Science and Programming in Python

Ana Bell, Eric Grimson, John Guttag

用于核对 C00 的基础程序语义、函数分解、集合与文件处理、异常、测试和可执行例题。

打开官方来源
课程 · 2024

Computational Methods of Scientific Programming

Thomas Herring, Chris Hill

用于核对 P11 的程序验证、并行性能测量、环境记录、结果传播和可复现计算实验要求。

打开官方来源

MIT OpenCourseWare 6.0001 覆盖 Python 集合、异常和文件处理;12.010 提供科学程序的数据、验证与可复算工作流背景。示例使用稳定的基础语义和标准结构,不依赖特定小版本的冷门语法,也不把渐近复杂度冒充具体性能测量。