【译】如何处理 JS 纯函数中的副作用

原文:How to deal with dirty side effects in your pure functional javascript

作者:James Sinclair

所以,你开始涉足函数式编程了,用不了多久,你就会了解到纯函数的概念。随着学习的深入,你会发现函数式程序员对这个概念非常着迷。"纯函数让代码更容易理解,"他们说。"纯函数不太可能引发严重问题。""纯函数提供了引用透明性。"等等。他们说的没错,纯函数确实是个好东西,但有一个问题......

纯函数是指没有副作用的函数。但如果你对编程有所了解,你就会知道副作用正是编程的重点所在。如果没有任何人能读得懂圆周率,那么将 𝜋 计算到 100 位又有什么意义呢?如果要将其打印出来,我们需要将数据写入控制台,或者把数据发送给打印类,或者在其他什么地方能让人读到。再说了,如果不能向数据库写入数据,数据库有什么用处呢?我们需要从输入设备读取数据,从网络请求信息。没有副作用,这些事情都做不了。然而,函数式编程是围绕纯函数构建的。那么,函数式程序员是如何完成这些任务的呢?

简单来说就是:他们"作弊"了。

我所说的"作弊"是指,技术上他们严格遵守函数式编程的规则,但他们利用了规则中的漏洞。要做到这一点主要有两种方式:

  1. 依赖注入,或者按我的说法,以邻为壑,委过于人。
  2. 使用 Effect 函子 ------ 我把它看作是一种极致的拖延。

依赖注入

依赖注入是处理副作用的第一种方法。在这种方法中,我们将代码中的任何不纯的部分都传递给函数参数,然后我们可以将其视为其他函数的职责。为了解释我的意思,我们来看一段代码:

js 复制代码
// logSomething :: String -> String
function logSomething(something) {
    const dt = (new Date())toISOString();

    console.log(`${dt}: ${something}`);

    return something;
}

logSomething() 函数有两个不纯的来源:它创建了一个 Date() 对象,并且将日志输出到控制台。因此,它不仅执行了 IO 操作,而且每次运行时都会得到不同的结果。那么,如何使这个函数成为纯函数呢?通过依赖注入,我们将所有不纯的部分都变为函数参数。因此,该函数不再只接收一个参数,而是三个:

js 复制代码
// logSomething: Date -> Console -> String -> *
function logSomething(d, cnsl, something) {
    const dt = d.toIsoString();

    return cnsl.log(`${dt}: ${something}`);
}

在调用时,我们必须明确地传入这些不纯的部分:

js 复制代码
const something = "Curiouser and curiouser!"

const d = new Date();

logSomething(d, console, something);

// ⦘ Curiouser and curiouser!

你可能会觉得:"这太蠢了。你只是把问题推到了上一层。它还是和以前一样不纯。" 没错,这完全就是一个漏洞。

这就像在无力地狡辩:"我完全不知道在 cnsl 对象上调用 log() 会执行 IO 操作。是别人把它传给我的。我完全不清楚它的来历。"

不过这其实并不像看起来那么愚蠢。注意一下我们的 logSomething() 函数。如果你想让它执行一些不纯的操作,你必须使它变得不纯。我们也可以传入其它不同的参数:

js 复制代码
const d = { toISOString: () => '1865-11-26T16:00:00.000Z' };

const cnsl = {
    log: () => {
        // do nothing
    },
};

logSomething(d, cnsl, "Off with their heads!");

//  ← "Off with their heads!"

现在,logSomething 函数什么都不做(除了返回传入的 something 参数)。它是完全纯粹的。如果你使用相同的参数调用它,它每次都会返回相同的结果。这就是关键所在。要使它变得不纯,我们必须采取有意识的行动。或者换句话说,该函数所依赖的一切都在函数签名中。它不访问任何全局对象,如 consoleDate。所有依赖项都非常清晰明了。

同样重要的是,我们也可以将函数传递给原本不纯的函数。让我们看另一个例子。假设我们有一个用户名表单。我们想要获取该表单输入的值:

js 复制代码
// getUserNameFromDOM :: () -> String
function getUserNameFromDOM() {
    return document.querySelector('#username').value;
}

