翻译连载 | JavaScript轻量级函数式编程-第4章:组合函数 |《你不知道的JS》姊妹篇

关于译者:这是一个流淌着沪江血液的纯粹工程:认真,是 HTML 最坚实的梁柱;分享,是 CSS 里最闪耀的一瞥;总结,是 JavaScript 中最严谨的逻辑。通过捶打磨练,成就了本书的中文版。本书包含了函数式编程之精髓,但愿能够帮助你们在学习函数式编程的道路上走的更顺畅。比心。前端

译者团队(排名不分前后):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry萝卜vavd317vivaxy萌萌zhouyaogit

JavaScript轻量级函数式编程

第 4 章:组合函数

到目前为止,我但愿你能更轻松地理解在函数式编程中使用函数意味着什么。程序员

一个函数式编程者,会将他们程序中的每个函数当成一小块简单的乐高部件。他们能一眼辨别出蓝色的 2x2 方块,并准确地知道它是如何工做的、能用它作些什么。当构建一个更大、更复杂的乐高模型时,当每一次须要下一块部件的时候,他们可以准确地从备用部件中找到这些部件并拿过来使用。github

但有些时候,你把蓝色 2x2 的方块和灰色 4x1 的方块以某种形式组装到一块儿,而后意识到:“这是个有用的部件,我可能会经常使用到它”。ajax

那么你如今想到了一种新的“部件”,它是两种其余部件的组合,在须要的时候能触手可及。这时候,将这个蓝黑色 L 形状的方块组合体放到须要使用的地方,比每次分开考虑两种独立方块的组合要有效的多。编程

函数有多种多样的形状和大小。咱们可以定义某种组合方式,来让它们成为一种新的组合函数,程序中不一样的部分均可以使用这个函数。这种将函数一块儿使用的过程叫作组合。设计模式

输出到输入

咱们已经见过几种组合的例子。好比,在第 3 章中,咱们对 unary(..) 的讨论包含了以下表达式:unary(adder(3))。仔细想一想这里发生了什么。api

为了将两个函数整合起来,将第一个函数调用产生的输出当作第二个函数调用的输入。在 unary(adder(3)) 中,adder(3) 的调用返回了一个值(值是一个函数);该值被直接做为一个参数传入到 unary(..) 中,一样的,这个调用返回了一个值(值为另外一个函数)。数组

让咱们回放一下过程而且将数据流动的概念视觉化,是这个样子:安全

functionValue <-- unary <-- adder <-- 3
复制代码

3adder(..) 的输入。而 adder(..) 的输出是 unary(..) 的输入。unary(..) 的输出是 functionValue。 这就是 unary(..)adder(..) 的组合。

把数据的流向想象成糖果工厂的一条传送带,每一次操做其实都是冷却、切割、包装糖果中的一步。在该章节中,咱们将会用糖果工厂的类比来解释什么是组合。

让咱们一步一步的来了解组合。首先假设你程序中可能存在这么两个实用函数。

function words(str) {
	return String( str )
		.toLowerCase()
		.split( /\s|\b/ )
		.filter( function alpha(v){
			return /^[\w]+$/.test( v );
		} );
}

function unique(list) {
	var uniqList = [];

	for (let i = 0; i < list.length; i++) {
		// value not yet in the new list?
		if (uniqList.indexOf( list[i] ) === -1 ) {
			uniqList.push( list[i] );
		}
	}

	return uniqList;
}
复制代码

使用这两个实用函数来分析文本字符串:

var text = "To compose two functions together, pass the \ output of the first function call as the input of the \ second function call.";

var wordsFound = words( text );
var wordsUsed = unique( wordsFound );

wordsUsed;
// ["to","compose","two","functions","together","pass",
// "the","output","of","first","function","call","as",
// "input","second"]
复制代码

咱们把 words(..) 输出的数组命名为 wordsFoundunique(..) 的输入也是一个数组,所以咱们能够将 wordsFound 传入给它。

让咱们从新回到糖果工厂的流水线:第一台机器接收的“输入”是融化的巧克力,它的“输出”是一堆成型且冷却的巧克力。流水线上的下一个机器将这堆巧克力做为它的“输入”,它的“输出”是一片片切好的巧克力糖果。下一步就是,流水线上的另外一台机器将这些传送带上的小片巧克力糖果处理,并输出成包装好的糖果,准备打包和运输。

糖果工厂靠这套流程运营的很成功,可是和全部的商业公司同样,管理者们须要不停的寻找增加点。

