前端学习函数式编程的方法和误区

343次阅读  |  发布于3年以前

函数式编程思想,前端同学的必选项。

函数式编程是一门古老的技术,从上个世纪60年代Lisp语言诞生开始,各种方言层出不穷。各种方言带来欣欣向荣的生态的同时,也给兼容性带来很大麻烦。于是更种标准化工作也在不断根据现有的实现去整理,比如Lisp就定义了Common Lisp规范,但是一大分支scheme是独立的分支。另一种函数式语言ML,后来也标准化成Standard ML,但也拦不住另一门方言ocaml。后来的实践干脆成立一个委员会,定义一个通用的函数式编程语言,这就是Haskell。后来Haskell被函数式原教旨主义者认为是纯函数式语言,而Lisp, ML系都有不符合纯函数式的地方。

不管纯不纯,函数式编程语言因为性能问题,一直影响其广泛使用。直到单核性能在Pentium 4时代达到顶峰,单纯靠提升单线程性能的免费午餐结束,函数式编程语言因为其多线程安全性再次火了起来,先有Erlang,后来还有Scala, Clojure等。

比如Java 8开始支持lambda表达式,而函数式编程的大厦最初就是基于lambda计算构建起来的。

不过比起后端用Java的同学对于函数式编程思想是可选的,对于前端同学变成了必选项。

前端同学为什么要学习函数式编程思想?

React框架的组件从很早开始就是不仅支持类式组件,也支持函数式的组件。

比如下面的类继承的方式更符合大多数学过面向对象编程思想同学的心智:


class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

但是,完全可以写成下面这样的函数式的组件:


function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

从React 16.8开始,React Hooks的出现,使得函数式编程思想越来越变得不可或缺。

比如通过React Hooks,我们可以这样为函数组件增加一个状态:

import React, { useState } from 'react';

