day_02: 我不會寫函式


其實這七天系列的構思之初,我原是要分享在 Python 語言裡的函數式開發範式的實作經驗。但我認為其實需要更寬廣地來思考,Python 是一個多範式的語言,支援物件導向,也可以刻意改成函數式,更有甚者,連參與者模式 (Actor Model) 也難不倒它。

但今明兩天,我們要來談 Python 的函數式開發。

我的學習歷程與許多我們那年代的程式員相似,從 BASIC 語言開始,經歷 C、C++,進入 Java 王朝。回頭想想,我已經使用這樣的語言概念寫了二十年了呢!

那這是什麼語言概念?正式一點稱為「命令式程式設計」。還記得之前某間中部大學知名的校長接受採訪,批評現在的學生程式能力不好,因為他們都不畫流程圖。而這類流程圖思維,表現的正是命令式的思考方式。

命令式又如何呢?即使是最原始的組合語言,也是一種命令式的思維。這個世界有九成以上的工程人員用著命令式的語言在開發。命令式不好嗎?

典範轉移

我們從一個很簡單的小問題來看這件事。假設我要一個數列,費氏數列好了,我不只要 fib(n),而是傳入 n 時,程式會回傳 fib(0)fib(1),一直到 fib(n) 的一個 list 給我。

用普通的命令式寫法會是如何呢?

f0 = 1
f1 = 1

def fib(n):
  if n == 0:
    return f0
  elif n == 1:
    return f1
  else:
    return fib(n - 1) + fib(n - 2)

def fib_list(n):
  result = []
  for i in range(0, n + 1):
    result.append(fib(i))

  return result

fibs = fib_list(10)

在此,我直接在 fib 函式使用遞迴,因為它不是重點,請先忽略它。重點在於 fib_list 函式,我們在這裡可以看到命令式程式設計的典範的幾個特色:

  1. 寫程式的人要說清楚程式要怎麼做
  2. 透過狀態的改變來記錄、控制程式的執行過程
  3. 程式透露出許多實作細節,在更大型的程式裡,你一定能看到更複雜的流程與邏輯

這樣不好嗎?有人會說,命令式的程式大量依賴狀態的改變,使得程式難以簡單地支援平行式或並行式的計算問題。也有人說,命令式的程式大量呈現實作細節,程式一變大就難以看見它的意圖。這些都是重要的事,

但我認為命令式的風格最大的問題在於,這樣的程式開發風格,容易使得寫 code 習慣不好的人把自己思慮的不周、邏輯的缺陷曝露在一層包一層的程式結構裡,並增加除錯的困難。

每一種風格都可以發揮的很好,而再強大的語言也可以寫得很爛。但命令式的開發範式,因為沒有太大的限制性,使得開發者容易把自己的問題曝露在程式裡,造成隱藏的風險。

因此,在這兩天裡,我要分享關於函數式開發的想法,我認為這是較好的一種典範,它跟命令式一起用得好,可以截長補短、相得益彰。

函數式程式設計

說到函數式開發,你有什麼印象呢?不可變?輕薄短小?充滿奇怪的符文?我想,在有限的篇幅裡,我們無法說明太多函數式的特性與定義,但我們可以想,當我們把函式當作一個可以傳來傳去的東西時,這有什麼好處?

我們把上面的例子改一下,使用 Python 的 list comprehension 的寫法,比較 Pythonic 些:

# 前面 fib 省略

def fib_list(n):
    return [ fib(i) for i in range(0, n + 1)]

# 後面呼叫也省略

這不賴,看起來又直觀、又精簡。那我想問,如果今天我們的問題變了,不只是費氏數列,要做階乘的話怎麼辦呢?如果用上面的寫法,我一定得寫一個 factorial_list(n) 的函式。

這時候,物件導向訓練出來的人會說,我們把計算功能包在一個物件裡,把這個物件傳進去。例如:

class Fibonacci:
    def calculate(self, n):
        # 計算過程

這樣雖然可行,但很麻煩。反而是這樣寫會比較好:

# 前面 fib 省略

def fact(n):
    if n == 0:
        return 1
    else:
        return n * fact(n - 1)

def build_list(n, func):
    return [func(i) for i in range(0, n + 1)]

fibs = build_list(10, fib)
facts = build_list(10, fact)

這樣寫的好處,除了減少了因加入 fact_list(n) 所需的複製貼上功夫外,最重要的是更清楚地告訴讀這段程式碼的人:

  • fibs 是一個 fib 的數列
  • facts 是一個 fact 的數列

其次,既然函式能被傳來傳去地使用,函式在呼叫時,必須謹慎處理其副作用,也就是對函式外的變數、物件內部的狀態的改變。最好是沒有副作用,即使有也必須謹慎控管。

