不久前,Python 3.10在10月12日正式釋出,其中一個重要特性是模式比對(PEP 634),在過去,這類特性主要常見於具備函數式典範的語言之中,像是Haskell、Scala等,然而近來一些主流語言,也紛紛內建這類特性,像是Ruby、Python等,JavaScript也在評估此特性,甚至於Java也在實現模式比對的道路上。

不少開發者會問,模式比對究竟有什麼好處?值得這些語言納入為特性之一?從支援函數式典範的角度來看,模式比對有利於處理代數資料型態,然而對於方才提及的一些主流語言來說,函數式並非其主要典範,這些語言內建模式比對特性的好處,主要在於想針對資料載體的結構,增加新的處理函式時比較方便,先前專欄〈不只是語法糖的記錄類別〉、〈揭露型態邊界的彌封類別〉,就談到了Java在這部份的一些支援。

模式比對有壞味道?

然而,若是物件導向的擁護者,對於模式比對可能會有點疑惑。以Java來說,不管是現在就支援的instanceof模式比對,或是未來的switch模式比對,都是針對各自型態來做出不同的處理,這會讓人聯想到一個古老的告誡:使用instanceof,往往意謂著壞味道,如果是針對不同子型態使用instanceof,應該考慮子型態多型的設計方式。

以〈揭露型態邊界的彌封類別〉談到的範例〈代數資料型態:Java 17〉來說,難道不是應該在List定義sum方法,然後讓Nil與Cons各自實現sum方法,就像〈代數資料型態:子型態多型〉的子型態多型寫法,才是個好的設計嗎?

物件導向vs函數式

子型態多型與模式比對,似乎是兩種不同的方向,畢竟物件導向的出發點是抽象資料型態,而函數式的出發點是代數資料型態,前者從一開始就討論資料的封裝,後者從一開始就討論資料的結構。

兩者其實各有優缺點,在物件導向上,子型態多型的好處是,若要增加新的子型態,既有的程式不用修改,這也是為何instanceof的寫法會被視為壞味道,因為,instanceof的實現方式,在增加子型態時,就必須回頭修改既有的程式碼。

然而,若要新增處理方法呢?例如在〈代數資料型態:子型態多型〉的List增加個連乘積處理方法?喔!全部的子類都會被影響!雖然Java的介面可以寫default實現,不過,這只是暫時騙過編譯器,子型態還是必須實現各自的處理方式,執行時期才符合實際需求。

如果我們要針對〈代數資料型態:Java 17〉的List,增加一個連乘積處理方法,不過就是新定義一個函式罷了,而這就是函數式上模式比對的好處,隨時可以針對資料增加新的處理函式,既有的程式不用修改。

然而,若要增加新的子型態(以函數式的術語來說,為sum型態定義新的值)呢?既有運用到模式比對的函式,都必須做出修改,Java的編譯器雖然會自動加入default,在沒比對到子型態時拋出例外,但實際上,還是須為新增的子型態實現相對應的處理。

如果你發布的是一個程式庫,就客戶端觀點來看,本來就不能在既有類別中新增方法,若你透過記錄類別揭露了資料載體的結構,模式比對相對而言就是個方便的工具,若你使用彌封類別,揭露子型態的邊界,客戶端本來就無法新增子型態,也就不會有新增子型態後,須修改模式比對相關程式碼的問題。

當然,若你改變彌封類別的子型態邊界,例如增加新的子型態,客戶端就必須修改,然而,這是程式庫相容性控管的問題,某些程度上,就跟你修改程式庫的interface後要面對的問題類似。

實際上,子型態多型與模式比對是能夠結合的,若你使用彌封類別與記錄類別,一個實現的方式是在彌封類別直接實現方法,這當中可使用模式比對,就像〈代數資料型態:子型態多型+模式比對〉的做法,對於資料載體常用的處理,以此方式事先定義,客戶端能方便地直接取用,至於不足的,客戶端可自行定義函式。

反模式的Visitor?

簡單來說,如果語言同時提供了子型態多型與模式比對的特性,在封裝的目的是隱藏狀態,希望讓客戶端可以有各自實現子型態的彈性,此時,子型態多型會是比較好的選擇;若封裝的目的是揭露(限制)子型態邊界、揭露資料載體的結構,希望客戶端有足夠的彈性,可以針對資料增加新的處理函式,那麼模式比對會是比較好的選擇。

無論使用的語言是哪個,這個基本原則應該可以讓你在子型態多型與模式比對間做出選擇,甚至考慮兩者的結合;不過,若你使用的是靜態定型語言,倒是有個問題可以思考一下:針對型態做不同的流程處理,不是還有一種實現方式?技術上稱為重載(overload)、靜態時期多型或特定(Ad-hoc)多型的方式?

沒有錯喔!以Java來說,就算沒有Java 17,也可以透過重載來實現出類似模式比對的功能,在〈代數資料型態:Visitor實現〉中就實作了一個範例,其中就重載static的sum方法來實作:

Integer sum(Nil nil) { return 0; }
Integer sum(Cons cons) { return cons.head() + cons.tail().sum(); }

雖然看起來囉嗦了一些,不過,這確實是模式比對的概念,留意到範例名稱上有Visitor字樣,實際上這就是Visitor模式的概念,Nil與Cons各自實現了sum()方法,其中都寫了List.sum(this),因為this的型態就是所在類別的型態,在編譯時期就會選擇對應的重載方法。

只不過,範例中怎麼沒看到Visitor這個角色呢?這是個迷思,是否要有Visitor,往往視需求而定,畢竟Visitor模式的本質就只是重載的應用,如果Visitor本身不具備狀態,根本上就不需要有Visitor角色。

另一個對Visitor的迷思是,它被許多人視為反模式(anti-pattern),因為許多文件或書籍會舉個使用Visitor的例子,接著改用子型態多型來解決,藉以彰顯Visitor的壞味道,但是,實際上,反模式的對象不是Visitor模式,而是應該用子型態多型解決,卻用了Visitor模式的作法。

也就是說,如果封裝的目的是揭露(限制)子型態邊界、揭露資料載體的結構,希望客戶端在針對資料增加新的處理函式時,有足夠的彈性,而你的語言還沒有模式比對的特性,那才是Visitor模式的應用場合。

模式比對就是多型?

當然,若語言本身已經具備了模式比對特性,就不用大費周章地實現Visitor模式了,此時,使用模式比對會簡潔許多,然而,這也說明了另一件事,模式比對就是多型,只不過,這個多型是特定多型,而非子型態多型罷了。

回過頭來想想,為什麼函數式典範的世界中,模式比對往往一開始就存在呢?因為函數式的世界中,首先著重在代數資料型態的設計,揭露其結構,在資料型態確認之後,重點就只需要擺在各種函式的新增了,這跟物件導向的世界,一開始放在抽象資料型態的設計,之後只需要著重在各種子型態的實現,是類似的道理!

專欄作者

熱門新聞

Advertisement