第 15 章 · 类与对象:给一支电芯建模
这一章要解决什么:把"一支电芯"的数据和它的行为(算 SOH、判断是否退役)打包成一个对象。 要打掉的关键坑:
self到底是什么,以及b = a只是起别名、不是复制 —— 这两个不搞清楚,后面读 PyTorch 和 sklearn 的代码会一直糊涂。
本章你需要完成的代码:demos/ch15-class.py
15.1 先看"不用类"的写法
用第 09 章的字典存一支电芯,再用第 10 章的函数算 SOH:
cell_dict = {"id": "C001", "cap_rated": 2.5, "cap_now": 2.30, "n_cycles": 862}
def dict_soh(c):
return c["cap_now"] / c["cap_rated"]=== ① 先看「不用类」的写法 ===
dict 版: C001 SOH=0.9200
问题:① 键名写错要到运行时才报 KeyError;② 函数和数据分离;③ 没有「这就是一支电芯」的语义能跑,但三个问题:键名靠字符串(c["cap_ratd"] 到运行时才报 KeyError); 数据和函数散落;没有"这是一个什么"的语义({"id": ...} 可以是任何东西)。 类把这三件事一起解决:数据(属性)和行为(方法)绑在一个名字下面。
15.2 class、__init__ 与 self
class BatteryCell:
"""一支电池电芯。"""
chemistry = "LFP" # 类属性:所有实例共享
def __init__(self, cell_id, cap_rated=2.5, cap_now=None, n_cycles=0):
"""构造函数:创建实例时自动调用,self 就是「正在被创建的这个对象」。"""
self.cell_id = cell_id # 实例属性:每支电芯各有一份
self.cap_rated = cap_rated
self.cap_now = cap_rated if cap_now is None else cap_now
self.n_cycles = n_cycles
def soh(self): # 方法:第一个参数永远是 self
"""健康度 = 当前容量 / 额定容量。"""
return self.cap_now / self.cap_rated
def is_eol(self, thr=0.80):
"""是否已达退役阈值。"""
return self.soh() < thr
def status(self):
"""三档状态:待退役 / 预警 / 健康。"""
s = self.soh()
if s < 0.80:
return "待退役"
if s < 0.85:
return "预警"
return "健康"
def __repr__(self):
return (f"BatteryCell(id={self.cell_id!r}, "
f"soh={self.soh():.4f}, n_cycles={self.n_cycles})")
c1 = BatteryCell("C001", cap_now=2.30, n_cycles=862)
print(f" {c1}")
print(f" c1.soh()={c1.soh():.4f} c1.is_eol()={c1.is_eol()} c1.status()={c1.status()}")
print(f" c1.chemistry = {c1.chemistry}(类属性,实例也能读)")真实输出:
=== ② 用类 ===
BatteryCell(id='C001', soh=0.9200, n_cycles=862)
c1.soh() = 0.9200
c1.is_eol() = False
c1.status() = 健康
c1.chemistry = LFP(类属性,实例也能读)三个关键理解:① self 就是"这个实例" —— 你写 c1.soh(),Python 实际执行的是 BatteryCell.soh(c1),把 c1 当成第一个参数传进去(这就是"第一个参数必须是 self"的原因)。 ② __init__ 是"初始化"函数:BatteryCell("C001") 先创建空对象,再自动调用它去填属性。 ③ 判断"有没有传参数"要用 is None:写成 cap_now or cap_rated 会在 cap_now=0 时出错。
15.3 实例属性 vs 类属性
=== ③ 每个实例各有一份数据 ===
BatteryCell(id='C001', soh=0.9200, n_cycles=862)
BatteryCell(id='C002', soh=0.7800, n_cycles=1200)
c1 is c2 ? False c1.cell_id=C001 c2.cell_id=C002| 在哪定义 | 谁有 | 例子 | |
|---|---|---|---|
| 实例属性 | self.xxx = ... | 每个实例各一份 | cell_id、cap_now |
| 类属性 | 类体里直接赋值 | 所有实例共享 | chemistry = "LFP" |
⚠ 陷阱:在实例上"修改"类属性,只会创建出一个同名的实例属性,其他实例不受影响:
c1.chemistry = "NCM" # 只是给 c1 加了个新属性;BatteryCell.chemistry 还是 "LFP"该用哪个:真正"每个对象都不同"的放实例属性;"整类共同的默认值"放类属性。 拿不准就放实例属性 —— 更安全。
15.4 方法链:return self
def degrade(self, cycles=1, beta=1.15):
"""模拟再循环若干次后的容量(用书里的退化公式)。"""
c = min(1.0, (self.n_cycles + cycles) / 1000)
self.cap_now = round(self.cap_rated * (1.0 - 0.20 * c ** beta), 4)
self.n_cycles += cycles
return self # ← 返回自己,就能连着写
c3 = BatteryCell("C003", cap_now=2.50)
c3.degrade(300).degrade(300).degrade(300)
print(f" 连跑 900 圈后:{c3}")=== ④ 方法链(返回 self) ===
连跑 900 圈后:BatteryCell(id='C003', soh=0.8228, n_cycles=900)return self 就是"方法链"的全部秘密:每次返回自己,下一个方法就能接着点 —— Pandas 的 df.dropna().sort_values().head() 就是这种设计(但它默认返回新表, 而这里会修改原对象,用的时候要清楚这一点)。
15.5 @property:用起来像属性,实际是算出来的
class CellWithProperty(BatteryCell):
"""继承 BatteryCell,加两个「算出来的属性」。"""
@property
def soh_pct(self):
"""SOH 的百分比形式(只读属性)。"""
return round(self.soh() * 100, 2)
@property
def fade_rate(self):
"""容量衰减率。"""
return round(1 - self.soh(), 4)
def __repr__(self):
return f"CellWithProperty({self.cell_id!r}, soh_pct={self.soh_pct})"=== ⑤ @property ===
CellWithProperty('C004', soh_pct=92.0)
cp.soh_pct = 92.0 ← 用起来像属性,没有括号
cp.fade_rate = 0.08
想给它赋值 → AttributeError: property 'soh_pct' of 'CellWithProperty' object has no setter价值:调用方不用关心"这个值是存的还是算的"(df.shape 就是这种设计)。 @property 默认只读,想让它可写要再加 @soh_pct.setter。
15.6 __repr__:调试时最值得写的方法
15.2 的类里有这一个方法,print(c1) 才能看到 BatteryCell(id='C001', soh=0.9200, n_cycles=862); 没有它只会得到 <__main__.BatteryCell object at 0x104f3c190> —— 一个内存地址,毫无信息量。 __repr__ 给开发者看(关键字段 + 值),__str__ 给用户看(print 优先用它,本书只写前者)。 {self.cell_id!r} 里的 !r 表示"用 repr() 输出",所以字符串带引号 —— 调试时一眼能分清 'C001' 和 C001。
15.7 __eq__ 与 @dataclass
默认的 == 比较的是"是不是同一个对象"。想按内容比较,要定义 __eq__ —— 而 @dataclass 能自动生成 __init__ / __repr__ / __eq__:
from dataclasses import dataclass, replace
@dataclass
class CellRecord:
"""AI 工程里「一条数据记录」几乎都用 dataclass 写。"""
cell_id: str
batch: str
soh: float
n_cycles: int = 0
r1 = CellRecord("C001", "A", 0.95)
r2 = CellRecord("C001", "A", 0.95)
r3 = CellRecord("C002", "B", 0.88)
print(f" r1 == r2 ? {r1 == r2} ← dataclass 自动按字段比较")
print(f" r1 == r3 ? {r1 == r3}")=== ⑦ __eq__ ==
r1 == r2 ? True ← dataclass 自动按字段比较
r1 == r3 ? False为什么 AI 项目爱用 @dataclass:少写一半代码(__init__ / __repr__ / __eq__ 全自动); 天生配类型注解(第 16 章的工具能检查);frozen=True 之后不可变、能放进 set; replace(r1, soh=0.90) 生成新对象,避免"某个函数悄悄改了配置"的事故。
15.8 继承、super(),以及什么时候别用继承
=== ⑥ 继承 ===
CellWithProperty 的父类链:['CellWithProperty', 'BatteryCell', 'object']
子类自动获得父类的 soh()/is_eol()/degrade(),只重写需要变的部分。__mro__(方法解析顺序)就是"找方法的顺序":先在子类找,再去 BatteryCell,最后是 object。 super() 用来调用父类的实现:class CellWithLog(BatteryCell) 的 __init__ 里 先写 super().__init__(cell_id, **kwargs)(把父类的初始化做完),再写自己的 self.history = []。
⚠ 忘了 super().__init__() 是最常见的继承 bug:父类属性根本没被创建, 调用 self.soh() 时报 AttributeError: 'CellWithLog' object has no attribute 'cap_rated'。 为什么说"组合优于继承"?判断标准是 "是一种"(is-a)还是"用到了"(has-a): 锂电池是一种电芯 → 继承;一个电池包里有 12 支电芯 → 组合:
class BatteryPack: # ✅ 组合:包里"有"电芯
def __init__(self, cells: list[BatteryCell]):
self.cells = cells
def mean_soh(self) -> float:
return sum(c.soh() for c in self.cells) / len(self.cells)硬套继承(class BatteryPack(BatteryCell))会把父类的所有东西带进来, 包括你根本不需要的 soh()/is_eol(),语义全乱。不确定就用组合; 继承链超过两层,通常该重构了。
15.9 b = a 是别名,不是副本
b = a # 不是复制!
b.cap_now = 1.90 # 通过 b 改了那个对象 → a.soh() 也变了(0.7600)这是第 03 章"变量是标签"的直接后果,在对象上表现得最明显。 要独立副本:copy.copy(a)(浅拷贝)、copy.deepcopy(a)(深拷贝,嵌套对象也复制), 或者干脆重新构造一个。判断标准:你想改的是"这个对象",还是"和它一样的新对象"?
15.10 实战:对象列表做汇总
有了 CellRecord,"一批电芯"就是"一个列表":fleet = [CellRecord(...), ...], 过滤 / 排序 / 统计全都变成了你第 06、11 章已经会的操作:
平均 SOH = 0.8733常见坑
- 忘了
self:def soh():少了self,调用时报TypeError: soh() takes 0 positional arguments but 1 was given。 - 类名后面忘了括号:
c = BatteryCell得到的是类本身,不是实例。 - 在
__init__里用可变默认值:def __init__(self, cells=[])会让所有实例共享同一个列表;默认值写None,函数体里再self.cells = [] if cells is None else cells。 - 以为
b = a是复制:它只是给同一个对象又贴了个标签。 - 继承时忘了
super().__init__():父类属性不存在,报AttributeError。 - 在实例上"改"类属性:
c1.chemistry = "NCM"只影响c1;想改整类要写BatteryCell.chemistry = "NCM"(但通常不该这么干)。
本章练习
练习 1:写一个完整的 BatteryCell 类
要求:① 属性 cell_id、batch、cap_rated=2.5、cap_now=None(为 None 时取 cap_rated)、n_cycles=0; ② 类属性 chemistry = "LFP";③ 方法 soh()、is_eol(thr=0.80)、 status()(< 0.80 待退役、< 0.85 预警、其余健康); ④ __repr__ 打印成 BatteryCell(id='C001', soh=0.9200, n_cycles=862)。 最后创建 3 支电芯打印汇总表,平均 SOH 应为 0.8633。
参考答案
class BatteryCell:
"""一支电池电芯。"""
chemistry = "LFP" # 类属性:所有实例共享
def __init__(self, cell_id, batch, cap_rated=2.5, cap_now=None, n_cycles=0):
"""构造函数:self 就是"正在被创建的这个对象"。"""
self.cell_id = cell_id
self.batch = batch
self.cap_rated = cap_rated
self.cap_now = cap_rated if cap_now is None else cap_now # ① 用 is None 判断
self.n_cycles = n_cycles
def soh(self):
"""健康度 = 当前容量 / 额定容量。"""
return self.cap_now / self.cap_rated
def is_eol(self, thr=0.80):
"""是否已达退役阈值。"""
return self.soh() < thr
def status(self):
"""三档状态:待退役 / 预警 / 健康。"""
s = self.soh()
if s < 0.80:
return "待退役"
if s < 0.85:
return "预警"
return "健康"
def __repr__(self):
"""决定 print(obj) 显示什么。"""
return (f"BatteryCell(id={self.cell_id!r}, "
f"soh={self.soh():.4f}, n_cycles={self.n_cycles})")
c1 = BatteryCell("C001", "A", cap_now=2.30, n_cycles=862)
c2 = BatteryCell("C002", "B", cap_now=2.20, n_cycles=1342)
c3 = BatteryCell("C003", "C", cap_now=1.975, n_cycles=859)
for c in (c1, c2, c3):
print(f"{c.cell_id:<7}{c.batch:<6}{c.soh():<9.4f}{c.status()}")
print(f"平均 SOH = {sum(c.soh() for c in (c1, c2, c3)) / 3:.4f}")五个要点:① self 是什么 —— 调用 c1.soh() 时,Python 实际执行 BatteryCell.soh(c1); ② __init__ 里每个 self.xxx 都是实例属性,各实例互不影响; ③ 用 cap_now is None 判断,而不是 cap_now or cap_rated(后者在传入 0 时误判); ④ __repr__ 让 print(obj) 直接看到关键信息;⑤ status() 的阈值判断有顺序(先判最严重的)。
完整练习在 exercises/ex04-15-class。
练习 2:加一个 @property
给 BatteryCell 加只读属性 soh_pct(SOH 的百分数,保留 2 位小数), 使 cp.soh_pct 得到 92.0(没有括号),而 cp.soh_pct = 50 报 AttributeError。
参考答案
class CellWithProperty(BatteryCell):
"""继承 BatteryCell,加一个只读的百分比属性。"""
@property
def soh_pct(self):
"""SOH 的百分比形式(只读)。"""
return round(self.soh() * 100, 2)
cp = CellWithProperty("C004", "D", cap_now=2.30)
print(f"{cp} → soh_pct = {cp.soh_pct}(用起来像属性,没有括号)")
cp.soh_pct = 50
# AttributeError: property 'soh_pct' of 'CellWithProperty' object has no setter三个要点:① @property 把方法"伪装"成属性 —— 存的值和算出来的值,用起来一样; ② 没有 setter 就是只读的;③ 子类通过继承自动获得 soh()、is_eol()、__repr__。 顺带回答"== 为什么变了":普通类的 == 用 object.__eq__,等价于 is (是不是同一个对象),所以两个内容相同的实例也是 False;@dataclass 自动生成 __eq__, 逐字段比较,于是 True。
练习 3:用 @dataclass 写"一组超参数"
用 @dataclass 定义 TrainConfig(learning_rate: float、batch_size: int = 64、seed: int = 42), 创建两个字段相同的对象并比较;再用 replace() 改一个字段,证明原对象没变。
参考答案
from dataclasses import dataclass, replace
@dataclass
class TrainConfig:
"""一次训练的超参数。"""
learning_rate: float
batch_size: int = 64
seed: int = 42
cfg1 = TrainConfig(0.001)
cfg2 = TrainConfig(0.001)
print(cfg1) # TrainConfig(learning_rate=0.001, batch_size=64, seed=42)
print(cfg1 == cfg2) # True ← __eq__ 自动按字段比较
cfg3 = replace(cfg1, learning_rate=0.0005)
print(cfg3) # learning_rate 变成 0.0005
print(cfg1) # 原对象没变三个收获:① __init__ / __repr__ / __eq__ 全自动生成,一行样板代码都不用写; ② replace() 生成新配置、原配置不变 —— 实验记录里非常关键; ③ 加 @dataclass(frozen=True) 之后对象不可变,可以当字典键、放进 set。 一次实验 = 一份配置对象 + 一个 git commit,"改了什么"能被 == 和 diff 直接看出来。
面向 AI 开发:你每天都在用的是"对象"
① sklearn 的模型就是一个对象,fit / predict 就是它的方法。
from sklearn.linear_model import LinearRegression
model = LinearRegression() # 创建实例(还没学到任何东西)
model.fit(X_train, y_train) # 结果存在 self 里(coef_、intercept_)
y_pred = model.predict(X_test) # 用的就是 fit 时存下的 self.coef_模型不是一个函数,而是一个"带着参数的对象":fit 改的是 self 的属性, predict 读的也是 self 的属性。看懂这一点,你就看懂了 sklearn 的所有模型。
② PyTorch 的 Dataset 是一个必须继承的类。
from torch.utils.data import Dataset
class BatteryDataset(Dataset):
"""自定义数据集:继承 Dataset,实现 __len__ 和 __getitem__。"""
def __init__(self, rows: list[dict]):
self.rows = rows
def __len__(self) -> int: # len(dataset)
return len(self.rows)
def __getitem__(self, idx: int): # dataset[idx]
r = self.rows[idx]
return r["features"], r["label"]两个"魔法方法"就是协议:__len__ 让 len(ds) 能用,__getitem__ 让 ds[0] 能用; DataLoader 靠它们遍历你的数据 —— 这和本章的 __repr__ / __eq__ 是同一套机制。
③ 配置用 @dataclass,别用字典(加 frozen=True 更好):IDE 能补全 cfg.lr (字典只能补全字符串,写错不报错)、防意外修改、字段带类型注解(第 16 章的工具能检查)。 ④ 有状态的东西,天然就该是一个对象:模型、数据集、优化器、回调、指标都带状态 (学到的参数、当前 epoch、累计的 loss)—— 这就是 AI 框架全是对象的原因。
一句话总结:类是"把数据和操作它的行为放在一起"的工具。 不懂类,你只能照抄 AI 框架的代码;懂了类,你才知道每一行在动哪个对象的哪个属性。