欢迎光临散文网 会员登陆 & 注册

Java二十一篇:Java8

2023-03-05 19:41 作者:小刘Java之路  | 我要投稿


圣诞节快乐


大家好,我是小刘!

函数式编程

众所周知,JDK8引入了函数式编程。什么是函数式编程呢?为何需要函数式编程呢?

认知决定高度。首先函数式编程是与面向对象编程一个层级的概念。

任何Java程序员都不可能不知道面向对象编程OOP。OOP的口号是“万物皆对象”。什么是对象呢?就是现实中一个东西在编程领域的投射。对象有属性,有方法。属性表示数据,方法表示行为。对象可以用来表示任何事物,非常强大。既然如此,为何又需要函数式编程呢?

什么是函数

函数式编程来自数学。有点(gaozhong)数学功底的你一定不会忘记这个东东:

img

数学中的函数表示的是什么呢?其实就是一个计算过程。编程领域也是一样的, 函数表示了一个计算过程,比如,加减乘除,取余等等。就是我们java的方法的作用。

你肯定要问,那java中有方法了啊,为何又弄个什么函数式编程出来呢?问题在于,java中的方法是“二等公民”,它只能依附于对象存在,而不能独立存在。

JS中的函数与Java中的方法比较

这方面我们可以把java与js放在一起比较。在js中,函数是一等公民,你可以直接声明函数,使用函数。如下例所示。

//将函数定义为变量
var add = function(a,b){
 return a+b;
}
var minus = function(a,b){
 return a-b;
}

//定义一个计算函数,第一个参数是个函数,注意我们已经开始传递函数了
function calc(fn,a,b){
 return fn(a,b);//调用函数fn,将a,b传入
}

//调用calc
//传入add函数作为第一个参数
var r1 = calc(add,10,5);
console.log("r1:"+r1)
//传入minus函数作为第一个参数
var r2 = calc(minus,10,5);
console.log("r2:"+r2)

在这里我们也看到,函数的好处是可以封装一段算法,一个计算过程,一个行为。这是对象无法做到的。但是好处却很显然,calc的第一个参数是个函数,在不改变calc方法的前提下,可以非常容易的扩展出各种算法。只需要提供不同函数实现即可。因为函数封装了算法。

这在java中是无法直接实现的。必须绕个弯。我们需要将函数放在一个接口中。代码如下:

package com.woniuxy.test;

/**
* 表示一个计算接口,这个接口存在的唯一用处就是存放calc方法
*/
publicinterface Fn {
   //计算函数的抽象方法
   double calc(double a,double b);

}

//实现加法
package com.woniuxy.test;

publicclass Add implements Fn {
   @Override
   public double calc(double a, double b) {
       return a+b;
   }
}

//实现减法
package com.woniuxy.test;

publicclass Minus implements Fn {
   @Override
   public double calc(double a, double b) {
       return a-b;
   }
}

//计算器类
package com.woniuxy.test;

publicclass Calculator {
   /**
    * 接受Fn接口作为参数,根据多态性,Fn可以传入Add和Minus
    */
   public double calc(Fn fn, double a, double b){
       return fn.calc(a,b);
   }

}

最后我们来组装一下,代码与上面的js代码就非常接近了。

//调用测试
package com.woniuxy.test;

publicclass App {
   public static void main(String[] args) {
       //声明函数实例
       Fn add = new Add();
       Fn minus = new Minus();
       
       Calculator c = new Calculator();
       //调用
       double r1 = c.calc(add,10,5);
       System.out.println("r1:"+r1);
       
       double r2 = c.calc(minus,10,5);
       System.out.println("r2:"+r2);
   }
}

我们来看,同样的计算逻辑,为何js为java要简单呢?因为java是oop的,基于对象。所以必须现有Fn接口才能有calc方法、必须现有Add类才能有calc方法的实现。方法必须依赖于对象。而JavaScript中函数是“一等公民”,可以直接创建。

引入函数式编程

为了简化这类代码,JDK8引入了函数式编程,终于Java也可以直接使用函数啦。

这里我们先给出BiFunction 这个接口,这个接口表示一个接受两个参数的函数,有三个泛型分别表示第一个参数的类型和第二个参数的类型以及返回值类型。所以可以直接用BiFunction来表示我们的这里的加减乘除的函数。

//修改Calculator.java,接受BiFunction作为第一个参数
   public double calc2(BiFunction<Double,Double,Double> fn,double a,double b){
       return fn.apply(a,b);
   }

//不再创建Fn、Add和Minus了,直接用BiFunction表示操作
publicclass App {
   public static void main(String[] args) {
       //直接用BiFunction封装加法运算
       BiFunction<Double,Double,Double> add =new BiFunction<Double, Double, Double>() {
           @Override
           public Double apply(Double a, Double b) {
               return a+b;
           }
       };
       //直接用BiFunction封装减法运算
       BiFunction<Double,Double,Double> minus =new BiFunction<Double, Double, Double>() {
           @Override
           public Double apply(Double a, Double b) {
               return a-b;
           }
       };
   }
}

