第 10 章 · 函数:把「算 SOH」封装起来
这一章要解决什么:把"算 SOH"这段逻辑写一次、用很多次,让阈值这类魔法数字只出现在一个地方。 四个关键坑:忘了
return会得到None、return、 默认参数def f(x, bucket=[])会串味、函数里的赋值不会改全局变量。
本章你需要完成的代码:demos/ch10-function.py
10.1 为什么要函数:同一段计算抄了三遍
cap_rated = 2.5
soh1 = 2.50 / cap_rated # 抄一遍
soh2 = 2.30 / cap_rated # 又抄一遍
soh3 = 2.00 / cap_rated # 再抄一遍三行还能忍,30 支呢?更麻烦的是规则变了:产线把额定容量改成 2.6 时, 你要找出每一处 2.5 并逐个确认它是不是"额定容量"。函数就是给一段逻辑起个名字:
def calc_soh(cap_now, cap_rated=2.5):
"""由实测容量与额定容量算 SOH(健康度)。"""
return cap_now / cap_rated
print(calc_soh(2.30)) # 0.92 —— 规则只写了一遍| 好处 | 具体表现 |
|---|---|
| 不重复(DRY) | 计算逻辑只有一处,改一处就全改 |
| 可命名 | calc_soh(2.30) 比 2.30 / 2.5 更能说明意图 |
| 可复用 / 可测试 | 换一批数据直接用;也能单独验证"2.30 应该得 0.92" |
def calc_soh(cap_now, cap_rated=2.5):
"""由实测容量与额定容量算 SOH(健康度)。
参数: cap_now 当前容量(Ah);cap_rated 额定容量(Ah),默认 2.5
返回: SOH,float,约 0.80 ~ 1.00
"""
return cap_now / cap_rated| 部分 | 名称 | 说明 |
|---|---|---|
cap_now | 位置参数 | 调用时必须给 |
cap_rated=2.5 | 默认参数 | 不给就用 2.5 |
return | 返回值 | 把结果交给调用方 |
| 三引号字符串 | docstring | 函数的第一句话,可用 __doc__ 读到 |
为什么要有关键字参数:calc_soh(1.80, 2.0) 里那个 2.0 是什么?读的人得回去看定义; 写成 calc_soh(cap_rated=2.0, cap_now=1.80) 就一目了然,顺序也随便。 参数超过两个、或含义不明显时,调用处就写参数名。 docstring 是给三个月后的自己、给同事、也给编辑器看的 —— IDE 里打 calc_soh( 时弹出的提示就是它。
10.3 忘了 return:得到的是 None
def bad_soh(cap_now, cap_rated=2.5):
cap_now / cap_rated # 算完就扔了,没有 return真实输出:
bad_soh(2.3) = None ← None
TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'两条铁律:① 没有 return 的函数返回 None,Python 不报错,错误会在后面用到这个返回值时 爆发 —— 报错位置离真正的 bug 很远,这才是难查的原因; ② print ≠ return,前者把东西显示给人看,后者把值交回程序。
def show_soh(cap_now): # ❌ 打印了,但调用方什么也拿不到
print(cap_now / 2.5)
x = show_soh(2.30) # 屏幕上有 0.92,但 x 是 None怎么判断该不该有 return:问"这个结果后面还要用吗?"算值、做判断 → 要; 打印报表 → 不要;lst.append 这种就地修改 → 返回值本来就是 None (第 03 章的规律:返回 None 的通常是原地修改,所以 y = lst.append(3) 会让 y 变成 None)。
10.4 返回多个值:其实是"打包成元组"
def analyze(caps, cap_rated=2.5):
"""返回(最低 SOH, 最高 SOH, 循环次数)。"""
sohs = [c / cap_rated for c in caps]
return min(sohs), max(sohs), len(sohs)
low, high, n = analyze([2.50, 2.30, 2.00]) # 左边三个变量自动接住
print(f"最低 {low:.4f},最高 {high:.4f},共 {n} 条")
# 最低 0.8000,最高 1.0000,共 3 条return a, b, c 交出去的其实是一个元组 (a, b, c),左边 low, high, n = ... 把它解包成三个变量 —— print(analyze([2.50, 2.30, 2.00])) 得到 (1.0, 0.8, 3) 就是证据。 纪律:返回值超过 3 个就改返回字典(第 09 章),a, b, c, d, e = f(...) 没人记得住谁是谁。
10.5 判断类函数:直接 return 比较结果
def is_eol(soh, thr=0.80):
"""SOH 是否已跌破退役阈值。"""
return soh < thr # ✅ 一行,等价,没有分支(写成 if soh < thr: return True else: return False 也对,但那是多余的分支 —— 比较运算本身就是布尔值。)
真实输出:
is_eol(0.95) = False is_eol(0.95, 0.85) = False
is_eol(0.78) = True is_eol(0.78, 0.85) = True判断类函数统一用 is_ / has_ 前缀,别人看到名字就知道它返回布尔值。 注意 thr=0.80 这个默认参数:阈值只写在一处,is_eol(soh) 和 is_eol(soh, 0.85) 都能用 —— 把阈值变成参数,是把"硬编码"变成"可配置"的第一步。
10.6 可变默认参数陷阱(面试常考)
def collect_bad(x, bucket=[]): # 默认值只在「定义时」求值一次,全程共用同一个列表
bucket.append(x)
return bucket
print(collect_bad(1), collect_bad(2), collect_bad(3))真实输出:
bad : [1, 2, 3] [1, 2, 3] [1, 2, 3] ← 脏了!
good: [1] [2] [3] ← 每次都是新的为什么:默认参数 [] 在函数定义时就创建好了,之后每次调用用的都是同一个列表对象 (第 03 章"变量是标签"在这里的后果),三次调用往同一个列表里各 append 一次。 正确写法是默认值用 None,进函数再建新的:
def collect_good(x, bucket=None):
if bucket is None:
bucket = [] # 每次调用新建一个列表
bucket.append(x)
return bucket口诀:默认参数只放不可变的东西(数字、字符串、None、元组)。 要放列表/字典/集合,就用 None 占位、在函数体里建。
10.7 作用域:函数里能读全局,赋值会造局部
GLOBAL_THR = 0.80 # 模块级(全局)变量
def read_global(soh):
return soh < GLOBAL_THR # ✅ 读全局没问题
def write_global():
GLOBAL_THR = 0.75 # ⚠ 这不是"改全局",而是新建了一个局部变量!函数里读一个外层的名字没问题;给它赋值就会创建局部变量,外面的那个完全不受影响。 为什么这么设计:这样读任何一个函数,都不用担心"它会不会偷偷改了全局变量"。 代价是"真想改全局"要写 global 关键字 —— 本项目约定:不用它; 需要变的东西靠参数传进去、靠 return 传出来(比如把 thr 变成参数)。
10.8 *args 与 **kwargs:参数个数不确定时
def mean_of(*caps): # *caps 把任意个位置参数收成一个元组
"""求任意个容量的均值。"""
return sum(caps) / len(caps)
def describe_cell(**info): # **info 把任意个关键字参数收成一个字典
"""把任意键值对拼成一行说明。"""
return " ".join(f"{k}={v}" for k, v in info.items())
mean_of(2.5, 2.4, 2.3) # 2.4
describe_cell(id="C001", soh=0.95) # id=C001 soh=0.95| 写法 | 收成什么 | 名字随意吗 |
|---|---|---|
*args | 元组 | args 只是惯例,写 *caps 更清楚 |
**kwargs | 字典 | 惯例写 kwargs,写 **info 也行 |
**kwargs 必须是参数表最后一个,所以常见形状是 def f(a, b=1, *args, **kwargs) —— 固定参数 → 默认参数 → 可变参数。
什么时候用:你真的不知道会有几个参数(比如 print 能收任意个)。 什么时候别用:能用固定参数就用固定的 —— *args 会让"这个函数要什么"变得不可读,IDE 也提示不了。 (调用侧的 f(*nums) / f(**info) 是相反的操作:把列表/字典拆开成参数。)
10.9 lambda:一次性的小函数
cells = [("C001", 0.95), ("C002", 0.79), ("C003", 0.88)]
sorted(cells, key=lambda item: item[1]) # 按 SOH 升序
# [('C002', 0.79), ('C003', 0.88), ('C001', 0.95)]
sorted(cells, key=lambda item: -item[1]) # 降序:加个负号lambda 参数: 表达式 就是一个没有名字、只有一行表达式的函数, 等价于先 def soh_of(item): return item[1] 再把 soh_of 传进去。 它只能是一个表达式 —— 不能 print、不能赋值、不能写多行。
纪律:lambda 只适合"一眼能看懂的一行"。一旦需要 if/else,或者想加注释说明, 就该写 def 并起个名字 —— 可读性优先于"少写两行"。
10.10 函数是"一等公民":可以当参数传
def apply_to_all(caps, fn):
"""把 fn 作用到每个容量上。"""
return [fn(c) for c in caps]
caps = [2.5, 2.3]
apply_to_all(caps, lambda c: c * 1000) # [2500.0, 2300.0](Ah → mAh)
apply_to_all(caps, round) # [3, 2](直接传内置函数)关键认识:apply_to_all 不知道"要做什么变换",它只负责循环;"做什么"由 fn 决定 —— 循环与变换分离,这就是函数式编程的核心思路,也是后面 pandas .apply() 的思想基础。 round 后面没有括号:写 round 是"把函数本身传进去",写 round() 是"调用它并传结果"。
10.11 完整示例与真实输出
运行 cd code && python demos/ch10-function.py,真实输出(节选):
=== ① def 与 return ===
calc_soh(2.30) = 0.92(默认 2.5) 帮助文档:由实测容量与额定容量算 SOH(健康度)。
=== ② 忘了 return ===
bad_soh(2.3) = None
TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'
=== ④ 判断类函数 ===
is_eol(0.95) = False is_eol(0.78) = True is_eol(0.78, 0.85) = True
=== ⑤ 可变默认值陷阱 ===
bad : [1, 2, 3] [1, 2, 3] [1, 2, 3] ← 脏了! good: [1] [2] [3]
=== ⑦ *args / **kwargs ===
mean_of(2.5, 2.4) = 2.45 describe_cell(id='C001', soh=0.95) → id=C001 soh=0.95
=== ⑨ 函数可以当参数 ===
apply_to_all(caps, lambda c: c * 1000) = [2500.0, 2300.0](Ah → mAh)
apply_to_all(caps, round) = [3, 2](直接传内置函数)常见坑
| 报错 / 现象 | 原因 | 怎么改 |
|---|---|---|
TypeError: ... for *: 'NoneType' and 'int' | 函数忘了 return | 补 return;先 print(f(参数)) 看是不是 None |
| 屏幕上有输出,但调用方拿不到值 | 只有 print 没有 return | print 给人看,return 给程序用 |
| 第二次调用,结果带着上次的数据 | 可变默认参数 def f(x, bucket=[]) | 默认写 None,函数内 if bucket is None: bucket = [] |
NameError: name 'GLOBAL_THR' is not defined | 以为函数里赋值能改全局 | 用参数传入、用 return 传出 |
| 排序结果不对 | 传了 key=func() 而不是 key=func | 去掉括号,传函数本身 |
| 函数里改了列表,外面也变了 | 列表是可变对象(第 03/06 章) | 需要独立副本时先 .copy() |
本章练习
练习 1:写 calc_soh + is_eol,并用它们重写三档判断
给定 cap_rated = 2.5、caps = [2.50, 2.30, 2.12, 1.95],完成: ① 定义 calc_soh(cap_now, cap_rated=2.5) 返回 SOH;② 定义 is_eol(soh, thr=0.80), 一行返回"soh 是否低于阈值";③ 用这两个函数输出每支电芯的三档状态 (默认阈值 0.80 判"待退役",0.85 判"预警",否则"健康");④ 打印两个函数的说明(__doc__)。
期望输出:SOH 1.000 健康、SOH 0.920 健康、SOH 0.848 预警、SOH 0.780 待退役, 以及 is_eol(0.78) = True、is_eol(0.78, thr=0.5) = False。
参考答案
cap_rated = 2.5
caps = [2.50, 2.30, 2.12, 1.95]
def calc_soh(cap_now, cap_rated=2.5): # ①
"""由实测容量与额定容量算 SOH。"""
return cap_now / cap_rated
def is_eol(soh, thr=0.80): # ②
"""SOH 是否已跌破退役阈值(默认 0.80)。"""
return soh < thr # 比较运算本身就是布尔值
for c in caps: # ③
soh = calc_soh(c, cap_rated)
if is_eol(soh): # 默认阈值 0.80(先判更严的)
status = "待退役"
elif is_eol(soh, 0.85): # 换个阈值,复用同一个函数
status = "预警"
else:
status = "健康"
print(f"SOH {soh:<6.3f} {status}")
print(f"is_eol(0.78) = {is_eol(0.78)}")
print(f"is_eol(0.78, thr=0.5) = {is_eol(0.78, thr=0.5)}")
print(f"calc_soh 的说明:{calc_soh.__doc__}") # ④四个要点:① 三档判断的顺序不能反 —— 必须先判更严的退役线(0.80), 反过来 soh=0.78 会先落进"预警"分支;② 阈值只出现在一处,产线标准改成 0.75 时只改默认参数, 这就是"封装"的实际收益;③ is_eol(soh, 0.85) 复用同一个函数:阈值变参数,一个函数顶两个; ④ calc_soh(c) 用默认的 cap_rated=2.5,写成 calc_soh(c, cap_rated) 更明确,两种都对。
完整练习在 exercises/ex04-10-function —— 含骨架 exercise.py 和参考答案 solution.py。
练习 2:找出"忘了 return"的函数并修好
下面这段代码运行后会报错。先说出报错在哪一行、为什么,再修好它:
def avg_soh(caps, cap_rated):
total = sum(c / cap_rated for c in caps) / len(caps)
print(f"平均 SOH = {total:.4f}")
result = avg_soh([2.50, 2.30, 2.00], 2.5) * 100
print(f"平均 SOH 百分比 = {result:.1f}%")参考答案
报错:TypeError: unsupported operand type(s) for *: 'NoneType' and 'int',在 * 100 那一行。
原因:avg_soh 里只 print 了,没有 return —— 没有 return 的函数一律返回 None, 于是变成 None * 100。屏幕上年能看到"平均 SOH = 0.9067",很容易误以为函数正常工作了, 这正是 print ≠ return 的坑。
# ✅ 修法:函数只负责算,打印交给调用方
def avg_soh(caps, cap_rated=2.5):
"""返回平均 SOH。"""
return sum(c / cap_rated for c in caps) / len(caps)
result = avg_soh([2.50, 2.30, 2.00]) * 100
print(f"平均 SOH 百分比 = {result:.1f}%") # 平均 SOH 百分比 = 90.7%两个要点:① 报错位置 ≠ bug 位置 —— 错误在 * 100,根因在函数里少了一行; 排查这类问题的第一招是 print(repr(avg_soh(...))),看到 None 就明白了; ② 只要调用方要用这个结果就必须有 return,而且函数名要能看出它打不打印 —— 只想打印就该叫 print_avg_soh。
练习 3:修好"会串味"的收集函数
下面这个函数的每次调用结果都被上一次污染了。解释原因并给出正确写法:
def add_soh(soh, history=[]):
history.append(soh)
return history
print(add_soh(0.95)) # [0.95]
print(add_soh(0.88)) # 期望 [0.88],实际是什么?参考答案
实际输出 [0.95] 然后 [0.95, 0.88] —— 上一次的数据还在。
原因:history=[] 这个默认值只在函数定义时创建一次,之后所有调用共用同一个列表对象。 它不是"每次调用都新建空列表",而是"定义时建了一个,以后一直用它"。
def add_soh(soh, history=None):
if history is None: # 每次调用时都新建一个
history = []
history.append(soh)
return history三个要点:① 默认参数只放不可变的东西(数字、字符串、布尔、元组、None), 列表/字典/集合都要用 None 占位 + 函数内新建; ② 用 is None 而不是 == None(第 03 章的纪律)—— 而且调用方传进来空列表 [] 时 history is None 是 False,会保留调用方的列表,语义正确; ③ 真实项目里的形态:把预测结果、日志、缓存往默认列表里塞,表现是"第二个样本带上第一个", 代码里完全看不出"共享",很难查。
面向 AI 开发:函数式编程与数据管道复用
AI 代码的本质是一串"数据变换"首尾相接:原始 CSV → 清洗 → 特征 → 模型输入 → 输出 → 指标。 每一个箭头都应该是一个函数 —— 不是风格偏好,而是工程必需:
| 好处 | 具体表现 |
|---|---|
| 可替换 | 换个标准化方式,只换那一个函数 |
| 可复用 | 训练和推理用同一个 preprocess(),避免线上线下不一致 |
| 可单测 / 可定位 | 单独验证一个函数;指标掉了按管道逐段打印中间结果 |
① pandas 的 .apply() 就是 10.10 节的 apply_to_all。
[fn(c) for c in caps] # 原生 Python 版
df["soh"] = df["capacity_ah"].apply(lambda v: v / 2.5) # pandas 版写法几乎一样,区别是 pandas 把逐行循环挪到了底层 C 里。先理解"循环与变换分离", 再学 .apply() 就不是新概念了。顺带记住:能用整列运算(df["a"] / 2.5)就别用 .apply() —— 后者要逐行调用 Python 函数,慢一个数量级。
② 默认参数就是"超参数的默认值":def make_loader(path, batch_size=32, shuffle=True): ...。 默认值保证最简调用能跑,参数保证能调优 —— HuggingFace 的 TrainingArguments 有上百个字段、 绝大多数有默认值,就是本章那个 cap_rated=2.5 的放大版。
③ 把函数传进管道,是"策略可插拔"的标准做法。
def run_pipeline(samples, steps):
for step in steps: # steps 是一个函数列表
samples = [step(s) for s in samples]
return samples
data = run_pipeline(data, [normalize, clip_outliers, to_float32])数据增强、损失函数、评价指标、优化器、学习率调度 —— 全都是"可插拔的变换"。 PyTorch 的 DataLoader(..., collate_fn=my_collate)、optimizer.step、scheduler 都是这个模式: 框架管流程,你只交一个函数。
④ 一条实战纪律:先写函数,再写脚本。 把逻辑全写在顶层就无法复用、无法测试、无法重跑单个步骤; 拆成 load(path) / clean(df) / summarize(df),最后 main() 只负责把三步串起来。 第 39 章的《电池数据体检报告》就是按这个形状组织的:main() 里只有调用,没有计算 —— 本章的 calc_soh / is_eol 是那个结构的第一块砖。