function Example() {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

同样我们可以使用useEffect来处理生命周期相关的操作,相当于是处理ComponentDidMount:

import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

那么,useState, useEffect之类的API跟函数式编程有什么关系呢?

我们可以看下useEffect的API文档:

Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a function component (referred to as React’s render phase). Doing so will lead to confusing bugs and inconsistencies in the UI.

Instead, use useEffect. The function passed to useEffect will run after the render is committed to the screen. Think of effects as an escape hatch from React’s purely functional world into the imperative world.

所有的可变性、消息订阅、定时器、日志等副作用不能使用在函数组件的渲染过程中。useEffect就是React纯函数世界与命令式世界的通道。

当我们用React写完了前端,现在想写个BFF的功能,发现serverless也从原本框架套类的套娃模式变成了一个功能只需要一个函数了。下面是阿里云serverless HTTP函数的官方例子:

var getRawBody = require('raw-body')
module.exports.handler = function (request, response, context) {
    // get requset header
    var reqHeader = request.headers
    var headerStr = ' '
    for (var key in reqHeader) {
        headerStr += key + ':' + reqHeader[key] + '  '
    };

    // get request info
    var url = request.url
    var path = request.path
    var queries = request.queries
    var queryStr = ''
    for (var param in queries) {
        queryStr += param + "=" + queries[param] + '  '
    };
    var method = request.method
    var clientIP = request.clientIP

    // get request body
    getRawBody(request, function (err, data) {
        var body = data
        // you can deal with your own logic here

        // set response
        var respBody = new Buffer('requestHeader:' + headerStr + '\n' + 'url: ' + url + '\n' + 'path: ' + path + '\n' + 'queries: ' + queryStr + '\n' + 'method: ' + method + '\n' + 'clientIP: ' + clientIP + '\n' + 'body: ' + body + '\n')
        response.setStatusCode(200)
        response.setHeader('content-type', 'application/json')
        response.send(respBody)
    })
};

虽然没有需要关注副作用之类的要求,但是既然是用函数来写了,用函数式思想总比命令式的要好。

学习函数式编程的方法和误区

如果在网上搜“如何学习函数式编程”,十有八九会找到要学习函数式编程最好从学习Haskell开始的观点。

然后很可能你就了解到那句著名的话”A monad is just a monoid in the category of endofunctors, what's the problem?“。

翻译过来可能跟没翻译差不多:”一个单子(Monad)说白了不过就是自函子范畴上的一个幺半群而已“。

别被这些术语吓到,就像React在纯函数式世界外给我们提供了useState, useEffect这些Hooks,就是帮我们解决产生副作用操作的工具。而函子Functor,单子Monad也是这样的工具,或者可以认为是设计模式。

Monad在Haskell中的重要性在于,对于IO这样虽然基础但是有副作用的操作,纯函数的Haskell是无法用函数式方法来处理掉的,所以需要借助IO Monad。大部分其它语言没有这么纯,可以用非函数式的方法来处理IO之类的副作用操作,所以上面那句话被笑称是Haskell用户群的接头暗号。

有范畴论和类型论等知识做为背景,当然会有助于从更高层次理解函数式编程。但是对于大部分前端开发同学来讲,这笔技术债可以先欠着,先学会怎么写代码去使用可能是更好的办法。前端开发的计划比较短,较难有大块时间学习,但是我们可以迭代式的进步,最终是会殊途同归的。

先把架式练好,用于代码中解决实际业务问题,比被困难吓住还停留在命令式的思想上还是要强的。

函数式编程的精髓:无副作用

前端同学学习函数式编程的优势是React Hooks已经将副作用摆在我们面前了,不用再解释为什么要写无副用的代码了。

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

  1. 要有输入参数。如果没有输入参数,这个函数拿不到任意外部信息,也就不用运行了。
  2. 要有返回值。如果有输入没有返回值,又没有副作用,那么这个函数白调了。
  3. 对于确定的输入,有确定的输出

做到这一点,说简单也简单,只要保持功能足够简单就可以做到;说困难也困难,需要改变写惯了命令行代码的思路。

比如数学函数一般就是这样的好例子,比如我们写一个算平方的函数:



let sqr2 = function(x){
    return x * x; 
}
console.log(sqr2(200));

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

  1. 可以进行缓存。我们就可以采用动态规划的方法保存中间值,用来代替实际函数的执行结果,大大提升效率。
  2. 可以进行高并发。因为不依赖于环境,可以调度到另一个线程、worker甚至其它机器上,反正也没有环境依赖。
  3. 容易测试,容易证明正确性。不容易产生偶现问题,也跟环境无关,非常利于测试。

即使是跟有副作用的代码一起工作,我们也可以在副作用代码中缓存无副作用函数的值,可以将无副作用函数并发执行。测试时也可以更重点关注有副作用的代码以更有效地利用资源。

用函数的组合来替代命令的组合

会写无副作用的函数之后,我们要学习的新问题就是如何将这些函数组合起来。

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



let sqr2 = function(x){
    if (typeof x === 'number'){
        return x * x;
    }else{
        return 0;
    }
}

但是,sqr2的代码已经测好了,我们能不能不改它,只在它外面进行判断?

是的,我们可以这样写:

let isNum = function(x){
    if (typeof x === 'number'){
        return x;
    }else{
        return 0;
    }
}
console.log(sqr2(isNum("20")));

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


let sqr2_v3 = function(fn, x){
    let y = fn(x);
    return y * y; 
}
console.log((sqr2_v3(isNum,1.1)));

嫌每次都写isNum烦,可以定义个新函数,把isNum给写死进去:



let sqr2_v4 = function(x){
    return sqr2_v3(isNum,x);
}
console.log((sqr2_v4(2.2)));

用容器封装函数能力

现在,我们想重用这个isNum的能力,不光是给sqr2用,我们想给其它数学函数也增加这个能力。

比如,如果给Math.sin计算undefined会得到一个NaN:

console.log(Math.sin(undefined));

这时候我们需要用面向对象的思维了,将isNum的能力封装到一个类中:


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

    map(fn){
        return new MayBeNumber(fn(isNum(this.x)));
    }

    getValue(){
        return this.x;
    }
}

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

我们先看调用sqr2的例子:


let num1 = new MayBeNumber(3.3).map(sqr2).getValue();
console.log(num1);
let notnum1 = new MayBeNumber(undefined).map(sqr2).getValue();
console.log(notnum1);

我们可以将sqr2换成Math.sin:


let notnum2 = new MayBeNumber(undefined).map(Math.sin).getValue();
console.log(notnum2);

可以发现,输出值从NaN变成了0.

封装到对象中的另一个好处是我们可以用"."多次调用了,比如我们想调两次算4次方,只要在.map(sqr2)之后再来一个.map(sqr2)


let num3 = new MayBeNumber(3.5).map(sqr2).map(sqr2).getValue();
console.log(num3);

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

如果不理解的话我们来举个例子,比如我们想求sin(1)的平方,用函数调用应该先写后执行的sqr2,后写先执行的Math.sin:

console.log(sqr2(Math.sin(1)));

而调用map就跟命令式一样了:


let num4 = new MayBeNumber(1).map(Math.sin).map(sqr2).getValue();
console.log(num4);

用 of 来封装 new

封装到对象中,看起来还不错,但是函数式编程还搞出来new对象再map,为什么不能构造对象时也用个函数呢?

这好办,我们给它定义个of方法吧:

MayBeNumber.of = function(x){
    return new MayBeNumber(x);
}

下面我们就可以用of来构造MayBeNumber对象啦:


let num5 = MayBeNumber.of(1).map(Math.cos).getValue();
console.log(num5);
let num6 = MayBeNumber.of(2).map(Math.tan).map(Math.exp).getValue();
console.log(num6);

有了of之后,我们也可以给map函数升升级。

