设计模式1:学习设计模式之前,我们需要了解哪些

(文章是个人对前谷歌工程师王争的设计模式之美专栏内容的学习笔记,文章内容均来自对原文内容的概括,第一篇相当于是对后面学习设计原则以及设计模式的准备的准备工作)

一.面向对象的四大特性

  java作为一门面向对象的语言,理所应当具有面向对象的四大特性:封装、抽象、继承、多态。后面的设计原则、设计模式很多都是在面向对象特性基础上形成的。接下来的内容也会照着下面这个思路推进。
在这里插入图片描述
  首先我们来看这四大特性:
1.封装
  封装也叫做信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。对于这个特性,我们需要访问权限控制来实现。例如java中的publi、protect、defult、以及private。
  如果我们对类中的属性访问不做限制,那任何代码都可以修改、访问类中的属性,修改逻辑可能散落在代码中的各个角落,比如我们有一个钱包类,其中有两个属性分别是balance(余额)和balanceLastModifiedTime(余额最后修改时间),有人在不了解业务逻辑的情况下,偷偷的重设了balanceLastModifiedTime属性,这就会导致banlance和balanceLastModifiedTime两个数据不一致。
  此外,经过封装处理后的类也具有更好的易用性。如果我们把类的属性都暴露给类的调用者,调用者想要正确的操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说反而是一种负担。经过封装以后,调用者就不需要了解太多背后的业务细节,用错的概率就会减少很多。

2.抽象
  抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供类哪些功能,并不需要知道这些功能是如何实现的。我们经常使用接口类(比如java中的interface)或者抽象类(比如java中的abstract)这两种语法机制来实现抽象这一特性。
  继承这个特性有些特殊,除了通过上述的两种方法来实现,我们在写一个函数本身时,也用到了抽象,我们通过函数来包裹具体的实现逻辑。我们可以发现,抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构的设计等,而且这个特性并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异性”,抽象有时候并不被看作面向对象编程的特性之一。这也是我们会看到有些书中的三大特性这个说法的原因。
  在我们面对复杂系统的时候,我们人脑能接收的信息复杂程度是有限的,因此我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮助我们的大脑过滤掉许多非必要的信息。
3.继承
  继承用来表示类之间的is-a关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持,比如Java使用extends关键字来实现继承,C++ 使用冒号(class B : public A),Python 使用paraentheses(),Ruby使用<。不过,有些编程语言只支持单继承,不支持多重继承,比如Java、PHP、 C#、Ruby等,而有些编程语言既支持单重继承,也支持多重继承,比如C++、Python、 Perl 等。
  继承最大的好处就是代码复用,让多个子类继承同一个父类,这样所有的子类就可以共同使用父类的代码。但是,当我们过度使用继承,层次太过复杂时,就会导致代码的可读性、可维护性变差。所以继承是一种具有争议的特性,很多人觉得这是一种反模式,认为应该多用组合少用继承
4.多态
  多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现。●第一个语法机制是编程语言要支持继承。●第二个语法机制是编程语言要支持父类对象可以引用子类对象。●第三个语法机制是编程语言要支持子类可以重写(override) 父类中的方法。
多态的条件
  多态可以提高代码的扩展性和复用性,是很多设计模式、 设计原 则、编程 技巧的代码实现基础。

二.哪些代码看似面向对象,实际上是面向过程

1.滥用getter、setter方法
  我们在开发过程中可能会使用lombok,mybatis-generator这样的代码生成插件,生成时直接帮助我们生成所有的getter、setter方法,而这种做法严重破坏类面向对象的封装特性。我们虽然将它定义成了私有属性,但是这种做法和把他定义成公有属性已经没有什么区别了。外部可以随意的通过getter、setter方法访问和设置这个属性。
2.滥用全局变量和全局方法(Constant类、Util类的设计问题)
  在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。单例类对象在全局代码中只有一份,所以,它相当于一个全局变量。静态成员变量归属于类.上的数据,被所有的实例化对象所共享,也相当于一定程度.上的全局变量。而常量是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个Constants类中。静态方法一般用来操作静态变量或者外部数据。你可以联想一下我们常用的各种Utils类,里面的方法一般都会定义成静态方法,可以在不用创建对象的情况下,直接拿来使用。静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格。