为了跟上更多糖果的生产需求,他们决定拿掉传送带这么个玩意,直接把三台机器叠在一块儿,这样第一台的输出阀就直接和下一台的输入阀直接连一块儿了。这样第一台机器和第二台机器之间,就不再会有一堆巧克力在传送带上慢吞吞的移动了,而且也不会有空间浪费和隆隆的噪音声了。

这项革新为工厂节省了很大的空间,因此管理者很高兴,他们天天可以造更多的糖果了!

等价于这种升级后的糖果工厂配置的代码跳过了中间步骤(上面代码片断中的 wordsFound 变量),仅仅是将两个函数调用一块儿使用:

var wordsUsed = unique( words( text ) );
复制代码

注意: 尽管咱们一般以从左往右的方式阅读函数调用 ———— 先 unique(..) 而后 words(..) ———— 这里的操做顺序其实是从右往左的,或者说是自内而外。words(..) 将会首先运行,而后才是 unique(..)。晚点咱们会讨论符合咱们天然的、从左往右阅读执行顺序的模式,叫作 pipe(..)

堆在一块儿的机器工做的还不错,但有些笨重了,电线挂的处处都是。创造的机器堆越多,工厂车间就会变得越凌乱。并且,装配和维护这些机器堆太占用时间了。

有一天早上,一个糖果工厂的工程师忽然想到了一个好点子。她想,若是她能在外面作一个大盒子把全部的电线都藏起来,效果确定超级棒;盒子里面,三台机器相互链接,而盒子外面,一切都变得很整洁、干净。在这个很赞的机器的顶部,是倾倒融化巧克力的管道,在它的底部,是吐出包装好的巧克力糖果的管道。

这样一个单个的组合版机器,变得更易移动和安装到工厂须要的地方中去了。工厂的车间工人也会变得更高兴,由于他们不用再摆弄三台机子上的那些按钮和表盘了;他们很快更喜欢使用这个独立的很赞的机器。

回到代码上:咱们如今了解到 words(..)unique(..) 执行的特定顺序 -- 思考:组合的乐高 -- 是一种咱们在应用中其它部分也可以用到的东西。因此,如今让咱们定义一个组合这些玩意的函数:

function uniqueWords(str) {
	return unique( words( str ) );
}
复制代码

uniqueWords(..) 接收一个字符串并返回一个数组。它是 unique(..)words(..) 的组合,而且知足咱们的数据流向要求:

wordsUsed <-- unique <-- words <-- text
复制代码

你如今应该可以明白了:糖果工厂设计模式的演变革命就是函数的组合。

制造机器

糖果工厂一切运转良好,多亏了省下的空间,他们如今有足够多的地方来尝试制做新的糖果了。鉴于以前的成功,管理者迫切的想要发明新的棒棒的组合版机器,从而制造愈来愈多种类的糖果。

但工厂的工程师们跟不上老板的节奏,由于每次造一台新的棒棒的组合版机器,他们就要花费不少的时间来造新的外壳,从而适应那些独立的机器。

因此工程师们联系了一家工业机器制供应商来帮他们。他们很惊讶的发现这家供应商居然提供 机器制造 器!听起来好像难以想象,他们买入了一台这样的机器,这台机器可以将工厂中小一点的机器 ———— 好比负责巧克力冷却、切割的机器 ———— 自动连线,甚至在外面还自动包了一个干净的大盒子。这么牛的机器简直能把这家糖果工厂送上天了!

回到代码上,让咱们定义一个实用函数叫作 compose2(..),它可以自动建立两个函数的组合,这和咱们手动作的是如出一辙的。

function compose2(fn2,fn1) {
	return function composed(origValue){
		return fn2( fn1( origValue ) );
	};
}

// ES6 箭头函数形式写法
var compose2 =
	(fn2,fn1) =>
		origValue =>
			fn2( fn1( origValue ) );
复制代码

你是否注意到咱们定义参数的顺序是 fn2,fn1,不只如此,参数中列出的第二个函数(也被称做 fn1)会首先运行,而后才是参数中的第一个函数(fn2)?换句话说,这些函数是以从右往左的顺序组合的。

这看起来是种奇怪的实现,但这是有缘由的。大部分传统的 FP 库为了顺序而将它们的 compose(..) 定义为从右往左的工做,因此咱们沿袭了这种惯例。

可是为何这么作?我认为最简单的解释(但不必定符合真实的历史)就是咱们在以手动执行的书写顺序来列出它们时,或是与咱们从左往右阅读这个列表时看到它们的顺序相符合。

unique(words(str)) 以从左往右的顺序列出了 unique, words 函数,因此咱们让 compose2(..) 实用函数也以这种顺序接收它们。如今,更高效的糖果制造机定义以下:

var uniqueWords = compose2( unique, words );
复制代码

