在你身边你左右 --函数式编程别烦恼

Posted 奇舞精选

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了在你身边你左右 --函数式编程别烦恼相关的知识,希望对你有一定的参考价值。

编者按:本文由掘金专栏作者17点授权奇舞周刊转载。来一起学习吧!

曾经的你是不是总在工作和学习过程中听到函数式编程(FP)。但学到函子的时候总是一头雾水。本文是我在函数式编程学习过程中,总结的笔记,也分享给想学函数式编程的同学。

在学之前,你先问自己几个问题,或者当作一场面试,看看下面的这些问题,你该怎么回答?

  • 你能说出对javascript工程师比较重要的两种编程范式吗?

  • 什么是函数式编程?

  • 函数式编程和面向对象各有什么优点和不足呢?

  • 你了解闭包吗?你经常在那些地方使用?闭包和柯里化有什么关系?

  • 如果我们想封装一个像underscorede的防抖的函数该怎么实现?

  • 你怎么理解函子的概念?Monad函子又有什么作用?

  • 下面这段代码的运行结果是什么?

var Container = function(x) { this.__value = x; }

Container.of = x => new Container(x);


Container.prototype.map = function(f){

  console.log(f)

  return Container.of(f(this.__value))


}


Container.of(3).map(x=>x+1).map(=> 'Result is ' + x);

console.log(Container.of(3).map(x=>x+1).map(=> 'Result is ' + x))

现在就让我们带着问题去学习吧。文章的最后,我们再次总结这些问题的答案。

1.1 函数式编程(FP)思想

面向对象(OOP)可以理解为是对数据的抽象,比如把一个人抽象成一个Object,关注的是数据。 函数式编程是一种过程抽象的思维,就是对当前的动作去进行抽象,关注的是动作。

举个例子:如果一个数a=1 ,我们希望执行+3(f函数),然后再*5(g函数),最后得到结果result是20


数据抽象,我们关注的是这个数据:a=1 经过f处理得到 a=4 , 再经过g处理得到 a = 20


过程抽象,我们关注的是过程:a要执行两个f,g两操作,先将fg合并成一个K操作,然后a直接执行K,得到 a=20

问题:f和g合并成了K,那么可以合并的函数需要符合什么条件呢?下面就讲到了纯函数的这个概念。

1.2 纯函数

定义:一个函数如果输入参数确定,输出结果是唯一确定的,那么他就是纯函数。
特点:无状态,无副作用,无关时序,幂等(无论调用多少次,结果相同)

下面哪些是纯函数 ?

let arr = [1,2,3];


arr.slice(0,3); //是纯函数


arr.splice(0,3); //不是纯函数,对外有影响


function add(x,y){ // 是纯函数

  return x + y // 无状态,无副作用,无关时序,幂等

} // 输入参数确定,输出结果是唯一确定


let count = 0; //不是纯函数

function addCount(){ //输出不确定

  count++ // 有副作用

}


function random(min,max){ // 不是纯函数

  return Math.floor(Math.radom() * ( max - min)) + min // 输出不确定

} // 但注意它没有副作用


function setColor(el,color){ //不是纯函数

  el.style.color = color ; //直接操作了DOM,对外有副作用

}

是不是很简单,接下来我们加一个需求?
如果最后一个函数,你希望批量去操作一组li并且还有许多这样的需求要改,写一个公共函数?

function change (fn , els , color){

    Array.from(els).map((item)=>(fn(item,color)))

}

change(setColor,oLi,"blue")

那么问题来了这个函数是纯函数吗?

首先无论输入什么,输出都是undefined,接下来我们分析一下对外面有没有影响,我们发现,在函数里并没有直接的影响,但是调用的setColor对外面产生了影响。那么change到底算不算纯函数呢?

答案是当然不算,这里我们强调一点,纯函数的依赖必须是无影响的,也就是说,在内部引用的函数也不能对外造成影响。

问题:那么我们有没有什么办法,把这个函数提纯呢?

1.3 柯里化(curry)

定义:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

function add(x, y) {

    return x + y;

}

add(1, 2)


