Java8 特性解析

Java8新特性

目录:

1. 概要

Java 8 在 2014年3月18日进行了发布,相较之前的版本,有了许多的改变,无论是从语法上还是类库上,都有了很多的变化,更加有利于程序的编写,本文用于此次小组分享,从其中个人认为的较重要的几个方面进行阐述。
总体来说,有了以下几个方面:
1. Lamda表达式
2. 函数接口
3. 类库的增加
4. 工具类

2.Lamda表达式

Lamda表达式也被称之为闭包(closures),利用该特性,我们可以将一个函数当做方法的参数进行传递,也就是将代码当做数据来看待。
由于以上原因,有人将Lamda表达式当做是匿名内部类的语法糖(Syntactic suger)来看待,但从虚拟机实现角度来看,并不是如此,因为Lamda表达式在编译的时候,并不会生成xxx$1.class的匿名类,而是通过动态绑定,在运行的时候在调用,因此避免了在编译时生成从而影响jvm的加载速度。
Lamda表达式没有名称,但是有参数列表,函数体,返回类型并且能够抛出异常,语法如下形式:

(parameters) -> {statements}
(parameters) -> statements
(parameters) -> expression

举例:

() -> Math.PI * 2.0  
(String s) -> s.length() 
(int i0, int i1) -> i0 + i1 
(int x, int y) -> { return x + y; }

使用:

//1. 省略类型
(i, j) ->{System.out.println(i + j)};

//2. 参数数量为1时,省略括号
//行数体只有1行时,可以省略大括号
i -> arrayList::add

//3. 函数体多行的时候需要用大括号包围
(String idStr) -> {
Long id = Long.valueOf(idStr);
try { 
    TEliteUser user = eliteAdapter.getUserById(id);
    } catch (Exception e) {
    log.error("", e)
    }
    userList.add(user);
    };

//4. 函数体只有一行且有返回值得可以省略return,此时大括号需要一并省略
(i, j) -> i - j;

//5. 用于Lamda的变量不可改变
int portNumber = 1337;  
Runnable r = () -> System.out.println(portNumber); // OK  
// 编译错误  
// Local variable portNumber defined in an enclosing scope must be final or effectively final  
int portNumber = 1337;  
Runnable r = () -> System.out.println(portNumber); // NG  
portNumber = 1338;  
// 通过数组实现  
final int[] wrappedNumber = new int[] { 1337 };  
Runnable r = () -> System.out.println(wrappedNumber[0]); // OK  
wrappedNumber[0] = 1338;  

其中所说的Lamda表达式中所引用的必须是不可变的类型,在编译器实现时是通过隐式的方式将类变量或局部变量进行转换的。也就是以下两种方法是等效的:

//隐式
String separator = ",";
Arrays.asList( "a", "b", "d" ).forEach(
    ( String e ) -> System.out.print( e + separator ) );
//显式
final String separator = ",";
Arrays.asList( "a", "b", "d" ).forEach(
    ( String e ) -> System.out.print( e + separator ) );

还有一点是,在使用Lamda表达式操作集合时,是无法对集合进行元素的删除的,否则会在产生RunTime Exception:

//Exception in thread "main" java.util.ConcurrentModificationException
    List<String> names = new ArrayList<String>(){{
        add("Zhao");
        add("Qian");
        add("Sun");
    }};
    names.forEach(name -> {
        if (Objects.equals(name, "Sun"));
        names.remove(name);
    });

当然,这并不是Lamda表达式的问题,使用foreach的话也会遇到同样的问题,在要对集合进行修改的时候,请使用iterator迭代器进行。

3.函数接口

为了使Lamda表达式与原有功能友好兼容,增加了函数接口:只有一个方法的接口(比如java.lang.Runnable和java.util.concurrent.Callable)。通过函数接口,接口能够隐式的转换为Lamda表达式。
为了确保函数接口中只有一个方法,java8中增加了一个注释@FunctionalInterface来确保这点。
Java现有接口中均已添加该注释,比如Runnable函数:

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

不过需要注意的是,默认方法和静态方法并不会违背函数接口。比如Java8中引入的Consumer接口的定义:

@FunctionalInterface
public interface Consumer<T> {
   void accept(T t);
   default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

关于接口的默认方法静态方法
在关于java的讨论中,抽象类和接口之间的相似性和区别一直是一个很经典的问题。而有趣的是,Oracle在java8中向接口中引入了默认方法和静态方法,以此来缩小接口和抽象类的区别。
* 默认方法:
默认方法允许我们在接口里添加新的方法,并不会破坏与实现该接口之前代码的兼容性。也就是并不要求实现该接口的类实现该方法。使用默认方法只需要在方法前加上default关键字。
* 静态方法:
Java8同样在接口中定义了静态方法,使用关键字static来进行修饰,默认是public修饰符,所以可以省略,使用方法与在class中定义静态方法相同。建立了方法与接口之间的联系。
以下的例子同时包含了默认方法和静态方法:

