这篇文章上次修改于 268 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

函数式编程之容器与函子

范畴与容器

  • 我们可以把”范畴”想象成是一个容器,里面包含两样东西。值(value)、值的变形关系,也就是函数。
  • 范畴论使用函数,表达范畴之间的关系。
  • 伴随着范畴论的发展,就发展出一整套函数的运算方法。这套方法起初只用于数学运算,后来有人将它在计算机上实现了,就变成了今天的”函数式编程"。
  • 函数式编程只是范畴论的数学运算方法,目的就是求值,不做其他事情,否则就无法满足函数运算法则了。

容器与函子

函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。

函子的概念

  • 函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
  • Functor(函子)遵守一些特定规则的容器类型。
  • Functor 是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。把东西装进一个容器,只留出一个接口 map 给容器外的函数,map 一个函数时,我们让容器自己来运行这个函数,这样容器就可以自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛掰的特性。
  • 任何具有map方法的数据结构,都可以当作函子的实现。

函子的代码实现

下面来实现一个简单的函子

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

//函数式编程一般约定,函子有一个of方法
Container.of = x => new Container(x);

//一般约定,函子的标志就是容器具有map方法。该方法将容器里面的每一个值,映射到另一个容器。
Container.prototype.map = function(f){
    return Container.of(f(this.__value))
}
Container.of(3)
    .map(x => x + 1) // ==> Container(4)
    .map(x => 'Result is ' + x); // ==> Container('Result is 4') 

函子的一般约定,含有value值、map方法、of方法,下面串串mapof方法。

of方法

of方法用来创建一个新的函子。

有上面可看出,我们在创建一个新的函子时,用到了new命令,这就像面向对象编程的方式,不像函数式编程。

所以我们需要一个方法来帮我们来实现,of方法是函子的一般约定,用来生成一个新的函子。

class Functor {
    constructor(val) {
        this.val = val;
    }
    Functor.of = function(val) {
        return new Functor(val);
    };

    ......
}

Functor.of(2); // ==> Functor {val: 3}

map方法

Functor是一个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被f处理过的(f(this.val))。

