在 Java 中看函数式编程

722 阅读13分钟

在 Java 中看函数式编程

  • 函数式编程开始获得越来越多的关注, 目前当红的编程语言中都对这种编程范式有很强的支持

  • 函数式编程基于数学的理论, 所以函数式编程写出的代码就如同数学的一个个公式, 简洁又自然

  • 对函数式编程支持程度高低的一个重要特征就是否把函数作为该语言的一等公民 (First-class citizen)

    • 函数可以作为其他函数的参数, 可以作为其他函数的返回值, 也可以赋值给变量
  • 在 Java 中

    • Java 8 开始支持函数式编程, 及函数可以作为一等公民
    • 具体函数的定义在 java.util.function 包中
    • 常用的函数
      • Function<T, R> 表示接受一个参数的函数, 输入类型为 T, 输出类型为 R
        • Function 接口只包含一个抽象方法 R apply(T t)
        • 也就是在类型为 T 的输入 t 上应用该函数, 得到类型为 R 的输出
      • Consumer<T> 接受一个输入, 没有输出
        • 抽象方法为 void accept(T t)
      • Supplier<T> 没有输入, 一个输出
        • 抽象方法为 T get()
      • Predicate<T> 接受一个输入, 输出为 boolean 类型
        • 抽象方法为 boolean test(T t)
    • Java 8 中也加入了许多基于函数式编程的标准 API
    • 具体可以阅读这个 repo: 深入理解 Java 函数式编程和 Streams API

相关概念

Pure Function - 纯函数

  • 若一个函数符合以下要求, 则它可能被认为是纯函数

    • 相同的输入总是产生相同的输出 (幂等性 idempotent)
    • 输出不能和输入值以外的任何元素有关 (引用透明)
    • 不改变函数外任何的状态 (无副作用)
  • 纯函数的优点如下

    • 没有副作用, 利于重构
    • 不受外部状态影响, 利于并行处理
    • 相同的输入, 输出永远固定, 利于测试, 利于缓存
  • 在 Java 中

    • 简单的提供一下纯函数和非纯函数的示例

    • // 纯函数
      int f1(int x) {
        return x + 1;
      }
      
      // 非纯函数
      int y;
      int f2(int x) {
        // 引用了外部变量
        return x + y;
      }
      
      // 非纯函数
      int f3(Counter c) {
        // 修改了入参状态
        c.inc();
        return 0;
      }
      
      // 非纯函数
      int f4(int x) {
        // 操作了 IO
        writeFile();
        return 1;
      }
      

Partial Function - 偏函数

  • 部分函数是指仅有部分输入参数被绑定了实际值的函数

  • 在 Java 中

    • 其实有时候我们实现重载 (Overloading) 时就无意中运用到了这种概念

      int sum(int a, int b, int c) {
        return a + b + c;
      }
      
      int sum(int a, int b) {
        return sum(a, b, 0);
      }
      
      // 非重载也可以
      int sumWith3(int a, int b) {
        return sum(a, b, 3);
      }
      

Lambda Calculus - λ 演算

  • 函数式编程的主要理论基础是 λ 演算

  • λ 演算是一种非常简洁的数学符号系统, 但又是图灵完备的, 有和图灵机等价的计算能力

  • 可以阅读这两篇文章: 符号: 抽象、语义, 认知科学家写给小白的 Lambda 演算

  • λ 演算中包含了如下概念

    • 匿名函数 (Anonymous Function)

    • 高阶函数 (High-Order Function)

      • 以另一个函数的作为参数或是返回值的函数
    • 柯里化 (Currying)

      • 任意多参数的函数都可以通过嵌套转换成单参数的高阶函数
    • 闭包 (Closure)

      • 函数和函数内部能访问到的变量 (也叫环境) 的总和, 就是一个闭包

      • var local = 'abc';
        function foo() {
          console.log(local);
        }
        // 上面三行代码在一个立即执行函数中, 组成了一个闭包
        
  • 在 Java 中

    • Java 8 中引入了 Lambda 表达式

      • 简单来说, Lambda 表达式是创建匿名内部类的语法糖
        • 在编译器的帮助下, 可以让开发人员用更少的代码来完成工作
    • 偏函数可以如下体现

      • class Partial <T, U, R> {
          
          BiFunction<T, U, R> needPartial;
        
          Function<T, R> partial =
            t -> needPartial.apply(t, null);
        }
        
    • 柯里化可以如下体现

      • class Currying <T, U, R> {
        
          // 需要将两个入参的函数转换为单入参的高阶函数
          BiFunction<T, U, R> needCurrying;
        
          Function<T, Function<U, R>> currying =
            t ->
              u -> needCurrying.apply(t, u);
        }
        
      • currying 将函数进行返回, 所以它属于高阶函数

      • 对于函数 u -> needCurrying.apply(t, u);

        • 引用到了函数之外的变量 t
        • 所以形成了一个闭包
        • 而且 t 会被编译器转换为一个 final 入参, 因为 Java 中规定了不是 final 的变量无法被共享访问

