聊聊Java 8 Lambda 表达式

 

    早在2014年oracle发布了jdk 8,在里面增加了lambda模块。于是java程序员们又多了一种新的编程方式:函数式编程,也就是lambda表达式。我自己用lambda表达式也差不多快4年了,但在工作中却鲜有看到同事使用这种编程方式,即使有些使用了,但感觉好像对其特性也不是很了解。我看了一上网上的资料也不少,自己整理了一下顺便写下一些自己的看法,希望我的分享能带给别人一些帮助。

 

    函数式编程基本概念入门

  •    什么是函数式编程 

        函数式编程(英语:functional programming)或称函数程序设计,又称泛函编程,是一种编程典范,它将电脑运算视为数学上的函数计算,并且避免使用程序状态以及易变对象。函数编程语言最重要的基础是λ演算(lambda calculus)。而且λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。比起指令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。这是维基百科给出的定义。从这个我们知道函数式编程是相对于指令式编程的一种编程典范,并且对而言具有一些优点。

  •   函数式编程的特性与优缺点

      特性

      1、函数是”第一等公民” 

         什么是”第一等公民”?所谓”第一等公民”(first class),指的是函数与其他数据类型一样,处于平等地位,它不仅拥有一切传统函数的使用方式(声明和调用),可以赋值给其他变量(赋值),也可以作为参数,传入另一个函数(传参),或者作为别的函数的返回值(返回)。函数可以作为参数进行传递,意味我们可以把行为”参数化”,处理逻辑可以从外部传入,这样程序就可以设计得更灵活。

     2、没有”副作用”

     所谓”副作用”(side effect),指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。函数式编程强调没有”副作用”,意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。

    3、引用透明

    引用透明(Referential transparency),指的是函数的运行不依赖于外部变量或”状态”,只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。这里强调了一点”输入”不变则”输出”也不变,就像数学函数里面的f(x),只要输入的x一样那得到的结果也肯定定是一样的。

      优点:

    1、代码简洁,开发快速。

     函数式编程大量使用函数,减少了代码的重复,因此程序比较短,开发速度较快。Paul Graham在《黑客与画家》一书中写道:同样功能的程序,极端情况下,Lisp代码的长度可能是C代码的二十分之一。如果程序员每天所写的代码行数基本相同,这就意味着,”C语言需要一年时间完成开发某个功能,Lisp语言只需要不到三星期。反过来说,如果某个新功能,Lisp语言完成开发需要三个月,C语言需要写五年。”当然,这样的对比故意夸大了差异,但是”在一个高度竞争的市场中,即使开发速度只相差两三倍,也足以使得你永远处在落后的位置。” 

    2. 接近自然语言,易于理解

       函数式编程的自由度很高,可以写出很接近自然语言的代码。以java为例把学生以性别分组:

       没用labmda表达式:       

Map<String,List<Student>> studentsMap = new HashMap<>();
        for(Student student : students){
            List<Student> studentList = studentsMap.getOrDefault(student.getSex(), new ArrayList<>());
            studentList.add(student);
            studentsMap.put(student.getSex(),studentList);
        }

  用了lambda表达式:

        Map<String,List<Student>> studentsMap = students.stream().collect(Collectors.groupingBy(Student::getSex));

       这基本就是自然语言的表达了,大家应该一眼就能明白它的意思吧。 

      3. 更方便的代码管理

      函数式编程不依赖、也不会改变外界的状态,只要给定输入参数,返回的结果必定相同。因此,每一个函数都可以被看做独立单元,很有利于进行单元测试(unit testing)和除错(debugging),以及模块化组合。 

      4. 易于”并发编程”
      函数式编程不需要考虑”死锁”(deadlock),因为它不修改变量,所以根本不存在”锁”线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程,部署”并发编程”(concurrency)。
     请看下面的代码:
     var s1 = Op1();
     var s2 = Op2();
     var s3 = concat(s1, s2);
     由于s1和s2互不干扰,不会修改变量,谁先执行是无所谓的,所以可以放心地增加线程,把它们分配在两个线程上完成。其他类型的语言就做不到这一点,因为s1可能会修改系统状态,而s2可能会用到这些状态,所以必须保证s2在s1之后运行,自然也就不能部署到其他线程上了。多核CPU是将来的潮流,所以函数式编程的这个特性非常重要。

      5. 代码的热升级

      函数式编程没有副作用,只要保证接口不变,内部实现是外部无关的。所以,可以在运行状态下直接升级代码,不需要重启,也不需要停机。Erlang语言早就证明了这一点,它是瑞典爱立信公司为了管理电话系统而开发的,电话系统的升级当然是不能停机的。

     缺点:

     1、函数式编程常被认为严重耗费在CPU和存储器资源。主因有二:

  • 早期的函数式编程语言实现时并无考虑过效率问题。
  • 有些非函数式编程语言为求提升速度,不提供自动边界检查或自动垃圾回收等功能。
    惰性求值亦为语言如