 private interface Defaulable {
    // Interfaces now allow default methods, the implementer may or
    // may not implement (override) them.
    default String notRequired() {
        return "Default implementation";
    }
}
private static class DefaultableImpl implements Defaulable {
}
private static class OverridableImpl implements Defaulable {
    @Override
    public String notRequired() {
        return "Overridden implementation";
    }
}
private interface DefaulableFactory {
    // Interfaces now allow static methods
    static Defaulable create( Supplier< Defaulable > supplier ) {
        return supplier.get();
    }
}
public static void main( String[] args ) {
    Defaulable defaulable = DefaulableFactory.create( DefaultableImpl::new );
    System.out.println( defaulable.notRequired() );
    defaulable = DefaulableFactory.create( OverridableImpl::new );
    System.out.println( defaulable.notRequired() );
}

4. 方法引用

为了使Lamda表达式更加简洁,Java8同样引入了方法引用。方法引用提供了一个很有用的语义来直接访问类或实例的方法。
比如我们定义了如下的类:

public class Car {
//Supplier<T>为Java8引入的函数接口,不接受参数,返回T
    public static Car create( final Supplier< Car > supplier ) {
        return supplier.get();
    }              

    public static void collide( final Car car ) {
        System.out.println( "Collided " + car.toString() );
    }

    public void follow( final Car another ) {
        System.out.println( "Following the " + another.toString() );
    }

    public void repair() {   
        System.out.println( "Repaired " + this.toString() );
    }
}

以下两种方式是等价的:

//原始
final Car car = Car.create(() -> {return new Car();});
//方法引用
final Car car = Car.create(Car::new);

可见,方法引用使得Lamda表达式简洁很多。方法引用具有四种类型:Class::new, Class::static_method, Class::method以及instance::method。例子如下:

//Class::new
List<Car> cars = Arrays.asList(Car.create(Car::new));
Car car = Car.create(Car::new);
//Class::static_method
cars.forEach(Car::collide);
//Class::method
cars.forEach(Car::repair);
//instance::method
cars.forEach(car::follow);

5. Stream

引入了Stream API(java.util.stream),从而与Lamda共同组成了Java的函数式编程,目的是简化,整洁复杂的代码编写,从而调高生产率。
Stream旨在简化基于集合的操作,专注于对集合对象进行各种便利、高效的聚合操作,或者批量数据操作。在原来的对集合操作时,只能通过对集合Iterator或者foreach循环来进行便利操作,非常笨拙,而通过Stream和函数编程,能够极大的简化该过程。

5.1 Stream工作方式

以下例子展示了stream的工作方式:

List<String> myString = Arrays.asList("a1", "a2", "c", "c2", "c1");
myString.stream().filter(s -> s.startsWith("c")).map(String::toUpperCase).sorted().forEach(System.out::println);

stream的操作分为两种,要不是中间操作,要不是终点操作。中间操作返回一个新的Stream。这些中间操作是延迟的,执行一个中间操作比如filter实际上不会真的做过滤操作,而是创建一个新的Stream,当这个新的Stream被遍历的时候,它里头会包含有原来Stream里符合过滤条件的元素。而终点操作则不返回或者返回一个非stream的结果。在以上例子中,filter, map, sorted均是中间操作,而forEach则是终点操作。以上的对于stream的操作,我们称之为操作管道(operation pipeline)。在stream上所有的操作可以通过查看javadoc来进行查看。在一下的文章中,我们会就其中最重要几个函数进行介绍。
需要注意的是,一般stream操作均会和Lamda表达式,函数接口和方法引用等结合起来,并且是非引用(non-interfering)的。

5.2 不同类型的streams

stream可以是不同的来源的数据,不够大部分的时候我们用它来处理集合的问题。通过stream()和parallelStream()来分别构造同步或异步的stream。
我们可以通过如下的形式来构建同步的stream,异步parallelStream只是在实现的线程上有所区别。

//通过集合的stream方法
List<String> myString = Arrays.asList("a1", "a2", "c", "c2", "c1");
myString.stream().findFirst().ifPresent(System.out::println);
//通过Stream.of()方法
Stream.of("a1", "a2", "c", "c2","c1").findFirst().ifPresent(System.out::println);

需要注意的是,对于不同的原生数据类型(Primitive DataType),Stream也有相对应的数据类型,IntStream,LongStream, DoubleStream等,与Stream一样,他们都是BaseStream<T,BaseStream<T>>的实现。
原生类型的Stream与普通对象的区别有一下几点(以IntStream为例):
1. IntFunction代替Function<T,R>(接受一个T类型参数,返回R类型参数),IntPredicate代替Predictae<T>(接受一个T类型参数,返回boolean值)…
2. 支持一些附加的终点操作,比如sum()或者average()。

Arrays.stream(new int[] {1, 2, 3}) .map(n -> 2 * n + 1).average().ifPresent(System.out::println);

3.普通Stream<T>与原生类型Stream之间的转换通过mapToInt(Function<T, Integer> mapper)和mapToObj(<Interger, T> mapper)转换。

//regular steam to intStream
Stream.of("a1", "a2", "a3").map(s -> s.substring(1)).mapToInt(Integer::parseInt) .max().ifPresent(System.out::println);
// intStream to regular Stream      
IntStream.range(1, 4).mapToObj(i -> "a" + i).forEach(System.out::println);
//first Stream<Double> to int, then int to regular
Stream.of(1.0, 2.0, 3.0).mapToInt(Double::intValue).mapToObj(i -> "a" + i).forEach(System.out::println);

5.3 操作的顺序

对于stream来讲,操作管道的顺序是串行,垂直的(vertically),而不是水平的(horizontally)。为了理解这句话,我们看一下这个例子:

Stream.of("d2", "a2", "b1", "b3", "c").filter(s -> {
        System.out.println("filter: " + s);
        return true;
    }).forEach(s -> System.out.println("forEach: " + s));
//结果是:
filter: d2
forEach: d2
filter: a2
forEach: a2
filter: b1
forEach: b1
filter: b3
forEach: b3
filter: c
forEach: c

由此可见,操作的顺序是一个接着一个元素顺序进行的,也就是当”d2”元素全部操作完成后,”a2”才会继续进行。
这样顺序的一个好处是,可以减少进行判断的次数:

Stream.of("d2", "a2", "b1", "b3", "c")
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .anyMatch(s -> {
        System.out.println("anyMatch: " + s);
        return s.startsWith("A");
    });
    //结果:
map: d2
anyMatch: D2
map: a2
anyMatch: A2

对于anyMatch来讲,当匹配到a2后,就不在进行后续的操作,从而减少了操作的次数。
由以上分析我们可以看出操作管道的顺序会影响到计算的性能,比如以下这个例子:

//order1
Stream.of("d2", "a2", "b1", "b3", "c")
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("A");
    })
    .forEach(s -> System.out.println("forEach: " + s));
