30 分鐘 Java Lambda 入門教程

程式碼之道_程式設計之法發表於2016-08-10

Lambda簡介

Lambda作為函數語言程式設計中的基礎部分,在其他程式語言(例如:Scala)中早就廣為使用,但在Java領域中發展較慢,直到java8,才開始支援Lambda。

拋開數學定義不看,直接來認識Lambda。Lambda表示式本質上是匿名方法,其底層還是通過invokedynamic指令來生成匿名類來實現。它提供了更為簡單的語法和寫作方式,允許你通過表示式來代替函式式介面。在一些人看來,Lambda就是可以讓你的程式碼變得更簡潔,完全可以不使用——這種看法當然沒問題,但重要的是lambda為Java帶來了閉包。得益於Lamdba對集合的支援,通過Lambda在多核處理器條件下對集合遍歷時的效能提高極大,另外我們可以以資料流的方式處理集合——這是非常有吸引力的。

Lambda語法

Lambda的語法極為簡單,類似如下結構:

(parameters) -> expression

或者

(parameters) -> { statements; }

Lambda表示式由三部分組成:

  1. paramaters:類似方法中的形參列表,這裡的引數是函式式介面裡的引數。這裡的引數型別可以明確的宣告也可不宣告而由JVM隱含的推斷。另外當只有一個推斷型別時可以省略掉圓括號。
  2. ->:可理解為“被用於”的意思
  3. 方法體:可以是表示式也可以程式碼塊,是函式式介面裡方法的實現。程式碼塊可返回一個值或者什麼都不反回,這裡的程式碼塊塊等同於方法的方法體。如果是表示式,也可以返回一個值或者什麼都不反回。

我們通過以下幾個示例來做說明:

//示例1:不需要接受引數,直接返回10
()->10

//示例2:接受兩個int型別的引數,並返回這兩個引數相加的和
(int x,int y)->x+y;

//示例2:接受x,y兩個引數,該引數的型別由JVM根據上下文推斷出來,並返回兩個引數的和
(x,y)->x+y;

//示例3:接受一個字串,並將該字串列印到控制到,不反回結果
(String name)->System.out.println(name);

//示例4:接受一個推斷型別的引數name,並將該字串列印到控制檯
name->System.out.println(name);

//示例5:接受兩個String型別引數,並分別輸出,不反回
(String name,String sex)->{System.out.println(name);System.out.println(sex)}

//示例6:接受一個引數x,並返回該該引數的兩倍
x->2*x

Lambda用在哪裡

在[函式式介面][1]中我們知道Lambda表示式的目標型別是函式性介面——每一個Lambda都能通過一個特定的函式式介面與一個給定的型別進行匹配。因此一個Lambda表示式能被應用在與其目標型別匹配的任何地方,lambda表示式必須和函式式介面的抽象函式描述一樣的引數型別,它的返回型別也必須和抽象函式的返回型別相容,並且他能丟擲的異常也僅限於在函式的描述範圍中。

接下來,我們看一個自定義的函式式介面示例:

  @FunctionalInterface
  interface Converter<F, T>{

      T convert(F from);

}

首先用傳統的方式來使用該介面:

  Converter<String ,Integer> converter=new Converter<String, Integer>() {
            @Override
            public Integer convert(String from) {
                return Integer.valueOf(from);
            }
        };

       Integer result = converter.convert("200");
        System.out.println(result);

很顯然這沒任何問題,那麼接下里就是Lambda上場的時刻,用Lambda實現Converter介面:

Converter<String ,Integer> converter=(param) -> Integer.valueOf(param);
        Integer result = converter.convert("101");
        System.out.println(result);

通過上例,我想你已經對Lambda的使用有了個簡單的認識,下面,我們在用一個常用的Runnable做演示:

在以前我們可能會寫下這種程式碼:

new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello lambda");
            }
        }).start();

在某些情況下,大量的匿名類會讓程式碼顯得雜亂無章。現在可以用Lambda來使它變得簡潔:

new Thread(() -> System.out.println("hello lambda")).start();

方法引用

