onJava – 函数式编程 (2022.12.22)
概论
- 函数式编程的意义:通过整合现有代码来产生新的功能,而不是从零开始编写所有内容,由此我们会得到更可靠的代码,而且实现起来更快。(这个理论看起来是成立的,至少在某些情况下如此)
- 可以这样认为:面向对象编程抽象数据,而函数式编程抽象行为
- 纯函数式语言规定了额外的约束条件:所有的数据必须是不可变的:设置一次,永不改变。函数会接受值,然后产生新值,但是绝对不会修改自身之外的任何东西(包括其参数或该函数作用域之外的元素)。有了这一保证,可以知道不会再有任何所谓的 “副作用” 引起的bug,因为函数只是创建并返回了一个结果,其他的什么都没做 (纯函数式语言经常被当作并行编程问题的解决方案,当然也有其他可行的解决方案)
Lambda表达式
-
Lambda 表达式 是使用尽可能少的语法编写的函数定义
-
Lambda 表达式产生的是函数,而不是类。在Java虚拟机(JVM)上,一切都是类,所以幕后会有各种各样的操作,让Lambda 看起来像函数。但是作为程序员,我们可以开心地假装他们 “就是函数”
-
基本语法:
- 参数;
- 后面跟 -> ,你可以将其读作 “产生” (produces);
- -> 后面的都是方法体。
-
规则:
- 只有一个参数,可以只写这个参数,不写括号。不过这是一种特殊情况
- 通常情况是用括号将参数包裹起来。为了一致性,在单个参数时也可以使用括号,尽管这并不常见
- 在没有参数的情况下,必须使用括号来指示空的参数列表
- 在有多个参数的情况下,将它们放在括号包裹起来的参数列表内
- 如果方法体只有一行,方法体中表达式的结果会自动成为Lambda表达式的返回值,这时使用 return 关键字是不合法的。而当方法体有多行时,则必须将这些代码行放到花括号中,并需要使用 return 关键字 从 Lambda 表达式中生成一个值。
-
代码示例
package closure; /** * @title: LambdaExpressions * @description: Lambda 表达式 * @author: sunwenlong * @date 2022/12/22 22:00 */ // 定义三个接口,每个接口中都有且只有一个方法(后续就能理解其意义了),为了演示Lambda表达式的语法,每个方法的参数数量不同 interface ZeroArg { String zeroArg(); } interface OneArg { String oneArg(String str); } interface TwoArgs { String twoArgs(String str, Double num); } public class LambdaExpressions { // 规则3 static ZeroArg zero = () -> "zero arg"; // 规则2 static OneArg one = (h) -> h + "one arg"; // 规则1 static OneArg one1 = h -> h + "Arg"; // 规则4 static TwoArgs two = (h, n) -> h + n; // 规则5 static ZeroArg moreLines = () -> { System.out.println("More lines"); return "from moreLines()"; }; public static void main(String[] args) { System.out.println(zero.zeroArg()); // 输出:zero arg System.out.println(one.oneArg("Hi, ")); // 输出:Hi, one arg System.out.println(one1.oneArg("Ho, ")); // 输出:Ho, Arg System.out.println(two.twoArgs("Pi = ", 3.141592)); // 输出:Pi = 3.141592 System.out.println(moreLines.zeroArg()); // 输出:More lines \n from moreLines() } }
-
递归
-
可以编写递归的 Lambda 表达式,但是有一点要注意:这个 Lambda 表达式必须被赋值给一个静态变量或实例变量,否则会出现编译错误。
/** * @title: IntCall * @description: 两个用 Lambda 表达式 写递归示例的通用接口 * @author: sunwenlong * @date 2022/12/22 22:27 */ public interface IntCall { int call (int arg); } /** * @title: RecursiveFactorial * @description: 用 Lambda 表达式 写递归 -- 阶乘 * @author: sunwenlong * @date 2022/12/22 22:28 */ // 示例1:赋值给静态变量 public class RecursiveFactorial { // 注意,不能在定义的时候像这样初始化 fact // 尽管这样的期望非常合理,但是对于Java编译器而言处理起来太复杂了,所以会产生编译错误 // static IntCall fact = n -> n == 0 ? 1 : n * fact.call(n - 1); static IntCall fact; public static void main(String[] args) { fact = n -> n == 0 ? 1 : n * fact.call(n - 1); for (int i = 0; i <= 10; i++) { System.out.println(fact.call(i)); } } } /** * @title: RecursiveFibonacci * @description: 用 Lambda 表达式 写递归 -- 斐波那契数列 * @author: sunwenlong * @date 2022/12/22 22:35 */ // 示例2:赋值给实例变量 public class RecursiveFibonacci { IntCall fib; RecursiveFibonacci() { fib = n -> n == 0 ? 0 : n == 1 ? 1 : fib.call(n - 1) + fib.call(n - 2); } int fibonacci(int n) { return fib.call(n); } public static void main(String[] args) { RecursiveFibonacci rf = new RecursiveFibonacci(); for (int i = 0; i <= 10; i++) { System.out.println(rf.fibonacci(i)); } } }
-
方法引用
-
Java8 方法引用指向的是方法。方法引用是用类名或对象名,后面跟上 :: ,然后跟方法名
-
注意,比如:Describe :: show 是产生一个引用,而不是调用该方法
-
个人理解:方法引用好比于new 对象,比如
Person p = new Person(); // 方法引用产生该方法的一个引用,然后将该引用赋值给了 Callable 中的call() 方法, // 这样,通过调用call() 方法就能调用show() 方法了 // 对象引用要能赋值给一个引用变量,需要类型相同或是继承关系, // 同样的,要把show() 方法的引用赋值给Callble,需要 show() 方法的签名 (参数列表和返回值类型)和Callable中的call() 一致 Callable c = Describe::show
-
示例:
// 注意,这里的示例方法的签名都和 Callable中的 call() 方法是一致的 interface Callable { void call(String str); } class Describe { void show(String msg) { System.out.println(msg + "!!!"); } } public class MethodReferences { static void hello(String name) { System.out.println("Hello, " + name); } static class Description { String about; Description(String desc) { about = desc; } void help(String msg) { System.out.println(about + " " + msg); } } static class Helper { static void assist(String msg) { System.out.println(msg); } } public static void main(String[] args) { // 将Describe对象的一个方法引用赋值给一个Callable,Callable中没有show() 方法,只有一个call() 方法。 // 然而,Java 似乎对这种看似奇怪的赋值并没有意见 // 因为,这个方法引用的签名(参数类型和返回值类型)和Callable中的call() 方法一致 Describe d = new Describe(); // Java 将call() 映射到了show() 上 Callable c = d::show; c.call("call()"); // 调用的show() 方法。 输出: call()!!! c = MethodReferences::hello; c.call("Bob"); // 这是 Callable c = d::show 的另一个版本——绑定方法引用 c = new Description("valuable")::help; c.call("information"); c = Helper::assist; c.call("Help!"); } }
-
Runable
-
Runable 接口在java.lang 包中,所以不需要import。它也遵从特殊的单方法接口格式:其run() 方法没有参数,也没有返回值。所以可以将Lambda 表达式或方法引用作Runable:
class Go { static void go() { System.out.println("Go::go()"); } } public class RunableMethodReference { public static void main(String[] args) { // 匿名内部类 方式 // 三种方式,只有匿名内部类需要提供名为run()的方法 new Thread(new Runnable() { @Override public void run() { System.out.println("内部类Runable"); } }).start(); // Lambda 表达式 方式 new Thread( () -> System.out.println("Lambda 表达式") ).start(); // 方法引用 方式 new Thread(Go::go).start(); } }
-
未绑定的方法引用
-
绑定方法引用:对某个活跃对象上的方法的方法引用,叫作 “绑定方法引用”,如上述方法引用示例中的 c = new Describe(“valuable”)::help
-
未绑定方法引用:尚未关联到某个对象的普通方法(非静态方法)。对于未绑定引用,必须先提供对象,然后才能使用。
-
示例
class X { String f() { return "X::f()"; } } interface MakeString { String make(); } interface TransformX { String transform(X x); } public class UnboundMethodReference { public static void main(String[] args) { // MakeString ms = X::f; TransformX sp = X::f; X x = new X(); System.out.println(sp.transform(x)); System.out.println(x.f()); } }
-
上面的示例中的 MakeString ms = X::f ,编译器会报错,提示 “无效方法引用”,即使make() 和 f() 的签名相同。问题在于,这里事实上还涉及了另一个(隐藏的)参数:this。如果没有一个可供附着的 X 对象,就无法调用 f() 。因此,X::f 代表一个未绑定的引用,因为它没 “绑定到” 某个对象。
-
为解决这个问题,我们需要一个 X 对象,所以我们的接口事实上还需要一个额外的参数,如 TransformX 所示。如果将 X::f 赋值给一个TransformX ,Java 会很开心地接受。我们必须再做一次心理调节:在未绑定引用的情况下,函数式方法(接口只有一个方法,那个单一的方法就是函数式方法)的签名与方法引用的签名不再完全匹配(函数式方法要多一个参数,第一个参数的类型必须是方法引用所属的类),因为我们需要一个对象,让方法在其上调用。
-
示例
class Args { void two(int i, double d) {} void three(int i, double d, String s) {} void four(int i, double d, String s, char c) {} } interface Two { // 需要比对应的Args 中的two() 多一个参数:Args args,并且必须是第一个参数 void call2(Args args, int i, double d); } interface Three { void call3(Args args, int i, double d, String s); } interface Four{ void call4(Args args, int i, double d, String s, char c); } public class MultiUnbound { public static void main(String[] args) { Two two = Args::two; Three three = Args::three; Four four = Args::four; Args arg = new Args(); two.call2(arg, 11, 14); } }
构造器方法引用
-
我们也可以捕获对某个构造器的引用,之后通过该引用来调用那个构造器
-
构造器引用的写法是:类名::new
-
示例
class Dog { String name; int age; Dog() {name = "stray";} Dog(String name) { this.name = name; } Dog(String name, int age) {this.name = name; this.age = age;} } interface MakeNoArgs { Dog make(); } interface Make1Arg { Dog make(String name); } interface Make2Args { Dog make(String name, int age); } public class CtorReference { public static void main(String[] args) { MakeNoArgs mna = Dog::new; Make1Arg m1a = Dog::new; Make2Args m2a = Dog::new; Dog dn = mna.make(); Dog d1 = m1a.make("Comet"); Dog d2 = m2a.make("Ralph", 4); } }
-
所有这3个构造器都只有一个名字 ::new,但是在每种情况下,构造器引用被赋值给了不同的接口,编译器可以从接口来推断使用哪个构造器
函数式接口
-
方法引用和Lambda 表达式都必须赋值,而这些赋值都需要类型信息,让编译器确保类型的正确性。考虑如下代码: (x, y) -> x + y
因为Lambda 表达式包含了某种方式的类型推断(编译器推断出类型的某些信息,而不需要程序员显示指定),所以编译器必须能够以某种方式推断出 x,y 的类型。
现在 x 和 y 可以是支持 + 操作符的任何类型,如两个数值类型,或者一个String 和某个能够自动转换为String的其他类型。但是在Lambda 表达式被赋值之后,编译器就必须确定 x 和 y 的精确类型并生成正确的代码了。同样的情况也适用于方法引用。
-
为解决这个问题,Java 8 引入了包含一组接口的 java.util.function,这些接口是Lambda 表达式和方法引用的目标类型。每个接口都值包含一个抽象方法,叫做函数式方法。
-
当编写接口时,这种 “函数式方法” 模式可以使用 @FunctionalInterface 注解来强制实施
@FunctionalInterface interface Functional { String goodbye(String arg); } // @FunctionalInterface 注解是可选的 // @FunctionalInterface 的价值:如果接口中的方法多于一个,则会产生一条编译错误信息 interface FunctionalNoAnn { String goodbye(String arg); } public class FunctionalAnnotation { public String goodbye(String arg) { return "Goodbye" + arg; } public static void main(String[] args) { FunctionalAnnotation fa = new FunctionalAnnotation(); Functional f = fa::goodbye; FunctionalNoAnn fna = fa::goodbye; Functional fl = a -> "Goodbye" + a; FunctionalNoAnn fnal = a -> "Goodbye" + a; } }
-
仔细看一下 f 和 fna 的定义中发生了什么。Functional 和 FunctionalNoAnn 定义了接口。然而被赋值给它们的只是方法googbye。首先,goodbye 只是一个方法,而不是类。其次,它甚至不是实现了这里定义的某个接口的类中的方法。这是Java 8 增加的一个小魔法:如果我们将一个方法引用或Lambda 表达式赋值给某个函数式接口(而且类型可以匹配),那么Java 会调整这个赋值,使其匹配目标接口。而在底层,Java编译器会创建一个实现了目标接口的类的实例,并将我们的方法引用或Lambda 表达式包裹在其中。
-
java.util.function 旨在创建一套足够完备的目标接口,这样一般情况下我们就不需要定义自己的接口了。不详细讲述,具体见《on Java》基础卷 356 页
高阶函数
-
高阶函数:只是一个能接受函数作为参数或能把函数当返回值的函数
-
示例
// 使用继承,可以轻松地为专门的接口创建一个别名 // Function<> 是 java.util.function 提供的一个基本接口 // Function<String, String> 代表该接口的唯一的方法的签名是参数列表只有一个String类型参数,返回值是String型(最后一个类型是返回值类型) interface FuncSS extends Function<String, String> {} // 把函数当返回值 public class ProduceFunction { static FuncSS produce() { return s -> s.toLowerCase(); } public static void main(String[] args) { FuncSS f = produce(); System.out.println(f.apply("HELLO, JAVA")); // 输出:hello, java } }
class One {} class Two {} // 接受函数作为参数 public class ConsumeFunction { // Function<A, B, C> 中,最后一个也就是C,是 apply() 方法的返回值 static Two consume(Function<One, Two> oneTwo) { return oneTwo.apply(new One()); } public static void main(String[] args) { Two two = consume(one -> new Two()); } }
闭包
-
闭包:是一个可调用的对象,它保留了来自它被创建时所在的作用域的信息
-
Java 8之前,要生成类似闭包的行为,唯一的方法是通过内部类。现在Java 8 中有了Lambda表达式,它也有闭包行为,而且语法更漂亮,更简洁。所以首选Lambda表达式做闭包行为
-
示例
public class Closure1 { IntSupplier makeFun(int x) { int i = 0; // 该Lambda 表达式保留了 x,i (来自于被创建时所在的作用域的信息) return () -> x + i; // 尝试去增加 i 或 x,会报错 } }
-
如果尝试去增加 i 或 x,会报错,提示:从lambda 表达式引用的局部变量必须是最终变量或实际上的最终变量
-
需要注意,闭包中使用的其作用域外的局部变量(注意是局部变量),必须是最终变量(final 修饰的变量)或实际最终变量(该术语是为Java 8 创建的),所谓实际最终变量,意味着我们可以在变量声明前面加上final关键字,而不用修改其余代码。它实际上就是final的,我们只是懒得说而已
-
对于下面的示例,i 增加 并没有报错
public class Closure2 { int i; IntSupplier makeFun(int x) { return () -> x + i++; } }
请注意,上面的规则并不是像说 “任何在Lambda 表达式之外定义的变量都必须是最终变量或实际最终变量” 那么简单。相反,我们必须从被捕获的变量是 “实际上的最终变量” 这个角度考虑。如果它是某个对象中的一个字段,那么它会有独立的生命周期,并不需要任何特殊的捕获,以便在之后这个Lambda 表达式被调用时,变量仍然存在。
函数组合
-
函数组合:将多个函数结合使用,以创建新的函数。
-
java.util.function 中的一些接口也包含了支持函数组合的方法
组合方法 说明 andThen(argument) 先执行原始操作,再执行参数操作 compose(argument) 先执行参数操作,再执行原始操作 and(argument) 对原始谓词和参数谓词执行短语逻辑与(AND)计算 or(argument) 对原始谓词和参数谓词执行短语逻辑或(OR)计算 negate() 所得谓词为该谓词的逻辑取反 - 谓词:java.util.function 中的一条命名规则,如果接口接受一个参数并返回boolean,则会被命名为Predicate (谓词)
-
示例
public class FunctionComposition { static Function<String, String> f1 = s -> { System.out.println(s); return s.replace('A', '_'); }, f2 = s -> s.substring(3), f3 = s -> s.toLowerCase(), f4 = f1.compose(f2).andThen(f3); public static void main(String[] args) { System.out.println(f4.apply("GO AFTER ALL AMBULANCES")); } }
public class PredicateComposition { static Predicate<String> p1 = s -> s.contains("bar"), p2 = s -> s.length() < 5, p3 = s -> s.contains("foo"), p4 = p1.negate().and(p2).or(p3); public static void main(String[] args) { Stream.of("bar", "foobar", "foobaz", "fongopuckey").filter(p4).forEach(System.out::println); } }
柯里化和部分求值
-
柯里化:将一个接受多个参数的函数转变为一系列只接受一个参数的函数
-
示例
// 柯里化:将一个接受多个参数的函数转变成一系列只接受一个参数的函数 public class Curring { // 未柯里化 static String uncurring(String a, String b) { return a + b; } public static void main(String[] args) { // 柯里化函数 Function<String, Function<String, String>> sum = a -> b -> a + b; System.out.println(uncurring("Hi, ","Ha")); // 输出:Hi, Ha Function<String, String> hi = sum.apply("Hi "); System.out.println(hi.apply("Ho")); // 输出:Hi Ho // 部分应用 Function<String, String> sumHi = sum.apply("Hup "); System.out.println(sumHi.apply("Ho")); // 输出: Hup Ho System.out.println(sumHi.apply("Hey")); // 输出: Hup Hey // 接受三个参数的函数进行柯里化 Function<String, Function<String, Function<String, String>>> three = a -> b -> c -> a + b + c; Function<String, Function<String, String>> two = three.apply("Hi, "); Function<String, String> one = two.apply("Ho, "); System.out.println(one.apply("Hup")); // 输出: Hi, Ho, Hup } }