【修煉內功】[Java8] Lambda表示式裡的"陷阱"
本文已收錄【修煉內功】躍遷之路
Lambdab表示式帶來的好處就不再做過多的介紹了,這裡重點介紹幾點,在使用Lambda表示式過程中可能遇到的"陷阱"
Effectively Final
在使用Lambda表示式的過程中,經常會遇到如下的問題
圖中的 sayWords
為什麼一定要是 final
型別, effectively
final又是什麼?
但,如果改為如下,貌似問題又解決了
似乎,只要對 sayWords
不做變動就可以
如果將 sayWords
從方法體的變數提到類的屬性中,情況又會有變化,即使對 sayWords
有更改,也會編譯通過
難道,就是因為區域性變數和類屬性的區別?
在Java 8 in Action一書中有這樣一段話
You may be asking yourself why local variables have these restrictions. First, there’s a key difference in how instance and local variables are implemented behind the scenes. Instance variables are stored on the heap, whereas local variables live on the stack. If a lambda could access the local variable directly and the lambda were used in a thread, then the thread using the lambda could try to access the variable after the thread that allocated the variable had deallocated it. Hence, Java implements access to a free local variable as access to a copy of it rather than access to the original variable. This makes no difference if the local variable is assigned to only once—hence the restriction. Second, this restriction also discourages typical imperative programming patterns (which, as we explain in later chapters, prevent easy parallelization) that mutate an outer variable.
首先,要理解 Local Variables
和 Instance Variables
在JVM記憶體中的區別
Local Variables
隨 Thread
儲存在 Stack
棧記憶體中,而 Instance Variables
則隨 Instance
儲存在 Heap
堆記憶體中
Local Variables Instance Variables
試想,如果Lambda表示式引用了局部變數,並且該Lambda表示式是在另一個執行緒中執行,那在 某種情況下 該執行緒則會在該區域性變數被收回後(函式執行完畢,超出變數作用域)被使用,顯然這樣是不正確的;但如果Lambda表示式引用了類變數,則該類(屬性)會增加一個引用數,線上程執行完之前,引用數不會歸為零,也不會觸發JVM對其的回收操作
但這解釋不了圖2的情況,同樣是區域性變數,只是未對 sayWords
做改動,也是可以通過編譯的,這裡便要介紹 effectively final
Baeldung
大神的博文中有這樣一段話
According to the “ effectively final ” concept, a compiler treats every variable as final, as long as it is assigned only once.
It is safe to use such variables inside lambdas because the compiler will control their state and trigger a compile-time error immediately after any attempt to change them.
其中提到了 assigned only once ,字面理解便是隻賦值了一次,對於這種情況,編譯器便會 treats variable as final ,對於只賦值一次的區域性變數,編譯器會將其認定為 effectively final
,其實對於 effectively final
的區域性變數,Lambda表示式中引用的是其副本,而該副本的是不會發生變化的,其效果就和 final
是一致的
Throwing Exception
Java的異常分為兩種,受檢異常(Checked Exception)和非受檢異常(Unchecked Exception)
Unchecked Exception, the exceptions that are not checked at compiled time. It is up to the programmers to be civilized, and specify or catch the exceptions.
簡單的講,受檢異常必須使用 try…cache
進行捕獲處理,或者使用 throws
語句表明該方法可能丟擲受檢異常,由呼叫方進行捕獲處理,而非受檢異常則不用。受檢異常的處理是強制的,在編譯時檢測。
在Lambda表示式內部丟擲異常,我們該如何處理?
Unchecked Exception
首先,看一段示例
public class Exceptional { public static void main(String[] args) { Stream.of(3, 8, 5, 6, 0, 2, 4).forEach(lambdaWrapper(i -> System.out.println(15 / i))); } private static Consumer<Integer> lambdaWrapper(IntConsumer consumer) { return i -> consumer.accept(i); } }
該段程式碼是可以編譯通過的,但執行的結果是
> 5 > 1 > 3 > 2 > Exception in thread "main" java.lang.ArithmeticException: / by zero at Exceptional.lambda$main$0(Exceptional.java:13) at Exceptional.lambda$lambdaWrapper$1(Exceptional.java:17) at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948) at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:580) at Exceptional.main(Exceptional.java:13)
由於Lambda內部計算時,由於除數為零丟擲了ArithmeticException異常,導致流程中斷,為了解決此問題可以在 lambdaWrapper
函式中加入try…catch
private static Consumer<Integer> lambdaWrapper(IntConsumer consumer) { return i -> { try { consumer.accept(i); } catch (ArithmeticException e) { System.err.println("Arithmetic Exception occurred : " + e.getMessage()); } }; }
再次執行
> 5 > 1 > 3 > 2 > Arithmetic Exception occurred : / by zero > 7 > 3
對於Lambda內部非受檢異常,只需要使用try…catch即可,無需做過多的處理
Checked Exception
同樣,一段示例
public class Exceptional { public static void main(String[] args) { Stream.of(3, 8, 5, 6, 0, 2, 4).forEach(lambdaWrapper(i -> writeToFile(i))); } private static Consumer<Integer> lambdaWrapper(IntConsumer consumer) { return i -> consumer.accept(i); } private static void writeToFile(int integer) throws IOException { // logic to write to file which throws IOException } }
由於 IOException
為受檢異常,該段將會程式編譯失敗
按照Unchecked Exception一節中的思路,我們在 lambdaWrapper
中使用try…catch處理異常
private static Consumer<Integer> lambdaWrapper(IntConsumer consumer) { return i -> { try { consumer.accept(i); } catch (IOException e) { System.err.println("IOException Exception occurred : " + e.getMessage()); } }; }
但出乎意料,程式依然編譯失敗
檢視 IntConsumer
定義,其並未對介面 accept
宣告異常
@FunctionalInterface public interface IntConsumer { /** * Performs this operation on the given argument. * * @param value the input argument */ void accept(int value); }
為了解決此問題,我們可以自己定義一個聲明瞭異常 的ThrowingIntConsumer
@FunctionalInterface public interface ThrowingIntConsumer<E extends Exception> { /** * Performs this operation on the given argument. * * @param value the input argument * @throws E */ void accept(int value) throws E; }
改造程式碼如下
private static Consumer<Integer> lambdaWrapper(ThrowingIntConsumer<IOException> consumer) { return i -> { try { consumer.accept(i); } catch (IOException e) { System.err.println("IOException Exception occurred : " + e.getMessage()); } }; }
但,如果我們希望在出現異常的時候終止流程,而不是繼續執行,可以在獲取到受檢異常後丟擲非受檢異常
private static Consumer<Integer> lambdaWrapper(ThrowingIntConsumer<IOException> consumer) { return i -> { try { consumer.accept(i); } catch (IOException e) { throw new RuntimeException(e.getMessage(), e.getCause()); } }; }
所有使用了 ThrowingIntConsumer
的地方都需要寫一遍try…cache,有沒有優雅的方式?或許可以從 ThrowingIntConsumer
下手
@FunctionalInterface public interface ThrowingIntConsumer<E extends Exception> { /** * Performs this operation on the given argument. * * @param value the input argument * @throws E */ void accept(int value) throws E; /** * @return a IntConsumer instance which wraps thrown checked exception instance into a RuntimeException */ default IntConsumer uncheck() { return i -> { try { accept(i); } catch (final E e) { throw new RuntimeException(e.getMessage(), e.getCause()); } }; } }
我們在 ThrowingIntConsumer
中定義了一個預設函式 uncheck
,其內部會自動呼叫Lambda表示式,並在捕獲到異常後將其轉為非受檢異常並重新丟擲
此時,我們便可以將 lambdaWrapper
函式優化如下
private static Consumer<Integer> lambdaWrapper(ThrowingIntConsumer<IOException> consumer) { return i -> consumer.accept(i).uncheck(); }
unCheck
會將 IOException
異常轉為 RuntimeException
丟擲
有沒有更優雅一些的方式?由於篇幅原因不再過多介紹,感興趣的可以參考 throwing-function 及 Vavr
this
pointer
Java中,類(匿名類)中都可以使用 this
,Lambda表示式也不例外
public class ThisPointer { public static void main(String[] args) { ThisPointer thisPointer = new ThisPointer("manerfan"); new Thread(thisPointer.getPrinter()).start(); } private String name; @Getter private Runnable printer; public ThisPointer(String name) { this.name = name; this.printer = () -> System.out.println(this); } @Override public String toString() { return "hello " + name; } }
在 ThisPointer
類的建構函式中,使用Lambda表示式定義了 printer
屬性,並重寫了類的 toString
方法
執行後結果
> hello manerfan
ThisPointer
類的建構函式中,將 printer
屬性的定義改為匿名類
public class ThisPointer { public static void main(String[] args) { ThisPointer thisPointer = new ThisPointer("manerfan"); new Thread(thisPointer.getPrinter()).start(); } private String name; @Getter private Runnable printer; public ThisPointer(String name) { this.name = name; this.printer = new Runnable() { @Override public void run() { System.out.println(this); } }; } @Override public String toString() { return "hello " + name; } }
重新執行後結果
> ThisPointer$1@782b1823
可見,Lambda表示式及匿名類中的 this
指向的並不是同一記憶體地址
這裡我們需要理解,在Lambda表示式中它在詞法上繫結到 周圍的類 (定義該Lambda表示式時所處的類),而在匿名類中它在詞法上繫結到 匿名類</u>
Java語言規範在 15.27.2 描述了這種行為
this
and
super
keywords appearing in a lambda body, along with the accessibility of referenced declarations, are the same as in the surrounding context (except that lambda parameters introduce new names).
The transparency of this (both explicit and implicit) in the body of a lambda expression – that is, treating it the same as in the surrounding context – allows more flexibility for implementations, and prevents the meaning of unqualified names in the body from being dependent on overload resolution.
Practically speaking, it is unusual for a lambda expression to need to talk about itself (either to call itself recursively or to invoke its other methods), while it is more common to want to use names to refer to things in the enclosing class that would otherwise be shadowed (this, toString()). If it is necessary for a lambda expression to refer to itself (as if via this), a method reference or an anonymous inner class should be used instead.
那,如何在匿名類中如何做到Lambda表示式的效果,獲取到 周圍類 的 this
呢?這時候就必須使用 qualified this 了,如下
public class ThisPointer { public static void main(String[] args) { ThisPointer thisPointer = new ThisPointer("manerfan"); new Thread(thisPointer.getPrinter()).start(); } private String name; @Getter private Runnable printer; public ThisPointer(String name) { this.name = name; this.printer = new Runnable() { @Override public void run() { System.out.println(ThisPointer.this); } }; } @Override public String toString() { return "hello " + name; } }
執行結果如下
> hello manerfan
其他
在排查問題的時候,檢視異常棧是必不可少的一種方法,其會記錄異常出現的詳細記錄,包括類名、方法名行號等等資訊
那,Lambda表示式中的異常棧資訊是如何的?
public class ExceptionStack { public static void main(String[] args) { new ExceptionStack().run(); } private Function<Integer, Integer> divBy100 = divBy(100); void run() { Stream.of(1, 7, 0, 6).filter(this::isEven).map(this::div).forEach(System.out::println); } boolean isEven(int i) { return 0 == i / 2; } int div(int i) { return divBy100.apply(i); } Function<Integer, Integer> divBy(int div) { return i -> div / i; } }
這裡我們故意製造了一個 ArithmeticException
,並且增加了異常的棧深,執行後的異常資訊如下
Exception in thread "main" java.lang.ArithmeticException: / by zero at ExceptionStack.lambda$divBy$0(ExceptionStack.java:30) at ExceptionStack.div(ExceptionStack.java:26) at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193) at java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:175) at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948) at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481) at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471) at java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151) at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174) at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234) at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418) at ExceptionStack.run(ExceptionStack.java:18) at ExceptionStack.main(ExceptionStack.java:12)
異常資訊中的 ExceptionStack.lambda$divBy$0
ReferencePipeline$3$1.accept
等並不能讓我們很快地瞭解,具體是類中哪個方法出現了問題,此類問題在很多程式語言中都存在,也希望JVM有朝一日可以徹底解決
關於Lambda表示式中的"陷阱"不僅限於此,也希望大家能夠一起來討論