瞎话 JavaScript 函数式:add(3)(4)一点都不酷

这可能是一篇很无聊的文章,它无聊就无聊在或许这个世界真的需要这样的文章。

国际惯例,写在前面

作为一名野鸡大学毕业的野路子程序员,第一次听说函数式编程这个概念的时候我大概已经工作一年了。彼时的我正在做 Ruby&JavaScript 全栈工程师的美梦。

一名有抱负(暂时)的工程师通常不会放过任何一个知识盲点,于是我立刻打开某书某乎某 SDN 找来了几篇文章学习。

当柯里化、纯函数、高阶函数等一个个概念出现在我的眼前时,我看的如痴如醉云里雾里,不久之后我就把这些概念完全抛在了脑后。

某天下午我在一篇文章里我看到了如下这样一段代码,我忽而意识到我不能继续这样自己糊弄自己了。

function isEven(n) {
  if (n === 0) {
    return true;
  }
  return isOdd(n - 1);
}

function isOdd(n) {
  if (n === 0) {
    return false;
  }
  return isEven(n - 1);
}

我想大部分有过一点 JavaScript 开发经验的开发者都能看得出这段代码的问题,一方面这段代码如此抽象的实现了一个判断奇偶数的功能颇有高射炮打蚊子的感觉,另一方面相互递归无疑是低效且危险的。

光阴任然,我在龟速学习的道路上一转眼又混过去两年,当有一天我又看到一篇文章里看到了如下代码块的时候(请注意,下面的代码块是我从原文抄过来的,包括注释),我知道有些事情不能再拖了。

// 初级程序员
let arr = [1, 2, 3, 4]
let newArr = []
for (var i = 0; i < arr.length; i++) {
  newArr.push(arr[i] + 1)
}
console.log(newArr) //[2, 3, 4, 5]

// 函数式编程
let arr = [1, 2, 3, 4]
let newArr = (arr, fn) => {
  let res = []
  for (var i = 0; i < arr.length; i++) {
    res.push(fn(arr[i]))
  }
  return res
}
let add = item => item + 1 //每项加1
let multi = item => item * 5 //每项乘5
let sum = newArr(arr, add)
let product = newArr(arr, multi)
console.log(sum, product) // [2, 3, 4, 5] [5, 10, 15, 20]

函数式编程从哪儿来?

提到函数式编程,不得不提的是函数式编程的鼻祖——Lambda 演算法

考虑到 Lambda 演算法可能并不是一个人尽皆知的概念,我们将要提到同一时期的另一个概念——图灵机,相信这样就有部分人会稍微明白些了。

简单来说,图灵机是一种理论上的计算模型,用大白话说就是它假设了一台机器,而这台机器最大的特点是任何可以用科学计算解决的问题都可以用它来解决(也就是说显然也存在不可以用科学计算解决的问题,此时图灵机亦无能为力)。

图灵机是存在于假象之中的机器,因为世界上并不存在无限长的纸带。但与之同时,图灵机又明确了这样一台机器应该具备的最基本的特性,上世纪的计算机科学家们就是基于这些特性才发明了计算机。

本质上,Lambda 演算法与图灵机有着同样的目的,我们的祖师爷艾伦·图灵(Alan Turing)已经证明了 Lambda 演算法与图灵机在计算能力上的等价性(在 Lambda 演算法中模拟了图灵机的行为),而这一结论有一个更为人尽皆知的名字——图灵等价

正是因为等价性的存在,即便当今的计算机大多都是基于图灵机实现的,依然不妨碍我们实现大量的继承了 Lambda 演算法思想的编程语言,这些语言正是我们今天所说的函数式编程语言

Lambda 演算法定义了什么?

不同于图灵机,Lambda 演算法并不是一台机器,而是一种数学模型,是一堆数学公式。

一个通用的计算模型要通过有限的手段来抽象无限的问题,Lambda 演算法通过函数实现了这种抽象能力。事实上 Lambda 演算法里除了函数就没有别的东西了。

