返回顶部

OutOfMemory.CN技术专栏-> Python-> 函数式编程实用介绍(2)

函数式编程实用介绍(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]})

代码仍然被拆分为多个函数,但是所有函数都是函数式的。它们有三个特征:第一,不存在任何共享的变量。 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])  
# => 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() 实例运行时, 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_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 帮助审阅并完成校对。

推荐阅读:
支持

1

反对

0

Qingniu:我有明珠一颗,久被尘劳关锁,今朝尘尽光生,照破山河万朵。 柴陵郁禅师(宋)

您可以通过下面的社交媒体联系/了解作者的更多信息:

发表评论