显然一下子少了3个类,比刚才简单了一些,但是 BiFunction<Double,Double,Double> 仍然看着头大呀。有简化的办法吗?

引入lambda表达式

是了,就是jdk8引入的lambda表达式。先看效果

//直接用BiFunction封装加法运算
BiFunction<Double,Double,Double> add = (a, b) -> a+b;
//直接用BiFunction封装减法运算
BiFunction<Double,Double,Double> minus = (a, b) -> a-b;

Calculator c = new Calculator();
//传入函数
double r1 = c.calc2(add,10,5);
System.out.println("r1:"+r1);

double r2 = c.calc2(minus,10,5);
System.out.println("r2:"+r2);

感觉如何?已经无限接近js的代码简洁程度了。

lambda基础

在Java程序中,我们经常遇到一大堆单方法接口,即一个接口只定义了一个方法:

  • Comparator

  • Runnable

  • Callable

以Comparator为例,我们想要调用Arrays.sort()时,可以传入一个Comparator实例,以匿名类方式编写如下:

String[] array = ...
Arrays.sort(array, new Comparator<String>() {
   public int compare(String s1, String s2) {
       return s1.compareTo(s2);
   }
});

上述写法非常繁琐。从Java 8开始,我们可以用Lambda表达式替换单方法接口。改写上述代码如下:

public class Main {
   public staticvoid main(String[] args) {
       String[] array = newString[] { "Apple", "Orange", "Banana", "Lemon" };
       Arrays.sort(array, (s1, s2) -> {
           return s1.compareTo(s2);
       });
       System.out.println(String.join(", ", array));
   }
}

观察Lambda表达式的写法,它只需要写出方法定义:

(s1, s2) -> {
   return s1.compareTo(s2);
}

其中,参数是(s1, s2),参数类型可以省略,因为编译器可以自动推断出String类型。-> { ... }表示方法体,所有代码写在内部即可。Lambda表达式没有class定义,因此写法非常简洁。

如果只有一行return xxx的代码,完全可以用更简单的写法:

Arrays.sort(array, (s1, s2) -> s1.compareTo(s2));

返回值的类型也是由编译器自动推断的,这里推断出的返回值是int,因此,只要返回int,编译器就不会报错。

方法引用

方法引用通过方法的名字来指向一个方法。

方法引用可以使语言的构造更紧凑简洁,减少冗余代码。

方法引用使用一对冒号 :: 。

类型语法对应的Lambda表达式静态方法引用类名::staticMethod(args) -> 类名.staticMethod(args)实例方法引用inst::instMethod(args) -> inst.instMethod(args)对象方法引用类名::instMethod(inst,args) -> 类名.instMethod(args)构建方法引用类名::new(args) -> new 类名(args)

No Magic

代码是清晰了,但是你可能没弄明白是怎么回事。

BiFunction<Double,Double,Double> add =new BiFunction<Double, Double, Double>() {
           @Override
           public Double apply(Double a, Double b) {
               return a+b;
           }
       };
//被转换成了
BiFunction<Double,Double,Double> add = (a, b) -> a+b;

这是怎么做到的呢?

道理其实很简单。BiFunction接口只有一个方法apply,而BiFunction存在的意义就是为这个方法提供载体。换言之,我们使用BiFunction接口就是奔着apply方法去的。既然如此,为何不直接把那个方法表示出来呢?jdk就提供了一种简洁的表示法,称为lambda表达式,直接表示出了接口里的方法。

反过来想一下,因为接口只有一个方法,所以非常明确的定位到这个方法。这里就是apply方法。并不会混淆。但是为何a,b参数都没有类型了呢?当然是为了简化代码,但其实是参数类型可以进行推断出来的。我们通过反射能够得到BiFunction的泛型参数,根据约定就可以知道a和b的类型了。有了这些约定,jdk可以获取到足够的信息,自动将lambda表达式转换为匿名内部类。

到这里,我们了解到JDK函数式编程的优势了。同样的封装计算过程(Add、Minus),传统的方式和函数式编程的差别是非常大的。

一些实例

事已至此,我们不如来看几个例子,感受下函数式编程的惊人魅力。请留意,lambda表达式都是在封装算法。

package com.woniuxy.examples;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

