篇幅较长,请耐心读完,相信小先会让你有所收获。如有任何错误,也请欢迎随时在评论区指正。
自上而下(抽象到具体)地描述什么是面向对象编程的思想和设计原则。期间也会涉及到面向对象三大特性。还有许多鲜明的例子供大家参考理解。
适合读者:一是刚刚接触面向对象编程的并学习过一门面向对象语言的同学,这一部分同学我推荐将本文通读并理解。二是还学习过《UML》、《软件工程》、《设计模式》等的同学,建议着重了解行为思路,然后关注自己不了解的或者不够深入的点即可,但如果没有明确的体系概念,也建议通读。
虽然篇幅较长,但是肯定能给学习面向对象编程的同学带来一定的帮助。你可以从文章结构中一窥本文的思路,也可以跳到总结中看看本文的行文思路。如果用一张图概括的话:
从问题出发,依次解决问题,这是本文的思路。
软件的价值,就是对利用计算机的计算能力,对现实世界进行模拟,以此产生比人工更大的效率与生产价值。比如:淘宝就像一个现实世界中有着无数商家的商圈,QQ就像一个能容纳十亿人的棋牌室。
所以,当模拟的系统异常庞大时,如何保证软件系统的正常运行,以及能够良好的对软件进行维护与拓展呢?
软件的问题就是软件设计要解决的问题。
像修房子前先画建筑图一样去设计软件——也就是建模。
所谓软件建模,就是为要开发的软件建造模型。模型是对事物的抽象,比如建筑图纸、或者数学公式模型。
只有建模,我们才能在纷繁复杂的业务和系统中理清思路。才能搞清软件的本质规律和基本特征。才能在软件开发伊始,就能知道软件做出来后是否稳定,是否可维护与拓展!
面向对象建模的标准就是——UML。
搞软件就像盖房子,也需要设计设计。
20世纪,80年代至90年代。面向对象分析与设计方法发展迅速。相关研究十分活跃,诞生了很多方法与技术。随着百家争鸣中的优胜劣汰与兼容并和,UML成为了面向对象分析与设计建模的标准。
软件建模面临两个问题,一个我们要解决的领域问题,一个是我们最终的软件系统。这两个方面客观存在的分析、抽象与设计,就是我们的软件模型。
那么软件有建模,UML有没有什么指导性的方法论呢?当然。
UML的方法论(也叫软件建模方法论)就是4+1视图。
4+1视图将UML要关注的模型分为了主要的几个层次。如下图:
逻辑视图:描述软件的功能逻辑,由哪些模块组成,模块中包含那些类,其依赖关系如何。
开发视图:包括系统架构层面的层次划分,包的管理,依赖的系统与第三方的程序包。开发视图某些方面和逻辑视图有一定重复性,不同视角看到的可能是同一个东西,开发视图中一个程序包,可能正好对应逻辑视图中的一个功能模块。
过程视图:描述程序运行期的进程、线程、对象实例,以及与此相关的并发、同步、通信等问题。
物理视图:描述软件如何安装并部署到物理的服务上,以及不同的服务器之间如何关联、通信。
场景视图:针对具体的用例场景,将上述 4 个视图关联起来,一方面从业务角度描述,功能流程如何完成,一方面从软件角度描述,相关组成部分如何互相依赖、调用。
说完了UML的方法论4+1模型。我们再回到UML本身。UML是一种图形化语言。UML 规范包含了十多种模型图,常用的有 7 种:类图、序列图、组件图、部署图、用例图、状态图和活动图等。由于后文只需要类图的知识即能理清,其他图由于篇幅请自行拓展学习。
UML的各种图对应了各自的4+1视图(参考:4+1视图与UML对应关系)。所以他们都是4+1视图的具体描述。就像几何三视图一样,分散时可能很抽象,但是合起来看就会勾勒出整体的轮廓。
总的来说,4+1 视图模型很好地向我们展示了如何对一个软件的不同方面用不同的模型图进行建模与设计,以期完整描述一个软件的业务场景与技术实现。
类图是最常见的 UML 图形,用来描述类的特性和类之间的静态关系。
一个类包含三个部分:类的名字、类的属性列表和类的方法列表。类之间有4 种静态关系:关联、泛化、依赖、实现。把相关的一组类及其关系用一张图画出来,就是类图。
关联:类中引入另一个类当做成员变量。
泛化:就是继承。
依赖:引入一个类作为一个方法的参数,或者方法内部引入。
实现:实现接口。
UML模型图之类图如下:
类间关系 | 对应类图 |
---|---|
关联 | |
泛化 | |
依赖 | |
实现 | |
从实际出发总是发人深省的。
假设,你需要开发一个程序,输入a、b,计算a、b的值并打印到控制台。任务看起来很简单,几行代码就能搞定:
calculate(int a, int b){
System.out.println(a + b);
}
你将程序开发出来,测试没有问题,很开心得发布了,其他程序员在他们的项目中依赖你的代码。过了几个月,老板忽然过来说,这个程序需要支持a、b的减法,于是你不得不修改代码:
//type = 1加法,type = 2减法
calculate(int type, int a, int b){
if(type == 1){
System.out.println(a + b);
} else {
System.out.println(a - b);
}
}
为了能支持减法,你在方法中增加了形参type,用type判断增减。并且还好心的写了注释。但是始终有些人搞错了type的含义,导致运行时出了bug。
虽然没有人责怪你,但是你还是很沮丧。然而这时候,老板又来了,想让你增加乘除法~
你硬着头皮改,但是代码越来越臃肿,复杂,难以理解。导致的bug越来越多,你犹豫着要不要跑路~
上述场景,在各种各样的软件开发场景中,随处可见。比如针对上面这个例子,更加灵活,对需求更加有弹性的设计、编程方式可以是下面这样的:
//操作的抽象接口
interface Operation(){
operate(int a, int b);
}
//加法实现类
class Add implements Operation{
@Override
public void operate(int a, int b){
System.out.println(a + b);
}
}
//减法实现类
class Sub implements Operation{
@Override
public void operate(int a, int b){
System.out.println(a - b);
}
}
//type = 1加法,type = 2减法
calculate(int a, int b){
//获得具体实现类的项目包路径,要什么操作就在XML中配置操作实现类的项目包路径。
String classPath = XML.get();
//反射实现动态加载类
Operation op = Class.fromName(classPath);
//传入a、b,多态调用方法
op.operate(a, b);
}
以上代码,如果需要乘除法,只需要定义乘除法的具体实现类,然后XML中配置项目包路径即可。
我们通过接口,将a、b执行的操作抽象出来。然后针对抽象(接口)编程,在利用语言为我们实现的多态特性。就能达到在不修改代码的前提下,实现功能的拓展。
你能看到,应对需求变更最好的办法就是一开始的设计就是针对需求变更的,并在开发过程中根据真实的需求变更不断重构代码,保持代码对需求变更的灵活性。
我们用类图来对以上代码建模:我们后续还会详细讲解此图。
所以,为何好的程序员拥抱需求变化,因为他们老早就为需求变化设计了解决方案,没有变化,心思岂不白费?
如何不修改代码实现需求变更就是面向对象编程的目的之一。
聪明的人,做每件事情前,总是会设定一个初始的目标或目的。面向对象的软件系统设计,也会有一个总体的目标。那么目标有哪些呢?
内聚是从功能角度来度量模块内的联系,一个好的内聚模块应当恰好做一件事。它描述的是模块内的功能联系。提高可维护性和可复用性。侧重于单个类或系统的设计。
低耦合是用来度量模块与模块直接的依赖关系。依赖越低,越容易复用,复杂性也越低。提高可拓展性和降低复杂性。侧重于类与类,系统与系统之间的解耦。
遵循面向对象的设计原则,请移步5.面向对象设计原则。
面向对象7大设计原则是解决如何高内聚、低耦合地设计面向对象软件系统的原则。
描述:对修改关闭,对拓展开放。
意思:在不修改代码的前提下,拓展功能。
为什么:参照本文3. 说面向对象前先讨论需求变更。
原则意义:是设计原则的目标,总的指导原则。
描述:高层模块不依赖低层模块,二者都应该依赖抽象。抽象不应该依赖具体实现,具体实现应该依赖抽象。
意思:首先,讨论一个问题,什么是依赖?依赖就是上面UML类图中讲的,一个类中引入了另一个类。这两个类也就存在依赖关系。其次,什么是高层,什么是底层模块?计算机中处处体现分层的思想,软件开发也不例外。控制层依赖业务层,业务层依赖数据存储层。抽象与具体实现意思差不多。
为什么:接下来我们讨论下这种依赖会存在什么问题。以及依赖倒置的具体例子。从而知道为什么需要依赖的倒置。首先,依赖会导致维护困难。高层业务依赖实现底层具体实现细节时。当具体实现细节需要拓展改变时,极有可能会改变高层业务逻辑,因为你是直接依赖。其次,依赖会导致复用困难。越是高层的模块,越有复用的价值。因为它是抽象的,是所有细节的抽象部分,所有细节都能复用。如果高层直接依赖于底层,那么高层复用时可能面临引入不必要的依赖,从而导致复用困难。
具体例子:我们回到本文3.2中。借用当时建模的类图。
我们先描述下图的意图,然后再谈依赖倒置。图中service可以当做是高层,而Add、Sub或者新操作即是低层,也是具体实现类。关键在Operation类,它是一个抽象的高层接口。高层采用面向接口编程的方式,可以从XML配置文件中获取配置的Add或Sub类的项目包路径,然后通过反射机制加载,并赋给接口变量op。接下来,当运行时,多态就起作用了,这时的Operation运行时判定为Operation的子类,调用的是子类的operation(a,b)方法。如果老板让你再加新的操作,那么你可以不用修改代码,新建一个类实现Operation接口,最后修改配置文件即可。有的小伙伴可能会问了,小先你不是说过,这不是开闭原则吗?请接着往下看。
图中的依赖倒置:终于轮到依赖倒置了,我已经迫不及待了呢。回到依赖倒置的描述-高层模块不依赖低层模块,二者都应该依赖抽象——再看图中,高层service不直接依赖Add,高层service和底层Add都依赖于抽象。之后是-抽象不应该依赖具体实现,具体实现应该依赖抽象——图中抽象Operation接口不依赖Add,而是Add具体实现依赖了抽象Operation接口。用图解释了依赖倒置的描述,那依赖倒置中的倒置到底是什么意思呢?请看下面依赖倒置的关键点。
依赖倒置的关键点:依赖倒置有一个重要的关键点,就是抽象接口的所属问题,也叫做接口所有权的倒置。日常开发中,我们都是如下图:
图中的层层依赖,导致了我们所说的维护困难、复用困难。而依赖倒置的一个关键点在于。定义的抽象接口要属于高层。让低层去实现属于高层的接口,然后高层再通过抽象接口调用低层。
这样高层就不需要直接依赖底层了,而是变成了底层依赖高层定义的接口,从而实现了依赖倒置(因为接口是高层的,底层依赖了高层的接口)。这就是依赖倒置的最终解释。我们用依赖倒置,实现了在不修改代码的情况下拓展系统。
原则意义:开闭原则是目标,依赖倒置就是实现目标的方法。
描述:所有使用基类的地方,都可以使用子类去替换。
意思:用另一句话来,子类必须能够替换掉他们的基类型。有小伙伴可能会懵逼,这不是显而易见的吗?下面我会讲讲我理解的两层意思。
为什么:为什么需要里氏替换原则?第一:就是显而易见的意思,因为需要多态,这也是面向对象的要求之一。第二:就和面向对象三大特性,封装、继承、多态中的继承有关了。三大特性本文虽然不会单独拿出来讲,但是会在文中一一阐述。说到继承,可能会觉得,就是子类继承父类,很简单啊。但是实际上并非都是如此。继承也会有出现误用的时候。比较典型的问题是,正方形可以继承长方形吗?我们看下下面这个例子:
具体例子:
假设长方形类有属性:宽(width)和高(height)。还有一个计算面积的方法area=width*height。
public class Rectangle {
private double width;
private double height;
public void setWidth(double w) {
width = w; }
public void setHeight(double h) {
height = h; }
public double getWidth() {
return width; }
public double getHeight() {
return height; }
public double calculateArea() {
return width * height;}
}
如果正方形继承长方形,正方形中的宽和高的set方法需要重写为同时设置宽和高->width= height = 值。
public class Square extends Rectangle {
public void setWidth(double w) {
width = height = w;
}
public void setHeight(double h) {
height = width = w;
}
}
我们看下测试类,testArea中传入一个Square正方形,期待的计算是长方形的宽乘以高,也就是3*4 = 12,但是实际却是16。说明正方形继承长方形是有误的。违背了里氏替换原则。里氏替换原则也有一个较为通俗的理解——子类不能比父类更严格。在这个例子中,正方形继承了长方形,但是正方形有比长方形更严格的契约,即正方形要求宽和高是一样的。因为正方形有比长方形更严格的契约,那么在使用长方形的地方,正方形因为更严格的契约而无法替换长方形。
void testArea(Rectangle rect) {
rect.setWidth(3);
rect.setHeight(4);
rect.calculateArea();//计算的面积为12
}
原则意义:实现多态,以及采用继承时用此原则去审视是否正确。
描述:一个类,应该只有一个引起它变化的原因。
意思:封装一个类时,应该让这个类的职责单一。即只有一个原因可以引起类的变化,那么就说符合单一职责原则。当然,设计时,不可能完全符合单一职责原则。但是,我们要以此为指导。再说说封装。封装:封装就是指隐藏对象的属性和实现细节,仅仅对外提供公共方法去访问它。这是笼统的概念,我们从它的作用中理解为什么需要封装。作用一是隐藏属性和细节——不需要知道一个类是如何工作的,属性是什么,细节如何实现,只需要拿来使用,提高了复用性。作用二是只有公共方法能够访问——你只能通过类的设计者暴露出的方法去访问,然后修改类的状态(通常指封装的数据),让外界使用与内部变化隔离,设计者也可以在方法里设置访问限制等,提高了安全性。日常开发中,封装其实大家都在用,只是用的好与不好的区别。封装和单一职责:单一职责原则是封装的充分不必要条件。达到单一职责原则,必定用到了封装。而封装一个类,则不一定达到了单一职责原则的要求。
为什么:为什么需要单一职责原则?第一:降低耦合。当一个人职责太多,说明这个类耦合了太多的职责。违反了面向对象设计总的目标——低耦合。第二:降低复杂度。职责太多,则代表代码多,类太大。很容易违反开闭原则,且修改时容易导致错误。
原则意义:提高内聚性、降低耦合度,提高可复用性。
描述:不应该强迫用户依赖他们不需要的方法。
意思:在一个类或接口中,有些方法暴露给调用者可能会被调用者胡乱调用,或难以理解。所以,如何对类的调用者隐藏类的某些方法就是接口隔离。这样说可能有点迷糊,我们来看一个学生、老师的例子。
具体例子:
场景:上课
被调用者:学生
调用者:老师
学生功能:被点名,被表扬。
如果我们是学生,我们在课上会被老师调用,那你一定希望老师调用不到被点名这个方法(假设不希望,你希望我也没办法了),而是调用被表扬方法(假设你希望)。那么该如何实现呢?我们画下实现的类图:
可以看到,在类图中,被表扬、被点名分别由一个接口定义,然后让学生实现了这两个方法的接口。最重要的是,我们只将被表扬的接口给了老师,老师使用的是被表扬的接口编程,当我们传一个被表扬的实现类学生时,因为老师使用的是被表扬接口编程,他只能看到一个方法,那就是被表扬,肯定也就调用不到我们被点名的接口啦,是不是很完美,哈哈(对不起,我飘了)。所以接口隔离可以采用的方法,即是将一个实现类的不同方法包装在不同的接口中对外暴露。
为什么:为什么需要接口隔离原则?一来,用户看到这些他们不需要,也不理解的方法,这样无疑会增加他们使用的难度,如果错误地调用了这些方法,就会产生错误。也可表述为,减少用户的依赖复杂度(因为只依赖必要的)。二来,当这些方法如果因为某种原因需要更改的时候,虽然不需要但是依赖这些方法的用户程序也必须做出更改,这是一种不必要的耦合。
原则意义:降低依赖复杂度,降低耦合。
描述:多用组合/聚合关联关系,少用继承。
意思:面向对象设计中,有两种基本方法可以复用已有的设计与实现——通过组合/聚合关联或通过继承。合成复用原则则提倡在复用时,要尽量使用组合/组合的关联关系,少用继承。
为什么:为什么要使用合成复用原则?第一:降低耦合。采用继承时,一个类的变化,容易引起另外一个类的变化。而采用合成复用,则这种变化影响相对较少。第二:继承会破坏封装性。说到封装,再拓展下什么叫“黑箱”复用和“白箱”复用。我们上面提到过封装,隐藏细节,只暴露想暴露的。这就可以理解成是一种“黑箱”复用,因为我确实隐藏了某些细节。但是继承则是一种“白箱”复用。继承让所有的属性和方法都暴露给了子类。子类可以任意调用修改。所以继承是与封装相反的复用,它破坏了封装性。
原则意义:降低耦合。
描述:又称最少知识原则,指一个软件实体应当尽可能少地与其他实体发生相互作用。
意思:限制了软件实体之间通信的深度和宽度。这样,当一个模块改变时,就会少影响其他模块。深度指可能不仅一个模块的一个类。宽度指与多个模块进行通信。
为什么:为什么需要迪米特法则?第一:解耦。这是狭义的迪米特法则,如果两个类不必直接进行通信,那么两个类就不必直接相互作用,可以通过第三者转发这个调用。比如:分布式开发中的消息队列。第二:信息隐藏。这是广义的迪米特法则,信息的隐藏也可以使各子系统之间脱耦。从而使各子系统能够独立开发、优化、使用。类比:如计算机网络体系结构,各层之间信息隐藏,互不干涉。
原则意义:降低耦合。
面向对象的本质是什么?
答:多态
多态是什么?
答:子类实现父类或者接口的抽象方法,程序使用抽象父类或者接口编程,运行期注入不同的子类,程序就表现出不同的形态,是为多态。
面向对象编程和此前的面向过程编程的核心区别是什么?
答:常说面向对象编程有三大特性,其中封装在面向过程c语言中可以较容易实现,继承也可以较容易实现(结构体中定义结构体),但是在c语言中,多态非常难以实现。所以,多态是面向对象和面向过程语言的非常重要的一个区别。正是多态,使得面向对象编程和以往的编程方式有了巨大的不同。
为什么多态是面向对象编程的本质?
最大的好处就是程序运行时的具体实现无关性,程序针对接口和抽象类编程,而不需要关心具体实现是什么。我们在3.2中讲到的加减法案例,如果输入的数值a、b与操作类型耦合在一起。那么当我们需要其他操作时,就不得不修改代码,之后会越来越难以理解,复用困难。
而通过使用接口,程序里只需针对接口编程,而无需关心具体实现操作(通过XML配置项目包路径,反射)。当需要更换操作时,修改配置文件,使得程序稳定,易于复用。这就像现实中的插座一样,可以随时更替,随时插拔。
第二个好处是可以将依赖关系倒置。我们在讲依赖倒置时,正是通过面向接口编程,利用多态的特性去实现依赖的倒置的。
正是这两个好处,决定了多态就是面向对象编程的本质!
那么我们如何用好多态呢?
但是就算知道了面向对象编程的多态特性,也很难利用好多态的特性,开发出强大的面向对象程序。到底如何利用好多态特性呢?人们通过不断的编程实践,总结了一系列的设计原则和设计模式。
设计原则大部分都是和多态有关的,不过这些设计原则更多时候是具有指导性。编程的时候还需要依赖更具体的编程设计方法,这些方法就是设计模式。
模式是可重复的解决方案,人们在编程实践中发现,有些问题是重复出现的,虽然场景各有不同,但是问题的本质是一样的,而解决这些问题的方法也是可以重复使用的。人们把这些可以重复使用的编程方法称为设计模式。经典的设计模式一共有23种,MVC三层架构也能算是一种模式。而现今不论是框架还是一些库,几乎都大量使用了设计模式。大多数设计模式都是对多态的灵活运用,少部分专注于特殊的功能。所以,设计模式的精髓就是对多态的灵活应用。
框架是对某一类架构方案可复用的设计与实现。比如 Tomcat、Spring、Mybatis、Junit 等,这些框架会调用我们编写的代码,而我们编写的代码则会调用工具完成某些特定的功能,比如输出日志,进行正则表达式匹配等。
框架应该满足开闭原则,即面对不同应用场景,框架本身是不需要修改的。
同时框架还应该满足依赖倒置原则,即框架不应该依赖应用程序,因为开发框架的时候,应用程序还没有呢。应用程序也不应该依赖框架,这样应用程序可以灵活更换框架。框架和应用程序应该都依赖抽象。
前面我们提到 Tomcat 是一个框架,那么是代码如何被 Web 容器执行的?
Web 容器主要使用了策略模式的设计模式,多个策略实现同一个策略接口。编程的时候 Tomcat 依赖策略接口,而在运行期根据不同上下文,Tomcat 装载不同的策略实现。
这里的策略接口就是 Servlet 接口,而我们开发的代码就是实现这个 Servlet 接口,处理HTTP 请求。其实我们前面的加减法案例就是一种简单的策略模式,Tomcat策略模式类图如下:
Web 容器完成了 HTTP 请求处理的主要流程,指定了 Servlet 接口规范,实现了 Web 开发的主要架构,开发者只要在这个架构下开发具体 Servlet 就可以了。因此我们可以称Tomcat、Jetty 这类 Web 容器为框架。(更多模式应用可参考-Spring 中经典的 9 种设计模式)
首先,我们从软件的问题出发,根据软件的问题提出软件设计需要建模。
其次,又说到了统一建模语言-UML。
然后,我们从一个实际例子出发,描述了程序员一开始就应该考虑到程序的可拓展、可维护、可复用等问题。
之后,这些问题又可以统一为面向对象的设计目标——高内聚、低耦合。
而,面向对象的目标又可细化为面向对象的7大设计原则。
进而,提出了设计原则的运用——设计模式
最后,我们举例了框架中设计模式的例子,了解到框架就是设计原则和例子的最具体实现方案。引用导读中的图:
谢谢阅读!
参考资料
博文:
我曾想深入了解的:依赖倒置、控制反转、依赖注入
封装的基本概念-java核心技术卷1
教程:
后端技术面试38讲——李智慧
书籍:
设计模式实训教程(第2版)-合成复用、迪米特