这篇文章会让读者了解到在 python 背景下的函数式编程。除了作为学术课程的一部分,大部分程序员很少会接触到函数式编程语言,比如 Lisp 或者 Haskell。由于 Python 是一门应用广泛的语言,并且支持大部分函数式编程的结构,所以这篇文章会尽可能展示它们的用途和优点。对于 Python 这门语言,函数式编程可能不是最好或者最 Pythonic 的方式,但是在一些应用中,函数式编程有它的优点,这也是本文接下来要讲的东西。
1. 什么是函数式编程?
函数式编程是一种以纯函数为中心的编程范式。如果你曾经写过程序,你可能会将函数与子程序联系在一起,这是典型的‘CS’视角。这里,我们更倾向于其数学定义:
纯函数是指:任何函数基本上都可以用一个数学表达式来表示。这就意味着没有副作用:没有 IO 操作、没有全局状态的变化、没有数据库的交互(你确实无法用一个数学表达式来实现这些,对吗?)。纯函数的输出只依赖其输入。所以,如果你使用相同的输入多次调用纯函数,每次得到的结果都一样。当然,对于实际的函数式编程语言如 Haskell,也能够实现 IO 操作等功能,但是重点仍然是纯函数。
2. lambda结构
Python 中,初始化一个纯函数最简单的方式就是使用 lambda 关键字:
1 2 3 4 5 6 7 |
>>> square_func = lambda x: x**2 >>> square_func(2) 4 >>> some_list = [(1, 3), (5, 2), (6, 1), (4, 6)] >>> some_list.sort(key=lambda x: x[0]**2 - x[1]*3) >>> some_list [(1, 3), (4, 6), (5, 2), (6, 1)] |
lambda 关键字可以让你在一行内定义一个函数,如果仅仅是一个数学表达式,这种定义方法就非常方便。事实上,lambda 关键字在函数式编程(不只针对 Python )中非常重要,其根源于 Lambda Calculus — 函数式编程的“鼻祖”之一。
使用 lambda 初始化的函数也被称作匿名函数。看一下上面代码的第五行,你把一个用 lambda 初始化的函数传给排序方法。但并未给它命名,只是动态地定义并将它作为参数传递,因此称它是“匿名的”。当然, 你也可以将匿名函数赋值给一个变量(见第一行),然后像普通函数一样调用它们(见第二行)。
3. 函数是“第一等”公民
在函数式编程中,函数是“第一等公民”。这基本上就意味着你可以像使用其他对象那样使用函数 — 赋值给变量、作为参数传递,甚至作为函数返回值。之前已经在代码中见过这种用法,下面是一些示例:
1 2 3 4 5 6 |
>>> square_func = lambda x: x_*2 >>> function_product = lambda F, m: lambda x: F(x)_m >>> square_func(2) 4 >>> function_product(square_func, 3)(2) 12 |
square_func
本身是一个函数。另一方面,function_product
是一个高阶函数,它有两个输入 — 函数F(x)
和乘数 m
。function_product
返回 F'(x)
, 等同于 m * F(x)
。因此,对于上面第五行代码,function_product(square_func, 3)
被调用时带有参数2
,返回 2² * 3 = 12。
4. 数据和数据流的不变性
对象的不变性意味着一旦对象被初始化,你就绝不能再修改其数据的值。在函数式编程中,无论何时你针对某些数据调用函数,你总会得到一个新的实例 — 你从不会“更新”任何变量的值。从编程角度来说,这就意味着,一旦你初始化一个变量如 x=3
,变量x将不再出现在声明的左边。
所以,任何函数式的代码都可以被看作是前馈数据流,你绝不会“回来”改变任何变量的值。因此,数据总是向前移动,从输入到最终的输出 — 从一个函数到另一个函数。
这种数据的不变性引出了另外一个属性,称之为引用透明性。这就意味着,只要所需的变量定义好,一个表达式的值在程序中任何地方都是一样的。由于你不会更新任何变量或对象(包括函数)的值,所以在任何上下文中,一旦被定义,它们的意义就相同。基于此,函数式代码非常容易分析和调试。你无需追踪变量状态的值或者记住任何更新。
数据不变性可以让我们利用制表法 — 基本上,只要你“记住“一些代价高昂的函数输出以及一种查表的某些常见参数即可。这就是牺牲内存来减少了计算的复杂度。
5. 递归
函数式编程并未通过 while
或者 for
声明提供迭代功能,同样也未提供状态更新。所以,递归在函数式编程中就显得尤为重要。值得记住的是,任何可迭代的代码都可以转换为可递归的代码。
下面是计算第n个 Fibonacci 数的函数式版本:
1 2 3 4 5 6 7 8 9 |
>>> fibonacci = (lambda x, x_1=1, x_2=0: x_2 if x == 0 else fibonacci(x - 1, x_1 + x_2, x_1)) >>> fibonacci(1) 1 >>> fibonacci(5) 5 >>> fibonacci(6) 8 |
第二行定义了计算的基本条件,第三行实现了递归调用。思考一下,x
、x_1
以及 x_2
是必要的状态变量,它们更新后的版本被传到每一次递归中,这就是在函数式代码处理状态的机制。
编写函数式代码时,最好实现尾递归,特别是对纯函数式的语言如 Schema 。这样做的好处是:尾递归代码很容易通过编译器优化成可迭代的代码(尽管这不适用于 Python),使得编译后的代码更加高效。
6. 惰性求值
这是一个 Python 没有采用函数式编程的一个方面。在很多纯函数式语言中,如Haskell,无需求值的对象是不会被求值的。求值意味着要计算函数表达式的值。考虑下面的代码:
1 |
length([3 + 4, 5, 1/0]) |
在例如Python这样的语言中,出现 1/0 将会立刻抛出异常。但是,如果我们实现惰性求值,函数就会返回3。因为列表中有三个对象,计数时无需计算它们的值。这会在数据流中造成一些图规约,导致更少的函数调用(这会有忽略错误的风险)。
Python 3.x 确实支持不同种类的惰性求值,比如 some_dict.keys()
,调用时返回迭代器,这可以防止加载所有数据到内存中,从编程角度来说,这样更高效。
7. 序列中没有迭代器
虽然这是一点不太重要,但是由于迭代器下一个元素的值依赖于它的状态(这违反了引用透明性),而在纯函数式代码中没有迭代器。如果我们编写纯函数式代码,替代序列,我们只处理明确且不变的tuple,在 Python 中可以使用 tuple()
从迭代器中生成 tuple。
8. map, reduce and filter
map
、 reduce
以及 filter
是三个高阶函数,在所有纯函数式语言中都包含这三个函数,当然 Python 中也有。它们的普遍性证明了,为了使函数式代码更加优雅,它们是多么频繁地被使用。
map 通过对 list 或者 array 中所有元素调用函数,基本上提供了一种并行性。下面是一个示例:
1 2 |
>>> map(lambda x: x**2, [1, 2, 3]) [1, 4, 9] |
注意 map 为何是并行的,由于你针对数组中的是所有元素调用了同一个函数,并且没有做任何修改,所以你可以以任何既定顺序调用函数。
filter 针对序列提供另外一种并行。输入一个 bool 值和函数,返回一个序列,保留序列中调用函数后返回 True 的值,示例如下:
1 2 |
>>> filter(lambda x: x % 2 == 0, [1,2,3,4,5,6]) [2, 4, 6] |
上面的代码过滤出列表中所有的偶数。
reduce 是一个结构体,它在序列上执行一系列迭代。其第一个参数是函数 F(a, x)
,F
接收两个参数 — 一个累加器 a
和当前输入 x
。F
计算并返回一个新值 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。 一个示例:
1 2 |
>>> reduce(lambda x, y: x + y, [1, 2, 3], 0) 6 |
作为列表中的元素,累加器不必是相同类型,也可以是列表。下面是使用 reduce 翻转序列的例子:
1 2 |
>>> reduce(lambda L, element: [element] + L, [1, 2, 3], []) [3, 2, 1] |
注意上面的代码中未改变列表(提醒一下,reduce
需要从 Python 3.x 中的 functools
中导入)
此外,需要理解的是,因为每一次 reduce 对一个元素调用 F
函数,都依赖于上个元素产生的值,所以 reduce 不是并行的。
尾注 1:为了更好地让您理解如何在 Python 中使用上述关键字或操作进行函数式编程,本文提供了非常简洁的例子和代码,同时也引入了一些先进的技术,如管道。一定要看一看!
尾注 2:对于为什么函数式编程是一些专门业务更喜爱的方式,本文提供了一些观点,并对一些函数式代码提供了易懂的注解(写的不好的地方请见谅)。