【大前端攻城狮之路】JavaScript函数式编程

时间:2021-01-25 22:04:40

  转眼之间已入五月,自己毕业也马上有三年了。大学计算机系的同学大多都在北京混迹,大家为了升职加薪,娶媳妇买房,熬夜加班跟上线,出差pk脑残客户。同学聚会时有不少兄弟已经体重飙升,开始关注13号地铁线上铺天盖地的植发广告。都说25岁是一个男人的分界线,之前是越活越精致,往后是越活越糙。现在体会到了。父母开始老去,自己尚一无所有,攒的钱不够买一平米的房。昨天和一哥们撸串,我问:有啥打算?哥们吞了几口羊肉串,喝了一口啤酒,说:存点钱吧,然后回家。

  说实话,我以前的想法也一样。奈何来北京容易,想走却很难。清明节回太原一趟,总觉的路上太过于寂静,大家走路速度太慢,商店关门太早,竟然有些许不适应。兀的发觉,北京肉体虽然每天很疲惫,但灵魂力量却修炼的很强。回到昌平的20平出租屋内,内心暗想,继续混,混到混不下去为止。

  之前写过一篇博文《我在百度做外包》,没想到许多同学深有同感。北京的外包群体是一个很大的社会组织结构,他们需要得到更多的关注。但生存是社会永恒的主题,外包人员更要懂得,一切以实力说话,没有技术就没有发言权。没有名校背景的同学进大厂很难,反过来说外包也是另外一条路,只不过这条路需要付出更多的努力,机会永远留给有准备的人。

  这篇博文,作为我侥幸从外包转成正式百度员工的纪念。

  这是一个系列博客,希望他能在我的码农生涯中留下些什么。


  闲话不多说,这篇文章主要和大家分析下前端的函数式编程思想。纲要如下:

  • 函数式编程思维

  • 函数式编程常用核心概念

  • 当下函数式编程最热的库

  • 函数式编程的实际应用场景

一 函数式编程思维

范畴论 Category Theory

•1.函数式编程是范畴论的数学分支是一门很复杂的数学,认为世界上所有概念体系都可以抽象出一个个范畴
•2.彼此之间存在某种关系概念、事物、对象等等,都构成范畴。任何事物只要找出他们之间的关系,就能定义
•3.箭头表示范畴成员之间的关系,正式的名称叫做"态射"(morphism)。范畴论认为,同一个范畴的所有成员,就是不同状态的"变形"(transformation)。通过"态射",一个成员可以变形成另一个成员。
【大前端攻城狮之路】JavaScript函数式编程

对于前端,所有的成员是一个集合,变形关系是函数。

函数式编程基础理论

1..函数式编程(Functional Programming)其实相对于计算机的历史而言是一个非常古老的概念,甚至早于第一台计算机的诞生。函数式编程的基础模型来源于 λ (Lambda x=>x*2)演算,而 λ 演算并非设计于在计算机上执行,它是在 20 世纪三十年代引入的一套用于研究函数定义、函数应用和递归的形式系统。

2.函数式编程不是用函数来编程,也不是传统的面向过程编程。主旨在于将复杂的函数符合成简单的函数(计算理论,或者递归论,或者拉姆达演算)。运算过程尽量写成一系列嵌套的函数调用

3.JavaScript 是披着 C 外衣的 Lisp。

4.真正的火热是随着React的高阶函数而逐步升温。

5.函数是一等公民。所谓”第一等公民”(first class),指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。

6.不可改变量。在函数式编程中,我们通常理解的变量在函数式编程中也被函数代替了:在函数式编程中变量仅仅代表某个表达式。这里所说的’变量’是不能被修改的。所有的变量只能被赋一次初值。

7.map & reduce他们是最常用的函数式编程的方法。

将上面的概念简述一下:

1. 函数是”第一等公民”

2. 只用”表达式",不用"语句"

3. 没有”副作用"

4. 不修改状态

5. 引用透明(函数运行只靠参数)

二 函数式编程常用核心概念

•纯函数

•函数的柯里化

•函数组合

•Point Free

•声明式与命令式代码

•核心概念

1.纯函数

什么是纯函数呢?

对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态的函数,叫做纯函数。

举个栗子:

var xs = [1,2,3,4,5];// Array.slice是纯函数,因为它没有副作用,对于固定的输入,输出总是固定的
xs.slice(0,3);
xs.slice(0,3);
xs.splice(0,3);// Array.splice会对原array造成影响,所以不纯
xs.splice(0,3);

  

