函数式编程(纯函数函数柯里化代码组合)
函数
一等公民的函数
函数没什么特殊的,你可以像对待任何其他数据类型一样对待它们——把它们存在数组里,当作参数传递,赋值给变量…等等。
纯函数
什么是纯函数
- 纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用
- 不依赖外部变量,可以自给自足
比如数组的 slice 和 splice:
- slice 符合纯函数的定义:因为对相同的输入它保证能返回相同的输出;
- splice 却不同:会产生可观察到的副作用,即这个数组永久地改变了;
不纯的版本:取决于系统状态;因为它引入了外部的环境,从而增加了认知负荷
副作用
副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。
副作用可能包含,但不限于:
- 更改文件系统;
- 往数据库插入记录;
- 发送一个 http 请求;
- 可变数据;
- 打印/log;
- 获取用户输入;
- DOM 查询;
- 访问系统状态…
概括来讲,只要是跟函数外部环境发生的交互就都是副作用——这一点可能会让你怀疑无副作用编程的可行性。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。
这并不是说,要禁止使用一切副作用,而是说,要让它们在可控的范围内发生。
副作用让一个函数变得不纯是有道理的:从定义上来说,纯函数必须要能够根据相同的输入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了
追求纯函数的原因
1 可缓存性
// 可以借助一个方法:
var memoize = function (f) {
var cache = {};
return function () {
var arg_str = JSON.stringify(arguments);
cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
return cache[arg_str];
};
};
var fibonacci = memoize((n) => {
return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
});
fibonacci(6); // 8
2 可移植性/自文档化
- 纯函数对于其依赖必须要明确,这样我们就能知道它的目的
- 通过强迫“注入”依赖,或者把它们当作参数传递,我们的应用也更加灵活;
- 命令式编程中“典型”的方法和过程都深深地根植于它们所在的环境中,通过状态、依赖和有效作用(available effects)达成;纯函数与此相反,它与环境无关,只要我们愿意,可以在任何地方运行它。
3 可测试性
测试更加便捷,可以直接简单地给函数一个输入,然后断言输出就可以
4 合理性
- 纯函数最大的好处是引用透明性(referential transparency)
- 由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性
5 并行代码
最后一点,也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。
并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。
柯里化
什么是柯里化?
curry 的概念很简单:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
你可以一次性地调用 curry 函数,也可以每次只传一个参数分多次调用。
var add = function(x) {
return function(y) {
return x + y;
};
};
var increment = add(1);
var addTen = add(10);
increment(2);
// 3
addTen(2);
// 12
如何封装柯里化工具函数
回想之前我们对于柯里化的定义,接收一部分参数,返回一个函数接收剩余参数,接收足够参数后,执行原函数。
- 拓展:
function foo() {}
foo.length; // 指的是初始化时未被给定默认值的参数个数
- 实现:
function curry(fn) {
return function returnFn() {
const args = Array.prototype.slice.call(arguments);
if (args?.length < fn.length) {
return function () {
const argsNext = Array.prototype.slice.call(arguments);
return returnFn.apply(null, args.concat(argsNext));
};
}
return fn.apply(null, args);
};
}
var getNumber = function (a, b, c) {
console.log(a, b, c);
};
var example = curry(getNumber);
example(1)(2)(3);
柯里化的用途
柯里化实际是把简答的问题复杂化了,但是复杂化的同时,我们在使用函数时拥有了更加多的自由度。 而这里对于函数参数的自由处理,正是柯里化的核心所在。
柯里化强调的是生成单元函数,部分函数应用的强调的固定任意元参数,而我们平时生活中常用的其实是部分函数应用,这样的好处是可以固定参数,降低函数通用性,提高函数的适合用性。
// 假设一个通用的请求 API
const request = (type, url, options) => ...
// GET 请求
request('GET', 'http://....')
// POST 请求
request('POST', 'http://....')
// 但是通过部分调用后,我们可以抽出特定 type 的 request
const get = request('GET');
get('http://', {..})
实现参数复用
我们工作中会遇到各种需要通过正则检验的需求,比如校验电话号码、校验邮箱、校验身份证号、校验密码等, 这时我们会封装一个通用函数 checkByRegExp ,接收两个参数,校验的正则对象和待校验的字符串。
示例
function curry(fn) {
return function returnFn() {
const args = Array.prototype.slice.call(arguments);
if (args?.length < fn.length) {
return function () {
const argsNext = Array.prototype.slice.call(arguments);
return returnFn.apply(null, args.concat(argsNext));
};
}
return fn.apply(null, args);
};
}
// 校验函数
function checkByRegExp(regExp, string) {
return regExp.test(string);
}
checkByRegExp(/^1\d{10}$/, "18642838455"); // 校验电话号码
checkByRegExp(/^1\d{10}$/, "13109840560"); // 校验电话号码
checkByRegExp(/^1\d{10}$/, "13204061212"); // 校验电话号码
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, "test@163.com"); // 校验邮箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, "test@qq.com"); // 校验邮箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, "test@gmail.com"); // 校验邮箱
我们每次进行校验的时候都需要输入一串正则,再校验同一类型的数据时,相同的正则我们需要写多次, 这就导致我们在使用的时候效率低下,并且由于 checkByRegExp 函数本身是一个工具函数并没有任何意义, 一段时间后我们重新来看这些代码时,如果没有注释,我们必须通过检查正则的内容, 我们才能知道我们校验的是电话号码还是邮箱,还是别的什么。
此时,我们可以借助柯里化对 checkByRegExp 函数进行封装,以简化代码书写,提高代码可读性
柯里化实现
// checkByRegExp进行柯里化
var _check = curry(checkByRegExp);
var checkPhone = _check(/^1\d{10}$/);
var checkMail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);
checkPhone("18642838455");
checkPhone("13109840560");
checkPhone("13204061212");
checkMail("test@163.com");
checkMail("test@qq.com");
checkMail("test@gmail.com");
代码组合
什么是代码组合?
var compose = function(f,g) {
return function(x) {
return f(g(x));
};
};
f 和 g 都是函数,x 是在它们之间通过“管道”传输的值(即上一个方法的结果作为下一个函数的入参)。
var toUpperCase = function(x) { return x.toUpperCase(); };
var exclaim = function(x) { return x + '!'; };
var shout = compose(exclaim, toUpperCase);
shout("send in the clowns"); //=> "SEND IN THE CLOWNS!"
在 compose 的定义中,g 将先于 f 执行,因此就创建了一个从右到左的数据流。这样做的可读性远远高于嵌套一大堆的函数调用。
代码组合:
- 两个函数组合之后返回了一个新函数,也就是组合某种类型的两个元素本就该生成一个该类型的新元素
- 从右向左运行,而不是由内而外运行
如何实现组合
// 参数不固定、通过reduceRight 从右往左遍历
function compose(...fns) {
return function (initValue) {
return fns.reduceRight((preValue, fn) => fn(preValue), initValue);
};
}
值的一提的是,React中Redux的中间件就是用compose实现的,webpack中loader的加载顺序也是从右往左,这是因为他也是compose实现的。有兴趣可以了解下它compose的实现
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import DevTools from './containers/DevTools'
import reducer from '../reducers'
const store = createStore(
reducer,
compose(
applyMiddleware(thunk),
DevTools.instrument()
)
)
举例
// 需求:我们登记了一系列人名存在数组中,现在需要对这个结构进行一些修改,需要把字符串数组变成一个对象数组,方便后续的扩展,并且需要把人名做一些转换
['john-reese', 'harold-finch', 'sameen-shaw']
// 转换成
[{name: 'John Reese'}, {name: 'Harold Finch'}, {name: 'Sameen Shaw'}]
步骤:
- 通过split - 合成数组
- 首字母大写,其他小写
- 通过’ '空格组合成数组
- 设置给name
// 柯里化
function curry(fn) {
return function returnFn() {
const args = Array.prototype.slice.call(arguments);
if (args?.length < fn.length) {
return function () {
const argsNext = Array.prototype.slice.call(arguments);
return returnFn.apply(null, args.concat(argsNext));
};
}
return fn.apply(null, args);
};
}
// 代码组合处理
function compose(...fns) {
return function (initValue) {
return fns.reduceRight((preValue, fn) => fn(preValue), initValue);
};
}
// 首字母大写,其他小写
const capitalize = (strSplit) =>
strSplit?.map((str) => str[0].toUpperCase() + str.slice(1).toLowerCase());
// 切割字符串
const splitFn = curry((splitSign, arr) => arr.split(splitSign));
// 合并字符串
const joinFn = curry((joinSign, str) => str.join(joinSign));
// 组合成对象 key:name
const genObj = curry((key, val) => {
let obj = {};
obj[key] = val;
return obj;
});
// 调用组合
const getNameObj = compose(
genObj("name"),
joinFn(" "),
capitalize,
splitFn("-")
);
const convertName = (strArr) => strArr.map((str) => getNameObj(str));
var combineNames = convertName(["john-reese", "harold-finch", "sameen-shaw"]);
console.log(combineNames); // [{ name: "John Reese" }, { name: "Harold Finch" }, { name: "Sameen Shaw" }];
函数组合的 Debug
可以通过自定义一个方法就能在任何阶段后获取到值
const trace = curry((tip, v) => {
console.log(tip, v);
return x;
});
const getNameObj = compose(
genObj("name"),
trace("tip"),
joinFn(" "),
capitalize,
splitFn("-")
);
pointfree
pointfree 模式指的是,永远不必说出你的数据。它的意思是说,函数无须提及将要操作的数据是什么样的。一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。
// 非 pointfree,因为提到了数据:word
var snakeCase = function (word) {
return word.toLowerCase().replace(/\s+/ig, '_');
};
// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
总结:
优点:
- 代码简洁,开发快速:函数式编程大量使用函数的组合,函数的复用率很高,减少了代码的重复,因此程序比较短,开发速度较快。
- 接近自然语言,易于理解:函数式编程大量使用声明式代码,基本都是接近自然语言的,加上它没有乱七八糟的循环,判断的嵌套,因此特别易于理解。
- 易于"并发编程":函数式编程没有副作用,所以函数式编程不需要考虑“死锁”(Deadlock),所以根本不存在“锁”线程的问题。
- 更少的出错概率:因为每个函数都很小,而且相同输入永远可以得到相同的输出,因此测试很简单,同时函数式编程强调使用纯函数,没有副作用,因此也很少出现奇怪的 Bug。
缺点:
- 性能:函数式编程相对于指令式编程,性能绝对是一个短板,因为它往往会对一个方法进行过度包装,从而产生上下文切换的性能开销。同时,在 JS 这种非函数式语言中,函数式的方式必然会比直接写语句指令慢(引擎会针对很多指令做特别优化)。就拿原生方法 map 来说,它就要比纯循环语句实现迭代慢 8 倍。
- 资源占用:在 JS 中为了实现对象状态的不可变,往往会创建新的对象,因此,它对垃圾回收(Garbage Collection)所产生的压力远远超过其他编程方式。这在某些场合会产生十分严重的问题。
- 递归陷阱:在函数式编程中,为了实现迭代,通常会采用递归操作,为了减少递归的性能开销,我们往往会把递归写成尾递归形式,以便让解析器进行优化。但是众所周知,JS 是不支持尾递归优化的(虽然 ES6 中将尾递归优化作为了一个规范,但是真正实现的少之又少)