阅读 343

用函数式编程写出“傻瓜”都能看懂的代码

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

名词

  • FP: Functional Programming,函数式编程。
  • OOP:Object Oriented Programming,面向对象编程。
  • AOP:Aspect Oriented Programming,面向切面编程。

导读

“任何傻瓜都能写机器可执行的代码,而优秀的程序员写的代码傻瓜都能看懂。”——Martin Fowler

这句名言以饱满的感情描述了“优秀程序员”的标准(个人认为这个翻译要比原文给力)。无论是 OOP、AOP、FP 最终都要服务于这个基本点。所以本文并不会照搬市面上流行的介绍函数式编程的内容,而是一切围绕此基本点展开,有所取舍,以最小的成本,带来最大的收益。

背景

目前业内对于 FP 的讨论很多,也比较热烈,但是想要找到一些真正能指导实践,切实提高生产力的有用信息,着实不易。正如导读中说的,笔者本着切实提高生产力为目的,研究了 FP 的一些相关资料,再综合平时的实践,经过取舍和变通,整理出了此文档。旨在让 FP 切实应用到日常开发中,并且有所收益。

另外,从目前流行的三大框架来看,都已经逐渐“数据化”。以 React 为例,其抽象思想可以简化为 UI = fn(state),利用 VDOM、diff、JSX 等方式将 DOM 操作过程屏蔽,实现了 state(纯数据)与 UI 的映射,其带来的影响就不展开了,这里只强调一点:

目前前端开发的绝大部分工作是在处理数据(state),而不是像之前 Jquery 那样直接处理 DOM。

这会从底层改变我们的编码思维与方式,请仔细品味这句话,很重要。既然是处理数据,而且是 immutable 的数据,那么 FP 就有用武之地了,这也将是本文的讨论重点。

最后,简单列举一下笔者认定 FP 能够在前端有所建树的判断依据:

  • React immutable 和单向数据流的概念与 FP 很吻合;
  • Redux 是目前使用最多的 React 状态管理库,它的存在提供了 FP 在前端实践的典型案例;
  • ES 的迭代引入了箭头函数、Array.prototype.map 等具有明显 FP 特色的语法,让人忍不住联想;
  • 笔者曾主导过一个“部门前端质量控制”项目,对于代码的“可测试性”有一些研究。可测试性高就意味着“高内聚低耦合”,是项目可维护性的基石。而 FP 就是提升代码可测试性的最佳途径,详见《如何说服前端写单测》;
  • FP 看起来很酷,可以用来 ZB。

目标

  1. 选出 FP 中适合前端实践的特性,总结成可落地的规范,指导开发;
  2. 以 FP 为切入点,引出编程思想层面的思考,让内容更具有启发性;
  3. 一切的一切都是为了能写出“傻瓜都能看懂”的代码,降低代码的维护成本。

结论

PS:以下内容为突出重点,适用范围为绝大多数项目,corner case 不在讨论范围之内。

篇幅太长,先抛结论,思考过程见下文。规范是:

除框架的模板代码外,项目里不要出现 Class(详见《前端不需要 Class》),只允许出现函数,且只能是以下两种函数,具体约束如下:

纯函数

  1. 除回调(包括生命周期钩子)、初始化以外的所有函数;
  2. 一定有入参和出参,即 (params: any) => any
  3. 不允许对入参进行任何修改操作(immutable),尤其是引用类型数据。如果非要修改,先 cloneDeep 生成新对象,操作后再返回该新对象;
  4. 代码里禁止出现 thisfetchlocalStoragewindowDOM 等明显需要访问外部的关键字或全局变量;
  5. 特别的,返回 Promise 对象的函数,均使用 async/await 调用,并将其视为纯函数(如 ajax 请求,严格意义来说不能算纯函数,这里做了变通);

副作用函数

  1. 只能是回调或初始化函数;
  2. 没有出参,入参只能由回调函数自身提供,不能引入额外入参,即 (parmas?: any) => void;
  3. 内部代码绝大部分是声明式,而非命令式;