2.函数柯里化

传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

我们有这样一个函数checkage:

var min = 18; 
var checkage = age => age > min;

  

这个函数并不纯,checkage 不仅取决于 age还有外部依赖的变量 min。 纯的 checkage 把关键数字 18 硬编码在函数内部,扩展性比较差,柯里化优雅的函数式解决。

var checkage = min => (age => age > min);

var checkage18 = checkage(18); // 先将18作为参数,去调用此函数,返回一个函数age => age > 18;
checkage18(20);// 第二步,上面返回的函数去处理剩下的参数,即 20 => 20 > 18; return true;

  

 再看一个例子:
// 柯里化之前
function add(x, y) {
return x + y;
}
add(1, 2) // 3
// 柯里化之后
function addX(y) {
return function (x) {
return x + y;
};
}
addX(2)(1) // 3

  

  事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。

3.函数组合

为了解决函数嵌套过深,洋葱代码:h(g(f(x))),我们需要用到“函数组合”,我们一起来用柯里化来改他,让多个函数像拼积木一样。

const compose = (f, g) => (x => f(g(x)));
var first = arr => arr[0];
var reverse = arr => arr.reverse();
var last = compose(first, reverse);
last([1, 2, 3, 4, 5]); // 5

  

函数组合交换律,类似于乘法交换律:

【大前端攻城狮之路】JavaScript函数式编程

4.Point Free

把一些对象自带的方法转化成纯函数,不要命名转瞬即逝的中间变量。
大家看一下下面的函数:
const f = str => str.toUpperCase().split(' ');
这个函数中,我们使用了 str 作为我们的中间变量,但这个中间变量除了让代码变得长了一点以外是毫无意义的。
 
下面我们用函数组合去改造一下:
var toUpperCase = word => word.toUpperCase();
var split = x => (str => str.split(x));
var f = compose(split(' '), toUpperCase);
f("abcd efgh");

  

把一些对象自带的方法转化成纯函数,然后通过函数组合去调用,这种风格能够帮助我们减少不必要的命名,让代码保持简洁和通用。是不是很方便!

5.声明式与命令式代码

在我们日常业务开发中,写的代码绝大多数都为命令式代码;

我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。
而声明式就要优雅很多了,我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。
//命令式
let CEOs = [];
for (var i = 0; i < companies.length; i++) {
CEOs.push(companies[i].CEO)
}
//声明式
let CEOs = companies.map(c => c.CEO);

  

函数式编程的一个明显的好处就是这种声明式的代码,对于无副作用的纯函数,我们完全可以不考虑函数内部是如何实现的,专注于编写业务代码。优化代码时,目光只需要集中在这些稳定坚固的函数内部即可。相反,不纯的函数式的代码会产生副作用或者依赖外部系统环境,使用它们的时候总是要考虑这些不干净的副作用。在复杂的系统中,这对于我们的心智来说是极大的负担。

6.核心概念

下面我们再深入一下,大家注意好好理解吸收:

高阶函数

高阶函数,就是把函数当参数,把传入的函数做一个封装,然后返回这个封装函数,达到更高程度的抽象。

//命令式
var add = function (a, b) {
return a + b;
}; function math(func, array) {
return func(array[0], array[1]);
}
math(add, [1, 2]); // 3

  

递归与尾递归

指函数内部的最后一个动作是函数调用。 该调用的返回值, 直接返回给函数。 函数调用自身, 称为递归。 如果尾调用自身, 就称为尾递归。 递归需要保存大量的调用记录, 很容易发生栈溢出错误, 如果使用尾递归优化, 将递归变为循环, 那么只需要保存一个调用记录, 这样就不会发生栈溢出错误了。通俗点说,尾递归最后一步需要调用自身,并且之后不能有其他额外操作。
// 不是尾递归,无法优化
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
} function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
} //ES6强制使用尾递归

  

我们看一下递归和尾递归执行过程:

递归:

function sum(n) {
if (n === 1) return 1;
return n + sum(n - 1);
}

  

sum(5)
(5 + sum(4))
(5 + (4 + sum(3)))
(5 + (4 + (3 + sum(2))))
(5 + (4 + (3 + (2 + sum(1)))))
(5 + (4 + (3 + (2 + 1))))
(5 + (4 + (3 + 3)))
(5 + (4 + 6))
(5 + 10)
15 // 递归非常消耗内存,因为需要同时保存很多的调用帧,这样,就很容易发生“栈溢出”

  

