函数、模块与依赖注入

本节目标

学完本节,你应能用小函数分离解析、业务计算与副作用,把代码组织成可导入模块,并通过显式参数注入时钟、网络或存储依赖,而不是在核心逻辑中隐藏全局调用。

函数是最小契约

一个工程函数至少应让读者看出:

  • 输入是什么,是否已校验;
  • 返回什么;
  • 会抛哪些业务相关异常;
  • 是否读文件、联网、写状态或读取环境变量;
  • 重复调用是否会产生额外副作用。
def unfinished_ids(tasks: list[Task]) -> list[str]:
    return [task.task_id for task in tasks if task.status != "done"]

这是纯函数:结果只由输入决定,没有隐藏 I/O。纯函数不是道德要求,而是更易测试、缓存、并发与复用。

“函数式核心,命令式外壳”

可以把程序分成:

命令式外壳:读文件 / 调 API / 读时钟 / 写日志
       ↓ 转成已校验对象
函数式核心:计算、决策、排序、汇总
       ↓ 返回明确结果或错误
命令式外壳:写文件 / 调工具 / 输出

不要让一个函数同时读取文件、解析 JSON、调用模型、修改数据库并打印结果。失败时难以定位,测试也必须搭建所有依赖。

位置参数、关键字参数与默认值

def search(
    query: str,
    *,
    limit: int = 5,
    include_archived: bool = False,
) -> list[Result]:
    ...

* 让后续参数只能按名称传递:search("RAG", limit=10) 比 search("RAG", 10, False) 更清楚。默认值应是不可变值;不要把可变列表或字典作为默认参数。

模块与入口

模块是可导入的 .py 文件。可测试模块不应在导入时执行真实工作:

def main() -> int:
    # 解析 CLI,调用应用函数,返回退出码
    return 0
 
 
if __name__ == "__main__":
    raise SystemExit(main())

测试可以导入函数而不触发命令行、网络或文件写入。导入时读取密钥、创建客户端或启动后台任务,会让测试和工具发现过程变得不可预测。

显式依赖注入

**依赖注入(dependency injection)**只是把外部能力作为参数提供,而不是在函数内部硬编码创建。最小形式可以是可调用对象:

from collections.abc import Callable
 
 
def with_retry(
    operation: Callable[[], str],
    *,
    attempts: int,
    sleep: Callable[[float], None],
) -> str:
    ...

生产代码注入真实 time.sleep 与 API 调用;测试注入会记录次数的假函数和不等待的 sleep。这比修改全局对象或在测试中真的联网更稳定。

依赖较复杂时,可用 Protocol 描述需要的方法:

from typing import Protocol
 
 
class NoteStore(Protocol):
    def search(self, query: str, limit: int) -> list[str]: ...
 
 
def answer(store: NoteStore, query: str) -> list[str]:
    return store.search(query, limit=5)

调用方只依赖最小能力,不必知道具体数据库或 SDK 类。

错误返回还是异常

  • 对“没有匹配结果”等正常分支,返回空集合或明确结果类型;
  • 对输入不满足契约、依赖不可用等不能按正常路径继续的情况,抛出具体异常;
  • 不要同时返回 None、打印错误并抛异常;调用方会不知道哪个才是接口。

公共函数应记录异常语义,但不要把供应商原始异常、密钥或完整请求直接暴露给模型或用户。

模块边界建议

小项目可以从以下分工开始:

app/
├── models.py       # 已校验对象与结果类型
├── parsing.py      # 外部数据 → 内部对象
├── service.py      # 纯业务用例
├── adapters.py     # 文件/API/数据库边界
└── cli.py          # 参数、输出和退出码
tests/

不是每个项目都需要这些文件。拆分依据是变化原因与副作用边界,不是追求目录数量。

常见错误

  • 在模块顶层读取环境变量并创建真实客户端;
  • 为“可测试”引入庞大抽象层,却没有第二种实现或明确边界;
  • 捕获所有异常后返回空列表,让依赖故障伪装成“无结果”;
  • 函数通过全局变量共享可变状态;
  • main() 不返回退出码,CLI 失败仍显示成功。

练习

把“读取任务 JSON → 校验 → 统计 → 写报告”拆成至少四个函数。标出纯函数、I/O 函数、异常边界与重复执行的副作用。再把“当前时间”作为参数或可调用依赖注入,证明测试不依赖真实时钟。

自测

  1. 纯函数为什么更易测试?
  2. 导入模块时为什么不应执行真实工作?
  3. 依赖注入一定需要框架或容器吗?
  4. Protocol 应描述整个供应商 SDK 还是最小能力?
  5. 空结果与依赖失败为什么不能都返回 []?

相关概念与下一步

参考资料

获取日期:2026-07-14。