[控场AI]
· 7 分钟阅读· 3,707 字

类型对象模式:用对象替代臃肿的类继承体系

类型对象模式:用对象替代臃肿的类继承体系

用Python建模订阅套餐变体,解析继承、枚举与类型对象模式的选择逻辑。

本文以SaaS订阅系统为例,拆解在Python中处理「同一领域概念存在多种变体」时的三种设计方案:子类继承、纯数据枚举,以及类型对象模式。子类继承直觉但易导致类层级膨胀,因为这些子类只存储数据而非实现不同算法;纯数据方案简洁却导致知识分散,容易遗漏配置项;类型对象模式用独立对象表示套餐变体,既保持信息聚合,又贴近「订阅拥有一个套餐」的真实领域语义。文章进一步指出,当套餐涉及根本不同的行为(如固定计费与用量计费)时,类型对象可与Python协议组合使用,将配置变体与行为变体分开管理。选择设计的核心原则是:判断变化的本质——标签用枚举,带数据和规则用对象,不同行为用多态。

在构建软件时,我们经常会遇到同一种领域概念存在多种变体的情况。以 SaaS 应用的订阅系统为例:免费版、专业版、企业版,每种套餐提供不同的功能、价格、项目上限和存储空间。面对这种变化,很多开发者的第一反应就是继承——为每种套餐创建一个子类。但这真的是最好的做法吗?

本文基于 Iron Codes(ArjanCodes)频道的一期深度设计分析,拆解在 Python 中建模这类变体的三种方式,并讨论每种方式各自适用的场景。核心结论并不是「某种模式永远更好」,而是要根据「究竟是什么在变化」来做决策。

继承:直觉但容易失控的方案

最初的实现方式是用子类。有一个 Subscription 基类,包含客户 ID、起始时间、月费、最大项目数、存储空间和功能列表等属性,以及若干方法。然后为每种套餐类型创建子类:FreeSubscription、ProSubscription、BusinessSubscription,每个子类只是在初始化方法里设置不同的数值。

这种做法在套餐数量稳定时(比如只有三种)其实没什么大问题。多态工作得很好——拿到一个订阅对象,无论它是免费版还是专业版,你都可以直接调用「是否支持某功能」「能否创建项目」这类方法,不用关心具体类型。

但问题在于,这些子类并没有实现任何「根本不同的算法」,它们只是存放不同的数据。一旦套餐类型开始膨胀——创业版、教育版、Business Plus、遗留版 Pro、企业版、旗舰版……每新增一种产品配置,就要新增一个 Python 类,而且遗留套餐还得保留。

用类继承体系来存储业务配置

这就引出了一个关键的质疑:如果它只是数据,为什么还需要继承?用类继承体系来存储业务配置,本质上是把语言的类型系统当成了配置管理工具。

纯数据方案:简单但知识分散

第二种思路是彻底移除子类,直接用数据定义所有套餐。保留 Feature 枚举,再加一个 PlanType 枚举,然后用字典分别定义每个套餐的各项属性:按套餐划分的功能列表、按套餐划分的月费、按套餐划分的最大项目数和存储空间。

需要新增套餐类型时,只要往数据里添加一条即可。这种方式看起来更简单,而且在配置项数量有限的情况下,这确实已经足够好。

但它的缺点随着时间推移会逐渐显现。订阅通常会随业务发展而产生越来越多的变化维度,功能、月费、项目数、存储只是现在有的,未来可能还会加更多。而每个套餐的信息被分散在多个独立的字典里,知识是割裂的。更糟的是,你可能忘记为某个套餐定义月费,导致应用在特定情况下崩溃。

如果把所有信息合并进一个大的配置字典里会怎样?恰好,你差不多就「意外地」发明了类型对象模式(Type Object Pattern)。

类型对象模式:让变体成为对象

第三种方案又回到了类,但方式不同。定义一个 SubscriptionPlan 类——因为我们在构建 SaaS 平台,「订阅套餐」本身就是领域中的一个概念,为它建模是合理的。

关键区别在于:客户的订阅不再存储一个 PlanType 枚举值,而是持有一个指向真实 SubscriptionPlan 对象的引用。然后我们创建若干 SubscriptionPlan 实例——free、pro、business,每个实例包含对应套餐的全部设置。

仍然把它当作数据,只是用了类来表示

我们依然把这些当作数据来处理,只是借助类(对象)来更有效地在 Python 中表达它们。这正是类型对象模式的核心:不用编程语言的类型系统来表示变化,而是用对象来表示这些变化。

从领域建模的角度看,这三种设计表达了三种不同的语义关系:

  • 继承:「专业版订阅是一种订阅」(is-a 关系)
  • 枚举:「订阅有一个套餐类型枚举值」
  • 类型对象:「订阅有一个订阅套餐」(has-a 关系)