Memoization - 记忆化

  • 核心思想是以空间换时间, 提高函数的执行性能, 其实就是缓存
  • 例如在计算斐波那契数列的时候, 把每步推出的结果放入 Map 缓存, 避免重复计算

ADT (Algebraic Data Type) - 代数数据类型

  • 维基百科上的定义:In computer programming, especially functional programming and type theory, an algebraic data type is a kind of composite type, i.e., a type formed by combining other types.

    • 也就是指一种组合类型, 由多种其他类型组合而成的类型
    • Algebraic 是体现在哪里呢?Haskell wiki 是这样描述的:
      1. sum is alternation (A | B, meaning A or B but not both)
      2. product is combination (A B, meaning A and B together)
    • 所谓“代数”, 指的就是用符号代替元素和运算, 在 ADT 中, 代数就是 productsum (即积与和 )
  • 在 Java 中

    • Java 中的类可以拥有其他类型的成员变量, 这也是组合了其他的类型, 但这种组合方式只能实现 Product type, 无法实现 Sum type

    • 类的数据是可变的, ADT 的数据都不可变

    • 类的实现包含了它所支持的操作, 但 ADT 只有数据没有操作

    • 枚举算是一种 Sum type

      • enum Shape {
          Circle, Rectangle, Square;
        }
        
      • 对于每一个 Shape 而言, 它只可能是 Circle、Rectangle 或 Square之一

  • ADT 与模式匹配

    • ADT 的一个很明显的优势就是用来结合模式匹配
    • 类似 Java 中的 switch case 语句, 可以针对给定的 ADT 不同 case 值情况进行判断处理
  • Abstract Data Types - 抽象数据类型的区别

    • 抽象数据类型指的是数学模型以及定义在此模型上的一系列操作, 也就是一个数据元素集合以及在这些数据上的操作
    • 比如树, 图, 队列, 列表这些抽象数据类型
    • 对应到 Java 中的 List、Set、Map, 其实就是 Java 里提供的对 “列表”、“集合”、“关联数组”的实现

Type Class - 类型类

  • 如果一个类型是某个类型类的实例, 那么这个类型必须实现所有该类型类所定义的行为
    • 可以理解为 Java 中的 interface 和具体实例的关系
  • 下面介绍几个函数式编程中常见的类型类, 可以先看这篇图解: Functors, Applicatives, And Monads In Pictures

Functor - 函子

  • Functor 可以如下表示

    • interface Functor<T> {
        <R> Functor<R> map(Function<T, R> f);
      }
      
    • Functor 提供的唯一操作是 map() (在 Haskell 中称为 fmap)

      • 它的输入是一个函数 f
      • 这个函数会接收盒子 (也就是封装了值的 Functor) 中的任何东西, 并对其进行转换, 最后将结果包装为第二个Functor
    • Functor 经常被比作成装有实例 T 的箱子, 而唯一使用这个值的方法就是转换它

  • 在 Java 中

    • StreamOptional 都是 Functor, 都具有一个进行转换的 map 方法

    • Optional<String> strNum = Optional.of("1");
      Optional<Integer> num = strNum.map(Integer::valueOf);
      
      Stream<String> strNum = Stream.of("1", "2", "3");
      Stream<Integer> num = strNum.map(Integer::valueOf);
      

