Skip to content

Latest commit

 

History

History
576 lines (425 loc) · 22.2 KB

File metadata and controls

576 lines (425 loc) · 22.2 KB

七、TDD 和函数式编程——完美匹配

“任何足够先进的技术都无法与魔法区分开来。”

-亚瑟·C·克拉克

到目前为止,我们在本书中看到的所有代码示例都遵循一种特定的编程范式:面向对象编程OOP)。这种模式长期垄断了软件行业,大多数软件公司都采用面向对象编程(OOP)作为标准编程方式。

然而,OOP 已经成为最常用的范例并不意味着它是唯一存在的范例。事实上,还有更多值得一提的内容,但本章将只关注其中之一:函数式编程。此外,本书的语言是 Java,因此所有代码片段和示例都将基于 Java 版本 8 中包含的函数 API。

本章涵盖的主题包括:

  • 选修课
  • 重温职能
  • TDD 在函数式编程中的应用

建立环境

为了以测试驱动的方式探索 Java 函数式编程的一些优点,我们将使用 JUnit 和 AssertJ 框架建立一个 Java 项目。最后一种方法包含了许多方便的方法用于Optional

让我们开始一个新的毕业项目。这就是build.gradle的样子:

apply plugin: 'java'

sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
  mavenCentral()
}

dependencies {
  testCompile group: 'junit', name: 'junit', version: '4.12'
  testCompile group: 'org.assertj', name: 'assertj-core', version: '3.9.0'
}

在下面的部分中,我们将探讨 Java8 中包含的一些实用程序和类,它们可以增强编程体验。它们中的大多数不仅用于函数式编程,甚至可以用于命令式编程。

可选–处理不确定性

自从它诞生以来,null在无数的程序中被开发人员无数次地使用和滥用。null的常见情况之一是,除其他外,表示没有值。这一点也不方便;它既可以表示缺少值,也可以表示代码的异常执行。

此外,为了访问可能是null的变量并缓解NullPointerException等不希望出现的运行时异常,开发人员倾向于使用if语句包装变量,以便在安全模式下访问这些变量。尽管它可以工作,但是这种针对空值的保护添加了一些与代码的功能或目标无关的样板文件:

if (name != null) {
  // do something with name
}

前面的代码克服了null的创建者在 2009 年的一次会议上著名引用中发现的问题:

"I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years."

-托尼·霍尔

随着 Java8 的发布,名为Optional的实用程序类作为前面代码块的替代品被包括在内。在其他好处中,它带来了编译检查和零样板代码。让我们用一个简单的例子来看看Optional在起作用。

可选的示例

作为Optional的演示,我们将创建一个内存中的学生存储库。该库有一种通过name查找学生的方法,为了方便起见,将其视为 ID,该方法返回的值为Optional<Student>;这意味着响应可能包含也可能不包含Student。这种模式基本上是Optional的常见场景之一。

此时,读者应该熟悉 TDD 过程。为了简洁起见,跳过了完整的红绿重构过程。测试将以方便的顺序与实现一起呈现,这与 TDD 迭代中的顺序不一致。

首先,我们需要一个Student类来代表我们系统中的学生。为了简单起见,我们的实现将非常基本,只有两个参数:学生的nameage

**```java public class Student { public final String name; public final int age;

public Student(String name, int age) { this.name = name; this.age = age; } }


下一个测试类验证两个场景:成功的查找和不成功的查找。注意,AssertJ 对`Optional`有一些有用且有意义的断言方法。这使得测试非常流畅且可读:

```java
public class StudentRepositoryTest {

  private List<Student> studentList = Arrays.asList(
    new Student("Jane", 23),
    new Student("John", 21),
    new Student("Tom", 25) 
  );

  private StudentRepository studentRepository = 
    new StudentRepository(studentList);

  @Test
  public void whenStudentIsNotFoundThenReturnEmpty() {
    assertThat(studentRepository.findByName("Samantha"))
      .isNotPresent();
  }

  @Test
  public void whenStudentIsFoundThenReturnStudent() {
    assertThat(studentRepository.findByName("John"))
      .isPresent();
  }
}

如果用name验证学生的存在还不够,我们可以对返回的对象执行一些断言。在大多数情况下,这是一条路要走:

