vlambda博客
学习文章列表

读书笔记《the-complete-coding-interview-guide-in-java》第17章函数式编程

Chapter 17: Functional-Style Programming

您可能知道,Java 并不是像 Haskell 那样的纯函数式编程语言,但从版本 8 开始,Java 增加了一些函数式风格的支持。添加这种支持的努力取得了成功,函数式代码被开发人员和公司广泛采用。函数式编程支持更易于理解、可维护和可测试的代码。但是,以函数式风格编写 Java 代码需要对 lambda、流 API、Optional、函数式接口等有深入的了解。所有这些函数式编程主题也可以是面试主题,在本章中,我们将介绍一些通过常规 J​​ava 面试必须了解的热门问题。我们的议程包含以下主题:

  • 简而言之,Java 函数式编程
  • 问题和编码挑战

让我们开始吧!

Java functional-style programming in a nutshell

像往常一样,这个 部分旨在突出和刷新我们主题的主要概念,并为回答技术面试中可能出现的基本问题提供全面的资源。

Key concepts of functional-style programming

因此,函数式编程的关键概念包括以下内容:

  • 作为一等对象的函数
  • 纯函数
  • 高阶函数

让我们简要介绍一下这些概念。

Functions as first-class objects

函数是一等对象意味着我们可以创建一个函数的 instance 作为具有引用该函数实例的变量.这就像引用 StringList 或任何其他对象。此外,函数可以作为参数传递给其他函数。但是,Java 方法不是一流的对象。我们能做的最好的事情就是依赖 Java lambda 表达式。

Pure functions

函数是一个执行没有副作用的函数,并且返回值仅取决于其输入参数。以下 Java 方法是纯函数:

public class Calculator {
  public int sum(int x, int y) {
    return x + y;
  }
}

如果一个方法使用了成员变量或改变了成员变量的状态,那么它就不是一个函数。

Higher-order functions

高阶 函数将一个或多个函数作为参数和/或返回另一个函数作为结果。 Java 通过 lambda 表达式模拟高阶函数。换句话说,在 Java 中,高阶函数是一种获取一个(或多个)lambda 表达式作为参数和/或返回另一个 lambda 表达式的方法。

例如,Collections.sort() 方法,它以 Comparator 作为参数,是一个高阶函数:

Collections.sort(list, (String x, String y) -> {
  return x.compareTo(y);
});

Collections.sort() 的第一个参数是 List,第二个参数是 lambda 表达式。这个 lambda 表达式参数使 Collections.sort() 成为高阶函数。

Pure functional programming rules

现在,让我们简要讨论纯函数式编程规则。纯函数式编程也有一套规则要遵循。这些如下:

  • 无状态
  • 无副作用
  • 不可变变量
  • 递归优于循环

让我们简要介绍一下这些规则。

No state

无状态,我们确实并不意味着函数式编程消除了状态。通常,没有状态意味着函数没有外部状态。换句话说,一个函数可以使用内部包含临时状态的局部变量,但它不能引用它所属的类/对象的任何成员变量。

No side effects

通过无副作用,我们 应该明白一个函数不能改变(变异)函数之外的任何状态(在它的功能范围)。函数外的状态包括以下内容:

  • 包含该函数的类/对象中的成员变量
  • 作为参数传递给函数的成员变量
  • 或者外部系统中的状态(例如,数据库或文件)。

Immutable variables

函数式编程鼓励和支持不可变变量的使用。依赖不可变变量有助于我们以更简单、更直观的方式避免副作用

Favoring recursion over looping

由于递归依赖于重复的函数调用来模拟循环,因此代码变得更具功能性。这意味着函数式编程不鼓励使用以下计算阶乘的迭代方法:

static long factorial(long n) {
  long result = 1;
  for (; n > 0; n--) {
    result *= n;
  }
  return result;
}

函数式编程鼓励以下递归方法:

static long factorial(long n) {
  return n == 1 ? 1 : n * factorial(n - 1);
}

