函數語言程式設計實用介紹(下)

青牛發表於2015-08-31

續上文 函數語言程式設計實用介紹(上)

使用函式

通過把程式碼片段抽象為函式,程式可以變得更具宣告性。

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

要理解這段程式,讀者只需閱讀主迴圈即可。“如果時間還有剩餘,則比賽前進一步並輸出結果,然後再次檢查時間。如果讀者想了解關於"比賽前進一步"或"列印輸出"的更多細節,可以閱讀相關函式程式碼。

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

拆分程式碼到函式之中,可以讓程式碼更具可讀性。

該技術使用到了函式,但它只是把函式用作子程式來打包程式碼,從這個指導意義上來說,程式碼並非函式式的。函式中的程式碼使用到了並非作為引數傳入的狀態值。它們通過改變外部變數影響了其周圍的程式碼,而不是通過返回函式值。為了檢查一個函式究竟做了什麼,讀者必須仔細閱讀每一行程式碼。如果他們發現一個外部變數,他們必須找到變數的源頭,而且必須檢視是否有其它函式改變了該變數的值。

移除狀態

這是車輛比賽程式碼的函式式版本:

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

程式碼仍然被拆分為多個函式,但是所有函式都是函式式的。它們有三個特徵:第一,不存在任何共享的變數。 timecar_positions 被直接傳入 race() 方法中。第二,所有函式都接受引數。第三,函式中沒有變數被例項化。所有資料變化都以返回值方式完成。race() 使用 run_step_of_race() 的返回值進行遞迴。每一次前進一步產生的新狀態,都被立即傳入下一步之中。

現在,這裡有兩個函式,zero()one()

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() 的函式,它接受一個字串和一個形如zero()one() 的規則函式列表作為引數。對字串呼叫第一個規則函式,除非 None 被返回,否則它對返回的字串呼叫第二個規則函式。除非 None 被返回,否則它對返回的字串呼叫第三個規則函式。以此類推... 如果有任何規則函式返回 Nonerule_sequence()函式停止執行並返回 None,否則它會返回最後一個規則函式的返回值。

這是一些輸入輸出示例:

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

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

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

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

    return s

練習 3. 上述程式碼使用了一個迴圈來完成工作。如果將其重寫為一個遞迴函式,它看起來會更具宣告性。

我的解決方法:

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

使用管道

在上一節中,一些命令式迴圈被重寫為遞迴函式,這些遞迴函式呼叫某些輔助函式。在本節中,一個不同型別的命令式迴圈將使用管道技術進行重寫。

下面的迴圈對字典進行轉換,該字典包含樂隊名稱、錯誤國籍以及一些樂隊活動狀態。

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';刪除樂隊名稱中的標點符號;樂隊名稱首字母大寫。很難說清程式碼究竟想要做什麼,也很難判斷程式碼是否做了它似乎想要做的事情。而且程式碼難以複用、難以測試,也難以並行化。

將其與以下相比:

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

這段程式碼很容易理解,它給人的印象是,這些輔助函式都是函式式的,因為它們似乎都串在一起。來自前一個函式的輸出構成了下一個函式的輸入。如果它們是函式式的,它們很容易驗證,而且它們易於複用、易於測試、易於並行化。

pipeline_each() 作業每次傳遞一個樂隊到一個轉換函式中,比如 set_canada_as_country()。在函式應用於所有樂隊之後,pipeline_each() 函式捆綁所有轉換後的樂隊,然後,它傳遞每一個樂隊到下一個函式中。

讓我們看看轉換函式。

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

每一個函式將一個樂隊的一個鍵關聯到一個新值。在不改變原樂隊的情況下,很難做到這一點。通過使用 deepcopy() 函式生成一個字典拷貝,assoc() 函式解決了這一難題。每一個轉換函式都是基於拷貝來修改,並返回這個拷貝。

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

幸運的是,replace()title() 不會改變它們所操作的字串,這是因為字串在Python中是不可變的。例如,當樂隊名字字串呼叫 replace() 時,原樂隊名字先生成一個拷貝,然後使用這個拷貝呼叫 replace() 函式 。哎呀,好險啊!

Python 的字串和字典在可變性方面的反差,充分展示瞭如 Clojure 之類語言的魅力。程式設計師再也不需要為他們是否改變了資料而擔心,答案當然是否定的。

練習 4. 試著編寫 `pipeline_each`` 函式,把排序考慮進去。對於傳入的樂隊陣列,每次只傳入一個樂隊到第一個轉換函式。返回的樂隊結果陣列再次作為引數傳入,每次只傳入一個樂隊到第二個轉換函式。以此類推。

我的解決方法:

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

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

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

或者,如果我們為了簡潔而犧牲可讀性的話,可以這樣寫:

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

call() 函式程式碼:

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

這裡發生了很多事情,讓我們一點一點來剖析。

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

第二,apply_fn() 看上去和三個轉換函式非常類似。它接受一個記錄(一個樂隊)作為引數,查詢 record[key] 的值,然後對該值執行 fn 函式,並把函式執行結果分配給該記錄的一份拷貝,最後返回這個拷貝。

第三, call() 函式並不執行任何實際操作。 apply_fn() 函式被呼叫時,負責執行具體操作。在上面使用 pipeline_each() 的例子中,apply_fn() 其中的一個例項是對傳入樂隊的 'country' 設定為 'Canada'。 另一個例項是將傳入樂隊的名稱首字母大寫。

第四,當一個 apply_fn() 例項執行時,fnkey 已經不在函式範圍之中了。它們既不是 apply_fn() 的引數,也不是它的內部變數,但是它們仍然可以被訪問到。當定義一個函式時,它可以儲存對一個已關閉變數的引用:那些被定義在函式範圍之外卻在函式內部使用的變數。當函式執行並且程式碼中引用了一個變數時,Python會在本地變數和引數中查詢這個變數。如果沒有找到,它會到儲存過的已關閉變數引用中去查詢。這裡正是找到 fnkey 的地方。

第五,在 call() 函式程式碼中並沒有提及樂隊。這是因為 call() 函式被用來為任何程式生成管道函式,不管是什麼主題。函數語言程式設計部分是關於構建一個通用的、可重用的、可組合的函式庫。

幹得不錯。閉包、高階函式以及變數範圍在上面幾個段落中全部涉及到了。來杯檸檬水放鬆一下(看來女程式設計師都喜歡檸檬水啊)。

關於樂隊,還有一點工作需要去做,也就是隻保留名稱和國籍,其它都刪除。extract_name_and_country() 可以把那些無關資訊刪除:

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() 可以這樣用:

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

練習 5. pluck() 接受一個鍵列表作為引數,從每一個記錄中提取資訊。嘗試編寫這個函式,它需要使用一個高階函式。

我的解決方法:

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

接下來呢?

函式式程式碼可以很好地與其它風格編寫的程式碼和平共處。本篇文章涉及到的轉換函式可以應用於任何語言程式碼,嘗試應用它們到你自己的程式碼中。

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

思考那個比賽問題,把程式碼封裝成函式,使那些函式成為函式式程式碼,把重複過程的迴圈轉換成遞迴方式。

思考那個關於樂隊的問題,把一系列操作轉換成管道方式。


(1) 不可變資料是指不能被改變的資料。一些語言如 Clojure,預設所有值為不可變資料。任何“改變”操作都是基於原值的拷貝進行,首先複製一個拷貝,然後改變拷貝,最後傳回這個更改的拷貝。程式可能會陷入程式設計師不完備的可能狀態模型,這樣做可以消除由此引發的錯誤。

(2) 所有將函式視為一等公民,對函式和其它值一視同仁的語言。這就意味著、你不僅可以建立函式,你還可以將函式作為引數傳遞給函式,作為返回值從函式中返回,儲存在資料結構中。

(3) 尾部呼叫優化是一種程式語言的特性。每一次遞迴呼叫,都會產生一個新堆疊幀,用於為當前呼叫儲存引數和本地變數。如果一個函式遞迴呼叫很多次,很可能會導致直譯器或編譯器記憶體溢位。具備尾部呼叫優化功能的語言,對於整個序列的遞迴呼叫,重用同一個堆疊幀。像 Python 之類的語言沒有尾部呼叫優化這一特性,因此限制了一個函式可以遞迴呼叫的次數只能數千次。在 race() 函式中,只有區區5次呼叫,因此毫無問題。

(4) 柯里化的意思是,把接受多個引數的函式轉換成接受第一個引數作為(唯一)引數的函式,並且返回接受第二個引數作為(唯一)引數的新函式,其餘以此類推。

(5) 並行化意味著同時執行相同的程式碼而無需同步。這些併發程式通常執行在多個處理器上。

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

(7) 如果每次執行都能生成同樣的結果,那麼這個程式就具有確定性。

-全文結束-


作者:Mary Rose,一名程式設計師兼音樂人,生活在紐約,在 Recurse Center 工作。

原文: A practical introduction to functional programming

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

相關文章