
第九章:函数接口设计:输入 / 输出 / 边界条件
-
- [1. 先建立一个共识:接口就是"契约"](#1. 先建立一个共识:接口就是“契约”)
- [2. 输入设计:比你想的更重要](#2. 输入设计:比你想的更重要)
-
- [2.1 明确输入的"数据形状"](#2.1 明确输入的“数据形状”)
- [2.2 输入要"可验证":尽早 fail fast](#2.2 输入要“可验证”:尽早 fail fast)
- [2.3 输入不要过于"灵活"](#2.3 输入不要过于“灵活”)
- [3. 输出设计:决定你的函数是否可组合](#3. 输出设计:决定你的函数是否可组合)
-
- [3.1 返回值要"稳定且明确"](#3.1 返回值要“稳定且明确”)
- [3.2 不要随意"打印",要么返回,要么记录日志](#3.2 不要随意“打印”,要么返回,要么记录日志)
- [3.3 输出要可序列化(在数据/AI工程中很关键)](#3.3 输出要可序列化(在数据/AI工程中很关键))
- [4. 边界条件:这才是数据工程的主战场](#4. 边界条件:这才是数据工程的主战场)
-
- [4.1 空数据](#4.1 空数据)
- [4.2 缺失值与 NaN](#4.2 缺失值与 NaN)
- [4.3 类型漂移](#4.3 类型漂移)
- [4.4 极端值](#4.4 极端值)
- [4.5 重复与不唯一](#4.5 重复与不唯一)
- [5. 三种边界处理策略:选一种并保持一致](#5. 三种边界处理策略:选一种并保持一致)
-
- [策略 A:严格模式(工程默认推荐)](#策略 A:严格模式(工程默认推荐))
- [策略 B:宽松模式(业务可用)](#策略 B:宽松模式(业务可用))
- [策略 C:显式返回"状态"](#策略 C:显式返回“状态”)
- [6. 接口设计的"工程模板":一眼看懂该怎么写](#6. 接口设计的“工程模板”:一眼看懂该怎么写)
- [7. 两个实战案例:写对接口后,测试和维护都轻了](#7. 两个实战案例:写对接口后,测试和维护都轻了)
-
- [7.1 指标函数:CTR](#7.1 指标函数:CTR)
- [7.2 文本清洗:clean_text](#7.2 文本清洗:clean_text)
- [8. 把接口设计和 pytest 串起来:你会更快进入"可维护状态"](#8. 把接口设计和 pytest 串起来:你会更快进入“可维护状态”)
- [9. 本章小结:你应该带走的三条规则](#9. 本章小结:你应该带走的三条规则)
- 课后练习(建议你真的做一下)
- 下一章:
写 Python 写到后面,你会发现:
真正决定代码"能不能长期维护"的,不是你会不会写算法,而是你函数接口设计得好不好。
尤其在数据分析与 AI 工程里,一个函数往往要面对:
- 上游数据格式不稳定(字段缺失、类型漂移)
- 下游复用场景越来越多(训练/推理/回测/批处理)
- 边界条件极其常见(空数据、NaN、分母为 0、超长文本、极端值)
这章我们不讲"抽象的设计原则",只讲一件事:
如何把函数设计成"别人敢用、未来敢改、出了问题好定位"。
1. 先建立一个共识:接口就是"契约"
函数接口不是参数列表那么简单,它至少包含四部分契约:
- 输入:允许什么类型、什么形状、什么范围
- 输出:返回什么、是否稳定、是否可复现
- 边界条件:空值、异常、极端情况如何处理
- 副作用:是否读写文件、是否修改入参、是否依赖全局状态
你把这四件事写清楚,函数就能"长期存在";
写不清楚,函数就会变成"只敢自己用"的脚本片段。
2. 输入设计:比你想的更重要
2.1 明确输入的"数据形状"
数据类函数最常见的设计失败:
只写了类型,没写"形状"。
list[float]到底允许空列表吗?pd.DataFrame需要哪些列?np.ndarray是一维还是二维?维度顺序是什么?
建议你在 docstring 里把 shape 写出来,尤其是 numpy:
python
def cosine_sim(a: "ndarray[float] shape=(d,)", b: "ndarray[float] shape=(d,)") -> float:
...
或者在注释里写清楚:
X: (n_samples, n_features)y: (n_samples,)
2.2 输入要"可验证":尽早 fail fast
接口设计里最实用的一条:
校验越靠近入口越好。
python
def build_features(df):
required = {"user_id", "ts", "amount"}
missing = required - set(df.columns)
if missing:
raise KeyError(f"missing columns: {sorted(missing)}")
Fail fast 的好处:
- 错误离源头近
- 日志更清晰
- 不会在 pipeline 末端才爆炸
2.3 输入不要过于"灵活"
很多人为了"好用",允许函数同时接受:
- list / numpy / pandas / 单值 / None
结果就是函数内部一堆 if/else,最后谁也不敢改。
更稳妥的做法:
- 主入口只接受一种形态
- 其他形态通过适配器函数转换
这在 AI 工程里尤其重要:训练与推理需要一致,接口也要一致。
3. 输出设计:决定你的函数是否可组合
3.1 返回值要"稳定且明确"
一个函数如果有多种返回结构,下游会痛苦:
- 有时返回 float
- 有时返回 dict
- 有时返回 None
建议你做到两点:
- 返回结构固定
- 异常时明确抛错或返回默认值,不要"悄悄吞掉"
例如指标函数:
- 业务允许空集:返回
0.0 - 业务不允许空集:直接抛错
不要两种策略混用。
3.2 不要随意"打印",要么返回,要么记录日志
在工程化代码里,print 是一种副作用。
测试、部署、并发、异步环境下会变得不可控。
策略:
- 调试阶段:用 logging(可控)
- 业务输出:通过 return 返回
- 错误信息:通过 exception 抛出
3.3 输出要可序列化(在数据/AI工程中很关键)
你会经常把输出写入:
- parquet / json / sqlite / redis
- 特征仓库
- 模型输入输出缓存
所以建议:
- 返回
dict / dataclass / pydantic model - 避免返回不可序列化对象(复杂迭代器、匿名函数、open file handle)
4. 边界条件:这才是数据工程的主战场
在算法题里边界条件是"加分项",
在数据工程里边界条件是"常态"。
下面是高频边界列表(建议直接贴到你的项目 Wiki):
4.1 空数据
- 空 DataFrame / 空数组 / 空列表
- 空字符串 / 全是空格
4.2 缺失值与 NaN
- None
- NaN
- inf / -inf
4.3 类型漂移
- 数字字段变成字符串
"123" - 时间字段变成
"2026/01/01"
4.4 极端值
- 分母为 0
- outlier
- 超长文本(prompt、文档)
4.5 重复与不唯一
- user_id 重复
- 主键不唯一导致 merge 爆炸
5. 三种边界处理策略:选一种并保持一致
很多人代码混乱的根源:
同一类边界在不同函数里处理方式不一致。
建议你在项目里固定策略,常见三种:
策略 A:严格模式(工程默认推荐)
- 边界条件直接抛错
- 用于训练/离线计算/数据质量要求高的场景
策略 B:宽松模式(业务可用)
- 返回默认值或空结构
- 用于在线服务必须"不断"的场景
策略 C:显式返回"状态"
- 返回
(result, warnings)或Result[T] - 用于需要记录质量问题但不中断流程的场景
关键点:
不要在同一个函数里混着来。
6. 接口设计的"工程模板":一眼看懂该怎么写
这里给一个数据特征函数的推荐模板(你可以复用到整套专栏里):
python
def make_user_features(
df,
*,
user_col: str = "user_id",
ts_col: str = "ts",
strict: bool = True,
):
"""
Build user-level features.
Inputs:
df: DataFrame, must contain [user_col, ts_col, ...]
Outputs:
DataFrame with columns: [user_col, feat_x, feat_y, ...]
Edge cases:
- empty df: raise ValueError if strict else return empty feature df
- missing columns: raise KeyError
- invalid ts: raise ValueError
Side effects:
none
"""
...
你会发现,这个模板强迫你把"契约"写出来。
写出来,你就更容易测试;也更容易复用。
7. 两个实战案例:写对接口后,测试和维护都轻了
7.1 指标函数:CTR
坏接口(常见):
- views=0 时返回 None
- clicks 不是 int 时默默转型
- 同时打印日志
好接口:
- views=0 返回 0.0(宽松策略)
- 类型不对直接抛错(严格策略)
关键是:你必须提前决定"你的项目属于哪种策略"。
7.2 文本清洗:clean_text
坏接口:
- 输入 None 直接报错
- 输入数字自动 str()
- 输出有时返回 list 有时返回 str
好接口:
- 输入必须是 str(或显式允许 Optional[str])
- 输出永远是 str
- 空文本返回 ""(明确策略)
8. 把接口设计和 pytest 串起来:你会更快进入"可维护状态"
你会发现:
- 接口契约清晰 → 测试用例自然 → 迭代风险变小
这一章和上一章(pytest)的关系非常紧:
- 第 8 章:怎么写测试
- 第 9 章:怎么设计一个"值得测试、好测试"的函数接口
工程里最怕的是:
接口混乱导致测试写不下去,最终没人写测试。
9. 本章小结:你应该带走的三条规则
- 接口先写契约:输入/输出/边界/副作用
- 边界处理选策略:严格、宽松、显式状态,选一种统一
- 越靠近入口越校验:fail fast,错误更可控
课后练习(建议你真的做一下)
从你现有项目里找一个"最容易出事"的函数,按本章重构:
-
补齐 docstring 契约
-
加输入校验
-
明确边界策略
-
给它写 3 个 pytest:
- 正常样例
- 空数据
- 缺列/非法值
你做完会发现:
代码没变复杂,反而更"敢改"。
如果你愿意,把你项目里一个函数(脱敏即可)贴出来,我可以按本章的"接口契约模板"帮你重写一版,并顺手给出对应的 pytest 用例框架。
下一章:
《面向复用的工具库:utils 的设计与拆分》