我们使用 tail recursion 来改善性能损失,因为在前面的示例中,每个函数调用都作为一个帧保存在递归堆栈中。当有许多递归调用时,首选尾递归。在尾递归中,函数执行递归调用作为最后要做的事情,因此编译器不需要将函数调用保存为递归堆栈中的帧。大多数编译器会优化尾递归,从而避免性能损失:

static long factorialTail(long n) {
  return factorial(1, n);
}
static long factorial(long acc, long v) {
  return v == 1 ? acc : factorial(acc * v, v - 1);
}

或者,循环 可以通过受功能启发的 Java Stream API 实现:

static long factorial(long n) {
  return LongStream.rangeClosed(1, n)
     .reduce(1, (n1, n2) -> n1 * n2);
}

现在,是时候练习一些问题和编码挑战了。

Questions and coding challenges

在本节中,我们将介绍面试中非常流行的 21 个问题和编码挑战。让我们开始!

Coding challenge 1 – Lambda parts

问题:描述 Java 中 lambda 表达式的部分。此外,lambda 表达式的特点是什么?

解决方案:如下图所示,lambda 包含三个主要部分:

读书笔记《the-complete-coding-interview-guide-in-java》第17章函数式编程

图 17.1 – Lambda 零件

lambda 表达式的组成部分如下:

  • 在箭头的左侧,有在 lambda 主体中使用的这个 lambda 的参数。在此示例中,这些是 FilenameFilter.accept(File folder, String fileName) 方法的参数。
  • 在箭头的右侧,有 lambda 主体。在此示例中,lambda 主体检查是否可以读取找到文件 (fileName) 的文件夹 (folder)以及此文件的名称是否以 .pdf 字符串为后缀。
  • 位于参数列表和 lambda 主体之间的箭头充当分隔符。

接下来,让我们谈谈lambda表达式的特性。因此,如果我们编写上图中 lambda 的匿名类版本,那么它将如下所示:

FilenameFilter filter = new FilenameFilter() {
  @Override
  public boolean accept(File folder, String fileName) {
    return folder.canRead() && fileName.endsWith(".pdf");
  }
};

现在,如果我们比较匿名版本和 lambda 表达式,我们会注意到 lambda 表达式是一个简洁的匿名函数,可以作为参数传递给方法或保存在变量中。

下图中显示的四个单词表征了一个 lambda 表达式:

读书笔记《the-complete-coding-interview-guide-in-java》第17章函数式编程

图 17.2 – Lambda 特性

根据经验,请记住 lambda 支持行为参数化设计 模式(行为 作为参数传递函数),它只能在函数接口的上下文中使用。

Coding challenge 2 – Functional interface

问题:什么是功能接口?

解决方案:在Java中,一个函数式接口是一个只包含一个抽象方法的接口。换句话说,一个功能接口只包含一个未实现的方法。因此,函数式接口将函数包装为接口,并且该函数由接口上的单个抽象方法表示。

可选地,除了这个抽象方法之外,功能接口也可以具有默认和/或静态方法。通常,函数式接口使用 @FunctionalInterface 进行注释。这只是一种用于标记功能接口的信息注释类型。

下面是一个函数式接口的例子:

@FunctionalInterface
public interface Callable<V> {
  V call() throws Exception;
}

根据经验,如果一个接口有更多没有实现的方法(即抽象方法),那么它就不再是一个函数式接口。这意味着这样的接口 不能由Java lambda 表达式实现

Coding challenge 3 – Collections versus streams

问题:集合和流之间的主要区别是什么?

