函数式编程的技巧

函数式编程的技巧

一. 无处不在的函数

我们知道函数式编程是指函数或方法的行为就像数学函数一样——没有任何副作用。对于程序员来说,这个术语还意味着函数可以像任何其他值一样随意使用:可以作为参数传递,可以作为返回值,还能存储在数据结构中。能够像普通变量一样使用的函数被称为一等函数,通过操作符::创建的方法引用,可以像使用函数值一样使用方法,也能使用Lambda表达式直接表示方法的值。

1.1 高阶函数

在之前我们使用一等函数只是为了将代码传递给流处理操作,达到行为参数化的效果。我们可以接受函数作为参数同时返回另一个函数。

1
Comparator<Apple> c = comparing(Apple::getWeight);

comparing方法接受一个函数作为参数同时返回另一个函数

在Java 8中函数不仅可以作为参数传递,也可以作为结果返回,能赋值给本地变量,也可以插入到某个数据结构。comparing方法是一个高阶函数,我们知道传递给流的操作应该是无副作用的,高阶函数也适用这一原则。

高阶函数要满足的要求:

  • 接受至少一个函数作为参数
  • 返回的结果是一个函数

1.2 科里化

科里化是一种可以帮助我们模块化函数、提高代码重用性的技术。

假设我们需要实现单位转换的需求,一般单位转换会涉及到转换因子以及基线调整,如摄氏度转换为华氏度:CtoF(x)=x*9/5 + 32。

1
2
3
4
5
6
7
8
9
10
/**
* 单位转换
* @param x 需要转换的值
* @param f 转换因子
* @param b 基线值
* @return 结果
*/
static double converter(double x, double f, double b){
return x * f + b;
}

但一些情况下(如公里和英里转换),可能不会同时用到三个参数,这时我们可能首先想到的是方法重载,但其实有一些更简单的办法。如下列curriedConverter方法,可以生产带一个参数的转换方法,这样一来我们就复用了转换逻辑,代码也更灵活了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 工厂方法生产单位转换公式
* @param f 转换因子
* @param b 基线值
* @return 单位转换公式
*/
static DoubleUnaryOperator curriedConverter(double f, double b){
return (double x) -> x * f + b;
}

public static void main(String[] args) {
DoubleUnaryOperator converterCtoF = curriedConverter(9.0/5, 32);
DoubleUnaryOperator converterUSDtoGBP= curriedConverter(0.6, 0);
DoubleUnaryOperator converterKmtoMi = curriedConverter(0.6214, 0);
double c = converterCtoF.applyAsDouble(100);//212.0
double gbp = converterUSDtoGBP.applyAsDouble(1000);//600.0
double km = converterKmtoMi.applyAsDouble(1_000_000);//621400.0
}

科里化是一种将具备2个参数的函数f转换为使用一个参数的函数g,并且这个函数的返回值也是一个函数,它会作为新函数的一个参数。后者的返回值和初始函数的返回值相同,即f(x, y) = (g(x))(y)。所以我们可以把一个有6个参数的函数科里化为一个接受2、4、6号参数,并返回一个接受5号参数的函数,这个返回的函数又返回一个接受剩下的1号和3号参数的函数。


二. 持久化数据结构

函数式编程中常见的数据结构叫法:函数式数据结构,不可变数据结构,持久化数据结构。函数式方法不允许修改任何全局数据结构或者任何作为参数传入的结构,一旦允许对这些数据进行修改,那么多次调用就可能得到不同的结构,这违背了引用透明性原则,我们也就无法将方法简单的看作从参数到结果的映射。

2.1 破坏式更新和函数式更新的比较

假设我们要对火车旅行从A地到B地进行建模,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 单向链表实现对火车旅行建模
*/
public class TrainJourney {
public int price;//当前路途段价格
public TrainJourney onward;//下一段路途
public TrainJourney(int p, TrainJourney t){
price = p;
onward = t;
}

/**
* 将代表X到Y和Y到Z两段路途串接起来
* @param a 路途段a
* @param b 路途段b
* @return 新的路途段
*/
static TrainJourney link(TrainJourney a, TrainJourney b){
//a为空则直接返回b
if(a == null) return b;
TrainJourney t = a;
while (t.onward != null){
//当a的下个路途段不为空时,将t引用指向下个路途段
t = t.onward;
}
//将a路途段链表的最后一节指向b路途段
t.onward = b;
return a;
}
}

