函数编程是已以函数作为主要载体的编程方式,用函数去拆解、抽象一般的表达式。
与命令式编程相比有哪些好处?
- 语义清晰
- 复用性高
- 可维护性好
- 作用局局限,副作用少
基本的函数式编程
1、数组中的每个字母的首字母大写
一般写法
var list = ['apple', 'pen', 'style'];
for(const i in list){
const c = list[i][0];
console.log(c.toLocaleLowerCase())
list[i] = c.toUpperCase() + list[i].slice(1);
}
console.log(list)
函数式写法一
var list = ['apple', 'pen', 'style'];
function upperFirst(word){
return word[0].toUpperCase() + word.slice(1);
}
function wordToUpperCase(list){
return list.map(upperFirst);
}
console.log(wordToUpperCase(list));
函数式写法二
console.log(['apple', 'pen', 'style'].map(word => word[0].toUpperCase() + word.slice(1)));
当情况变得复杂的时候,表达式写法会遇到几个问题。
- 表意不明显,逐渐难以维护。
- 复用性差,产生更多的代码量。
- 会产生很多的中间变量。
函数式编程很好的解决以上的问题,如函数式写法一,它利用了函数封装性将功能做拆解,并封装为不同的函数,再利用组合的调用达到目的。这样做使表意更清晰,易于维护、复用以及扩展。其次利用高阶函数,Array.map 代替 for ... of做数组遍历,减少了中间变量和操作。
而函数式写法一和函数式写法二的区别在于,可以考虑后续函数复用的可能,如果没有,则后者更优。
链式优化
从上面的函数式写法二中可以看出,函数式代码在写的过程中,很容易造成横向延展,即产生多层嵌套。
计算数字之和
//一般写法
console.log(1 + 2 + 3 - 4);
//函数式写法
function sum(a, b) {
return a + b;
}
function sub(a, b) {
return a - b;
}
console.log(sub(sum(sum(1, 2), 3), 4);
随着函数的嵌套层数不断增多,导致代码的可读性下降,还容易产生错误。
在这种情况下,我们可以考虑链式优化。
const utils = {
chain(a) {
this._temp = a;
return this;
},
sum(b) {
this._temp += b;
return this;
},
sub(b) {
this._temp -= b;
return this;
},
value() {
return this._temp;
}
};
console.log(utils.chain(1).sum(2).sum(3).sub(4).value());
这样改写之后,结构整体变得比较清晰。函数的嵌套和链式对比还有一个很好的例子,就是回调函数和Promise模式。
顺序请求两个接口
//回调函数
import $ from 'jquery';
$.post('a/url/to/target', (rs) => {
if(rs){
$.post('a/url/to/another/target', (rs2) => {
if(rs2){
$.post('a/url/to/third/target');
}
});
}
});
//Promise
import request from 'catta'; // catta 是一个轻量级请求工具,支持 fetch,jsonp,ajax,无依赖
request('a/url/to/target')
.then(rs => rs ? $.post('a/url/to/another/target') : Promise.reject())
.then(rs2 => rs2 ? $.post('a/url/to/third/target') : Promise.reject());
随着回调函数嵌套层级和单层复杂数增加,它会变得臃肿且难以维护,而 Promise 的链式结构,在高度复杂时,仍能纵向扩展,而且层次清晰。
常见的函数式编程模型
闭包
闭包是由函数以及创建该函数的词法环境组合而成。
可以保留局部变量不被释放的代码块,称为闭包。
//创建一个闭包
function makeCounter() {
let k = 0;
return function() {
return ++k;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
makeCounter 这个函数的代码块,在返回的函数中,对局部变量 k ,进行了引用,导致局部变量无法在函数执行结束后,被系统回收掉,从而产生了闭包。而这个闭包的作用就是,“保留住“ 了局部变量,使内层函数调用时,可以重复使用该变量;而不同于全局变量,该变量只能在函数内部被引用。
换句话说,闭包其实就是创造出了一些函数私有的 ”持久化变量“。
所以从这个例子,我们可以总结出,闭包的创造条件是:
1、存在内、外两层函数
2、内层函数对外层函数的局部变量进行了引用
闭包的用途
闭包的主要用途就是可以定义一些作用域局限的持久化变量,这些变量可以用来做缓存或者中间量等等。
简单的缓存工具
//匿名函数创建一个闭包
const cache = (function() {
const store = {};
return {
get(key) {
return store[key];
},
set(key, val) {
store[key] = val;
}
}
}());
cache.set('a', 1);
cache.get('a'); // 1
上面的例子是一个简单的缓存工具的实现,匿名函数创建了一个闭包,使得 store 对象,一直可以被引用,不会被回收。
闭包的弊端
持久化的变量不会被正常释放,持续占有内存空间,很容易造成内存浪费,所以一般需要一些额外手动的清除机制。
高阶函数
接受或者返回一个函数的函数称为高阶函数。
JavaScript 预言师原生支持高阶函数的,因为 JavaScript 的函数是一等公民,它既可以作为参数又可以作为另一个函数的返回值使用。
我们经常在JavaScript中见到雨多原生的高阶函数,例如 Array.map, Array.reduce , Array.filter。
下面以 map 为例,看看它是如何使用的。
map(映射)
映射是对集合而言的,即把集合的每一项都做相同的变换,产生一个新的集合。
map 作为一个高阶函数,它接受一个函数的参数作为映射的逻辑。
数组中的每一项加一,组成一个新数组。
//一般写法
const arr = [4, 5, 6, 7];
const rs = [];
for(const n of arr){
rs.push(n + 1);
}
console.log(rs)
// map改写
const arr = [1, 2, 3, 4];
const rs = arr.map(n => ++n);
console.log(rs)
上面的一般写法,利用 for ...of 循环的方式遍历数组会产生额外的操作,而且有改变原数组的风险,而 map 函数封装了必要的操作,使我们仅需要关心映射的逻辑的函数实现即可,减少了代码量,也降低了副作用产生的风险。
柯里化
给定一个函数的部分参数,生成一个接受其他参数的新函数。
可能不常听到这个名词,但是用过 undescore 或 lodash 的人都见过他。
有一个神奇的 _.partial 函数,它就是柯里化的实现
// 获取目标文件对基础路径的相对路径
// 一般写法
const BASE = '/path/to/base';
const relativePath = path.relative(BASE, '/some/path');
// _.parical 改写
const BASE = '/path/to/base';
const relativeFromBase = _.partial(path.relative, BASE);
const relativePath = relativeFromBase('/some/path');
通过 _.partial ,我们得到了新的函数 relativeFromBase ,这个函数在调用时就相当于调用 path.relative ,并默认将第一个参数传入 BASE ,后续传入的参数顺序后置。
本例中,我们真正想完成的操作是每次获得相对于 BASE 的路径,而非相对于任何路径。柯里化可以使我们只关心函数的部分参数,使函数的用途更加清晰,调用更加简单。
组合(Composing)
将多个函数的能力合并,创造一个新的函数。
同样你第一次见到他可能还是在 lodash 中,compose 方法(现在叫 flow)
// 数组中每个单词大写,做 Base64
// 一般写法 (其中一种)
const arr = ['pen', 'apple', 'applypen'];
const rs = [];
for(const w of arr){
rs.push(btoa(w.toUpperCase()));
}
console.log(rs);
// _.flow 改写
const arr = ['pen', 'apple', 'applypen'];
const upperAndBase64 = _.partialRight(_.map, _.flow(_.upperCase, btoa));
console.log(upperAndBase64(arr));
_.flow 将转大写和转 Base64 的函数的能力合并,生成一个新的函数。方便作为参数函数或后续复用。
函数式编程的特点
1. 函数式一等公民
指的是函数和其他的数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为其他函数的返回值。
2. 只用表达式,不用语句
表达式是一个单纯的运算过程,总有返回值;语句是执行某种操作,没有返回值。函数式编程的需求,只使用表达式,不使用语句,也就是说每一步都是单纯的运算,而且都有返回值。
原因是函数式编程的开发动机,一开始就是为了处理运算(computation),不考虑系统的读写(I/O)。"语句"属于对系统的读写操作,所以就被排斥在外。
当然,实际应用中,不做I/O是不可能的。因此,编程过程中,函数式
编程只要求把I/O限制到最小,不要有不必要的读写行为,保持计算过程的单纯性。
3. 没有"副作用"
所谓"副作用",指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。
函数式编程强调没有"副作用",意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。
4. 不修改状态
上一点已经提到,函数式编程只是返回新的值,不修改系统变量。因此,不修改变量,也是它的一个重要特点。
在其他类型的语言中,变量往往用来保存"状态"(state)。不修改变量,意味着状态不能保存在变量中。函数式编程使用参数保存状态,最好的例子就是递归。下面的代码是一个将字符串逆序排列的函数,它演示了不同的参数如何决定了运算所处的"状态"。
function reverse(string) {
if(string.length == 0) {
return string;
} else {
return reverse(string.substring(1, string.length)) + string.substring(0, 1);
}
}
5. 引用透明
引用透明(Referential transparency),指的是函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。