之前的isNum有个问题,如果是非数字的话,其实没必要赋给个0再去调用函数,直接返回个0就好了。

之前我们一直没写过箭头函数,顺手写一写:

isNum2 = x => typeof x === 'number';

map用isNum2和of改写下:


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

我们再来看下另一种情况,我们处理返回值的时候,如果有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(1.2,undefined).map(sqr2));

输出结果为:

Result { Ok: 1.44, Err: undefined }

我们来总结下前面这种容器的设计模式:

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

我们可以把这个设计模式叫做Functor函子。

如果这个容器还提供一个of函数将值转换成容器,那么它叫做Pointed Functor.

比如我们看下js中的Array类型:


let aa1 = Array.of(1);
console.log(aa1);
console.log(aa1.map(Math.sin));

它支持of函数,它还支持map函数调用Math.sin对Array中的值进行计算,map的结果仍然是一个Array。

那么我们可以说,Array是一个Pointed Functor。

简化对象层级

有了上面的Result结构了之后,我们的函数也跟着一起升级。如果是数值的话,Ok是数值,Err是undefined。如果非数值的话,Ok是undefined,Err是0:

let sqr2_Result = function(x){
    if (isNum2(x)){
        return Result.of(x*x, undefined);
    }else{
        return Result.of(undefined,0);
    }
}

我们调用这个新的sqr2_Result函数:

console.log(Result.of(4.3,undefined).map(sqr2_Result));

返回的是一个嵌套的结果:

Result { Ok: Result { Ok: 18.49, Err: undefined }, Err: undefined }

我们需要给Result对象新加一个join函数,用来获取子Result的值给父Result:

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

我们调用的时候最后加上调用这个join:

console.log(Result.of(4.5,undefined).map(sqr2_Result).join());

嵌套的结果变成了一层的:

Result { Ok: 20.25, Err: undefined }

每次调用map(fn).join()两个写起来麻烦,我们定义一个flatMap函数一次性处理掉:

  flatMap(fn){
        return this.map(fn).join();
    }

调用方法如下:

console.log(Result.of(4.7,undefined).flatMap(sqr2_Result));

结果如下:

Result { Ok: 22.090000000000003, Err: undefined }

我们最后完整回顾下这个Result:


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;
        }else{
            return this.Err;
        }
    }

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

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

偏函数和高阶函数

在前面各种函数式编程模式中对函数的用法熟悉了之后,回来我们总结下函数式编程与命令行编程体感上的最大区别:

  1. 函数是一等公式,我们应该熟悉变量中保存函数再对其进行调用
  2. 函数可以出现在返回值里,最重要的用法就是把输入是n(n>2)个参数的函数转换成n个1个参数的串联调用,这就是传说中的柯里化。这种减少了参数的新函数,我们称之为偏函数
  3. 函数可以用做函数的参数,这样的函数称为高阶函数

偏函数可以当作是更灵活的参数默认值。

比如我们有个结构叫spm,由spm_a和spm_b组成。但是一个模块中spm_a是固定的,大部分时候只需要指定spm_b就可以了,我们就可以写一个偏函数:

const getSpm = function(spm_a, spm_b){
    return [spm_a, spm_b];
}

const getSpmb = function(spm_b){
    return getSpm(1000, spm_b);
}

console.log(getSpmb(1007));

高阶函数我们在前面的map和flatMap里面已经用得很熟了。但是,其实高阶函数值得学习的设计模式还不少。

比如给大家出一个思考题,如何用函数式方法实现一个只执行一次有效的函数?

不要用全局变量啊,那不是函数式思维,我们要用闭包。

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

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

let init_data = once(
    () => {
        console.log("Initialize data");
    }
);

init_data();
init_data();

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

递归与记忆

前面介绍了这么多,但是函数编程其实还蛮复杂的,比如说涉及到递归。

递归中最简单的就是阶乘了吧:


let 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));
}

逻辑很简单,返回值是lamdba表达式,它仍然支持闭包,所以我们在其同层定义一个cache,然后如果cache中的某项为空则计算并保存之,如果已经有了就直接使用。

这个高阶函数很好用,阶乘的逻辑不用改,只要放到memo中就好了:


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

在本文即将结尾的时候,我们再回归到前端,React Hooks里面提供的useMemo,就是这样的记忆机制:

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

小结

综上,我们希望大家能记住几点:

  1. 函数式编程的核心概念很简单,就是将函数存到变量里,用在参数里,用在返回值里
  2. 在编程时要时刻记住将无副作用与有副作用代码分开
  3. 函数式编程的原理虽然很简单,但是因为大家习惯了命令式编程,刚开始学习时会有诸多不习惯,用多了就好了
  4. 函数式编程背后有其数学基础,在学习时可以先不要管它,当成设计模式学习。等将来熟悉之后,还是建议去了解下背后的真正原理

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8