组合的变体

看起来貌似 <-- unique <-- words 的组合方式是这两种函数可以被组合起来的惟一顺序。但咱们实际上可以以另外的目的建立一个实用函数,将它们以相反的顺序组合起来。

var letters = compose2( words, unique );

var chars = letters( "How are you Henry?" );
chars;
// ["h","o","w","a","r","e","y","u","n"]
复制代码

由于 words(..) 实用函数,上面的代码才能正常工做。为了值类型的安全,首先使用 String(..) 将它的输入强转为一个字符串。因此 unique(..) 返回的数组 -- 如今是 words(..) 的输入 -- 成为了 "H,o,w, ,a,r,e,y,u,n,?" 这样的字符串。而后 words(..) 中的行为将字符串处理成为 chars 数组。

不得不认可,这是个刻意的例子。但重点是,函数的组合不老是单向的。有时候咱们将灰方块放到蓝方块上,有时咱们又会将蓝方块放到最上面。

假如糖果工厂尝试将包装好的糖果放入搅拌和冷却巧克力的机器,那他们最好要当心点了。

通用组合

若是咱们可以定义两个函数的组合,咱们也一样可以支持组合任意数量的函数。任意数目函数的组合的通用可视化数据流以下:

finalValue <-- func1 <-- func2 <-- ... <-- funcN <-- origValue
复制代码

如今糖果工厂拥有了最好的制造机:它可以接收任意数量独立的小机器,并吐出一个大只的、超赞的机器,能把每一步都按照顺序作好。这个糖果制做流程简直棒呆了!简直是威利·旺卡(译者注:《查理和巧克力工厂》中的人物,他拥有一座巧克力工厂)的梦想!

咱们可以像这样实现一个通用 compose(..) 实用函数:

function compose(...fns) {
	return function composed(result){
		// 拷贝一份保存函数的数组
		var list = fns.slice();

		while (list.length > 0) {
			// 将最后一个函数从列表尾部拿出
			// 并执行它
			result = list.pop()( result );
		}

		return result;
	};
}

// ES6 箭头函数形式写法
var compose =
	(...fns) =>
		result => {
			var list = fns.slice();

			while (list.length > 0) {
				// 将最后一个函数从列表尾部拿出
				// 并执行它
				result = list.pop()( result );
			}

			return result;
		};
复制代码

如今看一下组合超过两个函数的例子。回想下咱们的 uniqueWords(..) 组合例子,让咱们增长一个 skipShortWords(..)

function skipShortWords(list) {
	var filteredList = [];

	for (let i = 0; i < list.length; i++) {
		if (list[i].length > 4) {
			filteredList.push( list[i] );
		}
	}

	return filteredList;
}
复制代码

让咱们再定义一个 biggerWords(..) 来包含 skipShortWords(..)。咱们指望等价的手工组合方式是 skipShortWords(unique(words(text))),因此让咱们采用 compose(..) 来实现它:

var text = "To compose two functions together, pass the \ output of the first function call as the input of the \ second function call.";

var biggerWords = compose( skipShortWords, unique, words );

var wordsUsed = biggerWords( text );

wordsUsed;
// ["compose","functions","together","output","first",
// "function","input","second"]
复制代码

如今,让咱们回忆一下第 3 章中出现的 partialRight(..) 来让组合变的更有趣。咱们可以构造一个由 compose(..) 自身组成的右偏函数应用,经过提早定义好第二和第三参数(unique(..)words(..));咱们把它称做 filterWords(..)(以下)。

而后,咱们可以经过屡次调用 filterWords(..) 来完成组合,可是每次的第一参数却各不相同。

// 注意: 使用 a <= 4 来检查,而不是 skipShortWords(..) 中用到的 > 4
function skipLongWords(list) { /* .. */ }

var filterWords = partialRight( compose, unique, words );

var biggerWords = filterWords( skipShortWords );
var shorterWords = filterWords( skipLongWords );

biggerWords( text );
// ["compose","functions","together","output","first",
// "function","input","second"]

shorterWords( text );
// ["to","two","pass","the","of","call","as"]
复制代码

花些时间考虑一下基于 compose(..) 的右偏函数应用给了咱们什么。它容许咱们在组合的第一步以前作指定,而后以不一样后期步骤 (biggerWords(..) and shorterWords(..)) 的组合来建立特定的变体。这是函数式编程中最强大的手段之一。

你也能经过 curry(..) 建立的组合来替代偏函数应用,但由于从右往左的顺序,比起只使用 curry( compose, ..),你可能更想使用 curry( reverseArgs(compose), ..)