//结果
// map:     d2
// filter:  D2
// map:     a2
// filter:  A2
// forEach: A2
// map:     b1
// filter:  B1
// map:     b3
// filter:  B3
// map:     c
// filter:  C

//order2
Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("a");
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .forEach(s -> System.out.println("forEach: " + s));
//结果
// filter:  d2
// filter:  a2
// map:     a2
// forEach: A2
// filter:  b1
// filter:  b3
// filter:  c

可见,当我们更换了map和filter的操作顺序后,执行的次数也发生了变化,所以说顺序会影响整个操作管道执行的性能。

5.4 重复使用Stream

Stream的终结是以终点操作来标识结束的,也就是一旦调用了终点方法,那么这个stream就会关闭,不能再次使用。

Stream<String> stream =Stream.of("d2", "a2", "b1", "b3", "c").filter(s -> s.startsWith("a"));
stream.anyMatch(s -> true);    // ok
stream.noneMatch(s -> true);   // exception

解决方法就是每次重新构造新的stream操作链来进行。比如我们可以构造一个stream supplier来每次获得新的stream:

Supplier<Stream<String>> streamSupplier = () -> Stream.of("d2", "a2", "b1", "b3", "c").filter(s -> s.startsWith("a"));
streamSupplier.get().anyMatch(s -> true);   // ok
streamSupplier.get().noneMatch(s -> true);  // ok

5.5 一些重要的操作

所有Stream操作管道支持的操作均在javadoc中列出。在上面我们已经使用了包括map,filter在内的一些操作,在下面我们将会介绍包括collectreduce这两个操作。

  • Collect

Collect是一个非常常用的终点操作,能够将stream转换为各种集合,比如List, Map或者Set。Collect接受Collector为参数,而Collector由四部分组成:Supplier,Accumulator,Combiner,Finisher。虽然Collector很复杂,但是我们可以通过框架类Collector来获得,在大多数情况下并不需要我们手动实现。
比如构返回一个List, Set, Map只需:

//返回List
List<Person> filtered = persons.stream().filter(p -> p.name.startsWith("P")).collect(Collectors.toList());
System.out.println(filtered);
//返回Set
Set<Person> filtered = persons.stream().filter(p -> p.name.startsWith("P")).collect(Collectors.toSet());
System.out.println(filtered);
//返回Map
Map<Integer, List<Person>> personsByAge = persons.stream().collect(Collectors.groupingBy(p -> p.age));
personsByAge.forEach((age, p) -> System.out.format("age %s: %s\n", age, p));
//结果
// age 18: [Max]
// age 23: [Peter, Pamela]
// age 12: [David]

