Haskell 简明教程
上一章的 Functor 可以把单参数函数映射到上下文中的值上。现在考虑下面的情况:
有一个两个参数的函数 add :: Int -> Int -> Int,以及两个包在 Maybe 里的值 Just 2 与 Just 3。如何把它们相加?
先试试 fmap:
ghci> :t (+) <$> Just 2
(+) <$> Just 2 :: Num a => Maybe (a -> a)
结果是一个包在 Maybe 里的函数。Functor 没有办法把上下文中的函数应用到另一个上下文中的值上。解决这个问题的是应用函子(Applicative Functor)。
定义在 Control.Applicative 中:
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
pure:把一个普通值放进该上下文的最小环境中。
<*>(有时被叫作 tie-fighter):接收一个“上下文中的函数”,把它应用到“上下文中的值”上,得到新的上下文结果。
把 <$>(即 fmap)与 <*> 连起来用,就能把任意多参数的普通函数提升到上下文里:
ghci> (+) <$> Just 2 <*> Just 3
Just 5
ghci> (+) <$> Just 2 <*> Nothing
Nothing
无论目标函数有三个、四个还是更多参数,依次用 <*> 串联即可:
data Profile = Profile String Int String deriving (Show)
buildProfile :: Maybe String -> Maybe Int -> Maybe String -> Maybe Profile
buildProfile mName mAge mEmail =
Profile <$> mName <*> mAge <*> mEmail
小技巧
如果你熟悉其他语言:把 Applicative 理解为 Applyable / Zippable / Combinable:
为什么 Applicative 不好起名:Functor 对应 map,Monad 对应 flatMap,而 Applicative 的核心操作是“把一个装在盒子里的函数应用到装在盒子里的值上”(f (a -> b) -> f a -> f b)。日常语言里没有现成的动词描述这个动作,所以社区会用 Applyable、Zippable 或 Combinable 来称呼它。
Elm 的动词化方案:andMap 与 map2:Elm 不使用类型类名称,而是把 Applicative 的用法提炼成动词:
核心心智:把两个或多个“装在独立盒子里”的值凑到一起。
Functor 只能处理单个盒子的映射(map);如果有多个包在上下文里的参数(例如用户的名字、年龄、邮箱分别被 Maybe 包装),就需要把它们一起装配到一个多参数构造函数中。
Elm 用 andMap 把多个盒子串起来:Ok Profile |> andMap maybeName |> andMap maybeAge,这与 Haskell 的 Profile <$> maybeName <*> maybeAge 结构相同。
跨语言映射:
JavaScript:类似 Promise.all([p1, p2])。各个异步任务彼此独立,没有先后依赖,可以并发发起,最后把各自的返回值聚合起来。
Java:类似 CompletableFuture.allOf() 或二元合并操作 thenCombine()。
与 Monad 的区别:
Applicative(多个独立盒子):类似表单批量校验或并行依赖图,各个计算彼此独立,结构在运行前就确定。
Monad(前后依赖的盒子):类似 async/await,第 2 步的计算依赖第 1 步的返回值,整个流程无法在执行前确定。
把普通的二元或三元函数直接提升为在 Applicative 上下文中运算的函数:
liftA2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
liftA2 f x y = f <$> x <*> y
在有副作用的操作中,经常需要依次执行两个动作,但只关心其中一个的返回值:
(*>) :: Applicative f => f a -> f b -> f b:执行双方的效果,只保留右侧的值。
(<*) :: Applicative f => f a -> f b -> f a:执行双方的效果,只保留左侧的值。
ghci> Just "Hello" *> Just "World"
Just "World"
ghci> Nothing *> Just "World"
Nothing
普通列表的 Applicative 实例表示所有可能组合的笛卡尔积:
ghci> [(+ 1), (* 2)] <*> [10, 20]
[11,21,20,40]
ghci> (,) <$> ["A", "B"] <*> [1, 2]
[("A",1),("A",2),("B",1),("B",2)]
如果希望像 zipWith 一样把第 i 个函数作用于第 i 个元素,可以使用 Control.Applicative 提供的 ZipList:
ghci> import Control.Applicative
ghci> getZipList $ ZipList [(+ 1), (* 2)] <*> ZipList [10, 20]
[11,40]
通过 newtype 包装,同一个列表类型可以拥有两种不同的 Applicative 行为。
合法的 Applicative 实例需要满足以下四条法则:
同一律(Identity):
pure id <*> v == v
同态律(Homomorphism):先在纯值上应用函数再放入上下文,等价于分别放入上下文后再应用:
pure f <*> pure x == pure (f x)
交换律(Interchange):把上下文中的函数应用于纯值,等价于把“应用该值”这个操作放入上下文后作用于函数:
u <*> pure y == pure ($ y) <*> u
复合律(Composition):函数复合在 Applicative 上下文中保持不变:
pure (.) <*> u <*> v <*> w == u <*> (v <*> w)
这些法则保证效果的组合方式有确定的语义,使用者可以据此做重构和推理。
一个常见的问题是:既然下一章的 Monad 更通用,为什么还需要 Applicative?
区别在于:Applicative 的各个子计算彼此独立,效果在运行前就已知;而 Monad 的后续计算依赖前一步的返回值。
在表单验证中,如果用 Monad 或普通的 Either 做链式验证,用户名校验一失败,计算就会短路退出,用户每次提交只能看到一个错误。
利用 Applicative 各子计算相互独立的特点,可以一次性验证所有字段,并用 Semigroup(例如列表拼接)把所有错误收集起来:
data Validation err a
= Failure err
| Success a
deriving (Show, Eq)
instance Functor (Validation err) where
fmap _ (Failure err) = Failure err
fmap f (Success a) = Success (f a)
instance Semigroup err => Applicative (Validation err) where
pure = Success
-- 两边都失败时,用 (<>) 合并两个错误
Failure e1 <*> Failure e2 = Failure (e1 <> e2)
Failure e1 <*> _ = Failure e1
_ <*> Failure e2 = Failure e2
Success f <*> Success a = Success (f a)
验证一下:
data User = User String Int deriving (Show)
checkName :: String -> Validation [String] String
checkName name = if null name then Failure ["用户名不能为空"] else Success name
checkAge :: Int -> Validation [String] Int
checkAge age = if age < 18 then Failure ["年龄必须满 18 岁"] else Success age
-- 同时输入非法名字与非法年龄:
ghci> User <$> checkName "" <*> checkAge 15
Failure ["用户名不能为空","年龄必须满 18 岁"]
两个字段的错误都被收集到了结果里。这依赖于 Applicative 各效果之间的独立性,用 Monad 是做不到的。
Applicative 解决了 Functor 无法应用上下文中的函数的问题。
习惯写法 f <$> x <*> y 把多参数函数提升到上下文中。
列表有笛卡尔积和 ZipList 两种 Applicative 实例。
四条法则(同一、同态、交换、复合)规定了效果组合的语义。
Applicative 的各子计算彼此独立,适合批量验证与并发这类场景。