Python函數語言程式設計系列001:無副作用

三次方根 發表於 2021-09-26
Python

這個部落格的目的本來是討論資料(用\(\tau\)表示)和函數語言程式設計/電腦科學(用\(\lambda\)表示)的兩類主題的。但事實上,本部落格還沒寫過任何關於函數語言程式設計的內容,顯得有些「名不副實」。而近幾年在一些專案上和自己理論學習中的實踐,對於函數語言程式設計有了一些不大不小的洞識。希望能借由這個系列來給大家傳遞一些函數語言程式設計非常有用的方法,以及更督促自己對這方面進行思考和學習。

當然,介紹函數語言程式設計的不錯的部落格/文章或者書籍,但是,因為Python在函數語言程式設計方面的支援不算是非常好的(比如遞迴加速之類),所以以Python實現函數語言程式設計的例子往往基於functoolsitertools以及一些遞迴概念的介紹。所以這個部落格系列試圖彌補這方面的不足,並且將視野擴大,讓我們在哲學、範疇學等領域的內容加入進來,進一步「元」思考程式設計本身。

何為函式

當然,作為本系列的第一篇文章,我們要來討論的是「無副作用」這個概念。

首先,我們要回到一個思考,就是Pythonfunction(s)是一個什麼概念。function討論最好當然是分析哲學的先驅弗雷澤(具體可參考下面的引用)。但我們具體抽象出來,數學上的函式有個明確的定義,即「一個自變數對映到唯一一個因變數」。也就是說,函式反覆帶入同一個值,理應這個結果是一致的。比如我們舉下面一個例子,無論我們帶入幾次 x = 1都可以返回2的結果。

def f(x):
    return x + 1

也有書中(例如引用中提到的Functional Programming in Scala)也將這種原則表示為「符號代換原則」,即我們完全可以用宣告函式的等式代換到下面的式子中。這個也是一個引申而來的判斷是否是函式的例子,譬如:

def f(x):
    return x + 1

def g(x):
    return f(x) ** 2

這個例子中,我們完全可以使用下面的代換原則來實現。這個是數學定義的函式的最佳例子。

def g(x):
    return (x + 1) ** 2

我們把上面這些明確符合數學定義的函式就叫做「無副作用」的,因為它們的計算只涉及到了計算自己的概念,並且所有符號,只是某一個值/函式的指示詞,不帶有別的意涵。

事實上Python中的函式

但是事實上Python中永遠允許我們定義一些不符合上面規範,但是Python術語中還是叫函式的東西,比如下面一個全域性變數的例子:

a = 1

def f(x):
    global a
    a += x
    return a

我們在兩次帶入x = 1時,結果第一次結果是2,第二次是3。這就不符合我們函式的定義了,而究其原因,它是改變了一個函式外的變數a,因此它除了計算之外,還改變了什麼,我們於是說它是有「副作用」的。

第二個產生的原因涉及可變變數,其實上面的a也是一個可變變數的例子。但是更加突出的乃是listdict之類的可變變數或者原地操作的值,例如下面一個例子:

def f(ls, a):
    ls.append(a)
    return ls

在這個例子裡,沒有涉及操作全域性變數,但是當我們帶入了ls = [1, 2, 3]以及a = 1後,我們依舊發現每一次帶入的時候,返回值還是不一樣的。我們也可以把這種對於ls的改變表述為產生了副作用。如果我們僅僅是想得到ls加了一個元素後的結果,那麼這個問題將顯得非常嚴重。

SCIP(Structure and Interpretation of Computer Programs)一書中,非常好的概述了這兩種思路處理函式的不同。在前者「無副作用」的例子裡,af之類的東西,僅僅具有指示一個值/函式的意義;但是對於有「副作用」概念的後者的函式裡,我們必須得構造一個「環境」的概念。這個環境裡,有一個個屋子(在計算機裡可能就是記憶體/CPU快取的概念),af指示的是屋子(但有的時候又是指屋子裡放的東西),更可怕的是,屋子的大小也會變化;而「無副作用」的例子裡,我們只要知道符號永遠指的是一個東西就好了,一次指定後就不在變化,並不需要屋子的變化。

副作用的好和壞

現在,我們就來看「無副作用」和「副作用」到底好處和壞處是什麼。

1. 回溯問題

如果一個函式,它對於一個確定的輸入必定有一個確定的輸出,這意味著,我們很容易找到問題、定位問題、以及復現問題。而如果一個程式包含有非常多的「副作用」,這意味著我們無法控制它在函式體外修改了什麼,小到一個可變函式、大到計算機的環境變數。這也就導致為什麼,很多程式的報錯反饋,都要列印那麼多的環境變數、計算機環境之類的概念。

而無副作用意味著非常強的「可測性」,我們在後面的文章中也會一一列舉出來。此外,「基於性質的測試」也成為可能。也就意味著,我們能更加強勢地控制我們的程式。甚至對於一個靜態的函式式語言(可惜Python不是),編譯階段就能暴露和解決絕大部分的問題。

2. 無法和環境互動的程式其實大概率是沒啥用的

單純的使用函式式的概念,我們事實上構造的是一個邏輯符號運算系統,如果沒有和外界環境互動,則它就是樓臺的玩具。我們甚至使用print都是在產生一個函式外的螢幕的副作用。所以,無副作用也就意味著它的應用很難。當然,「單子」的概念、把副作用限縮在一個非常小的範圍裡,這些方法都可以讓我們對自己的程式把握還是非常強,並且 又有一定的「互動自由」。這個也是我們這個系列要強調的程式設計思路

3. 效率

事實上,我們上面的舉例中,已經可以看出,計算機(/圖靈機)本身的概念就是基於環境或者說基於副作用的。而函數語言程式設計在一個副作用機子上實現,本來就會效率下降。更何況,如果我們不使用類似append之類的原地操作,這就意味著更多空間,更多的值的複製的概念。這些都讓程式的效率大打折扣。此外,Python對諸如遞迴等函式式的速度優化效果並不好,這也使得副作用可能更容易讓人青睞。

不過,這個效率的概念可能還有一些更加曖昧的地方。如果更大視野地看待函數語言程式設計,裡面有各種屬於自己的優化方案,我們將會在後面一一介紹。

4. 表達能力(新)

在上面關於「環境」和「代換」的討論中,我們也發現,如果使用「環境」的概念我們將要多出很多概念,譬如「傳值」、「傳址」、「可變變數」、「全域性變數」之類的概念,而且這些概念是必須內生在語言內的。一部分程度上,這是表達效率弱的體現。事實上,函數語言程式設計僅僅靠值和函式兩個概念,加上基本的型別、運算就能實現幾乎所有的事(或者說圖靈完全的)。而我們後面提的「遞迴」、「單子」等概念,某種程度上是「派生的」而不是「內生的」。這更有Top-down數學的特徵(當然數學是否如此那又是另一個問題了)。

一個關於定義域的說明

最後,我們要提到一個關於「定義域」的小問題,我在上面的論述中沒有提到,因為我們稍微用到型別/定義域的概念。譬如下面一個函式(我特意帶上了型別註解):

def f(x: int) -> int:
    if x > 0:
        return x + 1

這個例子中,其實\(x\)的取值範圍只能是\(x > 0\)(雖然在例外的情況Python會輸出None),但事實上這種操作和數學中的函式還是有略微的區別,因為它在宣告時的定義域為int事實上的定義域是\(x \in N\)。int裡並非所有取值都沒用到。

在諸如scala或者haskell等函式式支援較好的語言裡,也稱這種函式為Partial Function(注意和Curry化中的Partial Applied Function的區別),意思是並不是所有的定義域的自變數都宣告過了,比如,scala中定義上述的函式f會用到PartialFunction

val f: PartialFunction[Int, Int] = {
  case x if x > 0 => x + 1
}

但在實際運用情況下,我們更傾向於用「無副作用」表述函數語言程式設計的基本特性和性質,所以這種細節層面的討論在大多數場合都被忽略,但是注重數學表達的你應該值得留意一下。

References

  • 張翠媛. 淺談弗雷格的 “函式和概念”. 現代交際 14 (2018).
  • Chiusano, Paul, and Runar Bjarnason. Functional Programming in Scala. Simon and Schuster, 2014.
  • Abelson, Harold, and Gerald Jay Sussman. Structure and Interpretation of Computer Programs. The MIT Press, 1996.