link方法有一个问题,它会破坏性的更新a,让b加入到a的链表中,执行后a不再表示从X到Y的路途段,而是新的X到Z的路途段。所以现实中可能会导致什么问题?本来应该从X到Y的旅客,因为数据被破坏了会多坐几站到最新的终点站。

函数式编程要杜绝这种带副作用的方法,为了计算需要创建现存数据结构的副本,这样的做法也适用于标准的面向对象程序设计。当然有异议的是这样可能会导致过度的对象复制,可能你会说你记住了这个副作用,在别处会注意这个缺陷,但最终还是挖坑留给了维护人员。我们通过递归的方式改写link方法,代码如下。

1
2
3
4
5
6
7
8
9
/**
* 递归函数式的将代表X到Y和Y到Z两段路途串接起来
* @param a 路途段a
* @param b 路途段b
* @return 新的路途段
*/
static TrainJourney append(TrainJourney a, TrainJourney b){
return a == null ? b : new TrainJourney(a.price, append(a.onward, b));
}

append方法是函数式的,而且并未创建整个新TrainJourney对象的副本:如果a是n个元素的序列,b是m个元素的序列,那么调用此函数后,返回的是一个n+m个元素的序列,这个序列的前n个元素是新创建的,而后m个元素是和b共享的。要注意的是,append方法生成的结果不能被用户破坏,否则会影响到b的使用者。

破坏式和函数式数据结构对比

2.2 案例:二叉查找树

我们实现一个二叉查找树,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

/**
* 二叉查找树
*/
public class Tree {
private String key;
private int val;
private Tree left,right;

//构造器和成员方法
}

public class TreeProcessor {
/**
* 查找树中给定字符串对应键值的整型value
* @param k 给定字符串
* @param defaultval 默认value
* @param t 二叉查找树
* @return 对应整型value
*/
public static int lookup(String k, int defaultval, Tree t){
if (t == null) return defaultval;
if (k.equals(t.getKey())) return t.getVal();
return lookup(k, defaultval, k.compareTo(t.getKey()) < 0 ? t.getLeft() : t.getRight());
}

//处理Tree的其他方法
}

如果需要对映射中指定键对应的值做更新,我们可能会如下列代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 更新键对应值
* @param k 给定字符串
* @param newval 新值
* @param t 二叉查找树
*/
public static void update(String k, int newval, Tree t){
if (t == null){
/* 增加一个新的节点 */
}
else if (k.equals(t.getKey())) t.setVal(newval);
else update(k, newval, k.compareTo(t.getKey()) < 0 ? t.getLeft() : t.getRight());
}

新增节点最简单的方法是在递归过程中让update直接返回其刚遍历的树,如下列代码所示。

1
2
3
4
5
6
7
8
9
public static Tree update(String k, int newval, Tree t){
if (t == null){
t = new Tree(k, newval, null, null);
}
else if (k.equals(t.getKey())) t.setVal(newval);
else if ( k.compareTo(t.getKey()) < 0) t.setLeft(update(k, newval, t.getLeft()));
else t.setRight(update(k, newval, t.getRight()));
return t;
}

以上两种实现都会对现有的树进行修改,意味着树的使用者都会感知到这些修改。

2.3 采用函数式的方法

如果要用函数式的方法实现键值更新,需要为新的键值创建一个新的节点,还有创建从树的根节点到新节点路径上的所有节点(副本)。通常情况下,这个操作的代价并不会很大,如果树的深度为d,且保持着一定的平衡性,那么这个树的节点总数为2^d,我们只需要创建树的一小部分节点。我们实现时没有使用if-then-else,目的是强调函数式的思想,但也可以用if-then-else实现这些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 函数式的更新键对应值
* @param k 给定字符串
* @param newval 新值
* @param t 二叉查找树
* @return 新的二叉查找树
*/
public static Tree fupdate(String k, int newval, Tree t){
return (t == null) ?
new Tree(k, newval, null, null) :
k.equals(t.getKey()) ?
new Tree(k, newval, t.getLeft(), t.getRight()) :
k.compareTo(t.getKey()) < 0 ?
new Tree(t.getKey(), t.getVal(), fupdate(k, newval, t.getLeft()), t.getRight()) :
new Tree(t.getKey(), t.getVal(), t.getLeft(), fupdate(k, newval, t.getRight()));
}

