1. 程式人生 > >Java 8: Lambdas, Part 1

Java 8: Lambdas, Part 1

原文連結  作者:Ted Neward  譯者:趙峰

瞭解Java8 中的lambda表示式

對開發人員來說沒有什麼比自己選擇的語言或平臺釋出新版本更令人激動了。Java開發者也不例外。實際上,我們更期待新版本的釋出,有一部分原因是因為在不久前我們還在考慮Java的前途,因為Java的創造者——Sun在衰落。一次與死亡的擦肩而過會使人更加珍惜生命。但在這種情況下,我們的熱情來源不像以前釋出版本時那樣,這次是來源於事實。Java 8最終會獲得一些我們期待了幾十年的“現代”語言特性。

當然,Java 8主要的改變集中在lambdas(或者叫閉包),這也是這兩篇文章主要討論的內容。但是一個語言特性,就其本身而言它的出現除非其背後有一定的支援,如果它不實用或有趣。Java 7的幾個特點符合這種描述:例如,增強數值文字不能讓大多數人注意。

然而,這次不僅僅是作為Java 8函式式語言改變的一個核心部分,而且它們的引入帶來了一些能讓它們更易使用的附加語言特性,同樣一些包的改進也使那些特效能直接使用。這將能讓我們更容易的做一個Java開發者。

Java Magazine在之前發表過lambdas的文章,但寫這篇文章時可能比較之前的語法有修改了,並且並不是所有的人都有時間去上面閱讀。我將假設讀者從沒有讀過那篇文章。

注意:這篇文章是以即將釋出的Java SE 8為基礎的,同樣的,可能與最終釋出版本時有一點區別。因為在最終釋出之前語法與語義總會改變。

背景:功能函式

Java一直需要功能性物件(也可以稱為功能函式),雖然我們在社群中為淡化其的影響而一直掙扎。在Java的早些年,當我們建立GUI時,我們需要像視窗開啟、關閉、按鈕按下和滾動條移動這樣的響應使用者事件的程式碼塊。

在Java 1.0中,抽象視窗工具包(AWT)應用被期待像它的C++前輩一樣去擴充套件視窗類和覆蓋選擇的事件方法;這被認為是笨拙的和不可行的。所以在Java 1.1 Sun給我們一系列監聽介面,每一個介面對應一個或多個GUI事件方法。

CODE = OBJECT

程式碼=物件
隨著Java的成長和成熟,我們發現在很多地方我們把程式碼塊當做物件(資料)不僅很有用並且很必要。

但是為了更簡單的去寫這些類,必須實現這些介面和介面中相關連的方法。Sun給了我們內部類,其中匿名內部類可以在已存在的類的內部不需要特別命名而去實現一個類。(順便說一句,監聽事件並不是在Java歷史中唯一的例子。我們稍後會看到更“核心的”介面,例如:Runnable和Comparator。)

內部類對它們來說不管在語法還是語義上都有一些陌生。例如,決定內部類是靜態內部類或例項內部類,並不是由指定的關鍵字決定的(當然靜態內部類,可以用static關鍵字宣告),而是由例項被建立的語境決定的。實際情況中,Java開發者經常在面試中被問到Listing 1中所展示的錯誤。

Listing 1

class InstanceOuter {
  public InstanceOuter(int xx) { x = xx; }

  private int x;

  class InstanceInner {
    public void printSomething() {
      System.out.println("The value of x in my outer is " + x);
    }
  }
}

class StaticOuter {
  private static int x = 24;

  static class StaticInner {
    public void printSomething() {
      System.out.println("The value of x in my outer is " + x);
    }
  }
}

public class InnerClassExamples {
  public static void main(String... args) {
    InstanceOuter io = new InstanceOuter(12);

    // Is this a compile error?
    InstanceOuter.InstanceInner ii = io.new InstanceInner();

    // What does this print?
    ii.printSomething(); // prints 12

    // What about this?
    StaticOuter.StaticInner si = new StaticOuter.StaticInner();
    si.printSomething(); // prints 24
  }
}

像內部類這樣的“特點”一直讓Java開發者認為是適合面試而不是其它用途的Java角落裡的知識——除非我們用到它。即便如此,大多數時候它們只被用在事件處理上。

Above and Beyond超出本文範圍的內容

