JavaScript学习(九) —— 函数式编程

百科定义: 函数式编程(Functional Programming)是种编程范式,它将电脑运算视为函数的计算。函数编程语言最重要的基础是λ演算(lambda calculus),而且λ演算的函数可以接受函数当作输入(参数)和输出(返回值)。

写在前头:本人之前并没有过多了解过函数式编程,也很少发散思维和总结。直到最近开始写技术文章,写到函数式编程这个主题时,阅览了大量大神写的文章后才恍然,发现实战项目中很多模块用到了函数式编程。写这篇博文的过程也是自己系统学习这种编程范式的过程,用自己所学的知识尽力把函数式编程涉及到的知识点都说明白。

一、纯函数

要弄清楚函数式编程的具体实现和实际使用方法,需要先弄清楚纯函数的概念。我们一直说的函数式编程中的函数指的是数学中的函数,类似我们高中学过的关于自变量x的函数f(x)、g(x)还有复合函数f(gx)等概念。这样的数学函数可算是纯函数的一种。

纯函数的特性:

  • 对于相同的输入,永远会得到相同的输出
  • 没有任何可观察的副作用
  • 不依赖外部环境的状态。

JS中某些对数组的一些方法(函数)就有非纯的分别:

纯函数:

var arr = [1,2,3,4];
console.log(arr.slice(0,2));  // [1, 2]
console.log(arr);  	// [1, 2, 3, 4]

slice是纯函数,它没有改变原来的数组arr

非纯函数:

var arr = [1,2,3,4];
console.log(arr.splice(0,2));  // [1, 2]
console.log(arr);  	// [3, 4]

splice改变了原来的数组arr,是非纯的函数。

以上面两个例子对比:

  • 我们的目的是想通过调用一个函数后,获得一个截取原数组arr的结果,但并不想改变原数组arr。
  • 非纯函数splice随便就把外部变量或状态(原数组arr)修改,会导致很多预期之外的结果,这样的副作用不是我们所期望的。
  • 在函数式编程范式中,我们当然希望使用纯函数slice,它不会修改原数组,没有副作用。

看一个函数会依赖外部环境的状态的示例:

例1:

var price = 69.99;
function discount(){
  return price * .8;
}
console.log(discount());  // 55.992
console.log(price);  	// 69.99

例1的discount方法引用了外部状态price,如果修改了外部状态price,会轻松影响discount方法返回的值,对于大型应用程序会增强系统复杂性和维护的难度。

解决这个问题的方法是把价格当作参数传入函数:

例2:

var price = 69.99;
function discount(p){
  return p * .8;
}
console.log(discount(price));  // 55.992
console.log(price);  	// 69.99

例1例2虽然结果都一样,区别在于是否把价格当作参数传入,函数内部是否有形参接收。形参是按值传递,发生了一次复制行为,返回的结果不影响函数外的状态。例1属于非纯函数,例2是纯函数。

使用纯函数的目的:

本人认为纯函数可以理解为最小功能的单元,可比喻为拼插玩具(乐高)的基本单位。我们所实现的一些复杂需求就由这些纯函数组合而成。后面的函数组合将详细说明。

二、函数柯里化(Currying)

之前《闭包》中提到过柯里化。假设有两个函数A和B,当函数A作为函数B的返回值被缓存在一个变量中,函数A引用了函数B作用域中的变量,其展现形态就是一个闭包。这种特性可作为函数式编程的一种体现。

举例: 下面是一个计算折扣价格的例子:

// 缓存8折优惠后的价格
var discount80 = discount(.8);

// 缓存9折优惠后的价格
var discount90 = discount(.9);

// 优惠价格Currying
function discount(percent){ 
  return function(price){
    return price * percent;
  }
}
console.log(discount80(69.99));  // 55.992
console.log(discount90(69.99));  // 62.991

柯里化对于函数式编程的意义在于,它可以缓存调用函数先传一部分参数的结果,然后再调用时传入另一部分参数,第一次调用缓存可以理解为对某个最终结果的预加载。第二次调用缓存的函数引用得到最终结果。

三、函数组合

函数式编程的另一种体现是函数中的某个参数也是一个函数。写过很多年代码的人肯定听过函数是第一等公民这句话。它是指函数可以赋值给一个变量(函数表达式),也可以当成一个参数传递给另一个函数。还有在另一个函数体内被当作结果返回,就是刚才说的函数柯里化。

当一个函数被当成参数传递给另一个函数时,就如文章开头提到的高中数学的 f(g(x)) 这种形态。

用JS代码表示:

