在下函数式编程,有何贵干?

时间:2022-04-28
本文章向大家介绍在下函数式编程,有何贵干?,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

本文简单介绍了一下函数式编程的各种基本特性,希望能够对于准备使用函数式编程的人起到一定入门作用。

函数式编程,一个一直以来都酷,很酷,非常酷的名词。虽然诞生很早也炒了很多年但是一直都没有造成很大的水花,不过近几年来随着多核,分布式,大数据的发展,函数式编程已经广泛投入到了实战中。

然而现实中还是有不少人不太了解这种编程范式,觉得这仅仅是一个逼格较高的名词。我们这里就来简单介绍一下这个举手投足都充满酷劲的小东西。

本文之后的代码主要以 Java 和 Scala 为主,前者说明如何在非函数式语言中实现函数式风格,后者说明在函数式语言中是如何做的。代码比较简单,无论你是否懂这两门语言,相信都能很容易看懂。此外由于函数式编程这几个词太长了,以下都以 FP 进行简写。

特性

函数是一等公民

所谓的函数是一等公民指的是在 FP 中,函数可以作为直接作为变量的值。

Scala

val add = (x: Int, y: Int) => x + y
add(1, 2)

以上我们定义了一个负责将两个整型相加的匿名函数并赋值给变量 add,并且直接将这个变量当前函数进行调用,这在大部分面向对象的语言中你都是无法直接这样做的。

Java

interface Adder {
    int add(int x, int y);
}
Adder adder = (x, y) -> x + y;
adder.add(1, 2);

由于 Java 并不是函数式语言,所以无法直接将函数赋值给变量,因此以上例子中我们使用 SAM 转换来实现近似功能。

闭包

闭包是一种带有自由变量的代码块,其最根本的功能就是能够扩大局部变量的生命周期。闭包相信很多人都很熟悉,在 JavaScript 中闭包无处不在,是一种很好用但是一不注意就会掉坑里的特性。

Scala

var factor = 10
factor = factor * 10
val multiplier = (x: Int) => x * factor

以上例子中函数体使用了两个参数,其中 x 只是很普通的函数参数,而 factor 则是函数体外定义的一个局部变量,且该变量可以任意进行修改,所以对 factor 的引用使该函数变成了一个闭包。

Java

int factor = 10;
//        factor = factor * 10;
Multiplier multiplier = (x) -> x * factor;

在 Java 中匿名函数只能引用外部的 final 变量,Java 8 虽然可以省略 final 关键字,但是实际还是没有任何变化,所以第二句语句必须注释掉。这也就是说在 Java 中实际是无法使用自由变量的,因此 Java 是否有真正的闭包一直都是一个争论点,这里就不多牵扯了。

惰性求值 Lazy Evaluation

一般而言成员变量在实例创建时就会被初始化,而惰性求值可以将初始化的过程延迟到变量的第一次使用,对于成员变量的值需要经过大量计算的类来说可以有效加快实例的创建过程。

Scala

lazy val lazyField = {
    var sum = 0
    for (i <- 1 to 100) {
        sum += i
    }
    sum
}

在 Scala 中是通过关键字 lazy 来声明惰性求值的。在以上例子中定义了一个从 1 加到 100 的惰性变量,在第一次访问该变量时这个计算过程才会被执行。

Java


Supplier<Integer> lazyField = () -> {
    int sum = 0;
    for (int i = 1; i <= 100; i++) {
      sum += i;
    }
    return sum;
};

Java 虽然在语言层面没有提供该功能,但是可以通过 Java 8 提供的 Supplier 接口来实现同样的功能。

尾递归 Tail Recursion

递归大家都知道,就是函数自己调用自己。

定义一个递归函数

def addOne(i: Int) {
    if (i > 3) return
    println(s"before $i")
    addOne(i + 1)
    println(s"after $i")
}

调用以上函数并传入参数 3 会打印如下语句

before 1
before 2
before 3
after 3
after 2
after 1

这就是递归的基本形式。在每次递归调用时程序都必须保存当前的方法调用栈,即调用 addOne(2) 时程序必须记住之前是如何调用 addOne(1) 的,这样它才能在执行完 addOne(2) 后返回到 addOne(1) 的下一条语句并打印 after 1。因此在 Java 等语言中递归一来影响效率,二来消耗内存,调用次数过多时会引起方法栈溢出。

而尾递归指的就是只在函数的最后一个语句调用递归。这样的好处是可以使用很多 FP 语言都支持的尾递归优化或者叫尾递归消除,即递归调用时直接将函数的调用者传入到下一个递归函数中,并将当前函数弹出栈中,在最后一次递归调用完毕后直接返回传入的调用者处而不是返回上一次递归的调用处。

用简单的示意图即是由原来的

line xxx, call addOne -> addOne(1) -> addOne(2) -> addOne(3) -> addOne(2) -> addOne(1) -> line xxx

优化为

line xxx, call addOne -> addOne(1) -> addOne(2) -> addOne(3) -> line xxx

纯函数 Pure Function

纯函数并不是 FP 的特性,而是 FP 中一些特性的集合。所谓的纯函数简单来讲就是函数不能有副作用,保证引用透明。即函数本身不会修改参数的值也不会修改函数外的变量,无论执行多少次,同样的输入都会有同样的输出。

