JEPA4Japan · 教程

异常与输入验证

1,655字 5分钟阅读 #Python

检测无效状态、从预料之中的失败中恢复,并报告有用的错误信息。

课程进度 课程大纲 已发布 24/24 课

异常就像警报

第 9 章为每个函数规定了契约。如果某个输入违背了这个约定,会发生什么?Python 会停止当前执行路径,并引发一个异常。这是有用的信息,并不是什么怪物。

  1. 停止 某个操作无法完成
  2. 阅读 从最后一行开始
  3. 确定名称 类型加消息
  4. 选择 修复、拒绝或恢复
回溯信息会告诉你哪里出了问题,以及应该去哪里查找。

下面这行代码会故意失败:

minutes = int("forty")

回溯信息的末尾是一条 ValueError 消息。先阅读最后一行,然后向上查找你文件中的对应代码行。不同的异常类型指向不同的契约违背情况:int(None) 会引发 TypeError,而 10 / 0 会引发 ZeroDivisionError。

只捕获你理解的警报

try 代码块应该非常短。里面只放你打算处理的那个有风险的操作。

raw_minutes = "forty"

try:
    minutes = int(raw_minutes)
except ValueError:
    print("Minutes must be a whole number.")
else:
    print("Accepted:", minutes)

输出:

Minutes must be a whole number.

except ValueError 的范围很精确:它只处理自己理解的转换失败。不要使用不指定类型的 except:,也不要仅仅为了让程序继续运行而捕获 Exception。它们可能会掩盖拼写错误和计算错误。

把后续的格式化操作放在 else 中。这样,格式化代码里的缺陷就不会被误认为是输入错误。只有当程序边界确实会以相同方式处理多种异常时,才同时捕获多个类型,例如使用 except (TypeError, ValueError) 处理文本或 None 输入。

四条路径各有各的职责

  1. try 尝试一个有风险的步骤
  2. except 处理一个预期中的失败
  3. else 使用成功得到的结果
  4. finally 始终执行清理工作
每个代码块都描述了程序中的一条不同执行路径。
def show_average(total, count):
    try:
        average = total / count
    except ZeroDivisionError:
        print("No sessions to average.")
    else:
        print(f"Average: {average:.1f}")
    finally:
        print("Calculation finished.")


show_average(90, 3)
show_average(0, 0)

输出:

Average: 30.0
Calculation finished.
No sessions to average.
Calculation finished.

else 只会在操作成功后运行。无论操作成功、异常被捕获,还是出现未被捕获的异常,finally 都会运行。请用它执行真正的清理工作,不要用它隐藏错误或替换返回值。第 12 章会介绍一种用于清理文件资源的更简单工具。

验证是一道关卡,不是一次猜测

转换一个值和检查它的含义是两道不同的关卡。"0" 可以转换成整数 0,但规则可能仍然不允许使用零。

  1. 原始输入 "45" 或 "many"
  2. 转换 它能变成整数吗?
  3. 验证 允许使用 1 到 1440 之间的数吗?
只有通过两道关卡的数据,才能成为可信数据。
def parse_minutes(raw_minutes):
    try:
        minutes = int(raw_minutes)
    except (TypeError, ValueError) as cause:
        raise ValueError("minutes must be a whole number") from cause

    if not 1 <= minutes <= 1440:
        raise ValueError("minutes must be from 1 to 1440")
    return minutes

raise ... from cause 可以向调用方提供一条稳定且有帮助的消息,同时将最初的转换失败保留在 error.__cause__ 和回溯信息中。

当对象的类型不符合接口要求时,使用 TypeError;当对象类型可以接受、但它的值不合法时,使用 ValueError。对于必需却无效的数据,不要悄悄用零代替。还有一个容易忽略的边缘情况:int(True) 会得到 1;如果你的契约规定布尔值不能表示分钟数,就要明确拒绝布尔值。

构建一道可信的学习关卡

将这个完整项目保存为 study_gate.py:

def normalize_topic(raw_topic):
    if not isinstance(raw_topic, str):
        raise TypeError("topic must be text")
    topic = raw_topic.strip()
    if not topic:
        raise ValueError("topic must not be empty")
    return topic


def parse_minutes(raw_minutes):
    if isinstance(raw_minutes, bool):
        raise TypeError("minutes must not be a Boolean")
    try:
        minutes = int(raw_minutes)
    except (TypeError, ValueError) as cause:
        raise ValueError("minutes must be a whole number") from cause
    if not 1 <= minutes <= 1440:
        raise ValueError("minutes must be from 1 to 1440")
    return minutes


def make_session(raw_topic, raw_minutes):
    return {
        "topic": normalize_topic(raw_topic),
        "minutes": parse_minutes(raw_minutes),
    }


def process_entries(raw_entries):
    accepted = []
    problems = []
    for number, (topic, minutes) in enumerate(raw_entries, start=1):
        try:
            session = make_session(topic, minutes)
        except (TypeError, ValueError) as error:
            problems.append(f"Entry {number}: {error}")
        else:
            accepted.append(session)
    return accepted, problems


def format_report(sessions, problems):
    total = 0
    for session in sessions:
        total += session["minutes"]
    lines = [
        f"Accepted: {len(sessions)}",
        f"Rejected: {len(problems)}",
        f"Trusted minutes: {total}",
    ]
    for problem in problems:
        lines.append("- " + problem)
    return "\n".join(lines)


def run_checks():
    assert normalize_topic(" Python ") == "Python"
    assert parse_minutes("45") == 45
    try:
        parse_minutes("many")
    except ValueError as error:
        assert str(error) == "minutes must be a whole number"
        assert isinstance(error.__cause__, ValueError)
    else:
        raise AssertionError("bad minutes were accepted")


run_checks()
raw_entries = [
    (" Python ", "45"),
    ("Git", "many"),
    ("   ", "15"),
    ("SQL", "30"),
    ("Testing", "0"),
]
sessions, problems = process_entries(raw_entries)

print("Checks passed.")
print(format_report(sessions, problems))

运行 python3 study_gate.py:

Checks passed.
Accepted: 2
Rejected: 3
Trusted minutes: 75
- Entry 2: minutes must be a whole number
- Entry 3: topic must not be empty
- Entry 5: minutes must be from 1 to 1440

集合边界只会捕获它预期的验证失败。程序缺陷仍会清楚地暴露出来,而不会被说成“无效输入”。

三个小任务

  1. 编写 parse_rating(),让它接受 1 到 5 之间的整数,并测试 0、1、5 和 6。
  2. 从一个字典中删除 minutes 键;只捕获 KeyError,并把成功时的打印操作放在 else 中。
  3. 在项目数据中加入 (42, "10") 和 ("Git", True),并预测这两条消息。

准备好学习第 11 章了吗?

  • 我会从最后一行开始阅读回溯信息。
  • 我会保持 try 简短,并且只捕获预期的异常类型。
  • 我能解释 except、else 和 finally。
  • 我会先进行转换,再验证含义。
  • 我会使用 raise ... from ... 保留异常原因。
  • 我运行了学习关卡,并且所有检查都已通过。

接下来,你会把这些可信的辅助函数移入模块和包中,并为每个项目提供自己的环境。