这段时间基于极客时间的《设计模式之美》重新学习了下面向对象设计思想,收获认知提升颇多。这里对这一阶段的面向对象设计思想的学习和重点进行一个小结。
面向对象设计思想博客目录
话不多说,本篇Blog的意图是对之前的七篇设计思想学习型Blog做一个重点归纳,从我的角度出发,看看深入的收获有哪些:
基于以上文章划分的脉络如下:
面向对象设计思想重点内容
将各个Blog中认为认知得到加深的内容总结一下。
封装、抽象、继承、多态四大特性
以下内容为我认为认知得到加深的内容:
什么是面向对象编程(OOP),什么是面向对象编程语言
- 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
- 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
面向对象编程一般使用面向对象编程语言来进行,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的
如果按照严格的的定义,需要有现成的语法支持类、对象、四大特性才能叫作面向对象编程语言。如果放宽要求的话,只要某种编程语言支持类、对象语法机制,那基本上就可以说这种编程语言是面向对象编程语言了,不一定非得要求具有所有的四大特性
封装的定义是什么,能够解决什么问题
封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。
封装可以提升代码的安全性和易用性,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。
抽象的定义是什么,能够解决什么问题
抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的,在面向对象编程中,我们常借助编程语言提供的接口类(比如 Java 中的 interface 关键字语法)或者抽象类(比如 Java 中的 abstract 关键字语法)这两种语法机制,来实现抽象这一特性。
抽象可以为我们提升代码的可扩展性、维护性、过滤非必要信息,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。需要注意的是,其实单纯的函数也提供抽象的特性,所以面向过程也支持基于单纯函数的抽象。
继承的定义是什么,能够解决什么问题
继承是用来表示类之间的 is-a
关系,从继承关系上来讲,继承可以分为两种模式,单继承和多继承。我们更多的使用单继承来避免二义性和菱形继承问题。
继承可以提高代码的可复用性
多态的定义是什么,能够解决什么问题
多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态有三种实现方式:继承、接口类以及duck-typing。Java支持前两种。前两种方式实现多态的语法机制如下,必须满足:
- 第一个语法机制是编程语言要支持父类对象可以引用子类对象(或者接口可以引用实现)
- 第二个语法机制是编程语言要支持继承(或者支持实现)
- 第三个语法机制是编程语言要支持子类可以重写(override)父类中的方法(实现类实现接口的方法)
而duck-typing比较简单,只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的 duck-typing,是一些动态语言所特有的语法机制。
多态特性能提高代码的可扩展性和可复用性,有新的实现方式不必对主流程原有执行逻辑进行扩展,而只需编写一个扩展类,作为参数传入原有执行逻辑即可,提升了代码的可扩展性;同时原有执行逻辑也可以作为各个扩展类的通用实现,提升了代码的可复用性
封装、抽象、继承、多态的意义何在
回顾设计模式目标,写出高质量的代码:易维护、易读、易扩展、灵活、简洁、可复用、可测试,面向对象设计思想是基本指导思想,是很多设计原则、设计模式的实现基础,后者进一步支持高质量代码目标达成。而面向对象四大特性本来被设计出来也能一定意义上本身作为一个达成路径,例如封装,可以让数据更安全,不能被随便修改,让代码更易维护;通用的抽象影响无处不在,抽象的代码设计让代码易扩展、易维护;继承让代码更加可复用;多态让代码更易扩展、易复用。它们的作用远不止这些,这些顶层的语言特性抽象,一种极致抽象的设计范式,某种意义上决定了基于此的设计原则、设计模式等能够方便的实现
面向对象和面向过程
以下内容为我认为认知得到加深的内容:
面向对象编程和面向过程编程的区别,以及面向对象编程语言和面向过程编程语言的区别
什么是面向对象与面向对象编程语言?
- 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
- 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
类比面向对象编程与面向对象编程语言的定义,面向过程编程和面向过程编程语言:
- 面向过程编程也是一种编程范式或编程风格。它以**过程(可以理解为方法、函数、操作)**作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。
- 面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如继承、多态、封装),仅支持面向过程编程。
面向过程和面向对象最基本的区别就是,代码的组织方式不同。
面向对象编程相比面向过程编程有哪些优势?
- OOP 更加能够应对大规模复杂程序的开发。对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象提供了一种网状复杂业务开发的高效开发方式以及一种更加清晰的、更加模块化的代码组织方式
- OOP 风格的代码更易复用、易扩展、易维护,面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。
- OOP 语言更加人性化、更加高级、更加智能,从编程语言跟机器打交道的方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。
越高级的思考方式越将我们推到业务建模一端。
哪些是我们用面向对象语言写的面向过程代码
- 滥用 getter、setter 方法,破坏了面向对象的封装特性,在设计实现类的时候,除非真的需要,否则,尽量不要给属性定义 setter 方法。除此之外,尽管 getter 方法相对 setter 方法要安全些,但是如果返回的是集合容器(比如例子中的 List 容器),也要防范集合内部数据被修改的危险
- Constants 类、Utils 类的设计问题,也就是全局变量和全局方法的使用问题,对于这两种类的设计,我们尽量能做到职责单一,定义一些细化的小类,比如 RedisConstants、FileUtils,而不是定义一个大而全的 Constants 类、Utils 类。除此之外,如果能将这些类中的属性和方法,划分归并到其他业务类中,那是最好不过的了,能极大地提高类的内聚性和代码的可复用性。
- 基于贫血模型的开发模式,数据定义在一个类中,方法定义在另一个类中
面向对象和面向过程需要辩证的看待,只要是服务于我们的目标:写出易维护、易读、易复用、易扩展的高质量代码,面向过程我们也不是完全排斥,最好能为我所用,谈概念和优势的时候一定要关联使用场景,没有最好,只有最合适。
抽象类和接口
以下内容为我认为认知得到加深的内容:
什么是抽象类,抽象类有哪些特性
抽象类是种特殊的类,抽象类不允许被实例化,只能被继承;抽象类可以包含属性和方法;非抽象子类继承抽象类,必须实现抽象类中的所有抽象方法。抽象类更像是介于普通类和接口之间的一种结合体
什么是接口,接口有哪些特性
接口不能包含属性(也就是成员变量);接口只能声明方法,方法不能包含代码实现(即使1.8之后有默认方法default,但也并非接口设计的本意,只是为了兼容考虑);类实现接口的时候,必须实现接口中声明的所有方法
为什么要有抽象类,和普通类的区别是什么
抽象类与普通类相比可以,去掉父类无意义的方法实现、优雅的实现多态,以及保留继承的代码复用能力。我们为什么要设计抽象类呢?结合抽象类的特性,它必须被子类继承才能实例化,所以要想使用抽象类一定会用到继承的特性,而且抽象类可以包含属性和方法。所以可以解决代码复用的问题;子类要想实例化必须实现抽象方法,这样其实就起到一个强制子类都实现抽象方法的作用,如果某个类想管理强制所有子类都实现某个方法,并且父类不需要实现(去掉父类无意义的方法实现),那么这个类定义成抽象类,方法定义为抽象方法就能很方便的起到这个强制实现的作用,防止编写子类代码时忘记实现这个方法,所以优雅的实现了多态的特性。
抽象类和接口的区别是什么,什么时候用抽象类、什么时候用接口
- 从语义上来看:继承关系是一种 is-a 的关系,那抽象类既然属于类,也表示一种 is-a 的关系。相对于抽象类的 is-a 关系来说,接口表示一种 has-a 关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)
- 从用途上来看:抽象类的主要作用是代码复用,接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性,主要作用是解耦
如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那我们就用接口。
基于接口而非实现编程
以下内容为我认为认知得到加深的内容:
什么是基于接口而非实现编程
实际上,基于接口而非实现编程这条原则的另一个表述方式,是基于抽象而非实现编程,也就是抽象特性。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一
基于接口而非实现编程有什么实现路径
我们要为实现类定义接口,同时我们在定义接口的时候,一方面,命名要足够通用,不能包含跟具体实现相关的字眼;另一方面,与特定实现有关的方法不要定义在接口中,而是封装到具体的实现中
容易陷入的思维误区有哪些
- 通过实现类反推接口定义,如果按照这种思考方式,就有可能导致接口定义不够抽象,依赖具体的实现。这样的接口设计就没有意义了,如果这样思考思维更顺畅,也要考虑将实现类的方法搬移到接口定义中的时候,要有选择性的搬移,不要将跟具体实现相关的方法搬移到接口中
- 为每个实现类都定义接口,这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间
多用组合少用继承编程
以下内容为我认为认知得到加深的内容:
为什么不推荐使用继承
继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可读性和可维护性。
- 一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。
- 另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合, 代码的可维护性变差,一旦父类代码修改,就会影响所有子类的逻辑。
总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性
组合相比继承有哪些优势
继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成(接口定义行为特性实现多态,组合+委托实现代码复用)。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。
如何判断该用组合还是继承
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合,从继承层级和结构上来看
- 如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。
- 如果系统不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承
从使用场景来看: 如果两个类之间并没有业务含义上的父子关系,而仅仅是为了代码复用,就没必要强行使用继承,使用组合更灵活
除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。
MVC贫血模式与DDD领域驱动开发
以下内容为我认为认知得到加深的内容:
MVC贫血模式与DDD领域驱动开发充血模式区别是什么
传统的MVC后端开发中一般包括三层:Repository 层、Service 层、Controller 层,Repository 层负责数据访问,Service 层负责业务逻辑,Controller 层负责暴露接口。
基于充血模型的 DDD 开发模式实现的代码,也是按照 MVC 三层架构分层的。Controller 层还是负责暴露接口,Repository 层还是负责数据存取,Service 层负责核心业务逻辑。它跟基于贫血模型的传统开发模式的区别主要在 Service 层:
- 在基于贫血模型的传统开发模式中,Service 层包含 Service 类和 BO 类两部分,BO 是贫血模型,只包含数据,不包含具体的业务逻辑。业务逻辑集中在 Service 类中
- 在基于充血模型的 DDD 开发模式中,Service 层包含 Service 类和 Domain 类两部分。Domain 就相当于贫血模型中的 BO。不过,Domain 与 BO 的区别在于它是基于充血模型开发的,既包含数据,也包含业务逻辑。而 Service 类变得非常单薄
总结一下的话就是,基于贫血模型的传统的开发模式,重 Service 轻 BO;基于充血模型的 DDD 开发模式,轻 Service 重 Domain,基于贫血模型的传统开发模式,是典型的面向过程的编程风格。相反,基于充血模型的 DDD 开发模式,是典型的面向对象的编程风格
传统MVC架构贫血模式流行的原因
基于贫血模型的传统开发模式,将数据与业务逻辑分离,违反了 OOP 的封装特性,实际上是一种面向过程的编程风格。这种风格一个问题就是不能提供面向对象的封装特性,比如,数据和操作分离之后,数据本身的操作就不受限制了。任何代码都可以随意修改数据
- 大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于 SQL 的 CRUD 操作,所以,根本不需要动脑子精心设计充血模型,贫血模型就足以应付这种简单业务的开发工作。除此之外,因为业务比较简单,即便我们使用充血模型,那模型本身包含的业务逻辑也并不会很多,设计出来的领域模型也会比较单薄,跟贫血模型差不多,没有太大意义
- 充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在 Service 层定义什么操作,不需要事先做太多设计。
- 思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。在没有遇到开发痛点的情况下,我们是不愿意也没必要做这件事情的。
落实到开发模式,我们平时的开发,大部分都是 SQL 驱动(SQL-Driven)的开发模式,接到需求也是从表往上去单线思考。
什么情况下更推荐使用充血模式?DDD的优势?
DDD从代码上的区别很小,就是对Service层进行重新设计并将BO升级为Domain,但是从设计和思维模式上区别很大,它是一套自底向上的面向对象思维方式和开发流程,能让我们从业务建模的视角去看问题。同时要用辩证的看待贫血和充血,无论是哪种模式都是基于具体业务实现去实践
- 从可读性上来说:它可以把原来最重的service逻辑拆分并且转移一部分逻辑,可以使得代码可读性略微提高,另外,模型充血以后基于模型的业务抽象在不断的迭代之后会越来越明确,业务的细节会越来越精准,通过阅读模型的充血行为代码,能够极快的了解系统的业务,对于开发来说能说明显的提升开发效率。
- 从可维护性上来说:如果项目新进了开发人员,如果是贫血模型的service代码,无论代码如何清晰,注释如何完备,代码结构设计得如何优雅,都没有办法第一时间理解系统的核心业务逻辑,但是如果是充血模型,直接阅读充血模型的行为方法,起码能够很快理解70%左右的业务逻辑,因为充血模型是业务的精准抽象
基于充血模型的 DDD 开发模式,更适合业务复杂的系统开发,越复杂的系统,对代码的复用性、易维护性要求就越高,我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的 DDD 开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发
DDD充血模式中Service类的职责
在基于充血模型的 DDD 开发模式中,将业务逻辑移动到 Domain 中,Service 类变得很薄,但在我们的代码设计与实现中,并没有完全将 Service 类去掉,它还是有一定作用的
- Service 类负责与 Repository 交流。之所以让 Service 类与 Repository 打交道,而不是让领域模型 与 Repository 打交道,那是因为我们想保持领域模型的独立性,不与任何其他层的代码(Repository 层的代码)或开发框架(比如 Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从 DB 中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用
- Service 类负责跨领域模型的业务聚合功能。当一个功能需要跨领域对象操作时,就需要写到Service中了
- Service 类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中
所以即使在充血模式中Service也是有必要的,只不过更薄,和业务实体隔离
Controller层和Repository 为何不充血设计
Controller 层主要负责接口的暴露,Repository 层主要负责与数据库打交道,这两层包含的业务逻辑并不多,前面我们也提到了,如果业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄,看起来也很奇怪,实际上即使是面向过程的方式,只要做好控制,也可以:
- 对 Repository 的 Entity 来说,即便它被设计成贫血模型,违反面向对象编程的封装特性,有被任意代码修改数据的风险,但 Entity 的生命周期是有限的。一般来讲,我们把它传递到 Service 层之后,就会转化成 BO 或者 Domain 来继续后面的业务逻辑。Entity 的生命周期到此就结束了,所以也并不会被到处任意修改
- Controller 层的 VO。实际上 VO 是一种 DTO(Data Transfer Object,数据传输对象)。它主要是作为接口的数据传输承载体,将数据发送给其他系统。从功能上来讲,它理应不包含业务逻辑、只包含数据,而且VO的生命周期也有限,只在业务逻辑完成后被转为VO返回而已,也并不会被到处任意修改
所以综上所述,只有业务层足够复杂时,被充血建模就非常合适。
面向对象分析、面向对象设计和面向对象编程
以下内容为我认为认知得到加深的内容:
面向对象分析主要做什么?如何做面向对象分析?
面向对象分析主要工作是:将笼统的需求细化到足够清晰、可执行。通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的,做合理取舍、权衡、假设,把抽象的问题具象化,最终产生清晰的、可落地的需求定义。那么如何做面向对象分析呢?
需求分析的过程实际上是一个不断迭代优化的过程。不要试图一下就能给出一个完美的解决方案,而是先给出一个粗糙的、基础的方案,有一个迭代的基础,然后再慢慢优化,不断的提出问题-解决问题。
面向对象设计主要做什么?如何做面向对象设计?
面向对象设计的产出是类。将面向对象分析产出的需求描述转化为具体的类的设计
- 划分职责进而识别出有哪些类: 根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,是否应该归为同一个类
- 定义类及其属性和方法: 需求描述中的动词,作为候选的方法,再进一步过滤筛选出真正的方法,把功能点中涉及的名词,作为候选属性,然后同样再进行过滤筛选
- 定义类与类之间的交互关系: UML 统一建模语言中定义了六种类之间的关系:泛化、实现、关联、聚合、组合、依赖。从更加贴近编程的角度,对类与类之间的关系做了调整,保留四个关系:泛化、实现、组合(关联,包含UML中的组合和聚合)、依赖
- 将类组装起来并提供执行入口: 将所有的类组装在一起,提供一个执行入口。这个入口可能是一个 main() 函数,也可能是一组给外部用的 API 接口。通过这个入口触发整个代码跑起来
虽然限于MVC的开发模式框架之下,能做的面向对象分析和设计有限,但这提供了一种思考方式:细化需求(通过提出问题-解决问题),将需求描述映射为代码。
总结一下
说到面向对象设计思想,看似每个学习Java语言的人入门时都知道,类、对象等概念也耳熟能详,但是每个人对面向对象这种设计思想理解是千差万别的,就拿我来说,16年读研时初一学习Java时只知道:面向对象开发有类、对象、成员变量、方法等,学会了基本语法,然后死记硬背会了抽象类和接口的区别、类之间的关系,根本不清楚为啥有抽象类,它和接口到底啥区别,为啥有普通类了还要它?甚至说多态是啥意思都没能深入理解,以为多态就是接口。18年初刚一工作的时候想学习时新技术,去学DDD,结果只背会了一堆概念,因为没有工作实践过,写了一篇MVC和DDD对比实际上DDD都没有用过,都是理论上的条条框框罢了,理解都在皮毛。20年重新找工作的时候结合两年的工作经验再加上理论才对面向对象设计思想有了一些粗浅的认知,直至今日,学习了《设计模式之美》这认知方才变得深刻,方知,知识是需要反复咀嚼的,而且纸上得来终觉浅,绝知此事要躬行,认知是伴随着实践逐步加深的。以后说不定接触认知了新的概念和实践,这理解会更加深刻。同时对设计思想的理解其应用可在代码实践而又不惟代码实践。它也是一种好的开阔思路,训练思维的方式。