许多函数式文章讲述的是组合、流水线和高阶函数这样的抽象函数式技术。本文不同,它展示了人们每天编写的命令式、非函数式代码示例,以及将这些示例转换为函数式风格。
文章的第一部分将一些短小的数据转换循环重写成函数式的 maps 和 reduces 。第二部分选取长一点的循环,把他们分解成单元,然后把每个单元改成函数式的。第三部分选取一个很长的连续数据转换循环,然后把它分解成函数式流水线。
示例都是用 Python 写的,因为很多人觉得 Python 易读。为了证明函数式技术对许多语言来说都相同,许多示例避免使用 Python 特有的语法:map、reduce、pipeline 。
导引
当人们谈论函数式编程,他们会提到非常多的 “函数式” 特性。提到不可变数据1、一等函数2以及尾调用优化3,这些是帮助函数式编程的语言特征。提到 mapping(映射)、reducing(归纳)、pipelining(管道)、recursing(递归)、currying4(科里化)以及高阶函数的使用,这些是用来写函数式代码的编程技术。提到并行5、惰性计算6以及确定性7,这些是有利于函数式编程的属性。
忽略全部这些,可以用一句话来描述函数式代码的特征:避免副作用。它不会依赖、也不会改变当前函数以外的数据。所有其他的 “函数式” 的东西都源于此。当你学习本文时请将这句话作为指引。
这是一个非函数式方法:
a = 0
def increment():
global a
a += 1
这是一个函数式的方法:
def increment(a):
return a + 1
不要在lists上迭代。使用map和reduce。
Map(映射)
Map 接受一个方法和一个集合作为参数。它创建一个新的空集合,以集合中每个元素作为参数,调用传入的方法,然后将返回值插入到新创建的集合中。最后返回这个新集合。
这是一个简单的 map,接受一个存放名字的 list,返回一个存放名字长度的 list:
name_lengths = map(len, ["Mary", "Isla", "Sam"])
print(list(name_lengths))
# => [4, 4, 3]
接下来这个 map 将传入的 collection 中每个元素都做平方操作:
squares = map(lambda x: x * x, [0, 1, 2, 3, 4])
print(list(squares))
# => [0, 1, 4, 9, 16]
这个 map 并没有使用一个命名的方法。它是使用了一个用 lambda
定义的,匿名并且内联的方法。lambda 的参数定义在冒号左边,方法主体定义在冒号右边,返回值是方法体运行的结果。
下面的非函数式代码接受一个真名列表,然后用随机指定的代号来替换真名。
import random
names = ['Mary', 'Isla', 'Sam']
code_names = ['Mr. Pink', 'Mr. Orange', 'Mr. Blonde']
for i in range(len(names)):
names[i] = random.choice(code_names)
print(names)
# => ['Mr. Blonde', 'Mr. Blonde', 'Mr. Blonde']
(正如你所见的,这个算法可能会给多个密探同一个秘密代号。希望这不会在任务中混淆。)
现在可以用 map 重写:
import random
names = ['Mary', 'Isla', 'Sam']
secret_names = map(lambda x: random.choice(['Mr. Pink',
'Mr. Orange',
'Mr. Blonde']),
names)
练习1:尝试用 map 重写下面的代码。它接受由真名组成的 list 作为参数,然后用一个更加稳定的策略产生一个代号来替换这些名字。
names = ['Mary', 'Isla', 'Sam']
for i in range(len(names)):
names[i] = hash(names[i])
print(names)
# => [-3894872169223495002, -1027760254031825677, -767524867397611892]
(希望密探记忆力够好,不要在执行任务时把代号忘记了。)
我的解决方案:
names = ['Mary', 'Isla', 'Sam']
secret_names = map(hash, names)
Reduce(迭代)
Reduce 接受一个方法和一个集合做参数。返回通过这个方法迭代容器中所有元素产生的结果。
这是个简单的 reduce。返回集合中所有元素的和。
from functools import reduce
sum = reduce(lambda a, x: a + x, [0, 1, 2, 3, 4])
print(sum)
# => 10
x
是迭代的当前元素。a
是累加和也就是在之前的元素上执行lambda返回的值。reduce()
遍历元素。每次迭代,在当前的 a
和 x
上执行 lambda 然后返回结果作为下一次迭代的 a
。
第一次迭代的 a
是什么?在这之前没有迭代结果传进来。reduce()
使用集合中的第一个元素作为第一次迭代的 a
,然后从第二个元素开始迭代。也就是说,第一个 x
是第二个元素。
下面这段代码记 'Sam'
这个单词在字符串列表中出现的频率:
sentences = ['Mary read a story to Sam and Isla.',
'Isla cuddled Sam.',
'Sam chortled.']
sam_count = 0
for sentence in sentences:
sam_count += sentence.count('Sam')
print(sam_count)
# => 3
下面是用 reduce 写的:
from functools import reduce
sentences = ['Mary read a story to Sam and Isla.',
'Isla cuddled Sam.',
'Sam chortled.']
sam_count = reduce(lambda a, x: a + x.count('Sam'),
sentences,
0)
这段代码如何初始化 a
?出现 'Sam'
的起始点不能是 ’Mary read a story to Sam and Isla.’
。初始的累加和 a 由 reduce()
的第三个参数来指定。这样就允许了集合中元素的类型可以与累加器不同。
为什么 map 和 reduce 更好?
首先,它们大多是一行代码。
其次,迭代中最重要的部分:集合、操作和返回值,在所有的 map 和 reduce 中总是在相同的位置。
再次,循环(for
)中的代码可能会改变之前定义的变量或之后要用到的变量。照例,maps 和 reduces 是函数式的。
然后,map 和 reduce 是元素操作。每次有人读到 for
循环,他们都要逐行读懂逻辑。几乎没有什么规律性的结构可以帮助理解代码。相反,map 和 reduce 都是创建代码块来组织复杂的算法,并且读者也能非常快的理解元素,并在脑海中抽象出来。“啊,这段代码正在转换集合中的每一个元素。它丢弃了一些转换结果。它把剩下的组合成一个输出。”
最后,map 和 reduce 有许多提供便利的 “好朋友”,它们是基本行为的修订版。例如 filter
、all
、any
以及 find
。
练习2:尝试用 map 、reduce 和 filter 重写下面的代码。Filter 接受一个方法和一个集合,返回集合中使方法返回 True
的元素。
people = [{'name': 'Mary', 'height': 160},
{'name': 'Isla', 'height': 80},
{'name': 'Sam'}]
height_total = 0
height_count = 0
for person in people:
if 'height' in person:
height_total += person['height']
height_count += 1
if height_count > 0:
average_height = height_total / height_count
print(average_height)
# => 120.0
如果这个比较棘手,试着不要考虑数据上的操作。考虑下数据要经过的状态,从 people 字典列表到平均高度。不要尝试把多个转换捆绑在一起。把每一个放在独立的一行,并且把结果保存在命名良好的变量中。代码可以运行后,再提炼。
我的方案:
from functools import reduce
people = [{'name': 'Mary', 'height': 160},
{'name': 'Isla', 'height': 80},
{'name': 'Sam'}]
heights = list(map(lambda x: x['height'],
filter(lambda x: 'height' in x, people)))
if len(heights) > 0:
from operator import add
average_height = reduce(add, heights) / len(heights)
写声明式代码,而不是命令式
下面的程序演示三辆车比赛。每次可移动时间,每辆车可能移动或者不动。每次可移动时间,程序会打印到目前为止所有车的路径。五次后,比赛结束。
下面是某一次的输出:
-
--
--
--
---
--
--
----
--
--
-----
---
---
------
----
这是程序:
from random import random
time = 5
car_positions = [1, 1, 1]
while time:
# decrease time
time -= 1
print()
for i in range(len(car_positions)):
# move car
if random() > 0.3:
car_positions[i] += 1
# draw car
print('-' * car_positions[i])
代码是命令式的。一个函数式的版本应该是声明式的。应该描述要做什么,而不是怎么做。
使用函数
通过绑定代码片段到函数里,可以使程序更有声明式的味道。
from random import random
def move_cars():
for i, _ in enumerate(car_positions):
if random() > 0.3:
car_positions[i] += 1
def draw_car(car_position):
print('-' * car_position)
def run_step_of_race():
global time
time -= 1
move_cars()
def draw():
print()
for car_position in car_positions:
draw_car(car_position)
time = 5
car_positions = [1, 1, 1]
while time:
run_step_of_race()
draw()
想要理解这段代码,读者只需要看主循环:“如果 time 不为 0,运行一下 run_step_of_race 和 draw ,再检查一下 time 。”如果读者想更多地理解这段代码中的 run_step_of_race 或 draw ,可以自行阅读函数对应的代码。
代码里没有注释,因为这段代码是自描述的。
把代码分解成函数是非常好的,这样可以提高代码的可读性。
上面的代码用到了函数,但也只是将函数作为子程序来使用,最后把它们打包成代码。根据本文导引的定义,上述代码不是函数式的。因为代码中的函数使用了状态,并且这个状态不是通过函数参数传入的。函数通过改变外部变量,而不是通过返回值,来影响了其他代码的行为。为了搞清楚函数的真正行为,读者必须仔细阅读每行代码。如果发现一个外部变量,你就必须先找到它定义的地方,再寻找有哪些函数会修改这个变量。
移除状态
下面是函数式的版本:
from random import random
def move_cars(car_positions):
return list(map(lambda x: x + 1 if random() > 0.3 else x,
car_positions))
def output_car(car_position):
return '-' * car_position
def run_step_of_race(state):
return {'time': state['time'] - 1,
'car_positions': move_cars(state['car_positions'])}
def draw(state):
print()
print('\n'.join(map(output_car, state['car_positions'])))
def race(state):
draw(state)
if state['time']:
race(run_step_of_race(state))
race({'time': 5,
'car_positions': [1, 1, 1]})
代码仍然是分解成不同的函数,但是这段代码是函数式的。
函数式的函数有三个特征:
-
没有共享变量。
time
和car_positions
直接传进方法race()
中。 -
函数接受参数。
-
函数里没有实例化变量。
所有的数据变化都通过返回值实现。
race()
使用run_step_of_race()
的结果进行递归3。每次递归的 step 都产生一个新状态,这个状态会直接传递给下一个 step 。
现在,有两个函数,zero()
和 one()
:
def zero(s):
if s[0] == "0":
return s[1:]
def one(s):
if s[0] == "1":
return s[1:]
zero()
接受一个字符串 s
作为参数,如果第一个字符是 '0'
,方法返回字符串的其他部分。如果不是,返回 None,Python 的默认返回值。one()
做的事情相同,除了第一个字符要求是 '1'
。
想象一个名为 rule_sequence()
的函数,接受一个 string 和一个 list,list 用来存放由 zero()
和 one()
组成的一系列规则函数。在 string 上调用第一个规则。除非返回 None
,不然它会继续接受返回值并且在 string 上调用第二个规则。除非返回 None
,不然它会接受返回值,并且调用第三个规则。等等。如果有哪一个规则返回 None
,rule_sequence()
方法停止,并返回 None
。不然,返回最后一个规则方法的返回值。
下面是一个示例:
print(rule_sequence('0101', [zero, one, zero]))
# => 1
print(rule_sequence('0101', [zero, zero]))
# => None
这是 rule_sequence()
的命令式版本:
def rule_sequence(s, rules):
for rule in rules:
s = rule(s)
if s == None:
break
return s
练习3:上面的代码用循环来完成功能。用递归重写使它更有声明式的味道。
我的方案:
def rule_sequence(s, rules):
if s == None or not rules:
return s
else:
return rule_sequence(rules[0](s), rules[1:])
使用流水线
前上一节中,一些命令式的循环被重写为调用辅助函数的递归形式。在本节中,会用一种称为 pipeline 的技术重写为另一种形式的命令式循环。
下面的 list 存放了三个字典型的数据,每个字典存放一个乐队相关的三个键值对:姓名、不正确的国籍和激活状态。format_bands()
函数循环处理这个 list 。
bands = [{'name': 'sunset rubdown', 'country': 'UK', 'active': False},
{'name': 'women', 'country': 'Germany', 'active': False},
{'name': 'a silver mt. zion', 'country': 'Spain', 'active': True }]
def format_bands(bands):
for band in bands:
band['country'] = 'Canada'
band['name'] = band['name'].replace('.', '')
band['name'] = band['name'].title()
format_bands(bands)
print(bands)
# => [{'name': 'Sunset Rubdown', 'country': 'Canada', 'active': False}, {'name': 'Women', 'country': 'Canada', 'active': False}, {'name': 'A Silver Mt Zion', 'country': 'Canada', 'active': True}]
函数名会引起一些担忧。”format” 是一个很模糊的词。仔细检查代码以后,这些担忧开始显现。循环中做了三件事:键为 'country'
的值被设置为 'Canada'
;姓名中的标点符号被移除了;姓名首字母改成了大写。但是很难看出这段代码的目的是什么,是否做了它看上会去做的事。并且代码难以重用、难以测试已经难以并行。
和下面这段代码比较一下:
print(pipeline_each(bands, [set_canada_as_country,
strip_punctuation_from_name,
capitalize_names]))
这段代码很容易理解。它去除了副作用,辅助函数给人的感觉是函数式的,因为它们看上去被链接在一起。前一个函数的输出构成下一个函数的输入。如果这些方法是函数式的,那么就很容易进行验证。它们很容易重用、测试并且也很容易并行。
pipeline_each()
的工作是传递 bands,一次传递一个元素给转换函数,比如 set_cannada_as_country()
。当 bands 中的所有元素都用来调用过这个函数之后,pipeline_each()
将转换后的 bands 收集起来。再依次传递给下一个函数。
我们来看看转换函数。
def assoc(_d, key, value):
from copy import deepcopy
d = deepcopy(_d)
d[key] = value
return d
def set_canada_as_country(band):
return assoc(band, 'country', "Canada")
def strip_punctuation_from_name(band):
return assoc(band, 'name', band['name'].replace('.', ''))
def capitalize_names(band):
return assoc(band, 'name', band['name'].title())
每一个函数都将 band 的一个 key 关联到一个新的 value 上。这在不改变原始 band 的情况下是很难做到的。assoc()
通过使用 deepcopy()
生成传入的字典的一个拷贝来解决这个问题。每个转换函数修改这个拷贝,然后将这个拷贝返回。
一切看上去好像都很完美。原始的 band 字典不再担心因为某个 key 需要关联新的 value 而被改变。但是上面的代码有两个潜在的副作用。在 strip_punctuation_from_name()
中,不含标点的姓名是通过在原值上调用 replace()
方法产生的。在 capitalize_names()
中,姓名的首字母大写是通过在原值上调用 title()
产生的。如果 replace()
和 title()
不是函数式的,那么 strip_punctuation_from_name()
和 capitalize_names()
也就不是函数式的。
幸运的是,replace()
和 title()
并不改变它们所操作的字符串。因为 Python 中的字符串是不可变的。当 replace()
操作 band 的姓名字符串时,也是先拷贝原始字符串,然后再对拷贝的字符串做修改。啧啧。
Python 中字符串和字典之间可变性的不同,突显出类似 Clojure 这类语言的吸引力。程序员永远不用担心数据是否可变,数据是不可变的。
练习4:试着写出 pipeline_each
函数。考虑操作的顺序,首先传递数组中的每个 band 给第一个转换函数,每次传递一个 ;接着传递结果数组中的每个 band 给第二个转换函数,每次传递一个。等等。
我的方案:
def pipeline_each(data, fns):
return reduce(lambda a, x: map(x, a),
fns,
data)
归根到底,三个转换函数都是对传入的 band 的一个特定字段进行更改。call()
可以用来抽象这个功能。call()
接受一个函数和一个键作为参数。
set_canada_as_country = call(lambda x: 'Canada', 'country')
strip_punctuation_from_name = call(lambda x: x.replace('.', ''), 'name')
capitalize_names = call(str.title, 'name')
print pipeline_each(bands, [set_canada_as_country,
strip_punctuation_from_name,
capitalize_names])
或者,如果我们希望能满足简洁方面的可读性,那么就:
print pipeline_each(bands, [call(lambda x: 'Canada', 'country'),
call(lambda x: x.replace('.', ''), 'name'),
call(str.title, 'name')])
call()
的代码:
def assoc(_d, key, value):
from copy import deepcopy
d = deepcopy(_d)
d[key] = value
return d
def call(fn, key):
def apply_fn(record):
return assoc(record, key, fn(record.get(key)))
return apply_fn
这段代码做了很多事情,让我们一点一点的看。
-
首先,
call()
是一个高阶函数。高阶函数接受一个函数作为参数,或者返回一个函数。或者像call()
,两者都有。 -
其次,
apply_fn()
看起来很像那三个转换函数。它接受一个 record(一个 band),查找在record[key]
的值,以这个值为参数调用fn
,指定fn
的结果返回到 record 的拷贝中,然后返回这个拷贝。 -
再次,
call()
没有做任何实际的工作。它被调用时,实际上是apply_fn()
真正在工作。上面使用pipeline_each()
的例子中,一个apply_fn()
的实例将传入的 band 的'country'
设为'Canada'
。另一个实例将传入的 band 中的名字的首字母设为大写。 -
然后,当一个
apply_fn()
实例运行时,fn
和key
将不在作用域中。它们既不是apply_fn()
的参数,也不是其中的局部变量。但是它们仍然可以被访问。当一个函数被定义时,它会保存方函数结束前包含的变量的引用:那些定义在函数的作用域外,却在函数中使用的变量。当函数运行并且代码引用一个变量时,Python 会查找局部和参数中的变量。如果没找到,就会去找闭包内保存的变量。那就是找到fn
和key
的地方。 -
最后,在
call()
的代码中没有提到 bands。因为不管程序的主题是什么,call()
都可以为其生成流水线函数。函数式编程的一部分目的就是构建一个通用、可重用、可组合的函数库。
干的漂亮。闭包、高阶函数和变量作用域都被包含在本节中。喝杯可口的柠檬水吧。
还需要在 band 上做一点处理。就是移除 name 和 country 以外的所有东西。extract_name_and_country()
可以实现这个目标。
def extract_name_and_country(band):
plucked_band = {}
plucked_band['name'] = band['name']
plucked_band['country'] = band['country']
return plucked_band
print pipeline_each(bands, [call(lambda x: 'Canada', 'country'),
call(lambda x: x.replace('.', ''), 'name'),
call(str.title, 'name'),
extract_name_and_country])
# => [{'name': 'Sunset Rubdown', 'country': 'Canada'},
# {'name': 'Women', 'country': 'Canada'},
# {'name': 'A Silver Mt Zion', 'country': 'Canada'}]
extract_name_and_country()
可以写成名为 pluck()
的通用函数。pluck()
可以这样使用:
print pipeline_each(bands, [call(lambda x: 'Canada', 'country'),
call(lambda x: x.replace('.', ''), 'name'),
call(str.title, 'name'),
pluck(['name', 'country'])])
练习5:pluck()
接受一系列的键值,根据这些键值从 record 中提取数据。试着写写。需要用到高阶函数。
我的方案:
def pluck(keys):
def pluck_fn(record):
return reduce(lambda a, x: assoc(a, x, record[x]),
keys,
{})
return pluck_fn
还有什么要做的吗?
函数式代码可以很好的和其他风格的代码配合使用。文章中的转换器可以用在任何代码里面,并且可以用任何语言实现。试试将它们用在你自己的代码里。
想想 Mary、Isla 和 Sam 。是如何将对 list 的迭代操作,转换成 map 和 reduce 操作?
想想汽车赛。是如何将代码分解成函数,再将函数改造成函数式,最后把循环的重复处理转换成递归操作的?
想想乐队。是如何将一系列的操作转换成流水线(pipeline)的?
标注
英文原文:A practical introduction to functional programming
译文原文:函数式编程实战教程(Python版) —— 链接已失效
一块不可变数据是指不能被改变的数据。一些语言(比如 Clojure),默认所有的值都是不可变的。任何的可变操作都是通过拷贝值,并返回修改后的拷贝来实现的。这样就消除了程序中访问未完成状态,所造成的 bug。 ↩︎
支持一等函数的语言允许像处理其他类型的值那样处理函数。意味着函数可以被创建、传给其他函数、从函数中返回以及存储在其他数据结构里。 ↩︎
尾调用优化是一种编程语言特性。每次函数递归,都会创建一个栈。栈用来存储当前函数需要使用的参数和局部变量。如果一个函数递归次数非常多,很可能会让编译器或解释器消耗掉所有的内存。有尾调用优化的语言会通过重用同一个栈来支持整个递归调用的序列。像 Python 这样不支持尾调用优化的语言,通常会限制函数递归的数量在千次级别。在
race()
方法中,只有5次,所以很安全。 ↩︎ ↩︎Currying 的意思是:将接受多个参数的函数分解成一个只接受第一个参数的函数,这个函数返回一个接受第二个参数的函数,以此类推,直到接受完所有参数。 ↩︎
并行的意思是在不同步的情况下并发地运行同一段代码。这些并发操作常常运行在不同的处理器上。 ↩︎
惰性计算是编译器的技术,用来避免在真正需要代码的结果之前,就运行了代码。 ↩︎
只有当每次重复都能得出相同的结果,才能说处理是确定性的。 ↩︎