目 录CONTENT

文章目录

java中函数式编程详解

zhouzz
2024-09-13 / 0 评论 / 0 点赞 / 25 阅读 / 40830 字
温馨提示:
本文最后更新于 2024-09-15,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

我们最常用的面向对象编程(Java)属于命令式编程(Imperative Programming)这种编程范式。常见的编程范式还有逻辑式编程(Logic Programming),函数式编程(Functional Programming)。

1.概述

1.1 面向对象编程

面向对象编程(OOP)则是一种模拟现实世界的方式。你可以把它想象成在电脑世界里创造出各种“物体”,这些物体有自己的特性(属性)和行为(方法)。

1.1.1 基本原理

OOP通过封装、继承和多态等原则来解决问题。这就像是给电脑世界里的物体分类,定义它们共同的行为,然后通过继承来创建更具体的子类。

1.1.2 优缺点

OOP的优点是它能帮助我们更好地组织和管理复杂的程序,让程序的结构更加清晰,比较适合大型工程中多人合作。但缺点是可能会导致过度设计,使得程序变得复杂难懂。这里展开谈几点:

优点:

  • 提高了代码的可重用性:通过类来创建模块化的代码,而不需要每次都从头开始设计。
  • 易于维护和修改:由于OOP的封装特性,代码的特定部分可以独立于其他部分进行修改,而不会影响到整个系统。
  • 提高了代码的可读性:OOP倾向于创建有明确层次和关系的代码结构,这有助于新的开发者理解代码的工作方式。对象、类、继承等概念都是人类自然理解的,这就像是给代码建立了一个清晰的家谱。

缺点

  • 过度设计:开发者可能会被各种设计模式和原则所束缚,创建出许多不必要的抽象层和类,这反而使得程序难以理解和维护。这就像是为了建造一栋只需要简单功能的小屋,却设计了一个复杂的多层建筑。
  • 性能问题:面向对象的程序可能因为抽象和封装导致性能上的开销。例如,对象创建、方法调用、继承等都可能增加额外的内存和CPU使用。
  • 降低了代码的灵活性:在某些情况下,OOP的严格结构可能限制了代码的灵活性。一旦一个类的结构被定义,修改它可能会牵一发而动全身,尤其是在有很多继承和依赖的情况下。

1.1.3 面向对象编程示例

public class MainTest {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
        for (int i = 0; i < list.size(); i++) {
            Integer num = list.get(i);
            list.set(i, num * num);
        }
        System.out.println(list);
    }
}

1.2 函数式编程

函数式编程就像是在做数学题,你定义了一些函数,每个函数都只关心输入和输出,不会去改变外面的世界。这种范式强调无副作用的函数,避免了状态和可变数据。

1.2.1 基本原理

它通过高阶函数、纯函数和递归等概念来解决问题,就好比你用一套数学公式来描述问题的解决方案。

一切都是数学函数。函数式编程语言里也可以有对象,但通常这些对象都是恒定不变的 —— 要么是函数参数,要什么是函数返回值。
函数式编程语言里没有 for/next 循环,因为这些逻辑意味着有状态的改变。相替代的是,这种循环逻辑在函数式编程语言里是通过递归、把函数当成参数传递的方式实现的

1.2.2 优缺点

函数式编程的优点是代码通常更简洁、更易于推理,但它的缺点是对于习惯了命令式编程的人来说,可能需要一些时间来适应。这里展开谈几点:

优点:

  • 简洁的代码:函数式编程往往可以用更少的代码完成同样的功能。
  • 易于推理:函数式编程强调无副作用的纯函数,这意味着给定相同的输入,函数总是产生相同的输出,不会影响或被外部状态影响。
  • 更好的并发性:因为函数式编程避免使用可变状态,所以它天生适合并发编程。在多线程环境中,不需要担心线程安全问题,因为数据不会被多个线程同时修改。

