函數式編程概述——Python版

函數式編程是種編程方式,它將電腦運算視為函數的計算。和指令式編程相比,函數式編程強調函數的計算比指令的執行重要。和過程化編程相比,函數式編程裡函數的計算可隨時調用。——百度百科

函數式編程通過在函數中定義表達式和對錶達式求值完成計算。它儘量避免由於狀態變化和使用可變對象引入複雜性,讓程序變得簡潔明瞭。

本文將介紹函數式編程的一些基本技術和基本原則,以及如何在流行編程語言Python中運用這些技術。

雖然Python不是純粹的函數式語言,但仍然可以使用Python進行函數式編程。Python具備函數式編程的許多核心特徵,使得我們可以借鑑其他函數式語言的設計模式和編程技術,編寫出簡潔優雅的代碼。尤其值得一提的是Python的生成器表達式,使用它可以避免在內存中創建大型數據結構,通過降低資源消耗來提高執行速度。

最後我們還會介紹通過這些設計模式構建Python應用時,函數式編程帶來的好處。

編程範式

簡單說,"函數式編程"是一種"編程範式"(programming paradigm),也就是如何編寫程序的方法論。它屬於"結構化編程"的一種,主要思想是把運算過程儘量寫成一系列嵌套的函數調用。——百度百科

編程範式並沒有統一的劃分標準。我們重點分析其中兩個範式:

函數式編程命令式編程。二者最重要的特徵區別是狀態

在命令式語言(比如Python)中,計算的狀態是通過不同命名空間中變量的值反映的。變量的值決定計算的當前狀態,一條語句通過增加或改變(甚至是刪除)變量來改變當前狀態。

“命令式”語言的每一條語句都是一個通過某種方式改變狀態的命令。比如,Python中用於在命令空間中改變變量規則的global、nonlocal等,用於改變變量所處語境的def、class和import等,用作判斷條件確定一組語句如何改變計算狀態的try、except、if、elif和else等。而循環語句,如for和while,則是將一組語句作為整體以重複改變計算狀態。可見所有這些語句都是通過某種方式改變變量狀態的。

理想狀態下,每一條語句通過改變狀態,推動計算從初始狀態向期望的最終結果不斷靠近。然而,這種“推動計算一步步向前”的模式難以驗證。需要首先定義出最終狀態,找到能達到該狀態的語句,從而推導出達到該狀態需要的前提條件,然後重複上述步驟,直到找到一個可接受的初始狀態。

在函數式語言中,使用“對函數求值”這一更簡單的概念代替改變變量值的“狀態”,每次對函數求值都會在現有對象的基礎上創建一個或多個新對象。函數式程序即函數的組合,相應的開發過程是:首先設計一組易於理解的底層函數,然後在此基礎上設計符合業務需求的高級函數。相比於由複雜的流程控制組成的指令集合,高級函數更容易可視化。

形式上,函數求值更接近算法的數學表達。以簡單的代數形式設計算法,便於處理特殊情況和邊界條件,而且函數更有可能按照預期工作,也便於編寫單元測試用例。

請注意,通常函數式程序比功能相同的命令式(面向對象或者過程式的)程序更加簡潔明瞭和高效,但這些優點並不是自然而然的,需要仔細地設計,但付出的努力通常少於設計功能類似的過程式程序。

細分過程範式

命令式編程可以再細分為多個子類別,比如過程式編程和麵向對象編程,下面我們簡單介紹下它們的區別,並重點講解面向對象屬於命令式編程的原因。

下面通過代碼示例解釋這些概念。

有些人覺得這麼做是在重新造輪子,然而這其實是抽象概念的具體表現。

對於某些計算過程,完全可以忽略Python的面向對象特點,只使用簡單的數值計算。例如用下面的方法可以得到一組屬性相同的數。

s = 0
for n in range(1, 10):
if n % 3 == 0 or n % 5 == 0:
s += n
print(s)

和m僅包括3或5的倍數。以上程序是嚴格過程式的,避免使用Python的任何面向對象特徵。程序的狀態由變量s和n定義,變量n的取值範圍是1~10,每次循環中n的值依次增加,可以確定n == 10時循環結束。使用類似的原始數據結構,完全可以用C或者Java編寫出功能相同的程序。

下面利用Python的面向對象編程(object-oriented programming,OOP)特徵編寫一段類似的代碼。

m = list()
for n in range(1, 10):
if n % 3 == 0 or n % 5 == 0:
m.append(n)
print(sum(m))