******* 柯里化之后 *************


function addX(y) {

    return function (x) { return x + y; };

}

var newAdd = addX(2)

newAdd (1)

现在我们回过头来看上一节的问题?
如果我们不让setColor在change函数里去执行,那么change不就是纯函数了吗?

function change (fn , els , color){

    Array.from(els).map((item)=>(fn(item,color)))

}

change(setColor,oLi,"blue")


****** 柯里化之后 *************


function change(fn){

    return function(els,color){

       Array.from(els).map((item)=>(fn(item,color)))

    }

}

var newSetColor = change(setColor);

newSetColor(oLi,"blue")

  • 我们先分析柯里化(curry)过程。在之前change函数中fn , els , color三个参数,每次调用的时候我们都希望参数fn值是 setColor,因为我们想把不同的颜色給到不同的DOM上。我们的最外层的参数选择了fn,这样返回的函数就不用再输入fn值啦。

  • 接下来我们分析提纯的这个过程,改写后无论fn输入是什么,都return出唯一确定的函数,并且在change这个函数中,只执行了return这个语句,setColor函数并未在change上执行,所以change对外也不产生影响。显然change这时候就是一个纯函数。

  • 最后如果我们抛弃柯里化的概念,这里就是一个最典型的闭包用法而已。而change函数的意义就是我们可以通过它把一类setColor函数批量去改成像newSetColor这样符合新需求的函数。

上面那个例子是直接重写了change函数,能不能直接在原来change的基础上通过一个函数改成 newSetColor呢?

function change (fn , els , color){

    Array.from(els).map((item)=>(fn(item,color)))

}

change(setColor,oLi,"blue")


//******* 通过一个curry函数*************


var changeCurry = curry(change);

var newSetColor = changeCurry(setColor);

newSetColor(oLi,"blue")

哇!真的有这种函数吗?当然作为帮助函数(helper function),lodash 或 ramda都有啊。我们在深入的系列的课程中会动(chao)手(xi)写一个。

问题:处理上一个问题时,我们将一个函数作为参数传到另一个函数中去处理,这好像在函数式编程中很常见,他们有什么规律吗?

1.4 高阶函数

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

很显然上一节用传入fn的change函数就是一个高阶函数,显然它是一个纯函数,对外没有副作用。可能这么讲并不能让你真正去理解高阶函数,那么我就举几个例子!

1.4.1 等价函数

定义 :调用函数本身的地方都可以叫等价函数;

function __equal__(fn){

  return function(...args){

      return fn.apply(this,args);

  }

}

//第一种

function add(x,y){

    return x + y

}

var addnew1 = __equal__(add);

console.log(add(1,2));

console.log(addnew1(1,2));

//第二种

let obj = {

  x : 1,

  y : 2,

  add : function (){

    console.log(this)

    return this.+ this.y

  }

}

var addnew2 = __equal__(obj.add);

console.log( obj.add() ) ; //3

console.log( addnew2.call(obj)); //3

第一种不考虑this

  • equal(add):让等价(equal)函数传入原始函数形成闭包,返回一个新的函数addnew1

  • addnew1(1,2):addnew1中传入参数,在fn中调用,fn变量指向原始函数

第二种考虑this

  • addnew2.call(obj): 让equal函数返回的addnew2函数在obj的环境中执行,也就是fn.apply(this,args);中的父级函数中this,指向obj

  • fn.apply(this,args)中,this是一个变量,继承父级, 父级指向obj,所以在obj的环境中调用fn

  • fn是闭包形成指向obj.add

好了,看懂代码后,我们发现,这好像和直接把函数赋值给一个变量没啥区别,那么等价函数有什么好处呢?

等价函数的拦截和监控:

function __watch__(fn){

  //偷偷干点啥

  return function(...args){

    //偷偷干点啥

    let ret = fn.apply(this,args);

    //偷偷干点啥 return ret

  }

}

我们知道,上面本质就是等价函数,fn执行结果没有任务问题。但是可以在执行前后,偷偷做点事情,比如consle.log("我执行啦")。

问题:等价函数可以用于拦截和监控,那有什么具体的例子吗?

