Java函数式编程的思考


前言

学习《Java实战》,首先就了解了函数式编程,就是把函数作为参数,传递到函数式接口,后续还学习了Lambda,Stream,Collector等等,只知道这样写会更加简洁和易读,但对于函数式编程的思想还并不明晰。在书的第18、19和20章写了函数式编程的一些思考和技巧,为了辅助理解,就在此记录下来。


一、实现和维护系统

对于大多数程序员来说,最关心的是代码维护时的调试:代码遇到无法预期的值就有可能发生崩溃,这种情况的产生原因是什么?这种状态是如何进入的?而函数式编程提出的无副作用不变性对解决这一难题有很大的作用。

1.共享的可变数据

针对无法预知的变量修改问题,源于共享的数据结构被代码中多个方法读取和更新。由于使用了可变的共享数据结构,我们很难追踪程序各个部分的变化情况。
在这里插入图片描述
如果有一个系统,不用修改任何数据,我们不会再由于修改了某个数据结构结果报错,那就很爽了。

如果有一个方法,不用修改内嵌类状态,也不用修改其他对象的状态,使用return返回所有计算结果,那么就称其为纯粹的或者无副作用的

那么,副作用是什么呢?书上的定义是函数的效果超出了函数自身的范畴。
例如,除了构造器的初始化,对数据结构进行修改,比如user类的setName()方法;抛出异常,try catch;输入输出,比如向文件写数据。

如果使用不可变的对象,一旦初始化就不会由任何方法修改,怎么共享都是线程安全的。当然,只使用不可变对象是不可能的,于是,函数式编程解决了这个问题。

2.声明式编程

当我们思考如何编程时,一般有两种思考方式。

一种是专注于如何实现,这种思考方式很适合传统的面向对象编程,也被称为命令式编程,因为它的指令和计算机底层的词汇非常接近。

比如赋值、条件分支和循环,一步一步进行。

// 判断tansactions是否为空
Transaction mostExpensive = transactions.get(0)  
if(mostExpensive == null)
    throw new IllegalArgumentException("Empty list of transactions");
// 查询最大值
for(Transaction t: transactions.subList(1, transactions.size())){
	if(t.getValue() > mostExpensive.getValue()){
		mostExpensive = t;
	}
}

另一种则更关注于要做什么

// 使用stream流
Transaction mostExpensive = transactions.stream().max(comparing(Transaction::getvalue));

这种查询把如何实现的细节交给了函数库,我们称这种思想为内部迭代

显然,这种方法更简洁且利于理解,这种“要做什么”风格的编程被称为声明式编程

为什么要使用这种方式呢?简单一句话,可以实现更加健壮的程序,且不会有任何副作用。


二、函数式编程

什么是函数式编程?用废话来说就是用函数进行编程的方式。
书中对其进行了描述,就是看做一个黑盒,输入产生输出,但我寻思一般来说写个方法不就是个黑盒吗?不过书中强调的重点是函数没有副作用,如果按命令式编程写个处理数据的方法,会不断更新处理共享变量,也就不是个封闭的黑盒了。
还有就是,有些函数,虽然内部存在一些非函数式的操作,只要这些操作不暴露给其他部分,不被其他部分感知,那么副作用就可以视作没有,于是就出现了个界定方式,分为纯粹的函数式编程和函数式编程。
说实话,这样的定义看起来有点抽象。

1.函数式Java编程

现在,让我们具体看看什么是函数式,Java中的函数式是什么样的。

在Java语言中,如果你希望编写函数式的程序,那么首先需要做的是确保没有人能察觉到你代码的副作用,这也是函数式的定义。

确保你写的一个方法,里面要处理的东西不会被其他线程并发调用。
可能还有一个方法是加锁,不过这样做了你自己就不能并发执行了。

我们的准则是,被称为函数式的函数或方法只能修改本地变量。除此之外,它引用的对象都应该是不可修改的对象。

也就是所有字段都为final类型,所有引用类型字段都指向不可变对象。

还有一个附加条件,要被称为函数式,函数或方法不应该抛出任何异常。

这个的解释是抛出异常意味着被终止了,形象的来说就是因为会抛出异常,所以不是黑盒了。
那不用异常怎么面对不可避免的错误情况呢?答案是用Optional类,返回一个空的对象表示有问题,而且Optional可以避免结果暴露给其他方法。

