1、概述
在计算机科学中,函数式编程是一种编程范式,一种构建计算机程序结构和元素的方式,类似于比较熟悉的面向对象编程。将计算视为数学函数的处理并尽量避免改变状态和可变数据。它是一种声明性编程范例,这意味着使用表达式或声明而不是语句来完成编程。在功能代码中,函数的输出值仅取决于传递给函数的参数,因此用参数x的相同值调用函数f两次会产生相同的结果f(x)。这与依赖于本地或全局状态的过程形成对比,当使用相同的参数但使用不同的程序状态时,有可能在不同的时间产生不同的结果。不依赖于函数输入的状态变化,就可以使得更容易理解和预测程序的行为,这是开发函数式编程的关键动机之一。
和Java没有任何关系的语言JavaScript就是一个函数编程语言。Java最初被设计出来是作为一门面向对象编程语言来设计的。但是随着时代的变化,异步处理在编程中变得越来越频繁。在现代编程语言中,任何可并行操作希望是一个简单的动词,开发者不仅希望能处理并发问题,并且必须能够使用这些动词而无需任何复杂的实现。对象都可以根据并发动词进行演变。所以在Java8中带有用于异步任务的动词,例如CompletableFuture和用于并行处理的CoolJoinPool等结构。异步编程需要隔离的编程单元,使用隔离的编程单元,可以保证结果,这种隔离的编程单元称为“纯函数”。如果在程序中使用纯函数,那么可以理解为在进行函数式编程。而异步处理的这种需求,正好非常适合用函数编程来解决,这也促使来Java 需要支持函数式编程。Java8 正好做了相关的支持。在Android开发中经常使用的Rx系列也采取了函数式编程的理念。
2、相关前置概念讨论
2.1 纯函数
纯函数不依赖于任何其他对象实例或类定义。在Java中,如果对象实例是实例函数,则该函数必须存在于对象实例中,如果它是静态的,则必须存在于类定义上。所以说,严格来讲Java里面其实是没有纯函数的。同时纯函数的输出可以是一个函数。
2.2 函数程序外观
Java代码下面处理一个CSV文件数据。每行通过转换数据或搜索,过滤,排序数据来输出新的结果集。
try(Stream stream = Files.lines(Paths.get(fileName)) ){
stream
.limit(10)
.map(t->t.split(","))
.flatMap(Arrays::stream)
.collect(Collectors.groupingBy(Function.identity(),Collectors.counting()))
.entrySet().stream()
.filter(t -> t.getValue()>10)
.forEach(t->System.out.println(t.getKey()+" "+t.getValue()));
}catch(IOException e ){}
上面的代码过滤了一些大于10的数据。里面的语法用到了很多Java里面的新语法,之后会进行讨论,可以认为大于10的数据会被某些东西过滤掉。这里没有如何过滤数据的操作,只是按照需求一步步的进行操作。在传统的程序中,可能需要遍历列表,将每个项目与某些项目进行比较,如果正确,则将其放入目标列表。但这看到这样的东西吗,因为这里不使用命令式编程,而是使用声明性编程。此外,在此代码中看不到任何for循环。因为函数式编程不使用迭代和迭代器,而使用递归。
3、对象不可变
相同引用可以指向的不同对象是面向对象编程的基本能力之一。但在函数式编程中,对象必须是不可变的,即使有权访问对象,也必须无法根据需要对其进行修改。因为可变性是多线程环境中出现错误的主要原因。任何线程都可能意外地更改对象的状态,从而导致程序出错。为了防止这种情况,使用synchronized关键字,Barrier类,互斥体,信号量,锁等。但所有这些实现都很麻烦,甚至可能会导致死锁,线程匮乏等问题。因此,如果为了同步而导致这些问题并且解决起来如此费劲,为什么不能直接消除其可变性,来一劳永逸。这就是在函数式编程中所做的。
在下面的代码中,创建了一个健身房会员对象,会员开始日期与会员数据一起存储。这里,printEndDate()和printStartDate()方法都具有访问和修改startDate值的能力。它在不考虑任何其他方法的情况下能修改原始日期对象。
public class GymMember {
String name;
Integer id;
Date startDate;
public GymMember(String name, Integer id, Date date) {
super();
this.name = name;
this.id = id;
this.startDate = date;
}
public static void main(String[] args) {
GymMember object = new GymMember("talha", 1, new Date());
object.printEndDate();
object.printStartDate();
}
private void printEndDate() {
Calendar calendar = Calendar.getInstance();
calendar.setTime(startDate);
calendar.add(Calendar.MONTH,1);
startDate = calendar.getTime();
System.out.println(startDate);
}
private void printStartDate() {
System.out.println(startDate);
}
}
为了防止上面的随意修改,可以将startDate属性设置为final
final Date startDate;
当这时候在在其中一个方法中更改startDate时,编译器将报错。但这样设置并不能完全将startDate设置为不可变。final仅阻止修改引用,使之无法更改此引用引用的地址。但仍然可以更改引用指向的对象的属性。仍然可以更改startDate的属性值,即使它被final修饰。所以在并行环境中使用它仍然可能不安全。为此Java8 中对于Data类提供了一个不可变类LocalDate。同时为了防止GymMember类通过子类注入的方式改变其属性,比如如下:
public class VIPGymMember extends GymMember {
public VIPGymMember(String name, Integer id, LocalDate date) {
super(name, id, date);
id = id + 1000000;
}
}
需要将类设置为final可以阻止这类事情的发生,下面是一个最终版的:
public final class GymMember {
final String name;
final Integer id;
final LocalDate startDate;
private void printEndDate() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MONTH,1);
System.out.println(startDate);
}
public GymMember(String name, Integer id, LocalDate date) {
super();
this.name = name;
this.id = id;
this.startDate = date;
}
private void printStartDate() {
System.out.println(startDate);
}
}
如上面的Data,Java8 中提供了许多不可变的对象,例如JDK架构师为每个集合提供不可变版本。可以使用这些不可变集合类型并使用Collections实用程序类转换可变集合,如下所示:
public static void main(String[] args) {
final Map myMap = new HashMap<>();
Map myUnmodifiableMap = Collections.unmodifiableMap(myMap);
myMap.put(2, "talha");
addElement(myUnmodifiableMap); // 会报错
}
public static void addElement(Map m) {
m.put(3, "john");
}
如上所示,使用Collections的 unmodifiableMap方法使Map不可变。可以再次使用Map引用此方法的结果。但是此集合初始化后,永远不允许修改。
综上所述,主要讨论了使用final关键字使自定义对象不可变。final关键字用于;
① 防止对象的地址更改
② 防止对象实例的属性的地址更改
③ 防止类被子类继承
同时还讨论了JDK集合和Date API是可变的。但是在在函数式编程中这是不可接受的。于是在Java8中出现了LocalDate类来替换Date类并将可变Map,List,Set接口用不可变类替换。可以通过使用java.util.Collections类的unmodifiableMap(),unmodifiableCollection()和unModifiableSet()等方法来获取不可变集合。
4、引用函数
函数是函数式编程中的一等公民。这句话可以说是函数式编程中最关键的一句话。但比较遗憾的是在Java中函数并不是一等公民,类或者说对象才是一等公民。Java中的函数是不能脱离类或者对象而存在的。但是在Java8中通过一些巧妙的方法将函数变成了伪一等公民。而在比较新的一门语言Kotlin中函数就正式成为了一等公民。
函数不能作为参数传递给Java 8之前的其他函数。在Java8函数可以作为一种伪独立对象调用,它们可以作为伪参数传递给其他函数。它其实是一个只有一个接口函数的接口类。在Java8中增加了一些语法糖,使它看上去像一个函数对象,这会在下面进行讨论。
同时在Java8中可以用“ ::” 来表示引用此函数,可以通过此表示法访问类或实例的构造函数和方法。同时可以用Supplier和Function接口来引用对应的构造函数和方法,以上面的GymMember类为例。
public GymMember() {
this.name = "gpj";
this.id = 0;
startDate = LocalDate.MIN;
}
public Date getNextDay(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.DAY_OF_YEAR, 1);
Date dateNew = calendar.getTime();
return dateNew;
}
public static void main(String[] args) {
Supplier memberCreator = GymMember::new;
GymMember mGymMember = memberCreator.get();
Function nextDayCalculator = mGymMember::getNextDay;
nextDayCalculator.apply(new Date());
}
在上面的main函数中第一行用了GymMember::new来获取GymMember的构造函数,左侧是一个特殊的SAM(单一抽象方法),下面将讨论,并用于引用一个没有输入但返回一个对象的函数。将其引用赋值给memberCreator引用。第二行通过这个引用来构造对象。第三行获取了GymMember的getNextDay 赋值给Function类型nextDayCalculator。这里的Function 第一个泛型是函数输入类型,第二个是输出参数类型。Function这个类型只有一个输入,这边对于这个设计可以给的一个解释是 纯函数的性质和形式定义来讲,必须有且只有一个输入参数。第四行使用apply方法来独立调用一个函数并独立地提供输入参数,可以理解为此时该函数是剥离于对象的。
4.1 单一抽象方法接口(SAM)
前面已经多次提到了这个概念,在Java中实际上是不存在脱离于对象的函数的。于是为了满足函数式编程,设计了单一抽象方法接口来进行模拟。其实在Java8之前就有许多单一抽象方法接口,比如说Runnable,Callable,Comparator和ActionListener等。他们的相似之处在于,当实现其中任何一个时,只需处理此接口的一种方法。其他方法对开发者来说并不重要。在实现此特定方法(此方法默认情况下未实现,即abstract)之后,将传递实现这些接口的类的新实例。
在Java 8中,几个SAM接口和SAM接口的新表示(Lambda表达式)被添加到JDK中。使用lambda表达式(后面会讨论),不再需要创建一个类来包装所需的方法并调用它。因为从开发者层面讲可以引用方法并独立调用它。所有这些SAM接口都可以在Stream API中使用。以下是Java8中新的SAM接口:
接口 | 输入 | 输出 | 目标 |
---|---|---|---|
Function | 一个任意类型 | 一个任意类型 | 实现或改变一个逻辑功能 |
BiFunction | 两个任意类型 | 一个任意类型 | 实现或改变一个逻辑功能 |
Predicate | 一个或多个任意类型 | boolean | 测试值是否符合逻辑 |
Consumer | 一个或多个任意类型 | 无 | 使用数据并产出一些副效果 |
Supplier | 无 | 一个任意类型 | 创建所需类型的对象 |
下面会分别对这些类进行讨论。
4.2 Function
这个函数用于基于给定输入和逻辑创建输出对象,并可能与其他函数链接。可以重写其apply()方法。也是需要实施的唯一方法。
方法 | 功能 |
---|---|
R apply(T t) | 调用该方法,其传入参数类型是T,返回值类型是R |
andThen(Function after) | 先调用其自己的apply()方法,再调用传入的Function的apply()方法 |
compose(Function before) | 先调用其传入的Function的apply()方法,再调用自己的apply()方法 |
identity() | 静态函数,返回一个输入输出类型一样的Function |
4.3 Predicate
测试数据是否符合某些值或逻辑。其test()是需要实现的单一抽象接口。已经实现了其他方法。
方法 | 功能 |
---|---|
boolean test(T t) | 测试值是否符合逻辑 |
Predicate and(Predicate other) | 和传入的Predicate进行与操作作为一个合的Predicate返回 |
Predicate or(Predicate other) | 和传入的Predicate进行或操作作为一个合的Predicate返回 |
Predicate |
将自身取反作为一个合的Predicate返回 |
4.4 Consumer
获取一个对象,使用但不返回。它可能有副效果,例如更改对象的值或将某些输出写入某处。
方法 | 功能 |
---|---|
void accept(T t) | 使用实例t,必要时修改它而不返回输出 |
void andThen(Consumer other) | 形成消费链,调用完自己的accept()后 调用传入的Consumer 的accept() |
4.5 Supplier
通过构造函数或其他方式(构建器,工厂等)提供给定类型的实例。
方法 | 功能 |
---|---|
T get() | 提供一个实例 |
5、Lambda表达式
在上一节中,讨论了Functional Interface。由于这些实际上都是接口类,所以实现起来需要实现多行代码,这个函数式编程里面的单行编程理念所违背。于是在Java8中也同时推出了Lambda表达式用来简化需要编写的代码。以上一节的Function为例,原始的实现方法如下:
Function findWordCount = new Function(){
@Overide
public Integer apply(String t){
return t.split(" ").length;
}
}
使用Lambda表达式实现上述接口类
Function findWordCount = (String t)->{ return t.split(" ").length; };
上面的Lambda表达式删除了匿名类外壳,只留下了剩余的部分。Lambda表达式一般由如下部分组成:
① 在括号内输入参数
② - > lambda签名
③ 返回单个结果的方法体
如果输入列表只有一个项目,则可以删除括号
Function findWordCount = t ->{ return t.split(" ").length; };
下面是一些常见的lambda表达式语法:
表达式 | 例子 |
---|---|
(parameters)->expression | (List |
(parameters)->{return ... ;} | (int a, int b )->{return a+b;} |
(parameters)->statements | (int a, int b )->a+b |
lambda表达式的右侧是方法体。如果更喜欢使用return关键字,则必须遵循方法体语法并使用分号和花括号。但是如果喜欢语法糖,可以删除花括号和return关键字,如第三个表达式所示,但这种情况下必须是方法体里面只有一句代码。
上面的例子在复制号右侧编写了一个方法体。JDK会自动检查方法的输入和输出参数,并使用适当的Functional Interface类型包装此方法。比如如下lambda表达式:
t - > t%2 == 0
这个lambda表达式是符合上一节所说的Predicate接口的,所以可以用Predicate来引用该表达式。
Predicate predicate = t -> t % 2 == 0;
boolean isEven = predicate.test(5);
6、Stream API
在前面第二节举的关于函数式编程的例子里面用的就是Stream API,这个API也是在Java8新出的。这是一个非常经典的用于函数式编程的思想来进行处理的API,主要用来处理集合结构。这个API可以理解为将数据倒入到一个管道中,在整个管道中一些过滤器过滤掉不正确的项目,一些处理器转换项目,一些根据某些逻辑对它们进行分组,一些数据在整个管道中流式传输时计算一个值。
类似上图所示在流开始处,获取一些塑料球作为数据源,过滤红色球,将其融化并将其转换为随机三角形。之后过滤器过滤掉小三角形。最后计算出总周长。
使用常规Collection执行此操作意味着多次迭代,多次函数调用。使用常规Collection,无法并行化该过程。并且只能处理有限数量的对象。如果数据源是无限流式传输的,则在Java8之前就无法很好的进行处理。而采用Stream API就能很好的解决这些问题,其优点在于:
① 可以处理来自源的无限数量的对象
② 具有函数式编程风格
③ 能使用前面所说的Functional Interface
在Stream中,可以依次连接几个处理器。最后一个用于产生结果。此结果可能是示例中的标量值,也可能是全新的集合,例如List或Map。最后一个操作称为终端操作,因为它终止了流进程。其他处理器称为中间操作。终端操作后,无法再次使用该流,也无法再使用Stream的其他中间操作。
以下是中间操作:
Stream 操作 | 作用 | 输入 |
---|---|---|
filter | 根据给定的Predicate过滤对象 | Predicate |
map | 根据给定对象对其进行相应的转换 | Function |
limit | 限制集合个数 | int |
sorted | 给对象进行排序 | Comparator |
distinct | 根据对象的equals()过滤掉重复的对象 | |
flatMap | 将多个流合并成一个流 | Function,Stream |
以下是终端操作
Stream 操作 | 作用 | 输入 |
---|---|---|
forEach | 遍历每个对象,并执行传入的Consumer的操作 | Consumer |
count | 输出对象个数 | |
collect | 将最终过滤的数据输出 |