理解 Javascript 中的函数式编程——完整指南「译文」

873 阅读15分钟

原文链接:levelup.gitconnected.com/understandi…

最近在看函数式编程,发现这篇文章不错,就边看边翻译了~ 希望能给大家带来些许便利~

当谈到编程范式时,最流行的便是面向对象编程;大部分开发人员都能够描述类的作用或如何实例化对象。

函数式编程比 OOP 出现的时间要久。最好的例子是 LISP,它的第一个规范是在 1958 年编写的。然而,与 OOP 不同的是,能够理解函数概念(例如 PurityCurryingFunction Composition)的开发人员并不多。

Javascript 不是函数式编程语言,至少,这不是它的主要面向范式。这并不意味着我们不能通过使用 Lodash、Underscore、RambdaJS 等库或仅使用 Vanilla Javascript 以函数式方式工作。

今年我有机会阅读了 Federico Kereki 所著的 “Mastering Javascript Functional Programming” 这本书。这是一本我个人推荐给想要深入了解函数式编程概念并且是用Javascript 做开发的人员的书。在本文中,我将解释我从 Kereki 的书中学到的东西。我的目的不是重复书中的所有内容——如果你需要,请阅读本书!- 而是提供摘要和要点。

注意:本文面向中级/高级级别的 Javascript 开发人员。

Function 是一等对象

在进行函数式编程时,函数是一等对象。这意味着你可以像使用变量或常量一样使用函数。你可以将函数与其他函数结合并在流程中生成新函数;可以将函数连接在一起以进行复杂的计算。总的来说,功能就是一切!

在 Javascript 中,你可以通过多种方式定义函数。我发现使用箭头形式很方便——除非你想使用 this 状态。例如:

const add = (a, b) => a + b;

如果你是一位经验丰富的 JS 开发人员,你很可能遇到过回调函数,例如 setTimeoutsetInterval 的回调函数。下面是如何使用函数作为参数的完美示例:

var doSomething = function(status) {
    // Doing something
};
var foo = function(data, func) { 
    // Passing function as a parameter
    func(data);
}
foo("some data", doSomething);

纯函数的重要性

函数式编程不只是使用函数而已,你还需要保持你的函数纯粹。这是什么意思呢?根据 Federico Kereki 的说法,当它满足以下条件时,你就有了一个纯函数:

  • 给定相同的参数,函数总是计算并返回相同的结果
  • 在计算其结果时,该函数不会引起任何可观察到的副作用,包括输出到 I/O 设备、对象突变、更改为函数外部的程序状态等。

让我们看一个例子:

const getRectangleArea = (sideA, sideB) => sideA * sideB;
console.log(getRectangleArea(2, 3)); // 6

这个函数是纯函数,因为对于给定的参数“2”和“3”,它总是返回结果“6”。这个函数根本不影响它的外部上下文。一切都在它内部发生,它在不改变(变异)其参数的情况下产生一个新结果。 所以我们可以自信地说:它没有副作用,是一个纯粹的函数。

差不多就是这样。如果它没有副作用,那么你的函数就是纯函数。是不是很简单?但是到底什么是副作用呢?下面几条是我们常犯的错误:

  • 使用全局变量(除非它们是常量)。
  • 作为参数接收的变异对象。
  • 执行任何类型的 I/O、使用,更改文件系统、更新数据库、调用外部 API 等操作。

最后但并且很重要的一点是,使用一个碰巧不纯的函数。Federico 说不纯函数是“会传染的”。所以如果你的函数使用了一些会引起副作用的东西,你的函数就会变得不纯。

处理副作用

纯函数听起来不错,但是,除非你正在开发一个简单的计算器,否则你很可能需要使用异步操作,例如访问数据库或调用外部 API。像灭霸一样,副作用是不可避免的。

灭霸.gif

那么我们如何在函数式编程的同时设法使用不纯的函数呢?首先,你需要接受这样一个事实,即在你的日常工作中不可能实现 100% 的纯函数式编程。并且,这也不应该是你的目标。正如费德里科在他的书中所说:

「 但是,不要陷入以 FP 为目标的陷阱!与所有软件工具一样,将 FP 仅视为达到目的的一种手段。函数式代码不仅仅因为是函数式的……使用 FP 和任何其他技术一样可以编写糟糕的代码!」

Federico 有一个名字:“Sorta Functional Programming”。 一般而言,我们的目标应该是将代码的不纯部分与纯部分分离。请看下面的示例:

/*
  file.txt content:
  Hello file!
*/

const fs = require("fs").promises;
const path = require("path");

const getFileContent = fileName =>
  fs.readFile(path.join(__dirname, fileName), "utf-8");

const getWordsAmount = content => content.split(" ").length;

// 假设这是在进行 API 调用
const sendWordAmount = wordAmount => Promise.resolve({ statusCode: 200 });

(async () => {
  // 读取文件(不纯函数)
  const fileContent = await getFileContent("file.txt");

  // Counting words(纯函数)
  const wordAmount = getWordsAmount(fileContent);

  console.log(`File has ${wordAmount} words`);

  // 将结果发送到 API(不纯函数)
  const response = await sendWordAmount(wordAmount);
  console.log("Sending to API", response);
})();

这个 Node 脚本正在读取一个文本文件,计算它的字数,并将结果发送到一个外部 API——嗯,不一定是“实际”的 API,我们假装它是真实的——只有一个纯函数(计算字数),其余不纯。通过这种方式,我们可以将代码的不纯部分与纯部分分开。

过去的策略效果很好。 但是,函数调用是以命令形式(顺序指令)进行的。一个更函数化的替代方法是将不纯函数注入纯函数中。我们看另一个解释这一点的例子:

假设我们想生成一个范围内的随机整数。 使用 Mozilla 文档建议的解决方案,你可以这样操作:

function getRandomInt(min, max) {
    return Math.floor(Math.random() * (max - min)) + min;
}

这个方法的问题是 Math.random 的不纯度。因此,为了将这个方法与 getRandomInt 函数解耦,我们可以这样做:

function getRandomInt(min, max, random = Math.random) {
    return Math.floor(random() * (max - min)) + min;
}

就是这样。 我们已经解耦了 getRandomInt 函数。注意我们是如何使用 ES6 默认参数的,这样我们就不必在每次调用时都传递 Math.random 引用。不过,我知道你在想什么……

你告诉我,如果一个纯函数使用了不纯的东西,这个函数也会变得不纯。

你是对的。 只要传递的函数是不纯的,这个函数就不会以纯的方式运行。 但是,这在运行测试时给了我们很大的优势。 让我们看看这个使用 Test 的例子:

function getRandomInt(min, max, random = Math.random) {
  return Math.floor(random() * (max - min)) + min;
}

describe("My Tests", () => {
  it("should generate a random number", () => {
    expect(getRandomInt(0, 10, () => 0.7)).toBe(7);
    expect(getRandomInt(0, 10, () => 0.6)).toBe(6);
    expect(getRandomInt(0, 10, () => 0.5)).toBe(5);
  });
});

在上面的例子中,我们把默认函数改成了作为第三个参数传递的函数(一个只返回一个值的箭头函数)。通过这样做,我们使随机函数具有确定性,因此是纯的,这使我们更容易进行测试断言。

使用高阶函数

如果你使用 JS 已经有一段时间了,那么你很可能已经偶然发现了 map、filter、reduce 等函数。这些都是高阶函数或 HOF 的好例子。它们是将其他函数作为参数的函数。它们可以返回一个新函数或基于传递给它的函数的结果。

示例一:计算时间差

假设你想记录一个函数完成所需的时间。 你的第一个想法可能是这样的:

const calculateRectangleArea = (sideA, sideB) => sideA * sideB;
(() => {
  const startTime = Date.now();
  const rectangleArea = calculateRectangleArea(2, 3);
  const time = Date.now() - startTime; 
  console.log(`Function calculateRectangleArea took ${time} to complete`);
})();

这个解决方案效果很好,但是如果我们想测量其他函数的时间怎么办?我们是否必须一遍又一遍地重复这段代码?没门!因此,让我们实现一个 HOF,它将函数作为参数传入并生成一个记录执行时间的新函数。我们将称之为 addTiming

const logTime = (message, fname, startTime, endTime) =>
  console.log(`${fname}: ${message} - ${startTime - endTime}`);

const getCurrentTime = () => Date.now();

const addTiming = (fn, getTime = getCurrentTime, logFnTime = logTime) => (
  ...args
) => {
  const startTime = getCurrentTime();
  try {
    const valueToReturn = fn(...args);
    logFnTime("Normal execution", fn.name, startTime, getTime());
    return valueToReturn;
  } catch (err) {
    logFnTime("Error thrown", fn.name, startTime, getTime());
  }
};

// Example
const calculateRectangleArea = (sideA, sideB) => sideA * sideB;
addTiming(calculateRectangleArea)(2, 3);
// -> calculateRectangleArea: Normal execution - 0

正如你在上面看到的,我们正在实现一个带有三个参数的 HOF,但只有一个是强制性的(第一个)。第一个是我们要环绕的目标函数,第二个是用于获取当前时间的函数,第三个是用于记录时间的函数。

该函数将首先获取当前时间,然后它将调用带有相应参数的目标函数——我们使用扩展运算符获得——然后,它将执行并记录目标函数完成所需的时间。最后,它会返回相应的结果。还有一些错误处理,以防函数没有按预期结束。

示例二:记忆功能

记忆(或缓存结果)是 HOF 的另一个有趣的特性,通常被忽视。正如我们之前提到的,在使用纯函数时,我们可以确保对于任何给定的参数集,它总是会返回相同的特定结果。这意味着我们可以将这些结果保存在内存中,以便在以后的调用中使用它们。在处理需要大量时间才能完成的计算时,这种记忆技术尤其重要。

以斐波那契函数为例:

const fib = n => {
  if (n === 0) {
    return 0;
  } else if (n === 1) {
    return 1;
  } else {
    return fib(n - 2) + fib(n - 1);
  }
};

这个斐波那契函数使用递归,它会随着输入的增加而增加其执行时间。我们可以使用我们最近创建的 addTiming 函数来验证:

addTiming(fib)(10); // fib: Normal execution - 0
addTiming(fib)(20); // fib: Normal execution - 1
addTiming(fib)(30); // fib: Normal execution - 11
addTiming(fib)(40); // fib: Normal execution - 1447
addTiming(fib)(50); // fib: Normal execution - 181611

上面显示的时间可能与你在 PC 中看到的时间不同,但除非你有一台量子计算机——我敢打赌你没有 ^_^ 以 50 的输入运行这个斐波那契函数需要很长时间才能完成。

等待斐波那契.jpeg

Waiting for the Fibonacci(50) function to complete be like…

那么我们如何优化它呢?当然,通过使用记忆。我们将使用 Lodash 提供的 HOF,而不是重新发明轮子,实现我们自己的记忆解决方案。

注意:如果你从未使用过 Lodash,你可以在这里查看它的 文档

下面是带有实时示例的完整解决方案:

const _ = require("lodash");

const logTime = (message, fname, startTime, endTime) =>
  console.log(`${fname}: ${message} - ${endTime - startTime} ms`);

const getCurrentTime = () => Date.now();

const addTiming = (fn, getTime = getCurrentTime, logFnTime = logTime) => (
  ...args
) => {
  const startTime = getCurrentTime();
  try {
    const valueToReturn = fn(...args);
    logFnTime("Normal execution", fn.name, startTime, getTime());
    return valueToReturn;
  } catch (err) {
    logFnTime("Error thrown", fn.name, startTime, getTime());
  }
};

// fib 不能再是常量了,因为我们需要覆盖它的引用
let fib = n => {
  if (n === 0) {
    return 0;
  } else if (n === 1) {
    return 1;
  } else {
    return fib(n - 2) + fib(n - 1);
  }
};

// 注释掉下面这行,查看 fib 调用在没有使用 _.memoize() 的情况下的运行
fib = _.memoize(fib);

console.log(addTiming(fib)(10));
console.log(addTiming(fib)(20));
console.log(addTiming(fib)(30));
console.log(addTiming(fib)(40));
console.log(addTiming(fib)(50));

如你所见,我们使用 let 而不是 const 来定义斐波那契函数。这样做是因为我们需要原始函数引用被 _.memoize() 覆盖。否则,它将无法正常工作。

让我们并排比较这两个执行输出。我使用 CodeSandbox.io 运行了这些示例:

运行结果对比.png

如你所见,对于每个使用的输入,_.memoize() 几乎不需要任何时间(少于 1 毫秒)即可完成。 而未更改的函数最多需要 182,248 毫秒(超过 2 分钟)。这是一个非常显着的差异。

通过声明式工作避免循环

正如在上一部分中提到的,Javascript 已经包含一系列内置高阶函数 (HOF)。这些函数大多数用于处理数组或对象集合。如果你正在阅读本文,那么你很可能已经是一名经验丰富的 JS 开发人员。所以这里不会花太多时间详细解释每一个。

本文将要描述的函数:

  • Reduce
  • Map
  • ForEach
  • Filter
  • Find
  • Every and Some

注意:如果您已经知道上面这些方法的工作原理,请跳过这部分。

使用 Reduce 方法计算结果

假设你有一个数字数组,你想计算它的平均值。实现这一点的最佳功能方法是使用内置的 HOF reduce。 例如:

const getAverage = myArray =>
  myArray.reduce((sum, val, ind, arr) => {
    sum += val;
    return ind === arr.length - 1 ? sum / arr.length : sum;
  }, 0);
console.log("Average:", getAverage([22, 9, 60, 12, 4, 56])); 
// Average: 27.166666666666668

reduce()  方法对数组中的每个元素执行一个由您提供的 reducer 函数(升序执行),将其结果汇总为单个返回值。(源自 Mozilla

使用 Map 方法创建新数组

假设你有一个数组 markers,其中包含多个国家/地区的纬度和经度。

const markers = [
  { name: "UY", lat: -34.9, lon: -56.2 },
  { name: "AR", lat: -34.6, lon: -58.4 },
  { name: "BR", lat: -15.8, lon: -47.9 },
  { name: "BO", lat: -16.5, lon: -68.1 }
];

假设现在让我们创建一个只有纬度值的数组。这可以使用 map 方法轻松实现,如下所示:

console.log("Lat values:", markers.map(x => x.lat));
// Lat values: [ -34.9, -34.6, -15.8, -16.5 ]

Map 对象保存键值对,并且能够记住键的原始插入顺序。任何值都可以作为一个键或一个值。(源自 Mozilla

使用 ForEach 方法循环

有时,我们唯一想做的就是遍历一系列值或对象。在这种情况下我们可以使用 forEach。例如,假设我们要记录所有标记数组数据:

const logMarkersData = markers => {
  console.log("Data provided:");
  markers.forEach(marker => console.log(`Country: ${marker.name} Latitude: ${marker.lat} Longitude: ${marker.lon}`));
};
logMarkersData(markers);
/*
Data provided:
Country: UY Latitude: -34.9 Longitude: -56.2
Country: AR Latitude: -34.6 Longitude: -58.4
Country: BR Latitude: -15.8 Longitude: -47.9
Country: BO Latitude: -16.5 Longitude: -68.1
*/

forEach() 方法对数组的每个元素执行一次给定的函数。(源自 Mozilla

使用 Filter 方法过滤数组元素

继续以 markers 数组为例,假设我们要过滤来自首字母为“B”的国家的数据。 filter 方法可以帮助您:

console.log(
  "Data of countries starting with B:",
  markers.filter(mark => mark.name.charAt(0) === "B")
);
/*
Data of countries starting with B: [ { name: 'BR', lat: -15.8, lon: -47.9 }, { name: 'BO', lat: -16.5, lon: -68.1 } ]
*/

filter() 方法创建一个新数组, 其包含通过所提供函数实现的测试的所有元素。(源自 Mozilla

使用 Find 和 FindIndex 方法查找特定元素

来个更具体的假设:我们只需要来自巴西 (BR) 的数据。使用find方法:

console.log("Brazil Data:", markers.find(m => m.name === "BR"));
// Brazil Data: { name: 'BR', lat: -15.8, lon: -47.9 }

find() 方法返回数组中满足提供的测试函数的第一个元素的值,否则返回 undefined。(源自 Mozilla

但是如果你只想知道索引,可以使用findIndex方法。

console.log("Brazil Data Index:", markers.findIndex(m => m.name === "BR"));
// Brazil Data Index: 2

findIndex() 方法返回数组中满足提供的测试函数的第一个元素的索引,若没有找到对应元素则返回-1。(源自 Mozilla

使用 Every 和 Some 方法链接逻辑操作

当您需要确定数组中的所有元素是否符合某些特定逻辑时,它们尤其有用。这相当于按顺序使用 AND/OR 运算符。所以不要做这样的事情:

if (arr[0] > 0 && arr[1] > 0 ..... arr[n] > 0) {...}

你可以这样做:

if (arr.every(item => item > 0)) {...}

这同样适用于 OR 运算符,但这次使用 some 方法代替。

假设我们有一个数字序列(例如:4、8、15、16、23 和 42),我们想确定至少含有一个偶数。对于这种情况,需要使用 some 方法:

const lostNumbers = [4, 8, 15, 16, 23, 42];
console.log(
  "Does it contain even numbers?",
  lostNumbers.some(n => n % 2 === 0)
);
// Does it contain even numbers? true

some() 方法测试数组中是不是至少有1个元素通过了被提供的函数测试。它返回的是一个Boolean类型的值。(源自 Mozilla

如果我们想确定所有的数组编号是否都是偶数,我们需要使用 every 方法:

console.log("Are all even numbers?", lostNumbers.every(n => n % 2 === 0));
// Are all even numbers? false

every() 方法测试一个数组内的所有元素是否都能通过某个指定函数的测试。它返回一个布尔值。(源自 Mozilla

使用 Function Composition 来组合函数

理想情况下,我们的函数应该很小。它们应该只做一件事并且把它做对。那么我们如何将这些“小”功能结合起来一起工作呢?通过 Function Composition 来实现:它使一个函数的结果成为下一个函数的参数。

函数组合有两种形式:PipeliningComposing。 它们几乎相同,但又不同。 一个从左到右运行,而另一个以相反的方向运行。

Pipelining

注意:在 Federico Kereki 的书中,他教授了如何从头开始实现“Pipelining”功能。 但在这里我们将使用 Lodash 提供的解决方案。

对于 Lodash,我们有两种选择。1、使用来自标准 API 的方法流;2、使用来自 FP API 的方法管道。 让我们用这两种方式去探索一个例子。

假设我们需要连接到仍然使用 XML 作为数据格式的遗留 Web 服务(SOAP WS 或类似服务)。 这个 WS 提供了一些书籍信息,如下所示:

<?xml version="1.0"?>
<Library>
    <Book>
        <Author>Sam Newman</Author>
        <Title>Building Microservices: Designing Fine-Grained Systems</Title>
        <Year>2015</Year>
        <Price>30.38</Price>
    </Book>
    <Book>
        <Author>Federico Kereki</Author>
        <Title>Mastering JavaScript Functional Programming: In-depth guide for writing robust and maintainable JavaScript code in ES8 and beyond</Title>
        <Year>2017</Year>
        <Price>22.39</Price>
    </Book>
    <Book>
        <Author>Robert C. Martin </Author>
        <Title>Clean Code: A Handbook of Agile Software Craftsmanship</Title>
        <Year>2008</Year>
        <Price>26.39</Price>
    </Book>
</Library>

我们的任务如下。拿到上面 response 的并且:

  • 按价格(升序)和…
  • 以JSON格式打印信息(使用console.log)

事实证明,(在 JS 中)处理 XML 格式的数据是非常痛苦的。幸运的是,我们可以使用几个库将 XML 转换为方便的 JS 对象。在这个例子中,我们将使用其中的一个库 -- xml-js。

首先,让我们从“网络服务”中获取数据。不幸的是,我们手头没有。所以我们伪造一下它:

// Let's pretend this is a Webservice call
const getingWsData = () =>
   Promise.resolve(fs.readFileSync(`${__dirname}/books.xml`, "utf8"));

接下来,我们需要从 XML 响应中提取所需的信息。我们使用 xml2js 库方法来获取值,使用 Lodash 的 get 方法:提供一个对象路径,map 方法:用于解析初始化 XML 文本。

const transformToJsObject = xmlData =>
  _(xmlJs.xml2js(xmlData, { compact: true })) // Transforming to JS
    .get("Library.Book") // Getting Books reference
    .map(book => ({ // Interating books array to generate new one
      author: book.Author._text,
      title: book.Title._text,
      year: Number(book.Year._text),
      price: Number(book.Price._text)
    }));

注意:这里不包括对 XML 数据的任何校验。我们假设它的格式正确,year 和 price 的值是数字。

最后,按价格金额排序。在这里,我们可以使用内置的 sort 方法。 但是它会改变目标数组,这使我们的函数变得不纯。所以我们最好使用 Lodash 等价的 sortBy 如下:

const sortByPrice = booksArray => _.sortBy(booksArray, "price");

我们已经定义了完成指定任务所需的所有功能。但是我们如何将它们连接在一起呢?你是不是想到以命令式形式执行此操作:

let booksArray = transformToJsObject(xmlData);
booksArray = sortByPrice(booksArray);
const jsonData = JSON.stringify(booksArray);
console.log(jsonData);

它运行的很好,但需要太多行!并且,我们需要使用多少个中间变量/常量。所以我们简化一下:

console.log(JSON.stringify(sortByPrice(transformToJsObject(xmlData))));

但现在一切都变成了一个不可读的括号地狱。所以我们应用流水线技术。 首先使用 Lodash 的 flow 方法:

_.flow(
   transformToJsObject,
   sortByPrice,
   JSON.stringify,
   console.log
)(xmlData);

也可以使用 FP Lodash pipe

fp.pipe(
   transformToJsObject,
   sortByPrice,
   JSON.stringify,
   console.log
)(xmlData);

如上,两个高阶函数几乎相同。两者都以相同的顺序接收参数,即从左到右,最左边的函数将是最先被调用的函数。 这里有完整的例子:

const _ = require("lodash");
const fp = require("lodash/fp");
const xmlJs = require("xml-js");
const fs = require("fs");

// Let's pretend this is a Webservice call
const getingWsData = () =>
  Promise.resolve(fs.readFileSync(`${__dirname}/books.xml`, "utf8"));

const transformToJsObject = xmlData =>
  _(xmlJs.xml2js(xmlData, { compact: true }))
    .get("Library.Book") // Let's assume the XML is validated
    .map(book => ({
      author: book.Author._text,
      title: book.Title._text,
      year: Number(book.Year._text),
      price: Number(book.Price._text)
    }));

const sortByPrice = booksArray => _.sortBy(booksArray, "price");

(async () => {
  const xmlData = await getingWsData(); // This function is impure so it's out of the pipeline

  console.log("\nImperative calls... eww!!");

  let booksArray = transformToJsObject(xmlData);
  booksArray = sortByPrice(booksArray);
  const jsonData = JSON.stringify(booksArray);
  console.log(jsonData);

  console.log("\nPipelining using flow (Lodash standard API)");
  _.flow(
    transformToJsObject,
    sortByPrice,
    JSON.stringify,
    console.log
  )(xmlData);

  console.log("\nPipelining using pipe (Lodash FP API)");
  fp.pipe(
    transformToJsObject,
    sortByPrice,
    JSON.stringify,
    console.log
  )(xmlData);
})();

Composing 组合

正如上面提到的,组合的工作方式与流水线非常相似。因此,让我们看一下相同的示例,但这次使用 composeflowRight

const _ = require("lodash");
const fp = require("lodash/fp");
const xmlJs = require("xml-js");
const fs = require("fs");

// Let's pretend this is a Webservice call
const getingWsData = () =>
  Promise.resolve(fs.readFileSync(`${__dirname}/books.xml`, "utf8"));

const transformToJsObject = xmlData =>
  _(xmlJs.xml2js(xmlData, { compact: true }))
    .get("Library.Book") // Let's assume the XML is validated
    .map(book => ({
      author: book.Author._text,
      title: book.Title._text,
      year: Number(book.Year._text),
      price: Number(book.Price._text)
    }));

const sortByPrice = booksArray => _.sortBy(booksArray, "price");

(async () => {
  const xmlData = await getingWsData(); // This function is impure so it's out of the pipeline

  console.log("\nImperative calls... eww!!");

  let booksArray = transformToJsObject(xmlData);
  booksArray = sortByPrice(booksArray);
  const jsonData = JSON.stringify(booksArray);
  console.log(jsonData);

  console.log("\nComposing using flowRight (Lodash standard API)");
  _.flowRight(
    console.log,
    JSON.stringify,
    sortByPrice,
    transformToJsObject
  )(xmlData);

  console.log("\nComposing using compose (Lodash FP API)");
  fp.compose(
    console.log,
    JSON.stringify,
    sortByPrice,
    transformToJsObject
  )(xmlData);
})();

如上, flowRight 和 compose 方法的工作方式与 flow 和 pipe 类似。唯一的区别是参数的顺序现在颠倒了。但是输出结果和之前完全一样。

看到这个,你可能想知道:我应该使用哪个?答案是你喜欢的那个!在我个人看来,我更喜欢使用流水线,因为我们通常从左到右阅读,所以我更自然地朝那个方向前进。但是其他人可能更喜欢使用组合,因为初始参数更接近接收它的函数。无论你使用哪个选项,只要你了解自己在做什么,都可以。

柯里化的重用性

在前面的部分中,我们看到了如何使用 Function Composition 来组合函数。那么现在我有一个问题:你是否注意到这些功能的一个特殊特征?

我指的是 pipelines 和 composers 中使用的函数。它们只有一个参数,由于前一个函数只能有一个返回值。因此,为了使我们的函数与 pipelines 和 composers 一起工作,默认情况下它们必须是一元的(一个参数)。柯里化是一种将非一元函数转换为一元形式的方法。

让我们通过一个示例来了解这一点。在这里你可以看到一个将三个数字相加的函数,没什么新鲜的:

const sum = (a, b, c) => a + b + c;

这个函数的柯里化版本将是这样的:

const sum = (a) => (b) => (c) => a + b + c;
sum(1)(2)(3); // *Result is 6*

我知道你在想什么……我们到底能从中得到什么?答案很简单:如果函数的初始参数在多次调用中没有改变,你可以创建函数的柯里化版本。例如,假设你有一个函数可以计算给定百分比的折扣。如下:

const calculateDiscount = (discount, price) => price - (price * discount) / 100;
console.log(calculateDiscount(10, 2000)); // 1800

现在,想象一种情况,我们需要对不同的产品(价格不同)应用始终相同的折扣。这意味着多次重复相同的第一个参数:

console.log(calculateDiscount(10, 2000)); // 1800
console.log(calculateDiscount(10, 500)); // 450
console.log(calculateDiscount(10, 750)); // 675
console.log(calculateDiscount(10, 900)); // 810

多次重复单个参数可能没什么大不了的,但是想象一下重复参数数量较多的情况(2、4、5 等)所以我们将柯里化应用到这个 calculateDiscount 函数中。为此,我们将使用 Lodash 的 curry 方法:

const _ = require("lodash");

const calculateDiscount = (discount, price) => price - (price * discount) / 100;
const apply10percDiscount = _.curry(calculateDiscount)(10);

console.log(apply10percDiscount(1000)); // 900
console.log(apply10percDiscount(500)); // 450
console.log(apply10percDiscount(750)); // 675
console.log(apply10percDiscount(900)); // 810

在上面这段代码中,我们将柯里化应用于 calculateDiscount 函数,同时将第一个参数(折扣百分比)传递给结果函数。现在我们有了一个新函数,其唯一目的是计算任何给定价格的 10% 折扣。

当然,这并不是您可以使用柯里化的唯一实际案例。假设你想为一个事件创建多个侦听器,你可以将柯里化应用于负责添加侦听器的函数,并根据需要使用该(柯里化)函数。像这样,还有很多其他的例子。

部分应用的可重用性

当我们的常量参数按顺序排列时,柯里化很有用。但有时情况并非如此,我们必须处理固定参数不遵循任何特定顺序的情况。此时就需要 Partial Application(部分应用)技术。

下面是一个简单的 Winston 记录器实例,我们看看它是如何工作的:

const logger = winston.createLogger({
  transports: [new winston.transports.Console()]
});

注意:Winson 是一个可以轻松管理 NodeJS 日志的库。 如果您想了解更多信息,请前往 github

假设我们正在开发一个包含多个步骤的系统——就像 pipeline。我们想记录与特定步骤相对应的消息,因此需要为我们的系统提供一些“可跟踪性”。例如:step: "Order Preparation", message: "Assigning Carrier to order XXX"。下面我们定义一个函数来实现一下:

const logMessage = (level, message, step, loggerFn = logger) =>
  loggerFn[level]({ message, step });

如上,我们将记录器实例注入到 logMessage 函数中。现在我们调用这个函数来开始记录消息:

logMessage("info", "Assigning Carrier to order XXX", "Order Preparation")

这种方法的问题在于,如果我们必须在同一步骤中以相同(信息)级别记录多条消息,我们将需要多次重复相同的两个参数“信息”和“订单准备”。 所以让我们应用部分应用程序(使用 Lodash)来修复这两个参数。

const loggerStepInfo = _.partial(logMessage, "info", _, "Order Preparation");

Lodash 使用 _ 来表示一个“占位符”参数,它(在这种情况下)是唯一一个可以更改的参数。 但是,如果需要,我们可以设置多个占位符参数。 这是柯里化和部分应用之间的主要区别之一。 结果函数不必是一元的。

现在让我们用一些示例调用来测试我们的结果函数:

loggerStepInfo("Doing something");
loggerStepInfo("Doing another thing on same step");
loggerStepInfo("Doing some last thing");

输出如下:

输出.png

这是完整的代码及其实时示例:

const winston = require("winston");
const _ = require("lodash");

// 创建一个 winston 实例
const logger = winston.createLogger({
  transports: [new winston.transports.Console()]
});

// 定义日志消息函数
const logMessage = (level, message, step, loggerFn = logger) =>
  loggerFn[level]({ message, step });

// level 和 step 参数是固定的。 Message 是唯一可以使用的
const loggerStepInfo = _.partial(logMessage, "info", _, "Order Preparation");
// Examples:
loggerStepInfo("Doing something");
loggerStepInfo("Doing another thing on same step");
loggerStepInfo("Doing some last thing");

总结

使用函数式编程不是灵丹妙药。因此,由你决定是否使用你在这里或其他地方看到的任何 FP 技术。

以我的拙见,在设计和实现复杂算法或执行不需要访问外部资源(文件、数据库、API)的复杂计算时,我喜欢以函数式和声明式的方式进行。因为我可以轻松地对所有内容进行单元测试和解耦。但是如果我必须密集地处理外部资源,我会使用众所周知的命令式形式。这只是我个人的习惯!你可能有不同的想法!

我认为这里的关键要素是将项目的不纯部分与纯部分分开。这样你就可以轻松地解耦代码,并在你觉得方便时应用 FP 技术。

在这篇文章中,我没有提到其他 FP 相关的概念,如递归优化、monads、functors 等。我发现它们非常易于理解并且几乎没有实际用例,所以我决定跳过它们。如果你仍然对它们感到好奇,建议你阅读 Federico Kereki 的书 Mastering JavaScript Functional Programming

译者

截止到这里,已翻译完整篇文章。历时4个小时,本人受益匪浅,希望同时能给大家提供些许帮助~