Haskell 简明教程
图灵奖得主托尼·霍尔(Tony Hoare)把空指针(Null / Nil)称为计算机科学中“价值十亿美元的错误”。隐式的空值会破坏类型系统的可靠性,让运行时充满难以追踪的崩溃。
Haskell 没有 Null 引用,而是把“数据缺失”与“失败原因”表达为显式的、强类型的代数数据类型。
定义在 Prelude 中:
data Maybe a = Nothing | Just a deriving (Show, Eq)
Nothing:数据不存在。
Just a:包含一个有效的数据 a。
它就是上一章的和类型,基数是 |a| + 1;下面的 Either 同样是和类型。上一章说“非法状态在类型上无法表示”,用到“失败”这件事上就是这两个类型:函数可能失败,就让它的返回类型多出一个分支,而不是返回一个碰巧不能用的值。
警告
避免使用 fromJust:标准库中的 fromJust :: Maybe a -> a 遇到 Nothing 时会抛出异常,等于把 Null 引用又请了回来。应当用模式匹配、fromMaybe 或 maybe 代替。
操作失败时,Maybe 只能表达“失败了”,无法说明“为什么失败”。这时需要 Either:
data Either a b = Left a | Right b deriving (Show, Eq)
Left a:按惯例用于包裹错误信息。
Right b:用于包裹成功的结果(“Right”在英文中既有“正确”也有“右侧”的含义)。
小技巧
如果你熟悉其他语言:
Maybe a:
Rust:对应 Option<T>(Some(x) 与 None)。
Java:类似 Optional<T>。
Swift:对应可选类型 Optional<T>(语法糖 T?)。
Either a b:
Rust:对应 Result<T, E>(Ok(val) 对应 Right,Err(e) 对应 Left)。
Swift:对应 Result<Success, Failure>。
C++23:对应 std::expected<T, E>。
共同思路:这些语言都在转向这种错误处理模型:不再返回 -1、null 或直接抛出运行时异常,而是把“可能失败”写进函数的类型签名里。
Data.Maybe 和 Data.Either 里的辅助函数不多,与其逐个记,不如看它们在一段真实代码里各自出场的位置。任务是:从一份 [(String, String)] 形式的配置里取出端口号,转成数字,再检查范围。三步都可能失败,缺键、不是数字、超出范围。
最直接的写法是逐层 case:
import Text.Read (readMaybe)
config :: [(String, String)]
config = [("port", "8080"), ("workers", "abc"), ("timeout", "30")]
portV1 :: Int
portV1 =
case lookup "port" config of
Nothing -> 80
Just s -> case readMaybe s of
Nothing -> 80
Just n -> n
能工作,但默认值 80 写了两遍,每多一步就多一层缩进。Data.Maybe 的两个函数专门收拾这种代码:
maybe :: b -> (a -> b) -> Maybe a -> b -- Nothing 给默认值,Just 交给函数
fromMaybe :: a -> Maybe a -> a -- 只补默认值
import Data.Maybe (fromMaybe)
-- maybe 把“缺键”和“不是数字”折叠成同一个 Nothing
parsePort :: Maybe Int
parsePort = maybe Nothing readMaybe (lookup "port" config)
portV2 :: Int
portV2 = fromMaybe 80 parsePort
范围检查需要说明失败原因,换成 Either。either 是它的解构函数,两个分支各给一个处理函数,把结果统一成同一种类型:
either :: (a -> c) -> (b -> c) -> Either a b -> c
checkPort :: Int -> Either String Int
checkPort n
| n > 0 && n < 65536 = Right n
| otherwise = Left ("端口超出范围: " ++ show n)
report :: Either String Int -> String
report = either ("配置错误: " ++) (\n -> "监听端口 " ++ show n)
ghci> report (checkPort portV2)
"监听端口 8080"
ghci> report (checkPort 70000)
"配置错误: 端口超出范围: 70000"
批量处理时有两种态度。mapMaybe 跳过失败的项,只收集成功的;traverse 要求全部成功,否则整体失败。后者来自 Traversable 一章,这里先看效果:
ghci> import Data.Maybe (mapMaybe)
ghci> mapMaybe readMaybe ["10", "abc", "20"] :: [Int]
[10,20]
ghci> traverse readMaybe ["10", "abc", "20"] :: Maybe [Int]
Nothing
ghci> traverse readMaybe ["10", "20"] :: Maybe [Int]
Just [10,20]
还有几个一看名字就知道用途的函数:isJust、isNothing、catMaybes(丢掉列表里的 Nothing)、listToMaybe(安全地取列表首项)、maybeToList;Either 这边有 lefts、rights 和 partitionEithers(一次遍历把成功和失败分成两个列表)。
面对一个可能失败的函数,先问调用方需要知道什么,再决定返回类型:
调用方需要什么 |
用什么 |
说明 |
|---|---|---|
只需要知道有没有 |
|
缺失是正常情况而不是错误,如 |
需要一句原因,只用来打日志或显示 |
|
适合原型和小工具。第九章的 |
需要根据原因分别处理 |
|
调用方可以模式匹配,编译器检查是否漏了分支。见下一节。 |
多个可能失败的步骤混在 IO 里 |
|
避免每一步都手写 |
来自外部环境的故障 |
|
文件不存在、网络中断、磁盘满。这些错误不属于业务逻辑,在 IO 层用 |
程序本身的 bug,逻辑上不可能到达的分支 |
|
只用于“到了这里说明代码写错了”。预期内的失败(用户输入非法、记录不存在)不能用它们,否则调用方无从处理,类型签名也在撒谎。 |
String 之所以只适合原型,是因为调用方拿到 Left "端口超出范围: 70000" 后只能原样打印,无法可靠地判断是哪一种错误,更不能让编译器检查“每种错误都处理了”。换成自定义的和类型,这两件事都有了。
警告
注意纯代码中的 throw:
Haskell 允许在纯代码中调用 throw :: Exception e => e -> a。但由于惰性求值,抛出的异常不会在生成它的地方立即触发,而是藏在 Thunk 中,直到别处的代码真正求值该值时才会出现,那时已经很难追溯来源。
建议的做法是:纯函数只返回 Either 或 Maybe;需要抛异常时,在 IO 中使用 throwIO。
按上表的第三行,为业务定义专门的错误类型:
-- 领域错误类型
data ValidationError
= UsernameTooShort !Int
| AgeOutOfRange !Int
| InvalidEmailFormat !String
deriving (Show, Eq)
type ValidationResult a = Either ValidationError a
validateUsername :: String -> ValidationResult String
validateUsername name
| length name < 3 = Left (UsernameTooShort (length name))
| otherwise = Right name
validateAge :: Int -> ValidationResult Int
validateAge age
| age < 0 || age > 150 = Left (AgeOutOfRange age)
| otherwise = Right age
在 REPL 中验证:
ghci> validateUsername "Al"
Left (UsernameTooShort 2)
ghci> validateUsername "Alice"
Right "Alice"
ghci> validateAge (-5)
Left (AgeOutOfRange (-5))
两个校验单独看都很清楚,真正的问题出在把它们组合起来的时候:
data User = User { userName :: String, userAge :: Int } deriving (Show)
validateUser :: String -> Int -> ValidationResult User
validateUser name age =
case validateUsername name of
Left err -> Left err
Right validName -> case validateAge age of
Left err -> Left err
Right validAge -> Right (User validName validAge)
ghci> validateUser "Alice" 30
Right (User {userName = "Alice", userAge = 30})
ghci> validateUser "Al" 200
Left (UsernameTooShort 2)
Left err -> Left err 这样的分支每加一个字段就要再写一遍,内容完全一样:失败就原样往外传。这正是单子一章要解决的问题,用 do 记号写出来只剩三行。另外注意上面的组合在第一个错误处就停下了,"Al" 和 200 两个问题只报了一个;如果想把所有错误一次收齐,要用应用函子一章的 Validation。
Maybe 与 Either 只是普通的和类型,Haskell 用它们代替 Null 引用,把“可能失败”写进签名。
maybe 与 either 是标准解构函数,fromMaybe 补默认值,mapMaybe 跳过失败项,traverse 要求全部成功。
选类型先问调用方需要知道什么:只要有无用 Maybe,要原因用 Either,要分别处理就自定义错误类型,混在 IO 里的多步失败用 ExceptT,环境故障用异常,error 只留给 bug。
避免在纯代码中使用 fromJust 或 throw。
逐层 case 组合多个 Either 会产生重复的分支,单子一章消除它。