編碼 · 程式語言
Haskell 入門教學
簡答
Haskell 是一種純函數式、靜態型別的語言,具備型別推論與惰性求值。程式由純函式、代數資料型別與模式比對組成,副作用則透過 IO 等型別在型別層級被追蹤。本頁涵蓋安裝 GHC、型別、函式與 guard、串列與高階函式、資料型別、型別類別、Maybe 與 Either,以及一個完整範例。
Haskell 是什麼、可以用來做什麼
Haskell 是一種純函數式程式語言,擁有強大的靜態型別系統、型別推論與惰性求值。它由一群研究人員組成的委員會設計,於 1990 年首度定義;目前的標準是 Haskell 2010,主要的實作則是 GHC(Glasgow Haskell Compiler)。在 Haskell 中,除非型別明確標示,否則函式不會有副作用,因此程式更容易推理與測試。
Haskell 應用於金融與金融科技、編譯器與程式語言工具(Pandoc 與 ShellCheck 靜態檢查工具都是用 Haskell 寫的)、區塊鏈專案、網頁後端與學術研究。許多人學它,也是因為代數資料型別、型別類別與純函式等概念,影響了 Rust、Swift、Scala、Kotlin 與 TypeScript。
安裝 GHC 並執行 Hello, world
建議使用 GHCup 安裝,它會一併裝好 GHC、建置工具 Cabal 與 Stack,以及供編輯器使用的 Haskell Language Server。每個程式都有一個型別為 IO () 的 main 動作。
-- hello.hs
main :: IO ()
main = putStrLn "Hello, world!"runghc hello.hs # run without compiling
# Hello, world!
ghc hello.hs && ./hello # compile to a native executable
ghci # interactive REPL; load a file with :load hello.hs註解以 -- 開頭。在 GHCi 中,:type expression(或 :t)可以顯示任何運算式的型別,是學習 Haskell 最好的方式之一。
值、型別與型別推論
名稱只會綁定一次,之後不會改變:Haskell 沒有可變變數。型別簽章以 :: 撰寫,是選擇性的,因為編譯器能自行推論,但替頂層定義加上型別簽章是良好習慣。
name :: String
name = "Haskell"
year :: Int
year = 1990
ratio :: Double
ratio = fromIntegral year / 1000 -- convert Int to Double explicitly
main :: IO ()
main = do
putStrLn (name ++ " " ++ show year) -- Haskell 1990
print ratio -- 1.99
print (7 `div` 2, 7 `mod` 2) -- (3,1)
print (2 ^ 64 :: Integer) -- 18446744073709551616| 型別 | 範例 | 說明 |
|---|---|---|
Int | 42 | 固定大小的整數(在常見平台上,GHC 為 64 位元) |
Integer | 2 ^ 100 | 任意精度整數 |
Double | 3.14 | 64 位元浮點數 |
Bool | True, False | 建構子首字母大寫 |
Char | 'h' | 一個 Unicode 字元 |
String | "text" | 由 Char 組成的串列 [Char];Data.Text 速度更快 |
show 會把值轉成 String,而 print 就是 putStrLn . show。數字不會被隱含轉換,請使用 fromIntegral、round 或 truncate。
函式、guard 與模式比對
呼叫函式時用空格而非括號:add 2 3。每個函式都只接收一個引數,並回傳另一個函式來處理其餘引數,這項特性稱為柯里化(currying),所以 add 2 本身就是一個函式。模式比對與 guard 可以取代大部分的 if 串接。
add :: Int -> Int -> Int
add x y = x + y
factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)
grade :: Int -> Char
grade score
| score >= 90 = 'A'
| score >= 70 = 'B'
| otherwise = 'C'
describe :: [a] -> String
describe [] = "empty"
describe [_] = "one item"
describe _ = "many items"
main :: IO ()
main = do
print (add 2 3) -- 5
print (map (add 10) [1, 2, 3]) -- [11,12,13] (partial application)
print (factorial 20) -- 2432902008176640000
print (grade 75) -- 'B'
putStrLn (describe "hi") -- many items
print (if even 4 then "even" else "odd") -- "even"if 是運算式,而且一定要有 else。Haskell 沒有迴圈:重複的工作要靠遞迴,或是 map、filter、foldr 等高階函式來完成。
串列、元組與高階函式
串列存放同一種型別的值,是最常見的集合。元組則把固定數量、型別可能不同的值組在一起。串列生成式、範圍與函式合成運算子 . 能讓資料處理寫得簡潔。
import Data.List (sortOn)
main :: IO ()
main = do
let nums = [5, 3, 8, 1] :: [Int]
print (head nums, length nums, sum nums) -- (5,4,17)
print (0 : nums) -- [0,5,3,8,1] (prepend)
print (filter even nums) -- [8]
print (foldr (+) 0 [1 .. 10]) -- 55
print [x * x | x <- [1 .. 5], odd x] -- [1,9,25]
print (take 5 [1, 3 ..]) -- [1,3,5,7,9] (lazy infinite list)
let people = [("Ann", 31), ("Bo", 25)] :: [(String, Int)]
print (map fst (sortOn snd people)) -- ["Bo","Ann"]
print ((length . filter (> 2)) nums) -- 3
print (zip [1 :: Int ..] "abc") -- [(1,'a'),(2,'b'),(3,'c')]由於 Haskell 是惰性的,值只在需要時才會計算,所以 take 5 [1, 3 ..] 才能對無限串列運作。惰性也可能累積出龐大且尚未求值的運算式;對長串列求和時,請優先使用 Data.List 的 foldl',而不是 foldl。
代數資料型別與型別類別
用 data 定義自己的型別。一個型別可以有多個建構子(和型別),每個建構子可帶有欄位(積型別)。Show、Eq、Ord、Functor 等型別類別描述共通行為,類似介面;deriving 可以自動產生常見的實例。
data Shape = Circle Double | Rect Double Double
deriving (Show, Eq)
area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rect w h) = w * h
data User = User { userName :: String, userAge :: Int }
deriving Show
class Describable a where
describe :: a -> String
instance Describable Shape where
describe (Circle _) = "a circle"
describe (Rect _ _) = "a rectangle"
main :: IO ()
main = do
print (map area [Circle 1, Rect 2 3]) -- [3.141592653589793,6.0]
putStrLn (describe (Rect 1 1)) -- a rectangle
let u = User { userName = "Ann", userAge = 31 }
print (u { userAge = 32 }) -- User {userName = "Ann", userAge = 32}Maybe、Either 與 IO
Haskell 沒有 null,純函式程式碼中也沒有未受檢查的例外。可能缺值的值型別為 Maybe a(Just x 或 Nothing),可能失敗並附帶原因的運算則是 Either e a(Left err 或 Right x)。讀取輸入等副作用則放在 IO 型別中,並以 do 記法串接。
import Text.Read (readMaybe)
safeDiv :: Int -> Int -> Either String Int
safeDiv _ 0 = Left "division by zero"
safeDiv x y = Right (x `div` y)
parseAge :: String -> Maybe Int
parseAge s = case readMaybe s of
Just n | n >= 0 -> Just n
_ -> Nothing
main :: IO ()
main = do
print (safeDiv 10 2) -- Right 5
print (safeDiv 1 0) -- Left "division by zero"
print (parseAge "42") -- Just 42
print (parseAge "abc") -- Nothing
print (fmap (+ 1) (Just 41)) -- Just 42
putStrLn "Your name?"
n <- getLine -- an IO action that returns a String
putStrLn ("Hi, " ++ n)完整的小程式:字詞頻率統計
這個程式從標準輸入讀取文字,用 Map 統計每個字詞出現的次數,並印出最常見的三個字詞。它用到函式合成、GHC 隨附的 containers 套件中的 Data.Map,以及排序。
import Data.Char (isAlpha, toLower)
import Data.List (sortBy)
import Data.Ord (comparing, Down (..))
import qualified Data.Map.Strict as Map
wordFreq :: String -> [(String, Int)]
wordFreq =
sortBy (comparing (Down . snd) <> comparing fst)
. Map.toList
. Map.fromListWith (+)
. map (\w -> (w, 1))
. words
. map (\c -> if isAlpha c then toLower c else ' ')
main :: IO ()
main = do
text <- getContents
mapM_ (\(w, n) -> putStrLn (w ++ ": " ++ show n)) (take 3 (wordFreq text))echo "The cat and the dog. The dog sat, and the cat ran." | runghc wordfreq.hs
# the: 4
# and: 2
# cat: 2接下來可以學什麼
- haskell.org 的文件頁面,內有教學、書籍與 GHC User's Guide 的連結。
- Hackage 套件庫,以及 Hoogle,可依名稱或型別搜尋函式的搜尋引擎。
- Learn You a Haskell for Great Good! 與 Real World Haskell,兩本可免費線上閱讀的書。
- Haskell 2010 Language Report,提供精確的語言定義。
常見問題
Haskell 很難學嗎?
它確實給人不太一樣的感覺,因為沒有可變變數與迴圈,且使用表達力很強的型別系統。只要循序漸進學會遞迴、模式比對與型別,初學者通常會發現核心語言其實小而一致。
純函數式是什麼意思?
函式對相同輸入永遠回傳相同結果,也不會改變狀態或進行輸入輸出。副作用仍然做得到,但會以 IO 等型別表達出來。
Haskell 的 monad 是什麼?
Monad 是一種型別類別,適用於 Maybe、Either、IO 這類支援以 >>= 串接運算的型別。do 記法就是這種串接的語法糖。
什麼是惰性求值?
運算式只在需要其值時才會求值。這讓無限串列成為可能,也避免不必要的運算,但記憶體用量會比較難預測。
該用 Stack 還是 Cabal?
兩者都能建置 Haskell 專案,也都能透過 GHCup 安裝。Cabal 是標準建置工具;Stack 則多了精選的套件快照。初學者用哪個都可以。