函数式编程里面的运算,都是通过函子完成,即运算不直接针对值,而是针对这个值的容器----函子。函子本身具有对外接口(map方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。

因此,学习函数式编程,实际上就是学习函子的各种运算。由于可以把运算方法封装在函子里面,所以又衍生出各种不同类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不同的函子,解决实际问题。

class Functor {
    constructor(val) {
        this.val = val;
    }
    map(f) {
        return new Functor(f(this.val));
    }
    static of(val) {
        return new Functor(val);
    }
}

Functor.of(3)
    .map(x => x + 1) // ==> Functor(4)
    .map(x => 'Result is ' + x) // ==> Functor("Result is 4") 
    .val;  // ==> "Result is 4"

概念扯了这么多,下面就上述例子来说说函子的运算过程:

  • of方法传一个参数3生成一个函子,该函子有个val值就为3,并向外暴露一个map接口,用来val值变形。
  • 然后调用第一个map调用,传了一个纯函数x => x + 1运算符,并将该运算符的函数调用,映射给了新创建的函子,这时,新函子的val值就是x => x + 1执行的结果,最后该函子同样向外暴露一个map方法。
  • 第二个map执行,同上一个一样,就是处理val变形的纯函数不一样。
  • 最后的运算结果可以通过.val获取,完成。

再来说说一些常用的函子

常用函子

Maybe函子

函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。

Maybe.of(null).map(s => s.toUpperCase()); // TypeError

null数据类型没有toUpperCase方法,自然报错。

Maybe函子能解决这个问题

var Maybe = function(x) {
    this.__value = x;
}
Maybe.of = function(x) {
    return new Maybe(x);
}
Maybe.prototype.map = function(f) {
    return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
}
Maybe.prototype.isNothing = function() {
    return (this.__value === null || this.__value === undefined);
} 

通过`isNothing判断当前函子的value是否存在,若存在就正常map,不存在就把null传给下一个函子的value`。

Either函子

条件运算if...else是最常见的运算之一,函数式编程里面,使用 Either 函子表达。Either 函子内部有两个值:左值(Left)和右值(Right),右值是正常情况下使用的值,左值是右值不存在时使用的默认值

class Either extends Functor {
    constructor(left, right) {
      this.left = left;
      this.right = right;
    }
    map(f) {
      return this.right ?
      Either.of(this.left, f(this.right)) :
      Either.of(f(this.left), this.right);
    }
    Either.of = function (left, right) {
      return new Either(left, right);
    };
}

·1、第一种情况,left、right为普通参数时:

var addOne = function (x) {
  return x + 1;
};

Either.of(5, 6).map(addOne); // Either(5, 7);

Either.of(1, null).map(addOne); // Either(2, null);

2、第二种情况,left, right为函子时:

var Left = function(x) {
  this.__value = x;
}
var Right = function(x) {
  this.__value = x;
}
Left.of = function(x) {
  return new Left(x);
}
Right.of = function(x) {
    return new Right(x);
}

// map实现不同
Left.prototype.map = function(f) {
  return this;
}
Right.prototype.map = function(f) {
  return Right.of(f(this.__value));
}

LeftRight 唯一的区别就在于 map 方法的实现,Right.map 的行为和我们之前提到的 map 函数一样。但是 Left.map 就很不同了:它不会对容器做任何事情,只是很简单地把这个容器拿进来又扔出去。这个特性意味着,Left 可以用来传递一个错误消息。

var getAge = user => user.age ? Right.of(user.age) : Left.of("ERROR!");

getAge({name: 'stark', age: '21'}).map(age => 'Age is ' + age);
// ==> Right('Age is 21')

getAge({name: 'stark'}).map(age => 'Age is ' + age);
// ==> Left('ERROR!') 

Left 可以让调用链中任意一环的错误立刻返回到调用链的尾部,这给我们错误处理带来了很大的方便,再也不用一层又一层的try/catch

AP(Applicative)函子

先看个需求:

有这个例子

function add(x) {
  return x + 1;
}

const A = Functor.of(1);
const B = Functor.of(add)

现在需要使用函子B的内部函数,来使用函子B内部的value值进行运算,这时就需要用到AP函子。

首先了解一点,凡是设置了ap方法的函子,就是AP函子。

下面就是一个Ap函子,注意一点ap方法接收的参数是一个函子,不是一个函数

class Ap extends Functor {
  ap(F) {
     return Ap.of(this.val(F.val));
  }
  
  of(f) {
    return new Ap(f);
  }
}

再来实现上面的例子:

Ap.of(add).ap(Functor.of(1)); // ==> AP(2)

AP还能实现对多参的函数实现函子的链式操作

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

Ap.of(add).ap(Maybe.of(2)).ap(Maybe.of(3));

Monad 函子

Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算
所需的函数,整个运算就会自动进行下去。Monad 让我们避开了嵌套地狱,可以轻松地进行深度嵌套的函数式编程,比如IO和其它异步任务。

Monad 函子作用就是总是返回⼀个单层的函⼦。它有⼀个flatMap⽅法,与map⽅法作⽤相同,唯⼀的区别是如果⽣成了⼀个嵌套函⼦,它会取出后者内部的值,保证返回的永远是⼀个单层的容器,不会出现嵌套的情况。

多个嵌套的函子

Functor.of(
  Functor.of(
    Functor.of(1)
  )
)

再来看看Monad 函子的实现


class Monad extends Functor {
  join() {
    return this.val;
  }
  flatMap(f) {
    return this.map(f).join();
  }
  map(f) {
    return Monad.of(f(this.val));
  }
}

它是怎么实现的呢?

要知道map返回的也是一个新的函子,接收的f方法会处理当前函子的value值。当f是一个函子时,就会形成函子嵌套,flatMap里的join方法保证了flatMap方法总是返回一个单层的函子。注意:join方法的this指向map新生成的函子,所以join执行后返回的就是f(this.val)函子

Monad 函子与IO

IO 跟前面那几个 Functor 不同的地方在于,它的 __value 是一个函数。它把不纯的操作(比如 IO、网络请求、DOM)包裹到一个函数内,从而延迟这个操作的执行。所以我们认为,IO 包含的是被包裹的操作的返回值。

var fs = require('fs');
var readFile = function (filename) {
    return IO.of(function () {
        return fs.readFileSync(filename, 'utf-8');
    });
};

var print = function (x) {
    return IO.of(function () {
        return x + "函数式编程";
    });
}

var tail = function (x) {
    return IO.of(function () {
        return x + "强!!!";
    });
}

IO其实也算是惰性求值。
IO负责了调用链积累了很多很多不纯的操作,带来的复杂性和不可维护性

如果IO函子继承了Monad函子,就可以很好的处理IO操作了。

class Monad extends Functor {
    join() {

        return this.val();
    }
    flatMap(f) {
        return this.map(f).join();
    }
}

class IO extends Monad {
    static of (val) {
        return new IO(val);
    }
    map(f) {
        let val = compose(f, this.val);
        return IO.of(val)
    }
}

readFile('./user.txt')
    .flatMap(print)
    .flatMap(tail);

执行过程绕的头晕~~卡了好久,下面来理一下:

  • readFile执行,返回一个IO函子,val为函数体function () { return ... }
  • flatMap方法执行,调用第一步生成IO函子的map方法,该map进行函数组合运算:首先执行this.val, 就是上一步执行留下的脏操作(val),然后将执行的结果,丢给了print函数,print执行又返回了一个IO函子,最后整个map方法返回了一个嵌套的函子。通过join方法拿到了print执行后创建的函子。
  • 第二个flatMap方法执行和上一步一样,print函子的val执行,结果丢给了tail,然后将tail执行创建的函子当作val值,传给一个新的IO函子,在通过join拿到tail执行后的创建的函子.
  • 最后可直接.val()执行得到最终结果
  • 假如user.txt读的内容是“哇”,最终运算结果为:"哇函数式编程强!!!"

总结

函子是我们主要的函数式编程方式,虽然很绕,但只要逻辑能转变过来,就会很快适应这种编程风格的,加油!!!