@Test
public void whenStudentIsFoundThenReturnStudent() {
  assertThat(studentRepository.findByName("John"))
    .hasValueSatisfying(s -> {
      assertThat(s.name).isEqualTo("John");
      assertThat(s.age).isEqualTo(21);
    });
}

现在,我们来关注StudentRepository类,它只包含一个构造函数和执行学生查找的方法。如下面的代码所示,查找方法findByName返回一个包含StudentOptional。请注意,这是一个有效但不起作用的实现,用作起点:

public class StudentRepository {
  StudentRepository(Collection<Student> students) { }

  public Optional<Student> findByName(String name) {
    return Optional.empty();
  }
}

如果我们对前面的实现运行测试,我们会得到一个成功的测试,因为查找方法在默认情况下返回一个Optional.empty()。另一个测试抛出一个错误,如下所示:

java.lang.AssertionError: 
Expecting Optional to contain a value but was empty.

为完整起见,这是一种可能的实现方式:

public class StudentRepository {
  private final Set<Student> studentSet;

  StudentRepository(Collection<Student> students) {
    studentSet = new HashSet<>(students);
  }

  public Optional<Student> findByName(String name) {
    for (Student student : this.studentSet) {
      if (student.name.equals(name))
        return Optional.of(student);
    }
    return Optional.empty();
  }
}

在下一节中,我们将看到关于函数的不同观点。在 Java 8 中,如果函数以特定方式使用,则会添加一些附加功能。我们将用一些例子来探讨其中的一些。

重温职责

与面向对象程序不同,以函数方式编写的程序不具有任何可变状态。相反,代码由接受参数和返回值的函数组成。因为不存在可以改变执行的内部状态或副作用,所以所有函数都是确定性的。这是一个非常好的特性,因为它意味着使用相同的参数对相同的函数执行不同的操作将产生相同的结果。

下面的代码段演示了一个不改变任何内部状态的函数:

public Integer add(Integer a, Integer b) {
  return a + b;
}

以下是使用 Java 函数 API 编写的相同函数:

public final BinaryOperator<Integer> add =
  new BinaryOperator<Integer>() {

    @Override
    public Integer apply(Integer a, Integer b) {
      return a + b;
    }
  };

任何 Java 开发人员都应该完全熟悉第一个示例;它遵循函数的通用语法,该函数将两个整数作为参数并返回它们的总和。然而,第二个例子与我们习惯的传统代码有点不同。在这个新版本中,函数是一个计算为值的对象,可以将其指定给字段。这在某些情况下非常方便,因为它在某些情况下仍然可以用作函数,在某些情况下还可以用作返回值、函数中的参数或类中的字段。

有人可能认为函数的第一个版本更合适,因为它较短,并且不需要创建新对象。没错,但函数也可以是对象这一事实通过一系列新功能增强了它们。关于代码的详细程度,可以通过使用 Lambda 表达式将其大大减少到一行:

public final BinaryOperator<Integer> addLambda = (a, b) -> a + b;

在下一节中,将介绍一种可能的解决方案反向波兰符号RPN)。我们将使用函数编程的强大功能和表达能力,特别是 Lambda 表示法,当函数需要作为某些函数的参数时,Lambda 表示法非常方便。使用 Lambdas 使我们的代码非常简洁优雅,增加了可读性。

Kata–反向波兰符号

RPN 是一种用于表示数学表达式的符号。它在运算符和操作数的顺序上不同于传统和广泛使用的中缀表示法。

在中缀表示法中,运算符放在操作数之间,而在 RPN 中,操作数放在第一位,运算符放在最后

这是一个使用中缀符号编写的表达式:

3 + 4

使用 RPN 编写的相同表达式:

3 4 +

要求

我们将避免如何阅读这些表达式,以便我们能够集中精力解决这个问题。此外,我们将只使用正整数来简化问题,尽管接受浮点数或双精度也不是很困难。为了解决这个 kata 问题,我们只需要满足以下两个要求:

  • 对于无效输入(不是 RPN),应抛出错误消息
  • 它获取使用 RPN 编写的算术表达式并计算结果

以下代码片段是我们启动项目的一个小框架:

