30

深入理解 Java 中的 Lambda

 5 years ago
source link: https://www.oschina.net/translate/understanding-java-lambdas?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

我花了相当多的阅读和编码时间才最终理解Java Lambdas如何在概念上正常工作的。我阅读的大多数教程和介绍都遵循自顶向下的方法,从用例开始,最后以概念性问题结束。在这篇文章中,我想提供一个自下而上的解释,从其他已建立的Java概念中推导出Lambdas的概念。

首先介绍下方法的类型化,这是支持方法作为一流公民的先决条件。基于此,Lambdas的概念是被以匿名类用法的进化和特例提出的。所有这一切都通过实现和使用 高阶函数映射 来说明。

这篇文章的主要受众是那些已掌握函数式编程基础的人,以及那些想从概念上理解Lambdas如何嵌入Java语言的人。

方法类型

从Java 8起方法就是 一等公民 了。 按照标准的定义,编程语言中的一等公民是一个具有下列功能的实体,

  • 可以作为参数进行传递,

  • 可以作为方法的返回值

  • 可以赋值给一个变量.

在Java中,每一个参数、返回值或变量都是有类型的,因此每个一等公民都必须是有类型的。 Java中的一种类型可以是以下内容之一:

  • 一种内建类型 (比如 int 或者 double)

  • 一个类 (比如ArrayList)

  • 一个接口 (比如 Iterable)

方法是通过接口进行定义类型的。 它们不隐式的实现特定接口,但是在必要的时候,如果一个方法符合一个接口,那么在编译期间,Java编译器会对其进行隐式的检查。 举个例子说明:

class LambdaMap {
    static void oneStringArgumentMethod(String arg) {
        System.out.println(arg);
    }
}

关于oneStringArgumentMethod函数的类型,与之相关的有:它的的函数是静态的,返回类型是void,它接受一个String类型的参数。一个静态函数符合包含一个apply函数的接口,apply函数的签名相应地符合这个静态函数的签名。oneStringArgumentMethod函数对应的接口因此必须符合下列标准。

  • 它必须包含一个名为apply的函数。

  • 函数返回类型必须是void。

  • 函数必须接受一个String类型可以转换到的对象的参数。

在符合这个标准的接口之中,下面的这个是最明确的:

interface OneStringArgumentInterface {
    void apply(String arg);
}

利用这个接口,函数可以分配给一个变量:

OneStringArgumentInterface meth = LambdaMap::oneStringArgumentMethod;

用这种方法使用接口作为类型,函数可以借此被分配给变量,传递参数并且从函数返回:

static OneStringArgumentInterface getWriter() {
    return LambdaMap::oneStringArgumentMethod;
}

static void write(OneStringArgumentInterface writer, String msg) {
    writer.apply(msg);
}

最终函数是一等公民。

泛型函数类型

就像使用集合一样,泛型为函数类型增加了大量的功能和灵活性。实现功能上的算法而不考虑类型相关信息,泛型函数类型使其变为可能。在对map函数的实现中,会在下面用到这种功能。

在这提供的OneStringArgumentInterface一个泛型版本:

interface OneArgumentInterface<T> {
    void apply(T arg);
}

OneStringArgumentInterface函数可以被分配给它:

OneArgumentInterface<String> meth = LambdaMap::oneStringArgumentMethod;

通过使用泛型函数类型,它现在可以以一种通用的方法实现算法,就像它在集合中使用的一样:

static <T> void applyArgument(OneArgumentInterface<T> meth, T arg) {
    meth.apply(arg);
}

上面的函数并没有什么用,然而它至少可以提出一个想法:对函数作为第一个类成员的支持怎样可以形成非常简洁且灵活的代码:

applyArgument(Lambda::oneStringArgumentMethod, "X");

实现map

在诸多高阶函数中,map是最经典的. map的第一个参数是函数,该函数可以接收一个参数并返回一个值;第二个参数是值列表. map使用传入的函数处理值列表的每一项,然后返回一个新的值列表。下面Python的代码片段,可以很好的说明map的用法:

>>> map(math.sqrt, [1, 4, 9, 16])
[1.0, 2.0, 3.0, 4.0]

在本节的后续内容中,将给出该函数的Java实现。Java 8已经通过Stream提供了该函数。因为主要出于教学目的,所以,本节中给出的实现特意保持简单,仅限于List对象使用。

与Python不同, 在Java中 必须 首先考虑map第一个参数的类型:一个可以接收一个参数并返回一个值的方法。参数的类型和返回值的类型可以不同。下面接口符合这个预期,显然,I表示参数(入参),O表示返回值(出参):

interface MapFunction<I, O> {
    O apply(I in);
}

泛型map方法的实现,变得惊人的简单明了:

static <I, O> List<O> map(MapFunction<I, O> func, List<I> input) {
    List<O> out = new ArrayList<>();

    for (I in : input) {
        out.add(func.apply(in));
    }

    return out;
}
  1. 创建新的返回值列表out(用于保存O类型的对象).

  2. 通过遍历input,func处理列表的每一项,并将返回值添加到out中。

  3. 返回out.

下面是实际使用map方法的实例:

MapFunction<Integer, Double> func = Math::sqrt;

List<Double> output = map(func, Arrays.asList(1., 4., 9., 16.));
System.out.println(output);

在Python one-liner的推动下,可以用更简洁的方法表达:

System.out.println(map(Math::sqrt, Arrays.asList(1., 4., 9., 16.)));

Java毕竟不是Python...

Lambdas来了!

读者可能会注意到,还没有提到Lambdas。这是由于采用了“自下而上”的方式描述,现在基础已基本建立,Lambdas将在后续的章节中介绍。

下面的用例作为基础:一个double类型的list,表示半径,然后得到一个列表,表示圆面积。map方法就是为此任务预先准备的。计算圆面积的公式是众所周知的:

A  =  r 2 π

应用这个公式的方法很容易实现:

static Double circleArea(Double radius) {
    return Math.pow(radius, 2) * Math.PI;
}

这个方法现在可以用作map方法的第一个参数:

System.out.println(
        map(LambdaMap::circleArea,
            Arrays.asList(1., 4., 9., 16.)));

如果circleArea方法只需要这一次, 没有道理把类接口被他弄得乱七八糟,也没有道理将实现和真正使用它的地方分离。最佳实践是使用用匿名内部类。可以看到,实例化一个实现MapFunction接口的匿名内部类可以很好的完成这个任务:

System.out.println(
        map(new MapFunction<Double, Double>() {
                public Double apply(Double radius) {
                    return Math.sqrt(radius) * Math.PI;
                }
            },
            Arrays.asList(1., 2., 3., 4.)));

这看起来很漂亮,但是很多人会认为函数式的解决方案更清晰,更具可读性:

List<Double> out = new ArrayList<>();
for (Double radius : Arrays.asList(1., 2., 3., 4.)) {
    out.add(Math.sqrt(radius) * Math.PI);
}
System.out.println(out);

到目前为止,最后是使用Lambda表达式。 读者应该注意Lambda如何取代上面提到的匿名类:

System.out.println(
        map(radius -> { return Math.sqrt(radius) * Math.PI; },
            Arrays.asList(1., 2., 3., 4.)));

这看起来简洁明了 - 请注意 Lambda 表达式如何缺省任何明确的类型信息。 没有显式模板实例化,没有方法签名。

Lambda表达式由两部分组成,这两部分被->分隔。第一部分是参数列表,第二部分是实际实现。

Lambda表达式和匿名内部类作用完全相同,然而它摒弃了许多编译器可以自动推断的样板代码。让我们再次比较这两种方式,然后分析编译器为开发人员节省了哪些工作。

MapFunction<Double, Double> functionLambda =
        radius -> Math.sqrt(radius) * Math.PI;

MapFunction<Double, Double> functionClass =
        new MapFunction<Double, Double>() {
            public Double apply(Double radius) {
                return Math.sqrt(radius) * Math.PI;
            }
        };
  • 对于Lambda实现来说,只有一个表达式,返回语句和花括号可以省略。这使得代码更简短。

  • Lambda表达式的返回值类型是从Lambda实现推荐出来的。

  • 对于参数类型,我不完全确定,但我认为必须从Lambda表达式所处的上下文中推断出参数类型。

  • 最后编译器必须检查返回值类型 是否 与Lambda的上下文匹配,以及参数类型是否与Lambda实现匹配。

这一切都可以在编译期间完成,根本没有运行时开销。

总而言之,Java中的Lambdas的概念是整洁的。我支持编写更简洁、更清晰的代码,并让程序员免于编写可由编译器自动推断的架手架代码。它是语法糖,如上所述,它只不过是使用匿名类也能实现的功能。然而,我会说它是非常甜的语法糖。

另一方面,Lambdas还支持更加混淆以及难以调试的代码。Python社区很早就意识到了这一点 - 虽然Python也有Lambda,但它若被广泛使用则通常被认为是不好的风格(当嵌套函数可以被使用时,它并不难于规避)。对于Java来说,我会给出类似的建议。毫无疑问,在某些情况下,使用Lambdas会导致代码大大缩减并更易读,尤其在与流有关时。在其他情况下,如果采取更保守的做法和最佳实践,另外一种方法可能会是更好的替代。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK