这是【JS如何函数式编程】系列文章第三篇。点赞👍关注👀,持续追踪😄
前两篇传送门:
在第二篇,我们谈了基础之基础,重要之重要——“偏函数”,偏函数通过函数封装,实现了减少传参数量的目的,解决了手动指定实参的麻烦。
更具重要意义的是:
当函数只有一个形参时,我们能够比较容易地组合它们。这种单元函数,便于进行后续的组合函数;
没错,本篇就是谈关于 “组合函数”。它是函数编程的重中之重之重之重重重!
组合函数
含义
函数编程就像拼乐高!
乐高有各式各样的零部件,我们将它们组装拼接,拼成一个更大的组件或模型。
函数编程也有各种功能的函数,我们将它们组装拼接,用于实现某个特定的功能。
下面来看一个例子,比如我们要使用这两个函数来分析文本字符串:
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++) { if (uniqList.indexOf( list[i] ) === -1 ) { uniqList.push( list[i] ); } } return uniqList; } var text = "To compose two functions together"; var wordsFound = words( text ); var wordsUsed = unique( wordsFound ); wordsUsed; // ["to", "compose", "two", "functions", "together"]
不用细看,只用知道:我们先用 words 函数处理了 text,然后用 unique 函数处理了上一处理的结果 wordsFound;
这样的过程就好比生产线上加工商品,流水线加工。
想象一下,如果你是工厂老板,还会怎样优化流程、节约成本?
这里作者给了一种解决方式:去掉传送带!
即减少中间变量,我们可以这样调用:
var wordsUsed = unique( words( text ) ); wordsUsed
确实,少了中间变量,更加清晰,还能再优化吗?
我们还可以进一步把整个处理流程封装到一个函数内:
function uniqueWords(str) { return unique( words( str ) ); } uniqueWords(text) 复制代码
这样就像是一个黑盒,无需管里面的流程,只用知道这个盒子输入是什么!输出是什么!输入输出清晰,功能清晰,非常“干净”!如图:
与此同时,它还能被搬来搬去,或再继续组装。
我们回到 uniqueWords() 函数的内部,它的数据流也是清晰的:
uniqueWords <-- unique <-- words <-- text
封装盒子
上面的封装 uniqueWords 盒子很 nice ,如果要不断的封装像 uniqueWords 的盒子,我们要一个一个的去写吗?
function uniqueWords(str) { return unique( words( str ) ); } function uniqueWords_A(str) { return unique_A( words_A( str ) ); } function uniqueWords_B(str) { return unique_B( words_B( str ) ); } ...
所以,一切为了偷懒,我们可以写一个功能更加强大的函数来实现自动封装盒子:
function compose2(fn2,fn1) { return function composed(origValue){ return fn2( fn1( origValue ) ); }; } // ES6 箭头函数形式写法 var compose2 = (fn2,fn1) => origValue => fn2( fn1( origValue ) );
接着,调用就变成了这样:
var uniqueWords = compose2( unique, words ); var uniqueWords_A = compose2( unique_A, words_A ); var uniqueWords_B = compose2( unique_B, words_B );
太清晰了!
任意组合
上面,我们组合了两个函数,实际上我们也可以组合 N 个函数;
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(..) 的例子,我们进一步再增加一个函数来处理(过滤掉长度小于等于4的字符串):
function skipShortWords(list) { var filteredList = []; for (let i = 0; i < list.length; i++) { if (list[i].length > 4) { filteredList.push( list[i] ); } } return filteredList; } var text = "To compose two functions together"; var biggerWords = compose( skipShortWords, unique, words ); var wordsUsed = biggerWords( text ); wordsUsed; // ["compose", "functions", "together"]
这样 compose 函数就有三个入参且都是函数了。我们还可以利用偏函数的特性实现更多:
function skipLongWords(list) { /* .. */ } var filterWords = partialRight( compose, unique, words ); // 固定 unique 函数 和 words 函数 var biggerWords = filterWords( skipShortWords ); var shorterWords = filterWords( skipLongWords ); biggerWords( text ); shorterWords( text );
filterWords 函数是一个更具有特定功能的变体(根据第一个函数的功能来过滤字符串)。
compose 变体
compose(..)函数非常重要,但我们可能不会在生产中使用自己写的 compose(..),而更倾向于使用某个库所提供的方案。了解其底层工作的原理,对我们强化理解函数式编程也非常有用。
我们理解下 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 ); };
通过递归进行重复的动作比在循环中跟踪运行结果更易懂,这可能需要更多时间去体会;
基于之前的例子,如果我们想让参数反转:
var biggerWords = compose( skipShortWords, unique, words ); // 变成 var biggerWords = pipe( words, unique, skipShortWords );
只需要更改 compose(..) 内部实现这一句就行:
... while (list.length > 0) { // 从列表中取第一个函数并执行 result = list.shift()( result ); } ... 复制代码
虽然只是颠倒参数顺序,这二者没有本质上的区别。
抽象能力
你是否会疑问:什么情况下可以封装成上述的“盒子”呢?
这就很考验 —— 抽象的能力了!
实际上,有两个或多个任务存在公共部分,我们就可以进行封装了。
比如:
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 ); } }
在做这类抽象时,有一个原则是,通常被称作 DRY(don't repeat yourself),即便我们要花时间做这些非必要的工作。
抽象能让你的代码走得更远! 比如上例,还能进一步升级:
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 语句也被抽象封装了。
抽象是一个过程,程序员将一个名字与潜在的复杂程序片段关联起来,这样该名字就能够被认为代表函数的目的,而不是代表函数如何实现的。通过隐藏无关的细节,抽象降低了概念复杂度,让程序员在任意时间都可以集中注意力在程序内容中的可维护子集上。—— 《程序设计语言》
我们在本系列初始提到:“一切为了创造更可读、更易理解的代码。”
从另一个角度,抽象就是将命令式代码变成声命式代码的过程。从“怎么做”转化成“是什么”。
命令式代码主要关心的是描述怎么做来准确完成一项任务。声明式代码则是描述输出应该是什么,并将具体实现交给其它部分。
比如 ES6 增加的结构语法:
function getData() { return [1,2,3,4,5]; } // 命令式 var tmp = getData(); var a = tmp[0]; var b = tmp[3]; // 声明式 var [ a ,,, b ] = getData();
开发者需要对他们程序中每个部分使用恰当的抽象级别保持谨慎,不能太过,也不能不够。
阶段小结
函数组合是为了符合“声明式编程风格”,即关注“是什么”,而非具体“做什么”。
它能将一个函数调用的输出路由跳转到另一个函数的调用上,然后一直进行下去,它借助 compose(..) 或它的变体实现。。
我们期望组合中的函数是一元的(输入输出尽量是一个),这个也是前篇有提到的很重要的一个点。
组合 ———— 声明式数据流 ———— 是支撑函数式编程其他特性的最重要的工具之一!
以上!
我是掘金安东尼,公众号【掘金安东尼】,输出暴露输入,以技术见生活。