【JS进阶】纯函数 + 高阶函数 + 函数柯里化

纯函数 + 高阶函数 + 函数柯里化

1.纯函数

纯函数 (Pure Function)函数式编程中一个非常重要的概念。

纯函数是一个定义,对于一个纯函数,执行它不会产生不可预料的行为,也不会对外部产生影响

如果一个函数是纯函数,其必须符合两个条件:

  • 返回结果只依赖于其参数
  • 在执行过程中没有副作用
1.1 副作用

首先要了解一个概念,什么是副作用

函数副作用,指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量)或修改参数。

函数的副作用会给程序设计带来一些不必要的麻烦,使得程序中的错误难以追溯,因此,严格的函数式编程中要求函数必须是无副作用的。

了解了副作用的定义后,我们可以进一步扩展纯函数的定义:

纯函数中,输入输出数据流全是显式(Explicit)的。 显式(Explicit)的意思是,函数与外界交换数据只有一个唯一渠道就是参数返回值。函数从函数外部接受的所有输入信息都通过参数传递到该函数内部。函数输出到函数外部的所有信息都通过返回值传递到该函数外部。

接下来,来对纯函数的两个条件进行详细的说明。

1.2 函数的返回结果只依赖于它的参数

例1:

const a = 1
const foo = (b) => a + b
foo(2) // => 3

虽然这里的 a 是外部常量,而且我们在函数中也没有改变它的值,但是 foo 函数仍然不属于纯函数,因为其返回值并不是只依赖于他的参数 b,还依赖了常量 a。

当我们在这里调用 foo 时,a 的确是一个值为2的常量,但是我们无法保证在其他地方调用 foo 时,a 仍然是值为2的常量,即存在不可控的情况!

下面来看第二个例子:

const a = 1
const foo = (x, y) => x + y
foo(a, 2)

可以看到,这里的 foo 函数,其返回值就是完全只依赖其接收到的参数,并不存在不可控的情况。因此这个 foo 函数是一个纯函数。

1.3 函数执行过程中没有副作用

如上面所说,副作用,就是指在函数的执行过程中,对外部(即调用函数的作用域)产生了可被观测的影响。

这样的定义难免有些抽象,下面看一个简单的例子:

const counter = { x: 1 }
const foo = (obj, b) => {
  obj.x = 2
  return obj.x + b
}

foo(counter, 2) // => 4
counter.x // => 2

这里可以看到,我们向函数 foo 中传入了一个对象 obj 的引用,然后在 foo 函数中,修改了 obj 身上 x 属性的值!这意味着,无论我们在哪里调用这个函数,我们都修改了外部作用域中变量的值,即产生了副作用。所以 foo 函数不是一个纯函数。

而如果我们将上面例子中的 obj.x = 2 去掉,则 foo 函数中没有修改外部变量的值,且其返回值也是完全仅依赖于其接收到的参数,因此,此时的 foo 是一个纯函数。

当然,副作用并不只包括修改外部变量的值,在实际开发中,有很多操作都会产生副作用,例如:

  • 调用 DOM API 修改了页面的内容
  • 发送 ajax 请求获取数据
  • 调用 window.reload 刷新浏览器
  • 调用 console.log() 输出某些数据到控制台
  • 调用 Date.now() 或 Math.Radom() 等不纯的方法

2.高阶函数

高阶函数的定义如下:

如果一个函数符合下面两个要求中的任何一个,那么该函数就是一个高阶函数:

  • 函数接收的参数是一个函数
  • 函数调用的返回值仍为一个函数

常见的高阶函数包括:Promise、setTimeout、setInterval、节流防抖函数以及数组方法中的 map、reduce、filter 等等。

在高阶函数中,我们往往会大量地使用闭包来实现封装,进而实现偏函数、函数柯里化等等。

3.函数柯里化

柯里化是编程语言中的一个通用的概念(不只是Js,其他很多语言也有柯里化),是指把接收多个参数的函数变换成接收单一参数的函数,嵌套返回直到所有参数都被使用并返回最终结果。更简单地说,柯里化是一个函数变换的过程,是将函数从调用方式:f(a,b,c)变换成调用方式:f(a)(b)(c)的过程。柯里化不会调用函数,它只是对函数进行转换。

柯里化是把一个具有较多参数数量的函数转换为具有较少参数数量函数的过程

下面是一个简单的柯里化例子:

要实现三个数相加的函数,一般的写法如下

function add (a, b, c) {
    return a + b + c;
}

add(1, 2, 3)

如果对该函数进行柯里化

function addCurry (a) {
    return function (b) {
        return function (c) {
            return a + b + c;
        }
    }
}

addCurry(1)(2)(3)
// 柯里化后的函数还可以分步调用
const add1 = addCurry(1)
const add2 = add1(2)
const add3 = add2(3)
console.log(add3) // 6

如果仅仅看上面的代码,可能会觉得,柯里化没有什么作用,反而增加了代码量。但实际上,需要注意的是,对于我们柯里化后的函数,我们每次仅传入单个参数,并在最后一个参数传入后,进行最终的求和,即,函数中始终保存着之前我们传入的状态,直到最终操作的所有条件都满足后,才执行最后的求和

3.1 柯里化的作用

在上面的例子中,我们可以看到,柯里化后的函数,通过闭包实现了我们传入状态的持久化存储,并将三个参数拆分开来进行处理。

下面对柯里化的作用、应用场景进行详细的介绍。

1. 延迟计算(分步处理) + 参数复用

首先是延迟计算,想象这样一个场景,现在我们需要对商店中的物品打折出售,但物品之间的折扣并不完全相同,所以我们一般的写法为:

// discount 为优惠的百分比,
function sell (discount, price) {
    return price * ((100 - discount) / 100)
}

