面向对象的设计原则
学会封装、继承和组合后,我们已经能够用类描述对象之间的关系。但随着需求变化,类的职责可能越来越杂,修改一个地方也可能牵动其他代码。怎样划分类的职责、安排类之间的依赖,才能让代码更容易维护和扩展?这就涉及面向对象的设计原则。
面向对象设计(OOD)中最核心、最经典的是 SOLID 五大原则,此外在软件工程实践中,通常还会结合另外两条至关重要的通用原则,合称七大设计原则。

单一职责原则(SRP)
单一职责的定义与好处
单一职责原则(SRP):一个类只能承担一种功能。换言之,一个类应该只有一个引起它变化的原因
这是六大原则中最容易理解、也最容易被忽视的一条
好处如下:
- 降低耦合性
机制:当一个类只做一件事时,它的依赖关系会变得非常简单,不需要知道其他模块的内部细节
收益:程序出现问题时可以快速定位和调试,问题必然出现在与其职责直接相关的那一小段代码中 - 增强可读性与可维护性
机制:功能越少,代码越清晰。一个只负责一个功能、仅200行代码的类,任何人打开它都能在几分钟内理解其意图
收益:后续更新迭代功能时,需要关注的范围就越窄,越不容易”瞻前顾后”产生遗漏 - 提高复用性
机制:单一职责原则是可复用的基础。将功能拆得越细,越容易无副作用地将相似需求用同一套代码解决
反面案例:整个应用或游戏只有一个”类”(即上帝类),所有功能逻辑堆在一起,代码毫无复用性可言,如想复用”渲染”功能?它和”更新””输入处理””碰撞检测”全部纠缠在一起,根本拆不出来
组合模式与ECS架构
“游戏对象的功能通常是灵活多变的。如果只使用传统的面向对象思想,遵循单一职责原则来实现全部的游戏对象,那么会有非常繁重和灵活性极差的代码。”
那么可以有以下两种解决方法
解决方案一:组合模式
核心思想:不再通过继承来获得功能,而是将功能封装成独立的组件对象,游戏对象通过”拥有”这些组件来获得能力,如GameObject 只是一个容器,持有一个组件列表,需要什么功能,就挂载什么组件。
解决方案二:ECS架构
核心思想:将组合模式推向极致,进一步分离数据(Component)与行为(System)

与 SRP 的关系:每个 System 只负责一种逻辑(渲染、移动、碰撞……),每个 Component 只承载一组数据,双双符合 SRP
优势:极高性能(数据连续存储,缓存友好)与极高灵活性(运行时动态增删组件)
注意组合模式 ≠ ECS。组合模式中,组件通常还带有方法(如 UpdateComponent.update());ECS 中,组件是纯数据,逻辑全部在 System 中,ECS 是组合模式的进一步演化。
开闭原则(OCP)
开闭原则的概念和解释
开闭原则 (Open-Closed Principle, OCP):软件实体(类、模块、函数等)应该对扩展开放,对修改封闭
对扩展开放的意思就是当需求变化时,应当能够通过添加新的代码来扩展系统功能
对修改关闭的意思就是在进行扩展时,不应修改已有的、经过测试的源代码
也即是需求变更时,尽量通过扩展编写新代码,而非修改已有代码
因为修改已有代码,可能引起牵一发动全身的雪崩效应,故而有句话是这样说的:”如果一个代码能够正常运行,那就不要去动它。”
同时团队协作中,有些模块甚至是流传了好几代的”上古代码”,设计细节和架构思想已无从考究,触碰这些未知代码极具风险
开闭原则的核心诉求:在设计系统时就预留好后续功能扩展的接口,而非在功能迭代时侵入式地修改已有的封装
针对游戏开发这种需求多变的领域,提前设计好扩展性强的系统,其实是在提升工作效率,是后续迭代中节省时间成本的关键
开闭原则的简单实践
开闭原则最简单的落地方式就是:子类重写父类的方法
实现方式如下:
- 父类定义好接口(方法签名)
- 子类通过重写(Override)提供不同的实现
- 需求变更时,只需新增子类,不修改父类代码
- 通过多态机制,让父类引用指向子类对象,运行时自动调用子类实现
调用方代码只依赖父类接口(如 base->method()),无论加多少子类,调用方代码都不用改,新增功能时,只需编写新的子类并重写方法
同时模板方法模式 (Template Method Pattern)也是对开闭原则的典型使用,在后续具体的设计模式文章中会详细讨论
里氏替换原则(LSP)
里氏替换原则概念和案例
里氏替换原则定义:所有父类出现的地方,都可以使用子类对象来替换,而不会改变程序的正确性
层面一 · 继承的逻辑正确性:子类继承父类,意味着子类是父类的具象描述,子类”是一个”父类(is-a 关系),并遵循与父类相同的功能约定
案例拆解 · 宠物喂食系统
场景:游戏中宠物系统,角色调用 feed 方法给宠物喂食,传入 Pet 基类指针增加饱食度
设计:无论传入猫、狗还是兔子(都是 Pet 子类),feed 方法都应正常增加饱食度
违反 LSP:新角色子类的 feed 方法仅在传入兔子时生效,传入猫、狗时无效,无法完全替代父类工作
启发:子类不应强加自己的约束。父类约定”喂任何宠物”,子类就不能把范围缩小到”只能喂兔子”
层面二 · 多态机制的正常运作,多态允许通过父类指针或引用调用子类重写的方法,前提是:子类重写的方法必须保持父类接口的功能语义
LSP的实践
- 参数:匹配或更抽象,子类方法参数必须与父类匹配或更抽象(接受父类型)
- 返回值:匹配或更具体,子类方法返回值必须与父类匹配或为子类型(更具体)
- 异常:不抛出预期之外的类型,客户端代码只捕获父类声明的异常类型,子类抛出新异常种类会穿透客户端防御代码,导致崩溃
- 不加强前置条件,子类不能比父类”更挑剔”。父类接受任何 int,子类不能要求必须为正数
- 不削弱后置条件,子类不能比父类”更敷衍”。父类保证方法返回前关闭文件句柄,子类必须同样保证
- 保留父类不变量,不变量是形式上的规则(非语法常量),在整个类生命周期中始终保持为真,例如所有猫都有四条腿、一条尾巴,子类(波斯猫、狸花猫)必须遵守
- 不修改父类私有成员,私有成员是父类封装的核心实现细节,父类方法基于其特定状态工作
接口隔离原则(ISP)
接口隔离原则概念和解释
接口隔离原则(Interface Segregation Principle, ISP):不应该强迫客户端(即接口的实现者或调用者)依赖于它们不使用的接口方法。与其设计一个大而全的接口,不如将其拆分为更小的、关注点更精细的细分接口。
“大接口”的问题场景
假设我们设计了一个游戏对象接口 GameObject,它包含以下方法:

认为“接口方法越多,功能越强大”是错误的,实际上,大接口迫使无关类实现冗余方法,这正是被迫耦合的来源,接口设计的核心原则是“按需提供”,而非“越多越好”
ISP 在游戏对象设计中的实践
解决方案是将“大而全”的接口拆分为职责单一的小接口,让每个实现者只依赖自己真正需要的能力

实现策略如下:

ISP和SRP的关联
ISP 与 SRP 看似针对不同层面(一个管类,一个管接口),但本质上共享同一套设计哲学

依赖倒置原则(DIP)
依赖倒置原则的概念和解释
依赖倒置原则:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象

注意”高层次”与”低层次”不是父类与子类的关系!例:Game 与 TCP 无继承关系,但 Game 是高层、TCP 是低层
传统依赖方向,高层直接依赖低层,这是不推荐的,因为 底层一改,高层就要跟着改
依赖倒置原则案例
以 Network 接口作为案例,Game 类中直接实例化并使用 TCP 类进行通信
那么此时切换为 UDP → 必须修改 Game 代码,同时如果新增 QUIC → 再次修改 Game 代码
方案:引入 Network 抽象接口


但不得不说,”直接调用底层”成本更低,代码更短,上手更快,故而正确使用DIP这对开发者是个挑战
还有值得提及的一点是,依赖倒置原则(DIP)与开闭原则(OCP)是一对好搭档:DIP 是实现 OCP 的关键手段之一
高层依赖抽象,扩展新实现时只需新增低层类,高层代码无需改动,自然满足”对扩展开放,对修改封闭”
迪米特法则(LoD)
迪米特法则概念和解释
迪米特法则(Law of Demeter, LoD),又称最少知识原则(Least Knowledge Principle, LKP):一个对象应当只与其最直接关联的对象进行通信,而不与陌生对象产生耦合
是否关联如下:

简单的判断标准是这个对象是 O 直接持有的,还是通过其他对象“借”来的? 若属于后者,则不是直接朋友
主要是为了避免链式调用的问题,这样可以减少耦合与调试成本
合成/聚合复用原则(CARP)
合成/聚合复用原则概念和解释
合成/聚合复用原则(Composite/Aggregate Reuse Principle, CARP):优先使用对象组合(Composition)或聚合(Aggregation),而不是类继承(Inheritance)来达到复用的目的
聚合关系(Aggregation)语义:表示 has-a(“有一个”)的关系。一个类(整体)包含另一个类(部分)的对象,但部分可以独立于整体而存在,整体与部分之间没有必然的生命周期绑定
组合关系(Composition)语义:表示 contains-a(“包含一个”)的关系,部分与整体是“同生共死”的强依赖关系
继承关系(Inheritance)语义:表示 is-a(“是一个”)的关系。子类是一种特殊的父类,通过继承获得父类的属性和方法
那么我们先看继承复用遇到的麻烦,再展示组合怎么解决
场景:假设游戏中有些对象需要移动,有些需要攻击,还有些同时需要这两种能力。如果单纯通过继承来复用功能,随着能力组合增多,就可能出现许多不同的子类;修改共同功能时,也需要留意各个子类是否受到影响
这时可以把移动和攻击分别封装为独立组件,让游戏对象按需持有并调用它们。需要移动的对象使用移动组件,需要攻击的对象使用攻击组件,两种能力都需要时就同时持有。这样,移动和攻击逻辑可以被不同对象复用,也不必为每种能力组合建立新的继承层次

这与前面“组合模式与 ECS 架构”中的组件思路相呼应,故而需要我们优先考虑组合或聚合,谨慎使用继承





