R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。 MUGer, hacker, developer, amateur UI designer, punster, Japanese learner.

使用函数式编程思想来优化代码

注意:本文发布于 800 天前,文章中的一些内容可能已经过时。

首先我作为一个普通的开发人员,肯定不是主攻函数式编程的,对它顶多算是浅显的了解,并没有深入去研究。但是目前的这些“了解”已经可以极大提高我的开发效率,因此就把我的经验写一写,方便一下那些跟我一样“不准备入坑函数式而只是观望一下”的开发者。

先上一个非常简单的、大家已经习以为常的函数式编程的例子:

function f(x) { return x <= 1 ? x : x * f(x - 1); }
f(5); // => 120

我与“函数式”一词的历史

第一次听到“函数式编程”这个概念是高二,当时一个一起搞竞赛的大佬提到了 Haskell 是一门函数式编程语言。听他讲了很久,我现在只记住了“Haskell 的运算使用了前缀表达式”这么丧心病狂的东西,以及“Haskell 会将尾递归优化为循环”这种神奇的东西。高二下学期去听 ByVoid 大佬讲课,又听到了函数式编程,这次他讲的是 lambda 运算和递归,顺便还推销了一下他没开始发售的《Node.js 开发指南》。直到近两年,我身边“函数式”一词出现的频率越来越高,毕竟就像任何一篇讲函数式的文章里都会写到的那样:

多亏摩尔定律,函数式编程在数十年后重新走进了大家的视野。

所以按照这个规律,本文应该也算是一篇讲函数式的文章了……23333

对函数式编程思想的简单理解

维基百科上对函数式编程的定义是:

函数式编程是一种编程模型,他将计算机运算看做是数学中函数的计算,并且避免了状态以及变量的概念。

搜了一圈,发现除了定义以外,还有很多其它说法:

函数式编程关心数据的映射,命令式编程关心解决问题的步骤。
函数式编程关心类型(代数结构)之间的关系,命令式编程关心解决问题的步骤。
用计算来表示程序, 用计算的组合来表达程序的组合。

不管是哪个说法,至少都能看出来,函数式编程跟我们日常的编程不一样。在看了若干个函数式编程的代码样例和总结之后,我逐渐形成了自己的想法:

函数式编程只是一种解决问题的思路,与具体的编程语言和奇技淫巧无关。它用数学中的函数(或者叫映射)来解决问题,代码精简但抽象。

由于是数学,所以变量是不可变的,函数是无状态的纯函数,这两个概念倒是比较好理解。毕竟解数学题的时候一般不会说“设 x 为方程的解,再让 x 乘以二”,也不会说“设函数 f(x) = 2x + y,其中 y 是某某某”;更多是“再令 y 等于 x 乘以二”和“设函数 f(x, y) = 2x + y”。

函数式,命令式 or 两者结合?

命令式编程大家应该都很熟悉了,定义一个瓶子(变量),往瓶子里装东西(赋值),将一段代码用一个步骤(函数)代替,将一堆步骤让一个主管(类)负责……几乎所有我们生活中常用的软件都是使用了命令式编程来编写的。函数式编程则更偏向于理论研究、并行运算等高深行业,和一些喜欢追求代码优雅或奇技淫巧的人。

命令式编程的缺点是在并行的时候一般需要引入信号量之类的概念,不方便调试;纯函数式编程则限定的太严,无法对外部产生影响(例如写文件)。作为一个普通的开发人员,我接触到的语言都是 JavaScript、PHP 之流,无法支持函数式编程语言的特性(例如变量不可变)。但是在追求代码优雅的路上,我逐渐遇到了很多函数式编程的特性,虽然我当时并没意识到这些。

let req = [1, 2, 3];
let res = [];
for (let i = 0; i < req.length; i++) {
    let item = req[i];
    if (item > 1) {
        res.push(item);
    }
}
console.log(res); // => [2, 3]

我知道看完这段代码之后,很多人就想打我了:不会改变的变量为啥用 let 啊,用 const 不好?这就部分涉及到了函数式“变量不可变”的思想了。之所以是部分涉及,因为在 JavaScript 中,就算对数组和 Object 使用了 const 来定义,依旧是可以发生改变的。

当然,直到我听说了有个叫 filter 的方法,直接打开了新世界的大门:

console.log([1, 2, 3].filter(item => item > 1)); // => [2, 3]

这里的 item => item > 1 是 ES6 中的箭头函数,可以理解为 function (item) { return item > 1; },这绝对符合了“纯函数”的概念,无论什么情况,只要传进来的参数一样,返回的结果一定一样。当然,console.log 绝对不是函数式编程中应该出现的东西,因为它影响到了外部的东西——在终端中输出了一个值。

代码超级好理解,就不多说了。此外还有 mapreducesomeevery 等超级好用的方法。

