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

Functor 与 Applicative:抽象的台阶

上一章留下两个没解开的结:嵌套 case 金字塔,和「想收集全部校验错误却只能短路」。解开它们需要爬两级台阶。好消息:第一级你其实早就站上去了——fmap 就是你用了十年的 map,只是「列表」二字被抠掉了;第二级 Applicative 解决一个很具体的尴尬:函数本身也掉进了盒子里怎么办。名字都是数学家起的,东西都是工程里长出来的。爬完这两级,第 10 章的 Monad 只剩一小步。

fmapFunctor 定律Applicative校验累积

Functor:把 map 的「列表」抠掉

你对 map 的理解是「对列表每个元素做变换」。现在做一个思维动作:把「列表」换成「上下文」——一个装着值的东西,可能是列表(装零到多个),可能是 Maybe(装零或一个),可能是 Either e(装一个或一个错误)。「保持上下文结构不变,只变换里面的值」这个能力,就是 Functor:

class Functor f where
  fmap :: (a -> b) -> f a -> f b

instance Functor Maybe where
  fmap _ Nothing  = Nothing        -- 空盒子:保持空
  fmap g (Just x) = Just (g x)     -- 有货:拆开、变换、装回

instance Functor [] where
  fmap = map                        -- 列表的 fmap 就是老朋友
λ> fmap (+1) (Just 41)
Just 42
λ> fmap (+1) Nothing
Nothing
λ> fmap length (Right "hello") :: Either String Int
Right 5
λ> (+1) <$> Just 41        -- <$> 是 fmap 的中缀写法,高频出场
Just 42

注意 class 头部的 f:它不是普通类型,是类型构造子——「还差一个参数才成为类型」的东西。Maybe 不是类型,Maybe Int 才是;Maybe 本身是个「类型级函数」。类型的类型叫 kind:普通类型的 kind 是 *,Maybe 的 kind 是 * -> *。Functor 是对类型构造子的抽象——这是 Java/Kotlin 泛型做不到的一步(它们没有高阶类型变量,这就是为什么 Kotlin 里写不出统一的 Functor 接口,Arrow 只能用编译期戏法模拟)。

λ 源头碑 · Functor 定律:抽象的质保书

合法的 Functor 实例必须满足两条:fmap id = id(变换恒等函数,结构原封不动)和 fmap (g . h) = fmap g . fmap h(两次遍历可以熔成一次)。定律不是学究趣味,是重构许可证:第二条保证你把 list.map(f).map(g) 优化成 list.map(f andThen g) 永远安全——GHC 的 fusion 优化、你手工合并两个 map 的每一次重构,靠的都是它。一个不守定律的「map」(比如顺手把列表排了个序)会让所有依赖定律的重构悄悄变错——这就是为什么 Haskell 社区对定律近乎洁癖。

▸ 下游对照 · 你集齐了多少 Functor

Kotlin/Java 的 Optional.mapList.mapResult.map,TS 的 Array.map、Promise 的 then(只算半个,后述),Rust 的 Option::map/Result::map——全是 Functor 实例,只是每家单独发明、接口名恰好一致。上游的区别是一个 class 统一签约:写 Functor f => ... 的代码,自动对所有盒子生效,包括未来才发明的盒子。这就是高阶类型的红利:抽象「容器性」本身。

卡壳时刻:函数掉进了盒子里

fmap 拿双参函数试试,看看哪里卡住:

λ> :t fmap (+) (Just 3)
fmap (+) (Just 3) :: Num a => Maybe (a -> a)

没报错,但看类型:得到一个 Maybe (a -> a)——盒子里装了半个应用了一半的函数(柯里化在此显形)。现在想把它用到 Just 4 上,fmap 无能为力:它的签名要求裸函数 a -> b,而我们的函数在盒子里。缺的那块拼图,签名一写出来就明白了:

(<*>) :: f (a -> b) -> f a -> f b     -- 盒装函数,应用到盒装值
pure  :: a -> f a                       -- 把裸值放进最平凡的盒子

这两个函数(加上 Functor 基础)构成 Applicative。连起来用,「盒子里做多参调用」的完整句式诞生:

λ> (+) <$> Just 3 <*> Just 4
Just 7
λ> (+) <$> Just 3 <*> Nothing
Nothing
λ> (,,) <$> Just 1 <*> Just 2 <*> Just 3     -- 三参也一样,链下去就行
Just (1,2,3)

读法从左到右:「用 (+) 去消化 Just 3 和 Just 4」。任何一环 Nothing,整体 Nothing——短路语义和上一章的铁路一致,但注意形状差异:铁路是串行依赖(下一站的输入是上一站的输出),这里是并列独立(三个盒子互不依赖,只是最后合体)。这个差异马上兑现成真实价值。

真实代码:表单校验的 Applicative 风格

data User = User { name :: Name, age :: Age, email :: Email }

validateName  :: String -> Either VError Name
validateAge   :: String -> Either VError Age
validateEmail :: String -> Either VError Email

mkUser :: Form -> Either VError User
mkUser f = User <$> validateName  (rawName f)
                <*> validateAge   (rawAge f)
                <*> validateEmail (rawEmail f)