缺点:

  • 学习曲线:对于那些习惯了命令式编程的开发者来说,函数式编程的概念可能会显得陌生和难以理解,需要一些时间来适应不同的操作方式。
  • 抽象层次高:函数式编程通常比命令式编程更抽象,这有时候会使得代码难以理解。特别是在处理非常复杂的逻辑时,过度的抽象可能会使得代码读起来像是在解谜。
  • 性能问题:某些情况下,函数式编程可能会引入性能问题。例如,频繁的创建新对象(而不是修改现有的对象)可能会导致内存使用效率低下。递归,尽管是函数式编程中的常见模式,但如果不当使用,可能会导致栈溢出错误。

1.2.3 函数编程示例

public class MainTest {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
        // map函数做转化
        List<Integer> results = list.stream().map(num -> num * num).collect(Collectors.toList());
        System.out.println(results);
    }
}

2.函数与方法

2.1 方法示例

我们先来看一个例子:

public class ShopTest {
  
    static ShopManager smr = new ShopManager("王店长");
  
    static class ShopManager {
        String name;

        public ShopManager(String name) {
            this.name = name;
        }
    }

    static String pay(String person) {
        return (person + "买了商品后,向[" + smr.name + "]付款");
    }

    public static void main(String[] args) {
        System.out.println(pay("张三"));
        System.out.println(pay("张三"));
        System.out.println(pay("张三"));
    }
}

以上 pay 的执行结果,除了参数变化外,希望函数的执行规则永远不变

张三买了商品后,向[王店长]付款
张三买了商品后,向[王店长]付款
张三买了商品后,向[王店长]付款

然而,由于设计上的缺陷,函数引用了外界可变的数据,如果这么使用

smr.name = "李店长";
System.out.println(pay("张三"));

结果就会是

张三买了商品后,向[李店长]付款

这个在调用时,店长莫名被换掉了。问题出在哪儿呢?函数的目的是除了参数能变化,其它部分都要不变,这样才能成为规则的一部分。佛祖要成为规则的一部分,也要保持不变

static class ShopManager {
    final String name;

    public ShopManager(String name) {
        this.name = name;
    }
}

name属性上添加了final关键字,这样就在设定参数后不可再变化。

这里主要说明:

  • 不是说函数不能引用外界的数据,而是它引用的数据必须也能作为规则的一部分
  • 让 smr.name 不变,才能成为规则

2.2 方法与函数关系

方法本质上也是函数。不过方法绑定在对象之上,它是对象个人法则

方法是

  • 对象数据.方法(其它参数)

函数是

  • 函数(对象数据,其它参数)

2.3 函数转化函数对象

函数是无形的,也就是它代表的规则:它只强调入参的类型、个数以及返回结果,至于中间的怎么实现是不关心的。

若要函数有形,让函数的规则能够传播,需要将函数化为函数对象。

接下来对比下面的示例,看看函数对象到底指什么。

public class MyCalculate {

    public int add(int a, int b) {
        return a + b;
    }

    public static void main(String[] args) {
        // OOP对象
        MyCalculate calculate = new MyCalculate();
        // 调用
        System.out.println(calculate.add(1, 2));
    }
}

public interface Calculate {
    int calculate(int a, int b);
}

public class CalculateTest {
    public static void main(String[] args) {
        // 它已经变成了一个 函数对象
        Calculate add = (a, b) -> a + b;
        // 调用
        System.out.println(add.calculate(1, 2));

        // 规则可以由函数对象变化
        Calculate subtract = (a, b) -> a - b;
        System.out.println(subtract.calculate(10, 5));
    }
}

可以看到,我们定义了一个函数 int calculate(int a, int b);,并没有具体的实现。当我们使用时,在将其转换成具体的函数对象,由对象来具体实现细节。这个看起来就和匿名内部类中的方法使用好相似。