這意味著什麼呢?當我們在設計函式時,它必須儘可能地保持清晰、可信,我傳 10fib,一定會拿到 89,不會在某個時刻裡拿到 98,這個特性叫「引用透明度」。

具備引用透明度的函式,就代表著 它不管今天被傳播到哪一個地方,它面對同樣的參數值,回傳值永遠不變。

引用透明度並不代表著函數式開發的全部,然而這樣的程式碼具有高度的可信、安全、清晰的品質。

我們能把函式當作參數傳來傳去後,接下來可以怎麼發揮這種概念呢?

map, filter, reduce

我們簡單地擴充前面的問題,若我們要在一費氏函數的數列中,找出奇數的部份,並加總。

如果使用傳統的命令式寫法可能如此:

fib_list = build_list(10, fib) # [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

def is_odd(n):
    return (n % 2) != 0

def get_odd(lst):
    return [i for i in lst if is_odd(i)]

def sum_list(init, lst):
    result = init
    for item in lst:
        result = result + item
    return result

odd_fib = get_odd(fib_list)
sum_odd = sum_list(0, odd_fib)

倘若使用函數式的想法呢?

首先,我們先不用 list comprehension 的做法,改採一種像是批次處理的方式 - map:

init_list = [i for i in range(0, 11)]
fib_list = map(fib, init_list)

這時我們會得到一個 iter 物件,實際展開後就是我們原本要的 fib(0) ~ fib(10) 數列。接著,我們借用前面的判斷函式,把這個費氏數列的奇數過濾 (filter) 出來:

odd_fib = filter(is_idd, fib_list)

最後,我們寫一個累加的程式:

import functools

def sum_items(accu, item):
    return accu + item

sum_odd = functools.reduce(sum_items, odd_fib, 0)

reduce 這個函式指的是把傳入的最後一個參數 0 當作起始,如之前的 init,然後第二個參數的 list 當作一個一個值,藉著第一個參數的函式 sum_items 的計算,一個一個累加上去 (accumulate)。

Python 為這事,提供了一個方便的模組:operator,因此可以改寫如下:

import functools, operator

sum_odd = functools.reduce(operator.add, odd_fib, 0)

最後加上一個在函數式開發裡常用的 id,整個組合起來就是這樣:

import functools, operator

f0 = 1
f1 = 1

def fib(n):
    if n == 0:
        return f0
    elif n == 1:
        return f1
    else:
        return fib(n - 1) + fib(n - 2)

def is_odd(n):
    return (n % 2) != 0

def id(n):
    return n

def build_list(n, func):
    return [func(i) for i in range(0, n + 1)]

init_list = build_list(10, id)
fib_list = map(fib, init_list)
odd_fib = filter(is_odd, fib_list)
odd_sum = functools.reduce(operator.add, odd_fib, 0)

或直接把下面四句組成一句:

odd_sum = functools.reduce(operator.add, 
                           filter(is_odd,
                                  map(fib, build_list(10, id))), 0)

哪一種比較好讀其實見仁見智,因為這幾種寫法都是在程式語言發展史上出現過的風格,但這樣做,有什麼好處?

結語

我們用 map, filter, reduce,有什麼好處呢?

首先,init_list如果是從別的地方傳入的參數,你保證你敢直接改動裡面的內容嗎?不可能吧!map 的機制,確保了這個過程會產生一個新的 list。

其次,這是我的猜測,就是在進行 map, filter 時,函式回傳的是 iter,不是 list,這似乎意味著map, filter 的執行是 lazy 的,例如在第二種寫法中,我認為它會在最終進行 reduce 時,才真的進行 list iterator 的展開,如果我們今天傳進去 map 的計算函式不是 fib 這種簡單函式,而是具有更大量計算的函式,這個機制有助於程式在有必要時,才真的執行內容。然而這是我讀 Python Function Programmin HOWTO 時的猜測,還需要進一步實驗證實。

Python 的創始人 Guido van Rossum 曾表示他並不想把 Python 設計成函數式語言,也表示過他不是物件導向信徒,於是 Python 成為獨樹一格的語言。

我認為追求純粹的函數式,或純粹的物件導向,這種想法本身是不務實的,追求純粹化的過程,會使人忽略了語言本身的特性與對問題領域更深刻的理解。

但我們仍能在這些典範中,看見許多前人寶貴的智慧,我們明天會繼續討論這個議題。

#Python #Functional Programming





七夜 py 談
不知道 Python 在你眼中是個什麼樣的語言,有人說她優雅,有人說她簡單,有人說她很有彈性,有人說她很強大。無可否認的,她很微妙,她的創始者既非物件導向信徒,也不是函數式的教徒,他的確創造了一個獨樹一格的語言。這七天,來談一個 OOP 與 FP 開發者眼中的 Python。

留言討論