可以看到,规则只用到了纯函数与副作用的概念,仅此而已。没有 pointfree、container、monad、functor 等概念,甚至要坚决杜绝引入这些概念,否则只会带来更难维护的后果,切记切记。即使这样,如果完全做到了上述规则,你的项目代码质量肯定已经很棒了。

正文

傻瓜能看懂的代码是什么样的

笔者将其总结为以下几点:

  1. 符合常识和一般逻辑,No Surprise;
  2. 宏观上看整体代码,能够像书的目录一样快速看懂整个项目是做什么的,每个模块大概负责什么;
  3. 中观上看每个模块的代码,就像合体机器人一样。每个子机器人内部自成体系,自给自足,给上电(输入)就能单独作战;同时,输入输出明确,有明确的接口可以进行组合,组成完全体;
  4. 微观上看每行代码,以声明式代码为主,代码规范清晰,可读性强,可参考《Lint 不能解决的前端代码规范》、《Vue3 最佳实践之编码规范

嗯……描述的就挺傻瓜的,很合理。其实我们可以把写一个项目类比成写一本书,从目录到章节,再到逐字逐句的推敲,实在像极了写代码。我们就是在写一本“傻瓜”都能看得懂的书。

声明式(Declarative) vs 命令式(Imperative)

上文提到了要以声明式代码为主,与之对应的是命令式,下面展开说明下。我们来看一个《函数式编程指北》中的例子:

// 命令式
var makes = [];
for (i = 0; i < cars.length; i++) {
  makes.push(cars[i].make);
}

// 声明式
var makes = cars.map(function(car){ return car.make; });
复制代码

很明显,命令式会把每一步操作都写出来,即每一步做什么都需要下命令,这样的代码不太能第一时间看出其意义;而声明式可以很快的看出是要实现 makescars 之间的一种映射关系,不需要看具体的逻辑,从名字也能够猜到个大概。所以在快速阅读和理解代码意义上来讲,我们更应该使用声明式编程的范式。

但是,在微观层面来讲,粒度已经细致到函数的具体实现了,势必要细读每一行代码。那么即使使用命令式编程范式也差别不大了,实际上当遇到 break、continue 等逻辑时,使用上述命令式编程反而是更好的选择。这个尺度可以自己把握,在保证宏观整体质量可控的情况下,在微观层面有所变通,甚至偶尔耍一些“花活”也无伤大雅。

PS:此处对函数式编程的概念做了取舍,在函数式编程中是没有循环的,取而代之的是递归调用。笔者认为大可不必,很明显循环要比递归(尤其是尾递归)好理解,毕竟我们的目标用户是“傻瓜”。

纯函数的好处

本小节内容主要来自《函数式编程指北 - 追求“纯”的理由》,笔者原创的部分会标注,未标注部分全部为引用内容。

可缓存性

原创)这点比较好理解,根据纯函数的概念:

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

输入相同就意味着输出相同,所以可以进行缓存,利用空间换时间,提升速度。

可移植性/自文档化

纯函数是完全自给自足的,它需要的所有东西都能轻易获得。仔细思考思考这一点...这种自给自足的好处是什么呢?

首先,纯函数的依赖很明确,因此更易于观察和理解——没有偷偷摸摸的小动作。

// 不纯的
var signUp = function(attrs) {
  var user = saveUser(attrs);
  welcomeUser(user);
};

var saveUser = function(attrs) {
    var user = Db.save(attrs);
    ...
};

var welcomeUser = function(user) {
    Email(user, ...);
    ...
};

// 纯的
var signUp = function(Db, Email, attrs) {
  return function() {
    var user = saveUser(Db, attrs);
    welcomeUser(Email, user);
  };
};

var saveUser = function(Db, attrs) {
    ...
};

var welcomeUser = function(Email, user) {
    ...
};
复制代码