const username = getUserNameFromDOM();

username;
// ← "mhatter"

在这个示例中,我们试图查询 DOM 以获取一些信息。这显然是不纯的,因为 document 是一个全局对象,它随时都可能发生变化。我们让这个函数变为纯函数的一种方法是将全局的 document 对象作为参数传递进来。但是,我们也可以传递一个 querySelector() 函数,就像这样:

js 复制代码
// getUserNameFromDOM :: (String -> Element) -> String
function getUserNameFromDOM($) {
    return $('#username').value;
}

// qs :: String -> Element
const qs = document.querySelector.bind(document);

const username = getUserNameFromDOM(qs);

username;
// ← "mhatter"

现在,你可能会认为"这还是很蠢!"我们所做的只是将不纯性从 getUsernameFromDOM() 中移出。它并没有消失。我们只是把它放到另一个函数 qs() 当中。这除了让代码变得更冗长之外似乎并没有什么用。相比于之前的一个不纯的函数,现在有了两个函数,并且其中一个仍然是不纯的。

请继续听我解释。想象一下,我们要为 getUserNameFromDOM() 编写一个测试用例。现在,对比不纯的和纯的版本,哪个更容易?对于不纯的版本,要使其正常工作,我们需要一个全局的 document 对象。而且除此之外,它还需要在内部包含一个 ID 为 "username" 的元素。如果我想在浏览器之外测试它,那么我就必须导入类似 JSDOM 或无头浏览器之类的东西。所有这些只是为了测试一个非常小的函数。但是对于第二个版本,我可以这样:

js 复制代码
const qsStub = () => ({value: 'mhatter'});
const username = getUserNameFromDOM(qsStub);

assert.strictEqual(
    'mhatter',
    username,
    `Expected username to be ${username}`
)

这并不是说你用不着创建一个在真实浏览器中运行的集成测试。(或者,至少是在模拟浏览器环境下,比如 JSDOM)。但这个例子展示了 getUserNameFromDOM() 现在是完全可预测的。如果我们传入 qsStub,它会始终返回 "mhatter"。我们把不可预测性转移到了更小的函数 qs() 中。

如果我们愿意,我们可以不断将这种不可预测性推向更远的地方,最终将它们推到代码的边缘。因此,我们最终得到一个薄薄的不纯代码外壳,包裹着一个经过良好测试、可预测的核心。随着您开始构建更大的应用程序,这种可预测性会非常重要。

依赖注入的缺点

这种方式可以用来构建大型、复杂的应用程序,这是我的亲身实践。代码会更容易测试,并且每个函数的依赖关系非常明确。但它也有一些缺点。主要缺点是你最终会得到像这样的冗长的函数签名:

js 复制代码
function app(doc, con, ftch, store, config, ga, d, random) {
    // Application code goes here
 }

app(document, console, fetch, store, config, ga, (new Date()), Math.random);

这倒也不算太糟糕,除非你碰到参数穿透的问题。假设你需要在一个非常底层的函数中使用其中一个参数,你就必须将该参数穿透到许多中间层的函数调用中。这很烦人。例如,你可能需要将日期参数穿透到 5 层中间函数中。而且这些中间函数都压根不使用该日期对象。不过这并不是一个致命的问题,而且能够看到这些明确的依赖关系还是挺好的。但这仍然很烦人。好在还有另一种方法...

延迟函数

让我们来看看函数式程序员利用的第二个漏洞。它的底层逻辑是这样的:副作用直到它实际发生之前都不是副作用。听上去有点绕,我试着把它解释的清楚一点。考虑这段代码:

js 复制代码
// fZero :: () -> Number
function fZero() {
    console.log('发射核弹');
    // 这是一段发射核弹的代码
    return 0;
}

我知道,这个例子不太恰当。如果我们想要在代码中获取一个 0 值,直接写就好了。而且我知道,没有谁会在 JavaScript 中编写控制核武器的代码。但它有助于阐述问题。

