Skip to content

Latest commit

 

History

History
665 lines (568 loc) · 39.4 KB

ch-04.md

File metadata and controls

665 lines (568 loc) · 39.4 KB

title: 组合函数 category: JS轻量级函数式编程 date: 2017-06-25 tag: [JavaScript,函数式编程,翻译] layout: post toc: true


《JS轻量级函数式编程》系列的第四章。利用函数将某个复杂的操作拆分成可复用的简单部分,是编程者很常用的手段,这一章就是从这个角度讲述了简单函数的各种组合方法,如同积木一般用简单函数搭建复杂程序。最后还从函数组合的角度再次讲述了无值风格的编程技巧。

到现在,我希望你已经对我们使用函数来进行函数式编程的意义有所了解。 一个函数式程序员看待他们程序中的每个函数就像是看到乐高积木那样。他们一眼就能认出这个蓝色的2X2的砖块,并且知道它是如何工作的,以及它们能够做什么。随着他们开始搭建起更大更复杂的模型时,对于他们所需要的每个组件,他们都已经有了从备件中挑选出所需部件的本能。 但是,有时候你会把蓝色的2X2的砖块和灰色的4X4的砖块按照正确的方式放在一起,而且你随之想到:“这是个很有用的组件,我应该会经常用到它。” 所以,现在你构造了一个新的“组件”,这是由另外两个组件组合而来的,你可以在任何你需要的时候通过刚才的方式来构造这个新的组件。在你需要的时候直接使用这种复合型的蓝灰砖块明显要更有效率,而不是每次都考虑重新组装它们。 函数有着各种各样的形状和尺寸,我们可以定义它们的某种组合,以形成一个新的复合函数,这在程序的各个部分中都很方便。这个一起使用多个函数的过程就被称作组合 composition

输出到输入

我们已经看过不少有关组合的例子了。比如,在第三章我们讨论unary(..)的时候,我们提到了这个表达式unary(adder(3))。想想看这里发生了什么吧。

要组合两个函数,将第一个函数调用的输出作为第二个函数调用的输入。在unary(adder(3))中,adder(3)调用输出了一个值(一个函数);然后这个值就被当作实参,直接传递到了unary(..)中,它也将返回一个值(另一个函数)。 我们后退一步,将这个数据在概念上的流动状态可视化,大概式这样的:

functionValue <-- unary <-- adder <-- 3

3被输入到了adder(..)中,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 ) );
	};
}

// or the ES6 => form
var compose2 =
	(fn2,fn1) =>
		origValue =>
			fn2( fn1( origValue ) );

你注意到了吗?我们定义形参的顺序是fn2, fn1;此外,列表中的第二个函数(形参名是fn1)将会首先运行,然后才是列表中的第一个函数(fn2)。换句话说,这些函数是从右往左组成的。 这看起来似乎是个挺奇怪的选择,但是之所以这么做是有原因的。因为大部分典型的函数式库在定义它们的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){
		// copy the array of functions
		var list = fns.slice();

		while (list.length > 0) {
			// take the last function off the end of the list
			// and execute it
			result = list.pop()( result );
		}

		return result;
	};
}

// or the ES6 => form
var compose =
	(...fns) =>
		result => {
			var list = fns.slice();

			while (list.length > 0) {
				// take the last function off the end of the list
				// and execute it
				result = list.pop()( result );
			}

			return result;
		};

`...fns`是收集实参的数组,而不是传入的数组,因此它是`compose(..)`作用域内的变量。 你可能会觉得`fns.slice()`这句话没什么必要,但是在这个特定的实现中,`composed(..)`函数内部的`.pop()`方法将会改变这个列表,所以如果我们每次都直接操作其本身而不是拷贝的话,那么返回的组合函数只能可靠的使用一次。我们将会在第六章中重温这个风险。

现在,我们来看看这个组合了超过2个函数的例子。回想一下组合函数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;
}

我们来定义一个包含了skipShortWords(..)biggerWords(..)函数。手动组合的形式是这样的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"]

现在,我们将会使用第三章中介绍过的partialRight(..),然后对“组合”做些更有趣的事情。我们可以对compose(..)本身做右向部分应用,这样我们就能提前分别指定第二和第三参数(unique(..)words(..)),我们把这个新的函数称为filterWords(..)(见下)。 在调用filterWords(..)的时候往里面塞入不同的参数,可以完成不同的组合函数:

// Note: uses a `<= 4` check instead of the `> 4` check
// that `skipShortWords(..)` uses
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(..)shorterWords(..))来创建不同的专门的组合变体。这是函数式编程中非常有用的技巧! 比起部分应用,你同样可以对组合使用curry(..)函数,不过因为从右往左的顺序,你可能会经常使用curry( reverseArgs(compose), ..),而不仅仅是curry( compose, ..)

因为`curry(..)`(至少是我们在第三章中的实现方式)依赖于对计数值(`length`)的检测,或者你手动指定计数值,所以`compose(..)`是个可变函数,你需要手动指定预期的计数值,像这样`curry(.. , 3)`。

替代实现

可能你并没有在自己的产品中使用自己实现的compose(..),而是使用了各种库提供的实现。我发现理解它在包装下的工作原理,实际上有助于巩固通用的函数式感念。 所以,我们来研究一下不同的compose(..)的实现方式。我们还将会看到每种实现的优缺点,特别是在性能上的问题。 我们稍后再来详细解释reduce(..)^注^,现在你只需要知道它会将列表(数组)j减少到某个极限值,它就像是个花哨的循环而已。

在《JavaScript高级程序设计》中,此方法被归类到“缩小方法”之中,本文之后也将采用这个名称。

比如,假如你对数字列表[1,2,3,4,5,6]进行了累加的缩小操作,那么程序将会循环这个列表然后把里面的数字相加到一起。缩小方法迭代的过程是这样的,首先是1 + 2,然后再把之前的结果和3相加,之后再把上次的结果和4相加以此类推,最后将会得到最终的和:21。 原版的compose(..)使用了循环,并且急切地(立即调用)计算了当前调用的结果,然后把它传给了下一次调用。我们可以用reduce(..)来做同样的事情:

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

// or the ES6 => form
var compose = (...fns) =>
	result =>
		fns.reverse().reduce(
			(result,fn) =>
				fn( result )
			, result
		);

请注意,每当运行最后的compose(..)函数时,都会启动reduce(..)循环,每个循环中的result(..)都将会作为输入传递到下一个循环中。 这个实现方式的优点在于代码简洁,并且使用了非常容易理解的函数式结构:reduce(..)。而且它的性能也非常接近于原版的for循环。 然而,这个实现是有极限的,因为外部的组合函数(即组合中的第一个函数)只能接受一个参数。而大多数其他的方法则会将所有的实参传递给第一个函数调用。假如组合中的每个函数都是一元的,对于这种实现而言没什么问题。但是假如你需要传递多个参数到第一个函数调用,那么你需要一个不同的实现。

为了修复第一个调用是单实参的限制,我们仍然能够使用reduce(..),不过会产生一个惰性评估函数的封装:

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

// or the ES6 => form
var compose =
	(...fns) =>
		fns.reverse().reduce( (fn1,fn2) =>
			(...args) =>
				fn2( fn1( ...args ) )
		);

请注意,我们直接返回了reduce(..)的调用结果,reduce(..)运行之后也将生成一个函数,而非计算的结果。这个函数允许我们传递任意数量的实参,然后将实参全部传递给组合函数中的第一个函数调用,然后将每个结果传递给后续的调用。 之前的实现方式是在reduce(..)循环运行中计算和传递中间结果,与之不同的是,当前的实现则是在组合的时候就运行一次reduce(..)循环,所有的函数调用和计算都会被延迟(这被称作惰性计算)。每个缩小运算的部分结果都是对函数的一层封装。 当你调用最后组合完整的函数,并且提供了1个或者多个实参,此时所有被嵌套起来的函数,将会沿着由内向外的顺序依次运行(没有使用循环)。从输入的组合函数角度来看,运行的顺序和输入的顺序相反的。 这个实现的性能特征和之前基于reduce(..)的实现有所不同。在这个实现中reduce(..)只会运行一次,这将会生成一个大的组合函数,当调用这个组合函数的时候,只是会依次运行所有嵌套函数。而在之前的版本中,组合函数的每次运行都将会调用reduce(..)。 你对于这两种实现到底哪个好可能有不同的看法,但是请记住,后一种对前面一种而言,没有实参数量的限制。 我们也能使用递归来定义compose(..)compose(fn1,fn2, .. fnN)的递归定义如下所示:

compose( compose(fn1,fn2, .. fnN-1), fnN );

我们将会在第9章详细的介绍递归,所以如果你觉得这种方法看起来很混乱,请马上跳过它,阅读完第九章之后再回来。

