1. 程式人生 > >小碼哥教育Android學院

小碼哥教育Android學院

引入

我們都知道java是一門面向物件的語言,我們也清楚面向物件思想的強大。但是,這個概念,在java8出現之後,被推翻了。
java8引入了函式式開發,其實C語言就是標準的函式式開發。 那我們就更疑惑了,為什麼如此好的一種思想,還會在後期被推翻了呢?
其實原因很簡單,隨著網際網路的發展,越來越重視並行開發和基於事件的開發,而函式式開發,就特別擅長做這些事情。

什麼是函數語言程式設計

函數語言程式設計的起源,是一門叫做範疇論(Category Theory)的數學分支。什麼是範疇呢?

“範疇就是使用箭頭連線的物體。”(In mathematics, a category is an algebraic structure that comprises “objects” that are linked by “arrows”. )
也就是說,彼此之間存在某種關係的概念、事物、物件等等,都構成”範疇”。隨便什麼東西,只要能找出它們之間的關係,就能定義一個”範疇”。
範疇論使用函式,表達範疇之間的關係。
伴隨著範疇論的發展,就發展出一整套函式的運算方法。這套方法起初只用於數學運算,後來有人將它在計算機上實現了,就變成了今天的”函數語言程式設計”。
本質上,函數語言程式設計只是範疇論的運算方法,跟數理邏輯、微積分、行列式是同一類東西,都是數學方法,只是碰巧它能用來寫程式。
函式式是一種數學運算,原始目的就是求值

,不做其他事情,否則就無法滿足函式運演算法則了。

環境準備

如果還沒有安裝Java 8,那麼你應該先安裝才能使用lambda和stream。 如果是使用Eclipse工具,那麼需要安裝4.4以上的版本。之後,就可以使用Java 8的新特性了,包括lambda表示式,可重複的註解,緊湊的概要檔案和其他特性。

面向物件程式設計

我們先使用面向物件程式設計,來完成兩個小案例。
1. 對陣列中的物件進行排序
2. 建立一個執行緒,並開啟執行緒。

