函数式编程

Functional Programming是一种编程范式,是一种构建计算机程序状态结构和元素的风格,它把计算看做是对数学函数的运算,避免了状态的变化和数据的可变。

在函数式编程的世界中,主要解决的是计算。函数是函数式编程中的基础元素,可以完成几乎所有的操作。在命令式编程中,使用各种变量,反观函数式编程,使用函数替代变量进。

对比命令式编程:详细的命令机器怎么取处理一件事情以达到想要的结果。

可以通过下面的例子感受下:

// 数组每个元素加一
let arr = [1,2,3,4];
// 命令式编程
for(var i=0;i<arr.length;i++){
    arr[i]=arr[i]+1
}
console.log(arr)

但是这样编程比较死板,无法代码重用

那我们来改造下:

let arrAddOne =(arr)=>{
    let temp=[]
    for(let i =0;i<arr.length;i++){
        temp.push(arr[i]+1)
    }
    return temp;
}

这里我们封装为一个函数,但如果我们要修改为数组每一项增加2呢?你可能会想到加一个参数不就可以了?

let arrAddNum =(arr,num)=>{
    let temp=[]
    for(let i =0;i<arr.length;i++){
        temp.push(arr[i]+num)
    }
    return temp;
}

那要是改成数组每一项都乘以5呢?再去扩充一个参数么?我们继续改造~

let handlerArr = (arr,fn,data)=>{
    let res =[];
    for(let i =0;i<arr.length;i++){
        res.push(fn(arr[i],data))
    }
    return res;
}
let add = (item,num) => item + num;
let sub = (item,num) => item - num;
let multiply = (item,mum) => item * num;
let divide = (item,num) => item / num;
let arr=[1,2,3,4]
//加5
handlerArr(arr,add,5);
函数式编程的核心:将程序分解为一些更可重用、更可靠且更易于理解的部分,然后将他们组合起来,形成一个更易推理的程序成体。

也就是将复杂函数转换为简单的函数,将一系列过程尽量写成函数的嵌套调用形式。

张三丰:无忌,你学会了吗?

张无忌:我全忘了。

张三丰:那你上吧。

到这里,只需要记得我们的初衷:为了程序的扩展性、可维护性。

特性

纯函数

含义:如果函数的调用参数相同,则永远返回相同的结果。它不依赖程序执行期间函数外部任何状态或数据的变化,必须只依赖于其输入参数

非纯函数例子:

let discount =0.8;//折扣
const calculatePrice = price =>price *  discount;
let price = calculatePrice(200); //160
discount = 0.9; //修改后的数据
price = calculatePrice(200); //180

在这个例子中函数calculatePrice 计算过程中,受外部参数的影响,无法保证函数相同输入的情况下得到相同的输出。

无函数副作用

含义:当调用函数时,除了返回函数值之外,还会对调用函数产生附加影响(修改全局变量、修改参数),会降低程序的可读性,并给程序带来难易排查的错误。

比如:

  • 修改某个变量
  • 修改数据结构
  • 对外界某个变量设置字段
  • 抛出错误信息

思考一下代码:

let a = 5;
let foo = ()=>a=a*10
foo();

在本例中,函数中对全局变量a进行了修改。

在Javascript的api中也存在此类现象:

let arr = [1,2,3,4,5];
arr.slice(1,3); //纯函数 返回[2,3] 未修改原数组
arr.splice(1,3); //非纯函数 返回 [2,3,4] 原数组发生了变化

