AberSheeran
Aber Sheeran

写函数式的 Python

起笔自
所属文集: 程序杂记
共计 5059 个字符
落笔于

这是一个老生常谈的话题。在此不介绍什么是函数式编程之类的教条,因为我觉得那偏离了函数式编程的本意——函数式编程应当是让人节约脑细胞而不是浪费脑细胞的。

简单来说,函数式编程就是你将一些重复的过程编写成名称清晰的函数,通过不断地调用函数来完成工作的一种编程方式。每个老程序员几乎都有一点相关的心得。所以,放轻松。函数式编程并不是全是 lambda、组合子、Monad 这类东西。

很多人讲函数式喜欢从 λ 演算开始,确实,那是函数式编程的理论基础。但函数式编程教派和 λ 演算之间的关系就像是道教和道家的关系,宗教与思想。 λ 演算无疑是伟大的,但切勿抬高其衍生宗教的地位。编程是一个务实的工作,所以咱们还得从实际出发。以下的内容,没有那么“原教旨函数式”,但都解决了现实存在的问题。如果想要拓展思维,从根本上去学习函数式编程,那么还是建议看看 λ 演算。

我们开始吧。

partial

这个单词翻译成中文是“部分的”,在函数式编程里有重要的地位。它的功能就是给一个函数固定一些参数然后创建一个需要剩余参数的函数,著名的柯里化(currying)就是它的一种特例——柯里化只固定一个参数,偏函数允许你固定任意个参数。

文字说明似乎有点抽象,在 Python 标准库 functools 里有一个同名的函数 partial 就实现了相同的功能。看下面一段代码,通过 partial 这个函数,固定了 map 函数的 func 参数,并返回了一个新的函数 square,它的功能是计算并返回每个元素的平方。

from functools import partial

square = partial(map, lambda x: x**2)
print(list(square([1, 2, 3, 4])))

如果不使用标准库中的 partial,也可以绑定参数,只不过标准库默认使用了 C 实现,速度更快。看下面的例子。

partial = lambda f, *p: lambda *args: f(*p, *args)

square = partial(map, lambda x: x**2)
print(list(square([1, 2, 3, 4])))

管道

几乎所有支持函数式编程的语言里都有管道这个设计。但影响最广的应该是 Shell 的管道,ps aux | grep python 几乎每个 Python 程序员都用过这条命令。它的作用是将前一条命令的输出作为后一条命令的输入,它可以优雅、方便的组合多个程序。

在编程语言里,管道就是把 f(args) 这种传参形式变成了 args | f。目前看起来并没有什么变化,好像没什么优势。那么看看下面这个代码,它的作用是计算 0 到 100 和 900 到 1000 之内所有奇数的和。你能分清楚后面哪个括号对应前面哪个括号吗?老实说,在我写这段代码的时候丢掉了最后一个括号,如果不是我的编辑器告诉我,我很难发现这个错误。

from itertools import chain

sum(filter(lambda x: x % 2, chain(range(100), range(900, 1000))))

这是嵌套函数调用的弊端,当你试图这么去写代码的时候,先思考一下,你的身体素质能不能扛得住将来维护这段代码的同事的组合拳攻击。

那么如果使用管道改造呢?啊,等等,Python 没有管道!没关系,先用上面的知识写一个管道吧(下面代码的出处:四行代码实现 Python 管道)。

from functools import partial


class F(partial):
    def __ror__(self, other):
        return self(other)

接下来使用管道来改造上面那段括号都不知道谁对谁的代码。请先跟着我的文字来理解一下上面那段代码的含义:首先把 0 到 100 的值和 900 到 1000 的值合并成一个可迭代对象,然后筛选这个可迭代对象里的奇数,最后把所有奇数加起来。

chain(range(100), range(900, 1000)) | F(filter, lambda x: x % 2) | F(sum)

看这份经过管道改造后的代码,代码的书写顺序和人的思考顺序达成了惊人的一致。而且你也不需要区分哪个括号对应哪个了,当你需要往里面插入一点新功能的时候,可以放心的在任何一个管道前后插入一个新函数而不用担心因为括号层数不对导致代码加错了地方。

当然,如果你希望把 chain(range(100), range(900, 1000)) 这种需要接受两个参数的函数也使用管道改造,则需要用到四行代码实现 Python 管道中的 FF,可以改成下面这样。

(range(100), range(900, 1000)) | FF(chain) | F(filter, lambda x: x % 2) | F(sum)

纯函数

提到函数式编程,必然不能不提纯函数。

纯函数是指一个不修改除了作用域仅在函数内的变量、不操作 IO 设备且其输出和输入值以外的其他隐藏信息或状态无关、也和由I/O设备产生的外部输出无关的函数。

拆解成正常人能读懂的话,其中有三个关键点:

  • 输出不依赖于除去函数参数以外的变量(注意,可以依赖外部的常量)
  • 输出不依赖于 IO 设备产生的外部输出(例如,不依赖于数据库查询、读取文件)
  • 函数不更改除去作用域仅在函数内部以外的变量