3.定义数据和方法分离的类(基于贫血模型的开发模式)
  我们再来看最后一种面向对象编程过程中,常见的面向过程风格的代码。那就是,数据定义在一个类中,方法定义在另一个类中。你可能会觉得,这么明显的面向过程风格的代码,谁会这么写呢?实际上,如果你是基于MVC三层结构做Web方面的后端开发,这样的代码你可能天天都在写。
  传统的MVC结构分为Model 层、Controller 层、View层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为Controller层、Service 层、Repository层。Controller层负责暴露接口给前端调用,Service层负责核心业务逻辑,Repository层负责数据读写。而在每一层中,我们又会定义相应的VO (View Object)、BO(Business Object)、 Entity。 一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应Controller 类、Service 类、Repository类中。这就是典型的面向过程的编程风格。

三.抽象类和接口的区别

1.抽象类的特性:
●抽象类不允许被实例化,只能被继承。也就是说,你不能new一个抽象类的对象出来.

●抽象类可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。

●子类继承抽象类,必须实现抽象类中的所有抽象方法。

2.接口的特性:
●接口不能包含属性(也就是成员变量)。
●接口只能声明方法,方法不能包含代码实现。
●类实现接口的时候,必须实现接口中声明的所有方法。

抽象类实际上就是类,只不过是一种特殊的类, 这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种is-a的关系,那抽象类既然属于类,也表示一种is-a的关系。相对于抽象类的is-a关系来说,接口表示一种has-a关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)

3.为什么需要抽象类,它解决了什么编程问题
  抽象类不能实例化,只能被继承。继承能解决代码复用的问题。所以,抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。
  不过,既然继承本身就能达到代码复用的目的,而继承也并不要求父类一定是抽象类,那我们不使用抽象类,照样也可以实现继承和复用。从这个角度上来讲,我们貌似并不需要抽象类这种语法呀。那抽象类除了解决代码复用的问题,还有什么其他存在的意义吗?
  现在假设我们有一个抽象类有抽象方法需要被子类继承,那么当我们不使用抽象类的时候,我们就不用在父类里定义这个方法,直接让子类去继承,但是这样的话当一个父类引用持有一个子类对象时,我们就没有办法使用它的多态特性了。在父类引用调用子类对象的方法的时候会编译报错。你可能会说,这个问题我们可以在父类中定义一个空的方法不久可以解决了吗,事实上我们会发现之前抽象父类所做的事情就是这样,在父类中定义一个空的抽象方法留给子类继承。但是如果我们现在使用普通的父类去定义一个空的方法,会存在下面的问题:
1.会影响代码的可读性。
2.当我们创建一个新的子类时,我们可能会忘记去重写这个待实现的方法,而之前编译期会强制要求我们去重写抽象父类的抽象方法。
3.由于这个父类是一个普通的父类,我们可以实例化这个类,并且调用类中的空方法,这样增加了代码误用的风险。

4.为什么需要接口,它能够解决什么问题

  抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下API接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。
  实际上,接口是一个比抽象类应用更加广泛、更加重要的知识点。比如,我们经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。

5.什么时候用抽象类,什么时候用接口
  判断的标准很简单。如果我们要表示一种is-a的关系,并且是为了解决代码复用的问题,我们就用抽象类;如果我们要表示一种has-a关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。
  从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。

四.基于接口而非实现编程

1.如何理解“接口”?
  “基于接口而非实现编程”这条原则的英文描述是:“Program to an interface, not animplementation”。我们理解这条原则的时候,千万不要一开始就与 具体的编程语言挂钩,局限在编程语言的“接口”语法中(比如 Java中的interface接口语法)。这条原则最早出现于1994年GoF的《设计模式》这本书,它先于很多编程语言而诞生(比如 Java语言),是一条比较抽象、泛化的设计思想。
  实际上,理解这条原则的关键,就是理解其中的“接口”两个字。从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”,甚至是一组通信的协议都可以叫作“接口”。刚刚对“接口”的理解,都比较偏上层、偏抽象,与实际的写代码离得有点远。如果落实到具体的编码,“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类。
  前面我们提到,这条原则能非常有效地提高代码质量,之所以这么说,那是因为,应用这条.原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。
  实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。