那方法与函数两者区别在哪?

  • 前者是纯粹的一条两数加法规则,它的位置是固定的,要使用它,需要通过 MyCalculate对象.add 找到它,然后执行
  • 而后者(Calculate add 对象)就像长了腿,它的位置是可以变化的,想去哪里就去哪里,哪里要用到这条加法规则,把它传递过去
  • 接口的目的是为了将来用它来执行函数对象,此接口中只能有一个方法定义

2.4 行为参数化

已知学生类定义如下

public class Student {
    private String name;
    private int age;
    private String sex;

    public Student(String name, int age, String sex) {
        this.name = name;
        this.age = age;
        this.sex = sex;
    }

    public int getAge() {
        return age;
    }

    public String getName() {
        return name;
    }

    public String getSex() {
        return sex;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", sex='" + sex + '\'' +
                '}';
    }
}

针对一组学生集合,筛选出男学生,下面的代码实现如何

public class StudentTest {

    public static void main(String[] args) {
        List<Student> studentList = new ArrayList<>();
        studentList.add(new Student("张无忌", 18, "男"));
        studentList.add(new Student("杨不悔", 16, "女"));
        studentList.add(new Student("周芷若", 19, "女"));
        studentList.add(new Student("宋青书", 20, "男"));

        // 能得到 张无忌,宋青书
        System.out.println(filter(studentList));
    }

    private static List<Student> filter(List<Student> students) {
        List<Student> result = new ArrayList<>();
        for (Student student : students) {
            if ("男".equals(student.getSex())) {
                result.add(student);
            }
        }
        return result;
    }
}

如果需求再变动一下,要求找到 18 岁以下的学生,上面代码显然不能用了,改动方法如下

static List<Student> filter(List<Student> students) {
    List<Student> result = new ArrayList<>();
    for (Student student : students) {
        if (student.getAge() <= 18) {
            result.add(student);
        }
    }
    return result;
}

// 能得到 张无忌,杨不悔
System.out.println(filter(students)); 

那么需求如果再要变动,找18岁以下男学生,怎么改?显然上述做法并不太好。更希望一个方法能处理各种情况,仔细观察以上两个方法,找不同。

不同在于筛选条件部分:

student.getSex().equals("男")

student.getAge() <= 18

既然它们就是不同,那么能否把它作为参数传递进来,这样处理起来不就一致了吗?

它俩要判断的逻辑不同,那这两处不同的逻辑必然要用函数来表示,将来这两个函数都需要用到 student 对象来判断,都应该返回一个 boolean 结果,怎么描述自定义函数的长相呢?

interface Lambda {
    boolean test(Student student);
}

方法可以统一成下述代码

static List<Student> filter(List<Student> students, Lambda lambda) {
    List<Student> result = new ArrayList<>();
    for (Student student : students) {
        if (lambda.test(student)) {
            result.add(student);
        }
    }
    return result;
}

最后怎么给它传递不同实现呢?

// 找男学生
List<Student> results = filter(studentList, student -> student.getSex().equals("男"));
System.out.println(results);

// 找18岁以下的学生
results = filter(studentList, student -> student.getAge() <= 18);
System.out.println(results);

// 找18岁以下男学生
results = filter(studentList, student -> student.getSex().equals("男") && student.getAge() <= 18);
System.out.println(results);

这样就实现了以不变应万变,而变换即是一个个函数对象,也可以称之为行为参数化。

3.函数编程语法

在 Java 语言中,lambda 对象有两种形式:lambda 表达式方法引用

  • lambda 表达式: 功能更加全面
  • 方法引用: 写法更加简洁

3.1 Lambda表达式

Lambda表达式是Java 8 引入的一种新的语法元素,它允许我们将函数作为方法参数传递,或者将代码作为数据处理。
Lambda表达式的基本语法如下:

(parameters) -> expression(parameters) -> { statements; }
  • parameters:参数列表,可以为空或包含多个参数。
  • ->:Lambda操作符,将参数列表与Lambda主体分隔开。
  • expression{ statements; }:Lambda主体,可以是一个表达式或一组语句。

