函数式编程语言使得操纵代码片段就像操作数据一样容易。 虽然 Java 不是函数式语言,但 Java 8 Lambda 表达式和方法引用使得你可以以函数式编程的思想来编程。
函数式编程是现在比较流行的一个编程方式,它并没有严格的定义,但有以下几个特点:函数是第一等公民、只用表达式不用语句、无副作用、不修改状态、引用透明性
这里面的后面四个特点我们可以暂时不需要了解,现在只需要弄懂第一个就可以了,函数是第一等公民的意思就是:函数在编程语言里地位很高,不仅允许把函数本身作为参数传入另一个函数,还允许返回一个函数!
但我们知道,Java 里函数并不是一等公民,它必须得依附类而存在,那该如何进行函数式编程?
让我们首先来看看下面这一个案例:
你需要写一个对字符串的加密程序,但要实现可以动态替换加密算法,你可以很简单的写出如下代码:
//加密器
public class Cipher {
private CipherStrategy cipherStrategy=new CipherOne();
//更改加密算法
public void setCipherStrategy(CipherStrategy strategy){
this.cipherStrategy=strategy;
}
public String cipher(String source){
return new String(cipherStrategy.cipher(source.getBytes()));
}
//加密算法的接口
interface CipherStrategy{
byte[] cipher(byte[] source);
}
//加密算法1
static class CipherOne implements CipherStrategy{
@Override
public byte[] cipher(byte[] source) {
//省略加密代码
}
}
//加密算法2
static class CipherTwo implements CipherStrategy{
@Override
public byte[] cipher(byte[] source) {
//省略加密代码
}
}
//加密算法3
static class CipherThree implements CipherStrategy{
@Override
public byte[] cipher(byte[] source) {
//省略加密代码
}
}
}
使用起来也很简单:
//初始化一个加密器
Cipher cipher=new Cipher();
//将字符串进行加密并输出
String result=cipher.cipher("I am happy.");
System.out.println(result);
//更改加密算法为“加密算法2”
cipher.setCipherStrategy(new CipherTwo());
//使用匿名内部类临时采用其他加密算法
cipher.setCipherStrategy(new CipherStrategy(){
public byte[] cipher(byte[] source){
//其他加密算法...
}
});
result=cipher.cipher("I am sad.");
上面的做法就是不支持函数式编程时我们经常采取的方法,也叫做策略模式(设计模式中的一种模式),但在 Java 8 之后我们有了更优雅的解决方式:
cipher.setCipherStrategy(source->{
//省略加密算法
});
//假设在Test类中有如下静态方法,那我们还可以这样做
public static byte[] cipherFour(byte[] source){
//省略加密算法
}
cipher.setCipherStratigey(Test::cipherFour);
这两种方式分别叫做 Lambda 表达式和方法引用,让我们来逐个击破吧!
一、Lambda 表达式
除了Java之外,很多其他编程语言也有 lambda 表达式:
# python
func=lambda a:print(a)
//javascript
func=(a)=>{
console.log(a)
}
//c++
auto f=[](int a,int b){return a>b;};
Lambda 表达式本质上其实就是匿名函数。但在 Java 中,一切都是类,因此在 Java 中的 lambda 表达式实际返回的也是一个类,只是编译器在后面做了很多工作使它看起来就像一个函数一样——但作为程序员,你可以高兴地假装它们“只是函数”。
下面的代码中展示了几种 lambda 表达式的语法形式:
public class Main {
interface OneArgInterface {
void test(int a);
}
interface TwoArgsInterface {
void test(int a, int b);
}
interface ReturnInterface {
int test(int a,int b);
}
interface NoArgInterface {
void test();
}
public static void useInterface(OneArgInterface func){
func.test(789)
}
public static void main(String[] args) {
TwoArgsInterface x = (a, b) -> {
System.out.println(a);
}; //[1]
useInterface(a -> {
System.out.println(a);
}); //[2]
ReturnInterface z = (a, b) -> a+b; //[3]
NoArgInterface b = () -> System.out.println("Nothing"); //[4]
x.test(123, 456);
y.test(78);
int a = z.test(234,789);
//调用起来没什么特别的,就和正常的类一样调用
}
}
[1] Lambda表达式的最完整形式,括号里面写参数列表,然后用箭头语法连接方法体。之所以括号里的参数列表不需要写类型是因为编译器可以自动根据左边的变量类型自动推断出来,这句话可以等价于下面的匿名内部类的代码:
x = new TwoArgsInterface(){
@Override
public void test(int a, int b) {
System.out.println(a);
}
};
可以看出 lambda 表达式只能用来简化只有一个抽象函数的接口或抽象类的匿名内部类的书写,而多个抽象函数的则不行,所以它其实也是一个“语法糖”。
[2] 对于只有一个参数的类型,括号可以省略。
[3] 如果方法体里只有一条语句那方法体的大括号也可以省略,返回值直接就是这条语句的返回值。
[4] 如果抽象方法没有参数则括号不能省略
二、方法引用
lambda 表达式很优雅的解决了单方法的匿名内部类占太多代码行数的问题,而且更加直观明了。但如果我想将一个已经存在的方法作为抽象方法的执行语句的话就需要用到方法引用了。
回想一下上面代码中的第二条 lambda 表达式语句:
useInterface(a -> {
System.out.println(a);
});
我们只在方法体中执行了一条方法调用语句,有些颇为浪费,有了方法引用后我们就可以这么做的:
useInterface(System.out::println);
方法引用的语法组成:类名或对象名,后面跟 ::
,然后跟方法名称。
有些同学看到这里可能就会问了,System.out.println()
有这么多重载方法,它怎么知道该调用哪一个呢?
这个问题问得好。这不得不又得扯到编译器自动推断了,因为useInterface()
的参数是已经确定的,它只支持放入OneArgInterface
类型的对象,而因为OneArgInterface
里的抽象方法的参数是单个 int 类型,所以编译器自动推断出来应该调用参数为单个 int 类型的 println
方法,不得不多说编译器真的替我们做了好多事情啊!
方法引用使用起来很简单,但还有下面两个值得讲解的地方。
未绑定的方法引用
未绑定的方法引用是指没有关联对象的非静态方法的引用。 让我们先来看看这个例子:
class Test {
String func(int i) { return "Test::func()"; }
}
interface InterfaceA {
String run(int i);
}
interface InterfaceB {
String run(Test test, int i);
}
public class UnboundMethodReference {
public static void main(String[] args) {
// InterfaceA inter = Test::func; // 这条语句会报错
InterfaceB inter = Test::func;
Test test = new Test();
System.out.println(inter.run(test, 123));
System.out.println(test.func(123)); // 与上面那句话同等效果
}
}
刚看到这段代码的时候可能会感到非常迷惑,没事,我们一步一步来。
首先,这次我们没有像之前那样用的是“对象名::方法名”,而是用的类名,所选的调用方法也是非静态方法,这就叫做未绑定方法。我们都知道,非静态方法必须得依附一个对象存在,也就是所谓的this
;没有对象,自然也就无法调用非静态方法。而与之相反的是静态方法可以独立于对象存在,直接用类名加.
进行调用。
所以我们可以把非静态方法用另一种方式书写,例如最后一句代码里的test.func(123)
可以等价转换为Test.func(test, 123)
(PS: 只能是这样理解,但语法上并不允许)
这样你就应该明白为什么 InterfaceB
的run
函数开头需要多一个参数了吧,这是为了绑定方法运行的对象用的。
稍微总结一下的话就是形似“类名::非静态方法的方法名”的方法引用会自动在参数列表前面加一个该类对象的参数。
构造函数引用
构造函数引用非常容易理解,就是将构造函数引用成抽象类或接口,对于有多个构造函数的情况,编译器也可以自动推断出应该引用哪一个构造函数。
class Dog {
String name;
int age = -1; // For "unknown"
Dog() { name = "stray"; }
Dog(String nm) { name = nm; }
Dog(String nm, int yrs) { name = nm; age = yrs; }
}
interface MakeNoArgs {
Dog make();
}
interface Make1Arg {
Dog make(String nm);
}
interface Make2Args {
Dog make(String nm, int age);
}
public class CtorReference {
public static void main(String[] args) {
MakeNoArgs mna = Dog::new; // [1]
Make1Arg m1a = Dog::new; // [2]
Make2Args m2a = Dog::new; // [3]
Dog dn = mna.make();
Dog d1 = m1a.make("Comet");
Dog d2 = m2a.make("Ralph", 4);
}
}
三、内置的函数式接口
由于 Java 所有函数都依附于类的特性,使得想要进行纯粹的函数式编程非常麻烦,例如之前看到的代码例子,我们每次都需要创建各种各样的接口来满足不同的方法类型,这着实非常麻烦,于是 Java 就给我们提供了一大堆已经写好的满足函数式编程的接口。如下表所示:
特征 | 示例 |
---|---|
无参数; 没有返回值 | Runnable |
无参数; 有返回值 |
Supplier<T> BooleanSupplier IntSupplier LongSupplier DoubleSupplier
|
一个参数; 没有返回值 |
Consumer<T> IntConsumer LongConsumer DoubleConsumer
|
两个参数;没有返回值 | BiConsumer |
两个参数 ,分别是一个引用类型、一个基本类型;没有返回值 |
ObjIntConsumer ObjLongConsumer ObjDoubleConsumer
|
一个参数; 有返回值 |
Function<T,R> IntFunction<R> LongFunction<R> DoubleFunction<R> ToIntFunction<T> ToLongFunction<T> ToDoubleFunction<T> IntToLongFunction IntToDoubleFunction LongToIntFunction LongToDoubleFunction DoubleToIntFunction DoubleToLongFunction
|
一个参数;参数与返回值类型相同 |
UnaryOperator<T> IntUnaryOperator LongUnaryOperator DoubleUnaryOperator
|
两个参数,类型相同; 参数与返回值类型相同 |
BinaryOperator<T> IntBinaryOperator LongBinaryOperator DoubleBinaryOperator
|
两个参数; 返回值是布尔型 |
Predicate<T> BiPredicate<T,U> IntPredicate LongPredicate DoublePredicate
|
参数是基本类型; 返回值也是基本类型 |
IntToLongFunction IntToDoubleFunction LongToIntFunction LongToDoubleFunction DoubleToIntFunction DoubleToLongFunction
|
两个参数,类型不同 |
BiFunction<T,U,R> BiConsumer<T,U> BiPredicate<T,U> ToIntBiFunction<T,U> ToLongBiFunction<T,U> ToDoubleBiFunction<T,U>
|
这个表格看起来有点复杂,不过可以稍微总结一下:
- 没有参数且没有返回值的是
Runnable
- 有一个参数,没返回值的是
Consumer
,Consumer的意思是消耗者,也就是只消耗东西(参数)却没有产生新东西(返回值) - 没有参数,有一个返回值的是
Supplier
,Supplier的意思是提供者,也就是没有获得东西(参数)却提供新东西(返回值) - 有两个参数并且有返回值,参数与返回值相同,是因为操作符运算就符合这个特征,例如加减乘除就是两个同类型的数字相加再返回同类型的数字;而 Binary 表示二元运算,Unary 表示一元运算,所以分别表示有两个参数和一个参数
- 然后剩下的 Bi 开头的表示有两个参数;XXXToYYYFunction 表示参数是XXX,返回值是 YYY;Predicate 表示返回值是布尔值
有了这些接口以后就不再需要自己写函数式接口了,例如最开始的加密程序就可以变成这样了:
public class Cipher {
private CipherStrategy cipherStrategy=new CipherOne();
public void setCipherStrategy(CipherStrategy strategy){
this.cipherStrategy=strategy;
}
public String cipher(String source){
//这里调用的方法名也变了,不同的函数式接口,方法名也不相同,查看接口的源码可知
byte[] result = cipherStrategy.apply(source.getBytes());
return new String(result);
}
//表示方法的参数和返回值相同,且为字节数组
class CipherStrategy implements UnaryOperator<byte[]>{}
//...省略其他代码
}
除此之外,这些函数式接口不只是帮你节省了写接口的时间,还可以实现诸如方法组合以及柯里化等高级操作,具体方法由于时间问题就不讲述了,在下面的拓展阅读中有详细介绍。
关于 Java 函数式编程还想了解更多的同学可以看看这个网页 https://lingcoder.gitee.io/onjava8/#/book/13-Functional-Programming