前端开发之函数式编程

通用的函数式编程语言,是Haskell,被函数式原教旨主义者认为是纯函数式语言。函数式编程的思想也不断影响着传统编程语言,比如Java 8开始支持lambda表达式,而函数式编程的大厦最初就是基于lambda计算构建起来的。函数式编程对于前端来说是必选项,后端则不必。

React框架的组件从很早开始就是不仅支持类式组件,也支持函数式的组件。而后React Hooks的出现,使得函数式编程思想越来越变得不可或缺。

无副作用

这是函数式编程的精髓所在。

前端开发之函数式编程_第1张图片

无副作用的函数应该符合下面的特点:

  1. 要有输入参数。如果没有输入参数,这个函数拿不到任意外部信息,也就不用运行了。

  2. 要有返回值。如果有输入没有返回值,又没有副作用,那么这个函数白调了。

  3. 对于确定的输入,有确定的输出

数学函数就是如此的:

const sqr3 = function(x){
    return x * x * x; 
}
console.log(sqr3(2));

无副作用函数拥有三个巨大的好处:

  1. 可以进行缓存。我们就可以采用动态规划的方法保存中间值,用来代替实际函数的执行结果,大大提升效率。

  2. 可以进行高并发。因为不依赖于环境,可以调度到另一个线程、worker甚至其它机器上,反正也没有环境依赖。

  3. 容易测试,容易证明正确性。不容易产生偶现问题,也跟环境无关,非常利于测试。

组合函数

在会无副作用的函数之后,需要好的将这些函数组合起来。

上述的 sqr3 函数有个问题,如果不是number类型,计算就会出错。按照命令式的思路,我们可能就直接去修改sqr2的代码,比如改成这样:

/** 命令式 */
const sqr3 = function(x){
    if (typeof x === 'number'){
        return x * x * x;
    }
    return 0;
}

/** 函数式 */
const isNum = x => typeof x === 'number';
console.log(sqr3(isNum("20")));

或者是我们在设计sqr3的时候就先预留出来一个预处理函数的位置,将来要升级就换这个预处理函数,主体逻辑不变:

/** fn 作为 预处理函数 */
const sqr3 = function(fn, x){
    const y = fn(x);
    return y * y; 
}

const sqr3New = function(x){
    return sqr3(isNum,x);
}
console.log((sqr3New(2.2)));

​​​​容器封装函数能力

如果我们想给其他的函数也复用这个isNum的能力,可以封装一个容器对象来提供这个能力:


class MayBeNumber{
    constructor(x){
        this.x = x;
    }

    map(fn){
        if (isNum(this.x)){
            return MayBeNumber.of(fn(this.x));
        }
        return MayBeNumber.of(0);
        
    }

    getValue(){
        return this.x;
    }
}

这样,我们不管拿到一个什么对象,用其构造一个MayBeNumber对象出来,再调用这个对象的map方法去调用数学函数,就自带了isNum的能力:

const notnum = new MayBeNumber(undefined).map(Math.sin).getValue();
console.log(notnum);

可以发现,输出值从NaN变成了0。而且封装到对象中的另一个好处是可以用"."多次调用了。

再者,使用对象封装之后的另一个好处是,函数嵌套调用跟命令式是相反的顺序,而用map则与命令式一致,先执行的先写:

const num = new MayBeNumber(1).map(Math.sin).map(sqr2).getValue();
console.log(num);

of 封装 new

上面的封装到对象中,但是函数式编程还搞出来new对象再map,最好是构造对象也是个函数。给它定义个 of 方法:

MayBeNumber.of = function(x){
    return new MayBeNumber(x);
}
const num = MayBeNumber.of(2).map(Math.tan).map(Math.exp).getValue();
console.log(num);

再来看下另一种情况,我们处理返回值的时候,如果有Error,就不处理Ok的返回值,可以这么写:

class Result{
    constructor(Ok, Err){
        this.Ok = Ok;
        this.Err = Err;
    }

    isOk(){
        return this.Err === null || this.Err === undefined;
    }

    map(fn){
        return this.isOk() 
                 ? Result.of(fn(this.Ok),this.Err) 
                 : Result.of(this.Ok, fn(this.Err));
    }
}

Result.of = function(Ok, Err){
    return new Result(Ok, Err);
}

console.log(Result.of(2, undefined).map(sqr3));

// 输出结果为:Result { Ok: 8, Err: undefined }

这是一种容器的设计模式:

  1. 有一个用于存储值的容器

  2. 这个容器提供一个map函数,作用是map函数使其调用的函数可以跟容器中的值进行计算,最终返回的还是容器的对象

我们可以把这个设计模式叫做Functor函子。如果这个容器还提供一个of函数将值转换成容器,那么它叫做Pointed Functor。比如 JavaScript 中的Array类型。

简化对象层级

借助Result结构,对sqr3的返回值进行格式化。如果是数值的话,Ok是数值,Err是undefined。如果非数值的话,Ok是undefined,Err是0:

const sqr3Res = function(x){
    if (isNum(x)){
        return Result.of(x * x * x, undefined);
    }
    return Result.of(undefined, 0);
}

console.log(Result.of(4.3, undefined).map(sqr3Res));
// 输出结果:Result { Ok: Result { Ok: 18.49, Err: undefined }, Err: undefined }

返回的是一个嵌套的结果,但是我们需要的是子Result的值。需要个Result 加一个 join函数:

class Result{
    constructor(Ok, Err){
        this.Ok = Ok;
        this.Err = Err;
    }

    isOk(){
        return this.Err === null || this.Err === undefined;
    }

    map(fn){
        return this.isOk() 
                ? Result.of(fn(this.Ok),this.Err) 
                : Result.of(this.Ok, fn(this.Err));
    }

    join(){
        if (this.isOk()) {
            return this.Ok;
        }
        return this.Err;
    }

    flatMap(fn){
        return this.map(fn).join();
    }
}
Result.of = function(Ok, Err){
    return new Result(Ok, Err);
}

console.log(Result.of(3, undefined).flatMap(sqr3Res));

// 输出结果:Result { Ok: 27, Err: undefined }

不严格地讲,像Result这种实现了flatMap功能的 Pointed Functor,就是传说中的Monad。

偏函数和高阶函数

函数式编程与命令行编程体感上的最大区别:

  1. 函数是一等公式,我们应该熟悉变量中保存函数再对其进行调用

  2. 函数可以出现在返回值里,最重要的用法就是把输入是n(n>2)个参数的函数转换成n个1个参数的串联调用,这就是传说中的柯里化。这种减少了参数的新函数,我们称之为偏函数

  3. 函数可以用做函数的参数,这样的函数称为高阶函数。

如何用函数式方法实现一个只执行一次有效的函数?

once是一个高阶函数,返回值是一个函数,如果done是false,则将done设为true,然后执行fn。done是在返回函数的同一层,所以会被闭包记忆获取到:

const once = (fn) => {
    let done = false;
    return function() {
        return done ? undefined : ((done=true), fn.apply(this,arguments));
    }
}

const initData = once(
    () => {
        console.log("Initialize data");
    }
);

initData();
initData();

可以发现,第二次调用init_data()没有发生任何事情。

递归与记忆

递归是函数式编程中比较复杂的,最简单的递归就是阶乘:

const factorial = (n) => {
    if (n === 0){
        return 1;
    }
    return n * factorial(n - 1);
}

console.log(factorial(10));

如此会重复计算好多次,效率较低,应该使用动态规划或者缓存记忆。没错,我们可以封装一个叫memo的高阶函数来实现这个功能:

const memo = (fn) => {
    const cache = {};
    return (arg) => cache[arg] || (cache[arg] = fn(arg));
}

 使用memo的后阶乘:

 const fastFact = memo(
    (n) => {
        if (n <= 0){
            return 1;
        }
        return n * fastFact(n-1);
    }
);

言归前端,React Hooks 中的 useMemo就是使用的这种记忆机制:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

总结

在日常使用中,需要记住这几点:

  1. 函数式编程的核心就是将函数存到变量里,用在参数里,用在返回值里;

  2. 在编程时要时刻记住将无副作用与有副作用代码分开;

  3. 函数式编程背后有其数学基础,不仅仅是一种设计模式。

你可能感兴趣的:(前端,JavaScript,函数式编程)