解决方案:集合 流完全不同。其中一些差异如下:

  • 概念差异:集合和流之间的主要区别在于它们在概念上是两个不同的东西。虽然集合旨在存储数据(例如,ListSetMap< /code>),流意味着应用操作(例如,过滤映射匹配) 在该数据上。换句话说,流对由存储在集合中的数据表示的视图/源应用复杂的操作。此外,对流执行的任何修改/更改都不会反映在原始集合中。
  • 数据修改:虽然我们可以从集合中添加/删除元素,但我们不能从流中添加/删除元素。实际上,流使用视图/源,对其执行操作,并返回结果而不修改视图/源。
  • 迭代:当流使用视图/源时,它会自动在内部执行该视图/源的迭代。迭代的发生取决于应应用于视图/源的所选操作。另一方面,集合必须在外部进行迭代。
  • 遍历:虽然集合可以遍历多次,但流只能遍历一次。因此,默认情况下,Java 流不能被重用。尝试遍历流两次将导致读取错误 Stream 已被操作或关闭
  • 构造:集合被急切地构造(所有元素都从开始)。另一方面,流是懒惰构造的(所谓的中间操作 在调用 terminal 操作之前不会进行评估)。

Coding challenge 4 – The map() function

问题:的作用 地图() 函数,你为什么要使用它?

解决方案:map()函数是一个名为mapping的中间操作,并且可通过 Stream API 获得。它用于通过简单地应用给定的函数将一种对象转换为其他类型。因此,map() 遍历给定流,并通过应用给定函数并将结果累积到新的 。给定的 Stream 没有被修改。例如,通过 Stream# 将 List<String> 转换为 List<Integer> map() 可以按如下方式完成:

List<String> strList = Arrays.asList("1", "2", "3");
List<Integer> intList = strList.stream()
  .map(Integer::parseInt)
  .collect(Collectors.toList());

挑战自己练习更多的例子。尝试应用 map() 将一个数组转换为另一个数组。

Coding challenge 5 – The flatMap() function

问题:做了什么 flatMap () 函数,你为什么要使用它?

解决方案:flatMap()函数是一个名为flattening的中间操作,并且可通过 Stream API 获得。该函数是 map() 的扩展,意思是除了将给定对象转换为另一种类型的对象外,还可以将其展平。例如,有一个 List<List<Object>>,我们可以通过 List<Object>代码 class="literal">Stream#flatMap() 如下:

List<List<Object>> list = ...
List<Object> flatList = list.stream()
  .flatMap(List::stream)
  .collect(Collectors.toList());

下一个编码 challenge 与这个有关,所以也考虑一下。

Coding challenge 6 – map() versus flatMap()

问题:区别是什么< /a> 在 map()flatMap() 函数之间?

解决方案:这两个函数都是中间操作,能够通过应用给定函数将给定类型的对象转换为另一种类型的对象。此外,flatMap() 函数也能够展平给定的对象。换句话说,flatMap() 也可以展平一个 Stream 对象。

为什么这很重要?好吧,map() 知道如何在 Stream 中包装一系列元素,对吧?这意味着 map() 可以产生 Stream Stream<List<String>>Stream<Set<String>>,甚至是 Stream<Stream<R> ;> 。但问题是这些类型的流不能被sum()这样的流操作成功操作(也就是我们预期的那样) distinct()filter()

例如,让我们考虑以下 List

List<List<String>> melonLists = Arrays.asList(
  Arrays.asList("Gac", "Cantaloupe"),
  Arrays.asList("Hemi", "Gac", "Apollo"),
  Arrays.asList("Gac", "Hemi", "Cantaloupe"));

我们试图从这个列表中获得甜瓜的不同名称。如果可以通过 Arrays.stream() 将数组包装到流中,那么对于集合,我们有 Collection.stream() 。因此,第一次尝试可能如下所示:

melonLists.stream()
  .map(Collection::stream) // Stream<Stream<String>>
  .distinct();

但这不起作用,因为 map() 将返回 Stream<Stream<String>>。解决方案由flatMap()提供,如下:

List<String> distinctNames = melonLists.stream()
  .flatMap(Collection::stream) // Stream<String>
  .distinct()
  .collect(Collectors.toList());

输出如下:Gac, 哈密瓜HemiApollo

