在Java中使用函数范式提高代码质量


在一个范式和技术堆栈一直在变化的世界中,保持竞争力和提高生产力和质量的斗争有时候证明是一项挑战。
在本文中,我想首先展示一下函数编程(FP)的优势,特别是加强Java编码体验。在尝试将范式转换为函数式编程时,我将尝试迭代我发现最重要的几个原因。请记住,这绝不是一个巨大的创新,我相信FP自70年代以来一直存在,但仅在最近几年它才获得吸引力并增加了人们的兴趣。我们来看看为什么!

并发
随着多核/多线程处理器的出现,函数式编程开始受到更多关注。这绝不是一个简单的巧合,因为函数式编程鼓励使用不可变对象,属性和变量应该是一种其值不能更改的数据容器)。看看下面代码:

private int aNumber;
public void setNumber(int numberParameter){ 
  this.aNumber = numberParameter; 
}

很简单吧?你以前可能已经看过很多次了。但是如果两个线程同时访问setNumber方法会发生什么?你可以想象某种阻塞可能会发生,最后,只有访问该方法的最后一个线程才会对aNumber的值有最终决定权。但这不确定,取决于各种因素,因此,我们可以说方法setNumber不是引用透明的(后面会详细介绍)。这种情况下不变性有助于推理代码,因为我们确信无论有多少线程访问它的一部分,它的值总是相同的。

引用透明和可测试性
函数式编程鼓励使用引用透明的函数。那是什么意思?嗯,这意味着一个函数总是可以被它的值替换,一切都将保持不变。看一下以下代码块:

import java.util.Random;
public class RandomValueProvider { 
  public int getSomeRandomValue(){ 
    Random rand = new Random();
    return rand.nextInt(50);
  } 
}

getSomeRandomValue()方法引用透明吗?试着用它的值替换它,它总是保持不变吗?可能不会。尽可能尝试使用引用透明的函数可能是一个好习惯。想象一下,测试上面的getSomeRandomValue方法比测试以下方法要困难得多:
public int getSum(int a,int b){ 
  return a + b; 
}

具有暗示名称的小函数通常比式样表示它们返回值的表达式更好。好处是能确保我们编写的(至少大部分)函数是确定性的。这将增加代码推理的简易性以及可测试性。

函数组合
在应用FP原则时,操作现在更简单,更具确定性,这一事实使我们能够通过将不同的功能组合在一起来创建更复杂的行为。将其他函数作为参数或返回函数一起接收的函数称为高阶函数。
这里的一些示例来自Java 8 Stream API。自2014年成为JDK的一部分(甚至在此之前)以来,已经在流上编写了大量内容。我只是想在这里使用Consumer函数接口展示一个简单的例子:

public void processListOfNumbers(List<Integer> listOfNumbers, Consumer<Integer> processor) {
  return listOfNumbers.stream()
    .forEach(number -> processor.accept(number));
}

客户端代码:

List<Integer> numbers = Arrays.asList(5, 6, 7, 8);
Consumer<Integer> numberPrinter = n -> System.out.println(n);
processListOfNumbers(numbers, numberPrinter);

方法processListOfNumbers是函数组合的一个示例,您可能会听到它有时被命名为高阶函数。在Java中,函数(也包括suppliers, consumers)是对象。这意味着我们可以应用它们,组合它们并将它们作为参数传递。

以FP风格编写的应用程序更加强大
在以函数式编写代码时,应用程序本身的更不容易出错。这是因为当您的移动一些组件时,应用程序往往变得更容易预测,更容易推理并且更能适应逆境。函数组合和不变性的一般用法将确保所有那些因为应用程序不同部分的状态变化而导致的错误现在默认消失了。该应用程序将更加强大,可以提供更短的开发 - >测试 - >调试迭代循环。

专注于“什么”而不是“如何”
假设我们有一个getUserById方法(在同一个类中)负责从数据库中获取相应的User对象,请使用以下Java流的经典应用程序:

public List<User> getAdultUsers(List<Integer> listOfUserIds) { 
  return listOfUserIds.stream().map(this::getUserById)
    .filter(user -> user.getAge() >= 18)
    .collect(Collectors.toList());
}

现在让我们看看非函数风格的相同代码:

public List<User> getAdultUsers(List<Integer> listOfUserIds) {
  List<User> adultUsers = new ArrayList<>();
  for(int id: listOfUserIds) {
    User user = getUserById(id);
    if (user.getAge() >= 18) {
      adultUsers.add(user);
    }
  }
  return adultUsers;

}

除了第二段略长外,我们还可以注意到这段代码需要花时间来“解释”此操作的每个步骤是如何完成的:创建一个空白列表,迭代id,获取每个用户,添加一些基于条件表达式的用户到空白列表,完成并返回收集的用户。
另一方面,在第一段中,功能方法更侧重于“什么”。代码在做什么?它将一些ID映射到某些用户,将其过滤掉并将其余用户收集到列表中。有人可能会争辩说,通过在第二种情况下提取小方法可以实现同样的目的,但我相信第一段的流和函数作为数据方法仍然更好。它将我们的函数置于业务逻辑的最前沿,具有与在我们的应用程序中移动的任何其他数据相同的状态。

更好看的方法签名
当我们的功能从命令式转变为函数式时,命名也一目了然,以下方法很难通过其签名来阅读:

public void executeProcess() {
  // executing some mysterious stuff!
}

代码做了什么?为什么它不想要我们的任何输入参数,为什么它不想返回任何结果?你能测试一下吗?你能读懂吗?不容易吧。如果像下面这样看起来如何?

public ExecutionStatus executeProcess(Process processToBeExecuted) {
  // execute "processToBeExecuted" and return some status
}

只需采用一些FP概念,并在这个简单的情况下使用它们,代码就变得更具可读性。函数现在是可通过查看它的方法签名来说明自己(虽然方法名称可能仍然可以改进)。它需要一个Process输入并以某种ExecutionStatus状态返回。除了直接在代码中提供更好的“文档”之外,签名变得更有意义。执行什么Process?我们可以查看Process对象并在运行时查看它。它发挥作用后会发生什么?我然后可在我们的流程中使用该函数的返回结果。

结论
如今,无论我们是在处理遗留代码还是新建绿地项目,我们都可以使用一些东西来提高日常工作的质量和生产率。函数编程从不同的角度进行编码。它通常意味着更简洁,但如果给予适当的照顾,也会提高可读性。它还帮助我们解决一些常见的痛苦,例如并发编程中的竞争条件,令人讨厌的对象状态错误或难以遵循的代码。
 

java8引入lambda表达式,也算是基本跟上了主流语言的步伐了,
lambda越用越喜欢。