程序運行結果與前面的結果相同,但它內部維護了一個狀態可變的集合對象m,計算狀態由m和n定義。

m.append(n)和sum(m)令人費解的語法讓一些開發者誤以為Python不是純粹的面嚮對象語言:它混合了function()和object.method()兩種語法。然而事實上Python是純粹的面嚮對象語言,一些語言(例如C++)允許使用非對象的原始數據類型,例如int、float和long。Python中沒有原始數據類型,前綴的語法形式不會改變語言的本質。

嚴格地說,完全可以採用純粹的面向對象風格,基於list類生成一個包含sum方法的子類。

class Summable_List(list):
def sum(self):
s = 0
for v in self:
s += v
return s

接下來使用Summable_List()類代替list()方法初始化變量m,就可以用m.sum()方法代替sum(m)方法來對m求和了。該示例可以證明Python是純粹的面嚮對象語言,前綴的使用僅是語法糖而已。

前面3個例子都基於變量值顯式確定程序的狀態,使用賦值語句改變變量值,推動計算前進。我們可以在程序中插入assert語句,確保程序狀態完全按照要求變化。

關鍵之處不是命令式編程存在某種缺陷,而是函數式編程是一種思維方式的轉變,這種改變適用於許多場景。下面介紹如何用函數式方法編寫同一個算法,你會發現函數式編程並沒有使算法顯著變短或變快。

1.使用函數式範式

在函數式編程中,求3或5的倍數可分為兩部分。

  • 對一系列數值求和。
  • 生成一個滿足某個條件的序列,例如3或5的倍數組成的序列。

一個列表的和的遞歸形式定義如下。

def sumr(seq):
if len(seq) == 0: return 0
return seq[0] + sumr(seq[1:])

可以把序列的和分為兩種情況。基礎形式:一個長度為0的序列,和為0。遞歸形式:序列的和等於序列中的第一個元素加上序列中後續元素的和。

由於遞歸形式的序列長度小於原序列,所以任何長度有限的序列最終都會退化為基礎形式。

該函數運行示例如下。

>>> sumr([7, 11])
18

>>> 7+sumr([11])
18
>>> 18+sumr([])
0

第一個例子計算了包含多個值的列表之和。第二個例子演示了遞歸規則將第一個值seq[0]和後續所有值的和seq[1:]相加。最後一個計算包含了對空列表求和,其值定義為0。

這個例子中,代碼最後一行的+運算符和初始值0表明其為求和。如果將運算符從+改為*,將初始值從0改為1,則表明其為序列乘積。後面會詳細介紹這種抽象化方法。

對於一列值,可以用類似的方法遞歸,定義如下。

def until(n, filter_func, v):
if v == n: return []
if filter_func(v): return [v] + until(n, filter_func, v+1)
else: return until(n, filter_func, v+1)

該函數的基礎形式為:給定一個值v和一個上限n,如果v達到上限,則返回一個空列表。

根據filter_func()函數的不同返回值,遞歸形式有兩種情況。如果v通過了filter_func()函數的測試,返回一個序列,則該序列的第一個元素是v,後續元素由until()作用於後續序列的返回值組成。如果v沒有通過filter_func()函數的測試,將忽略該值,返回值由函數作用於剩餘元素得到的值組成。

可以看到v在每次遞歸中遞增,直到達到上限n,也就是基礎形式。

下面介紹如何使用until()函數生成3或5的倍數。首先定義一個用於篩選數值的lambda對象。

mult_3_5 = lambda x: x % 3 == 0 or x % 5 == 0

(這裡使用lambda定義簡單函數是為了保持簡潔。如果函數比較複雜,多於一行代碼,請使用def語句。)

從命令提示符界面觀察lambda的行為,如下所示。

>>> mult_3_5(3)
True
>>> mult_3_5(4)
False
>>> mult_3_5(5)
True

結合until()函數,它可以生成一系列3或5的倍數。

使用until()函數生成一系列值,如下所示。

>>> until(10, lambda x: x % 3 == 0 or x % 5 == 0, 0)
[0, 3, 5, 6, 9]

然後可以使用之前定義的遞歸版sum()函數計算一系列數值的和了。這裡將用到的所有函數,包括sum()、until()和mult_3_5(),都定義為遞歸的,計算時不需要使用臨時變量保存計算狀態。

