JAVA基础之四-函数式接口和流的简介
自从J8开始,对于开发JAVAEE应用的工程师而言,函数式接口会常常接触,某种程度上有点不可绕过。
这是因为在绝大部分企业中都会使用Spring来开发JAVAEE,而Spring在它的实现中越来越多地使用上函数式编程。
如果我们阅读它的源码,函数式编程是绕不过去的。
函数式编程有其好处,这个好处就是工程上的:让代码看起来简洁;如果你熟练一点,还是能够节省一些时间的。
就具体而言,函数式编程用起来和JS的郎打表达式差不多,不过后者更加随意的(因为不需要考虑性能和稳定性,相对后端而言)。
要了解java的函数式编程,需要掌握以下内容:
- 函数式接口
- 流api(即stream api)
- 函数式编程优缺点和适用的业务场景
- JAVA中函数式编程的未来瞻望
一、函数式接口
1.1、定义
函数接口注解
@Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface FunctionalInterface {}
函数式接口的实现是java自行实现的。和我们在spring中定义各种注解不太一样。
看这个注解,知道三个重要信息:
Documented -- 会在javaDoc之类工具生成的文档中展示
Retention- 只有运行时才会生效
Target- 只能用于对象(具体是接口)
函数式接口
一种特有的接口,必须具备两个特点:
1.必须在接口上添加@FunctionalInterface注解,表明这是一个函数接口
2.在接口体内只能定义一个public abstract类型的方法。
@FunctionalInterface public interface Isort { public int add(int a,int b); }
3.虽然只能定义一个公共抽象方法,但其实还可以定义其它乱七八糟的东西,但只有以下是允许的:only public, private, abstract, default, static and strictfp are permitted
换言之,可以定义私有方法,默认方法等,但只要保证一个原则即可:只能有一个公共的抽象方法
package study.base.oop.interfaces.functional; import java.util.Random; @FunctionalInterface public interface Isort { /** * 1.允许定义公共静态属性 * 2.允许默认方法 * 3.允许私有方法(私有,静态私有) */ public static int SORT_TYPE_ASC=1; public static int SORT_TYPE_DESC=2; //私有静态 private static int rand() { return (new Random()).nextInt(100); } //私有方法 private void testPrivate() { System.out.printf("生车一个随机数%d\n", rand()); } //默认方法 default void doSomething(int a,int b) { testPrivate(); System.out.printf("两个参数分别是%d,%d",a,b); } //公共抽象方法 -- 这是函数式接口对外暴露的唯一方法 public int add(int a,int b); }
验证代码见后端有关章节。
java自身从J8之后,创建了一个很重要的类型
@FunctionalInterface public interface Function<T, R> { }
并有大量基于这个接口的实现,某种形式上,Function类似于Object在类中地位。
除了Funciton,还推出了相关一堆的类型,以便支持流式API,例如:
Predicate,Supplier,Consumer...
概念有点小多,需要专门另开文章阐述。
1.2、简单实现
java目前提供了5种方式,用于实现函数式接口:
1.传统类
2.朗打表达式
3.匿名函数
4.方法引用
5.构造函数
其中2~5是重点,目的都是为了节省编码+实现流式API。
为了演示这几种,我写了一个相对完整的例子,具体如下(为了节省篇幅,放在一起,不再列出包等信息),其中最重要的函数式接口Isort 见前文。
//实现类 public class Sort { public int add(int a, int b) { int total= a+b; System.out.println("虽然不是函数式实现,但是方法同约定方法一样的结果:"+total); return total; } } //用于演示基于构造函数引用 @FunctionalInterface public interface IFace { public Face show(int a,int b); } public class Face { int a; int b; public Face(int a,int b) { this.a=a; this.b=b; } public void write() { System.out.println(a+b); } }
测试代码:
public class StudentSortImpl implements Isort { @Override public int add(int a, int b) { int total = a + b; System.out.println(total); this.doSomething(a,b); return total; } public static void main(String[] args) { // 1.0 函数式接口的传统实现-类实现 System.out.println("1.函数式接口的实现方式一:实现类"); Isort sort = new StudentSortImpl(); sort.add(10, 20); // 函数式接口的实现二-朗打方式 System.out.println("2.函数式接口的实现方式一:朗打表达式"); // 2.1 有返回的情况下,注意不要return语句,只能用于单个语句的 // 如果只有一个参数,可以省掉->前的小括弧 // 如果有返回值,某种情况下,也可以省略掉后面的花括弧{} // 有 return的时候 // a->a*10 // (a)->{return a*10} 要花括弧就需要加return // (a,b)->a+b // (a,b)->{return a+b;} Isort sort2 = (a, b) -> a + b; Isort sort3 = (a, b) -> { return a * 10 + b; }; // 2.2 有没有多条语句都可以使用 ->{}的方式 Isort sort4 = (a, b) -> { a += 10; return a + b; }; int a=10; int b=45; int total=sort2.add(a, b)+sort3.add(a, b)+sort4.add(a, b); System.out.println("总数="+total); // 3 使用 new+匿名函数的方式来实现 System.out.println("3.函数式接口的实现方式一:匿名类"); Isort sort5 = new Isort() { @Override public int add(int a, int b) { int total = a * a + b; System.out.println(total); return total; } }; sort5.add(8, 2); // 4.0 基于方法引用-利用已有的方法,该方法必须结构同接口的方式一致 // 在下例中,从另外一个类实例中应用,而该实例仅仅是实现了方法,但是没有实现接口 // 可以推测:编译的时候,通过反射或者某些方式实现的。具体要看编译后的字节码 System.out.println("4.函数式接口的实现方式一:方法引用"); Sort otherClassSort=new Sort(); Isort methodSort = otherClassSort::add; methodSort.add(90, 90); // 5.0 基于构造函数 // 这种方式下,要求构造函数返回的对象类型同函数接口的返回一致即可,当然参数也要一致 System.out.println("5.函数式接口的实现方式一:构造函数引用"); IFace conSort=Face::new; Face face=conSort.show(10, 90); face.write(); //小结:基于方法和基于构造函数的实现,应该仅仅是为了stream和函数式服务,和朗打没有什么关系 //这个最主要是为了编写一个看起来简介的表达式。 } }
函数式接口简化了接口,以便方便实现流式操作。
二、流式API
如果光有函数式接口,那么距离函数式变成还有一点距离:流式API
从java8开始,java新增一个java.util.stream.Stream<T>接口,该接口约定了流式操作所需要包含的各种实现抽象定义。
有了流式API,那么通过连续的点号和流式操作可以实现看起来相对高效,相对简洁的代码。
注意:这里强调了“相对“,这是因为现有函数式编程(包括流式API)都是有特定使用场景,至少在目前阶段,它的实现未必是四海皆准,这是在JAVAEE应用中
看起来还不错,还是具有不错的工程价值。
限于篇幅,流式API不是本篇的重点,这里简单介绍流式API是什么,如何定义。
2.1、Stream接口及其基本方法
java.util.stream.Stream<T>
以下是J17中JAVADoc的内容:
Stream<T>是一个支持顺序和并行聚合操作的元素序列。以下示例展示了如何使用Stream和IntStream执行聚合操作: java int sum = widgets.stream() .filter(w -> w.getColor() == RED) .mapToInt(w -> w.getWeight()) .sum(); 在这个示例中,widgets是一个Collection<Widget>。我们通过Collection.stream()方法创建了一个Widget对象的流,然后使用filter方法过滤出只有红色的Widget,接着将其转换为一个包含每个红色Widget重量的int值流。最后,对这个流进行求和操作以得到总重量。 除了Stream(一个对象引用的流)之外,还有针对原始类型的专门化,如IntStream、LongStream和DoubleStream,所有这些都被称为“流”,并遵守此处描述的特征和限制。
为了执行计算,流操作被组合成一个流管道。流管道由一个源(可能是数组、集合、生成器函数、I/O通道等)、零个或多个中间操作(将流转换为另一个流,如Stream.filter(Predicate))和一个终端操作(产生结果或副作用,如Stream.count()或Stream.forEach(Consumer))组成。流是惰性的;只有在启动终端操作时才会对源数据进行计算,并且只有在需要时才会消耗源元素。 流实现被允许在优化结果计算方面有很大的自由度。例如,如果流实现可以证明从流管道中省略某些操作(或整个阶段)不会影响计算结果,那么它可以自由地省略这些操作(或整个阶段),以及省略行为参数的调用。这意味着除非另有说明(如由终端操作forEach和forEachOrdered指定),否则行为参数的副作用可能不会总是被执行,因此不应依赖它们。 集合和流虽然表面上有一些相似之处,但它们有不同的目标。集合主要关注于其元素的高效管理和访问。相比之下,流不提供直接访问或操作其元素的方式,而是关注于声明性地描述其源和将对其源执行的聚合计算操作。然而,如果提供的流操作不提供所需的功能,可以使用iterator()和spliterator()操作进行受控遍历。 像上面的“widgets”示例这样的流管道可以被视为对流源的查询。除非源被明确设计为支持并发修改(如ConcurrentHashMap),否则在查询流源时修改它可能会导致不可预测或错误的行为。 大多数流操作接受描述用户指定行为的参数,如上面mapToInt中传递的lambda表达式w -> w.getWeight()。为了保持正确的行为,这些行为参数: 必须是非干扰性的(它们不修改流源); 在大多数情况下必须是无状态的(它们的结果不应依赖于在执行流管道期间可能更改的任何状态)。 这样的参数始终是功能接口(如java.util.function.Function)的实例,并且经常是lambda表达式或方法引用。除非另有说明,否则这些参数必须非空。 一个流应该只被操作(调用中间或终端流操作)一次。这排除了例如“分叉”流的情况,即同一个源同时供给两个或多个管道,或对同一流进行多次遍历。如果流实现检测到流正在被重用,它可能会抛出IllegalStateException。但是,由于某些流操作可能会返回接收器本身而不是新的流对象,因此可能无法在所有情况下检测到重用。 流具有close()方法并实现AutoCloseable接口。在流关闭后对其进行操作将抛出IllegalStateException。大多数流实例在使用后实际上不需要关闭,因为它们是由集合、数组或生成函数支持的,这些不需要特殊的资源管理。通常,只有其源是I/O通道(如Files.lines(Path)返回的流)的流才需要关闭。如果流需要关闭,则必须在try-with-resources语句或类似的控制结构中将其作为资源打开,以确保在操作完成后及时关闭。 流管道可以顺序执行或并行执行。这是流的属性。流在创建时可以选择顺序执行或并行执行(例如,Collection.stream()创建顺序流,而Collection.parallelStream()创建并行流)。可以通过sequential()或parallel()方法修改执行模式的选择,并通过isParallel()方法查询。
个人觉得,官方的JavaDoc已经把流api说的比较清楚了(部分翻译可能不是很恰当),上文可以归纳为几点:
1.流是一个支持顺序和并行聚合操作的元素序列。
2.流管道-由一个源(可能是数组、集合、生成器函数、I/O通道等)、零个或多个中间操作(将流转换为另一个流,如Stream.filter(Predicate))和一个终端操作(产生结果或副作用,如Stream.count()或Stream.forEach(Consumer))组成
3.流是惰性(lazy(的-只有在启动终端操作时才会对源数据进行计算,并且只有在需要时才会消耗源元素。(参考了另外一些资料,可以概述为:流管道的操作是比较智能高效,知道中止、知道优化,并非每个中间都会执行)
注:lazy“惰性“的翻译可能值得商榷,也是翻译为"延迟"更好一些。这个含义大体同spring中用于bean上@lazy注解,行为上也是相似的。
4.其它一些注意事项:一个流应该只被操作(调用中间或终端流操作)一次;只有其源是I/O通道(如Files.lines(Path)返回的流)的流才需要关闭;
5.流参数-应该必须是非干扰性的,在大多数情况下必须是无状态的
2.2、流式API的优缺点
这个待完善,因为本人对于流式API的体会并不是那么深刻,所以只能给出大部分人认可的优缺点。
2.2.1、优点
1.代码看起来更加简洁 - 可以算一个
2.高效-这个有待商榷-因为有人专门研究了这个东西。 不过如前所述,在大部分的JAVAEE开发中,只要秉着专业技能编写,使用流式API处理数据还是一个不错的主意。
对于程序员的主要影响是两个:能以较小的代码实现并发(并非是java实现的); 能够实现可接受的“高性能“。
关于stream性能这个事,有许多研究参考,虽然不算非常严谨,但大体可用:
JDK8 Stream 数据流效率分析 -- https://www.cnblogs.com/jpfss/p/11262231.html
Java8 Stream 数据流,大数据量下的性能效率怎么样?-- https://blog.csdn.net/2401_84048338/article/details/138879395
3.灵活可扩展 -- 操作可以灵活组合、容易添加中间或者终端操作
2.2.2、缺点
1.不好调试 - 这是实话-即使idea之类的工具有针对朗打表达式的调试,但是针对对于流的调试还不算有好
2.并行流性能可能不如预期 - 如前。并行流并不总是比顺序流快。并行化的开销以及任务划分的复杂性可能导致性能下降;在处理小数据集或数据集分割不均匀时,并行流可能效率不高
还有一些,但个人认为不属于流所有特有的。因为当你选择流的时候,意味着就要承受的对应的缺陷,例如开启并行就要耗费更多资源。
除非这个缺陷是非常显著的、难于忽视的,才值得单列。
因为流式api的特点,所以在日常工作中,我对于使用流式api并不是很热衷,并警告有关人不要滥用。
但在有些业务场景也会考虑用:
a.这个业务对性能要求不高
b.一般属于sql无法完成的,例如转换
有些同事老是把互联网开发规则放到非互联网行业。似乎阿里之类的都是对的,并热衷于把数据捞到jvm中,做各种集聚操作(通常是流)。
那样做其实至少有两大坏处:浪费数据库资源(闲置),在集聚上sql做得比java好多了;很可能会撑爆应用服务器
这种行为,在非互联网行业,或者说并发不是那么大的情况下,并不值得提倡,而应该批评。
三、函数式编程适用业务场景
java是一个OOP编程语言,JAVA函数式编程有什么用?
个人认为的核心效果:可以接受的效果,添加新特性(完成升级JAVA的KPI)
事实上,“函数式编程“本身我并没有找到官定的(后面会继续找找)。
就我个人理解而言,JAVA的所为函数式编程就是:利用函数式接口+流式api+朗打表达式 创建有关功能。
虽然函数式编程具有所为的一些好处,但考虑到java的现状,函数式变成还是只能局限在几个方面,前文已经提到,此处不再赘述。
由于我个人的习惯和企业业务特点,所以基本没有考虑使用函数式编程,主要用到的就是Stream的map功能。
个人把函数式编程当作一个可有可无的东西,坚持面向过程和面向对象才是真正的核心!!!
四、函数式编程的未来瞻望
如果JCP不能把JAVA变成JS,我个人觉得函数式编程应该适可而止,优缺点列出了。
作为JAVAEE工程师,只有在特定的条件下,才会考虑用用,或者仅仅是为了便于读懂Spring之类的源码。