为什么在Python中使用函数式编程?

37 投票
8 回答
4137 浏览
提问于 2025-04-15 16:56

在工作中,我们以前都是用比较标准的面向对象的方式来写Python代码。最近,有几个同事开始尝试函数式编程,他们的代码里现在用了很多lambda表达式、map和reduce这些东西。我知道函数式编程在处理并发方面很不错,但用函数式的方式编写Python真的能帮助处理并发吗?我只是想搞明白,如果我开始多用Python的函数式特性,会有什么好处。

8 个回答

19

我每天都在用Python编程,想说的是,过于追随面向对象编程(OO)或函数式编程的潮流,可能会让我们错过一些优雅的解决方案。我认为这两种编程方式在某些问题上各有优点,关键在于你知道在什么情况下使用哪种方法。当用函数式编程能让你的代码干净、易读且高效时,就该用它。面向对象编程也是如此。

这就是我喜欢Python的原因之一——它支持多种编程方式,让开发者可以自由选择解决问题的方法。

25

函数式编程(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.partialmy.reduce的原因)

那么运行速度呢?是的,在像Python这样的语言中使用函数式编程会有一些开销。在这里我只是重复一些教授们的观点:

  • 过早优化是万恶之源。
  • 大多数程序80%的运行时间花在20%的代码上。
  • 先分析,再猜测!

我不太擅长解释事情。别让我把事情搞得太复杂,看看约翰·巴克斯在1977年获得图灵奖时所做的演讲的前半部分,引用:

5.1 一个冯·诺依曼程序用于内积

c := 0
for i := I step 1 until n do
   c := c + a[i] * b[i]

这个程序有几个值得注意的特性:

  1. 它的语句根据复杂的规则在一个看不见的“状态”上操作。
  2. 它不是层次化的。除了赋值语句的右侧,它并不从简单的实体构建复杂的实体。(不过更大的程序通常会这样做。)
  3. 它是动态和重复的。必须在脑中执行它才能理解它。
  4. 它通过重复(赋值)和修改(变量i)逐字计算。
  5. 部分数据n在程序中,因此它缺乏通用性,只能用于长度为n的向量。
  6. 它给参数命名;只能用于向量ab。要变得通用,它需要一个过程声明。这涉及复杂的问题(例如,按名称调用与按值调用)。
  7. 它的“家务”操作在散落的地方用符号表示(在for语句和赋值中的下标)。这使得将最常见的家务操作整合成单一、强大、广泛使用的运算符变得不可能。因此在编程这些操作时,必须总是从头开始,写“for i := ...”和“for j := ...”,然后是满是ij的赋值语句。
72

编辑: 在评论中,有人批评我没有提供更多的解释和例子,所以我决定扩展一下我的回答,补充一些内容。

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 也让你在需要临时或永久插入像 printraise 这样的语句时变得非常简单)。

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(以及 mapfilter)常常迫使你插入这样的函数调用,而简单的循环、列表推导式和生成器表达式则允许你以行内操作的方式保持可读性、紧凑性和速度。

或许比上面提到的“给 lambda 赋名”的反模式更糟糕的是,实际上是以下这种反模式,例如按字符串长度对列表进行排序:

thelist.sort(key=lambda s: len(s))

而不是明显、可读、紧凑、速度更快的

thelist.sort(key=len)

在这里,使用 lambda 只是在插入一个间接层——没有任何好的效果,反而带来了很多坏处。

使用 lambda 的动机通常是为了使用 mapfilter,而不是使用更可取的循环或列表推导式,这样可以让你在行内进行普通的计算;当然,你仍然要付出“间接层”的代价。考虑“我应该在这里使用列表推导式还是 map”的想法并不符合 Pythonic 的风格:当两者都适用而你又不知道该选择哪个时,始终使用列表推导式,基于“应该有一种,最好只有一种,明显的方法来做某事”。你经常会写出无法合理转换为 map 的列表推导式(嵌套循环、if 条件等),而没有任何调用 map 是无法合理重写为列表推导式的。

在 Python 中,完全合适的函数式编程方法通常包括列表推导式、生成器表达式、itertools、高阶函数、各种形式的一阶函数、闭包、生成器(偶尔还有其他类型的迭代器)。

正如一位评论者指出的,itertools 确实包括 imapifilter:不同之处在于,像所有的 itertools 一样,这些都是基于流的(就像 Python 3 中的 mapfilter 内置函数,但与 Python 2 中的那些内置函数不同)。itertools 提供了一组可以很好组合的构建块,并且性能出色:特别是如果你可能处理非常长(甚至无限!)的序列,你应该熟悉 itertools——它们在文档中的整个章节值得一读,尤其是食谱部分非常有启发性。

编写你自己的高阶函数通常是有用的,特别是当它们适合用作装饰器时(包括文档中解释的函数装饰器和在 Python 2.6 中引入的类装饰器)。记得在你的函数装饰器上使用functools.wraps(以保留被包装函数的元数据)!

所以,总结一下...:你用 lambdamapfilter 能做到的事情,通常可以用 def(命名函数)和列表推导式更有利地做到——而且通常向上移动一步,使用生成器、生成器表达式或 itertools,效果会更好。reduce 符合“吸引人的麻烦”的法律定义...:它几乎从来不是合适的工具(这就是为什么它在 Python 3 中终于不再是内置函数了!-)。

撰写回答