内容来自《 java8实战 》,本篇文章内容均为非盈利,旨为方便自己查询、总结备份、开源分享。如有侵权请告知,马上删除。
书籍购买地址:java8实战
共享的可变数据
- 假设几个类同时都保存了指向某个列表的引用,由于使用了可变的共享数据结构,我们很难追踪程序中各个组成部分所发生的变化,如图
- 如果一个系统中不像上面图中表示的可以随意修改数据,它不修改任何数据,这样你就不会再收到任何由于对象修改了数据而导致的错误
- 如果一个方法既不修改它内嵌类的状态,也不修改其他对象的状态,使用return返回所有的计算结果,那么就可以称其为纯粹的或者无副作用的
-
那到底哪些因素会造成副作用呢?(副作用就是函数的效果已经超出了函数自身的范围)
- 处理构造器进行初始化操作,对类中数据结构的任何修改,包含字段的赋值操作setter都是有副作用的
- 抛出异常是有副作用的
- 进行输入输出是有副作用的
- 观察上面,我们不可能不涉及IO,那么java就不可能在一个系统中实现完全的无副作用
- 无副作用的实现,我们就应该考虑不可变对象.不可变对象只要初始化完成,那么他就不可能有不可预见性,你就可以放心的共享他,无需保留副本并且他不会被修改还是线程安全的
- 如果系统中各个组件都遵循了无副作用规则,那么这个系统就可以在完全无锁的状态下运行,使用多核的并发机制,因为任何一个方法都不会对其他的方法造成干扰,(无副作用不影响外部数据)
声明式编程
- "如何做"风格的编程非常适合面向对象编程,因为他的特点是他的指令和计算机底层的词汇很接近:赋值,条件,分支,循环
-
什么是"如何做":就是你通过编程告诉计算机:第一步第二步第三步都要做什么,比如
List<String> list = new ArrayList<>(); List<String> collect = new ArrayList<>(); for (String s : list) { //告诉如何遍历list if (!"str".equals(s)){ //告诉筛选条件 collect.add(s); //告诉如何收集到List } }
-
而"要做什么"风格的编程通常被称为声明式编程,你给出希望完成的目标,让系统来决定如何实现这个目标,比如
List<String> list = new ArrayList<>(); List<String> collect = list.stream() .filter(s -> !"str".equals(s)) .collect(Collectors.toList());
- 如上的好处就是更加接近于问题的描述:我要做什么,而不是我要怎么做
函数式编程
- 在函数式编程的上下文中,一个函数对应于一个数学函数:他接受零个或多个参数,产生一个或多个结果,并且不会有任何副作用,可以把它看做是一个黑盒,他接受输入并产生一些输出
- 上面是一个没有任何副作用的函数,他和java中见到的函数之间的区别是相当重要的:尤其是,使用同样的参数调用数学函数,他会返回的结果也一定相同(排除Random.nextxx)
- 函数式,它其实是像数学函数那样,没有副作用,但是就会有一些问题:我们实现的函数就只能使用if-then-else这样的数学思想来构建吗?又或者说,函数内部可以执行一些非函数式的操作,不过该操作的副作用不被人感知或者调用者根本就不知道又或者这些产生的副作用完全没有影响,对于这两种情况第一种数学思想的方法称作为纯粹的函数式编程,而后面的称为函数式编程
到这自己的函数的理解就是:函数是函数,java函数是java函数,由于单纯的函数完全可以像数据公式一样,传入相同的值返回相同结果,类似上面的黑盒图,而java函数如果需要用到数学函数的情况下,完全也可以写一个纯粹的java函数,它完全的无副作用,就好像用方法体的花括号隔绝了一切,出入形参,任何地方都不会跟程序有瓜葛,但是除了一些纯粹的java函数用来计算,其他的java函数还是需要跟程序的整体挂钩的,因为函数的执行不仅依赖于输入值,而且会受到全局变量,输入文件,类的成员变量等诸多因素的影响.
函数式java编程
- 我们之前提到了,java是无法实现完全以纯粹的函数式来完成一个完整的程序的,因为其涉及了IO等操作,如Scanner.nextLine,返回结果不尽相同,不过我们还是应该利用好函数这个概念,尽量可能为系统的核心组件编写接近纯粹函数式的实现
- 在java中实现函数式的程序,需要做的是确保没有人能够察觉到你代码带来的那点副作用,比如只是更改了int i的值,如果你在这个单线程中这个是完全没问题的,但是如果是多线程并且其他线程可以访问这个i变量,那么这就肯定不是函数式的实现了,你的副作用被人发现了,如果加锁,虽然可以实现i的共享,但是就损失了并发性了
- 准则是:被称为函数式的方法都只能修改本地变量,除此之外,他引用的变量都应该是不可变的对象
- 还有就是函数式不应该抛出异常:有异常你的函数就会终端,而不是return一个恰当值了,而try起来异常是一种非函数式的控制流,因为这种操作违背了再黑盒中定义的传入参数,返回结果的规则,它引出了处理异常的第三支箭头
- 拿除法运算遇到除数是零的例子说明:如果是这样情况,try的话,他将引发by zero的异常处理方向,我们应该想办法避免try,这里就应该使用之前用到的Optional处理类,如果不是零就进行处理,如果出现为除数为0情况,那么就返回一个空Optional对象
引用透明性
- 不要被名词给吓到
- "没有可感知的副作用"(不改变对调用者可见的变量,不进行IO,不抛出异常)的这些限制都隐含着引用透明性.
- 如果一个函数传入相同值返回相同结果,那么这个函数就是引用透明性的
- 函数透明性的例子:String.replace
- 违反了函数头型的例子:Scanner.nextLine,:因为每次调用都会返回不同值
- 引用透明性还包含了对代价昂贵或者需要长时间计算才能得到结果的变量值的优化,通过保存而不是重复计算,通常被称为缓存
无处不在的函数
- 我们前面说,函数式边恒意指函数或者方法的行为应该像数学函数一样没有任何副作用,他还意味着函数可以像任何其他值一样随意使用:可以作为参数传递,可以作为返回值,还能存储在数据结构中,能够像普通变量一样使用的函数称为 一等函数 ,比如:
BiFunction<Integer,Integer,Integer> compare = Integer::compare;
-
高阶函数
- 我们总结到现在,一直以来都是将函数作为参数传入方法达到行为参数化的,但是它不仅仅有这个用途,比如Comparator.comparing的使用,如下是方法定义
public static <T, U extends Comparable<? super U>> Comparator<T> comparing( Function<? super T, ? extends U> keyExtractor) //接收一个函数 { Objects.requireNonNull(keyExtractor); return (Comparator<T> & Serializable) //返回一个函数 (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2)); }
- 这个方法有很多重载的方法,使用如下
Apple a1 = new Apple(12); Apple a2 = new Apple(22); Comparator<Apple> comparing = Comparator.comparing(Apple::getWeight); int compare = comparing.compare(a1, a2); System.out.println("compare = " + compare);
- 执行流程大概是这样的:传入Apple::getWeight后,抵用comparing静态方法返回了一个比较函数,当比较函数对象调用比较方法,传入a1a2后,这时候才会开始运行返回的那个函数
(c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
,a1对c1,a2对c2然后分别对c1c2调用apply方法,这里的apply实现就是调用getWeight,需要注意的是Comparator.comparing(Apple::getWeight);
并没有去调用apple对象的此方法,因为这时候还没有实现具体的apply行为 - 所以满足
接受至少一个函数作为参数
或返回的结果是一个函数
任一一个条件,那么就可被称为高阶函数
-
科里化
- 这是一个能够帮助你模块化函数,提高代码重用性的技术
- 我们举个例子说明引出这个概念,如果有一个要求,对一个数进行调整,比方说这个数为x,我们需要x乘以调整数y,那么就成了x*y,为了更准确的调整这个数,我们还需要一个调整偏量z,那么现在就是
x*y+z
,好了,我们按照这个写一个方法
public static double getNum(int initNum , double adj , double deviator){ return initNum * adj + deviator; }
- 这个方法再简单不过了,现在我们来使用它
getNum(1000,0.3D,0.03D); getNum(1210,0.2D,- 0.5D); getNum(1000,0.5D,0.73D); getNum(1050,0.5D,0.73D);
- 我们可以发现,如果是相同的调整参数,比如最后两个调用,方法传入发生变化的也就是基本数量而已,但是我们不得不重复的传入相同的调整参数,那么我们可以这样解决
public static DoubleUnaryOperator getNum( double adj , double deviator){ return (double x) -> x * adj + deviator; }
- DoubleUnaryOperator是这样的
double applyAsDouble(double operand);
- 好了改进后,我们应该怎么使用呢?
DoubleUnaryOperator num1 = getNum(0.9D, 0.15D); DoubleUnaryOperator num2 = getNum(0.12D, 0.75D); //相同的调整参数调用 num1.applyAsDouble(1000); num1.applyAsDouble(1111); //对于不同的调整参数 num1.applyAsDouble(1211);
- 现在我们就不存在上面遇到的问题了,他更加灵活,并且复用了相同的调整参数(逻辑)
- 好了现在就可以引出科里化的理论定义了
科里化是一种将有两个参数的函数转换为使用一个参数的函数,并且这个函数的返回值也是一个函数,他会作为新函数的一个参数.后者的返回值和初始函数的返回值相同,即f(X,y) = (g(x))(y)
-
还是不太理解看例子:
Function<Integer, Function<Integer, Function<Integer, Integer>>> currying = x -> y -> z -> x*y*z; Function<Integer, Integer> function = currying.apply(4).apply(5); Integer apply = function.apply(1);//20 Integer apply1 = function.apply(2);//40
-
如上是一个柯理化的实现:类似于一步一步的给值,然后每一步的值可以定制,又可以重用之前apply好的值,类似builder模式,build->build->build->over.
IntFunction<IntFunction<IntUnaryOperator>> function = x -> y -> z -> (x + y) * z; IntFunction<IntUnaryOperator> apply = function.apply(2);
i = apply2.applyAsInt(4);
System.out.println("i = " + i); //20
- 如上第一次apply其实是返回的
IntFunction
的实现x -> y -> z -> (x + y) * z
,第二次调用返回的是泛型中的IntFunction
的实现y -> z -> (x + y) * z
,之后就剩下了IntUnaryOperator
的实现z -> (x + y) * z
持久化数据结构
- 我们应该时刻注意:函数式方法不允许修改任何全局数据结构或者任何作为参数传入的结构,因为一旦对这些数据进行修改,两次相同的调用就很可能产生不同的结构--这违背了引用透明性原则,我们也就无法将方法简单的看做由参数到结果的映射
- 这里的标题持久化并不是说生命周期比程序的执行周期更长的数据,而是指"函数式数据结构"或"不可变数据结构"
-
破坏式更新和函数式更新的比较
- 现在我们需要取旅行,我们要坐火车,所以不可避免的要倒火车,下面的类是一个简单的单项列表的结构
public class Train { //0火车票价格 private int price; //火车站点 private Train onward; public Train(int price, Train onward) { this.price = price; this.onward = onward; } //setter getter }
- 现在我们从北京->广州,玩够了之后,我们启程广州->上海,我们现在写一个方法,将两段路程连接起来
public void test() { Train train1 = new Train(12, null); Train train2 = new Train(14, null); Train link = link(train1, train2); System.out.println(link); // Train{price=12, onward=Train{price=14, onward=null}} System.out.println(train1); // Train{price=12, onward=Train{price=14, onward=null}} System.out.println(train2); // Train{price=14, onward=null} } public static Train link(Train train1 , Train train2){ if (null == train1) return train2; Train t = train1; // 如果他又下一个,那么就遍历到没有下一个元素为止 while (t.getOnward() != null){ t = t.getOnward(); } t.setOnward(train2); return t; }
- 我们发现,上面的这种方式有个相当大的缺点,那么就是他改变了传入的train1的结构,本来我们想的只是连接两个trian对象,返回一个连接好的对象,而现在却造成了train也跟着变动了
- 采用函数式编程的方案如下
public void test() { Train train1 = new Train(12, null); Train train2 = new Train(14, null); Train link = link(train1, train2); System.out.println(link); // Train{price=12, onward=Train{price=14, onward=null}} System.out.println(train1); // Train{price=14, onward=null} System.out.println(train2); // Train{price=14, onward=null} } public static Train link(Train train1 , Train train2){ return train1 == null ? train2 : new Train(train1.getPrice(),link(train1.getOnward(),train2)); }
- 上面是一个队规调用,如果你不熟悉递归,那么可以参考这一篇文章:递归学习
- 上面的输出结果也证明了,我们并没有对原来的Train做修改,但是这需要注意的是,我们并没有创建整个新的Train的副本,而是创建train2之前的所有对象的副本,而train2或者之后的元素都是共享的原来的train2,如图
- 如果是共享的train2的话,那么我们就需要注意了,对于返回的新Train对象,不能去操作他的后半部分也就是共享部分
- 如果自己写一个二叉树,那么我们除非是想共享这些数据.直接修改二叉树的值,否则我们就应该立刻创建它的一个副本,因为谁也不知道将来的某天,某人会对他突然进行修改,这种函数式数据结构通常被称为持久化的->数据结构的值始终保持一致,不受其他部分变化的影响,要达到这个效果就需要遵循不修改原则,即创建副本