第十章:应用函子(Applicative Functors)
应用函子的应用
应用函子的名称在其函数式起源中具有精妙的描述性。函数式程序员以创造诸如mappend或liftA4这类名字而闻名——在数学实验室的视野下显得浑然天成,但在其他场景里却如同在得来速餐厅(快餐店的汽车穿梭通道)犹豫不决的达斯·维达般含糊其辞。
无论如何,这个名称应当直白地揭示了接口的能力:允许函子彼此应用。
那么,像您这样的理性人士为何需要这种特性?将两个函子相互应用究竟有何意义?
为解答这些疑问,让我们从一个函数式编程中可能遇到的场景入手。假设我们有两个同类型函子,想要用它们包含的值作为参数调用函数。例如将两个「Container」的值相加这种简单操作。
// We can't do this because the numbers are bottled up.
add(Container.of(2), Container.of(3));
// NaN
// Let's use our trusty map
const containerOfAdd2 = map(add, Container.of(2));
// Container(add(2))现在我们有一个包含部分应用函数的「Container」。更具体地说,我们拥有一个「Container(add(2))」,并希望将它的「add(2)」应用于「Container(3)」中的「3」以完成调用。换言之,我们需要将一个函子应用于另一个函子。
我们实际上已具备实现这个任务的工具。可以通过「chain」而后「map」来操作部分应用的「add(2)」:
Container.of(2).chain(two => Container.of(3).map(add(two)));问题根源在于我们陷入了单子的顺序执行世界——必须等待前一个单子完成才能继续。面对两个独立的值时,仅为满足单子的顺序需求而延迟「Container(3)」的创建并不合理。
实际上,若能简洁地将某个函子的内容应用于另一个函子的数值,免去不必要的函数与变量,将是完美解决方案——特别是当我们深陷此类困境时。
瓶中之舟

