上一篇文章 介绍了函数式编程的思维,这一篇我们来了解下函数式编程的魅力。

我们已经说过,函数式编程最关键的事情就是在做「组合」。然而,这种可被组合的「函数式组件」到底是怎样的?换句话说,它们必定符合某些规律和原则。

当前我们已知晓的是,函数式编程是近似于数学中推理的过程。那么我们思考下,数学中的推理具备怎样的特点?

很快,我们便可以发现数学推理最大的一个优点 —「只要推理逻辑正确,结果便千真万确」。

其实,这也便是本篇文章要描述的函数式编程的一个很大的优势,所谓的「等式推理」。

那么,我们再进一步探讨,「是所谓怎样的原则和方法,才能使函数式编程具备如此特点?」。

引用透明性

答案便是 引用透明性,它在数学和计算机中都有近似的定义。

An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program’s behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions.

简单地,我们可以理解为「一个表达式在程序中可以被它等价的值替换,而不影响结果」。如果一个函数的输入相同,对应的计算结果也相同,那么它就具备「引用透明性」,它可被称为「纯函数」。

举个例子:

1
2
3
def f(x: Int, y: Int) = x + y

println(f(2, 3))

其实我们完全可以用 5 来直接替代 f(2, 3),而对结果不会产生任何影响。

这个非常容易理解,那么反过来怎样才算「非引用透明性」呢?

再来举个例子:

1
2
3
4
5
6
7
8
9
var a = 1

def count(x: Int) = {
a = a + 1
x + a
}

count(1) // 3
count(1) // 4

在以上代码中,我们会发现多次调用 count(1) 得到的结果并不相同,显然这是受到了外部变量 a 的影响,我们把这个称为 副作用

副作用

简单理解,「副作用」就是 changing something somewhere,例如:

  • 修改了外部变量的值
  • IO 操作,如写数据到磁盘
  • UI 操作,如修改了一个按钮的可操作状态

因此,不难发现副作用的产生往往跟「可变数据」以及「共享状态」有关,常见的例子如我们在采用多线程处理高并发的场景,「锁竞争」就是一个明显的例子。然而,在函数式编程中,由于我们推崇「引用透明性」以及「数据不可变性」,我们甚至可以对「两个返回异步结果的函数」进行组合,从而提升了代码的推理能力,降低了系统的复杂程度。

总结而言,引用透明性确保了「函数式组件」的独立性,它与外界环境隔离,可被单独分析,因此易于组合和推理。

注:这里的异步操作函数,举个例子可以是数据库的读写操作,我们会在后面的文章中介绍如何实现。

不可变性

以上我们已经提到「不可变性」是促进引用透明性一个很关键的特性。在 Haskell 中,任何变量都是不可变的,在 Scala 中我们可以使用 val (而不是 var)来声明不可变变量。

显然,越来越多的编程语言都支持这一特性。如 Swift 中的 let,ES6 中的 const。以及一些有名的开源项目,如 Facebook 的 Immutable.js

那么,关于「引用透明性」的部分我们是否已经讲完了呢?

等等,前面提到「引用透明性」的关键点之一,就是返回相同的计算结果。这里,我们打算再深入一步,研究下什么才是所谓「相同的计算结果」,它仅仅指的就是返回相同的值吗?

代换模型

我们来看下这段代码,它符合我们所说的引用透明性:

1
2
3
def f1(x: Int, y: Int) = x
def f2(x: Int): Int = f2(x)
f1(1, f2(2))

用 Scala 开发的小伙伴看了相当气愤,这是一段自杀式的代码,如果我们执行了它,那么 f2 必然被不断调用,从而导致死循环。

似乎已经有了答案,所谓「相同的计算结果」,还可以是死循环。。。

这时,一个会 Haskell 的程序员路过,迷之微笑,花了 10 秒钟翻译成了以下的版本:

1
2
3
4
5
f1 :: Int -> Int -> Int
f1 x y = x

f2 :: Int -> Int
f2 x = f2 x

运行 ghci 载入函数后调用 f1 1 (f2 2),你就会发现:纳尼!竟然成功返回了结果 1。这到底是怎么回事呢?

应用序 vs 正则序

也许相当多开发的同学至今未曾思考过这个问题:编程语言中的表达式求值策略是怎样的?

其实,编程语言中存在两种不同的代换模型:应用序正则序

大部分我们熟悉如 Scala、C、Java 是「应用序」语言,当要执行一个过程时,就会对过程参数进行求值,这也是上述 Scala 代码导致死循环的原因,当我们调用 f1(1, f2(2)) 的时候,程序会先对 f2(2) 进行求值,从而不断地调用 f2 函数。

然而,Haskell 采用了不一样的逻辑,它会延迟对过程参数的求值,直到确实需要用到它的时候,才进行计算,这就是所谓的「正则序」,也就是我们常说的 惰性求值。当我们调用 f1 1 (f2 2) 后,由于 f1 的过程中压根不需要用到 y,所以它就不会对 f2 2 进行求值,直接返回 x 值,也就是 1。

注:对以上情况进行描述的角度,还有你可能知道的「传值调用」和「引用调用」。

那么这样做到底有什么好处呢?

惰性求值

Haskell 是默认采用惰性求值的语言,在其它一些语言中(如 Scala 和 Swift),我们也可以利用 lazy 关键字来声明惰性的变量和函数。

惰性求值可以带来很多优势,如部分人知晓的「无限长的列表结构」。当然,它也会制造一些麻烦,如使程序求值模型变得更加复杂,滥用惰性求值则会导致效率下降。

这里,我们并不想深究惰性求值的利和弊,这并不是一个容易的问题。那么,我们为什么要介绍惰性求值呢?

这是因为,它与我们一直在探讨的「组合」存在些许联系。

如何组合副作用

函数式编程思维,就是抽象并组合一切,包括现实中的副作用。

常见的副作用,如 IO 操作,到底如何组合呢?

来一段代码:

1
println("I am a IO operation.")

显然,这里的 println 不是个纯函数,它不利于组合。我们该如何解决这个问题?

先看看 Haskell 中的惰性求值是如何实现的。

Thunk

A thunk is a value that is yet to be evaluated. It is used in Haskell systems, that implement non-strict semantics by lazy evaluation.

Haskell 中的惰性求值是靠 Thunk 这种机制来实现的。我们也可以在其它编程语言中通过提供一个 thunk 函数来模拟类似的效果。

要理解 Thunk 其实很容易,比如针对以上的非纯函数,我们就可以如此改造,让它变得 “lazy”:

1
2
3
4
object Pure {
def println(msg: String) =
() => Predef.println(msg)
}

如此,当我们的程序调用 Pure.println("I am a IO operation.") 的时候,它仅仅只是返回一个可以进行 println 的函数,它是惰性的,也是可替代的。这样,我们就可以在程序中将这些 IO 操作进行组合,最后再执行它们。

也许你还会思考,这里的 thunk 函数何时会被调用,以及如果要用以上的思路开发业务,我们该如何避免在业务过程中避免这些随机大量的 thunk 函数。

关于这些,我们会在后续的文章中继续介绍,它跟所谓的 Free Monad 有关。

总结

第二篇文章进一步探索了函数式编程的几个特点和优势,似乎至此仍然没有提及 Cats。不着急,在下一篇中,我们将步入正题,我们计划先从「高阶类型」谈起。