函數(shù)式編程實用介紹(下)

函數(shù)式編程實用介紹(上)

使用函數(shù)

通過把代碼片段抽象為函數(shù),程序可以變得更具聲明性。

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()

要理解這段程序,讀者只需閱讀主循環(huán)即可。“如果時間還有剩余,則比賽前進一步并輸出結(jié)果,然后再次檢查時間。如果讀者想了解關(guān)于"比賽前進一步"或"打印輸出"的更多細節(jié),可以閱讀相關(guān)函數(shù)代碼。

無需過多說明,代碼已描述了一切。

拆分代碼到函數(shù)之中,可以讓代碼更具可讀性。

該技術(shù)使用到了函數(shù),但它只是把函數(shù)用作子程序來打包代碼,從這個指導意義上來說,代碼并非函數(shù)式的。函數(shù)中的代碼使用到了并非作為參數(shù)傳入的狀態(tài)值。它們通過改變外部變量影響了其周圍的代碼,而不是通過返回函數(shù)值。為了檢查一個函數(shù)究竟做了什么,讀者必須仔細閱讀每一行代碼。如果他們發(fā)現(xiàn)一個外部變量,他們必須找到變量的源頭,而且必須查看是否有其它函數(shù)改變了該變量的值。

移除狀態(tài)

這是車輛比賽代碼的函數(shù)式版本:

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]})

代碼仍然被拆分為多個函數(shù),但是所有函數(shù)都是函數(shù)式的。它們有三個特征:第一,不存在任何共享的變量。 timecar_positions 被直接傳入 race() 方法中。第二,所有函數(shù)都接受參數(shù)。第三,函數(shù)中沒有變量被實例化。所有數(shù)據(jù)變化都以返回值方式完成。race() 使用 run_step_of_race() 的返回值進行遞歸。每一次前進一步產(chǎn)生的新狀態(tài),都被立即傳入下一步之中。

現(xiàn)在,這里有兩個函數(shù),zero()one()

def zero(s):
    if s[0] == "0":
        return s[1:]

def one(s):
    if s[0] == "1":
        return s[1:]

zero() 接受一個字符串 s 作為參數(shù)。如果該參數(shù)的第一個字符是 '0',則函數(shù)返回余下的字符串;如果不是,則返回 None,即 Python 函數(shù)的默認返回值。one() 函數(shù)功能一樣,只不過用于判斷的字符換成了 '1'

想象一個名為 rule_sequence() 的函數(shù),它接受一個字符串和一個形如zero()one() 的規(guī)則函數(shù)列表作為參數(shù)。對字符串調(diào)用第一個規(guī)則函數(shù),除非 None 被返回,否則它對返回的字符串調(diào)用第二個規(guī)則函數(shù)。除非 None 被返回,否則它對返回的字符串調(diào)用第三個規(guī)則函數(shù)。以此類推... 如果有任何規(guī)則函數(shù)返回 Nonerule_sequence()函數(shù)停止執(zhí)行并返回 None,否則它會返回最后一個規(guī)則函數(shù)的返回值。

這是一些輸入輸出示例:

print rule_sequence('0101', [zero, one, zero])
# => 1

print rule_sequence('0101', [zero, zero])
# => None

這是 rule_sequence() 函數(shù)的命令式版本:

def rule_sequence(s, rules):
    for rule in rules:
        s = rule(s)
        if s == None:
            break

    return s

練習 3. 上述代碼使用了一個循環(huán)來完成工作。如果將其重寫為一個遞歸函數(shù),它看起來會更具聲明性。

我的解決方法:

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

使用管道

在上一節(jié)中,一些命令式循環(huán)被重寫為遞歸函數(shù),這些遞歸函數(shù)調(diào)用某些輔助函數(shù)。在本節(jié)中,一個不同類型的命令式循環(huán)將使用管道技術(shù)進行重寫。

下面的循環(huán)對字典進行轉(zhuǎn)換,該字典包含樂隊名稱、錯誤國籍以及一些樂隊活動狀態(tài)。

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'}]

函數(shù)的名字讓人有點‘擔心’,因為“format”一詞的含義非常模糊。在仔細檢查代碼之前,擔心就已經(jīng)開始了。同一循環(huán)中做了三件事情:'country' 鍵的值被設為 'Canada';刪除樂隊名稱中的標點符號;樂隊名稱首字母大寫。很難說清代碼究竟想要做什么,也很難判斷代碼是否做了它似乎想要做的事情。而且代碼難以復用、難以測試,也難以并行化。

將其與以下相比:

print pipeline_each(bands, [set_canada_as_country,
                            strip_punctuation_from_name,
                            capitalize_names])

