为什么在Python中使用函数式编程?
在工作中,我们以前都是用比较标准的面向对象的方式来写Python代码。最近,有几个同事开始尝试函数式编程,他们的代码里现在用了很多lambda表达式、map和reduce这些东西。我知道函数式编程在处理并发方面很不错,但用函数式的方式编写Python真的能帮助处理并发吗?我只是想搞明白,如果我开始多用Python的函数式特性,会有什么好处。
8 个回答
我每天都在用Python编程,想说的是,过于追随面向对象编程(OO)或函数式编程的潮流,可能会让我们错过一些优雅的解决方案。我认为这两种编程方式在某些问题上各有优点,关键在于你知道在什么情况下使用哪种方法。当用函数式编程能让你的代码干净、易读且高效时,就该用它。面向对象编程也是如此。
这就是我喜欢Python的原因之一——它支持多种编程方式,让开发者可以自由选择解决问题的方法。
函数式编程(FP)不仅仅对并发很重要;实际上,在经典的Python实现中几乎没有并发(也许3.x会改变这一点?)。无论如何,函数式编程非常适合并发,因为它能让程序的状态更少或没有(显式)状态。状态会带来一些麻烦,首先是它让计算的分发变得更困难(这就是并发的问题),其次,在大多数情况下,更重要的是它容易引发错误。现代软件中最大的错误来源是变量(变量和状态之间有很大的关系)。函数式编程可能会减少程序中的变量数量:这样就能减少错误的发生!
看看在这些版本中,你能引入多少错误,都是因为混淆了变量:
def imperative(seq):
p = 1
for x in seq:
p *= x
return p
与之相比(警告,my.reduce
的参数列表与Python的reduce
不同;稍后会解释原因)
import operator as ops
def functional(seq):
return my.reduce(ops.mul, 1, seq)
如你所见,函数式编程确实给你提供了更少的机会去犯与变量相关的错误。
还有可读性:这可能需要一点训练,但函数式
的代码比命令式
的代码容易读得多:你看到reduce
(“好吧,它是把一个序列缩减成一个单一的值”),mul
(“通过乘法”)。而命令式
的代码则是一个个for
循环,里面满是变量和赋值。这些for
循环看起来都差不多,所以要想明白命令式
代码在干什么,你几乎得把所有的代码都读一遍。
接下来是简洁性和灵活性。你给我命令式
的代码,我告诉你我喜欢它,但我还想要一个能对序列求和的东西。没问题,你说,然后开始复制粘贴:
def imperative(seq):
p = 1
for x in seq:
p *= x
return p
def imperative2(seq):
p = 0
for x in seq:
p += x
return p
那么你能做些什么来减少重复呢?好吧,如果运算符是值,你可以这样做:
def reduce(op, seq, init):
rv = init
for x in seq:
rv = op(rv, x)
return rv
def imperative(seq):
return reduce(*, 1, seq)
def imperative2(seq):
return reduce(+, 0, seq)
哦等等!operators
提供的运算符就是值!但是……Alex Martelli已经谴责了reduce
……看起来如果你想遵循他建议的边界,你就注定要复制粘贴一些基础代码。
那么函数式编程的版本会更好吗?你肯定也需要复制粘贴吧?
import operator as ops
def functional(seq):
return my.reduce(ops.mul, 1, seq)
def functional2(seq):
return my.reduce(ops.add, 0, seq)
其实,这只是半吊子方法的一个结果!放弃命令式
的def
,你可以把两个版本简化为:
import functools as func, operator as ops
functional = func.partial(my.reduce, ops.mul, 1)
functional2 = func.partial(my.reduce, ops.add, 0)
甚至可以是:
import functools as func, operator as ops
reducer = func.partial(func.partial, my.reduce)
functional = reducer(ops.mul, 1)
functional2 = reducer(ops.add, 0)
(func.partial
是my.reduce
的原因)
那么运行速度呢?是的,在像Python这样的语言中使用函数式编程会有一些开销。在这里我只是重复一些教授们的观点:
- 过早优化是万恶之源。
- 大多数程序80%的运行时间花在20%的代码上。
- 先分析,再猜测!
我不太擅长解释事情。别让我把事情搞得太复杂,看看约翰·巴克斯在1977年获得图灵奖时所做的演讲的前半部分,引用:
5.1 一个冯·诺依曼程序用于内积
c := 0 for i := I step 1 until n do c := c + a[i] * b[i]
这个程序有几个值得注意的特性:
- 它的语句根据复杂的规则在一个看不见的“状态”上操作。
- 它不是层次化的。除了赋值语句的右侧,它并不从简单的实体构建复杂的实体。(不过更大的程序通常会这样做。)
- 它是动态和重复的。必须在脑中执行它才能理解它。
- 它通过重复(赋值)和修改(变量i)逐字计算。
- 部分数据
n
在程序中,因此它缺乏通用性,只能用于长度为n
的向量。- 它给参数命名;只能用于向量
a
和b
。要变得通用,它需要一个过程声明。这涉及复杂的问题(例如,按名称调用与按值调用)。- 它的“家务”操作在散落的地方用符号表示(在for语句和赋值中的下标)。这使得将最常见的家务操作整合成单一、强大、广泛使用的运算符变得不可能。因此在编程这些操作时,必须总是从头开始,写“
for i := ...
”和“for j := ...
”,然后是满是i
和j
的赋值语句。
编辑: 在评论中,有人批评我没有提供更多的解释和例子,所以我决定扩展一下我的回答,补充一些内容。
lambda
,尤其是 map
(和 filter
),更不用说 reduce
,在 Python 中几乎从来不是合适的工具,因为 Python 是一种支持多种编程风格的语言。
lambda
的主要优点(?)是它可以创建一个匿名函数,而 def
则给函数起了个名字。为了这个不太靠谱的优点,你要付出很大的代价(函数的内容只能是一条表达式,生成的函数对象不能被序列化,缺少名字有时会让你更难理解错误信息或调试问题——我还需要继续吗?!)。
想象一下,有一种在“Python”中(用引号表示,因为这显然不是标准的 Python)常见的非常愚蠢的用法,这种用法其实是从习惯于 Scheme 的写法糟糕地翻译过来的,就像在 Python 中过度使用面向对象编程是从 Java 等语言糟糕翻译过来的:
inc = lambda x: x + 1
通过给 lambda 赋一个名字,这种做法立刻就抛弃了前面提到的“优点”——而且没有失去任何的缺点!例如,inc
并不知道自己的名字——inc.__name__
的值是毫无用处的字符串 '<lambda>'
——想要理解带有这些的错误信息可真是个难题;-)。在这种简单情况下,正确的 Python 写法是:
def inc(x): return x + 1
现在 inc.__name__
的值是字符串 'inc'
,这显然是应该的,而且这个对象是可以被序列化的——在这个简单的情况下,语义是相同的(因为所需的功能可以很简单地用一条表达式实现——当然,def
也让你在需要临时或永久插入像 print
或 raise
这样的语句时变得非常简单)。
lambda
是(表达式的一部分),而 def
是(语句的一部分)——这就是让人们有时使用 lambda
的一个语法糖。许多函数式编程的爱好者(就像许多面向对象和过程式编程的粉丝一样)不喜欢 Python 在表达式和语句之间的明显区分(这与命令-查询分离的总体立场有关)。我认为,使用一种语言时,最好是“顺应其道”——按照它设计的方式来使用,而不是与之对抗;所以我在 Python 中用 Pythonic 的方式编程,在 Scheme 中用 Schematic 的方式,在 Fortran 中用 Fortesque 的方式,等等:-)。
接下来谈谈 reduce
——有个评论说 reduce
是计算列表乘积的最佳方法。哦,真的吗?让我们看看...:
$ python -mtimeit -s'L=range(12,52)' 'reduce(lambda x,y: x*y, L, 1)'
100000 loops, best of 3: 18.3 usec per loop
$ python -mtimeit -s'L=range(12,52)' 'p=1' 'for x in L: p*=x'
100000 loops, best of 3: 10.5 usec per loop
所以,简单的、基础的、显而易见的循环速度大约是“最佳方法”的两倍(而且更简洁)?-) 我想速度和简洁的优势使得这个简单的循环成为“最佳”的方法,对吧?-)
通过进一步牺牲紧凑性和可读性...:
$ python -mtimeit -s'import operator; L=range(12,52)' 'reduce(operator.mul, L, 1)'
100000 loops, best of 3: 10.7 usec per loop
...我们几乎可以回到最简单、最明显、最紧凑和可读的方法(简单的、基础的、显而易见的循环)。这实际上指出了 lambda
的另一个问题:性能!对于足够简单的操作,比如乘法,函数调用的开销相对于实际操作来说是相当大的——reduce
(以及 map
和 filter
)常常迫使你插入这样的函数调用,而简单的循环、列表推导式和生成器表达式则允许你以行内操作的方式保持可读性、紧凑性和速度。
或许比上面提到的“给 lambda 赋名”的反模式更糟糕的是,实际上是以下这种反模式,例如按字符串长度对列表进行排序:
thelist.sort(key=lambda s: len(s))
而不是明显、可读、紧凑、速度更快的
thelist.sort(key=len)
在这里,使用 lambda
只是在插入一个间接层——没有任何好的效果,反而带来了很多坏处。
使用 lambda
的动机通常是为了使用 map
和 filter
,而不是使用更可取的循环或列表推导式,这样可以让你在行内进行普通的计算;当然,你仍然要付出“间接层”的代价。考虑“我应该在这里使用列表推导式还是 map”的想法并不符合 Pythonic 的风格:当两者都适用而你又不知道该选择哪个时,始终使用列表推导式,基于“应该有一种,最好只有一种,明显的方法来做某事”。你经常会写出无法合理转换为 map 的列表推导式(嵌套循环、if
条件等),而没有任何调用 map
是无法合理重写为列表推导式的。
在 Python 中,完全合适的函数式编程方法通常包括列表推导式、生成器表达式、itertools
、高阶函数、各种形式的一阶函数、闭包、生成器(偶尔还有其他类型的迭代器)。
正如一位评论者指出的,itertools
确实包括 imap
和 ifilter
:不同之处在于,像所有的 itertools 一样,这些都是基于流的(就像 Python 3 中的 map
和 filter
内置函数,但与 Python 2 中的那些内置函数不同)。itertools
提供了一组可以很好组合的构建块,并且性能出色:特别是如果你可能处理非常长(甚至无限!)的序列,你应该熟悉 itertools——它们在文档中的整个章节值得一读,尤其是食谱部分非常有启发性。
编写你自己的高阶函数通常是有用的,特别是当它们适合用作装饰器时(包括文档中解释的函数装饰器和在 Python 2.6 中引入的类装饰器)。记得在你的函数装饰器上使用functools.wraps(以保留被包装函数的元数据)!
所以,总结一下...:你用 lambda
、map
和 filter
能做到的事情,通常可以用 def
(命名函数)和列表推导式更有利地做到——而且通常向上移动一步,使用生成器、生成器表达式或 itertools
,效果会更好。reduce
符合“吸引人的麻烦”的法律定义...:它几乎从来不是合适的工具(这就是为什么它在 Python 3 中终于不再是内置函数了!-)。