函數式編程實戰教程

許多函數式文章講述的是組合,流水線和高階函數這樣的抽象函數式技術。本文不一樣,它展現了人們天天編寫的命令式,非函數式代碼示例,以及將這些示例轉換爲函數式風格。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意即分解一個接受多個參數的方法成一個只接受第一個參數而且返回一個接受下一個參數的方法的方法,直到接受完全部參數。

五、並行意即在不一樣步的狀況下同時運行同一段代碼。這些併發操做經常運行在不一樣的處理器上。

六、惰性計算是編譯器的技術,爲了不在須要結果以前就運行代碼。

七、只有當每次重複都能得出相同的結果,才能說處理是肯定性的。

 

文章首先在伯樂在線翻譯並校稿,而後收錄在本博客內

相關文章
相關標籤/搜索