STATION III · 峡谷CH 11里程 11/20

IO:纯语言怎么干脏活

第 1 章立了个 flag:Haskell 的函数不能打日志、不能发请求、不能读文件。一门碰不了现实世界的语言是个数学玩具——而 Haskell 显然能写 Web 服务、能驱动 Meta 的反垃圾系统。矛盾怎么解的?答案漂亮到值得专门一章:副作用没有被禁止,而是被「降格为值」。这个降格顺手送了你一样东西:一条由编译器看守的、纯与不纯之间的国境线。全书最值钱的架构课,就长在这条线上。

IO amain配方与执行纯核心脏外壳

心智模型:IO a 是配方,不是行动

先看类型,再建模型:

getLine  :: IO String        -- 一份「执行后会产出 String」的配方
putStrLn :: String -> IO ()  -- 吃一个字符串,给你一份「执行后会打印它」的配方
main     :: IO ()            -- 你的整个程序:一份大配方

IO String 读作「一个执行后会给出 String 的行动方案」。关键区分:值是配方,执行是另一回事。写下 putStrLn "hi" 不会打印任何东西——它只是构造了一个描述「打印 hi」的值,和写下 42 一样无害、一样纯:同样的输入(那个字符串)永远给出同样的配方。这个值可以存进变量、塞进列表、当参数传,全程什么都不发生:

greetings :: [IO ()]
greetings = [putStrLn "早", putStrLn "午", putStrLn "晚"]   -- 三份配方躺在列表里,零副作用

那谁来执行?运行时只执行一份配方:main。你的程序要做的所有事,必须组装进 main 这份总配方——「函数不执行副作用,只是把描述副作用的值层层拼装,最后由系统一次性执行」。组装的工具,正是上一章的 >>= 和 do:

main :: IO ()
main = do
  putStrLn "报上名来:"
  name <- getLine                     -- 把「执行 getLine 的产出」绑给 name
  putStrLn ("你好," ++ name ++ "!")  -- ++ 是纯函数,在 IO 块里随便用

IO 的 >>= 语义是「先执行左边,把产出喂给右边,接着执行右边」——顺序,在纯惰性语言里靠数据依赖重新发明了出来:下一步的配方依赖上一步的产出,想乱序都不行。这就是 Wadler 在 1990 年代初用 Monad 解开的死结:惰性语言没有语句顺序,但 bind 链有依赖顺序——在此之前 Haskell 的 IO 用的是别扭的「惰性响应流」方案,do 记法一出,旧方案整体退役。

为什么取不出来:单向的国境线

新手最常问:「我就想要那个 String,怎么把它从 IO String 里拿出来?」答案:没有 IO a -> a 这个函数,故意的。看两边的通行规则:

-- ✓ 纯值进 IO:随便进(fmap/do 里直接用)
fmap length getLine :: IO Int

-- ✗ IO 值出来变纯值:此路不通
-- badLength :: Int
-- badLength = length (getLine ???)     -- 不存在这种写法

如果存在逃生门,灾难是连锁的:一个「纯」函数偷偷读了文件,它的结果就不再可缓存(第 7 章的共享立刻变成 bug 源)、不再可并行重排、不再能被 GHC 优化。所以国境线必须单向:纯的世界可以被 IO 引用,IO 的痕迹永远洗不掉——一个函数只要碰过 IO,它的返回类型就带着 IO,它的所有调用者也被迫带上 IO,一路「污染」到 main。听起来像诅咒,实际是标记:任何函数干不干脏活,签名一眼可见,靠语言保证、不靠代码评审。

▸ 下游对照 · 你见过这条国境线的仿制品

Kotlin 的 suspend fun 是最像的:suspend 函数只能被 suspend 函数调用,「悬挂性」沿调用链向上传染,普通函数想调必须经过显式的桥(runBlocking/launch)——把 suspend 换成 IO、runBlocking 换成 main,规则几乎重合。区别在覆盖面:suspend 只标记「可能挂起」,不拦 println 和全局变量;IO 标记一切副作用。TS 世界的 Effect、Kotlin 的 Arrow,都在把这条线往各自语言里搬——方向一致:副作用要出现在类型上

全书最值钱的一课:纯核心,脏外壳

国境线存在,架构问题就变成:线画在哪。Haskell 社区三十年的答案只有一句话:把 IO 压到系统最外层薄薄一圈,把所有业务逻辑做成纯函数。看一个真实形状的小工具——统计日志文件里每种错误码的次数:

-- ══ 纯核心:全部业务逻辑,一滴 IO 都没有 ══
parseLine   :: String -> Maybe ErrorCode
tally       :: [ErrorCode] -> [(ErrorCode, Int)]
formatTable :: [(ErrorCode, Int)] -> String

analyze :: String -> String                    -- 核心的总装:纯函数进,纯函数出
analyze = formatTable . tally . mapMaybe parseLine . lines

-- ══ 脏外壳:main 只干三件事——读、调、写 ══
main :: IO ()
main = do
  raw <- readFile "app.log"
  putStr (analyze raw)

数一数比例:五个函数,四个纯的,IO 只住在 main 的两行里——真实的 Haskell 服务大体维持这个形状:handler 层薄薄的 IO,底下是大片纯逻辑的湖。这个比例带来的红利,每一条都能折算成工时:测试——analyze 的测试不需要 mock 文件系统,给字符串断言字符串,第 13 章的 QuickCheck 还能自动造一万个输入;推理——core 里任何函数,看签名就知道它不会突然发请求,重构时不用「顺藤摸瓜查副作用」;复用——同一个 analyze,明天接 HTTP 接口、后天接消息队列,核心零改动,换个外壳即可。

