给 Python程序员的函数式编程实践经验

760 查看

这篇文章会让读者了解到在 python 背景下的函数式编程。除了作为学术课程的一部分,大部分程序员很少会接触到函数式编程语言,比如 Lisp 或者 Haskell。由于 Python 是一门应用广泛的语言,并且支持大部分函数式编程的结构,所以这篇文章会尽可能展示它们的用途和优点。对于 Python 这门语言,函数式编程可能不是最好或者最 Pythonic 的方式,但是在一些应用中,函数式编程有它的优点,这也是本文接下来要讲的东西。

1. 什么是函数式编程?

函数式编程是一种以纯函数为中心的编程范式。如果你曾经写过程序,你可能会将函数与子程序联系在一起,这是典型的‘CS’视角。这里,我们更倾向于其数学定义:

函数的数学定义纯函数是指:任何函数基本上都可以用一个数学表达式来表示。这就意味着没有副作用:没有 IO 操作、没有全局状态的变化、没有数据库的交互(你确实无法用一个数学表达式来实现这些,对吗?)。纯函数的输出只依赖其输入。所以,如果你使用相同的输入多次调用纯函数,每次得到的结果都一样。当然,对于实际的函数式编程语言如 Haskell,也能够实现 IO 操作等功能,但是重点仍然是纯函数。

2. lambda结构

Python 中,初始化一个纯函数最简单的方式就是使用 lambda 关键字:

lambda 关键字可以让你在一行内定义一个函数,如果仅仅是一个数学表达式,这种定义方法就非常方便。事实上,lambda 关键字在函数式编程(不只针对 Python )中非常重要,其根源于 Lambda Calculus — 函数式编程的“鼻祖”之一。

使用 lambda 初始化的函数也被称作匿名函数。看一下上面代码的第五行,你把一个用 lambda 初始化的函数传给排序方法。但并未给它命名,只是动态地定义并将它作为参数传递,因此称它是“匿名的”。当然, 你也可以将匿名函数赋值给一个变量(见第一行),然后像普通函数一样调用它们(见第二行)。

3. 函数是“第一等”公民

在函数式编程中,函数是“第一等公民”。这基本上就意味着你可以像使用其他对象那样使用函数 — 赋值给变量、作为参数传递,甚至作为函数返回值。之前已经在代码中见过这种用法,下面是一些示例:

square_func 本身是一个函数。另一方面,function_product 是一个高阶函数,它有两个输入 — 函数F(x) 和乘数 mfunction_product 返回 F'(x), 等同于 m * F(x)。因此,对于上面第五行代码,function_product(square_func, 3)被调用时带有参数2,返回 2² * 3 = 12。

4. 数据和数据流的不变性

对象的不变性意味着一旦对象被初始化,你就绝不能再修改其数据的值。在函数式编程中,无论何时你针对某些数据调用函数,你总会得到一个新的实例 — 你从不会“更新”任何变量的值。从编程角度来说,这就意味着,一旦你初始化一个变量如 x=3,变量x将不再出现在声明的左边。

所以,任何函数式的代码都可以被看作是前馈数据流,你绝不会“回来”改变任何变量的值。因此,数据总是向前移动,从输入到最终的输出 — 从一个函数到另一个函数。

数据移动

这种数据的不变性引出了另外一个属性,称之为引用透明性。这就意味着,只要所需的变量定义好,一个表达式的值在程序中任何地方都是一样的。由于你不会更新任何变量或对象(包括函数)的值,所以在任何上下文中,一旦被定义,它们的意义就相同。基于此,函数式代码非常容易分析和调试。你无需追踪变量状态的值或者记住任何更新。

数据不变性可以让我们利用制表法 — 基本上,只要你“记住“一些代价高昂的函数输出以及一种查表的某些常见参数即可。这就是牺牲内存来减少了计算的复杂度。

5. 递归

函数式编程并未通过 while 或者 for 声明提供迭代功能,同样也未提供状态更新。所以,递归在函数式编程中就显得尤为重要。值得记住的是,任何可迭代的代码都可以转换为可递归的代码

下面是计算第n个 Fibonacci 数的函数式版本:

第二行定义了计算的基本条件,第三行实现了递归调用。思考一下,xx_1 以及 x_2 是必要的状态变量,它们更新后的版本被传到每一次递归中,这就是在函数式代码处理状态的机制。

编写函数式代码时,最好实现尾递归,特别是对纯函数式的语言如 Schema 。这样做的好处是:尾递归代码很容易通过编译器优化成可迭代的代码(尽管这不适用于 Python),使得编译后的代码更加高效。

6. 惰性求值

这是一个 Python 没有采用函数式编程的一个方面。在很多纯函数式语言中,如Haskell,无需求值的对象是不会被求值的。求值意味着要计算函数表达式的值。考虑下面的代码:

在例如Python这样的语言中,出现 1/0 将会立刻抛出异常。但是,如果我们实现惰性求值,函数就会返回3。因为列表中有三个对象,计数时无需计算它们的值。这会在数据流中造成一些图规约,导致更少的函数调用(这会有忽略错误的风险)。

Python 3.x 确实支持不同种类的惰性求值,比如 some_dict.keys(),调用时返回迭代器,这可以防止加载所有数据到内存中,从编程角度来说,这样更高效。

7. 序列中没有迭代器

虽然这是一点不太重要,但是由于迭代器下一个元素的值依赖于它的状态(这违反了引用透明性),而在纯函数式代码中没有迭代器。如果我们编写纯函数式代码,替代序列,我们只处理明确且不变的tuple,在 Python 中可以使用 tuple() 从迭代器中生成 tuple。

8. map, reduce and filter

mapreduce 以及 filter是三个高阶函数,在所有纯函数式语言中都包含这三个函数,当然 Python 中也有。它们的普遍性证明了,为了使函数式代码更加优雅,它们是多么频繁地被使用。

map 通过对 list 或者 array 中所有元素调用函数,基本上提供了一种并行性。下面是一个示例:

注意 map 为何是并行的,由于你针对数组中的是所有元素调用了同一个函数,并且没有做任何修改,所以你可以以任何既定顺序调用函数。

filter 针对序列提供另外一种并行。输入一个 bool 值和函数,返回一个序列,保留序列中调用函数后返回 True 的值,示例如下:

上面的代码过滤出列表中所有的偶数。

reduce 是一个结构体,它在序列上执行一系列迭代。其第一个参数是函数 F(a, x)F 接收两个参数 — 一个累加器 a 和当前输入 xF 计算并返回一个新值 a。reduce中第二个参数是序列本身 — [x1, x2, ..., x_n],第三个参数初始的累加器 a,内部实现是:

i. a_1 = F(a, x1)
ii. a_2 = F(a_1, x2)
iii. a_3 = F(a_2, x3)

最后得到的是 an。 一个示例:

作为列表中的元素,累加器不必是相同类型,也可以是列表。下面是使用 reduce 翻转序列的例子:

注意上面的代码中未改变列表(提醒一下,reduce 需要从 Python 3.x 中的 functools 中导入)

此外,需要理解的是,因为每一次 reduce 对一个元素调用 F 函数,都依赖于上个元素产生的值,所以 reduce 不是并行的。

尾注 1:为了更好地让您理解如何在 Python 中使用上述关键字或操作进行函数式编程,本文提供了非常简洁的例子和代码,同时也引入了一些先进的技术,如管道。一定要看一看!

尾注 2:对于为什么函数式编程是一些专门业务更喜爱的方式,本文提供了一些观点,并对一些函数式代码提供了易懂的注解(写的不好的地方请见谅)。