并发的艺术-2:函数式编程

起源中,简单讨论了并发相关的各种概念,这一篇我们换个出发点,从函数式编程出发,找到它们之间的交点。

什么是函数式编程

函数是一个从数学那儿借来的工具,如果大伙还记得,课本上一般把函数定义成从定义域(Domain)到值域(Range)的映射(Mapping)。

放在编程语言的语境中,函数式编程意味着一种程序设计的范式:即所有计算通过函数来表达,给定输入,就能得到输出。
理想情况下,在纯函数(Pure Function)内部,不存在任何状态,只有单纯的计算过程(原料仅仅是那些输入)。

因此,调用函数具有绝对的确定性。 拿生活中的例子来看,函数式编程就像操作计算器,输入1 + 1,必然等于2,任何时候都等于2。

如此简单明了。

函数式编程的高级特性

高阶函数(High-Order Function)

高阶函数之所以比普通函数“高”,是因为在高阶函数眼里,函数也能作为输入或者输出。这为我们抽象建模提供了跨世代的便利:
如果说普通函数给了我们定义行为的能力。
那么高阶函数给了我们编排行为的能力。

1
2
3
4
5
def add_one(a):
return a + 1

def reduce_one(a):
return a - 1

例如上述两个普通函数,分别定义了加和乘两个行为。
如果我们想实现把行为执行n次的话,在没有高阶函数前,我们需要分别定义:

1
2
3
4
5
def add_one_by_n_times(a, n):
return add_one_by_n_times(add_one(a), n-1) if n > 0 else a

def reduce_one_by_n_times(a, n):
return reduce_one_by_n_times(reduce_one(a), n-1) if n > 0 else a

但是我们只需要一个高阶函数就能实现同样的目的:

1
2
def operate_by_n_times(operate, n):
return reduce_one_by_n_times(operate(a), n-1) if n > 0 else a

高下立判。

让我们再看一个例子:

1
2
3
4
5
6
7
8
9
10
def add_one(a):
return a + 1

def add_two(func):
def inner():
return func() + 2
return inner

add_three = add_two(add_one)
print(add_three(10)) # 13

通过利用返回函数的能力,我们可以将多个函数显示的编排成新函数。

通过将函数提拔为一等公民(First Class),我们进入了新的境界 = ^ = 。

闭包(Closure)

闭包是实现函数作为返回值所带来的副产品,考虑如下例子:

1
2
3
4
5
6
7
def add_x(x):
return lambda a, x : a + x # lambda是python的匿名函数实现

add_one = add_x(1)
add_two = add_x(2)
print(add_one(3)) # 4
print(add_two(3)) # 5

这段代码中的add_one是一个偏函数(Partial Function),即部分参数一井被固化的函数。这种函数定义加上执行环境(固化部分参数)的组合就叫闭包。

柯里化(Currying)

柯里化是上文闭包的一种特殊应用,即把多入参函数等价转化成多个单参函数的递归调用。

代码先上为敬:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import inspect
from functools import partial

def currying(func):
sig = inspect.signature(func)
args_cnt = len(sig.parameters.items())

def wrapper(*args):
return currying(partial(func, *args)) if len(args) < args_cnt else func(*args)
return wrapper

def log(level, obj, msg):
return "[{level}]{obj}: {msg}".format(level=level, obj=obj, msg=msg)

curry_log = currying(log)
info_log = curry_log('info')
server_info_log = info_log('server')
print(server_info_log('i am server')) # output: [info]server: i am server
print(info_log('client', 'i am client')) # output: [info]client: i am client
print(curry_log('debug')('middleware')('i am middleware')) # output: [debug]middleware: i am middleware

为了简化说明问题,上面这段程序假设被柯里化的程序只接受位置参数(Positional Arguments)而非字典参数(Dictionary Arguments)。

可以发现,柯里化的实际意义在于两处:

  1. 启示我们一个多参函数可以被设计成多个子功能函数的管道执行,这些子函数尽量做到正交,这样就能排列组合出各种变化。
  2. 可以延迟一个多参函数的执行到所有参数收集完毕之时。

延迟执行(Lazy Evaluation)

延迟执行是函数式编程赠送给我们的一个超能力,通过把计算逻辑封装到一个新的函数中,我们可以把这部分计算的执行推迟到这个新函数被调用的时候。

可是这种计算和执行的分离能带来什么实际意义呢?我的答案是优化空间。
当计算都被包装成一个个函数时,我们可以在外部提供一个优化器,在全局视角下根据执行资源(例如线程/内存/CPU)对函数进行重新编排重组甚至有选择地执行。

函数式编程的优势

易于测试

为自己的代码准备单元测试是一个好习惯,但是如果手上的待测函数是这样的:

1
2
3
4
import time

def now():
return time.time()

我们要如何为它写单元测试呢?因为这个函数依赖外部状态(时间),我们无法精确模拟结果。
但是如果我们手上的每个函数都是纯函数,就可以把它当作黑盒编写测试代码,并确保只要通过测试,这个函数就是牢不可破的。

并发友好

起源中,我们已经提到计算机的并发源自于物理上多核的能力,并且其最大的挑战就来自于如何管理共享资源。
回过来看函数式编程,由于其纯函数的形式完全避免了对函数外的状态管理,对于并发执行天然友好。同时对于计算的编排能力也能让我们更好的根据资源优化执行。

为什么我们对函数式感觉陌生

既然函数式编程看上去具有这么多优点,为什么我们会对函数式感觉那么陌生呢?
我认为有两个主要原因:

  • 先入为主
    x = x + 1 这个表达式大家都很熟悉吧,很多计算机初学者第一次接触到它的时候都感到震惊,因为在常识中,x无论如何都不可能和x+1相等。这背后揭示了计算机领域太过基础易被忽略的细节:
    x = x + 1 建模的是内存单元的赋值,即状态改变是计算机最最最基础的操作,正是它,构筑起了冯诺依曼机器的底座。
    在计算机指令集中,到处都是类似上述内存赋值这类基于操作状态的指令,工程师们在理解这些指令的同时,维护状态的意识已经潜移默化渗入潜意识。当遇到函数式编程这种强调避免副作用(状态)的思想时自然惊为天人。

  • 理解曲线陡峭
    函数式编程毕竟出自于数学的形式化理论,属于在基础之上抽象出来的新范式。为了支持它,需要大量使用递归调用。而我们的大脑,神经网络的结构决定我们对于递归调用的理解效率明显低于不同函数之间的相互调用(本质上还是广度折叠)。
    关于这一点,大家只要想想平时自己走读代码时的切身体验就能确认了。

结论

虽然都是为了写程序让计算机达成我们预设的目标,指令式编程和函数式编程之间不仅仅是语法上的差异,其背后暗示的是两种不同的世界观:

在函数式眼中:我们的世界是可以被模拟和推导的,只要我们定义出世界函数以及对应的入参,就一定能得到对应且唯一的状态作为输出。
在指令式眼中:我们的世界是独一无二且不可复制的,即使各个入参都相等,计算本身也对这个世界施加了独一无二的影响,能够与这个世界之外的神衹产生联系。

哈哈没错,这是无神论与有神论的又一次碰撞。

参考文献

  1. 编程语言概念