update和fupdate这两种实现有什么区别呢?update的用户只会共享一份数据结构,对于此数据结构的修改会影响到所有用户,用户应该也想要及时了解程序任何部分所做的更新。fupdate则是纯函数式的,它会创建一个新树并将其作为结果返回,通过参数的方式实现共享。

对树结构进行更新时,现存数据结构不会被破坏

这种函数式的数据结构通常被称为持久化的——数据结构的值始终保持一致,不受其他部分变化的影响(数据库中的持久化表示数据生命周期比程序的执行周期更长的数据)。持久化的数据结构有一个附加条件:所有使用持久化数据结构的用户都需要遵守不修改原则。如果忽视此条件去修改fupdate结果(如修改共享的Emily的年龄),会被所有使用此结果的用户感知,造成无法预知的影响。

fupdate可能有更高效的方式:基于不对现存结构进行修改规则,对仅有细微差别的这些通用数据结构可以考虑使用共享存储。可以通过编译器将Tree类的字段key、val、left和right改为final,需要注意的是final只能应用于类的字段,无法应用于它指向的对象,如果需要对对象保护,需要对其中的字段声明final。

有些时候你可能需要对树结构的更新对某些用户可见,为了实现这一需求,我们可以通过两种方式:第一种是典型的Java解决方案:对对象进行更新时,需要特别小心,慎重的考虑是否需要在改动之前保存对象的一分副本;另一种是函数式的解决方案:逻辑上,我们在做任何改动之前都会创建一份新的数据结构,只要确保按照用户的需求传递给他正确版本的数据结构就可以了。后者甚至可以通过API强制实施,如果数据结构的某些用户需要进行可见性的改动,应该调用API返回最新版的数据结构。而对于另外一些客户应用,不希望发生任何可见的改动,就直接使用它们保存的备份。


三. Stream的延迟计算

Stream的局限是其只能使用一次,所以无法声明一个递归的Stream。

3.1 自定义的Stream

回顾流相关内容时生成质数的例子,代码如下。

1
2
3
4
5
6
7
8
9
10
public static Stream<Integer> primes(int n){
return Stream.iterate(2, i -> i + 1)
.filter(MyMathUtils::isPrime)
.limit(n);
}

public static boolean isPrime(int candidate){
int candidateRoot = (int) Math.sqrt((double) candidate);
return IntStream.rangeClosed(2, candidateRoot).noneMatch(i -> candidate % i == 0);
}

上述实现每次都要遍历每个数字,查看它是否能被候选数字整除,实际上我们只需要测试已被判定为质数的数字。理想情况下,Stream应该实现实时筛掉能被质数整除的数字。

为了实现这一想法,我们需要:

  1. 一个数字构成的Stream,用来筛选质数
  2. 从该Stream中取出第一个数字,它是一个质数(初始时为2)
  3. 紧接着从Stream尾部开始,筛选掉所有能被该数字整除的元素
  4. 最后剩下的结果即新的Stream,继续用其进行质数的查找,因为要回到第一步,所以此算法是递归的。

第一步,构造由数字组成的Stream。

1
2
3
static IntStream numbers(){
return IntStream.iterate(2, n -> n + 1);
}

第二步,取得首元素

1
2
3
static int head(IntStream numbers){
return numbers.findFirst().getAsInt();
}

第三步,对尾部元素进行筛选

1
2
3
4
5
6
7
static IntStream tail(IntStream numbers){
return numbers.skip(1);
}

IntStream numbers = numbers();
int head = head(numbers);
IntStream filtered = tail(numbers).filter(n -> n % head != 0);

第四步,递归的创建由质数组成的Stream

1
2
3
4
5
6
7
static IntStream primes(IntStream numbers){
int head = head(numbers);
return IntStream.concat(
IntStream.of(head),
primes(tail(numbers).filter(n -> n % head != 0))
);
}

不幸的是执行第四步会抛出异常:“java.lang.IllegalStateException: stream has already been operated upon or closed”,因为试图使用两个终端操作:findFirst和skip将Stream切分成头尾两部分,一旦执行一次终端操作流就会终止。