在开发中,我们要尽可能的减少函数副作用。可以通过以下方式保证函数的无副作用:

  1. 函数入口使用参数运算,而不修改它
  2. 函数内不修改函数外的变量
  3. 运算结果通过函数返回给外部。
  4. 避免随机性(Math.random)和不确定性(Date.now
  5. 保持幂等,即每一次调用 f(x) 的结果和第一次调用 f(x) 的结果没有任何改变。

不变性

可变性:指一个变量创建以后可以任意修改

不可变性:指一个变量,一旦被创建,就永远不会发生改变。不可变性是函数式编程的核心概念。

let data = {count:1};
let foo = (data) => {
    data.count =3
}
console.log(data.count);//1
foo();
console.log(data.count);//3

在函数中,调用foo后修改了的data的属性

为了保证程序的可靠性和稳定性,我们可以通过深拷贝来达到数据的不可变性

let data = {count:1};
let foo = (data) => {
    // 扩展运算符实现的深拷贝只能针对对象或数组的第一层
    // 深层对象可以使用 JSON.parse(JSON.stringify(data))
    let temp = {...data};
    temp.count =3
    return temp;
}
console.log(data.count);//1
console.log(foo().count);//3
console.log(data.count);//1

原则:通过把变量当作不可变的变量来避免副作用,即使其本身是可变的

可以通过以下方式实现:

  • 冻结对象 Object.freeze(obj)
  • const关键字
  • 深拷贝
  • Array.concat、Array.map、Array.filter、reduce

优点

单元测试

严格函数式编程的每一个符号都是对直接量或者表达式结果的引用,不会产生副作用。因为从未在某个地方修改过值,也没有函数修改过在其作用域之外的变量。影响其返回值的就是函数的参数。

对被测试程序中的每个函数,你只需关注其参数,而不必考虑函数调用顺序,不用谨慎地设置外部状态。所有要做的就是传递代表了边际情况的参数。

调试

因为函数式程序的 bug 不依赖于之前运行过得不相干的代码,你遇到的问题就总是可以重现现。在命令式程序中,bug 时隐时现,因为在那里函数的功能依赖与其他函数的副作用,你可能会在和 bug 的产生无关的方向探寻很久,毫无收获。函数式程序就不是这样——如果一个函数的结果是错误的,那么无论之前你还执行过什么,这个函数总是返回相同的错误结果。

调试函数式程序时检查堆栈上函数的参数和返回值,只要发现一个不尽合理的结果就进入那个函数然后一步步跟踪下去,重复这一个过程,就能发现bug。

并发执行

编译器可以对函数式程序进行分析并找到适合并行执行的函数。

扩展

高阶函数

高阶函数,就是把函数当参数,把传入的函数做一个封装,然后返回这个封装函数,达到更高程度的抽象。

function a(arg,b) {
    return b(arg);
}

递归

递归是最有名的函数式编程技术,指函数调用自身并且该调用执行相同的操作,并且此循环一直进行到满足基本条件并且调用循环结束为止

针对需要多次重复调用时,使用递归可以减少代码量。可用来替换反复循环(但是使用递归会导致代码可读性差)

例如对1…100进行求和

// 命令式编程
let total=()=>{
    let temp=0;
    for(let i=1;i<=100;i++){
        temp+=i
    }
    return temp;
}
// 递归
let total2=(num)=>{
   // 函数调用应该放在最后一步去执行,都必须return 
   // 函数执行完毕后不在需要当前的堆栈帧了 
   return num!=1? num + total2(num - 1):num
}

注意以下事项,防止RangeError异常。

  • 递归必须要有结束条件
  • 尽量减少相互递归(在一个递归循环中,出现两个及以上的函数相互调用),

闭包

最大的特点是不需要通过传递变量(符号)的方式就可以从内层直接访问外层的环境,这为多重嵌套下的函数式程序带来了极大的便利性,下面是一个累加器例子:

let add=function (){
    let count = 0
    return function (){
        return count++;
    }
}()
add();//1
add();//2
add();//3

在这个例子中我们通过闭包来保护局部变量

柯里化

柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。

// 非函数柯里化
var add = function (x,y) {
    return x+y;
}
add(3,4) //7
// 函数柯里化
let add = function (x) {
    //**返回函数**
    return function (y) {
        return x+y;
    }
}
add(3)(4); //7
add(3,4);//7

如果实参个数等于形参个数,返回函数执行结果,否者,返回一个柯里化函数。

惰性求值

Lazy evaluation,也称作call-by-need,表示函数执行的分支只会在函数第一次调用的时候执行,通过避免不必要的求值达到提升性能。

应用情景在于当我们遇到一个需要判断场景去调用不同的方法时,避免重复进入函数内的if判断,也就是说if判断只进行一次,之后函数就会被分支里的代码替换掉。

let useSuperpower =(power)=> {
  if(power==='Shapeshifting'){
    useSuperpower=function (){
      console.log('你使用了变形!')
    }
  }else if(power==='Teleport'){
    useSuperpower=function (){
      console.log('你使用了瞬移!')
    }
  }else{
    useSuperpower=function (){
      console.log('你除了有钱什么都没有了..')
    }
  }
  return useSuperpower;
};
useSuperpower('Shapeshifting')();//你使用了变形!
useSuperpower('Teleport');//你使用了变形!
useSuperpower();//你使用了变形!

useSuperpower('Teleport')();//useSuperpower(...) is not a function

在这个例子中,函数useSuperpower第一次运行后,函数已被替换,相当于第一次运行后,才确定函数的功能,可以提高后续调用的效率。

其实际应用场景有如下:

  • 频繁调用
  • 模式固定

绑定监听事件需要区分不同浏览器,只需要判断一次当前浏览器就好。

var addEvent = (type, element, fun) => {
  if(!element) throw Error(`元素不存在`)
  if (element.addEventListener) {
    addEvent = (type, element, fun) => element.addEventListener(type, fun,
      false)

  } else if (element.attachEvent) {
    addEvent = (type, element, fun) => element.attachEvent('on' + type, fun)

  } else {
    addEvent = (type, element, fun) => element['on' + type] = fun

  }
  return addEvent(type, element, fun)
};
var ele=document.getElementById('test');

addEvent('click',ele,()=>{
  this.style.backgroundColor = "red";
})

Continuation Pass Style

参考:https://cgi.soic.indiana.edu/...

function a(x,cb){return cb(x)}通过 callback 来返回函数的执行结果,叫做 CPS(continuation-passing style)

(f (g (h i) j) k) 对于这个表达式,(h i)要在(g (h i))前先执行,那么 (g (h i))就是(h i)continuation (延续,也就是程序剩下的部分)

个人理解:把函数调用完之后接下来要执行的代码通过闭包包裹并作为函数参数调用要执行的函数。continuation 就是函数调用栈中的下一个地址的信息。

var add=(a,b)=>a+b;
var square =i=>i*i
var i = add(5, 10);//15
var j = square(i);//225

square依赖于add执行成功并返回结果。

如果我们使用CPS改写:

var add=(a,b,cb)=>{
  return cb(a+b)
}
var j=add(5,10,square);//225

可以借助Ajax理解,向服务器发送异步请求,在callback中处理响应。这不就是我们的回调地狱么~但是我们有了async/await,省去了我们显示传递callback,底层实现就是通过CPS变换将普通函数转换成一个CPS的函数。

模式匹配

ES6新增的解构就是简单的模式匹配实现。只要等号两边的模式相同,左边的变量就会被赋予对应的值

let [foo, [[bar], baz]] = [1, [[2], 3]];
console.log(foo,bar,baz);//1,2,3

https://ponyfoo.com/articles/...

函数组合

Composition,将两个或两个以上的函数进行组合,返回一个新函数。核心思想是专注于函数执行过程,隔离数据的影响。

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

f(g(x))表示g先于f执行,更符合程序逻辑表达,可阅读性高。

我们来写一个例子感受下:从数组中取出最后一个元素

//命令式编程
let getLast = function (arr) {
  return arr.reverse()[0];
};
//函数组合风格
let compose = (f, g) => (x => f(g(x)));
let head=arr=>arr[0]
let reverse=arr=>arr.reduce(function(acc, arr){ return [arr].concat(acc); }, []);
//1.对数组翻转
//2.取第一位元素
let last=compose(head,reverse);
let arr=[1,2,3,4,5];
console.log(last(arr));//5

扩展学习:https://segmentfault.com/a/11...

Point Free

个人理解是,省略不必要的参数

先看看代码:

//命令式编程
let getWord = function (word) {
  return word.toLowerCase().replace(/\s+/ig, '_');
};
//point free 风格
// pipe 组合函数(从左到右调用,上)
const pipe = (...args) => x=> args.reduce((res, fn)=> fn(res), x)
//分过程转为纯函数
// 自定义函数封装
let toUpperCase = word => word.toUpperCase()
// rambda.js
let replace = (pattern, replacer, str) => {
  if (replacer === undefined) {
    return (_replacer, _str) => replace(pattern, _replacer, _str)
  } else if (str === undefined) {
    return _str => replace(pattern, replacer, _str)
  }
  return str.replace(pattern, replacer)
}
// pointfree
let snakeCase = pipe(replace(/\s+/ig,'_'), toUpperCase)
console.log(snakeCase(test));//_HELLO_WORLD_!
console.log(test);// hello world !

先封装纯函数,利用组合函数,能够帮助我们减少不必要的命名,让代码保持简洁和通用。

张三丰:无忌,你学会了吗?

张无忌:我全忘了。

张三丰:那你上吧。

参考:

https://github.com/getify/Fun... 《JS轻量化函数式编程》

https://llh911001.gitbooks.io... 《JS 函数式编程指南》

https://github.com/selfrefact... Javascript 函数式编程库


王大山
28 声望2 粉丝