微信限制:不能放置链接、代码;发布后不能更新。。。
Code is by people and for people. —— Kent Beck
1 写在前面
1.1 很多人不关心代码可读性
对于很多人,包括我,最开始写代码的时候,对代码的认识就是:写一些特殊的符号,让机器去执行,从而达到效果。这时候,我们关心的是:机器如何执行我们写的代码。
例如,我曾经会设计这样的函数 —— 在一个循环内完成两件事:一边实现某个操作,一边统计结果并返回。
代码1- 一个循环内完成两件事
随着代码写的越来越多,我们发现:一般读代码的时间比写代码的时间要长。所以,我们在写代码的时候开始关心:如何通过代码来传达我们的想法。
对于上边的例子,把查询和修改分开 (Separate Query from Modifier),变为 和 ,更有利于传达我们的想法 —— 因为一般有返回值的方法只作为查询,没有副作用;没有返回值的方法作为修改,才会产生副作用。(见笔记)
1.2 很多人用着面向对象语言,却只写着命令式程序
此外,很多人,包括我,最开始学习的编程语言是命令式(面向过程)语言,在使用面向对象语言编写程序的时候,仍然只用着最简单的命令式控制流 —— 把所有的逻辑写成一个超长的函数,而且只用了条件、循环实现相应功能。
而面向对象语言引入了类和对象,把对外表现的行为(behavior)和用于支持行为的状态(state)封装在一起,实现功能的模块化。(见笔记)
同时,面向对象语言为我们提供了丰富的控制流描述方法,更符合人们直观的想法 —— 循序(sequence),消息(messages),迭代(iteration),条件(conditionals),异常(exceptions)。(见笔记)
1.3 为什么要写这篇文章
2018 年,读了 Martin Fowler 的《重构》、Kent Beck 的《实现模式》,深有感触
分享给大家,并收集反馈
1.4 从一个例子开始
正如 Martin Fowler 所说,抽象的原理不如具体的例子更能说明问题。(见笔记)
假设我们在给一个桌面宠物游戏加入一些功能:
支持两个玩家参与游戏
每个玩家可以养多只宠物(猫 / 狗)
每个玩家可以给自己或对方的宠物喂食、洗澡
需要分别统计猫和狗的数量
根据直观的命令式编程的想法(虽然使用了 C++ 的 STL),我们可以:
定义一组变量,用于存放每个玩家的宠物、猫/狗粮和洗发水
定义一组函数,用于统计猫/狗数量、给宠物喂食、给宠物洗澡
代码2- 命令式编程风格
本文将围绕这个例子,从封装、继承、多态,介绍如何使用面向对象的方法实现上述功能。
2 封装 Encapsulation
面向对象语言引入了类和对象,可以把一组密切相关的逻辑(logic)和对应的数据(data)放在一起,从而实现模块化。(见笔记)
放在同一个类里的逻辑和数据,往往解决了同一领域的问题,变化频率一致—— 理想情况下,当这个领域问题变化时,只需要修改对应的这个类,就可以实现所有的更改。(见笔记)
封装是面向对象编程的核心,有两个重要的优势(见笔记):
对象只需要更少的“了解”系统的其他部分,减少对象以外的依赖
当领域问题变化时,只有少数几个对象需要修改,就可以实现这个变化
2.1 例子:状态/数据的封装
代码2中,为了表示 3 类不同的状态,使用 和 构成了 2 组平行的、独立的数据。很容易发现,每一组数据都可以捆绑在一起,组成 ;利用捆绑后的结构,可以很方便的定义多组平行的、结构相同的数据 , 。从而实现对状态/数据的封装。
代码3- 封装状态/数据
2.2 例子:行为/逻辑的封装
进一步的,代码2中的 4 个函数是对 数据进行操作的逻辑,解决了同一领域的问题、变化频率一致。所以,在代码3的基础上,把这些逻辑和数据封装在一起,构成了完整的类 。从而实现对行为/逻辑的封装。
代码4- 封装行为/逻辑
3 继承 Inheritance
在面向对象设计中,继承常用于对领域模型进行分类(例如 / 都属于 )。而在面向对象编程中,继承提供了实现共享逻辑的最简单方法(子类可以使用父类的 字段/方法)。
但是,Alan Snyder 在Encapsulation and inheritance in object-oriented languages一文中,提出了继承破坏封装(inheritance breaks encapsulation)的观点 —— 子类使用了父类的 字段/方法,父类的被依赖部分一旦发生改变,子类也被迫需要修改。
所以,人们常说应该面向接口编程,而不是面向实现(code to interfaces, not implementations)(见笔记)—— 子类尽可能避免过度依赖于父类的实现,尤其是两者变化不可控的时候。(在 Java/C# 语言中,专门引入了 的概念)
3.1 例子:继承实现
为了避免重复,我们可以把两个类的共享逻辑抽出到一个父类里,然后这两个类继承于该父类。例如,猫和狗吃东西的逻辑有共同之处 —— 吃了食物后,需要一段时间消化食物,然后饥饿值下降。消化食物的逻辑 、饥饿值的状态 是猫和狗共有的,可以提到父类 里,从而避免分别在 和 里重复代码。
代码5- 继承实现
但是,代码5中的 一旦有修改, 和 可能会被动的受到这个修改的困扰(例如 要求传入参数, 和 需要修改为传入参数的形式)。
4 多态 Polymorphism
多态,很难顾名思义,是指通过重写函数(overriding)/实现接口(implementing)的方式,让同一个消息(messages)实现不同的逻辑。
使用消息控制流,是面向对象编程的一个重要特性:
两个对象之间用消息调用,能更丰富的表达我们的想法
基于多态,消息对未来不可预见的扩展是开放的 —— 改变消息的接收者,不需要改变消息的发送者 ——发送者只关心消息的意图,而接收者才需要关心消息的实现(见笔记)
利用多态,我们可以更灵活的设计逻辑策略的切换(见笔记):
命令式编程中,我们只能通过条件(conditionals)实现逻辑的切换
基于多态,我们可以通过派生(subclasses)和委托(delegation)实现
派生一般通过继承实现;而委托一般通过组合实现
派生切换的逻辑,在对象生命周期内不能再次改变;而委托可以多次修改
4.1 例子:条件逻辑切换
很多人,包括我,喜欢使用命令式语言里的测试条件(testing conditionals)实现逻辑策略切换。例如,在实现喂食功能 ,而对于不同的动物有着不同的喂食逻辑时,我们会使用 语句先判断 的类型,然后针对不同类型进行处理。
代码6- 条件逻辑切换
这段代码并不是一个良好的设计:
在函数中, 作为消息的发送者, 是消息的接收者 —— 操作 的对象实现喂食逻辑
破坏了封装性
消息的发送者不仅需要关心消息的意图,还需要关心消息处理逻辑的实现
一个类过度访问另一个类 的数据/实现,在 里实现了应该在 里实现的功能,类的职责划分不恰当,是重构的一个信号(见笔记)
不易于扩展
当我们需要引入一个新的宠物类型(例如,兔子)的时候,就需要修改消息的发送者的 实现,即加入 分支
4.2 例子:派生逻辑切换
利用面向对象的方法,我们可以实现基于多态的派生逻辑切换。例如,同样是实现喂食功能 ,我们只需要给 定义一个统一的接口 ,接收 发送的消息;对于不同动物的不同逻辑,我们可以通过重写函数(overriding)/实现接口(implementing)实现。
代码7- 派生逻辑切换
相对于代码6,这个设计有着极大的优势:
在函数中, 作为消息的发送者, 是消息的接收者 —— 操作 的对象实现喂食逻辑
有良好的封装性
消息的发送者只需要关心消息(简单的 接口)的意图,不需要关心处理逻辑的实现
消息的接收者只负责处理消息,和发送者没有过多的耦合,尽职尽责
易于扩展
当我们需要引入一个新的宠物类型(例如,兔子)的时候,只需要在 类里实现 接口即可;消息发送者代码不需要修改
4.3 例子:委托逻辑切换
类似于代码7,把 委托到 函数:
对于 的消息调用是透明的,和派生相比,仅仅是实现上的不同
相对于派生实现的优势在于,可以在 对象生命周期内切换逻辑—— 将变量 赋值为 对象时,可以实现猫的逻辑;赋值为 对象时,就可以动态切换为狗的逻辑(虽然在这个业务场景下没有实际意义,猫生出来不会变成狗。。。)
代码8- 委托逻辑切换
写在最后
领取专属 10元无门槛券
私享最新 技术干货