cv.cm

编码 · 编程语言

Haskell 入门教程

阅读约 10 分钟

简短回答

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
类型示例说明
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。

函数、守卫与模式匹配

函数调用用空格书写,不用括号: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 额外提供精选的包快照。初学者用哪个都可以。

全部编码教程