2.我们在实际编程中如何遵循这条原则
1.函数的命名不能暴露任何实现细节。比如,uploadToAliyun() 就不符合要求,应该改为去掉aliyun这样的字眼,改为更加抽象的命名方式,比如: upload()。
2.封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
3.为实现类定义抽象的接口。具体的实现类都依赖统- -的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。

3.是否需要为每一个类都定义接口
  你可能会有这样的疑问:为了满足这条原则,我是不是需要给每个实现类都定义对应的接口呢?在开发的时候,是不是任何代码都要只依赖接口,完全不依赖实现编程呢?
  做任何事情都要讲求一个“度”,过度使用这条原则,非得给每个类都定义接口,接口满天飞,也会导致不必要的开发负担。至于什么时候,该为某个类定义接口,实现基于接口的编程,什么时候不需要定义接口,直接使用实现类编程,我们做权衡的根本依据,还是要回归到设计原则诞生的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题而产生的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。
  前面我们也提到,这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。
  从这个设计初衷.上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。
  除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间。

五.多用组合少用继承

1.为什么不推荐使用继承
  继承是面向对象的四大特性之一,用来表示类之间的is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。比如当我们有一个类Animal,我们为了区分Animal会不会飞,我们创建了两个类FlyableAnimal和UnFlyableAnimal都继承Animal,但是当我们为了区分另外一个属性时,我们的这两个属性就会产生四种组合,同样的,当我们继续增加属性时,这样产生的类的数量就会指数级增长,很快就会产生继承爆炸的问题。

2.组合相比继承有哪些优势
  实际上,我们可以使用组合、接口、和委托三个技术手段,一起来解决刚刚的问题。我们前面讲到接口的时候说过,接口表示具有某种行为特性。针对“会飞’这样一个行为特性,我们可以定义一个Flyable接口,只让会飞的动物去实现这个接口。对于会叫、会下蛋这些行为特性,我们可以类似地定义Tweetable接口、EggLayable接口。但是,在这种情况下,对于每一种继承了Flyable接口的会飞的动物,我们都要重写其中的的Fly方法,就会造成的大量的代码重复。对于这个问题,我们可以使用组合和委托的方法来解决。
  我们可以针对三个接口再定义三个实现类,它们分别是:实现了fly() 方法的FlyAbility 类、实现了tweet() 方法的TweetAbility类、实现了layEgg() 方法的Eggl ayAbility类。然后,通过组合和委托技术来消除代码重复。我们来看一下代码实现:

public interface Flyable {
	void fly();
}
public class FlyAbility implements Flyable {
@Override
public void fly() { /1... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility

public class Ostrich implements Tweetable, EggLayable {//鸵鸟

private TweetAbility tweetAbility = new TweetAbility(); //组合

private EggLayAbility eggLayAbility = new EggLayAbility(); //组合

//...省略其他属性和方法...
@Override
public void tweet() {
tweetAbility. tweet();
} //委托

  我自己举个例子来说明一下我是如何来理解这种操作的,我把他想像成根据图纸拼装一个成品这样一个过程,图纸上声明了我们需要哪些零件来拼装这个成品(接口),之前我们需要自己去加工这个零件,现在我们已经提供了现成的零件(接口的实现类),我们根据图纸明确我们需要哪些零件,然后我们直接把现有的零件拿到我们的成品拼装起来就可以。这个例子🌰可能具得不是很恰当,因为成品与零件看起来好像是一种has-a的关系,但是我们大概能明白其中的意思即可。
3.什么时候用组合,什么时候用继承?
  尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。
  如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
  除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、 策略模式(strategy pattern)、 组合模式(composite pattern) 等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值