示例:

(int a, int b) -> a + b;
明确指定参数类型

(int a, int b) ->{ int c = a + b; return c; }
多行执行语句需要{},若有返回值需要return

Lambda Lambda = (a, b) -> a + b;
public interface Lambda {
int op(int a, int b);
}
可以根据上下文推断出参数类型时,可以省略参数类型

a -> a;
只有一个参数时,可以省略 ()

lambda 对象的类型是由它的行为决定的,如果有一些 lambda 对象,它们的入参类型、返回值类型都一致,那么它们可以看作是同一类的 lambda 对象,它们的类型,用函数式接口来表示

与匿名内部类的对比

Lambda表达式可以看作是匿名内部类的简化形式。它们都可以用来创建函数式接口的实例。以下是Lambda表达式与匿名内部类的对比:

语法简洁性:Lambda表达式通常比匿名内部类更简洁。

类型推断:Lambda表达式允许编译器推断参数类型,而匿名内部类需要显式声明。

作用域:Lambda表达式可以捕获外部作用域的变量,但这些变量必须是final或实际上是final的。

在Java 8之前,我们可能会这样创建一个线程:

new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello from a thread!");
    }
}).start();

使用Lambda表达式,代码可以简化为:

new Thread(() -> System.out.println("Hello from a thread!")).start();

3.2 方法引用

将现有的方法调用转换为函数对象

1)静态方法引用

静态方法引用允许我们直接引用一个类的静态方法。语法如下:

ClassName::staticMethodName

如何理解:

  • 函数对象的逻辑部分是:调用此静态方法
  • 因此这个静态方法需要什么参数,函数对象也提供相应的参数即可

例如,我们可以引用Integer类的parseInt静态方法:

List<String> numbers = Arrays.asList("1", "2", "3");
List<Integer> nums = numbers.stream().map(Integer::parseInt).collect(Collectors.toList());

这里的 Integer::parseInt其实是 Integer 类中静态 parseInt(String s ) 方法的:

public final class Integer extends Number implements Comparable<Integer> {
    ...
    
    public static int parseInt(String s) throws NumberFormatException {
        return parseInt(s,10);
    }
    
    ...
}

2)实例方法引用

实例方法引用允许我们引用特定对象的实例方法。语法如下:

instanceReference::methodName

如何理解:

  • 函数对象的逻辑部分是:调用此非静态方法
  • 因此这个函数对象需要提供一个额外的对象参数,以便能够调用此非静态方法
  • 非静态方法的剩余参数,与函数对象的剩余参数一一对应

例如,我们可以引用String对象的length方法:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
int maxLength = names.stream().map(String::length).max(Integer::compare).orElse(0);

这里的 String::length其实是 (String str) -> str.length()。表示需要 String的实例对象调用 length() 方法。

3)对象::非静态方法名

与实例方法引用类似,不过需要提供额外的对象。

如何理解:

  • 函数对象的逻辑部分是:调用此非静态方法
  • 因为对象已提供,所以不必作为函数对象参数的一部分
  • 非静态方法的剩余参数,与函数对象的剩余参数一一对应

较为典型的一个应用就是 System.out 对象中的非静态方法,打印输出:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream().forEach(System.out::println);

.forEach(System.out::println) 对应的表达式 (Object obj) -> System.out.println(obj);

4)构造器引用

构造器引用允许我们引用类的构造器。语法如下:

ClassName::new

函数类型应满足

  • 参数部分与构造方法参数一致
  • 返回值类型与构造方法所在类一致

例如,我们可以引用ArrayList的构造器:

// 构造无参
Supplier<List<String>> supplier = ArrayList::new;
List<String> list = supplier.get();
// 构造有参
Supplier<List<String>> supplier2 = () -> new ArrayList<>(5);
List<String> list2 = supplier2.get();

