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

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

使用函数

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容