- 函数式编程的引入:通过四种两类编程范式之间的对比,介绍函数式编程这种编程模式的特别之处。
- Java8引入的两个新特性:lambda函数和stream流式编程。这两个特性让java8有了一些函数式编程的能力或者说特点。
- 函数式编程的优势。
函数式编程的引入
面向对象编程
经过一学期OO课程的设计与构造,体会到面向对象编程的特点是封装、继承、多态,让程序有了非常丰富的可扩展性。
比如这是一段最简单的多线程创建的例子。我们的电梯就是在这个实现了runnable接口的类中被定义了开关门、上下行的行为。
电梯作业中从这个电梯对象出发,和其他对象交互:比如调度器对象、等待对象。
这就是面向对象编程范式的核心,以类作为对象的模板,把类和继承作为构造机制,以对象为中心,来思考并解决问题。好处是易于拓展,允许我们实现复杂的逻辑。
让我们再来仔细看这个输出线程。其实output类
作为接口,只实现了一个run方法
,甚至只是作为了thread
创建的一个构造参数。java比较痛苦的一点是要经常新建类,给他起名,写构造函数等等。
匿名类
为了简化这种负担,java有匿名类的机制,可以省略掉这个外部类的定义,就是把output类
匿名,直接把new runnavle接口
实现作为thread
的构造参数。
使用了匿名类之后代码看起来简洁了不少,线程的行为也更加直观。不过可以看到,idea给我们把new runnable
这一行标灰了,意思是可以省略。
函数式编程
其实思考一下也不难理解,上边这么多行,真正有效的代码就一行输出语句。创建线程是为了执行某一个任务,任务也就是一个方法,那我们为何不直接传入一个方法呢?干脆把新建类也省去。
这就是函数式编程的一个核心概念,函数式编程的主要抽象是函数,函数是一等公民。
所谓的一等公民,指的是函数与其他数据类型一样,处于同等地位。函数可以在任何地方定义,在函数内或函数外,可以作为函数的参数和返回值,因此可以对函数进行组合。
放在这个代码里理解,就是这短短的一行函数,作了线程创建的参数,用到了Lambda函数。这是函数式编程和我们面向对象的世界中不一样的一种抽象。
命令式编程
下面我们再来看另一组编程范式。
面向对象是一种典型的命令式编程,命令式编程是关于定义如何做。
怎么做,是一种面向硬件的抽象,程序员需要告诉机器每一步的实现过程。比如这个例子,想计算销量大于100的店铺的总数。命令式编程中,我们就得告诉计算机,你要先做循环,再做条件判断,销量比1000
高就给count+1
。哦对一定不要忘了,非常致命的一点,**count
这个负责计数的变量要记得初始化**。
命令式程序有变量(对应着存储单元),赋值语句(获取,存储指令),表达式(内存引用和算术运算)和控制语句(跳转指令)。简单来说,命令式程序就是一个冯诺依曼机的指令序列。
声明式编程
声明式编程的风格,是关于定义要做什么而不是如何做,更接近自然语言的接口。
同样的功能,声明式的代码不仅写起来起来更简洁,不需要看函数内部的实现细节就可以了解大意。比如这段这段声明式编程的代码只是说:我要过滤(filter)
一下这些店 构成的流(stream)
, 只把那些销量大于1000
的留下, 计算出个数就行了。
流式编程是一种典型的声明式编程,更接近自然语言接口,这一些函数调用返回一个结果给count
变量,也规避了count
自增前的初始化问题。
这也是函数式编程的另一个优势,不需要考虑过程量的副作用。
看这例子:两段代码都是先排序,然后返回第一项作为最近的店。区别在于上面的排序改变了这个表的顺序,按照距离由小到大排序。而流式编程的sort
并未改变原数据。也就是函数执行没有副作用。
第一个排序,跟下边的比起来也是唯一一个没有使用Lambda表达式的排序写法,它的compartor接口
就需要在这个店铺的类里边继承接口,然后重写compareto方法
。
在排序的代码中,后面几种出现了 ->
、::
的,都用了lambda表达式实现compartor
这个比较接口。
可以看到都是lambda函数,有的刷灰了意思是还可有更简化的写法。
我们可以对比集合排序的比较器接口的几种使用lambda函数实现的方法,从compartor接口的例子中了解lambda函数的语法。
二、lambda函数
这么多次OO作业肯定大家都用到过,尽管我们在作业中使用Lambda的场景并不是为了函数式编程,是使用新特性来让代码的书写更加优雅。
Lambda表达式语法
这张图使用了刚刚提到过的runnable接口
的实现的例子。包括函数签名,这个标志着lambda函数的箭头,还有函数式编程中最重视的函数实现。
这三部分组成了Lambda表达式的完整结构,这一行就可以作为一个runnable接口
变量。lambda表达式也可以向我们刚刚创建线程那样作为参数进行传递。
函数签名
函数签名包括函数名、函数的返回值和传入参数。
刚刚提到了,lambda函数是匿名类的进一步简化。lambda的匿名性省略了函数名。同时它有一种更强大的省略特性,这种机制被允许的原因是java编译器的自动类型推断。
- 返回值可以省略,可以从接口类型判断出返回值。
- 参数的类型,也可以被省略。
- 如果只有一个参数,可以省略括号。
- 把它存成 比较器接口变量,借助这个变量的
reverse方法
我们可以实现逆序。
函数主体
主体只包含一个语句,可以省略大括号。刚刚的实现里,箭头后边都只有一条语句,没有大括号。
函数实现也可以语句块作为函数,这里除了比较距离,还比较了年龄这个参数,实现了多重条件的比较。
函数实现比较复杂,下面介绍如何一步步化简省略定义这种多重条件的比较逻辑。
首先
::
这个符号,两个冒号,像C++的作用域解析运算符,是Java 8的一种更方便的方法引用。我们可以通过使用这种实例方法的引用和
Comparator.comparing
方法来避免定义比较逻辑——它会自动提取和创建一个基于这个函数的Comparable
。这里看起来没有lambda表达式的结构,是怎么实现的接口呢?具体原因我们放在后边再展开讲。
Lambda表达式的化简
总结一下刚刚逐步简化的Lambda表达式。它的作用是取代一个类,用来实现接口。
函数式接口
Lambda表达式的语法是什么支持的呢?这种实现的对象类型。再怎么函数式编程,也要在java面向对象的机制中扩展。表达式的类型是函数式接口。
只有一个接口函数需要被实现的接口类型,称为”函数式接口“。为了避免后来的人在这个接口中增加接口函数导致其有多个接口函数需要被实现,变成”非函数接口”,我们可以在这个上面加上一个声明@FunctionalInterface
, 这样就无法在里面添加新的接口函数。
函数式接口的前提条件
使用Lambda必须具有接口,且要求接口中有且仅有一个抽象方法。
无论是JDK内置的Runnable、Comparator接口还是自定义的接口,只有当接口中的抽象方法存在且唯一时,才可以使用Lambda。
使用Lambda必须具有上下文推断。
也就是方法的参数或局部变量类型必须为Lambda对应的接口类型,才能使用Lambda作为该接口的实例。上文提到的依托java编译器的自动类型推断的省略机制要求上下文推断。
几种常见的函数式接口
predicte
:推断出参数类型返回值类型,是用来判断真假的函数接口。 函数实现是一个布尔表达式。BinaryOperator
:接收两个参数,返回一个值,返回值和参数的数据类型相同。体现一种映射关系,x y ->x+y
。comparing
:上文提到的 “comparing
没有lambda表达式的结构是怎么实现的接口?” 因为这个方法返回了一个函数式接口,使用comparing()
的地方,也就是sort()
的传入参数就是函数式接口function。comparing()
的传入参数也是函数式接口function。key::lambda
是对函数式接口的更一步简化。
集合的函数式接口
java集合框架也新增部分函数接口用于与Lambda表达式对接。
作业中很常见,比如说removeif规避了for遍历删除的问题,这里的参数就是Prediect 函数式接口。
创建优先队列的构造参数,是一个比较器接口。
foreach遍历接受函数式接口做参数。
第四单元类重名的异常机制,可以借助这个merge方法,BinaryOperator接口作为重映射函数。
作业中使用lambda函数的优化
unit1
如果在某些情况下我们只需要实现一些行为/操作而不需要实现状态,OOP 会限制将该行为包装在一个类中以便能够执行它。会导致不必要的冗长代码,其中计算只需要执行一个函数。
合并同类项的加法类:代码完美遵循了面向对象地封装概念。美中不足是略为繁琐
- 合并同类项:选用hashmap的merge()方法和lambda函数来实现合并同类项非常简洁。
- forEach()方法用于对 HashMap 中的每个键值对执行指定的操作。匿名函数 lambda 的表达式 作为 forEach()方法的参数传入。
- merge()方法用于合并两个hashmap,使用lambda表达式 (oldValue, newValue) -> (oldValue + newValue) 作为重映射函数。
- Java 8的方法引用更方便,方法引用由::双冒号操作符标示,使用BigInteger::add作为重映射函数即可。
减法:
- 由于hashmap.merge()在插入hashmap2中不存在的key与其对应的value时不会调用重映射函数,故减法不能使用BigInteger::subtract作为映射函数;
- 解决办法为减数先取反,再与被减数调用quanticAdd()即可
乘法:
- 将两个BaseKey相乘后的新BaseKey作为merge方法的key参数,系数的乘积作为value参数,重映射函数BigInteger::add
- Key为自定义类型BaseKey,重写hashcode()和equal()后便于合并同类项
- 由于需要维护可变类型BaseKey作为hashmap的key的不可变性,以及value代表的系数为不可变类型BigInteger,没有出现深浅拷贝的Bug
三、Stream流式编程
Stream 使用类似 SQL 语句从数据库查询数据的方式来提供一种对 Java 集合运算和表达的高阶抽象。
Stream API 将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理, 比如筛选,排序,聚合等。
流式编程特点
- Stream并无数据存储,不会修改背后的数据源。所有惰性操作以pipeline的方式执行,减少迭代次数.
- 处理•大量元素时,为了提高性能需要并行处理,并利用多核架构。并行化的Stream 不需要再写多线程代码,所有对它的操作会自动并行进行的。
- 流的操作可以分为两类:处理操作、聚合操作。
- 处理操作(惰性求值):诸如filter、map等处理操作将Stream一层一层的进行抽离,返回一个流给下一层使用。
- 聚合操作(及早求值):从最后一次流中生成一个结果给调用方,得到最终的结果而不是Stream。
Stream API
- filter():过滤大规模数据集合。接受一个前面提到的 Predicate 断言型函数式接口,传入一个lambda表达式作为过滤逻辑,获得一个新的列表。
//Unit4作业
//Elements读取不用使用多层循环嵌套
Map<String,MyClass> myClass = Arrays.stream(elements)
.filter(element -> element instanceof UmlClass)
.map(MyClass::new)
.collect(Collectors.toMap(MyClass::getId,item -> item));
//重写MyClass构造方法
public MyClass(UmlElement std) {
this.std = (UmlClass) std;
this.id = std.getId();
//...
}
- map():是函数式编程中非常重要的一个概念,能够将对象进行转换
// 为每个订单加上12%的税,使用reduce计算总开销
// 老方法:
List costBeforeTax = Arrays.asList(100, 200, 300, 400, 500);
double total = 0;
for (Integer cost : costBeforeTax) {
double price = cost + .12*cost;
total = total + price;
}
System.out.println("Total : " + total);
// 新方法:
List costBeforeTax = Arrays.asList(100, 200, 300, 400, 500);
double bill = costBeforeTax.stream().map((cost) -> cost + .12*cost)
.reduce((sum, cost) -> sum + cost).get();
System.out.println("Total : " + bill);
ForkJoin框架
四、函数式编程的特点与优势
特点
函数是输入和输出之间的映射。可以将其视为将输入转换为输出的“黑匣子”。
(1)函数避免改变状态和改变数据。他们观察到的所有状态只是提供给他们的输入。
(2)函数不会改变输入的值,对它们的执行没有副作用。
(3)对于每个输入,都有相同的输出。
以上特点自然适合并发和并行适用性。计算朝着更多内核和分布式/并行计算的方向发展,事实证明函数式编程更适合这些要求。
java并不是很典型的函数式编程语言。Apache Spark 是一个用 Scala 编码的大数据平台,它是一种函数式语言。另一个例子是 R,这是数据科学家中最流行的语言,它是函数式的。
优势
单元测试
因为FP中的每个符号都是final的,没有什么函数会有副作用。谁也不能在运行时修改任何东西,也没有函数可以修改在它作用域之外的值给其他函数继续使用(在指令式编程中可以用类成员或是全局变量做到)。这意味着决定函数执行结果的唯一因素就是它的返回值,而影响其返回值的唯一因素就是它的参数。
测试程序中的函数时只需要关注它的参数就可以。完全不需要担心函数调用的顺序,也不用费心设置外部某些状态值。
调试时的可复现性
如果一段FP程序没有按照预期设计那样运行,调试的工作非常容易,因为这些错误是百分之一百可以重现的。FP程序中的错误不依赖于之前运行过的不相关的代码。
而在一个指令式程序中,一个bug可能有时能重现而有些时候又不能。因为这些函数的运行依赖于某些外部状态, 而这些外部状态又需要由某些与这个bug完全不相关的代码通过某个特别的执行流程才能修改。
并发执行
所有FP程序都是可以并发执行的。由于根本不需要采用锁机制,因此完全不需要担心死锁或是并发竞争的发生。
某个FP程序本身只是单线程的,编译器也可以将其优化成可以在多CPU上运行的并发程序。
这在指令式编程中是无法做到的,因为每一个函数都有可能修改其外部状态,然后接下来的函数又可能依赖于这些状态的值。