Java 设计模式最佳实践:1~5(2)

简介: Java 设计模式最佳实践:1~5(2)

Java 设计模式最佳实践:1~5(1)https://developer.aliyun.com/article/1426755

高阶函数

高阶函数是可以将其他函数作为参数,创建并返回它们的函数。它们通过使用现有的和已经测试过的小函数来促进代码重用。例如,在下面的代码中,我们计算给定温度(华氏度)的平均值(摄氏度):

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 -> zf :: (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 世界中需要考虑这个建议的原因在于可变代码有许多可移动的部分;它太复杂,不容易理解和修复。促进不变性简化了代码,并允许开发人员专注于流,而不是关注一段代码可能产生的副作用。最糟糕的副作用是,一个地方的微小变化可能会在另一个地方产生灾难性的结果(蝴蝶效应)。可变代码有时很难并行化,并且常常使用不同的锁。

函子

函子允许我们对给定的容器应用函数。他们知道如何从包装对象中展开值,应用给定的函数,并返回另一个包含结果/转换包装对象的函子。它们很有用,因为它们抽象了多种习惯用法,如集合、FuturePromise)和Optional。下面的代码演示了 Java 中的Optional函子的用法,其中Optional可以是一个给定的值,这是将函数应用于现有的包装值(5Optional的结果):

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 包含了StreamCompletableFuture和已经出现的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 库里也有一些函数式的好东西。

Java 函数式编程简介

函数式编程是基于流和 Lambda 表达式的,两者都是在 Java8 中引入的。像 RetroLambda 这样的库允许 Java8 代码在旧的 JVM 运行时运行,比如 Java5、6 或 7(通常用于 Android 开发)。

Lambda 表达式

Lambda 表达式是用于java.util.functions包接口的语法。最重要的是:

  • BiConsumer:一种使用两个输入参数而不返回结果的操作,通常用在forEach映射方法中。支持使用andThen方法链接BiConsumers
  • BiFunction:通过调用apply方法,接受两个参数并产生结果的函数。
  • BinaryOperator:对同一类型的两个操作数进行的一种操作,产生与操作数类型相同的结果,通过调用其继承的apply方法来使用。它静态地提供了minBymaxBy方法,返回两个元素中的较小值/较大值。
  • 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(...),可以构造各种类型的流,例如装箱原始类型(IntegerLongDouble)或其他类。为原始类型调用它的结果是以下特定流:IntStreamLongStreamDoubleStream。这些专用流类可以使用它们的静态方法之一来构造流,例如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 个元素。

下面的代码显示了peeklimitskip方法的用法。它计算出商务旅行折合成欧元的费用。第一笔和最后一笔费用与业务无关,因此需要过滤掉(也可以使用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.ofNullable方法外,Java9 还引入了dropWhiletakeWhile。它们的目的是让开发人员更好地处理无限流。在下面的代码中,我们将使用它们将打印的数字限制在 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()只针对IntStreamLongStreamDoubleStream
  • 布尔值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"

Java 设计模式最佳实践:1~5(3)https://developer.aliyun.com/article/1426753

相关文章
|
16天前
|
设计模式 Java 开发者
设计模式揭秘:Java世界的七大奇迹
【4月更文挑战第7天】探索Java设计模式:单例、工厂方法、抽象工厂、建造者、原型、适配器和观察者,助你构建健壮、灵活的软件系统。了解这些模式如何提升代码复用、可维护性,以及在特定场景下的应用,如资源管理、接口兼容和事件监听。掌握设计模式,但也需根据实际情况权衡,打造高效、优雅的软件解决方案。
|
17天前
|
设计模式 存储 Java
23种设计模式,享元模式的概念优缺点以及JAVA代码举例
【4月更文挑战第6天】享元模式(Flyweight Pattern)是一种结构型设计模式,旨在通过共享技术有效地支持大量细粒度对象的重用。这个模式在处理大量对象时非常有用,特别是当这些对象中的许多实例实际上可以共享相同的状态时,从而可以减少内存占用,提高程序效率
31 4
|
16天前
|
设计模式 监控 Java
设计模式 - 观察者模式(Observer):Java中的战术与策略
【4月更文挑战第7天】观察者模式是构建可维护、可扩展系统的关键,它在Java中通过`Observable`和`Observer`实现对象间一对多的依赖关系,常用于事件处理、数据绑定和同步。该模式支持事件驱动架构、数据同步和实时系统,但需注意避免循环依赖、控制通知粒度,并关注性能和内存泄漏问题。通过明确角色、使用抽象和管理观察者注册,可最大化其效果。
|
5天前
|
设计模式 算法 Java
Java中的设计模式及其应用
【4月更文挑战第18天】本文介绍了Java设计模式的重要性及分类,包括创建型、结构型和行为型模式。创建型模式如单例、工厂方法用于对象创建;结构型模式如适配器、组合关注对象组合;行为型模式如策略、观察者关注对象交互。文中还举例说明了单例模式在配置管理器中的应用,工厂方法在图形编辑器中的使用,以及策略模式在电商折扣计算中的实践。设计模式能提升代码可读性、可维护性和可扩展性,是Java开发者的必备知识。
|
8天前
|
设计模式 算法 Java
小谈设计模式(30)—Java设计模式总结
小谈设计模式(30)—Java设计模式总结
|
9天前
|
设计模式 存储 Java
Java设计模式:解释一下单例模式(Singleton Pattern)。
`Singleton Pattern`是Java中的创建型设计模式,确保类只有一个实例并提供全局访问点。它通过私有化构造函数,用静态方法返回唯一的实例。类内静态变量存储此实例,对外仅通过静态方法访问。
15 1
|
13天前
|
设计模式 算法 Java
23种设计模式,模板方法模式的概念优缺点以及JAVA代码举例
【4月更文挑战第10天】模板方法模式是一种行为设计模式,它定义了一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些特定步骤。
14 0
|
14天前
|
设计模式 Java
23种设计模式,状态模式的概念优缺点以及JAVA代码举例
【4月更文挑战第9天】状态模式是一种行为设计模式,允许一个对象在其内部状态改变时改变它的行为,这个对象看起来似乎修改了它的类。
26 4
|
16天前
|
设计模式 Java
23种设计模式,命令模式的概念优缺点以及JAVA代码举例
【4月更文挑战第7天】命令模式是一种行为设计模式,它将请求或简单操作封装为一个对象。这种模式允许用户通过调用对象来参数化其他对象的方法,并能保存、排队和执行方法调用。
20 1
|
16天前
|
设计模式 缓存 安全
分析设计模式对Java应用性能的影响,并提供优化策略
【4月更文挑战第7天】本文分析了7种常见设计模式对Java应用性能的影响及优化策略:单例模式可采用双重检查锁定、枚举实现或对象池优化;工厂方法和抽象工厂模式可通过对象池和缓存减少对象创建开销;建造者模式应减少构建步骤,简化复杂对象;原型模式优化克隆方法或使用序列化提高复制效率;适配器模式尽量减少使用,或合并多个适配器;观察者模式限制观察者数量并使用异步通知。设计模式需根据应用场景谨慎选用,兼顾代码质量和性能。