之後還會多次用到這種純粹遞歸風格的函數來定義思想。請注意,許多函數式語言的編譯器可以優化此類簡單的遞歸函數,但Python不會進行此類優化。

2.使用混合範式

下面介紹如何用函數式編碼實現前面計算3或5的倍數的例子。混合型函數的實現代碼如下所示。

print(sum(n for n in range(1, 10) if n % 3 == 0 or n % 5 == 0))

這裡使用了嵌入式生成器表達式迭代數值集合,並計算它們的和。range(1, 10)方法是可迭代的,所以它是一種生成器表達式,返回一個數值序列{n |1≤n < 10}。n for n in range(1, 10) if n % 3 == 0 or n % 5 == 0稍複雜一些,但它也是可迭代表達式,返回一個數值集合{n |1≤n < 10 ∧ (n mod 3 = 0 ∨ n mod 5 = 0)}。變量n與集合中的每個值綁定,表示集合的內容,而非當前的計算狀態。sum()方法的輸入值是一個可迭代表達式,輸出最終計算結果:對象23。

 綁定變量僅存在於生成器表達式內部,上述程序中,變量n在生成器表達式之外是不可見的。

可以將表達式中的if從句看作獨立的函數,便於用其他函數替換它。可以使用一個名為filter()的高階函數代替上面生成器表達式中的if從句。

這個例子中的變量n不同於前面兩個命令式實現中的變量n,生成器表達式之外的for語句在本地命名空間中創建變量,而生成器表達式並不創建for語句式的變量。

>>> sum(n for n in range(1, 10) if n % 3 == 0 or n % 5 == 0)
23
>>> n
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'n' is not defined/<module>/<stdin>

生成器表達式綁定的範圍外不存在變量n,即它並不定義計算狀態。

3.對象的創建過程

在某些情況下,觀察中間對象有助於理解計算過程,但請注意,計算的路徑並不是唯一的。當函數滿足交換律和結合律的時候,改變求值順序會創建出不同的中間對象。通過這種方式,可以在保證計算正確性的同時提升計算性能。

以下面這個表達式為例:

>>> 1+2+3+4
10

下面講解不同的計算過程是如何得到相同的計算結果的。由於+運算符滿足交換律和結合律,有許多條計算路徑都能得到相同結果。

根據選擇待計算值順序的不同,有以下兩種主要的計算路徑。

>>> ((1+2)+3)+4
10

>>> 1+(2+(3+4))
10

第一種情形是從左向右結合並求值,此時對象3和6作為求值過程的中間對象被創建出來。

第二種情形則是從右向左結合並求值,中間對象是7和9。在這個簡單的整數算術運算中,兩種方式的表現相同,優化無助於提升性能。

涉及數組的追加操作時,改變結合方式可能會提升性能。

示例如下。

>>> import timeit
>>> timeit.timeit("((([]+[1])+[2])+[3])+[4]")
0.8846941249794327
>>> timeit.timeit("[]+([1]+([2]+([3]+[4])))")
1.0207440659869462

可以看到,從左向右計算性能更佳。

對於函數式編程的設計,以任意順序使用+運算符(或者add()函數),結果不變,即+運算符不影響使用方式。

4.烏龜塔

嚴格意義上,Python的函數式編程並非函數式的,Python不是HaskellOCaml

Erlang。請注意,真正完成計算過程的處理器硬件本身就不是函數式的,甚至嚴格意義上不是面向對象的,CPU實際上是過程式的。

所有的編程語言都基於抽象、庫、框架和虛擬機,這裡的抽象又基於更底層的抽象、庫、框架和虛擬機。有個很形象的比喻:整個世界被一隻大烏龜馱在背上,這隻大烏龜又被另外一隻更大的烏龜馱在背上,這隻更大的烏龜又被一隻比它還大的烏龜馱在背上······

一言以蔽之:全是烏龜。

——佚名

抽象形成的層是無盡的。

更重要的是,這種抽象和虛擬機並不會影響通過Python的函數式特性設計軟件。

即使在函數式語言內部,也存在更純粹的語言和不太純粹的語言。有些語言經常使用monads處理像文件系統輸入、輸出這樣有狀態的事務,另外一些語言則使用類似於Python的混合型環境,通過仔細地隔離含有狀態的過程式動作來設計函數式的軟件。

我們講的函數式Python編程基於以下3層抽象。

  • 應用由函數組成,直到層層分解碰到對象。
  • 支撐函數式編程的Python運行時環境是由對象組成的,直到層層分解碰到庫。
  • 支撐Python運行的庫就是馱著Python的烏龜。