class User{  
    public String name;  
    public int score;  
    public User(String name, int score) {  
        super();  
        this.name = name;  
        this.score = score;  
    }  
    @Override  
    public String toString() {  
        return "User [name=" + name + ", score=" + score + "]";  
    }  
}    
@Test  
public void arrayComparableTest(){  
    User[] us = new User[]{new User("張三", 10), new User("李四", 15), new User("王五", 12)};
    Arrays.sort(us, new Comparator<User>() {  
        // 對陣列中的元素進行排序
        @Override
        public int compare(User o1, User o2) {  
            return Integer.compare(o1.score, o2.score);  
        }  
    });  
    System.out.println(Arrays.toString(us));  
}    
@Test
public void threadTest(){  
    Thread t = new Thread(new Runnable() {

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

其實,上面兩個案例,都是體現的函數語言程式設計。方法中需要另外一個方法來實現業務邏輯。因為java中之前沒有函數語言程式設計,所以想用另外一個方法來實現邏輯,只能通過匿名介面的方式。

現在,我們對這段程式碼進行分析:

    User[] us = new User[]{new User("張三", 10), new User("李四", 15), new User("王五", 12)};
    Arrays.sort(us, new Comparator<User>() {  
        // 對陣列中的元素進行排序
        @Override
        public int compare(User o1, User o2) {  
            return Integer.compare(o1.score, o2.score);  
        }  
    });    
  1. sort方法,第二個引數,一定是一個Comparator介面。
  2. Comparator介面,一定需要實現一個compare方法。
  3. compare方法,一定需要返回一個int型別的結果。
    那我們就有疑問了,這些一定會發生的事情,編譯器是否可以幫助我們做,幫助我們推導出來結果呢?
    假設,編譯器可以幫助我們推導,那麼,我們就可以將程式碼改成下面的樣子。
    我們,將一定會發生的事情,刪除掉,讓編譯器去推導。

    User[] us = new User[]{new User("張三", 10), new User("李四", 15), new User("王五", 12)};
    Arrays.sort(us, 
            (User o1, User o2) {
            return Integer.compare(o1.score, o2.score);
    
    });
    System.out.println(Arrays.toString(us));
    

此時,語法不正確,程式碼當然編譯不能通過。那我們該如何處理呢?之前有講到一個概念,範疇論。使用箭頭來連線物體。所以,這裡提出一種新的語法: -> ,指向某處的意思(goes to)。

    User[] us = new User[]{new User("張三", 10), new User("李四", 15), new User("王五", 12)};
    Arrays.sort(us, 
            (User o1, User o2) -> {
            return Integer.compare(o1.score, o2.score);

    });
    System.out.println(Arrays.toString(us));

不負眾望,編譯通過了。編譯通過,說明語法沒問題。
那這種新的語法是什麼呢?這個,就是Lambda表示式

面向函數語言程式設計

有沒有覺得很厲害很神奇?
其實不然,回過頭,我們去對比一下,發現,其實,使用Lambda表示式,就是將我們分析出來的,一定、必不可少的東西刪除了,讓編譯器自己去推匯出結果。從一定需要程式設計師來書寫的東西,交給了編譯器而已。
所以說,儘管我們使用了一種簡潔的方式去寫,但是,編譯器也能推匯出來,程式碼在編譯的時候,也會推導成我們最開始的寫法,其實Lambda表示式是新特性嗎?不是,僅僅是編譯器的新特性,解放程式設計師而已。

但是,有問題。我發現,感覺也沒之前的寫法方便多少呀,而且我還要記新的語法。
其實,我們還可以對上面的程式碼進行優化,優化的原則就是:將一定需要做的事情,簡化掉。

    Arrays.sort(us, 
            (User o1, User o2) -> {
            return Integer.compare(o1.score, o2.score);

    });

這已經是一個Lambda表示式了。那我們再來分析,還有什麼地方是一定要做的呢?
1. compare方法中只有一行程式碼,並且,這一行程式碼,一定會返回一個int型別的值。那麼,我們去刪除它。

Arrays.sort(us, (User o1, User o2)-> Integer.compare(o1.score, o2.score));

編譯通過。
2. Compare方法,有兩個引數,這兩個引數的型別,可以通過傳遞的陣列來推導元素型別,一定是User型別。那麼,我們刪除它。

Arrays.sort(us, (o1, o2)-> Integer.compare(o1.score, o2.score));

編譯通過。

到這裡,我們在來比較一下:

    Arrays.sort(us, new Comparator<User>() {

        @Override
        public int compare(User o1, User o2) {
            return Integer.compare(o1.score, o2.score);
        }
    });
    //Arrays.sort(us, (o1, o2)-> Integer.compare(o1.score, o2.score));

對於程式碼的簡潔程度來說,高下立判。
好,我們快速的將第二個案例修改一下。

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

Lambda表示式的含義

表示式的含義是什麼?表示式就是一個組合,可以求得結果。Lambda表示式,首先是一個表示式,那麼,代表著這個表示式在運算之後,也會有一個結果的。因為我們已經很清楚的為函式式定義過:函式式是一種數學運算,原始目的就是求值,不做其他事情
所以,現在我們可以嘗試,將Lambda表示式作為一個結果,賦值給一個變數

 Runnable r = new Runnable(){
    public void run(){
        System.out.println("hello lambda");
    }
}
// 將上面的程式碼,用Lambda表示式去修改。

Runnable r1 = () -> System.out.println("hello lambda");

當編譯器看到這一段Lambda表示式的時候,就會去做推導,根據接受變數的型別,來推匯出Lambda的常規寫法

Lambda表示式詳解

概念

我們先看一下,百度百科對Lambda表示式的定義:
“Lambda 表示式”(lambda expression)是一個匿名函式,Lambda表示式基於數學中的λ演算得名,直接對應於其中的lambda抽象(lambda abstraction),是一個匿名函式,即沒有函式名的函式。Lambda表示式可以表示閉包(注意和數學傳統意義上的不同)。

我們將帶有引數變數的表示式,就稱之為Lambda表示式

Lambda表示式基本語法

Lambda表示式的基本語法:方法引數列表 -> 表示式; 引數列表,是介面的方法引數列表,表示式,是介面方法的具體邏輯實現。

方法引數列表

  1. 方法沒有引數
    這個案例其實我們已經做過了,建立一個執行緒,傳遞一個Runnable物件即可。
    沒有引數,需要注意的是,引數列表的 () 是一定不能省略的。因為括號才表示是方法的引數列表。

    new Thread(() -> System.out.println("沒有引數")).start();
    
  2. 方法有一個引數
    先引入一個案例:

    Frame f = new Frame("lambda");
    f.setBounds(100, 100, 100, 300);
    Button b = new Button("按鈕");
    b.addActionListener((ActionEvent event) -> System.out.println("按鈕被點選"));
    f.setVisible(true);
    

    如果是一個引數,需要注意:

    1. 如果引數寫型別,那麼,必須寫方法的()
    2. 如果引數不寫型別(編譯器可以推匯出來引數的型別),那麼,可以省略方法的()
      所以,我們還可以這麼寫:

      Frame f = new Frame("lambda");
      f.setBounds(100, 100, 100, 300);
      Button b = new Button("按鈕");
      b.addActionListener(event -> System.out.println("按鈕被點選"));
      f.setVisible(true);
      
  3. 方法有兩個或多個引數
    如果是兩個或者多個引數,那麼需要注意,不管是否有引數型別,都必須有()

    Integer[] arr = new Integer[]{1,5,3,4};
    Arrays.sort(arr, (x, y) -> Integer.compare(x, y));
    System.out.println(Arrays.toString(arr));
    
  4. 如果有其他的修飾符修飾
    比如,可以用final修飾區域性變數,此時,如果要寫額外的修飾符的時候,引數必須得有型別。不管是一個引數還是多個引數。

    Integer[] arr = new Integer[]{1,5,3,4};
    Arrays.sort(arr, (final Integer x, final Integer y) -> Integer.compare(x, y));
    System.out.println(Arrays.toString(arr));
    

表示式

  1. 如果表示式只有一行程式碼
    可以不需要{},直接寫,不能寫return關鍵字。編譯器幫助我們推導return。
  2. 如果表示式超過一行
    那麼這一段程式碼,就必須寫{},就必須遵循方法的規則,並且如果方法有返回,就必須有返回語句。

變數作用域

在Lambda表示式中,可以存在三種變數。
1. 區域性變數
2. 方法引數
3. 自由變數(不是區域性變數,也不是方法引數)

我們分析這三種變數,其實對於區域性變數和方法引數變數,跟我們普通的方法使用規則都是一樣的。這裡就不介紹了。我們要說兩個比較特殊的情況:
比如下面的這一段程式碼

@Test
public void test1(){
    print("lambda", 5);
}
public void print(String content, int times){
    new Thread(()-> {
        for (int i = 0; i < times; i++) {
            System.out.println(content);
        }
    }).start();
}
  1. 自由變數,在Lambda表示式中,可以隨意使用自由變數。比如,我們可以在run方法中使用到外層方法的引數。
    其實這種寫法,編譯器在編譯的時候,會推導成常規的寫法,常規的我們會如何寫?我們會使用final修飾content 和 times,為什麼嗎?因為print方法可能執行完畢之後,子執行緒才會執行,那麼如果沒有用final修飾content,方法執行完畢之後,這個區域性變數就會被回收,相當於是執行緒中訪問了一塊已經被回收的記憶體,所以我們使用final修飾,將區域性變數放置到常量池中儲存起來。
    那Lambda中,可以直接使用content變數,我們可以如何推斷?其實編譯器已經預設將自由變數用final修飾,儲存起來了。我們只能使用這個變數,不能修改final變數。
  2. this,在匿名內部類中,如果我們使用this,那麼,這個this其實是匿名內部類對自己的引用。但是,在Lambda表示式中的this,卻是指向的該Lambda表示式所在方法,這個方法所在的類的引用。這一點需要跟以前的情況區分開。
  3. 操作自由變數的程式碼塊,我們就稱之為“閉包”

函式式介面

想想我們寫的幾個案例,使用的都是介面,比如Runnable、Comparator、ActionListener等,發現,這些介面都只有一個抽象方法。那麼,我們可以用Lambda寫一個介面,介面中有多個方法嗎?
不可以,我們能夠寫Lambda表示式的地方,只能是介面,並且這個介面中只能有一個抽象方法。這種介面,我們就稱之為函式式介面
思考兩個問題:
1. 為什麼Lambda表示式中,介面不能有多個方法?
其實,要回答這個問題,也很容易。Lambda主要是如何執行的?編譯器推匯出常規的寫法來執行的。那麼,大家可以試想一下,如果一個介面中包含多個方法,那麼,編譯器可以很容易的推導麼?當然不可以!
2. 我們可不可以自定義一個介面,然後,只要在有介面引數的地方,就用Lambda表示式來寫?可以!

    @Test
    public void test5(){
        this.test4(()->System.out.println("my work"));
    }
    public void test4(IWork work){
        System.out.println("begin work");
        work.doWork();
        System.out.println("end work");
    }

    interface IWork {
        void doWork();
    }
  1. 在java中,如果介面中只有一個抽象方法,那麼這個介面就是函式式介面。我們可以使用註解@FunctionalInterface 來檢測這個介面是否是函式式介面。
  2. @FunctionalInterface註解只是用來檢測,並不能定義某個介面是否是函式式介面,函式式介面主要是看介面的方法數。
  3. 簡化函式式介面的使用,是Lambda表示式出現的唯一作用
  4. 回過頭,我們在看一下這一句程式碼。

    this.test4(()->System.out.println("my work"));
    

好像,我們可以用一個介面變數來接收Lambda表示式的結果。所以,程式碼變成了這樣:

IWork work = ()->System.out.println("my work");
this.test4(work);

那麼,問題來了,

Runnable r = ()->System.out.println("my work");

這樣去申明一個Runnable介面,也是可以的。 那,如果我僅僅這麼寫

()->System.out.println("my work");

這個Lambda表示式,到底是用什麼型別的介面去接收呢?
所以,結論是什麼呢?結論就是,單靠Lambda表示式,是不能知道它的返回型別的,必須通過接收的變數型別,去推導這個Lambda表示式是否符合規範。而不能從Lambda表示式推匯出變數型別

  1. 函式式介面中,是可以寫Object中的方法的,因為介面也是一個特殊的類,重寫Object中的方法,不影響函式式介面
  2. Lambda表示式中的異常處理, 要麼就是在Lambda的程式碼塊中自行try,要麼是在介面中的方法中申明throws。

方法引用

引入

我們還是看之前寫過的一個案例。

    Integer[] arr = new Integer[]{1,5,3,4};
    Arrays.sort(arr, (x, y) -> Integer.compare(x, y));
    System.out.println(Arrays.toString(arr));

如何分析這一段程式碼呢?我們發現,方法需要兩個引數 x/y,而這兩個引數,原封不動的交給了Integer的compare方法的引數列表。那我們就有一個推論,既然這種情況出現,那麼可不可以通過某種方式,讓編譯器自己去推導常規寫法呢?
這裡,我們引入一種新的語法結構 :: ,如果Lambda表示式中,介面方法的實現,只調用其他方法來真正完成邏輯,並且介面方法的引數原封不動的傳遞給了那個完成邏輯的方法,那麼我們就可以使用這種語法
我們還修正一下上面的程式碼。

    Integer[] arr = new Integer[]{1,5,3,4};
    Arrays.sort(arr, Integer::compare);// 兩個引數,原封不動的傳遞給了compare方法
    System.out.println(Arrays.toString(arr));

這種寫法,我們就稱之為方法引用

方法引用詳解

在Lambda表示式中,支援三種方法引用:
1. 類 :: 靜態方法
2. 物件 :: 普通方法
3. 物件 :: 靜態方法
對於靜態方法,第一種和第三種其實是一樣的,因為儘管用物件去呼叫靜態方法,其實編譯器還是使用的類去訪問。

類 :: 靜態方法, 我們講解的案例,就是這種情況。
那現在我們來研究一下,物件 :: 普通方法
案例一:
我們對上面的程式碼進行改造:

@Test
public void test3(){
    Integer[] arr = new Integer[]{1,5,3,4};
    LambdaTest test = new LambdaTest();
    // 物件 :: 普通方法
    Arrays.sort(arr, test::myCompare);
    System.out.println(Arrays.toString(arr));
}
public int myCompare(int x, int y){
    return Integer.compare(x, y);
}

程式碼寫到這裡,立馬就會發現一個問題,為啥我們還要去建立一個物件呢?在類的普通方法裡面,this就代表當前物件。所以,程式碼,我們還可以這麼寫:

@Test
public void test3(){
    Integer[] arr = new Integer[]{1,5,3,4};
    // 物件 :: 普通方法
    Arrays.sort(arr, this::myCompare);
    System.out.println(Arrays.toString(arr));
}
public int myCompare(int x, int y){
    return Integer.compare(x, y);
}

另外,我們花半分鐘的時間,將這個普通方法改成靜態方法,再用Lambda表示式寫一遍。

@Test
public void test3(){
    Integer[] arr = new Integer[]{1,5,3,4};
    // 類 :: 靜態方法
    Arrays.sort(arr, LambdaTest::myCompare);
    System.out.println(Arrays.toString(arr));
}
public static int myCompare(int x, int y){
    System.out.println("一些其他的邏輯程式碼");
    return Integer.compare(x, y);
}

案例二:
疑問,我們都是呼叫的系統的方法,那麼我們是否可以自己定義一個介面,用Lambda表示式來寫呢?

// 定義的函式式介面
@FunctionalInterface
public interface IWork {
    void work(int x, int y);// 介面方法沒有返回值。
}

public class lambdatest {
    public void wrap(IWork work){
        System.out.println("some work");
        work.work(3, 4);
    }
    @Test
    public void test1(){
        this.wrap(this::getSum);// 使用物件來呼叫普通方法,注意,這個方法是有返回值的。
    }
    public int getSum(int x, int y){
        System.out.println(x + y);
        return x + y;
    }
}

對於這個案例,我們分析一下這個介面方法返回值和呼叫的普通方法返回值的問題。
1. 如果介面方法有返回值,我們使用方法引用,那麼引用的方法,也必須有對應的返回值。因為介面方法需要return語句,僅僅是我們沒有寫出來而已,編譯器會推導一個return語句。
2. 如果介面方法沒有返回值,那麼,引用的普通方法,返回值型別就隨意定
要想這個問題其實也很簡單。比如

// 情況一
public void run(){
    // 如果這個是一個介面方法,這個介面方法裡面可以呼叫有返回的普通方法,允許。
    otherMethod();
}

// 情況二
public int run1(){
    // 如果這個是一個介面方法,這個介面方法呼叫普通方法,普通方法必須有對應的return語句才行
    return otherMethod();
}

案例三
java8中,List集合為我們提供了一個forEach方法,從字面意思,我們就能猜出,這個方法是用來遍歷元素的,這個方法需要傳遞一個介面Consumer,Consumer就是一個函式式介面。所以,我們完成以下,遍歷集合,列印元素。

@Test
public void forEach(){
    Integer[] arr = new Integer[]{1,2,3,4,5};
    List<Integer> ls = Arrays.asList(arr);
    // 既然是函式式介面,那麼我們就可以使用Lambda表示式來書寫
    ls.forEach(x -> System.out.println(x));
}

我們分析這一個Lambda表示式

ls.forEach(x -> System.out.println(x));

這句表示式的含義是,Consumer介面的方法中需要一個引數,我們將這個引數直接傳遞給了列印操作。將引數原封不動的傳遞給某一個方法,我們也可以嘗試的使用方法引用來改造程式碼。如何修改呢?
記住語法格式: 物件 :: 普通方法
println 是一個普通的方法,沒有用static修飾,所以,我們需要用物件呼叫,那物件又是誰呢? System.out就是一個列印流物件。所以,我們可以將程式碼修改成這樣:

ls.forEach(System.out::println);

這,也是我們以後使用Lambda表示式做列印操作,最常用的寫法。

構造器引用

引入

其實,構造器,也是一個特殊的方法,但是這個方法,我們是結合關鍵字new一起使用的。比如,建立一個物件,我們就 new 類名(引數列表);
現在我們有這種使用場景:
當我們呼叫一個方法,返回的是一個介面物件的時候,一般,我們必須要返回這個介面物件的實現類物件,因為介面不能建立物件,那麼,我們之前怎麼做的呢?

// Arrays工具類中的as List 方法。
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

但是,這個方法是返回一個固定的ArrayList,如果我們期望,可以指定新增到一個集合物件中,比如這裡,我希望可以將元素新增到LinkedList中,那我們怎麼做?我們是不是應該在定義方法的時候,就要顯示的將要建立的物件的型別傳遞給方法。所以,我們需要這麼定義。

public static <T> List<T> asList(Class clz, T ... a){
    // 通過反射機制,來建立傳遞過來的類的物件,然後再將元素新增到這個物件中,進行返回。
}

但是這樣寫,程式碼耦合度太高,我們為了模組之間的解耦,可以使用一個介面來實現這種方式

// 申明泛型為List 或者其子類型別
public interface IMyCreator<T extends List<?>>{
    T create();
}


// 測試類
public class ListTest {
    // 傳入一個介面型別,通過介面方法,返回一個類物件,再將元素新增到這個物件中,返回回去
    @SuppressWarnings("unchecked")
    public <T> List<T> myAsList(IMyCreator<List<T>> create, T ... a){
        List<T> list = create.create();
        for (T t : a){
            list.add(t);
        }
        return list;
    }
    @Test
    public void add(){
        // 這裡就使用了Lambda表示式,介面引數列表為空,介面中的方法具體邏輯就是返回一個ArrayList
        List<Integer> list = this.myAsList(()->new ArrayList<Integer>(), 1, 2, 3, 4);
        System.out.println(list);
    }
}

分析這一段程式碼:

this.myAsList(()->new ArrayList<Integer>(), 1, 2, 3, 4);// 這已經是一個Lambda表示式了

這種場景,就是一個介面中定義一個方法,返回一個物件。那麼,在Lambda表示式中,這種情況,我們就稱之為構造器引用,語法格式是: 類 :: new
我們可以使用構造器引用的方式,來改造上面的Lambda表示式:

this.myAsList(ArrayList<Integer>::new, 1, 2, 3, 4);// 建立物件的時候,指定泛型

構造器引用細節

  1. 構造器引用的函式式介面,這個介面中的方法不能有引數
  2. Lambda表示式需要返回的物件,這個物件必須要有一個無參的構造器
    如果不滿足這兩點,我們就不能使用構造器引用,只能使用普通的Lambda表示式,比如:

    this.myAsList((x)->new MyArrayList<Integer>(x), 1, 2, 3, 4);// 只能使用簡單的Lambda表示式
    

Lambda表示式的優點

Lambda表示式,看起來挺先進,其實經過我們的分析,還是由編譯器來幫助我們推導成為常規程式碼。所以,很多人都覺得不建議使用Lambda表示式,因為他們覺得,如此寫程式碼,感覺儘管簡單,但是難懂,難以除錯,不利於維護。
是嗎?不是的,Lambda表示式,他的一些缺點,掩蓋不了它的優點。
1. 程式碼簡潔,開發快速
函數語言程式設計大量使用函式,減少了程式碼的重複,因此程式比較短,開發速度較快。
2. 接近自然語言,易於理解
函數語言程式設計的自由度很高,可以寫出很接近自然語言的程式碼。
3. 更方便的程式碼管理
函數語言程式設計不依賴、也不會改變外界的狀態,只要給定輸入引數,返回的結果必定相同。因此,每一個函式都可以被看做獨立單元,很有利於進行單元測試(unit testing)和除錯(debugging),以及模組化組合。
4. 易於”併發程式設計”
函數語言程式設計不需要考慮”死鎖”(deadlock),因為它不修改變數,所以根本不存在”鎖”執行緒的問題。不必擔心一個執行緒的資料,被另一個執行緒修改,所以可以很放心地把工作分攤到多個執行緒,部署”併發程式設計”(concurrency)。
5. 程式碼的熱升級
函數語言程式設計沒有副作用,只要保證介面不變,內部實現是外部無關的。所以,可以在執行狀態下直接升級程式碼,不需要重啟,也不需要停機。