無所不在的函式

January 30, 2022

在〈初探變數與函式〉中談過如何定義函式,也談過 Haskell 鼓勵定義函式的型態,有留意到我的用語是「定義函式的型態」嗎?

一級函式

函式會有型態?這表示 Haskell 的函式是個值?是的!或許該拜 JavaScript 熱潮之賜,函式作為一級(First-class)值的概念,不少開發者都很熟悉了,也就是說,跟 13.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 可使用 --。程式中呼叫了 doubleMedoubleThis 的作用是一樣的;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,因為執行順序最低,不會先取 putStrLnshow 來執行,而是先處理 show (1 + 2)

如果不想寫 putStrLn $ show (1 + 2),進一步地,可以寫 putStr $ show $ 1 + 2,因為 $ 是右結合,1 + 2 會先處理,然後是 show 3,結果再丟給 putStrLnputStr $ 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,如果需要改變優先權,可以使用 infixlinfixinfixr,分別用來設定左結合、無結合、右結合優先權。例如:

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 可以省略,直接令 biggerThanThreefilter (> 3) 就可以了:

biggerThanThree :: (Ord a, Num a) => [a] -> [a]
biggerThanThree = filter (> 3)

這稱為 Point freePointless 風格,這類風格想強調的,是在面對這類需求時,可以思考如何組合既有函式產生新函式來滿足需求。

舉例來說,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>

分享到 LinkedIn 分享到 Facebook 分享到 Twitter