Pattern matching

January 20, 2022

在〈Visitor〉曾經談過模式比對(Pattern matching),也談到模式比對這類功能,概念源自於函數式,優先思考資料該具有什麼樣的結構。

資料的結構

從純函數式的典範來看,優先思考資料該具有什麼樣的結構,本來就該如此,畢竟不是物件導向,不是去考量資料本身能具備哪些方法,而是考量拿到資料之後,該怎麼處理它。

Visitor〉中談到的揭露結構/型態,或者具體地說,在 Java 中透過 record 類別揭露資料的結構,透過 sealed 限制子類別的型態,其實是限制你不要用物件導向觀點來看待資料罷了,因為 Java 支援物件導向,許多開發者也太容易用物件導向的思維去設計,在面對函數式的元素時,容易不明白其目的與作用,從而用在不適當的場合。

如果一開始就將資料看成是資料,那就自然多了,例如,再度以〈Immutable〉中的這個例子開始吧!

def leng(lt):
    count = 0
    for _ in lt:
        count = count + 1
    return count

lt = [1, 3, 2, 5, 8]
length = leng(lt)

Immutable〉中談到「上面這個 leng,以白話來描述,你做了什麼呢?你走訪 lt,有元素時遞增 1,直到沒有元素為止…」這段描述其實就表示,你知道 lt 的結構會是什麼,也就是它可以循序走訪,那麼循序走訪的意思是?

可以從 lt 中一個一個取出元素,若將 lt 分為已走訪與未走訪部份,那就是每次從未走訪的部份,取出首個元素,而剩下的部份,又是未走訪的清單,這也是後來遞迴版的實作概念:

def leng(lt):
    if lt == []:   
        return 0
    else:          
        return 1 + leng(lt[1:])

lt = [1, 3, 2, 5, 8]
length = leng(lt)

lt[] 時,表示沒有元素,長度自然是 0,沒什麼好處理的,這表示你也知道,你處理的 lt 在結構上,會有 [] 的可能性。

因此,你實際要處理資料,在結構上會有 [] 的可能性,當你只有一個元素時,例如,[10],首個元素會是 10,未走訪清單會是 [],這就是你面對的 lt

比對結構

現代許多語言,都有模式比對的語法,〈Visitor〉中看過 Java 的做法了,來看看 Python 吧!Python 3.10 以後,可以使用 match/case,有人說 Python 終於內建 switch 語法了,那完全是個誤解,match/case 是用來做模式比對的,只不過剛好可以涵蓋 switch 的功能罷了。

Python 支援物件導向,Python 的資料本身就是物件,不過,若你想將資料看成是純綷的資料,並且清楚資料的結構組成的話,那麼搭配 match/case,就是自然不過的一件事:

def leng(lt):
    match lt:
        case []:
            return 0
        case [_, *tail]:
            return 1 + leng(tail)

lt = [1, 3, 2, 5, 8]
length = leng(lt)

上面的計算不會用到首元素,因此變數命名為 _,在慣例上,這表示用不到這個變數,來看看〈Immutable〉中這段命令式語法的範例:

lt = [1, 3, 2, 5, 8]

count = 0
for _ in lt:
    count = count + 1
        
doubled_lt = []
for n in lt:
    doubled_lt.append(n * 2)
        
greaterThan5 = []
for n in doubled_lt:
    if n > 5:
        greaterThan5.append(n)

在第二與第三個迴圈,會用到 n,那就表示使用 match/case 實現時,會用到首元素:

def leng(lt):
    match lt:
        case []:
            return 0
        case [_, *tail]:
            return 1 + leng(tail)
            
def doubleIt(lt):
    match lt:
        case []:
            return []         # 空清單能乘 2 的元素沒有,結果就還是空清單
        case [head, *tail]:
            return [head * 2] + doubleIt(tail) # 首元素乘 2 並跟處理完的尾清單串起來
            
def greaterThanFive(lt):
    match lt:
        case []:
            return []         # 空清單大於 5 的清單當然是空清單
        case [head, *tail]:
            if head > 5:
                return [head] + greaterThanFive(tail) # 串起來
            else:
                return greaterThanFive(tail)          # 不理首元素

lt = [1, 3, 2, 5, 8]
count = leng(lt)
doubled_lt = doubleIt(lt)
greaterThan5 = greaterThanFive(doubled_lt)

資料載體

list 只是一種常見的資料結構,如果你想用模式比對概念處理資料,你必須清楚資料的結構,它是樹狀結構嗎?還是有無的結構?或者是有限元素?比方說 tuple

你可能想起來,對了!tuple 不是可以拆解嗎?

pt = (10, 2, 3)
x, y, z = pt

確實地,在函數式語言中,tuple 就是一種簡便的資料結構,或說是資料載體,Python 只是借了一些特性來用,像是不可變動、元素有限等,過去的 Python 支援的 tuple 拆解,就是一種簡單、只針對 tuple 的模式比對。

也就是說,有些語言中的拆解之類的語法,像是 JavaScript,其實都具有模式比對的概念,想善用的話,就是用來處理資料載體,而你必須清楚其資料結構。

模式比對的特性,會進入到這類語言中的原因之一,與現在不少應用程式,都會結合 Web 服務,呼叫公開 API,以 JSON 之類的格式交換資料,因為資料本身就是具結構性的格式,欄位都是公開的,通常應用程式中就會設計對應結構的資料載體,而為了要便於擷取資料中特定結構的訊息,也就開始納入了模式比對、拆解之類的語法。

有些語言為了在定義資料載體時,能有個規範或共同語彙,會針對資料載體設計語法或程式庫,例如 Java 的 record 類別,或者是 Python 的 dataclass

例如,來配合 Python 的 dataclass 以及 Python 3.10 以後的 match/case,將〈Visitor〉中的範例實作出來:

from dataclasses import dataclass
import math

@dataclass
class Point:
    x: int
    y: int
   
@dataclass
class Rectangle:
    center: Point
    width: float
    height: float

@dataclass
class Circle:
    center: Point
    radius: float
   
def area(shape):
    match shape:
        case Rectangle(_, width, height):
            return width * height
        case Circle(_, radius):
            return radius * radius * math.pi
            
rect = Rectangle(Point(0, 0), 10, 20)
circle = Circle(Point(0, 0), 10)
   
print(area(rect))
print(area(circle))

就命令式語言來說,如果你使用了某種資料載體,想對資料載體進行處理,那麼可以留意語言本身,是否提供了模式比對、拆解之類的語法,試著結合函數式的其他概念(例如 Immutable 等)來運用它,或許就可以找到不錯的設計思路。