我的结论是,只用命令式不够优雅,只用函数式过于激进,因此在日常开发中,还是需要二者结合。

纯函数与不可变数据的优点和在 React/Redux 中的运用

先说说纯函数吧。就比如我们写过的竞赛(OI、蓝桥杯、ACM)程序,只要你不是那种卡时的程序、随机化的程序、需要 Special Judge 的程序,就一定可以被封装为一个纯函数。毕竟,对于十组甚至几十组输入数据,无论什么时候测试,你的程序都应该返回与第一次一样的结果。这也是目前“单元测试”最经常用的方法——不管你的函数是怎么写的,如果针对很多可能出现的情况都可以返回正确的结果,就认为你的函数是正确的。

至于不可变数据,对于竞赛程序可能没有太大的用处,但对于开发而言,可以用来追踪状态变化,很方便的回退到之前的任一状态。此外也正如函数式编程所说的,由于数据不可变,任何的修改本质上都是生成新数据,在并行运算的时候就不会有任何需要信号量和锁来解决的问题。

什么?生成新数据太占时间和空间?首先,新旧数据没有改变的地方可以进行内存共享,其次,你忘了刚才提到的摩尔定律了吗?

说一点我认为 React/Redux 完虐 Vue/Vuex 的地方:前者使用状态机来监视修改,后者则劫持了变量的赋值操作。这使得前者的改变比后者更易于追踪,就算程序员未经过专门的训练(例如掌握框架的 Best Practice),也可以显著降低 Bug 出现的概率。

React 的 state 和 Redux 的 state 本身都是状态机,只能分别通过 setState 和 Reducer 进行修改(都是推荐生成新的值,如果可以和 immutable-js 共同使用就更好了),Reducer 本身还是一个纯函数,方便测试,因此推荐将少部分必要的数据处理(例如将嵌套的数据格式拍平)放到 Reducer 中,Action 只用来获取数据。大部分数据处理呢?例如我获取的是一个时间戳,但我需要将它格式化为时间字符串来显示,一个前同事告诉我,Store 中最好是存原始数据,因为时间戳可能用来显示也可能后续会用来运算。等需要显示的时候,将格式化的函数扔到 render 中,或者在组件内再写一个纯函数就好了。

后来又遇到了如何在 JSX 中优雅实现循环的问题。很显然,这里 map 函数派上了用场:

<ul className={s.list}>
    {list.map(item => <li className={s.listItem}>{item.name}: {item.age} year(s) old</li>)}
</ul>

使用函数式编程思想优化代码

对我而言,最早的起源大概在高中竞赛的时候对于“是否使用递归算法”的争论了。对于二叉堆的上浮与下沉操作,既可以使用只有几句话的递归版本,也可以使用比较慢的循环版本;对于并查集(先不考虑路径压缩)的查找算法,则可以优化到只有一行:

int find(int x) { return f[x] == x ? x : find(f[x]); }

这是完全的函数式编程思想,当然,为了路径压缩,就不得不使用一些对外部产生影响的方法:

int find(int x) { return f[x] == x ? x : f[x] = find(f[x]); }

与之等价的循环写法是:

int find(int x) {
    // find root
    int root = x;
    while (f[root] != root) {
        root = f[root];
    }
    // change parent to root
    while (f[x] != root) {
        x = f[x];
        f[x] = root;
    }
    return root;
}

在 JavaScript 里,函数式编程的思想则可以被用来使代码变得更加优雅,除了刚才提到的 mapfilter 等函数之外,我们自己也可以封装一系列这样的函数。

下面是我游戏中的一个函数,它通过传进来一个物件 sprite 和一个计算位置数据的函数 pos,生成一个具体物件的位置数据:

/**
 * Convert responsive data to fixed pixel
 * @param {Sprite} sprite - The sprite we want to move
 * @param {function or object} pos - Position information
 */
painter(sprite, pos) {
    // some calculations with sprite and pos...
    return result; // => maybe { x: 0, y: 0 }
};

然后把 painter 封装到其它函数,例如创建物件的函数,这样我就可以在创建物件的时候这样调用它:

return createText(str, { fontSize: 24 }, (w, h, self) => ({
    x: 0.5 * (w - self.width),
    y: 0.5 * (h - self.height)
}));

这样就创建了一个新物件,其中 createText 的第三个参数就是上文提到用于计算位置的 pos 函数,三个参数分别为窗体的宽、窗体的高、当前的物件。

当然,函数式编程还有很多其它好玩的东西,例如柯里化、惰性求值,期待在以后的编程中可以逐渐使用上它们。毕竟,描述一个东西可比编写一系列步骤好玩多了。

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。
上一篇: 在 Electron 下调用 Win32 API 的经历
下一篇: 2017 年的回顾和结论

这是我们共同度过的

第 1553 天