1.4.2 节流(throtle)函数

前端开发中会遇到一些频繁的事件触发,为了解决这个问题,一般有两种解决方案:

function throttle(fn,wait){

  var timer;

  return function(...args){

    if(!timer){

      timer = setTimeout(()=>timer=null , wait);

      console.log(timer)

      return fn.apply(this,args)

    }

  }

}


const fn = function(){

    console.log("btn clicked")

}

const btn = document.getElementById('btn');

btn.onclick = throttle(fn , 5000);

分析代码

  • 首先我们定义了一个timer

  • 当timer不存在的时候,执行if判断里函数

  • setTimeout给timer 赋一个id值,fn也执行

  • 如果继续点击,timer存在,if判断里函数不执行

  • 当时间到时,setTimeout的回调函数清空timer,此时再去执行if判断里函数

所以,我们通过对等价函数监控和拦截很好的实现了节流(throtle)函数。而对函数fn执行的结果丝毫没有影响。这里给大家留一个作业,既然我们实现了节流函数,那么你能不能根据同样的原理写出防抖函数呢?

问题:哦,像这样节流函数,在我平时的项目中直接写就好了,你封装成这样一个函数似乎还麻烦了呢?

1.5 命令式与声明式

在平时,如果我们不借助方法函数去实现节流函数,我们可能会直接这么去实现节流函数。

var timer;

btn.onclick = function(){

  if(!timer){

    timer = setTimeout(()=>timer=null , 5000);

    console.log("btn clicked")

  }

}

那么与之前的高阶函数有什么区别呢?

很显然,在下面的这例子中,我们每次在需要做节流的时候,我们每次都需要这样重新写一次代码。告诉 程序如何执行。而上面的高阶函数的例子,我们定义好了一个功能函数之后,我们只需要告诉程序,你要做 什么就可以啦。

  • 命令式 : 上面的例子就是命令式

  • 声明式 : 高阶函数的例子就是声明式

那下面大家看看,如果遍历一个数组,打印出每个数组中的元素,如何用两种方法实现呢?

//命令式

var array = [1,2,3];

for (i=0; i<array.length;i++){

console.log(array[i])

}


//声明式

array.forEach((i) => console.log(i))

看到forEach是不是很熟悉,原来我们早就在大量使用函数式编程啦。

这里我们可以先停下来从头回顾一下,函数式编程。

  • 函数式编程,更关注的是动作,比如我们定义的节流函数,就是把节流的这个动作抽象出来。

  • 所以这样的函数必须要输入输出确定且对外界没有,我们把这样的函数叫纯函数

  • 对于不纯的函数提纯的过程中,用到了柯里化的方法。

  • 我们柯里化过程中,我们传进去的参数恰恰是一个函数,返回的也是一个函数,这就叫高阶函数。

  • 高阶函数往往能抽象写出像节流这样的功能函数。

  • 声明式就是在使用这些功能函数

问题:现在我们对函数编程有了初步的了解,但还并没有感受到它的厉害,还记得我们之前讲到的纯函数可以合并吗?下一节,我们就去实现它

1.6 组合(compose)

function double(x) {

    return x * 2

}

function add5(x) {

    return x + 5

}

double(add5(1))

上面的代码我们实现的是完成了两个动作,不过我们觉得这样写double(add5(x)),不是很舒服。 换一个角度思考,我们是不是可以把函数合并在一起。 我们定义了一个compose函数

var compose = function(f, g) {

  return function(x) {

      return f(g(x));

  };

};

有了compose这个函数,显然我们可以把double和add5合并到一起

var numDeal = compose(double,add5)

numDeal(1)

  • 首先我们知道compose合并的double,add5是从右往左执行的

  • 所以1先执行了加5,在完成了乘2

那么这时候就有几个问题,

  • 这只使用与一个参数,如果是多个参数怎么办?有的同学已经想到了用柯里化

  • 还有这只是两个函数,如果是多个函数怎么办。知道reduce用法的同学,可能已经有了思路。

  • compose是从从右往左执行,我想左往右行不行?当然,他还有个专门的名字叫管道(pipe)函数

