函数式编程实战教程

许多函数式文章讲述的是组合,流水线和高阶函数这样的抽象函数式技术。本文不一样,它展现了人们天天编写的命令式,非函数式代码示例,以及将这些示例转换为函数式风格。python

文章的第一部分将一些短小的数据转换循环重写成函数式的maps和reduces。第二部分选取长一点的循环,把他们分解成单元,而后把每一个单元改为函数式的。第三部分选取一个很长的连续数据转换循环,而后把它分解成函数式流水线。程序员

示例都是用Python写的,由于不少人以为Python易读。为了证实函数式技术对许多语言来讲都相同,许多示例避免使用Python特有的语法:map,reduce,pipeline。算法

导引

当人们谈论函数式编程,他们会提到很是多的“函数式”特性。提到不可变数据¹,第一类对象²以及尾调用优化³。这些是帮助函数式编程的语言特征。提到mapping(映射),reducing(概括),piplining(管道),recursing(递归),currying4(科里化);以及高阶函数的使用。这些是用来写函数式代码的编程技术。提到并行5,惰性计算6以及肯定性。这些是有利于函数式编程的属性。编程

忽略所有这些。能够用一句话来描述函数式代码的特征:避免反作用。它不会依赖也不会改变当前函数之外的数据。全部其余的“函数式”的东西都源于此。当你学习时把它当作指引。api

这是一个非函数式方法:数组

1
2
3
4
= 0
def increment1():
     global a
     + = 1

这是一个函数式的方法:安全

1
2
def increment2(a):
     return + 1

不要在lists上迭代。使用map和reduce。

Map(映射)

Map接受一个方法和一个集合做为参数。它建立一个新的空集合,以每个集合中的元素做为参数调用这个传入的方法,而后把返回值插入到新建立的集合中。最后返回那个新集合。数据结构

这是一个简单的map,接受一个存放名字的list,而且返回一个存放名字长度的list:闭包

1
2
3
4
name_lengths  = map ( len , [ "Mary" "Isla" "Sam" ])
 
print name_lengths
# => [4, 4, 3]

接下来这个map将传入的collection中每一个元素都作平方操做:并发

1
2
3
4
squares  = map ( lambda x: x  * x, [ 0 1 2 3 4 ])
 
print squares
# => [0, 1, 4, 9, 16]

这个map并无使用一个命名的方法。它是使用了一个匿名而且内联的用lambda定义的方法。lambda的参数定义在冒号左边。方法主体定义在冒号右边。返回值是方法体运行的结果。

下面的非函数式代码接受一个真名列表,而后用随机指定的代号来替换真名。

1
2
3
4
5
6
7
8
9
10
import random
 
names  = [ 'Mary' 'Isla' 'Sam' ]
code_names  = [ 'Mr. Pink' 'Mr. Orange' 'Mr. Blonde' ]
 
for in range ( len (names)):
     names[i]  = random.choice(code_names)
 
print names
# => ['Mr. Blonde', 'Mr. Blonde', 'Mr. Blonde']

(正如你所见的,这个算法可能会给多个密探同一个秘密代号。但愿不会在任务中混淆。)

这个能够用map重写:

1
2
3
4
5
6
7
8
import random
 
names  = [ 'Mary' 'Isla' 'Sam' ]
 
secret_names  = map ( lambda x: random.choice([ 'Mr. Pink' ,
                                             'Mr. Orange' ,
                                             'Mr. Blonde' ]),
                    names)

练习1.尝试用map重写下面的代码。它接受由真名组成的list做为参数,而后用一个更加稳定的策略产生一个代号来替换这些名字。

1
2
3
4
5
6
7
names  = [ 'Mary' 'Isla' 'Sam' ]
 
for in range ( len (names)):
     names[i]  = hash (names[i])
 
print names
# => [6306819796133686941, 8135353348168144921, -1228887169324443034]

(但愿密探记忆力够好,不要在执行任务时把代号忘记了。)

个人解决方案:

1
2
3
names  = [ 'Mary' 'Isla' 'Sam' ]
 
secret_names  = map ( hash , names)

Reduce(迭代)

Reduce 接受一个方法和一个集合作参数。返回经过这个方法迭代容器中全部元素产生的结果。

这是个简单的reduce。返回集合中全部元素的和。

1
2
3
4
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'这个词在字符串列表中出现的频率:

 

1
2
3
4
5
6
7
8
9
10
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写的:

1
2
3
4
5
6
7
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.' 初始的累加和由第三个参数来指定。这样就容许了集合中元素的类型能够与累加器不一样。

为何map和reduce更好?

首先,它们大可能是一行代码。

2、迭代中最重要的部分:集合,操做和返回值,在全部的map和reduce中老是在相同的位置。

3、循环中的代码可能会改变以前定义的变量或以后要用到的变量。照例,map和reduce是函数式的。

4、map和reduce是元素操做。每次有人读到for循环,他们都要逐行读懂逻辑。几乎没有什么规律性的结构能够帮助理解代码。相反,map和reduce都是建立代码块来组织复杂的算法,而且读者也能很是快的理解元素并在脑海中抽象出来。“嗯,代码在转换集合中的每个元素。而后结合处理的数据成一个输出。”

5、map和reduce有许多提供便利的“好朋友”,它们是基本行为的修订版。例如filter,all,any以及find。

练习2。尝试用map,reduce和filter重写下面的代码。Filter接受一个方法和一个集合。返回集合中使方法返回true的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

若是这个比较棘手,试着不要考虑数据上的操做。考虑下数据要通过的状态,从people字典列表到平均高度。不要尝试把多个转换捆绑在一块儿。把每个放在独立的一行,而且把结果保存在命名良好的变量中。代码能够运行后,马上凝练。

个人方案:

1
2
3
4
5
6
7
8
9
10
people  = [{ 'name' 'Mary' 'height' 160 },
           { 'name' 'Isla' 'height' 80 },
           { 'name' 'Sam' }]
 
heights  = 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)

写声明式代码,而不是命令式

下面的程序演示三辆车比赛。每次移动时间,每辆车可能移动或者不动。每次移动时间程序会打印到目前为止全部车的路径。五次后,比赛结束。

下面是某一次的输出:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-
- -
- -
 
- -
- -
- - -
 
- - -
- -
- - -
 
- - - -
- - -
- - - -
 
- - - -
- - - -
- - - - -

这是程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from random  import random
 
time  = 5
car_positions  = [ 1 1 1 ]
 
while time:
     # decrease time
     time  - = 1
 
     print ''
     for in range ( len (car_positions)):
         # move car
         if random() >  0.3 :
             car_positions[i]  + = 1
 
         # draw car
         print '-' * car_positions[i]

代码是命令式的。一个函数式的版本应该是声明式的。应该描述要作什么,而不是怎么作。

使用方法

经过绑定代码片断到方法里,可使程序更有声明式的味道。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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,能够读方法里的代码。

注释没有了。代码是自描述的。

把代码分解提炼进方法里是很是好且十分简单的提升代码可读性的方法。

这个技术用到了方法,可是只是当作常规的子方法使用,只是简单地将代码打包。根据指导,这些代码不是函数式的。代码中的方法使用了状态,而不是传入参数。方法经过改变外部变量影响了附近的代码,而不是经过返回值。为了搞清楚方法作了什么,读者必须仔细阅读每行。若是发现一个外部变量,必须找他它的出处,找到有哪些方法修改了它。

移除状态

下面是函数式的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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中。第二,方法接受参数。第三,方法里没有实例化变量。全部的数据变化都在返回值中完成。rece() 使用run_step_of_race() 的结果进行递归。每次一个步骤会产生一个状态,这个状态会直接传进下一步中。

如今,有两个方法,zero() 和 one():

1
2
3
4
5
6
7
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和一个用于存放zero()和one()模式的规则方法的list。在string上调用第一个规则。除非返回None,否则它会继续接受返回值而且在string上调用第二个规则。除非返回None,否则它会接受返回值,而且调用第三个规则。等等。若是有哪个规则返回None,rule_sequence()方法中止,并返回None。否则,返回最后一个规则方法的返回值。

下面是一个示例输出:

1
2
3
4
5
print rule_sequence( '0101' , [zero, one, zero])
# => 1
 
print rule_sequence( '0101' , [zero, zero])
# => None

This is the imperative version of rule_sequence():
这是一个命令式的版本:

1
2
3
4
5
6
7
def rule_sequence(s, rules):
     for rule  in rules:
         = rule(s)
         if = = None :
             break
 
     return s

练习3。上面的代码用循环来完成功能。用递归重写使它更有声明式的味道。

个人方案:

1
2
3
4
5
def rule_sequence(s, rules):
     if = = None or not rules:
         return s
     else :
         return rule_sequence(rules[ 0 ](s), rules[ 1 :])

使用流水线

在以前的章节,一些命令式的循环被重写成递归的形式,并被用以调用辅助方法。在本节中,会用pipline技术重写另外一种类型的命令式循环。

下面有个存放三个子典型数据的list,每一个字典存放一个乐队相关的三个键值对:姓名,不许确的国籍和激活状态。format_bands方法循环处理这个list。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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'。名称中的标点符号被移除了。名称首字母改为了大写。可是很难看出这段代码的目的是什么,是否作了它看上去所作的。而且代码难以重用,难以测试和并行。

和下面这段代码比较一下:

1
2
3
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收集起来。而后再依次传入下一个方法中。

咱们来看看转换方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def assoc(_d, key, value):
     from copy  import deepcopy
     = 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上。在不改变原值的状况下是很难作到的。assoc()经过使用deepcopy()根据传入的dictionary产生一个拷贝来解决这个问题。每一个转换方法修改这个拷贝,而后将这个拷贝返回。

彷佛这样就很好了。原始Band字典再也不担忧由于某个键值须要关联新的值而被改变。可是上面的代码有两个潜在的反作用。在方法strip_punctuation_from_name()中,未加标点的名称是经过在原值上调用replace()方法产生的。在capitalize_names()方法中,将名称的首字母大写是经过在原值上调用title()产生的。若是replace()和title()不是函数式的,strip_punctuation_from_name()和capitalize_names()也就不是函数式的。

幸运的是,replace() 和 title()并不改变它们所操做的string。由于Python中的strings是不可变的。例如,当replace()操做band的名称字符串时,是先拷贝原字符串,而后对拷贝的字符串作修改。啧啧。

Python中string和dictionaries的可变性比较阐述了相似Clojure这类语言的吸引力。程序员永远不用担忧数据是否可变。数据是不可变的。

练习4。试着重写pipeline_each方法。考虑操做的顺序。每次从数组中拿出一个bands传给第一个转换方法。而后相似的再传给第二个方法。等等。

My solution:
个人方案:

1
2
3
4
def pipeline_each(data, fns):
     return reduce ( lambda a, x:  map (x, a),
                   fns,
                   data)

全部的三个转换方法归结于对传入的band的特定字段进行更改。call()能够用来抽取这个功能。call接受一个方法作参数来调用,以及一个值的键用来当这个方法的参数。

1
2
3
4
5
6
7
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])

或者,若是咱们但愿能知足简洁方面的可读性,那么就:

1
2
3
print pipeline_each(bands, [call( lambda x:  'Canada' 'country' ),
call( lambda x: x.replace( '.' , ' '), ' name'),
call( str .title,  'name' )])

call()的代码:

1
2
3
4
5
6
7
8
9
10
def assoc(_d, key, value):
     from copy  import deepcopy
     = 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

There is a lot going on here. Let’s take it piece by piece.

这段代码作了不少事。让咱们一点一点的看。

1、call() 是一个高阶函数。高阶函数接受一个函数做为参数,或者返回一个函数。或者像call(),二者都有。

2、apply_fn() 看起来很像那三个转换函数。它接受一个record(一个band),查找在record[key]位置的值,以这个值为参数调用fn,指定fn的结果返回到record的拷贝中,而后返回这个拷贝。

3、call() 没有作任何实际的工做。当call被调用时,apply_fn()会作实际的工做。上面使用pipeline_each()的例子中,一个apply_fn()的实例会将传入的band的country值改成”Canada“。另外一个实例会将传入的band的名称首字母大写。

4、当一个apply_fn() 实例运行时,fn和key将再也不做用域中。它们既不是apply_fn()的参数,也不是其中的本地变量。可是它们仍然能够被访问。当一个方法被定义时,方法会保存方法所包含的变量的引用:那些定义在方法的做用域外,却在方法中使用的变量。当方法运行而且代码引用一个变量时,Python会查找本地和参数中的变量。若是没找到,就会去找闭包内保存的变量。那就是找到fn和key的地方。

5、在call()代码中没有提到bands。由于无论主题是什么,call()均可觉得任何程序生成pipeline。函数式编程部分目的就是构建一个通用,可重用,可组合的函数库。

干的漂亮。闭包,高阶函数和变量做用域都被包含在段落里。喝杯柠檬水。

还须要在band上作一点处理。就是移除band上除了name和country以外的东西。extract_name_and_country()能拉去这样的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()能够这样使用:

1
2
3
4
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中抽取数据。试着写写。须要用到高阶函数。

个人方案:

1
2
3
4
5
6
def pluck(keys):
     def pluck_fn(record):
         return reduce ( lambda a, x: assoc(a, x, record[x]),
                       keys,
                       {})
     return pluck_fn

What now?

还有什么要作的吗?

函数式代码能够很好的和其余风格的代码配合使用。文章中的转换器能够用任何语言实现。试试用你的代码实现它。

想一想Mary,Isla 和 Sam。将对list的迭代,转成maps和reduces操做吧。

想一想汽车竞赛。将代码分解成方法。把那些方法改为函数式的。把循环处理转成递归。

想一想乐队。将一系列的操做改写成pipeline。

标注:

一、一块不可变数据是指不能被改变的数据。一些语言像Clojure的语言,默认全部的值都是不可变的。任何的可变操做都是拷贝值,并对拷贝的值作修改并返回。这样就消除了程序中对未完成状态访问所形成的bugs。

二、支持一等函数的语言容许像处理其余类型的值那样处理函数。意味着方法能够被建立,传给其余方法,从方法中返回以及存储在其余数据结构里。

三、尾调用优化是一个编程语言特性。每次方法递归,会建立一个栈。栈用来存储当前方法须要使用的参数和本地值。若是一个方法递归次数很是多,极可能会让编译器或解释器消耗掉全部的内存。有尾调用优化的语言会经过重用同一个栈来支持整个递归调用的序列。像Python这样的语言不支持尾调用优化的一般都限制方法递归的数量在千次级别。在race()方法中,只有5次,因此很安全。

四、Currying意即分解一个接受多个参数的方法成一个只接受第一个参数而且返回一个接受下一个参数的方法的方法,直到接受完全部参数。

五、并行意即在不一样步的状况下同时运行同一段代码。这些并发操做经常运行在不一样的处理器上。

六、惰性计算是编译器的技术,为了不在须要结果以前就运行代码。

七、只有当每次重复都能得出相同的结果,才能说处理是肯定性的。

 

文章首先在伯乐在线翻译并校稿,而后收录在本博客内

相关文章
相关标签/搜索