类型对象的版本其实最贴近真实领域。客户并不拥有一个叫「专业版订阅」的特殊对象,他们拥有的是「对专业版套餐的一个订阅」。

类型对象模式(Type Object Pattern)最早由 Ralph Johnson 和 Bobby Woolf 在《Pattern Languages of Program Design 3》中系统阐述。它的核心思想是:当你需要一组实例共享相同行为,但彼此之间只有属性值不同时,与其为每种变体创建一个子类,不如创建一个「类型类」(type class)来承载这些配置,再让主类持有一个指向类型实例的引用。这样,新增一种变体只需实例化一个新对象,而不需要修改类层次结构。这个模式在游戏开发中非常常见——例如用一个 MonsterType 对象描述「哥布林」的属性,所有哥布林实例都引用同一个类型对象,而非各自继承自 Goblin 子类。它与策略模式的区别在于:策略模式强调封装可互换的「算法」,类型对象强调封装可互换的「配置数据」;两者可以叠加使用,正如本文最后的组合示例所示。

该如何选择?取决于什么在变化

作者反复强调:类型对象不是「自动更好」的方案。选择的依据应该是——究竟是什么在变化?

  • 如果变化只是一个标签或类别,比如订阅状态(活跃、逾期、已取消),那么用枚举或简单值就足够了,没必要做成完整的对象。
  • 如果变化带有数据和规则,比如订阅套餐有价格、项目上限、存储限制和功能集合,那就应该考虑把变体做成对象。对象最擅长的正是把这类相关信息组合在一起。
  • 如果变化代表根本不同的行为实现,那么通过类或协议实现的多态才是正确的工具。

组合的力量:类型对象 + 协议

子类方案在某些场景下恰恰是你需要的。假设订阅系统支持根本不同的计费机制:固定价格订阅每月收取相同金额,而基于用量的计费则需要收集使用数据再计算最终价格。这两者不只是配置不同,而是真正不同的行为。

引入定价模型协议

作者给出了一个融合版本:定义一个 PricingModel 协议,FixedPricing 和 UsageBasedPricing 分别实现它。然后 SubscriptionPlan 持有一个 PricingModel 的引用——专业版用固定计费,企业版则可以用基于用量的计费。

这里同时用到了类型对象模式(用对象表示套餐变体)和协议(表示不同的计费行为)。这是一个非常有力的组合用法:我们没有消除多态,而是把每种变化放到了它真正该在的位置。

Python 的协议(Protocol)来自 PEP 544,于 3.8 版本引入,是实现结构子类型(structural subtyping,也称「鸭子类型的静态化」)的机制。与抽象基类(ABC)要求显式继承不同,Protocol 只要求对象在结构上满足接口定义——即拥有特定的方法和属性签名——而无需任何继承声明。在本文的定价模型场景中,FixedPricing 和 UsageBasedPricing 无需继承任何公共父类,只需各自实现约定的 calculate 方法,类型检查器(如 mypy 或 Pyright)就能静态验证其正确性。这种方式使得各计费实现之间完全解耦,也更易于独立测试。协议与类型对象的结合,实际上体现了「组合优于继承」这一设计原则的具体落地:用 has-a 关系管理配置变体,再用结构多态管理行为变体,两种维度的变化各归其位。

工程实践:数据存在哪里?

一个绕不开的实际问题是:这些套餐定义和设置到底存在哪里?

如果套餐是固定的、只在部署时才变化(对许多 SaaS 平台来说确实如此),最简单的做法是在一个独立的 plans.py 文件里定义不可变对象,需要时导入使用即可。

对象与类的实际存储问题

但如果是更大型的应用,套餐配置需要在不部署新代码的前提下变更,那么这些对象可以从数据库加载——订阅记录存客户 ID 和套餐 ID,套餐表存套餐定义,关联表存功能列表。新增套餐只需在数据库里加一条记录即可。

不过数据库方案也有代价:如果为每个客户单独存储可用功能,产品演进时容易产生数据不一致(某些客户可能因为忘记更新而丢失功能访问权);需要测试的组合更多;频繁从数据库读取配置也可能拖慢应用。灵活性是以复杂度为代价换来的。

小结

当你在领域中遇到某种变化时,不要条件反射般地伸手去用继承或类型对象。先想清楚:变化的是一个值?是领域数据和规则?还是真正不同的行为?然后再决定实现方式。这种「理解何时该用哪种设计」的能力,远比记住一堆模式名称更重要。

#类型对象模式#Type Object Pattern#设计模式#Python软件设计#类继承#组合优于继承#SaaS订阅系统#多态
分享:

相关推荐