在JS中探索DO DO符号
#javascript #功能

介绍

在纯FP语言Haskell(和其他)中,有一种称为Do Notationâ的组合机制。在这篇文章中,我们将使用JavaScript模仿这种机制,而不是嘲笑它,而是要调查它并将其添加到我们的解决方案工具包中。

Haskell Do Notation

DO表示法通常用于组合单子以执行IO(输入/输出)之类的操作,但是我们不会在此处讨论该主题。足以了解以下内容:

  • 单调通常用于隔离导致副作用的操作,以保护其余应用免受意外后果。
  • Monads的行为就像一个简单的(纯)功能,这是我们将使用的。
  • 函数从期望提供单个值的操作中返回一个值;尽管值可能很复杂(对象和/或数组)。

也有类似的方法来组合称为管道的操作。请参阅脚注,以发现有关将管道带到JS的TC39提案的更多信息。在这两种情况下


用例

为了证明和对比,我们可以使用一种简单的过程来行使技术。我们将使用如何在摄氏(C)和华氏度(F)度之间转换温度。如果您熟悉此过程,请随时跳过本节。如果您不是,尤其是如果您对数学不舒服,我保证会慢慢完成计算。

在C和F之间转换温度

信不信由你,这很简单,仅使用基本计算器上的操作类型:加法(+),减法( - ),乘法(x,尽管在计算中,我们使用符号 *)和部门(/) .

两个量表C和F是一致的,可以表示为图上的直线。与我保持联系,我将解释。

C vs. F Graphs

如果我们取水,将其冷冻并测量其温度,我们可以获得两个数字。当温度计处于F模式时,我们将获得32,当在C模式下时,我们将获得零。如果我们沸腾并测量其温度,我们再次获得两个值:212°F和100°C。这在上图上表示为红线。 f值在左下(垂直)轴上延伸,C刻度沿底部的水平轴延伸。

我们可以通过简单地在左轴上找到温度并水平沿着红色对角线的路径来将华氏度转换为摄氏。在我们登上线路时,将垂直路径向下追踪到底轴,我们将在其中找到摄氏温度。我们的功能将做与此过程类似的事情。

如果我们通过减去32来调节F温度,也使冷水0F调节,则对角线在两个轴上都通过0。显示为绿线,但请注意,红色和绿线保持平行,就像一对火车轨道一样,它们之间的距离从一端到另一端保持不变,并且从不交叉。

然而,沸水将变为180°F,但仍然100°C,对角线的斜率保持不变。这很重要,因为它意味着C变化也会改变,而不是按相同的数量,而是按一致的速率或比率进行变化。实际上,随着C从冷冻(0)变为沸腾(100),F中的温度升高180。在50°C时,绿线(F -32)的点为90。要获得F(在F(获得F(F(F -32))中红线)我们只需要添加32个重新= 90 + 32 = 112。斜率的关系为100:180,它也是50:90(如上所示)和5:9。对于C的每增加5度,F将增加9度,这是一致的。

现在,对于C到F到C的公式/方程式(配方),示例是示例,这只是显示我们在上面发现的东西的另一种方式。

((C * 9) / 5) + 32 gives us F

((F - 32) * 5) / 9 gives us C

让我们将塞尔西修斯转换为华氏

Boiling point: 100°C * 9 = 900
    900 / 5 = 180
        180 + 32 = 212°F

Freezing point: 0°C * 9 = 0
    0 / 5 = 0
        0 + 32 = 32°F

现在将F转换为C,

Boiling point: 212°F - 32 = 180
    180 * 5 = 900
        900 / 9 = 100°C

Freezing point: 32°F - 32 = 0
    0 * 5 = 0
        0 / 9 = 0°C

红线的最左端有第三个数字,这很有趣。使它有趣的是,它在C和F中是相同的值,即-40度。

-40°C * 9 = -360
    -360 / 5 = -62
        -62 + 32 = -40°F

另外,

-40°F - 32 = -72
    -72 * 5 = -360
        -360 / 9 = -40°C

在上面的所有计算中,我们仅使用四个简单操作:

  • 加法(+ 32)
  • 减法(-32)
  • 乘数( * 9和 * 5)
  • 分隔(/ 5和/ 9)

以正确的顺序(如上图所示)结合其中三个操作将为我们提供所需的计算。如果每个操作都是一个简单的函数,则将它们组合与编写功能以创建温度转换功能相同。


规范的命令式方法

在命令式(函数/方法)方法中编码上述计算相对简单。实际上,可以删除代码中的某些括号,但已包括以与上述说明和以后的代码保持一致。

/* canonical.js */

import runTestCases from './testcases.js';

const INTERCEPT = 32;
const CELSIUS_DELTA = 5;
const FAHRENHEIT_DELTA = 9;
const SLOPE = CELSIUS_DELTA / FAHRENHEIT_DELTA;

function celsiusToFahrenheit(c) {
    return c / SLOPE + INTERCEPT;
}
function fahrenheitToCelsius(f) {
    return (f - INTERCEPT) * SLOPE;
}

runTestCases(celsiusToFahrenheit, fahrenheitToCelsius);

请注意上述代码片段顶部的导入。它用于引入测试功能,该测试能力通过调用runTestCases函数在最后一行行使。在此示例中,它将在以下测试用例中显示两个温度功能:

/* Fragment of the testcases.js file (testcase 1) */

celsiusToFahrenheit: [
    { input: 100, expected: 212 },
    { input: 0, expected: 32 },
    { input: -40, expected: -40 },
],
fahrenheitToCelsius: [
    { input: 212, expected: 100 },
    { input: 32, expected: 0 },
    { input: -40, expected: -40 },
],

测试使用输入值调用函数,然后将输出与预期值进行比较。这是结果。

Table of test results of the Canonical implementation

在下一个示例中,我们以命令式编码方式继续,但以更精致的方式与本文的主题进行了更精致的方式。

/* imperative.js */

import runTestCases from './testcases.js';

function add(m, n) {
    return m + n;
}
function sub(m, n) {
    return m - n;
}
function mul(m, n) {
    return m * n;
}
function div(m, n) {
    return m / n;
}

function celsiusToFahrenheit(c) {
    return add(div(mul(c, 9), 5), 32);
}
function fahrenheitToCelsius(f) {
    return div(mul(sub(f, 32), 5), 9);
}

runTestCases(celsiusToFahrenheit, fahrenheitToCelsius);

我们以与以前相同的方式行使功能,它们产生相同的结果。不过,这里的最大区别是准备和使用数学操作的方式。请注意如何使用divmul函数两次。还要注意温度转换函数如何由简单的数学操作组成。


发挥作用

下一个示例采用了更“功能”的编码样式,如功能编程中,而不是到目前为止所展示的程序和其他命令风格。

/* functional.js */

import runTestCases from './testcases.js';

import { specificOperations } from './operations.js';

const { add32, div5, mul9, div9, mul5, sub32 } = specificOperations;

// Conversion functions
function celsiusToFahrenheit(c) {
    return add32(div5(mul9(c)));
}
function fahrenheitToCelsius(f) {
    return div9(mul5(sub32(f)));
}

runTestCases(celsiusToFahrenheit, fahrenheitToCelsius);

对上述代码片段的测试与以前完全相同,但是这次我们还从 operations 模块中导入包含特定操作的对象(如下所述)。这些功能使得正在做什么,并减少对完成方式的关注,这是功能编程的声明性质(FP)。但是,我们为转换功能组成操作的方式比声明性更重要。

通用和特定操作

基于操作的数学函数非常简单,但功能组成通常会更多地参与。

/* operations.js */

// Generic operations
function addM(m) {
    return n => n + m;
}
function subM(m) {
    return n => n - m;
}
function mulM(m) {
    return n => n * m;
}
function divM(m) {
    return n => n / m;
}

export const genericOperations = {
    addM,
    subM,
    mulM,
    divM,
};

// Specific operations
const add32 = addM(32);
const div5 = divM(5);
const mul9 = mulM(9);
const div9 = divM(9);
const mul5 = mulM(5);
const sub32 = subM(32);

export const specificOperations = {
    add32,
    div5,
    mul9,
    div9,
    mul5,
    sub32,
};

在上面的模块中,两个对象被导出genericOperations, specificOperations,基于 em> genem> genercorperations 特定操作 genericocerations 使用称为咖喱的FP技术基于四个基本数学函数,因此可以独立提供参数。这使得特定操作能够采用另一种称为部分应用的FP技术,其中提供了第一个参数(绑定到第一个参数)来生成专业函数(操作)。有关技术的更完整说明,请参见this article


在JS中进行表示法

在某些FP语言中,Do Notation是惯用的,这意味着它是内置在语言中的。尽管JS带有来自FP学校的某些功能,但DO表示并不是其中之一,因此我们必须自己重新创建它。 “管道”中有一些新的语法,但是还有一些路要走(请参见脚注2)。

在接下来的七个代码示例中,我们将开发一组函数以模拟DO符号;我们去探索do-notation.js模块的片段。

“饼干”图

作为描述以下每个示例的另一种方法,我设计了一种说明与圣诞节饼干相似的功能的方法。如果您不熟悉新颖性,您可能会发现这个兴趣的Wikipedia page

图从左到右流动并采用以下符号。

Cracker diagram symbol key

该图试图说明如何提供给组合函数的数据(在左侧)流过一系列函数以产生输出值(右侧)。

Example cracker diagram


示例1:使用特定功能

Example One

上面的“饼干”图是下面两个DO组成的描述。观察者如何获得数值,通过一系列三个特定函数(组成单个DO函数),然后返回数字值作为输出。前面的句子对于图和源代码都是正确的。

/* do-mk1.js */

import runTestCases from './testcases.js';

import { DO } from './do-notation.js';

import { specificOperations } from './operations.js';

const { add32, div5, mul9, div9, mul5, sub32 } = specificOperations;

// Conversion functions
const celsiusToFahrenheit = DO(mul9, div5, add32);

const fahrenheitToCelsiusOperations = [sub32, mul5, div9];
const fahrenheitToCelsius = DO(fahrenheitToCelsiusOperations);

runTestCases(celsiusToFahrenheit, fahrenheitToCelsius);

在上面的代码片段中,使用DO函数组成特定函数。这些将在初始数字输入上运行以产生数字输出,但我们将在以后进行扩展,但首先我们将定义DO函数。

/* Fragment of do-notation.js */

export function DO(...fns) {
    return data => 
        fns.flat().reduce((output, fn) =>
            fn(output), data);
}

DO函数非常简单。它使用rest syntax接受函数列表作为单个数组参数。它返回一个期望单个数据参数的函数。当调用时,返回的函数“管道”使用reduce方法将输入从一个函数到另一个函数,最终结果是DO函数的输出。

示例2:使用通用功能

Example Two

此示例实际上与以前相同,但使用了genericOperations。这意味着进口的操作较少,但是当被称为它们时,必须将其提供初始论点来指定它们。

/* Fragment of do-mk2.js */

const { addM, subM, mulM, divM } = genericOperations;

// Conversion functions
const celsiusToFahrenheit = DO(
    mulM(9), 
    divM(5), 
    addM(32)
);

const fahrenheitToCelsiusOperations = [
    subM(32),
    mulM(5),
    divM(9)
];
const fahrenheitToCelsius = DO(fahrenheitToCelsiusOperations);

示例3:使用格式(字符串)输入

Example Three

在第三个示例中,我们将使用字符串来表示输入温度,但这只是垫脚石,因此我们的输出仍然是数字。此示例的主要目的是证明数据输入的类型可能与输出的类型不同。

/* Fragment of the testcases.js file (testcase 2) */

celsiusToFahrenheit: [
    { input: '100°C', expected: 212 },
    { input: '0°C', expected: 32 },
    { input: '-40°C', expected: -40 },
],
fahrenheitToCelsius: [
    { input: '212°F', expected: 100 },
    { input: '32°F', expected: 0 },
    { input: '-40°F', expected: -40 },
],

测试结果报告略有不同。

Example 3: Test results

请注意,输入值是一个结合数值和尺度(C或F)的字符串,该数字(C或F)由度符号(°)隔开。这意味着,将需要使用以下函数从输入字符串中提取数值。

function extractTemp(tempStr) {
    return parseInt(tempStr, 10);
}

转换功能将如下构造:

/* Fragment of do-mk3.js */

// Conversion functions
const celsiusToFahrenheit = DO(
    extractTemp, 
    mulM(9), 
    divM(5), 
    addM(32)
);

const fahrenheitToCelsiusOperations = [
    extractTemp,
    subM(32),
    mulM(5),
    divM(9),
];

const fahrenheitToCelsius = DO(fahrenheitToCelsiusOperations);

请注意,函数参考和函数调用的组合(返回函数参考)在DO指令的函数列表中。理想情况下,我们也喜欢输出是一个字符串,因此在示例四。

示例4:格式输入和输出的条件处理

Example Four

在此示例中,我们的转换功能将作为字符串作为字符串作为字符串。

/* Fragment of the testcases.js file (testcase 3) */

celsiusToFahrenheit: [
    { input: '100°C', expected: '212°F' },
    { input: '0°C', expected: '32°F' },
    { input: '-40°C', expected: '-40°F' },
],
fahrenheitToCelsius: [
    { input: '212°F', expected: '100°C' },
    { input: '32°F', expected: '0°C' },
    { input: '-40°F', expected: '-40°C' },
],

Example 4 - String to string test results

在这一点上,我们将通过IF函数与isCelsius谓词功能一起引入有条件执行的机制(又称分支)。

/* Fragment of do-mk4.js */

function isCelsius(tempStr) {
    return tempStr.at(-1).toUpperCase() === 'C';
}
function convertToString(scale) {
    return n => `${n}°${scale.toUpperCase()}`;
}

现在看起来像这样。

// Conversion functions
const celsiusToFahrenheit = [
    extractTemp,
    mulM(9),
    divM(5),
    addM(32),
    convertToString('F'),
];

const fahrenheitToCelsius = [
    extractTemp,
    subM(32),
    mulM(5),
    divM(9),
    convertToString('C'),
];

const convertTemperature = DO(
    IF(isCelsius,
        DO(celsiusToFahrenheit),
        DO(fahrenheitToCelsius)
    )
);

上面的代码将IF函数包装在DO操作中,但由于这是构图中唯一的任务,而IF调用返回函数,可以直接执行。 do-notation IF函数非常简单,定义如下。

export function IF(condition, doTrue, doFalse) {
    return data => (condition(data)
        ? doTrue(data)
        : doFalse(data)
    );
}

上面的功能接受三个参数,所有函数,并返回一个新功能,该函数将单个输入作为DO组成的一部分。第一个“条件”是所谓的谓词函数,因为它将其输入转换为布尔输出(true或false)。这决定将执行接下来的两个函数中的哪一个。在我们的示例中,条件确定了输入的比例(C = true或F = false),并对celsiusToFahrenheitdoFalse执行适当的doTrue

示例5:do_if_then_else

Example Five

do表示法IF函数的可读性不如其命令等效范围,因此在此示例中,我们使用函数链接来使更“可读”的机制。

export function DO_IF(condition) {
    return {
        THEN_DO: doTrue => ({
            ELSE_DO: doFalse => 
                data => DO(condition(data)
                    ? doTrue 
                    : doFalse)(data),
        }),
    };
}

实施情况更为复杂,通常是权衡的,但是作为交换,我们抽象了要使用的地方的复杂性。

/* Fragment of do-mk5.js */

const convertTemperature = DO_IF(isCelsius)
    .THEN_DO(celsiusToFahrenheit)
    .ELSE_DO(fahrenheitToCelsius);

在这一点上,我认为有条件的方法是个人喜好的问题,因为它们具有相同的效果。我们可能要采用的另一个常见的处理机制是循环,因此这是我们的下一个示例。

示例6:DO_WITH

Example Six

在此示例中,我们想在一个呼叫中处理多个输入值。这是我们将作为对象的属性提出的测试用例,我们将向DO组成提供。每个属性的名称是单个计算的输入值,属性值是比较的预期输出。

/* Fragment of the testcases.js file (testcase 5) */

{
    '100°C': '212°F',
    '0°C': '32°F',
    '-40°C': '-40°F',
    '212°F': '100°C',
    '32°F': '0°C',
    '-40°F': '-40°C',
}

通过上述值通过DO进程,我们得到以下结果。

Example 6 - Single run test results

进行比较,这是源代码。

/* Fragment of do-mk6.js */

const extractInputs = _ =>
    Object.entries(_).map(([input, expected]) => ({
        input,
        expected,
    }));

const convertInput = _ => ({ ..._, actual: 

convertTemperature(_.input) });

const evaluateResult = _ =>
    ({ ..._, result: _.expected === _.actual });

console.table(
    DO_WITH(
        extractInputs, 
        convertInput, 
        IDENTITY, 
        evaluateResult
    )(testCases[4])
);

示例7:结局 - 对象处理

Example Seven

在这里,我们是最后一个示例,在该示例中,我们处理在对象中保存的复杂数据。为此,我们将通过processObject功能采用更多的单声道样式,该功能将用于包装genericOperations对象提供的部分应用的数学操作。包装器被用作适配器,将输入数据对象变成专用功能可以使用的东西。它还采用计算的结果并将其转换回另一个对象,准备下一个调用。

function processObject(func) {
    return tempObj => ({
        num: func(tempObj.num),
        scale: tempObj.scale,
    });
}

function extractTemp(tempStr) {
    const [temp, scale] = tempStr.split(/°/);
    return { num: +temp, scale };
}

function convertToString(tempObj) {
    const newScale = isCelsius(tempObj) ? 'F' : 'C';
    return `${tempObj.num}°${newScale}`;
}

extractTemp函数将测试用例字符串转换为我们将通过DO进程的对象。相反,convertToString将将完成的对象编码回一个字符串以进行验证和呈现。但是,所有processObject包装器的转换功能看起来确实有些奇怪。

// Conversion functions
const celsiusToFahrenheit = DO(
    processObject(mulM(9)),
    processObject(divM(5)),
    processObject(addM(32))
);

const fahrenheitToCelsius = DO(
    processObject(subM(32)),
    processObject(mulM(5)),
    processObject(divM(9))
);

但是convertTemperature函数看起来与示例四的函数并没有大不相同。

const convertTemperature = DO(
    extractTemp,
    IF(isCelsius,
        celsiusToFahrenheit, 
        fahrenheitToCelsius),
    convertToString
);

总之

在我的脑海中,我相信其他人已经说过,功能性编程都是关于构图的。将较小(和简单的)功能组合在一起,将功能融合在一起,使其同时执行所有功能。

JavaScript正在逐渐获得FP风格的功能,可以极大地增强您的工具套件。您不必完全使用FP或OOP。开发人员应努力使用最合适的工具来制定手头问题所需的解决方案。我已经将FP和OOP功能结合起来效果很好,并生成了更易于理解,测试和维护的解决方案。


脚注

  1. 必须说,正如Haskell文档的this page所示,Haskell中的Do表示法并不普遍。
  2. JS中有一个用于管道操作员语法的ECMAScript proposal,但仅在阶段2中。它与Haskell的DO注释不完全相同,而是我们上面实现的某些功能的替代方法。