注意: 由于 curry(..)(至少咱们在第 3 章中实现的是这样)依赖于探测参数数目(length)或手动指定其数目,而 compose(..) 是一个可变的函数,因此你须要手动指定数目,就像这样 curry(.. , 3)

不一样的实现

固然,你可能永远不会在生产中使用本身写的 compose(..),而更倾向于使用某个库所提供的方案。但我发现了解底层工做的原理实际上对强化理解函数式编程中通用概念很是有用。

因此让咱们看看对于 compose(..) 的不一样实现方案。咱们能看到每一种实现的优缺点,特别是性能方面。

咱们将稍后在文中查看 reduce(..) 实用函数的细节,但如今,只需了解它将一个列表(数组)简化为一个单一的有限值。看起来像是一个很棒的循环体。

举个例子,若是在数字列表 [1,2,3,4,5,6] 上作加法约减,你将要循环它们,而且随着循环将它们加在一块儿。这一过程将首先将 12,而后将结果加 3,而后加 4,等等。最后获得总和:21

原始版本的 compose(..) 使用一个循环而且饥渴的(也就是,马上)执行计算,将一个调用的结果传递到下一个调用。咱们能够经过 reduce(..) (代替循环)作到一样的事。

function compose(...fns) {
	return function composed(result){
		return fns.reverse().reduce( function reducer(result,fn){
			return fn( result );
		}, result );
	};
}

// ES6 箭头函数形式写法
var compose = (...fns) =>
	result =>
		fns.reverse().reduce(
			(result,fn) =>
				fn( result )
			, result
		);
复制代码

注意到 reduce(..) 循环发生在最后的 composed(..) 运行时,而且每个中间的 result(..) 将会在下一次调用时做为输入值传递给下一个迭代。

这种实现的优势就是代码更简练,而且使用了常见的函数式编程结构:reduce(..)。这种实现方式的性能和原始的 for 循环版本很相近。

可是,这种实现局限处在于外层的组合函数(也就是,组合中的第一个函数)只能接收一个参数。其余大多数实如今首次调用的时候就把全部参数传进去了。若是组合中的每个函数都是一元的,这个方案没啥大问题。但若是你须要给第一个调用传递多参数,那么你可能须要不一样的实现方案。

为了修正第一次调用的单参数限制,咱们能够仍使用 reduce(..) ,但加一个懒执行函数包裹器:

function compose(...fns) {
	return fns.reverse().reduce( function reducer(fn1,fn2){
		return function composed(...args){
			return fn2( fn1( ...args ) );
		};
	} );
}

// ES6 箭头函数形式写法
var compose =
	(...fns) =>
		fns.reverse().reduce( (fn1,fn2) =>
			(...args) =>
				fn2( fn1( ...args ) )
		);
复制代码

注意到咱们直接返回了 reduce(..) 调用的结果,该结果自身就是个函数,不是一个计算过的值。函数让咱们可以传入任意数目的参数,在整个组合过程当中,将这些参数传入到第一个函数调用中,而后依次产出结果给到后面的调用。

相较于直接计算结果并把它传入到 reduce(..) 循环中进行处理,这种实现经过在组合以前只运行 一次 reduce(..) 循环,而后将全部的函数调用运算所有延迟了 ———— 称为惰性运算。每个简化后的局部结果都是一个包裹层级更多的函数。

当你调用最终组合函数而且提供一个或多个参数的时候,这个层层嵌套的大函数内部的全部层级,由内而外调用,以相反的方式连续执行(不是经过循环)。

这个版本的性能特征和以前 reduce(..) 基础实现版有潜在的差别。在这儿,reduce(..) 只在生成大个的组合函数时运行过一次,而后这个组合函数只是简单的一层层执行它内部所嵌套的函数。在前一版本中,reduce(..) 将在每一次调用中运行。

在考虑哪种实现更好时,你的状况可能会不同,可是要记得后面的实现方式并无像前一种限制只能传一个参数。

咱们也可以使用递归来定义 compose(..)。递归式定义的 compose(fn1,fn2, .. fnN) 看起来会是这样:

compose( compose(fn1,fn2, .. fnN-1), fnN );
复制代码

注意: 咱们将在第 9 章揭示更多的细节,因此若是这块看起来让你疑惑,那么暂时跳过该部分是没问题的,你能够在阅读完第 9 章后再来看。

这里是咱们用递归实现 compose(..) 的代码:

function compose(...fns) {
	// 拿出最后两个参数
	var [ fn1, fn2, ...rest ] = fns.reverse();

	var composedFn = function composed(...args){
		return fn2( fn1( ...args ) );
	};

	if (rest.length == 0) return composedFn;

	return compose( ...rest.reverse(), composedFn );
}

