4.2 函子与应用函子:不拆箱的变换


4.2 函子与应用函子:不拆箱的变换

本节摘要:函子(Functor)回答一个问题:对"装着值的容器",如何在不拆箱的前提下把箱内变换做了?应用函子(Applicative)把这个问题推进一阶:函数本身也在箱里、或者有多个箱时怎么办?本节给出两者的类型签名、三条定律与手写实现,并完成抽象阶梯的下半段——为下一节 Monad 的登场铺好每一级踏板。

从一个似曾相识的操作开始

你已经写过很多次"对容器里的每个值做变换":列表 map、Optional.map、Stream.map、Promise.then。它们散落在不同语言不同 API 里,长得却不像巧合。函子就是这批操作的数学统一:一个类型若定义了 fmap 操作并满足两条定律,它就是一个函子。没有继承、没有接口继承树、没有设计模式——一个函数加两条定律,这就是函子的全部家当。

class Functor f where fmap :: (a -> b) -> f a -> f b -- 读作:给一个从 a 到 b 的函数,和一个"装着a的 f 容器", -- 返回"装着b的 f 容器"。容器形状不动,内容被映射。

f a 这种写法第一眼陌生,拆开就平常了:把类型看成"箱子",f 是箱子种类,a 是箱内货品。Maybe Int 是"可能装着一个 Int 的箱子",[String] 是"装着一堆 String 的箱子"。fmap 的承诺:不管箱内有多少货、什么结构,变换后箱子还是那种箱子

一、手写三个函子:列表、Maybe、函数本身

-- 实现一:列表函子(就是 map) instance Functor [] where fmap _ [] = [] fmap g (x:xs) = g x : fmap g xs -- 实现二:Maybe 函子——空箱直通,有货才变换 instance Functor Maybe where fmap _ Nothing = Nothing fmap g (Just x) = Just (g x) -- 实现三:最令人意外的函子——函数自身 -- 把 (r ->) 看成箱子:"带着环境 r 的计算" instance Functor ((->) r) where fmap g h = g . h -- fmap 就是函数组合!

第三个实现值得停下来看:fmap g h = g . h——函子在你没意识到的层面已经用了很多年,Bash 里的管道、电子表格的公式重算、React 的组件映射,本质上都是"结构保持的内容变换"。函子不是要学的新东西,是给已有直觉起的名字。

两条定律保证 fmap 语义端正:恒等定律 fmap id = id(原样映射什么也不变),复合定律 fmap (g . h) = fmap g . fmap h(先复合再映射与先映射再复合等价)。定律的价值在测试与推理:它们是属性测试的免费用例生成器(第 7 章兑现),也是"这个 API 敢不敢放心组合"的合同条款。

二、应用函子:函数也在箱里的时候

fmap 有一个天花板:它只能处理"单个裸函数"。两个场景立刻露怯——函数有两个参数(add :: Int -> Int -> Int,fmap 只喂得进一个);值分散在两个箱里(Just 3Just 4 想相加)。应用函子补上这块:新增 pure(把裸值装箱)与 <*>(从箱里取出函数来用):

class Functor f => Applicative f where pure :: a -> f a -- 裸值装箱 (<*>) :: f (a -> b) -> f a -> f b -- 箱中函数 作用于 箱中值 -- 手写 Maybe 的实例 instance Applicative Maybe where pure = Just Nothing <*> _ = Nothing -- 任一箱空,结果即空 (Just g) <*> (Just x) = Just (g x) -- 两个箱里的数相加:多参数提升一气呵成 addLifted :: Maybe Int -> Maybe Int -> Maybe Int addLifted a b = pure (+) <*> a <*> b main = do print (addLifted (Just 3) (Just 4)) -- Just 7 print (addLifted (Just 3) Nothing) -- Nothing —— 任一缺失即传播 print (addLifted Nothing (Just 4)) -- Nothing

pure (+) <*> a <*> b 的读法值得手推一遍:+ 装箱后先吃 a 得到"装着部分应用的加法"的箱,再吃 b 得到结果箱——柯里化(第 3.2 节)与函子(本节)在此会师:多参数函数被逐个参数逐步提升。这个手法叫"提升"(lifting),应用函子的正式名字 Applicative 正由此而来。列表作为应用函子时行为是"笛卡尔积"——每个函数配每个值,这给了非确定性计算的免费建模:

-- 列表应用函子:函数列表 × 值列表 = 全部组合 results = [(+1), (*10)] <*> [1, 2, 3] -- [2,3,4, 10,20,30] -- 实战:从多个可选配置源合成最终配置 config :: Maybe String -> Maybe Int -> Maybe Bool -> Maybe String config host port tls = render <$> host <*> port <*> tls where render h p t = h ++ ":" ++ show p ++ if t then " (安全)" else "" -- 三个源任一缺失,结果整体缺失 —— 无一处 if None 手写检查

render <$> host <*> port <*> tls 是应用式代码的典型长相:n 个可能缺失的值,n-1 个 <*> 串起来,缺失自动传播,一行检查代码都不用写。对比手写嵌套 if 的等价物,抽象的红利实实在在。

图:抽象阶梯——从裸值到 Monad 的四级踏板

图:抽象阶梯——从裸值到 Monad 的四级踏板

三、何时函子够用,何时需要升级

一张判别表收束本节。值在箱里、函数裸奔——fmap 足够(把箱内金额都乘汇率);函数与值都在箱里、或多个箱要合并——应用函子(合成三源配置);下一步算什么取决于上一步的结果——只能 Monad(下一节的正主)。升级的判断不是口味问题,而是表达力边界:应用函子的 <*> 无法表达"第二个箱依赖第一个箱的内容",因为它拿不到箱内的裸值。这条边界的严格论证在下一节实验三里给出。

💡 关键直觉:函子与应用函子是"结构保持"的两档精度。档位越低(函子),能保证的性质越多、能表达的计算越少;档位越高(Monad),表达力越强、推理难度越大。选型的艺术是用够用的最低档——满篇 Monad 的代码库,多半有一半该降档。

本节要点回顾

  • 函子=一个fmap加两条定律:容器形状保持、内容逐个变换;列表、Maybe、函数都是函子。
  • 两条定律即合同:恒等与复合定律支撑等式推理,也是属性测试的免费用例。
  • 应用函子补上多参数:pure 装箱加双箭头星号逐参数提升,n 缺一即整体缺失。
  • 柯里化与函子会师:多参数函数的逐步提升就是柯里化在类型层面的重演。
  • 最低档够用原则:能 fmap 就别用应用函子,能用应用函子就别用 Monad,抽象降档即减税。

阶梯还剩最后一级。下一节用三个亲手实验拆解 Monad 的 bind——包括最神秘的 IO——你会看到它比传说中简单得多,也比传说中深刻得多。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U