然而,隨著語法和語義的越來截止臃腫,系統仍在執行。隨著Java的成長和成熟,我們發現很多地方把程式碼塊當作物件(資料)並不僅僅是有用而且是必要的。在Java SE1.2修訂後的安全系統發現傳入一個程式碼塊在不同的安全上下文中執行非常有用。Java8 修改後的Collection類發現傳入一段程式碼塊順便去了解如何在排序的集合中排序是非常有用的。Swing發現傳一段程式碼塊順便去決定使用者開啟檔案時展示哪些檔案很有用,等等。它起作用——雖然它的語法讓人很不喜歡。

但是當函數語言程式設計要進入主流程式設計時,所有人都放棄了。雖然可行(參考這個非常完整的例子),無論如何,函數語言程式設計在Java中都是棘手的。Java需要成長和加入主流的程式語言,併為定義、傳遞、儲存後執行程式碼塊提供一流的語言支援。

Java8: Lambdas,目標型別和詞法作用域(Lexical Scoping)

Java 8 介紹了幾種新的語言特性目的是讓寫這樣的程式碼更加容易——其中最主要的是lambda表示式,通俗稱為閉包(原因我們以後更說)或者匿名方法。接下來讓我們一條一條解釋。

Lambda 表示式。從根本上說,lambda表示式只是簡單的實現稍後執行的方法。因此,當我們在Listing 2中定義一個Runnable,這個Runnable是用匿名內部類直接實現(這意味著需要寫很多行程式碼)。但是,Java 8中的lambda允許我們像Listing 3中那樣實現。

Listing 2

public class Lambdas {
  public static void main(String... args) {
    Runnable r = new Runnable() {
      public void run() {
        System.out.println("Howdy, world!");
      }
    };
    r.run();
  }
}


Listing 3

public static void main(String... args) {
    Runnable r2 = () -> System.out.println("Howdy, world!");
    r2.run();
  }

這兩種方法可以得到相同的結果:一個實現Runnable的物件,其中的run()方法被呼叫,並輸出結果。然而,在底層Java 8並不是僅僅實現了一個Runnable介面的匿名類——其中一些需要Java 7 中介紹的呼叫動態位元組碼。我們將不會去深入討論這方面的內容,但是你要知道這不是“僅僅”實現了一個匿名類介面。

函式式介面。Runnable介面、Callable<T>介面、Comparator<T>介面,和Java中定義的其它大量介面——在Java 8中我們稱為函式式介面:它們是隻需要實現一個方法去滿足需求的介面。這就是為什麼它實現起來很簡潔的原因,因為這樣你可以很確切的知道需要實現哪個方法。

Java 8 的設計者給了我們一個註釋,@FunctionalInterface,它被當作介面使用lambdas的一個文件提示,但是編譯器不需要這個——它決定了”功能性介面”是從介面的結構而來,而不是從註釋。

這一整篇文章,我們將會用Runnale和Comparator<T>介面作為例子,這不是因為它們有什麼特別之處,除了它們是單方法介面外。任何開發者任何時間可以定義一個新的介面,像下面的例子那樣,它都可以使用lambda實現。

interface Something {
  public String doit(Integer i);
}

Something介面是像Runnable和Comparator<T>那樣完全合法的功能性介面;我們將在看一些lambda例子後再分析這個。

語法。Java中的lambda本質上有三部分組成:一些引數加上括號,一個箭頭和實體,它可以是一個單獨的表示式或一塊程式碼。像Listing 2中的例子那樣,run不需要引數並且返回void,所以那個不需要引數和返回值。但是Listing 4中展示的Comparator<T>例子,符合上面的三個條件。Comparator需要兩個string並且需要返回integer型別的負值(小於)、正值(大於)和0(相等)。

Listing 4

public static void main(String... args) {
    Comparator<String> c = 
      (String lhs, String rhs) -> lhs.compareTo(rhs);
    int result = c.compare("Hello", "World");
  }

如果lambda本身需要多個表示式,則表示式可以被當做返回值呼叫,像其它的Java程式碼塊那樣(參考Listing 5)。

Listing 5

public static void main(String... args) {
    Comparator<String> c =
      (String lhs, String rhs) ->
        {
          System.out.println("I am comparing" +
                             lhs + " to " + rhs);
          return lhs.compareTo(rhs);
        };
    int result = c.compare("Hello", "World");
  }