尾递归

function sum(x, total) {
if (x === 1) {
return x + total;
}
return sum(x - 1, x + total);
}

  

sum(5, 0)
sum(4, 5)
sum(3, 9)
sum(2, 12)
sum(1, 14)
15

  

整个计算过程是线性的,调用一次sum(x, total)后,会进入下一个栈,相关的数据信息和跟随进入,不再放在堆栈上保存。当计算完最后的值之后,直接返回到最上层的sum(5,0)。这能有效的防止堆栈溢出。 在ECMAScript 6,我们将迎来尾递归优化,通过尾递归优化,javascript代码在解释成机器码的时候,将会向while看起,也就是说,同时拥有数学表达能力和while的效能。
 

范畴与容器

1.函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。
2.函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。

容器与函子(Functor)

$(...) 返回的对象并不是一个原生的 DOM 对象,而是对于原生对象的一种封装,这在某种意义上就是一个“容器”(但它并不函数式)。

Functor(函子)遵守一些特定规则的容器类型。任何具有map方法的数据结构,都可以当作函子的实现。
Functor 是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。把东西装进一个容器,只留出一个接口 map 给容器外的函数,map 一个函数时,我们让容器自己来运行这个函数,这样容器就可以*地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛掰的特性。

下面我们看下函子的代码实现:

var Container = function (x) {
this.__value = x;
}
// 函数式编程一般约定,函子有一个of方法
Container.of = x => new Container(x);
// Container.of(‘abcd’);
// 一般约定,函子的标志就是容器具有map方法。该方法将容器
// 里面的每一个值, 映射到另一个容器。
Container.prototype.map = function (f) {
return Container.of(f(this.__value))
}
Container.of(3)
.map(x => x + 1) //=> Container(4)
.map(x => 'Result is ' + x); //=> Container('Result is 4')

  

class Functor {
constructor(val) {
this.val = val;
}
map(f) {
return new Functor(f(this.val));
}
}
(new Functor(2)).map(function (two) {
return two + 2;
});
// Functor(4)

  

上面代码中,Functor是一个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被f处理过的(f(this.val))。
一般约定,函子的标志就是容器具有map方法。该方法将容器里面的每一个值,映射到另一个容器。上面的例子说明,函数式编程里面的运算,都是通过函子完成,即运算不直接针对值,而是针对这个值的容器----函子。函子本身具有对外接口(map方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。
因此,学习函数式编程,实际上就是学习函子的各种运算。由于可以把运算方法封装在函子里面,所以又衍生出各种不同类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不同的函子,解决实际问题。
你可能注意到了,上面生成新的函子的时候,用了new命令。这实在太不像函数式编程了,因为new命令是面向对象编程的标志。函数式编程一般约定,函子有一个of方法,用来生成新的容器。
Functor.of = function (val) {
return new Functor(val);
};
Functor.of(2).map(function (two) {
return two + 2;
});
// Functor(4)

  

下面我们介绍一些常用的函子。

Maybe 函子

函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。
var Maybe = function (x) {
this.__value = x;
}
Maybe.of = function (x) {
return new Maybe(x);
}
Maybe.prototype.map = function (f) {
return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
}
Maybe.prototype.isNothing = function () {
return (this.__value === null || this.__value === undefined);
}
//新的容器我们称之为 Maybe(原型来自于Haskell,Haskell是通用函数式编程语言)

  

Functor.of(null).map(function (s) {
return s.toUpperCase();
});
// TypeError Maybe.of(null).map(function (s) {
return s.toUpperCase();
});
// Maybe(null)

  

错误处理、Either函子

我们的容器能做的事情太少了,try/catch/throw 并不是“纯”的,因为它从外部接管了我们的函数,并且在这个函数出错时抛弃了它的返回值。Promise 是可以调用 catch 来集中处理错误的。事实上 Either 并不只是用来做错误处理的,它表示了逻辑或。

条件运算if...else是最常见的运算之一,函数式编程里面,使用 Either 函子表达。Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。

class Either extends Functor {
constructor(left, right) {
this.left = left;
this.right = right;
}
map(f) {
return this.right ?
Either.of(this.left, f(this.right)) :
Either.of(f(this.left), this.right);
}
}
Either.of = function (left, right) {
return new Either(left, right);
};

 

使用Either函子:

var addOne = function (x) {
return x + 1;
};
Either.of(5, 6).map(addOne);
// Either(5, 7);
Either.of(1, null).map(addOne);
// Either(2, null);
Either
.of({
address: 'xxx'
}, currentUser.address)
.map(updateField);

  

AP函子

函子里面包含的值,完全可能是函数。我们可以想象这样一种情况,一个函子的值是数值,另一个函子的值是函数。
class Ap extends Functor {
ap(F) {
return Ap.of(this.val(F.val));
}
}

  

function addOne(x) {
return x + 1;
}
Ap.of(addOne).ap(Functor.of(1)) // ap函子,让addOne可以用后面函子中的val运算 结果为Ap(2)

  

IO函子

真正的程序总要去接触肮脏的世界。
function readLocalStorage(){
        return window.localStorage;
}
IO 跟前面那几个 Functor 不同的地方在于,它的 __value 是一个函数。它把不纯的操作(比如 IO、网络请求、DOM)包裹到一个函数内,从而延迟这个操作的执行。所以我们认为,IO 包含的是被包裹的操作的返回值。
IO其实也算是惰性求值。
IO负责了调用链积累了很多很多不纯的操作,带来的复杂性和不可维护性
 
class IO extends Monad {
map(f) {
return IO.of(compose(f, this.__value))
}
}

  

在这里,我们提到了Monad,Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算所需的函数,整个运算就会自动进行下去。Promise 就是一种 Monad。Monad 让我们避开了嵌套地狱,可以轻松地进行深度嵌套的函数式编程,比如IO和其它异步任务。

class Monad extends Functor {
join() {
return this.val;
}
flatMap(f) {
return this.map(f).join();
}
}

  

关于更多的Monad介绍,可以移步知乎什么是Monad

三 流行的几大函数式编程库

1.Rxjs

响应式编程是继承自函数式编程,声明式的,不可变的,没有副作用的是函数式编程的三大护法。其中不可变武功最高深。一直使用面向对象范式编程的我们,习惯了用变量存储和追踪程序的状态。RxJS从函数式编程范式中借鉴了很多东西,比如链式函数调用,惰性求值等等。在函数中与函数作用域之外的一切事物有交互的就产生了副作用。比如读写文件,在控制台打印语句,修改页面元素的css等等。在RxJS中,把副作用问题推给了订阅者来解决。
在 Rxjs 中,所有的外部输入(用户输入、网络请求等等)都被视作一种 『事件流』:用户点击了按钮 --> 网络请求成功 --> 用户键盘输入 --> 某个定时事件发生 —> 这种事件流特别适合处理游戏,上上下下上上下下举个最简单的例子,下面这段代码会监听点击事件,每 2 次点击事件产生一次事件响应:
var clicks = Rx.Observable
.fromEvent(document, 'click')
.bufferCount(2)
.subscribe(x => console.log(x)); // 打印出前2次点击事件

  

2.Cycle.js

Cycle.js 是一个基于 Rxjs 的框架,它是一个彻彻底底的 FRP 理念的框架,和 React 一样支持 virtual DOM、JSX 语法,但现在似乎还没有看到大型的应用经验。
本质的讲,它就是在 Rxjs 的基础上加入了对 virtual DOM、容器和组件的支持,比如下面就是一个简单的『开关』按钮:
function main(sources) {
const sinks = {
DOM: sources.DOM.select('input').events('click')
.map(ev => ev.target.checked)
.startWith(false)
.map(toggled =>
<div>
<input type="checkbox" /> Toggle me
<p>{toggled ? 'ON' : 'off'}</p>
</div>
)
};
return sinks;
}
const drivers = {
DOM: makeDOMDriver('#app')
};
run(main, drivers);

  

3.Underscore.js

Underscore 是一个 JavaScript 工具库,它提供了一整套函数式编程的实用功能,但是没有扩展任何 JavaScript 内置对象。 他解决了这个问题:“如果我面对一个空白的 HTML 页面,并希望立即开始
工作,我需要什么?” 他弥补了 jQuery 没有实现的功能,同时又是 Backbone 必不可少的部分。
Underscore 提供了100多个函数,包括常用的:map、filter、invoke— 当然还有更多专业的辅助函数,如:函数绑定、JavaScript 模板功能、创建快速索引、强类型相等测试等等。

4.Lodash.js