以下是一些纯函数的例子。第一个函数的输出只依赖于输入参数,典型纯函数。第二个函数使用了函数外的常量,它也是一个纯函数。第三个例子里,虽然为 CONVALUE 赋值了,但没有使用 global 关键词,所以没有改变外部的变量,仅改变了函数作用域内的变量,它仍然是一个纯函数。

add = lambda x, y: x + y

CONVALUE = 10
f = lambda x: x * x * CONVALUE


def ff(x):
    CONVALUE = 8
    return x * x * 8

非纯函数的例子。第一个例子,修改了外部变量,非纯函数。第二个读了文件,非纯函数。

CONVALUE = 10


def ff(x):
    global CONVALUE
    CONVALUE = 8
    return x * x * 8


def read_file(filepath):
    with open(filepath) as file:
        return file.read()

纯函数的好处在于它不依赖外部状态,便于做测试、阅读与 DEBUG。现实世界里是无法避免进行 IO 操作的,于是纯粹的函数式编程语言使用 Monad 来处理这种情况。但 Python 并不是纯粹的函数式编程语言,所以引入 Monad 完全没有必要,也比较反 Python 程序员的一般习惯。在 Python 里,你只需要尽量缩小非纯函数(数据库查询、网络 IO、文件操作等)能影响的范围,使大部分函数都是纯函数,就可以让代码更加方便测试、维护。

高阶函数

在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数:接受一个或多个函数作为输入;输出一个函数。在数学中它们也叫做算子或泛函。微积分中的导数就是常见的例子,因为它映射一个函数到另一个函数。

上述的 partial 就是一种高阶函数。高阶函数只是名字里带一个“高”而已,并不高级。在 Python 这种万物皆 PyObject 的编程语言里,把函数作为参数调用是最自然的一件事,比如 Python 的装饰器。@ 只不过是一个语法糖,它的本质是把装饰器下面的函数作为参数传递给装饰器这个高阶函数,然后用装饰器返回的函数替换原始的函数。用代码表示如下,两种写法是等价的。

@d
def f(): ...

---

def f(): ...
f = d(f)

如果把函数当作一系列过程的组合,那么高阶函数就是一种可以把部分过程委托出去的函数。高阶函数的意义在于你可以组合其他函数来完成工作。

看看下面这样一个函数,给出一个数字,遍历所有小于它的自然数,如果是奇数则最终生成的序列中同等位置标记为 True,否则标记 False,并不是很复杂的函数。但如果你还需要编写取偶自然数、质数、非质数等等函数,那么你需要把 for number in range(max) 这行代码写许多遍。

def get_odd_numbers(max):
    array = []
    for number in range(max):
        array.append(bool(number % 2))
    return array

大家都知道,如果一段代码你经常用到,那么你应该把它写成一个函数。但在循环下的代码不是固定的代码,如果用传统的函数写法,肯定是行不通的。这时候,高阶函数就可以派上用场了。通过把部分过程委托给 f 这个参数,可以做到标记奇数、偶数、质数等等各种数字,消灭了相当一部分的重复代码。

def iter_numbers(f, max):
    array = []
    for i in range(max):
        array.append(f(i))
    return array


get_odd_numbers = partial(iter_numbers, lambda i: bool(i % 2))

上述代码实现了一个类似于 Python 内置函数 map 的高阶函数。而 Python 内置的高阶函数 mapfilter 的定义类似于下面的代码。当有类似于下述代码被大量使用时,先想想是不是可以使用内置的 mapfilter 等函数来消灭一部分的重复代码。

def map(f, iterable):
    for i in iterable:
        yield f(i)


def filter(f, iterable):
    for i in iterable:
        if f(i):
            yield i

另外,Python 有自己的方式可以代替 mapfilter,你也不必任何时候都想着高阶函数,推导式可以在一次循环里先执行 filter 再执行 map(一个小技巧,推导式可以嵌套),某些情况下比高阶函数更加实用。

range(100) | F(map, lambda x: x * x) | F(filter, lambda x: x % 2) | F(list)

上面这段代码可以改写成下面的推导式形式。在内层推导式里,对每个 i 都做了平方运算,在外层推导式里进行筛选,最后使用一个 [] 包裹整个推导式可以把推导式的结果变成一个列表。

[i for i in (i * i for i in range(100)) if i % 2]

推导式和你嵌套调用函数一样,代码执行顺序和阅读顺序并不是一致的。所以切勿写太长的推导式,否则你还是会挨一套同事的组合拳。但是有一种情况下,推导式比 | F(map, ...) 更优雅。看下面的代码,先 filter 然后 map,没有推导式一次就搞定来的爽快、优雅。

range(100) | F(filter, lambda x: x % 2) | F(map, lambda x: x * x) | F(list)

---

[i * i for i in range(100) if i % 2]

你还可以把推导式和其他函数混在一起用,毕竟推导式也只是一个普通的表达式。如下例所示。

range(100) | F(lambda it: [i * i for i in it if i % 2])

---

range(100) | F(filter, lambda x: x % 2) | F(map, lambda x: x * x) | F(list)
如果你觉得本文值得,不妨赏杯茶
K8S 中控制 Pod 内容器启动顺序
基于抢占式 Keepalived 的健康检查设计