(像Listing 5列出的花括號中的程式碼將會在未來幾年主導Java留言板和部落格。)lambda寫程式碼有幾個限制,其中大部分都很直觀——不能使用”break”或”continue”跳出lambda,並且如果lambda返回一個值,每一個程式碼路徑都要返回一個值或丟擲異常,等等。普通的方法也有類似的規則,所以不要大驚小怪。

推理型別。另一個被其它語言使用的概念是推理型別:編譯器應該足夠聰明去辨認出這個引數應該是什麼型別,而不是強制開發者是重新輸入引數。

就像Listing 5中的Comparator的例子。如果目標型別是Comparator<String>,傳入lambda中的型別就必須是string;否則程式碼將不能編譯。

在這種情況下在lhs和rhs前面再宣告String是完全多餘的,多謝Java 8增強了型別推斷機制,如Listing 6他們是完全可選的。

Listing 6

public static void main(String... args) {
    Comparator<String> c =
      (lhs, rhs) ->
        {
          System.out.println("I am comparing" +
                             lhs + " to " + rhs);
          return lhs.compareTo(rhs);
        };
    int result = c.compare("Hello", "World");
  }

語言規範中有準確的規則時,需要明確宣告lambda正式型別,但在大多數情況下它被當做預設的,而不是需要特別註明的,所以引數型別的宣告可能會被完全排除。

Java的lambda語法在Java史中一個有趣的影響是,我們發現不需要分配一個指定型別的引用物件(參考Listing 7)——至少不是沒有幫助。

Listing 7

public static void main4(String... args) {
    Object o = () -> System.out.println("Howdy, world!");
      // will not compile
  }

編譯器可能會抱怨Object不是一個功能性介面,儘管真正的原因是編譯器並不能理解這個lambda需要實現哪個功能性介面:Runnable或者是其它的?我們可以用一個例子來幫助編譯器,如Listing 8。

Listing 8

 public static void main4(String... args) {
    Object o = (Runnable) () -> System.out.println("Howdy, world!");
      // now we're all good
  }

從前lambda語法適用於任何介面,所以一個lambda可以很容易實現一個定製介面,像Listing 9。順便說一句,原始型別與它們的包裝型別在Lambda型別簽名中一樣。

Listing 9

 Something s = (Integer i) -> { return i.toString(); };
    System.out.println(s.doit(4));

再一次,這是真正新的東西;Java 8只是應用了Java的長期原則、模式和語法。如果還是明白,就花幾分鐘時間去探索下程式碼中的型別推理。

詞法作用域(Lexical scoping)。這是新的,對於編譯器在lambda和內部類中處理名稱的方式。參考在Listing 10內部類的例子。

Listing 10

class Hello {
  public Runnable r = new Runnable() {
      public void run() {
        System.out.println(this);
        System.out.println(toString());
      }
    };

  public String toString() {
    return "Hello's custom toString()";
  }
}

public class InnerClassExamples {
  public static void main(String... args) {
    Hello h = new Hello();
    h.r.run();
  }
}

當我執行Listing 10中的程式碼,在我們機器上會直接輸出“[email protected]”。原因很簡單:在匿名Runnable的實現中包括的this和toString是繫結在匿名內部類實現的,因為這是滿足要求的最內層範圍。

如果我們需要打印出Hello版本的toString,我們不得不明確使用Java規範中內部類的”outerthis”語法,如Listing 11。

Listing 11

class Hello {
  public Runnable r = new Runnable() {
      public void run() {
        System.out.println(Hello.this);
        System.out.println(Hello.this.toString());
      }
    };

  public String toString() {
    return "Hello's custom toString()";
  }
}

坦白的講,這是其中一點比起內部類解決的問題,它給我們製造了更多的困惑。當然,直到解釋this關鍵字出現在這不直觀的語法中的原因時發現它是有意義的,但是它的意義在於讓狡辯者找到藉口。

然而,Lambdas是語法作用域,意義是lambda辨認出它定義周圍的直接環境作為它的下一層作用域。所以Listing 12中的lambda例子會產生Listing 11中第二個例子的效果,但這種形式語法上更直觀。

Listing 12

class Hello {
  public Runnable r = () -> {
      System.out.println(this);
      System.out.println(toString());
    };

  public String toString() {
    return "Hello's custom toString()";
  }
}

順便說一句,這意味著this不是引用的lambda,這可能在某些情況下很有用——但是這種情況非常少。而且,如果這種情況出現了(例如,也許一個lambda需要返回一個lambda,並且要返回它自身),這裡有一個相對簡單的方法,我們稍後會講。

