乐趣区

java-8-实战读书笔记-第十三章-函数式的思考

一、实现和维护系统

1. 共享的可变数据

如果一个方法既不修改它内嵌类的状态,也不修改其他对象的状态,使用 return 返回所有的计算结果,那么我们称其为 纯粹的 或者 无副作用的
副作用就是函数的效果已经超出了函数自身的范畴。下面是一些例子。

  • 除了构造器内的初始化操作,对类中数据结构的任何修改,包括字段的赋值操作(一个典型的例子是 setter 方法)。
  • 抛出一个异常。
  • 进行输入 / 输出操作,比如向一个文件写数据。

从另一个角度来看“无副作用”的话,我们就应该考虑 不可变对象 。不可变对象是这样一种对象,它们一旦完成初始化就不会被任何方法修改状态。这意味着一旦一个不可变对象初始化完毕,它永远不会进入到一个无法预期的状态。你可以放心地共享它,无需保留任何副本,并且由于它们不会被修改,还是 线程安全 的。如果构成系统的各个组件都能遵守这一原则,该系统就能在完全无锁的情况下,使用 多核的并发机制

2. 声明式编程

如果你希望通过计算找出列表中最昂贵的事务,摒弃传统的命令式编程“如何做”的风格,采用如下这种“要做什么”风格的编程通常被称为 声明式编程(利用了函数库,内部迭代)。

Optional<Transaction> mostExpensive = 
 transactions.stream() 
 .max(comparing(Transaction::getValue));

3. 为什么要采用函数式编程

使用函数式编程,你可以实现更加健壮的程序,还不会有任何的副作用。

二、什么是函数式编程

对于“什么是函数式编程”这一问题最简化的回答是“它是一种使用函数进行编程的方式”。

当谈论“函数式”时,我们想说的其实是“像数学函数那样——没有副作用”。由此,编程上的一些精妙问题随之而来。我们的意思是,每个函数都只能使用函数和像 if-then-else 这样的数学思想来构建吗?或者,我们也允许函数内部执行一些非函数式的操作,只要这些操作的结果不会暴露给系统中的其他部分?换句话说,如果程序有一定的副作用,不过该副作用不会为其他的调用者感知,是否我们能假设这种副作用不存在呢?调用者不需要知道,或者完全不在意这些副作用,因为这对它完全没有影响。当我们希望能界定这二者之间的区别时,我们将第一种称为 纯粹的函数式编程 ,后者称为 函数式编程

1. 函数式 Java 编程

我们的准则是,被称为“函数式”的函数或方法都 只能修改本地变量。除此之外,它引用的对象都应该是不可修改的对象。通过这种规定,我们期望所有的字段都为 final 类型,所有的引用类型字段都指向不可变对象。

要被称为函数式,函数或者方法不应该抛出任何异常。
那么,如果不使用异常,你该如何对除法这样的函数进行建模呢?答案是请使用 Optional<T> 类型

最后,作为函数式的程序,你的函数或方法调用的库函数如果有副作用,你必须设法隐藏它们的非函数式行为,否则就不能调用这些方法。

2. 引用透明性

如果一个函数只要传递同样的参数值,总是返回同样的结果,那这个函数就是 引用透明 的。

Java 语言中,关于引用透明性还有一个比较复杂的问题。假设你对一个返回列表的方法调用了两次。这两次调用会返回内存中的两个不同列表,不过它们包含了相同的元素。如果这些列表被当作可变的对象值(因此是不相同的),那么该方法就 不是引用透明的 。如果你计划将这些列表作为单纯的值(不可修改),那么把这些值看成相同的是合理的,这种情况下该方法是引用透明的。通常情况下, 在函数式编程中,你应该选择使用引用透明的函数

3. 面向对象的编程和函数式编程的对比

作为 Java 程序员,毫无疑问,你一定使用过某种函数式编程,也一定使用过某些我们称为极端面向对象的编程。一种支持极端的面向对象:任何事物都是对象,程序要么通过更新字段完成操作,要么调用对与它相关的对象进行更新的方法。另一种观点支持引用透明的函数式编程,认为方法不应该有(对外部可见的)对象修改。

三、递归和迭代

纯粹的函数式编程语言通常不包含像 while 或者 for 这样的迭代构造器。之后你该如何编写程序呢?比较理论的答案是每个程序都能使用无需修改的递归重写,通过这种方式避免使用迭代。使用递归,你可以消除每步都需更新的迭代变量。
比如阶乘

static long factorialStreams(long n){return LongStream.rangeClosed(1, n) 
 .reduce(1, (long a, long b) -> a * b); 
}

每次执行 factorialRecursive 方法调用都会在调用栈上创建一个新的栈帧,用于保存每个方法调用的状态(即它需要进行的乘
法运算),这个操作会一直指导程序运行直到结束。这意味着你的递归迭代方法会依据它接收的输入成比例地消耗内存。这也是为什么如果你使用一个大型输入执行 factorialRecursive 方法,很容易遭遇 StackOverflowError 异常:

Exception in thread "main" java.lang.StackOverflowError

函数式语言提供了一种方法解决这一问题:尾调优化(tail-call optimization), 基本的思想是你可以编写阶乘的一个迭代定义,不过迭代调用发生在函数的最后(所以我们说调用发生在尾部)。
基于“尾递”的阶乘

static long factorialTailRecursive(long n) {return factorialHelper(1, n); 
} 

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

使用栈桢方式的阶乘的递归定义:

阶乘的尾递定义这里它只使用了一个栈帧:

坏消息是,目前 Java 还不支持这种优化。很多的现代 JVM 语言,比如 Scala 和 Groovy 都已经支持对这种形式的递归的优化,最终实现的效果和迭代不相上下(它们的运行速度几乎是相同的)。

退出移动版