前言
学习《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真好用。