在本文的第一部分中,我没有从学术角度,也没有从营销角度,而是以一种搬砖码农能看懂的方式,解释了什么是函数式编程语言。更重要的是,我希望我对副作用的定义,能帮助搬砖码农更轻松地在应用程序失控前找到它们。
现在,让我们来看看现实世界的函数式编程语言吧…
编程领域盘点
有了快速定位副作用的技能之后,我们可以查看一个指定函数并指出它的隐藏复杂体。有了函数式编程的定义之后,我们可以盘点一下编程领域,在每个方向上提出一些新观点…
函数式编程不是…
它不是 map
和 reduce
即使你知道函数式语言中都有这些函数,但它们并不是决定语言是函数式的关键。只不过每次在你试图从一系列处理中分离复杂性时,它们都意外地出现罢了。
它不是 λ 函数
同样,你可能知道每一个 FP(函数式编程) 语言都推崇“函数是一等公民”。但它只是你创建一门避免副作用语言的自然产物。它是一个助推器,但不是根本原因。
它不是类型
静态类型检测是一个非常有用的工具,但它不是 FP 的必备条件。Lisp 是最早的函数式编程语言,也是最早的动态语言。
不过静态类型非常有用。Haskell 在对抗副作用时非常优雅地使用了类型系统。但它们不是构成或破坏函数式语言的要素。
一直说,大声说,函数式编程就是讨论副作用。
(当然,也讨论副原因。)
这对于语言来说意味着什么?
JavaScript 不是函数式编程语言
函数式语言会帮你消除副作用,不能消除时会控制副作用。JavaScript 并不符合这条准则。实际上,很容易发现 JavaScript 许多推荐使用副作用的地方。
最明显的就是 this
。这个隐含输入存在每一个函数里。特别不可思议的是 this
的含义改变的是那么任性。即使是 JavaScript 专家也会在定位 this
当前所指对象时遇到困难。从函数式的角度看,this
的神出鬼没应该算设计上的一个败笔。
但是你可以使用 JavaScript 的函数式编程库(例如,Immutable.js),它可以轻松将编程变成函数式风格,而不会改变语言的本质。
(顺便说一句,如果你喜欢这个 JavaScript 领域大受欢迎的 函数式库,那想象一下你会多喜欢一门完全支持函数式风格的语言。)
Java 不是函数式编程语言
Java 绝不是函数式编程语言。Java 1.8 版本加入的那些 λ 函数也不会改变这个事实。Java 是完全站在函数式编程的对立面。它的核心设计原则表明,代码应该以一系列副作用,也就是依赖同时又会改变对象本地状态的方法来实现。
事实上,Java 对函数式编程是不友好的。如果你写了一段无副作用的 Java 代码,这段代码不读取也不改变任何本地对象的状态,那你就是一个糟糕的 Java 程序员。因为 Java 代码不应该那么写啊。无副作用的代码将会处处都是 static
关键字,其他人看了之后会皱起眉头然后把你赶出去。
我不是说 Java 错了。(呃,好吧,我就是这个意思。)但关键是它对副作用的太对与 FP 完全不同。Java 认为局部副作用是优质代码的基石;而 FP 认为它们是魔鬼。
你可以从略微不同的角度看待这个事情。你可以把 Java 和 FP 看做解决副作用问题的两个不同答案。两种模式都认为副作用是个问题,只是解决方法不同。面向对象的解决方案是,“将它们包含在被称之为‘对象’的范围内”,而 FP 的解决方案是“消灭他们”。然而,在实践中 Java 并不仅仅试图封装副作用;还利用它们。如果你没有使用有状态的对象制造副作用,那你就是一个糟糕的 Java 程序员。经常写 static
的人实际上会被鱿鱼。
Scala 身负重任
其实,Scala 是一个非常有挑战性的命题。如果它的目标是统一 OO 和 FP 这个两个世界,那么从副作用来看,我们认为它试图弥合“强制性副作用”与“禁止副作用”之间的鸿沟(对,此处我说的 OO 就是指 Java,在 Scala 这个上下文中,我认为把他们划上等号没有什么不妥。)。这么多相对的观点,我不能确定它们都能被化解。仅通过让对象支持 map
函数是绝对不能统一这两者的。你需要更深入和协调关于副作用的这两个对立立场之间的冲突。
Scala 是否成功协调留给你自己判断。但如果我负责推广 Scala,我会把它的卖点定位为从Java的副作用向纯粹FP的一个平滑过渡。与其统一他们,不如让它成为一条桥梁。事实上,许多人在实践中都是这么认为的。
Clojure
Clojure 对副作用的态度很有意思。它的开发者,Rich Hickey,说过 Clojure “80% 是函数式的”。我想我能解释为什么只有 80%。从一开始,Clojure 就被设计为解决一个特定类型的副作用: 时间。
举例说明,这里有个 Java 笑话:
- 5 加 2 等于多少?
- 7。
- 答对了。 5 加 3 等于多少?
- 8。
- 错。等于 10,因为上一步的 5 变成 7 了,还记得吗?
好吧,这不是很好笑。但关键是,在 Java 中,值不会一成不变。我们可以把 5 赋给某个值,然后调用函数,你会发现那个值不再是 5 了。数学告诉我们 5 永远不可能改变——我们可以调用函数返回一个新值,但是我们不可能影响数字 5 的本质。而 Java 却说值一直在发生着变化,除非把它们放在某个对象里。
这个整数的例子可能看不出来什么,但遇到更大的值时影响也会被放大。还记得第一部分中 InboxQueue
的例子吗? InboxQueue
的状态是一个随着时间变化的值。我们可以认为时间就是 InboxQueue
的一个副原因。
Clojure 一直恶狠狠地盯着时间的副原因。Rich Hickey 的观点(所有观点之一!)是,时间的隐含影响意味着我们无法依赖不变的输入值;如果我们不能依赖这个值,那我们也就不能依赖函数输入,结果就是我们无法依赖任何可预见的或重复的行为。如果连输入值都有副作用,那一切都会有副作用。如果输入值不是纯净的,那程序里的任何东西都不会纯净。
所以 Clojure 拿时间开刀了。它所有的值默认都是不可变的(随时间不可变)。如果你需要一个可变值,Clojure 提供对不可变值的包装器,这些包装器的使用都有着严格的限制:
- 必须使用包装器来改变值。
- 你不能不知不觉地创建一个可变值。你必须使用 guards 显式地标明潜在的副作用。
- 你也不会不知不觉地销毁一个可变值。你必须使用 guards 显式地告知副作用的风险。
- 当你打开一个可变值的包装器,你得到的依然是不可变值。这样就可以轻松摆脱依赖时间的环境,进入一个纯净的环境。
从时间的角度看,Clojure 是函数式编程语言一个极佳的例子。这个语言强烈敌视时间副作用。默认情况下它会消除副作用,但当你必须使用副作用时,它帮你牢牢的控制它,防止副作用污染其他的程序。
Haskell
如果说 Clojure 只是敌视时间,那 Haskell 就是明显的敌意了。但 Haskell 真的也很讨厌副作用,且花费了大量努力来控制它们。
Haskell 与副作用斗争过程中的一个方式就是使用类型。它将所有的副作用都推进类型系统。例如,假设你有一个 getPerson
的函数。在 Haskell 中它看起来像这样:
|
|
你可以把它解读成,“在 Database
的上下文中,传入一个 UUID
,返回一个 Person
”。这很有趣——你只需看一下 Haskell 函数的类型签名就可以确定包含了哪些副作用,没包含哪些副作用。你也可以做如下担保,“该函数不会访问文件系统,因为它没有申明那种类型的副作用。”严格控制(PureScript 进一步采取了这个思想,这也是值得研究)。
同样的,你可以看看如下函数:
|
|
…这个函数就是传入一个 Person
,返回一个 String
。没有别的了,因为如果存在副作用,你会看到它们被锁进了类型签名中。
但也许最有趣的是,下面这个例子:
|
|
这个签名告诉我们,这个版本的 formatName
函数包含数据库相关的副作用。什么鬼?为什么 formatName
函数需要数据库?你的意思是我将需要初始化和模拟出一个数据库就为了测试一个名字格式器?这真的很奇怪。
仅仅通过函数签名,我就知道这个函数设计的有问题。我不需要查看代码,仅从概览就知道这个函数不对劲。多么神奇!
简短地与 Java 的签名对比一下:
|
|
这与哪个 Haskell 版本等价?如果不看函数体,你没有办法知道。它也许是个纯净版,也有可能会访问数据库,还有可能会删除文件系统然后返回,=“去你的老板!”=。Java 的类型签名里关于函数的功能信息和表面信息都很有限。
相反,Haskell 的类型签名则告诉了你很多关于函数设计的东西。又因为它们是由编译器检查,那它们告诉你的东西你能确定都是真的。这就意味着它们是个非常好的架构工具。它们能非常高效地暴露设计缺陷,它们也能暴露代码模式。我会在本文之外也会继续使用 “functor” 和 “monad”,但我依然认为高水准的软件设计模式首先得有高水准的需求分析,但当你有一个高水准的符号系统,那高水准的需求分析也会更容易(我有一些伟大的关于 Clojure 的设计讨论,在这些讨论中我们使用 Haskell 签名来解释我们自己,验证设计的一致性,并总结我们的结论。是的,你没听错,就是 Clojure 的讨论。Haskell 的符号系统具有远超语言本身的价值。)。
Perl
Perl 在任何关于副作用的讨论中都值得被提及。因为它有个神奇的参数,$_
,该参数是“上一个函数调用的返回值(查看 man perlvar
获取准确定义)。”它被许多核心函数库隐式地使用和/或改变。据我所知,这让 Perl 成为了唯一的将全局副作用当做核心特性的语言。
Python
我们来快速浏览一下 Java 中一个基本的副作用模式:
我们如何使这个调用纯净化?因为,this
是一个隐含输入,所以我们要做的就是将它作为一个参数输入:
现在 getName
是一个纯函数。值得一提的是 Python 默认选择了第二种方式。在 Python 中,所有对象方法使用 this
作为第一个参数,按照惯例一般写成 self
:
数据模拟
数据模拟框架一般来说会做两件事情。
第一件事就是帮你初始化输入对象。语言初始化复杂值时越困难,数据模拟越有用。但这不重要。
第二件事才是本次讨论的重点——通过测试给函数初始化正确的副原因,测试之后查看正确的副作用是否发生。
通过副作用的视角,数据模拟是代码不纯的一个标志,在函数式程序员的眼里,数据模拟就是证明某些事情是错误的。我们应该消除副作用,而不是去下载一些库来检查它的正确性。
一个铁杆 TDD/Java 兄弟曾经问我,在 Clojure 中如何模拟数据。答案是,我们不模拟。如果需要模拟数据,说明我们需要重构代码了。
设计缺陷
如果我是小间谍(I-Spy)出一本关于副作用的书,那最容易找到的目标就是无参数输入的函数,与无返回值的函数。
没有参数意味着副原因
无论何时你看到一个没有参数的函数,以下两个说法定有个对的:要么函数是总返回相同的值,要么函数的输入来自其他地方(函数有副原因)。
例如,下面这个函数必须始终返回一个相同的整数值(否则就有副原因):
|
|
没有返回值意味着副作用
无论何时你看到一个没有返回值的函数,那么这个函数要么有副作用,要么调用它就毫无意义:
|
|
根据函数签名,绝对没有理由去调用这个函数。因为它不会给你任何东西。唯一调用它的理由就是,它会悄悄地制造一些神奇的副作用。
概要/总结
对副作用真正的,直观的认识,会改变你对写码的看法。会改变一切你对单独的函数,甚至整个系统架构的看法。会改变你评判编程语言,工具和编程技巧的方式。它会改变一切。从今开始消灭副作用…
原文链接:Which Programming Languages Are Functional?
译者:zhouweicsu