下面这段代码,线上排查权限问题时我见过类似的。
classStaff:
defcan_export(self):
returnFalse
classAdmin(Staff):
pass
user = Admin()
print(user.can_export()) # False
一眼看过去,Admin 是管理员,怎么导出权限还是 False?
别急着骂配置中心,也别急着查数据库。这里的问题很简单:Admin 继承了 Staff,但是它自己没重写 can_export(),所以 Python 顺着继承链往父类里找,最后执行的还是 Staff.can_export()。
这就是 Python 面向对象里继承最常见的一个特点:子类会自动拥有父类的属性和方法。
继承不是为了少写几行代码,真只为了少写代码,后面大概率会写出一堆奇怪的父类。继承更像是在表达一种关系:Admin 是一种 Staff,CsvReport 是一种 Report,RetryTask 是一种 Task。
比如报表导出,我一般会这么拆:
classExportJob:
def__init__(self, operator, rows):
self.operator = operator
self.rows = rows
defcheck_rows(self):
ifnot self.rows:
raise ValueError("rows is empty")
defrun(self):
self.check_rows()
return self.build_file()
defbuild_file(self):
raise NotImplementedError("subclass must build file")
classCsvExportJob(ExportJob):
defbuild_file(self):
lines = []
for row in self.rows:
lines.append(",".join(str(row.get(k, "")) for k in ("id", "name", "amount")))
return"\n".join(lines)
job = CsvExportJob("dong", [
{"id": 1001, "name": "order_a", "amount": 32.5},
{"id": 1002, "name": "order_b", "amount": 19.8},
])
print(job.run())
这里 CsvExportJob 没有写 run(),但它能调用。因为 run() 是父类 ExportJob 给它的。
这个设计里有个细节:父类负责固定流程,子类只负责变化点。check_rows()、run() 这些流程不应该每个导出类都抄一遍,不然后面加一个空数据校验,三个导出类漏改两个,很正常。
但 build_file() 我故意在父类里抛了 NotImplementedError。这个地方我不喜欢写成空实现。空实现太安静,出了问题还要查半天。直接抛异常,谁没实现,启动或者调用时就露出来。
继承的第二个特点,是子类可以重写父类方法。
classStaff:
def__init__(self, name):
self.name = name
defcan_export(self):
returnFalse
classAdmin(Staff):
defcan_export(self):
returnTrue
print(Admin("root").can_export()) # True
这时候再调用 can_export(),Python 先看 Admin 有没有这个方法,有就直接用子类自己的,不会再去父类拿。
所以排查这类问题时,我一般先看两件事:对象实际类型是什么,方法到底在哪个类上命中的。
user = Admin("root")
print(type(user))
print(user.can_export.__qualname__)
输出类似:
<class '__main__.Admin'>
Admin.can_export
__qualname__ 这个小东西很实用。尤其项目里一堆父类、Mixin、抽象基类叠在一起时,别靠猜,直接看方法来源。
继承还有一个容易被写乱的地方:子类初始化。
很多人写子类时直接把 __init__ 覆盖掉,然后父类里的字段没初始化,后面某个方法一调用就炸。
classImportTask:
def__init__(self, task_id):
self.task_id = task_id
self.status = "created"
defmark_running(self):
self.status = "running"
classUserImportTask(ImportTask):
def__init__(self, task_id, filename):
super().__init__(task_id)
self.filename = filename
这里的 super().__init__(task_id) 不能随手省。
因为 UserImportTask 重写了 __init__ 后,父类的 __init__ 不会自动执行。你不主动调,task_id、status 这些父类字段就不存在。
我见过这种报错:
AttributeError: 'UserImportTask' object has no attribute 'status'
看到这种,不要第一反应去查属性有没有拼错。先看子类是不是覆盖了 __init__,又忘了调 super()。
Python 还支持多继承,这个东西能用,但别乱用。
classJsonLogMixin:
deflog_payload(self):
return {
"task_id": self.task_id,
"status": self.status
}
classImportTask:
def__init__(self, task_id):
self.task_id = task_id
self.status = "created"
classProductImportTask(JsonLogMixin, ImportTask):
pass
task = ProductImportTask("imp_202405")
print(task.log_payload())
这里 JsonLogMixin 只提供日志能力,不负责业务主流程。像这种我觉得可以接受。
但如果两个父类都写了同名方法,就要小心了。Python 会按照 MRO,也就是方法解析顺序去找。
classLocalCache:
defload(self):
return"load from local"
classRemoteCache:
defload(self):
return"load from remote"
classConfigCache(LocalCache, RemoteCache):
pass
print(ConfigCache().load())
print(ConfigCache.__mro__)
输出会是:
load from local
(<class '__main__.ConfigCache'>, <class '__main__.LocalCache'>, <class '__main__.RemoteCache'>, <class 'object'>)
LocalCache 写在前面,所以先命中它。
这个顺序不是小事。多继承里如果混了业务父类、工具父类、框架父类,方法名再撞一下,问题会很隐蔽。代码看着没报错,实际执行的却不是你以为的那个方法。
所以我自己的习惯是:业务主线尽量单继承;多继承只拿来做 Mixin,而且 Mixin 不要藏状态,不要偷偷改核心流程。
Python 继承还有一个基础点:所有类最终都继承自 object。平时不用写,但它在背后一直在。
classOrder:
pass
print(Order.__mro__)
输出:
(<class '__main__.Order'>, <class 'object'>)
这也是为什么很多基础方法,比如对象表示、属性访问、比较行为,都能被类重写。
继承用得好,代码会很稳:公共流程放父类,变化点留给子类。继承用得烂,父类会变成垃圾桶,什么字段都往里塞,最后谁也不敢改。
判断一个继承设计靠不靠谱,我通常就看一句话能不能说顺:
CsvExportJob 是一种 ExportJob,顺。
UserService 是一种 BaseService,勉强。
OrderHandler 是一种 CommonUtils,这就不对味了。
继承不是工具箱,父类也不是公共方法收纳盒。它表达的是类型关系。这个边界守住,Python 的继承就不难用。