函数式编程入门实践(一)

2022-10-20,,,

在文章之前,先和大家讲一下对于函数编程(functional programming, aka. fp)的理解(下文我会用fp指代函数式编程):

  1. fp需要保证函数都是纯净的,既不依赖外部的状态变量,也不产生副作用。基于此前提下,那么纯函数的组合与调用,在时间顺序上就不会产生依赖,改变多个函数的调用顺序也不必担心产生问题,因此也会消灭许多潜在的bug。
  2. 函数必须有输入输出。如果一个函数缺乏输入或输出,那么它其实是一段处理程序procedure而已。
  3. 函数尽可能的保持功能的单一,如果一个函数做了多件事情,那么它理论上应当被拆分为多个函数。
  4. fp的意义之一就是,在适当的时机使用声明式编程,抽象了程序流的控制与表现,从理解和维护的角度上会胜于命令式编程。
  5. fp是一种范式,但并不意味这和oop(面向对象编程)冲突,两者当然是可以和谐共存的。个人认为 react 其实就是一个很好的栗子~
  6. javascript的函数一等公民以及闭包的特性,决定了javascript的确是适合施展fp的舞台

理解闭包

闭包对于 javascript 来说,当然十分重要。然而对于函数式编程来说,这更加是必不可少的,必须掌握的概念,闭包的定义如下:

closure is when a function remembers and accesses variables from outside of its own scope, even when that function is executed in a different scope.

相信大部分同学都对闭包有不错的理解,但是由于对fp的学习十分重要。接下来我还是会啰嗦的带大家过一遍。闭包就是能够读取其他函数内部变量的函数

简单示例如下

// closure demo
function cube(x) {
 let z = 1;
 return function larger(y) {
 return x * y * z++;
 };
}

const makecube = cube(10);
console.log(makecube(5)); // 50
console.log(makecube(5)); // 100

那么有没有想过在函数makecube,或者也可以说是函数larger是怎么记住原本不属于自己作用域的变量x和z的呢?在控制台查看makecube.prototype,点开会发现原来是有个[[scopes]]这个内置属性里的closure(cube)记住了函数larger返回时记住的变量x和z。如果多嵌套几层函数,也会发现多几个closure(name)在[[scopes]]的scopes[]数组里,按序查找变量。

再看下图测试代码:

function cube(x) {
 return function wrapper(y) {
 let z = 1;
 return function larger() {
  return x * y * z++;
 };
 }
}

const makecubey = cube(10);
const makecube = makecubey(5);
const $__var1__ = '1. this var is just for test.';
let $__var2__ = '2. this var is just for test.';
var $__var3__ = '3. this var is just for test.';
console.log(makecubey.prototype, makecube.prototype);
console.log(makecube()); // 50
console.log(makecube()); // 100

打印makecubey.prototype:

打印makecube.prototype:

 

通过这几个实验可以从另一个角度去理解javascript中闭包,一个闭包是怎么去查找不是自己作用域的变量呢?makecube函数分别从[[scopes]]中的closure(wrapper)里找到变量y、z,closure(cube)里找到变量x。至于全局let、const声明的变量放在了script里,全局var声明的变量放在了global里。

在学习fp前,理解闭包是尤为重要的~ 因为事实上大量的fp工具函数都使用了闭包这个特性。

工具函数

unary

const unary = fn => arg => fn(arg);

一元函数,应用于当只想在某个函数上传递一个参数情况下使用。尝试考虑以下场景:

console.log(['1', '2', '3'].map(parseint)); // [1, nan, nan]
console.log(['1', '2', '3'].map(unary(parseint))); // [1, 2, 3]

parseint(string, radix)接收两个参数,而map函数中接收的回调函数callback(currentvalue[, index[, array]]),第二个参数是index,此时如果parseint的使用就是错误的。当然除了array.prototype.map,大量内置的数组方法中的回调函数中都不止传递一个参数,如果存在适用的只需要第一个参数的场景,unary函数就发挥了它的价值,无需修改函数,优雅简洁地就接入了。(对于unary函数,fn就是闭包记忆的变量数据)

identity

const identity = v => v;

有同学会看到identity函数会觉得莫名其妙?是干嘛的?我第一眼看到也很迷惑?但是考虑以下场景:

console.log([false, 1, 2, 0, '5', true].filter( identity )); // [1, 2, "5", true]
console.log([false, 0].some( identity )); // false
console.log([-2, 1, '3'].every( identity )); // true

怎么样?眼前一亮吧,没想到identity函数原来深藏不露,事实上虽然identity返回了原值,但是在这些函数中javascript会对返回的值进行类型装换,变成了布尔值。比如filter函数。我们可以看mdn定义filter描述如下(看标粗的那一句)。

filter() calls a provided callback function once for each element in an array, and constructs a new array of all the values for which callback returns a value that coerces to true.

constant

const constant = v => () => v;

同样,这个函数...乍一看,也不知道具体有什么用。但是考虑场景如下:

onst p1 = new promise((resolve, reject) => {
 settimeout(() => {
 resolve('hello!');
 }, 200);
});
p1.then(() => 'hi').then(console.log); // hi!
p1.then(constant('hi')).then(console.log); // hi!
p1.then('hi').then(console.log); // hello!

由于promise.prototype.then只接受函数,如果我仅仅只需要传递一个值时,那么constant便会提供这种便利。当然这个并没有什么功能上的提升,但是的确提高了可阅读性,也是函数式编程的一个优点。

spreadargs & gatherargs

const spreadargs = fn => argsarr => fn( ...argsarr );
const gatherargs = fn => (...argsarr) => fn( argsarr );

嗯这两个函数见名知义。分别用于展开一个函数的所有参数和收集一个函数所有参数,这两个函数明显对立,那么它们的应用场景又是什么呢?

spreadargs函数示例如下:

function cube(x, y, z) {
 return x * y * z;
}

function make(fn, points) {
 return fn(points);
}

console.log(make(cube, [3, 4, 5])); // nan
console.log(make(spreadargs(cube), [3, 4, 5])); // 60

gatherargs函数示例如下:

function combinefirsttwo([v1, v2]) {
 return v1 + v2;
}

console.log([1, 2, 3, 4, 5].reduce(combinefirsttwo)); // uncaught typeerror
console.log([1, 2, 3, 4, 5].reduce(gatherargs(combinefirsttwo))); // 15

看完以上代码,简单的两个工具函数,轻易的做到了对一个函数的转换,从而使其适用于另一个场景。如果从此应该可以瞥见函数式编程的一点点魅力,那么下面的两个函数将给大家带来更多的惊喜。

partial & curry

const partial = (fn, ...presetargs) => (...laterargs) =>
 fn(...presetargs, ...laterargs);
 
const curry = (fn, arity = fn.length, nextcurried) =>
 (nextcurried = prevargs => nextarg => {
 const args = [...prevargs, nextarg];

 if (args.length >= arity) {
  return fn(...args);
 } else {
  return nextcurried(args);
 }
 })([]);

相信大家对函数柯里化应该或多或少有点了解。维基百科定义:

在计算机科学中,柯里化(英语:currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

当然得益于闭包的强大威力,柯里化这个武器得以诞生于javascript世界。请大家先精读以上关于partiel、curry函数的代码。

喝一杯咖啡~

先模拟一个ajax函数如下:

function ajax(url, params, callback) {
 settimeout(() => {
 callback(
  `get ${url} \nparams: ${params} \ndata: hello! ${params} `
 );
 });
}

考虑partial使用场景如下:

const fetchperson = partial( ajax, "http://some.api/person" );

fetchperson('teddy bear', console.log);
/*
get http://some.api/person 
params: teddy bear 
data: hello! teddy bear 
*/

考虑curry使用场景如下:

const fetchperson = curry(ajax)('http://some.api/person');
const fetchunclebarney = fetchperson('uncle barney');

fetchunclebarney(console.log);
/*
get http://some.api/person 
params: uncle barney 
data: hello! uncle barney 
*/

partial和curry函数功能相似,但又有具体的不同应用场景,但总体来说curry会比partial更自动化一点。
但是!相信看完示例的同学又会有一连串问号?为什么好好地参数不一次性传入,而非要分开多次传入这么麻烦?原因如下:

  1. 最首要的原因是partial和curry函数都允许我们通过参数控制将一个函数的调用在时间和空间上分开了。传统函数需要一次性将参数凑齐才能调用,但是有时候我们可以提前预置部分参数,在最终需要触发此函数时,才将剩余参数传入。这时候partial和curry就会变得十分有用。
  2. partial和curry的存在让函数组合(compose)会更加便利。(函数组合也计划之后和大家分享,这里就不详细说了)。
  3. 当然最重要是也提升了可阅读性!一开始可能不这么以为,但是如果你实践操作感受之后,也许会改观。

p.s. 关于函数式编程的实践,大家可以使用lodash/fp模块进行入门实践。

一些思考
因为我也是函数式编程的初学者,如有不正确的地方,欢迎大家纠正~

接下来还是会继续整理fp的学习资料,学习实践,连载一些我对于函数式编程的学习与思考,希望和大家一起进步~

谢谢大家(●´∀`●)~

以上所述是小编给大家介绍的javascript函数式编程详解整合,希望对大家有所帮助

《函数式编程入门实践(一).doc》

下载本文的Word格式文档,以方便收藏与打印。