以上4种类型的方法引用,其实和平时的方法调用区别不大,简而言之,就是首先要知道入参是什么,然后你需要调用哪些类做什么业务逻辑。

写出等价的方法引用示例:

public class LambdaTest {
    public static void main(String[] args) {
        //Function<String, Integer> lambda1 = (String s) -> Integer.parseInt(s);
        Function<String, Integer> lambda1 = Integer::parseInt;

        //BiPredicate<List<String>, String> lambda2 = (list, element) -> list.contains(element);
        BiPredicate<List<String>, String> lambda2 = List::contains;

        //BiPredicate<Student, Object> lambda3 = (stu, obi) -> stu.equals(obi);
        BiPredicate<Student, Object> lambda3 = Object::equals;

        //Predicate<File> lambda4 = (file) -> file.exists();
        Predicate<File> lambda4 = File::exists;

        Runtime runtime = Runtime.getRuntime();
        //Supplier<Long> lambda5 = () -> runtime.freeMemory();
        Supplier<Long> lambda5 = runtime::freeMemory;
    }
}

4.函数对象类型

4.1如何对函数对象类型分类

如何分类?

  • 参数个数类型是否相同
  • 返回值类型是否相同

函数式接口定义:

  • 仅包含一个抽象方法
  • @Functionallnterface 来检查

示例:

public class LambdaTest {
    public static void main(String[] args) {
        Type1 type1 = s -> "Mr." + s;
        System.out.println(type1.op("zhang"));

        Type2 type2 = num -> num + 10;
        System.out.println(type2.op(20));
    }

    @FunctionalInterface
    public interface Type1 {
        String op(String name);
    }

    @FunctionalInterface
    public interface Type2 {
        int op(int num);
    }
}

上面演示了定义两个函数式接口 Type1,Type2。这两个参数类型和返回类型都不相同。那怎么使用一个函数式接口来同时满足上面的调用呢?

我们可以通过泛型:

@FunctionalInterface
public interface CommonType<P, R> {
    R op(P param);
}

这样我们就可以使用相同的函数式接口了:

CommonType<String, String> commonType1 = s -> "Mr." + s;
CommonType<Integer, Integer> commonType2 = num -> num + 10;
CommonType<String, Integer> commonType3 = Integer::parseInt;
System.out.println(commonType1.op("zhang"));
System.out.println(commonType2.op(20));
System.out.println(commonType3.op("45"));

4.2 JDK中的函数对象类型

4.2.1 Runnable

@FunctionalInterface
public interface Runnable {
    
    public abstract void run();
}

4.2.2 Callable

@FunctionalInterface
public interface Callable<V> {
    
    V call() throws Exception;
}

4.2.3 Comparator

@FunctionalInterface
public interface Comparator<T> {
    
    int compare(T o1, T o2);
}

4.2.4 Consumer

消费者接口,接受T 返回void

@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);
}

4.2.5 Function

单元函数,Function<T,R> 接收一个T类型的参数,返回一个R类型对象

@FunctionalInterface
public interface Function<T, R> {

    R apply(T t);
}

4.2.6 Predicate

谓词接口,接受一个T 返回boolean
Predicate<T> IntPredicate LongPredicate DoublePredicate

Predicate 除了有谓词的意思,还有断定,断言的意思,它的作用就是声明一个或组合的条件,满足为true,不满足为false

看接口中定义的方法, 接口方法中有一个test接口需要我们实现,其他都是默认方法,包括逻辑与,做逻辑非操作,逻辑或。
还有一个非基本类型(仅指int long daouble)Predicate没有的方法判断两个对象是否相同

@FunctionalInterface
public interface Predicate<T> {

    boolean test(T t);
}

4.2.7 UnaryOperator

单元操作接口 接受一个T类型参数 返回T类型对象