public class ReversePolishNotation {
  int compute(String expression) {
    return 0;
  }
}

public class NotReversePolishNotationError extends RuntimeException {
  public NotReversePolishNotationError() {
    super("Not a Reverse Polish Notation");
  }
}

以前面的代码片段为起点,我们将继续进行,将需求分解为更小的规范,可以逐个解决。

需求–处理无效输入

假设我们的实现基本上什么都不做,我们将只关注一件事,即读取单个操作数。如果输入是单个数字(无运算符),则它是一个有效的反向表示法,并返回该数字的值。除此之外的任何内容目前都被视为无效 RPN。

这一要求转化为以下四项测试:

public class ReversePolishNotationTest {
  private ReversePolishNotation reversePolishNotation =
    new ReversePolishNotation();

  @Test(expected = NotReversePolishNotationError.class)
  public void emptyInputThrowsError() {
    reversePolishNotation.compute("");
  }

  @Test(expected = NotReversePolishNotationError.class)
  public void notANumberThrowsError() {
    reversePolishNotation.compute("a");
  }

  @Test
  public void oneDigitReturnsNumber() {
    assertThat(reversePolishNotation.compute("7")).isEqualTo(7);
  }

  @Test
  public void moreThanOneDigitReturnsNumber() {
    assertThat(reversePolishNotation.compute("120")).isEqualTo(120);
  }
}

我们的compute方法现在需要在提供无效输入时抛出IllegalArgumentException。在任何其他情况下,它都将数字作为整数值返回。这可以通过以下代码行实现:

public class ReversePolishNotation {
  int compute(String expression) {
    try {
      return (Integer.parseInt(expression));
    } catch (NumberFormatException e) {
      throw new NotReversePolishNotationError();
    }
  }
}

这一要求已得到满足。另一个要求有点复杂,所以我们将其分为两个单独的操作,这意味着只有一个操作,以及复杂的操作,这涉及到任何类型的多个操作。

要求-单一操作

因此,计划是支持加法、减法、乘法和除法运算。如 kata 演示中所述,在 RPN 中,运算符位于表达式的末尾。

这意味着将a-b表示为ab-,其他运算符也同样如此:加法+、乘法*和除法/

让我们将每个受支持的操作中的一个添加到测试中:

@Test
public void addOperationReturnsCorrectValue() {
  assertThat(reversePolishNotation.compute("1 2 +")).isEqualTo(3);
}

@Test
public void subtractOperationReturnsCorrectValue() {
  assertThat(reversePolishNotation.compute("2 1 -")).isEqualTo(1);
}

@Test
public void multiplyOperationReturnsCorrectValue() {
  assertThat(reversePolishNotation.compute("2 1 *")).isEqualTo(2);
}

@Test
public void divideOperationReturnsCorrectValue() {
  assertThat(reversePolishNotation.compute("2 2 /")).isEqualTo(1);
}

这还包括使其成功通过的必要更改。该行为基本上是在表达式之间放置运算符,并在表达式作为输入时执行操作。如果expression中只有一个元素,则前面的规则适用:

int compute(String expression) {
  String[] elems = expression.trim().split(" ");
  if (elems.length != 1 && elems.length != 3)
    throw new NotReversePolishNotationError();
  if (elems.length == 1) {
    return parseInt(elems[0]);
  } else {
    if ("+".equals(elems[2]))
      return parseInt(elems[0]) + parseInt(elems[1]);
    else if ("-".equals(elems[2]))
      return parseInt(elems[0]) - parseInt(elems[1]);
    else if ("*".equals(elems[2]))
      return parseInt(elems[0]) * parseInt(elems[1]);
    else if ("/".equals(elems[2]))
      return parseInt(elems[0]) / parseInt(elems[1]);
    else
      throw new NotReversePolishNotationError();
  }
}

parseInt是一个private方法,它解析输入并返回整数值或引发异常:

private int parseInt(String number) {
  try {
    return Integer.parseInt(number);
  } catch (NumberFormatException e) {
    throw new NotReversePolishNotationError();
  }
}

下一个要求是魔法发生的地方。我们将在expression内支持多个操作。

需求-复杂操作