更底層的操作系統和硬件有它們各自的烏龜塔,而且與我們要處理的問題無關。

函數式編程經典示例

下面我們基於John Hughes的論文“Why Functional Programming Matters”,來分析一個函數式編程的經典實例,這篇文章出自論文集Research Topics in Functional Programming。

此論文深入分析了函數式編程,並提供了幾個例子,我們只分析其中的一個:用Newton-Raphson算法求解函數(平方根函數)。

該算法的許多實現都是通過loops顯式管理狀態的,比如Hughes的論文中就給出了一段Fortran代碼,通過有狀態的命令式流程求解。

算法的主體是如何根據當前的近似值計算出下一個近似值。函數next_()以sqrt(n)的當前近似值x為參數,計算出下一個近似值,並確保最終解就在之前近似值的範圍內,代碼如下所示。

def next_(n, x):
return (x + n / x) / 2

該函數計算出一系列值

函數式編程概述——Python版

相近兩個值的距離每次迭代減半,所以會迅速收斂到a=n/a,即

函數式編程概述——Python版

這裡沒有將迭代函數命名為next(),以避免與Python的內置函數發生衝突,使用next_()保證在不衝突的前提下儘量清晰地表達出函數的功能。

在命令提示符界面使用該函數,如下所示。

>>> n = 2
>>> f = lambda x: next_(n, x)
>>> a0 = 1.0
>>> [round(x,4) for x in (a0, f(a0), f(f(a0)), f(f(f(a0))),)]
[1.0, 1.5, 1.4167, 1.4142]

首先定義收斂到

函數式編程概述——Python版

的lambda表達式並賦值給變量f,將變量a0(0為下標)作為初始值,然後對一系列遞歸值求值:

函數式編程概述——Python版

等等。將這些函數放在一個生成器表達式中,便於對返回值做指定精度的四捨五入,從而使計算結果更易讀,並便於doctest使用。該序列會快速地向根號2收斂。

我們可以編寫一個函數,生成一個含ai(i為下標)的無限長序列,向平方根收斂。

def repeat(f, a):
yield a
for v in repeat(f, f(a)):
yield v

該函數利用近似函數f()和初始值a生成近似值。如果把近似函數替換成前面定義的next_()函數,就可以得到關於參數n平方根的一系列近似值。

 其中repeat()函數要求f()函數只有一個參數,而定義的next_()函數有兩個參數。可以用一個匿名函數對象lambda x: next_(n, x)綁定其中一個變量,創建next_()函數的部分綁定版本。

Python的生成器函數不能自動實現遞歸,必須顯式迭代遞歸結果,並一個一個單獨生成計算結果。使用return repeat(f, f(a))並不能多次循環生成一系列值,而會結束迭代並返回一個生成器表達式。

有兩種方法可以返回一系列值,而不是生成器表達式。

  • 編寫顯式for循環:for x in some_iter: yield x
  • 使用yield from語句:yield from some_iter

從遞歸生成器表達式中返回結果,這兩種方法的效果相同,這裡傾向於使用yield from語句。不過在有些情況下,yield結合複雜表達式,往往比相應的映射和生成器表達式更清晰。

當然,我們並不想計算無限長序列,只要兩次迭代的近似值足夠接近,就可以任取其中一個作為最終解。通常用希臘字母ε表示兩個值足夠接近,這裡的含義是計算平方根的誤差上限。

在Python中,我們需要設法從無限序列中一次取一個值,通常把複雜的遞歸包裹在簡單的接口函數中,見如下代碼片段。

def within(ε, iterable):
def head_tail(ε, a, iterable):
b = next(iterable)
if abs(a-b) <= ε: return b
return head_tail(ε, b, iterable)
return head_tail(ε, next(iterable), iterable)

首先定義了內部函數head_tail(),以誤差允許範圍ε、可迭代序列中的一個值a和可迭代序列的剩餘部分iterable為參數,iterable的下一個值與變量b綁定。如果| a - b |≤ε,兩個值距離足夠近,表明已找到平方根的解;否則以b為參數,遞歸調用函數head_tail(),以獲取下一次迭代的近似值。

函數within()只需要用參數iterable的第一個值初始化內部的head_tail()函數,後面由遞歸自動完成。