這段代碼很容易理解,它給人的印象是,這些輔助函數(shù)都是函數(shù)式的,因為它們似乎都串在一起。來自前一個函數(shù)的輸出構(gòu)成了下一個函數(shù)的輸入。如果它們是函數(shù)式的,它們很容易驗證,而且它們易于復用、易于測試、易于并行化。

pipeline_each() 作業(yè)每次傳遞一個樂隊到一個轉(zhuǎn)換函數(shù)中,比如 set_canada_as_country()。在函數(shù)應用于所有樂隊之后,pipeline_each() 函數(shù)捆綁所有轉(zhuǎn)換后的樂隊,然后,它傳遞每一個樂隊到下一個函數(shù)中。

讓我們看看轉(zhuǎn)換函數(shù)。

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())

每一個函數(shù)將一個樂隊的一個鍵關(guān)聯(lián)到一個新值。在不改變原樂隊的情況下,很難做到這一點。通過使用 deepcopy() 函數(shù)生成一個字典拷貝,assoc() 函數(shù)解決了這一難題。每一個轉(zhuǎn)換函數(shù)都是基于拷貝來修改,并返回這個拷貝。

一切看起來很完美,原樂隊字典被保護起來,免受字典中的鍵被賦予新值的影響。但上述代碼仍然存在兩處潛在的改變。在 strip_punctuation_from_name() 函數(shù)中,去除名字中的標點符號是通過對原名字調(diào)用 replace() 函數(shù)完成的。在 capitalize_names() 函數(shù)中,名字的首字母大寫是通過對原名字調(diào)用 title() 函數(shù)完成的。如果 replace()title() 不是函數(shù)式的,那么 strip_punctuation_from_name()capitalize_names() 也不是函數(shù)式的。

幸運的是,replace()title() 不會改變它們所操作的字符串,這是因為字符串在Python中是不可變的。例如,當樂隊名字字符串調(diào)用 replace() 時,原樂隊名字先生成一個拷貝,然后使用這個拷貝調(diào)用 replace() 函數(shù) 。哎呀,好險??!

Python 的字符串和字典在可變性方面的反差,充分展示了如 Clojure 之類語言的魅力。程序員再也不需要為他們是否改變了數(shù)據(jù)而擔心,答案當然是否定的。

