JavaScript的函数式特性

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/qq_41694291/article/details/102508698

前言

作为一门面向对象的语言,JavaScript本身却具有明显的函数式语言特征。而这也是很多JavaScript的支持者钟爱它的原因之一 – 函数式特性为这门语言带来了极大的灵活性。高阶函数、偏函数、函数柯里化、闭包这些概念都不同程度地依赖JavaScript的函数式特性。下面我们就来了解一下JavaScript的函数式特性,以及上述与函数式特性息息相关的重要概念。

什么是函数式特性?

目前计算机领域的编程语言按设计思想可以大致分为三类:面向过程的语言、面向对象的语言和函数式语言。

最典型的面向过程的语言就是C语言,它强调解决一个问题需要经过什么样的过程,并把某些具有特定规律的过程抽象成算法。C语言开发者最朴素的想法就是,解决一个问题需要哪些步骤,而函数、结构体这些概念都服务于这些步骤。函数在C语言中更多是作为工具存在的,比如:

int add(int a, int b){
  return a + b;
}

int a = 1;
int b = 2;
int sum = add(a, b);
printf("%d", sum); 

面向对象语言最典型的就是Java – 一门完全的面向对象语言,也是目前最为流行的语言之一。面向对象的思想是把所有的实体都抽象成对象(比如一辆车可以抽象成一个对象),同一类对象的集合又抽象成类。每个对象有自己的属性和方法,属性用于描述对象的某些特性(如车的颜色、重量等),方法用于描述对象的行为(如移动、停止等),对象之间会产生联系和相互作用。JavaScript也是一门面向对象的语言,但与Java存在一个重要的差别:Java中的函数通常作为对象的方法存在,而JavaScript的函数本身就是对象(所以可以说,函数在JavaScript中的地位比在Java中更高)。

而对于函数式语言来说,函数是一等公民。它把所有的过程都抽象成一系列函数的嵌套组合,所有的数据结构都服务于函数。从某些方面来说,函数式编程与面向过程的编程具有一定的相似性,两者都强调过程,但函数式编程会把所有的过程封装成函数,而不是将其视为工具。

简单介绍了三种设计思想后我们看到,一门语言属于哪种类型主要取决于它采用哪种思维方式。虽然使用同一门语言的开发者倾向于采用同一种思维方式,但也有例外,C++就是一个很好的例子。它完全兼容C语言,也就意味着开发者可以以面向过程的思维方式进行开发(甚至直接采用C语言);同时它也支持类和对象,开发者也可以选择面向对象的思维方式进行开发。类似的,JavaScript中函数也是对象的语言特性使得它可以从函数式语言中借鉴到很多技巧,高阶函数就是其函数式特性的集中体现。

那什么是高阶函数呢?这是一个比较广义的概念,在JavaScript中,只要一个函数允许接收其他函数作为参数,或者可以返回一个函数,那么这个函数就被称为高阶函数。这类函数在Java中是不允许的,在C++中需要借助函数指针来实现,但是在函数式语言(如Lisp)中,这就是最基本的操作(因为函数封装了过程,而函数式编程的基本思路就是过程的组装和嵌套)。举个例子:

function f(a, b){
  return a + b;
}

function g(func){
  func();
}

g(f);

这里的g就是一个高阶函数,因为它以另一个函数作为参数(当然我们一般不会这样使用高阶函数,这样封装g几乎毫无意义)。

如果你认为高阶函数一般是高级JavaScript开发者才会用到的那就错了。实际上我们最常用的addEventListener、map、sort这些函数都是高阶函数,此外我们经常使用的闭包,也是在封装一个高阶函数。

下面来看一下一些常见的高阶函数应用。

高阶函数的应用

下面的案例只是个人分类,请勿直接引用。

1. 回调函数

回调函数模式可能是JavaScript开发者最基本的思维方式之一了。比如下面的例子:

let button = document.querySelector("#add");
button.addEventListener("click", function(e){
  ...  //处理用户点击事件
})

这里就是在调用DOM节点对象button原型上的原生高阶方法addEventListener,我们传入的回调函数会在用户点击按钮时被浏览器执行。注意,这里我们传入的不过是一个匿名函数罢了,它的调用发生在事件循环中。由于接收函数作为参数,显然addEventListener是一个高阶函数。