这三道题我们留作思考题。我们在深入的专题里会去实现的哈。

问题:现在我们想完成一些功能都需要去合并函数,而且合并的函数还会有一定顺序,我们能不能像JQ的链式调用那样去处理数据呢。

1.7 函子(Functor)

讲到函子,我们首先回到我们的问题上来。之前我们执行函数通常是下面这样。

function double(x) {

    return x * 2

}

function add5(x) {

    return x + 5

}


double(add5(1))

//或者

var a = add5(5)

double(a)

那现在我们想以数据为核心,一个动作一个动作去执行。

(5).add5().double()

显然,如果能这样执行函数的话,就舒服多啦。那么我们知道,这样的去调用要满足

  • (5)必须是一个引用类型,因为需要挂载方法。

  • 引用类型上要有可以调用的方法

所以我们试着去给他创建一个引用类型

class Num{

  constructor (value) {

    this.value = value ;

  }

  add5(){

    return this.value + 5

  }

  double(){

    return this.value * 2

  }

}

var num = new Num(5);

num.add5()

我们发现这个时候有一个问题,就是我们经过调用后,返回的就是一个值了,我们没有办法进行下一步处理。所以我们需要返回一个对象。

class Num{

  constructor (value) {

    this.value = value ;

  }

  add5 () {

    return new Num( this.value + 5)

  }

  double () {

    return new Num( this.value * 2)

  }

}


var num = new Num(2);

num.add5 ().double ()

  • 我们通过new Num ,创建了一个num 一样类型的实例

  • 把处理的值,作为参数传了进去从而改变了this.value的值

  • 我们把这个对象返了回去,可以继续调用方法去处理函数

我们发现,new Num( this.value + 5),中对this.value的处理,完全可以通过传进去一个函数去处理

并且在真实情况中,我们也不可能为每个实例都创建这样有不同方法的构造函数,它们需要一个统一的方法。

class Num{

  constructor (value) {

    this.value = value ;

  }

  map (fn) {

    return new Num(fn(this.value))

  }

}


var num = new Num(2);

num.map(add5).map(double)

我们创建了一个map的方法,把处理的函数fn传了进去。这样我们就完美的实现啦,我们设想的功能啦。

最后我们整理一下,这个函数。

class Functor{

  constructor (value) {

    this.value = value ;

  }

  map (fn) {

    return Functor.of(fn(this.value))

  }

}


Functor.of = function (val) {

  return new Functor(val);

}

Functor.of(5).map(add5).map(double)

  • 我们把原来的构造函数Num的名字改成了Functor

  • 我们给new Functor(val);封住了一个方法Functor.of

现在Functor.of(5).map(add5).map(double)去调用函数。有没有觉得很爽。