lodash是一个具有一致接口、模块化、高性能等特性的JavaScript工具库,是underscore.js的fork,其最初目标也是“一致的跨浏览器行为。。。,并改善性能”。
lodash采用延迟计算,意味着我们的链式方法在显式或者隐式的value()调用之前是不会执行的,因此lodash可以进行shortcut(捷径) fusion(融合)这样的优化,通过合并链式大大降低迭代的次数,从而大大提升其执行性能。就如同jQuery在全部函数前加全局的$一样,lodash使用全局的_来提供对工具的快速访问。
var abc = function (a, b, c) {
return [a, b, c];
};
var curried = _.curry(abc);
curried(1)(2)(3);

  

function square(n) {
return n * n;
}
var addSquare = _.flowRight(square, _.add); // 类似于上面说的函数组合
addSquare(1, 2);
// => 9

  

5.Ramdajs

ramda是一个非常优秀的js工具库, 跟同类比 更函数式主要体现在以下几个原则
1. ramda里面的提供的函数全部都是curry的 意味着函数没有默认参数可选参数从而减轻认知函数的难度。
2. ramda推崇pointfree简单的说是使用简单函数组合实现一个复杂功能, 而不是单独写一个函数操作临时变量。
3. ramda有个非常好用的参数占位符 R._ 大大减轻了函数在pointfree过程中参数位置的问题相比underscore / lodash 感觉要干净很多。
 

四 实际应用场景

易调试、热部署、并发

1.函数式编程中的每个符号都是 const 的,于是没有什么函数会有副作用。谁也不能在运行时修改任何东西,也没有函数可以修改在它的作用域之外修改什么值给其他函数继续使用。这意味着决定函数执行结果的唯一因素就是它的返回值,而影响其返回值的唯一因素就是它的参数。
2.函数式编程不需要考虑”死锁"(deadlock),因为它不修改变量,所以根本不存在"锁"线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程,部署"并发编程"(concurrency)。
3.函数式编程中所有状态就是传给函数的参数,而参数都是储存在栈上的。这一特性让软件的热部署变得十分简单。只要比较一下正在运行的代码以及新的代码获得一个diff,然后用这个diff更新现有的代码,新代码的热部署就完成了。

单元测试

1.严格函数式编程的每一个符号都是对直接量或者表达式结果的引用,没有函数产生副作用。因为从未在某个地方修改过值,也没有函数修改过在其作用域之外的量并被其他函数使用(如类成员或全局变量)。这意味着函数求值的结果只是其返回值,而惟一影响其返回值的就是函数的参数。
2.这是单元测试者的梦中仙境(wet dream)。对被测试程序中的每个函数,你只需在意其参数,而不必考虑函数调用顺序,不用谨慎地设置外部状态。所有要做的就是传递代表了边际情况的参数。如果程序中的每个函数都通过了单元测试,你就对这个软件的质量有了相当的自信。而命令式编程就不能这样乐观了,在 Java 或 C++ 中只检查函数的返回值还不够——我们还必须验证这个函数可能修改了的外部状态。
 

总结和补充

函数式编程不应被视为灵丹妙药。相反,它应该被视为我们现有工具箱的一个很自然的补充—— 它带来了更高的可组合性,灵活性以及容错性。现代的JavaScript库已经开始尝试拥抱函数式编程的概念以获取这些优势。Redux 作为一种 FLUX的变种实现,核心理念也是状态机和函数式编程。
 
本文章介绍了纯函数、柯里化、Point Free、声明式代码和命令式代码的区别,大家只要记住『函数对于外部状态的依赖是造成系统复杂性大大提高的主要原因』以及『让函数尽可能地纯净』就行了。
然后介绍了『容器』的概念和 Maybe、Either、IO 这三个强大的 Functor。是的,大多数人或许都没有机会在生产环境中自己去实现这样的玩具级 Functor,但通过了解它们的特性会让你产生对于函数式编程的意识。
 
软件工程上讲『没有银弹』,函数式编程同样也不是万能的,它与烂大街的 OOP一样,只是一种编程范式而已。很多实际应用中是很难用函数式去表达的,选择OOP 亦或是其它编程范式或许会更简单。但我们要注意到函数式编程的核心理念,如果说 OOP 降低复杂度是靠良好的封装、继承、多态以及接口定义的话,那么函数式编程就是通过纯函数以及它们的组合、柯里化、Functor 等技术来降低系统复杂度,而 React、Rxjs、Cycle.js 正是这种理念的代言。让我们一起拥抱函数式编程,打开你程序的大门!
 

参考文献:

1.阮一峰 函数式编程入门教程

2.知乎 什么是 Monad (Functional Programming)?