Applicative - 可应用函子

  • Applicative 可以如下表示

    • interface Applicative<T> extends Functor<T> {
        Applicative<T> of(T t);
        <R> Applicative<R> liftA(Applicative<Function<T, R>> applicativeFunc);
      }
      
    • 首先 Applicative 是 Functor, 然后还额外提供了两个操作:

      • of() (叫做 unit, 在 Haskell 中称为 pure)
      • 接收任意类型的值为参数, 返回包裹了该值的 Applicative 值
      • liftA() (在 Haskell 中称为 <*>)
      • map 有些像, 唯一的区别是 map 的输入是一个普通函数, 而 liftA 的输入是把普通函数用 Applicative 包裹
  • 在 Java 中

    • StreamOptional 中并没有找到类似的 liftA 函数, 我们来自己实现一个

    • static class ApplicativeOptional<T> {
      
        private final Optional<T> value;
      
        private ApplicativeOptional(Optional<T> value) {
          this.value = value;
        }
      
        public <R> ApplicativeOptional<R> map(Function<T, R> f) {
          return new ApplicativeOptional<>(value.map(f));
        }
      
        static public <T> ApplicativeOptional<T> of(T t) {
          return new ApplicativeOptional<>(Optional.ofNullable(t));
        }
      
        public <R> ApplicativeOptional<R> liftA(ApplicativeOptional<Function<T, R>> applicativeFunc) {
          if (applicativeFunc.value.isPresent()) {
            return new ApplicativeOptional<>(value.map(applicativeFunc.value.get()));
          }
          return new ApplicativeOptional<>(Optional.empty());
        }
      
        public static void main(String[] args) {
          ApplicativeOptional<String> strNum = ApplicativeOptional.of("1");
          ApplicativeOptional<Function<String, Integer>> strNumFunc = ApplicativeOptional.of(Integer::valueOf);
          ApplicativeOptional<Integer> num = strNum.liftA(strNumFunc);
        }
      }
      

Monad - 单子

  • Monad 可以如下表示

    • interface Monad<T> extends Applicative<T> {
        <R> Monad<R> flatMap(Function<T, Monad<R>> f);
      }
      
    • 首先 Monad 是 Applicative, 然后还额外提供了一个操作 flatMap() (也叫 bind, 在 Haskell 中称为 >>=)

      • map() 类似, 只是输入的函数 f 所返回的是用 Monad 包裹的值
  • 在 Java 中

    • StreamOptional 都有 flatMap 方法

    Optional<String> strNum = Optional.of("1");
    Function<String, Optional<Integer>> func = s -> Optional.of(Integer.parseInt(s));
    Optional<Integer> num = strNum.flatMap(func);
    
    Stream<String> strNum = Stream.of("1", "2", "3");
    Function<String, Stream<Integer>> func = s -> Stream.of(Integer.parseInt(s), 1, 2, 3, 4, 5);
    Stream<Integer> num = strNum.flatMap(func);
    

总结

  • Functor 类型解决的是如何将一个 in context 的 value 输入到普通 function 中, 并得到一个 in context 的 value

  • Applicative 类型解决的是如何将一个 in context 的 value 输入到只返回普通 value 的 in context 的 function 中, 并得到一个 in context 的 value

  • Monad 类型解决的是如何将一个 in context 的 value 输入到只返回 in context 的 value 的 function 中, 并得到一个 in context 的 value

范畴论

  • 在范畴论里, 一个范畴 (category) 由三部分组成:

    1. 一系列的对象 (object)
      • 数学上的群, 环, 甚至简单的有理数, 无理数等都可以归为一个对象
      • 对应到编程语言里, 可以理解为一个类型, 比如说整型, 布尔型
      • 类型事实上可以看成是值的集合, 例如整型就是由 0, 1, 2...等组成的, 因此范畴论里的对象简单理解就可以看成是值(value) 的集合
    2. 一系列的态射 (morphism)
      • 态射指的是一种映射关系
      • 态射的作用就是把一个对象 A 里的值 va 映射为另一个对象 B 里的值 vb, 这和代数里的映射概念是很相近的, 因此也有单射, 满射等区分
      • 态射的存在反映了对象内部的结构, 这是范畴论用来研究对象的主要手法:对象内部的结构特性是通过与别的对象的关系反映出来的, 动静是相对的, 范畴论通过研究关系来达到探知对象的内部结构的目的
    3. 一个组合 (composition) 操作符, 用点 (.) 表示, 用于将态射进行组合
      • 组合操作符的作用是将两个态射进行组合, 例如, 假设存在态射 f: A -> B, g: B -> C, 则 g.f : A -> C
  • 一个结构要想成为一个范畴, 除了必须包含上述三样东西, 它还要满足以下三个限制:

    1. 态射要满足结合律, 即 f.(g.h) = (f.g).h
    2. 态射在这个结构必须是封闭的, 也就是, 如果存在态射 f, g, 则必然存在 h = f.g
    3. 对结构中的每一个对象 A, 必须存在一个单位态射 Ia: A -> A, 对于单位态射, 显然, 对任意其它态射 f, f.I = f
  • “一个单子 (Monad )说白了不过就是自函子范畴上的一个幺半群而已 (A monad is just a monoid in the category of endofunctors)”

  • 要理解上面这句话所需要知道的几个知识点

    • 半群 (semigroup) 与幺半群 (monoid)

      • 数学里定义的群 (group): G 为非空集合, 如果在 G 上定义的二元运算 *, 满足以下情况则称 G 是群

        1. 封闭性 (Closure) : 对于任意 a, b ∈ G, 有 a*b ∈ G
        2. 结合律 (Associativity) : 对于任意 a, b, c ∈ G, 有 (a*b) * c = a * (b*c )
        3. 幺元 (Identity) : 存在幺元 e, 使得对于任意 a ∈ G, e*a = a*e = a
        4. 逆元: 对于任意 a ∈ G, 存在逆元 a^-1, 使得 a^-1 * a = a * a^-1 = e
      • 如果仅满足封闭性和结合律, 则称 G 是一个半群 (Semigroup)

      • 如果仅满足封闭性, 结合律并且有幺元, 则称 G 是一个幺半群 (Monoid)

      • 用 Java 代码实现半群和幺半群

        // 半群
        interface Semigroup<T> {
          // 二元运算
          T append(T a, T b);
        }
        
        class IntSemigroup implements Semigroup<Integer> {
          @Override
          public Integer append(Integer a, Integer b) {
            return a + b;
          }
        }
        
        // 幺半群
        interface Monoid<T> extends Semigroup<T> {
          // 幺元
          T zero();
        }
        
        class IntMonoid implements Monoid<Integer> {
          @Override
          public Integer append(Integer a, Integer b) {
            return a + b;
          }
          @Override
          public Integer zero() {
            return 0;
          }
        }
        
        class StringMonoid implements Monoid<String> {
          @Override
          public String append(String a, String b) {
            return a + b;
          }
          @Override
          public String zero() {
            return "";
          }
        }
        
        class ListMonoid<T> implements Monoid<List<T>> {
          @Override
          public List<T> append(List<T> a, List<T> b) {
            List<T> ret = new ArrayList<>(a);
            ret.addAll(b);
            return ret;
          }
          @Override
          public List<T> zero() {
            return new ArrayList<>();
          }
        }
        
    • 自函子 (Endofunctor)

      • 先看看自函数 (Endofunction), 自函数是入参和出参的类型一致, 比如 (x:Int) => x * 2(x:Int) => x * 3 都属于自函数

      • 再看看恒等函数 (Identity function), 恒等函数是什么也不做, 传入什么参数返回什么参数, 它属于自函数的一种特例

      • 自函子映射的结果是自身, 下图是一个简单的情况:

        • img

        • 假设这个自函子为 F, 则对于 F[Int] 作用的结果仍是 Int, 对于函数 f: Int=>String 映射的结果 F[f] 也仍是函数 f

          • 所以这个自函子实际是一个恒等函子 (Identity functor) (自函子的一种特例), 即对范畴中的元素和关系不做任何改变
        • 把 haskell 里的所有类型和函数都放到一个范畴里, 取名叫 Hask, 那么对于这个 Hask 的范畴, 它看上去像是这样的:

          • img

          • A, B 代表普通类型如 String, Int, Boolean 等, 这些 (有限的) 普通类型是一组类型集合, 还有一组类型集合是衍生类型 (即由类型构造器与类型参数组成的), 这是一个无限集合 (可以无限衍生下去)

            • 这样 范畴 Hask 就涵盖了 haskell 中所有的类型
          • 对于范畴 Hask 来说, 如果有一个函子 F, 对里面的元素映射后, 其结果仍属于Hask, 比如我们用 List 这个函子:

          • List[A], List[List[A]], List[List[List[A]]]...
            
          • 发现这些映射的结果也是属于 Hask 范畴 (子集), 所以这是一个自函子, 实际上在 Hask 范畴上的所有函子都是自函子

  • 现在再来看 "一个单子 (Monad) 说白了不过就是自函子范畴上的一个幺半群而已" 这句话

    • 其实真正的原话是 "总而言之, 一切范畴 X 上的 Monad 都不过是范畴 X 上的自函子所构成范畴中的一个幺半群, 二元运算被自函子的组合所代替, 幺元则取恒等函子 (All told, a monad in X is just a monoid in the category of endofunctors of X, with product × replaced by composition of endofunctors and unit set by the identity endofunctor.)"
    • 可以结合这个回答进行理解

关于 Monad 设计模式

  • 可以参考 repo: java-design-patterns/monad/
    • 设计了一个 monad 进行数据校验
    • 个人认为这个并不算是严格意义上的 monad, 因为缺少了 bind 函数
  • 当然也可以考虑使用 Java 自带的 monad 或第三方库 (如 Vavr) 中提供的 monad

参考

Scala 与 Algebraic data type

Java中的Functor与monad

如何解释 Haskell 中的单子(Monad)?

我所理解的monad(1):半群(semigroup)与幺半群(monoid)

我所理解的monad(5):自函子(Endofunctor)是什么