函式的正確用法

閱讀時間約 7 分鐘

前幾篇文章在討論類型時,只討論了乘法與加法類型,這只是最基礎的類型構造方式,另外還有函式類型和泛型等概念還沒討論。在討論函式的類型之前,必須先討論函式的正確用法。對於程序式編程來說,函式是一段可重複使用的執行代碼,輸入的參數是用來控制執行行為的,因此比起函式(function)更應該稱它為程序(procedure)。或許是在這種觀念下的影響,很多陳舊的函式庫,例如MFC,都會利用參數傳回結果,比較現代的函式庫則比較少這麼做了。對於函數式編程,函式就像是函數,函式的參數代表輸入,回傳值則是輸出。在擁有代數資料類型的支援下,回傳多個值變得非常容易,不再需要透過參數輸出結果。函數式編程也建議少用變數修改(mutation),因此也不應該只為了回傳結果而使用參數傳入參考。


函式最原始的用途是重用程式碼。重用程式碼的目的是將不同地方之中相同的程式邏輯抽取出來,讓我們不用每次都寫一樣的東西。然而在現代應該依據概念定義函式,而非只為了重用程式碼而定義。有時候程式碼一樣不一定代表它們有相同的概念。反之如果它的概念很重要,就算沒有重用的必要,也應該抽取成函式。然而有些人建議程式碼不應超過三層縮排,如果超過了應該考慮用函式抽取出來,我認為這種建議很蠢。把一段程式碼抽取出來會增加程式碼的「深度」,若要知道這個函式是在做什麼,除非它的名稱非常明瞭,否則就必須閱讀它的說明文件,如果沒有就得找原始碼來看。現代編輯器大部分都帶有提示與跳轉功能,因此這麽做並不會造成太多閱讀上的困難。但一般編輯器也會帶有程式碼折疊的功能,只要在複雜的操作前加上註解,再把它折疊起來就能達到相同或更好的效果。縮排層數一般代表流程控制的層數,太多層的流程控制的確會使理解程式邏輯更困難,然而直接拿縮排層數判斷是不準確的,有時我們只是想要控制變數作用範圍而使用區塊,它雖然會增加一層縮排,但反而會減少流程控制複雜度。透過一些技巧,例如及早返回(early return)或是改變條件判斷的順序,可以減少層數,這麽做的確是好的。然而使用物件導向設計時很多人會傾向使用多型把判斷狀態與操作交給多型實現(polymorphism > if/switch),這種做法多了一層抽象,方法不應根據這種狀況而增加。


把程式碼片段抽取成函式時,需要把它使用到的變數轉為參數傳進去,或是實作成物件的方法,讓它自動獲取成員變數的存取權。第一種方法可能會讓參數列表太長,這不是好事;第二種方法依賴於物件導向的支援,而且不適用於關係到區域變數時。如果這些操作牽涉到變數的修改,就更不應該抽取成函式。函式應該要把副作用封裝在內部,否則會增加隱性狀態。另外把它抽取成函式需要把相關的上下文也包含進去,也就是說明這個函式應該在怎樣的情境下使用,否則你的同事可能會誤會它的用途。當你把它抽取成函式之後,如果不是私有函式,就不應該再改變它的定義與使用情境,而這非常不利於修改。如果你沒有遵守這點,當你突然想要修改這部分的一些操作邏輯而修改了這個函式的一點定義,其他使用這個函式的程式碼很有可能就會因此出錯。這種抽取函式的目的本質上就違反了依賴抽象的原則。最好的方法是直接定義在方法內部,如此一來只有這個方法能存取,因此我們也不需說明使用情境或是太過在意是否依賴抽象,這在Haskell很常看到(where clause)。如果你只是為了減少縮排而抽取函式,這種函式的情境通常都非常特定,或是你在寫的時候認為有泛用性,但沒有意識到你利用了當下情境的一些假設。就算你有理解這點,應該也沒有人會給他寫這麽複雜的說明註解,畢竟你只是為了減少縮排層數。