定义三个函数

def add(x: Int, y: Int) = x + y


def clear(list: mutable.MutableList): Unit = {
  list.clear()
}


def random() = Random.nextInt()

以上代码中定义了三个函数,其中 add() 符合纯函数的定义;clear() 会清除传入的 List 的所有元素,所以不是纯函数;random() 无法保证每次调用都产生同样的输入,所以也不是纯函数。

高阶函数 High-Order Function

高阶函数指一个函数的参数是另一个函数,或者一个函数的返回值是另一个函数。

参数为函数

def assert(predicate: () => Boolean) =
    if (!predicate())
        throw new RuntimeException("assert failed")


assert(() => 1 == 2)

以上函数 assert() 接收一个匿名函数 () => 1 == 2 作为参数,本质上是应用了传名调用的特性。

返回值为函数

def create(): Int => Int = {
  val factor = 10
  (x: Int) => x * factor
}

集合操作 Collection

集合操作可以说是 FP 中最常用的一个特性,激进的 FP 拥护者甚至认为应该使用 foreach 替代所有循环语句。这些集合操作本质上就是多个内置的高阶函数。

Scala

val list = List(1, 2, 3)
list.map(i => {
  println(s"before $i")
  i * 2
}).map(i => i + 1)
  .foreach(i => println(s"after $i"))

以上定义了一个包含三个整形的列表,依次对其中每个元素乘以 2 后再加 1,最后进行打印操作。输出结果如下:

before 1
before 2
before 3
after 3
after 5
after 7

可以看到 FP 中的集合操作关注的是数据本身,至于如何遍历数据这一行为则是交给了语言内部机制来实现。相比较 for 循环来说这有两个比较明显的优点:1. 一定程度上防止了原数据被修改,2. 不用关心遍历的顺序。这样用户可以在必要时将操作放到多线程中而不用担心引起一些副作用,编译器也可以在编译时自行对遍历进行深度优化。

Java

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.stream()
        .map(i -> {
            System.out.println("before " + i);
            return i * 2;
        }).map(i -> i + 1)
        .forEach(i -> System.out.println("after " + i));

输出

before 1
after 3
before 2
after 5
before 3
after 7

可以从以上输出看到对于集合操作 Scala 和 Java 的实现完全不一样。

Scala 中一个操作中的所有数据完成处理后才流向下一个操作,可以看做每个操作都是一个关卡。而 Java 则是默认使用了惰性求值的方式,并且概念非常类似 Spark。其各种集合操作主要分为两种: transformation 和 action。transformation 即转换操作,所有返回 Stream 对象的函数都是 transformation 操作,该操作不会立即执行,而是将执行步骤保存在 Stream 对象中。action 即执行操作,action 没有返回值,调用后会立即执行之前 Stream 对象中保存的所有操作。 map() 这样的就是 transformation 操作,forEach() 就是 action 操作。

柯理化 Currying

柯里化指的是将一个接收多个参数的函数分解成多个接收单个参数的函数的一种技术。

比如说有这样一个普通的函数

def minus(x: Int, y: Int) = x - y

柯理化后就变成以下形式,一个减法操作被分割为两部分

def minusCurrying(x: Int)(y: Int) = x - y

调用以上两个函数

minus(5, 3)
minusCurrying(5)(3)

部分应用 Function Partial Application

函数的部分应用指的是向一个接收多个参数的函数传入部分参数从而获得一个接收剩余参数的新函数的技术。

比如说有这样一个包含多个参数的函数

def show(prefix: String, msg: String, postfix: String) = prefix + msg + postfix

获得部分应用函数

val applyPrefix = show("(", _: String, _: String)
println(applyPrefix("foo", ")")) //  (foo)


val applyPostfix = show(_: String, _: String, ")")
println(applyPostfix("(", "bar")) //  (bar)

以上 applyPrefix() 是应用了 show() 的第一个参数的新函数,applyPostfix() 是应用了 show() 的最后一个参数的新函数。

偏函数 Partial Function

函数指对于所有给定类型的输入,总是存在特定类型的输出。

偏函数指对于某些给定类型的输入,可能没有对应的输出,即偏函数无法处理给定类型范围内的所有值。

定义一个偏函数

val isEven: PartialFunction[Int, String] = {
  case x if x != 0 && x % 2 == 0 => x + " is even"
}

以上 isEven() 只能处理偶数,对于奇数则无法处理,所以是一个偏函数。

偏函数可以用于责任链模式,每个偏函数只处理部分类型的数据,其余类型的数据由下一个偏函数进行处理。

val isOdd: PartialFunction[Int, String] = {
    case x if x % 2 != 0 => x + " is odd"
}
val other: PartialFunction[Int, String] = {
    case _ => "else"
}
val partial = isEven orElse isOdd orElse other
println(partial(3)) //  3 is odd
println(partial(0)) //  else

尾声

除了以上特性,函数式编程中还有 Monoid,SemiGroup 等比较难以理解的概念,本文暂时不牵扯那么深,留待有兴趣的人自行调查。最后我想说的是使用函数式编程的确很坂本,但是多了解一种编程范式对于从码农进化为码农++还是很有帮助的。

如果你对以上代码有兴趣的话可以直接访问 https://github.com/SidneyXu/JGSK。