这显然是一段不纯的代码。它存在控制台输出,并且还可能引发核战争。不过,假设我们就想要那个返回的 0。假设我们需要在核弹发射后计算一些东西。我们可能需要启动倒计时啥的。在这种情况下,提前规划如何执行该计算是完全合理的。我们希望非常小心地控制核弹发射的时机。我们不希望以一种可能意外发射核弹的方式混淆我们的计算。因此,我们可以将fZero()包装在另一个函数中,该函数只是返回它。有点类似保险箱的意思。

js 复制代码
// fZero :: () -> Number
function fZero() {
    console.log('发射核弹');
    // 这是一段发射核弹的代码
    return 0;
}

// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
    return fZero;
}

我可以不限次数地随意运行 returnZeroFunc(),只要不调用返回值,我(理论上)是安全的。我的代码不会发射任何核弹。

js 复制代码
const zeroFunc1 = returnZeroFunc();
const zeroFunc2 = returnZeroFunc();
const zeroFunc3 = returnZeroFunc();
// No nuclear missiles launched.

现在,是时候更正式地定义一下纯函数了。然后我们才可以更详细地检查我们的returnZeroFunc()函数。

一个函数如果满足以下条件,那它就是一个纯函数:

  1. 它没有可观察的副作用;且
  2. 它是引用透明的,也就是说,给定相同的输入,它总是返回相同的输出。

我们来看看returnZeroFunc()函数。它有副作用吗?我们刚刚确认调用returnZeroFunc()函数不会发射核弹。除非你进一步调用它返回的函数,否则什么都不会发生。所以,这里没有副作用。

returnZeroFunc()函数是否具有引用透明性?也就是说,给定相同的输入,它是否始终返回相同的值?根据目前的写法,我们可以测试一下:

js 复制代码
zeroFunc1 === zeroFunc2; // true
zeroFunc2 === zeroFunc3; // true

但它其实还不是完全的纯函数。returnZeroFunc()引用了其作用域之外的变量。为了解决这个问题,我们可以这样重写:

js 复制代码
// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
    function fZero() {
        console.log('发射核弹');
        // 这是一段发射核弹的代码
        return 0;
    }
    return fZero;
}

现在它是纯函数了。但是,JavaScript 的语言特性导致我们不能在这里使用 === 来验证引用透明性。这是因为returnZeroFunc()每次都会返回一个新的函数引用。但是你可以通过检查代码来验证引用透明性。我们的returnZeroFunc()函数除了每次返回相同的函数之外什么都不会做。

这是一个很巧妙的小漏洞。但是我们真的能用它来编写实际的代码吗?答案是肯定的。但在我们讨论如何在实践中应用之前,让我们再深入一点点。回到那个危险的fZero()函数:

js 复制代码
// fZero :: () -> Number
function fZero() {
    console.log('发射核弹');
    // 这是一段发射核弹的代码
    return 0;
}

假设我们就是需要使用fZero()返回的那个0,但我们暂时还不想发动核战争。现在我们需要创建一个函数,它接收fZero()最终返回的0,并将其加1

js 复制代码
// fIncrement :: (() -> Number) -> Number
function fIncrement(f) {
    return f() + 1;
}

fIncrement(fZero);
// ⦘ 核弹发射
// ← 1

完了,我们不小心发动了核战。

重来一次。这次,我们不会直接返回一个数字,而是返回一个最终会返回一个数字的函数:

js 复制代码
// fIncrement :: (() -> Number) -> (() -> Number)
function fIncrement(f) {
    return () => f() + 1;
}

fIncrement(zero);
// ← [Function]

哇,危机解除了。我们继续吧!有了这两个函数,我们可以创建一系列"最终数字":

js 复制代码
const fOne   = fIncrement(zero);
const fTwo   = fIncrement(one);
const fThree = fIncrement(two);
// ......

我们还可以创建一堆与"最终值"一起使用的 f*() 函数:

js 复制代码
// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fMultiply(a, b) {
    return () => a() * b();
}

// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fPow(a, b) {
    return () => Math.pow(a(), b());
}

// fSqrt :: (() -> Number) -> (() -> Number)
function fSqrt(x) {
    return () => Math.sqrt(x());
}

