原文:Design Patterns and Best Practices in Java
协议:CC BY-NC-SA 4.0
译者:飞龙
本文来自【ApacheCN Java 译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。
本章的目的是向读者介绍使用设计模式和 Java 中可用的最新特性编写健壮、可维护和可扩展代码的基本概念。为了实现我们的目标,我们将讨论以下主题:
在 1995,一个新的编程语言被释放,灵感来自于著名的 C++ 和鲜为人知的 SimultalTalk。Java 是这门新语言的名字,它试图修复它的前身所具有的大部分局限性。例如,Java 的一个重要特性使它流行起来,那就是只需编写一次就可以在任何地方运行;也就是说,您可以在 Windows 机器上开发代码,然后在 Linux 或任何其他机器上运行,您所需要的只是一个 JVM。它还提供了垃圾收集等附加功能,使开发人员无需维护内存分配和释放;即时编译器(JIT)使 Java 智能化和快速化,删除指针等功能使其更加安全。前面提到的所有特性以及后来添加的 Web 支持使 Java 成为开发人员的热门选择。大约 22 年后,在一个新语言来去匆匆的世界里,java 10 已经成功地被社区发布和改编,这充分说明了 Java 的成功。
什么是编程范式?自从软件开发开始,就有不同的方法来设计编程语言。对于每种编程语言,我们都有一套概念、原则和规则。这样一组概念、原则和规则称为编程范式。在理论上,语言被认为只属于一种范式,但在实践中,编程范式大多是在一种语言中结合起来的。
在下一节中,我们将重点介绍 Java 编程语言所基于的编程范式,以及描述这些范式的主要概念。它们是命令式、面向对象、声明式和函数式编程。
命令式编程是一种编程范式,在这种范式中,编写语句来改变程序的状态。这个概念出现在计算机的初期,非常接近计算机的内部结构。程序是在处理单元上运行的一组指令,它以命令式的方式改变状态(作为变量存储在内存中)。名称命令意味着执行的指令决定了程序的运行方式。
今天大多数最流行的编程语言都或多或少地基于命令式范式。主要是命令式语言的最好例子是 C。
为了更好地理解命令式编程范式的概念,让我们举下面的例子:你在你的镇上和一个朋友见面,参加一个黑客竞赛,但他不知道如何去那里。我们将以一种必要的方式向他解释如何到达那里:
面向对象的范例通常与命令式编程相关联,但是在实践中,函数式范例和面向对象范例可以共存。Java 就是支持这种协作的活生生的证明。
在下一节中,我们将简要介绍主要的面向对象概念,因为它们是用 Java 语言实现的。
对象是面向对象编程(OOP)语言的主要元素。对象同时包含状态和行为。
如果我们把类看作模板,那么对象就是模板的实现。例如,如果Human
是一个类,它定义了一个人可以拥有的行为和属性,那么你和我就是这个Human
类的对象,因为我们已经满足了作为一个人的所有要求。或者,如果我们认为汽车是一个阶级,一个特定的本田思域汽车将是这个阶级的对象。它将实现汽车的所有特性和行为,如发动机、方向盘、前照灯等,并具有前进、后退等行为。我们可以看到面向对象的范例是如何与现实世界联系在一起的。现实世界中几乎所有的东西都可以用类和对象来考虑,因此 OOP 变得轻松和流行。
面向对象编程基于四个基本原则:
封装基本上就是属性和行为的绑定。其思想是将对象的属性和行为保持在一个位置,以便易于维护和扩展。封装还提供了一种向用户隐藏不必要细节的机制。在 Java 中,我们可以为方法和属性提供访问说明符,以管理类用户可见的内容和隐藏的内容。
封装是面向对象语言的基本原则之一。它有助于不同模块的解耦。解耦模块可以或多或少地独立开发和维护。通过这种技术,解耦的模块/类/代码可以在不影响其外部公开行为的情况下进行内部更改,这种技术称为代码重构。
抽象与封装密切相关,在某种程度上,它与封装重叠。简单地说,抽象提供了一种机制,它公开了一个对象做什么,隐藏了这个对象如何做它应该做的事情。
一个真实的抽象例子是汽车。为了驾驶一辆汽车,我们并不需要知道汽车引擎盖下有什么,但我们需要知道它暴露给我们的数据和行为。数据显示在汽车的仪表板上,行为由我们可以用来驾驶汽车的控件表示。
继承是将一个对象或类基于另一个对象或类的能力。有一个父类或基类,它为实体提供顶级行为。满足作为父类一部分的条件的每个子类实体或子类都可以从父类继承,并根据需要添加其他行为。
让我们举一个真实的例子。如果我们把Vehicle
看作父类,我们就知道Vehicle
可以有某些属性和行为。例如,它有一个引擎、门等等,而且它可以移动。现在,所有满足这些标准的实体,例如,Car
、Truck
、Bike
等等,都可以从Vehicle
继承并添加到给定的属性和行为之上。换句话说,我们可以说Car
是Vehicle
的子类。
让我们看看这将如何作为代码;我们将首先创建一个名为Vehicle
的基类。该类有一个构造器,它接受一个String
(车辆名称):
public class Vehicle
{
private Stringname;
public Vehicle(Stringname)
{
this.name=name;
}
}
现在我们可以用构造器创建一个Car
类。Car
类派生自Vehicle
类,因此继承并可以访问基类中声明为protected
或public
的所有成员和方法:
public class Car extends Vehicle
{
public Car(String name)
{
super(name)
}
}
广义地说,多态为我们提供了一种选择,可以为不同类型的实体使用相同的接口。多态有两种主要类型:编译时和运行时。假设你有一个Shape
类,它有两个区域方法。一个返回圆的面积,它接受一个整数;也就是说,输入半径,它返回面积。另一种方法计算矩形的面积,并采用两种输入:长度和宽度。编译器可以根据调用中参数的数量来决定调用哪个area
方法。这是多态的编译时类型。
有一群技术人员认为只有运行时多态才是真正的多态。运行时多态,有时也称为子类型多态,在子类继承超类并覆盖其方法时起作用。在这种情况下,编译器无法决定最终是执行子类实现还是执行超类实现,因此在运行时做出决定。
为了详细说明,让我们以前面的示例为例,向汽车类型添加一个新方法来打印对象的类型和名称:
public String toString()
{
return "Vehicle:"+name;
}
我们在派生的Car
类中覆盖相同的方法:
public String toString()
{
return "Car:"+name;
}
现在我们可以看到子类型多态在起作用。我们创建一个Vehicle
对象和一个Car
对象。我们将每个对象分配给一个Vehicle
变量类型,因为一个Car
也是一个Vehicle
。然后我们为每个对象调用toString
方法。对于vehicle1
,它是Vehicle
类的一个实例,它将调用Vehicle.toString()
类。vehicle2
是Car
类的实例,调用Car
类的toString
方法:
Vehicle vehicle1 = new Vehicle("A Vehicle");
Vehicle vehicle2 = new Car("A Car")
System.out.println(vehicle1.toString());
System.out.println(vehicle2.toString());
让我们回到现实生活中的祈使式示例,在这个示例中,我们向朋友指示如何到达一个地方。当我们按照声明式编程范式思考时,我们可以简单地给他地址,让他知道如何到达那里,而不是告诉我们的朋友如何到达特定的位置。在这种情况下,我们告诉他该怎么做,而我们并不关心他是否使用地图或 GPS,或者他是否向某人请示:“早上 9:30 在第五大道和第九大道的交界处”。
与命令式编程相反,声明式编程是一种编程范式,它指定程序应该做什么,而不指定如何做。纯声明性语言包括数据库查询语言,如 SQL 和 XPath,以及正则表达式。
声明式编程语言比命令式编程语言更抽象。它们不模仿硬件结构,因此,它们不改变程序的状态,而是将程序转换为新的状态,更接近于数学逻辑。
一般来说,非强制性的编程风格被认为属于声明性的范畴。这就是为什么有许多类型的范式属于声明性范畴。在我们的探索中,我们将看到与我们的旅程范围相关的唯一一个:函数式编程。
函数式编程是声明式编程的一个子范式。与命令式编程相反,函数式编程不会改变程序的内部状态。
在命令式编程中,函数可以更多地看作是指令序列、例程或过程。它们不仅依赖于存储在内存中的状态,还可以改变这种状态。这样,调用具有相同参数的命令函数可以根据当前程序的状态产生不同的结果,同时,执行的函数可以更改程序的变量。
在函数式编程术语中,函数类似于数学函数,函数的输出只取决于它的参数,而不管程序的状态如何,同时不受函数执行的影响。
自相矛盾的是,虽然命令式编程自计算机诞生以来就已经存在,但函数式编程的基本概念可以追溯到这之前。大多数函数式语言都是基于 Lambda 演算的,Lambda 演算是由数学家 Alonzo Church 在 20 世纪 30 年代创建的一种形式化的数理逻辑系统。
函数式语言在那个时代如此流行的原因之一是它们可以很容易地在并行环境中运行。这不应与多线程混淆。允许函数式语言并行运行的主要特性是它们所依赖的基本原则:函数只依赖于输入参数,而不依赖于程序的状态。也就是说,它们可以在任何地方运行,然后将多个并行执行的结果连接起来并进一步使用。
每个使用 Java 的人都知道集合。我们以一种强制性的方式使用集合:我们告诉程序如何做它应该做的事情。让我们以下面的示例为例,其中我们实例化了一个由 10 个整数组成的集合,从 1 到 10:
List<Integer> list = new ArrayList<Integer>();
for (int i = 0; i < 10; i++)
{
list.add(i);
}
现在,我们将创建另一个集合,在其中只过滤奇数:
List<Integer> odds = new ArrayList<Integer>();
for (int val : list)
{
if (val % 2 == 0)
odds.add(val);
}
最后,我们要打印结果:
for (int val : 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 中,我们可以定义一个系统的结构和行为,我们可以通过图表来可视化模型或部分模型。有两种类型的图表:
类图是在面向对象的设计和开发阶段使用最多的一种图。它们是一种结构图,用于说明类的结构以及它们之间的关系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9hSqUhJZ-1681378128976)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/63b89fcb-6f88-4e78-9446-ddb0c5ae8cd2.jpg)]
类图对于描述类在应用中的结构非常有用。大多数情况下,只看结构就足以理解类是如何交互的,但有时这还不够。对于这些情况,我们可以使用行为图和交互图,其中序列图用于描述类和对象的交互。让我们用一个序列图来展示在继承和多态示例中,Car
和Vehicle
对象是如何交互的:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wrwNKIZ2-1681378128977)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/aa49e427-57eb-4726-bb4e-ba65ca305a6e.png)]
在面向对象编程中,除了表示基本概念之一的继承关系外,还有一些其他类关系可以帮助我们建模和开发复杂的软件系统:
继承也称为 IS-A 关系,因为从另一个类继承的类可以用作超类。
当一个类表示多个类的共享特征时,称为泛化;例如车辆是自行车、轿车、卡车的泛化。类似地,当一个类代表一个普通类的特殊实例时,它被称为特化,所以轿车是车辆的特化,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f7Fizp5A-1681378128978)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/ba825a8f-bf4e-4f82-9846-feb9c5df8d27.jpg)]
在 UML 术语中,描述继承的关系称为泛化。
如果泛化是 UML 中面向对象继承的对应术语,那么在 UML 中,实现表示面向对象编程中类对接口的实现。
假设我们创建了一个名为Lockable
的接口,该接口仅由可锁定的Car
实现。在本例中,前面的图的一个版本为Car
类实现Lockable
:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cbuSBSh7-1681378128978)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/a5bc9f3e-fbe5-451f-98a6-8d9ea4b4da02.png)]
依赖关系是 UML 关系中最通用的类型之一。它用于定义一个类以某种方式依赖于另一个类,而另一个类可能依赖于也可能不依赖于第一个类。从属关系用于表示不属于以下各节所述情形之一的关系。依赖有时被称为 USES-A 关系。
通常,在面向对象编程语言中,依赖关系用于描述一个类是否在方法的签名中包含第二个类的参数,或者它是否通过将第二个类的实例传递给其他类而不使用它们(不调用其方法)来创建第二个类的实例:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PVXjYxAa-1681378128978)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/97a1ab93-81da-4488-93bd-14e843198581.png)]
关联表示两个实体之间的关系。有两种类型的关联,即组合和聚合。通常,关联由箭头表示,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5HocizRm-1681378128979)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/80357f18-8480-4a90-ac71-d11e3d0a00f8.png)]
聚合是一种特殊的关联类型。如果继承被认为是 IS-A 关系,那么聚合可以被认为是 HAS-A 关系。
聚合用于描述两个或多个类之间的关系,从逻辑角度来看,一个类包含另一个类,但包含的类的实例可以独立于第一个类,在其上下文之外,或者可以在其他类之间共享。例如,一个学院有一个老师;另外,每个老师必须属于学院,但是如果学院不存在,一个老师仍然可以是活动的,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YcAfOLuX-1681378128979)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/203ed563-2000-44fb-a156-f3b26d3366dd.png)]
顾名思义,一个类是另一个类的组合。这在某种程度上类似于聚合,区别在于当主类不存在时,依赖类就不存在了。例如房屋由房间组成,但房屋被毁后房间不复存在,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ftdCptLe-1681378128979)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/fbe52909-cff7-4a88-a6ba-92790a9f37f6.png)]
在实践中,尤其是在 Java 等具有垃圾收集器的语言中,组合和聚合之间的边界没有得到很好的定义。对象不会被手动销毁;当它们不再被引用时,垃圾收集器会自动销毁它们。因此,从编码的角度来看,我们不应该真正关心我们是否处理组合或聚合关系,但是如果我们想在 UML 中有一个定义良好的模型,这一点很重要。
软件开发是一个不仅仅是编写代码的过程,无论您是在一个大型团队中工作还是在一个人的项目中工作。应用的结构方式对软件应用的成功程度有着巨大的影响。
当我们谈论一个成功的软件应用时,我们不仅要讨论应用是如何完成它应该做的事情,还要讨论我们在开发它上投入了多少精力,以及它是否易于测试和维护。如果不以正确的方式进行,飞涨的开发成本将导致一个没有人想要的应用。
软件应用是为了满足不断变化和发展的需求而创建的。一个成功的应用还应该提供一个简单的方法,通过它可以扩展以满足不断变化的期望。
幸运的是,我们不是第一个遇到这些问题的人。一些问题已经面临并得到处理。在软件的设计和开发过程中,应用一套面向对象的设计原则和模式,可以避免或解决这些常见问题。
面向对象的设计原则也称为实体。这些原则是在设计和开发软件时可以应用的一组规则,以便创建易于维护和开发的程序。它们最初是由 robertc.Martin 介绍的,它们是敏捷软件开发过程的一部分。实体原则包括单一责任原则、开闭原则、Liskov 替代原则、接口分离原则和依赖倒置原则。
除了设计原则之外,还有面向对象的设计模式。设计模式是可以应用于常见问题的通用可重用解决方案。遵循 Christopher Alexander 的概念,设计模式首先被 Kent Beck 和 Ward Cunningham 应用到编程中,并在 1994 年被所谓的四人帮(GOF)一书所推广。在下一节中,我们将介绍坚实的设计原则,在接下来的章节中,设计模式将遵循这些原则。
单一责任原则是一种面向对象的设计原则,它规定软件模块只有一个改变的理由。在大多数情况下,在编写 Java 代码时,我们会将其应用于类。
单一责任原则可以被视为使封装发挥最佳效果的良好实践。更改的原因是触发更改代码的需要。如果一个类受到多个更改原因的影响,那么每个原因都可能引入影响其他原因的更改。当这些更改单独管理但影响同一模块时,一组更改可能会破坏与其他更改原因相关的功能。
另一方面,每一个改变的责任/理由都会增加新的依赖关系,使得代码不那么健壮,更难改变。
在我们的示例中,我们将使用数据库来持久化对象。假设Car
类增加了方法来处理创建、读取、更新、删除的数据库操作,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kVrmhYba-1681378128980)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/8db505f4-fe4c-4a8e-9c2a-e8b7615f4007.jpg)]
在这种情况下,Car
将不仅封装逻辑,而且封装数据库操作(两个职责是更改的两个原因)。这将使我们的类更难维护和测试,因为代码是紧密耦合的。Car
类将依赖于数据库,因此如果将来要更改数据库系统,则必须更改Car
代码。这可能会在Car
逻辑中产生错误。
相反,更改Car
逻辑可能会在数据持久性中产生错误。
该解决方案将创建两个类:一个封装Car
逻辑,另一个负责持久性:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LO7nsJYh-1681378128980)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/1b8c5fbd-6819-47a2-8d2c-dbb2fc68823c.jpg)]
这一原则如下:
模块、类和函数应该为扩展而打开,为修改而关闭
应用这一原则将有助于我们开发复杂而健壮的软件。我们必须想象我们开发的软件正在构建一个复杂的结构。一旦我们完成了它的一部分,我们就不应该再修改它,而应该在它的基础上进行构建。
开发软件的时候,也是一样的。一旦我们开发并测试了一个模块,如果我们想改变它,我们不仅要测试我们正在改变的功能,还要测试它负责的整个功能。这涉及到大量额外的资源,这些资源可能从一开始就无法估计,而且还可能带来额外的风险。一个模块中的更改可能会影响其他模块或整个模块的功能。以下为图示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RUrCDWhH-1681378128981)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/cb6c842d-fb85-46a2-a8cd-d458c780061a.jpg)]
因此,最佳实践是在模块完成后保持不变,并通过使用继承和多态扩展模块来添加新功能。开/闭原则是最重要的设计原则之一,是大多数设计模式的基础。
barbaraliskov 指出,派生类型必须完全可以替代它们的基类型。里氏替代原则(LSP)与亚型多态密切相关。基于面向对象语言中的子类型多态,派生对象可以用其父类型替换。例如,如果我们有一个Car
对象,它可以在代码中用作Vehicle
。
LSP 声明,在设计模块和类时,我们必须确保从行为的角度来看派生类型是可替换的。当派生类型被其父类型替换时,其余代码将以子类型的形式对其进行操作。从这个角度来看,派生类型的行为应该和它的父类型一样,而不应该破坏它的行为。这被称为强行为亚型。
为了理解 LSP,让我们举一个违反原则的例子。在开发汽车服务软件时,我们发现需要对以下场景进行建模。当一辆汽车被留下来维修时,车主就离开了汽车。服务助理拿着钥匙,当车主离开时,他去检查他是否有正确的钥匙,是否发现了正确的车。他只需打开门锁,然后把钥匙放在一个指定的地方,上面有一张便条,这样修理工在检查汽车时就可以很容易地取起来。
我们已经定义了一个Car
类。我们现在创建一个Key
类,并在Car
类中添加两个方法:lock
和unlock
。我们添加了相应的方法,以便助手检查钥匙是否与汽车匹配:
public class Assistant
{
void checkKey(Car car, Key key)
{
if ( car.lock(key) == false ) System.out.println("Alert! Wrong
key, wrong car or car lock is broken!");
}
}
示意图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LhGs6NON-1681378128981)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/b04f803c-be13-4f32-b095-4361b1ae4a74.jpg)]
在使用我们的软件时,我们意识到,小车有时是通过汽车服务来维修的。由于小车是四轮车,我们创建了一个Buggy
类,继承自Car
:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7TNaIAKQ-1681378128981)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/412d100b-a7cf-47f9-af4f-4e70d99b94ed.jpg)]
四轮马车没有门,所以不能上锁或开锁。我们相应地实现我们的代码:
public bool lock(Key key)
{
// this is a buggy so it can not be locked return false;
}
我们设计的软件适用于汽车,不管它们是否是小车,因此将来我们可能会将其扩展到其他类型的汽车。一个问题可能是因为汽车需要上锁和开锁。
以下引用自这个页面:
“不应强迫客户依赖他们不使用的接口。”
应用时,接口分离原则(ISP)减少了代码耦合,使软件更健壮,更易于维护和扩展。ISP 最初是由 robertmartin 宣布的,当时他意识到,如果这个原则被打破,客户端被迫依赖于他们不使用的接口,那么代码就变得紧密耦合,几乎不可能为它添加新的功能。
为了更好地理解这一点,让我们再次以汽车服务为例(参见下图)。现在我们需要实现一个名为·Mechanic 的类。技工修车,所以我们增加了一种修车方法。在这种情况下,Mechanic
类依赖于Car
类。然而,Car
类比Mechanic
类需要更多的方法:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vql0wW7G-1681378128982)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/0663ce8b-34f9-42a9-956d-168fa1ce414a.jpg)]
这是一个糟糕的设计,因为如果我们想用另一辆车替换一辆车,我们需要在Mechanic
类中进行更改,这违反了开/关原则。相反,我们必须创建一个只公开Mechanic
类中所需的相关方法的接口,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Pp7sA45z-1681378128982)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/e4532531-c690-46b0-95e3-adf8b1e4a238.jpg)]
“高级模块不应依赖于低级模块。两者都应该依赖于抽象。”
“抽象不应该依赖于细节。细节应该取决于抽象。”
为了理解这一原理,我们必须解释耦合和解耦的重要概念。耦合是指软件系统的模块之间相互依赖的程度。依赖性越低,系统的维护和扩展就越容易。
有不同的方法来解耦系统的组件。其中之一是将高级逻辑与低级模块分开,如下图所示。在这样做的时候,我们应该通过使它们依赖于抽象来减少两者之间的依赖性。这样,可以在不影响其他模块的情况下更换或扩展其中任何模块:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X91mWhWW-1681378128983)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/d18ae88d-58d2-42a4-be15-563b80d69ec4.jpg)]
在本章中,我们介绍了 Java 中使用的主要编程范式。我们已经了解到两种不同的范式,如命令式编程和函数式编程,可以在同一种语言中共存;我们还了解了 Java 如何从纯命令式面向对象编程发展到集成函数式编程元素。
尽管 Java 从版本 8 开始引入了新的功能元素,但它的核心仍然是一种面向对象的语言。为了编写易于扩展和维护的可靠而健壮的代码,我们学习了面向对象编程语言的基本原理。
开发软件的一个重要部分是设计程序组件的结构和所需的行为。这样,我们就可以在大型系统上工作,在大型团队中工作,在团队内部或团队之间共享我们的面向对象设计。为了能够做到这一点,我们重点介绍了与面向对象设计和编程相关的主要 UML 图和概念。我们在书中还广泛地使用 UML 来描述这些例子。
在介绍了类关系并展示了如何在图中表示它们之后,我们进入下一节,在这里我们描述了什么是面向对象的设计模式和原则,并介绍了主要原则。
在下一章中,我们将继续介绍一组处理对象创建的设计模式,使我们的代码具有健壮性和可扩展性。
本章的目的是学习创造模式。创造模式是处理对象创造的模式。在本章中,我们将介绍以下主题:
单例模式可能是自 Java 诞生以来使用最广泛的设计模式。这是一个简单的模式,易于理解和使用。有时它被过度使用,在不需要它的情况下。在这种情况下,使用它的缺点大于它带来的好处。因此,单例有时被认为是反模式。然而,有许多场景需要单例。
顾名思义,单例模式用于确保只能创建对象的单个实例。除此之外,它还提供对该实例的全局访问。下面的类图描述了单例模式的实现:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IRwdpdgc-1681378128983)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/370befdc-f771-4b59-b3b1-850cd0481247.png)]
单例模式的实现非常简单,只包含一个类。为了确保单例实例是唯一的,所有单例构造器都应该是私有的。全局访问是通过一个静态方法完成的,可以全局访问该方法来获取单例实例,如下代码所示:
public class Singleton
{
private static Singleton instance;
private Singleton()
{
System.out.println("Singleton is Instantiated.");
}
public static Singleton getInstance()
{
if (instance == null)
instance = new Singleton();
return instance;
}
public void doSomething()
{
System.out.println("Something is Done.");
}
}
当我们需要在代码中的某个地方使用单例对象时,我们只需这样调用它:
Singleton.getInstance().doSomething();
在getInstance
方法中,我们检查实例是否为null
。如果实例不为null
,则表示该对象是在之前创建的;否则,我们将使用new
操作符创建它。之后,无论哪种情况,它都不再为null
,因此我们可以返回实例对象。
同步单例的代码简单而有效,但是有一种情况我们应该考虑。如果我们在多线程应用中使用我们的代码,可能是两个线程在实例为null
时同时调用getInstance
方法。当发生这种情况时,可能是第一个线程继续使用new
操作符实例化单例,在完成之前,第二个线程检查单例是否为null
。因为第一个线程没有完成实例化,所以第二个线程会发现实例为空,所以它也会开始实例化它。
这种情况看起来几乎不可能,但是如果需要很长时间来实例化单例,那么发生这种情况的可能性就非常大,不可忽视。
这个问题的解决办法很简单。我们必须创建一个检查实例是否为空线程安全的块。这可以通过以下两种方式实现:
synchronized
关键字,使getInstance
方法线程安全:public static synchronized Singleton getInstance()
if (instance == null)
状态包装在synchronized
块中。当我们在这个上下文中使用synchronized
块时,我们需要指定一个提供锁的对象。我们为此使用了Singleton.class
对象,如下代码段所示:synchronized (SingletonSync2.class)
{
if (instance == null)
instance = new SingletonSync2();
}
前面的实现是线程安全的,但它引入了不必要的延迟:检查实例是否已创建的块是同步的。这意味着块一次只能由一个线程执行,但只有在实例尚未创建时锁定才有意义。当单例实例已经创建时,每个线程都可以以不同步的方式获取当前实例。
在synchronized
块前增加一个附加条件,只有在单例还没有实例化时,才会移动线程安全锁:
if (instance == null)
{
synchronized (SingletonSync2.class)
{
if (instance == null)
instance = new SingletonSync2();
}
}
注意,instance == null
被检查了两次。这是必要的,因为我们必须确保在synchronized
块中也进行了检查。
Java 中单例模式的最佳实现之一依赖于一个类是一次加载的事实。通过在声明时直接实例化静态成员,我们可以确保只有一个类实例。此实现避免了锁定机制和查看实例是否已创建的附加检查:
public class LockFreeSingleton
{
private static final LockFreeSingleton instance = new
LockFreeSingleton();
private LockFreeSingleton()
{
System.out.println("Singleton is Instantiated.");
}
public static synchronized LockFreeSingleton getInstance()
{
return instance;
}
public void doSomething()
{
System.out.println("Something is Done.");
}
}
根据创建实例对象的时间,单例可以分为两类。如果单例是在应用启动时创建的,则认为它是一个早期/急切实例化。否则,如果第一次调用getInstance
方法时调用了单例构造器,则认为是惰性加载单例。
上一个示例中提供的无锁线程安全单例被认为是 Java 第一个版本中的早期加载单例。然而,在最新版本的 Java 中,类是在需要时加载的,所以这个版本也是一个延迟加载版本。此外,类被加载的时刻取决于 JVM 实现,不同版本的类可能不同。应该避免基于 JVM 实现做出设计决策。
目前,Java 中没有可靠的选项来创建早期加载的单例。如果我们真的需要一个早期的实例化,我们应该在应用开始时强制它,只需调用getInstance()
方法,如下代码所示:
Singleton.getInstance();
如前一章所讨论的,继承是面向对象编程的基本概念之一。与亚型多态一起,它给出了 IS-A 关系。Car
对象可以作为Vehicle
对象处理。Truck
对象也可以作为Vehicle
对象处理。一方面,这种抽象使我们的代码更薄,因为同一段代码可以处理Car
和Truck
对象的操作。另一方面,它给我们提供了一个选项,通过简单地添加新的类,比如Bike
和Van
,而不修改它,就可以将代码扩展到新类型的Vehicle
对象。
当我们处理这样的场景时,最棘手的部分之一就是对象的创建。在面向对象编程中,使用特定类的构造器实例化每个对象,如下代码所示:
Vehicle vehicle = new Car();
这段代码意味着实例化对象的类和实例化对象的类之间的依赖关系。这样的依赖关系使得我们的代码紧密耦合,在不修改代码的情况下很难扩展。例如,如果我们需要用另一个类型替换Car
,比如说Truck
,我们需要相应地更改代码:
Vehicle vehicle = new Truck();
但这里有两个问题。首先,我们的类应该为扩展而开放,为修改而关闭(开闭原则)。第二,每个类应该只有一个改变的理由(单一责任原则)。每次添加一个新类时更改主代码将打破开放/关闭原则,让主类除了功能外还负责实例化vehicle
对象将打破单一责任原则。
在这种情况下,我们需要为代码提供更好的设计。我们可以添加一个新类来负责实例化vehicle
对象。我们将基于这个SimpleFactory
类调用模式。
工厂模式用于封装逻辑,以实例化通过公共接口引用的对象。只需稍作改动就可以添加新类。
下面的类图描述了简单工厂的实现:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0UC6OOxy-1681378128983)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/43b09446-d183-4a9c-a32b-93048a4062e3.png)]
SimpleFactory
类实现实例化ConcreteProduct1
和ConcreteProduct2
的代码。当客户端需要一个对象时,它调用SimpleFactory
的createProduct()
方法,参数表示它需要的对象的类型。SimpleFactory
实例化对应的具体产品并返回。返回的产品被转换为基类类型,因此客户端将以相同的方式处理任何Product
,而不管它是ConcreteProduct1
还是ConcreteProduct2
。
让我们编写一个简单的工厂来创建车辆实例。我们有一个抽象的Vehicle
类和从中继承的三个具体类:Bike
、Car
和Truck
。工厂,也称为静态工厂,将如下所示:
public class VehicleFactory
{
public enum VehicleType
{
Bike,Car,Truck
}
public static Vehicle create(VehicleType type)
{
if (type.equals(VehicleType.Bike))
return new Bike();
if (type.equals(VehicleType.Car))
return new Car();
if (type.equals(VehicleType.Truck))
return new Truck();
else return null;
}
}
工厂看起来非常简单,负责实例化vehicle
类,遵循单一责任原则。它帮助我们减少耦合,因为客户端只依赖于Vehicle
接口,符合依赖倒置原则。如果我们需要添加一个新的vehicle
类,我们需要更改VehicleFactory
类,这样就打破了开/关原则。
我们可以改进这个简单的工厂模式,通过使用一种机制来注册新的类,在需要时实例化这些类,从而使它在扩展时打开,在修改时关闭。有两种方法可以实现这一点:
newInstance
方法,该方法返回一个与其自身相同的类的新实例对于此方法,我们将使用一个映射来保留产品 ID 及其相应的类:
private Map<String, Class> registeredProducts = new HashMap<String,Class>();
然后,我们添加了一个注册新车的方法:
public void registerVehicle(String vehicleId, Class vehicleClass)
{
registeredProducts.put(vehicleId, vehicleClass);
}
create
方法如下:
public Vehicle createVehicle(String type) throws InstantiationException, IllegalAccessException
{
Class productClass = registeredProducts.get(type);
return (Vehicle)productClass.newInstance();
}
在某些情况下,进行反思要么是不可能的,要么是不鼓励的。反射需要在某些环境中可能不存在的运行时权限。如果性能是一个问题,反射可能会减慢程序,因此应该避免。
Product.newInstance
执行类注册的简单工厂在前面的代码中,我们使用反射来实例化新的车辆。如果我们必须避免反射,我们可以使用一个类似的工厂来注册工厂应该能够创建的新车辆类。我们将不向映射中添加类,而是添加要注册的每种类型的对象的实例。每个产品将能够创建自己的新实例。
我们首先在基类Vehicle
中添加一个抽象方法:
abstract public Vehicle newInstance();
对于每个产品,必须实现基类中声明为abstract
的方法:
@Override
public Car newInstance()
{
return new Car();
}
在factory
类中,我们将更改映射以保留对象的 ID 以及vehicle
对象:
private Map<String, Vehicle> registeredProducts = new HashMap<String,Vehicle>();
然后我们通过传递一个实例来注册一个新类型的车辆:
public void registerVehicle(String vehicleId, Vehicle vehicle)
{
registeredProducts.put(vehicleId, vehicle);
}
我们相应地改变createVehicle
方法:
public AbstractProduct createVehicle(String vehicleId)
{
return registeredProducts.get(vehicleId).newInstance();
}
工厂方法模式是对静态工厂的改进。factory
类是抽象的,实例化特定产品的代码被移动到实现抽象方法的子类中。这样,factory
类就可以扩展而不需要修改。工厂方法模式的实现在以下类图中描述:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H6HKIAJk-1681378128983)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/7b9b05d9-b441-4fa4-a6f4-05bbda05524a.png)]
现在是编写示例代码的时候了。假设我们有一个汽车工厂。目前,我们生产两种车型:小型跑车和大型家用车。在我们的软件中,客户可以决定他们是想要一辆小型车还是一辆大型车。首先,我们创建一个包含两个子类的Vehicle
类:SportCar
和SedanCar
。
现在我们有了车辆结构,让我们建立抽象工厂。请注意,工厂没有创建新实例的任何代码:
public abstract class VehicleFactory
{
protected abstract Vehicle createVehicle(String item);
public Vehicle orderVehicle(String size, String color)
{
Vehicle vehicle = createVehicle(size);
vehicle.testVehicle();
vehicle.setColor(color);
return vehicle;
}
}
为了添加代码来创建Car
实例,我们将VehicleFactory
子类化,创建一个CarFactory
。汽车工厂必须实现从父类调用的createVehicle
抽象方法。实际上,VehicleFactory
将具体车辆的实例化委托给子类:
public class CarFactory extends VehicleFactory
{
@Override
protected Vehicle createVehicle(String size)
{
if (size.equals("small"))
return new SportCar();
else if (size.equals("large"))
return new SedanCar();
return null;
}
}
在客户端中,我们只需创建工厂并创建订单:
VehicleFactory carFactory = new CarFactory();
carFactory.orderVehicle("large", "blue");
在这一点上,我们意识到一个汽车厂能带来多少利润。是时候扩展我们的业务了,我们的市场调查告诉我们,卡车的需求量很大。那么让我们构建一个TruckFactory
:
public class TruckFactory extends VehicleFactory
{
@Override
protected Vehicle createVehicle(String size)
{
if (size.equals("small"))
return new SmallTruck();
else if (size.equals("large"))
return new LargeTruck();
return null;
}
}
启动订单时,我们使用以下代码:
VehicleFactory truckFactory = new TruckFactory();
truckFactory.orderVehicle("large", "blue");
我们继续前面的代码,添加了一个BikeFactory
,客户可以从中选择一辆小自行车或一辆大自行车。我们不需要创建单独的类文件就可以做到这一点;我们可以简单地创建一个匿名类,直接在客户端代码中扩展VehicleFactory
:
VehicleFactory bikeFactory = new VehicleFactory()
{
@Override
protected Vehicle createVehicle(String size)
{
if (size.equals("small"))
return new MountainBike();
else if (size.equals("large"))
return new CityBike();
return null;
}
};
bikeFactory.orderVehicle("large", "blue");
抽象工厂是工厂方法的扩展版本。它不是创建单一类型的对象,而是用于创建相关对象的族。如果工厂方法有一个AbstractProduct
,则抽象工厂有几个AbstractProduct
类。
factory 方法有一个抽象方法,由每个具体的工厂用代码来实例化抽象产品。抽象工厂对每个抽象产品都有一种方法。
如果我们采用抽象工厂模式,并将其应用于包含单个对象的族系,那么我们就有了工厂方法模式。工厂方法只是抽象工厂的一个特例。
抽象工厂模式的实现在以下类图中描述:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XxqIUPnP-1681378128984)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/06fa611e-4cc9-4253-b11b-b5010e23e9cc.png)]
抽象工厂模式由以下类组成:
AbstractFactory
:这个抽象类声明了创建产品类型的方法。它包含每个要创建的AbstractProduct
的方法。ConcreteFactories
:实现AbstractFactory
基类中声明的方法的具体类。每套混凝土产品都有一个工厂。AbstractProducts
:需要的对象的基本接口或类。一个相关产品系列由每个层次结构中的相似产品组成:ProductA1
和ProductB1
来自第一个类系列,由ConcreteFactory1
实例化;第二个类系列ProductA2
和ProductB2
由ConcreteFactory2
实例化。我们讨论了实现工厂模式的三种方法,即简单工厂、工厂方法和抽象工厂模式。如果您对这三种实现感到困惑,就不必责怪您,因为它们之间有很多重叠。此外,这些模式没有一个单一的定义,专家们在如何实现这些模式上可能存在分歧。
其思想是理解核心概念。我们可以说,工厂模式的核心是将创建适当对象的责任委托给工厂类。如果我们的工厂很复杂,也就是说,它应该服务于多种类型的对象或工厂,我们可以相应地修改代码。
构建器模式的作用与其他创造性模式相同,但它以不同的方式和出于不同的原因。在开发复杂的应用时,代码往往变得更加复杂。类倾向于封装更多的功能,同时,类结构变得更加复杂。随着功能的增长,需要覆盖更多的场景,对于这些场景,需要不同的类表示。
当我们有一个复杂的类需要实例化为具有不同结构或不同内部状态的不同对象时,我们可以使用不同的类来封装实例化逻辑。这些类被称为构建器。每次我们需要来自同一类的具有不同结构的对象时,我们都可以创建另一个构建器来创建这样的实例。
同样的概念不仅可以用于需要不同表示的类,也可以用于由其他对象组成的复杂对象。
创建构建器类来封装实例化复杂对象的逻辑符合单一责任原则和打开/关闭原则。实例化复杂对象的逻辑被移动到一个单独的构建器类。当我们需要不同结构的对象时,我们可以添加新的构建器类,这样代码就可以关闭进行修改,打开进行扩展,如图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7RXQE5C4-1681378128984)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/be5faf3c-8da2-4ee3-8f78-d3692b71ae57.png)]
构建器模式中涉及以下类:
Product
:我们必须构建其对象的类。它是一个复杂的或复合的对象,我们需要不同的表示。Builder
:一个抽象类或接口,它声明了构建产品的各个部分。它的作用是只公开构建Product
所需的功能,隐藏Product
功能的其余部分;它将Product
与构建它的高级类分离。ConcreteBuilder
:实现Builder
接口中声明的方法的具体构建器。除了在Builder
抽象类中声明的方法外,它还有一个getResult
方法返回生成的产品。Director
:一个类,指导如何构建对象。在构建器模式的某些变体中,这个类被删除,它的角色由客户端或构建器承担。在本节中,我们将把构建器模式应用到汽车软件中。我们有一个Car
类,我们需要创建它的实例。根据我们在汽车上添加的部件,我们可以制造轿车和跑车。当我们开始设计软件时,我们意识到:
Car
类相当复杂,创建类对象也是一项复杂的操作。在Car
构造器中添加所有实例化逻辑将使类变得相当大。我们将创建以下类结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bsM1Nsqt-1681378128985)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/c3a042d9-7a7b-4839-b7e9-c2fb94e31516.png)]
CarBuilder
是构建器基类,它包含四个抽象方法。我们创建了两个混凝土构建器:ElectricCarBuilder
和GasolineCarBuilder
。每个具体的构建器都必须实现所有的抽象方法。不需要的方法,例如ElectricCarBuilder
的addGasTank
被保留为空,或者它们可以抛出异常。电动汽车和汽油汽车有不同的内部结构。
Director
类使用构建器来创建新的Car
对象。buildElectricCar
和buildGasolineCar
可能相似,但略有不同:
public Car buildElectricCar(CarBuilder builder)
{
builder.buildCar();
builder.addEngine("Electric 150 kW");
builder.addBatteries("1500 kWh");
builder.addTransmission("Manual");
for (int i = 0; i < 4; i++)
builder.addWheel("20x12x30");
builder.paint("red");
return builder.getCar();
}
但假设我们想制造一辆混合动力汽车,配备电动和汽油发动机:
public Car buildHybridCar(CarBuilder builder)
{
builder.buildCar();
builder.addEngine("Electric 150 kW");
builder.addBatteries("1500 kWh");
builder.addTransmission("Manual");
for (int i = 0; i < 4; i++)
builder.addWheel("20x12x30");
builder.paint("red");
builder.addGasTank("1500 kWh");
builder.addEngine("Gas 1600cc");
return builder.getCar();
}
在构建器模式的一些实现中,Director
类可以被删除。在我们的类示例中,它封装的逻辑非常简单,因此在这种情况下,我们实际上不需要控制器。在本例中,简化的构建器模式如下所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5WqFi9yu-1681378128985)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/f83fa1cf-2400-42ea-8f9e-beeea0254f39.png)]
在Director
类中实现的代码被简单地移动到Client
。当Builder
和Product
类太复杂或使用构建器从数据流构建对象时,不建议进行此更改。
如前所述,处理来自同一类且应采用不同形式的对象的最直观方法是为每个场景创建几个构造器来实例化它们。使用构建器模式来避免这种情况是一种很好的做法。在《Effective Java》中,Joshua Bloch 建议使用内部构建器类和方法链接来替换多个构造器。
方法链接是一种从某些方法返回当前对象(this
)的技术。这样,可以在链中调用这些方法。例如:
public Builder setColor()
{
// set color
return this;
}
在我们定义了更多这样的方法之后,我们可以在一个链中调用它们:
builder.setColor("Blue")
.setEngine("1500cc")
.addTank("50")
.addTransmission("auto")
.build();
但是,在我们的例子中,我们将使builder
成为Car
对象的内部类。因此,当我们需要新客户时,我们可以执行以下操作:
Car car = new Car.Builder.setColor("Blue")
.setEngine("1500cc")
.addTank("50")
.addTransmission("auto")
.build();
原型模式是一种看起来比实际更复杂的模式。实际上,它只是一种克隆对象的方法。如今,实例化对象在性能上并不太昂贵,为什么我们需要克隆对象呢?有几种情况需要克隆已实例化的对象:
让我们看看下面的类图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Hql8oxFF-1681378128985)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/d9e65cb7-be8c-4e98-8abc-506d3aaedf64.png)]
在原型模式中,涉及以下类:
Prototype
:这是基类,或者一个接口,它声明派生对象必须实现的clone()
方法。在一个简单的场景中,我们可能没有基类,直接的具体类就足够了。ConcretePrototype
:这些类实现或扩展了clone()
方法。应该始终实现此方法,因为它返回其类型的新实例。如果clone()
方法是在基类中实现的,而我们没有在ConcretePrototype
中实现,那么当我们在ConcretePrototype
对象上调用clone()
方法时,它会返回一个基类Prototype
对象。clone()
方法可以在接口中声明,因此实现该方法的类必须实现该方法。这种强制是在编译时完成的。但是,对于继承自在具有多个级别的层次结构中实现clone()
方法的类的类,不会强制执行该方法。
在克隆物体时,我们应该意识到克隆的深度。当我们克隆一个包含简单数据类型的对象,比如int
和float
,或者不可变对象,比如字符串,我们应该简单地将这些字段复制到新对象,就这样。
当我们的对象包含对其他对象的引用时,问题就出现了。例如,如果我们必须为一个Car
类实现一个克隆方法,这个类有一个引擎和一个四个轮子的列表,我们不仅要创建一个新的Car
对象,还要创建一个新的Engine
和四个新的Wheel
对象。毕竟,两辆车不能共用同一台发动机和同一个车轮。这被称为深克隆。
浅层克隆是一种只克隆被克隆对象的方法。例如,如果我们必须为一个Student
对象实现一个clone
方法,我们就不会克隆它指向的Course
对象。多个Student
对象可以指向同一Course
对象。
在实践中,我们应该根据每个场景来决定是需要深度克隆、浅层克隆还是混合克隆。通常,浅克隆对应于第一章,“从面向对象到函数式编程”中描述的聚合关系,而深克隆对应于组合关系。
就性能而言,对象的实例化是最昂贵的操作之一。虽然在过去这可能是一个问题,但现在我们不应该担心它。但是,当我们处理封装外部资源的对象(如数据库连接)时,创建新对象的成本会很高。
解决方案是实现一种机制,可以重用和共享创建成本高昂的对象。此解决方案称为对象池模式,它具有以下结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bCwrxRI0-1681378128985)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/aef3f759-2802-45cc-9ac8-edf5210c31ad.png)]
对象池模式中使用的类如下:
ResourcePool
:封装逻辑以保存和管理资源列表的类。Resource
:封装有限资源的类。Resource
类总是被ResourcePool
引用,所以只要ResourcePool
没有被反分配,它们就永远不会被垃圾收集。Client
:使用资源的类。当一个Client
需要一个新的Resource
时,它向ResourcePool
请求。池检查并获取第一个可用资源并将其返回给客户端:
public Resource acquireResource()
{
if ( available.size() <= 0 )
{
Resource resource = new Resource();
inuse.add(resource);
return resource;
}
else
{
return available.remove(0);
}
}
然后,当Client
结束使用Resource
时,它释放它。资源被添加回工具,以便可以重用。
public void releaseResource(Resource resource)
{
available.add(resource);
}
资源池的最佳示例之一是数据库连接池。我们维护一个数据库连接池,并让代码使用这个池中的连接。
在这一章中,我们讨论了创造性的设计模式。我们讨论了单例、工厂、构建器、原型和对象池模式的变体。所有这些模式都用于实例化新对象,并在创建对象时提供代码灵活性和可重用性。在下一章中,我们将介绍行为模式。虽然创建模式帮助我们管理对象的创建,但行为模式提供了管理对象行为的简单方法。
本章的目的是学习行为模式。行为模式是关注对象交互、通信和控制流的模式。大多数行为模式是基于组合和委托而不是继承的。我们将在本章中了解以下行为模式:
计算机软件是用来处理信息的,构造和处理这些信息有不同的方法。我们已经知道,当我们谈论面向对象编程时,我们应该为每个类分配一个单独的职责,以便使我们的设计易于扩展和维护。
考虑一个场景,其中可以对客户端请求附带的一组数据执行多种类型的操作。我们可以维护负责不同类型操作的不同类,而不是在单个类中添加有关所有操作的信息。这有助于我们保持代码松散耦合和干净。
这些类称为处理器。第一个处理器将接收请求并在需要执行操作时进行调用,或者将其传递给第二个处理器。类似地,第二个处理器检查并可以将请求传递给链中的下一个处理器。
责任链模式以这样一种方式将处理者链接起来:如果处理者不能处理请求,他们将能够处理请求或传递请求。
下面的类图描述了责任链模式的结构和参与者:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9x0D6PY0-1681378128986)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/6a95c8e4-32d5-4cf8-9c37-420a05442514.png)]
在前面的图表中涉及以下类:
Client
:这是使用该模式的应用的主要结构。它负责实例化一系列处理器,然后在第一个对象上调用handleRequest
方法。Handler
:这个抽象类继承了所有具体的Handler
。它有一个handleRequest
方法,接收应该处理的请求。ConcreteHandlers
:这些是具体的类,为每个案例实现一个handleRequest
方法。每个ConcreteHandler
都保留一个对链中下一个ConcreteHandler
的引用,并且必须检查它是否能够处理请求;否则,它必须将其传递给链中的下一个ConcreteHandler
。每个处理器都应该实现一个方法,客户端使用该方法设置下一个处理器,如果无法处理请求,则应该将请求传递给该处理器。此方法可以添加到基Handler
类中:
protected Handler successor;
public void setSuccessor(Handler successor)
{
this.successor = successor;
}
在每个ConcreteHandler
类中,我们都有下面的代码,检查它是否能够处理请求;否则,它将传递请求:
public void handleRequest(Request request)
{
if (canHandle(request))
{
//code to handle the request
}
else
{
successor.handleRequest();
}
}
客户端负责在调用链的头之前构建处理器链。调用将被传播,直到找到可以处理请求的正确处理器。
让我们以汽车服务应用为例。我们意识到,每一次一辆坏了的车进来,它都会首先由技工检查,如果问题在他们的专业领域,技工就会修理它。如果他们做不到,就把它交给电工。如果他们不能修复它,他们会把它传给下一位专家。下面是图表的外观:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NA6tLivq-1681378128986)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/92c9a22a-c460-4b2e-bd40-3aefd6ba6b9e.png)]
以下是责任链模式的适用性和示例:
javax.servlet.Filter
用于过滤请求或响应。doFilter
方法还接收过滤链作为参数,并将请求传递给其他方法。在面向对象编程中要做的最重要的事情之一就是采用一种可以使代码解耦的设计。例如,假设我们需要开发一个复杂的应用,在其中我们可以绘制图形形状:点、线、线段、圆、矩形等等。
随着代码绘制各种形状,我们需要实现许多操作来处理菜单操作。为了使我们的应用具有可维护性,我们将创建一个统一的方法来定义所有这些命令,这样它将对应用的其余部分(扮演客户端角色)隐藏实现细节。
命令模式执行以下操作:
命令模式的类图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rCd8pBev-1681378128986)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/d9f47057-0b1c-4022-b666-7022515b7748.png)]
在前面的实现图中,我们可以区分以下参与者:
Command
:这是表示命令封装的抽象。它声明执行的抽象方法,该方法应由所有具体命令实现。ConcreteCommand
:这是Command
的实际执行。它必须执行命令并处理与每个具体命令相关联的参数。它将命令委托给接收器。Receiver
:负责执行与命令相关联的动作的类。Invoker
:触发命令的类。这通常是一个外部事件,例如用户操作。Client
:这是实例化具体命令对象及其接收器的实际类。最初,我们的冲动是在一个大的if-else
块中处理所有可能的命令:
public void performAction(ActionEvent e)
{
Object obj = e.getSource();
if (obj = fileNewMenuItem)
doFileNewAction();
else if (obj = fileOpenMenuItem)
doFileOpenAction();
else if (obj = fileOpenRecentMenuItem)
doFileOpenRecentAction();
else if (obj = fileSaveMenuItem)
doFileSaveAction();
}
但是,我们可以决定将命令模式应用于绘图应用。我们首先创建一个命令接口:
public interface Command
{
public void execute();
}
下一步是将菜单项、按钮等所有对象定义为类,实现命令接口和execute()
方法:
public class OpenMenuItem extends JMenuItem implements Command
{
public void execute()
{
// code to open a document
}
}
在我们重复前面的操作,为每个可能的操作创建一个类之后,我们将朴素实现中的if-else
块替换为以下块:
public void performAction(ActionEvent e)
{
Command command = (Command)e.getSource();
command.execute();
}
我们可以从代码中看到,调用程序(触发performAction
方法的客户端)和接收器(实现命令接口的类)是解耦的。我们可以很容易地扩展我们的代码而不必更改它。
命令模式的适用性和示例如下:
java.lang.Runnable
是一个命令接口。在下面的代码中,runnable
接口作为命令接口,由RunnableThread
实现:
class RunnableThread implements Runnable
{
public void run()
{
// the command implementation code
}
}
客户端调用命令以启动新线程:
public class ClientThread
{
public static void main(String a[])
{
RunnableThread mrt = new RunnableThread();
Thread t = new Thread(mrt);
t.start();
}
}
计算机应该用来解释句子或求值表达式。如果我们必须编写一系列代码来处理这样的需求,首先,我们需要知道结构;我们需要有表达式或句子的内部表示。在许多情况下,最适合使用的结构是基于复合模式的复合结构。我们将在第 4 章、“结构模式”中进一步讨论复合模式,目前我们可以将复合表示看作是将性质相似的对象分组在一起。
解释器模式定义了语法的表示和解释。
解释器模式使用复合模式来定义对象结构的内部表示。除此之外,它还添加了解释表达式并将其转换为内部结构的实现。因此,解释器模式属于行为模式范畴。类图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5nCPfiL9-1681378128986)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/7150f64c-31b1-43c8-8cfd-92f2248e14d2.png)]
解释器模式由以下类组成:
Context
:用于封装对解释器来说是全局的,需要所有具体解释器访问的信息。AbstractExpression
:一个抽象类或接口,声明执行的解释方法,由所有具体的解释程序实现。TerminalExpression
:一个解释器类,实现与语法的终端符号相关的操作。这个类必须始终被实现和实例化,因为它标志着表达式的结束。NonTerminalExpression:
:这些类实现不同的语法规则或符号。对于每个类,应该创建一个类。解释器模式在实际中用于解释正则表达式。对于这样的场景,实现解释器模式是一个很好的练习;但是,我们将选择一个简单的语法作为示例。我们将应用它来解析一个带有一个变量的简单函数:f(x)
。
为了使它更简单,我们将选择反向波兰符号。这是一种将操作数加到运算符末尾的表示法。1 + 2
变为1 2 +
;(1 + 2) * 3
变为1 2 + 3 *
。优点是我们不再需要括号,所以它简化了我们的任务。
以下代码为表达式创建接口:
public interface Expression
{
public float interpret();
}
现在我们需要实现具体的类。我们需要以下要素:
Number
:解释数字+, -, *, /
):对于下面的示例,我们将使用加号(+
)和减号(-
):public class Number implements Expression
{
private float number;
public Number(float number)
{
this.number = number;
}
public float interpret()
{
return number;
}
}
现在我们到了困难的部分。我们需要实现运算符。运算符是复合表达式,由两个表达式组成:
public class Plus implements Expression
{
Expression left;
Expression right;
public Plus(Expression left, Expression right)
{
this.left = left;
this.right = right;
}
public float interpret()
{
return left.interpret() + right.interpret();
}
}
类似地,我们有一个负实现,如下所示:
public class Minus implements Expression
{
Expression left;
Expression right;
public Minus(Expression left, Expression right)
{
this.left = left;
this.right = right;
}
public float interpret()
{
return right.interpret() - left.interpret();
}
}
现在我们可以看到,我们已经创建了类,这些类允许我们构建一个树,其中操作是节点,变量和数字是叶子。这个结构可能非常复杂,可以用来解释一个表达式。
现在我们必须编写代码,使用我们创建的类来构建树:
public class Evaluator
{
public float evaluate(String expression)
{
Stack<Expression> stack = new Stack<Expression>();
float result =0;
for (String token : expression.split(" "))
{
if (isOperator(token))
{
Expression exp = null;
if(token.equals("+"))
exp = stack.push(new Plus(stack.pop(), stack.pop()));
else if (token.equals("-"))
exp = stack.push(new Minus(stack.pop(), stack.pop()));
if(null!=exp)
{
result = exp.interpret();
stack.push(new Number(result));
}
}
if (isNumber(token))
{
stack.push(new Number(Float.parseFloat(token)));
}
}
return result;
}
private boolean isNumber(String token)
{
try
{
Float.parseFloat(token);
return true;
}
catch(NumberFormatException nan)
{
return false;
}
}
private boolean isOperator(String token)
{
if(token.equals("+") || token.equals("-"))
return true;
return false;
}
public static void main(String s[])
{
Evaluator eval = new Evaluator();
System.out.println(eval.evaluate("2 3 +"));
System.out.println(eval.evaluate("4 3 -"));
System.out.println(eval.evaluate("4 3 - 2 +"));
}
}
解释器模式可以在表达式需要解释并转换为其内部表示时使用。模式不能应用于复杂语法,因为内部表示是基于复合模式的。
Java 实现了java.util.Parser
中的解释器模式,用于解释正则表达式。首先,在解释正则表达式时,将返回Matcher
对象。匹配器使用模式类基于正则表达式创建的内部结构:
Pattern p = Pattern. compile("a*b");
Matcher m = p.matcher ("aaaaab");
boolean b = m.matches();
迭代器模式可能是 Java 中最著名的模式之一。一些 Java 程序员在使用它时,并不知道集合包是迭代器模式的实现,而不管集合的类型是:数组、列表、集合或任何其他类型。
不管集合是列表还是数组,我们都可以用同样的方式处理它,这是因为它提供了一种在不暴露其内部结构的情况下遍历其元素的机制。此外,不同类型的集合使用相同的统一机制。这种机制称为迭代器模式。
迭代器模式提供了一种顺序遍历聚合对象的元素而不暴露其内部表示的方法。
迭代器模式基于两个抽象类或接口,可以通过一对具体类来实现。类图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HnWirY64-1681378128986)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/dc480e83-6ac3-4091-bdb3-16ce8b9b6370.png)]
迭代器模式中使用了以下类:
Aggregate
:应该由所有类实现的抽象类,可以由迭代器遍历。这对应于java.util.Collection
接口。Iterator
:迭代器抽象定义了遍历聚合对象的操作和返回对象的操作。ConcreteAggregate
:具体聚合可以实现内部不同的结构,但是暴露了具体迭代器,该迭代器负责遍历聚合。ConcreteIterator
:这是处理特定混凝土骨料类的混凝土迭代器。实际上,对于每个ConcreteAggregate
,我们必须实现一个ConcreteIterator
。在 Java 中使用迭代器可能是每个程序员在日常生活中都要做的事情之一。让我们看看如何实现迭代器。首先,我们应该定义一个简单的迭代器接口:
public interface Iterator
{
public Object next();
public boolean hasNext();
}
We create the aggregate:
public interface Aggregate
{
public Iterator createIterator();
}
然后我们实现一个简单的Aggregator
,它维护一个字符串值数组:
public class StringArray implements Aggregate
{
private String values[];
public StringArray(String[] values)
{
this.values = values;
}
public Iterator createIterator()
{
return (Iterator) new StringArrayIterator();
}
private class StringArrayIterator implements Iterator
{
private int position;
public boolean hasNext()
{
return (position < values.length);
}
public String next()
{
if (this.hasNext())
return values[position++];
else
return null;
}
}
}
我们在聚合中嵌套了迭代器类。这是最好的选择,因为迭代器需要访问聚合器的内部变量。我们可以在这里看到它的样子:
String arr[]= {"a", "b", "c", "d"};
StringArray strarr = new StringArray(arr);
for (Iterator it = strarr.createIterator(); it.hasNext();)
System.out.println(it.next());
迭代器现在在大多数编程语言中都很流行。它可能与collections
包一起在 Java 中使用最广泛。当使用以下循环构造遍历集合时,它也在语言级别实现:
for (String item : strCollection)
System.out.println(item);
迭代器模式可以使用泛型机制实现。这样,我们就可以确保避免强制转换产生的运行时错误。
在 Java 中实现新的容器和迭代器的好方法是实现现有的java.util.Iterator
和java.util.Collection
类。当我们需要具有特定行为的聚合器时,我们还应该考虑扩展java.collection
包中实现的一个类,而不是创建一个新的类。
在本书中,我们不断提到解耦的重要性。当我们减少依赖性时,我们可以扩展、开发和测试不同的模块,而不必知道其他模块的实现细节。我们只需要知道它们实现的抽象。
然而,模块在实践中应该协同工作。一个对象中的变化被另一个对象知道,这并不少见。例如,如果我们在一个游戏中实现了一个car
类,那么汽车的引擎应该知道油门何时改变位置。最简单的解决方案是有一个engine
类,它会不时检查加速器的位置,看它是否发生了变化。一个更聪明的方法是让加速器给引擎打电话,让它知道这些变化。但是如果我们想拥有设计良好的代码,这是不够的。
如果Accelerator
类保留了对Engine
类的引用,那么当我们需要在屏幕上显示Accelerator
的位置时会发生什么?这是最好的解决方案:与其让加速器依赖于引擎,不如让它们都依赖于抽象。
观察者模式使一个对象的状态变化可以被其他对象观察到,这些对象被注册为被通知。
观察者模式的类图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n0DknL6Z-1681378128987)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/94a34ff4-dea6-46ba-93d5-d2906c1d22c3.png)]
观察者模式依赖于以下类:
Subject
:这通常是一个必须由类实现的接口,应该是可观察的。应通知的观察者使用attach()
方法注册。当不必再通知他们更改时,将使用detach()
方法取消注册。ConcreteSubject
:实现Subject
接口的类。它处理观察者列表,并更新他们关于更改的信息。Observer
:这是一个由对象实现的接口,对象的变化需要更新这个接口。每个观察者都应该实现update()
方法,该方法会通知他们新的状态变化。在许多情况下,当我们设计和开发软件应用时,我们会遇到许多场景,其中我们有必须相互通信的模块和对象。最简单的方法是让他们彼此了解,并且可以直接发送消息。
然而,这可能会造成混乱。例如,如果我们设想一个通信应用,其中每个客户端都必须连接到另一个客户端,那么客户端管理多个连接就没有意义了。更好的解决方案是连接到中央服务器,并由服务器管理客户端之间的通信。客户端将消息发送到服务器,服务器保持与所有客户端的连接处于活动状态,并且可以向所有所需的收件人广播消息。
另一个例子是需要一个专门的类在图形界面中的不同控件(如按钮、下拉列表和列表控件)之间进行中介。例如,GUI 中的图形控件可以相互引用,以便交互调用它们的方法。但显然,这将创建一个极为耦合的代码,其中每个控件都依赖于所有其他控件。更好的方法是让父级负责在需要执行某些操作时将消息广播到所有必需的控件。当控件中有修改时,它将通知窗口,窗口将检查哪些控件需要被通知,然后通知它们。
中介模式定义了一个对象,该对象封装了一组对象如何交互,从而减少了它们之间的依赖性。
中介模式基于两种抽象:Mediator
和Colleague
,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-w5DPcabl-1681378128987)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/fed68fc2-c734-4e79-86d3-4aba7ef0cfb4.png)]
中介模式依赖于以下类:
Mediator
:这定义了参与者是如何互动的。此接口或抽象类中声明的操作特定于每个场景。ConcreteMediator
:实现中介声明的操作。Colleague
:这是一个抽象类或接口,定义了需要中介的参与者应该如何进行交互。ConcreteColleague
:这些是实现Colleague
接口的具体类。当有许多实体以类似的方式交互时,应该使用中介模式,并且这些实体应该解耦。
中介模式在 Java 库中用于实现java.util.Timer
。timer
类可以用来安排线程以固定的间隔运行一次或多次。线程对象对应于ConcreteColleague
类。timer
类实现了管理后台任务执行的方法。
封装是面向对象设计的基本原则之一。我们也知道每个类都应该有一个单一的责任。当我们向对象添加功能时,我们可能会意识到我们需要保存其内部状态,以便能够在稍后的阶段恢复它。如果我们直接在类中实现这样的功能,那么类可能会变得太复杂,最终可能会打破单一责任原则。同时,封装阻止我们直接访问需要记忆的对象的内部状态。
备忘录模式用于保存对象的内部状态而不破坏其封装,并在后期恢复其状态。
备忘录模式依赖于三个类:Originator
、Memento
、CareTaker
,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-COPjXzD8-1681378128988)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/07a8192f-105b-4200-9523-4c4db5134748.png)]
备忘录模式依赖于以下类:
Originator
:发起者是我们需要记忆状态的对象,以备在某个时候需要恢复状态。CareTaker
:这个类负责触发发端人的变化,或者触发一个动作,发端人通过这个动作返回到以前的状态。Memento
:这个类负责存储发起者的内部状态。Memento
提供了两种设置和获取状态的方法,但是这些方法应该对管理员隐藏。实际上,备忘录比听起来容易得多。让我们把它应用到我们的汽车服务应用中。我们的机修工必须测试每辆车。他们使用一个自动装置来测量不同参数(速度、档位、刹车等)下汽车的所有输出。他们执行所有的测试,必须重新检查那些看起来可疑的。
我们首先创建originator
类。我们将其命名为CarOriginator
,并添加两个成员变量。state
表示测试运行时车辆的参数。这是我们要保存的对象的状态;第二个成员变量是result
。这是测得的汽车输出,我们不需要存储在备忘录。这是一个空巢备忘录的发起者:
public class CarOriginator
{
private String state;
public void setState(String state)
{
this.state = state;
}
public String getState()
{
return this.state;
}
public Memento saveState()
{
return new Memento(this.state);
}
public void restoreState(Memento memento)
{
this.state = memento.getState();
}
/**
* Memento class
*/
public static class Memento
{
private final String state;
public Memento(String state)
{
this.state = state;
}
private String getState()
{
return state;
}
}
}
现在我们对不同的州进行汽车测试:
public class CarCaretaker
{
public static void main(String s[])
{
new CarCaretaker().runMechanicTest();
}
public void runMechanicTest()
{
CarOriginator.Memento savedState = new CarOriginator.
Memento("");
CarOriginator originator = new CarOriginator();
originator.setState("State1");
originator.setState("State2");
savedState = originator.saveState();
originator.setState("State3");
originator.restoreState(savedState);
System.out.println("final state:" + originator.getState());
}
}
只要需要实现回滚操作,就使用备忘录模式。它可以用于所有类型的原子事务中,在这些事务中,如果其中一个操作失败,则必须将对象还原为初始状态。
有限状态机是计算机科学中的一个重要概念。它有一个强大的数学基础,它代表了一个抽象的机器,可以在有限的状态数。有限状态机应用于计算机科学的所有领域。
状态模式只是面向对象设计中有限状态机的一种实现。类图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Brd9kpke-1681378128988)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/c0679c9a-7512-4b02-8542-088d150c2564.png)]
特定于行为模式的一种特殊情况是,当我们需要改变解决另一个问题的方式时。正如我们在第一章已经学到的,改变是不好的,而扩展是好的。因此,我们可以将它封装在一个类中,而不是将代码的一部分替换为另一部分。然后我们可以创建代码所依赖的类的抽象。从那时起,我们的代码变得非常灵活,因为我们现在可以使用任何实现我们刚刚创建的抽象的类。
策略模式定义了一系列算法,将每个算法封装起来,并使它们可以互换。
策略模式的结构实际上与状态模式相同。然而,实现和意图完全不同:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QoK3w3LI-1681378128988)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/9a79967e-6311-4c2a-a326-13cc1913487d.png)]
策略模式非常简单:
Strategy
:对特定策略的抽象ConcreteStrategy
:实现抽象策略的类Context
:运行特定策略的类顾名思义,模板方法模式为代码提供了一个模板,可以由实现不同功能的开发人员填写。理解这一点最简单的方法是从 HTML 模板的角度来考虑。你访问的大多数网站都遵循某种模板。例如,通常有一个页眉、一个页脚和一个侧边栏,在它们之间,我们有核心内容。这意味着模板是用页眉、页脚和侧边栏定义的,每个内容编写器都可以使用此模板添加内容。
使用模板方法模式的想法是避免编写重复的代码,这样开发人员就可以专注于核心逻辑。
模板方法模式最好使用抽象类实现。我们所知道的关于实现的区域将被提供;默认实现和保持开放以供实现的区域被标记为抽象的。
例如,设想一个非常高级别的数据库获取查询。我们需要执行以下步骤:
我们可以看到,创建和关闭连接部分将始终保持不变。因此,我们可以将其添加为模板实现的一部分。其余的方法可以根据不同的需要独立实现。
空对象模式是本书中介绍的最轻的模式之一。有时,它被认为只是策略模式的一个特例,但考虑到它在实践中的重要性,它有自己的部分。
如果我们使用测试驱动的方法开发程序,或者如果我们只是想开发一个模块而不需要应用的其余部分,我们可以简单地用一个模拟类来替换我们没有的类,模拟类具有相同的结构,但什么也不做。
在下图中,我们可以看到我们只是创建了一个NullClass
,它可以替换程序中的实际类。如前所述,这只是策略模式的一个特例,在这种模式中,我们选择无所事事的策略。类图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hRfPiDva-1681378128988)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/854cd250-7c36-44bd-b999-927ee29fe5e3.png)]
让我们回到我们在讨论命令模式时介绍的形状应用。我们应用了命令模式,所以我们必须重做所实现的操作。是时候添加保存功能了。
我们可能会认为,如果我们向Shape
基类添加一个抽象的Save
方法,并对每个形状进行扩展,那么问题就解决了。这个解决方案也许是最直观的,但不是最好的。首先,每个类应该有一个单一的职责。
其次,如果需要更改保存每个形状的格式,会发生什么情况?如果我们要实现相同的方法来生成 XML 输出,那么我们是否必须更改为 JSON 格式?这种设计绝对不遵循开/关原则。
访问者模式将操作与其操作的对象结构分离,允许添加新操作而不更改结构类。
访问者模式在一个类中定义了一组操作:它为要操作的结构中的每种类型的对象定义了一个方法。只需创建另一个访问者,就可以添加一组新的操作。类图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v8R54HHM-1681378128989)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/4e1a433f-8105-460d-a053-1b4ed95d5d8b.png)]
访问者模式基于以下类:
Element
:表示对象结构的基类。结构中的所有类都是从它派生的,它们必须实现accept(visitor:visitor)
方法。ConcreteElementA
和ConcreteElementB
:这些都是具体的类,我们想向它们添加在Visitor
类中实现的外部操作。Visitor
:这是基础Visitor
类,它声明了每个ConcreteElement
对应的方法。方法的名称是相同的,但每个方法都根据其接受的类型进行区分。我们可以采用这种解决方案,因为在 Java 中,我们可以使用相同名称和不同签名的方法;但是,如果需要,我们可以使用不同的名称声明方法。ConcreteVisitor
:这是访问者的实现。当我们需要一组单独的操作时,我们只需创建另一个访问者。在本节中,我们讨论了各种行为模式。我们研究了一些最常用的行为模式,如责任链模式、命令模式、解释器模式等等。这些模式帮助我们以可控的方式管理对象的行为。在下一章中,我们将研究有助于我们管理复杂结构的结构模式。
本章的目的是学习结构模式。结构模式是通过利用对象和类之间的关系来创建复杂结构的模式。大多数结构模式都是基于继承的。在本章中,我们将只关注以下 GOF 模式:
我们可能无法详细介绍其他已确定的结构模式,但值得了解。具体如下:
Serializable
),从而可以按接口名进行搜索。有关更多信息,请阅读文章,《第 37 项 -使用标记接口定义类型》,引用了乔舒亚·布洛赫的《Effective Java(第二版)》。适配器模式为代码重用提供了一个解决方案;它将现有的旧代码适配/包装到新的接口,这些接口在原始代码的设计时是未知的。1987 年,当 PS/2 端口被设计出来时,没有人想到它会连接到 9 年后设计的 USB 总线上。然而,我们仍然可以使用一个旧的 PS/2 键盘在我们最新的电脑连接到 USB 端口。
适配器模式通常在处理遗留代码时使用,因为通过包装现有代码并使其适应新的代码接口,我们可以立即访问已经测试过的旧功能。这可以通过使用多个继承(在 Java8 中默认的接口实现是可能的)来实现,也可以通过使用组合(旧对象成为类属性)来实现。适配器模式也称为包装器。
如果旧代码需要使用新代码,反之亦然,我们需要使用一个称为双向适配器的特殊适配器,它实现两个接口(旧接口和新接口)。
JDK 中的java.io.InputStreamReader
和java.io.OutputStreamWriter
类是适配器,因为它们将 JDK1.0 中的输入/输出流对象适配到稍后在 JDK1.1 中定义的读写器对象。
其目的是将现有的旧接口应用到新的客户端接口。目标是尽可能地重用旧的和已经测试过的代码,同时可以自由地对新接口进行更改。
下面的 UML 图对新客户端代码和修改后的代码之间的交互进行了建模。适配器模式通常是通过使用多重继承在其他语言中实现的,从 Java8 开始这是部分可能的。我们将使用另一种方法,这种方法也适用于较旧的 Java 版本;我们将使用聚合。它比继承更具限制性,因为我们无法访问受保护的内容,只能访问适配器公共接口:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4znh3A7p-1681378128989)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/56380966-dcb3-4d46-afac-b8d1eddbc906.png)]
我们可以从实现图中区分以下参与者:
Client
:代码客户端Adapter
:将调用转发给被适配器的适配器类Adaptee
:需要修改的旧代码Target
:要支持的新接口下面的代码模拟在 USB 总线中使用 PS/2 键盘。它定义了一个 PS/2 键盘(适配器)、一个 USB 设备接口(目标)、一个 PS2ToUSBAdapter(适配器)和使设备工作的连接线:
package gof.structural.adapter;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
class WireCap
{
WireCap link = WireCap.LooseCap;
private Wire wire;
publicstatic WireCap LooseCap = new WireCap(null);
public WireCap(Wire wire)
{
this.wire = wire;
}
publicvoid addLinkTo(WireCap link)
{
this.link = link;
}
public Wire getWire()
{
return wire;
}
public String toString()
{
if (link.equals(WireCap.LooseCap))
return "WireCap belonging to LooseCap";
return "WireCap belonging to " + wire + " is linked to " +
link.getWire();
}
public WireCap getLink()
{
return link;
}
}
顾名思义,WireCap
类模型是每根导线的两端。默认情况下,所有导线都是松的;因此,我们需要一种方法来发出信号。这是通过使用空对象模式来完成的,LooseCap
是我们的空对象(一个空替换,它不抛出NullPointerException
)。请看下面的代码:
class Wire
{
private String name;
private WireCap left;
private WireCap right;
public Wire(String name)
{
this.name = name;
this.left = new WireCap(this);
this.right = new WireCap(this);
}
publicvoid linkLeftTo(Wire link)
{
left.addLinkTo(link.getRightWireCap());
link.getRightWireCap().addLinkTo(left);
}
public WireCap getRightWireCap()
{
return right;
}
publicvoid printWireConnectionsToRight()
{
Wire wire = this;
while (wire.hasLinkedRightCap())
{
wire.printRightCap();
wire = wire.getRightLink();
}
}
public Wire getRightLink()
{
return getRightWireCap().getLink().getWire();
}
publicvoid printRightCap()
{
System.out.println(getRightWireCap());
}
publicboolean hasLinkedRightCap()
{
return !getRightWireCap().link.equals(WireCap.LooseCap);
}
public String getName()
{
return name;
}
public String toString()
{
return "Wire " + name;
}
}
Wire
类对来自 USB 或 PS/2 设备的电线进行建模。它有两端,默认情况下是松散的,如以下代码所示:
class USBPort
{
publicfinal Wire wireRed = new Wire("USB Red5V");
publicfinal Wire wireWhite = new Wire("USB White");
publicfinal Wire wireGreen = new Wire("USB Green");
publicfinal Wire wireBlack = new Wire("USB Black");
}
根据 USB 规范,USBPort 有四根导线:5V 红色、绿色和白色导线用于数据,黑色导线用于接地,如下代码所示:
interface PS2Device
{
staticfinal String GND = "PS/2 GND";
staticfinal String BLUE = "PS/2 Blue";
staticfinal String BLACK = "PS/2 Black";
staticfinal String GREEN = "PS/2 Green";
staticfinal String WHITE = "PS/2 White";
staticfinal String _5V = "PS/2 5V";
public List<Wire> getWires();
publicvoid printWiresConnectionsToRight();
}
class PS2Keyboard implements PS2Device
{
publicfinal List<Wire> wires = Arrays.asList(
new Wire(_5V),
new Wire(WHITE),
new Wire(GREEN),
new Wire(BLACK),
new Wire(BLUE),
new Wire(GND));
public List<Wire> getWires()
{
return Collections.unmodifiableList(wires);
}
publicvoid printWiresConnectionsToRight()
{
for(Wire wire : wires)
wire.printWireConnectionsToRight();
}
}
PS2Keyboard
是适配器。我们需要使用的是旧设备,如下代码所示:
interface USBDevice
{
publicvoid plugInto(USBPort port);
}
USBDevice
是目标接口。它知道如何与USBPort
接口,如下代码所示:
class PS2ToUSBAdapter implements USBDevice
{
private PS2Device device;
public PS2ToUSBAdapter(PS2Device device)
{
this.device = device;
}
publicvoid plugInto(USBPort port)
{
List<Wire> ps2wires = device.getWires();
Wire wireRed = getWireWithNameFromList(PS2Device._5V,
ps2wires);
Wire wireWhite = getWireWithNameFromList(PS2Device.WHITE,
ps2wires);
Wire wireGreen = getWireWithNameFromList(PS2Device.GREEN,
ps2wires);
Wire wireBlack = getWireWithNameFromList(PS2Device.GND,
ps2wires);
port.wireRed.linkLeftTo(wireRed);
port.wireWhite.linkLeftTo(wireWhite);
port.wireGreen.linkLeftTo(wireGreen);
port.wireBlack.linkLeftTo(wireBlack);
device.printWiresConnectionsToRight();
}
private Wire getWireWithNameFromList(String name, List<Wire>
ps2wires)
{
return ps2wires.stream()
.filter(x -> name.equals(x.getName()))
.findAny().orElse(null);
}
}
PS2ToUSBAdapter
是我们的适配器类。它知道如何布线,以便新的USBPort
仍然可以使用旧的设备,如下代码所示:
publicclass Main
{
publicstaticvoid main (String[] args)
{
USBDevice adapter = new PS2ToUSBAdapter(new PS2Keyboard());
adapter.plugInto(new USBPort());
}
}
输出如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C0kcK7dV-1681378128989)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/8dd12513-b286-44f6-9514-1a19d3d5fd8f.png)]
正如预期的那样,我们的设备已连接到 USB 端口并准备好使用。所有接线都已完成,例如,如果 USB 端口将红线设置为 5 伏,则该值将到达键盘,如果键盘通过绿线发送数据,则该值将到达 USB 端口。
每当您使用企业或 SpringBeans、模拟实例和实现 AOP 时,对具有相同接口的另一个对象进行 RMI 或 JNI 调用,或者直接/间接使用java.lang.reflect.Proxy
,都会涉及到代理对象。它的目的是提供一个真实对象的代理,具有完全相同的封装外形。它在调用之前或之后执行其他操作时将工作委托给它。代理类型包括:
@Cacheable
方法,它缓存特定参数的方法结果,不调用实际代码,而是从缓存返回先前计算的结果。@Aspect
,为所需的方法定义一个@Pointcut
,并定义一个@Around
通知)或者进行延迟初始化。适配器和代理之间的主要区别在于代理提供完全相同的接口。装饰器模式增强了接口,而适配器改变了接口。
其目的是为真实对象提供代理,以便更好地控制它。它是一个实际对象的句柄,其行为类似于它,因此使客户端代码使用它就像使用实际对象一样。
下图对代理模式进行了建模。请注意,由于真实和代理主题都实现了相同的接口,因此它们可以互换:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-umFxFKPZ-1681378128989)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/687be95e-9a61-4304-99b4-5de30692b84b.png)]
我们可以在实现图中区分以下参与者:
Subject
:客户端使用的现有接口RealSubject
:真实对象的类ProxySubject
:代理类下面的代码模拟从 localhost EJB 上下文中查找 bean 的远程代理。我们的远程代理是在另一个 JVM 中运行的几何计算器。我们将使用工厂方法来制作代理和真实对象,以证明它们是可互换的。代理版本的计算时间更长,因为我们还模拟 JNI 查找部分并发送/检索结果。看看代码:
package gof.structural.proxy;
publicclass Main
{
publicstaticvoid main (String[] args) throws java.lang.Exception
{
GeometryCalculatorBean circle = GeometryCalculatorBeanFactory.
REMOTE_PROXY.makeGeometryCalculator();
System.out.printf("Circle diameter %fn",
circle.calculateCircleCircumference(new Circle()));
}
}
class Circle
{}
interface GeometryCalculatorBean
{
publicdouble calculateCircleCircumference(Circle circle);
}
这是我们的主题,我们要实现的接口。模拟@RemoteInterface
和@LocalInterface
接口的建模,如下代码所示:
class GeometryBean implements GeometryCalculatorBean
{
publicdouble calculateCircleCircumference(Circle circle)
{
return 0.1f;
}
}
这是我们真正的主题,知道如何执行实际的几何计算,如以下代码所示:
class GeometryBeanProxy implements GeometryCalculatorBean
{
private GeometryCalculatorBean bean;
public GeometryBeanProxy() throws Exception
{
bean = doJNDILookup("remote://localhost:4447", "user",
"password");
}
private GeometryCalculatorBean doJNDILookup
(final String urlProvider, final String securityPrincipal, final
String securityCredentials)
throws Exception
{
System.out.println("Do JNDI lookup for bean");
Thread.sleep(123);//simulate JNDI load for the remote location
return GeometryCalculatorBeanFactory.LOCAL.
makeGeometryCalculator();
}
publicdouble calculateCircleCircumference(Circle circle)
{
return bean.calculateCircleCircumference(circle);
}
}
这是我们的代理主题。请注意,它没有业务逻辑;它在设法建立对它的句柄之后,将它委托给真正的主题,如以下代码所示:
enum GeometryCalculatorBeanFactory
{
LOCAL
{
public GeometryCalculatorBean makeGeometryCalculator()
{
returnnew GeometryBean();
}
},
REMOTE_PROXY
{
public GeometryCalculatorBean makeGeometryCalculator()
{
try
{
returnnew GeometryBeanProxy();
}
catch (Exception e)
{
// TODO Auto-generated catch block
e.printStackTrace();
}
returnnull;
}
};
publicabstract GeometryCalculatorBean makeGeometryCalculator();
}
以下输出显示代理成功链接到真实对象并执行所需的计算:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a0wMjYI4-1681378128990)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/0e7c9d90-f3b1-4a45-8369-fe10e6dd9713.png)]
有时我们需要在不影响现有代码的情况下,向现有代码添加或从现有代码中删除功能,有时创建子类是不实际的。在这些情况下,装饰器非常有用,因为它允许在不更改现有代码的情况下这样做。它通过实现相同的接口、聚合要修饰的对象、将所有公共接口调用委派给它,并在子类中实现新功能来实现这一点。将此模式应用于具有轻量级接口的类。在其他情况下,通过将所需的策略注入组件(策略模式)来扩展功能是更好的选择。这将保持特定方法的局部更改,而不需要重新实现其他方法。
装饰对象及其装饰器应该是可互换的。装饰器的接口必须完全符合装饰对象的接口。
因为它使用递归,所以可以通过组合装饰器来实现新功能。在这方面,它类似于复合模式,它将多个对象组合在一起,以形成作为一个对象的复杂结构。装饰器可以被视为护照上的一块玻璃或一张卡片(安装在一块玻璃和一张卡片之间的图片或照片),其中图片/照片本身就是装饰对象。另一方面,策略可以看作是艺术家在照片上的签名。
JScrollPane
swing 类是装饰器的一个示例,因为它允许在现有容器周围添加新功能,例如滚动条,并且可以多次执行,如下代码所示:
JTextArea textArea = new JTextArea(10, 50);
JScrollPane scrollPane1 = new JScrollPane(textArea);
JScrollPane scrollPane2 = new JScrollPane(scrollPane1);
其目的是动态扩展现有对象的功能,而不更改其代码。它符合原始接口,并且能够通过使用组合(而不是子类化)在功能上扩展。
下图对装饰器模式进行了建模。结果表明,扩展构件和修饰构件可以相互替换。装饰器可以递归地应用;它可以应用于现有的组件实现,但也可以应用于另一个装饰器,甚至应用于它自己。装饰器接口不是固定到组件接口的;它可以添加额外的方法,装饰器的子级可以使用这些方法,如图所示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4O0qs6Oc-1681378128990)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/2f78a8bc-9189-4132-9597-590ce8f39abf.png)]
我们可以在实现图中区分以下参与者:
Component
:抽象组件(可以是接口)ComponentImplementation
:这是我们要装饰的组件之一Decorator
:这是一个抽象的组件Decorator
ExtendedComponent
:这是添加额外功能的组件装饰器下面的代码显示了如何增强简单的打印 ASCII 文本,以打印输入的十六进制等效字符串,以及实际文本:
package gof.structural.decorator;
import java.util.stream.Collectors;
publicclass Main
{
publicstaticvoid main (String[] args) throws java.lang.Exception
{
final String text = "text";
final PrintText object = new PrintAsciiText();
final PrintText printer = new PrintTextHexDecorator(object);
object.print(text);
printer.print(text);
}
}
interface PrintText
{
publicvoid print(String text);
}
PrintText is the component interface:
class PrintAsciiText implements PrintText
{
publicvoid print(String text)
{
System.out.println("Print ASCII: " + text);
}
}
PrintASCIIText
是要装饰的构件。注意,它只知道如何打印ASCII
文本。我们想让它也以十六进制打印;我们可以使用下面的代码
class PrintTextHexDecorator implements PrintText
{
private PrintText inner;
public PrintTextHexDecorator(PrintText inner)
{
this.inner = inner;
}
publicvoid print(String text)
{
String hex = text.chars()
.boxed()
.map(x -> "0x" + Integer.toHexString(x))
.collect(Collectors.joining(" "));
inner.print(text + " -> HEX: " + hex);
}
}
PrintTextHexDecorator
是装饰师。也可应用于其它PrintText
元件。假设我们要实现一个组件PrintToUpperText
。我们可能仍然使用我们现有的装饰,使其打印十六进制以及。
以下输出显示当前功能(ASCII)和新添加的功能(十六进制显示):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z2kITj00-1681378128990)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/a386e857-80fe-4faa-842f-eb9455095f8c.png)]
在软件设计过程中,我们可能会面临一个问题,即同一个抽象可以有多个实现。这在进行跨平台开发时最为明显。例如 Linux 上的换行符换行符或 Windows 上存在注册表。需要通过运行特定操作系统调用来获取特定系统信息的 Java 实现肯定需要能够改变实现。一种方法是使用继承,但这会将子级绑定到特定接口,而该接口可能不存在于不同的平台上。
在这些情况下,建议使用桥接模式,因为它允许从扩展特定抽象的大量类转移到嵌套泛化,这是 Rumbaugh 创造的一个术语,在这里我们处理第一个泛化,然后处理另一个泛化,从而将所有组合相乘。如果所有子类都同等重要,并且多个接口对象使用相同的实现方法,那么这种方法就可以很好地工作。如果由于某种原因,大量代码被复制,这就表明这种模式不是解决特定问题的正确选择。
其目的是将抽象与实现分离,以允许它们独立地变化。它通过在公共接口和实现中使用继承来实现这一点。
下图显示了一个可能的网桥实现。请注意,抽象和实现都可以更改,不仅接口可以更改,实现代码也可以更改。例如,精化抽象可以利用只有SpecificImplementation
提供的doImplementation3()
:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XYMfMRcE-1681378128990)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/97eed6fb-6f8d-41df-a03b-10eee13f3460.png)]
我们可以在实现图中区分以下参与者:
Abstraction
:这是抽象组件Implementation
:这是抽象实现Refined
:这是具体组件SpecificImplementation
:这是具体实现下面的代码展示了一个电子邮件客户端,它使用了基于运行平台的实现。可以使用工厂方法模式对其进行增强,以创建特定的平台实现:
package gof.structural.bridge;
publicclass Main
{
publicstaticvoid main (String[] args)
{
new AllMessageClient(new WindowsImplementation())
.sendMessageToAll("[email protected]", "Test");
}
}
interface PlatformBridge
{
publicvoid forwardMessage(String msg);
}
PlatformBridge
是我们的实现抽象类。它指定了每个实现需要提供什么—在我们的例子中,是转发文本给出的消息。以下两种实现(Windows 和 POSIX)都知道如何执行此任务:
class WindowsImplementation implements PlatformBridge
{
publicvoid forwardMessage(String msg)
{
System.out.printf("Sending message n%s nFrom the windows
machine", msg);
}
}
class PosixImplementation implements PlatformBridge
{
publicvoid forwardMessage(String msg)
{
System.out.printf("Sending message n%s nFrom the linux
machine", msg);
}
}
class MessageSender
{
private PlatformBridge implementation;
public MessageSender(PlatformBridge implementation)
{
this.implementation = implementation;
}
publicvoid sendMessage(String from, String to, String body)
{
implementation.forwardMessage(String.format("From :
%s nTo : %s nBody : %s", from, to, body));
}
}
抽象MessageSender
使用特定于平台的实现发送消息。AllMessageClient
细化抽象向特定组[email protected]
发送消息。其他可能的精化抽象可以包括特定于平台的代码和对平台实现的调用。代码如下:
class AllMessageClient extends MessageSender
{
private String to = "[email protected]";
public MyMessageClient(PlatformBridge implementation)
{
super(implementation);
}
publicvoid sendMessageToAll(String from, String body)
{
sendMessage(from, to, body);
}
}
以下输出显示所有消息客户端都使用 Windows 实现发送了消息:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wy5nqUf0-1681378128990)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/9a2cf576-cd90-4390-a040-33a4f15b10ee.png)]
顾名思义,复合模式是在将对象组合成一个作为一个对象的复杂结构时使用的(请参阅下图)。在内部,它使用数据结构(如树、图形、数组或链表)来表示模型:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uW5VgQlV-1681378128991)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/d3840628-21ac-4275-a843-e610f2513e46.png)]
JVM 提供了复合模式的最佳示例,因为它通常被实现为一个栈机器(出于可移植性的原因)。从当前线程栈中推送和弹出操作。例如,要计算1+4-2
等于什么,它将按 1、按 4,然后执行加法。栈现在只有值 5,按下 2,然后执行减号。现在栈只有值 3,这是弹出的。操作1+4+2-
(反向波兰符号)可以使用复合模式轻松建模,其中每个节点都是值、复数或操作数。每个节点都有一个执行操作的perform
方法(push
、execute
和pop
或combine
,具体取决于类型)。
Composite 使用递归组合,其中客户端代码以相同的方式处理每个部分、叶或节点。
其目的是将对象建模为树或图形结构,并以相同的方式处理它们。客户端代码不需要知道节点是单个对象(叶节点)还是对象的组合(具有子节点的节点,如根节点);客户端代码可以对这些细节进行抽象并统一处理。
下图显示客户端使用组件接口doSomething()
方法。该方法在根节点和叶节点中的实现方式不同。根节点可以有 1 到n
子节点;叶节点没有子节点。当子树的数目为 2 且不存在循环时,我们有一个二叉树的情况:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y5sQn1Qe-1681378128991)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/5caeb982-741d-4769-9591-a8289f17dea4.png)]
我们可以在实现图中区分以下参与者:
Client
:客户端代码Component
:抽象节点Leaf
:叶子节点Composite
:具有子节点的复合节点,子节点可以是复合节点,也可以是叶节点下面的代码为算术表达式计算器建模。表达式被构造为复合表达式,并且只有一个方法-getValue
。这将给出当前值;对于叶,它是叶数值,对于组合节点,它是子组合值:
package gof.structural.composite;
publicclass Main
{
publicstaticvoid main (String[] args) throws java.lang.Exception
{
ArithmeticComposite expr = new MinusOperand(
new PlusOperand(new NumericValue(1), new NumericValue(4)),
new NumericValue(2));
System.out.printf("Value equals %dn", expr.getValue());
}
}
客户端代码创建一个(1 + 4) - 2
算术表达式并打印其值,如下代码所示:
interface ArithmeticComposite
{
publicint getValue();
}
ArithmeticComposite
是我们的复合接口,它只知道如何返回一个整数值,表示算术表达式的值(复合ArithmeticOperand
)或持有值(叶子NumericValue
),如下代码所示:
class NumericValue implements ArithmeticComposite
{
privateint value;
public NumericValue(int value)
{
this.value = value;
}
publicint getValue()
{
return value;
}
}
abstractclass ArithmeticOperand implements ArithmeticComposite
{
protected ArithmethicComposite left;
protected ArithmethicComposite right;
public ArithmethicOperand(ArithmeticComposite left,
ArithmeticComposite right)
{
this.left = left;
this.right = right;
}
}
class PlusOperand extends ArithmeticOperand
{
public PlusOperand(ArithmeticComposite left,
ArithmeticComposite right)
{
super(left, right);
}
publicint getValue()
{
return left.getValue() + right.getValue();
}
}
class MinusOperand extends ArithmeticOperand
{
public MinusOperand(ArithmeticComposite left,
ArithmeticComposite right)
{
super(left, right);
}
publicint getValue()
{
return left.getValue() - right.getValue();
}
}
PlusOperand
和MinusOperand
是当前支持的算术类型。他们知道如何表示加号(+)和减号(-)的算术表达式。
如预期,(1 + 4) - 2
算术表达式返回 3,并将值打印到控制台,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vjtk2mCV-1681378128991)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/f2cdc200-c021-41d4-af7c-9c654e878838.png)]
许多复杂的系统可以简化为它们的几个用例,由子系统公开。这样,客户端代码就不需要了解子系统的内部结构。换句话说,客户端代码与之解耦,开发人员使用它所花费的时间更少。这被称为外观模式,外观对象负责公开所有子系统的功能。这个概念类似于封装,即隐藏对象的内部。在外观中,我们隐藏了子系统的内部,只暴露了其本质。其结果是,用户仅限于由外观公开的功能,并且不能使用/重用子系统的特定功能。
外观模式需要采用内部子系统接口(多个接口)到客户端代码接口(一个接口)。它通过创建一个新接口来实现这一点,而适配器模式适应现有接口(有时需要多个旧类来为新代码提供所需的功能)。外观对结构的作用与中介对对象通信的作用一样,它统一并简化了使用。在第一种情况下,客户端代码通过使用外观对象访问子系统的功能;在第二种情况下,不知道彼此(松耦合)的对象可以通过使用中介器/促进者进行交互。
其目的是为复杂的子系统提供一个统一的接口。这通过为最重要的用例提供接口简化了大型复杂系统的使用。
下图显示了如何简化子系统的使用并将其与客户端代码解耦。外观是子系统的入口点;因此,子系统代码可以很容易地切换到不同的实现。客户端依赖关系也可以更容易地管理,并且更明显:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eYBlvx7k-1681378128992)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/dd39d2e8-9db2-41ca-8666-6cedea4117aa.png)]
我们可以在实现图中区分以下参与者:
Client
:子系统客户端代码Facade
:子系统接口SubSystem
:子系统中定义的类咖啡机就像咖啡研磨机和咖啡酿造机的正面,因为它们隐藏了它们的功能。下面的代码模拟了一台咖啡机,它可以研磨咖啡豆、冲泡咖啡并将其放入咖啡杯中。
从下面的代码中你会发现,问题是我们不能得到细磨咖啡(我们必须把咖啡豆磨得再久一点),因为serveCoffee()
方法只知道如何制作粗磨咖啡。这对一些喝咖啡的人来说是好的,但对所有人来说不是这样:
package gof.structural.facade;
publicclass Main
{
publicstaticvoid main (String[] args) throws java.lang.Exception
{
CoffeeMachineFacade facade = new SuperstarCoffeeMachine();
facade.serveCoffee();
}
}
class GroundCoffee
{}
class Water
{}
class CoffeeCup
{}
GroundCoffee
、Water
和CoffeeCup
是我们将要使用的项目类:
interface CoffeeMachineFacade
{
public CoffeeCup serveCoffee() throws Exception;
}
CoffeeMachineFacade
是我们的正面。它提供了一个方法,返回一个包含Coffee
的CoffeCup
:
interface CoffeeGrinder
{
publicvoid startGrinding();
public GroundCoffee stopGrinding();
}
interface CoffeeMaker
{
publicvoid pourWater(Water water);
publicvoid placeCup(CoffeeCup cup);
publicvoid startBrewing(GroundCoffee groundCoffee);
public CoffeeCup finishBrewing();
}
class SuperstarCoffeeGrinder implements CoffeeGrinder
{
publicvoid startGrinding()
{
System.out.println("Grinding...");
}
public GroundCoffee stopGrinding ()
{
System.out.println("Done grinding");
returnnew GroundCoffee();
}
}
class SuperstarCoffeeMaker implements CoffeeMaker
{
public CoffeeCup finishBrewing()
{
System.out.println("Done brewing. Enjoy!");
returnnull;
}
@Override
publicvoid pourWater(Water water)
{
System.out.println("Pouring water...");
}
@Override
publicvoid placeCup(CoffeeCup cup)
{
System.out.println("Placing the cup...");
}
@Override
publicvoid startBrewing(GroundCoffee groundCoffee)
{
System.out.println("Brewing...");
}
}
为了煮咖啡,我们使用不同的机器,比如咖啡研磨机和咖啡机。它们都是巨星公司的产品。外观机器是一个虚拟机;它只是我们现有机器的一个接口,并且知道如何使用它们。不幸的是,它不是高度可配置的,但它完成了大多数现有的咖啡饮料者的工作。让我们看看这个代码:
class SuperstarCoffeeMachine implements CoffeeMachineFacade
{
public CoffeeCup serveCoffee() throws InterruptedException
{
CoffeeGrinder grinder = new SuperstarCoffeeGrinder();
CoffeeMaker brewer = new SuperstarCoffeeMaker();
CoffeeCup cup = new CoffeeCup();
grinder.startGrinding();
Thread.sleep(500);//wait for grind size coarse
brewer.placeCup(cup);
brewer.pourWater(new Water());
brewer.startBrewing(grinder.stopGrinding());
Thread.sleep(1000);//wait for the brewing process
return brewer.finishBrewing();
}
}
以下输出显示,我们的立面能够提供我们的早餐咖啡:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qmDtrEF9-1681378128992)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/06dd0adf-9667-482b-8137-4304b855a0d0.png)]
创建对象需要花费时间和资源。最好的例子是 Java 常量字符串创建、Boolean.valueOf(boolean b)
或Character valueOf(char c)
,因为它们从不创建实例;它们返回不可变的缓存实例。为了提高速度(并保持较低的内存占用),应用使用对象池。对象池模式和享元模式的区别在于,第一个(创建模式)是一个保存可变域对象的容器,而享元(结构模式)是一个不可变的域对象。因为它们是不可变的,所以它们的内部状态是在创建时设置的,外部状态是在每次方法调用时从外部给定的。
大多数 Web 应用使用连接池—创建/获取、使用数据库连接并将其发送回连接池。由于这种模式非常常见,因此它有一个名称:连接享元。其他资源,如套接字或线程(线程池模式),也使用对象池。
享元和外观的区别在于前者知道如何制作许多小对象,而后者制作单个对象,简化并隐藏了由许多对象组成的子系统的复杂性。
其目的是通过在相似对象之间共享状态来减少内存占用。只有将大量的对象减少到具有代表性的、不依赖于对象相等性的、并且它们的状态可以外化的少数对象,才能做到这一点。
下图显示了享元对象是从池中返回的,为了运行,它需要将外部状态(extrinsic)作为参数传递。有些享元可以与其他享元共享状态,但这不是强制执行的规则:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xPd2GvjJ-1681378128992)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/45661156-c9d3-4ba4-9bda-f8045cd6fcfa.png)]
我们可以在实现图中区分以下参与者:
Client
:客户端代码。FlyweightFactory
:如果享元不存在,则创建享元;如果享元存在,则从池中返回享元。Flyweight
:抽象享元。ConcreateShareableFlyweight
:设计为与对等方共享状态的享元。ConcreateUnshareableFlyweight
:不共享其状态的享元。它可以由多个混凝土享元组成,例如,一个由三维立方体和球体组成的结构。下面的代码使用附加的物理引擎模拟三维世界。因为创建新的 3D 对象在内存方面是沉重和昂贵的,一旦创建它们就会是相同的,只是从一个地方移动到另一个地方。想象一个有很多岩石、树木、灌木和不同纹理的 3D 世界。只有一种岩石,一棵树,一丛灌木(它们可以共享一些纹理),只要记住它们的位置,我们就节省了大量的内存,我们仍然能够用它们填充相当大的地形:
package gof.structural.flyweight;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
publicclass Main
{
publicstaticvoid main (String[] args) throws java.lang.Exception
{
World world = new World();
world.get3DObject(_3DObjectTypes.Cube).makeVisible().
move(10d, -13.3d, 90.0d);
world.get3DObject(_3DObjectTypes.Sphere).makeVisible().
move(11d, -12.9d, 90.0d);
world.get3DObject(_3DObjectTypes.Cube).makeVisible().
move(9d, -12.9d, 90.0d);
}
}
enum _3DObjectTypes
{
Cube,
Sphere
}
我们的 3D 世界目前只由立方体和球体构成。它们可以组合在一起形成更复杂的形式,如以下代码所示:
class PhysicsEngine
{
publicvoid animateCollision(_3DObject collider, _3DObject
collidee)
{
System.out.println("Animate Collision between " + collider +
" and " + collidee);
}
}
class World
{
private PhysicsEngine engine = new PhysicsEngine();
private Map<String, _3DObject> objects = new ConcurrentHashMap<>();
private Map<String, Location> locations = new ConcurrentHashMap<>();
public _3DObject get3DObject(_3DObjectTypes type)
{
String name = type.toString();
if (objects.containsKey(name))
return objects.get(name);
_3DObject obj = make3DObject(type);
objects.put(obj.getName(), obj);
return obj;
}
private _3DObject make3DObject(_3DObjectTypes type)
{
switch (type)
{
caseCube:
returnnew Cube(this, type.toString());
caseSphere:
returnnew Sphere(this, type.toString());
default:
returnnew _3DObject(this, type.toString());
}
}
publicvoid move(_3DObject obj, Location location)
{
final List<String> nearObjectNames = getNearObjects(location);
locations.put(obj.getName(), location);
for (String nearObjectName: nearObjectNames)
{
engine.animateCollision(objects.get(nearObjectName), obj);
}
}
private List<String> getNearObjects(Location location)
{
if (objects.size() < 2)
returnnew ArrayList<>();
return objects.values().stream()
.filter(obj ->
{
Location loc = locations.get(obj.getName());
return loc != null && loc.isNear(location, 1);
})
.map(obj -> obj.getName())
.collect(Collectors.toList());
}
}
World
类表示享元工厂。它知道如何构造它们,并把自己当作一种外在的状态。除了渲染部分外,World
类还使用了昂贵的物理引擎,它知道如何对碰撞进行建模。让我们看看代码:
class _3DObject
{
private World world;
private String name;
public _3DObject(World world, String name)
{
this.world = world;
this.name = name;
}
public String getName()
{
return name;
}
@Override
public String toString()
{
return name;
}
public _3DObject makeVisible()
{
returnthis;
}
publicvoid move(double x, double y, double z)
{
System.out.println("Moving object " + name + " in the world");
world.move(this, new Location(x, y, z));
}
}
class Cube extends _3DObject
{
public Cube(World world, String name)
{
super(world, name);
}
}
class Sphere extends _3DObject
{
public Sphere(World world, String name)
{
super(world, name);
}
}
三维物体Sphere
和Cube
是享元,它们没有同一性。World
类知道它们的身份和属性(位置、颜色、纹理和大小)。请看下面的代码:
class Location
{
public Location(double x, double y, double z)
{
super();
}
publicboolean isNear(Location location, int radius)
{
returntrue;
}
}
下面的输出显示,即使在三维世界中已经有一个立方体,添加另一个立方体也会使它与现有对象(另一个立方体和一个球体)发生碰撞。他们都没有身份;他们都是他们类型的代表:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a2wj48Rz-1681378128992)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/46d8a038-d3e0-4b3c-a7c8-db69402955a5.png)]
在本章中,我们学习了 GOF 结构模式。我们查看了它们的描述和意图,并用示例代码说明了它们的用法。我们学习了为什么,何时,以及如何应用它们,同时也研究了它们之间的细微差别。我们还简要介绍了其他鲜为人知的结构模式。
在接下来的章节中,我们将看到这些模式中的一些是如何在函数式和反应式世界中发生变化的。
本章的目的是学习函数模式,以及通过引入函数式编程风格(现在在最重要的编程语言中是可能的)对传统模式所做的更改。Java8 引入了一些函数式特性,增加了一个新的抽象级别,影响了我们编写一些面向对象设计模式的方式,甚至使其中一些模式变得无关紧要。在本章中,我们将看到设计模式是如何被新的语言特性所改变,甚至取代的。在他的论文《动态语言中的设计模式》中,Peter Norvig 注意到 23 种设计模式中有 16 种更简单,或者被动态语言中现有的语言特征所取代,比如 Dylan。全文见这个页面。在这一章中,我们将看到什么可以被取代,以及新出现的模式是怎样和怎样的。正如 peternorvig 在他的论文中所说的,很久以前,子程序调用只是一种模式,随着语言的发展,这些模式会发生变化或被替换。
为了运行本章中的代码,我们使用了 Java 中可用的 JShell REPL 工具,可以从 Windows 中的$JAVA_HOME/bin/jshell on Linux or %JAVA_HOME%/bin/jshell.exe
访问该工具。
在 20 世纪 30 年代,数学家阿隆佐教会发展了 Lambda 微积分。这是函数式编程范式的起点,因为它提供了理论基础。下一步是 John McCarthy 于 1958 年设计的 LISP(简称列表编程)。LISP 是第一种函数式编程语言,它的一些风格,如 commonlisp,至今仍在使用。
在函数式编程(通常缩写为 FP)中,函数是一级公民;这意味着软件是通过将函数而不是对象组合为 OOP 来构建的。这是以声明的方式完成的,告诉而不请求它,通过组合函数,促进不变性,避免副作用和共享数据。这就产生了一个更简洁的代码,它对变化具有弹性、可预测性,并且更易于维护和业务人员阅读。
函数代码具有更高的信噪比;我们必须编写更少的代码才能实现与 OOP 相同的功能。通过避免副作用和数据突变,依靠数据转换,系统变得更简单,更易于调试和修复。另一个好处是可预测性。我们知道,对于同一个输入,同一个函数总是会给出相同的输出;因此,它也可以用于并行计算,在任何其他函数之前或之后调用(CPU/编译器不需要对调用顺序进行假设),其返回值一经计算就可以缓存,从而提高性能。
作为一种声明式编程类型,它更关注需要做什么,而命令式则侧重于应该如何做。样品流如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-87QAL6Z8-1681378128993)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/802a63ff-3eb0-4942-805e-4d0700b6c553.png)]
函数式编程范式使用以下概念和原则:
这个名字来自 Lambda 演算,希腊字母 Lambda(λ
)用于将一个术语绑定到一个函数。Lambda 项可以是变量(x
,例如,λ.x.M
,其中M
是函数或应用,其中两个项,M
和N
相互应用。通过构造(合成)术语,现在可以进行表达式缩减和/或转换。Lambda 表达式缩减可以通过使用解释器进行在线测试,例如 Berkeley 的解释器。
以下是用于在已知x
、y
坐标时计算圆半径平方的 Lambda 演算 Lambda 表达式的示例:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DWdszx2Z-1681378128993)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/32aaa83a-4d8b-4c11-b447-3e2c1c89604c.png)]
它在数学上定义为一个 n 元函数:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jkkeCv0m-1681378128993)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/483911fb-d2f2-4532-af2b-0c12c15a9684.png)]
申请如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bcOZTv77-1681378128993)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/65470300-9036-4d64-bda5-9d4b474d9c9a.png)]
这是柯里化版本(注意额外的减少步骤):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YdDgczGv-1681378128993)(https://gitcode.net/apachecn/apachecn-java-zh/-/raw/master/docs/design-pattern-best-prac-java/img/4bbd6be2-aeb6-49d0-9bac-ceddaba43ac0.png)]
在语句上使用 Lambda 表达式的主要好处是 Lambda 表达式可以组合并简化为更简单的形式。
Java8 引入了 Lambda 表达式(以前通过使用匿名类提供),实现使用了 Java8 中引入的 invoke 动态,而不是匿名类,以提高性能(需要加载太多生成的类)和定制(将来的更改)的原因。
纯函数是一个没有副作用的函数,它的输出对于相同的输入是相同的(可预测的和可缓存的)。副作用是修改函数外部上下文的操作。这方面的例子包括:
副作用有时是不可避免的,甚至是需要的——I/O 或低级操作就是带有副作用的代码的例子(冯·诺依曼机器因为副作用而工作)。根据经验,尝试将有副作用的函数与代码的其余部分隔离开来。Haskell 和其他函数式编程语言使用 monad 来完成任务。稍后我们将有一个关于单子的介绍部分。
由于纯函数的输出是可预测的,因此也可以用缓存的输出替换它;这就是为什么纯函数被称为提供引用透明性的原因。Robert Martin 在他的书《Clean Code》中写道,纯函数更容易阅读和理解:
“事实上,花在阅读和写作上的时间之比远远超过 10:1。作为编写新代码的一部分,我们不断地阅读旧代码。。。[因此,]使阅读更容易,使写作更容易。”
在代码中使用纯函数可以提高工作效率,并允许新手花更少的时间阅读新代码,花更多的时间使用和修复新代码。
引用透明性是一个函数的属性,它可以用输入的返回值替换。好处是巨大的,因为这有利于记忆(缓存返回值)和对特定函数调用的并行化。测试这样的函数也很容易。
第一类函数是可以像面向对象编程中创建、存储、用作参数和作为值返回的对象一样处理的函数。
高阶函数是可以将其他函数作为参数,创建并返回它们的函数。它们通过使用现有的和已经测试过的小函数来促进代码重用。例如,在下面的代码中,我们计算给定温度(华氏度)的平均值(摄氏度):
jshell> IntStream.of(70, 75, 80, 90).map(x -> (x - 32)*5/9).average();
$4 ==> OptionalDouble[25.5]
注意在高阶map
函数中使用 Lambda 表达式。相同的 Lambda 表达式可以在多个地方用于转换温度。
jshell> IntUnaryOperator convF2C = x -> (x-32)*5/9;
convF2C ==> $Lambda$27/1938056729@4bec1f0c
jshell> IntStream.of(70, 75, 80, 90).map(convF2C).average();
$6 ==> OptionalDouble[25.5]
jshell> convF2C.applyAsInt(80);
$7 ==> 26Function
在数学中,函数是用一个函数的输出作为下一个函数的输入而组合起来的。同样的规则也适用于函数式编程,其中一阶函数由高阶函数使用。前面的代码已经包含了这样一个示例,请参见map
函数中的andThen
纯函数的用法。
为了使函数的组成更加直观,我们可以用andThen
方法重写转换公式:
jshell> IntUnaryOperator convF2C = ((IntUnaryOperator)(x -> x-32)).andThen(x -> x *5).andThen(x -> x / 9);
convF2C ==> java.util.function.IntUnaryOperator$$Lambda$29/1234776885@dc24521
jshell> convF2C.applyAsInt(80);
$23 ==> 26
柯里化是将一个 n 元函数转化为一系列或一元函数的过程,它是以美国数学家 Haskell Curry 的名字命名的。形式g:: x -> y -> z
是f :: (x, y) -> z
的柯里化形式。对于前面给出的平方半径公式,f(x,y) = x2 + y2
,一个柯里化版本,不使用双函数,将使用apply
多次。一个函数的单一应用只会用一个值替换参数,正如我们前面看到的。下面的代码展示了如何创建一个双参数函数,对于n
个参数,Function
类的apply
函数将有 n 个调用:
jshell> Function<Integer, Function<Integer, Integer>> square_radius = x -> y -> x*x + y*y;
square_radius ==> $Lambda$46/1050349584@6c3708b3
jshell> List<Integer> squares = Arrays.asList(new Tuple<Integer, Integer>(1, 5), new Tuple<Integer, Integer>(2, 3)).stream().
map(a -> square_radius.apply(a.y).apply(a.x)).
collect(Collectors.toList());
squares ==> [26, 13]
闭包是实现词汇作用域的一种技术。词法范围允许我们访问内部范围内的外部上下文变量。假设在前面的例子中,y
变量已经被赋值。Lambda 表达式可以保持一元表达式,并且仍然使用y
作为变量。这可能会导致一些很难找到的 bug,如在下面的代码中,我们希望函数的返回值保持不变。闭包捕获一个对象的当前值,正如我们在下面的代码中看到的,我们的期望是,add100
函数总是将 100 添加到给定的输入中,但是它没有:
jshell> Integer a = 100
a ==> 100
jshell> Function<Integer, Integer> add100 = b -> b + a;
add100 ==> $Lambda$49/553871028@eec5a4a
jshell> add100.apply(9);
$38 ==> 109
jshell> a = 101;
a ==> 101
jshell> add100.apply(9);
$40 ==> 110
在这里,我们期望得到 109,但是它用 110 回答,这是正确的(101 加 9 等于 110);我们的a
变量从 100 变为 101。闭包需要谨慎使用,而且,根据经验,使用final
关键字来限制更改。闭包并不总是有害的;在我们想要共享当前状态的情况下(并且在需要的时候能够修改它),闭包非常方便。例如,我们将在需要提供数据库连接(抽象连接)的回调的 API 中使用闭包;我们将使用不同的闭包,每个闭包提供基于特定数据库供应商设置的连接,通常从外部上下文中已知的属性文件读取。它可以用函数的方式实现模板模式。
在《Effective Java》中,Joshua Bloch 提出了如下建议:将对象视为不可变的。在 OOP 世界中需要考虑这个建议的原因在于可变代码有许多可移动的部分;它太复杂,不容易理解和修复。促进不变性简化了代码,并允许开发人员专注于流,而不是关注一段代码可能产生的副作用。最糟糕的副作用是,一个地方的微小变化可能会在另一个地方产生灾难性的结果(蝴蝶效应)。可变代码有时很难并行化,并且常常使用不同的锁。
函子允许我们对给定的容器应用函数。他们知道如何从包装对象中展开值,应用给定的函数,并返回另一个包含结果/转换包装对象的函子。它们很有用,因为它们抽象了多种习惯用法,如集合、Future
(Promise
)和Optional
。下面的代码演示了 Java 中的Optional
函子的用法,其中Optional
可以是一个给定的值,这是将函数应用于现有的包装值(5
的Optional
的结果):
jshell> Optional<Integer> a = Optional.of(5);
a ==> Optional[5]
现在我们将函数应用于值为 5 的包装整数对象,得到一个新的可选保持值 4.5:
jshell> Optional<Float> b = a.map(x -> x * 0.9f);
b ==> Optional[4.5]
jshell> b.get()
$7 ==> 4.5
Optional
是一个函子,类似于 Haskell 的Maybe
(只是| Nothing
),它甚至有一个静态Optional.empty()
方法,返回一个没有值(Nothing
)的Optional
。
应用添加了一个新级别的包装,而不是将函数应用于包装对象,函数也被包装。在下面的代码中,函数被包装在一个可选的。为了证明应用的一个用法,我们还提供了一个标识(所有内容都保持不变)选项,以防所需的函数(在我们的例子中是toUpperCase
)为空。因为没有语法糖来自动应用包装函数,所以我们需要手动执行,请参阅get().apply()
代码。注意 Java9 added 方法Optional.or()
的用法,如果我们的输入Optional
为空,它将延迟返回另一个Optional
:
jshell> Optional<String> a = Optional.of("Hello Applicatives")
a ==> Optional[Hello Applicatives]
jshell> Optional<Function<String, String>> upper = Optional.of(String::toUpperCase)
upper ==> Optional[$Lambda$14/2009787198@1e88b3c]
jshell> a.map(x -> upper.get().apply(x))
$3 ==> Optional[HELLO APPLICATIVES]
这是我们的应用,它知道如何将给定的字符串大写。让我们看看代码:
jshell> Optional<Function<String, String>> identity = Optional.of(Function.identity())
identity ==> Optional[java.util.function.Function$$Lambda$16/1580893732@5c3bd550]
jshell> Optional<Function<String, String>> upper = Optional.empty()
upper ==> Optional.empty
jshell> a.map(x -> upper.or(() -> identity).get().apply(x))
$6 ==> Optional[Hello Applicatives]
前面的代码是我们的应用,它将标识函数(输出与输入相同)应用于给定的字符串。
单子应用一个函数,将一个包装值返回给一个包装值。Java 包含了Stream
、CompletableFuture
和已经出现的Optional
等示例。flatMap
函数通过将给定的函数应用于邮政编码映射中可能存在或不存在的邮政编码列表来实现这一点,如下代码所示:
jshell> Map<Integer, String> codesMapping = Map.of(400500, "Cluj-Napoca", 75001, "Paris", 10115, "Berlin", 10000, "New York")
codesMapping ==> {400500=Cluj-Napoca, 10115=Berlin, 10000=New York, 75001=Paris}
jshell> List<Integer> codes = List.of(400501, 75001, 10115, 10000)
codes ==> [400501, 75001, 10115, 10000]
jshell> codes.stream().flatMap(x -> Stream.ofNullable(codesMapping.get(x)))
$3 ==> java.util.stream.ReferencePipeline$7@343f4d3d
jshell> codes.stream().flatMap(x -> Stream.ofNullable(codesMapping.get(x))).collect(Collectors.toList());
$4 ==> [Paris, Berlin, New York]
Haskell 使用以下单子(在其他函数式编程语言中导入)。它们对于 Java 世界也很重要,因为它们具有强大的抽象概念:
为了掌握函子、应用和单子的概念,我们建议您查阅这个页面和这个页面。在这个页面的 Cyclops React 库里也有一些函数式的好东西。
函数式编程是基于流和 Lambda 表达式的,两者都是在 Java8 中引入的。像 RetroLambda 这样的库允许 Java8 代码在旧的 JVM 运行时运行,比如 Java5、6 或 7(通常用于 Android 开发)。
Lambda 表达式是用于java.util.functions
包接口的语法。最重要的是:
BiConsumer
:一种使用两个输入参数而不返回结果的操作,通常用在forEach
映射方法中。支持使用andThen
方法链接BiConsumers
。BiFunction
:通过调用apply
方法,接受两个参数并产生结果的函数。BinaryOperator
:对同一类型的两个操作数进行的一种操作,产生与操作数类型相同的结果,通过调用其继承的apply
方法来使用。它静态地提供了minBy
和maxBy
方法,返回两个元素中的较小值/较大值。BiPredicate
:由两个参数(也称为谓词)组成的布尔返回函数,用于调用其test
方法。Consumer
:使用单个输入参数的操作。就像它的二进制对应项一样,它支持链接,并通过调用它的apply
方法来应用,如下面的示例所示,其中使用者是System.out.println
方法:jshell> Consumer<Integer> printToConsole = System.out::println;
print ==> $Lambda$24/117244645@5bcab519
jshell> printToConsole.accept(9)
9
Function
:接受一个参数并产生结果的函数。它转换输入,而不是变异。它可以通过调用其apply
方法直接使用,使用andThen
链接,使用compose
方法组合,如下面的示例代码所示。这样,我们的代码就可以通过在现有函数的基础上构造新函数来保持 DRY(缩写为不要重复):jshell> Function<Integer, Integer> square = x -> x*x;
square ==> $Lambda$14/1870647526@47c62251
jshell> Function<Integer, String> toString = x -> "Number : " + x.toString();
toString ==> $Lambda$15/1722023916@77caeb3e
jshell> toString.compose(square).apply(4);
$3 ==> "Number : 16"
jshell> square.andThen(toString).apply(4);
$4 ==> "Number : 16"
Predicate
:一个参数的布尔返回函数。在下面的代码中,我们将测试字符串是否完全小写:jshell> Predicate<String> isLower = x -> x.equals(x.toLowerCase())
isLower ==> $Lambda$25/507084503@490ab905
jshell> isLower.test("lower")
$8 ==> true
jshell> isLower.test("Lower")
$9 ==> false
Supplier
:这是一个值供应器:jshell> String lambda = "Hello Lambda"
lambda ==> "Hello Lambda"
jshell> Supplier<String> closure = () -> lambda
closure ==> $Lambda$27/13329486@13805618
jshell> closure.get()
$13 ==> "Hello Lambda"
UnaryOperator
:作用于单个操作数的一种特殊函数,其结果与其操作数的类型相同;可以用Function
代替。流是一个函数管道,用于转换而不是变异数据。它们有创造者、中间者和终端操作。要从流中获取值,需要调用终端操作。流不是数据结构,不能重复使用,一旦被使用,如果第二次收集,它将保持关闭状态,java.lang.IllegalStateException
异常:流已经被操作或关闭,将被抛出。
流可以是连续的,也可以是并行的。它们可以从Collection
接口、JarFile、ZipFile 或位集创建,也可以从 Java9 开始从Optional class stream()
方法创建。Collection
类支持parallelStream()
方法,该方法可以返回并行流或串行流。通过调用适当的Arrays.stream(...)
,可以构造各种类型的流,例如装箱原始类型(Integer
、Long
、Double
)或其他类。为原始类型调用它的结果是以下特定流:IntStream
、LongStream
或DoubleStream
。这些专用流类可以使用它们的静态方法之一来构造流,例如generate(...)
、of(...)
、empty()
、iterate(...)
、concat(...)
、range(...)
、rangeClosed(...)
或builder()
。通过调用lines(...)
方法可以很容易地从BufferedReader
对象获取数据流,该方法也以静态形式存在于Files
类中,用于从路径给定的文件获取所有行。Files
类提供了其他流创建者方法,如list(...)
、walk(...)
、find(...)
。
Java9 除了前面提到的Optional
之外,还添加了更多返回流的类,比如Matcher
类(results(...)
方法)或Scanner
类(findAll(...)
和tokens()
方法)。
中间流操作是延迟应用的;这意味着只有在终端操作被调用之后才进行实际调用。在下面的代码中,使用在网上使用随机生成的名称,一旦找到第一个有效名称,搜索将停止(只返回一个Stream
对象):
jshell> Stream<String> stream = Arrays.stream(new String[] {"Benny Gandalf", "Aeliana Taina","Sukhbir Purnima"}).
...> map(x -> { System.out.println("Map " + x); return x; }).
...> filter(x -> x.contains("Aeliana"));
stream ==> java.util.stream.ReferencePipeline$2@6eebc39e
jshell> stream.findFirst();
Map Benny Gandalf
Map Aeliana Taina
$3 ==> Optional[Aeliana Taina]
流中间操作包含以下操作:
sequential()
:将当前流设置为串行流。parallel()
:将当前流设置为可能的并行流。根据经验,对大型数据集使用并行流,并行化可以提高性能。在我们的代码中,并行操作会导致性能下降,因为并行化的成本大于收益,而且我们正在处理一些否则无法处理的条目:jshell> Stream<String> stream = Arrays.stream(new String[] {"Benny Gandalf", "Aeliana Taina","Sukhbir Purnima"}).
...> parallel().
...> map(x -> { System.out.println("Map " + x); return x; }).
...> filter(x -> x.contains("Aeliana"));
stream ==> java.util.stream.ReferencePipeline$2@60c6f5b
jshell> stream.findFirst();
Map Benny Gandalf
Map Aeliana Taina
Map Sukhbir Purnima
$14 ==> Optional[Aeliana Taina]
unordered()
:无序处理输入。它使得序列流的输出顺序具有不确定性,并通过允许更有效地实现一些聚合函数(如去重复或groupBy
),从而提高并行执行的性能。onClose(..)
:使用给定的输入处理器关闭流使用的资源。Files.lines(...)
流利用它来关闭输入文件,比如在下面的代码中,它是自动关闭的,但是也可以通过调用close()
方法手动关闭流:jshell> try (Stream<String> stream = Files.lines(Paths.get("d:/input.txt"))) {
...> stream.forEach(System.out::println);
...> }
Benny Gandalf
Aeliana Taina
Sukhbir Purnima
filter(..)
:应用谓词过滤输入。map(..)
:通过应用函数来转换输入。flatMap(..)
:使用基于映射函数的流中的值替换输入。distinct()
:使用Object.equals()
返回不同的值。sorted(..)
:根据自然/给定比较器对输入进行排序。peek(..)
:允许使用流所持有的值而不更改它们。limit(..)
:将流元素截断为给定的数目。skip(..)
:丢弃流中的前 n 个元素。下面的代码显示了peek
、limit
和skip
方法的用法。它计算出商务旅行折合成欧元的费用。第一笔和最后一笔费用与业务无关,因此需要过滤掉(也可以使用filter()
方法)。peek
方法是打印费用总额中使用的费用:
jshell> Map<Currency, Double> exchangeToEur = Map.of(Currency.USD, 0.96, Currency.GBP, 1.56, Currency.EUR, 1.0);
exchangeToEur ==> {USD=0.96, GBP=1.56, EUR=1.0}
jshell> List<Expense> travelExpenses = List.of(new Expense(10, Currency.EUR, "Souvenir from Munchen"), new Expense(10.5, Currency.EUR, "Taxi to Munich airport"), new Expense(20, Currency.USD, "Taxi to San Francisco hotel"), new Expense(30, Currency.USD, "Meal"), new Expense(21.5, Currency.GBP, "Taxi to San Francisco airport"), new Expense(10, Currency.GBP, "Souvenir from London"));
travelExpenses ==> [Expense@1b26f7b2, Expense@491cc5c9, Expense@74ad ... 62d5aee, Expense@69b0fd6f]
jshell> travelExpenses.stream().skip(1).limit(4).
...> peek(x -> System.out.println(x.getDescription())).
...> mapToDouble(x -> x.getAmount() * exchangeToEur.get(x.getCurrency())).
...> sum();
Taxi to Munich airport
Taxi to San Francisco hotel
Meal
Taxi to San Francisco airport
$38 ==> 92.03999999999999
除了前面介绍的Stream
方法外,Java9 还引入了dropWhile
和takeWhile
。它们的目的是让开发人员更好地处理无限流。在下面的代码中,我们将使用它们将打印的数字限制在 5 到 10 之间。移除上限(由takeWhile
设置)将导致无限大的递增数字打印(在某个点上,它们将溢出,但仍会继续增加–例如,在迭代方法中,使用x -> x + 100
):
jshell> IntStream.iterate(1, x-> x + 1).
...> dropWhile(x -> x < 5).takeWhile(x -> x < 7).
...> forEach(System.out::println);
输出是 5 和 6,正如预期的那样,因为它们大于 5,小于 7。
终端操作是遍历中间操作管道并进行适当调用的值或副作用操作。它们可以处理返回的值(forEach(...)
、forEachOrdered(...)
),也可以返回以下任意值:
iterator()
和spliterator()
方法)toArray(...)
、collect(...)
,使用集合toList()
、toSet()
、toColletion()
、groupingBy()
、partitioningBy()
或toMap()
)findFirst()
、findAny()
)min(...)
、max(...)
、count()
或sum()
、average()
、summaryStatistics()
只针对IntStream
、LongStream
、DoubleStream
。anyMatch(...)
、allMatch(...)
和noneMatch(...)
。reduce(...)
或collect(...)
方式。一些可用的收集器包括maxBy()
、minBy()
、reducing()
、joining()
和counting()
。在本节中,我们将根据 Java8 和 Java9 中提供的新特性来回顾一些 GOF 模式。
使用闭包和Supplier
可以重新实现单例模式。Java 混合代码可以利用Supplier
接口,比如在下面的代码中,单例是一个枚举(根据函数编程,singleton 类型是那些只有一个值的类型,就像枚举一样)。以下示例代码与第 2 章“创建模式”中的代码类似:
jshell> enum Singleton{
...> INSTANCE;
...> public static Supplier<Singleton> getInstance()
...> {
...> return () -> Singleton.INSTANCE;
...> }
...>
...> public void doSomething(){
...> System.out.println("Something is Done.");
...> }
...> }
| created enum Singleton
jshell> Singleton.getInstance().get().doSomething();
Something is Done.
Lombock 库将生成器作为其功能的一部分引入。只要使用@Builder
注解,任何类都可以自动获得对builder
方法的访问权,如 Lombock 示例代码在这个页面中所示:
Person.builder().name("Adam Savage").city("San Francisco").job("Mythbusters").job("Unchained Reaction").build();
其他 Java8 之前的实现使用反射来创建通用生成器。Java8+ 泛型构建器版本可以通过利用供应器和BiConsumer
组合来实现,如下代码所示:
jshell> class Person { private String name;
...> public void setName(String name) { this.name = name; }
...> public String getName() { return name; }}
| replaced class Person
| update replaced variable a, reset to null
jshell> Supplier<Person> getPerson = Person::new
getPerson ==> $Lambda$214/2095303566@78b66d36
jshell> Person a = getPerson.get()
a ==> Person@5223e5ee
jshell> a.getName();
$91 ==> null
jshell> BiConsumer<Person, String> changePersonName = (x, y) -> x.setName(y)
changePersonName ==> $Lambda$215/581318631@6fe7aac8
jshell> changePersonName.accept(a, "Gandalf")
jshell> a.getName();
$94 ==> "Gandalf"
最好的例子是使用map
函数,它执行从旧接口到新接口的自适应。我们将重用第 4 章中的示例“结构模式”,稍加改动;映射模拟适配器代码:
jshell> class PS2Device {};
| created class PS2Device
jshell> class USBDevice {};
| created class USBDevice
jshell> Optional.of(new PS2Device()).stream().map(x -> new USBDevice()).findFirst().get()
$39 ==> USBDevice@15bb6bea
装饰器可以通过利用函数组合来实现。例如,如前所示,可以使用stream.peek
方法将日志添加到现有函数调用,并从提供给peek
的Consumer
将日志记录到控制台。
我们的第 4 章“结构模式”,装饰器示例可以用函数式重写;注意装饰器用于使用与初始装饰器消费者相同的输入:
jshell> Consumer<String> toASCII = x -> System.out.println("Print ASCII: " + x);
toASCII ==> $Lambda$159/1690859824@400cff1a
jshell> Function<String, String> toHex = x -> x.chars().boxed().map(y -> "0x" + Integer.toHexString(y)).collect(Collectors.joining(" "));
toHex ==> $Lambda$158/1860250540@55040f2f
jshell> Consumer<String> decorateToHex = x -> System.out.println("Print HEX: " + toHex.apply(x))
decorateToHex ==> $Lambda$160/1381965390@75f9eccc
jshell> toASCII.andThen(decorateToHex).accept("text")
Print ASCII: text
Print HEX: 0x74 0x65 0x78 0x74
责任链可以实现为处理器(函数)的列表,每个处理器执行一个特定的操作。下面的示例代码使用闭包和一系列函数,这些函数一个接一个地应用于给定的文本:
jshell> String text = "Text";
text ==> "Text"
jshell> Stream.<Function<String, String>>of(String::toLowerCase, x -> LocalDateTime.now().toString() + " " + x).map(f -> f.apply(text)).collect(Collectors.toList())
$55 ==> [text, 2017-08-10T08:41:28.243310800 Text]
其目的是将一个方法转换成一个对象来存储它并在以后调用它,能够跟踪它的调用、记录和撤消。这是Consumer
类的基本用法。
在下面的代码中,我们将创建一个命令列表并逐个执行它们:
jshell> List<Consumer<String>> tasks = List.of(System.out::println, x -> System.out.println(LocalDateTime.now().toString() + " " + x))
tasks ==> [$Lambda$192/728258269@6107227e, $Lambda$193/1572098393@7c417213]
jshell> tasks.forEach(x -> x.accept(text))
Text
2017-08-10T08:47:31.673812300 Text
解释器的语法可以存储为关键字映射,相应的操作存储为值。在第二章“创建模式”中,我们使用了一个数学表达式求值器,将结果累加成一个栈。这可以通过将表达式存储在映射中来实现,并使用reduce
来累加结果:
jshell> Map<String, IntBinaryOperator> operands = Map.of("+", (x, y) -> x + y, "-", (x, y) -> x - y)
operands ==> {-=$Lambda$208/1259652483@65466a6a, +=$Lambda$207/1552978964@4ddced80}
jshell> Arrays.asList("4 5 + 6 -".split(" ")).stream().reduce("0 ",(acc, x) -> {
...> if (operands.containsKey(x)) {
...> String[] split = acc.split(" ");
...> System.out.println(acc);
...> acc = split[0] + " " + operands.get(x).applyAsInt(Integer.valueOf(split[1]), Integer.valueOf(split[2])) + " ";
...> } else { acc = acc + x + " ";}
...> return acc; }).split(" ")[1]
0 4 5
0 9 6
$76 ==> "3"
迭代器部分是通过使用流提供的序列来实现的。Java8 添加了forEach
方法,该方法接收消费者作为参数,其行为与前面的循环实现类似,如下面的示例代码所示:
jshell> List.of(1, 4).forEach(System.out::println)
jshell> for(Integer i: List.of(1, 4)) System.out.println(i);
如预期的那样,每个示例的输出是 1 和 4。
在 Java8 中,观察者模式被 Lambda 表达式取代。最明显的例子是ActionListener
替换。使用匿名类监听器的旧代码被替换为一个简单的函数调用:
JButton button = new Jbutton("Click Here");
button.addActionListener(new ActionListener()
{
public void actionPerformed(ActionEvent e)
{
System.out.println("Handled by the old listener");
}
});
新代码只有一行:
button.addActionListener(e -> System.out.println("Handled by lambda"));
这个策略可以被一个函数代替。在下面的代码示例中,我们对所有价格应用 10% 的折扣策略:
jshell> Function<Double, Double> tenPercentDiscount = x -> x * 0.9;
tenPercentDiscount ==> $Lambda$217/1990160809@4c9f8c13
jshell> List.<Double>of(5.4, 6.27, 3.29).stream().map(tenPercentDiscount).collect(Collectors.toList())
$98 ==> [4.86, 5.643, 2.9610000000000003]
当模板提供调用顺序时,可以实现模板方法以允许注入特定的方法调用。在下面的示例中,我们将添加特定的调用并从外部设置它们的内容。它们可能已经插入了特定的内容。通过使用接收所有可运行项的单个方法,可以简化代码:
jshell> class TemplateMethod {
...> private Runnable call1 = () -> {};
...> private Runnable call2 = () -> System.out.println("Call2");
...> private Runnable call3 = () -> {};
...> public void setCall1(Runnable call1) { this.call1 = call1;}
...> public void setCall2(Runnable call2) { this.call2 = call2; }
...> public void setCall3(Runnable call3) { this.call3 = call3; }
...> public void run() {
...> call1.run();
...> call2.run();
...> call3.run();
...> }
...> }
| created class TemplateMethod
jshell> TemplateMethod t = new TemplateMethod();
t ==> TemplateMethod@70e8f8e
jshell> t.setCall1(() -> System.out.println("Call1"));
jshell> t.setCall3(() -> System.out.println("Call3"));
jshell> t.run();
Call1
Call2
Call3
在本节中,我们将学习以下函数式设计模式:
MapReduce 是 Google 开发的一种用于大规模并行编程的技术,由于易于表达,它以函数设计模式出现。在函数式编程中,它是单子的一种形式。
其目的是将现有任务分解为多个较小的任务,并行运行它们,并聚合结果(reduce
)。它有望提高大数据的性能。
我们将通过基于给定的 Sleuth 跨度解析和聚合来自多个 Web 服务的日志并计算每个命中端点的总持续时间来演示 MapReduce 模式的用法。日志取自这个页面并拆分成相应的服务日志文件。下面的代码并行读取所有日志、映射、排序和过滤相关日志条目,收集并减少(聚合)结果。如果有结果,它将被打印到控制台。导入的日期/时间类用于排序比较。flatMap
代码需要处理Exception
,如下代码所示:
jshell> import java.time.*
jshell> import java.time.format.*
jshell> DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")
dtf ==> Value(YearOfEra,4,19,EXCEEDS_PAD)'-'Value(MonthOf ... Fraction(NanoOfSecond,3,3)
jshell> try (Stream<Path> files = Files.find(Paths.get("d:/"), 1, (path, attr) -> String.valueOf(path).endsWith(".log"))) {
...> files.parallel().
...> flatMap(x -> { try { return Files.lines(x); } catch (IOException e) {} return null;}).
...> filter(x -> x.contains("2485ec27856c56f4")).
...> map(x -> x.substring(0, 23) + " " + x.split(":")[3]).
...> sorted((x, y) -> LocalDateTime.parse(x.substring(0, 23), dtf).compareTo(LocalDateTime.parse(y.substring(0, 23), dtf))).
...> collect(Collectors.toList()).stream().sequential().
...> reduce((acc, x) -> {
...> if (acc.length() > 0) {
...> Long duration = Long.valueOf(Duration.between(LocalDateTime.parse(acc.substring(0, 23), dtf), LocalDateTime.parse(x.substring(0, 23), dtf)).t oMillis());
...> acc += "n After " + duration.toString() + "ms " + x.substring(24);
...> } else {
...> acc = x;
...> }
...> return acc;}).ifPresent(System.out::println);
...> }
2016-02-26 11:15:47.561 Hello from service1\. Calling service2
After 149ms Hello from service2\. Calling service3 and then service4
After 334ms Hello from service3
After 363ms Got response from service3 [Hello from service3]
After 573ms Hello from service4
After 595ms Got response from service4 [Hello from service4]
After 621ms Got response from service2 [Hello from service2, response from service3 [Hello from service3] and from service4 [Hello from service4]]
借贷模式确保资源一旦超出范围就被决定性地处置。资源可以是数据库连接、文件、套接字或任何处理本机资源的对象(内存、系统句柄、任何类型的连接)之一。这与 MSDN 上描述的 Dispose 模式的意图类似。
这样做的目的是让用户在未使用的资源被使用后,从释放这些资源的负担中解脱出来。用户可能忘记调用资源的release
方法,从而导致泄漏。
在处理数据库事务时,最常用的模板之一是获取事务、进行适当的调用、确保在异常时提交或回滚并关闭事务。这可以实现为借贷模式,其中移动部分是事务中的调用。以下代码显示了如何实现这一点:
jshell> class Connection {
...> public void commit() {};
public void rollback() {};
public void close() {};
public void setAutoCommit(boolean autoCommit) {};
...> public static void runWithinTransaction(Consumer<Connection> c) {
...> Connection t = null;
...> try { t = new Connection(); t.setAutoCommit(false);
...> c.accept(t);
...> t.commit();
...> } catch(Exception e) { t.rollback(); } finally { t.close(); } } }
| created class Connection
jshell> Connection.runWithinTransaction(x -> System.out.println("Execute statement..."));
Execute statement...
尾部调用优化(TCO)是一些编译器在不使用栈空间的情况下调用函数的技术。Scala 通过用@tailrec
注解递归代码来利用它。这基本上告诉编译器使用一个特殊的循环,称为 trampoline,它反复运行函数。函数调用可以处于一种或多种要调用的状态。在完成时,它返回结果(头部),在更多的情况下,它返回当前循环而不返回头部(尾部)。这个模式已经被 cyclops-react 提供给我们了。
其目的是在不破坏栈的情况下启用递归调用。它只用于大量的递归调用,对于少数调用,它可能会降低性能。
cyclops-react 的维护者 John McClean 演示了 TCO 在 Fibonacci 序列中计算数字的用法。代码简洁易懂,基本上是从初始状态 0 和 1 开始累加斐波那契数,f(0) = 0
、f(1) = 1
,应用f(n) = f(n-1) + f(n-2)
函数:
importstatic cyclops.control.Trampoline.done;
importstatic cyclops.control.Trampoline.more;
import cyclops.control.Trampoline;
publicclass Main
{
publicvoid fib()
{
for(int i=0;i<100_000;i++)
System.out.println(fibonacci(i, 0l, 1l).get());
}
public Trampoline<Long> fibonacci(Integer count, Long a, Long b)
{
return count==0 ? done(a) : more(()->fibonacci (count - 1,
b, a + b));
}
publicstaticvoid main(String[] args)
{
new Main().fib();
}
}
多次调用前面的 Fibonacci 实现将导致 CPU 周期的浪费,因为有些步骤是相同的,并且我们可以保证,对于相同的输入,我们总是得到相同的输出(纯函数)。为了加速调用,我们可以缓存输出,对于给定的输入,只返回缓存结果,而不是实际计算结果。
其目的是缓存给定输入的函数结果,并使用它加速对给定相同输入的相同函数的进一步调用。它应该只用于纯函数,因为它们提供了引用透明性。
在下面的示例中,我们将重用 Fibonacci 代码并添加 Guava 缓存。缓存将保存 Fibonacci 的返回值,而键是输入数字。缓存配置为在大小和时间上限制内存占用:
importstatic cyclops.control.Trampoline.done;
importstatic cyclops.control.Trampoline.more;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import cyclops.async.LazyReact;
import cyclops.control.Trampoline;
publicclass Main
{
public BigInteger fib(BigInteger n)
{
return fibonacci(n, BigInteger.ZERO, BigInteger.ONE).get();
}
public Trampoline<BigInteger> fibonacci(BigInteger count,
BigInteger a, BigInteger b)
{
return count.equals(BigInteger.ZERO) ? done(a) :
more(()->fibonacci (count.subtract(BigInteger.ONE), b,
a.add(b)));
}
publicvoid memoization(List<Integer> array)
{
Cache<BigInteger, BigInteger> cache = CacheBuilder.newBuilder()
.maximumSize(1_000_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
LazyReact react = new LazyReact().autoMemoizeOn((key,fn)->
cache.get((BigInteger)key,()-> (BigInteger)fn.
apply((BigInteger)key)));
Listresult = react.from(array)
.map(i->fibonacci(BigInteger.valueOf(i), BigInteger.ZERO,
BigInteger.ONE))
.toList();
}
publicstaticvoid main(String[] args)
{
Main main = new Main();
List<Integer> array = Arrays.asList(500_000, 499_999);
long start = System.currentTimeMillis();
array.stream().map(BigInteger::valueOf).forEach(x -> main.fib(x));
System.out.println("Regular version took " +
(System.currentTimeMillis() - start) + " ms");
start = System.currentTimeMillis();
main.memoization(array);
System.out.println("Memoized version took " +
(System.currentTimeMillis() - start) + " ms");
}
}
输出如下:
Regular version took 19022 ms
Memoized version took 394 ms
在度量每个版本的代码的性能时,前面的代码似乎都在重复。这可以通过环绕执行方法模式解决,方法是将执行的业务代码包装到 Lambda 表达式中。这种模式的一个很好的例子是单元测试前后的设置/拆卸函数。这类似于前面描述的模板方法和借贷模式。
其目的是让用户可以在特定业务方法之前和之后执行某些特定的操作。
上一个示例中提到的代码包含重复的代码(代码气味)。我们将应用环绕执行模式来简化代码并使其更易于阅读。可能的重构可以使用 Lambda,如我们所见:
publicstaticvoid measurePerformance(Runnable runnable)
{
long start = System.currentTimeMillis();
runnable.run();
System.out.println("It took " + (System.currentTimeMillis() -
start) + " ms");
}
publicstaticvoid main(String[] args)
{
Main main = new Main();
List<Integer> array = Arrays.asList(500_000, 499_999);
measurePerformance(() -> array.stream().map(BigInteger::valueOf)
.forEach(x -> main.fib(x)));
measurePerformance(() -> main.memoization(array));
}
在本章中,我们了解了函数式编程的含义、最新 Java 版本提供的特性,以及它们是如何改变一些现有的 GOF 模式的。我们还使用了一些函数式编程设计模式。
在下一章中,我们将深入到反应式世界,学习如何使用 RxJava 创建响应式应用。