有些函數式語言允許將一個值放回可迭代序列,在Python中,這類似於用unget()或者previous()方法將一個值追加到迭代器中,然而Python的可迭代數據結構並沒有提供這種高級功能。

結合上面3個函數next_()、repeat()和within(),即可創建求平方根函數。

def sqrt(a0, ε, n):
return within(ε, repeat(lambda x: next_(n,x), a0))

repeat()函數基於next_(n, x)函數生成一個(可能的)無限長序列,當兩次迭代值之差小於ε時,within()即停止序列繼續生成值。

使用這個sqrt()函數需要提供一個初始值a0和誤差值ε,表達式sqrt(1.0, .0001, 3)表示從初始估計值1.0開始計算根號3,誤差值為0.0001。對於大多數應用,初始值可以選擇1.0,不過初始值與實際平方根越接近,函數收斂越快。

以上近似算法的最初版本是用Miranda語言編寫的,可以看到Miranda和Python的實現之間有一些顯著區別,主要是Miranda可以構建cons,可以通過類似於unget的方式將值放回可迭代對象中。Miranda和Python的這種對比說明了Python適用於實現多種函數式編程技術。

EDA

EDA領域包含很多處理複雜數據集的算法和技術,函數式編程往往能很好地連接起問題領域和解決方案。

雖然每個人有自己的行事風格,但處理EDA領域的問題通常可以劃分成下面幾個階段。

  • 準備數據:主要是抽取和變換源應用中的數據。例如解析原始數據格式,對數據執行某種程度的清洗(比如移除不可用數據和異常數據等),這是函數式編程擅長的領域。
  • 數據探測:對數據進行初始畫像,通常使用一些基本的統計函數來完成,這也是函數式編程擅長的領域。用專業術語講,該階段我們關注數據的單變量和雙變量統計特徵,實際上就是數據的描述性統計特徵值,包括平均值、中位數、眾數等。數據探測還可能涉及數據可視化,但本書不探討這個主題,因為它不怎麼採用函數式編程。如果你感興趣,可以嘗試一些工具包,例如SciPy。訪問如下網址,可獲取有關SciPy工作原理和使用方法的更多信息。https://www.packtpub.com/big-data-and-business-intelligence/learning-scipy-numerical-and-scientific-computinghttps://www.packtpub.com/big-data-and-business-intelligence/learning-python-data-visualization
  • 數據建模與機器學習:主要解決如何從已有模型中提取新數據,我們暫不涉及,因為從數學角度看有些模型十分複雜,討論這些問題無助於理解函數式編程。
  • 評估與比較:當存在多個可用模型時,就需要針對當前數據評估哪個模型更適合。此過程主要涉及計算模型常用的一些描述型統計特徵值,函數式設計技術能有所幫助。

EDA的目標是創建模型為應用決策提供依據。很多情況下,一個模型可能就是一個簡單的函數。使用函數式編程方式,便於將已有模型應用於新數據,生成業務人員可以理解的結果。

小結

本文主要介紹了編程範式,並比較了函數式編程和另外兩種常用的命令式編程範式的區別。我們旨在向讀者介紹Python的函數式編程特徵。我們講到了Python並非純粹的函數式編程語言,Python函數式編程的指導思想是將簡潔明瞭的函數式編程與性能優化相結合,形成有Python特色的混合式編程方法。

後面會詳細介紹函數式編程的5種基本技術,這些技術構成了Python混合函數式編程的核心要素。

——本文選自《Python函數式編程(第2版)》

函數式編程概述——Python版

Python不僅擁有命令式編程的強大優化能力,而且還具備函數式編程的諸多特性。

本書通過Python詮釋函數式編程的核心思想,詳細介紹如何利用函數式編程的優點,編寫代碼簡潔明瞭且易於維護的高性能Python程序,充分釋放Python潛力。

各章由淺入深,循序漸進,全方位展示Python函數式編程的強大與精妙,助你邁向高階Python開發。更有豐富代碼示例,讓你快速上手,學以致用。

  • 函數式編程的基本概念與特性
  • 使用迭代器和生成器表達式
  • 用Python的內置函數操作數據集
  • 常用高階函數以及新建方法
  • 使用遞歸設計算法以及常用歸約函數
  • 元組處理技術
  • 多個實用模塊和PyMonad庫
  • 用裝飾器構建複合函數
  • 避開Python嚴格求值順序的方法
  • Web服務設計方法
  • 常用優化技巧


分享到:


相關文章: