用Python进行基础的函数式编程的教程
许多函数式文章讲述的是组合,流水线和高阶函数这样的抽象函数式技术。本文不同,它展示了人们每天编写的命令式,非函数式代码示例,以及将这些示例转换为函数式风格。
文章的第一部分将一些短小的数据转换循环重写成函数式的maps和reduces。第二部分选取长一点的循环,把他们分解成单元,然后把每个单元改成函数式的。第三部分选取一个很长的连续数据转换循环,然后把它分解成函数式流水线。
示例都是用Python写的,因为很多人觉得Python易读。为了证明函数式技术对许多语言来说都相同,许多示例避免使用Python特有的语法:map,reduce,pipeline。
导引
当人们谈论函数式编程,他们会提到非常多的“函数式”特性。提到不可变数据1,第一类对象2以及尾调用优化3。这些是帮助函数式编程的语言特征。提到mapping(映射),reducing(归纳),piplining(管道),recursing(递归),currying4(科里化);以及高阶函数的使用。这些是用来写函数式代码的编程技术。提到并行5,惰性计算6以及确定性。这些是有利于函数式编程的属性。
忽略全部这些。可以用一句话来描述函数式代码的特征:避免副作用。它不会依赖也不会改变当前函数以外的数据。所有其他的“函数式”的东西都源于此。当你学习时把它当做指引。
这是一个非函数式方法:
a=0 defincrement1(): globala a+=1
这是一个函数式的方法:
defincrement2(a): returna+1
不要在lists上迭代。使用map和reduce。
Map(映射)
Map接受一个方法和一个集合作为参数。它创建一个新的空集合,以每一个集合中的元素作为参数调用这个传入的方法,然后把返回值插入到新创建的集合中。最后返回那个新集合。
这是一个简单的map,接受一个存放名字的list,并且返回一个存放名字长度的list:
name_lengths=map(len,["Mary","Isla","Sam"]) printname_lengths #=>[4,4,3]
接下来这个map将传入的collection中每个元素都做平方操作:
squares=map(lambdax:x*x,[0,1,2,3,4]) printsquares #=>[0,1,4,9,16]
这个map并没有使用一个命名的方法。它是使用了一个匿名并且内联的用lambda定义的方法。lambda的参数定义在冒号左边。方法主体定义在冒号右边。返回值是方法体运行的结果。
下面的非函数式代码接受一个真名列表,然后用随机指定的代号来替换真名。
importrandom names=['Mary','Isla','Sam'] code_names=['Mr.Pink','Mr.Orange','Mr.Blonde'] foriinrange(len(names)): names[i]=random.choice(code_names) printnames #=>['Mr.Blonde','Mr.Blonde','Mr.Blonde']
(正如你所见的,这个算法可能会给多个密探同一个秘密代号。希望不会在任务中混淆。)
这个可以用map重写:
importrandom names=['Mary','Isla','Sam'] secret_names=map(lambdax:random.choice(['Mr.Pink', 'Mr.Orange', 'Mr.Blonde']), names)
练习1.尝试用map重写下面的代码。它接受由真名组成的list作为参数,然后用一个更加稳定的策略产生一个代号来替换这些名字。
names=['Mary','Isla','Sam'] foriinrange(len(names)): names[i]=hash(names[i]) printnames #=>[6306819796133686941,8135353348168144921,-1228887169324443034]
(希望密探记忆力够好,不要在执行任务时把代号忘记了。)
我的解决方案:
names=['Mary','Isla','Sam'] secret_names=map(hash,names)
Reduce(迭代)
Reduce接受一个方法和一个集合做参数。返回通过这个方法迭代容器中所有元素产生的结果。
这是个简单的reduce。返回集合中所有元素的和。
sum=reduce(lambdaa,x:a+x,[0,1,2,3,4]) printsum #=>10
x是迭代的当前元素。a是累加和也就是在之前的元素上执行lambda返回的值。reduce()遍历元素。每次迭代,在当前的a和x上执行lambda然后返回结果作为下一次迭代的a。
第一次迭代的a是什么?在这之前没有迭代结果传进来。reduce()使用集合中的第一个元素作为第一次迭代的a,然后从第二个元素开始迭代。也就是说,第一个x是第二个元素。
这段代码记'Sam'这个词在字符串列表中出现的频率:
sentences=['MaryreadastorytoSamandIsla.', 'IslacuddledSam.', 'Samchortled.'] sam_count=0 forsentenceinsentences: sam_count+=sentence.count('Sam') printsam_count #=>3
下面这个是用reduce写的:
sentences=['MaryreadastorytoSamandIsla.', 'IslacuddledSam.', 'Samchortled.'] sam_count=reduce(lambdaa,x:a+x.count('Sam'), sentences, 0)
这段代码如何初始化a?出现‘Sam'的起始点不能是'MaryreadastorytoSamandIsla.'初始的累加和由第三个参数来指定。这样就允许了集合中元素的类型可以与累加器不同。
为什么map和reduce更好?
首先,它们大多是一行代码。
二、迭代中最重要的部分:集合,操作和返回值,在所有的map和reduce中总是在相同的位置。
三、循环中的代码可能会改变之前定义的变量或之后要用到的变量。照例,map和reduce是函数式的。
四、map和reduce是元素操作。每次有人读到for循环,他们都要逐行读懂逻辑。几乎没有什么规律性的结构可以帮助理解代码。相反,map和reduce都是创建代码块来组织复杂的算法,并且读者也能非常快的理解元素并在脑海中抽象出来。“嗯,代码在转换集合中的每一个元素。然后结合处理的数据成一个输出。”
五、map和reduce有许多提供便利的“好朋友”,它们是基本行为的修订版。例如filter,all,any以及find。
练习2。尝试用map,reduce和filter重写下面的代码。Filter接受一个方法和一个集合。返回集合中使方法返回true的元素。
people=[{'name':'Mary','height':160}, {'name':'Isla','height':80}, {'name':'Sam'}] height_total=0 height_count=0 forpersoninpeople: if'height'inperson: height_total+=person['height'] height_count+=1 ifheight_count>0: average_height=height_total/height_count printaverage_height #=>120
如果这个比较棘手,试着不要考虑数据上的操作。考虑下数据要经过的状态,从people字典列表到平均高度。不要尝试把多个转换捆绑在一起。把每一个放在独立的一行,并且把结果保存在命名良好的变量中。代码可以运行后,立刻凝练。
我的方案:
people=[{'name':'Mary','height':160}, {'name':'Isla','height':80}, {'name':'Sam'}] heights=map(lambdax:x['height'], filter(lambdax:'height'inx,people)) iflen(heights)>0: fromoperatorimportadd average_height=reduce(add,heights)/len(heights)
写声明式代码,而不是命令式
下面的程序演示三辆车比赛。每次移动时间,每辆车可能移动或者不动。每次移动时间程序会打印到目前为止所有车的路径。五次后,比赛结束。
下面是某一次的输出:
- -- -- -- -- --- --- -- --- ---- --- ---- ---- ---- -----
这是程序:
fromrandomimportrandom time=5 car_positions=[1,1,1] whiletime: #decreasetime time-=1 print'' foriinrange(len(car_positions)): #movecar ifrandom()>0.3: car_positions[i]+=1 #drawcar print'-'*car_positions[i]
代码是命令式的。一个函数式的版本应该是声明式的。应该描述要做什么,而不是怎么做。
使用方法
通过绑定代码片段到方法里,可以使程序更有声明式的味道。
fromrandomimportrandom defmove_cars(): fori,_inenumerate(car_positions): ifrandom()>0.3: car_positions[i]+=1 defdraw_car(car_position): print'-'*car_position defrun_step_of_race(): globaltime time-=1 move_cars() defdraw(): print'' forcar_positionincar_positions: draw_car(car_position) time=5 car_positions=[1,1,1] whiletime: run_step_of_race() draw()
想要理解这段代码,读者只需要看主循环。”如果time不为0,运行下run_step_of_race和draw,在检查下time。“如果读者想更多的理解这段代码中的run_step_of_race或draw,可以读方法里的代码。
注释没有了。代码是自描述的。
把代码分解提炼进方法里是非常好且十分简单的提高代码可读性的方法。
这个技术用到了方法,但是只是当做常规的子方法使用,只是简单地将代码打包。根据指导,这些代码不是函数式的。代码中的方法使用了状态,而不是传入参数。方法通过改变外部变量影响了附近的代码,而不是通过返回值。为了搞清楚方法做了什么,读者必须仔细阅读每行。如果发现一个外部变量,必须找他它的出处,找到有哪些方法修改了它。
移除状态
下面是函数式的版本:
fromrandomimportrandom defmove_cars(car_positions): returnmap(lambdax:x+1ifrandom()>0.3elsex, car_positions) defoutput_car(car_position): return'-'*car_position defrun_step_of_race(state): return{'time':state['time']-1, 'car_positions':move_cars(state['car_positions'])} defdraw(state): print'' print'n'.join(map(output_car,state['car_positions'])) defrace(state): draw(state) ifstate['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():
defzero(s): ifs[0]=="0": returns[1:] defone(s): ifs[0]=="1": returns[1:]
zero()接受一个字符串s作为参数,如果第一个字符是'0′,方法返回字符串的其他部分。如果不是,返回None,Python的默认返回值。one()做的事情相同,除了第一个字符要求是'1′。
想象下一个叫做rule_sequence()的方法。接受一个string和一个用于存放zero()和one()模式的规则方法的list。在string上调用第一个规则。除非返回None,不然它会继续接受返回值并且在string上调用第二个规则。除非返回None,不然它会接受返回值,并且调用第三个规则。等等。如果有哪一个规则返回None,rule_sequence()方法停止,并返回None。不然,返回最后一个规则方法的返回值。
下面是一个示例输出:
printrule_sequence('0101',[zero,one,zero]) #=>1 printrule_sequence('0101',[zero,zero]) #=>None
Thisistheimperativeversionofrule_sequence():
这是一个命令式的版本:
defrule_sequence(s,rules): forruleinrules: s=rule(s) ifs==None: break returns
练习3。上面的代码用循环来完成功能。用递归重写使它更有声明式的味道。
我的方案:
defrule_sequence(s,rules): ifs==Noneornotrules: returns else: returnrule_sequence(rules[0](s),rules[1:])
使用流水线
在之前的章节,一些命令式的循环被重写成递归的形式,并被用以调用辅助方法。在本节中,会用pipline技术重写另一种类型的命令式循环。
下面有个存放三个子典型数据的list,每个字典存放一个乐队相关的三个键值对:姓名,不准确的国籍和激活状态。format_bands方法循环处理这个list。
bands=[{'name':'sunsetrubdown','country':'UK','active':False}, {'name':'women','country':'Germany','active':False}, {'name':'asilvermt.zion','country':'Spain','active':True}] defformat_bands(bands): forbandinbands: band['country']='Canada' band['name']=band['name'].replace('.','') band['name']=band['name'].title() format_bands(bands) printbands #=>[{'name':'SunsetRubdown','active':False,'country':'Canada'}, #{'name':'Women','active':False,'country':'Canada'}, #{'name':'ASilverMtZion','active':True,'country':'Canada'}]
担心源于方法的名称。”format”是一个很模糊的词。仔细查看代码,这些担心就变成抓狂了。循环中做三件事。键值为'country'的值被设置为'Canada'。名称中的标点符号被移除了。名称首字母改成了大写。但是很难看出这段代码的目的是什么,是否做了它看上去所做的。并且代码难以重用,难以测试和并行。
和下面这段代码比较一下:
printpipeline_each(bands,[set_canada_as_country, strip_punctuation_from_name, capitalize_names])
这段代码很容易理解。它去除了副作用,辅助方法是函数式的,因为它们看上去是链在一起的。上次的输出构成下个方法的输入。如果这些方法是函数式的,那么就很容易核实。它们很容易重用,测试并且也很容易并行。
pipeline_each()的工作是传递bands,一次传一个,传到如set_cannada_as_country()这样的转换方法中。当所有的bands都调用过这个方法之后,pipeline_each()将转换后的bands收集起来。然后再依次传入下一个方法中。
我们来看看转换方法。
defassoc(_d,key,value): fromcopyimportdeepcopy d=deepcopy(_d) d[key]=value returnd defset_canada_as_country(band): returnassoc(band,'country',"Canada") defstrip_punctuation_from_name(band): returnassoc(band,'name',band['name'].replace('.','')) defcapitalize_names(band): returnassoc(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传给第一个转换方法。然后类似的再传给第二个方法。等等。
Mysolution:
我的方案:
defpipeline_each(data,fns): returnreduce(lambdaa,x:map(x,a), fns, data)
所有的三个转换方法归结于对传入的band的特定字段进行更改。call()可以用来抽取这个功能。call接受一个方法做参数来调用,以及一个值的键用来当这个方法的参数。
set_canada_as_country=call(lambdax:'Canada','country') strip_punctuation_from_name=call(lambdax:x.replace('.',''),'name') capitalize_names=call(str.title,'name') printpipeline_each(bands,[set_canada_as_country, strip_punctuation_from_name, capitalize_names])
或者,如果我们希望能满足简洁方面的可读性,那么就:
printpipeline_each(bands,[call(lambdax:'Canada','country'), call(lambdax:x.replace('.',''),'name'), call(str.title,'name')])
call()的代码:
defassoc(_d,key,value): fromcopyimportdeepcopy d=deepcopy(_d) d[key]=value returnd defcall(fn,key): defapply_fn(record): returnassoc(record,key,fn(record.get(key))) returnapply_fn
Thereisalotgoingonhere.Let'stakeitpiecebypiece.
这段代码做了很多事。让我们一点一点的看。
一、call()是一个高阶函数。高阶函数接受一个函数作为参数,或者返回一个函数。或者像call(),两者都有。
二、apply_fn()看起来很像那三个转换函数。它接受一个record(一个band),查找在record[key]位置的值,以这个值为参数调用fn,指定fn的结果返回到record的拷贝中,然后返回这个拷贝。
三、call()没有做任何实际的工作。当call被调用时,apply_fn()会做实际的工作。上面使用pipeline_each()的例子中,一个apply_fn()的实例会将传入的band的country值改为”Canada“。另一个实例会将传入的band的名称首字母大写。
四、当一个apply_fn()实例运行时,fn和key将不再作用域中。它们既不是apply_fn()的参数,也不是其中的本地变量。但是它们仍然可以被访问。当一个方法被定义时,方法会保存方法所包含的变量的引用:那些定义在方法的作用域外,却在方法中使用的变量。当方法运行并且代码引用一个变量时,Python会查找本地和参数中的变量。如果没找到,就会去找闭包内保存的变量。那就是找到fn和key的地方。
五、在call()代码中没有提到bands。因为不管主题是什么,call()都可以为任何程序生成pipeline。函数式编程部分目的就是构建一个通用,可重用,可组合的函数库。
干的漂亮。闭包,高阶函数和变量作用域都被包含在段落里。喝杯柠檬水。
还需要在band上做一点处理。就是移除band上除了name和country之外的东西。extract_name_and_country()能拉去这样的信息。
defextract_name_and_country(band): plucked_band={} plucked_band['name']=band['name'] plucked_band['country']=band['country'] returnplucked_band printpipeline_each(bands,[call(lambdax:'Canada','country'), call(lambdax:x.replace('.',''),'name'), call(str.title,'name'), extract_name_and_country]) #=>[{'name':'SunsetRubdown','country':'Canada'}, #{'name':'Women','country':'Canada'}, #{'name':'ASilverMtZion','country':'Canada'}]
extract_name_and_country()可以写成叫做pluck()的通用函数。pluck()可以这样使用:
printpipeline_each(bands,[call(lambdax:'Canada','country'), call(lambdax:x.replace('.',''),'name'), call(str.title,'name'), pluck(['name','country'])])
练习5。pluck()接受一系列的键值,根据这些键值去record中抽取数据。试着写写。需要用到高阶函数。
我的方案:
defpluck(keys): defpluck_fn(record): returnreduce(lambdaa,x:assoc(a,x,record[x]), keys, {}) returnpluck_fn
Whatnow?
还有什么要做的吗?
函数式代码可以很好的和其他风格的代码配合使用。文章中的转换器可以用任何语言实现。试试用你的代码实现它。
想想Mary,Isla和Sam。将对list的迭代,转成maps和reduces操作吧。
想想汽车竞赛。将代码分解成方法。把那些方法改成函数式的。把循环处理转成递归。
想想乐队。将一系列的操作改写成pipeline。
标注:
1、一块不可变数据是指不能被改变的数据。一些语言像Clojure的语言,默认所有的值都是不可变的。任何的可变操作都是拷贝值,并对拷贝的值做修改并返回。这样就消除了程序中对未完成状态访问所造成的bugs。
2、支持一等函数的语言允许像处理其他类型的值那样处理函数。意味着方法可以被创建,传给其他方法,从方法中返回以及存储在其他数据结构里。
3、尾调用优化是一个编程语言特性。每次方法递归,会创建一个栈。栈用来存储当前方法需要使用的参数和本地值。如果一个方法递归次数非常多,很可能会让编译器或解释器消耗掉所有的内存。有尾调用优化的语言会通过重用同一个栈来支持整个递归调用的序列。像Python这样的语言不支持尾调用优化的通常都限制方法递归的数量在千次级别。在race()方法中,只有5次,所以很安全。
4、Currying意即分解一个接受多个参数的方法成一个只接受第一个参数并且返回一个接受下一个参数的方法的方法,直到接受完所有参数。
5、并行意即在不同步的情况下同时运行同一段代码。这些并发操作常常运行在不同的处理器上。
6、惰性计算是编译器的技术,为了避免在需要结果之前就运行代码。
7、只有当每次重复都能得出相同的结果,才能说处理是确定性的。