登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python API 设计:什么时候返回 None,什么时候抛异常,如何保留异常链

来源:17golang原创

时间:2026-07-15 16:01:54 313浏览 收藏

写Python的时候你肯定碰到过纠结的场景:一个函数到底该返回 None,还是直接抛出异常?这个问题看似只是个人编码偏好,实际会直接影响调用方的分支逻辑写法、告警配置逻辑,真出线上故障的时候,还会决定排查人员能不能快速定位到根因。

我习惯把这个判断逻辑当成API契约来落地:调用方能正常预判到的「没有结果」场景,可以用返回值来表达;但输入不合法、对象状态不满足前置条件、外部依赖不可用这类异常情况,就应该用异常把问题明确抛出边界,不要藏着掖着。下面把整套判断逻辑整理出来,大家遇到同类场景可以直接参考。

核心要点

  • 「没有查到数据」不等于「调用失败」,先把正常业务分支和错误分支区分开。
  • 返回 None 的场景,要让函数名、类型标注和配套文档都明确说明这个可能性。
  • 没法完成函数约定的操作时,要抛出语义明确的异常,不能静默吞掉错误。
  • 跨存储、网络或文件边界调用的时候,要用异常链保留底层报错的原始原因。

先确认:调用方是否能把「没有结果」当成合法的正常情况

判断的出发点从来不是「写异常处理麻不麻烦」,而是调用方的业务语义本身。比如按用户可选昵称查找联系人,找不到联系人本来就是符合预期的正常结果;但创建订单的时候传入负数值的金额,说明调用方本身就违背了函数的使用前提。

场景更合理的契约设计调用方需要做的处理
可选查询没有匹配到任何结果返回 None走「未找到」对应的正常业务分支
传入的参数格式或者范围不符合要求抛出 ValueError修正调用逻辑或者给前端返回用户提示
文件、数据库等底层依赖不可用抛出领域自定义异常并保留原始报错原因执行重试、降级逻辑或者记录告警信息
调用方需要区分不同类型的业务失败场景定义继承自 Exception 的专属异常按异常类型走对应的处理逻辑

这里有个非常实用的判断标准:如果调用方每次拿到返回值都得先猜「这个 None 是没查到数据、数据本身损坏了,还是外部依赖挂了」,说明返回值已经承载了太多超出边界的含义。这时候就应该把故障类场景从正常返回结果里剥离出来,单独用异常传递。

把可选结果的语义直接写进可读的函数签名

当「没有结果」确实属于合法的正常分支时,函数名和类型标注要配合起来明确说明这个特性。下面的 find_user 专门用来做查找操作;调用方看到 User | None 的返回声明,自然就知道需要额外判断结果是否为空。反过来,要是 get_user 这个函数承诺必定会返回合法的用户实例,就不该在内部悄悄返回空值糊弄调用方。

from dataclasses import dataclass


@dataclass
class User:
    user_id: str
    name: str


def find_user(users: dict[str, User], user_id: str) -> User | None:
    return users.get(user_id)


user = find_user(users, "u-1024")
if user is None:
    print("用户不存在,进入注册或提示流程")
else:
    print(user.name)

这种设计的价值根本不是少写几行代码,而是把责任划分到正确的位置:调用方自己负责走对应的业务分支,函数本身不会伪装成执行成功的状态。对于默认认为目标对象肯定存在的场景,可以单独提供 require_user 这类接口,让异常语义一眼就能看明白。

Python API 决策时间线:预期没有结果返回 None,无效操作进入异常路径

跨边界调用时:包装成语义贴近业务的领域错误,但不要丢掉原始报错原因

读取配置、访问缓存或者调用第三方服务的时候,底层错误往往带着很多无关的实现细节。上层对外暴露的API可以抛出更贴近业务场景的自定义异常名,但绝对不能把底层的原始报错原因抹掉。Python自带的异常链机制刚好可以完美处理这一层转换。

from pathlib import Path


class ConfigLoadError(Exception):
    """应用配置无法读取时抛出。"""


def load_config_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except OSError as cause:
        raise ConfigLoadError(f"无法读取配置文件:{path}") from cause

这时候上层调用方可以直接捕获 ConfigLoadError,执行重试逻辑或者给出统一的用户提示;而日志和异常回溯信息里还完整保留着 OSError 的直接报错原因。Python官方文档把这种 raise ... from ... 的写法定义为显式异常链,它可以让业务语义和底层根因两层信息都完整保留下来。

反过来,下面这种写法通常会把故障变成根本没法排查的「空结果」:捕获所有异常之后直接返回 None。除非这个接口的定义就明确说明任何失败场景都等同于「目标不存在/不可用」,否则绝对不要这么设计API。

def load_config_silently(path: Path) -> str | None:
    try:
        return path.read_text(encoding="utf-8")
    except Exception:
        return None

Python 异常链时间线:底层文件错误包装为领域错误,再由调用方重试或提示

把异常类型当成给调用方的明确决策信号

自定义异常完全没必要搞很多层复杂的继承树。只有当调用方需要根据异常类别执行不同处理逻辑的时候,新增专属异常类型才有实际价值。比如配置读取失败和业务规则校验不通过这两个场景,后续的恢复策略完全不一样,就可以分别定义 ConfigLoadErrorRuleViolationError 两个异常类型。它们一般都应该从 Exception 继承,而不是从用于程序退出等场景的基类继承。

接口升级的时候要尤其谨慎:已经对外公开的 find_* 接口如果长期用 None 来表示未找到结果,绝对不能在小版本里突然改成抛出异常的模式。更稳妥的方案是新增一个语义更明确的接口,给调用方留足迁移时间,同时在文档里写清每种返回结果对应的处理方式。

发布前的设计检查清单

  1. 调用方可以正常预判到「没有结果」的情况吗?可以的话,就考虑返回 None 或者语义明确的结果对象。
  2. 函数当前因为报错没法完成之前承诺的操作吗?是的话,直接抛出对应异常。
  3. 异常是不是跨了存储、网络或者文件这类边界?是的话,记得保留原始的报错根因。
  4. 调用方是否需要根据不同异常执行不同的恢复动作?需要的话,再定义少量语义清晰的领域异常。
  5. 返回值定义、类型标注、函数名和配套文档传递的信息是不是完全一致?发布前统一做一遍检查。

常见问题

可以用 except Exception: return None 统一兜底所有空返回场景吗?

通常不建议这么做。这种写法会把权限错误、文件损坏、程序本身的bug和「没有匹配数据」这几种完全不同的场景混在一起。除非你做的是一个明确的探测型接口,并且调用方完全不需要知道具体失败原因,否则要么让异常继续向上传递,要么把它转换成对应的领域异常抛出。

所有业务错误都要专门自定义异常吗?

完全不需要。参数不合法的场景,直接用Python内置的 ValueError 往往就足够了。只有当调用方要根据异常类型选择执行重试、降级、用户提示或者数据补偿这些不同动作的时候,自定义异常类型才能真正提升代码可读性。

什么时候需要显式保留异常链?

当你把底层抛出的原始错误,转换成更适合上层业务理解的自定义错误时,就需要显式保留异常链。这么做上层调用方能拿到语义稳定的领域异常做处理,排查问题的人员也能从异常回溯里看到最开始引发故障的原始原因。

总结

好用的Python API不会把所有场景下的异常都用 None 来兜底返回,也不会为了每一个小分支都特意造一个新异常。把预期内的结果缺失当成普通返回值处理,把没法完成约定操作的情况当成异常抛出,再用异常链把底层报错线索完整保留下来,不管是调用方还是后续维护这套代码的人,都会轻松很多。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>