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