此操作还附带一个更严重的问题:静态方法IntStream.concat接受两个Stream实例作为参数,但由于第二个参数是primes方法的直接递归调用,最终会导致无限递归的状况(当然Java的Stream不能递归)。而函数式语言Scala和Haskell的Stream具备的这些通用特性和模型仍能帮助到我们。我们需要一种方法来推迟primes中对concat的第二个参数计算,一般称之为延迟计算非限制式计算名调用,只有在需要处理那个质数时才对Stream进行计算。在Scala中操作符#::实现了延迟链接的功能。

1
2
3
4
def numbers(n: Int) Stream[Int] = n #:: numbers(n+1)
def primes(numbers: Stream[Int]): Stream[Int] = {
numbers.head #:: primes(numbers.tail filter (n -> n % numbers.head != 0))
}

在Java中执行一次方法调用,传递的所有参数在第一时间会被立即计算出来;而在Scala中,通过 #:: 操作符,连接操作会立刻返回,而元素的计算会推迟到实际计算需要的时候才开始。

3.2 创建自己的延迟列表

Stream具有延迟执行的特性,流就像一个黑盒,接收请求并生成结果。当你向流提交一系列操作请求时,这些请求只是被异议保存起来,只有当请求一个终端操作时,才会实际的进行计算。这样设计的优点是Stream只需要被遍历一次,不需要为每个操作都遍历一次元素。

延迟列表是一种更加通用的Stream形式,提供了一种极好的方式去理解高阶函数,你可以将函数作为值存储在某个数据结构中,等到需要调用时可以创建更多的数据结构。

LinkedList的元素存在于内存中LazyList的元素由函数在需要使用时动态创建,可以看作实时延展的

3.2.1 一个基本的链接列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public interface MyList<T> {
T head();

MyList<T> tail();

default boolean isEmpty(){
return true;
}
}

public class MyLinkedList<T> implements MyList<T> {
private final T head;
private final MyList<T> tail;

public MyLinkedList(T head, MyList<T> tail) {
this.head = head;
this.tail = tail;
}

@Override
public T head() {
return head;
}

@Override
public MyList<T> tail() {
return tail;
}

@Override
public boolean isEmpty() {
return false;
}
}

public class Empty<T> implements MyList<T> {
@Override
public T head() {
throw new UnsupportedOperationException();
}

@Override
public MyList<T> tail() {
throw new UnsupportedOperationException();
}
}

public static void main(String[] args) {
MyList<Integer> l = new MyLinkedList<>(5, new MyLinkedList<>(10,new Empty<>()));
}

3.2.2 一个基础的延迟列表

对链表进行改造,使其符合延迟列表的要求。最简单的方法是避免让tail立刻出现在内存中,用Supplier包装一层,对应的函数代码会产生列表的下个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class LazyList<T> implements MyList<T> {
final T head;
final Supplier<MyList<T>> tail;

public LazyList(T head, Supplier<MyList<T>> tail) {
this.head = head;
this.tail = tail;
}

@Override
public T head() {
return head;
}

@Override
public MyList<T> tail() {
return tail.get();
}

@Override
public boolean isEmpty() {
return false;
}
}

调用Supplier的get方法会触发延迟列表的节点创建,就像工厂会创建新的对象一样。我们可以通过递归传递函数创建一系列的数字序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 创建由数字构成的无限延迟列表
*/
public static LazyList<Integer> from(int n){
return new LazyList<>(n, () -> from(n+1));
}

public static void main(String[] args) {
LazyList<Integer> numbers = LazyList.from(2);
int two = numbers.head();
int three = numbers.tail().head();
int four = numbers.tail().tail().head();
System.out.println(two + " " + three + " " + four);//2 3 4
}

3.2.3 回到生成质数

如果我们直接用LazyList替换实现的primes函数,如下代码所示,会因为filter方法未定义不能通过编译。

1
2
3
4
5
6
public static MyList<Integer> primes(MyList<Integer> numbers){
return new LazyList<>(
numbers.head(),
() -> primes(numbers.tail().filter(n -> n % numbers.head() != 0))
);
}

3.2.4 实现一个延迟筛选器

我们在MyList接口添加方法定义,并在LazyList中实现filter方法,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public interface MyList<T> {
......
MyList<T> filter(Predicate<T> p);
}

