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

青牛發表於2015-08-26

許多講解函數語言程式設計的文章教授抽象的理論化的函數語言程式設計技術,如,組合(composition)、管道(pipelining)、高階函式(higher order functions)。而本文則有所不同,首先,我會展示一些命令式而非函式式程式碼的例子,這些例子均來自程式設計師日常編寫的程式碼,然後,我再將這些例子改寫成函式式風格。

文章第一部分演示簡單短小的資料轉換迴圈,以及如何將其改寫成函式式 maps 和 reduces。第二部分演示覆雜的長迴圈,以及如何把它分割成多個單元,每個單元都是一個函式。第三部分演示一個連續執行一長串資料轉換的迴圈,以及如何將其分解成一個函式式管道。

由於很多人認為 Python 非常易讀,所以我們的例子全部由 Python 編寫而成。有些例子特意避開了 Python 專有技術,這樣做的目的,就是為了更好地演示這些適用於很多語言的函數語言程式設計技術:map、reduce 和 pipeline。

指導方針

當人們談論函數語言程式設計時,許多令人眼花繚亂的“函式式”特徵都將涵蓋其中。他們會提及不可變資料(1)、一等函式(2)、尾部呼叫優化(3),這些都是有助函數語言程式設計的語言層面的功能特性;他們還會提及對映(mapping)、化簡(reducing)、管道(pipeling)、遞迴(recursing)、柯里化(currying)(4)、以及高階函式的應用,這些均為編寫函式式程式碼的技術和技巧;他們還會提及並行化(parallelization)(5)、惰性求值(lazy evaluation)(6)、確定性(determinism)(7),這些正是函式式程式的優勢所在。

拋開以上先不談,函式式程式碼可以歸結為一個基本特性:沒有副作用。它既不依賴於函式之外的資料,也不會改變函式之外的資料。其它每一個“函式式”特徵都源自這個特性。在學習過程中,希望你能把它當作一項指導原則。

這是一個非函式式函式:

a = 0
def increment1():
    global a
    a += 1

這是一個函式式函式:

def increment2(a):
    return a + 1

不要遍歷列表,應該使用 map 和 reduce。

Map

Map 接受一個函式和一個集合作為引數,它生成一個新的空集合,然後對集合中的每一個元素執行該函式,並把返回的結果值插入新集合中,最後返回這個新集合。

這是一個簡單的 map 示例,它接受一個名字列表並返回一個包含這些名字長度的列表:

name_lengths = map(len, ["Mary", "Isla", "Sam"])
print name_lengths
# => [4, 4, 3]

這是一個 map 示例,它對集合中的每一個元素求平方:

squares = map(lambda x: x * x, [0, 1, 2, 3, 4])

print squares
# => [0, 1, 4, 9, 16]

這個 map 沒有使用一個命名函式,而是一個匿名的、使用 lambda 定義的行內函數。lambda 的引數在冒號的左邊,函式體在冒號的右邊,函式體執行的結果被(隱式)返回。

下面是一段非函式式程式碼,它使用隨機分配的程式碼名字來替換一個真實名字列表。

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 重寫:

import random

names = ['Mary', 'Isla', 'Sam']

secret_names = map(lambda x: random.choice(['Mr. Pink',
                                            'Mr. Orange',
                                            'Mr. Blonde']),
                   names)

練習 1. 嘗試用 map 重寫以下程式碼。使用代號替換一個列表中的真實名字,代號使用更健壯的策略生成。

names = ['Mary', 'Isla', 'Sam']

for i in range(len(names)):
    names[i] = hash(names[i])

print names
# => [6306819796133686941, 8135353348168144921, -1228887169324443034]

(在祕密任務執行期間,希望特工們的腦袋瓜都足夠靈光,不至於忘記彼此的祕密代號。)

我的解決方法:

names = ['Mary', 'Isla', 'Sam']

secret_names = map(hash, names)

Reduce

Reduce 接受一個函式和一個集合作為引數,返回一個通過組合各個元素而生成的值。

這是一個簡單的 reduce 示例,它返回集合中所有元素之和。

sum = reduce(lambda a, x: a + x, [0, 1, 2, 3, 4])

print sum
# => 10

x 是當前被迭代的元素。a 是累加器,它是在前一個元素之上執行 lambda 表示式產生的結果值。reduce() 遍歷所有元素,對每一個元素,它對當前的 ax 執行 lambda 表示式,把返回的結果作為下一次迭代的 a 值。

在第一次跌代時 a 是什麼值呢?由於不存在前一次迭代結果作為返回值,因此 reduce() 使用集合中的第一個元素作為第一次迭代時的 a 值,然後開始迭代第二個元素。也就是說,第一個 x 值是第二個元素。

這段程式碼計算單詞'Sam'在字串列表中出現的頻率:

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 寫的功能相同的程式碼:

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.'。 最初的累加器在 reduce() 函式的第三個引數中指定,這個值可以與集合中元素的型別不一致。

為什麼 map 和 reduce 更好?

第一,它們通常一行程式碼就能搞定。

第二,迭代的重要部分 - 集合、操作和返回值 - 總是在 map 和 reduce 的同一位置。

第三,一個迴圈中的程式碼可能會影響在它之前定義的變數或者在它之後執行的程式碼。而按照慣例,maps 和 reduces 則是函式式的。

第四,map 和 reduce 是基本操作。每一次閱讀 for 迴圈程式碼,都必須逐行遍歷程式碼,而且它在結構上幾乎沒有什麼規律可言,無法通過建立一個腳手架來達到幫助理解程式碼的目的。與此相反,map 和 reduce 可以立即構建出組合複雜演算法的程式碼塊,以及程式碼讀者在腦海中可以立即理解和抽象的元素。“啊,這段程式碼會轉換集合中的每一項,丟棄一些中間轉換值,最後組合剩餘值作為一個單一值輸出。”

第五,map 和 reduce 有許多功能相似的“朋友”,這些都是基於它們的基本行為提供的有用功能的修改版。例如:filterallany 以及 find

練習 2. 嘗試使用 map、reduce 和 filter 重寫以下程式碼。Filter 接受一個函式和一個集合作為引數,對集合中的每一個元素執行函式,將執行結果為 True 的元素組成一個集合,最後返回這個集合。

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 = [{'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)

寫宣告式程式碼,而非命令式

下面的程式演示了三輛汽車之間的比賽過程。在每一時間步(time step),每輛汽車可能向前移動也可能停下來。在每一時間步,程式列印出車輛到目前為止前進的路線。五個時間步之後,比賽結束。

這是一些示例輸出:

-
--
--

--
--
---

---
--
---

----
---
----

----
----
-----

這是程式程式碼:

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]

以上程式碼為命令式風格。函式式版本與之不同,其具有宣告式程式設計的特點,它描述做什麼而不是怎樣去做。

敬請閱讀 函數語言程式設計實用介紹(下)


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

原文: A practical introduction to functional programming

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

P.S. 如果您喜歡這篇文章並且希望學習程式設計技術的話,請關注一下 復唧唧

相關文章