此外,如果您在理解这些函数式编程方法时遇到困难,那么我强烈建议您阅读我的另一本书Java Coding Problems ,可从 Packt (https://www.packtpub.com/programming/java-coding-问题)。这本书包含两章关于 Java 函数式编程的综合章节,提供了详细的解释、图表和应用程序,有助于深入研究这个主题。

Coding challenge 7 – The filter() function

问题:filter()函数的作用你为什么要使用它?

解决方案:filter()函数是一个名为filtering的中间操作可用通过 Stream API。用于过滤Stream中满足一定条件的元素。条件是通过 java.util.function.Predicate 函数指定的。这个谓词函数只不过是一个将 Object 作为参数并返回 boolean 的函数。

假设我们有以下整数 List

List<Integer> ints
  = Arrays.asList(1, 2, -4, 0, 2, 0, -1, 14, 0, -1);

流式传输此列表并仅提取非零元素可以如下完成:

List<Integer> result = ints.stream()
  .filter(i -> i != 0)
  .collect(Collectors.toList());

结果列表将包含以下元素:12-4, 2, -1, 14, -1

请注意,对于几个常见操作,Java Stream API 已经提供了开箱即用的中间 操作。例如,不需要使用 filter()定义一个 Predicate 进行如下操作:

  • distinct():从流中删除重复项
  • skip(n):丢弃第一个 n 元素
  • limit(s):将流截断为长度不超过 s
  • sorted():按照自然顺序对流进行排序
  • sorted(Comparator comparator):根据给定的Comparator对流进行排序

所有这些函数都内置在 Stream API 中。

Coding challenge 8 – Intermediate versus terminal operations

问题:中间操作和终端操作的主要区别是什么?

解决方案:中间 操作 返回另一个 Stream,而终端操作会产生 Stream 以外的结果(例如,集合或标量值)。换句话说,中间操作允许我们在名为 pipeline 的查询类型中链接/调用多个操作。

在调用终端操作之前,不会执行中间操作。这意味着中间操作是惰性的。主要是在实际需要某个给定处理的结果时执行。终端操作触发Stream遍历并执行管道。

在中间操作中,我们有 map()flatMap()filter() limit()skip()。在终端操作中,我们有 sum(), min(), max() count()collect()

Coding challenge 9 – The peek() function

问题:做了什么窥视() 函数,你为什么要使用它?

解决方案:peek()函数是一个名为peeking的中间操作通过 Stream API。它允许我们看穿 Stream 管道。主要是peek()应该对当前元素执行一定的不干扰动作,并将该元素转发到下一个操作管道。通常,此操作包括在控制台上打印有意义的消息。换句话说,peek() 是调试与流和 lambda 表达式处理相关的问题的好选择。例如,假设我们有以下地址列表:

addresses.stream()
  .peek(p -> System.out.println("\tstream(): " + p))
  .filter(s -> s.startsWith("c"))
  .sorted()
  .peek(p -> System.out.println("\tsorted(): " + p))
  .collect(Collectors.toList());

值得一提的是,即使 peek() 可以用来改变状态(修改流的数据源),它也代表 看,但不要碰。在并行流管道的情况下,通过 peek() 改变状态可能成为一个真正的问题,因为可以在任何时间和任何线程中调用该元素由上游操作。因此,如果操作修改了共享状态,它负责提供所需的同步。

根据经验,在使用 peek() 改变状态之前要三思。另外,请注意,这种做法是开发人员之间争论的焦点,可以归类为不良做法,甚至是 反模式保护伞。

Coding challenge 10 – Lazy streams

问题:说流是惰性的是什么意思?

解决方案:说流是惰性的,是指流定义了一个中间操作的管道,这些中间操作只有在管道遇到终端操作时才会执行。这个问题与本章的Coding challenge 8有关。

Coding challenge 11 – Functional interfaces versus regular interfaces

问题:功能接口和常规接口的主要区别是什么?

解决方案:功能接口和常规接口的主要区别在于常规接口可以包含任意数量的抽象方法,而函数式接口只能包含一个抽象方法。

你可以参考本书的Coding challenge 2来加深理解。

Coding challenge 12 – Supplier versus Consumer

问题:主要 SupplierConsumer的区别?

解决方案:SupplierConsumer是两个内置的函数式接口。 Supplier 充当工厂方法或 new 关键字。换句话说,Supplier 定义了一个名为 get() 的方法,它不接受参数并返回 T。因此,Supplier 对于supply 是有用的。

另一方面,Consumer 定义了一个名为 void accept(T t) 的方法。此方法接受单个参数并返回 voidConsumer 接口使用给定值并对其应用一些操作。与其他函数式接口不同,Consumer 可能会导致副作用。例如,Consumer 可以用作 setter 方法。

Coding challenge 13 – Predicates

问题:谓词

解决方案:Predicate是一个内置函数接口,包含一个抽象签名为 boolean test(T object) 的方法:

@FunctionalInterface
public interface Predicate<T> {
  boolean test(T t);
  // default and static methods omitted for brevity
}

test() 方法测试一个条件,如果满足该条件则返回 true,否则返回 Predicate 的常见用法是与 Stream<T> filter(Predicate predicate) 过滤流中不需要的元素的方法。

Coding challenge 14 – findFirst() versus findAny()

问题:是什么主要 findFirst()findAny() 的区别?

Solution:findFirst() 方法从流中返回第一个元素,在从序列中获取第一个元素时特别有用.只要流具有定义的顺序,它就会从流中返回第一个元素。如果没有遇到顺序,则 findFirst() 从流中返回任何元素。

另一方面,findAny() 方法返回流中的任何元素。换句话说,它从流中返回一个任意(非确定性)元素。findAny() 方法忽略遇到的顺序,并且在非并行操作中,它很可能会返回第一个元素,但不能保证这一点。为了最大限度地提高性能,在并行操作中无法可靠地确定结果。

请注意,根据 流的源和中间操作,流可能有也可能没有定义的遇到顺序。

Coding challenge 15 – Converting arrays to streams

问题:如何将数组转换为流?

解决方案:将对象数组转换为流至少可以通过三种方式完成,如下:

  1. 第一个是通过Arrays#stream()
    public static <T> Stream<T> toStream(T[] arr) { 返回 Arrays.stream(arr); }
  2. 其次,我们可以使用Stream#of()
    public static <T> Stream<T> toStream(T[] arr) { 返回 Stream.of(arr); }
  3. 最后一种技术是通过 List#stream()
public static <T> Stream<T> toStream(T[] arr) {        
  return Arrays.asList(arr).stream();
}

将基元数组(例如整数)转换为流至少可以通过两种方式完成,如下所示:

  1. 首先,通过 Arrays#stream()
    public static IntStream toStream(int[] arr) { 返回 Arrays.stream(arr); }
  2. 其次,通过使用 IntStream#of()
public static IntStream toStream(int[] arr) {
  return IntStream.of(arr);
}

当然,对于 longs,你可以使用LongStream,对于doubles ,您可以使用 DoubleStream

Coding challenge 16 – Parallel streams

问题:什么并行流?

解决方案:并行流是可以使用多个线程并行执行的流。例如,您可能需要过滤 1000 万个整数的流,以找到小于某个值的整数。您可以使用并行流,而不是使用单个线程按顺序遍历流。这意味着多个线程将同时在流的不同部分搜索这些整数,然后组合结果。

Coding challenge 17 – The method reference

问题:是什么方法参考?

解决方案:简而言之,方法引用是 lambda 表达式的快捷方式。主要是,方法引用是一种用于通过名称而不是通过描述如何调用方法来调用方法的技术。主要好处是可读性。通过将目标引用放在分隔符 :: 之前来编写方法引用,并在其后提供方法的名称。我们有以下参考资料:

  • 静态方法的方法引用:Class::staticMethod(例如,Math: :max 等价于 Math.max(x, y))
  • 对构造函数的方法引用:Class::new(例如,AtomicInteger:: new 等价于 new AtomicInteger(x))
  • 从实例中引用实例方法的方法:object::instanceMethod (System.out ::println 相当于 System.out.println(foo)< /代码>)
  • 类类型对实例方法的方法引用:Class::instanceMethod (String: :length 等价于 str.length())

Coding challenge 18 – The default method

问题:什么是默认方法?

解决方案:Java 8 中添加了默认方法,主要是为了提供对接口的支持,以便它们可以超越抽象契约(即只包含抽象方法)。这个工具对于编写库并希望以兼容的方式发展 API 的人非常有用。通过默认方法,可以在不破坏现有实现的情况下丰富接口。

默认方法直接在接口中实现,并由 default 关键字识别。例如,以下接口定义了一个名为 area() 的抽象方法和一个名为 perimeter() 的默认方法:

public interface Polygon {
  public double area();
  default double perimeter(double... segments) {
    return Arrays.stream(segments)
      .sum();
  }
}

由于 Polygon 有一个 单一抽象方法,它也是一个函数式接口。因此,它可以用 @FunctionalInterface 进行 注解。

Coding challenge 19 – Iterator versus Spliterator

问题: < /a>IteratorSpliterator 的主要区别?

Solution:Iterator 是为 Collection API 创建的,而 Spliterator 是为 Stream API 创建的。

通过分析它们的名称,我们注意到 Spliterator = Splittable Iterator。因此,Spliterator 可以拆分给定的源代码,也可以对其进行迭代。并行处理需要拆分。换句话说,Iterator 可以顺序迭代 Collection 中的元素,而 Spliterator< /code> 可以并行或顺序迭代流的元素。

Iterator 只能通过 hasNext()/next() 遍历集合的元素 因为它没有大小。另一方面,Spliterator 可以通过 estimateSize() 或完全通过 estimateSize() 来提供集合的大小代码类="literal">getExactSizeIfKnown()。

Spliterator 可以使用多个标志在内部禁用不必要的操作(例如,CONCURRENTDISTINCT IMMUTABLE)。 Iterator 没有这样的标志。

最后,您可以在 Iterator 周围创建一个 Spliterator,如下所示:

Spliterators.spliteratorUnknownSize(
  your_Iterator, your_Properties);

在书中Java Coding Problems (https:// /www.amazon.com/gp/product/B07Y9BPV4W/),您可以找到有关此主题的更多详细信息,包括编写自定义 Spliterator 的完整指南。

Coding challenge 20 – Optional

问题: 可选的 类?

解决方案:受 Haskell 和 Scala 的启发,Java 8 中引入了 Optional 类,其主要目的是减轻/避免 NullPointerException。 Java 语言架构师 Brian Goetz 的定义如下:

Optional 旨在为库方法返回类型提供一种有限的机制,其中需要一种明确的方式来表示没有结果,而使用 null 则极有可能导致错误。

简而言之,您可以将 Optional 视为包含值或为空的单个值容器。例如,一个空的 Optional 如下所示:

Optional<User> userOptional = Optional.empty();

一个非空的 Optional 看起来像这样:

User user = new User();
Optional<User> userOptional = Optional.of(user);

Java 编码问题 (https://www .amazon.com/gp/product/B07Y9BPV4W/),您可以找到专门介绍使用可选的最佳实践的完整章节。这是任何 Java 开发人员必读的一章。

Coding challenge 21 – String::valueOf

问题:String::valueOf是什么意思?

Solution:String::valueOfString 类的 class="literal">valueOf 静态方法。考虑阅读 Coding challenge 17 以了解更多信息。

Summary

在本章中,我们讨论了几个关于 Java 函数式编程的热门话题。虽然这个主题相当广泛,并且有很多书籍专门讨论它,但这里涉及的问题应该足以通过涵盖 Java 8 语言主要特性的常规 Java 面试。

在下一章中,我们将讨论与缩放相关的问题。