面向对象
封装、抽象、继承、多态
面向对象的关键是其四大特性
封装 Encapsulation
也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。
课程中提供的代码是 java,我用 ts 重新实现了一遍,当做是练习 ts 的熟练度了
1 | let id = 0; |
对于封装这个特性,需要编程语言本身提供访问权限控制的语法机制来支持。
之所以这样设计,是因为从业务的角度来说,id、createTime 在创建钱包的时候就确定好了,之后不应该再被改动,所有只提供 get 方法,这两个属性的初始化设置,对于调用者也是透明的,所以不提供用构造参数的方式进行外部赋值。
对于钱包余额 balance 这个属性,从业务的角度来说,只能增或者减,不会被重新设置。所在 Wallet 类中,只暴露了 increaseBalance() 和 decreaseBalance() 方法,并没有暴露 set 方法。alanceLastModifiedTime 这个属性,它完全是跟 balance 这个属性的修改操作绑定在一起的。只有在 balance 修改的时候,这个属性才会被修改,所以其相关的操作,都封装在 increaseBalance() 和 decreaseBalance() 方法中了
封装可以解决这些问题
如果对类中的属性访问不做控制,类的属性可能在代码的各个角落被随意修改,影响代码的可读性和可维护性
类通过有效的方法暴露必要的操作,也可以提高类的易用性
抽象 Abstraction
隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的
1 | interface Picture { |
抽象这个特性非常用以实现,不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,函数本身就是一种抽象。
相较于课程中代码,为了简化代码,有所改动。调用者在使用图片存储功能时,只需要了解 IPictureStorage 暴露了哪些方法就可以了,不需要查看 PictureStorage 里面的具体实现。
抽象可以解决这些问题
只关注功能点,不关注实现,很多设计原则体现了抽象的设计思想,如基于接口而非实现编程,开闭原则,代码解耦。我们在定义方法的时候,也要有抽象思维,不要再方法定义中,暴露太多的细节。如 getAliyunPictureUrl() 就不好,如果某一天讲图片存储地址改变了,那么函数命名也要改变,相反,应该叫做 getPictureUrl(),即便内部存储方式修改了,我们也不需要修改命名
继承 Inheritance
上升一个思维层面,去思考继承这一特性,可以这么理解:我们代码中有一个猫类,有一个哺乳动物类。猫属于哺乳动物,从人类认知的角度上来说,是一种 is-a 关系。我们通过继承来关联两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。
度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。我们应当 “多用组合少用继承”
多态 Polymorphism
子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现
这里就没有改写课程中的代码,换了一个更浅显的例子
1 | class Animal { |
多态需要编程语言拥有一下特殊语法机制
- 编程语言要支持父类对象可以引用子类对象
- 第二个语法机制是编程语言要支持继承
- 第三个语法机制是编程语言要支持子类可以重写(override)父类中的方法
多态除了利用 继承加方法重写的方式来实现外,还有两种比较常见的实现方式,一个是利用接口类语法,另一个是 duck-typing 语法。
接口类
1 | interface CanBark { |
duck-typing
1 | interface CanBark { |
多态能提高代码的可扩展性和复用性,也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等等。
抽象类与接口
抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。
基于接口而非基于实现编程
越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。
假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的 AliyunImageStore 类,供整个系统来使用。
整个上传流程包含三个步骤:创建 bucket(你可以简单理解为存储目录)、生成 access token 访问凭证、携带 access token 上传图片到指定的 bucket 中。代码实现非常简单,类中的几个方法定义得都很干净,用起来也很清晰,乍看起来没有太大问题,完全能满足我们将图片存储在阿里云的业务需求。
而有一天,我们要将图片改为上传到私有云,首先,AliyunImageStore 类中有些函数命名暴露了实现细节,比如,uploadToAliyun() 和 downloadFromAliyun()。
其次,将图片存储到阿里云的流程,跟存储到私有云的流程,可能并不是完全一致的。比如,阿里云的图片上传和下载的过程中,需要生产 access token,而私有云不需要 access token。
而要解决这些问题,编写代码的时候,要遵从 “基于接口而非实现编程”的原则
- 函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。
- 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
- 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。
在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。
课后习题
在项目中很多地方,我们都是通过下面第 8 行的方式来使用接口的。这就会产生一个问题,那就是,如果我们要替换图片存储方式,还是需要修改很多类似第 8 行那样的代码。
1 | public class ImageProcessingJob { |
看了评论区的答案,一种比较好的解决方式是使用工厂方法+配置文件
1 | ImageStore imageStore = ImageStoreFactory.newInstance(SOTRE_TYPE_CONFIG); |
多用组合 少用继承
继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。我们可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决继承存在的问题。
接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?
我们可以针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。然后,通过组合和委托技术来消除代码重复。具体的代码实现如下所示:
1 | interface Flyable { |