cv.cm

編碼 · 程式語言

Haskell 入門教學

閱讀約 10 分鐘

簡答

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
型別範例說明
Int42固定大小的整數(在常見平台上,GHC 為 64 位元)
Integer2 ^ 100任意精度整數
Double3.1464 位元浮點數
BoolTrue, 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 則多了精選的套件快照。初學者用哪個都可以。

全部編碼教學