Collectors能够做的功能远远不止这些,比如还能够计算平均值:

Double averageAge = persons.stream().collect(Collectors.averagingInt(p -> p.age));
System.out.println(averageAge); 

计算统计数据summaryStatistics:

IntSummaryStatistics ageSummary =persons.stream().collect(Collectors.summarizingInt(p -> p.age));
System.out.println(ageSummary);
// IntSummaryStatistics{count=4, sum=76, min=12, average=19.000000, max=23}

连接字符串:

String phrase = persons.stream().filter(p -> p.age >= 18).map(p -> p.name).collect(Collectors.joining(" and ", "In Germany ", " are of legal age."));
System.out.println(phrase);
// In Germany Max and Peter and Pamela are of legal age.

对于映射到Map来讲,需要传递三个函数接口:分别是key和value的Function,以及value合并的BinaryOperation:

Map<Integer, String> map = persons.stream().collect(Collectors.toMap(
        p -> p.age,
        p -> p.name,
        (name1, name2) -> name1 + ";" + name2));
System.out.println(map);
// {18=Max, 23=Peter;Pamela, 12=David}
  • Reduce

Reduce操作正如名字所言,是将stream中的各个数据结合为一个最终结果的方法。总共有三种reduce操作:

 Optional<T> reduce(BinaryOperator<T> accumulator);
 T reduce(T identity, BinaryOperator<T> accumulator);
 <U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator,  BinaryOperator<U> combiner);

我们分别来看一下。
第一种通过accumulator将stream中的所有元素均生成一个最终数据,如下所示:

persons.stream().reduce((p1, p2) -> p1.age > p2.age ? p1 : p2).ifPresent(System.out::println);

以上例子返回的是依据age得到的结果,函数接口使用的是BinaryOperator<Person>,该函数接口接受两个相同类型的参数,同时返回该类型的参数。返回的数据是Optional<Person>类型。
第二种接受一个变量identity和同样的accumulator。identity用于保存累加的结果。比如我们可以通过以下的方式来获取一个新的累加的人:

Person result = persons.stream().reduce(new Person("", 0), (p1, p2) -> {
            p1.age += p2.age;
            p1.name += p2.name;
            return p1;
        });
System.out.format("name=%s; age=%s", result.name, result.age);
// name=MaxPeterPamelaDavid; age=76

第三种接受三个变量,一个identity用于保存累加结果,一个函数接口accumulator用于计算累加方式,还有一个函数接口combiner用于计算两个accumulator计算得到的值。也就是说,combiner函数接口主要是用于parallel并行方法的。

Integer ageSum = persons.stream().reduce(0, (sum, p) -> sum += p.age, (sum1, sum2) -> sum1 + sum2);
System.out.println(ageSum);  // 76

为了印证以上的说法,可以如下测试:

Integer ageSum = persons.stream().reduce(0,
        (sum, p) -> {
            System.out.format("accumulator: sum=%s; person=%s\n", sum, p);
            return sum += p.age;
        },
        (sum1, sum2) -> {
            System.out.format("combiner: sum1=%s; sum2=%s\n", sum1, sum2);
            return sum1 + sum2;
        });
// accumulator: sum=0; person=Max
// accumulator: sum=18; person=Peter
// accumulator: sum=41; person=Pamela
// accumulator: sum=64; person=David

可见,串行的时候并没有用到combiner函数接口,而当采用parallelStream()转换为并行时,又有如下的结果:

Integer ageSum = persons.parallelStream() .reduce(0,
        (sum, p) -> {
            System.out.format("accumulator: sum=%s; person=%s\n", sum, p);
            return sum += p.age;
        },
        (sum1, sum2) -> {
            System.out.format("combiner: sum1=%s; sum2=%s\n", sum1, sum2);
            return sum1 + sum2;
        });
// accumulator: sum=0; person=Pamela
// accumulator: sum=0; person=David
// accumulator: sum=0; person=Max
// accumulator: sum=0; person=Peter
// combiner: sum1=18; sum2=23
// combiner: sum1=23; sum2=12
// combiner: sum1=41; sum2=35

6. 总结

java8无论是Lamda表达式,函数接口,方法引用还是Stream类库的加入,目的都是将函数编程的便利引入到java中来,而这些特性的加入也是的编程更加的简洁与便利。而这些内容还需要我们在以后的实践中不断的去试错与尝试,才能够深入体会到其中真谛。
最后感谢以下资源的贡献。

Java8特性官方页1
Java8 features tutorial2
Java8特性,终极手册3
深入浅出Lamda表达式4
Java8默认方法5
Java8 Stream Tutorial6
Javadoc7

阅读更多
博主设置当前文章不允许评论。
换一批

没有更多推荐了,返回首页