2. 通用函数封装

在Java中如果要实现一个通用的数组排序方法,通常需要对函数进行重载,也就是需要定义该方法的不同版本,来用于不同的需求。但是在JavaScript中我们不需要这么做(事实上由于没有函数签名,JavaScript也不支持重载)。我们知道,JavaScript的数组原型上只有一个通用的sort方法,对于不同的排序需求,我们只需要传入不同的排序函数即可实现。比如:

var arr = [5, 8, 2];

// ascArr为从小到大排序的结果
var ascArr = arr.sort(function(a, b){
  return a - b;
})

// descArr为从大到小排序的结果
var descArr = arr.sort(function(a, b){
  return b - a;
})

传入sort的函数是我们定义的排序规则。现在假设浏览器正在对数组元素5和8进行排序(可能是使用冒泡排序,也可能是归并排序等任意排序方法,它们都一定会涉及到两个元素的比较问题)。浏览器需要知道5和8哪个值应该排在前面,于是它调用我们传入的函数,以5和8为参数,结果得到一个负数,js引擎就知道,第一个参数(也就是5)应该排在前面。如果返回正数,那么8会被排在前面(这是接口所规定的)。

除了比较一般的数字,它还可以用来比较字符串,如:

var arr = ['sdfer', 'wre'];
arr.sort(function(a, b){
  if(a <= b){
    return -1;
  }else{
    return 1;
  }
})

原理很简单,我们定义了a <= b时返回-1,这表示我们希望这种情况下a应该排在b前面,否则a应该排在b后面。字符串大小的比较规则是以字符的ASCII值为依据的。同样的,sort还可以用来给对象数组排序,只要我们定义了排序规则即可,如:

var arr = [
  {name: "bbb", age: 24},
  {name: "aaa", age: 21},
  {name: "ccc", age: 26},
]
//按照名字的字母正序排序,这里"aaa" < "bbb" < "ccc"
var arrName = arr.sort(function(obj1, obj2){
  if(obj1.name <= obj2.name){
    return -1;
  } else {
    return 1;
  }
})
//按年龄从低到高来排序
var arrAge = arr.sort(function(obj1, obj2){
  return obj1.age - obj2.age;
})

显然,你可以以任意的规则来对数组进行排序,你甚至可以决定让数字全部排在字母的前面,或者以自定义的规则对中文排序,只要你的规则传入任意两个元素都可以返回一个数值即可。对于js引擎而言,sort函数只定义排序算法,不关心排序规则。对于开发者而言,sort采用什么算法并不重要,但是需要定义“大小”规则。

两者的解耦正是这种情况下使用高阶函数的精华所在。数组的很多原型方法如map、forEach、filter都是这个原理。它们既帮开发者封装了有用的工具方法,又给了开发者足够的灵活性。

3. 偏函数

偏函数是JavaScript中相对高级一些的概念,它将一个接收很多参数的函数转化为只接收较少参数的函数,主要目的是降低函数的通用性,提高函数的适用性。

举个例子,假设我们有一个可以计算三个数乘积的函数:

function multiple(a, b, c){
  if(typeof a === 'number' && 
          typeof b === 'number' && 
          typeof c === 'number'){
          
    return a * b * c;
  } else {
    return null;
  }
}

这个函数的通用性很好,只要传入三个数字,就可以计算他们的乘积。但是假如在某个模块中我需要计算圆形的周长(计算公式为C = 2 * π * r),显然2和π都是固定的数字,如果每次计算周长时都需要传入,其实并没有必要。但是我们又不想重写一个专门计算周长的函数(因为实际中的函数可能很复杂,我们必须考虑如何复用已有的函数),所以我们可以像下面这样把multiple包装一下:

function createComputeC(func, num1, num2){
  return function(radius){
    return func(num1, num2, radius);
  }
}
//调用该函数,传入通用函数multiple和两个固定参数,
//得到一个专门用于计算圆周长的函数
var computeC = createComputeC(multiple, 2, Math.PI);

//计算半径为2的圆的周长
computeC(2);