public class LazyList<T> implements MyList<T> {
......

@Override
public MyList<T> filter(Predicate<T> p){
return isEmpty() ?
this : p.test(head()) ? new LazyList<>(head(), () -> tail().filter(p)) : tail().filter(p);
}
}

public static MyList<Integer> primes(MyList<Integer> numbers){
return new LazyList<>(
numbers.head(),
() -> primes(numbers.tail().filter(n -> n % numbers.head() != 0))
);
}

public static void main(String[] args) {
//延迟列表实现生成质数
LazyList<Integer> numbers = LazyList.from(2);
int two = primes(numbers).head();
int three = primes(numbers).tail().head();
int four = primes(numbers).tail().tail().head();
System.out.println(two + " " + three + " " + four);//2 3 5
}

尝试循环打印延迟数组中的质数,这个程序不会一直运行下去,最终会因栈溢出而终止,因为Java不支持尾部调用消除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static <T> void printAll(MyList<T> list){
while (!list.isEmpty()){
System.out.println(list.head());
list = list.tail();
}
}

//递归写法
static <T> void printAll(MyList<T> list){
if(list.isEmpty())
return;
System.out.println(list.head());
printAll(list.tail());
}

3.2.5 使用场景

比如我们要编写游戏程序,可以定义一些数据结构来存放要加载的物体,具体内容可以在运行时创建,而不用花大量的时间在一开始就创建好,最终的结果是一个延迟树,而不是延迟列表,我们主要讲延迟列表是为了和Stream形成对比。

延迟操作的性能一般情况下都要比提前操作要好,但有时比如我们只访问列表前10个元素,每个节点会创建两次,最终创建20个节点,原因在于每次实时访问列表元素时,tail的Supplier都会被重复调用;我们可以设定tail中的Supplier方法只在第一次实时访问时才执行调用,可以在LazyList中添加一个私有的 Optional<LazyList<T>> 类型字段alreadyComputed,tail方法会根据情况查询及更新该字段的值。


四. 模式匹配

函数式编程的模式匹配和正则表达式的模式匹配容易混淆,是为了解决因为需求变更等原因导致if-else-then或switch代码数量迅速膨胀的问题。

假设我们用类Expr对一种数学语言建模,包含数字和二进制操作符。

1
2
3
class Expr { ... }
class Number extends Expr { int val; ... }
class BinOp extends Expr { String opname; Expr left; Expr right; ... }

假设我们要通过方法来简化某个表达式,比如5+0=5可以简化为 5 -> new BinOp("+", new Number(5), new Number(0)) 可以简化为Number(5),实现代码可能会如下。

1
2
3
4
5
6
7
8
9
Expr simplifyExpression(Expr expr){
if(expr instanceof BinOp
&& ((BinOp) expr).opname.equals("+"))
&& ((BinOp) expr).right instanceof Number
&& ... //变得非常笨拙
&& ...){
return (BinOp) expr.left;
}
}

4.1 访问者设计模式

访问者设计模式,需要创建一个单独的类,此类封装了一个算法,可以“访问”某种数据类型。访问者类会接受某种数据类型的实例作为输入,它可以访问实例所有的成员。

1
2
3
4
5
6
class BinOp extends Expr {
...
public Expr accept(SimplifyExprVistor v){
return v.visit(this);
}
}

SimplifyExprVistor可以访问BinOp对象并解包其中的内容。

1
2
3
4
5
6
7
8
9
public class SimplifyExprVistor{
...
public Expr visit(BinOp e){
if("+".equals(e.opname) && e.right instanceof Number && ...){
return e.left;
}
return e;
}
}

4.2 用模式匹配力挽狂澜

通过模式匹配这个特性,我们可以更简单的来解决上述问题。但此特性在Java 8版本还未支持,所以用Scala语言来展示其作用。

1
2
3
4
5
6
def simplifyExpression(expr: Expr): Expr = expr match{
case BinOp("+", e, Number(0)) => e //加0
case BinOp("*", e, Number(1)) => e //乘以1
case BinOp("/", e, Number(1)) => e //除以1
case _ => expr //不能简化expr
}

Scala语法如下,通配符判断和Java中的default:扮演同样的角色。Java中的模式判断被限制在基础类型、枚举类型、包装类型以及String类型。模式匹配可以避免出现大量嵌套的switch或if-then-else语句与字段选择操作相互交织的情况。

