使用函数
通过把代码片段抽象为函数,程序可以变得更具声明性。
from random import randomdef move_cars(): for i, _ in enumerate(car_positions): if random() > 0.3: car_positions[i] += 1def draw_car(car_position): print '-' * car_positiondef 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()
要理解这段程序,读者只需阅读主循环即可。“如果时间还有剩余,则比赛前进一步并输出结果,然后再次检查时间。如果读者想了解关于"比赛前进一步"或"打印输出"的更多细节,可以阅读相关函数代码。
无需过多说明,代码已描述了一切。
拆分代码到函数之中,可以让代码更具可读性。
该技术使用到了函数,但它只是把函数用作子程序来打包代码,从这个指导意义上来说,代码并非函数式的。函数中的代码使用到了并非作为参数传入的状态值。它们通过改变外部变量影响了其周围的代码,而不是通过返回函数值。为了检查一个函数究竟做了什么,读者必须仔细阅读每一行代码。如果他们发现一个外部变量,他们必须找到变量的源头,而且必须查看是否有其它函数改变了该变量的值。
移除状态
这是车辆比赛代码的函数式版本:
from random import randomdef move_cars(car_positions): return map(lambda x: x + 1 if random() > 0.3 else x, car_positions)def output_car(car_position): return '-' * car_positiondef 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()
的返回值进行递归。每一次前进一步产生的新状态,都被立即传入下一步之中。
现在,这里有两个函数, 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()
的函数,它接受一个字符串和一个形如 zero()
和 one()
的规则函数列表作为参数。对字符串调用第一个规则函数,除非 None
被返回,否则它对返回的字符串调用第二个规则函数。除非 None
被返回,否则它对返回的字符串调用第三个规则函数。以此类推... 如果有任何规则函数返回 None
, rule_sequence()
函数停止执行并返回 None
,否则它会返回最后一个规则函数的返回值。
这是一些输入输出示例:
print rule_sequence('0101', [zero, one, zero]) # => 1print 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:])
使用管道
在上一节中,一些命令式循环被重写为递归函数,这些递归函数调用某些辅助函数。在本节中,一个不同类型的命令式循环将使用管道技术进行重写。
下面的循环对字典进行转换,该字典包含乐队名称、错误国籍以及一些乐队活动状态。
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', 'active': False, 'country': 'Canada'},# {'name': 'Women', 'active': False, 'country': 'Canada' },# {'name': 'A Silver Mt Zion', 'active': True, 'country': 'Canada'}]
函数的名字让人有点‘担心’,因为“format”一词的含义非常模糊。在仔细检查代码之前,担心就已经开始了。同一循环中做了三件事情: 'country'
键的值被设为 'Canada'
;删除乐队名称中的标点符号;乐队名称首字母大写。很难说清代码究竟想要做什么,也很难判断代码是否做了它似乎想要做的事情。而且代码难以复用、难以测试,也难以并行化。
将其与以下相比:
print pipeline_each(bands, [set_canada_as_country, strip_punctuation_from_name, capitalize_names])
这段代码很容易理解,它给人的印象是,这些辅助函数都是函数式的,因为它们似乎都串在一起。来自前一个函数的输出构成了下一个函数的输入。如果它们是函数式的,它们很容易验证,而且它们易于复用、易于测试、易于并行化。
pipeline_each()
作业每次传递一个乐队到一个转换函数中,比如 set_canada_as_country()
。在函数应用于所有乐队之后, pipeline_each()
函数捆绑所有转换后的乐队,然后,它传递每一个乐队到下一个函数中。
让我们看看转换函数。
def assoc(_d, key, value): from copy import deepcopy d = deepcopy(_d) d[key] = value return ddef 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())
每一个函数将一个乐队的一个键关联到一个新值。在不改变原乐队的情况下,很难做到这一点。通过使用 deepcopy()
函数生成一个字典拷贝, assoc()
函数解决了这一难题。每一个转换函数都是基于拷贝来修改,并返回这个拷贝。
一切看起来很完美,原乐队字典被保护起来,免受字典中的键被赋予新值的影响。但上述代码仍然存在两处潜在的改变。在 strip_punctuation_from_name()
函数中,去除名字中的标点符号是通过对原名字调用 replace()
函数完成的。在 capitalize_names()
函数中,名字的首字母大写是通过对原名字调用 title()
函数完成的。如果 replace()
和 title()
不是函数式的,那么 strip_punctuation_from_name()
和 capitalize_names()
也不是函数式的。
幸运的是, replace()
和 title()
不会改变它们所操作的字符串,这是因为字符串在Python中是不可变的。例如,当乐队名字字符串调用 replace()
时,原乐队名字先生成一个拷贝,然后使用这个拷贝调用 replace()
函数 。哎呀,好险啊!
Python 的字符串和字典在可变性方面的反差,充分展示了如 Clojure 之类语言的魅力。程序员再也不需要为他们是否改变了数据而担心,答案当然是否定的。
练习 4 . 试着编写 `pipeline_each`` 函数,把排序考虑进去。对于传入的乐队数组,每次只传入一个乐队到第一个转换函数。返回的乐队结果数组再次作为参数传入,每次只传入一个乐队到第二个转换函数。以此类推。
我的解决方法:
def pipeline_each(data, fns): return reduce(lambda a, x: map(x, a), fns, data)
三个转换函数的功能归根到底是去改变传入乐队的一个特定键的值。 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 ddef call(fn, key): def apply_fn(record): return assoc(record, key, fn(record.get(key))) return apply_fn
这里发生了很多事情,让我们一点一点来剖析。
第一, call()
是一个高阶函数。高阶函数接受一个函数作为参数,或者返回一个函数,或者像 call()
函数一样两者兼具。
第二, apply_fn()
看上去和三个转换函数非常类似。它接受一个记录(一个乐队)作为参数,查找 record[key]
的值,然后对该值执行 fn
函数,并把函数执行结果分配给该记录的一份拷贝,最后返回这个拷贝。
第三, call()
函数并不执行任何实际操作。 apply_fn()
函数被调用时,负责执行具体操作。在上面使用 pipeline_each()
的例子中, apply_fn()
其中的一个实例是对传入乐队的 'country'
设置为 'Canada'
。 另一个实例是将传入乐队的名称首字母大写。
第四,当一个 apply_fn()
实例运行时, fn
和 key
已经不在函数范围之中了。它们既不是 apply_fn()
的参数,也不是它的内部变量,但是它们仍然可以被访问到。当定义一个函数时,它可以保存对一个已关闭变量的引用:那些被定义在函数范围之外却在函数内部使用的变量。当函数运行并且代码中引用了一个变量时,Python会在本地变量和参数中查找这个变量。如果没有找到,它会到保存过的已关闭变量引用中去查找。这里正是找到 fn
和 key
的地方。
第五,在 call()
函数代码中并没有提及乐队。这是因为 call()
函数被用来为任何程序生成管道函数,不管是什么主题。函数式编程部分是关于构建一个通用的、可重用的、可组合的函数库。
干得不错。闭包、高阶函数以及变量范围在上面几个段落中全部涉及到了。来杯柠檬水放松一下(看来女程序员都喜欢柠檬水啊)。
关于乐队,还有一点工作需要去做,也就是只保留名称和国籍,其它都删除。 extract_name_and_country()
可以把那些无关信息删除:
def extract_name_and_country(band): plucked_band = {} plucked_band['name'] = band['name'] plucked_band['country'] = band['country'] return plucked_bandprint 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()
接受一个键列表作为参数,从每一个记录中提取信息。尝试编写这个函数,它需要使用一个高阶函数。
我的解决方法:
def pluck(keys): def pluck_fn(record): return reduce(lambda a, x: assoc(a, x, record[x]), keys, {}) return pluck_fn
接下来呢?
函数式代码可以很好地与其它风格编写的代码和平共处。本篇文章涉及到的转换函数可以应用于任何语言代码,尝试应用它们到你自己的代码中。
思考 Mary、Isla 和 Sam 列表问题,把对列表的迭代转换成 maps 和 reduces 方式。
思考那个比赛问题,把代码封装成函数,使那些函数成为函数式代码,把重复过程的循环转换成递归方式。
思考那个关于乐队的问题,把一系列操作转换成管道方式。
(1) 不可变数据是指不能被改变的数据。一些语言如 Clojure,默认所有值为不可变数据。任何“改变”操作都是基于原值的拷贝进行,首先复制一个拷贝,然后改变拷贝,最后传回这个更改的拷贝。程序可能会陷入程序员不完备的可能状态模型,这样做可以消除由此引发的错误。
(2) 所有将函数视为一等公民,对函数和其它值一视同仁的语言。这就意味着、你不仅可以创建函数,你还可以将函数作为参数传递给函数,作为返回值从函数中返回,存储在数据结构中。
(3) 尾部调用优化是一种编程语言的特性。每一次递归调用,都会产生一个新堆栈帧,用于为当前调用存储参数和本地变量。如果一个函数递归调用很多次,很可能会导致解释器或编译器内存溢出。具备尾部调用优化功能的语言,对于整个序列的递归调用,重用同一个堆栈帧。像 Python 之类的语言没有尾部调用优化这一特性,因此限制了一个函数可以递归调用的次数只能数千次。在 race()
函数中,只有区区5次调用,因此毫无问题。
(4) 柯里化的意思是,把接受多个参数的函数转换成接受第一个参数作为(唯一)参数的函数,并且返回接受第二个参数作为(唯一)参数的新函数,其余以此类推。
(5) 并行化意味着同时运行相同的代码而无需同步。这些并发进程通常运行在多个处理器上。
(6) 惰性求值是一项编译器技术。其目的是将代码运行推迟到实际需要这段代码的最终结果的时候。
(7) 如果每次运行都能生成同样的结果,那么这个进程就具有确定性。
-全文结束-
作者: Mary Rose ,一名程序员兼音乐人,生活在纽约,在 Recurse Center 工作。
原文: A practical introduction to functional programming
感谢: Jodoo 帮助审阅并完成校对。