本人声明
- 本栏仅为归档自己看到的优秀文章;
- 文章版权归原作者所有;
- 因为个人水平有限,翻译难免有错误,请多多包涵。
- 第一部分已被翻译,详见Functional Javascript教程(一),全系列共四篇。
原文地址
https://jrsinclair.com/articles/2016/gentle-introduction-to-functional-javascript-functions/ Written by James Sinclair
文章正文
这是JavaScript函数式编程入门四部分的第三篇。在上一文中我们了解了函数式编程如何操作链表和数组。这篇文章我们将举例说明高阶函数——返回函数的函数。
返回函数的函数
上篇文章的最后, 我说深入了解函数式编程的道路并不适合每个人。这是因为一旦你了解处理列表的函数后,事情就会变得很奇怪。我的意思是刚开始我们把一系列指令抽象成函数,然后我们把循环抽象成map和reduce函数。下一个抽象阶段就是重构定义函数的模式。我们以返回函数的函数开始。这会是很有用也很优雅的东西,但是这会和你过去习惯写的JavaScript代码很不一样。
更多的组成成份
返回其它函数的函数有时候叫做高阶函数。为了彻底了解他们,我们需要复习一些JavaScript语言内置的使高阶函数成为可能的特性。
JavaScript中一个令人头痛的事情是一个函数能‘看到’(访问)的变量。在JavaScript中,如果你在一个函数中定义了一个变量,这个函数的外部是无法访问这个变量的。举个例子:
var thing = 'bat';
var sing = function(){
//This function can 'see' thing;这个函数能访问外面的变量thing
var line = 'Twinkle, twinkle, little ' + thing;
log(line);
sing(); //Twinkle, twinkle, little bat
//outside the function we can't see message though;但是外面无法看见这条信息
log();//undefined
然而,如果我们在一个函数内部定义一个函数,内部函数能访问外部函数的变量:
var outer = function(){
var outerVar = 'Hatter';
var inner = function(){
//We can 'see' outerVar here;这里能访问变量outerVar;
console.log(outerVar);
//Hatter
var innerVar = 'Dormouse';
//innerVar is only visible here inside inner();innerVar仅在inner函数内部能访问
}
//innerVar is not visible here.这里无法访问变量innerVar
这可能要花点时间才能习惯。其实这条规则很清晰,但是一旦我们把变量作为参数传递,这就会很难追踪哪个函数能访问哪个变量。如果刚开始这绕,耐心点:仔细观察定义函数的地方,找出哪些变量是可以访问的。如果你仅仅只观察调用函数的地方,事情可能会不是你期望的那样。
特殊的参数变量
当你在JavaScript中定义一个函数的时候,会同时定义一个叫做arguments的特殊变量,它有点类似于一个数组。包括了所有传入函数的实际参数。举个例子:
var showArgs = function(a, b){
console.log(arguments);
}
showArgs('Tweedledee', 'Tweedledum');
//=>{'0':'Tweedledee', '1':Tweedledum'}
注意输出与其说是一个数组更像是一个关键字正好是整数的object。
arguments有趣的地方是不管一个函数有多少形式参数,它包括了所有传给这个函数的实际参数,而且通过arguments变量都可以访问(所有的实际参数)。
showArgs('a', 'l', 'i','c', 'e');
//=> {'0':'a', '1':'l', '2':'i', '3':'c','4':'e'}
arguments变量和数组一样,也有一个length的属性。
varLen = function(){
console.log(arguments.length);
}
argLen('a', 'l', 'i', 'c', 'e');
//=>5
让arguments成为一个真正的数组通常很有用。在这些情况下我们可以通过数组内置方法slice把arguments变量转化成一个真正的数组。因为arguments不是一个真正的数组,所以我们必须通过一种较迂回的办法完成这项工作:
var showArgsAsArray = function(){
var args = Array.prototype.slice.call(arguments, 0);
console.log(args);
}
showArgsAsArray('Tweedledee', 'Tweedledum');
//=> ['Tweedledee', 'Tweedledum']
arguments常用来定义接受动态(变化)参数函数。稍后我们将看到这会有多方便。
Call和Apply
我们之前看到JavaScript数组有些内置方法,像.map和.reduce。其实函数也有一些内置方法。
调用一个函数的正常的办法是在函数名之后写上括号和任意的参数。举个例子:
function twinkleTwinkle(thing){
console.log('Twinkle, twinkle, little ' + thing);
}
twinkleTwinkle('bat);
//=> Twinkle, twinkle, little bat
函数的一个内置方法是call,并且它允许你以另一种方式调用一个函数:
twinkeTwinkel.call(null, 'star');
//=>Twinkle, twinkle, little star
.call方法的第一个参数规定函数内部的this变量的指向。但是我们现在可以忽略它。第二个参数开始的任何参数会直接传递给这个函数。.apply允许你将函数参数作为数组传入.apply的第二个参数,举个例子:
twinkleTwinkle.apply(null, ['bat']);
//=>Twinkle, twinkle.little bat
这两个方法在构造返回函数的函数时都非常有用。
匿名函数
JavaScript允许我们即时地定义函数。无论何处我们要定义一个变量,然后用这个变量做点事情,JavaScript允许我们即时定义一个包含此变量的函数。这经常发生在map和reduce中,举个例子:
var numbers = [1, 2, 3];
var doubleArray = map(function(x){return x*2;}, numbers);
console.log(doubleArray);
//=>[2, 4, 6]
像这样即时定义的一个函数我们把它叫做匿名函数,因为他们没有名字。他们有时也被称作'lambda'函数。
部分应用
有时候预先填充一个函数的参数会很方便。举个例子,想象一下我们定义了一个很方便的函数addClass,它读入一个类名(css类)和一个dom元素:
var addClass = function(className, element){
element.className += ' ' + className;
return element;
}
我们想利用此函数和map对多个元素添加类(css类),但是我们会遇到一个问题:map函数将数组中的项挨个作为回调函数的第一个参数,所以我们怎么告诉addClass添加哪个类名(addClass的第一个参数)?
一个解决办法是定义一个新的函数以一个我们想添加的类名调用addClass:
var addTweedleClass = function(el){
return addClass('tweedle', el);
}
现在我们有了只读入一个参数的函数。是时候调用map了:
var ids = ['DEE', 'DUM'];
var elements = map(document.getElementById, ids);
elements = map(addTweedleClass, elements);
但是如果我们想添加另一个类,我们必须定义另一个函数:
var addBoyClass = function(el){
return addClass('boy', el);
}
我们开始重复自己了(DRY)...,所以,来看看我们是否能为这种情况找到一个抽象模式。如果我们有一个函数能返回另一各第一个参数被预先填充的函数会怎样?
var partialFirstOfTwo = function(fn, param1){
return function(param2){
return fn(param1, param2);
}
}
注意第一个return语句。我们定义了返回另一个函数的函数。
var addTweedleClass = partialFirstOfTwo(addClass, 'tweedle');
var addBoyClass = partialFirstOfTwo(addClass, 'boy');
var ids = ['DEE', 'DUM'];
var elements = map(document.getElementById, ids);
elements = map(addTweedleClass, elements);
elements = map(addBoyClass, elements);
当我们的函数只读入两个参数时,这完成的还不错。但是如果我们想部分应用一个读取三个参数的函数怎么办?或者读取四个参数的函数?又或者我们想部分应用不止一个参数?这些情况下我们需要一个更普遍的部分应用函数。我们将用前面的slice和apply方法完成:
var argsToArray(args){
return Array.prototype.slice.call(args, 0);
}
var partial = function(){
var args = argsToArray(arguments);
var fn = args.shift();
return function(){
var remainingArgs = argsToArray(arguments);
return fn.apply(this, args.concat(remainArgs);
}
}
这个函数运行的细节不如它能做啥重要。此函数允许我们对接受任意参数的函数部分应用任意数量变量。
var twinkle = function(noun, wonderAbout){
return 'Twinkle, twinkle, little ' +
noun + '\nHow I wonder where you ' +
wonderAbout;
}
var twinkleBat = partial(twinkle, 'bat', 'are at');
var twinkleStar = partial(twinkle, 'star', 'are');
JavaScript有一个内置方法bind运行起来有点像partial。这是个所有函数都能访问的方法。问题是它要求它的第一个参数是你想将this变量绑定到的对象。这意味这,举个例子假如你想对document.getElementById部分应用某些东西,有必须把document作为第一个参数传入,像这样:
var getWhiteRabbit = document.getElementById.bind(document,'white-rabbit');
var getWhiteRabbit();
很多时候我们不需要this变量(尤其是在使用函数式编程时),所以我们可以就传入null作为第一个参数,举个例子:
var twinkleBat = twinkle.bind(null, 'bat', 'are at');
var twinkleStar = twinkle.bind(null, 'star', 'are');
你可以在.bind MDN JavaScript参考指南了解更多。
组合
在上篇文章我说过函数式编程是把小而精简的函数组合起来完成复杂的事情。正如之前演示的那样,部分应用是让过程更简单的工具。利用部分应用我们可以将我们的addClass函数转化成能和map配套使用的函数。组合是另一种将简单函数结合在一起的工具。
组合最简单的形式用两个函数a和b,两个函数都只读入一个参数。组合创建了第三个函数c。以参数x调用c返回的结果和以x为参数调用b的结果再调用a的结果一致...听上去很复杂,之前看例子会更好理解:
var composeTwo = function(funcA, funcB){
return function(x){
return funcA(funcB(x));
}
}
var nohow = function(sentence){
return sentence + ', nohow';
}
var statement = 'Not nothing’';
var nohowContrariwise = composeTwo(contrariwise, nohow);
console.log(nohowContrariwise(statement));
//=> Not nothing’, nohow! Contrariwise...
这很不错,我们可以用composeTwo就可以完成很多事情。但是,如果你开始写‘纯’函数(稍后会谈到),你就会发现自己想添加不止两个函数。为此我们需要一个更广义的compose函数:
var compose = function(){
var args = arguments;
var start = args.length-1;
return function(){
var i = start;
var result = args[start].apply(this, arguments);
i = i-1;
while(i>=0){
result = args[i].call(this, result);
i = i-1;
}
retur result;
};
};
再一次compose工作原理不如你能如何使用它重要。第一眼看上去,compose可能没那么神奇。我们可以用cmpose这样完成之前讨论的函数:
var nohowContrariwise = compose(contrariwise, nohow);
但是这似乎不比这种写法简单多少:
var nohowContrariwise = function(x){
return nohow(contrariwise(x));
}
一旦我们把它和curry结合compose的力量会更加强大,其实就算没有curry函数我们也能发现如果我们有一组实用简单的工具函数,我们可以利用compose让我们的代码更简洁清晰。举个例子,想象一下我们有首纯文本的诗:
var poem = 'Twas brillig, and the slithy toves\n' +
'Did gyre and gimble in the wabe;\n' +
'All mimsy were the borogoves,\n' +
'And the mome raths outgrabe.';
但是这首诗在浏览器并不会完美显示,所以我们来添加一些换行符。此外我们把brillig转换成更容易理解的词。然后我们再把整首诗装入一个段落标签和一个引用标签里。我们以定义两个简单的函数开始,然后依次完成其它任务:
var replace = function(find, replacement, str) {
return str.replace(find, replacement);
}
var wrapWith = function(tag, str) {
return '<' + tag + '>' + str + '</' + tag + '>';
}
var addBreaks = partial(replace, '\n', '<br/>\n');
var replaceBrillig = partial(replace, 'brillig', 'four o’clock in the afternoon');
var wrapP = partial(wrapWith, 'p');
var wrapBlockquote = partial(wrapWith, 'blockquote');
var modifyPoem = compose(wrapBlockquote, wrapP, addBreaks, replaceBrillig);
console.log(modifyPoem(poem));
//=> <blockquote><p>Twas four o’clock in the afternoon, and the slithy toves<br/>
// Did gyre and gimble in the wabe;<br/>
// All mimsy were the borogoves,<br/>
// And the mome raths outgrabe.</p></blockquote>
如果你从左到右读compose的参数就会注意到他们以调用的反序排列。这是因为compose反映了如果我们以嵌套方式调用这些函数的顺序。一些人觉得这种表示有点迷惑,所以大多数工具库都提供了一个反序版的compose,叫做pipe或者flow。
利用pipe函数,我们可以像下面这样写modifyPoem函数
var modifyPoem = pipe(replaceBrillig, addBreaks, wrapP, wrapBlockquote);
柯里化
compose函数的一个限制是它期望每个传入的函数都只接受一个参数。这并没有什么大不了,因为目前我们有partial函数,我们可以相对简单地将我们的多参数函数转化为单参数函数。但是这依然有点乏味。柯里化有点类似部分应用的极端情况。
curry函数的细节有点复杂,所以首先,我们来看一个例子。我们有一个formatName函数,返回一个人带引号的绰号,它读入三个参数。当我们以少于三个参数调用柯里化版的formatName时,它就会返回一个给定参数已经被部分应用的新函数:
var formatName = function(first, surname, nickname){
return first + ' "' + nickname + '" ' + surname;
}
var formatNameCurried = curry(formatName);
var james = formatName('James');
console.log(james('sinclair', 'Mad Hatter'));
//=> James "Mad Hatter" sinclair
var jamesS = james('Sinclair');
console.log(jamesS('Dormouse'));
//=> James "Dormouse" Sinclair
console.log(jamesS('Bandersnatch'));
//=> James "Bandersnatch" Sinclair
可以注意到柯里化函数的一些其它性质:
formatNameCurried('a')('b')('c') === formatNameCurried('a', 'b', 'c');
formatNameCurried('a', 'b')('c') === formatNameCurried('a')('b', 'c');
这些都很方便,但是好像不比partial强很多。假设一下,如果我们默认把定义的所有函数都柯里化。然后我们就能通过组合和柯里化构造任意的函数。
还记得之前的那首诗的例子吗?如果我们想在之前替换的字符串'four o'clock in the afternoon'加上强调标签怎么办?
var replace = curry(function(find, replacement, str){
var regex = new RegExp(find, 'g');
return str.replace(regex, replacement);
}
var wrapWith = curry(function(tag, str){
return '<' +tag + '>' + str + '</' + tag + '>';
});
var modifyPoem = pipe(
replace('brillig', wrapWith('em', 'four o'clock in the afternoon')),
replace('\n', '<br/>\n'),
wrapWith('p'),
wrapWith('blockquote')
);
console.log(modifyPoem(poem));
//=> <blockquote><p>Twas <em>four o'clock in the afternoon</em>
// Did gyre and gimble in the wabe;<br/>
// All mimsy were the borogoves,<br/>
// And the mome raths outgrabe.</p></blockquote>
注意我们用pipe替换了compose。并且没有其它的中间函数,我们把柯里化函数直接放在管道中。这依然很易懂。
下面是一份基于JavaScript Allongé的柯里化实现。再一次它的工作原理不如他能做什么重要。
function curry(fn){
var arity = fn.length;
function given(argsSoFar){
return function helper(){
var args = Array.prototype.slice.call(arguments, 0);
var updateArgsSoFar = argsSoFar.concat(argsd);
if(updateArgsSoFar.length>=arity){
reeturn fn.apply(this, updateArgsSoFar);
}
else{
return given(updateArgsSoFar);
}
}
}
return given([]);
}
但是为什么
目前为止,我们把partial,compose和curry看作串起那些简单函数来构造更加复杂函数的工具。但是,他们真的很有用?他们可以使之前不可能完成的事成为可能?额,他们只是打开了一种全新的编程方式。这种方式让我们以一种不同的方式思考问题,某一类问题的解决会变得更加的简单。这也能帮助我们写出更加健硕可信的代码。这会是下一篇文章的主题,所以如果你很好奇,请接着阅读...