1
2
3
4
Expression match { case Pattern => Expression ... }

//和Java的switch相似
switch (Expression) { case Constant : Statement ... }

Lambda表达式反而可以比较简洁的实现单层的模式匹配。

五. 杂项

5.1 缓存或记忆表

假设有一个方法 computeNumberOfNodesUsingCache(Range) ,用来计算一个树形网络中给定区间内的节点数目。假设此网络不会发生变化,即数据结构是不可变的,computeNumberOfNodesUsingCache需要递归遍历,所以方法的开销还是很大的。如果我们能保证引用透明性,就可以有一种方法来避免这种冗余的开销:记忆表——为方法添加一个封装器,在其中加入一块缓存(比如一个HashMap),当封装器被调用时,首先查看缓存,看请求的(参数,结果)是否已存于缓存中,如果存在就直接返回缓存结果,否则调用computeNumberOfNodesUsingCache 。严格的来看,这种实现并非纯粹的函数式解决方案,因为会修改多个调用者共享的数据结构,但这段代码的封装版本的确是引用透明的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
final Map<Range, Integer> numberOfNodes = new HashMap<>();
Integer computeNumberOfNodesUsingCache(Range range){
Integer result = numberOfNodes.get(range);
if(result != null){
return result;
}
result = computeNumberOfNodesUsingCache(range);
numberOfNodes.put(range, result);
return result;
}

//Java 8改进了Map接口,提供了computeIfAbsent方法来处理这种情况
Integer computeNumberOfNodesUsingCache(Range range){
return numberOfNodes.computeIfAbsent(range, this::computeNumberOfNodes)

numberOfNodes处于可变共享状态,并且HashMap也没有同步,这意味着这段代码不是线程安全的。如果多核对numberOfNodes执行并发调用,就算用(锁保护的)HashTable 或(并发无锁的)ConcurrentHashMap,可能都无法达到预期的性能,因为这中间又存在由于发现某个值不再Map中,需要将对应的键值对插回到Map而引起的竞态条件。这意味着多个核上的进程可能算出的结果相同,又都需要将其加入到Map中。

一旦并发和可变对象揉到一起所引起的复杂度要远超我们的想象,而函数式编程可以从根本上解决这一问题。以函数式的方式进行设计,不用担心是否采用了正确的同步方式,因为没有任何共享的可变状态。

5.2 “返回同样的对象”意味着什么

回顾2.3实现的fupdate方法,通过变量t指向一棵现存的树,调用fupdate(“Will”, 26, t)会生成一个新树。我们假设该树会被赋值给变量t2,那么再调用一次fupdate(“Will”, 26, t)并赋值给t3,应该会生成一个同样数据的新树。那么fupdate还符合引用透明性吗?因为引用透明性原则意味着使用相同的参数会产生相同的结果,但 t2 != t3,所以fupdate应该不符合引用透明性原则。

但即使如此,t2和t3使用时在逻辑上没有区别,关于这一点也有很多的辩论,对于函数式编程通常不使用==(引用相等),而是使用equal对数据结构值进行比较,所以这样看来fupdate还是符合引用透明性原则的。

5.3 结合器

函数式编程时经常会使用到高阶函数,接受两个或多个函数,并返回另一个函数,最终实现效果类似于把这些函数进行了结合。结合器就是用来表示这种思想的术语,Java 8中很多API都源自于此思想,如CompletableFuture类中的thenCombine,接受两个CompletableFuture方法和一个BiFunction方法,返回另一个CompletableFuture方法。

如下代码体现出函数组合的思想,效果就是先执行f再执行g。

1
2
3
static <A,B,C> Function<A,C> compose(Function<B,C> g, Function<A,B> f){
return x -> g.apply(f.apply(x));
}

假设我们想要对一个参数使用函数f连续的进行n次操作,类似于循环。

1
2
3
4
5
static <A> Function<A,A> repeat(int n, Function<A,A> f){
//n为0直接返回标识符表示什么也不做,否则执行f,重复执行n-1次,最后再执行一次
return n==0 ? x -> x
: compose(f, repeat(n-1, f));
}

这个想法稍作变更可以对迭代概念的更丰富外延进行建模,甚至包括对在迭代直接传递可变状态的函数式模型,但这里只是对函数式编程做一个全局的介绍就不继续展开了。


参考:

🔗 《Java8实战》