以下是我们用递归来实现的compose(..)

function compose(...fns) {
	// pull off the last two arguments
	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 );
}

// or the ES6 => form
var compose =
	(...fns) => {
		// pull off the last two arguments
		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,在那里很多程序都会通过*管道(pipe)*串联起来(|运算符)。第一个的输出将会作为第二个输入,依此类推(比如ls -la | grep "foo" | less)。 pipe(..)处理函数列表的顺序是从左至右的,此外它和compose(..)完全相同:

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

		while (list.length > 0) {
			// take the first function from the list
			// and execute it
			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 );

你可以回想一下partialRight(..)在第三章中的定义,它在封装内使用了reverseArgs(..),然而现在我们的pipe(..)也做了同样的事情。所以我们从这两个方式中都得到了相同的结果。 在这种特定的情况下,pipe(..)有些轻微的性能优势。这是由于我们并不需要使用右向部分应用来保存compose(..)那从右往左的实参顺序,在使用pipe(..)的时候,我们并不需要partialRight(..)这样在其内部反转实参。所以在这里,partial(pipe, ..)的性能比partialRight(compose, ..)要有一点优势。 一般来说,当你使用比较完善的函数式库时,pipe(..)compose(..)并没有任何显著的性能差异。

抽象

抽象,通常被定义为从2个或者更多任务中提取出来的通用部分。通用的部分一般只会定义一次,以避免重复。并且为了更专业化的执行每个具体的任务,通用部分通常也会被参数化。 比如,思考这个(明显非常做作的)代码:

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 );
}

为了努力避免重复的if语句,我们将条件转换为一般抽象。我们还假设我们可能会在程序的其他地方检查非空字符串或是非undefined的值,所以我们也可以对它们使用DRY原则。 这段代码要更具有DRY的风格,但是却有点过度了。程序员必须小心的在程序的每个部分中应用适当的抽象级别,不能太多,也不能太少。 在本章中我们对组合函数的讨论,看起来它的优点似乎就是DRY抽象。但是我们先别跳过这个结论,因为我觉得,“组合”在我们的代码中实际上有着另一个重要的目的。 就是,即使是针对某些只会发生一次的事件,组合仍然是有帮助的。 除了泛化和专业化之外,我认为抽象还有另一个非常有用的定义,正如下面我的引用所揭示的那样:

……抽象是程序员将名称与潜在的复杂程序片段相关联的过程,然后可以根据这个函数的目的而非实现方式来决定它的名称。通过隐藏不相关的细节,抽象可以减少概念的复杂性,使程序员尽量在任何特定的时间内,把焦点集中在程序文本的可管理子集中。 《程序设计语言 Programming Language Pragmatics》Michael L Scott

原文

这段关于抽象(通常我们会把这些代码片段放入它自己的函数中)的描述,它的要点是说抽象的主要目的就是为了从功能的角度将代码分割成两个部分,这样的话每个部分相对于另一个部分都是独立的。 请注意,从这个意义上来讲,抽象并不是为了把代码当作黑箱那样隐藏细节。这个概念其实更接近于封装的编程原则。我们并不是为了隐藏什么而进行抽象,而是为了改善代码的焦点而切割代码。 回想一下本书的开头,我对函数式编程的目标是这样描述的,为了书写具有更好的可读性,更容易被理解的代码。对于像绳索一样杂乱而紧密耦合在一起的代码,为了将这些绳索松绑,将代码拆分成一块块更为简单的部分,不失为一个有效的方法。这样一来,读者就能专心寻找需要的细节,而不会被其他部分的细节所干扰。

从这个意义上来说,我们和DRY思想是有冲突的,因为我们对于某些事情并不会只实现一次。实际上,我们常常在代码中会重复的实现某些东西,因为我们希望能单独的实现每个部分。因为通过这种方式,我们能改善代码的焦点,从而提高代码的可读性。 命令式和声明式的编程风格对比也能够描述这个目标。命令式的代码主要是能够明确的说明如何完成任务,而声明式的代码则是说明了结果应该是什么,不过这其中的实现则会交给其他部分。 换句话说,声明式的代码就是将如何做抽象成了是什么。通常声明式的代码的可读性比命令式要好,但是它们并不是非黑即白的关系,程序员必须在他们之间寻求平衡。 ES6添加了很多能把旧的命令式的操作转换成新的声明式的语法。这其中最为清晰明了的可能是解构赋值,它是一种分配模式,描述了复合值(对象、数组)如何被分解然后组成新的值。

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

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