練習 4. 試著編寫 `pipeline_each`` 函數(shù),把排序考慮進去。對于傳入的樂隊數(shù)組,每次只傳入一個樂隊到第一個轉(zhuǎn)換函數(shù)。返回的樂隊結(jié)果數(shù)組再次作為參數(shù)傳入,每次只傳入一個樂隊到第二個轉(zhuǎn)換函數(shù)。以此類推。

我的解決方法:

def pipeline_each(data, fns):
    return reduce(lambda a, x: map(x, a),
                  fns,
                  data)

三個轉(zhuǎn)換函數(shù)的功能歸根到底是去改變傳入樂隊的一個特定鍵的值。call() 函數(shù)可用于抽象,它接受一個待應用的函數(shù)和一個與變化值對應的鍵作為參數(shù)。

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])

或者,如果我們?yōu)榱撕啙嵍鵂奚勺x性的話,可以這樣寫:

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

call() 函數(shù)代碼:

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

這里發(fā)生了很多事情,讓我們一點一點來剖析。

第一,call() 是一個高階函數(shù)。高階函數(shù)接受一個函數(shù)作為參數(shù),或者返回一個函數(shù),或者像 call() 函數(shù)一樣兩者兼具。

第二,apply_fn() 看上去和三個轉(zhuǎn)換函數(shù)非常類似。它接受一個記錄(一個樂隊)作為參數(shù),查找 record[key] 的值,然后對該值執(zhí)行 fn 函數(shù),并把函數(shù)執(zhí)行結(jié)果分配給該記錄的一份拷貝,最后返回這個拷貝。

第三, call() 函數(shù)并不執(zhí)行任何實際操作。 apply_fn() 函數(shù)被調(diào)用時,負責執(zhí)行具體操作。在上面使用 pipeline_each() 的例子中,apply_fn() 其中的一個實例是對傳入樂隊的 'country' 設置為 'Canada'。 另一個實例是將傳入樂隊的名稱首字母大寫。

第四,當一個 apply_fn() 實例運行時,fnkey 已經(jīng)不在函數(shù)范圍之中了。它們既不是 apply_fn() 的參數(shù),也不是它的內(nèi)部變量,但是它們?nèi)匀豢梢员辉L問到。當定義一個函數(shù)時,它可以保存對一個已關(guān)閉變量的引用:那些被定義在函數(shù)范圍之外卻在函數(shù)內(nèi)部使用的變量。當函數(shù)運行并且代碼中引用了一個變量時,Python會在本地變量和參數(shù)中查找這個變量。如果沒有找到,它會到保存過的已關(guān)閉變量引用中去查找。這里正是找到 fnkey 的地方。

第五,在 call() 函數(shù)代碼中并沒有提及樂隊。這是因為 call() 函數(shù)被用來為任何程序生成管道函數(shù),不管是什么主題。函數(shù)式編程部分是關(guān)于構(gòu)建一個通用的、可重用的、可組合的函數(shù)庫。

干得不錯。閉包、高階函數(shù)以及變量范圍在上面幾個段落中全部涉及到了。來杯檸檬水放松一下(看來女程序員都喜歡檸檬水啊)。

關(guān)于樂隊,還有一點工作需要去做,也就是只保留名稱和國籍,其它都刪除。extract_name_and_country() 可以把那些無關(guān)信息刪除:

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() 可以寫成一個通用的函數(shù) 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() 接受一個鍵列表作為參數(shù),從每一個記錄中提取信息。嘗試編寫這個函數(shù),它需要使用一個高階函數(shù)。

我的解決方法:

def pluck(keys):
    def pluck_fn(record):
        return reduce(lambda a, x: assoc(a, x, record[x]),
                      keys,
                      {})
    return pluck_fn

接下來呢?

函數(shù)式代碼可以很好地與其它風格編寫的代碼和平共處。本篇文章涉及到的轉(zhuǎn)換函數(shù)可以應用于任何語言代碼,嘗試應用它們到你自己的代碼中。

思考 Mary、Isla 和 Sam 列表問題,把對列表的迭代轉(zhuǎn)換成 maps 和 reduces 方式。

思考那個比賽問題,把代碼封裝成函數(shù),使那些函數(shù)成為函數(shù)式代碼,把重復過程的循環(huán)轉(zhuǎn)換成遞歸方式。

思考那個關(guān)于樂隊的問題,把一系列操作轉(zhuǎn)換成管道方式。


(1) 不可變數(shù)據(jù)是指不能被改變的數(shù)據(jù)。一些語言如 Clojure,默認所有值為不可變數(shù)據(jù)。任何“改變”操作都是基于原值的拷貝進行,首先復制一個拷貝,然后改變拷貝,最后傳回這個更改的拷貝。程序可能會陷入程序員不完備的可能狀態(tài)模型,這樣做可以消除由此引發(fā)的錯誤。

(2) 所有將函數(shù)視為一等公民,對函數(shù)和其它值一視同仁的語言。這就意味著、你不僅可以創(chuàng)建函數(shù),你還可以將函數(shù)作為參數(shù)傳遞給函數(shù),作為返回值從函數(shù)中返回,存儲在數(shù)據(jù)結(jié)構(gòu)中。

(3) 尾部調(diào)用優(yōu)化是一種編程語言的特性。每一次遞歸調(diào)用,都會產(chǎn)生一個新堆棧幀,用于為當前調(diào)用存儲參數(shù)和本地變量。如果一個函數(shù)遞歸調(diào)用很多次,很可能會導致解釋器或編譯器內(nèi)存溢出。具備尾部調(diào)用優(yōu)化功能的語言,對于整個序列的遞歸調(diào)用,重用同一個堆棧幀。像 Python 之類的語言沒有尾部調(diào)用優(yōu)化這一特性,因此限制了一個函數(shù)可以遞歸調(diào)用的次數(shù)只能數(shù)千次。在 race() 函數(shù)中,只有區(qū)區(qū)5次調(diào)用,因此毫無問題。

(4) 柯里化的意思是,把接受多個參數(shù)的函數(shù)轉(zhuǎn)換成接受第一個參數(shù)作為(唯一)參數(shù)的函數(shù),并且返回接受第二個參數(shù)作為(唯一)參數(shù)的新函數(shù),其余以此類推。

(5) 并行化意味著同時運行相同的代碼而無需同步。這些并發(fā)進程通常運行在多個處理器上。

(6) 惰性求值是一項編譯器技術(shù)。其目的是將代碼運行推遲到實際需要這段代碼的最終結(jié)果的時候。

(7) 如果每次運行都能生成同樣的結(jié)果,那么這個進程就具有確定性。

-全文結(jié)束-


作者:Mary Rose,一名程序員兼音樂人,生活在紐約,在 Recurse Center 工作。

原文: A practical introduction to functional programming

感謝: Jodoo 幫助審閱并完成校對。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容