哈哈,更爽的是,你已经在不知不觉间把函子的概念学完啦。上面这个例子总的Functor就是函子。现在我们来总结一下,它有那些特点吧。

  • Functor是一个容器,它包含了值,就是this.value.(想一想你最开始的new Num(5))

  • Functor具有map方法。该方法将容器里面的每一个值,映射到另一个容器。(想一想你在里面是不是new Num(fn(this.value))

  • 函数式编程里面的运算,都是通过函子完成,即运算不直接针对值,而是针对这个值的容器----函子。(想一想你是不是没直接去操作值)

  • 函子本身具有对外接口(map方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。(说的就是你传进去那个函数把this.value给处理啦)

  • 函数式编程一般约定,函子有一个of方法,用来生成新的容器。(就是最后咱们整理了一下函数嘛)

嗯,这下明白什么是函子了吧。在初学函数编程时,一定不要太过于纠结概念。看到好多,教程上在讲 函子时全然不提JavaScript语法。用生硬的数学概念去解释。

我个人觉得书读百遍,其义自见。对于编程范式的概念理解也是一样的,你先知道它是什么。怎么用。 多写多练,自然就理解其中的含义啦。总抱着一堆概念看,是很难看懂的。

以上,函子(Functor)的解释过程,个人理解。也欢迎大家指正。

问题:我们实现了一个最通用的函子,现在别问问题,我们趁热打铁,再学一个函子

1.7.1 Maybe 函子

我们知道,在做字符串处理的时候,如果一个字符串是null, 那么对它进行toUpperCase(); 就会报错。

Functor.of(null).map(function (s) { return s.toUpperCase();

});

那么我们在Functor函子上去进行调用,同样也会报错。

那么我们有没有什么办法在函子里把空值过滤掉呢。

class Maybe{

  constructor (value) {

    this.value = value ;

  }

  map (fn) {

    return this.value ? Maybe.of(fn(this.value)) : Maybe.of(null);

  }

}

Maybe.of = function (val) {

  return new Maybe(val);

}

var a = Maybe.of(null).map(function (s) {

  return s.toUpperCase();

});

我们看到只需要把在中设置一个空值过滤,就可以完成这样一个Maybe函子。

所以各种不同类型的函子,会完成不同的功能。学到这,我们发现,每个函子并没有直接去操作需要处理的数据,也没有参与到处理数据的函数中来。

而是在这中间做了一些拦截和过滤。这和我们的高阶函数是不是有点像呢。所以你现在对函数式编程是不是有了更深的了解啦。

现在我们就用函数式编程做一个小练习: 我们有一个字符串‘li’,我们希望处理成大写的字符串,然后加载到id为text的div上

var str = 'li';

 Maybe.of(str).map(toUpperCase).map(html('text'))`

如果在有编写好的Maybe函子和两个功能函数的时候,我们只需要一行代码就可以搞定啦

那么下面看看,我们的依赖函数吧。

let $$ = id => Maybe.of(document.getElementById(id));

class Maybe{

  constructor(value){

    this.__value = value;

  }

  map(fn){

    return this.__value ? Maybe.of(fn(this.__value)) : Maybe.of(null);

  }

  static of(value){

    return new Maybe(value);

  }

}


let toUpperCase = str => str.toUpperCase();

let html = id => html => { $$(id).map(dom => { dom.innerHTML = html; }); };

我们来分析一下代码

  • 因为Maybe.of(document.getElementById(id)我们会经常用到,所以用双$封装了一下

  • 然后是一个很熟悉的Maybe函子,这里of用的Class的静态方法

  • toUpperCase是一个普通纯函数(es6如果不是很好的同学,可以用babel )编译成es5

  • html是一个高阶函数,我们先传入目标dom的id然后会返回一个函数将,字符串挂在到目标dom上

var html = function(id) {

  return function (html) {

    $$(id).map(function (dom) {

      dom.innerHTML = html;

    });

  };

};

大家再来想一个问题 Maybe.of(str).map(toUpperCase).map(html('text'))最后的值是什么呢?

我们发现最后没有处理的函数没有返回值,所以最后结果应该是 Maybe {__value: undefined}; 这里面给大家留一个问题,我们把字符串打印在div上之后想继续操作字符串该怎么办呢?

问题:在理解了函子这个概念之后,我们来学习本文最后一节内容。有没有很开心

1.8 Monad函子

Monad函子也是一个函子,其实很原理简单,只不过它的功能比较重要。那我们来看看它与其它的 有什么不同吧。

我们先来看这样一个例子,手敲在控制台打印一下。

var a = Maybe.of( Maybe.of( Maybe.of('str') ) )

console.log(a);

console.log(a.map(fn));

console.log(a.map(fn).map(fn));

function fn(e){ return e.value }

  • 我们有时候会遇到一种情况,需要处理的数据是 Maybe {value: Maybe}

  • 显然我们需要一层一层的解开。

  • 这样很麻烦,那么我们有没有什么办法得到里面的值呢

class Maybe{

  constructor (value) {

    this.value = value ;

  }

  map (fn) {

    return this.value ? Maybe.of(fn(this.value)) : Maybe.of(null);

  }

  join ( ) {

    return this.value;

  }

}


Maybe.of = function (val) {

  return new Maybe(val);

}

我们想取到里面的值,就把它用join方法返回来就好了啊。所以我给它加了一个join方法

var a = Maybe.of( Maybe.of('str') )

console.log(a.join().map(toUpperCase))

所以现在我们可以通过,join的方法一层一层得到里面的数据,并把它处理成大写

现在你肯定会好奇为什么会产生Maybe.of( Maybe.of('str')) 结构呢?

还记得html那个函数吗?我们之前留了一个问题,字符串打印在div上之后想继续操作字符串该怎么办呢?

很显然我们需要让这个函数有返回值。

let html = id => html => {

   return $$(id).map(dom => {

     dom.innerHTML = html;

     return html

   });

 };

分析一下代码。

  • 如果只在里面加 return html,外面函数并没有返回值

  • 如果只在外面加return,则取不到html

  • 所以只能里面外面都加

  • 这就出现了 Maybe.of( Maybe.of('LI') )

那么这时候我们想,既然我们在执行的时候就知道,它会有影响,那我能不能在执行的时候,就把这个应该 给消除呢。

class Maybe{

  constructor (value) {

    this.value = value ;

  }

  map (fn) {

    return this.value ? Maybe.of(fn(this.value)) : Maybe.of(null);

  }

  join ( ){

    return this.value;

  }

  chain(fn) {

    return this.map(fn).join();

  }

}

我们写了一个chain函数。首先它调用了一下map方法,执行结束后,在去掉一层嵌套的函子

所以在执行的时候,我们就可以这样去写。

Maybe.of(str).map(toUpperCase).chain(html('text'))`

这样返回的函数就是只有一层嵌套的函子啦。

学到这里我们已经把全部的函数式编程所涉及到概念都学习完啦。现在要是面试官拿这样一道题问题,答案是什么?是不是有点太简单啦。

var Container = function(x) {

  this.__value = x;

}

Container.of = x => new Container(x);


Container.prototype.map = function(f){

  console.log(f)

  return Container.of(f(this.__value))


}


Container.of(3).map(x=>x+1).map(=> 'Result is ' + x);

console.log(Container.of(3).map(x=>x+1).map(=> 'Result is ' + x))

但你会发现我们并没有具体纠结每一个概念上,而是更多的体现在可实现的代码上,而这些代码你也并不陌生。

哈哈,那你可能会问,我是不是学了假的函数式编程,并没有。因为我觉得函数式编程也是编程,最终都是要回归到日常项目的实践中。而应对不同难度的项目,所运用的知识当然也是不一样的,就好比造船,小船有小船的造法,邮轮有油轮的造法,航母有航母的造法。你没有必要把全部的造船知识点,逐一学完才开始动手。日常况且在工作中,你可能也并有真正的机会去造航母(比如写框架)。与其把大量的时间都花在理解那些概念上,不如先动手造一艘小船踏实。所以本文中大量淡化了不需要去立即学习的概念。

现在,当你置身在函数式编程的那片海中,看见泛起的一叶叶扁舟,是不是不再陌生了呢?

是不是在海角和天边,还划出一道美丽的曲线?

那么接下来我们会动手实践一个Underscore.js 的库。进一步深入每个细节去了解函数式编程。 学习更多的技巧。

最后本文是我学习函数式编程的笔记,写的时候经常自言自语,偶尔还安慰自己。如果有错的地方,欢迎大家批评指正。

文章最后总结的上面的答案是有的,不过现在还在我心中,等我有时间在写啊 啊 啊。。。。

关于奇舞周刊


客官请留步,给你推荐个活跃的开发者社区~掘金是面向程序员的的技术社区,从大厂技术分享到前端开发最佳实践,扫码下载掘金APP,来掘金你不会错过任何一个技术干货。


以上是关于在你身边你左右 --函数式编程别烦恼的主要内容,如果未能解决你的问题,请参考以下文章

分布式事务就在你身边,你却不去关爱他

漫步最优化三十九——Fletcher-Reeves法

漫步最优化三十九——Fletcher-Reeves法

留在我身边

8号选手Cocoa Touch组合|音乐是动听的陪伴 音乐常在你我身旁 这次就让我带给你好听的歌曲 陪在你左右

2016第49周二