这个架构在下游有个响亮的名字:functional core, imperative shell(Gary Bernhardt 2012 年的著名演讲让它出圈)。你其实见过它的变体:Android 的「ViewModel 持逻辑、Repository 管 IO」、六边形架构的「领域核心 + 端口适配器」、Redux 的「纯 reducer + effect 中间件」——同一个思想在不同社区的转世。区别又是那个词:默认值。在 Kotlin 里维持这个架构靠自律,任何一个组员在 domain 层写个 println 编译器都不吭声;在 Haskell 里,类型签名就是架构的执行者——纯核心里想发请求,签名立刻变成 IO,代码评审还没开始,编译器先亮牌了。

配方是值,所以控制流是库

「IO 是值」还有一层没兑现的红利:值可以被函数加工,所以「控制流」可以自己写。在多数语言里,重试、超时、循环是语法或框架的事;这里它们是普通函数:

retry :: Int -> IO a -> IO a          -- 吃一份配方,吐一份「带重试的配方」
retry 1 action = action
retry n action = action `catch` \e ->
  do putStrLn ("失败,再来。剩余 " ++ show (n-1) ++ " 次")
     retry (n - 1) action

main = retry 3 (fetchUrl "https://flaky.example.com")

细品 retry 的类型:它把「一个动作」变换成「另一个动作」,和 map 把列表变列表没有本质区别——控制流成了数据加工。标准库里这类「动作组合子」俯拾皆是:forever(无限循环)、when/unless(条件执行)、mapM_(批量执行)、replicateM(重复 n 次收集结果),没有一个是关键字,全是普通函数。你在 OkHttp 里配的 Interceptor、在 Resilience4j 里配的 RetryPolicy,就是这个思想的框架化——上游告诉你:当动作是一等公民,这类框架的核心只剩十行。

顺着这个思路,还有个极致的小玩具值得认识——interact :: (String -> String) -> IO ():给它一个纯函数,它把标准输入整个喂进去、把返回值写到标准输出。于是「整个程序」可以是一行外壳加一个纯函数:

main = interact (unlines . map process . lines)   -- 逐行处理的 CLI 工具,骨架就这一行

这是纯核心脏外壳的极限形态:外壳薄到只剩一个单词。配合第 7 章的惰性,它还天然是流式的——输入一行处理一行,百万行日志不用先读进内存。

资源管理也是同一思路的受益者。「打开-使用-保证关闭」在 Java 里是 try-with-resources 语法,在这里是一个函数 bracket 打开 关闭 使用——因为「使用」阶段是一份可以被传入的配方,「无论如何都执行关闭」的保证可以由库统一实现,而不必每处手写 finally。Kotlin 的 use {} 扩展函数已经领会了这层:资源安全是高阶函数问题,不是语法问题。

◆ 水文记录 · unsafePerformIO:国境线上的走私通道

诚实档案:逃生门其实存在,叫 unsafePerformIO :: IO a -> a,名字里的 unsafe 是血泪认证——用它等于向编译器谎报「这是纯的」,共享、内联、重排等所有基于纯度的优化会立刻把你的副作用执行零次或 N 次,时机随缘。它的合法用途极窄(FFI 绑定、全局只读缓存这类「道德上纯」的场景),业务代码里出现即事故。知道它存在的意义:明白 Haskell 的纯不是物理定律而是全社区共同维护的合同——以及,合同之所以有效,恰恰因为几乎没人违约。

⚠ 暗礁 · 两个真实工程坑

其一,String 干重活会很慢:String 是字符链表(第 4 章你见过它的真身),读大文件按字符链遍历,内存和速度都惨。生产代码文本用 Text、二进制用 ByteString,是 Haskell 工程第一条军规(第 17 章展开)。其二,惰性 IO 的时序陷阱:老式 readFile 是惰性的——文件句柄开着,内容按需才读;若句柄先被关了、内容还没被强迫,就会读到一半炸掉。现代实践直接用 Text/ByteString 的严格 readFile 绕开整类问题。两个坑记下即可,不影响本章的架构结论。

✚ 动手泅渡

1. 分类练习:当前时间SHA-256 哈希读取环境变量正则匹配生成 UUID——哪些该是纯函数,哪些必须 IO?有没有「看场景」的?(提示:随机数给了种子还随机吗?)

2. 把 greetings 列表「全部执行一遍」需要一个 [IO ()] -> IO () 的函数。它在标准库里叫 sequence_/mapM_——用第 5 章的 fold 思维想想它内部长什么样。

3. 架构审计:翻开你手头项目的某个核心业务类,数一数其中「纯的方法」和「碰 IO 的方法」(网络/DB/时间/随机/日志都算)。如果给每个碰 IO 的方法名后缀加个「IO」,类的接口会变成什么样?哪些方法其实可以纯化?

4. 组合子设计:写出 timeout :: Seconds -> IO a -> IO (Maybe a) 的语义(不用实现):为什么返回类型要套 Maybe?再设计 withRetryAndTimeout——两个组合子怎么叠?哪种叠法先超时后重试,哪种先重试后超时?

5. 思考:为什么 main 的类型是 IO () 而不是 IO Int 或干脆 ()?三个选项各自意味着什么?