【修煉內功】[Java8] Lambda表達式裏的"陷阱"

Lambdab表達式帶來的好處就不再做過多的介紹了,這裏重點介紹幾點,在使用Lambda表達式過程中可能遇到的"陷阱"

0x00 Effectively Final

在使用Lambda表達式的過程中,經常會遇到如下的問題

labmda1.png

圖中的sayWords爲什麼一定要是final類型,effectively final又是什麼?

但,如果改爲如下,貌似問題又解決了

labmda2.png

似乎,只要對sayWords不做變動就可以

如果將sayWords從方法體的變量提到類的屬性中,情況又會有變化,即使對sayWords有更改,也會編譯通過

labmda3.png

 

難道,就是因爲局部變量和類屬性的區別?

在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 VariablesInstance Variables在JVM內存中的區別

Local VariablesThread存儲在Stack棧內存中,而Instance Variables則隨Instance存儲在Heap堆內存中

  • Local Variables的回收取決於變量的作用域,程序的運行一旦超出變量的作用域,該內存空間便被立刻回收另作他用
  • Instance Variables的回收取決於引用數,當再沒有引用的時候,便會在一個"合適"的時間被JVM垃圾回收器回收

試想,如果Lambda表達式引用了局部變量,並且該Lambda表達式是在另一個線程中執行,那在某種情況下該線程則會在該局部變量被收回後(函數執行完畢,超出變量作用域)被使用,顯然這樣是不正確的;但如果Lambda表達式引用了類變量,則該類(屬性)會增加一個引用數,在線程執行完之前,引用數不會歸爲零,也不會觸發JVM對其的回收操作

但這解釋不了圖2的情況,同樣是局部變量,只是未對sayWords做改動,也是可以通過編譯的,這裏便要介紹effectively final

Baeldung大神的博文中有這樣一段話

Accessing a non-final variable inside lambda expressions will cause the compile-time error. But it doesn’t mean that you should mark every target variable as final.

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是一致的

Effectively Final更深入的解釋,可以參考Why Do Local Variables Used in Lambdas Have to Be Final or Effectively Final?

小結:

  1. Lambda表達式中可以直接引用Instance Variables
  2. Lambda表達式中引用Local Variables,必須爲finaleffectively finalassigned only once)

0x01 Throwing Exception

Java的異常分爲兩種,受檢異常(Checked Exception)和非受檢異常(Unchecked Exception)

Checked Exception, the exceptions that are checked at compile time. If some code within a method throws a checked exception, then the method must either handle the exception or it must specify the exception using throws keyword.

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…catch進行捕獲處理,或者使用throws語句表明該方法可能拋出受檢異常,由調用方進行捕獲處理,而非受檢異常則不用。受檢異常的處理是強制的,在編譯時檢測。

lambda-exption-1.jpg

在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爲受檢異常,該段將會程序編譯失敗

lambda-exption-2.jpg

按照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());
        }
    };
}

但出乎意料,程序依然編譯失敗

lambda-exption-4.jpg

查看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…catch,有沒有優雅的方式?或許可以從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

小結:

  1. Lambda表達式拋出非受檢異常,可以在Lambda表達式內部或外部直接使用try…catch捕獲處理
  2. Lambda表達式拋出受檢異常,可以在Lambda表達式內部直接使用try…catch捕獲處理,如果需要在Lambda表達式外部捕獲處理,必須在FunctionalInterface接口上顯式聲明throws

0x02 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表達式時所處的類),而在匿名類中它在詞法上綁定到匿名類

Java語言規範在15.27.2描述了這種行爲

Unlike code appearing in anonymous class declarations, the meaning of names and the 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

小結:

  1. Lambda表達式中,this在詞法上綁定到周圍的類 (定義該Lambda表達式時所處的類)
  2. 匿名類中,this在詞法上綁定到匿名類
  3. 匿名類中,如果需要引用周圍類this,需要使用qualified this

0x03 其他

在排查問題的時候,查看異常棧是必不可少的一種方法,其會記錄異常出現的詳細記錄,包括類名、方法名行號等等信息

那,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表達式中的"陷阱"不僅限於此,也希望大家能夠一起來討論

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章