三个独立校验,一行组装,类型严丝合缝:User 的构造子是 Name -> Age -> Email -> User,顺着 <$><*> 一路部分应用下去。这个句式在真实 Haskell 代码库里的出场率极高——JSON 解码、配置解析、命令行参数,凡是「多个独立来源拼一个结构」的地方全是它。

那么上一章泅渡第 4 题——收集全部错误——解决了吗?诚实回答:标准 Either 的 Applicative 还是短路的(第一个 Left 就返回)。但 Applicative 的形状已经把「并列独立」表达出来了,剩下只差换一个合并策略:社区的 Validation 类型(validation 包)与 Either 结构相同,唯独 <*> 遇到两个失败时不短路,而是用 Semigroup 把错误拼起来(第 6 章的 <> 在此上岗)。同一行 mkUser 代码,换个类型就从「报第一个错」变成「报全部错」——策略在类型里,不在业务代码里。这就是抽象台阶的意义:句式统一后,语义可以整体替换。

traverse:Applicative 的批量形态

上一章末尾的 sequence(把 [Maybe a] 翻成 Maybe [a])可以升级了。实战里你很少先造出 [Maybe a] 再翻转——通常是「拿一列原料,逐个做可能失败的加工,想要整批结果」。一步到位的函数叫 traverse:

traverse :: Applicative f => (a -> f b) -> [a] -> f [b]
--          「逐个加工,把所有盒子合并成一个装着列表的盒子」

λ> traverse validateAge ["18", "25", "30"]
Right [18,25,30]                       -- 全部成功,整批放行
λ> traverse validateAge ["18", "abc", "30"]
Left (BadAge "abc")                    -- 一个失败,整批失败

看签名里的 f:它只要求 Applicative——所以同一个 traverse,f 换成 Maybe 是「批量判空」,换成 Either 是「批量校验」,换成 IO 是「批量执行动作收集结果」(它就是你可能听过的 mapM),换成上文的 Validation 就是「批量校验且累积全部错误」。一个函数,吃遍所有盒子——这是高阶类型抽象的又一笔分红,也是 Haskell 老手口中「traverse 是标准库最好用的函数」的原因。Kotlin 的 Arrow 里它叫 traverse/traverseEither,Rust 里是 collect::<Result<Vec<_>,_>>() 的那记妙手——你见过它的马甲。

▸ 下游对照 · Promise.then 为什么只算半个

JS 的 promise.then(f) 看着像 fmap,但它有个著名的怪癖:如果 f 返回的又是 Promise,then 会自动帮你「拍平」——fmap 和 flatMap 被焊死成一个方法,Promise<Promise<T>> 在语言里不可表达。方便吗?方便。代价是你失去了「嵌套上下文」这个表达力,而且没法给 Promise 写守定律的 Functor 实例。Kotlin 协程的 async/await、Swift 的同款,都做了类似取舍。上游把 fmap 和后面第 10 章的 flatMap 严格分开,不是洁癖——是两个操作真的不同:一个保结构,一个塌缩结构。分开,才谈得上定律,才谈得上重构安全。

⚠ 暗礁 · 名字劝退,形状不劝退

Applicative 全名 applicative functor(可应用函子),论文 2008 年才定型,是三层抽象里最年轻的。学它最大的障碍是名字和 <*> 的长相。两个自救技巧:其一,把 f <$> a <*> b <*> c 整体当成「盒子世界的 f(a, b, c)」——句式记住,单个符号的玄学自然消解。其二,遇到读不懂的算子链,回第 3 章的办法::t 逐段问类型,九成疑惑当场蒸发。

◆ 水文记录 · 三层塔的全景(预告)

现在可以画出峡谷的等高线了:Functor(fmap:盒里的值可变换)⊂ Applicative(还能把盒装函数应用到盒装值,盒子可并列合并)⊂ Monad(第 10 章:后一步可以依赖前一步的结果,盒子可串行)。表达力递增,约束递减,能优化的空间也递减——所以社区的习惯是够用就往低处站:独立组装用 Applicative,真有依赖才上 Monad。这个「能力分级」的设计品味,同样值得带回你的语言。

✚ 动手泅渡

1. 在第 2 章终端验证:zipWith (+) [1,2,3] [10,20,30] 和「列表当盒子」的 (+) <$> [1,2,3] <*> [10,20,30] 结果为什么不同?(提示:列表的 Applicative 是笛卡尔积——每种组合都试一遍。)

2. 给第 4 章的 data Tree a = Leaf | Node (Tree a) a (Tree a) 手写 Functor 实例,并口头验证两条定律。

3. 用 Applicative 句式写:从环境变量读 host(String)和 port(Int)拼一个 Config,两个读取都返回 Either EnvError

4. traverse 认亲:在 Kotlin/Java 里手写过「循环调用可失败函数、遇错即返、否则收集结果」的代码吗?把那段代码找出来,标注它对应 traverse 的哪个实例化。

5. 思考:fmap fmap fmap 是合法表达式吗?别急着跑,先用第 3 章的方程法推推看,再去推断台对答案。(这题纯属娱乐,推不出来不影响毕业。)