Skip to content

第 10 章 · 函数:把「算 SOH」封装起来 ​

这一章要解决什么:把"算 SOH"这段逻辑写一次、用很多次,让阈值这类魔法数字只出现在一个地方。 四个关键坑:忘了 return 会得到 None、print 不等于 return、 默认参数 def f(x, bucket=[]) 会串味、函数里的赋值不会改全局变量。

本章你需要完成的代码:demos/ch10-function.py

10.1 为什么要函数:同一段计算抄了三遍 ​

python
cap_rated = 2.5
soh1 = 2.50 / cap_rated        # 抄一遍
soh2 = 2.30 / cap_rated        # 又抄一遍
soh3 = 2.00 / cap_rated        # 再抄一遍

三行还能忍,30 支呢?更麻烦的是规则变了:产线把额定容量改成 2.6 时, 你要找出每一处 2.5 并逐个确认它是不是"额定容量"。函数就是给一段逻辑起个名字:

python
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"
python
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 ​

python
def bad_soh(cap_now, cap_rated=2.5):
    cap_now / cap_rated           # 算完就扔了,没有 return

真实输出:

text
  bad_soh(2.3) = None  ← None
    TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'

两条铁律:① 没有 return 的函数返回 None,Python 不报错,错误会在后面用到这个返回值时 爆发 —— 报错位置离真正的 bug 很远,这才是难查的原因; ② print ≠ return,前者把东西显示给人看,后者把值交回程序。

python
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 返回多个值:其实是"打包成元组" ​

python
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 比较结果 ​

python
def is_eol(soh, thr=0.80):
    """SOH 是否已跌破退役阈值。"""
    return soh < thr           # ✅ 一行,等价,没有分支

(写成 if soh < thr: return True else: return False 也对,但那是多余的分支 —— 比较运算本身就是布尔值。)

真实输出:

text
  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 可变默认参数陷阱(面试常考) ​

python
def collect_bad(x, bucket=[]):     # 默认值只在「定义时」求值一次,全程共用同一个列表
    bucket.append(x)
    return bucket

print(collect_bad(1), collect_bad(2), collect_bad(3))

真实输出:

text
  bad : [1, 2, 3] [1, 2, 3] [1, 2, 3]  ← 脏了!
  good: [1] [2] [3]  ← 每次都是新的

为什么:默认参数 [] 在函数定义时就创建好了,之后每次调用用的都是同一个列表对象 (第 03 章"变量是标签"在这里的后果),三次调用往同一个列表里各 append 一次。 正确写法是默认值用 None,进函数再建新的:

python
def collect_good(x, bucket=None):
    if bucket is None:
        bucket = []            # 每次调用新建一个列表
    bucket.append(x)
    return bucket

口诀:默认参数只放不可变的东西(数字、字符串、None、元组)。 要放列表/字典/集合,就用 None 占位、在函数体里建。

10.7 作用域:函数里能读全局,赋值会造局部 ​

python
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:参数个数不确定时 ​

python
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:一次性的小函数 ​

python
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 函数是"一等公民":可以当参数传 ​

python
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,真实输出(节选):

text
=== ① 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 没有 returnprint 给人看,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。

参考答案
python
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"的函数并修好

下面这段代码运行后会报错。先说出报错在哪一行、为什么,再修好它:

python
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 的坑。

python
# ✅ 修法:函数只负责算,打印交给调用方
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:修好"会串味"的收集函数

下面这个函数的每次调用结果都被上一次污染了。解释原因并给出正确写法:

python
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=[] 这个默认值只在函数定义时创建一次,之后所有调用共用同一个列表对象。 它不是"每次调用都新建空列表",而是"定义时建了一个,以后一直用它"。

python
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。

python
[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 的放大版。

③ 把函数传进管道,是"策略可插拔"的标准做法。

python
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 是那个结构的第一块砖。


下一章:第 11 章 · 元组、推导式与解包