把 Lambda 演算法推一遍不是本文的目的(真要那样放一个 Wiki 的链接就完事了,再说我也推不出来。

阿隆佐·丘奇(Alonzo Church)通过一系列的参数传递与消除,实现了对数字、运算以及程序语言三种基本结构(顺序、分支、循环)的抽象,其中关于数字的抽象如今被称为丘奇数,这些内容看起来并不容易理解,下面是从 Google 上抄过来的一部分,能看明白定义出来的是个什么东西,以及怎么被应用到后续的运算中就够了,感兴趣的同学可以自行搜索关键字并深入了解。

// 数字的抽象
const zero = s => z => z;
const one = s => z => s(z);
const two = s => z => s(s(z));
const three = s => z => s(s(s(z)));
const four = s => z => s(s(s(s(z))));

// 加法的抽象
const add = x => y => s => z => x(s)(y(s)(z));

// 加法的应用
const five = add(two)(three);

很容易发现上述代码具备如下几个特点:

  1. 所有函数都只有一个参数
  2. 函数可以作为函数的参数和返回值
  3. 纯粹的数学推演,不会修改一个已有的值

如果你已经看过一些函数式编程的文章了,那么你应该已经发现了,上述分别对应了柯里化函数一等公民以及不可变性这些概念。实际上如果更进一步的讲,如今谈论函数式编程提到的这些概念均是来源于此,这是它们的因果。

当然,要实现完整的运算与三种结构并不是那么简单,这里特地把循环拿出来过一下。

要通过函数实现一个循环结构并不困难,相信大家都能在第一时间想到递归,如今在面试的时候也经常会遇到诸如「不使用递归实现 deepClone」这样的问题,考察的就是这个。

一个常见的的递归模板如下:

function loop(n) {
  if (n === 0) {
    return;
  }
  // do something
  loop(n - 1);
}

然鹅,在数学推演的世界里,在一个函数定义完成前将其应用到自身的运算中的行为并不严谨(当然,我并不确定这是不是主因,欢迎补充),因而 Lambda 演算法使用了一种特别的技巧来实现递归,这种技巧有一个很酷一听就让人想融资的名字——Y Combinator(Y 组合子)

关于 Y 组合子这里不过多展开了,大致的思路是把函数的定义和调用分离,感兴趣可以看文末附加的参考文章。

很多人会困惑上述是一坨什么东西,这样复杂的结构看起来并没有比如今面向过程,面向对象的代码更加易读和易维护。

然而如果是跟着全文走的同学应该还记得 Lambda 演算法的目的是什么。回过头来我们发现,现在 Lambda 演算法确实做到了仅通过寥寥无几的几个特征(单参数,函数一等公民,函数定义等)实现了一个通用计算模型所必备的所有特性,这也就意味着,Lambda 演算法是完全可以用来实现一台通用计算机的。

这个结论在今天有另一个词来描述——图灵完备

函数式 !== 函数式

事情并没有就此结束,Lambda 演算法复杂的数学推演让人望而却步,实际上在使用一门基于 Lambda 演算法设计的编程语言开发程序时并不一定要从定义数字/运算/结构开始,正如我们在图灵机上实现的编程语言亦不需要从纸带打孔开始定义程序一样。

在 Lambda 演算法的基础上人们发明了一系列的编程语言如最为著名的 Lisp 语言家族。这些语言共同继承了 Lambda 演算法的思想,其特点是通过函数来实现对问题的抽象,由此形成了一种特有的编程范式。

多啰嗦几句,编程范式是编程语言用于抽象并解决现实问题的通用方法,以大家熟识可能也不见得熟识的面向对象编程为例,在面向对象编程中,我们通常把事物抽象成类与对象来对现实问题的建模,在这个过程中我们需要考虑类,对象,属性甚至方法之间的组合与复用逻辑,因此有了如继承、多态、封装,接口,抽象类等一系列的概念,这些概念均是编程范式的一部分。

同理可得,函数式编程是通过函数来抽象现实问题的通用程序设计方法,而高阶函数,compose 等正是函数式编程中用于组合与复用函数这一单元的概念。

时至今日,函数式编程语言百花齐放,各种各样的特性被加入到了函数式编程语言中,这些特性有的是为了性能,有的是为了开发体验,还有的是为了可读性。这些特性并不是 Lambda 演算法所必须的,甚至有些特性是从其它编程范式中借鉴过来的,一方面这使得广义上的函数式编程与狭义上的函数式编程有了区别,另一方面也使得函数式编程语言的特性更加丰富,更加适合实际开发,事实上当前有很多流行的通用编程语言都结合了多种编程范式,JavaScript 便是其中之一。

所以学习函数式到底是学什么

在学习一门范式时对范式拥有一个正确的认知非常重要。从图灵等价性上我们很容易得出函数式编程与面向对象编程是等价的这样的结论,这也就意味着并不存在函数式编程可以解决而面向对象编程无法解决的问题,亦不存在谁比谁高级的说法。

在尝试了解函数式编程到底是什么的过程中,我曾接连尝试阅读了《计算机程序的构造和解释》(SICP)和《计算的本质》两本书但最后都以失败告终。

之后因机缘巧合大致翻阅了《程序设计方法》(HTDP)一书的前十几章,这是一本使用 Racket 语言描述的书,Racket 是 Lisp 大家族下的一门编程语言,亦是纯函数式编程语言。有趣的地方在于这本书从头到尾都没有提到函数式编程这个词,在那之后我又看了 UCB CS61A 关于函数抽象的部分,同样也没有提到柯里化、纯函数等乱七八糟的概念。

如今看来我认为这些才是真正能够教会你函数式编程的东西,函数式编程并不是复杂概念的集合,它只是一种协助你编写代码的方式罢了,在理解了它的本质以后,剩下的就只是平平凡凡的写代码而已了。如果不能在一开始就有这种认知,很有可能在学习的过程中走火入魔。

印象里好像《Ruby 元编程》中有一句非常经典的话:根本没有什么元编程,只有编程,我想函数式编程大概亦是如此。

对中文互联网的环境多少是有点怨念的,当我们使用搜索引擎搜索「JavaScript 函数式编程」会找到大量的文章,这些文章与其说在讲如何使用 JavaScript 进行函数式编程,不如说是在讲如何使用 JavaScript 实现 Lambda 演算法。

而当搜索柯里化的应用时就更有趣了,大量的文章在讲如何实现一个 add(3)(4) 的函数,最后的结论无非是惰性求值和参数复用等概念,却对这样做的性能开销只字不提。就更少有人提及最初的 Lambda 演算法本身就只支持一个参数这件事情了。

Lambda 只使用一个参数就实现了对问题的抽象,因而完全不需要支持多个参数,在编程语言为了表达能力而支持多个参数的今天反而柯里化回去强行使用一个参数真的是有意义的吗?

相比之下另外一些文章堪称扭曲事实,以纯函数无副作用的线程安全性出发,最终得到了一个函数式编程比面向对象编程更高级的结论。另一侧还有人完全忽视函数式编程的抽象程度,将其描述为一种“有水平的程序员都在业务开发里大量用到”的范式,如果 Bilibili 的搜索排名还没有变化,那么我很推荐大家去试试搜索「函数式编程」,看看第一个视频的标题。后来我点开视频看了大概 10 秒以后终于理解了所谓不可描述之事,流量密码都让你们懂完了,当个人吧

最后,多范式的 JavaScript

让我们把目光再次回到 JavaScript 上来,JavaScript 是一门多范式的编程语言,它既可以面向对象,也可以面向过程,同时也可以函数式。

大家所熟知的工具库如 Lodash 在使用方法上有着相当的函数式的影子:

const arr = _.map([1, 2, 3], item => item + 1);
const fib = _.memoize(n => n < 2 ? n : fib(n - 1) + fib(n - 2));

多范式语言的价值在于它可以让我们在解决问题时更加灵活,在《JavaScript 设计模式与开发实践》一书中,作者曾经多次使用面向对象与函数式两种范式来实现同一种模式,而事实上相比于面向对象,函数式的实现更加简洁,更加优雅,也更加符合大家对 JavaScript 的印象。

碎碎念和叠 Buff 环节

本文不针对任何人,也不针对任何文章,只是想说说我自己的看法。

正如上一节所说,JavaScript 是一门多范式的编程语言,早年间受到 Java 的影响,大家热衷于照搬 Java 的面向对象实践,在近几年随着 React 对函数式编程的推崇,函数式编程又渐渐成为了大家的热点。

坦白说我想写这篇文章很久了,曾有一段时间非常担心自己对函数式的理解可能并不全面甚至正确而迟迟不敢动笔,但转念一想人家 add(3)(4) 都有人写还有人点赞我有啥不敢写的,笑死。

本文的初版是一篇更有戾气的文章,但冷静下来还是觉得纠正更重要。于我个人,于我身边的很多朋友的成长经历来看,大家都有过被那些文章忽悠瘸了的过往,再说这互联网似乎从来都不缺戾气。

最后的最后,感谢能看到这里的你,如果文中有什么错误或者不妥的地方,欢迎指正。

参考

3
2