原文:Functional Programming: Concepts, Idioms and Philosophy
函数式编程是作为大多数现代问题的解决方案被提出的,例如并发性和可伸缩性。对于一些人而言,它是一种神秘的概念,仅适用于Erlang, Haskell以及一些其他奇怪的语言,这些语言要不是太复杂,要不就是不相关的。这简直不正确,所以我将展示如何在非函数式语言上应用一些函数式编程。
我将首先定义“函数式编程”的真正含义,然后说明常见惯用语,并且比较语法,来解构函数式范式。最后,我将展示如何对非函数式进行相关改变,以遵循函数式编程理念。
请注意,这篇文章主要是为了那些之前从未有过函数式编程的人,并且这里的目标是将函数式编程作为一种实践进行呈现,不仅针对于语言特性,还作为一种理念,这种理念,在一定程度上,可以在任何语言中遵循,提高代码的安全性,并为非函数式语言带来了一些函数式编程的优势。
函数式编程最简单的定义是纯函数 (或者更简单的,确定性函数)。
在另一方面,这可以有多种含义,并且必须对其仔细分析。这也导致函数式编程的一些其他重要的规则,例如不可变变量和组合函数。
作为一般规则,如你正在编写一个数学函数那样考虑你的代码:
- 它的结果应该根据某些不作为参数的东西不同而不同吗?不是
- 它应该改变应用的任何一个参数吗?不是
- 它应该改变其范围之外的东西吗?不是
- 对于相同应用的参数,它的结果应该总是相同的吗?是滴。
当你读到这一点时,如果你想起有个代码并不符合,那么我恐怕它不是函数式的。为什么呢?
函数在那些语言中是如此特殊,以致于它们成为了一等公民. 它们能够作为“变量”被传递,能够部分用于组成新的函数。稍后我会描述一些惯用语,那时我将更多的讨论到这点。这里要记住的是,函数被设计为可重用,可组合的。 任何副作用或外部的干扰会使得函数不可预知以及难以重用。
状态系统是非常难以并行化的,必须实现互斥,锁,信号量和其他形式的访问限制,,以使代码更安全。函数式编程只是打击到了易变性的概念; 相反,在函数式语言进行编码时,你编写函数来获取所需要的值。
这是最难以描述的概念之一,来自于一个非函数式的语言。它的好处是,不变性迫使你重新思考你的问题,以拥有一个关于该问题的(函数)正确的解决方法。一旦你理解函数式编程比起你如何设计你的数据,它更多关于你如何设计你的功能,这应该不是一个问题。
毕竟,值是你的数据的逻辑抽象,而函数是你的业务的逻辑抽象。
如果你从未见过函数式编程的源代码以及它的样子,那么从面向对象到函数式编程可能是复杂的。你可能不会看到从前使用过的for
和if
命令,相反,是map
, reduce
, filter
和flatten
。你将了解到(没有那么复杂) 单子(Monad), 函子(Functor)和代数数据类型。这些都应该意味着什么,以及为什么不能使用旧的结构?
对于那些在这里提出的惯用语,我将使用scala名称,但它们在函数式语言中都是一样的。
Monad是非常简单的,它带着非常复杂的规则。Monad是容器。有规范Monad如何工作以及如何与其他Monad进行交互的规则,这些规则也为那些从未在函数式语言中编程过的人定义术语,例如函子(Functor),Monoid,加强版函子(Applicative Functor),以及一些其他额外的术语。出于简单考虑,这里我将将它们分组成简单的‘Monad’,冒着语义错误的风险来让你理解这个概念。我保证稍后我将消除歧义,好吗?
让我给你展示一些scala代码来说明这意味着什么:
def someComputation(): Option[String] = ...
val myPossibleString = someComputation()
例如,想象一下,你可以拥有一个值(在这个例子中,是一个String
)作为一些计算的输出,或者没有。取代null
,这可能会导致严重的问题,例如不那么好的NullPointerException
,你可以返回Option[String]
。Option
就是scala提供的一个Monad,它包装了你的数据,允许只有当它存在时,你才能安全地与它交互。
在Java代码中,你会在做任何事之前先检查null
,以避免NPE(NullPointerException):
String myPossibleString = someComputation();
if(myPossibleString == null) {
//Short circuit out
}
return myPossibleString.toUpper();
在函数式编程语言中,你可以在Monad上使用map
,以实现相同的安全级别:
myPossibleString.map(_.toUpper)
这里唯一的区别在于,Java代码会返回一个String
,而scala代码会返回一个Option[String]
。
通过映射一个monad,我们在保有相同的容器的同时,转换被控制的(Ele注:原文是cointained,找不到相应的解释,怀疑原文有误,应该是cointained)值。其结果可以是:
Some("MYUPPERSTRING") // if the computation was successful
// 或者
None // if the computation was unsuccessful
需要注意的是,如果应用在None
上,_.toUpper
并不会中断。这允许在一个值上链接操作,而无需短路所有可能的问题:
String myStr = someComputation();
if(myStr == null) {
return false;
}
myStr = newOperationOnStr(str);
if(myStr == null || myStr.length < 3) {
return false;
} else if (!matchRegex(str)) {
return false;
} else {
return true;
}
这段代码可以在scala中这样写,使用Option[String]
:
someComputation()
.map(newOperationOnStr(_))
.filter(s => s.length >= 3 && matchRegex(s))
.isDefined
这里,我可以花费几个小时来写一写关于monad如何让你更好的以一种函数式的方式表达你的代码,但我会留给你来决定要不要这样。
有一种完整的数学理论来支撑这类书籍,但我会虚心地自我限制,来解释代数数据类型是有意义的复合类型。一个代数数据类型是通过_其形式的所有定义之和_来定义的。一些monad是作为代数数据类型来定义的,例如我们之前看到的scala的Options
,它可以是Some
,也可以是None
。在映射、过滤以及缩减的时候,它们每个都有不同的行为。
这意味着,虽然monad为数据容器提供规则,但是代数数据类型提供形式和意义。Try
和Option
在API方面都颇为相似,并且可以表现得非常类似,但两者的不同在于,当错误可能是有意义的或可以抛出多种类型的错误,它们每个都需要不同的操作时,你会想要使用Try
。如果你必须为一个函数处理两种可能的结果,那么你也可以使用Either
。一元代数类型的范围是巨大的,它们帮助你在无需重量级的必要概念的情况下构建应用程序。
一般情况下,使用这种类型抽象导致更清洁并且有意义的代码。就个人而言,我认为这种抽象比面向对象编程更高级,更有价值。当然面向对象编程有其价值,但它(函数式编程)更容易表达逻辑代数数据结构。
What makes a code truly functional is the usage of aforementioned concepts. The same way you can have OO code in scala, you can have functional code in python, for example. 虽然有些语言提供原生的惯用语,让你编写与函数式理念相对应的代码,但有几个可以移植到非函数式(或者原生非函数式)语言的概念。注意到在函数式语言中编写代码并不能使它立即是函数式的非常重要。让一个代码真正是函数式的在于上述概念的使用。例如,使用相同的方法,你可以在Scala中使用面向对象的代码,你可以在python中编写函数式代码。
试想一下这样的情况,使用实际的东西替换哑函数:
my_value = []
def fetch_values():
# Imagine that you're fetching a real data
# I'll return a dummy list of dummy ints
my_value = [8, 3, 2, 5, 1, 4]
def filter_values(even=True):
t = []
for i in my_value:
if even:
if i % 2 == 0:
t.append(i)
else:
if i % 2 == 1:
t.append(i)
my_value = t
def process_value(x):
# Also dummy here.
return x * x
def process_all_values():
for i in my_value:
process_value(x)
def do_process():
fetch_values()
# We (for some reason) need to process evens before odds
filter_values(true)
process_all_values()
fetch_values()
filter_values(false)
process_all_values()
我打破了一切可能的规则,以使示例更容易些。希望你能够认识到在真实的生产代码中,上述的一些反模式。我们要将它们去掉。下面,我会编写Python代码,来解决上述所有的反模式,但是会在实际读取转换版本之前,设法找到它们,并且想象一种函数式方法。
from functools import reduce
def fetch_values():
return [8, 3, 2, 5, 1, 4]
def partition_values(vals):
return reduce(lambda l, v: l[v % 2].append(v) or l, vals, ([], []))
def process_value(x):
# Processing here is a pure function.
return x * x
def process_all_values(lst):
# Be cautious when plumbing functions here;
# You should only map over pure functions, to avoid
# intermittent state or unhandled errors.
return map(process_value, lst)
def do_process():
id_list = fetch_values()
evens, odds = partition_values(id_list)
p_evens, p_odds = process_all_values(even), process_all_values(odds)
# Python `map` is lazy, so we force evaluation
list(p_even)
list(p_odds)
虽然由于Python的限制,没有达到最佳效果,但是我们已经拥有了一个更加函数式的代码。这里我们有大量可以进行加强的地方,比如用一元代数类型来包装我们的映射计算,允许我们安全的处理在process_value
过程中可能发生的错误。
另外,还要注意的是,虽然这个代码片段是函数式的,只有当process_value
是一个纯函数时,这样做才是安全的。否则会有问题,例如如果一个异常在此过程中被抛出,则会出现不确定状态,或者你必须重新处理所有的东西。
其他一些概念帮助你处理这类问题并且正交于函数式编程,例如幂等。重要的是要知道,函数式编程(或函数式编程理念)仅仅是一个工具,它可以帮助你编写更安全的代码。
你会发现遵循函数式理念的代码更容易测试,并行化,再利用和理解。这当然并不意味着函数式编程是灵丹妙药,可以解决所有的编程问题。如果是这样的话,我就不会广告在非函数式语言上那些实践的使用了,而是推崇移植到函数式编程语言。这里真正的价值是,你拥有传统命令性代码样式的替代品,这可能会提供上述优点。
虽然这篇文章超过我最初想象的长度,但是它只是触及了函数式编程的皮毛。在未来的文章中,我一定会写更多关于函数式编程的内容,我会永远更专注于理念,以及你怎么函数式地思考,即使你当前使用的语言没有完全实现函数式惯用语。人们可以从函数式编程中借鉴大量有用的技术,以使得代码更安全,更干净,更富有表现力。
如果你同意,不同意,或者只是想和我喝个小酒,你可以自由地给我写点什么。我的博客仍然没有评论功能,但是下周,我将处理这个问题:x