当前位置: 首页 > 后端技术 > Java

Java设计模式最佳实践:一、从面向对象到函数式编程

时间:2023-04-01 20:24:08 Java

原创:Java协议中的设计模式与最佳实践:CCBY-NC-SA4.0撰稿人:飞龙本文来自【ApacheCNJava译文集】,使用翻译后编辑(MTPE)流程来最大限度地提高效率。本章的目的是向读者介绍使用设计模式和Java中可用的最新功能编写健壮、可维护和可扩展代码的基本概念。为了实现我们的目标,我们将讨论以下主题:什么是编程范式?命令式范式命令式和函数式范式面向对象范式统一建模语言概述面向对象设计原则Java简介1995年,受著名的C++和鲜为人知的SimultalTalk启发,一种新的编程语言发布了。Java是新语言的名称,它试图修复其前身的大部分限制。例如,Java流行的关键特性之一是您可以编写一次并在任何地方运行;也就是说,您可以在Windows机器上开发代码并在Linux或任何其他机器上运行它,无论您想要什么。所需要的只是一个JVM。它还提供了垃圾收集等附加功能,使开发人员无需维护内存分配和释放;即时编译器(JIT)使Java变得智能和快速,删除指针等特性使其更安全。所有上述特性和后来添加的Web支持使Java成为开发人员的热门选择。大约22年后,在新语言来来去去的世界里,Java10已经成功发布并得到社区的适配,足以说明Java的成功。Java编程范式什么是编程范式?自从软件开发开始以来,就出现了不同的编程语言设计方法。对于每一种编程语言,我们都有一套概念、原则和规则。这样的一组概念、原则和规则称为编程范式。理论上,语言被认为只属于一种范式,但实际上,编程范式大多组合在一种语言中。在下一节中,我们将重点介绍Java编程语言所基于的编程范例,以及描述这些范例的主要概念。它们是命令式、面向对象、声明式和函数式编程。命令式编程命令式编程是一种编程范式,其中编写语句来改变程序的状态。这个概念出现在计算机的早期,与计算机的内部结构非常接近。程序是在处理单元上运行的一组指令,这些指令以命令方式更改状态(作为变量存储在内存中)。名称命令意味着执行的指令决定了程序的行为方式。当今大多数最流行的编程语言都或多或少地基于命令式范式。主要命令式语言的最佳示例是C。基本现实生活示例为了更好地理解命令式编程范式的概念,让我们举以下示例:您在镇上遇到一位朋友参加黑客马拉松,但他不知道到那里怎么走。我们将向他解释如何以必要的方式到达那里:从中央车站乘坐电车。第三站下车。朝第六大道右转,直到第三个十字路口。面向对象范式面向对象范式通常与命令式编程相关联,但在实践中,函数式范式和面向对象范式可以共存。Java就是支持这种协作的活生生的例子。在下一节中,我们将简要介绍在Java语言中实现的主要面向对象概念。对象和类对象是面向对象编程(OOP)语言的主要元素。对象包含状态和行为。如果我们把类比作模板,那么对象就是模板的实现。例如,如果Human是一个定义了人可以具有的行为和属性的类,那么你我就是这个Human类的对象,因为我们已经具备了做人的所有条件。或者,如果我们将汽车视为一个类,那么特定的本田思域就是该类的一个对象。它将实现汽车的所有特性和行为,如发动机、方向盘、大灯等,并具有前进、后退等行为。我们可以看到面向对象范式如何连接到现实世界。现实世界中的几乎所有事物都可以用类和对象来思考,因此OOP变得简单和流行。面向对象程序设计基于四个基本原则:封装抽象继承多态性(子类型多态性)。封装封装基本上是属性和行为的绑定。这个想法是将对象的属性和行为保存在一个地方,以便于维护和扩展。封装还提供了一种机制来向用户隐藏不必要的细节。在Java中,我们可以为方法和属性提供访问说明符,以管理对类的用户可见和隐藏的内容。封装是面向对象语言的基本原则之一。它有助于不同模块的解耦。解耦模块可以或多或少地独立开发和维护。使用这种技术,可以在内部更改解耦的模块/类/代码而不影响其外部暴露的行为,这种技术称为代码重构。抽象抽象与封装密切相关,在某种程度上,它与封装有重叠。简而言之,抽象提供了一种机制,可以公开对象的作用并隐藏对象如何做它应该做的事情。一个真正抽象的例子是汽车。为了驾驶汽车,我们不需要知道汽车的引擎盖下是什么,但我们确实需要知道它向我们公开的数据和行为。数据显示在汽车的仪表盘上,行为由我们可以用来驾驶汽车的控件表示。继承继承是将一个对象或类基于另一个对象或类的能力。有一个父类或基类,它为实体提供顶级行为。每个满足成为父类一部分条件的子类实体或子类都可以从父类继承并根据需要添加额外的行为。让我们举一个真实的例子。如果我们将Vehicle视为父类,我们就知道Vehicle可以具有某些属性和行为。比如它有引擎、门等,它可以移动。现在,满足这些条件的所有实体,例如Car、Truck、Bike等,都可以从Vehicle继承并添加到给定的属性和行为之上。换句话说,我们可以说Car是Vehicle的子类。让我们看看这在代码中是如何工作的;我们将从创建一个名为Vehicle的基类开始。这个类有一个接受字符串(车辆名称)的构造函数:publicclassVehicle{privateStringname;publicVehicle(Stringname){this.name=name;}}现在我们可以使用构造函数创建一个Car类。Car类派生自Vehicle类,因此它继承并可以访问基类中声明为protected或public的所有成员和方法:publicclassCarextendsVehicle{publicCar(Stringname){super(name)}}从广义上讲,多态性为我们提供了为不同类型的实体使用相同接口的选项。多态性有两种主要类型:编译时和运行时。假设您有一个具有两个区域方法的Shape类。一个返回圆的面积,接受整数;也就是说,输入半径并返回面积。另一种方法计算矩形的面积并接受两个输入:长度和宽度。编译器可以根据调用中的参数个数来决定调用哪个区域方法。这是多态编译时类型。有一群技术人员认为只有运行时多态才是真正的多态。运行时多态性,有时称为子类型多态性,在子类继承自超类并覆盖其方法时起作用。在这种情况下,编译器无法决定最终是执行子类实现还是超类实现,所以决定是在运行时做出的。为了详细说明,让我们以前面的例子为Car类型添加一个新方法来打印对象的类型和名称:publicStringtoString(){return"Vehicle:"+name;}我们在派生的Car类中有Override同样的方法:publicStringtoString(){return"Car:"+name;}现在我们可以看到子类型多态性的作用。我们创建一个Vehicle对象和一个Car对象。我们将每个对象分配给一个Vehicle变量类型,因为Car也是Vehicle。然后我们为每个对象调用toString方法。对于作为Vehicle类实例的vehicle1,它将调用Vehicle.toString()类。Vehicle2是Car类的实例,调用Car类的toString方法:Vehiclevehicle1=newVehicle("AVehicle");车辆vehicle2=newCar("ACar")System.out.println(vehicle1.toString());System.out.println(vehicle2.toString());声明式编程让我们回到现实生活中的命令式示例,我们向朋友展示如何到达某个地方。当我们从声明式编程范例的角度思考时,我们可以简单地告诉他如何到达特定位置,而不是告诉他的朋友地址并让他知道如何到达那里。在这种情况下,我们告诉他该做什么,我们不关心他是否使用地图或GPS,或者他是否向某人问路:“上午9点30分在第5和第9大道的拐角处”。与命令式编程相反,声明式编程是一种编程范式,它指定程序应该做什么,而不是如何去做。纯声明式语言包括数据库查询语言,如SQL和XPath,以及正则表达式。声明式编程语言比命令式编程语言更抽象。它们不模仿硬件结构,因此,它们不是改变程序的状态,而是将程序转换到一个新的状态,更接近数学逻辑。一般而言,不可强制执行的编程风格被认为属于声明式类别。这就是为什么有许多类型的范例属于声明性类别。在我们的探索中,我们将看到与我们的旅程范围相关的唯一一个:函数式编程。函数式编程函数式编程是声明式编程的子范式。与命令式编程相反,函数式编程不会改变程序的内部状态。在命令式编程中,函数可以更多地视为指令、例程或过程的序列。它们不仅依赖于存储在内存中的状态,而且还可以改变该状态。这样,调用具有相同参数的命令函数可以根据当前程序的状态产生不同的结果,同时执行的函数可以改变程序的变量。在函数式编程术语中,函数类似于数学函数,因为函数的输出仅取决于其参数,而不管程序的状态如何,并且不受函数执行的影响。矛盾的是,虽然命令式编程自计算机诞生以来就已经存在,但函数式编程的基本概念却可以追溯到很久以前。大多数函数式语言都基于Lambda演算,这是数学家AlonzoChurch在1930年代创建的数学逻辑形式化系统。函数式语言在那个时代如此流行的原因之一是它们可以很容易地在并行环境中运行。这不应与多线程相混淆。允许函数式语言并行运行的主要属性是它们所依赖的基本原则:函数只依赖于输入参数,而不依赖于程序的状态。也就是说,它们可以在任何地方运行,然后可以将多个并行执行的结果串联起来进一步使用。使用集合与流每个使用Java的人都知道集合。我们以一种强制性的方式使用集合:我们告诉程序如何做它应该做的事情。让我们来看下面的例子,我们实例化一组10个整数,编号从1到10:Listlist=newArrayList();for(inti=0;i<10;i++){list.add(i);}现在,我们将创建另一个仅过滤奇数的集合:Listodds=newArrayList();for(intval:list){if(val%2==0)odds.add(val);}最后,我们要打印结果:for(intval:odds){System.out.print(val);}如你所见,我们写了执行三个基本操作的代码:创建数字集合、过滤奇数并打印结果。当然,我们可以在一个循环中完成所有操作,但是如果我们根本不使用循环呢?毕竟,使用循环意味着我们告诉程序如何完成它的任务。从Java8开始,我们可以使用流在一行代码中完成相同的任务:IntStream.range(0,10).filter(i->i%2==0).forEach(System.out::print);流在java.util.stream包中定义,用于管理可以执行功能操作的对象流。流是集合的功能对应物,为映射和归约操作提供支持。我们将在后面的章节中进一步讨论Java中的流和函数式编程支持。统一建模语言简介统一建模语言(UML)是一种建模语言,可帮助我们表示软件的结构、不同的模块、类和对象如何交互以及它们之间的关系。UML通常与面向对象设计结合使用,但其范围更广。然而,这超出了本书的范围,因此在下一节中,我们将重点关注与本书相关的UML特性。在UML中,我们可以定义系统的结构和行为,我们可以通过图表可视化模型或模型的各个部分。有两种类型的图:结构图用于表示系统的结构。结构图有很多种,但我们只对类图感兴趣。对象图、包图和组件图类似于类图。行为图用于描述系统的行为。交互图是行为图的子集,用于描述系统不同组件之间的控制和数据流。在行为图中,序列图广泛用于面向对象的设计中。类图是面向对象设计和开发阶段使用最多的图。它们是一种结构图,用于说明类的结构和它们之间的关系:类图对于描述类在应用程序中的结构非常有用。大多数时候,仅仅查看结构就足以理解类是如何交互的,但有时这还不够。对于这些情况,我们可以使用行为图和交互图,其中序列图用于描述类和对象的交互。让我们用一个序列图来展示Car和Vehicle对象在继承和多态的例子中是如何交互的:IS-A关系,因为从另一个类继承的类可以用作超类。当一个类表示多个类的共同特征时,称为泛化;例如,车辆是自行车、汽车、卡车的概括。同样,当一个类表示一个通用类的特殊实例时,它被称为特化,所以汽车是车辆的特化,如下图所示:在UML术语中,描述继承的关系称为泛化。实现如果泛化是UML中面向对象继承的对应术语,那么在UML中,实现就是面向对象编程中类对接口的实现。假设我们创建了一个名为Lockable的接口,它仅由可锁定的Cars实现。在此示例中,上图的一个版本为Car类实现了Lockable:依赖关系依赖关系是UML中最常见的关系类型之一。它用于定义一个类以某种方式依赖于另一个类,而另一个类可能依赖于也可能不依赖于第一个类。从属关系用于指示不属于以下部分中描述的情况之一的关系。依赖关系有时称为USES-A关系。通常,在面向对象的编程语言中,依赖关系用于描述一个类是否在方法签名中包含第二个类的参数,或者是否通过将第二个类的实例传递给其他类而不使用它们(不调用它的方法)来创建第二个类的实例:Association关联表示两个实体之间的关系。有两种类型的关联,组合和聚合。通常,关联用箭头表示,如下图所示:Aggregation聚合是一种特殊类型的关联。如果继承被认为是一种IS-A关系,那么聚合可以被认为是一种HAS-A关系。聚合用于描述两个或多个类之间的关系。从逻辑的角度来看,一个类包含另一个类,但包含的类的实例可以独立于第一个类,在其上下文之外,也可以在其他类之间共享。例如,一所大学有一位老师;另外,每个老师必须属于学院,但是如果学院不存在,一个老师仍然可以活跃,如下图所示:Composite顾名思义,一个班级是另一个班级的复合班级。这有点类似于聚合,只不过当主类不存在时,依赖类也不存在。例如一个房子是由房间组成的,但是当房子被摧毁时房间就不存在了,如下图所示:在实践中,尤其是像Java这样有垃圾收集器的语言,组合和聚合之间的界限是不存在的很好的定义。对象不会被手动销毁;当它们不再被引用时,它们会被垃圾收集器自动销毁。因此,从编码的角度来看,我们不应该真正关心我们是在处理组合关系还是聚合关系,但如果我们想在UML中有一个定义良好的模型,这很重要。设计模式和原则软件开发是一个超越编写代码的过程,无论您是在大型团队中工作还是在一个人的项目中工作。应用程序的结构方式对软件应用程序的成功具有巨大影响。当我们谈论一个成功的软件应用程序时,我们不仅在谈论该应用程序如何完成它应该做的事情,而且还在谈论我们为开发它付出了多少努力,以及它是否易于测试和维护。如果没有以正确的方式完成,暴涨的开发成本将导致没有人想要的应用程序。创建软件应用程序是为了满足不断变化和不断发展的需求。一个成功的应用程序还应该提供一种简单的方法来扩展它以满足不断变化的期望。幸运的是,我们并不是第一个遇到这些问题的人。有些问题已经面对和处理了。这些常见问题可以通过在软件设计和开发过程中应用一套面向对象的设计原则和模式来避免或解决。面向对象的设计原则也称为实体。这些原则是一组可以在设计和开发软件时应用的规则,以便创建易于维护和开发的程序。它们最初由robertc.Martin作为敏捷软件开发过程的一部分引入。实质性原则包括单一职责原则、开闭原则、里氏替换原则、接口分离原则和依赖倒置原则。除了设计原则,还有面向对象的设计模式。设计模式是可应用于常见问题的通用可重用解决方案。遵循ChristopherAlexander的概念,设计模式首先由KentBeck和WardCunningham应用于编程,并于1994年通过所谓的四人帮(GOF)一书得到普及。在下一节中,我们将介绍可靠的设计原则,接下来的章节中将遵循这些设计模式。单一职责原则单一职责原则是一种面向对象的设计原则,指出软件模块只有一个更改原因。在大多数情况下,我们在编写Java代码时将其应用于类。单一职责原则可以被认为是使封装发挥最佳作用的良好实践。更改的原因是需要触发代码更改。如果一个类受到多个变化原因的影响,则每个原因都可能引入影响其他原因的变化。当这些更改单独管理但影响同一模块时,一组更改可能会破坏与其他更改原因相关的功能。另一方面,每一个改变的责任/原因都会增加新的依赖性,使代码不那么健壮并且更难改变。在我们的示例中,我们将使用数据库来持久化对象。假设Car类增加了处理create,read,update,delete等数据库操作的方法,如下图所示:在这种情况下,Car不仅会封装逻辑,还会封装数据库操作(两者的职责是两个改变的原因)。这将使我们的类更难维护和测试,因为代码是紧密耦合的。Car类会依赖于数据库,所以以后如果要改数据库系统,就得改Car的代码。这可能会在Car逻辑中产生错误。相反,改变Car的逻辑可能会产生数据持久化的bug。解决方案是创建两个类:一个封装Car逻辑,另一个负责持久化:On/Offprinciple这个原则如下:模块、类和函数应该对扩展开放,对修改关闭应用这个原则会有帮助我们开发复杂而强大的软件。我们必须想象我们开发的软件正在构建一个复杂的结构。一旦我们完成了它的一部分,我们不应该修改它,而是在它的基础上进行构建。开发软件时也是如此。一旦我们开发并测试了一个模块,如果我们想改变它,我们不仅要测试我们正在改变的功能,还要测试它负责的整个功能。这涉及大量的额外资源,这些资源最初可能没有被估计,而且还可能带来额外的风险。一个模块的更改可能会影响其他模块或整个模块的功能。下面是一个例子:因此,最好的做法是在模块完成后保持不变,并通过使用继承和多态扩展模块来添加新的功能。开闭原则是最重要的设计原则之一,也是大多数设计模式的基础。Liskov替换原则barbaraliskov指出派生类型必须完全可替换其基类型。Liskov替换原则(LSP)与亚型多态性密切相关。基于面向对象语言中的子类型多态性,派生对象可以用其父类型替换。例如,如果我们有一个Car对象,它可以在代码中用作Vehicle。LSP指出,在设计模块和类时,我们必须确保派生类型从行为的角度来看是可替代的。当派生类型被其父类型替换时,其余代码将其作为子类型进行操作。从这个角度来看,派生类型的行为应该与其父类型相同,并且不应破坏其行为。这被称为强迫行为亚型。为了理解LSP,我们举一个违反原则的例子。在开发汽车服务软件时,我们发现需要对以下场景进行建模。当汽车需要维修时,车主会离开汽车。服务助理拿着钥匙,当车主离开时,他去检查他是否有正确的钥匙并找到正确的汽车。他只需打开车门并将钥匙放在指定位置并附上便条,以便机械师在检查汽车时可以轻松取回。我们定义了一个Car类。我们现在创建一个Key类并向Car类添加两个方法:lock和unlock。我们为助手添加了一个方法来检查钥匙是否与汽车匹配:!错误的钥匙,错误的汽车或车锁坏了!");这是原理图:在使用我们的软件时,我们意识到有时汽车服务会修理汽车。由于汽车是四轮车,我们创建了一个继承自Car的Buggy类:四轮马车没有车门,所以不能上锁和解锁。我们相应地实现我们的代码:publicboollock(Keykey){//这是一个bug,所以它不能被锁定returnfalse;}我们设计的软件可以在汽车上工作,不管它们是小的还是小的,所以在未来我们可能会将其扩展到其他类型的汽车。一个问题可能是因为汽车需要上锁和解锁。接口分离原则中的以下引用来自此页面:“不应强迫客户依赖于他们不使用的接口。”在应用时,接口分离原则(ISP)减少了代码耦合,使软件更健壮,更易于维护和扩展。ISP最初是由罗伯特马丁宣布的,当时他意识到如果这个原则被打破并且客户端被迫依赖于他们不使用的接口,那么代码就会变得紧密耦合并且几乎不可能向它添加新功能。为了更好地理解这一点,让我们再次以汽车服务为例(见下图)。现在我们需要实现一个名为Mechanic的类。机械师修理汽车,所以我们添加了一种修理汽车的方法。在这种情况下,Mechanic类依赖于Car类。然而,Car类比Mechanic类需要更多的方法:这是一个糟糕的设计,因为如果我们想用另一辆车替换一辆车,我们需要在Mechanic类中进行更改,这违反了开/关原则。相反,我们必须创建一个接口,只暴露Mechanic类中需要的相关方法,如下图所示:依赖倒置原则“高层模块不应该依赖低层模块。两者都应该依赖抽象。”“抽象不应该依赖于细节。细节应该依赖于抽象。”要理解这个原理,就必须解释耦合和解耦的重要概念,耦合是指软件系统的模块相互依赖的程度。依赖性越低,越容易维护和扩展系统。有不同的方法来解耦系统的组件。其中之一是将高层逻辑与低层模块分开,如下图所示。这样做时,我们应该通过使它们依赖于抽象来减少两者之间的依赖性。这样,可以替换或扩展这些模块中的任何一个,而不会影响其他模块:总结在本章中,我们介绍了Java中使用的主要编程范式。我们已经看到两种不同的范式,例如命令式编程和函数式编程,可以共存于同一种语言中;我们还看到了Java如何从纯粹的命令式面向对象编程发展到集成函数式编程的元素。尽管Java从版本8开始引入了新的功能元素,但它的核心仍然是一门面向对象的语言。为了编写易于扩展和维护的可靠且健壮的代码,我们学习了面向对象编程语言的基础知识。开发软件的一个重要部分是设计程序组件的结构和期望的行为。这样,我们就可以在大型系统上工作,在大型团队中工作,并在团队内部或团队之间共享我们的面向对象设计。为此,我们重点介绍了与面向对象设计和编程相关的主要UML图和概念。我们还在书中广泛使用UML来描述这些示例。在介绍了类关系并展示了如何在图表中表示它们之后,我们继续下一节,描述什么是面向对象的设计模式和原则,并介绍主要原则。在下一章中,我们将继续介绍一组处理对象创建的设计模式,使我们的代码健壮且可扩展。