// imperative
var tmp = getData();
var a = tmp[0];
var b = tmp[3];

// declarative
var [ a ,,, b ] = getData();

在这里的是什么是指,我们把数组的第一个值赋值给了a,而第四个值赋值给了b。我们是如何做到的呢?我们拿到了这个数组的引用(temp),然后手动的将下标是03内的值分别复制给了ab。 数组的解构隐藏了赋值吗?这取决你自己的看法。我断言它只是简单地将是什么如何做之中分离了出来,JS引擎仍然是执行了赋值操作的,但是它预防了你因为关注它是如何做的而分心。 相对的,当你读到[ a ,,, b ] = ..的时候,这样的赋值方式只会告诉你将会发生的事情是什么。数组的解构赋值是声明式抽象的一个例子。

组合与抽象

但是这和函数组合有什么关系?实际上,函数组合同样也是声明式的抽象。 回想一下之前那个shorterWords(..)的例子,我们来比较一下命令式和声明式的定义:

// imperative
function shorterWords(text) {
	return skipLongWords( unique( words( text ) ) );
}

// declarative
var shorterWords = compose( skipLongWords, unique, words );

声明式代码着重于是什么——这三个函数形成了一个数据管道,可以将一个字符串变成较短的单词列表——并将如何做交给了compose(..)的内部接口来完成。 从更大的意义上来说,shorterWords = compose(..)这一行解释了如何去定义shorterWords(..)函数,而在代码的其他地方,留下一行声明式的代码足矣,因为我们仅仅只关注是什么

shorterWords( text );

经过所需的步骤得到较短的单词列表,上述组合抽象了这个操作。 相比之下,如果我们没有使用组合抽象呢?

var wordsFound = words( text );
var uniqueWordsFound = unique( wordsFound );
skipLongWords( uniqueWordsFound );

或者甚至是:

skipLongWords( unique( words( text ) ) );

这两个版本中的任何一个,都表现出了比之前声明式代码更为突出的命令式的风格。在这两段代码中如何做是什么是密不可分的,读者不可能只关注它们其中之一。 函数组合也不会仅仅依靠DRY来节约代码,即便shorterWords(..)仅仅只发生了一次——所以它并没有避免重复——将如何做是什么之中分离开来,也能提升代码的质量。 组合是一个强大的工具,它能够将命令式的代码转换为声明式的代码,从而提高代码的可读性。

再谈 Points

现在我们已经深入的了解了组合——它在函数式领域中是个非常有用的技巧——我们再在第三章中介绍过的无值风格中重新审视一下这个技巧,我们对第三章的场景中进行更为复杂的重构:

// given: 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的形参引用。 我们先来尝试下把personpersonFound(..)函数中删除。为此,我们先定义:

function extractName(person) {
	return person.name;
}

但是我们来观察一下,很明显这个操作可以用一个通用的操作来代替:通过属性的名字从任意对象中提取任意属性。让我们来定义这么一个工具函数prop(..)

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

// or the ES6 => form
var prop =
	(name,obj) =>
		obj[name];

当我们处理对象属性的时候,我们还要定义一个相反的工具函数:setProp(..),这个函数则是用来将属性值设置到对象之中。 但是我们必须要小心,不要去变化已经存在的对象,而是创建输入对象的克隆,然后我们操作这个克隆的对象,最后再返回它。至于为什么要这么操作,我们将会在第五章讨论它的细节。

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(..)的回调。所以我们可以使用partialRight(..)来定义一个名为processPerson(..)的函数来预设回调参数:

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 );
}

// or the ES6 => form
var makeObjProp =
	(name,value) =>
		setProp( name, {}, value );

这个工具函数在*Ramda*库中被称为`objOf(..)`。

就像我们使用prop(..)来创建extractName(..)一样,我们也可以对makeObjProp(..)使用部分应用,来创建函数personData(..),通过这个函数来创建数据对象:

var personData = partial( makeObjProp, "id" );

要使用processPerson(..)来执行对person的查询并对其追加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(..)的工具函数,它将会把具体的实现细节抽象化,这样一来代码的可读性就大大提高了,使得我们更专注于用组合来实现什么,而不是如何去实现。 函数组合——声明式数据流——是支持其余大多数函数式编程最重要的工具之一。