就算他有一個明確的抽象語義,也不一定有抽取成函式的價值,只有當他足夠簡單或是很重要,才應該抽取成函式。過多過於細緻的函式也會增加開發者的負擔,這會讓使用者需要記一堆非常特例的用法與概念,就只為了讀懂一段程式碼。我們不應該擔心縮排層數的問題,我們更應該擔心程式邏輯與概念的可理解性,我們應該盡量避免「概念污染」,過多的概念會增加整個系統的理解難度。設計程式時應該以讓他人能更容易理解而設計(除非你是為了優化效能而設計),因此適度地把一些相近的概念模糊化成一個統一的概念是好的,就像Lua把陣列和字典融合成table一樣(雖然我不認為這麽做是好的)。如果概念就是這麼複雜,應該考慮將這些概念封裝在內部以隱藏複雜度。就像一些排序演算法會用到排序網路的概念,然而使用排序函式時我們不需要理解它是什麼,因為它被封裝在內部了。


函式在函數式編程中是重用程式碼的唯一方法,而物件導向還可以使用繼承「重用」資料結構。一些函數式程式語言也能做到類似繼承資料結構的效果,但一般都是使用組合。繼承重用的是一套運行架構,而不只是一個執行程序。例如Stream類別是一套能進行讀寫的運行架構,我們可以透過它重用讀寫的整套邏輯,例如讀取指定長度的資料,或是Stream的狀態管理。它定義了一套運作規則,覆寫方法時都應遵守這套規則,其他方法會根據你覆寫的方法實現更複雜的操作。這種重用整個架構的方法可以看作是由繼承者提供一套互相關聯的函式,並透過組合這些基礎函式做出更進階的函式,因此那些可覆寫的方法(輸入)跟不可覆寫的方法(輸出)有結構上的不同。這種做法需要以類別作為輸入與輸出函式的載體。在函數式編程中,則是直接提供進階函式,但必須透過trait/typeclass作為輸入基礎函式的載體。它並不像類別把所有輸出函式都搜集起來到一個地方,因此看起來比較鬆散,但你仍然可以自己放到模組或命名空間裡面。這種風格用物件導向的介面也做得到,差別在於它更具彈性,把繼承類別改成實作介面能讓物件可以有更多能力。


定義函式時應該盡量避免帶有副作用(side effect)。在這裡副作用指的是函式會對「環境」造成影響或是被影響,也就是對這個函式的呼叫會執行某種操作,而不只是進行某種計算。常見的副作用包含存取外部變數、印出字串或讀取使用者的輸入、或是與外部設備的互動。副作用會產生隱性的依賴,使得看似無關的程式碼片段隔空互動,這會讓重構程式碼變得困難。就算有辦法明確地判斷函式是否有哪些副作用,也應該盡量避免。除非你明確知道這些函式是怎麼以副作用互動的,一般來說是不可能進一步重構程式碼的。例如當你想要把loop用iterator pattern重構,如果裡面的操作包含大量的變數修改,將會難以將它們分解成個別的map操作。例外機制也是類似的情況。有些程式語言的函式預設會丟出例外,而且還沒有辦法知道它會丟哪些例外。這使得例外處理變得非常麻煩,你必須祈禱說明文件有這部分的描述。就算有也不一定準確,如果它使用你提供的函式(例如comparator),但這個函式丟出了例外,這時會怎麼樣?就算Java要求必須明確地標示函式會丟出哪些例外,也應該少用例外機制。問題在於呼叫函式時預設不會處理例外,從語法上無法區別這時有沒有例外會被拋出,當除錯時發現沒執行到某行程式碼是因為前面呼叫的函式拋出例外,這常常讓人感到被背叛。比較好的做法應該是回傳執行狀態,讓使用者明確地判斷狀態後再進行下一步操作。這種做法比起例外機制繁雜一點,常常需要對每個函式都進行個別例外處理。然而例外機制的「預設正確」的行為就像是null pointer的問題,這只是透過掩蓋問題來簡化邏輯。有些程式語言甚至會濫用例外機制來控制流程,例如Python可以透過StopIteration, GeneratorExit跳出generator的程序,然而這根本不是例外,官方文件也說明它技術上不是例外。例外機制不應被當作能回傳多種狀態的方法而濫用。

3會員
23內容數
這不是教你如何從物件導向到函數式編程的入門教程。我會深入探討物件導向與函數式編程的差異,並討論為什麼你應該使用函數式編程並徹底放棄物件導向。
留言0
查看全部
發表第一個留言支持創作者!
have bear的沙龍 的其他內容
物件導向是什麼
閱讀時間約 3 分鐘
少用繼承,多用介面
閱讀時間約 6 分鐘
定義資料類型而非類別
閱讀時間約 10 分鐘
悅耳的類型系統
閱讀時間約 11 分鐘