单元操作接口有 UnaryOperator LongUnaryOperator IntUnaryOperator DoubleUnaryOperator

@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {

    static <T> UnaryOperator<T> identity() {
        return t -> t;
    }
}

使用示例:

我们需要对某个整数数组中元素判断是偶数,或者判断元素是完数的操作。

public class LambdaTest {

    public static void main(String[] args) {
        List<Integer> nums = Arrays.asList(1, 2, 4, 5, 6, 7, 9, 10, 14);

        List<Integer> results1 = filter(nums);
        System.out.println(results1);

        // 把规则抽象出来
        List<Integer> results2 = filter(nums, num -> (num & 1) == 0);
        System.out.println(results2);

        List<Integer> results3 = filter(nums, LambdaTest::isPerfectNumber);
        System.out.println(results3);
    }

    static List<Integer> filter(List<Integer> list) {
        List<Integer> results = new ArrayList<>();
        for (Integer num : list) {
            // 规则需要变动
            if ((num & 1) == 0) {
                results.add(num);
            }
        }
        return results;
    }

    static List<Integer> filter(List<Integer> list, Predicate<Integer> predicate) {
        List<Integer> results = new ArrayList<>();
        for (Integer num : list) {
            if (predicate.test(num)) {
                results.add(num);
            }
        }
        return results;
    }

    public static boolean isPerfectNumber(int num) {
        int sum = 0;
        for (int i = 1; i < num; i++) {
            if (num % i == 0) {
                sum = sum + i;
            }
        }
        if (sum == num) {
            return true;
        } else {
            return false;
        }
    }
}

解读: 在第一个filter 方法中,需要把判断条件动态化,其类型为 boolean functionMethod(int num),这样我们就确定了是使用 Predicate的函数接口。

5. 闭包和变量捕获

1)理解闭包

在Java中,Lambda表达式可以访问并操作其外部作用域中的变量,这种现象称为闭包。闭包允许Lambda表达式捕获并存储对其外部作用域中变量的引用。

2)变量捕获的规则

Lambda表达式捕获外部变量时,必须遵守以下规则:

  • 捕获的变量必须是final或实际上是final的(即,一旦初始化后就不能再被修改)。
  • Lambda表达式内部不能修改捕获的变量。

例如,以下代码展示了变量捕获:

int baseNumber = 10;
UnaryOperator<Integer> addToBase = (Integer number) -> number + baseNumber;
// result will be 15
int result = addToBase.apply(5);
System.out.println(result);
for (int i = 0; i < 5; i++) {
    //final int idx = i;
    // 这里 idx 实际上是final
    int idx = i;
    Runnable runnable = () -> System.out.println(Thread.currentThread().getName() + ":" + idx);
    new Thread(runnable).start();
}

6. 柯里化(Carrying)

柯里化的作用是让函数对象分步执行(本质上是利用多个函数对象和闭包),将多个接收参数的函数转换为接收一个参数的函数。

例如:

public class CarryingTest {

    public static void main(String[] args) {
        // 普通表达式
        Cal cal = (a, b) -> a + b;
        int exec = cal.exec(10, 20);
        System.out.println(exec);

        //柯里化,分步执行
        highOrder(a -> b -> a + b);
    }

    static void highOrder(Step1 step1) {
        Step2 step2 = step1.exec(10);
        System.out.println(step2.exec(20));
    }

    interface Step1 {
        Step2 exec(int a);
    }

    interface Step2 {
        int exec(int b);
    }

    interface Cal {
        int exec(int a, int b);
    }
}

Step1是第一个函数对象,它的返回结果 Step2 是第二个函数对象

后者与前面的参数 a 构成了闭包

step1.exec(10) 确定了 a 的值是 10,返回第二个函数对象 step2,a 被放入了 step2 对象的背包记下来了

step2.exec(20) 确定了 b 的值是 20,此时可以执行 a + b 的操作,得到结果 30

7.小结

0

评论区