反对函数式编程的政治正确

15,119 阅读9分钟

在技术社区里,与函数式编程相关的话题一直十分火热,这尤以素有娱乐圈之称的前端社区为甚。大量相关的入门文章中,面向对象与命令式编程常常被作为对比的反例,彷佛它们已经是丑陋而肮脏的过时技术了。对这种矫枉过正观点的担忧,正是这篇文章写作的初心。

为什么这里会牵扯到政治正确呢?这是因为对编程范式的执着,已经或多或少地成为了一种道德绑架了:这很接近政治正确的背后,那种强大的道德信念。这种信念掺杂在了对编程语言的信仰之中,其结果是用非强迫的方式完成了对话语权的控制。比如下面这样的观点:

  • 你这段代码用了 for 循环,这是过程式的。为了优雅,你应该写成函数式的。
  • 你这段代码有副作用,这是肮脏的。为了纯净性,你应该把 IO 包在 Monad 里。
  • 你这段代码用了 class,这是面向对象的。为了无状态,你应该写成高阶函数。

这种粗暴的逻辑和【父母都是为你好】与【女性不适合编程】有什么区别吗?许多这样不负责任的偏激言辞,造就了当前社区中对于面向对象与命令式编程的 Stereotype 刻板印象。实际上,把函数式编程与面向对象 / 命令式编程对立的观点,其本身在分类上就是不严谨的。姑且不论这点,这些论调也相当武断,忽略了技术的适用场景。

例如,对于函数式编程的最主要的赞誉之一,就在于纯函数的无状态性质。关于它的好处我们已经听到太多了:结果可预期、利于测试、利于复用、利于并发……一切听起来都这么理想,所以我们能够自底到上地用纯函数来编写出一个完整的系统了吗?这里的荒诞之处在于,当你越接近一台计算机 under the hood 的原貌时,你离纯函数与无状态越远

当你想要尝试理解 CPU / 显卡 / 网络等真正的基础的时候,你会发现它们一点儿也不函数式:

  • CPU 本身就是个最典型的状态机。例如笔者的周末玩具 CHIP-8 模拟器 里,它的 CPU 状态就是用几个变量模拟出的一堆寄存器、堆栈指针和计数器罢了。每条指令都是个修改全局状态的函数罢了——这当然很不纯粹,但十分符合对 CPU 运行方式的直觉与抽象。
  • 驱动显卡工作的 API 有状态得令人发指。相信任何尝试过从头搭建 WebGL 渲染管线的同学都能够明白这是什么意思。并且在下一代以压榨出极致性能到导向的显示驱动 API 里,需要人肉维护的状态还会更琐碎。
  • 网络协议栈的状态机如何迁移,已经在《计算机网络》的课本里画出了无数次了。即便是已经封装到应用层的最傻瓜的 Web 页面里,看看控制台里执行一句 performance.timing 的内容,要想正确地画出这些字段间的状态迁移关系都已经绝非易事了。

当你想要理解上面的这些玩意如何工作的时候,你所能查到的最经典的资料与业界最经过实战检验的实现,几乎都是清一色地在过程式、命令式的编程范式下非常地【有状态】的。难道说编写这些基础性工程壮举的开发者们,其技术水平都不如函数式编程的布道师们吗?

这个矛盾可以这样归结:函数式编程的理念,更接近理论上的数学概念。而命令式、过程式的编程,更贴近实际工程中硬件的工作方式。二者的简洁性是体现在不同维度的。作为例子,许多函数式编程狂热者所不屑乃至唾弃的 C 风格 while 循环,其实是个非常易于实现与硬件优化的设计。只要你尝试过阅读 gcc 生成的汇编码,不难发现一个 while 非常容易与汇编的跳转指令联系起来:

       JMP LOOP   ; 首先跳到底部以开始循环
BEGIN: NOP        ; 空指令占位符
                  ; ...此处开始放置循环体中代码
                  ; ...
                  ; ...
                  ; ...执行完循环体内代码
LOOP:  CMP ...    ; 检查条件
       JNE BEGIN  ; 若不满足则跳转到 BEGIN 位置

而对于饱受函数式爱好者们抨击的 for 循环,实现时只需在上面的控制流里增加初始化过程与计数器临时变量就行。而被嫌弃的 break 与 continue 等语法也只需要增加更多的标号即可灵活地基于 JMP 实现。相比于函数调用并返回时所需小心翼翼地保存并恢复上下文的一系列跳转逻辑,命令式的循环语法显然更易于实现——当然了,你可以硬杠说 Scheme 这样的函数式语言,其解释器更容易实现,笔者也确实作为周末玩具而实现过一门 Scheme 方言 哦语言 的解释器。但是,这种几乎等于手写语法树的语言有多少工程中的实用价值呢?类似的地方还体现在对各种数学计算的抽象上。如笔者蹭 PR 贡献过的 gl-matrix 矩阵运算库,其中大量的 mutation 也非常不函数式,但它实际上仍然十分简洁可靠而高效呀。

有些函数式编程的爱好者们,对递归一类函数式手法的 all in 推崇也有些令人费解:你写的什么 for 循环太低端啦,看我写成优雅的尾递归还自带解释器优化不会爆栈呢!诚然,在处理嵌套的数据结构的时候,使用递归是相当简洁易读的。这一点笔者在毕业前尝试实现递归下降和 LALR 语法分析器的课后作业时,就有了这样的体会:这时非递归的写法实在很啰嗦。然而,这种本来是以一定的性能代价来提高可读性的手法,却常常被误解为优秀的实现而被滥用。例如,一个深拷贝算法用递归实现固然简单,但它客观地存在 Stack Overflow 的风险。作为解决方案,我们可以用数组模拟栈来实现它。这时你的代码就没有那么函数式了:你会因为函数式的代码更加优雅,就拒绝修复栈溢出的 bug 吗?

类似的舍近求远,还体现在对一些实际上更加晦涩的概念的推崇上。许多希望入门函数式编程的初学者,都会被 Monad 这个【自函子上的幺半群】概念唬住。诚然,你可以把 Hooks 和 Promise 这些简单易用的概念解释成为 Monad,笔者在入门时也确实写了一篇文章从单位元与结合律的角度出发,来证明 为什么 Promise 是一种 Monad。然而,明明是实际开发中非常实用且易于理解的东西,却要使用更难以懂的一套概念去形式化地定义和解释,这恐怕并不利于优秀工具和理念的普及。比如,日语里函数的概念叫做関数,不懂日语体系里的这个词,也并不影响一个汉语使用者知识体系的自洽以及对函数的使用呀。并且,Monad 其实已经相当于函数式编程范式中的一种【设计模式】了,而对设计模式的摒弃,不正是函数式编程自身的优势之一吗?

提到了设计模式,就绕不开软件工程。这时候我们有不少【道】层面的设计准则,但这些真正利于工程 Scale Up 的准则,反倒和具体的编程范式关系不大了。比如,我们都知道高内聚低耦合的模块划分是利于维护的,但在这个维度上起最重要作用的并不是与函数式相关的语法特性,而是语言的包管理器与模块加载规范等。再比如变更游戏业界的 ECS 架构,它在【组合优于继承】方向上的演进也仍然是在面向对象语言上就能够实现的。即便到了实际的工程案例上,对于前端这种与 UI 深度相关的领域,其中最大规模且最可靠的实现仍然是非常面向对象的——Windows 和 macOS 的桌面 UI 环境并非源于函数式语言,难道操作系统的桌面管理器会像 Redux 那样在单个 store 里管理全局状态吗?

行文至此,这篇文章的吐槽应该告一段落了。但我们显然并不应该为了发泄而写作,笔者更不是命令式编程的死忠粉(相反地,笔者还特地写过一篇 RxJS 模拟电梯调度 的安利文章)。差不多时候做一些澄清与提出诉求了:

  • 函数式编程非常重要,且在许多细分领域值得学习与推广。但不能一概而论地认为它优于面向对象或过程式等其它范式。
  • 函数式编程同样存在着自身固有的缺陷,这些地方要客观地看待。
  • 我们希望能够平等地看待各种编程范式,保持开放的心态,拒绝哗众取宠的引战言论,根据实际需求折衷选择更具开发效率与运行效率的技术方案。

在实际的编码中,笔者更关注【符合直觉】这一点。这大概包括两个维度:

  • 一个语言特性的使用方式,是否符合它设计出来要解决的场景。
  • 一个具体需求的实现方式,是否符合对其最为简单直接的抽象。

不管黑猫白猫,只要能抓到老鼠就是好猫。只要是可读可维护的高质量代码,为什么要在乎它属于哪个范式呢 :)