转载

函数式编程实用介绍(2)

使用函数

通过把代码片段抽象为函数,程序可以变得更具声明性。

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()  

要理解这段程序,读者只需阅读主循环即可。“如果时间还有剩余,则比赛前进一步并输出结果,然后再次检查时间。如果读者想了解关于"比赛前进一步"或"打印输出"的更多细节,可以阅读相关函数代码。

无需过多说明,代码已描述了一切。

拆分代码到函数之中,可以让代码更具可读性。

该技术使用到了函数,但它只是把函数用作子程序来打包代码,从这个指导意义上来说,代码并非函数式的。函数中的代码使用到了并非作为参数传入的状态值。它们通过改变外部变量影响了其周围的代码,而不是通过返回函数值。为了检查一个函数究竟做了什么,读者必须仔细阅读每一行代码。如果他们发现一个外部变量,他们必须找到变量的源头,而且必须查看是否有其它函数改变了该变量的值。

移除状态

这是车辆比赛代码的函数式版本:

from random import random def 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_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]})  

代码仍然被拆分为多个函数,但是所有函数都是函数式的。它们有三个特征:第一,不存在任何共享的变量。 timecar_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 被返回,否则它对返回的字符串调用第三个规则函数。以此类推... 如果有任何规则函数返回 Nonerule_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:]) 

使用管道

在上一节中,一些命令式循环被重写为递归函数,这些递归函数调用某些辅助函数。在本节中,一个不同类型的命令式循环将使用管道技术进行重写。

下面的循环对字典进行转换,该字典包含乐队名称、错误的国籍以及一些乐队活动状态。

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 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())  

每一个函数将一个乐队的一个键关联到一个新值。在不改变原乐队的情况下,很难做到这一点。通过使用 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 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[key] 的值,然后对该值执行 fn 函数,并把函数执行结果分配给该记录的一份拷贝,最后返回这个拷贝。

第三, call() 函数并不执行任何实际操作。 apply_fn() 函数被调用时,负责执行具体操作。在上面使用 pipeline_each() 的例子中, apply_fn() 其中的一个实例是对传入乐队的 'country' 设置为 'Canada' 。 另一个实例是将传入乐队的名称首字母大写。

第四,当一个 apply_fn() 实例运行时, fnkey 已经不在函数范围之中了。它们既不是 apply_fn() 的参数,也不是它的内部变量,但是它们仍然可以被访问到。当定义一个函数时,它可以保存对一个已关闭变量的引用:那些被定义在函数范围之外却在函数内部使用的变量。当函数运行并且代码中引用了一个变量时,Python会在本地变量和参数中查找这个变量。如果没有找到,它会到保存过的已关闭变量引用中去查找。这里正是找到 fnkey 的地方。

第五,在 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_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() 接受一个键列表作为参数,从每一个记录中提取信息。尝试编写这个函数,它需要使用一个高阶函数。

我的解决方法:

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 帮助审阅并完成校对。

正文到此结束
Loading...