复杂的操作很难处理,因为混合操作使未经训练的人眼很难理解操作的顺序。此外,不同的评估顺序通常会导致不同的结果。为了解决这个问题,反向波兰表达式的计算由一个队列的实现来支持。下面是我们下一个功能的一些测试:

@Test
public void multipleAddOperationsReturnCorrectValue() {
  assertThat(reversePolishNotation.compute("1 2 5 + +"))
    .isEqualTo(8);
}

@Test
public void multipleDifferentOperationsReturnCorrectValue() {
  assertThat(reversePolishNotation.compute("5 12 + 3 -"))
    .isEqualTo(14);
}

@Test
public void aComplexTest() {
  assertThat(reversePolishNotation.compute("5 1 2 + 4 * + 3 -"))
    .isEqualTo(14);
}

在 Java 中,计算应按从左到右的顺序将表达式中的数字或操作数堆积在队列或堆栈中。如果在任何一点上找到一个运算符,则该堆将用将该运算符应用于这些值的结果替换顶部的两个元素。为了更好地理解,逻辑将被分成不同的功能。

首先,我们将定义一个函数,它接受一个堆栈和一个操作,并将该函数应用于顶部的前两项。请注意,由于堆栈的实现,在第一个实例中检索第二个操作数:

private static void applyOperation(
    Stack<Integer> stack,
    BinaryOperator<Integer> operation
) {
  int b = stack.pop(), a = stack.pop();
  stack.push(operation.apply(a, b));
}

下一步是创建程序必须处理的所有函数。对于每个操作符,函数定义为对象。这有一些优点,例如更好的测试隔离。在这种情况下,单独测试函数可能没有意义,因为它们很琐碎,但在其他一些场景中,单独测试这些函数的逻辑可能非常有用:

static BinaryOperator<Integer> ADD = (a, b) -> a + b;
static BinaryOperator<Integer> SUBTRACT = (a, b) -> a - b;
static BinaryOperator<Integer> MULTIPLY = (a, b) -> a * b;
static BinaryOperator<Integer> DIVIDE = (a, b) -> a / b;

现在,把所有的部分放在一起。根据我们找到的操作员,应用正确的操作:

int compute(String expression) {
  Stack<Integer> stack = new Stack<>();
  for (String elem : expression.trim().split(" ")) {
    if ("+".equals(elem))
      applyOperation(stack, ADD);
    else if ("-".equals(elem))
      applyOperation(stack, SUBTRACT);
    else if ("*".equals(elem))
      applyOperation(stack, MULTIPLY);
    else if ("/".equals(elem))
      applyOperation(stack, DIVIDE);
    else {
      stack.push(parseInt(elem));
    }
  }
  if (stack.size() == 1) return stack.pop();
  else throw new NotReversePolishNotationError();
}

代码可读性强,易于理解。此外,此设计允许通过轻松添加对其他不同操作的支持来扩展功能。

读者可以在提供的解决方案中添加模数(%)操作。

lambdas 非常适合的另一个很好的例子是 Streams API,因为大多数函数都有一个自解释的名称,比如filtermapreduce等等。让我们在下一节更深入地探讨这个问题。

Java8 中包含的一个顶级实用程序是流。在本章中,我们将结合使用 Lambda 和小代码片段中的流,并创建一个测试来验证它们。

为了更好地了解流是什么、做什么以及不做什么,强烈建议阅读 Oracle 的流页面。一个好的起点是这里

简而言之,Streams 提供了一系列工具来处理长时间的计算,这些计算可以并行执行,也可以按顺序执行。并行编程不在本书的范围内,因此下面的示例将仅是顺序的。此外,为了保持本章的简洁,我们将重点关注:

  • filter
  • map
  • flatMap
  • reduce

滤波器

让我们从filter操作开始。过滤器是一个具有自解释名称的函数;它根据值是否满足条件过滤流中的输入/输出元素,如以下示例所示:

@Test
public void filterByNameReturnsCollectionFiltered() {
  List<String> names = Arrays.asList("Alex", "Paul", "Viktor",
         "Kobe", "Tom", "Andrea");
  List<String> filteredNames = Collections.emptyList();

  assertThat(filteredNames)
      .hasSize(2)
      .containsExactlyInAnyOrder("Alex", "Andrea");
}