当顾客购买了一件优惠10%的原价为4000的商品,那么最后的计算过程如下:

const realPrice = sell(10, 4000)

那么当多个用户都购买了优惠10%但原价不同的商品时呢?

const realPrice1 = sell(10, 4000)
const realPrice2 = sell(10, 2000)
const realPrice3 = sell(10, 100)
const realPrice4 = sell(10, 500)
const realPrice5 = sell(10, 10000)

可以看到,这些计算的过程中,第一个参数是不变化的,此时,我们就可以对原本的函数进行一次柯里化,先对折扣进行处理:

function sellCurry(discount) {
	let per = (100 - discount) / 100
    return function(price) {
        return price * per
    }
}

这样,对于相同优惠力度的商品,我们就可以先设定一个折扣,然后传入商品的单价进行处理即可

const tenPercentDiscount = sellCurry(10)
const twentyPercentDiscount = sellCurry(20)
...

const realPrice1 = tenPercentDiscount(4000)
const realPrice2 = tenPercentDiscount(2000)
const realPrice3 = tenPercentDiscount(500)
const realPrice4 = twentyPercentDiscount(12000)

可以看到,代码中我们实际上进行了以下两步操作:

  1. 设定优惠力度
  2. 传入原价,得到现价

这实际上就是柯里化中的分步处理的思想体现,这样的分步处理,让我们的代码逻辑更加清晰。同时,通过闭包,将上一次传入的参数保留了下来,后续就不需要重复传入了,即参数复用。

2. 动态生成处理函数

动态生成处理函数,实际上就是对不同情况,生成不同的处理函数,从而将判断的逻辑与实际处理逻辑分离开来。

有这样一个场景,在实际开发中,为了兼容IE浏览器和其他浏览器的绑定事件监听方法,通常会进行下面的处理:

const addEvent = (el, type, fn, capture) => {
    if (window.addEventListener) {
		ele.addEventListener(type, (e) => fn.call(ele, e), capture);
	} else if (window.attachEvent) {
		ele.attachEvent('on'+type, (e) => fn.call(ele, e));
	}
}

addEvent(document.getElementById('app'), 'click', (e) => {console.log('click function has been call:', e);}, false);
addEvent(document.getElementById('demo'), 'scroll', (e) => {console.log('scroll function has been call:', e);}, false);
...

可以看到,每一次调用,都需要重复写大量的参数,且代码的可读性较低。

同时,每次绑定事件时,都会进行一次环境的判断,然后再去绑定函数,而如果我们进行下面的柯里化:

const addEventCurry = () => {
	if (window.addEventListener) {
		return function(ele) {
			return function(type) {
				return function(fn) {
					return function(capture) {
						ele.addEventListener(type, (e) => fn.call(ele, e), capture);
					}
				}
			}
		}
	} else if (window.attachEvent) {
		return function(ele) {
			return function(type) {
				return function(fn) {
					return function(capture) {
						ele.addEventListener(type, (e) => fn.call(ele, e), capture);
					}
				}
			}
		}
	}
};

使用时,我们只需要调用一次 addEventCurry 方法,就可以得到已经进行过环境兼容的处理函数:

const handleAddEvent = addEventCurry()
// 此时,已经完成了环境的判断以及兼容,直接使用即可

// 分步处理
const el = document.getElementById('app')
const appAttach = handleAddEvent(el);
appAttach('click')((e) => {console.log(e)})(false);
// 后续,如果我们要继续向id为app的元素身上添加事件,那么直接使用上面的函数即可
appAttach('scroll')((e) => {console.log(e)})(false);

// 如果我们想在别的元素身上绑定事件,那么就重新调用一次 handleAddEvent 就行了
const el2 = document.getElementById('demo')
const demoAttach = handleAddEvent(el2)
demoAttach('keyup')((e) => {...})(true);
...

当然这些代码在实际使用时,还可以根据实际情况进行优化,但这里就可以看到通过柯里化根据不同情况,生成不同的处理函数,并将判断逻辑与处理逻辑分离开后,降低了代码的复杂度,提高了可读性。

3.2 将普通函数柯里化

最后还有一个问题,形如我们前面说到的三数求和那样内部逻辑仅仅依赖于传入参数的函数,如果我们想要将这些函数柯里化,难道每次都要写重复的函数嵌套吗?

实际上,我们可以编写一个函数,专门用于将这些函数柯里化

const curry = (fn) => {
	return function nest (...args) {
        if (args.length === fn.length) {	// 这里 fn.length 是函数 fn 的参数数量
            // 当参数接收的数量达到了函数fn的形参个数,即所有参数已经都接收完毕则进行最终的调用
            return fn(...args);
        } else {
            // 参数还未完全接收完毕,递归,保留已传入的参数,并将新的参数传入
            return (arg) => {
                return nest(...args, arg);
            }
        }
    }
}

例:

function addNum(a, b, c) {
    return a + b + c;
}

const addCurry = curry(addNum);

addCurry(1)(2)(3);// 6

// 这个过程分为三步
// step1:
// addCurry(1) --- 此时 args 为 [1]
// 返回下面的函数
// ƒ (arg) {
// 	return judge(1, arg);
// }
// step2:
// addCurry(1)(2) --- 此时 args 为 [1, 2]
// 返回下面的函数
// ƒ (arg) {
// 	return judge(1,2, arg);
// }
// step3:
// addCurry(1)(2)(3) --- 此时 args 为 [1, 2, 3],长度与fn的参数数量相同,即参数接收完毕
// 返回并执行下面的函数
// return fn(1,2,3);
// 最终得到结果6

当然这个方法并不适用于所有函数的柯里化,像上面的环境兼容那个例子,就不适合用这个方法进行柯里化。

你可能感兴趣的:(JS进阶,javascript,前端)