这个例子表明,纯函数对于其依赖必须要诚实,这样我们就能知道它的目的。仅从纯函数版本的 signUp 的签名就可以看出,它将要用到 DbEmail 和 attrs,这在最小程度上给了我们足够多的信息。

其次,通过强迫“注入”依赖,或者把它们当作参数传递,我们的应用也更加灵活;因为数据库或者邮件客户端等等都参数化了。如果要使用另一个 Db,只需把它传给函数就行了。如果想在一个新应用中使用这个可靠的函数,尽管把新的 Db 和 Email 传递过去就好了,非常简单。

原创)最后,如果结合了 TS,出参和入参的类型又进一步的明确,每个纯函数的作用可以更容易地被推测出来,让阅读代码的体验更加顺滑。具体例子可参考《函数式编程指北 - 第 7 章:Hindley-Milner 类型签名》。

可测试性

原创)这点也很好理解,因为纯函数的特性,只需简单地给函数一个输入,然后断言输出就好了。值得一提的是《指北》一文中有提到:

事实上,我们发现函数式编程的社区正在开创一些新的测试工具,能够帮助我们自动生成输入并断言输出。这超出了本书范围,但是我强烈推荐你去试试 Quickcheck——一个为函数式环境量身定制的测试工具。

如果连单元测试都能够自动化生成,那这点无疑是非常有吸引力的。

合理性

原创)其实“合理性”这个翻译不太好,Reasonable 似乎更能表达出这种含义,大概是一种很确定,很有依据的意思。举个例子,在 debug 的时候,你可以毫无顾忌的把一个纯函数的调用结果,替换成一个“临时常量”,甚至删掉一些确定性的代码,来简化复杂度,提高 debug 的效率。

可并行

最后一点,也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。

并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。

如何处理副作用

对于副作用《函数式编程指北 - 副作用可能包括》中有较详细的分析。里面提到:

“作用”本身并没什么坏处,“副作用”的关键部分在于“副”。就像一潭死水中的“水”本身并不是幼虫的培养器,“死”才是生成虫群的原因。同理,副作用中的“副”是滋生 bug 的温床。

笔者深以为然。但是副作用是不可能避免的,副作用可能包含,但不限于:

  • 更改文件系统
  • 往数据库插入记录
  • 发送一个 http 请求
  • 可变数据
  • 打印/log
  • 获取用户输入
  • DOM 查询
  • 访问系统状态

纯正的 FP 中对于副作用的处理非常的复杂,个人认为如果全盘照搬会让使用成本大大增加,至少“傻瓜”是很难看懂的。所以笔者一直在思考,如何根据前端项目的特点,界定一个范围,把副作用给“圈住”。最后思考出了以下结论:

除了初始化和回调函数(也包括生命周期函数)之外,其他的函数都能够写成纯函数。

其实回调函数占了前端代码的绝大部分,如果是这样,FP 又有多大意义呢?于是又加了一条规则:

副作用函数禁止有返回值,即 (params?: any) => void

这条初听起来会觉得莫名其妙。试想下,没有返回值意味着没有任何地方会依赖这个函数的执行结果,屏蔽了下游。这样其实就起到了把“副作用”控制在一定范围内的效果,虽然显得比较笨,但是成本低,起码在 debug 的时候不用再担心任何意外的出现。

结语

综合上述内容再补充一些细节后,已经得出了结论,并写在文章开头了。说实话,写这篇文档实属不易,里面有很多的思考结论是找不到印证的。这就意味着这些结论需要反复推敲,很费脑细胞。而且最终的结果也很有可能是贻笑大方。不管怎样,把自己的思考整理成文,是逼着自己完善思想的一种方式,能达到这个目的也就知足了。

最后欢迎大家讨论,提意见,这也是让自己快速成长的另外一个强大助力。

“Reading makes a full man,conference a ready man,and writing an exact man”——Francis Bacon
“读书使人充实,讨论使人机敏,写作使人严谨”——弗朗西斯·培根

参考文献