const fFour = fPow(fTwo, fTwo);
const fEight = fMultiply(fFour, fTwo);
const fTwentySeven = fPow(fThree, fThree);
const fNine = fSqrt(fTwentySeven);
// 没有控制台输出也不会发动核战争

看明白我们在这里做的事情了吗?我们可以用最终数值做任何我们想用普通数值做的事情。数学上称之为"同构"。我们只需要将普通数值传入函数,就可以将普通数值转换成最终数值。然后,我们可以通过调用该函数来获取最终数值。换句话说,我们有一个数字与最终数值之间的映射。这其实是一个非常了不起的概念。我保证我们很快会回到这点上来。

这种函数包装的方法是一种合理的策略。我们可以一直隐藏在函数的背后。只要我们从未实际调用这些函数,它们在理论上就是纯净的。也没有人发动任何战争。

在常规(非核)代码中,我们实际上希望那些副作用最终发生。将所有内容包装在函数中可以让我们精确地控制这些效果。我们能够决定副作用发生的时间。但是,到处都要输入括号还是很麻烦。而且创建每个函数的新版本也很烦人。我们的语言中已经内置了一些优秀的函数,比如 Math.sqrt()。如果我们能用延迟值来使用这些普通函数就好了。这时,Effect 函子登场了。

Effect 函子

就我们的目标来说,Effect 函子不过是一个我们把延迟函数放进去的对象。因此,我们会将fZero函数放入一个Effect 函子对象中。但在此之前,我们稍微改动一下代码,不要让自己有那么大的压力:

js 复制代码
// zero :: () -> Number
function fZero() {
    console.log('啥也不是');
    // 绝对不会发动核打击。
    // 但这仍然不是一个纯函数
    return 0;
}

现在我们创建一个构造函数,它可以为我们创建一个 Effect 对象:

js 复制代码
// Effect :: Function -> Effect
function Effect(f) {
    return {};
}

到目前为止,还没有什么特别的。我们需要让它变得更有用。我们想要使用我们的常规 fZero() 函数与我们的 Effect 对象。我们将编写一个方法,以后再将它应用于我们延迟的值,而不触发效果。我们称之为 map。这是因为它创建了常规函数与 Effect 函数之间的映射。大概像这样:

js 复制代码
// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        }
    }
}

你可能会对map()感到好奇。它看起来很像compose函数。我们稍后会谈到这个问题。现在,我们来尝试运行一下:

js 复制代码
const zero = Effect(fZero);
const increment = x => x + 1; // A plain ol' regular function.
const one = zero.map(increment);

嗯,我们现在没有办法知道到底发生了什么。我们稍微修改一下 Effect,以便我们有一种"扳机"的方式,可以查看结果:

js 复制代码
// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        }
    }
}

const zero = Effect(fZero);
const increment = x => x + 1; // Just a regular function.
const one = zero.map(increment);

one.runEffects();
// ⦘ 啥也不是
// ← 1

而且,如果我们想的话,我们可以继续调用那个 map 函数:

js 复制代码
const double = x => x * 2;
const cube = x => Math.pow(x, 3);
const eight = Effect(fZero)
    .map(increment)
    .map(double)
    .map(cube);

eight.runEffects();
// ⦘ 啥也不是
// ← 8

现在,事情开始变得有意思了。既然我们称之为"函子"(functor),这就意味着 Effect 有一个 map 函数,并且遵守一些规则。这些规则不是那种限制你不能做什么的规则,而是关于你可以做什么的规则。它更像是一种"特权",其中之一是被称之为"组合规则"的东西:

如果我们有一个 Effect 对象 e 和两个函数 f 和 g, 那么 e.map(g).map(f) 等价于 e.map(x => f(g(x)))。

换句话说,连续进行两次映射(map)操作等同于合成这两个函数。这意味着 Effect 可以做类似下面的操作(回想一下我们之前的例子):

js 复制代码
const incDoubleCube = x => cube(double(increment(x)));
// 如果使用 Ramda 或者 lodash/fp 也可以这么写:
// const incDoubleCube = compose(cube, double, increment);
const eight = Effect(fZero).map(incDoubleCube);

而且这里我们可以确保得到与之前三次调用 map 后相同的结果。我们可以利用这一点来重构代码,并确保代码不会出错。在某些情况下,我们甚至可以通过在不同方法之间切换来进行性能优化。

好了,关于数字的例子就到此为止。接下来我们来做一些更像是"真实"代码的事情。

创建 Effect 的快捷方式

我们的 Effect 构造函数接受一个函数作为参数。这很方便,因为我们想要延迟的大多数副作用也是函数。例如,Math.random()console.log()都属于这种类型的函数。但有时候我们想将一个普通值放入 Effect 中。例如,假设我们在浏览器的window全局对象中附加了某种配置对象。我们想要取出一个值,但这是一个不纯的操作。我们可以写一个小小的快捷方式来简化这个任务:

js 复制代码
// of :: a -> Effect a
Effect.of = function of(val) {
    return Effect(() => val);
}

为了展示这能有多方便,想象一下你正在开发一个 Web 应用。这个应用有一些标准功能,比如文章列表和用户简介。但是,这些组件在 HTML 中的位置对于不同的客户可能会有所不同。由于你是一个非常聪慧的工程师,你决定将它们的位置存储在一个全局配置对象中,以便你可以随时取值。就像这样:

js 复制代码
window.myAppConf = {
    selectors: {
        'user-bio': '.userbio',
        'article-list': '#articles',
        'user-name': '.userfullname',
    },
    templates: {
        'greet': 'Pleased to meet you, {name}',
        'notify': 'You have {n} alerts',
    }
};

现在,借助Effect.of()快捷方式,你可以迅速将所需的值放入一个Effect包装器中:

js 复制代码
const win = Effect.of(window);
userBioLocator = win.map(x => x.myAppConf.selectors['user-bio']);
// ← Effect('.userbio')

嵌套和非嵌套的 Effects

在处理 Effects 时,使用 map 函数可以帮助我们完成许多任务。但有时候,我们可能会将一个返回 Effect 的函数进行映射。我们已经定义了getElementLocator()函数,它返回包含字符串的 Effect。但如果我们要真正定位 DOM 元素,需要调用document.querySelector(),这是另一个不纯的函数。因此,我们需要通过返回一个 Effect 来净化它:

js 复制代码
// $ :: String -> Effect DOMElement
function $(selector) {
    return Effect.of(document.querySelector(s));
}

现在,我们可以尝试使用map()将这两个函数结合起来:

js 复制代码
const userBio = userBioLocator.map($);
// ← Effect(Effect(<div>))

说句实话,现在这段代码运行起来并不方便。如果我们想要访问某个 div 元素,必须使用一个函数来进行两次映射。例如,如果我们想要获取 innerHTML,代码会这样写:

js 复制代码
const innerHTML = userBio.map(eff => eff.map(domEl => domEl.innerHTML));
// ← Effect(Effect('<h2>User Biography</h2>'))

让我们尝试从userBio开始逐步分解一下。这可能有点繁琐,但对我们弄清楚这里的代码是如何执行的很有帮助。我们一直在使用的Effect('user-bio')表示法有点误导。如果我们将其写成代码,会更接近这样:

js 复制代码
Effect(() => '.userbio');

这个表述其实也不准确。实际上更像是:

js 复制代码
Effect(() => window.myAppConf.selectors['user-bio']);

现在,当我们使用 map 时,相当于将这个内部函数与另一个函数进行组合(正如我们之前看到的)。因此,当我们使用 $ 进行映射时,代码如下:

js 复制代码
Effect(() => $(window.myAppConf.selectors['user-bio']));

展开后,我们得到:

js 复制代码
Effect(
    () => Effect.of(document.querySelector(window.myAppConf.selectors['user-bio'])))
);

展开 Effect.of 后,我们得到更清晰的表示:

js 复制代码
Effect(
    () => Effect(
        () => document.querySelector(window.myAppConf.selectors['user-bio'])
    )
);

注意:所有实际执行操作的代码都在最内部的函数中。没有任何代码泄漏到外部的 Effect 中。

合并(Join)

为什么要详细解释这些步骤?因为我们想要解除这些嵌套的 Effect。如果我们要这样做,就必须要确保在此过程中没有引入任何不必要的副作用。对于 Effect 来说,解除嵌套的方法是在外部函数上调用 .runEffects()。但这可能会引起困惑。我们所做的一切都是为了检查我们不会运行任何副作用。因此,我们将创建另一个函数(join)来执行这个操作。我们通过join来解除嵌套的 Effects,通过runEffects()来实际运行 Effects。这样可以更清晰地表达我们的意图。

js 复制代码
// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        }
        join(x) {
            return f(x);
        }
    }
}

回到用户简介的例子,我们可以用join来解除嵌套:

js 复制代码
const userBioHTML = Effect.of(window)
    .map(x => x.myAppConf.selectors['user-bio'])
    .map($)
    .join()
    .map(x => x.innerHTML);
// ← Effect('<h2>User Biography</h2>')

链式调用

这种先使用.map()再使用.join()的模式经常出现。因此,要是有一个快捷函数就非常棒了。这样,每当我们有一个返回 Effect 的函数时,我们就可以使用这个快捷函数,就不用一遍又一遍地写map然后join了。我们可以这样实现:

js 复制代码
// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        }
        join(x) {
            return f(x);
        }
        chain(g) {
            return Effect(f).map(g).join();
        }
    }
}

我们将这个新函数命名为chain,因为它允许我们链式调用 Effects(也是因为规范就是这么定的)。现在我们获取用户简介的 innerHTML 的代码就会是这样:

js 复制代码
const userBioHTML = Effect.of(window)
    .map(x => x.myAppConf.selectors['user-bio'])
    .chain($)
    .map(x => x.innerHTML);
// ← Effect('<h2>User Biography</h2>')

不过,其他编程语言对这个概念使用了许多不同的名称。如果你想要深入了解更多内容,可能会让你有点混淆。有时它被称为flatMap。这个命名很有道理,因为我们首先进行了常规的映射,然后用.join()方法展平了结果。然而,在 Haskell 中,它被命名为bind,这就有点让人摸不着头脑了。所以如果你有查阅相关资料,请记住:chainflatMapbind指的是类似的概念。

组合 Effect

在某些情况下,使用 Effect 可能会有点棘手,特别是当我们想要使用单个函数组合两个或多个 Effect 时。例如,假设我们想从 DOM 中获取用户的姓名,然后将其插入到由我们的应用配置提供的模板中。因此,我们可能会有一个类似下面的模板函数(请注意我们创建了一个柯里化版本):

js 复制代码
// tpl :: String -> Object -> String
const tpl = curry(function tpl(pattern, data) {
    return Object.keys(data).reduce(
        (str, key) => str.replace(new RegExp(`{${key}}`, data[key]),
        pattern
    );
});

好的,接下来是获取数据:

js 复制代码
const win = Effect.of(window);
const name = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str});
// ← Effect({name: 'Mr. Hatter'});

const pattern = win.map(w => w.myAppConfig.templates('greeting'));
// ← Effect('Pleased to meet you, {name}');

我们创建了一个模板函数,它接受一个字符串和一个对象,并返回一个字符串。但是我们获取到的字符串和对象(name 和 pattern)都被包装在 Effect 中。而我们要做的,是将 tpl() 函数提升到更高的层次,使其能够与 Effect 一起使用。

首先看一下如果我们在 pattern Effect 上调用map()方法并传入tpl()函数会发生什么:

js 复制代码
pattern.map(tpl);
// ← Effect([Function])

直接看类型签名可能会更清晰。map 方法的类型签名类似于以下内容:

map :: Effect a ~> (a -> b) -> Effect b

我们的模板函数的类型签名是:

tpl :: String -> Object -> String

是的,当我们在 pattern 上调用 map 时,我们得到了一个部分的应用函数(记住我们已经柯里化了 tpl 函数),它包含在 Effect 中。

Effect (Object -> String)

现在我们想要传入 pattern Effect 中的值,你会发现没有合适的方法来做到这一点。我们需要为 Effect 编写另一个方法(ap())来处理这个问题:

js 复制代码
// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        }
        join(x) {
            return f(x);
        }
        chain(g) {
            return Effect(f).map(g).join();
        }
        ap(eff) {
            // 当调用 ap 方法时,我们假设 eff 内部返回一个函数(而不是一个值)
            // 这里将使用 map 访问该函数(我们将其称为 'g')
            // 一旦拿到了 'g',就将 f() 的返回值传入并调用
            return eff.map(g => g(f()));
        }
    }
}

有了这个方法,我们可以运行 .ap() 来应用模板:

js 复制代码
const win = Effect.of(window);
const name = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str}));

const pattern = win.map(w => w.myAppConfig.templates('greeting'));

const greeting = name.ap(pattern.map(tpl));
// ← Effect('Pleased to meet you, Mr Hatter')

至此,我们已经实现了我们的目标。但坦白说,我会觉得ap()有点混乱。我很难记住必须先用map来处理函数,然后再运行ap()。然后我会忘记参数的顺序。但是有一种解决方法。大部分情况下,我想做的是将普通函数提升到应用层面。也就是说,一个普通函数,我想让它能够与具有.ap()方法的类似 Effect 的东西一起工作。我们可以编写一个这样的函数来完成操作:

js 复制代码
// liftA2 :: (a -> b -> c) -> (Applicative a -> Applicative b -> Applicative c)
const liftA2 = curry(function liftA2(f, x, y) {
    return y.ap(x.map(f));
    // 也可以这样写:
    // return x.map(f).chain(g => y.map(g));
});

我们称之为liftA2(),因为它提升了一个接受两个参数的函数。我们可以类似地编写一个liftA3()

js 复制代码
// liftA3 :: (a -> b -> c -> d) -> (Applicative a -> Applicative b -> Applicative c -> Applicative d)
const liftA3 = curry(function liftA3(f, a, b, c) {
    return c.ap(b.ap(a.map(f)));
});

注意liftA2liftA3 和 Effect 函子并没有关系。理论上,它们可以与任何具有ap()方法的对象一起使用。

通过liftA2(),我们可以将上面的示例重写为以下形式:

js 复制代码
const win = Effect.of(window);
const user = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str});

const pattern = win.map(w => w.myAppConfig.templates['greeting']);

const greeting = liftA2(tpl)(pattern, user);
// ← Effect('Pleased to meet you, Mr Hatter')

然后呢?

读到这里,你可能会想:"似乎为了避免一点副作用,需要付出精力太多了。" 这点副作用有什么关系呢?什么 Effect 函子、ap() 太难理解了。 为什么要费这个力气?不纯的代码不也能正常运行吗?在实际开发当中,什么场景下会需要这样做呢?

函数式程序员有点像中世纪的修道士,为了成为一个高尚的人,他会否认自己享受生活的乐趣。---- John Hughes

这些异议可以分解为两个问题:函数式纯度真的很重要吗?以及在实际开发中,Effect 有什么用处?

函数式纯度确实很重要

的确,当你局限于一个小函数时,一点点的函数纯度可能并不重要。const pattern = window.myAppConfig.templates['greeting']比下面这种代码更加简便:

js 复制代码
const pattern = Effect.of(window).map(w => w.myAppConfig.templates('greeting'));

如果你只是做一些简单的事情,这种说法可能是成立的。副作用可能并不重要。但是这只是一行代码,在一个可能包含成千上万,甚至数百万行代码的应用中。当你试图弄清楚为什么程序突然停止工作时,函数纯度就很重要了。当发生了一些意外状况,你试图分解问题并排查原因。在这种情况下,你可以排除的代码越多,就能越快地解决问题。如果一个函数是纯函数,那么你可以确信影响它行为的唯一因素就是传递给它的入参。这将大大缩小你的排查范围。换句话说,这将减少你的心智负担。在大型复杂应用中,这一点非常重要。

在实际开发中的 Effect 模式

好了,现在我们知道在构建大型复杂应用(比如像 Facebook 或 Gmail 这样的应用)时,函数式纯度非常重要。但如果你不是在开发那种体量的应用呢?我们假设你现在需要处理数百万行数据(这种情况可能会越来越常见),这些数据可能是 CSV 文本文件,或者是庞大的数据库表格。也许你正在训练一个人工神经网络来构建推断模型。也许你正在尝试寻找下一个大的加密货币趋势。无论如何,完成这项任务需要大量的计算资源。

Joel Spolsky 认为,函数式编程可以在这方面大有作为,并给出了非常有说服力的论证。函数式纯度使我们可以编写替代版本的 map 和 reduce 函数,让它们可以并行运行。但这还没完。当然,你可以编写一些高级的并行处理代码。但即使如此,受限于开发机的核心数,任务仍然需要运行很长时间。除非,你可以在大量的处理器上运行它......比如 GPU,或者是一整个集群的处理服务器。

为了完成这个目标,你需要在实际运行之前描述清楚你想要执行的计算。听起来很熟悉吧?理想情况下,你会将这些描述传递给某种框架。该框架会负责读取所有的数据,并将其分割成多个处理节点。然后框架会将结果汇集在一起,并告诉你整个过程的情况。这就是 TensorFlow 的工作原理。

TensorFlow™ 是一个开源的高性能数值计算软件库。其灵活的架构允许在多种平台上轻松部署计算(包括CPU、GPU和TPU),从桌面设备到服务器集群再到移动设备和边缘设备。最初由谷歌AI组织内的Google Brain团队的研究员和工程师开发,TensorFlow提供强大的机器学习和深度学习支持,其灵活的数值计算核心也被广泛应用于许多其他科学领域。 ---- TensorFlow home page

在 TensorFlow 中,你不会使用编程语言中的普通数据类型。相反,你需要创建'张量'(Tensors)。如果我们需要对两个数进行求和,代码可能是这样的:

python 复制代码
node1 = tf.constant(3.0, tf.float32)
node2 = tf.constant(4.0, tf.float32)
node3 = tf.add(node1, node2)

以上代码是用 Python 写的,但它看起来与 JavaScript 并没有太大区别,对吧?而且,就像我们的 Effect 一样,add 函数在我们明确运行(sess.run())之前不会执行:

python 复制代码
print("node3: ", node3)
print("sess.run(node3): ", sess.run(node3))
# ⦘ node3:  Tensor("Add_2:0", shape=(), dtype=float32)
# ⦘ sess.run(node3):  7.0

在调用sess.run()之前,我们得不到 7.0 这个计算结果。这与我们的延迟函数非常相似。我们提前规划好我们的计算过程,然后一旦准备就绪,就触发执行所有计算。

总结

我们已经涵盖了很多内容。在代码中处理函数式不纯性的两种方法有:

  1. 依赖注入;
  2. Effect 函子。

依赖注入通过将代码中不纯的部分移到函数外部来实现。因此,你必须将它们作为参数传递。而 Effect 函子则通过将所有副作用都包装在一个函数内部来实现。要触发这些副作用,就必须有意识地运行这个包装函数。

这两种方法都是一种取巧。它们并未完全消除代码中的杂质,只是将其推移到代码边缘。但这其实是有好处的。这样做可以明确地标示出哪些部分是含有杂质的。在调试复杂代码库时,这对你会有很大的帮助。

相关推荐
深度混淆1 分钟前
实用功能,觊觎(Edge)浏览器的内置截(长)图功能
前端·edge
Smartdaili China2 分钟前
如何在 Microsoft Edge 中设置代理: 快速而简单的方法
前端·爬虫·安全·microsoft·edge·社交·动态住宅代理
秦老师Q3 分钟前
「Chromeg谷歌浏览器/Edge浏览器」篡改猴Tempermongkey插件的安装与使用
前端·chrome·edge
滴水可藏海4 分钟前
Chrome离线安装包下载
前端·chrome
m512715 分钟前
LinuxC语言
java·服务器·前端
Myli_ing1 小时前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
dr李四维1 小时前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
雯0609~2 小时前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ2 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z2 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript