转载

【修炼内功】[Java8] Lambda表达式里的"陷阱"

本文已收录【修炼内功】跃迁之路

【修炼内功】[Java8] Lambda表达式里的"陷阱"

Lambdab表达式带来的好处就不再做过多的介绍了,这里重点介绍几点,在使用Lambda表达式过程中可能遇到的"陷阱"

Effectively Final

在使用Lambda表达式的过程中,经常会遇到如下的问题

【修炼内功】[Java8] Lambda表达式里的"陷阱"

图中的 sayWords 为什么一定要是 final 类型, effectively final又是什么?

但,如果改为如下,貌似问题又解决了

【修炼内功】[Java8] Lambda表达式里的"陷阱"

似乎,只要对 sayWords 不做变动就可以

如果将 sayWords 从方法体的变量提到类的属性中,情况又会有变化,即使对 sayWords 有更改,也会编译通过

【修炼内功】[Java8] Lambda表达式里的"陷阱"

难道,就是因为局部变量和类属性的区别?

在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

试想,如果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 是一致的

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…cache 进行捕获处理,或者使用 throws 语句表明该方法可能抛出受检异常,由调用方进行捕获处理,而非受检异常则不用。受检异常的处理是强制的,在编译时检测。

【修炼内功】[Java8] Lambda表达式里的"陷阱"

在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 为受检异常,该段将会程序编译失败

【修炼内功】[Java8] Lambda表达式里的&quot;陷阱&quot;

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

但出乎意料,程序依然编译失败

【修炼内功】[Java8] Lambda表达式里的&quot;陷阱&quot;

查看 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 描述了这种行为

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

其他

在排查问题的时候,查看异常栈是必不可少的一种方法,其会记录异常出现的详细记录,包括类名、方法名行号等等信息

那,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表达式中的"陷阱"不仅限于此,也希望大家能够一起来讨论

原文  https://segmentfault.com/a/1190000018857239
正文到此结束
Loading...