Skip to content

第十章:应用函子(Applicative Functors)

应用函子的应用

应用函子的名称在其函数式起源中具有精妙的描述性。函数式程序员以创造诸如mappendliftA4这类名字而闻名——在数学实验室的视野下显得浑然天成,但在其他场景里却如同在得来速餐厅(快餐店的汽车穿梭通道)犹豫不决的达斯·维达般含糊其辞。

无论如何,这个名称应当直白地揭示了接口的能力:允许函子彼此应用

那么,像您这样的理性人士为何需要这种特性?将两个函子相互应用究竟有何意义

为解答这些疑问,让我们从一个函数式编程中可能遇到的场景入手。假设我们有两个同类型函子,想要用它们包含的值作为参数调用函数。例如将两个「Container」的值相加这种简单操作。

js
// 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)」:

js
Container.of(2).chain(two => Container.of(3).map(add(two)));

问题根源在于我们陷入了单子的顺序执行世界——必须等待前一个单子完成才能继续。面对两个独立的值时,仅为满足单子的顺序需求而延迟「Container(3)」的创建并不合理。

实际上,若能简洁地将某个函子的内容应用于另一个函子的数值,免去不必要的函数与变量,将是完美解决方案——特别是当我们深陷此类困境时。

瓶中之舟

https://www.deviantart.com/hollycarden

「ap」是能够将一个函子的函数内容应用于另一个函子值的函数(请试着快速重复五次这个定义)。

js
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」:

js
Container.prototype.ap = function (otherContainer) {
  return otherContainer.map(this.$value);
};

请记住,「this.$value」本身是函数,因此只需对目标函子进行「map」操作。由此得出接口定义:

应用函子是带有「ap」方法的点状函子(Pointed Functor)

注意对点状的依赖。正如我们将在后续示例中看到的,点状接口在此至关重要。

此刻我察觉到您的犹疑(或是困惑与惊愕),但请保持开放心态:这个「ap」将展现其实用性。在深入探讨前,让我们先观察一个重要特性。

js
F.of(x).map(f) === F.of(f).ap(F.of(x));

规范表述中,映射「f」等同于对蕴含「f」的函子执行「ap」。更准确地说:我们可以将「x」放入容器后进行「map(f)」,或者将「f」与「x」都提升至容器后执行「ap」。这让我们可以左到右地链式调用来书写代码:

js
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接口。

js
// 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」。这种接口的惊人简洁性会使曾手动处理此类问题的人如获至宝,这正是通往技术奇点的美代码典范。

再看另一个示例:

js
// $ :: 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」操作:

js
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)」本身具有独特价值,因此参数长度需固定。

实际应用示例如下:

js
// 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」,从而提升泛用性与灵活性。

用此范式重构前例:

js
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等支持自定义中缀运算符的语言中,可见如下语法:

hs
-- Haskell / PureScript
add <$> Right 2 <*> Right 3
js
// JavaScript
map(add, Right(2)).ap(Right(3));

了解「<$>」代表「map」(即「fmap」)、「<*>」表示「ap」很有帮助。这种表达更贴近自然函数应用风格,也能减少括号用量。

免费开罐器

http://www.breannabeckmeyer.com/

我们没有过多讨论派生函数。鉴于这些接口相互构建并遵循系列定律,我们可根据强力接口来定义弱功能。

例如,已知应用函子首先是函子,因此若已拥有应用式实例,自然可为类型定义函子。

这种完美的计算和谐性源自数学框架下的操作。即便莫扎特幼年曾用Ableton创作音乐,也难以企及这般完美。

前文提及「of/ap」等同于「map」,可利用此关系免费定义「map」:

js
// map derived from of/ap
X.prototype.map = function map(f) {
  return this.constructor.of(f).ap(this);
};

可以说单子处于类型生态链顶端,因此若存在「chain」,我们可免费获得函子与应用函子:

js
// 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」不会改变容器类型(这也是优选应用式而非单子的另一个理由)。这并不意味着我们不能叠加不同类型的影响——通过堆叠类型设计,我们可确保其在应用过程中保持一致性。

实证如下:

js
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))

看,无需担心不同类型混入其中。

现在审视最喜欢的范畴律:同一性

同一性

js
// identity
A.of(id).ap(v) === v;

在函子中应用「id」不应改变值「v」。例如:

js
const v = Identity.of('Pillow Pets');
Identity.of(id).ap(v) === v;

「Identity.of(id)」的写法因其无效性让我忍俊不禁。有趣的是如前所述,「of/ap」等同于「map」,因此本定律直接继承自函子同一律:「map(id) == id」。

这些定律犹如恪守军规的幼儿园教练,强制所有接口协调运作。

同态性

js
// homomorphism
A.of(f).ap(A.of(x)) === A.of(f(x));

同态即结构保持映射。事实上,函子可视为范畴间的同态——其在映射下保持原范畴结构。

我们实际上只是将普通函数和值填入容器,并在其中执行计算。因此无论将整个过程置于容器内执行(等式左侧),还是先在外运算再装入容器(右侧),结果都应相同,这一点不足为奇。

简单示例:

js
Either.of(toUpperCase).ap(Either.of('oreos')) === Either.of(toUpperCase('oreos'));

交换律

交换律表明将函数提升至「ap」左边或右边操作等效。

js
// interchange
v.ap(A.of(x)) === A.of(f => f(x)).ap(v);

示例:

js
const v = Task.of(reverse);
const x = 'Sparklehorse';

v.ap(Task.of(x)) === Task.of(f => f(x)).ap(v);

组合律

最后是支持验证容器内函数组合正确性的组合律:

js
// composition
A.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));
js
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;


以下练习考虑如下辅助函数:

js
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;