原文链接:levelup.gitconnected.com/understandi…
最近在看函数式编程,发现这篇文章不错,就边看边翻译了~ 希望能给大家带来些许便利~
当谈到编程范式时,最流行的便是面向对象编程;大部分开发人员都能够描述类的作用或如何实例化对象。
函数式编程比 OOP 出现的时间要久。最好的例子是 LISP,它的第一个规范是在 1958 年编写的。然而,与 OOP 不同的是,能够理解函数概念(例如 Purity、Currying 或 Function 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 开发人员,你很可能遇到过回调函数,例如 setTimeout 和 setInterval 的回调函数。下面是如何使用函数作为参数的完美示例:
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。像灭霸一样,副作用是不可避免的。
那么我们如何在函数式编程的同时设法使用不纯的函数呢?首先,你需要接受这样一个事实,即在你的日常工作中不可能实现 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 的输入运行这个斐波那契函数需要很长时间才能完成。
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 运行了这些示例:
如你所见,对于每个使用的输入,_.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 来实现:它使一个函数的结果成为下一个函数的参数。
函数组合有两种形式:Pipelining 和 Composing。 它们几乎相同,但又不同。 一个从左到右运行,而另一个以相反的方向运行。
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 组合
正如上面提到的,组合的工作方式与流水线非常相似。因此,让我们看一下相同的示例,但这次使用 compose 和 flowRight。
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");
输出如下:
这是完整的代码及其实时示例:
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个小时,本人受益匪浅,希望同时能给大家提供些许帮助~