全文共3388字,预计学习时长7分钟
JavaScript编程员每天都会用多范式语言来处理大量函数式编程工作。那么,你了解多少呢?
一个简单的列表迭代
将下列数据视作为一个字符串列表
const langs = ['lisp', 'haskell', 'ocaml'];
JavaScript并不是强类型语言,因此采用比langs还庞大的集合可能会出现纰漏。鉴于此,我们可对其迭代,并检查控制台,观察所得结果。从迈入编程大门的那一刻起,我们便对该步骤了然于胸。
// imperative good old way for (let i = 0; i < langs.length; i++) { console.log(langs[i]); }
对我们来说,这条代码看起来清晰易懂,但真是这样吗?
这是命令式语言,因为它描述的是计算机应如何迭代,而非人类自己对迭代的理解。
虽然这条代码命令性比不上带有goto的if语句,但仍属于命令式语言。
除此之外,该代码自身还有几处缺陷:
· 此代码不具有可重用性/不符合DRY原则(即使是相似情形下还是要重写先前所有代码)。
· 鉴于其为程序代码块,该代码既不能分解,也无法重组。
· 维护难度大:也许会有语句排版错误,在循环中倘若i值遭到改变,便会产生程序漏洞。
· 可扩展性差,这是因为嵌套for循环语句足以让每个程序员抓狂不已。
语法糖
多亏了ES6这款对程序员相当友好的算法语言,我们只需使用一些语法糖,无须将额外变量声明为计数器。
// semi declarative syntactic sugar for (const lang of langs) { console.log(lang); }
现在就更像是个声明式语言了:一个编程小白都能够看懂这串代码,只要从左往右念即可。
虽然可维护性与可扩展性有所提高,但其复杂性仍差强人意。
老当益壮的forEach
早在ES6问世的五年前,JavaScript就已推出一个用处颇大的编程工具:forEach。
forEach既非本地控制结构,也非保留关键字,而是一个数组方法,可在任何数组中执行,就连空数组也不例外。
langs.forEach();
上述例子会导致错误,因为forEach需要一个参数[1]。
但不是某个简单、随机的参数,得是一个函数。
// try to familiarize with this notation const sayHi = () => alert("hi."); // let's use our brand new function langs.forEach(sayHi)
谨记,函数被尊为JS界的第一等公民。
这并不意味着函数是独一无二的,实际上刚好相反,在JS中,函数与其他数值(对象,本原布尔函数,数字&字符串)地位相当:它们可成为变量,可进行组合,可作为参数传递,可得返回值等。(在Haskell中,就连+也能被作为参数传递)
forEach即函数编程师口中的“高阶函数”,不过也没什么复杂的,不过是一个用于运行并返回其他函数值的函数而已。
// 3 "hello" alerts, one by element langs.forEach(() => alert('hello'))
因此, forEach 也不是了不得的魔法秘术; 它采用一个声明式函数(未执行!),辅之以下列签名 :(any) -> void[2] 。而后便可将给定函数连续地应用于数组中的每个元素。
该参数any是用来替换迭代中的现有元素, 而void表明该函数不应返回任何数值。
langs.forEach(lang => alert(`hi ${lang}`));
运行上述代码,会跳出一个警示框,内容为 “hi <language>”,针对本数组中的每一语言。这样以来,似乎可根据所学知识解决该问题。
// declarative higher order method langs.forEach(lang => console.log(lang));
且慢!
console.log 是函数吗?是的。
那 console.log 有 (any) -> void 这样一个签名吗?或许吧[3]。
那么问题来了,这个函数
const log = a => console.log(a); log("hi");
和以下这一函数
console.log("hi");
到底有何分别?
似乎看不出任何区别,此包装函数只是噪声污染,所以[4]……
// higher order method enlighten langs.forEach(console.log);
听上去有些奇怪,但从某种程度上却显得也更加透彻。
这难道是……现在做的就是函数式编程吗?
其实不然,真正的函数式编程极其严格,其中,只允许可证明的程序与可控的副作用存在。若想达到这一目标,需遵循以下规则。
纯函数与不可变性
还记得forEach中所用的函数签名吗?那个函数得不出任何返回值(也就是JS中所谓的未定义),或编程中的“void”。对forEach本身而言,它也是一个“未定义”的方法。这是函数副作用的明显症状,若函数无返回值,那它应该在返回语句发生前,对程序在其他方面上产生影响。例如改变了一些全局变量值,或改变数组本身。
这些事情某天我们可能会将其抛之脑后,但它们终将引发程序漏洞。总之,这些副作用是不可控的,而函数编程师都是控制狂。他们既要知其然,也要知其所以然。
虽然作为一个自定义函数,forEach比for…of语句更具可组合性,但它仍受数组的限制。forEach不能将自行传递给另一种函数。
无论怎么说,可组合性与可扩展性均未实现。
这样一来,想进入FP(函数式编程)的殿堂,就要先定义出一个独具特色的forEach。先用一个全新的函数来隐藏实现细节:
// custom pre-functional for each const forEach = (arr, func) => { for (let i = 0; i < arr.length; i++) { func(arr[i]); } }; forEach(langs, console.log);
你可能会说,这明明是命令式语句,小编你个骗纸!
然而,笔者似乎之前并未提到这一方式的具体实现方法。
诚然,这样确实是有瞒天过海之嫌。但如今有了这独家定制的forEach,只要全部编写完毕,就无需为此类函数费心。这样的forEach将前无古人后无来者,起码能用上三十年。函数的真谛,即通过隐藏命令性细节,打造出一个声明性工具。没人让你将先前所学的计算机知识抛之脑后,情况恰恰相反!
即便如此,哪怕不用for语句,我们仍可以完成迭代,这就是递归,不过这是后话,而且没有TCO的话,递归还会带来新的问题[5]。
若想开开眼,可研究下列代码:
// recursive for each const forEach = (arr, func) => { if (arr.length) { const [head, ...tail] = arr; func(head); forEach(tail, func); } }; forEach(langs, console.log);
接下来是总结时间,关于纯函数,后文还会有所叙述,现在,只需牢记,一个纯函数有两个特点:
· 应该有返回值
· 不能改变所给范围外一切数据,哪怕是给定的引用参数也不例外[6]
// custom functional for each const forEach = (arr, func) => { const newArr = [...arr]; // copy for (let i = 0; i < newArr.length; i++) { // @see footnote 2 func(newArr[i], i, newArr); } return newArr; // return copy }; forEach(langs, console.log);
这个forEach看上去真是好多了,可用来甄别与消除副作用。
但显然,代码的可维护性与可扩展性并未得到完全解决。
试问,倘若所有函数均为纯函数,且你的程序均由这些纯函数构成,这样一来易变性又将从何谈起?无从谈起!纯函数的引用透明性,换一种角度来看,恰恰就是其不变性。
科里化
接下来讨论函数式编程另一个有用的工具——科里化。
由于上文已谈到,函数被尊为第一等公民(和其他值类似),因此易知一个函数可以返回另一个函数,并将父变量囊括其中。
const f = a => b => a; const otherF = f('Hello'); console.log(otherF('Goodbye')); // will log "Hello". // Play with that until you'll understand why.
这样,一个可重用性与可组合性极佳的forEach就此问世:
// curried pure function const forEach = func => arr => { const newArr = [...arr]; for (let i = 0; i < newArr.length; i++) { func(newArr[i], i, newArr); } return newArr; }; const logEach = forEach(console.log); logEach(langs); logEach(['see', 'you', 'soon']); const doubleAndLogEach = forEach(a => console.log(a * 2)); doubleAndLogEach([1, 2, 3]);
注意,此类forEach参数顺序如下所示:
· func, 将被部分应用
· 接着是数列 arr
这种新顺序可以让程序员创造或重写logEach ,甚至是doubleAndLogEach 。未来还可实现forEach与其他函数的组合。
鉴于在JavaScript中,对象均通过引用项传递,因此这款forEach 还有一处较大的纰漏,即包含对象的数列有突变的风险。
JavaScript 既非静态类型语言,也非强类型语言,因此会将不纯函数甚至不良数据类导入 forEach(对于该问题,唯一的应对方式恐怕就是使用TypeScript 或Elm )
因此,理想的完美情形并未实现。读完全文也许你已发现:forEach 本身并非FP向,毕竟定义中也的确暗示了副作用的存在。这也是为何三巨头不包括forEach,而是map, filter, 和reduce。
[1]: 实际上,这还需要第二个参数,this的语境值。
[2]: 然而代码更像是 (any, number, any[]) -> void, 数字即当前索引, any[] 即调用数组,其他的args参数也有用处,例如,若想知道当前元素是否是该数列的最后一个,这些参数便派上了用场。
[3]: 例外:真正的 console.log 签名下,可以包含无限个逗号隔开的可选args参数,并返回错误值 (any, …any[]) -> false.
[?]: 实际上,由于签名存在差别, console.log w呈现的信息会更多(详见备注二与备注三^^).
[?]: 尾调用优化是用递归取代for循坏的先决条件。若采用递归,到最后所调用栈帧可能增长过多,而可调用的栈帧是有限的。TCO不存在于JavaScript中(Node 6除外),因此我们暂时仍困于文中的循环
[?]: 在JavaScript中, 所有对象(包括数组)总是通过引用共享,从不拷贝。你若对此有所疑窦,还需在这方面多下些功夫。
留言 点赞 关注
我们一起分享AI学习与发展的干货
编译组:董宇阳、张婷华
相关链接:
https://medium.com/better-programming/functional-js-from-%CE%B1-to-%CF%89-8dc0cfe1f4e1
如需转载,请后台留言,遵守转载规范