publicclass Sorting {

  privatestatic  List<Person> personList = Arrays.asList(new Person(20,"Dido")
           ,new Person(15,"Guava")
           ,new Person(30,"Alina")
           ,new Person(28,"Crack")
   );

   public static void main(String[] args) {
       //0)封装sqrt,虽然有点多此一举
       Function<Double,Double> sqrt = (number)->Math.sqrt(number);
       System.out.println(sqrt.apply(9.0));;

       //1)封装排序算法
       //按年龄排序
       Collections.sort(personList,(p1,p2)-> p1.getAge()-p2.getAge());
       System.out.println(personList);
       //按姓名排序
       Collections.sort(personList,(p1,p2)-> p1.getName().compareTo(p2.getName()));
       System.out.println(personList);

       //2)封装选择算法
       //选出18岁及以上的人
       List<Person> adultList = personList.stream()//这里用到了List的StreamAPI
               .filter((person -> person.getAge()>=18))//lambda表达式
               .collect(Collectors.toList());

       System.out.println(adultList);

       //3)封装Runnable里的run方法,妈妈再也不用担心我写Runnable累死了
       new Thread(()->{
           for (int i = 0; i < 10; i++) {
               System.out.println(Thread.currentThread().getName()+":"+i);
           }
       }).start();


   }



}

class Person{

   privateint age;
   private String name;

   public Person(int age, String name) {
       this.age = age;
       this.name = name;
   }
//...省略setter、getter
   @Override
   public String toString() {
       return"Person{" +
               "age=" + age +
               ", name='" + name + '\'' +
               '}';
   }
}

Stream

Java从8开始,不但引入了Lambda表达式,还引入了一个全新的流式API:Stream API。它位于java.util.stream包中。

划重点:这个Stream不同于java.io的InputStream和OutputStream,它代表的是任意Java对象的序列。两者对比如下:


java.iojava.util.stream存储顺序读写的byte或char顺序输出的任意Java对象实例用途序列化至文件或网络内存计算/业务逻辑

有同学会问:一个顺序输出的Java对象序列,不就是一个List容器吗?

再次划重点:这个Stream和List也不一样,List存储的每个元素都是已经存储在内存中的某个Java对象,而Stream输出的元素可能并没有预先存储在内存中,而是实时计算出来的。

换句话说,List的用途是操作一组已存在的Java对象,而Stream实现的是惰性计算,两者对比如下:


java.util.Listjava.util.stream元素已分配并存储在内存可能未分配,实时计算用途操作一组已存在的Java对象惰性计算

Stream看上去有点不好理解,但我们举个例子就明白了。

如果我们要表示一个全体自然数的集合,显然,用List是不可能写出来的,因为自然数是无限的,内存再大也没法放到List中:

List<BigInteger> list = ??? // 全体自然数?

但是,用Stream可以做到。写法如下:

Stream<BigInteger> naturals = createNaturalStream(); // 全体自然数

我们先不考虑createNaturalStream()这个方法是如何实现的,我们看看如何使用这个Stream。

首先,我们可以对每个自然数做一个平方,这样我们就把这个Stream转换成了另一个Stream:

Stream<BigInteger> naturals = createNaturalStream(); // 全体自然数
Stream<BigInteger> streamNxN = naturals.map(n -> n.multiply(n)); // 全体自然数的平方

因为这个streamNxN也有无限多个元素,要打印它,必须首先把无限多个元素变成有限个元素,可以用limit()方法截取前100个元素,最后用forEach()处理每个元素,这样,我们就打印出了前100个自然数的平方:

Stream<BigInteger> naturals = createNaturalStream();
naturals.map(n -> n.multiply(n)) // 1, 4, 9, 16, 25...
       .limit(100)
       .forEach(System.out::println);

我们总结一下Stream的特点:它可以“存储”有限个或无限个元素。这里的存储打了个引号,是因为元素有可能已经全部存储在内存中,也有可能是根据需要实时计算出来的。

Stream的另一个特点是,一个Stream可以轻易地转换为另一个Stream,而不是修改原Stream本身。

最后,真正的计算通常发生在最后结果的获取,也就是惰性计算。

Stream<BigInteger> naturals = createNaturalStream(); // 不计算
Stream<BigInteger> s2 = naturals.map(BigInteger::multiply); // 不计算
Stream<BigInteger> s3 = s2.limit(100); // 不计算
s3.forEach(System.out::println); // 计算

惰性计算的特点是:一个Stream转换为另一个Stream时,实际上只存储了转换规则,并没有任何计算发生。

小结

1.JDK8函数式编程提供了一种直接封装函数的方式,即提供了一系列预定义的Function接口,提供了封装函数所需的功能。并通过lambda表达式简化了函数的编写方式。

2.Stream API的特点是:

  • Stream API提供了一套新的流式处理的抽象序列;

  • Stream API支持函数式编程和链式操作;

  • Stream可以表示无限序列,并且大多数情况下是惰性求值的。

3.Java Steam API的使用我们下一篇来介绍,掌握了Java8的这种写法之后,让我们的代码看起来更加的逻辑清楚,代码更加优雅了。

4.java8引入的新特性是为了简化代码,也是引入函数式编程融入大环境。

5.好好学习java8 的新特性,现在有一些企业都在使用java11的版本了(我们公司技就是用的java11),你不会java8的东西还不会吧!不会吧!




Java二十一篇:Java8的评论 (共 条)

分享到微博请遵守国家法律