「ap」是能够将一个函子的函数内容应用于另一个函子值的函数(请试着快速重复五次这个定义)。
Container.of(add(2)).ap(Container.of(3));
// Container(5)
// all together now
Container.of(2).map(add).ap(Container.of(3));
// Container(5)如此这般,简洁优雅。这对「Container(3)」无疑是好消息——它从嵌套单子函数中得以解脱。值得注意的是,在这个案例中「add」必须在第一次「map」时完成柯里化处理才能生效。
我们可以这样定义「ap」:
Container.prototype.ap = function (otherContainer) {
return otherContainer.map(this.$value);
};请记住,「this.$value」本身是函数,因此只需对目标函子进行「map」操作。由此得出接口定义:
应用函子是带有「ap」方法的点状函子(Pointed Functor)
注意对点状的依赖。正如我们将在后续示例中看到的,点状接口在此至关重要。
此刻我察觉到您的犹疑(或是困惑与惊愕),但请保持开放心态:这个「ap」将展现其实用性。在深入探讨前,让我们先观察一个重要特性。
F.of(x).map(f) === F.of(f).ap(F.of(x));规范表述中,映射「f」等同于对蕴含「f」的函子执行「ap」。更准确地说:我们可以将「x」放入容器后进行「map(f)」,或者将「f」与「x」都提升至容器后执行「ap」。这让我们可以左到右地链式调用来书写代码:
Maybe.of(add).ap(Maybe.of(2)).ap(Maybe.of(3));
// Maybe(5)
Task.of(add).ap(Task.of(2)).ap(Task.of(3));
// Task(5)这种写法眯眼细看时,其形态甚至与常规函数调用有异曲同工之妙。本章稍后我们会探讨无点版本,但目前这是推荐写法。通过「of」将值置入容器组成的平行宇宙后,「ap」即可在此奇幻领域(支持异步或空值处理的场景)应用函数——犹如在瓶中搭建帆船。
注意到我们示例中使用的是「Task」了吗?这正是应用函子发挥实力的典型场景。让我们深入探讨一个案例。
协同激励
假设我们正在构建旅游网站,需要同时获取旅游目的地列表与本地活动信息,这两项数据来自独立的API接口。
// Http.get :: String -> Task Error HTML
const renderPage = curry((destinations, events) => { /* render page */ });
Task.of(renderPage).ap(Http.get('/destinations')).ap(Http.get('/events'));
// Task("<div>some page with dest and events</div>")两个「Http」调用将并行执行,「renderPage」在两者都完成后触发。与之相反的单子版本需要顺序执行,而此处无需等待目的地获取完成后再查询活动,因此实现了并行自由。
由于使用了柯里化处理来实现这个结果,必须保证「renderPage」经过柯里化处理才能正确等待两个「Task」。这种接口的惊人简洁性会使曾手动处理此类问题的人如获至宝,这正是通往技术奇点的美代码典范。
再看另一个示例:
// $ :: String -> IO DOM
const $ = selector => new IO(() => document.querySelector(selector));
// getVal :: String -> IO String
const getVal = compose(map(prop('value')), $);
// signIn :: String -> String -> Bool -> User
const signIn = curry((username, password, rememberMe) => { /* signing in */ });
IO.of(signIn).ap(getVal('#email')).ap(getVal('#password')).ap(IO.of(false));
// IO({ id: 3, email: 'gg@allin.com' })「signIn」是接收三个参数的柯里化函数,因此需要对应三次「ap」调用。每次「ap」调用后,「signIn」接收一个参数直至完成执行。另外需注意:前两个参数已自然存在于「IO」中,最后一个则需要通过「of」提升到「IO」——因为「ap」要求函数和所有参数处于同一类型。
兄弟,您会提升吗?
让我们探索这些应用式调用的无点写法。已知「map」等同于「of/ap」,可编写通用函数根据参数数量执行「ap」操作:
const liftA2 = curry((g, f1, f2) => f1.map(g).ap(f2));
const liftA3 = curry((g, f1, f2, f3) => f1.map(g).ap(f2).ap(f3));
// liftA4, etc「liftA2」这名称略显怪异,听起来像破旧工厂里挑剔的货运电梯,或是廉价礼宾车公司的虚荣车牌。但一旦理解其含义便不言自明:将这些元素提升(lift)到应用函子的领域。
初看2-3-4这种命名会让人觉得既冗余又丑陋。毕竟在JavaScript中我们可以动态检测函数参数个数来构建行为。但部分应用「liftA(N)」本身具有独特价值,因此参数长度需固定。
实际应用示例如下:
// checkEmail :: User -> Either String Email
// checkName :: User -> Either String String
const user = {
name: 'John Doe',
email: 'blurp_blurp',
};
// createUser :: Email -> String -> IO User
const createUser = curry((email, name) => { /* creating... */ });
Either.of(createUser).ap(checkEmail(user)).ap(checkName(user));
// Left('invalid email')
liftA2(createUser, checkEmail(user), checkName(user));
// Left('invalid email')由于「createUser」接收两个参数,我们使用对应的「liftA2」。两种写法等价,但「liftA2」版本无需提及「Either」,从而提升泛用性与灵活性。
用此范式重构前例:
liftA2(add, Maybe.of(2), Maybe.of(3));
// Maybe(5)
liftA2(renderPage, Http.get('/destinations'), Http.get('/events'));
// Task('<div>some page with dest and events</div>')
liftA3(signIn, getVal('#email'), getVal('#password'), IO.of(false));
// IO({ id: 3, email: 'gg@allin.com' })运算符系统
在Haskell、Scala、PureScript和Swift等支持自定义中缀运算符的语言中,可见如下语法:
-- Haskell / PureScript
add <$> Right 2 <*> Right 3// JavaScript
map(add, Right(2)).ap(Right(3));了解「<$>」代表「map」(即「fmap」)、「<*>」表示「ap」很有帮助。这种表达更贴近自然函数应用风格,也能减少括号用量。
免费开罐器

我们没有过多讨论派生函数。鉴于这些接口相互构建并遵循系列定律,我们可根据强力接口来定义弱功能。
例如,已知应用函子首先是函子,因此若已拥有应用式实例,自然可为类型定义函子。
这种完美的计算和谐性源自数学框架下的操作。即便莫扎特幼年曾用Ableton创作音乐,也难以企及这般完美。
前文提及「of/ap」等同于「map」,可利用此关系免费定义「map」:
// map derived from of/ap
X.prototype.map = function map(f) {
return this.constructor.of(f).ap(this);
};可以说单子处于类型生态链顶端,因此若存在「chain」,我们可免费获得函子与应用函子:
// map derived from chain
X.prototype.map = function map(f) {
return this.chain(a => this.constructor.of(f(a)));
};
// ap derived from chain/map
X.prototype.ap = function ap(other) {
return this.chain(f => other.map(f));
};若可定义单子,则能派生应用式与函子接口。这非常了不起——所有开罐器都可免费获得。我们甚至可以检视类型来自动完成这个过程。
需要指出的是,「ap」的部分吸引力在于并行运行任务的能力,而通过「chain」定义会丧失这种优化。尽管如此,在寻求最佳实现方案时,拥有即用型接口仍有价值。
您或会问:为何不完全依赖单子?根据所需能力层级选择工具才是良策。通过限制多余功能来最小化认知负荷,这就是优先选择应用函子而非单子的理由。
单子凭借其深层嵌套结构,具备串行计算、变量分配及执行中止的独特能力。当使用应用函子时,我们无需关心这些机制。
现在,让我们进入法律篇章...
定律体系
与其他数学构造类似,应用函子具备日常编码可依仗的有用属性。首先需要理解:应用函子对组合运算闭合(closed under composition),即「ap」不会改变容器类型(这也是优选应用式而非单子的另一个理由)。这并不意味着我们不能叠加不同类型的影响——通过堆叠类型设计,我们可确保其在应用过程中保持一致性。
实证如下:
const tOfM = compose(Task.of, Maybe.of);
liftA2(liftA2(concat), tOfM('Rainy Days and Mondays'), tOfM(' always get me down'));
// Task(Maybe(Rainy Days and Mondays always get me down))看,无需担心不同类型混入其中。
现在审视最喜欢的范畴律:同一性
同一性
// identity
A.of(id).ap(v) === v;在函子中应用「id」不应改变值「v」。例如:
const v = Identity.of('Pillow Pets');
Identity.of(id).ap(v) === v;「Identity.of(id)」的写法因其无效性让我忍俊不禁。有趣的是如前所述,「of/ap」等同于「map」,因此本定律直接继承自函子同一律:「map(id) == id」。
这些定律犹如恪守军规的幼儿园教练,强制所有接口协调运作。
同态性
// homomorphism
A.of(f).ap(A.of(x)) === A.of(f(x));同态即结构保持映射。事实上,函子可视为范畴间的同态——其在映射下保持原范畴结构。
我们实际上只是将普通函数和值填入容器,并在其中执行计算。因此无论将整个过程置于容器内执行(等式左侧),还是先在外运算再装入容器(右侧),结果都应相同,这一点不足为奇。
简单示例:
Either.of(toUpperCase).ap(Either.of('oreos')) === Either.of(toUpperCase('oreos'));交换律
交换律表明将函数提升至「ap」左边或右边操作等效。
// interchange
v.ap(A.of(x)) === A.of(f => f(x)).ap(v);示例:
const v = Task.of(reverse);
const x = 'Sparklehorse';
v.ap(Task.of(x)) === Task.of(f => f(x)).ap(v);组合律
最后是支持验证容器内函数组合正确性的组合律:
// composition
A.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));const u = IO.of(toUpperCase);
const v = IO.of(concat('& beyond'));
const w = IO.of('blood bath ');
IO.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));总结
应用函子的典型案例是需要处理多个函子参数的情形。其能力让我们得以在函子世界中应用函数参数。即使能用单子实现,在无需单子特有功能时仍应优先采用应用函子。
关于容器API的学习即将完成。我们已掌握如何「map」、「chain」,以及「ap」。下一章将学习如何优雅处理多个函子,并以原则性方法进行解构。
练习题
开始练习!
使用「Maybe」和「ap」编写一个函数,实现两个可能为空的数字相加。 // safeAdd :: Maybe Number -> Maybe Number -> Maybe Number const safeAdd = undefined;
开始练习!
将练习_b中的「safeAdd」改写为使用「liftA2」替代「ap」。 // safeAdd :: Maybe Number -> Maybe Number -> Maybe Number const safeAdd = undefined;
以下练习考虑如下辅助函数:
const localStorage = {
player1: { id:1, name: 'Albert' },
player2: { id:2, name: 'Theresa' },
};
// getFromCache :: String -> IO User
const getFromCache = x => new IO(() => localStorage[x]);
// game :: User -> User -> String
const game = curry((p1, p2) => `${p1.name} vs ${p2.name}`);开始练习!
编写一个IO操作,从缓存中获取players1和players2并启动游戏。 // startGame :: IO String const startGame = undefined;