什么是函数式编程
“函数式编程”是一种编程范式(programming paradigm),就如我们熟知的”面向对象编程”与“面向过程编程”,也就是如何编写程序的方法论。至于为什么要学习函数式编程还不是自己找不到对象嘛!(大雾)。
函数(Function)与方法(Method)
在这里需要提一下函数与方法的区别,区别概念性的东西,只是为了防止有些童鞋混淆函数与方法的指代。其实没啥区分,方法其实也是函数,只不过是比较特殊,方法是附属在对象上的函数。
纯函数(Pure Function)
什么是纯函数
在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:
- 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关。
- 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
via: Wikipiedia
下面的 double
函数就是一个纯函数:
let double = (value) => value * 2;
为了加深对纯函数的理解我们下面举几个不是纯函数的反例:
let add = (x) => x + a;
:因为依赖的外部变量,所以其不是纯函数。random()
:因为其输出有变化非相同的值,所以其不是一个纯函数。printf()
:产生了 I/0 输出操作,产生了副作用
纯函数的好处
- 可测试:因为纯函数不依赖外部变量,所以对其编写测试用例特别方便而不用考虑外部变量情况,只需专注于输入输出的情况。
- 可缓存:因为相同的输入值总是对应相同的输出值,所以可以将之前的确定值缓存起来,再次遇见相同的输入可快速从缓存中读取。
- 无副作用:同纯函数不会改变外部变量,所以无需担心其执行后对外部变量产生影响。特别是多线程的情况下,可以安全的并行执行。
高阶函数(Higher-Order Function/HOF)
说到高阶函数,首先理解一下为什么在 JavaScript 中函数是一等公民这个概念,这有助于我们理解高阶函数。
函数是一等公民(First-Class Citizens)
在 JavaScript 中函数是一等公民的存在,也是相较于一些传统语言(C / C++ / Java / C# 等)而言:在这些语言中函数只是二等公民,你只能用语言的关键字声明一个函数然后调用它,如果需要把函数作为参数传给另一个函数,或是赋值给一个本地变量,又或是作为返回值,就需要通过函数指针(function pointer)、代理(delegate)等特殊的方式周折一番。
在 JavaScript 中不仅拥有一切传统函数的使用方式(声明和调用),而且可以做到像简单值一样赋值、传参、返回,拥有这样能力也被称为第一公民。
OK,我们再将注意力转向高阶函数,正因为 JavaScript 中函数是一等公民,所以函数能够实现以下用法,而符合以下条件函数的也被称为高阶函数:
- 函数可以作为参数被传递
- 函数可以作为返回值输出
多说无益,看看具体的例子:
// 将函数作为返回值输出
let returnFunc = (() => () => '将函数作为返回值输出')();
returnFunc(); // 将函数作为返回值输出
// 将函数最为参数传递
let argFunction = () => '将函数作为参数传递';
let exec = (func) => typeof func === 'function' && func();
exec(argFunction); // 将函数作为参数传递
利用高阶函数进行抽象
我们已经了解了如何创建并执行高阶函数了,一般而言,高阶函数通常用于抽象通用问题,换句话说,高阶函数就是用来定义抽象的。光看这一结论,其实并不助于我们理解高阶函数的好用之处。
Array 常用方法中的函数式编程
大家熟知的 Array 好多常用方法,都利用了高阶函数的特性,我们通过自己来实现其中的一些方法,来加深对高阶函数的理解。
ForEach
比如我们常用的遍历数组,用面向过程的方式需要写一个 for
循环,大家基本在日常开发中经常使用也很熟悉,故不再赘述。
let array = ['this', 'is', 'a', 'array'];
for (let i = 0, len = array.length; i < len; i++) {
// do something
}
现在我们进一步去思考遍历这个操作的目的:通常情况下遍历一个数组,我们关心只不过是该数组的每个元素及其下标索引值、至多还有这个数组本身,获取这些数据进而去执行一些操作,至于遍历的过程本身是不 care 的。并且遍历数组方式基本是一致的,本着 DRY(Don’t repeat yourself) 原则,数组遍历这一操作可抽象成一个 forEach
函数 。
我们的最终目的是拿到所需数据之后进行一系列操作,这些操作各不相同,需要 forEach
函数的使用者去定义这些操作。这些操作也可以封装成函数,而函数可以通过参数的形式传递给 forEach
函数。将该函数通过参数传递给 forEach
后,交由其在执行遍历的时候去执行该函数,而该函数执行所需要的相应数据可通过参数传递的方式提供。这样一个灵活巧妙可复用的 forEach
函数就诞生了。
let forEach = (arr, fn) => {
for (var i = 0, len = arr.length; i < len; i++) {
typeof fn === 'function' && fn(arr[i], i, arr);
}
}
let array = [1, 2, 3, 4];
forEach(array, (item) => console.log(item));// 1 2 3 4
Map
Array
中的 map
方法与 forEach
函数非常类似,只不过前者返回了一个包含有捕获相应操作结果的新数组。
let map = (arr, fn) => {
var res = [];
forEach(arr, (item, index, arr) => {
typeof fn === 'function' && res.push(fn(item, index, arr));
});
return res;
}
let array = [1, 2, 3, 4];
console.log(map(array, item => item * 2));// [2, 4, 6, 8]
Filter
Array
的 filter
方法可用于筛选数组中符合筛选条件的选项,并将这些返回。我们已经拥有一个 forEach
工具函数,借助于该函数,我们可以构建出我们自己的 filter
函数。
let filter = (arr, fn)=> {
let res = [];
forEach(arr, (item, index) => {
if (typeof fn === 'function' && fn(item, idnex, arr)) {
res.push(item);
}
});
return res;
}
let array = [1, 2, 3, 4];
console.log(filter(array, item => item >= 4)); // [4]
Reduce
reduce
函数是一个很美妙的函数,其不光使用了高阶函数,其还借助了闭包的能力,至于该能力是啥,下文说到会提到。
const reduce = (arr, fn, initVal) => {
if(arr.length === 0) {
return ;
}
let accumlator;
accumlator = initVal !== undefined ? initVal : arr[0];
forEach(arr, (val, index, arr) => {
if (initVal === undefined && index >= 1) {
accumlator = fn(accumlator, val, index, arr);
} else {
accumlator = fn(accumlator, val, index, arr);
}
});
return accumlator;
}
let array = [1, 2, 3, 4];
console.log(reduce(array, (a, b) => a + b, 10));// 20
闭包
说到 JavaScript 哪能避得开闭包,作为面试的高频考察点,这里就不赘述什么是闭包了。闭包有个很重要的特性之一就是,将变量保存在内存中。
once
函数是一个小巧的函数,用于确保我们的目标函数只能被执行一次,其中标记函数是否被执行状态的 done
变量被一个匿名函数所引用,且该匿名函数也被外部的变量所引用,done
状态会被保持在内存中,所以其能记住函数执行的状态,进而控制目标函数能够被多次执行。
let once = (fn) => {
let done = false;
return (...args) => done ? undefined : ((done = true), fn.apply(this, args));
}
let times = 0;;
let fire = () => `I am fired ${++times} times`;
let justDoOnce = once(fire);
fire(); // I am fired 1 times
fire(); // undefined
fire(); // undefined
带记忆的斐波那契函数
请问如何实现一个斐波那契函数,是一个老掉牙的面试考点,相信大家已经熟练背住掌握了其用法。
const fibonacci = ( n ) => {
return n < 2 ? 1 : fibonacci( n - 1 ) + fibonacci( n - 2 );
}
不过这种实现方式不够高效,我们已知上面的 fibonacci
函数式一个纯函数,其特定输入产生的值也是固定的,所以何不缓存已求过的值呢?
const fibonacci = (() => {
let cache = {};
return (n) => {
if (cache[n]) {
return cache[n];
}
if (n < 2) {
cache[n] = 1;
return n;
}
cache[n] = fibonacci(n - 1) + fibonacci(n - 2);
return cache[n];
};
})();
函数式编程应用
柯里化
在很多函数式编程的教程中都提到了柯里化,那么柯里化是啥,先给出定义:
柯里化是吧一个多参数函数转换为一个嵌套的一元函数的过程。
如果看了定义还是觉得难以理解,那么请继续往下看,我们来一步步来理解上述定义。
一元函数(Unary Function)
只接受一个参数的函数叫一元函数
const identity = (x) => x;
二元函数
依照一元函数的定义类推,接受两个个参数的函数叫一元函数
const add = (x, y) => x + y;
变参函数
再次类推由普通到特殊:变参函数则是接受可变参数数量参数的函数,在 ES5 之前我们可以通过 arguments
来获得可变数量的参数。依照这个特性我们实现函数的重载。
function variadic(args) {
console.log(arguments);
}
function add() {
var sum =0,
len = arguments.length;
for(var i=0; i<len; i++){
sum += arguments[i];
}
return sum;
}
add();// 0
add(1);// 1
add(1,2,3,4);// 10
在 ES6 中我们有了一个新的运算符:拓展运算符,可以获得同样的使用效果。
let variadic = (...args) => {
console.log(args);
}
现在我们将目光继续转回到柯里化函数,我们先来实现一个简单点的。我们先把一个三元函数转换为嵌套的一元函数,比如这样的求和函数,只需疯狂 return
3 次,转换后的函数可读性更强了。
var add = (a, b, c) => a + b +c;
add(1, 2, 3); // 6
var add = (a) => (b) => (c) => a + b + c;
add(1)(2)(3); // 6
来,我们进一步把这个过程抽象一下:
var add = (a, b, c) => a + b +c;
var simpleCurry = (fn) => (a) => (b) => (c) => fn(a, b, c);
var addCurry = simpleCurry(add);
addCurry(1)(2)(3);// 6
假如是不定参数的函数呢怎么转换,鉴于本文也属于教你三步学会一个 XXX系列,想必聪明的你已经知道了答案。
ley curry = function (fn) {
var _args = [];
return function () {
if (arguments.length === 0) {
return fn.apply(this, _args);
}
Array.prototype.push.apply(_args, [].slice.call(arguments));
return arguments.callee;
}
};
管道与组合
管道对于熟悉 unix、类 unix 的用户来说,不会陌生。 符号 |
被称为管道符号,|
将最左侧函数输出作为输入发送给右侧的的函数,这样的处理过程从技术上将称为管道。
如下命令就是用于统计给定文本在文本文件 text.txt 中的数量。管道符号将一个个基础函数组合起来称为一个新函数,这就要求每一个基础函数都需要接受一个参数并返回数据。
cat test.txt | grep 'world' | wc
接下来我们先实现一个简单的 compose
函数,在该函数中,接受一个参数 c
,然后会从由向左执行,先执行函数 b
再将其执行结果过作为参数交由函数 a
去执行。
let compose = (a, b) => (c) => a(b(c));
let number = compose(Math.round, parseFloat);
number('3.145');//3
组合多个函数
let compose = (...fns) => {
(value) => reduce(fns.reverse(), (acc, fn) => fn(acc), value);
}
let number = compose(Math.round, parseFloat);
number('3.145');//3
管道
大家也注意到了,管道是从右向左执行的,上文中管道的执行方向则是与之相反,所以我们只要小小改变一下传入参数顺序即可。
let pipe = (...fns) => {
(value) => reduce(fns, (acc, fn) => fn(acc), value);
}