function fn(func){
  return func() + 20; 
}
console.log(fn(function(){ // 30
  return 10;
}));

这个例子无非是在一个函数体内调用了传进来的参数函数,没有太多意义。我们想介绍的是函数组合

下面是一个简单的函数组合实现,只接收两个参数的函数,从右到左顺序组合函数。

function compose(f, g) {
  return function(x) {
    return f(g(x));
  };
};

它是怎么用的:

function add10(num){
  return num + 10;
}
function add20(num){
  return num + 20;
}
var res = compose(add10, add20);
console.log(res(5)); // 35

add10add20 两个纯函数组合,并将返回函数缓存起来。

四、一个实际例子

介绍完纯函数函数柯里化函数组合,如果把这些结合起来能做什么?

现在有个这样的需求:有个折扣柯里化方法,有个保留价格小数位数的柯里化方法,将这两个方法组合计算出一个打了9折后保留2位小数的函数。随便代入一个价格,计算出最终结果。

// 缓存9折优惠后的价格
var discount90 = discount(.9);

// 缓存保留2位小数后的价格
var toFixed2 = toFixed(2);

// 优惠价格Currying
function discount(percent){ 
  return function(price){
    return price * percent;
  }
}

// 保留小数Currying
function toFixed(num){
  return function(price){
    return price.toFixed(num);
  }
}

// 函数组合
function compose(f, g) {
  return function(x) {
    return f(g(x));
  };
};

// 缓存一个打了9折且保留2位小数的组合函数
var final_price = compose(toFixed2,discount90);

// 调用得到最后结果
console.log(final_price(69.99)); // 62.99
  • 优惠价格函数 discount 和 保留小数位数 toFixed 两个方法用到了函数柯里化
  • 缓存两个柯里化的返回结果的方法 discount90toFixed2 是两个 纯函数
  • compose 组合了 discount90toFixed2 函数

五、一些通用函数库

underscoreramdalodash 等JS库都支持了函数式编程的范式,它们提供的API其实都大同小异,我们可以在项目中引入这些第三方库,写某些场景的业务代码时可适当使用函数式编程范式。

JS给数组实例提供了 reversesort 方法,但是这两个方法会改变原数组,它们是非纯函数,所以如果想先后调用这两个方法,需要先实现一个复制原数组的方法。

// 定义数组
var arr = [1,3,2];

// 定义升序排序函数
var diffAsc = function(a, b) { return a - b; };

// 缓存升序排序方法
var sortAsc = sort(diffAsc);

// 函数组合
function compose(f,g,h) { 
  return function() {
    return function(x) {
      return f(g(h(x)));
    };
  };
};

// 定义复制数组方法
function copy(arr){
  return [].concat(arr);
}

// 定义数组排序柯里化方法
function sort(type){
  return function(arr){
    return arr.sort(type);
  }
}

// 定义数组翻转方法
function reverse(arr){
  return arr.reverse();
}

// 缓存组合函数,注意后面还有个()
var res = compose(reverse,sortAsc,copy)();

console.log(res(arr)); // [3, 2, 1]
console.log(arr); // [1, 3, 2] 没有改变原数组

这里 compose 方法只是一种简单实现,它的扩张性不好,只固定三层嵌套函数。

好吧,我们直接看 ramda.js 是怎么实现的:

// 引入 ramda 依赖
var R = require('ramda');

// 定义数组
var arr = [1, 3, 2];

// 定义升序排序函数
var diffAsc = function(a, b) { return a - b; };

// 缓存组合了 R.reverse 和 R.sort 的函数
var res = R.compose(R.reverse,R.sort(diffAsc));

console.log(res(arr)); // [3, 2, 1]
console.log(arr); // [1, 3, 2] 没有改变原数组

注意:

  • R.compose 是从右往左执行函数组合。
  • R.reverseR.sort 是纯函数,它们复制了原数组,返回了一个新数组。
  • ramdajs 中很多方法类似 R.sort 这样。它们本身都是支持柯里化的,即 R.sort( diffAsc, arr )R.sort(diffAsc)(arr) 是等效的,所以在使用组合函数 R.compose 时,里面的参数可以这样传 R.sort(diffAsc)

最后总结:

函数式编程诞生的年头已经不短了,我在写业务代码实现一些比较复杂的功能时,不知不觉地使用了这种范式,但没有完全严格遵守规范(实现过程中还有很多不足),通过写这篇文章也能总结出自己的不足。

对于前端开发来说,JS能很强地支持函数式编程范式,我们无比幸运。充分掌握这个技能并把它用在对的场景是我们应该努力追寻的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值