回看最初的目标:【Java设计模式 学习目标及大纲】高质量代码的标准及实现路径在这篇Blog里我们明确了什么是高质量的代码:易维护、易读、易扩展、灵活、简洁、可复用、可测试,也知道高质量代码的达成路径工具箱:面向对象设计思想是基本指导思想,是很多设计原则、设计模式的实现基础;设计原则是代码设计的抽象经验总结、是设计模式设计的指导原则;设计模式是代码设计的一套具体解决方案或设计思路,主要用来提高代码可扩展性;编程规范是一套可执行的代码编写规范,主要用来提高代码的可读性;代码重构依赖面向对象设计思想、设计原则、设计模式、编程规范实现,主要用来提高代码的可维护性和可读性。
- 面向对象设计思想因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式等编码实现的基础。理论支撑,实现基础,核心思想:编程规范及代码组织
- 设计原则是指导我们代码设计的一些经验总结,对于某些场景下,是否应该应用某种设计模式,具有指导意义。比如,“开闭原则”是很多设计模式(策略、模板等)的指导原则。代码组织:高质量编程的道
- 设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。应用设计模式的主要目的是提高代码的可扩展性。从抽象程度上来讲,设计原则比设计模式更抽象。设计模式更加具体、更加可执行。代码组织:高质量编程的术
- 编程规范主要解决的是代码的可读性问题。编码规范相对于设计原则、设计模式,更加具体、更加偏重代码细节、更加能落地。持续的小重构依赖的理论基础主要就是编程规范。编程规范
- 代码重构作为保持代码质量不下降的有效手段,利用的就是面向对象、设计原则、设计模式、编码规范这些理论。高质量编程实践
实际上,面向对象、设计原则、设计模式、编程规范、代码重构,这五者都是保持或者提高代码质量的方法论,本质上都是服务于编写高质量代码这一件事的。也可以这么理解:1个设计思想、6个设计原则、23个设计模式、一套编程规范,在合适的时机进行代码重构,时刻保证和提高代码的质量
之所以每一个大的模块开始和结束都要提这个目标就是因为,本专栏全部内容都是通过不同的路径最终达成这个目标,全部思想及技能都是围绕个目标展开的。
书归正传,本部分我们回到思想的本质,回到面向对象编程思想以及面向对象语言上来,它是一切的基石。之前我常常思索C#和Java的相似度为什么这么高,C++虽说复杂点,但是也能很快上手,归根溯源,他们基本都采用了面向对象的编程思想,所以我理解起来才不是那么复杂,因为他们几乎都有相同的组成元素和编程范式,
面向对象思想
现在回到根本上去,什么是面向对象?
- 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
- 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
有了如上的两个定义就不难回答上边的问题了,他们的抽象基地都是类似的,只是实现形式不同,生态、IDE、机制等等不同,而最底层的实现思想和范式都相通,所以上手才能那么快。
回到标题,封装、继承、抽象、多态看起来老生常谈了,大多数时候我们只是知道直接用就完事儿了,不知道为啥有这些。坦白的说我对这四个特性也是逐层加深的,例如之前写过一篇总结:【Java SE基础 四】封装、继承、多态,其实感觉也是知其然不知其所以然,就拿封装来说,老说封装封装,结果最后发现字段都public了,就算private了,也lombook一下搞个@Data
,属实是骗自己。回归本源,看看这四个特性到底设计出来是为了什么,这可是面向对象最高度抽象的设计啊。
封装
封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。
使用场景示例
举一个虚拟钱包的示例,Wallet 类主要有四个属性,其中,id 表示钱包的唯一编号,createTime
表示钱包创建的时间,balance
表示钱包中的余额,balanceLastModifiedTime
表示上次钱包余额变更的时间。
- 我们并没有暴露 id、createTime 这两个属性的任何修改方法,比如 set 方法。而且,这两个属性的初始化设置,对于 Wallet 类的调用者来说,也应该是透明的,所以,我们在 Wallet 类的构造函数内部将其初始化设置好,而不是通过构造函数的参数来外部赋值
- 对于钱包余额 balance 这个属性,从业务的角度来说,只能增或者减,不会被重新设置。所以,我们在 Wallet 类中,只暴露了
increaseBalance
和decreaseBalance
方法,并没有暴露 set 方法。对于balanceLastModifiedTime
这个属性,它完全是跟 balance 这个属性的修改操作绑定在一起的。只有在 balance 修改的时候,这个属性才会被修改。所以,我们把 balanceLastModifiedTime 这个属性的修改操作完全封装在了increaseBalance
和decreaseBalance
方法两个方法中,不对外暴露任何修改这个属性的方法和业务细节。这样也可以保证 balance 和 balanceLastModifiedTime 两个数据的一致性
虚拟钱包类代码如下:
package com.example.designpattern; import lombok.Getter; import java.math.BigDecimal; import java.util.UUID; /** * 虚拟钱包 * @author tianmaolin */ @Getter public class Wallet { /** * 钱包id */ private String id; /** * 创建时间 */ private long createTime; /** * 账户余额 */ private BigDecimal balance; /** * 账户变动时间 */ private long balanceLastModifiedTime; /** * 构造钱包 */ public Wallet() { this.id = UUID.randomUUID().toString(); this.createTime = System.currentTimeMillis(); this.balance = BigDecimal.ZERO; this.balanceLastModifiedTime = System.currentTimeMillis(); } /** * 入账 * * @param increasedAmount the increased amount */ public void increaseBalance(BigDecimal increasedAmount) { if (increasedAmount.compareTo(BigDecimal.ZERO) < 0) { throw new NumberFormatException("..."); } this.balance.add(increasedAmount); this.balanceLastModifiedTime = System.currentTimeMillis(); } /** * 出帐 * * @param decreasedAmount the decreased amount */ public void decreaseBalance(BigDecimal decreasedAmount) { if (decreasedAmount.compareTo(BigDecimal.ZERO) < 0) { throw new NumberFormatException("..."); } if (decreasedAmount.compareTo(this.balance) > 0) { throw new NumberFormatException("..."); } this.balance.subtract(decreasedAmount); this.balanceLastModifiedTime = System.currentTimeMillis(); } }
对于封装这个特性,需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制,这个就不详细展开,Java本身就有。
解决什么问题
从两个角度来理解封装的好处和解决的问题,安全性和易用性
- 从安全性的角度来看:如果我们对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。比如某个同事在不了解业务逻辑的情况下,在某段代码中偷偷地重设了 wallet 中的 balanceLastModifiedTime 属性,这就会导致 balance 和 balanceLastModifiedTime 两个数据不一致。
- 从易用性的角度来看,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多
虽然封装特性好,但是很多时候我们业务上似乎不怎么太看重封装,我也一样,一般都是@Data
了事。那为啥一直没出事儿呢,可能和我们一般用贫血模型的方式开发,Model的操作含义极少,就是数据库数据的传递,且大多情况下哪些地方改属性大家都按照MVC有一套代码结构规则,所以一般不会出事,但是为了养成好的习惯,以后写代码还是考虑下类的封装特性。
抽象
封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的,在面向对象编程中,我们常借助编程语言提供的接口类(比如 Java 中的 interface 关键字语法)或者抽象类(比如 Java 中的 abstract 关键字语法)这两种语法机制,来实现抽象这一特性
使用场景示例
举一个简单的通过接口类来实现抽象的例子:
/** * 照片处理接口 * @author tianmaolin */ public interface IPictureStorage { void savePicture(Picture picture); Image getPicture(String pictureId); void deletePicture(String pictureId); void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo); } /** * 照片处理实现 * @author tianmaolin */ public class PictureStorage implements IPictureStorage { // ...省略其他属性... @Override public void savePicture(Picture picture) { ... } @Override public Image getPicture(String pictureId) { ... } @Override public void deletePicture(String pictureId) { ... } @Override public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... } }
在上面的这段代码中,我们利用 Java 中的 interface 接口语法来实现抽象特性。调用者在使用图片存储功能的时候,只需要了解 IPictureStorage 这个接口类暴露了哪些方法就可以了,不需要去查看 PictureStorage 类里的具体实现逻辑。
实际上,抽象这个特性是非常容易实现的,并不需要非得依靠接口类或者抽象类这些特殊语法机制来支持。换句话说,并不是说一定要为实现类(PictureStorage)抽象出接口类(IPictureStorage),才叫作抽象。即便不编写 IPictureStorage 接口类,单纯的 PictureStorage 类本身就满足抽象特性。因为类的方法是通过编程语言中的函数这一语法机制来实现的。通过函数包裹具体的实现逻辑,这本身就是一种抽象。调用者在使用函数的时候,并不需要去研究函数内部的实现逻辑,只需要通过函数的命名、注释或者文档,了解其提供了什么功能,就可以直接使用了。正是因为如此,有时抽象也不被看做一个特性,只要有方法(函数)就能算作做了一层抽象了,这个概念太通用了。
解决什么问题
从两个角度来理解抽象的好处和解决的问题,过滤非必要信息
- 过滤非必要信息:实际上,如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。
很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。
换一个角度来考虑,我们在定义(或者叫命名)类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其定义。举个简单例子,比如 getAliyunPictureUrl()
(就不是一个具有抽象思维的命名,因为某一天如果我们不再把图片存储在阿里云上,而是存储在私有云上,那这个命名也要随之被修改。相反,如果我们定义一个比较抽象的函数,比如叫作 getPictureUrl()
,那即便内部存储方式修改了,我们也不需要修改命名。当然这个前提是图片消费方不需要感知不同来源的图片差异。
继承
继承是用来表示类之间的 is-a
关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物,但多继承会有二义性问题和菱形继承问题,所以较新的语言都使用单继承,例如Java。
解决什么问题
继承有一个非常大的好处就是提高了代码可复用性,假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。
继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看,而且类和父类高度耦合,修改父类的代码,会直接影响到子类。所以这个特性使用起来是要斟酌的,实际上目前大多数情况下,我都很少用继承了,更多的是用组合。
多态
多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现
使用场景示例
实现多态的方式有三种,继承、接口类以及duck-typing
1 继承方式实现多态
创建一个动态数组,以及一个排序的动态数组子类来看看多态的特性:
package com.example.designpattern; /** * 动态数组 * @author tianmaolin */ public class DynamicArray { private static final int DEFAULT_CAPACITY = 10; /** * The Size. */ protected int size = 0; /** * The Capacity. */ protected int capacity = DEFAULT_CAPACITY; /** * The Elements. */ protected Integer[] elements = new Integer[DEFAULT_CAPACITY]; /** * Size int. * * @return the int */ public int size() { return this.size; } /** * Get integer. * * @param index the index * @return the integer */ public Integer get(int index) { return elements[index];} //...省略n多方法... /** * Add. * * @param e the e */ public void add(Integer e) { ensureCapacity(); elements[size++] = e; } /** * Ensure capacity. */ protected void ensureCapacity() { //...如果数组满了就扩容...代码省略... } } /** * 排序动态数组 * @author tianmaolin */ class SortedDynamicArray extends DynamicArray { @Override public void add(Integer e) { ensureCapacity(); int i; for (i = size-1; i>=0; --i) { //保证数组中的数据有序 if (elements[i] > e) { elements[i+1] = elements[i]; } else { break; } } elements[i+1] = e; ++size; } } /** 1. 测试排序 2. @author tianmaolin */ class Test { /** * Test. * * @param dynamicArray the dynamic array */ public static void test(DynamicArray dynamicArray) { dynamicArray.add(5); dynamicArray.add(1); dynamicArray.add(3); for (int i = 0; i < dynamicArray.size(); ++i) { System.out.println(dynamicArray.get(i)); } } /** * Main. * * @param args the args */ public static void main(String args[]) { DynamicArray dynamicArray = new SortedDynamicArray(); test(dynamicArray); // 打印结果:1、3、5 } }
多态这种特性也需要编程语言提供特殊的语法机制来实现。在上面的例子中,我们用到了三个语法机制来实现多态。
- 第一个语法机制是编程语言要支持父类对象可以引用子类对象,也就是可以将 SortedDynamicArray 传递给 DynamicArray。
- 第二个语法机制是编程语言要支持继承,也就是 SortedDynamicArray 继承了 DynamicArray,才能将 SortedDyamicArray 传递给 DynamicArray。
- 第三个语法机制是编程语言要支持子类可以重写(override)父类中的方法,也就是 SortedDyamicArray 重写了 DynamicArray 中的 add() 方法。
通过这三种语法机制配合在一起,我们就实现了在 test() 方法中,子类 SortedDyamicArray 替换父类 DynamicArray,执行子类 SortedDyamicArray 的 add() 方法,也就是实现了多态特性。当然除了继承的方式(无论是继承的普通类还是抽象类)
2 接口类方式实现多态
还可以使用接口的方式实现多态:
public interface Iterator { boolean hasNext(); String next(); String remove(); } public class Array implements Iterator { private String[] data; public boolean hasNext() { ... } public String next() { ... } public String remove() { ... } //...省略其他方法... } public class LinkedList implements Iterator { private LinkedListNode head; public boolean hasNext() { ... } public String next() { ... } public String remove() { ... } //...省略其他方法... } public class Demo { private static void print(Iterator iterator) { while (iterator.hasNext()) { System.out.println(iterator.next()); } } public static void main(String[] args) { Iterator arrayIterator = new Array(); print(arrayIterator); Iterator linkedListIterator = new LinkedList(); print(linkedListIterator); } }
在这段代码中,Iterator 是一个接口类,定义了一个可以遍历集合数据的迭代器。Array 和 LinkedList 都实现了接口类 Iterator。我们通过传递不同类型的实现类(Array、LinkedList)到 print(Iterator iterator) 函数中,支持动态的调用不同的 next()、hasNext() 实现。
3 duck-typing实现多态
扩展一下,虽然Java这样的语言目前不支持duck-typing实现多态,多多少少也可以了解一下,一段python代码:
class Logger: def record(self): print(“I write a log into file.”) class DB: def record(self): print(“I insert data into db. ”) def test(recorder): recorder.record() def demo(): logger = Logger() db = DB() test(logger) test(db)
从这段代码中,我们发现,duck-typing 实现多态的方式非常灵活。Logger 和 DB 两个类没有任何关系,既不是继承关系,也不是接口和实现的关系,但是只要它们都有定义了 record() 方法,就可以被传递到 test() 方法中,在实际运行的时候,执行对应的 record() 方法。也就是说,只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的 duck-typing,是一些动态语言所特有的语法机制。而像 Java 这样的静态语言,通过继承实现多态特性,必须要求两个类之间有继承关系,通过接口实现多态特性,类必须实现对应的接口
解决什么问题
多态特性能提高代码的可扩展性和复用性
- 多态提高了代码的可扩展性,我们利用多态的特性,仅用一个
print()
函数就可以实现遍历打印不同类型(Array、LinkedList)集合的数据。当再增加一种要遍历打印的类型的时候,比如 HashMap,我们只需让 HashMap 实现 Iterator 接口,重新实现自己的 hasNext()、next() 等方法就可以了,完全不需要改动 print() 函数的代码。所以说,。 - 多态提高了代码的可复用性,如果我们不使用多态特性,我们就无法将不同的集合类型(Array、LinkedList)传递给相同的函数(print(Iterator iterator) 函数)。我们需要针对每种要遍历打印的集合,分别实现不同的 print() 函数,比如针对 Array,我们要实现 print(Array array) 函数,针对 LinkedList,我们要实现 print(LinkedList linkedList) 函数。而利用多态特性,我们只需要实现一个 print() 函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。
除此之外,多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等
总结一下
今天可以说又重新认识了一下面向对象四大特性,回顾设计模式目标,写出高质量的代码:易维护、易读、易扩展、灵活、简洁、可复用、可测试,面向对象设计思想是基本指导思想,是很多设计原则、设计模式的实现基础,后者进一步支持高质量代码目标达成。而面向对象四大特性本来被设计出来也能一定意义上本身作为一个达成路径,例如封装,可以让数据更安全,不能被随便修改,让代码更易维护;通用的抽象影响无处不在,抽象的代码设计让代码易扩展、易维护;继承让代码更加可复用;多态让代码更易扩展、易复用。它们的作用远不止这些,这些顶层的语言特性抽象,一种极致抽象的设计范式,某种意义上决定了基于此的设计原则、设计模式等能够方便的实现。就如同最近工作的模型一样,底层的特性、思想、结构如果极致抽象,那么基于此的业务再复杂底层也能承接。