最后,作为函数式的程序,你的函数或方法调用的库函数如果有副作用,你必须设法隐藏它们的非函数式行为,否则就不能调用这些方法(换句话说,你需要确保它们对数据结构的任何修改对于调用者来说是不可见的,你可以通过首次复制,或者捕获任何可能抛出的异常实现这一目的)。

在实际使用中,我们还是需要输出调试日志来分析,严格意义上来说这并非函数式,但我们已经享受到了函数式带来的很多好处了。

有一说一,感觉还是有点抽象。

2.引用透明性

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

String.length() // 是引用透明的
Random.nextInt() // 不是 

引用透明性的意义是记忆化或缓存,就是优化了需要付出昂贵代价来计算的方法。
还有有一种复杂的情况,我没看明白。

通常情况下,在函数式编程中,我们应该选择使用函数透明的函数。

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

这两个都有各自的支持者,简单来说,面向对象是更新字段完成操作,或者调用与它相关的对象进行更新操作;而函数式编程认为方法不应该对对外部可见的对象进行修改。实际上这两种经常混着来。

4.实战

给定一个List<Integer>,比如{1,4,9},得到一个List<List<Integer>>,包含所有子集,也就是:
{{1,4,9},{1,4},{1,9},{4,9},{1},{4},{9},{}}

如果去网上找,会有很多种方法,比如递归枚举,循环枚举,还有用二叉树的。

    public static List<List<Integer>> subset(List<Integer> nums){ // 循环枚举
        List<List<Integer>> res= new ArrayList<List<Integer>>();
        res.add(new ArrayList<Integer>());
        for (int n : nums){
            int size = res.size();
            for(int i = 0;i < size;i++){
                List<Integer> newSub = new ArrayList<Integer>(res.get(i));
                newSub.add(n);
                res.add(newSub);
            }
        }
        return res;
    }

书上说用函数式,还还用了递归,思路是:对于{1,4,9},把子集分为含1和不含1的两部分,这样递归最后合并得到结果。

不过这个实战的目的还是要说函数式,总结一下有两点:
1.不对共享变量用for-each循环结构,因为可能会改变共享数据,所以书上用了递归。
2.不修改传入的参数,而是去复制,比如

List<List<Integer>> concat(List<List<Integer>> a, List<List<Integer>> b){
	a.addAll(b);
	return a;
}

改为

List<List<Integer>> concat(List<List<Integer>> a, List<List<Integer>> b){
	List<List<Integer>> r = new ArrayList<>(a);
	r.addAll(b);
	return r;
}

三.递归和迭代

递归是函数式编程特别推崇的一种技术,他能培养你思考要“做什么”的编程风格。
纯粹的函数式编程语言通常不提供像while或者for这样的迭代结构。为什么呢?因为这种结构经常藏着陷阱,诱使你修改对象。

不过实际上用for结构还是很多的,一般条件里变化的也都是局部变量。
但函数式推崇用递归取代迭代,使用递归可以不用每步都去更新迭代变量。

// 迭代阶乘
Static long factorialIterative(long n){
	long r = 1;
	for(int i = 1;i <= n;i++){
		r *= 1
	}
	return r;
}

// 递归阶乘
Static long factorialRecursive(long n){
	return n == 1?1 : n*factorialRecursive(n - 1);
}

// 还有一种方法,用Stream
Static long factorialStream(long n){
	return longStram.rangeClosed(1,n)
		   			.reduce(1, (long a, long b) -> a * b); 
}

实际上,用递归效率很差,因为每次执行递归都会在调用栈上创建一个新的栈帧来保存状态,这意味着会消耗很大的内存。
但是有个优化的方案,就是尾调优化,思想是写阶乘的一个迭代定义,不过调用发生在函数的尾部。

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

这就是个基于“尾-递”类型的函数,递归调用发生在方法最后。
在factorialHelper中,阶乘的中间结果直接作为参数传递给了该方法,不用为每个递归分栈帧用于跟踪中间结果了。
我们用图来理解这种方法的优势在哪:
在这里插入图片描述

坏消息是,目前Java还不支持这种优化。

6,我搜了一下,这种方法属于想象很美好,但也没那么美好,因为栈这东西搞复杂了容易出问题。

书上最后还是推荐用Stream取代迭代操作,如果你递归用的好也能用。

那我还是选择Stream吧。


小结

1.尽量不对共享的可变数据进行修改。
2.Stream真好用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值