Haskell增加了额外的管理工作。
    
    2、语言学习曲线陡峭,难度高

    函数式语言对开发者的要求比较高,学习曲线比较陡,而且很容易因为其灵活的语法控制不好程序的结构。

 

     介绍完函数式编程的概念和优缺点之后,下面让我们来进入java8 lambda的编程世界~

 

      Lambda表达式的组成

       java 8 中Lambda 表达式由三个部分组成:第一部分为一个括号内用逗号分隔的形式参数,参数是函数式接口里面方法的参数;第二部分为一个箭头符号:->;第三部分为方法体,可以是表达式和代码块。语法如下

       1、方法体为表达式,该表达式的值作为返回值返回。

(parameters) -> expression
(int a,int b) -> return a + b; //求和

       2、方法体为代码块,必须用 {} 来包裹起来,且需要一个 return 返回值,但若函数式接口里面方法返回值是 void,则无需返回值。

(parameters) -> { statements; }
(int a) -> {System.out.println("a = " + a);} //打印,无返回值
(int a) -> {return a * a;} //求平方

     

      Lambda表达式的底层实现

      java 8 内部Lambda 表达式的实现方式在本质是以匿名内部类的形式的实现的,看下面代码。代码中我们定义了一个叫binaryOperator的Lambda表达式,看返回值它是一个IntBinaryOperator实例。  

IntBinaryOperator binaryOperator = (int a, int b) -> {
    return a + b;
};
int result = binaryOperator.applyAsInt(1, 2);
System.out.println("result = " + result); //3

    我们再看一下IntBinaryOperator的定义 