變數捕捉(Variable capture)。lambda被稱為是閉包的一部分原因是,一個函式文字(function literal)(比如我們之前寫過的)能夠“覆蓋(Close over)”在作用域內函式文字體之外的引用變數(對於Java,這通常是lambda的方法被定義)。內部類也能這樣做,但是所有令Java開發都失望的部分大都關於內部類,實際上,只能從作用域引用在它本身定義的頂部的”final”變數。

Lambda放寬了限制,但是隻是放寬了一點:只要引用變數還是”有效的final“,這就是意味著它還是final,這樣lambda可以引用它(例如Listing 13)。因為message在main內不會被修改,包括lambda被定義。這就是有效的final,並且,有資格被Runnable lambda儲存在r中。(譯者注:這裡的意思是message不會被修改,而不是不能被修改)

Listing 13

public static void main(String... args) {
    String message = "Howdy, world!";
    Runnable r = () -> System.out.println(message);
    r.run();
  }

從表面上看好像沒有什麼,但記住lambda語法規則並不改變Java的性質。總的來說,引用在lambda的定義之後,是可以被訪問和修改的,例如Listing 14。

Listing 14

public static void main(String... args) {
    StringBuilder message = new StringBuilder();
    Runnable r = () -> System.out.println(message);
    message.append("Howdy, ");
    message.append("world!");
    r.run();
  }

熟悉老版本內部類語法的精明開發者,應該記得這也是被內部類引用的真正的“final”引用——final只被應用於引用上,而不是引用另一邊的物件(譯者注:如果要在老版本的Java內部類中使用message,這個message就必須是final)。這個在Java社群中仍被視為一個bug或者特性,但目前就是這樣,並且,為了避免出錯,開發者應該理解Lambda是怎麼捕獲變數的。(實事上,這種行為並不是新的——這只是重做Java在減少輸入這樣已有的功能,和從程式設計器處得到更多的支援。)

方法引用。到目前為止,我們所有的lambda的例子都是匿名的——本質上,lambda需要在它使用的地方定義。這種對單一場景使用非常有幫助,但是對多場景使用用處不大。例如,下面的Person類(這時請忽略封裝)。

class Person {
  public String firstName;
  public String lastName;
  public int age;
});

如果把一個Person放到SortedSet中,或者它需要以某種形式排序,我們將需要不同的機制來決定Person怎麼排序——例如,有時是以firstName,有時會以lastName排序。這就是Comparator<T>的作用:允許我們通過傳入Comparator<T>一個例項來決定怎麼排序。

SCOPE IT OUT注意

Lambdas是作用域,意思是lambda會辨認出它定義周圍的直接環境作為它的下一層作用域。

Lambda能寫出比較簡單的排序程式碼,如Listing 15。但是,使用firstName排序Person物件,可能會在之後用到很多次,這現在這樣的程式碼無疑違反了不重複自己(Dont’t Repeat Yourself)原則。

Listing 15

 public static void main(String... args) {
    Person[] people = new Person[] {
      new Person("Ted", "Neward", 41),
      new Person("Charlotte", "Neward", 41),
      new Person("Michael", "Neward", 19),
      new Person("Matthew", "Neward", 13)
    };
    // Sort by first name
    Arrays.sort(people, 
      (lhs, rhs) -> lhs.firstName.compareTo(rhs.firstName));
    for (Person p : people)
      System.out.println(p);
  }

Comparator可以被當作Person,像Listing 16。然後,Comparator<T>也像其它靜態欄位一樣被引用,像Listing 17。我確信函數語言程式設計的狂熱愛好者非常喜歡這種方式,因為它允許以多種方式組合功能。

Listing 16

class Person {
  public String firstName;
  public String lastName;
  public int age;

  public final static Comparator<Person> compareFirstName =
    (lhs, rhs) -> lhs.firstName.compareTo(rhs.firstName);

  public final static Comparator<Person> compareLastName =
    (lhs, rhs) -> lhs.lastName.compareTo(rhs.lastName);

  public Person(String f, String l, int a) {
    firstName = f; lastName = l; age = a;
  }

  public String toString() {
    return "[Person: firstName:" + firstName + " " +
      "lastName:" + lastName + " " +
      "age:" + age + "]";
  }
}


Listing 17

 public static void main(String... args) {
    Person[] people = . . .;

    // Sort by first name
    Arrays.sort(people, Person.compareFirstName);
    for (Person p : people)
      System.out.println(p);
  }

但是,傳統的Java開發者會感覺很奇怪,與簡單的建立一個符合Comparator<T>的方法然後直接使用相反——這正是一個方法引用所允許的(如Listing 18)。注意用::形式,這告訴編譯器定義在Person裡的compareFirstNames在這裡應該被用到,而不是簡單的字面方法(method literal)。

Listing 18

class Person {
  public String firstName;
  public String lastName;
  public int age;

  public static int compareFirstNames(Person lhs, Person rhs) {
    return lhs.firstName.compareTo(rhs.firstName);
  }

  // ...
}

  public static void main(String... args) {
    Person[] people = . . .;
    // Sort by first name
    Arrays.sort(people, Person::compareFirstNames);
    for (Person p : people)
      System.out.println(p);
  }

對那些好奇的人來說,這是另一種使用的方法,我們可以使用compareFirstNames方法去建立一個Comparator<Person>例項,像下面這樣:

Comparator cf =    Person::compareFirstNames;

當然,還能然再簡潔,我們還可以通過使用一些新的包特性來完全避免一些語法開銷,利用高階的函式(意思是,更粗略,一個函式傳另一些函式)從根本上避免之前的一行一行的程式碼。

Arrays.sort(people, comparing(
  Person::getFirstName));

這就是函數語言程式設計技術為什麼那麼強大的一部分原因。

虛擬擴充套件方法然而,關於介面被提及的一個缺點是,它們沒有預設實現,既然當實現是非常明顯的時候。例如,假如有一個Relational介面,它定義了一系列假想的關係方法(大於,小於,大於或等於,等等)。只要其中的一個方法被定義,你就會發現其它的方法可以依據這個方法實現。實際上,如果提前知道Comparable<T>中的compare方法,所有的這些方法都可以通過compare方法實現。但是,介面不能有預設行為,並且抽象類也是一個類,Java只能實現單繼承。

然而,在Java 8中這樣的函式變的很普遍,它變的更加重要的原因是能夠指定預設行為沒失去介面的“介面性”。因此,Java 8現在介紹虛擬擴充套件方法(在之前的版本中被稱為保守方法),如果沒有派生的實現,本質上允許一個介面提供一個預設方法。

回想一下Iterator介面。現在它有三個方法(hasNext,next和remove),每一個都必須定義。但是,在iteration流中“跳躍”到下一個元素可能很有用。並且,因為Iterator的這個方法很容易利用其它三個方法實現,我們在Listing 19中提供了實現。

Listing 19

interface Iterator<T> {
  boolean hasNext();
  T next();
  void remove();

  void skip(int i) default {
    for (; i > 0 && hasNext(); i--) next();
  }
}

有一些可能會在Java社群中引起爭議,宣告這些是弱化介面的作用,並運用這種形式實現多繼承。在某種程度上就是這樣,特別是在預設實現的優先順序方面(如果一個類繼承了多個介面,並且相同的方法有不同的實現的情況)的規則需要大量的研究。

但是,正如它的名字暗示的一樣,虛擬擴充套件方法提供了一個強大的擴充套件己存在介面的機制,並且不需要在它的實現類中再去實現該方法。運用這樣的機制,Oracle可以為現有的包提供附加的、更強大的實現,而不需要開發者去逐一實現其下的類。沒有SkippingIterator類,現在開發都需要去尋找集合去提供支援。實際上,程式碼不需要修改任何地方,所有的Iterator<T>,不管什麼時候寫的,它將自動擁有這個行為。

通過虛擬擴充套件方法,在Collection類中將會發生很多的改變。好的訊息是你的Collection類將會得到很多新的方法,更好的訊息是你的程式碼在此期間並不需要做任何改變。不好的訊息是我們將在這個系列的另一篇文章中繼續討論。

總結

Lambdas能給Java帶來很多改變,包括怎麼樣寫和設計Java程式碼。其中的一些改變是函數語言程式設計帶來的,這將會改變Java程式設計師寫程式碼的方式——這是個機會也是個挑戰。

我們將在另一篇文章中更深入的討論這些改變給Java庫帶來的影響,並且我們將會花一些時間去討論這些新的API、介面和類去設計一些以前由於內部類的原因而不能去實現的方法。

Java 8是一個非常有趣的版本。繫好安全帶,這將是一次火箭式旅行。