// ES6 箭头函数形式写法
var compose =
	(...fns) => {
		// 拿出最后两个参数
		var [ fn1, fn2, ...rest ] = fns.reverse();

		var composedFn =
			(...args) =>
				fn2( fn1( ...args ) );

		if (rest.length == 0) return composedFn;

		return compose( ...rest.reverse(), composedFn );
	};
复制代码

我认为递归实现的好处是更加概念化。我我的以为相较于不得不在循环里跟踪运行结果,经过递归的方式进行重复的动做反而更易懂。因此我更喜欢以这种方式的代码来表达。

其余人可能会以为递归的方法在智力上形成的困扰更让人有些畏惧。我建议你做出本身的评估。

重排序组合

咱们早期谈及的是从右往左顺序的标准 compose(..) 实现。这么作的好处是可以和手工组合列出参数(函数)的顺序保持一致。

不足之处就是它们排列的顺序和它们执行的顺序是相反的,这将会形成困扰。同时,不得不使用 partialRight(compose, ..) 提前定义要在组合过程当中 第一个 执行的函数。

相反的顺序,从右往左的组合,有个常见的名字:pipe(..)。这个名字听说来自 Unix/Linux 界,那里大量的程序经过“管道传输”(| 运算符)第一个的输出到第二个的输入,等等(即,ls -la | grep "foo" | less)。

pipe(..)compose(..) 如出一辙,除了它将列表中的函数从左往右处理。

function pipe(...fns) {
	return function piped(result){
		var list = fns.slice();

		while (list.length > 0) {
			// 从列表中取第一个函数并执行
			result = list.shift()( result );
		}

		return result;
	};
}
复制代码

实际上,咱们只需将 compose(..) 的参数反转就能定义出来一个 pipe(..)

var pipe = reverseArgs( compose );
复制代码

很是简单!

回忆下以前的通用组合的例子:

var biggerWords = compose( skipShortWords, unique, words );
复制代码

pipe(..) 的方式来实现,咱们只须要反转参数的顺序:

var biggerWords = pipe( words, unique, skipShortWords );
复制代码

pipe(..) 的优点在于它以函数执行的顺序排列参数,某些状况下可以减轻阅读者的疑惑。pipe(words,unique,skipShortWords) 看起来和读起来会更简单,能知道咱们首先执行 words(..),而后 unique(..),最后是 skipShortWords(..)

假如你想要部分的应用第一个函数(们)来负责执行,pipe(..) 一样也很方便。就像咱们以前使用 compose(..) 构建的右偏函数应用同样。

对比:

var filterWords = partialRight( compose, unique, words );

// vs

var filterWords = partial( pipe, words, unique );
复制代码

你可能会回想起第 3 章 partialRight(..) 中的定义,它实际使用了 reverseArgs(..),就像咱们的 pipe(..) 如今所作的。因此,无论怎样,咱们获得了一样的结果。

在这一特定场景下使用 pipe(..) 的轻微性能优点在于咱们没必要再经过右偏函数应用的方式来使用 compose(..) 保存从右往左的参数顺序,使用 pipe(..) 咱们没必要再跟 partialRight(..) 同样须要将参数顺序反转回去。因此在这里 partial(pipe, ..)partialRight(compose, ..) 要好一点。

通常来讲,在使用一个完善的函数式编程库时,pipe(..)compose(..) 没有明显的性能区别。

抽象

抽象常常被定义为对两个或多个任务公共部分的剥离。通用部分只定义一次,从而避免重复。为了展示每一个任务的特殊部分,通用部分须要被参数化。

举个例子,思考以下(明显刻意生成的)代码:

function saveComment(txt) {
	if (txt != "") {
		comments[comments.length] = txt;
	}
}

function trackEvent(evt) {
	if (evt.name !== undefined) {
		events[evt.name] = evt;
	}
}
复制代码

这两个实用函数都是将一个值存入一个数据源,这是通用的部分。不一样的是一个是将值放置到数组的末尾,另外一个是将值放置到对象的某个属性上。

让咱们抽象一下:

function storeData(store,location,value) {
	store[location] = value;
}

function saveComment(txt) {
	if (txt != "") {
		storeData( comments, comments.length, txt );
	}
}

function trackEvent(evt) {
	if (evt.name !== undefined) {
		storeData( events, evt.name, evt );
	}
}
复制代码

引用一个对象(或数组,多亏了 JS 中方便的 [] 符号)属性和将值设入的通用任务被抽象到独立的 storeData(..) 函数。这个函数当前只有一行代码,该函数能提出其它多任务中通用的行为,好比生成惟一的数字 ID 或将时间戳存入。