@FunctionalInterface
public interface IntBinaryOperator {
    /**
     * Applies this operator to the given operands.
     * @param left the first operand
     * @param right the second operand
     * @return the operator result
     */
    int applyAsInt(int left, int right);
}

       

     我们得知IntBinaryOperator是一个接口并且上面有一个@FunctionalInterface的注解,@FunctionalInterface标注了这是一个函数式接口,所以我们知道了(int a, int b) -> {return a + b;}返回的一个IntBinaryOperator的匿名实现类。

 

     Lambda表达式的函数式接口 

     上面提到了函数式接口,那这是一个什么样的概念呢?

      函数式接口(Functional Interface)是Java 8对一类特殊类型的接口的称呼。这类接口只定义了唯一的抽象方法的接口(除了隐含的Object对象的公共方法,因此最开始也就做SAM类型的接口(Single Abstract Method)。定义函数式接口的原因是在Java Lambda的实现中,开发组不想再为Lambda表达式单独定义一种特殊的Structural函数类型,称之为箭头类型(arrow type,依然想采用Java既有的类型(class, interface, method等).原因是增加一个结构化的函数类型会增加函数类型的复杂性,破坏既有的Java类型,并对成千上万的Java类库造成严重的影响。权衡利弊,因此最终还是利用SAM 接口作为 Lambda表达式的目标类型.另外对于函数式接口来说@FunctionalInterface并不是必须的,只要接口中只定义了唯一的抽象方法的接口那它就是一个实质上的函数式接口,就可以用来实现Lambda表达式。

       在java 8中已经为我们定义了很多常用的函数式接口它们都放在java.util.function包下面,一般有以下常用的四大核心接口:       

函数式接口 参数类型 返回类型 用途
Consumer<T>(消费型接口) T void 对类型为T的对象应用操作。void accept(T t)
Supplier<T>(供给型接口) T 返回类型为T的对象。 T get();
Function<T, R>(函数型接口) T R 对类型为T的对象应用操作并返回R类型的对象。R apply(T t);
Predicate<T>(断言型接口) T boolean 确定类型为T的对象是否满足约束。boolean test(T t);

 

    Lambda表达式的应用场景

     1、使用() -> {} 替代匿名类

 Thread t1 = new Thread(new Runnable() {
              @Override
              public void run() {
                  System.out.println("no use lambda");
              }
          });
         
Thread t2 = new Thread(() -> System.out.println("use lambda"));

  我们看到相对而言Lambda表达式要比匿名类要优雅简洁很多~。

     2、以流水线的方式处理数据

        List<Integer> integers = Arrays.asList(4, 5, 6,1, 2, 3,7, 8,8,9,10);

        List<Integer> evens = integers.stream().filter(i -> i % 2 == 0)
                .collect(Collectors.toList()); //过滤出偶数列表 [4,6,8,8,10]
List<Integer> sortIntegers = integers.stream().sorted() .limit(5).collect(Collectors.toList());//排序并且提取出前5个元素 [1,2,3,4,5] List<Integer> squareList = integers.stream().map(i -> i * i).collect(Collectors.toList());//转成平方列表 int sum = integers.stream().mapToInt(Integer::intValue).sum();//求和 Set<Integer> integersSet = integers.stream().collect(Collectors.toSet());//转成其它数据结构比如set Map<Boolean, List<Integer>> listMap = integers.stream().collect(Collectors.groupingBy(i -> i % 2 == 0)); //根据奇偶性分组 List<Integer> list = integers.stream().filter(i -> i % 2 == 0).map(i -> i * i).distinct().collect(Collectors.toList());//复合操作

  借助stream api和Lambda表达式,以住需要定义多个变量,编写数十行甚至数百行的代码的集合操作,现在都基本简化成了可以在一行之内完成~

      3、更简单的数据并行处理

        List<Integer> squareList = integers.stream().parallel().map(i -> i * i).collect(Collectors.toList());//转成平方列表

    数据并行处理,只需要在原来的基础上加一个parallel()就可以开启~。顺便提一下这里parallel()开启的底层并行框架是fork/join,默认的并行数是Ncpu个。

      4、用内部迭代取代外部迭代

       外部迭代:描述怎么干,代码里嵌套2个以上的for循环的都比较难读懂;只能顺序处理List中的元素;

       内部迭代:描述要干什么,而不是怎么干;不一定需要顺序处理List中的元素

List features = Arrays.asList("Lambdas", "Default Method", "Stream API", "Date and Time API");
for (String feature : features) {
    System.out.println(feature); //外部迭代
}

List features = Arrays.asList("Lambdas", "Default Method", "Stream API",
 "Date and Time API");
features.stream.forEach(n -> System.out.println(n)); //内部迭代

       5、重构现有臃肿代码,更高的开发效率

      在Lambda表达式出现之前,我们的处理逻辑只能是以命令式编程的方式来实现,需要大量的代码去编写程序的每一步操作,定义非常多的变量,代码量和工作量都相对的巨大。如果用Lambda表达式我们看到以往数十行甚至上百行的代码都可以浓缩成几行甚至一行代码。这样处理逻辑就会相对简单,开发效率可以得到明显提高,维护工作也相对容易。

 

       Lambda表达式中的Stream

        在java 8 中 Stream 不是集合元素,它不保存数据,它是有关算法和计算的,它更像一个高级版本的 Iterator。原始版本的 Iterator,用户只能显式地一个一个遍历元素并对其执行某些操作;高级版本的 Stream,用户只要给出需要对其包含的元素执行什么操作,比如 “过滤掉长度大于 10 的字符串”、“获取每个字符串的首字母”等,Stream 会隐式地在内部进行遍历,做出相应的数据转换。

        Stream 就如同一个迭代器(Iterator),单向,不可往复,数据只能遍历一次,遍历过一次后即用尽了,就好比流水从面前流过,一去不复返。而和迭代器又不同的是,Stream 可以并行化操作,迭代器只能命令式地、串行化操作。顾名思义,当使用串行方式去遍历时,每个 item 读完后再读下一个 item。而使用并行去遍历时,数据会被分成多个段,其中每一个都在不同的线程中处理,然后将结果一起输出。Stream 的并行操作依赖于 Java7 中引入的 Fork/Join 框架(JSR166y)来拆分任务和加速处理过程。

        Stream可以有限的也可以是无限的,流的构造方式有很多可以从常用的Collection(List,Array,Set and so on…),文件,甚至函数….

      由值创建流:

               Stream<String> stream = Stream.of(“Java 8 “, “Lambdas “, “In “, “Action”);

       由数组创建流: 

               int[] numbers = {2, 3, 5, 7, 11, 13}; int sum = Arrays.stream(numbers).sum();       

       由文件创建流

               Stream<String> lines =Files.lines(Paths.get(“data.txt”), Charset.defaultCharset())

       上面的这些Stream都是有限的,我们可以用函数来创建一个无限Stream

               Stream.iterate(0, n -> n + 2).forEach(System.out::println);

        Stream也很懒惰,它只会在你真正需要数据的时候才会把数据给传给你,在你不需要时它一个数据都不会产生。

 

       Lambda表达式的Best Practice

        1、保持Lambda表达式简短和一目了然

values.stream()
  .mapToInt(e -> {     
    int sum = 0;
    for(int i = 1; i <= e; i++) {
      if(e % i == 0) {
        sum += i;
      }
    }   
    return sum;
  })
  .sum());  //代码复杂难懂 

values.stream()
  .mapToInt(e -> sumOfFactors(e))
  .sum() //代码简洁一目了然

  长长的Lambda表达式通常是危险的,因为代码越长越难以读懂,意图看起来也不明,并且代码也难以复用,测试难度也大。

      2、使用@FunctionalInterface 注解

         如果你确定了某个interface是用于Lambda表达式,请一定要加上@FunctionalInterface,表明你的意图不然将来说不定某个不知情的家伙比如你旁边的好基友,在这个interface上面加了另外一个抽像方法时,你的代码就悲剧了。

      3、优先使用java.util.function包下面的函数式接口

         java.util.function 这个包下面提供了大量的功能性接口,可以满足大多数开发人员为lambda表达式和方法引用提供目标类型的需求。每个接口都是通用的和抽象的,使它们易于适应几乎任何lambda表达式。开发人员应该在创建新的功能接口之前研究这个包,避免重复定义接口。另外一点就是,里面的接口不会被别人修改~。

      4、不要在Lambda表达中执行有”副作用”的操作

        “副作用”是严重违背函数式编程的设计原则,在工作中我经常看到有人在forEach操作里面操作外面的某个List或者设置某个Map这其实是不对的。

      5、不要把Lambda表达式和匿名内部类同等对待

         虽然我们可以用匿名内部类来实现Lambda表达式,也可以用Lambda表达式来替换内部类,但并不代表这两者是等价的。这两者在某一个重要概念是不同的:this指代的上下文是不一样的。当您使用内部类时,它将创建一个新的范围。通过实例化具有相同名称的新局部变量,可以从封闭范围覆盖局部变量。您还可以在内部类中使用这个关键字作为它实例的引用。但是,lambda表达式可以使用封闭范围。您不能在lambda的主体内覆盖范围内的变量

private String value = "Enclosing scope value";

public String scopeExperiment() {
    Foo fooIC = new Foo() {
        String value = "Inner class value";
 
        @Override
        public String method(String string) {
            return this.value;
        }
    };
    String resultIC = fooIC.method("");
 
    Foo fooLambda = parameter -> {
        String value = "Lambda value";
        return this.value;
    };
    String resultLambda = fooLambda.method("");
 
    return "Results: resultIC = " + resultIC + 
      ", resultLambda = " + resultLambda;
}

   运行上面这段代码我们将到 resultIC = “Inner class value”,resultLambda = “Enclosing scope value”。也就是说在匿名内部类中this指的是自身的引用,在Lambda表达式中this指的是外部。

       6、多使用方法引用

       在Lambda表达式中 a -> a.toLowerCase()和String::toLowerCase都能起到相同的作用,但两者相比,后者通常可读性更高并且代码会简短。

       7、尽量避免在Lambda的方法体中使用{}代码块

       优先使用

Foo foo = parameter -> buildString(parameter);
private String buildString(String parameter) {
    String result = "Something " + parameter;
    //many lines of code
    return result;
}

     而不是

Foo foo = parameter -> { String result = "Something " + parameter; 
    //many lines of code 
    return result; 
};

  8、不要盲目的开启并行流

       Lambda的并行流虽好,但也要注意使用场景。如果平常的业务处理比如过滤,提取数据,没有涉及特别大的数据和耗时操作,则真的不需要开启并行流。我在工作中看到有些人一个只有几十个元素的列表的过滤操作也开启了并行流,其实这样做会更慢。因为多行线程的开启和同步这些花费的时间往往比你真实的处理时间要多很多。但一些耗时的操作比如I/O访问,DB查询,远程调用,这些如果可以并行的话,则开启并行流是可提升很大性能的。因为并行流的底层原理是fork/join,如果你的数据分块不是很好切分,也不建议开启并行流。举个例子ArrayList的Stream可以开启并行流,而LinkedList则不建议,因为LinkedList每次做数据切分要遍历整个链表,这本身就已经很浪费性能,而ArrayList则不会。