###### tags: `Code Sense` `Design-Pattern`
前幾天在看Youtube講[Currying](https://www.youtube.com/watch?v=5lY7caTLyXA&lc=Ugwm-V71T7Ah3fVPSt14AaABAg&ab_channel=%E8%B5%B0%E6%AD%AA%E7%9A%84%E5%B7%A5%E7%A8%8B%E5%B8%ABJames)時,看到留言處有人回關於在FP的世界closure觀念有很重要。於是好奇看了一下兩者的差異,對於OOP派的自己,已經很久沒寫FP設計了,有的話也是在很多年前對於軟工還處於碼農對OO完全不會時期的自己。特別寫一篇記錄Currying與Closure的認知紀錄,記錄過程順便釐清OOP與FP差異之處。
## OOP與FP在純數據處理應用場景差異
OOP在物件、設計模式面去設計實際架構非常好用,但在FP世界哩,感覺大多是用於處裡數據面的應用,大多會以API形式去呈現。對於一開始的認知是這樣,針對這部分做一個研讀
### 數學和科學計算
FP稍微查文獻,理念和概念來源於一種稱為 λ 演算(Lambda Calculus)的數學邏輯系統。在λ演算中,所有東西都是函數,並且只有一種操作,那就是應用(apply)。這種簡單、統一的架構與數學中函數的概念有很強的關聯,並且有助於構建簡潔、可預測和易於推理的程序。
- 純粹性:數學中,函數的值僅取決於它的輸入,並不取決於任何外部狀態。這與λ演算中函數的概念相一致,也是函數式編程中"純函數"的概念的基礎。
- 函數的一等性:數學中,函數可以作為輸入給其他函數,也可以作為其他函數的輸出。這與λ演算中函數的概念相一致,並且是函數式編程中"函數作為一等公民"的概念的基礎。
所以,可以說FP在設計之初就深受數學的影響。然而,這並不意味著函數式編程只適合於數學問題。事實上,函數式編程的概念,如**不可變性**、**純函數**和**函數組合**,在許多不同的應用情境和領域中都是有用的。
對於OOP較熟看到此的人大致會有點小感覺,會覺得對於這些特性的應用,其實OOP概念上也是可實現的。但在於數據需要進行大量並行處理和異步操作的場景中,FP的不可變性和狀態無關性可以帶來很大的優勢。
另外更簡單數據應用差異的例子如下,假設我們有一個任務,需要對一個數據集(比如一個數字列表)中的每個元素都進行平方運算。在一種物件導向的實現方式中,我們可能會創建一個數據集類別(DataSet),並為該類別提供一個方法來進行平方運算:
```python=
class DataSet:
def __init__(self, data):
self.data = data
def square(self):
for i in range(len(self.data)):
self.data[i] = self.data[i] ** 2
```
Context如下
```python=
data = DataSet([1, 2, 3, 4, 5])
data.square()
print(data.data) # Outputs: [1, 4, 9, 16, 25]
```
這種實現方式的問題在於,每當我們需要對數據進行新的操作時(比如開根號,取對數等),我們都需要在 DataSet 類別中添加一個新的方法。如果操作的種類很多,這樣會使得 DataSet 類別變得非常臃腫。
在FP設計中,我們將這些操作抽象為函數,並且可以很容易地將這些函數應用到數據集中的每一個元素如下
```python=
def square(x):
return x ** 2
data = [1, 2, 3, 4, 5]
data = list(map(square, data))
print(data) # Outputs: [1, 4, 9, 16, 25]
```
在這種情況下,當我們需要添加新的操作時,只需添加一個新的函數即可,無需修改數據結構。而且,這些函數可以重用於任何類型的數據集,而不僅僅是我們定義的 DataSet 類別。因此,對於這種需要對大量數據進行同樣操作的情境,函數式程式設計的風格通常能提供更高的靈活性和可重用性。
其實也許這時針對這例子你可能會思考,這例子有點爛,誰會針對這應用情境去設計物件。沒錯,你的觀察非常準確! FP傾向於表達計算過程為數學函數的求值,更多的關注在數據操作和轉換上,而不是像物件導向程式設計那樣,重視封裝資料和行為到物件中,並使用物件的互動來描述計算過程。
這並不是說 FP 不適合處理需要物件設計的場景,而是它們解決問題的角度和方法有所不同。事實上,在很多現代的語言中,FP 和 OOP 都被視為是語言的重要組成部分,並且在很多場景中可以一起使用。
以上述的例子,如果在一個更複雜的系統中,這些數據被封裝在物件中,並且物件有自己的行為和狀態,那麼使用 OOP 可能會更合適。但是,當我們需要處理大量數據,並且對這些數據進行各種轉換和處理時,FP 提供的 map, reduce, filter 等高階函數可以讓我們更直接地描述這些操作。
講到此,應該對於數學和科學計算的應用場景FP與OOP的差異性就會有較清楚的認知。
### 大數據處理和分析
FP與不可變數據結構的結合,在大數據處理和分析的應用中非常有效。比如,Apache Spark就是一個使用Scala(一種支持FP的語言)編寫的大數據處理框架,並利用了許多FP的概念,如不可變數據集和高階函數。
接著具體來談談 Apache Spark 以及函數式編程在大數據處理中的應用
Apache Spark 是一個用於大規模數據處理的開源集群運算系統,提供了一個非常高級的(抽象層次高的)數據操作接口,稱為 Resilient Distributed Dataset(RDD)。RDD是一種不可變的、分布式的、元素類型相同的數據集合。
在 Spark 中,RDD 是主要的數據結構,所有的操作(如 map, filter, reduce 等)都是在 RDD 上進行的。由於 RDD 是不可變的,所以一旦創建就不能被修改,這使得 Spark 能夠在大規模並行計算中有效地追蹤數據的來源和變化。
以下是一個用 Spark 的 Python API (pyspark) 進行簡單 map-reduce 操作的例子:
```python=
from pyspark import SparkContext
sc = SparkContext("local", "count app")
nums = sc.parallelize([1, 2, 3, 4, 5])
# 使用 map 操作將所有數字加一
add_one = nums.map(lambda x: x + 1)
# 使用 reduce 操作將所有數字相加
sum = add_one.reduce(lambda a, b: a + b)
print(sum) # 輸出: 20
```
這個例子中,首先創建了一個 RDD(nums),然後使用 map 操作將 RDD 中的每個數字加一,接著使用 reduce 操作將所有數字相加。這些操作都是以函數的形式(lambda 函數)進行的,並且可以自動地在多個節點上並行執行。
這就是函數式編程在大數據處理中的一個應用例子。通過使用不可變數據結構(RDD)和高階函數(map, reduce 等),Spark 可以將大規模數據處理問題抽象化,並提供一種有效、簡潔和可伸縮的解決方案。
如果這例子太難,在舉一個更簡單的例子來理解FP在大數據處理中的優勢。假設需要處理一個包含數百萬條記錄的數據集,每條記錄都是一個人的個人信息(例如,名字,年齡,地址等)。任務是找出年齡在 18 歲以上的所有人。
在物件導向的方法中,可能會像這樣來處理:
```python=
class Person:
def __init__(self, name, age, address):
self.name = name
self.age = age
self.address = address
people = [...] # 假設這是一個包含數百萬個 Person 物件的列表
adults = []
for person in people:
if person.age >= 18:
adults.append(person)
```
而在FP設計中,可以將這個任務抽象為一個過濾操作:
```python=
people = [...] # 假設這是一個包含數百萬個 tuples 的列表,每個 tuple 是一個人的信息
adults = filter(lambda person: person[1] >= 18, people)
```
在這個例子中,使用了內建的 filter 函數和一個 lambda 函數來達成目標。這種方式更為簡短和直觀,並且可以自動地利用並行處理來加速計算(例如,如果使用的是像 PySpark 這樣的分布式計算框架)。
講到此,再去觀察上述例子,FP設計具有狀態不變性、Pure Function(Output只取決於Input),因此他非常適合拿來做數據的應用處理。但真實世界不可能完全靠Pure方法設計,光是撈資料庫顯示到前端操作部分就會牽扯到很多資料面的狀態(與數據面不一樣)的變化處裡。此時OOP就會是較好的選擇。
## FP中的Closure與Currying
### 理解Closure
雖然FP特性在於狀態不變性去設計,但有些數據操作流程還是會有數據狀態應用,此時就可以使用Closure較精簡進階的寫法去達成。Closure是一種特殊類型的函數,它可以訪問並操作在它被定義的時候已經存在的變數,即使這個函數在它的定義環境之外被調用。特別是在需要創建一個能夠記住某些特定狀態的函數時。在 Python 中,Closure可以讓一個函數「記住」它被定義時的環境。舉個例子如下
```python=
def make_multiplier(x):
def multiplier(n):
return x * n
return multiplier
times3 = make_multiplier(3)
print(times3(10)) # Outputs: 30
```
這裡的 multiplier(n) 函數就是一個Closure。為何呢?因為它「記住」了當它被創建時所在的環境。當我們調用 make_multiplier(3) 時,我們實際上是在創建一個新的 multiplier(n) 函數,這個函數「知道」 x 是 3(狀態不再改變),即使multiplier(n) 函數是在 make_multiplier(x) 函數的內部被定義的,當它被返回並指派給 times3 變數時,它實際上已經離開了 make_multiplier(x) 的「範疇」或「作用域」。換句話說,multiplier(n) 函數已經在 make_multiplier(x) 函數的外部被調用。
這裡再加強補充multiplier(n) 函數已經在 make_multiplier(x) 函數的外部被調用這件事情。
在 Python 中,當你定義一個函數,這個函數會有它自己的作用域,也就是說它只能直接訪問在自己內部定義的變數。 make_multiplier(x) 函數裡,這個函數可以訪問變數 x,因為 x 是作為參數傳進來的。
然後, make_multiplier(x) 函數裡面定義了一個新的函數 multiplier(n)。這個新函數可以訪問 n,因為 n 是作為參數傳進來的,但它也能訪問 x,即使 x 是在外部的 make_multiplier(x) 函數中定義的。這是因為 multiplier(n) 函數是在 make_multiplier(x) 函數的作用域內創建的,所以它能夠訪問 make_multiplier(x) 函數的作用域。
然後將multiplier(n) 函數作為 make_multiplier(x) 函數的返回值,因此當調用 make_multiplier(3),實際上獲得的是一個新的函數,這個新函數在被調用時會將傳入的值乘以3。
當將 make_multiplier(3) 的返回值(即 multiplier(n) 函數)賦值給 times3 並調用 times3(10) 時,即使 multiplier(n) 函數現在已經在 make_multiplier(x) 函數的外部被調用,它仍然能夠「記住」 x 的值(在這裡是3)。當你調用 times3(10)(實際上就是調用 multiplier(10))時,它知道要將10乘以3,因為它“記住”了 x 的值為3。
**這就是所謂的Closure:一個函數記住並能夠訪問其外部作用域的變數(上述例子的x),即使它現在在其創建時的作用域之外被調用。** 換句話說multiplier(n)記住了當它被創建時的環境,它依舊有能力記住並訪問它所在的外部函數的變數(make_multiplier(x))。
### 理解Currying
因為FP大多是做function的組合(無物件特性),要讓程式碼比較自然且容易閱讀。此時我們就會談到Currying。它是一種將接收多個參數的函數轉換為一系列使用一個參數的函數的技術。例如,一個接收兩個參數的函數 f(x, y) 可以被 Curry 化為一個接收一個參數並返回一個函數的函數 g(x)(y)。在這種情況下,可以首先提供第一個參數(例如,g(2)),並得到一個新的函數,該函數接收第二個參數並返回最終結果(例如,g(2)(3) 返回與 f(2, 3) 相同的結果)。簡單來說Currying就是一種將接受多個參數的函數轉換為一系列使用一個參數的函數的技術。
根據上述例子在Currying實現回如下
```python=
def multiply(x):
def multiply_x(y):
return x * y
return multiply_x
double = multiply(2)
print(double(5)) # Outputs: 10
```
將一個接受兩個參數的函數 multiply 轉化為一個函數 multiply_x,這個函數接受一個參數並且返回一個函數,這個返回的函數也接受一個參數。我們可以看到,這裡的 multiply_x 同時也是一個Closure,因為它記住了 x 的值。雖然這範例看起來與Closure很像,但其實應用場景上還是有些差異...請往下看~
### Currying與Closure情境差異
這邊做個小整理
Closure:Closure的主要用途是「記住」來自外部函數的變數。當內部函數被返回並在其他地方使用時,即使原來的外部函數已經完成執行,這個內部函數仍然可以訪問和操作那些變數。這可以用來創造有狀態的函數,也就是說,這些函數的行為會被他們的「環境」影響。在上述例子中,multiplier(n) 就是一個Closure。
Currying:Currying的主要用途是將一個接收多個參數的函數轉化成一連串接收單一參數的函數。這可以使得我們能夠以更靈活的方式使用函數,尤其是在涉及函數作為參數的場合。在你的第二個例子中,multiply(x) 就進行了Currying的過程。
假設我們在開發一款遊戲,並且我們想要計數玩家取得的分數。我們可以使用Closure來實現這個需求:
```python=
def create_score_counter():
score = 0
def add_score(points):
nonlocal score
score += points
return score
return add_score
counter = create_score_counter()
print(counter(10)) # Outputs: 10
print(counter(20)) # Outputs: 30
```
create_score_counter 函數返回一個add_score,這個Closure會記住並修改它的外部環境中的 score 變數。即使 create_score_counter 函數的執行已經結束,add_score 函數仍然可以訪問和修改 score 變數。
在此例子中,Closure是更直觀且更簡單的方法來達到需求。我們要記錄和更新一個「狀態」(score),並且這個狀態需要在函數被連續調用的過程中保留。Closure讓我們能夠將狀態(這裡是 score)與用來操作狀態的函數(這裡是 add_score)捆綁在一起,並且這個狀態會在連續的函數調用之間被「記住」。
(Currying)主要用來將一個接收多個參數的函數轉換成一系列接收單一參數的函數。當我們需要讓一個函數的一部分參數(或預設參數)被「固定」,而另一部分參數在之後被提供時,Currying就很有用。然而在此例子中,我們主要的需求是「記錄和更新狀態」,而不僅僅是「固定一部分參數」。
另一個情境,假設我們正在處理一個列表,我們想要對列表中的每個元素應用一個函數。我們可以使用Currying來創建一個函數,這個函數接收一個函數和一個列表作為參數,然後返回一個新的函數,這個新的函數接收一個元素並應用我們之前傳入的函數:
```python=
def map_function(func):
def apply_func_to_list(lst):
return [func(x) for x in lst]
return apply_func_to_list
double = lambda x: x * 2
map_double = map_function(double)
print(map_double([1, 2, 3, 4, 5])) # Outputs: [2, 4, 6, 8, 10]
```
map_function 函數實際上就是在進行Currying:它接收一個函數作為參數,然後返回一個新的函數 apply_func_to_list。這個新的函數可以接收一個列表作為參數,並且對列表中的每個元素應用我們傳入 map_function 的那個函數。
此例沒有需要維護和更新的"狀態"。這個例子中的 map_function 可以看作一個被Currying的函數:它首先接收一個函數 func,然後返回一個新的函數 apply_func_to_list,這個新的函數會接收一個列表 lst,並將 func 應用到 lst 的每個元素上。
這種將函數的參數分階段接收的特性,使得你可以提前固定部分參數(在這裡就是固定了 func),並生成一個新的函數用於處理之後的參數(在這裡就是處理 lst)。這種特性在一些情況下可以使代碼更簡潔,更有彈性。而Closure則是針對狀態維護與更新的簡潔寫法。
看完後~應該對於FP設計概念及Closure與Currying有較清楚的認知~但實際還是要更具Context的親自下手設計才會更有感覺。
## 小記
其實Currying在C#也是可以用Delgate實現,如下
```csharp=
public static void Main()
{
Func<int, Func<int, int>> curriedMultiply = MultiplyCurried();
Func<int, int> multiplyBy2 = curriedMultiply(2);
int result = multiplyBy2(3); // result will be 6
Console.WriteLine(result);
}
static Func<int, Func<int, int>> MultiplyCurried()
{
return a => b => a * b;
}
```
雖說可以實現,但Delgate的設計主要用於事件處理、異步調用與回調函數場景,跟數據世界Closure的應用就不太相同。在數據分析和處理的領域,Closure的主要用途是「記住」來自外部函數的變數,並且在閉包函數內部操作這些變數。這特別適用於需要維護和更新內部狀態的情況。例如,在統計數據或者計數的時候,Closure可以使我們的代碼變得更簡潔,而且容易理解。
而Delegate應用於通用的軟件設計領域,例如事件處理、異步調用和回調函數等場景。一個Delgate實際上就是一個包含有指向其他函數或方法的指針的對象。當一個Delgate被調用時,它可以調用它所引用的函數或方法。這可以動態地改變函數或方法的行為,並讓程式碼在執行時更加靈活。
感覺自己可以在對FP做更完整的認知,每當聽到有人說分兩派信仰,我覺得應該是對於OOP與FP沒有完整的認知或是適當應用場景經驗的不足才會有偏差偏於用哪一種。