六、生成函数——高阶函数
在第五章,编程以声明的方式——一个是水獭风格,我们曾与一些预定义的高阶函数,能够看到它们的使用让我们写声明性的代码,这样我们可以获得可理解性和紧性。 在本章中,我们将进一步研究高阶函数并发展我们自己的函数。 我们可以将我们将要得到的结果大致分为三类:
:在保留原有功能的同时增加一些新功能。 在这个团体中,我们可以考虑日志(添加日志产能任何函数),时间(生产时间和性能数据对于一个给定的函数),和记忆(这个缓存结果,以避免未来返工)。
* 功能改变:与原有功能有一些关键点的不同。 在这里,我们可以包括once()
函数(我们把它写在第二章,思维功能——第一个例子【显示】),改变了原始的函数,使它只运行一次,等功能not()
或invert()
,这改变函数返回,arity-related 转换, 它产生一个带有固定数量参数的新函数。
* :这些产品提供了新的操作,将函数转换为承诺,允许增强搜索函数,或从对象分离方法,以便我们可以在其他上下文中使用它们,就像它们是通用函数一样。 我们将离开一个特例-传感器-第八章,*连接功能——流水线和 Composition*。
包装函数—保持行为
在本节中,我们将考虑一些高阶函数,它们围绕其他函数提供了包装,以在不改变其原始目标的情况下以某种方式增强它们。 的 d的结果模式(我们将回顾在第 11 章,实现设计模式——功能),我们也可以谈论【显示】decorator。 此模式基于向对象(在我们的例子中是函数)添加某些行为而不影响其他对象的概念。 装饰器这个术语也很流行,因为它在 Angular 等框架中使用,或者(在实验模式下)在 JavaScript 的通用编程中使用。
Decorators are being considered for general adoption in JavaScript, but are currently (December 2019) still at Stage 2, Draft level, and it may be a while until they get to Stage 3 (Candidate) and finally Stage 4 (Finished, meaning officially adopted). You can read more about the proposal for decorators at https://tc39.github.io/proposal-decorators/ and about the JavaScript adoption process itself, called TC39, at https://tc39.github.io/process-document/. See the Questions section in Chapter 11, Implementing Design Patterns – The Functional Way, for more information.
至于术语包装,它比你想象的更重要、更普遍; 事实上,JavaScript 广泛地使用它。 在哪里? 您已经知道对象属性和方法是通过点表示法访问的。 然而,您也知道您可以编写诸如myString.length
或22.9.toPrecision(5)
之类的代码——这些属性和方法从何而来,给定字符串和数字都不是对象? JavaScript 实际上围绕原始值创建了一个包装器对象。 这个对象继承所有适合包装的值的方法。 一旦完成了所需的计算,JavaScript 就会丢弃刚刚创建的包装器。 我们不能对这些临时包装器做任何事情,但是我们将回到一个关于包装器的概念,它允许对不属于适当类型的东西调用方法。 这是一个有趣的想法; 参考第 12 章,Building Better Containers - Functional Data Types,了解更多相关应用!
在本节中,我们将看三个例子:
- 向函数添加日志记录
- 从函数中获取计时信息
- 使用缓存(记忆)来提高函数的性能
让我们开始工作吧!
日志记录
让我们从一个常见的问题开始。 在调试代码时,您通常需要添加某种类型的日志信息,以查看是否调用了函数、参数是什么、返回了什么,等等。 (是的,当然,您可以简单地使用调试器并设置断点,但是容忍我的这个示例!) 如果要正常工作,这意味着您必须在进入和退出时修改函数本身的代码,以生成一些日志输出。 例如,你的原始代码可能如下所示:
function someFunction(param1, param2, param3) {
// *do something*
// *do something else*
// *and a bit more,*
// *and finally*
return *some expression*;
}
在这种情况下,您必须进行修改,使其看起来如下所示。 这里,我们需要添加一个auxValue
变量来存储我们想要记录和返回的值:
function someFunction(param1, param2, param3) {
console.log("entering someFunction: ", param1, param2, param3);
// *do something*
// *do something else*
// *and a bit more,*
// *and finally*
const auxValue = *some expression*;
console.log("exiting someFunction: ", auxValue);
return auxValue;
}
如果函数可以在多个地方返回,则必须修改所有的return
语句来记录将要返回的值。 如果您只是在动态地计算返回表达式,则需要一个辅助变量来捕获该值。
在下一节中,我们将学习日志记录和它的一些特殊情况,比如抛出异常的函数,以及以一种更纯粹的方式工作。
以功能方式进行日志记录
正如我们展示的那样,通过修改函数来进行日志记录并不困难,但是修改代码总是很危险的,容易发生事故。 所以,让我们戴上外交政策的帽子,想想一种新的方法来做这件事。 我们有一个执行某种工作的函数,我们想知道它接收到的参数和它返回的值。
在这里,我们可以编写一个只有一个参数的高阶函数——原始函数——并返回一个新函数,该新函数将依次执行以下操作:
- 记录接收的参数
- 调用原始函数,捕获其返回值
- 记录值
- 将它返回给调用者
可能的解决办法如下:
const addLogging = fn => (...args) => {
console.log(`entering ${fn.name}: ${args})`);
const valueToReturn = fn(...args);
console.log(`exiting ${fn.name}: ${valueToReturn}`);
return valueToReturn;
};
addLogging()
返回的函数行为如下:
- 第一行
console.log(...)
显示了原始函数的名称及其参数列表。 - 然后,调用原始函数
fn()
并存储返回值。 - 第二行
console.log(...)
再次显示了函数名及其返回值。 - 最后,返回
fn()
计算的值。
If you were doing this for a Node application, you would probably opt for a better way of logging by using libraries such as Winston, Morgan, or Bunyan, depending on what you wanted to log. However, our focus is on showing you how to wrap the original function, and the needed changes for using those libraries would be small.
例如,我们可以将它与即将到来的函数一起使用——我同意,这些函数是以一种过于复杂的方式编写的,只是为了有一个合适的示例! 我们将有一个函数,它通过改变第二个数的符号,然后把它加到第一个数上来完成减法。 下面的代码是这样做的:
function subtract(a, b) {
b = changeSign(b);
return a + b;
}
function changeSign(c) {
return -c;
}
subtract = addLogging(subtract);
changeSign = addLogging(changeSign);
let x = subtract(7, 5);
执行前一行的结果将是以下几行日志:
entering subtract: 7, 5
entering changeSign: 5
exiting changeSign: -5
exiting subtract: 2
我们在代码中所做的所有更改都是重新分配subtract()
和changeSign()
,这实际上用它们新的日志生成包装版本替换了它们。 对这两个函数的任何调用都将产生这个输出。
We'll see a possible error because we're not reassigning the wrapped logging function while memoizing in the following section.
这对于大多数函数来说都是可行的,但是如果包装的函数抛出异常会发生什么呢? 让我们来看看。
考虑到例外情况
让我们通过考虑调整来增强日志功能。 如果函数抛出错误,日志会发生什么? 幸运的是,这很容易解决。 我们只需要添加一个try/catch
结构,如下代码所示:
const addLogging2 = fn => (...args) => {
console.log(`entering ${fn.name}: ${args}`);
try {
const valueToReturn = fn(...args);
console.log(`exiting ${fn.name}: ${valueToReturn}`);
return valueToReturn;
} catch (thrownError) {
console.log(`exiting ${fn.name}: threw ${thrownError}`);
throw thrownError;
}
};
通过这个更改,如果函数抛出一个错误,您还将得到一个适当的日志消息,并且将重新抛出异常以进行处理。
获得更好的日志输出的其他更改将由您决定—添加日期和时间数据、增强参数列出的方式,等等。 然而,我们的实现仍然有一个重要的缺陷; 让我们让它变得更好、更纯净。
以更纯粹的方式工作
当我们写了addLogging()
功能,我们看一些戒律我们看到第四章,正确的行为——纯函数,因为我们有一个不纯洁的元素在我们的代码(console.log()
)。 这样一来,我们不仅失去了灵活性(您是否能够选择另一种日志记录方式?),而且使我们的测试复杂化了。 我们可以通过监视console.log()
方法来测试它,但这不是很清楚:我们依赖于知道我们想要测试的函数的内部结构,而不是做一个纯粹的黑盒测试。 为了更清楚地理解这一点,看看下面的例子:
describe("a logging function", function() {
it("should log twice with well behaved functions", () => {
let something = (a, b) => `result=${a}:${b}`;
something = addLogging(something);
spyOn(window.console, "log");
something(22, 9);
expect(window.console.log).toHaveBeenCalledTimes(2);
expect(window.console.log).toHaveBeenCalledWith(
"entering something: 22,9"
);
expect(window.console.log).toHaveBeenCalledWith(
"exiting something: result=22:9"
);
});
it("should report a thrown exception", () => {
let thrower = (a, b, c) => {
throw "CRASH!";
};
spyOn(window.console, "log");
expect(thrower).toThrow();
thrower = addLogging(thrower);
try {
thrower(1, 2, 3);
} catch (e) {
expect(window.console.log).toHaveBeenCalledTimes(2);
expect(window.console.log).toHaveBeenCalledWith(
"entering thrower: 1,2,3"
);
expect(window.console.log).toHaveBeenCalledWith(
"exiting thrower: threw CRASH!"
);
}
});
});
运行此测试表明addLogging()
的行为符合预期,因此这是一个解决方案。 我们的第一个测试只是做了一个简单的减法,并验证使用适当的数据调用了日志记录,而第二个测试则检查一个抛出错误的函数,以验证生成了正确的日志。
即使如此,能够以这种方式测试我们的功能也不能解决我们提到的缺乏灵活性的问题。 我们应该注意我们在注入非纯函数一节中所写的内容——日志函数应该作为参数传递给包装器函数,这样我们就可以在需要时更改它:
const addLogging3 = (fn, logger = console.log) => (...args) => {
logger(`entering ${fn.name}: ${args}`);
try {
const valueToReturn = fn(...args);
logger(`exiting ${fn.name}: ${valueToReturn}`);
return valueToReturn;
} catch (thrownError) {
logger(`exiting ${fn.name}: threw ${thrownError}`);
throw thrownError;
}
};
如果我们什么都不做,日志包装器显然会产生与上一节相同的结果。 但是,我们可以提供不同的日志记录工具——例如,在 Node 中,我们可以使用winston,这是一种常见的日志记录工具,结果会相应不同:
See https://github.com/winstonjs/winston for more on winston.
const winston = require("winston");
const myLogger = t => winston.log("debug", "Logging by winston: %s", t);
winston.level = "debug";
subtract = addLogging3(subtract, myLogger);
changeSign = addLogging3(changeSign, myLogger);
let x = subtract(7, 5);
// *debug: Logging by winston: entering subtract: 7,5*
// *debug: Logging by winston: entering changeSign: 5*
// *debug: Logging by winston: exiting changeSign: -5*
// *debug: Logging by winston: exiting subtract: 2*
既然我们已经遵循了自己的建议,我们就可以利用存根了。 用于测试的代码实际上与之前相同; 然而,我们使用的存根,dummy.logger()
,没有提供的功能或副作用,所以它是安全的。 在这种情况下,最初被调用的真正函数console.log()
不会造成任何伤害,但情况并非总是如此,所以建议使用存根:
describe("after addLogging3()", function() {
let dummy;
beforeEach(() => {
dummy = { logger() {} };
spyOn(dummy, "logger");
});
it("should call the provided logger", () => {
let something = (a, b) => `result=${a}:${b}`;
something = addLogging3(something, dummy.logger);
something(22, 9);
expect(dummy.logger).toHaveBeenCalledTimes(2);
expect(dummy.logger).toHaveBeenCalledWith("entering something: 22,9");
expect(dummy.logger).toHaveBeenCalledWith(
"exiting something: result=22:9"
);
});
it("a throwing function should be reported", () => {
let thrower = (a, b, c) => {
throw "CRASH!";
};
thrower = addLogging3(thrower, dummy.logger);
try {
thrower(1, 2, 3);
} catch (e) {
expect(dummy.logger).toHaveBeenCalledTimes(2);
expect(dummy.logger).toHaveBeenCalledWith("entering thrower: 1,2,3");
expect(dummy.logger).toHaveBeenCalledWith(
"exiting thrower: threw CRASH!"
);
}
});
});
前面的测试与我们前面编写的测试完全相同,但是使用并检查虚拟记录器,而不是处理原始的console.log()
调用。 以这种方式编写测试可以避免由于副作用而导致的所有可能的问题,因此更加干净和安全。
当应用 FP 技术时,请始终记住,如果您在某种程度上使自己的工作复杂化—例如,使测试任何函数变得困难—那么您一定是做错了什么。 在我们的例子中,仅仅是addLogging()
的输出是一个不纯函数这一事实就应该引起警报。 当然,考虑到代码的简单性,在这种特殊情况下,您可能会认为不值得进行修复,可以不进行测试,而且不需要更改生成日志的方式。 然而,长期的软件开发经验表明,您迟早会后悔那样的决定,所以尝试使用更干净的解决方案。
既然我们已经处理了日志,我们将研究另一种需求:出于性能原因的计时函数。
定时功能
包装函数的另一个可能应用是以完全透明的方式记录和记录每个函数调用的时间。 简单地说,我们希望能够知道函数调用需要多长时间,这很可能是为了性能研究。 但是,与处理日志相同,我们不希望修改原始函数,而是使用高阶函数。
If you plan to optimize your code, remember the following three rules: Don't do it, Don't do it yet, and Don't do it without measuring. It has been mentioned that much bad code arises from early attempts at optimization, so don't start by trying to write optimal code, don't try to optimize until you recognize the need for it, and don't do it haphazardly, without trying to determine the reasons for the slowdown by measuring all the parts of your application.
沿着前面的例子,我们可以编写一个addTiming()
函数,给定任何函数,它将生成一个包装版本,将在控制台上写出计时数据,但在其他方面将以完全相同的方式工作:
const myPut = (text, name, tStart, tEnd) =>
console.log(`${name} - ${text} ${tEnd - tStart} ms`);
const myGet = () => performance.now();
const addTiming = (fn, getTime = myGet, output = myPut) => (...args) => {
let tStart = getTime();
try {
const valueToReturn = fn(...args);
output("normal exit", fn.name, tStart, getTime());
return valueToReturn;
} catch (thrownError) {
output("exception thrown", fn.name, tStart, getTime());
throw thrownError;
}
};
注意,按照我们在上一节中对日志函数应用的增强,我们提供了单独的日志记录器和时间访问函数。 为我们的addTiming()
函数编写测试应该很容易,因为我们可以注入两个不纯函数。
Using performance.now()
provides the highest accuracy. If you don't need such precision as what's provided by that function (and it's arguable that it is overkill), you could simply substitute Date.now()
. For more on these alternatives, see https://developer.mozilla.org/en-US/docs/Web/API/Performance/now and https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Date/now. You could also consider using console.time()
and console.timeEnd()
; see https://developer.mozilla.org/en-US/docs/Web/API/Console/time for more information.
为了能够尝试日志记录功能,我修改了subtract()
函数,这样如果你试图减去 0,它就会抛出一个错误。 (是的,当然,你可以从另一个数字中减去 0,但我想要一些抛出错误的情况,不惜任何代价!)
如果需要,你也可以列出输入参数,以获得更多信息:
subtract = addTiming(subtract);
let x = subtract(7, 5); // subtract - normal exit 0.10500000000001819 ms
let y = subtract(4, 0); // subtract - exception thrown 0.0949999999999136
// ms
前面的代码与前面的addLogging()
函数非常相似,这是合理的——在这两种情况下,我们在实际函数调用之前添加了一些代码,然后在函数返回之后添加了一些新代码。 你甚至可以考虑编写一个高阶函数,这将得到三个函数,产生一个新的高阶函数作为输出(如addLogging()
或addTiming()
),将调用第一个函数开始,然后第二个函数如果包装函数返回一个值, 或者第三个函数,如果抛出了错误! 那关于什么?
记忆功能
在第四章,正确的行为——纯函数,我们考虑的情况下 Fibonacci 函数和学习如何变换,用手,变成一个更有效版本通过记忆:缓存计算值,以避免重算。 一个memoized函数是一个将避免重做的过程,如果结果被发现更早。 我们想把任何函数都变成记忆函数,这样我们就能得到一个更优化的版本。
A real-life memoizing solution should also take into account the available RAM and have some ways of avoiding filling it up; however, this is beyond the scope of this book. Also, we won't be looking into performance issues; those optimizations are also beyond the scope of this book.
为了简单起见,让我们只考虑具有单个非结构化参数的函数,而将具有更复杂参数(对象、数组)或多个参数的函数留到后面讨论。
The kind of values we can handle with ease are JavaScript's primitive values: data that aren't objects and have no methods. JavaScript has six of these: boolean
, null
, number
, string
, symbol
, and undefined
. Usually, we only see the first four as actual arguments. You can find out more by going to https://developer.mozilla.org/en-US/docs/Glossary/Primitive.
我们的目标不是找出最好的记忆解决方案,但让我们稍微研究一下这个问题,并找出记忆高阶函数的几种变体。 首先,我们将处理具有单个参数的函数,然后考虑具有多个参数的函数。
简单的记忆
我们将使用前面提到的 Fibonacci 函数,这是一种简单的情况:它接收一个数字参数。 该功能如下:
function fib(n) {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fib(n - 2) + fib(n - 1);
}
}
我们之前创建的解决方案在概念上是通用的,但在实现上尤其如此:我们必须直接修改函数的代码,以便利用上述记忆。 现在,我们应该研究一种自动完成此操作的方法,其方式与处理其他包装函数的方式相同。 解决方案将是一个memoize()
函数,包装任何其他函数,以应用记忆:
const memoize = fn => {
let cache = {};
return x => (x in cache ? cache[x] : (cache[x] = fn(x)));
};
这是怎么做到的呢? 对于任何给定的参数,返回的函数检查是否已经接收到参数,也就是说,是否可以在缓存对象中找到它作为键。 如果是,则不需要计算,并返回缓存的值。 否则,我们计算丢失的值并将其存储在缓存中。 (我们使用闭包来隐藏缓存,不让外部访问。) 在这里,我们假设 memoized 函数只接收一个参数(x
),并且它是一个原始值,然后可以直接用作缓存对象的键值; 我们以后再考虑其他情况。
这是工作吗? 我们必须给它计时——我们碰巧有一个有用的addTiming()
函数! 首先,我们对原始的fib()
函数进行计时。 我们希望对整个计算进行计时,而不是对每个递归调用进行计时,因此我们编写了一个辅助testFib()
函数,我们将对这个函数进行计时。
我们应该重复计时操作,做一个平均值,但是,因为我们只是想确认记忆是有效的,我们会容忍差异:
const testFib = n => fib(n);
addTiming(testFib)(45); // 15,382.255 ms
addTiming(testFib)(40); // 1,600.600 ms
addTiming(testFib)(35); // 146.900 ms
当然,您自己的时间会有所不同,这取决于您特定的 CPU、RAM 等。 然而,结果似乎是合乎逻辑的:我们在第四章、中提到的指数增长——纯函数似乎是存在的,而且时间增长很快。 现在,让我们记住fib()
。 我们应该缩短时间…… 我们不应该?
const testMemoFib = memoize(n => fib(n));
addTiming(testMemoFib)(45); // 15,537.575 ms
addTiming(testMemoFib)(45); // 0.005 ms... *good!*
addTiming(testMemoFib)(40); // 1,368.880 ms... *recalculating?*
addTiming(testMemoFib)(35); // 123.970 ms... *here too?*
东西是错的! 时代应该消失了,但它们几乎是一样的。 这是因为一个常见的错误,我甚至在一些文章和网页上看到过。 我们正在计时testMemoFib()
,但是没有人调用那个函数,除了计时,而且那只发生一次! 在内部,所有递归调用都是对fib()
的调用,它没有被记忆。 如果我们再次调用testMemoFib(45)
,,调用会被缓存,并且它几乎会立即返回,但这种优化并不适用于内部的fib()
调用。 这就是为什么对testMemoFib(40)
和testMemoFib(35)
的调用没有进行优化的原因——当我们计算testMemoFib(45)
时,这是唯一得到缓存的值。
正确的解决方案如下:
fib = memoize(fib);
addTiming(fib)(45); // 0.080 ms
addTiming(fib)(40); // 0.025 ms
addTiming(fib)(35); // 0.009 ms
现在,在计算fib(45)
时,所有中间的斐波那契值(从fib(0)
到fib(45)
本身)都被存储了,因此即将到来的调用实际上不需要做任何工作。
现在我们知道如何记忆单参数函数,让我们看看有更多参数的函数。
更复杂的记忆
如果我们必须处理一个接收两个或多个参数的函数,或者可以接收数组或对象作为参数的函数,我们该怎么做? 当然,就像我们看的问题在第二章,思维功能——第一个例子,有一个函数完成其工作只有一次,我们可以简单地忽略一个问题:如果函数 memoize 的是一元的,我们经历的记忆过程; 否则,如果函数有不同的特性,我们就什么也不做!
The number of parameters of a function is called the arity of the function, or its valence. You may speak in three different ways: you can say a function has arity 1, 2, 3, and so on; you can say that a function is unary, binary, ternary, and so on; or you can say it's monadic, dyadic, triadic, and so on. Take your pick!
我们的第一次尝试可能只是记住一元函数,其余的就不做了,如下面的代码所示:
const memoize2 = fn => {
if (fn.length === 1) {
let cache = {};
return x => (x in cache ? cache[x] : (cache[x] = fn(x)));
} else {
return fn;
}
};
更认真地工作,如果我们想要能够记住任何函数,我们必须找到一种方法来生成缓存键。 为此,我们必须找到将任何类型的参数转换为字符串的方法。 我们不能直接使用非原语作为缓存键。 我们可以尝试将值转换为一个字符串,类似于strX = String(x)
,但我们会遇到问题。 对于数组,这似乎是可行的。 但是,看看下面三种情况,它们涉及不同的数组,但有一些不同:
var a = [1, 5, 3, 8, 7, 4, 6];
String(a); // "1,5,3,8,7,4,6"
var b = [[1, 5], [3, 8, 7, 4, 6]];
String(b); // "1,5,3,8,7,4,6"
var c = [[1, 5, 3], [8, 7, 4, 6]];
String(c); // "1,5,3,8,7,4,6"
这三种情况产生了相同的结果。 如果我们只考虑一个数组参数,我们可能还能凑合,但当不同的数组产生相同的键时,这就有问题了。 如果我们必须接受对象作为参数,事情就会变得更糟,因为任何对象的String()
表示总是:
var d = {a: "fk"};
String(d); // "[object Object]"
var e = [{p: 1, q: 3}, {p: 2, q: 6}];
String(e); // "[object Object],[object Object]"
最简单的解决方案是使用JSON.stringify()
将接收到的任何参数转换为一个有用的、不同的字符串:
var a = [1, 5, 3, 8, 7, 4, 6];
JSON.stringify(a); // "[1,5,3,8,7,4,6]"
var b = [[1, 5], [3, 8, 7, 4, 6]];
JSON.stringify(b); // "[[1,5],[3,8,7,4,6]]"
var c = [[1, 5, 3], [8, 7, 4, 6]];
JSON.stringify(c); // "[[1,5,3],[8,7,4,6]]"
var d = {a: "fk"};
JSON.stringify(d); // "{"a":"fk"}"
var e = [{p: 1, q: 3}, {p: 2, q: 6}];
JSON.stringify(e); // "[{"p":1,"q":3},{"p":2,"q":6}]"
为了提高性能,我们的逻辑应该如下所示:如果我们正在记忆的函数接收到一个原始值的参数,我们可以直接使用该参数作为缓存键。 在其他情况下,我们会使用应用于参数数组的JSON.stringify()
的结果。 我们的增强记忆高阶函数可以如下:
const memoize3 = fn => {
let cache = {};
const PRIMITIVES = ["number", "string", "boolean"];
return (...args) => {
let strX =
args.length === 1 && PRIMITIVES.includes(typeof args[0])
? args[0]
: JSON.stringify(args);
return strX in cache ? cache[strX] : (cache[strX] = fn(...args));
};
};
就普遍性而言,这是最安全的版本。 如果您确定要处理的函数中的参数类型,那么可以肯定的是,我们的第一个版本更快。 另一方面,如果你想要更容易理解的代码,即使以浪费 CPU 周期为代价,你也可以使用更简单的版本:
const memoize4 = fn => {
let cache = {};
return (...args) => {
let strX = JSON.stringify(args);
return strX in cache ? cache[strX] : (cache[strX] = fn(...args));
};
};
If you want to learn about the development of a top-performance memoizing function, read Caio Gondim's How I wrote the world's fastest JavaScript memoization library article, available online at https://community.risingstack.com/the-worlds-fastest-javascript-memoization-library/.
到目前为止,我们已经实现了几个有趣的记忆功能,但是我们将如何为它们编写测试呢? 现在我们来分析一下这个问题。
记忆测试
测试高阶函数的记忆提出了一个有趣的问题——你会怎么做呢? 第一个想法是查看缓存——但那是私有的,不可见的。 当然,我们可以更改memoize()
,使其使用全局缓存或以某种方式允许外部访问缓存,但这样做内部检查是不允许的:您应该尝试仅基于外部属性进行测试。
接受我们不应该尝试检查缓存的事实,我们可以进行时间控制:如果函数没有被记忆,那么对于较大的 n 值,调用fib()
这样的函数应该花费更长的时间。 这当然是可能的,但它也容易出现故障:测试外部的一些东西可能在错误的时间运行,而且您的记忆运行可能比原始运行花费更长的时间。 好吧,这是可能的,但不太可能——但你的测试不是完全可靠的。
那么,让我们来更直接地分析一下对记忆函数的实际调用次数。 使用非记忆的原始fib()
,我们可以测试函数是否正常工作,并检查它调用了多少次:
var fib = null;
beforeEach(() => {
fib = n => {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fib(n - 2) + fib(n - 1);
}
};
});
describe("the original fib", function() {
it("should produce correct results", () => {
expect(fib(0)).toBe(0);
expect(fib(1)).toBe(1);
expect(fib(5)).toBe(5);
expect(fib(8)).toBe(21);
expect(fib(10)).toBe(55);
});
it("should repeat calculations", () => {
spyOn(window,"fib").and.callThrough();
expect(fib(6)).toBe(8);
expect(fib).toHaveBeenCalledTimes(25);
});
});
前面的代码相当简单:我们使用前面开发的 Fibonacci 函数并测试它是否生成正确的值。 例如,fib(6)
等于 8 这一事实很容易验证,但是从哪里可以发现该函数被调用了 25 次呢? 对于这个问题的答案,让我们重新回顾一下我们在第四章,中看到的图表:
Figure 6.1: All the recursive calls needed for calculating fib(6)
每个节点都是一个调用; 仅通过计数,我们可以看到,为了计算fib(6)
,实际上对fib()
进行了 25 次调用。 现在,我们来看看这个函数的记忆版本。 测试它仍然产生相同的结果很容易:
describe("the memoized fib", function() {
beforeEach(() => {
fib = memoize(fib);
});
it("should produce same results", () => {
expect(fib(0)).toBe(0);
expect(fib(1)).toBe(1);
expect(fib(5)).toBe(5);
expect(fib(8)).toBe(21);
expect(fib(10)).toBe(55);
});
it("shouldn't repeat calculations", () => {
spyOn(window, "fib").and.callThrough();
expect(fib(6)).toBe(8); // 11 calls
expect(fib).toHaveBeenCalledTimes(11);
expect(fib(5)).toBe(5); // 1 call
expect(fib(4)).toBe(3); // 1 call
expect(fib(3)).toBe(2); // 1 call
expect(fib).toHaveBeenCalledTimes(14);
});
});
但为什么计算fib(6)
时叫 11 次,计算fib(5)
、fib(4)
、fib(3)
后又叫 3 次呢? 为了回答这个问题的第一部分,让我们分析一下我们之前看到的图表:
- 首先,我们叫
fib(6)
,它叫fib(4)
和fib(5)
。 这是三个电话。 - 计算
fib(4)
时,调用fib(2)
、fib(3)
; 数到五。 - 计算
fib(5)
时,调用fib(3)
、fib(4)
; 这个数字上升到 11 个。 - 最后,计算并缓存
fib(6)
。 fib(3)
和fib(4)
都被缓存,因此不再进行调用。- 计算并缓存
fib(5)
。 - 计算
fib(2)
时,调用fib(0)
、fib(1)
; 现在,我们有 7 个电话。 -
计算
fib(3)
时,调用fib(1)
、fib(2)
; 数到九。 -
计算并缓存
fib(4)
。 fib(1)
和fib(2)
都已经缓存,因此不再进行进一步调用。- 计算并缓存
fib(3)
。 - 在计算
fib(0)
和fib(1)
时,不进行额外调用,两者都被缓存。 - 计算并缓存
fib(2)
。
唷! 因此,呼叫fib(6)
的次数是 11 次。 假定所有的fib(n)
值都已缓存,对于 n 从 0 到 6,很容易看出为什么计算fib(5)
、fib(4)
和fib(3)
只增加了三个调用:所有其他所需的值都已缓存。
在本节中,我们处理了几个隐含包装函数的示例,使它们能够继续工作,但添加了一些额外的特性。 现在,让我们看一个不同的例子,我们想要改变一个函数的实际工作方式。
改变函数的行为
在前一节中,我们考虑了一些包装函数的方法,以便它们保持其原始功能,即使它们已经在某种程度上得到了增强。 现在,我们将修改函数的功能,使新的结果与原始函数的结果不同。
我们将涵盖以下主题:
- 再次讨论函数工作的问题,但只有一次
- 对函数的结果求反或求反
- 改变函数的性质
让我们开始吧!
做一件事,再来一次
回到第 2 章,第一个例子,我们通过一个为一个简单问题开发一个 fp 风格的解决方案的例子:修复一些东西,使一个给定的功能只能工作一次。 下面的代码是我们当时写的:
const once = func => {
let done = false;
return (...args) => {
if (!done) {
done = true;
func(...args);
}
};
};
这是一个完美的解决方案; 它运行得很好,我们没有什么可反对的。 然而,我们可以考虑一种变体。 我们可以观察到给定的函数被调用一次,但是它的返回值丢失了。 这很容易修复:我们只需要添加一个return
语句。 然而,这还不够; 如果调用次数更多,函数会返回什么? 我们可以从记忆解决方案中取出一页,并存储函数的返回值以备将来调用。
让我们将函数的值存储在一个变量(result
)中,以便稍后返回:
const once2 = func => {
let done = false;
let result;
return (...args) => {
if (!done) {
done = true;
result = func(...args);
}
return result;
};
};
第一次调用函数时,它的值存储在result
中; 进一步的调用只是返回那个值,没有进一步的进程。 您还可以考虑让函数只工作一次,但对于每一组参数。 你不必为此做任何工作——memoize()
就足够了!
回到第二章,Thinking functional - A First Example,在An even better solution部分,我们考虑了一个可能的替代once()
的方案: 另一个高阶函数,它接受两个函数作为参数,允许第一个函数只被调用一次,从那时起调用第二个函数。 在之前的代码中添加一个return
语句,结果如下:
const onceAndAfter = (f, g) => {
let done = false;
return (...args) => {
if (!done) {
done = true;
return f(...args);
} else {
return g(...args);
}
};
};
如果我们记得函数是一阶对象,我们可以重写这个。 我们可以使用变量(toCall
)直接存储需要调用的函数,而不是使用标志来记住要调用哪个函数。 逻辑上,该变量将初始化为第一个函数,但随后将更改为第二个函数。 下面的代码实现了这个更改:
const onceAndAfter2 = (f, g) => {
let toCall = f;
return (...args) => {
let result = toCall(...args);
toCall = g;
return result;
};
};
toCall
变量是用f
初始化的,所以f()
会在第一次调用时被调用,而toCall
会在第一次调用时被调用g
,这意味着以后所有的调用都将执行g()
。 我们在本书前面看到的那个例子仍然有效:
const squeak = (x) => console.log(x, "squeak!!");
const creak = (x) => console.log(x, "creak!!");
const makeSound = onceAndAfter2(squeak, creak);
makeSound("door"); // *"door squeak!!"*
makeSound("door"); // *"door creak!!"*
makeSound("door"); // *"door creak!!"*
makeSound("door"); // *"door creak!!"*
就性能而言,这种差异可以忽略不计。 展示这种进一步变化的原因是,您应该记住,通过存储函数,通常可以以一种更简单的方式产生结果。 使用标志来存储状态是一种常见的技术,在过程编程中随处可见。 然而,在这里,我们设法跳过这种用法,并产生相同的结果。 现在,让我们看一些包装函数以改变其行为的新示例。
逻辑上否定一个函数
让我们考虑第 5 章中的filter()
方法,编程声明性-更好的风格。 给定一个谓词,我们可以过滤数组以只包含谓词为真的元素。 但是如何进行反向筛选并排除谓词为真的元素呢?
第一个解决方案应该是非常明显的:重新处理谓词,使其返回与最初返回的结果相反的结果。 在第 5 章,Programming Declaratively- A Better Style中,我们看了以下示例:
const delinquent = serviceResult.accountsData.filter(v => v.balance < 0);
所以,我们可以反过来写,用这两种等价的方式。 请注意编写相同谓词来测试非负值的不同方法:
const notDelinquent = serviceResult.accountsData.filter(
v => v.balance >= 0
);
const notDelinquent2 = serviceResult.accountsData.filter(
v => !(v.balance < 0)
);
这完全没问题,但我们也可以在我们的代码中有如下内容:
const isNegativeBalance = v => v.balance < 0;
// ...*many lines later..*.
const delinquent2 = serviceResult.accountsData.filter(isNegativeBalance);
在这种情况下,重写原始函数是不可能的。 然而,以函数的方式工作,我们可以编写一个高阶函数,它将接受任何谓词,计算它,然后否定它的结果。 由于现代 JavaScript 语法,可能的实现非常简单:
const not = fn => (...args) => !fn(...args);
按照这种方式,我们可以将前面的过滤器重写如下; 为了检验非负余额,我们使用原始的isNegativeBalance()
函数,该函数通过我们的not()
高阶函数求负:
const isNegativeBalance = v => v.balance < 0;
// ...*many lines later...*
const notDelinquent3 = serviceResult.accountsData.filter(
not(isNegativeBalance)
);
还有一个额外的解决方案我们可能想要尝试-而不是反转条件(正如我们所做的),我们可以编写一个新的过滤方法(可能是filterNot()
?),它将以与filter()
相反的方式工作。 下面的代码展示了如何编写这个新函数:
const filterNot = arr => fn => arr.filter(not(fn));
这个解决方案与filter()
不完全匹配,因为您不能将它作为一个方法使用,但我们可以将它添加到Array.prototype
或应用一些方法。 我们将在第 8 章、连接函数—流水线和组合中查看这些方法。 然而,更有趣的是,我们使用了负函数,因此not()
对于反过滤问题的两个解实际上都是必要的。 在即将到来的Demethodizing—将方法转换为函数一节中,我们将看到我们还有另一个解决方案,因为我们将能够将诸如filter()
之类的方法从它们所应用的对象中解耦,从而将它们转换为通用函数。
至于用新的filterNot()
来否定和函数,尽管两种可能性都同样有效,但我认为用not()
更清楚; 如果你已经了解了过滤是如何工作的,那么你实际上可以大声读出来,这是可以理解的:我们想要那些没有负余额的,对吗? 现在,让我们考虑一个相关的问题:将函数的结果反过来。
反相的结果
同样作为前过滤问题,让我们重温排序问题的注入——排序出来的部分第三章,开始函数——一个核心概念【显示】。 这里,我们想用特定的方法对数组进行排序。 因此,我们使用了.sort()
,为它提供了一个比较函数,基本上指出两个字符串中哪一个应该先走。 为了刷新你的记忆,给定两个字符串,函数应该做以下工作:
- 如果第一个字符串应该在第二个字符串之前,则返回一个负数
- 如果字符串相同则返回 0
- 如果第一个字符串应该在第二个字符串之后,则返回一个正数
让我们回到西班牙语排序的代码。 我们必须写一个特殊的比较函数,这样排序将考虑从西班牙特殊字符的顺序规则,如包括信n 之间的和啊,等等。 这方面的代码如下:**
const spanishComparison = (a, b) => a.localeCompare(b, "es");
palabras.sort(spanishComparison); // *sorts the* palabras *array according to Spanish rules*
我们面临着一个类似的问题:如何按照降序排序? 鉴于我们在上一节中看到的情况,我们应该马上想到两种选择:
- 编写一个函数,将比较函数的结果反转。 这将使所有决定哪个字符串应该排在前面的结果反转,最终结果将是一个以完全相反的方式排序的数组。
- 编写一个
sortDescending()
函数或方法,其工作方式与sort()
相反。
让我们写一个invert()
函数来改变比较的结果。 代码本身与not()
非常相似:
const invert = fn => (...args) => -fn(...args);
给定这个高阶函数,我们可以通过提供一个适当的反向比较函数来降序排序。 看看最后几行,我们使用invert()
来改变排序比较的结果:
const spanishComparison = (a, b) => a.localeCompare(b, "es");
var palabras = ["ñandú", "oasis", "mano", "natural", "mítico", "musical"];
palabras.sort(spanishComparison);
// ["mano", "mítico", "musical", "natural", "ñandú", "oasis"]
palabras.sort(invert(spanishComparison));
// ["oasis", "ñandú", "natural", "musical", "mítico", "mano"]
输出是预期的:当我们invert()
比较函数时,结果是相反的顺序。 编写单元测试是相当容易的,因为我们已经有了一些具有预期结果的测试用例,不是吗?
参数数量改变
解析数字默认年代检查第五章、编程以声明的方式,一个更好的风格,我们发现使用parseInt()
和reduce()
会产生问题,因为意想不到的参数数量的函数,它把多个 argument-remember 前面的例子吗?
["123.45", "-67.8", "90"].map(parseInt); // *problem: parseInt isn't
// monadic!*
// [123, NaN, NaN]
解决这个问题的方法不止一种。 在第五章,Programming Declaratively - A Better Style中,我们使用了一个箭头函数。 这是一个简单的解决方案,它的优点是易于理解。 在第 7 章、转换函数—curry 和 Partial Application中,我们将介绍另一种基于 Partial Application 的转换函数。 现在,我们来看一个高阶函数。 我们需要的是一个将另一个函数作为参数并将其转换为一元函数的函数。 使用 JavaScript 的扩展操作符和箭头函数,这很容易管理:
const unary = fn => (...args) => fn(args[0]);
使用这个函数,我们的数字解析问题就解决了:
["123.45", "-67.8", "90"].map(unary(parseInt)); // *[123, -67, 90]*
不用说,进一步定义binary()
、ternary()
和其他函数也同样简单,这些函数可以将任何函数转换为等效的、受限制的版本。 我们不要走极端,只看几个可能的函数:
const binary = fn => (...args) => fn(args[0], args[1]);
const ternary = fn => (...args) => fn(args[0], args[1], args[2]);
这是可行的,但详细说明所有参数可能会令人厌烦。 我们甚至可以更好地使用数组操作和扩展,并创建一个泛型函数来处理所有这些情况,如下所示:
const arity = (fn, n) => (...args) => fn(...args.slice(0, n));
有了这个通用的arity()
函数,我们可以为unary()
、binary()
等给出不同的定义。 我们甚至可以将前面的函数重写如下:
const unary = fn => arity(fn, 1);
const binary = fn => arity(fn, 2);
const ternary = fn => arity(fn, 3);
您可能会想,希望应用这种解决方案的情况并不多,但实际上,应用这种解决方案的情况比您预期的要多得多。 浏览所有的 JavaScript 函数和方法,你可以很容易地生成一个列表,从apply()
,assign()
,bind()
,concat()
,copyWithin()
,等等! 如果您希望以一种默认的方式使用其中的任何一种,您可能需要修复它的特性,以便它能够使用固定的、非可变数量的参数。
If you want a nice list of JavaScript functions and methods, check out https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Functions and https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Methods_Index. As for tacit programming (or pointfree style), we'll be coming back to it in Chapter 8, Connecting Functions – Pipelining and Composition.
到目前为止,我们已经学习了如何在保持函数原始行为或以某种方式更改其行为的同时包装函数。 现在,让我们考虑一些修改函数的其他方法。
以其他方式改变函数
让我们通过考虑其他一些提供结果的函数来结束本章,例如新的查找器、从对象中分离方法等等。 我们的例子包括以下内容:
- 将操作(如使用
+
操作符进行加法)转换为函数 - 将功能转化为承诺
- 访问对象以获取属性的值
- 将方法转换为函数
- 找到最优值的更好方法
将操作转换为函数
我们已经看到了几个例子,其中我们需要编写一个函数,仅仅是对数字的加法或乘法。 例如,在第 5 章、的中一节中,我们必须编写如下代码:
const mySum = myArray.reduce((x, y) => x + y, 0);
Working with rangessection ofChapter 5,Programming Declaratively - A Better Style,to calculate A factorial,
const factorialByRange = n => range(1, n + 1).reduce((x, y) => x * y, 1);
如果我们能把一个二元运算符转换成一个计算相同结果的函数,事情就会简单得多。 前面的两个例子本可以写得更简洁一些,如下所示。 你能理解我们的改变吗?
const mySum = myArray.reduce(binaryOp("+"), 0);
const factorialByRange = n => range(1, n + 1).reduce(binaryOp("*"), 1);
我们还没看binaryOp()
如何实现,但关键概念,而不是一个中缀操作符(比如我们使用写22+9
),我们现在有一个函数(就像如果我们可以写和+(22,9)
,这肯定不是有效的 JavaScript)。 让我们来看看怎么做。
实现操作
如何写这个binaryOp()
函数? 至少有两种方法可以做到这一点:一种是安全但长期的,另一种是风险更大、时间更短的。 第一种方法需要列出每个可能的操作符。 下面的代码使用了一个较长的switch
:
const binaryOp1 = op => {
switch (op) {
case "+":
return (x, y) => x + y;
case "-":
return (x, y) => x - y;
case "*":
return (x, y) => x * y;
//
// etc.
//
}
};
这个解决方案非常好,但是需要太多的工作。 第二种更危险,但时间更短。 为了学习的目的,请把这个当做一个例子; 出于安全原因,不推荐使用eval()
! 第二个版本将简单地使用Function()
创建一个新函数,该函数使用所需的操作符,如下所示:
const binaryOp2 = op => new Function("x", "y", `return x ${op} y;`);
如果遵循这一思路,您还可以定义一个unaryOp()
函数,尽管用于它的应用较少。 (我把这个实现留给你; 它与我们已经写过的非常相似。) 在第 7 章,转换函数——curry 和 Partial Application中,我们将介绍使用 Partial Application 创建这个一元函数的另一种方法。
方便的实现
让我们超越我们自己。 做 FP 并不意味着总是深入到最基本,最简单的可能的函数。 例如,在一个即将到来的这本书,我们需要一个函数检查是否一个数量是负数,我们会考虑(见转换成 pointfree风格的第八章,连接功能——流水线和混合涂料【显示】sition【病人】)使用binaryOp2()
写:
const isNegative = curry(binaryOp2(">"))(0);
不要担心现在curry()
函数(我们很快就会到达,在第七章,转换函数-局部套用和部分应用)——这个想法是它修复第一个参数为 0,这样我们的函数将检查一个给定的数字,,>如果0 n【显示】。 这里的重点是我们刚才写的函数不是很清楚。 如果定义一个二元运算函数,除了要使用的运算符外,还允许我们指定它的一个参数(左参数或右参数),那么我们可以做得更好。 在这里,我们可以编写以下两个函数,它们定义了缺少左操作符或右操作符的函数:**
const binaryLeftOp = (x, op) => y => binaryOp2(op)(x,y);
const binaryOpRight = (op, y) => x => binaryOp2(op)(x,y);
有了这些新函数,我们可以简单地写出下面两种定义中的一种,尽管我认为第二种更清楚。 我宁愿测试一个数字是否小于 0,而不是 0 是否大于这个数字:
const isNegative1 = binaryLeftOp(0, ">");
const isNegative2 = binaryOpRight("<", 0);
这有什么意义? 不要追求某种基本的简单或深入到最基本的代码。 我们可以将运算符转换为函数,但如果您可以做得更好,并通过指定运算的两个参数之一来简化代码,那么就这样做吧! FP 的理念是帮助编写更好的代码,而人为的限制对任何人都没有帮助。
当然,对于一个简单的函数,比如检查一个数字是否为负数,我永远不想用 curry、二进制操作符、pointfree 风格或其他任何东西来把事情复杂化,我只写下面的代码,没有更多的麻烦:
const isNegative3 = x => x < 0;
到目前为止,我们已经看到了解决同一问题的几种方法。 记住,FP 并不强迫你选择一种做事方式; 相反,它让你有很大的自由来决定走哪条路!
将功能转化为承诺
在节点中,大多数异步函数需要一个回调如(err,data)=>{...}
:如果err``null
,data
功能是成功的,是它的结果,而如果err
有一些值,函数失败,err
给出了原因。 (详见https://nodejs.org/api/errors.html#errors_node_js_style_callbacks)
然而,您可能更喜欢使用承诺。 因此,我们可以考虑编写一个高阶函数,它将一个需要回调的函数转换为一个允许您使用.then()
和.catch()
方法的承诺。 (在第 12 章,Building Better Containers - Functional Data Types中,我们将看到承诺实际上是单子,所以这个转换在另一种方式是有趣的。)
Node, since version 8, already provides the util.promisify()
function, which turns an async function into a promise. See https://nodejs.org/dist/latest-v8.x/docs/api/util.html#util_util_promisify_original for more on that.
我们该怎么做呢? 转换相当简单。 给定一个函数,我们生成一个新函数:这将返回一个承诺,当使用一些参数调用原始函数时,将适当地reject()
或resolve()
承诺。 promisify()
函数就是这样做的:
const promisify = fn => (...args) =>
new Promise((resolve, reject) =>
fn(...args, (err, data) => (err ? reject(err) : resolve(data)))
);
在 Node 中工作时,以下样式是相当常见的:
const fs = require("fs");
const cb = (err, data) =>
err ? console.log("ERROR", err) : console.log("SUCCESS", data);
fs.readFile("./exists.txt", cb); // *success, list the data*
fs.readFile("./doesnt_exist.txt", cb); // *failure, show exception*
但是,您可以使用promisify()
函数来代替 promise。 然而,在当前版本的 Node 中,您将使用util.promisify()
:
const fspromise = promisify(fs.readFile.bind(fs));
const goodRead = data => console.log("SUCCESSFUL PROMISE", data);
const badRead = err => console.log("UNSUCCESSFUL PROMISE", err);
fspromise("./readme.txt") *// success*
.then(goodRead)
.catch(badRead);
fspromise("./readmenot.txt") // *failure*
.then(goodRead)
.catch(badRead);
现在,您可以使用fspromise()
代替原来的方法。 要做到这一点,我们必须绑定fs.readFile
,当我们提到的一个不必要的错误的第三章,开始函数——一个核心概念。
从对象获取属性
我们还可以得到一个简单的函数。 从对象中提取属性是一种常见的操作。 例如,在第 5 章,Programming Declaratively - A Better Style中,我们必须得到纬度和经度,以便能够计算平均值。 这方面的代码如下:
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}
];
let averageLat = average(markers.map(x => x.lat));
let averageLon = average(markers.map(x => x.lon));
我们在学习如何过滤数组的时候见过另一个例子; 在我们的示例中,我们希望获得所有余额为负的帐户的 id。 在过滤掉所有其他帐户后,我们仍然需要提取 ID 字段:
const delinquent = serviceResult.accountsData.filter(v => v.balance < 0);
const delinquentIds = delinquent.map(v => v.id);
We could have joined those two lines and produced the desired result with a one-liner, but that's not relevant here. In fact, unless the delinquent
intermediate result was needed for some reason, most FP programmers would go for the one-line solution.
我们需要什么? 我们需要一个高阶函数,它将接收一个属性的名称,并生成一个新函数,该函数将能够从对象中提取一个属性。 使用箭头函数语法,这个函数很容易编写:
const getField = attr => obj => obj[attr];
In the Getters and setters section of Chapter 10, Ensuring Purity – Immutability, we'll write an even more general version of this function that's able to "go deep" into an object to get an attribute of it, regardless of its location within the object.
使用这个函数,坐标提取过程可以写成:
let averageLat = average(markers.map(getField("lat")));
let averageLon = average(markers.map(getField("lon")));
为了多样化,我们可以使用一个辅助变量来获取不良 id,如下所示:
const getId = getField("id");
const delinquent = serviceResult.accountsData.filter(v => v.balance < 0);
const delinquentIds = delinquent.map(getId);
确保你完全明白这里发生了什么。 getField()
调用的结果是一个函数,将在以后的表达式中使用。 map()
方法需要一个映射函数,这就是getField()
所产生的。
分解方法——将方法转换为函数
filter()
和map()
等方法仅对数组可用; 然而,你可能想要把它们应用到,比如说,一个NodeList
或String
,你就没有运气了。 此外,我们关注的是字符串,所以必须使用这些函数作为方法,这并不完全是我们所想的。 最后,当我们创建一个新的函数(如none()
、检查底片中我们看到的【显示】部分第五章,【病人】编程以声明的方式——一个更好的风格),它不能被应用于同行一样(some()
和every()
,在这种情况下),除非你做一些原型诡计。 这是不被允许的,也不被推荐。**
Read the Extending current data types section of Chapter 12, Building Better Containers – Functional Data Types, where we will make map()
available for most basic types.
所以… 我们能做什么? 我们可以用那句老话,如果山不来到穆罕默德,那么穆罕默德必须去山上,我们将不再担心不能创造新的方法,而是将现有的方法转化为功能。 如果将每个方法转换为一个函数,该函数将接收它将要处理的对象作为其第一个参数,就可以做到这一点。
将方法与对象分离可以帮助您,因为一旦实现了这种分离,所有内容都将变成函数,您的代码将变得更简单。 (还记得我们在中对函数进行逻辑上的求反,是关于一个可能的filterNot()
函数与filter()
方法的比较?) 解耦方法的工作原理类似于其他语言中的泛型函数,因为它们可以应用于不同的数据类型。
Take a look at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function for explanations on apply()
, call()
, and bind()
. We are going to use these for our implementation. Back in Chapter 1, Becoming Functional – Several Questions, we saw the equivalence between apply()
and call()
when we used the spread operator.
在 JavaScript 中有三种不同但相似的实现方法。 列表中的第一个参数(arg0
)将对应于对象,其他参数(args
)对应于被调用方法的实际参数。 三个等价的版本如下。 注意,它们中的任何一个都可以用作demethodize()
函数; 挑你喜欢的吧!
const demethodize1 = fn => (arg0, ...args) => fn.apply(arg0, args);
const demethodize2 = fn => (arg0, ...args) => fn.call(arg0, ...args);
const demethodize3 = fn => (...args) => fn.bind(...args)();
There's yet another way of doing this: const demethodize = Function.prototype.bind.bind(Function.prototype.call)
. If you want to understand how this works, read Leland Richardson's Clever Way to Demethodize Native JS Methods, at http://www.intelligiblebabble.com/clever-way-to-demethodize-native-js-methods.
让我们看看它们的一些应用! 从一个简单的示例开始,我们可以使用map()
来遍历字符串,而无需首先将其转换为字符数组。 假设你想把一个字符串分割成独立的字母,并使它们大写; 我们可以使用split()
和toUpperCase()
:
const name = "FUNCTIONAL";
const result = name.split("").map(x => x.toUpperCase());
/*
*["F", "U", "N", "C", "T", "I", "O", "N", "A", "L"]* */
然而,如果我们将map()
和toUpperCase()
分解,我们可以简单地写成:
const map = demethodize(Array.prototype.map);
const toUpperCase = demethodize(String.prototype.toUpperCase);
const result2 = map(name, toUpperCase);
/*
*["F", "U", "N", "C", "T", "I", "O", "N", "A", "L"]* */
For this particular case, we could have turned the string into uppercase and then split it into separate letters, as in name.toUpperCase().split("")
, but it wouldn't have been such a nice example, with two usages of demethodizing being used.
以类似的方式,我们可以将一个十进制数数组转换为具有数千个分隔符和小数点的格式正确的字符串:
const toLocaleString = demethodize(Number.prototype.toLocaleString);
const numbers = [2209.6, 124.56, 1048576];
const strings = numbers.map(toLocaleString);
/*
*["2,209.6", "124.56", "1,048,576"]* */
或者,考虑到前面的map()
功能,这也可以起作用:
const strings2 = map(numbers, toLocaleString);
在不同的情况下,将方法分解成函数的想法将被证明是非常有用的。 我们已经看到了一些可以应用它的例子,在这本书的其余部分还会有更多这样的例子。
寻找最优
让我们通过创建find()
方法的扩展来结束本节。 假设我们想找到一个数组的最优值——假设它是最大值。 我们可以这样做:
const findOptimum = arr => Math.max(...arr);
const myArray = [22, 9, 60, 12, 4, 56];
findOptimum(myArray); // 60
现在,这足够普遍了吗? 这种方法至少存在两个问题。 首先,你确定集合的最优总是最大值吗? 如果你在考虑几笔抵押贷款,利率最低的那一笔可能是最好的,不是吗? 也就是说,假设总是想要一个集合的最大值过于狭窄。
You could do a roundabout trick: if you change the signs of all the numbers in an array, find its maximum, and change its sign, then you actually get the minimum of the array. In our case,
-findOptimum(myArray.map((x) => -x))
would correctly produce 4
—but it's not easily understandable code, is it?
其次,这种寻找最大值的方法依赖于每个选项都有一个数值。 但如果这个值不存在,你如何找到最优值呢? 通常的方法依赖于元素之间的比较,并选择一个在比较之上的元素:将第一个元素与第二个元素进行比较,并保留其中最好的; 然后,将该值与第三个元素进行比较,保留其最佳值; 然后继续做,直到你把所有的元素都做完。
解决这个问题的更通用性的方法是假设存在一个comparator()
函数,该函数将两个元素作为参数,并返回其中最好的一个。 如果可以将数值与每个元素关联起来,那么比较器函数就可以简单地比较这些值。 在其他情况下,它可以执行任何必要的逻辑,以决定哪个元素会出现在最上面。
我们试着创建一个合适的高阶函数; 我们的新版本将使用reduce()
,如下:
const findOptimum2 = fn => arr => arr.reduce(fn);
有了这个,我们可以很容易地复制寻找最大值和最小值的函数——我们只需要提供适当的约简函数:
const findMaximum = findOptimum2((x, y) => (x > y ? x : y));
const findMinimum = findOptimum2((x, y) => (x < y ? x : y));
findMaximum(myArray); // 60
findMinimum(myArray); // 4
让我们做一个更好的比较非数值的值。 让我们想象一款超级英雄卡牌游戏:每张卡都代表一个英雄,并拥有若干数值属性,如 Strength、Powers 和 Tech。当两个英雄相互战斗时,拥有更多类别和更高价值的那个将成为赢家。 让我们实现一个比较器; 一个合适的compareHeroes()
功能可以如下:
const compareHeroes = (card1, card2) => {
const oneIfBigger = (x, y) => (x > y ? 1 : 0);
const wins1 =
oneIfBigger(card1.strength, card2.strength) +
oneIfBigger(card1.powers, card2.powers) +
oneIfBigger(card1.tech, card2.tech);
const wins2 =
oneIfBigger(card2.strength, card1.strength) +
oneIfBigger(card2.powers, card1.powers) +
oneIfBigger(card2.tech, card1.tech);
return wins1 > wins2 ? card1 : card2;
};
然后,我们可以将其应用到我们的比赛中。 让我们创建一个构造函数来构建英雄:
function Hero(n, s, p, t) {
this.name = n;
this.strength = s;
this.powers = p;
this.tech = t;
}
现在,让我们创建自己的英雄联盟:
const codingLeagueOfAmerica = [
new Hero("Forceful", 20, 15, 2),
new Hero("Electrico", 12, 21, 8),
new Hero("Speediest", 8, 11, 4),
new Hero("TechWiz", 6, 16, 30)
];
有了这些定义,我们可以编写一个findBestHero()
函数来获取 top 英雄:
const findBestHero = findOptimum2(compareHeroes);
findBestHero(codingLeagueOfAmerica); // Electrico is the top hero!
When you rank elements according to one-to-one comparisons, unexpected results may be produced. For instance, with our superheroes comparison rules, you could find three heroes where the results show that the first beats the second, the second beats the third, but the third beats the first! In mathematical terms, this means that the comparison function is not transitive and that you don't have a total ordering for the set.
通过这一点,我们已经看到了几种修改函数的方法,以便使用增强的处理生成更新的变体; 想想你可能会遇到的特殊情况,考虑一下高阶函数是否能帮到你。
总结
在这一章中,我们学习了如何编写我们自己的高阶函数,它可以封装另一个函数来提供一些新特性,改变函数的目标来做其他事情,甚至提供全新的特性,如从对象中分离方法或创建更好的查找器。 本章的主要收获是,你有一种方法可以修改函数的行为,而不必实际修改它自己的代码; 高阶函数可以以有序的方式进行管理。
在第 7 章、中,我们将继续使用高阶函数,并学习如何使用 curry 和 Partial 应用生成具有预定义参数的现有函数的专门版本。
问题
6.1。 :如果我们将getField()
函数应用到一个空对象上会发生什么? 它的行为应该是什么? 如有必要,修改函数。
6.2。 多少? 如果不记,需要多少次调用才能计算fib(50)
? 例如,要计算fib(0)
或fib(1)
,一次调用就足够了,不需要进一步的递归,而对于fib(6)
,我们看到需要 25 次调用。 你能找到一个公式来做这个计算吗?
6.3。 一个随机的均衡器:编写一个高阶函数,也就是说,randomizer(fn1, fn2, ...)
,将收到数量可变的函数作为参数并返回一个新的函数,每个电话,随机叫之一fn1
,fn2
等等。 如果每个函数都能够执行 Ajax 调用,那么您可以使用它来平衡对服务器上不同服务的调用。 为了加分,确保函数不会被连续调用两次。
6.4。 Just say no! 在本章中,我们编写了一个使用布尔函数的not()
函数和一个使用数字函数的negate()
函数。 您能更好地编写一个单独的opposite()
函数,根据需要发挥not()
或negate()
的作用吗?
6.5。 :如果我们有getField()
功能,我们也应该有setField()
功能,你能给它下定义吗? 我们将需要getField()
和setField()
在第 10 章,确保纯度-不变,当我们工作的 getter, setter,和镜头。 注意,setField()
不应该直接修改对象; 相反,它应该返回一个值已改变的新对象——它应该是一个纯函数!
6.6。 函数长度错误:我们的arity()
函数工作正常,但生成的函数没有正确的length
属性。 你能在没有这个缺陷的情况下编写一个不同的变化函数吗?
const f1 = arity(parseInt,1);
const f2 = arity(parseInt,2);
/*
f1.length === 0
f2.length === 0
*/
6.7。 :当我们编写findMaximum()
和findMinimum()
时,我们编写了自己的函数来比较两个值——但 JavaScript 已经提供了相应的函数! 你能根据这个提示找出我们代码的其他版本吗?*
版权属于:月萌API www.moonapi.com,转载请注明出处