若是咱们在多处重复通用的行为,咱们将会面临改了几处但忘了改别处的维护风险。在作这类抽象时,有一个原则是,一般被称做 DRY(don't repeat yourself)。

DRY 力求能在程序的任何任务中有惟一的定义。代码不够 DRY 的另外一个托辞就是程序员们太懒,不想作非必要的工做。

抽象可以走得更远。思考:

function conditionallyStoreData(store,location,value,checkFn) {
	if (checkFn( value, store, location )) {
		store[location] = value;
	}
}

function notEmpty(val) { return val != ""; }

function isUndefined(val) { return val === undefined; }

function isPropUndefined(val,obj,prop) {
	return isUndefined( obj[prop] );
}

function saveComment(txt) {
	conditionallyStoreData( comments, comments.length, txt, notEmpty );
}

function trackEvent(evt) {
	conditionallyStoreData( events, evt.name, evt, isPropUndefined );
}
复制代码

为了实现 DRY 和避免重复的 if 语句,咱们将条件判断移动到了通用抽象中。咱们一样假设在程序中其它地方可能会检查非空字符串或非 undefined 的值,因此咱们也能将这些东西 DRY 出来。

这些代码如今变得更 DRY 了,但有些抽象过分了。开发者须要对他们程序中每一个部分使用恰当的抽象级别保持谨慎,不能太过,也不能不够。

关于咱们在本章中对函数的组合进行的大量讨论,看起来它的好处是实现这种 DRY 抽象。但让咱们别急着下结论,由于我认为组合实际上在咱们的代码中发挥着更重要的做用。

并且,即便某些东西只出现了一次,组合仍然十分有用 (没有重复的东西能够被抽出来)。

除了通用化和特殊化的对比,我认为抽象有更多有用的定义,正以下面这段引用所说:

... 抽象是一个过程,程序员将一个名字与潜在的复杂程序片断关联起来,这样该名字就可以被认为表明函数的目的,而不是表明函数如何实现的。经过隐藏无关的细节,抽象下降了概念复杂度,让程序员在任意时间均可以集中注意力在程序内容中的可维护子集上。

《程序设计语言》, 迈克尔 L 斯科特

books.google.com/books?id=jM…

// TODO: 给这本书或引用弄一个更好的参照,至少找到一个更好的在线连接

这段引用表述的观点是抽象 ———— 一般来讲,是指把一些代码片断放到本身的函数中 ———— 是围绕着能将两部分功能分离,从而达到能够专一于某一独立的部分为主要目的来服务的。

须要注意的是,这种场景下的抽象并非为了隐藏细节,好比把一些东西看成黑盒来对待。这一观念其实更贴近于编程中的封装性原则。咱们不是为了隐藏细节而抽象,而是为了经过分离来突出关注点

还记得这段文章的开头,我说函数式编程的目的是为了创造更可读、更易理解的代码。一个有效的方法是将交织缠绕的 ———— 牢牢编织在一块儿,像一股绳子 ———— 代码解绑为分离的、更简单的 ———— 松散绑定的 ———— 代码片断。以这种方式来作的话,代码的阅读者将不会在寻找其它部分细节的时候被其中某块的细节所分心。

咱们更高的目标是不仅对某些东西实现一次,这是 DRY 的观念。实际上,有些时候咱们确实在代码中不断重复。因而,咱们寻求更分离的实现方式。咱们尝试突出关注点,由于这能提升可读性。

另外一种描述这个目标的方式就是 ———— 经过命令式 vs 声明式的编程风格。命令式代码主要关心的是描述怎么作来准确完成一项任务。声明式代码则是描述输出应该是什么,并将具体实现交给其它部分。

换句话说,声明式代码从怎么作中抽象出了是什么。尽管普通的声明式代码在可读性上强于命令式,但没有程序(除了机器码 1 和 0)是彻底的声明式或者命令式代码。编程者必须在它们之间寻找平衡。

ES6 增长了不少语法功能,能将老的命令式操做转换为新的声明式形式。可能最清晰的当属解构了。解构是一种赋值模式,它描述了如何将组合值(对象、数组)内的构成值分解出来的方法。

这里是一个数组解构的例子:

function getData() {
	return [1,2,3,4,5];
}

// 命令式
var tmp = getData();
var a = tmp[0];
var b = tmp[3];

// 声明式
var [ a ,,, b ] = getData();
复制代码

是什么就是将数组中的第一个值赋给 a,而后第四个值赋给 b怎么作就是获得一个数组的引用(tmp)而后手动的经过数组索引 03,分别赋值给 ab

数组的解构是否隐藏了赋值细节?这要看你看待的角度了。我认为它知识简单的将是什么怎么作中分离出来。JS 引擎仍然作了赋值的工做,但它阻止了你本身去抽象怎么作的过程。

相反的是,你阅读 [ a ,,, b ] = .. 的时候,便能看到该赋值模式只不过是告诉你将要发生的是什么。数组的解构是声明式抽象的一个例子。

将组合看成抽象

函数组合到底作了什么?函数组合一样也是一种声明式抽象。

回想下以前的 shorterWords(..) 例子。让咱们对比下命令式和声明式的定义。

// 命令式
function shorterWords(text) {
	return skipLongWords( unique( words( text ) ) );
}

// 声明式
var shorterWords = compose( skipLongWords, unique, words );
复制代码

声明式关注点在是什么上 -- 这 3 个函数传递的数据从一个字符串到一系列更短的单词 -- 而且将怎么作留在了 compose(..)的内部。

在一个更大的层面上看,shorterWords = compose(..) 行解释了怎么作来定义一个 shorterWords(..) 实用函数,这样在代码的别处使用时,只需关注下面这行声明式的代码输出是什么

shorterWords( text );
复制代码

组合将一步步获得一系列更短的单词的过程抽象了出来。

相反的看,若是咱们不使用组合抽象呢?

var wordsFound = words( text );
var uniqueWordsFound = unique( wordsFound );
skipLongWords( uniqueWordsFound );
复制代码

或者这种:

skipLongWords( unique( words( text ) ) );
复制代码

这两个版本展现的都是一种更加命令式的风格,违背了声明式风格优先原则。阅读者关注这两个代码片断时,会被更多的要求了解怎么作而不是是什么

函数组合并非经过 DRY 的原则来节省代码量。即便 shorterWords(..) 的使用只出现了一次 -- 因此并无重复问题须要避免!-- 从怎么作中分离出是什么仍能帮助咱们提高代码。

组合是一个抽象的强力工具,它可以将命令式代码抽象为更可读的声明式代码。

回顾形参

既然咱们已经把组合都了解了一遍 -- 那么是时候抛出函数式编程中不少地方都有用的小技巧了 -- 让咱们经过在某个场景下回顾第 3 章的“无形参”(译者注:“无形参”指的是移除对函数形参的引用)段落中的 point-free 代码,并把它重构的稍微复杂点来观察这种小技巧。

// 提供该API:ajax( url, data, cb )
var getPerson = partial( ajax, "http://some.api/person" );
var getLastOrder = partial( ajax, "http://some.api/order", { id: -1 } );

getLastOrder( function orderFound(order){
	getPerson( { id: order.personId }, function personFound(person){
		output( person.name );
	} );
} );
复制代码

咱们想要移除的“点”是对 orderperson 参数的引用。

让咱们尝试将 person 形参移出 personFound(..) 函数。要达到目的,咱们须要首先定义:

function extractName(person) {
	return person.name;
}
复制代码

但据咱们观察这段操做可以表达的更通用些:将任意对象的任意属性经过属性名提取出来。让咱们把这个实用函数称为 prop(..)

function prop(name,obj) {
	return obj[name];
}

// ES6 箭头函数形式
var prop =
	(name,obj) =>
		obj[name];
复制代码

咱们处理对象属性的时候,也须要定义下反操做的工具函数:setProp(..),为了将属性值设到某个对象上。

可是,咱们想当心一些,不改动现存的对象,而是建立一个携带变化的复制对象,并将它返回出去。这样处理的缘由将在第 5 章中讨论更多细节。

function setProp(name,obj,val) {
	var o = Object.assign( {}, obj );
	o[name] = val;
	return o;
}
复制代码

如今,定义一个 extractName(..) ,它能将对象中的 "name" 属性拿出来,咱们将部分应用 prop(..)

var extractName = partial( prop, "name" );
复制代码

注意: 不要误解这里的 extractName(..),它其实什么都尚未作。咱们只是部分应用 prop(..) 来建立了一个等待接收包含 "name"属性的对象的函数。咱们也能经过curry(prop)("name")作到同样的事。

下一步,让咱们缩小关注点,看下例子中嵌套的这块查找操做的调用:

getLastOrder( function orderFound(order){
	getPerson( { id: order.personId }, outputPersonName );
} );
复制代码

咱们该如何定义 outputPersonName(..)?为了方便形象化咱们所须要的东西,想一下咱们须要的数据流是什么样:

output <-- extractName <-- person
复制代码

outputPersonName(..) 须要是一个接收(对象)值的函数,并将它传递给 extractName(..),而后将处理后的值传给 output(..)

但愿你能看出这里须要 compose(..) 操做。因此咱们可以将 outputPersonName(..) 定义为:

var outputPersonName = compose( output, extractName );
复制代码

咱们刚刚建立的 outputPersonName(..) 函数是提供给 getPerson(..) 的回调。因此咱们还能定义一个函数叫作 processPerson(..) 来处理回调参数,使用 partialRight(..)

var processPerson = partialRight( getPerson, outputPersonName );
复制代码

让咱们用新函数来重构下以前的代码:

getLastOrder( function orderFound(order){
	processPerson( { id: order.personId } );
} );
复制代码

唔,进展还不错!

但咱们须要继续移除掉 order 这个“形参”。下一步是观察 personId 可以被 prop(..) 从一个对象(好比 order)中提取出来,就像咱们在 person 对象中提取 name 同样。

var extractPersonId = partial( prop, "personId" );
复制代码

为了建立传递给 processPerson(..) 的对象( { id: .. } 的形式),让咱们建立一个实用函数 makeObjProp(..),用来以特定的属性名将值包装为一个对象。

function makeObjProp(name,value) {
	return setProp( name, {}, value );
}

// ES6 箭头函数形式
var makeObjProp =
	(name,value) =>
		setProp( name, {}, value );
复制代码

提示: 这个实用函数在 Ramda 库中被称为 objOf(..)

就像咱们以前使用 prop(..) 来建立 extractName(..),咱们将部分应用 makeObjProp(..) 来建立 personData(..) 函数用来制做咱们的数据对象。

var personData = partial( makeObjProp, "id" );
复制代码

为了使用 processPerson(..) 来完成经过 order 值查找一我的的功能,咱们须要的数据流以下:

processPerson <-- personData <-- extractPersonId <-- order
复制代码

因此咱们只须要再使用一次 compose(..) 来定义一个 lookupPerson(..)

var lookupPerson = compose( processPerson, personData, extractPersonId );
复制代码

而后,就是这样了!把这整个例子从新组合起来,不带任何的“形参”:

var getPerson = partial( ajax, "http://some.api/person" );
var getLastOrder = partial( ajax, "http://some.api/order", { id: -1 } );

var extractName = partial( prop, "name" );
var outputPersonName = compose( output, extractName );
var processPerson = partialRight( getPerson, outputPersonName );
var personData = partial( makeObjProp, "id" );
var extractPersonId = partial( prop, "personId" );
var lookupPerson = compose( processPerson, personData, extractPersonId );

getLastOrder( lookupPerson );
复制代码

哇哦。没有形参。而且 compose(..) 在两处地方看起来至关有用!

我认为在这样的场景下,即便推导出咱们最终答案的步骤有些多,但最终的代码却变得更加可读,由于咱们不用再去详细的调用每一步了。

即便你不想看到或命名这么多中间步骤,你依然能够经过不使用独立变量而是将表达式串起来来来保留无点特性。

partial( ajax, "http://some.api/order", { id: -1 } )
(
	compose(
		partialRight(
			partial( ajax, "http://some.api/person" ),
			compose( output, partial( prop, "name" ) )
		),
		partial( makeObjProp, "id" ),
		partial( prop, "personId" )
	)
);
复制代码

这段代码确定没那么罗嗦了,但我认为比以前的每一个操做都有其对应的变量相比,可读性略有下降。可是无论怎样,组合帮助咱们实现了无点的风格。

总结

函数组合是一种定义函数的模式,它能将一个函数调用的输出路由到另外一个函数的调用上,而后一直进行下去。

由于 JS 函数只能返回单个值,这个模式本质上要求全部组合中的函数(可能第一个调用的函数除外)是一元的,当前函数从上一个函数输出中只接收一个输入。

相较于在咱们的代码里详细列出每一个调用,函数组合使用 compose(..) 实用函数来提取出实现细节,让代码变得更可读,让咱们更关注组合完成的是什么,而不是它具体作什么

组合 ———— 声明式数据流 ———— 是支撑函数式编程其余特性的最重要的工具之一。

** 【上一章】翻译连载 | JavaScript 轻量级函数式编程-第3章:管理函数的输入 |《你不知道的JS》姊妹篇 **

** 【下一章】翻译连载 | JavaScript轻量级函数式编程-第5章:减小反作用 |《你不知道的JS》姊妹篇 **

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。

>> 沪江Web前端上海团队招聘【Web前端架构师】,有意者简历至:zhouyao@hujiang.com <<


2019年,iKcamp原创新书《Koa与Node.js开发实战》已在京东、天猫、亚马逊、当当开售啦!