Java学习之旅第三季-17:Lambda表达式
17.1 函数式编程概述
函数式编程也是一种与面向过程编程和面向对象编程一样的编程范式。一种语言被认为具有函数性,当一门编程语言可以通过创建和组合抽象函数来表达计算时,就可以认为该语言具有函数式编程的特点。这一概念源于逻辑学家阿隆佐·丘奇(Alonzo Church)在 20 世纪 30 年代发明的形式数学系统:λ演算(Lambda Calculus)。这是一个用抽象函数表达计算以及如何将变量应用于这些函数的系统。“λ演算” 这一名称源自希腊字母"λ"。
λ 演算的一般概念由三大支柱构成:
- 抽象:一个匿名函数(Lambda 函数)接受单个输入。
- 应用:抽象应用于某个值以生成结果。从开发者的角度来看,它就是一个函数或方法调用。
- β-归约:用应用的参数替换抽象的变量。
作为一名面向对象的开发者,我们习惯于使用命令式编程方式:通过定义一系列语句,向计算机传达一系列指令,以通过一系列语句来完成特定任务。而要使一种编程语言被视为函数式的,就需要能够实现一种声明式风格,即无需描述实际的控制流程就能表达计算的逻辑。在这种声明式的编程风格中,是通过表达式来描述结果以及程序应如何运行,而不是通过语句来说明程序应做什么。
函数式编程将函数分为两类:纯函数和不纯函数。
纯函数有两个基本保证:
- 相同的输入总是会产生相同的输出:纯函数的返回值必须仅取决于其输入参数
- 它们是独立的,没有任何副作用:代码不能影响全局状态,比如改变参数值或使用任何输入/输出
这两项保障使得纯函数能够在任何环境中安全使用,甚至能够在并行环境中使用。以下代码展示了一个作为纯函数的方法,该方法接受一个参数,但不会影响其作用域之外的任何内容:
public String getSelf(String s) {return s;
}
17.2 Lambda表达式
从Java 8开始,就开始支持函数式编程的代码风格。在Java中,一个 Lambda表达式是一行或一段 Java 代码,它可以有零个或多个参数,并且可能返回一个值。从简化的角度来看,Lambda 表达式就像是一个不属于任何对象匿名方法。
声明语法如下:
(<参数列表>) -> { <主体> }
该语法结构由三个不同的部分组成:
- 参数列表:一个以逗号分隔的参数列表,就像方法参数列表一样。不过与方法参数不同的是,如果编译器能够推断出参数类型,就可以省略参数类型。在语法上不允许同时使用隐式类型参数和显式类型参数。如果只有一个参数可以省略小括号,但如果有零个或多个参数存在时则需要小括号
- 箭头:“->” 符号将参数与主体表达式的主体分隔开来。它在 Lambda 演算中相当于" λ"。
- 主体:可以是单个表达式,也可以是代码块。单行表达式无需使用大括号,其计算结果会自动返回,无需使用 return 语句。如果主体部分由多个表达式组成,则通常使用 Java 代码块。如果需要返回一个值,则必须用大括号将其包裹起来,并明确使用 return 语句。
上面的语法与方法有些类似,但是也存在很大差异:
- Lambda 表达式没有名称,而方法有具体的名称
- Lambda 表达式没有throws语句,由编译器根据使用环境推断
- Lambda 表达式不能声明类型参数,即不可是泛型的
- 可以将 Lambda 表达式赋值给一个合适的函数式接口变量
下面我分几种情况写几种合法的Lambda表达式示例:
1、没有参数也没有返回值,参数列表的小括号不可省略,主体部分大括号不可省略,其中的语句需要分号结尾:
() -> {System.out.println("老谭");
}
2、没有参数有返回值,参数列表的小括号不可省略,主体部分分两种情况:
一种是主体的返回值要使用多条语句才能返回,不能简单获取结果,那么就使用大括号,其中使用完整的语句,并最终使用 return 返回结果。
() -> { int num = 1;// 多条语句return num;
}
第二种情况是返回值可以直接跟在return后,一条表达式即可完成,则可以将大括号省略,return也省略,就放返回值表达式即可:
() -> 1
3、带有一个参数的情况,由于是否有返回值的场景与上述两种写法相同,就不再赘述了,
第一种写法,参数列表完整的带有小括号及参数类型
(String msg) -> { System.out.println(msg); }
(String msg) -> msg + "!"
第二种写法,参数列表省略数据类型,但保留小括号
(msg) -> { System.out.println(msg); }
(msg) -> msg + "!"
第三种写法,参数列表值保留参数,省略小括号及数据类型
msg -> { System.out.println(msg); }
msg -> msg + "!"
4、带有多个参数的情况,同样是否有返回值的场景与上述两种写法相同,就不再赘述了,
第一种写法,参数列表完整的带有小括号及参数数据类型
(int x, int y) -> { return x + y; }
(int x, int y) -> x + y
第二种写法,参数列表带有小括号,但省略参数数据类型
(x, y) -> { return x + y; }
(x, y) -> x + y
带有多个参数时,参数要么全部带有数据类型,要么都不带数据类型,下面的写法就不合法:
(int x, y) -> x + y
另外对应带参数的形式,如果在参数前使用 final 修饰符,则参数的数据类型不可省略
(final int x) -> x * 2
(final int x, int y) -> x + y
(final int x, final int y) -> x + y
下面也有几个不合法的 Lambda表达式写法:
(int x, y) -> x + y
String msg -> { System.out.println(msg); }
-> { System.out.println("老谭"); }
(final x, final y) -> { return x + y; }
可能同一个功能有不同的写法,至于选择哪种写法在很大程度上取决于上下文和个人偏好。通常,编译器可以推断出类型,但这并不意味着人能像编译器那样擅长理解尽可能短的代码。适度的冗长或许有助于包括我们自己在内的任何代码与读者更好地理解代码背后的逻辑。
17.3 函数式接口
到目前为止,我们只是单独探讨了 Lambda 表达式的声明语法。但是它们仍然必须存在于 Java 语言及其相关概念和语法规则之中。Java 的一大特点是具有良好的向后兼容性。正因为如此,尽管 Lambda 语法对 Java 语言本身来说是一种重大变革,但它仍然基于普通的接口来实现向后兼容,并且对于任何 Java 开发者来说都感觉非常熟悉。
为了实现其"一等公民"身份,Java 中的 Lambda 表达式需要有一种与现有类型(如对象和基本类型)类似的表示形式,因此,Lambda 表达式是由接口的某种特殊子类型来表示的,这种子类型被称为函数式接口。函数式接口就是只有一个抽象方法的接口。可以使用 @FunctionalInterface 注解修饰。
下面我声明几个函数式接口,其中唯一的抽象方法也根据参数数量及是否有返回值分几种情况:
@FunctionalInterface
public interface FuncInterface1 {void method(); // 无参,无返回值
}@FunctionalInterface
public interface FuncInterface2 {int method(); // 无参,有返回值
}@FunctionalInterface
public interface FuncInterface3 {int method(int s); // 有一个int类型的参数,有返回值
}@FunctionalInterface
public interface FuncInterface4 {int method(int num1, int num2); // 有两个int类型的参数,有返回值
}
既然在 Java 中 Lambda 表达式可以使用一个函数式接口类型来表示,但是在编写Lambda表达式时并不知道到底是哪个函数式接口类型与之对应。但是在使用时,编译器会根据使用环境推断出被期望的类型,即为目标类型。这也²意味着同样的Lambda 表达式在不同上下文里可以拥有不同的类型。
当然我们也也可直接将Lambda表达式赋值给某个函数式接口声明的对象。Lambda表达式可以赋值给泛型函数式接口类型的变量,但不能赋值给带有泛型方法的非泛型函数式接口类型的变量。
FuncInterface1 obj1 = () -> {System.out.println("老谭");
};
FuncInterface2 obj2 = () -> 1;
FuncInterface3 obj3 = num -> num * 2;
FuncInterface4 obj4 = (num1, num2) -> num1 + num2;
FuncInterface4 obj5 = (num1, num2) -> {if (num1 >= num2) {return num1 - num2;}return num1 + num2;
};
对于上述的写法,形式如下:
F f = <Lambda表达式>;
目标类型的推断规则:
- F 必须是函数式接口。既不能是带有多个抽象方法的接口,也不能是类或抽象类等其他类型。
- Lambda 表达式的参数数量和类型与 F 中的唯一抽象方法声明一致
- Lambda 表达式的主体返回值类型与 F 中唯一抽象方法返回值类型一致
- Lambda 表达式的主体中抛出的异常都要与 F 中的唯一抽象方法声明的异常要遵循方法程序中异常的规则,即子类不能抛出比父类方法更多或范围更大的受检异常(父类异常);这里Lambda 表达式就可以当成子类,而函数式接口就当成父类。
17.4 交集类型
Java交集类型(Intersection Type)是使用 & 将多个类型连接起来,一般是将标记接口和函数式接口进行连接。交集类型通常用于泛型约束、类型推断以及Lambda表达式的强制类型转换
/*** 标记接口* @author 老谭*/
public interface MakerInterface {
}
使用交集类型就可以将 Lambda表达式赋值给该标记接口类型的变量
MakerInterface o = (MakerInterface & FuncInterface4) (num1, num2) -> num1 + num2;
这种语法在开发中使用不多,了解即可。
17.5 Lambda表达式作为实参
更常见的使用方式是方法声明中的参数是函数式接口,那么在传入实参时就可以直接传入Lambda表达式了。
public int method(FuncInterface4 f, int num1, int num2) {return f.method(num1, num2);
}
在调用时,可以任意传入能与FuncInterface4函数式接口表示的Lambda表达式:
LambdaDemo demo = new LambdaDemo();
int result1 = demo.method((num1, num2) -> num1 + num2, 10, 20);
System.out.println(result1); // 30
int result2 = demo.method((num1, num2) -> num1 * num2, 10, 20);
System.out.println(result2); // 200
在某种情况下,可能方法重载时可以传入的 Lambda 表示可以匹配到不同的函数式接口,例如下面的示例:
@FunctionalInterface
interface Interface1 {void m(int num1, int num2);
}@FunctionalInterface
interface Interface2 {void m(String s1, String s2);
}public class OverrideDemo {static void test(Interface1 i) {}static void test(Interface2 i) {}static void main() {test((num1, num2) -> System.out.print(num1 + num2)); // 编译错误}
}
上面的代码中,我声明了两个函数式接口,其中的方法都能接收两个参数且没有返回值,在测试类中我声明了两个重载形式的方法,参数分别已使用不同的接口类型,但是在调用方法时,如果直接传入 Lambda 表达式,会导致没有办法区分是哪个类型从而导致编译错误。
这种情况,可以采取的办法有:
- 明确Lambda表达式的参数类型,如传入 (int num1, int num2) -> System.out.print(num1 + num2)
- 使用类型强制转换,如传入 (Interface1) (num1, num2) -> System.out.print(num1 + num2)
- 不要直接传Lambda表达式,而是先将其赋值给接口类型的变量,再将该变量传入方法
17.6 Lambda表达式与匿名内部类的差异
在没有Lambda表达式的语法之前,这样的方法也可以传入接口的任意子类实现,这里就不再赘述子类的声明与传参了。不过不用创建子类也可以使用匿名内部类的方式,下面我介绍下Lambda表达式与匿名内部类在这里的差异。
还是上面的方法,如果使用匿名内部类就应该这样写:
int result = demo.method(new FuncInterface4() {@Overridepublic int method(int num1, int num2) {return num1 + num2;}
}, 10, 20);
System.out.println(result); // 30
上面传入的第一个实参就是使用new关键字创建的FuncInterface4接口的匿名内部类的实例。即匿名类会创建一个匿名类型的新对象。而 Lambda 表达式版本则无需创建需要存放在栈中的实例。在内部它通过一个单一的指令(invokedynamic)将创建 Lambda 表达式的整个任务委托给了 JVM。这个区别开发人员可以不用过于关注,
但是 Lambda 表达式和匿名内部类之间的另一个重大区别在于它们各自的作用域。内部类会创建自己的作用域,从而将自身的局部变量隐藏在外部作用域之外。这就是为什么关键字“this”引用的是内部类自身的实例,而不是外部作用域。而 Lambda 表达式则完全存在于其外部作用域中。变量不能以相同名称重新声明,如果不是静态变量,那么“this”则引用的是创建 Lambda 表达式的那个实例。比如:
int initNum = 0;
int result = demo.method(new FuncInterface4() {int initNum = 1;@Overridepublic int method(int num1, int num2) {return this.initNum + num1 + num2;}
}, 10, 20);
System.out.println(result); // 输出 31
上面的代码在第1行声明了变量 initNum,方法调用时在匿名内部类中也声明了变量 initNum,没有问题,因为它们有各自的作用域。第7行使用的是匿名内部类中声明的变量。this代表内部类自身的实例。
如果类似变量声明用于Lambda表达式,则有语法错误:
LambdaDemo demo = new LambdaDemo();
int initNum = 0;
int result = demo.method((num1, num2) -> {int initNum = 1; // 重复声明变量,编译错误return initNum + num1 + num2;
}, 10, 20);
上面的代码片段,第2行声明了变量initNum,在Lambda表达式中第4行声明的变量initNum重复了,可以理解为Lambda表达式没有自己的作用域。
与匿名内部类相同的是 Lambda 表达式只能访问有效的final局部变量。要么局部变量声明为final,要么只初始化一次后面不会对其重新赋值。而访问类的实例变量和类变量则无此限制。
17.7 小结
本小节介绍了Java中的函数式编程概念和Lambda表达式。首先概述了函数式编程的特点,包括纯函数的特性。然后详细讲解了Lambda表达式的语法结构,包括参数列表、箭头和主体三部分,并给出了多种合法写法示例。接着阐述了函数式接口作为Lambda表达式的载体,以及目标类型推断规则。最后简要提及交集类型的概念和Lambda表达式作为方法参数的使用方式。全文通过具体代码示例展示了Java 8引入的函数式编程特性及其应用场景。
