許多函式式文章講述的是組合,流水線和高階函式這樣的抽象函式式技術。本文不同,它展示了人們每天編寫的命令式,非函式式程式碼示例,以及將這些示例轉換為函式式風格。
文章的第一部分將一些短小的資料轉換迴圈重寫成函式式的maps和reduces。第二部分選取長一點的迴圈,把他們分解成單元,然後把每個單元改成函式式的。第三部分選取一個很長的連續資料轉換迴圈,然後把它分解成函式式流水線。
示例都是用Python寫的,因為很多人覺得Python易讀。為了證明函式式技術對許多語言來說都相同,許多示例避免使用Python特有的語法:map,reduce,pipeline。
導引
當人們談論函數語言程式設計,他們會提到非常多的“函式式”特性。提到不可變資料¹,第一類物件²以及尾呼叫優化³。這些是幫助函數語言程式設計的語言特徵。提到mapping(對映),reducing(歸納),piplining(管道),recursing(遞迴),currying4(科裡化);以及高階函式的使用。這些是用來寫函式式程式碼的程式設計技術。提到並行5,惰性計算6以及確定性。這些是有利於函數語言程式設計的屬性。
忽略全部這些。可以用一句話來描述函式式程式碼的特徵:避免副作用。它不會依賴也不會改變當前函式以外的資料。所有其他的“函式式”的東西都源於此。當你學習時把它當做指引。
這是一個非函式式方法:
1 2 3 4 |
a = 0 def increment1(): global a a += 1 |
這是一個函式式的方法:
1 2 |
def increment2(a): return a + 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 i 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 i 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更好?
首先,它們大多是一行程式碼。
二、迭代中最重要的部分:集合,操作和返回值,在所有的map和reduce中總是在相同的位置。
三、迴圈中的程式碼可能會改變之前定義的變數或之後要用到的變數。照例,map和reduce是函式式的。
四、map和reduce是元素操作。每次有人讀到for迴圈,他們都要逐行讀懂邏輯。幾乎沒有什麼規律性的結構可以幫助理解程式碼。相反,map和reduce都是建立程式碼塊來組織複雜的演算法,並且讀者也能非常快的理解元素並在腦海中抽象出來。“嗯,程式碼在轉換集合中的每一個元素。然後結合處理的資料成一個輸出。”
五、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 i 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: s = rule(s) if s == None: break return s |
練習3。上面的程式碼用迴圈來完成功能。用遞迴重寫使它更有宣告式的味道。
我的方案:
1 2 3 4 5 |
def rule_sequence(s, rules): if s == 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 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()) |
每一個都將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 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 |
There is a lot going on here. Let’s take it piece by piece.
這段程式碼做了很多事。讓我們一點一點的看。
一、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()能拉去這樣的資訊。
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。
標註:
1、一塊不可變資料是指不能被改變的資料。一些語言像Clojure的語言,預設所有的值都是不可變的。任何的可變操作都是拷貝值,並對拷貝的值做修改並返回。這樣就消除了程式中對未完成狀態訪問所造成的bugs。
2、支援一等函式的語言允許像處理其他型別的值那樣處理函式。意味著方法可以被建立,傳給其他方法,從方法中返回以及儲存在其他資料結構裡。
3、尾呼叫優化是一個程式語言特性。每次方法遞迴,會建立一個棧。棧用來儲存當前方法需要使用的引數和本地值。如果一個方法遞迴次數非常多,很可能會讓編譯器或直譯器消耗掉所有的記憶體。有尾呼叫優化的語言會通過重用同一個棧來支援整個遞迴呼叫的序列。像Python這樣的語言不支援尾呼叫優化的通常都限制方法遞迴的數量在千次級別。在race()方法中,只有5次,所以很安全。
4、Currying意即分解一個接受多個引數的方法成一個只接受第一個引數並且返回一個接受下一個引數的方法的方法,直到接受完所有引數。
5、並行意即在不同步的情況下同時執行同一段程式碼。這些併發操作常常執行在不同的處理器上。
6、惰性計算是編譯器的技術,為了避免在需要結果之前就執行程式碼。
7、只有當每次重複都能得出相同的結果,才能說處理是確定性的。