createComputeC接收原始的函数multiple和两个固定参数,返回了一个专门用于计算圆的周长的函数。于是现在我们在任何地方只需要调用computeC,传入一个半径,立即就可以计算出圆的周长。

想象一下,假如这是一个可以接收七八个参数的通用函数,而在某种用途中只需要传入一两个参数,使用上面的方法生成一个专用函数可以大大减轻我们的负担。

不过偏函数的价值并不限于此。在Vue 2.6的编译器源码中有一种更典型的用法,那就是跨平台代码的解耦。我们知道,Vue可以在浏览器、服务端和weex三个平台下运行,而同样的模板在不同平台下进行编译时行为并不完全一致。假如Vue为每个平台都单独维护一个编译器,由于三者的核心逻辑相差不大,这三个编译器将存在大量重复的代码。如果代码发生变更,整个项目将几乎无法维护。

为了避免这种情况,Vue为各个平台定义一个核心版本的编译器,它是一个通用函数,接收模板和一个配置对象options作为参数。但是在核心模块中并不传入这个配置对象,而是向外暴露这个通用函数。各个平台模块都会导入这个核心版本的编译器(也就是一个函数),向它注入当前平台的环境参数,从而得到该平台下一个完整的编译器。

这个模式下,核心版本中只需要维护一个通用函数,各个跨平台的模块引入这个函数后注入环境参数得到专用编译器,从而实现了核心实现与平台兼容的解耦,便于分别维护。

4. 函数柯里化

柯里化,英语:Currying,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回一个接受余下参数的新函数的技术。

假如一个函数接收三个参数,那么将它柯里化后,将得到一个只接受第一个参数的函数,并且这个函数的返回值是一个可以接收剩余两个参数的函数,这是柯里化最简单的例子。

举个例子说明,仍以上面的multiple函数为例:

function multiple(a, b, c){
  return a * b * c;
}

function curringMultiple(a){
  return function(b, c){
    return a * b * c;
  }
}

乍一看上去和偏函数非常相似。实际上这个函数本身就是一个偏函数,但是它比一般的偏函数要更严格。它严格规定这个函数必须只能接收原来的第一个参数,剩余的参数需要交给返回的函数来处理(不过JavaScript中所说的柯里化往往没有这么严格)。把上面的multiple转化成curringMultiple的过程就称为函数的柯里化。

经过上述修改,你可以这样调用柯里化后的函数:

curringMultiple(1)(2, 3);

内部返回的函数还可以进一步柯里化:

function deepCurring(a){
  return function(b){
    return function(c){
      return a * b * c;
    }
  }
}

现在你可以这样调用函数:

deepCurring(1)(2)(3);

也可以不一次得到最后结果:

var res1 = deepCurring(1);

...   //执行某些其他操作
var res2 = res1(2);

...   //执行某些其他操作
var res = res2(3);

console.log(res);  //输出6

上面的例子是通过直接修改原来函数的实现来进行柯里化的,但不一定要这样做(而且也很少这样做)。大多数情况下,我们会封装一个函数对原函数进行柯里化,如下面的例子:

//用于将一个函数柯里化的函数
function curry(fn, args) {
    var length = fn.length;
    var args = args || [];
    return function(){
        newArgs = args.concat(Array.prototype.slice.call(arguments));
        if(newArgs.length < length){
            return curry.call(this,fn,newArgs);
        }else{
            return fn.apply(this,newArgs);
        }
    }
}
//被用于柯里化的测试函数
function multiFn(a, b, c) {
    return a * b * c;
}

var multi = curry(multiFn); //执行柯里化
//下面的调用都可以返回正确的结果
multi(1)(2)(3);
multi(1, 2)(3);
multi(1, 2, 3);
multi()(1)(2, 3);

var multi2 = curry(multiFn, 1);
multi2(2, 3);
...

这个函数接收一个需要柯里化的函数作为参数,同时允许传入若干个参数,然后返回一个新的函数,返回的函数可以接收剩余的一个或多个参数。这样实际上每次都可以只传入任意多个参数,剩余的参数可以等到合适的时候再传入。我们可以看到,一个多参数的函数经过这样的改造,传参的过程将变得极其灵活(实际上这里不能算严格的柯里化,因为改造后的结果每次都可以接收不止一个参数。柯里化之所以要求如此严格,是因为它在函数式编程中非常有利于函数分析,但是在这里,不完全严格的实现更加灵活)。

柯里化有以下三个用途:

  1. 参数复用
  2. 提前确认
  3. 延迟执行

函数柯里化后,某些参数会被预先传入参数,之后不需要重新传,类似于偏函数的参数固定,这称为参数复用。

对于提前确认,我们举一个封装监听器的例子:

var on = function(element, event, handler){
  if (document.addEventListener) {
        if (element && event && handler) {
            element.addEventListener(event, handler, false);
        }
    } else {
        if (element && event && handler) {
            element.attachEvent('on' + event, handler);
        }
    }
}

我们封装了一个on方法,它可以兼容addEventListener和attachEvent两个原生的绑定监听器的方法,这样的需求在jQuery这样的框架中很常见。但是这里的问题是,每次调用on来绑定监听器,函数都需要判断document.addEventListener是否存在。这实际上没有必要,因为在同一个环境下,这样的检查只进行一次就可以了。于是上面的函数可以改造成下面的样子:

var on = (function() {
    if (document.addEventListener) {
        return function(element, event, handler) {
            if (element && event && handler) {
                element.addEventListener(event, handler, false);
            }
        };
    } else {
        return function(element, event, handler) {
            if (element && event && handler) {
                element.attachEvent('on' + event, handler);
            }
        };
    }
})();

之前的逻辑被封装在了一个匿名函数内,它会在框架第一次被加载时执行,然后根据document.addEventListener是否存在返回不同的两个函数,前一个是document.addEventListener存在的版本,后一个则是它不存在的版本。相当于在生成on函数之前,我们提前判断了document.addEventListener是否存在,根据判断结果返回不同环境下的on函数,避免了调用on时进行判断的性能损耗。

表面上看这个函数并不是原来的on函数柯里化的结果,但它可以看做另一个版本的on函数的柯里化结果:

var addExist = !!document.addEventListener;
var on = function(addExist, element, event, handler){
  if(addExist){
    ...
  } else {
    ...
  }
}

所以柯里化的运用还是需要一定的技巧的。

关于第三个好处,延迟执行,在我看来是JavaScript灵活性的一种体现。在调用一个函数时,你可以在任何时候传入任意数量的参数,而不是每次都必须传入所有参数并立即执行。

5. 闭包

在大多数情况下,闭包都是通过返回一个函数来实现的,所以很显然,它是高阶函数的一种应用场景。我们来看一个例子:

var multi = (function(){
  var a = 1;
  var b = 2;
  return function(c){
    return a * b * c;
  }
})();

multiple(3);

上述( function(){} )()的写法是在定义并执行一个匿名函数。括号里的函数没有名字,而且定义完马上被执行。我们知道,在一个函数外部是无法访问函数内部定义的任何变量和函数的。所以我们没有办法直接访问这个函数里定义的变量a和b。

但是如果返回了一个函数(也可以是一个拥有方法的对象,或者更复杂的对象),问题就不是这样了。因为作用域链的存在,这个函数将可以访问这个匿名函数内部的变量(因为被返回的函数是在这个匿名函数内部定义的),也就是说,现在只有通过返回的函数可以访问a和b了。

所以借助闭包,我们得到了一块内存,这块内存的变量只能被返回的函数或对象访问,不能通过其他方式访问。这样就实现了对变量的保护。

总结

以上就是关于JavaScript的函数式特性的介绍,主要围绕着高阶函数展开,希望大家对函数式编程有一个初步的了解。偏函数、柯里化以及闭包这些概念在JavaScript中都非常的重要,它们也是各大框架经常用到的技巧。

有一点顺带提一下,React中的高阶组件,其实也是高阶函数的一种扩展。React官网中这样介绍高阶组件:高阶组件就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件。如果把组件视为一个函数,那高阶组件就可以称为高阶函数了(如果传入的是函数组件,那它本身就是高阶函数)。

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/102508698
今日推荐