编码 · 编程语言
Haskell 入门教程
简短回答
Haskell 是一门纯函数式、静态类型的语言,带有类型推断和惰性求值。程序由纯函数、代数数据类型和模式匹配构成,副作用则通过 IO 之类的类型在类型中体现。本文涵盖安装 GHC、类型、函数与守卫、列表与高阶函数、数据类型、类型类、Maybe 和 Either,以及一个完整示例。
Haskell 是什么,用来做什么
Haskell 是一门纯函数式编程语言,拥有强大的静态类型系统、类型推断和惰性求值。它由一个研究者委员会设计,1990 年首次定义;当前标准是 Haskell 2010,主要实现是 GHC(Glasgow Haskell Compiler)。在 Haskell 中,除非类型声明了副作用,否则函数没有副作用,因此程序更容易推理和测试。
Haskell 常见于金融与金融科技、编译器和编程语言工具(Pandoc 和 ShellCheck 代码检查工具都是用 Haskell 写的)、区块链项目、Web 后端和学术研究。也有很多人因为它的思想而学习它:代数数据类型、类型类和纯函数都影响了 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。
函数、守卫与模式匹配
函数调用用空格书写,不用括号:add 2 3。每个函数只接收一个参数,并返回另一个函数来接收其余参数,这一特性称为柯里化,所以 add 2 本身就是一个函数。模式匹配和守卫可以取代大多数 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 用户指南。
- 包仓库 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 额外提供精选的包快照。初学者用哪个都可以。