無所不在的函式
January 30, 2022在〈初探變數與函式〉中談過如何定義函式,也談過 Haskell 鼓勵定義函式的型態,有留意到我的用語是「定義函式的型態」嗎?
一級函式
函式會有型態?這表示 Haskell 的函式是個值?是的!或許該拜 JavaScript 熱潮之賜,函式作為一級(First-class)值的概念,不少開發者都很熟悉了,也就是說,跟 1
、3.14
、"Justin"
這些值一樣,函式也可以當作值,指定給另一個變數或作為引數傳遞,例如,將 doubleMe
函式指定給 doubleThis
:
doubleMe :: Num a => a -> a
doubleMe x = x + x
main = do
let doubleThis = doubleMe
putStrLn (show (doubleMe 3.14)) -- 顯示 6.28
putStrLn (show (doubleThis 3.14)) -- 顯示 6.28
這邊也可以看到,若要撰寫註解,在 Haskell 可使用 --
。程式中呼叫了 doubleMe
或 doubleThis
的作用是一樣的;putStrLn
只接受 String
,如果你直接將 Num
傳給 putStrLn
會發生編譯錯誤。show
函式的型態是 show :: Show a => a -> String
,也就是如果傳入具有 Show
行為的值,它會傳回 String
,因此,在這邊將 doubleMe 3.14
的結果傳給 show
,然後才能用 putStrLn
顯示結果。
最低優先權的 $ 函數
上頭看到了 putStrLn (show (doubleMe 3.14))
的寫法,括號可以用來定義運算式的優先順序,如果直接寫 putStrLn show doubleMe 3.14
的話會有問題,Haskell 會從左往右執行,putStrLn
看到 show
,會將 show
這個函式當作引數,不過,show
並不接受函式作為引數,因此會編譯錯誤。
只是,像這樣使用括號,形成了巢狀的結果並不好閱讀,你可以試著使用 $
函式來改善可讀性。$
是個接受兩個引數的函式,第一個引數是個單參數函式,第二個引數可以是任意值,$
就像 +
、-
、*
、/
這些函式一樣是中序形式,表面上看來,就像是用右邊的值來呼叫左邊的函式。例如:
ghci> putStrLn $ "Justin"
Justin
ghci>
用右邊的值來呼叫左邊的函式?這算什麼?還不如直接寫 putStrLn "Justin"
就好了!中序形式的函式是作為運算子使用,既然是中序,就會有結合律與優先權的問題,例如,*
的優先權高於 +
,因此 1 + 2 * 3
結果是 7
而不是 9
。
撰寫 putStrLn $ show (1 + 2)
時,$
左右會是其引數,然而 $
是右結合,優先權為 0,因為執行順序最低,不會先取 putStrLn
與 show
來執行,而是先處理 show (1 + 2)
。
如果不想寫 putStrLn $ show (1 + 2)
,進一步地,可以寫 putStr $ show $ 1 + 2
,因為 $
是右結合,1 + 2
會先處理,然後是 show 3
,結果再丟給 putStrLn
,putStr $ show $ 1 + 2
看來好寫、好讀一些,就結果而言,執行順序變成從右往左了。
因此,putStrLn (show (doubleMe 3.14))
,可以先在最外層右邊括號旁放上一個 $
、拿掉括號變成 putStrLn $ show (doubleMe 3.14)
,再來同樣在右邊括號旁放上一個 $
、拿掉括號變成 putStrLn $ show $ doubleMe 3.14
,最後,上頭的 main
可以改為:
doubleMe :: Num a => a -> a
doubleMe x = x + x
main = do
let doubleThis = doubleMe
putStrLn $ show $ doubleMe 3.14 -- 顯示 6.28
putStrLn $ show $ doubleThis 3.14 -- 顯示 6.28
不過,也不是用了 $
可讀性就會變好,而要要適當地搭配 $
與括號,找到可讀性的平衡點。
中序形式
之前一直談到,Haskell 的 +
、-
、*
、/
都是函式,都是接受兩個引數,可以使用中序形式呼叫的函式,自定義的函式也可以使用中序形式呼叫,只要用 ` 來括住函式,例如定義一個兩數相加的函式:
ghci> plus a b = a + b
ghci> plus 10 5
15
ghci> 10 `plus` 5
15
ghci>
如果在自定義函式時,也想使用中序形式定義,若函式名稱是字母組成,可以使用 ` 來括住函式,呼叫時也是使用 ` 來括住函式:
ghci> a `plus` b = a + b
ghci> 10 `plus` 20
30
ghci>
如果是函式名稱是由符號組成,定義函式或呼叫時,可以直接使用中序形式,例如:
ghci> a % b = a `mod` b
ghci> 10 % 3
1
ghci> :info %
(%) :: Integral a => a -> a -> a -- Defined at <interactive>:19:3
infixl 9 %
ghci>
由符號組成的中序函式,是用來定義中序運算子,既然如此,就會有結合律的問題,若沒有指定,預設會是左結合,優先權為 9,如果需要改變優先權,可以使用 infixl
、infix
、infixr
,分別用來設定左結合、無結合、右結合優先權。例如:
infixr 0 @
x @ [] = x:[]
x @ xs = x:xs
如果將 infixr 0 @
拿掉,預設會是左結合,就必須使用括號來表明順序,例如 3 (@ 2 @ (1 @ []))
,若有設定 infixr 0 @
表示右結合,就可以直接寫 3 @ 2 @ 1 @ []
。
多參數函式
方才定義的 plus
函式省略了函式宣告,Haskell 會試著推斷出合適的型態,它是什麼形態?
ghci> :t plus
plus :: Num a => a -> a -> a
ghci>
可以看到 plus
的形態是 Num a => a -> a -> a
,嗯?a -> a -> a
是什麼?
稍後才會談到,Haskell 的函式可以部份套用(partially applied),現在可以先知道的是,最後一個是傳回型態,之前的就是參數型態了,因此 Num a => a -> a -> a
表示會有兩個參數與一個傳回值。而且都是具 Num
行為的型態。
根據以上說明,可以來檢驗一下 +
、-
、*
、/
各函式的型態:
ghci> :t (+)
(+) :: Num a => a -> a -> a
ghci> :t (-)
(-) :: Num a => a -> a -> a
ghci> :t (*)
(*) :: Num a => a -> a -> a
ghci> :t (/)
(/) :: Fractional a => a -> a -> a
ghci>
對於自訂定義的函式,可以使用 ` 轉為中序形式,相對地,對於 Haskell 既有的中序形式函式,可以使用 ()
取得,這也用來將中序形式轉為前序形式。例如:
ghci> (+) 1 2
3
ghci> (-) 4 4
0
ghci> (*) 5 6
30
ghci> (/) 7 8
0.875
部份套用
多參數函式其實是由多個單參數函式組成,這到底是什麼意思?舉例來說,如果如下定義了函式:
plus :: Num a => a -> a -> a
plus x y = x + y
使用 plus 10 20
呼叫結果會是 30
,使用 (add 10) 20
的方式呼叫,結果也是 30
,幹嘛多此一舉地使用括號?你也可以如下方式來呼叫:
ghci> let plus10 = (plus 10)
ghci> plus10 20
30
ghci> :t plus10
plus10 :: Num a => a -> a
ghci>
範例中 (plus 10)
看似沒有提供 plus
完整的引數,然而有傳回東西,你令 plus10
為 (plus 10)
的傳回值,這個傳回值顯然是個函式,因為可以使用 plus10 20
來呼叫,使用 :t
來檢驗 plus10
,確實是個可接受 Num
並傳回 Num
的函式。
這種沒有對多參數函式套用全部引數而取回的函式,稱為部份套用函式,對於 plus
而言,可以將型態 a -> a -> a
看成或寫成是 a -> (a -> a)
,也就是接受一個引數,然後傳回一個 a -> a
的函式,這也就是為何使用 :t
檢驗 plus10
時,結果會是 Num a => a -> a
的原因,plus10
的效果其實相當於:
plus10 :: Num a => a -> a
plus10 y = 10 + y
有些語言的函式,需要特定的語法或程式庫處理,才能將多參數函式轉換為多個單參數函式組合而成的函式,這類轉換被稱為 currying。
Haskell 的函式是由多個單參數函式組合而成,因此可以隨時依需求從既有函式產生新函式,例如,因為 +
、-
、*
、/
等都是函式,想要有個 plus10
,只要如下進行就可以了,因為 (+ 10)
傳回一個函式:
ghci> let plus10 = (+ 10)
ghci> plus10 20
30
對於 +
、*
、/
等運算,都可以使用 (+ 10)
這樣的方式來得到新函式,-
是個例外,因為 (- 10)
會被 Haskell 當成負 10
,可以使用 ((-) 10)
,或者 subtract 10
得到一個減 10
的函式。
高階函式
如果函式可以接受函式作為引數,或者是傳回函式,或者兩者皆有,這種函式被稱為高階函式(High-order function),因此,任何 Haskell 任何多參數函式,都是高階函式了,在其他語言中層級高一層的高階函式,在 Haskell 其實很普通。
不過,高階函式真正重要的是其應用方式,例如,有些執行流程經常重複,因而將共用流程抽取出來,不能共用的部份,可以指定傳入函式。
舉例而言,想對一個數字清單,過濾出超過 3
的清單,你會走訪各元素,然後對各數字比對是否大於 3
,是的話收集到新的清單中,在另一個需求中,也會想對一個數字清單,比對出是否小於 5
,你會走訪各元素,然後對各數字比對是否小於 5
,是的話收集到新的清單中 …
單是在上頭的描述中,就會察覺得重複的文字描述了,實際上,我也只是複製然後改改幾個字,就完成第二個需求描述,也就是只將「大於 3
」改為「小於 5
」罷了,如果這個運算是可以傳遞的那麼「你也會想對一個數字清單,比對出是否 ???,你會走訪各元素,然後對各數字比對是否 ???,是的話收集到新的清單中 」就可以封裝為一個函式了。
在 Haskell 中,已經有這個函式,叫做 filter
,例如:
ghci> filter (> 3) [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[4,5,6,7,8,9,10]
ghci> filter (< 5) [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[1,2,3,4]
ghci> :t filter
filter :: (a -> Bool) -> [a] -> [a]
ghci>
>
、<
等也都是函式,(> 3)
建立了一個函式,(< 5)
也建立了一個函式,filter
的第二個參數接受函式,第三個函式接受一個清單,Haskell 中可以使用 []
來建立清單,filter
封裝了走訪各元素的流程,這樣你就可以只指定比對條件。
filter
是個高階函式,(a -> Bool)
是第一個參數接受的型態,也就是一個函式,第二個參數接受 [a]
清單,傳回值也是 [a]
清單。
如果只是想要一個可以比對大於 3
的函式,那該怎麼辦呢?可以如下定義:
biggerThanThree :: (Ord a, Num a) => [a] -> [a]
biggerThanThree xs = filter (> 3) xs
在函式的形態定義部份,[a]
表示一個清單,元素都要是 a
形態,a
必須具備 Num
的行為,而且 a
也要有 Ord
的行為,因為 Ord
定義了順序比較的行為,為什麼需要這個呢?因為 >
就是要比較順序!
初學 Haskell 時,如果不知道怎麼定義函式的型態,可以試著先不用定義:
biggerThanThree xs = filter (> 3) xs
若以上撰寫在 util.hs,在 ghci
載入模組,使用 :t
測試一下:
ghci> :l test
[1 of 1] Compiling Main ( test.hs, interpreted )
Ok, one module loaded.
ghci> :t biggerThanThree
biggerThanThree :: (Ord a, Num a) => [a] -> [a]
ghci>
可以透過這個方式,使用 Haskell 推斷出的形態作為你定義函式型態時的參考。
實際上,此時 xs
可以省略,直接令 biggerThanThree
為 filter (> 3)
就可以了:
biggerThanThree :: (Ord a, Num a) => [a] -> [a]
biggerThanThree = filter (> 3)
這稱為 Point free 或 Pointless 風格,這類風格想強調的,是在面對這類需求時,可以思考如何組合既有函式產生新函式來滿足需求。
舉例來說,Haskell 中有個 map
,如果有個清單 [10, 20, 30, 40, 50]
,想對每個都加上 5
後傳回新清單,那就 map (+ 5) [10, 20, 30, 40, 50]
,傳回結果就是 [15, 25, 35, 45, 55]
;如果只是要個可以對清單全部加 5
的函式呢?那就是組合 map
與 (+ 5)
了,map (+ 5)
就是你想要的:
ghci> let plusFiveTo = map (+ 5)
ghci> plusFiveTo [1, 2, 3, 4, 5]
[6,7,8,9,10]
ghci> plusFiveTo [10, 20, 30, 40, 50]
[15,25,35,45,55]
ghci>