方法引用是Lambda表示式的一個簡化寫法。所引用的方法其實是Lambda表示式的方法體的實現,其語法結構為:

ObjectRef::methodName

左邊可以是類名或者例項名,中間是方法引用符號”::”,右邊是相應的方法名。方法引用被分為三類:

1. 靜態方法引用

在某些情況下,我們可能寫出這樣的程式碼:

public class ReferenceTest {
    public static void main(String[] args) {
        Converter<String ,Integer> converter=new Converter<String, Integer>() {
            @Override
            public Integer convert(String from) {
                return ReferenceTest.String2Int(from);
            }
        };
        converter.convert("120");

    }

    @FunctionalInterface
    interface Converter<F,T>{
        T convert(F from);
    }

    static int String2Int(String from) {
        return Integer.valueOf(from);
    }
}

這時候如果用靜態引用會使的程式碼更加簡潔:

 Converter<String, Integer> converter = ReferenceTest::String2Int;
 converter.convert("120");

2. 例項方法引用

我們也可能會寫下這樣的程式碼:

public class ReferenceTest {
    public static void main(String[] args) {

        Converter<String, Integer> converter = new Converter<String, Integer>() {
            @Override
            public Integer convert(String from) {
                return new Helper().String2Int(from);
            }
        };
        converter.convert("120");
    }

    @FunctionalInterface
    interface Converter<F, T> {
        T convert(F from);
    }

    static class Helper {
        public int String2Int(String from) {
            return Integer.valueOf(from);
        }
    }
}

同樣用例項方法引用會顯得更加簡潔:

  Helper helper = new Helper();
  Converter<String, Integer> converter = helper::String2Int;
  converter.convert("120");

3. 構造方法引用

現在我們來演示構造方法的引用。首先我們定義一個父類Animal:

    class Animal{
        private String name;
        private int age;

        public Animal(String name, int age) {
            this.name = name;
            this.age = age;
        }

       public void behavior(){

        }
    }

接下來,我們在定義兩個Animal的子類:Dog、Bird

public class Bird extends Animal {

    public Bird(String name, int age) {
        super(name, age);
    }

    @Override
    public void behavior() {
        System.out.println("fly");
    }
}

class Dog extends Animal {

    public Dog(String name, int age) {
        super(name, age);
    }

    @Override
    public void behavior() {
        System.out.println("run");
    }
}

隨後我們定義工廠介面:

    interface Factory<T extends Animal> {
        T create(String name, int age);
    }

接下來我們還是用傳統的方法來建立Dog類和Bird類的物件:

        Factory factory=new Factory() {
            @Override
            public Animal create(String name, int age) {
                return new Dog(name,age);
            }
        };
        factory.create("alias", 3);
        factory=new Factory() {
            @Override
            public Animal create(String name, int age) {
                return new Bird(name,age);
            }
        };
        factory.create("smook", 2);

僅僅為了建立兩個物件就寫了十多號程式碼,現在我們用建構函式引用試試:

  Factory<Animal> dogFactory =Dog::new;
  Animal dog = dogFactory.create("alias", 4);

  Factory<Bird> birdFactory = Bird::new;
  Bird bird = birdFactory.create("smook", 3);

這樣程式碼就顯得乾淨利落了。通過Dog::new這種方式來穿件物件時,Factory.create函式的簽名選擇相應的造函式。

Lambda的域以及訪問限制

域即作用域,Lambda表示式中的引數列表中的引數在該Lambda表示式範圍內(域)有效。在作用Lambda表示式內,可以訪問外部的變數:區域性變數、類變數和靜態變數,但操作受限程度不一。

訪問區域性變數

在Lambda表示式外部的區域性變數會被JVM隱式的編譯成final型別,因此只能訪問外而不能修改。

public class ReferenceTest {
    public static void main(String[] args) {

        int n = 3;
        Calculate calculate = param -> {
            //n=10; 編譯錯誤
            return n + param;
        };
        calculate.calculate(10);
    }

    @FunctionalInterface
    interface Calculate {
        int calculate(int value);
    }

}

訪問靜態變數和成員變數

在Lambda表示式內部,對靜態變數和成員變數可讀可寫。

public class ReferenceTest {
    public int count = 1;
    public static int num = 2;

    public void test() {
        Calculate calculate = param -> {
            num = 10;//修改靜態變數
            count = 3;//修改成員變數
            return n + param;
        };
        calculate.calculate(10);
    }

    public static void main(String[] args) {

    }

    @FunctionalInterface
    interface Calculate {
        int calculate(int value);
    }

}

Lambda不能訪問函式介面的預設方法

java8增強了介面,其中包括介面可新增default關鍵詞定義的預設方法,這裡我們需要注意,Lambda表示式內部不支援訪問預設方法。

Lambda實踐

在[函式式介面][2]一節中,我們提到java.util.function包中內建許多函式式介面,現在將對常用的函式式介面做說明。

Predicate介面

輸入一個引數,並返回一個Boolean值,其中內建許多用於邏輯判斷的預設方法:

    @Test
    public void predicateTest() {
        Predicate<String> predicate = (s) -> s.length() > 0;
        boolean test = predicate.test("test");
        System.out.println("字串長度大於0:" + test);

        test = predicate.test("");
        System.out.println("字串長度大於0:" + test);

        test = predicate.negate().test("");
        System.out.println("字串長度小於0:" + test);

        Predicate<Object> pre = Objects::nonNull;
        Object ob = null;
        test = pre.test(ob);
        System.out.println("物件不為空:" + test);
        ob = new Object();
        test = pre.test(ob);
        System.out.println("物件不為空:" + test);
    }

Function介面

接收一個引數,返回單一的結果,預設的方法(andThen)可將多個函式串在一起,形成複合Funtion(有輸入,有輸出)結果,

    @Test
    public  void functionTest() {
        Function<String, Integer> toInteger = Integer::valueOf;
        //toInteger的執行結果作為第二個backToString的輸入
        Function<String, String> backToString = toInteger.andThen(String::valueOf);
        String result = backToString.apply("1234");
        System.out.println(result);

        Function<Integer, Integer> add = (i) -> {
            System.out.println("frist input:" + i);
            return i * 2;
        };
        Function<Integer, Integer> zero = add.andThen((i) -> {
            System.out.println("second input:" + i);
            return i * 0;
        });

        Integer res = zero.apply(8);
        System.out.println(res);
    }

Supplier介面

返回一個給定型別的結果,與Function不同的是,Supplier不需要接受引數(供應者,有輸出無輸入)

    @Test
    public void supplierTest() {
        Supplier<String> supplier = () -> "special type value";
        String s = supplier.get();
        System.out.println(s);
    }

Consumer介面

代表了在單一的輸入引數上需要進行的操作。和Function不同的是,Consumer沒有返回值(消費者,有輸入,無輸出)

    @Test
    public void consumerTest() {
        Consumer<Integer> add5 = (p) -> {
            System.out.println("old value:" + p);
            p = p + 5;
            System.out.println("new value:" + p);
        };
        add5.accept(10);
    }

以上四個介面的用法代表了java.util.function包中四種型別,理解這四個函式式介面之後,其他的介面也就容易理解了,現在我們來做一下簡單的總結:

Predicate用來邏輯判斷,Function用在有輸入有輸出的地方,Supplier用在無輸入,有輸出的地方,而Consumer用在有輸入,無輸出的地方。你大可通過其名稱的含義來獲知其使用場景。

Stream

Lambda為java8帶了閉包,這一特性在集合操作中尤為重要:java8中支援對集合物件的stream進行函式式操作,此外,stream api也被整合進了collection api,允許對集合物件進行批量操作。

下面我們來認識Stream。

Stream表示資料流,它沒有資料結構,本身也不儲存元素,其操作也不會改變源Stream,而是生成新Stream.作為一種運算元據的介面,它提供了過濾、排序、對映、規約等多種操作方法,這些方法按照返回型別被分為兩類:凡是返回Stream型別的方法,稱之為中間方法(中間操作),其餘的都是完結方法(完結操作)。完結方法返回一個某種型別的值,而中間方法則返回新的Stream。中間方法的呼叫通常是鏈式的,該過程會形成一個管道,當完結方法被呼叫時會導致立即從管道中消費值,這裡我們要記住:Stream的操作儘可能以“延遲”的方式執行,也就是我們常說的“懶操作”,這樣有助於減少資源佔用,提高效能。對於所有的中間操作(除sorted外)都是執行在延遲模式下。

Stream不但提供了強大的資料操作能力,更重要的是Stream既支援序列也支援並行,並行使得Stream在多核處理器上有著更好的效能。

Stream的使用過程有著固定的模式:

  1. 建立Stream
  2. 通過中間操作,對原始Stream進行“變化”並生成新的Stream
  3. 使用完結操作,生成最終結果
    也就是
建立——>變化——>完結

Stream的建立

對於集合來說,可以通過呼叫集合的stream()或者parallelStream()來建立,另外這兩個方法也在Collection介面中實現了。對於陣列來說,可以通過Stream的靜態方法of(T … values)來建立,另外,Arrays也提供了有關stream的支援。

除了以上基於集合或者陣列來建立Stream,也可以通過Steam.empty()建立空的Stream,或者利用Stream的generate()來建立無窮的Stream。

下面我們以序列Stream為例,分別說明Stream幾種常用的中間方法和完結方法。首先建立一個List集合:

List<String> lists=new ArrayList<String >();
        lists.add("a1");
        lists.add("a2");
        lists.add("b1");
        lists.add("b2");
        lists.add("b3");
        lists.add("o1");

中間方法

過濾器(Filter)

結合Predicate介面,Filter對流物件中的所有元素進行過濾,該操作是一箇中間操作,這意味著你可以在操作返回結果的基礎上進行其他操作。

    public static void streamFilterTest() {
        lists.stream().filter((s -> s.startsWith("a"))).forEach(System.out::println);

        //等價於以上操作
        Predicate<String> predicate = (s) -> s.startsWith("a");
        lists.stream().filter(predicate).forEach(System.out::println);

        //連續過濾
        Predicate<String> predicate1 = (s -> s.endsWith("1"));
        lists.stream().filter(predicate).filter(predicate1).forEach(System.out::println);
    }

排序(Sorted)

結合Comparator介面,該操作返回一個排序過後的流的檢視,原始流的順序不會改變。通過Comparator來指定排序規則,預設是按照自然順序排序。

     public static void streamSortedTest() {
        System.out.println("預設Comparator");
        lists.stream().sorted().filter((s -> s.startsWith("a"))).forEach(System.out::println);

        System.out.println("自定義Comparator");
        lists.stream().sorted((p1, p2) -> p2.compareTo(p1)).filter((s -> s.startsWith("a"))).forEach(System.out::println);

    }

對映(Map)

結合Function介面,該操作能將流物件中的每個元素對映為另一種元素,實現元素型別的轉換。

    public static void streamMapTest() {
        lists.stream().map(String::toUpperCase).sorted((a, b) -> b.compareTo(a)).forEach(System.out::println);

        System.out.println("自定義對映規則");
        Function<String, String> function = (p) -> {
            return p + ".txt";
        };
        lists.stream().map(String::toUpperCase).map(function).sorted((a, b) -> b.compareTo(a)).forEach(System.out::println);

    }

在上面簡單介紹了三種常用的操作,這三種操作極大簡化了集合的處理。接下來,介紹幾種完結方法:

完結方法

“變換”過程之後,需要獲取結果,即完成操作。下面我們來看相關的操作:

匹配(Match)

用來判斷某個predicate是否和流物件相匹配,最終返回Boolean型別結果,例如:

    public static void streamMatchTest() {
        //流物件中只要有一個元素匹配就返回true
        boolean anyStartWithA = lists.stream().anyMatch((s -> s.startsWith("a")));
        System.out.println(anyStartWithA);
        //流物件中每個元素都匹配就返回true
        boolean allStartWithA
                = lists.stream().allMatch((s -> s.startsWith("a")));
        System.out.println(allStartWithA);
    }

收集(Collect)

在對經過變換之後,我們將變換的Stream的元素收集,比如將這些元素存至集合中,此時便可以使用Stream提供的collect方法,例如:

    public static void streamCollectTest() {
        List<String> list = lists.stream().filter((p) -> p.startsWith("a")).sorted().collect(Collectors.toList());
        System.out.println(list);

    }

計數(Count)

類似sql的count,用來統計流中元素的總數,例如:

    public static void streamCountTest() {
        long count = lists.stream().filter((s -> s.startsWith("a"))).count();
        System.out.println(count);
    }

規約(Reduce)

reduce方法允許我們用自己的方式去計算元素或者將一個Stream中的元素以某種規律關聯,例如:

    public static void streamReduceTest() {
        Optional<String> optional = lists.stream().sorted().reduce((s1, s2) -> {
            System.out.println(s1 + "|" + s2);
            return s1 + "|" + s2;
        });
    }

執行結果如下:

a1|a2
a1|a2|b1
a1|a2|b1|b2
a1|a2|b1|b2|b3
a1|a2|b1|b2|b3|o1

並行Stream VS 序列Stream

到目前我們已經將常用的中間操作和完結操作介紹完了。當然所有的的示例都是基於序列Stream。接下來介紹重點戲——並行Stream(parallel Stream)。並行Stream基於Fork-join並行分解框架實現,將大資料集合切分為多個小資料結合交給不同的執行緒去處理,這樣在多核處理情況下,效能會得到很大的提高。這和MapReduce的設計理念一致:大任務化小,小任務再分配到不同的機器執行。只不過這裡的小任務是交給不同的處理器。

通過parallelStream()建立並行Stream。為了驗證並行Stream是否真的能提高效能,我們執行以下測試程式碼:

首先建立一個較大的集合:

   List<String> bigLists = new ArrayList<>();
        for (int i = 0; i < 10000000; i++) {
            UUID uuid = UUID.randomUUID();
            bigLists.add(uuid.toString());
        }

測試序列流下排序所用的時間:

    private static void notParallelStreamSortedTest(List<String> bigLists) {
        long startTime = System.nanoTime();
        long count = bigLists.stream().sorted().count();
        long endTime = System.nanoTime();
        long millis = TimeUnit.NANOSECONDS.toMillis(endTime - startTime);
        System.out.println(System.out.printf("序列排序: %d ms", millis));

    }

測試並行流下排序所用的時間:

    private static void parallelStreamSortedTest(List<String> bigLists) {
        long startTime = System.nanoTime();
        long count = bigLists.parallelStream().sorted().count();
        long endTime = System.nanoTime();
        long millis = TimeUnit.NANOSECONDS.toMillis(endTime - startTime);
        System.out.println(System.out.printf("並行排序: %d ms", millis));

    }

結果如下:

序列排序: 13336 ms
並行排序: 6755 ms

看到這裡,我們確實發現效能提高了約麼50%,你也可能會想以後都用parallel Stream不久行了麼?實則不然,如果你現在還是單核處理器,而資料量又不算很大的情況下,序列流仍然是這種不錯的選擇。你也會發現在某些情況,序列流的效能反而更好,至於具體的使用,需要你根據實際場景先測試後再決定。

懶操作

上面我們談到Stream儘可能以延遲的方式執行,這裡通過建立一個無窮大的Stream來說明:

首先通過Stream的generate方法來一個自然數序列,然後通過map變換Stream:

 //遞增序列
  class NatureSeq implements Supplier<Long> {
        long value = 0;

        @Override
        public Long get() {
            value++;
            return value;
        }
    }

  public  void streamCreateTest() {
        Stream<Long> stream = Stream.generate(new NatureSeq());
        System.out.println("元素個數:"+stream.map((param) -> {
            return param;
        }).limit(1000).count());

    }

執行結果為:

元素個數:1000

我們發現開始時對這個無窮大的Stream做任何中間操作(如:filter,map等,但sorted不行)都是可以的,也就是對Stream進行中間操作並生存一個新的Stream的過程並非立刻生效的(不然此例中的map操作會永遠的執行下去,被阻塞住),當遇到完結方法時stream才開始計算。通過limit()方法,把這個無窮的Stream轉為有窮的Stream。

相關文章