Python 抽象基类 ABC 与 @abstractmethod
适用场景:你正在写一个会被多人协作、长期演进的 Python 项目,需要让”一组类必须长得一样”。 阅读路径:第一部分建立直觉,第二部分掌握语法,第三部分拆底层机制,第四部分是可直接落地的企业级案例。 文中所有代码均在 Python 3.13.15 实测通过,关键输出直接附在代码之后。
第一部分 入门:为什么需要抽象基类
1.1 鸭子类型:自由度与它的账单
Python 的默认哲学是鸭子类型——不关心对象是什么类,只关心它有没有你要的方法。这让早期开发很快,代价是约定只存在于人脑和文档里,一旦有人记错,问题往往拖到运行期才爆出来。
先看一个再普通不过的报表模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class CSVDataSource:
def fetch_data(self): return "csv-data"
class APIDataSource:
def get_data(self): return "api-data"
def load(source): return source.fetch_data()
|
调用时:
1 2 3
| print(load(CSVDataSource())) print(load(APIDataSource()))
|
第二行会在运行时抛出:
1 2
| AttributeError: 'APIDataSource' object has no attribute 'fetch_data'
|
这里暴露了三个问题。命名没有统一约束,fetch_data 和 get_data 谁对谁错全凭记忆;错误暴露得太晚,要等到真去调用才发现;新增数据源时没有任何”照着填”的模板,漏掉哪个方法只能靠运气。项目小的时候这些问题还能忍,类一多、人一多就开始互相拆台。
1.2 用 NotImplementedError 打补丁,以及它的漏洞
一个流传很广的做法,是在基类里把方法体写成抛异常:
1 2 3 4 5 6 7 8 9
| class BaseProcessor:
def process(self, data): raise NotImplementedError("子类必须实现 process()")
class Broken(BaseProcessor): pass
|
实测行为是:
1 2 3
| b = Broken() b.process("x")
|
1 2 3
| 实例化成功(埋雷): <Broken object at 0x...> 调用时才爆炸: 子类必须实现 process()
|
它能跑,但约束是”软”的。漏实现的对象照样能被创建、被传递、被存进数据库,直到某条业务链路真正调用到它才崩。线上事故常常就是这么来的——process 可能只在某个分支里被调用,测试没覆盖到,问题就一路溜到生产。
1.3 ABC + @abstractmethod:把契约提前到实例化那一刻
标准库 abc 模块提供了官方方案:抽象基类(Abstract Base Class)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| from abc import ABC, abstractmethod
class BaseProcessor(ABC):
@abstractmethod def process(self, data): ...
class Broken(BaseProcessor): pass
b = Broken()
|
这一行会直接失败:
1 2 3
| TypeError: Can't instantiate abstract class Broken without an implementation for abstract method 'process'
|
区别就在于报错的时机。NotImplementedError 要等调用,ABC 在创建对象时就拦下了。对象都造不出来,自然没机会流进业务逻辑。
1.4 逐行拆解第一个完整的抽象基类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| from abc import ABC, abstractmethod import math
class Shape(ABC):
@abstractmethod def area(self) -> float: ...
@abstractmethod def perimeter(self) -> float: ...
def describe(self): return f"面积={self.area():.2f} 周长={self.perimeter():.2f}"
class Circle(Shape):
def __init__(self, r): self.r = r
def area(self): return math.pi * self.r**2
def perimeter(self): return 2 * math.pi * self.r
|
逐点说明:
Shape 继承 ABC。这个继承的意义是让 Shape 的元类变成 ABCMeta,从而启用”抽象方法检查”这套机制。它不是功能增强,而是告诉解释器”这个类是契约,不是成品”。
@abstractmethod 给方法打上”必须被实现”的标记。它只做标记,不改变函数本身的行为。
- 抽象方法体写
... 或 pass 都行。Python 不禁止你写真实逻辑,后续 2.3 会讲这有什么用。
describe 是普通的具体方法,带有默认实现。子类直接继承,不用重写。抽象基类可以同时容纳”必须实现”和”已经写好”的部分。
Circle 实现了两个抽象方法,于是它成为了”具体类”,可以实例化。
运行结果:
1 2 3 4
| print(Circle(5).describe())
Shape()
|
Shape() 抛出:
1 2 3
| TypeError: Can't instantiate abstract class Shape without an implementation for abstract methods 'area', 'perimeter'
|
如果子类只实现了一半:
1 2 3 4 5
| class Incomplete(Shape):
def area(self): return 0.0
|
实例化时报错会精确点出缺的是哪个:
1 2 3
| TypeError: Can't instantiate abstract class Incomplete without an implementation for abstract method 'perimeter'
|
1.5 怎么读这些报错
报错信息里有两个关键信息点。一是缺哪个方法,abstract method 'perimeter' 直接告诉你少写了谁;二是发生在实例化时,说明这不是写错了逻辑,而是根本没满足契约。以后看到 Can't instantiate abstract class,先去看子类是不是漏实现了某个抽象成员,八成就在那里。
第二部分 进阶:ABC 的六大能力
2.1 两种定义方式:继承 ABC 还是指定元类
1 2 3 4 5 6 7 8 9 10
| from abc import ABC, ABCMeta
class Way1(ABC): pass
class Way2(metaclass=ABCMeta): pass
|
两者完全等价。abc.ABC 本身只是一个空壳类,它的元类被设成了 ABCMeta:
推荐第一种写法。第二种在你已经有别的元类、需要手工组合时才会用到(见 3.1),平时没必要引入 metaclass= 这种更容易出错的语法。
2.2 @abstractmethod 到底做了什么
@abstractmethod 的实现非常轻。它给函数对象挂一个属性:
1 2 3 4
| def abstractmethod(funcobj): funcobj.__isabstractmethod__ = True return funcobj
|
真正的检查发生在类创建阶段。ABCMeta 在生成类时,会扫描命名空间和所有基类,把所有带 __isabstractmethod__ = True、且当前类没有覆盖的名字,收集进一个集合 __abstractmethods__。名字还在集合里,类就算抽象类,实例化时被拦下。
动手看一眼这个集合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class ABC1(ABC):
@abstractmethod def a(self): ...
@abstractmethod def b(self): ...
class Partial(ABC1):
def a(self): return 1
|
1 2
| print(Partial.__abstractmethods__)
|
__abstractmethods__ 是一个 frozenset。它非空,就说明还有没兑现的契约。这也是各框架判断”类是否抽象”的底层依据——inspect.isabstract(cls) 内部就是看这个集合。
2.3 抽象方法可以有方法体,也能被 super() 调用
很多人以为抽象方法必须空着,其实不是。抽象方法完全可以写实现,子类通过 super() 拿到它:
1 2 3 4 5 6 7 8 9 10 11 12
| class Base(ABC):
@abstractmethod def step(self): return "base-step"
class Impl(Base):
def step(self): return "impl+" + super().step()
|
这个特性在真实项目里很有用:把”每个子类都该做的公共部分”放进抽象方法体,子类先调用 super() 再补充自己的逻辑,既强制了实现,又复用了代码。
2.4 抽象属性
抽象成员不限于方法,属性也能抽象。装饰器的叠加顺序同样是 @abstractmethod 在最内层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Repository(ABC):
@property @abstractmethod def table_name(self) -> str: ...
class UserRepo(Repository):
@property def table_name(self): return "users"
|
1 2 3
| print(UserRepo().table_name) Repository()
|
子类必须提供一个同名的 property,光实现成普通方法也不行——类型对不上。
2.5 抽象类方法与抽象静态方法(顺序写反会直接报错)
@classmethod、@staticmethod 可以和 @abstractmethod 叠加,但顺序有硬性要求:@abstractmethod 必须写在最靠近 def 的位置(最内层)。
正确写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class Codec(ABC):
@classmethod @abstractmethod def name(cls) -> str: ...
@staticmethod @abstractmethod def encode(data: str) -> bytes: ...
class UTF8Codec(Codec):
@classmethod def name(cls): return "utf-8"
@staticmethod def encode(data): return data.encode("utf-8")
|
1 2 3
| print(UTF8Codec.name()) print(UTF8Codec.encode("中"))
|
如果顺序写反:
1 2 3 4 5 6 7
| class Wrong(ABC):
@abstractmethod @classmethod def name(cls): ...
|
在 Python 3.13 下,这个类定义的时候就报错:
1 2
| AttributeError: attribute '__isabstractmethod__' of 'classmethod' objects is not writable
|
静态方法反序同理('staticmethod' objects is not writable)。原因是装饰器自下而上执行:@classmethod 先把函数包成 classmethod 对象,@abstractmethod 再去给它设 __isabstractmethod__,而这个属性不可写。
顺带说一句历史包袱。abc 早期提供过 abstractproperty、abstractclassmethod、abstractstaticmethod 三个独立装饰器。它们从 Python 3.3 起就已弃用,现在还在标准库里但不要再用了,统一改成上文的叠加写法即可。
2.6 模板方法模式:具体方法 + 抽象步骤
这是 ABC 最有价值的一种用法,也是设计模式里的”模板方法”。把固定流程写在具体方法里,把可变步骤定义成抽象方法,交给子类填。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class DataPipeline(ABC):
def run(self, data): cleaned = self.clean(data) result = self.transform(cleaned) self.save(result) return result
@abstractmethod def clean(self, data): return data
@abstractmethod def transform(self, data): ...
@abstractmethod def save(self, data): ...
|
子类只需要关心自己的那几步:
1 2 3 4 5 6 7 8 9 10 11 12
| class UpperPipeline(DataPipeline):
def clean(self, data): return data.strip()
def transform(self, data): base = super().transform(data) return data.upper() if base is None else base
def save(self, data): print("已保存:", data)
|
1 2
| UpperPipeline().run(" hello ")
|
输出:
run 里的编排逻辑只写一次,所有子类共享同一条流水线。以后要在流程里插入埋点或审计,只改基类一处,不用挨个改子类。
2.7 isinstance / issubclass 与虚拟子类 register()
抽象基类天然参与类型判断:
1 2 3
| issubclass(Circle, Shape) isinstance(Circle(5), Shape)
|
更灵活的是”虚拟子类”。假设有个现成的旧类没继承你的基类,你又不想改它的源码:
1 2 3 4 5 6 7 8 9 10 11 12
| class Notifier(ABC):
@abstractmethod def send(self, msg): ...
class LegacySMS:
def send(self, msg): print("legacy sms:", msg)
|
1 2 3 4 5
| print(issubclass(LegacySMS, Notifier)) Notifier.register(LegacySMS) print(issubclass(LegacySMS, Notifier)) print(isinstance(LegacySMS(), Notifier))
|
注册之后,isinstance 和 issubclass 都会认账。有两点要注意:
1 2
| print(Notifier in LegacySMS.__mro__)
|
虚拟子类不会进入 MRO,也拿不到基类的具体方法,它只是让类型判断通过。另外 register 不做任何方法检查——上面 LegacySMS 恰好有 send,就算没有,注册照样成功。所以它是”声明式”的,责任在注册的人身上。
2.8 subclasshook:让类型判断认”结构”不认”出身”
如果想让判定更聪明——不看你继承没继承,只看你有没有某个方法,可以重写 __subclasshook__。这正是标准库 collections.abc 的做法,也是 issubclass(tuple, abc.Sequence) 能返回 True 的原因:tuple 是 C 实现,根本没机会去继承 Python 的 Sequence。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class MyIterable(ABC):
@classmethod def __subclasshook__(cls, C): if cls is MyIterable: if any("__iter__" in B.__dict__ for B in C.__mro__): return True return NotImplemented
class MyList:
def __iter__(self): return iter([])
|
1 2 3 4
| print(issubclass(MyList, MyIterable)) print(issubclass(list, MyIterable)) print(issubclass(tuple, MyIterable))
|
返回值有三种含义:True 表示认定为子类,False 表示认定为不是(即使它本来就继承了你),NotImplemented 表示交回默认机制继续判断。写这个钩子时务必定义成 classmethod,否则行为不稳定。
第三部分 精通:底层机制与选型
理解 ABCMeta 的时间线,能解释后面所有的”坑”。它继承自内置的 type,核心动作在 __new__:
1 2 3 4 5 6 7
| class ABCMeta(type):
def __new__(mcls, name, bases, namespace, **kwargs): cls = super().__new__(mcls, name, bases, namespace, **kwargs) _abc_init(cls) return cls
|
顺序很关键:super().__new__(...) 先执行,它会触发 __init_subclass__;_abc_init 在后面才算出 __abstractmethods__。记住这条时间线,下一个坑就顺理成章了。
另外,当你同时需要别的元类(比如 enum、ORM 的元类)时,得手工做一个同时继承 ABCMeta 的元类,因为 Python 只允许一个”最派生”的元类参与实例化。
3.2 一个几乎人人会踩的坑:abstractmethods 是 type 的描述符
先看现象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Base(ABC):
def __init_subclass__(self, **kwargs): super().__init_subclass__(**kwargs) print(getattr(cls, "__abstractmethods__", None))
@abstractmethod def work(self): ...
class StillAbstract(Base): pass
|
输出是:
1 2 3
| [__init_subclass__] StillAbstract 读到 __abstractmethods__ = None 类创建完成后再看: frozenset({'work'})
|
在 __init_subclass__ 里完全读不到抽象方法集合。原因有两层。第一层是时间线:__init_subclass__ 触发时 _abc_init 还没跑,集合还没算出来。第二层更隐蔽:
1 2
| print("__abstractmethods__" in type.__dict__)
|
__abstractmethods__ 是定义在 type 上的一个描述符,它只读当前类自己那份值,不会沿着父类 MRO 去找。所以别说读不到父类的,连”继承下来”这个概念都不成立——它是每个类各自独立的。
结论:任何依赖 __abstractmethods__ 的逻辑,都必须等类创建完成之后再执行。__init_subclass__ 不是合适的时机,得用自定义元类的 __init__(它跑在 _abc_init 之后)。第四部分的插件案例会用上这一点。
3.3 分层抽象与多重继承
抽象类可以继续派生抽象类,逐层细化契约:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class L1(ABC):
@abstractmethod def a(self): ...
@abstractmethod def b(self): ...
class L2(L1):
def a(self): return 1
|
1 2 3
| print(L2.__abstractmethods__) L2()
|
再补上 b,L3 就能实例化。这套机制让你可以先定义完整契约,再用中间层实现其中一部分,逐步逼近可用。
多重继承时,如果两个基类声明了同名抽象方法,子类实现一次就同时满足两者:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Readable(ABC):
@abstractmethod def open(self): ...
class Writable(ABC):
@abstractmethod def open(self): ...
class ReadWrite(Readable, Writable):
def open(self): return "opened"
|
1 2
| ReadWrite().__abstractmethods__
|
抽象基类也很适合当 mixin:提供若干具体方法,混进业务类里复用。
3.4 ABC、Protocol、鸭子类型:到底怎么选
Python 里描述”接口”有三条路,各有边界。
抽象基类(ABC):靠显式继承建立契约,运行时会强制。子类没实现全,实例化就失败。适合你要控制继承体系、需要运行时拦截的场景:框架、插件、支付/存储这类适配层。
Protocol(typing,Python 3.8+):靠结构判断,只在静态检查阶段生效,运行时不做任何拦截。对象只要有匹配的方法签名就算符合协议,哪怕它压根不知道协议的存在。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| from typing import Protocol, runtime_checkable
@runtime_checkable class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self): return "画个圆"
|
1 2 3
| isinstance(Circle(), Drawable) issubclass(Circle, Drawable)
|
注意 runtime_checkable 只检查方法名在不在,不校验签名。默认的 Protocol 连 isinstance 都会报错。
纯鸭子类型:什么约束都不加,最灵活也最容易失控。脚本、一次性工具用它没问题。
选型的一个实用判据:需要”运行时把不合格的对象挡在门外”就选 ABC;只需要”让类型检查器帮我抓错、又不想强迫别人继承”就选 Protocol;内部小工具、改动范围可控,就继续用鸭子类型。
3.5 update_abstractmethods 与 get_cache_token
update_abstractmethods(cls)(Python 3.10+)用于在类创建之后重新计算抽象状态。典型场景是动态给类挂方法:
1 2 3 4 5 6 7 8 9 10 11 12 13
| from abc import ABC, abstractmethod, update_abstractmethods
class Base(ABC):
@abstractmethod def go(self): ...
class Dyn(Base): pass
|
1 2 3 4 5 6
| print(Dyn.__abstractmethods__) Dyn.go = lambda self: "dynamic" update_abstractmethods(Dyn) print(Dyn.__abstractmethods__) print(Dyn().go())
|
get_cache_token() 返回一个令牌,每次调用 register() 都会让它变化。写缓存逻辑时可以用它判断”ABC 的注册表有没有变过”:
1 2 3 4 5 6 7
| from abc import get_cache_token
t1 = get_cache_token() Notifier.register(SomeClass) t2 = get_cache_token() assert t1 != t2
|
3.6 常见陷阱清单
- 装饰器顺序写反:
@abstractmethod 必须在最内层,否则定义时就报 AttributeError。
- 以为抽象方法不能有实现:可以有,而且能被
super() 调用,是个好用的复用点。
- 在
__init_subclass__ 里判断 __abstractmethods__:读不到,用自定义元类的 __init__。
- 对抽象类用
super().__init__() 传参不当:抽象基类若有 __init__,子类记得正确调用。
- 忘了
register 只影响类型判断:它不检查方法、不进 MRO、不给方法。
- 抽象面开太大:抽象方法越多,子类负担越重,改契约越像改公共 API。
第四部分 企业级实战
以下五个案例都来自真实系统的常见形态,代码可直接运行。
案例 1 支付网关:策略 + 模板方法 + 异常契约
背景:系统要接入支付宝、微信、银联等多条渠道。每个渠道的初始化参数、请求方式都不同,但对外必须统一提供”预授权、扣款、退款”。同时,重试、异常映射这类编排逻辑不该让每个渠道各写一遍。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| class PaymentError(Exception): """支付领域异常基类"""
class RetryableError(PaymentError): """可重试的基础设施错误(超时、限流等)"""
class PaymentGateway(ABC): """支付渠道统一契约"""
timeout_seconds: float = 5.0
def __init__(self, merchant_id: str): self.merchant_id = merchant_id
@abstractmethod def authorize(self, order_id: str, amount: float) -> str: """预授权,返回授权流水号"""
@abstractmethod def capture(self, auth_id: str, amount: float) -> dict: """扣款"""
@abstractmethod def refund(self, order_id: str, amount: float) -> dict: """退款"""
def charge(self, order_id: str, amount: float, retries: int = 2) -> dict: """模板方法:预授权 -> 扣款,统一重试与异常收敛""" last_err = None for attempt in range(1, retries + 1): try: auth_id = self.authorize(order_id, amount) return self.capture(auth_id, amount) except RetryableError as e: last_err = e print(f" 第 {attempt} 次重试...") raise PaymentError(f"扣款失败: {last_err}")
|
具体渠道只填三个原语:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class AlipayGateway(PaymentGateway):
def authorize(self, order_id, amount): return f"ali-auth-{order_id}"
def capture(self, auth_id, amount): return { "channel": "alipay", "auth": auth_id, "amount": amount, "status": "success", }
def refund(self, order_id, amount): return {"channel": "alipay", "order": order_id, "refund": amount}
|
运行:
1 2
| print(AlipayGateway("M001").charge("O1001", 99.9))
|
1 2
| {'channel': 'alipay', 'auth': 'ali-auth-O1001', 'amount': 99.9, 'status': 'success'}
|
再用一个会抖动的渠道验证重试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class FlakyGateway(PaymentGateway): _counter = 0
def authorize(self, order_id, amount): FlakyGateway._counter += 1 if FlakyGateway._counter < 3: raise RetryableError("网关超时") return "flaky-auth"
def capture(self, auth_id, amount): return {"channel": "flaky", "amount": amount, "status": "success"}
def refund(self, order_id, amount): return {"status": "refunded"}
print(FlakyGateway("M002").charge("O1002", 50.0, retries=3))
|
1 2 3 4
| 第 1 次重试... 第 2 次重试... {'channel': 'flaky', 'amount': 50.0, 'status': 'success'}
|
这个案例体现了几件事。三个 @abstractmethod 把渠道能力钉死成契约,谁少实现一个就实例化不了;charge 是模板方法,重试和异常收敛写一遍就够;异常也做了契约化——RetryableError 和不可重试的 PaymentError 分开,调用方知道哪些能重试。这是生产系统里最容易被忽略的部分:一个方法名清楚但失败语义含糊的接口,不算真正的接口。
案例 2 ETL 数据管道:模板方法 + 组合
背景:数据团队要把不同来源的数据清洗、转换、写入不同目标。流程固定,两端和中间变换可变。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| class DataSource(ABC):
@property @abstractmethod def name(self) -> str: """数据源名称(抽象属性)"""
@abstractmethod def read(self) -> list: """读取原始记录"""
class DataSink(ABC):
@abstractmethod def write(self, records: list) -> int: """写入记录,返回条数"""
class ETLJob(ABC):
def __init__(self, source: DataSource, sink: DataSink): self.source = source self.sink = sink
def run(self): print(f"[ETL] 源={self.source.name}") raw = self.source.read() cleaned = self.clean(raw) transformed = self.transform(cleaned) n = self.sink.write(transformed) print(f"[ETL] 写入 {n} 条") return n
def clean(self, records: list) -> list: """可选钩子:给了默认实现,子类可按需覆盖""" return [r for r in records if r]
@abstractmethod def transform(self, records: list) -> list: """必填:业务转换"""
|
端到端跑一遍:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| class CsvSource(DataSource):
@property def name(self): return "csv://orders.csv"
def read(self): return [" alice ", None, " bob "]
class MemorySink(DataSink):
def __init__(self): self.data = []
def write(self, records): self.data.extend(records) return len(records)
class UpperJob(ETLJob):
def transform(self, records): return [r.strip().upper() for r in records]
sink = MemorySink() UpperJob(CsvSource(), sink).run() print("sink 内容:", sink.data)
|
1 2 3 4
| [ETL] 源=csv://orders.csv [ETL] 写入 2 条 sink 内容: ['ALICE', 'BOB']
|
这里用了三种不同性质的成员:transform 是必须实现的抽象方法;name 是必须实现的抽象属性;clean 是带默认实现的可选钩子。这种”必需的少、可选的给默认值”的分配方式,是成熟抽象基类的典型特征——契约要小而稳,扩展点要留够。
案例 3 插件系统:自定义元类自动注册
背景:主程序要支持第三方插件。理想效果是——开发者写一个插件类,框架自动发现并注册,不用手工维护一张映射表。
直觉写法是在 __init_subclass__ 里注册:
1 2 3 4 5 6 7 8
| class Plugin(ABC): registry = {}
def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if not getattr(cls, "__abstractmethods__", None): Plugin.registry[cls.plugin_name] = cls
|
但这个写法有问题。前面 3.2 讲过,__init_subclass__ 触发时 __abstractmethods__ 还没写入,getattr 拿到的是 None,于是连还没填满的抽象中间层也会被注册。实测会看到:
1 2 3 4
| 已注册插件: json 已注册插件: reverse 已注册插件: abstract_middle # 错误的注册
|
正确做法是自定义一个继承 ABCMeta 的元类,把注册逻辑放到 __init__——它跑在 _abc_init 之后,此时集合已经算好。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class PluginMeta(ABCMeta):
def __init__(cls, name, bases, namespace, **kwargs): super().__init__(name, bases, namespace, **kwargs) plugin_name = namespace.get("plugin_name") if plugin_name and not cls.__abstractmethods__: Plugin.registry[plugin_name] = cls print(f" 已注册插件: {plugin_name}")
class Plugin(ABC, metaclass=PluginMeta): registry: dict = {} plugin_name: str = ""
@abstractmethod def execute(self, payload: dict) -> dict: """执行插件逻辑"""
|
开发者按模板写插件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class JsonPlugin(Plugin): plugin_name = "json"
def execute(self, payload): import json
return {"ok": True, "data": json.dumps(payload)}
class ReversePlugin(Plugin): plugin_name = "reverse"
def execute(self, payload): return {"ok": True, "data": payload.get("text", "")[::-1]}
class AbstractMiddleLayer(Plugin): plugin_name = "abstract_middle"
def extra(self): return 1
|
实测结果:
1 2 3 4 5
| 已注册插件: json 已注册插件: reverse 注册表: ['json', 'reverse'] {'ok': True, 'data': 'olleh'}
|
AbstractMiddleLayer 没被误注册,注册表干净。这个案例把”抽象方法的强制力”和”元类介入类创建过程”结合了起来,是 ABC 用得比较深的一种形态。
案例 4 仓储模式:泛型抽象基类
背景:数据访问层要在内存实现和数据库实现之间切换,业务代码只依赖接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| from dataclasses import dataclass from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass class User: id: int name: str
class Repository(ABC, Generic[T]):
@abstractmethod def add(self, entity: T) -> T: ...
@abstractmethod def get(self, entity_id: int) -> "T | None": ...
@abstractmethod def delete(self, entity_id: int) -> None: ...
def exists(self, entity_id: int) -> bool: """具体方法:所有仓储共享,不用各写一遍""" return self.get(entity_id) is not None
|
内存实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class InMemoryUserRepository(Repository[User]):
def __init__(self): self._store: dict = {}
def add(self, entity: User) -> User: self._store[entity.id] = entity return entity
def get(self, entity_id: int): return self._store.get(entity_id)
def delete(self, entity_id: int) -> None: self._store.pop(entity_id, None)
|
1 2 3 4 5 6 7
| repo = InMemoryUserRepository() repo.add(User(1, "张三")) print(repo.get(1)) print(repo.exists(1)) repo.delete(1) print(repo.exists(1))
|
ABC 和 Generic 可以一起继承。抽象方法定义读写删三个原语,exists 作为基于 get 的具体方法放在基类,所有实现共享。这种”薄接口 + 基类复用”的搭配,是数据访问层的标准形态。
案例 5 通知系统:抽象工厂,呼应开篇
回到本文开头那段通知代码,用抽象基类把它收拾干净后是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| class Notifier(ABC):
@abstractmethod def send(self, message: str) -> bool: ...
class EmailNotifier(Notifier):
def send(self, message): print(f"[Email] {message}") return True
class SMSNotifier(Notifier):
def send(self, message): print(f"[SMS] {message}") return True
class NotificationFactory(ABC):
@abstractmethod def create_notifier(self) -> Notifier: ...
def deliver(self, message: str) -> bool: notifier = self.create_notifier() print("-> 前置校验通过") ok = notifier.send(message) print("-> 后置记录日志") return ok
class EmailFactory(NotificationFactory):
def create_notifier(self): return EmailNotifier()
|
1 2
| EmailFactory().deliver("您的验证码是 1234")
|
1 2 3 4
| -> 前置校验通过 [Email] 您的验证码是 1234 -> 后置记录日志
|
这里叠了两层模式:Notifier 是抽象产品,NotificationFactory 是工厂方法(create_notifier 抽象),deliver 又是模板方法(流程固定、产品可变)。两层抽象各管一段职责,新增一个渠道只要加两个小类,不动任何既有代码。
第五部分 工程落地建议
契约要小。 抽象方法只放真正必须由每个实现提供的能力。可选项给默认实现。抽象面越大,”改契约”就越像”改公共 API”。
变更要当迁移处理。 往抽象基类里加一个抽象方法,等于让所有现有子类立刻报错。稳妥的顺序是:先加一个带默认实现的具体方法,或加一个能力探测方法(如 supports_batch_write()),再逐步收紧。
异常语义要写进契约。 明确哪些失败是返回值、哪些是异常、哪些可重试。成熟系统会定义领域异常层次,要求各实现把底层错误统一映射过来。
什么时候不要用 ABC。 只有一个内部小类、没有扩展计划;短脚本、测试已经覆盖了行为;需要靠结构化匹配适应已有的、不能改的类——这些情况下,鸭子类型或 Protocol 更合适。抽象基类不是越用越好,它是用来减少意外的。
测试上要覆盖”漏实现”。 写一个测试,断言某个故意漏实现的子类在实例化时抛 TypeError,等于给契约上了一把锁。
附录 速查表
| 需求 |
写法 |
| 定义抽象基类 |
class X(ABC): |
| 抽象方法 |
@abstractmethod def m(self): ... |
| 抽象属性 |
@property 在上,@abstractmethod 在下 |
| 抽象类方法 |
@classmethod 在上,@abstractmethod 在下 |
| 抽象静态方法 |
@staticmethod 在上,@abstractmethod 在下 |
| 抽象方法带默认实现 |
直接在方法体里写逻辑,子类用 super() 调用 |
| 查看未实现的抽象成员 |
Cls.__abstractmethods__(frozenset) |
| 判断是否抽象类 |
inspect.isabstract(Cls) |
| 注册虚拟子类 |
Base.register(Other) |
| 结构化判定 |
重写 __subclasshook__(定义为 classmethod) |
| 类创建后重算抽象状态 |
update_abstractmethods(Cls)(3.10+) |
| 注册表缓存令牌 |
get_cache_token() |
装饰器顺序口诀:角色装饰器在上,@abstractmethod 永远贴着 def。
结语
抽象基类解决的不是”怎么写代码”,而是”怎么让一堆代码不互相拆台”。它的三个收益很实在:把口头约定变成实例化时的硬约束,把接口文档固化成可执行的类,把公共流程收敛到一处。代价是类变多、层次变深,所以要按 3.4 的判据权衡——需要运行时把关就上 ABC,只需要静态检查就用 Protocol,范围可控就留给鸭子类型。