计算filteredNames列表的一种可能性如下:

List<String> filteredNames = names.stream()
      .filter(name -> name.startsWith("A"))
      .collect(Collectors.toList());

那是最容易的。简而言之,filter过滤输入并返回一个没有过滤掉所有元素的值。使用 Lambdas 可以使代码变得优雅且易于阅读。

地图

map函数将流中的所有元素转换为另一个元素。结果对象可以与输入共享类型,但也可以返回不同类型的对象:

@Test
public void mapToUppercaseTransformsAllElementsToUppercase() {
  List<String> names = Arrays.asList("Alex", "Paul", "Viktor");
  List<String> namesUppercase = Collections.emptyList();

  assertThat(namesUppercase)
      .hasSize(3)
      .containsExactly("ALEX", "PAUL", "VIKTOR");
}

列表namesUppercase的计算方法如下:

List<String> namesUppercase = names.stream()
  .map(String::toUpperCase)
  .collect(Collectors.toList());

注意toUpperCase方法是如何调用的。它属于 Java 类String,只能通过引用函数和函数所属的类在该场景中使用。在 Java 中,这称为方法引用

平面图

flatMap函数与map函数非常相似,但当操作可能返回多个值,并且我们希望保留单个元素流时,使用它。在map的情况下,将返回一个集合流。让我们看看flatMap在使用中:

@Test
public void gettingLettersUsedInNames() {
  List<String> names = Arrays.asList("Alex", "Paul", "Viktor");
  List<String> lettersUsed = Collections.emptyList();

  assertThat(lettersUsed)
    .hasSize(12)
    .containsExactly("a","l","e","x","p","u","v","i","k","t","o","r");
}

一种可能的解决办法是:

List<String> lettersUsed = names.stream()
  .map(String::toLowerCase)
  .flatMap(name -> Stream.of(name.split("")))
  .distinct()
  .collect(Collectors.toList());

这次我们使用了Stream.of(),这是一种创建流的方便方法。另一个非常好的特性是方法distinct(),它返回一组独特的元素,并使用方法equals()对它们进行比较。

减少

在上一个示例中,函数返回作为输入传递的所有名称中使用的字母列表。但是如果我们只对不同字母的数量感兴趣,有一种更简单的方法可以继续。reduce基本上对所有元素应用一个函数,并将它们组合成一个结果。让我们看一个例子:

@Test
public void countingLettersUsedInNames() {
  List<String> names = Arrays.asList("Alex", "Paul", "Viktor");
  long count = 0;

  assertThat(count).isEqualTo(12);
}

此解决方案与我们在上一个练习中使用的解决方案非常相似:

long count = names.stream()
  .map(String::toLowerCase)
  .flatMap(name -> Stream.of(name.split("")))
  .distinct()
  .mapToLong(l -> 1L)
  .reduce(0L, (v1, v2) -> v1 + v2);

尽管前面的代码片段解决了这个问题,但有一种更好的方法:

long count = names.stream()
  .map(String::toLowerCase)
  .flatMap(name -> Stream.of(name.split("")))
  .distinct()
  .count();

函数count()是 Streams 包含的另一个内置工具。这是reduction函数的一个特殊快捷方式,用于计算流包含的元素数。

总结

函数式编程是一个越来越流行的古老概念,因为当试图通过并行执行任务来提高性能时,它更容易使用。本章介绍了功能领域的一些概念以及 AssertJ 提供的一些测试工具

测试没有副作用的功能非常容易,因为测试范围缩小了。与测试函数可能在不同对象上引起的更改不同,唯一需要验证的是调用的结果。无副作用意味着只要参数相同,功能的结果是相同的。因此,执行可以根据需要重复多次,每次执行都会得到相同的结果。此外,测试更容易阅读和理解。

总之,如果您需要在项目中使用这个范例,Java 包含了一个很好的函数式编程 API。但也有一些语言,其中一些纯粹是函数式的,提供了更强大的功能,语法更好,样板文件更少。如果您的项目或方法完全是功能性的,您应该评估使用这些其他语言中的一种是否有意义。

本章中介绍的所有示例可在这里找到。

现在是时候来看看遗留代码,以及如何对其进行调整,使其更加 TDD 友好。******