Cats(二):引用透明性和等式推理
上一篇文章 介绍了函数式编程的思维,这一篇我们来了解下函数式编程的魅力。
我们已经说过,函数式编程最关键的事情就是在做「组合」。然而,这种可被组合的「函数式组件」到底是怎样的?换句话说,它们必定符合某些规律和原则。
当前我们已知晓的是,函数式编程是近似于数学中推理的过程。那么我们思考下,数学中的推理具备怎样的特点?
很快,我们便可以发现数学推理最大的一个优点 —「只要推理逻辑正确,结果便千真万确」。
其实,这也便是本篇文章要描述的函数式编程的一个很大的优势,所谓的「等式推理」。
那么,我们再进一步探讨,「是所谓怎样的原则和方法,才能使函数式编程具备如此特点?」。
引用透明性
答案便是 引用透明性,它在数学和计算机中都有近似的定义。
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 | def f(x: Int, y: Int) = x + y |
其实我们完全可以用 5 来直接替代 f(2, 3)
,而对结果不会产生任何影响。
这个非常容易理解,那么反过来怎样才算「非引用透明性」呢?
再来举个例子:
1 | var a = 1 |
在以上代码中,我们会发现多次调用 count(1)
得到的结果并不相同,显然这是受到了外部变量 a
的影响,我们把这个称为 副作用。
副作用
简单理解,「副作用」就是 changing something somewhere,例如:
- 修改了外部变量的值
- IO 操作,如写数据到磁盘
- UI 操作,如修改了一个按钮的可操作状态
因此,不难发现副作用的产生往往跟「可变数据」以及「共享状态」有关,常见的例子如我们在采用多线程处理高并发的场景,「锁竞争」就是一个明显的例子。然而,在函数式编程中,由于我们推崇「引用透明性」以及「数据不可变性」,我们甚至可以对「两个返回异步结果的函数」进行组合,从而提升了代码的推理能力,降低了系统的复杂程度。
总结而言,引用透明性确保了「函数式组件」的独立性,它与外界环境隔离,可被单独分析,因此易于组合和推理。
注:这里的异步操作函数,举个例子可以是数据库的读写操作,我们会在后面的文章中介绍如何实现。
不可变性
以上我们已经提到「不可变性」是促进引用透明性一个很关键的特性。在 Haskell 中,任何变量都是不可变的,在 Scala 中我们可以使用 val
(而不是 var
)来声明不可变变量。
显然,越来越多的编程语言都支持这一特性。如 Swift 中的 let
,ES6 中的 const
。以及一些有名的开源项目,如 Facebook 的 Immutable.js。
那么,关于「引用透明性」的部分我们是否已经讲完了呢?
等等,前面提到「引用透明性」的关键点之一,就是返回相同的计算结果。这里,我们打算再深入一步,研究下什么才是所谓「相同的计算结果」,它仅仅指的就是返回相同的值吗?
代换模型
我们来看下这段代码,它符合我们所说的引用透明性:
1 | def f1(x: Int, y: Int) = x |
用 Scala 开发的小伙伴看了相当气愤,这是一段自杀式的代码,如果我们执行了它,那么 f2
必然被不断调用,从而导致死循环。
似乎已经有了答案,所谓「相同的计算结果」,还可以是死循环。。。
这时,一个会 Haskell 的程序员路过,迷之微笑,花了 10 秒钟翻译成了以下的版本:
1 | f1 :: Int -> Int -> Int |
运行 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 | object Pure { |
如此,当我们的程序调用 Pure.println("I am a IO operation.")
的时候,它仅仅只是返回一个可以进行 println
的函数,它是惰性的,也是可替代的。这样,我们就可以在程序中将这些 IO 操作进行组合,最后再执行它们。
也许你还会思考,这里的 thunk 函数何时会被调用,以及如果要用以上的思路开发业务,我们该如何避免在业务过程中避免这些随机大量的 thunk 函数。
关于这些,我们会在后续的文章中继续介绍,它跟所谓的 Free Monad 有关。
总结
第二篇文章进一步探索了函数式编程的几个特点和优势,似乎至此仍然没有提及 Cats。不着急,在下一篇中,我们将步入正题,我们计划先从「高阶类型」谈起。