JavaScript 函数式编程

565 阅读13分钟

简述

如果你没接触过函数式编程,那么希望这篇文章能帮助到你。如果你也恰好使用的是javascript,那么就再好不过啦~

  1. 本文首先介绍函数式编程的定义及其核心的概念:λ演算;

  2. 接下来结合函数式编程的特点来帮助理解λ演算,发现其中一以贯之的原理;

  3. 再结合函数式编程常用的工具,加深对上面所述的数式编程的特点的理解;

  4. 最后用两个在平常就可能接触过的例子来打破函数式编程的壁垒,望君在本文大量文字的背后能把握最核心的东西。

1.定义

函数式编程(英语:functional programming)或称函数程序设计、泛函编程,是一种编程范式,它将计算机运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算(lambda calculus)为该语言最重要的基础。而且,λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。

----维基百科

所以,接下来我们来了解下什么是λ演算

λ演算

λ运算的核心是λ表达式,以此形成函数定义函数应用递归的形式系统。当使用λ表达式定义出布尔值、数值和各种基本操作符等语言元素后,就能够形成一种编程语言,所以,λ运算是函数式编程语言共同的祖先.

由于任何一个可计算函数都能用λ运算来表达和求值,因而它等价于图灵机(图灵完备)。

λ表达式(也可以称之为项:λ-expression or λ-term)有且仅有以下三种合法形式:

1.变量(Variable)

形式 x

描述:变量名可能是一个字符或字符串,它表示一个参数(形参)或者一个值(实参)。

e.g.bar x y z

2.抽象(Abstraction)

形式λx.M

描述:它表示获取一个参数x并返回M的lambda函数,M是一个合法lambda表达式,且符号λ和.表示绑定变量x于该函数抽象的函数体M。简单来说就是表示一个形参为x的函数M。

e.g.λx.y λx.(λy.xy),其中:

λx.y表示一个常量函数(constant function),输出恒为y与输入无关

λx.(λy.xy)的输出是一个函数抽象λy.xy,输入可以是任意的lambda表达式。

注意:一个lambda函数的输入和输出也可以是函数。

3.应用(Application)

形式M N

描述:它表示将函数M应用于参数N,其中MN均为合法lambda表达式。简单来说就是给函数M输入实参N

e.g.(λx.x) y, (λx.x) (λx.x),其中:

(λx.x) y表示将函数λx.x应用于变量y,得到y;

(λx.x) (λx.x)表示将函数λx.x应用于λx.x,得到λx.x。函数λx.x是一个恒等函数(identity function),即输入恒等于输出,它可以用 I 来表示.

λ表达式是函数式编程的数学基础,也是函数式编程的思想核心。函数式编程所具有的的特点也来源自λ表达式。

2.特点

函数式编程作为一种编程范式,自然会有自己独特之处。本节将列举这些特点,并举例说明与我们平常coding时的不同之处。

2.1 一等公民的函数

当我们说函数是“一等公民”的时候,我们实际上说的是它们和其他对象都一样...

所以就是普通公民。函数真没什么特殊的,你可以像对待任何其他数据类型一样对待它们——把它们存在数组里,当作参数传递,赋值给变量...等等。

函数对应了λ表达式中的抽象,在λ表达式的定义中,抽象与变量平起平坐,并无差异。

虽说在JavaScript语言中,这是基本概念,但在实际应用中仍有如下不合理的代码组织存在:

// 太傻了
const getServerStuff = callback => ajaxCall(json => callback(json));

// 这才像样(其效果跟上面是等价的)
const getServerStuff = ajaxCall;

下面是这样做的原因

  • 这样做除了徒增代码量,提高维护和检索代码的成本外,没有任何用处。

  • 如果一个函数被不必要地包裹起来了,而且发生了改动,那么包裹它的那个函数也要做相应的变更。

2.2 纯函数

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

即:相同输入得到相同输出

举个例子:

// 不纯的
var minimum = 21;

var checkAge = function(age) {
  return age >= minimum;
};


// 纯的
var checkAge = function(age) {
  var minimum = 21;
  return age >= minimum;
};

副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。

副作用可能包含,但不限于:

  • 更改文件系统
  • 往数据库插入记录
  • 发送一个 http 请求
  • 可变数据
  • 打印/log
  • 获取用户输入
  • DOM 查询
  • 访问系统状态
  • ...

这个列表还可以继续写下去。概括来讲,只要是跟函数外部环境发生的交互就都是副作用——这一点可能会让你怀疑无副作用编程的可行性。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。

纯函数的优势

  1. 可缓存性(Cacheable)

纯函数总能够根据输入来做缓存,实现缓存的一种典型方式是 memoize 技术:

var squareNumber  = memoize(function(x){ return x*x; });

squareNumber(4);
//=> 16

squareNumber(4); // 从缓存中读取输入值为 4 的结果
//=> 16

squareNumber(5);
//=> 25

squareNumber(5); // 从缓存中读取输入值为 5 的结果
//=> 25
  1. 可移植性/自文档化(Portable / Self-Documenting)

纯函数是完全自给自足的,它需要的所有东西都能轻易获得。仔细思考思考这一点...这种自给自足的好处是什么呢?首先,纯函数的依赖很明确,因此更易于观察和理解——没有偷偷摸摸的小动作。

  1. 可测试性(Testable)

纯函数让测试更加容易。我们不需要伪造一个“真实的”支付网关,或者每一次测试之前都要配置、之后都要断言状态(assert the state)。只需简单地给函数一个输入,然后断言输出就好了。

  1. 合理性(Reasonable)

很多人相信使用纯函数最大的好处是引用透明性(referential transparency)。如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。

由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。

5.代码并行

后一点,也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。

并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。

3. 常用工具

本节我们将介绍函数式编程的常用工具,感受不一样的编程方式。

3.1 柯里化(curry)

curry 的概念很简单:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

你可以一次性地调用 curry 函数,也可以每次只传一个参数分多次调用。

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

var increment = add(1);
var addTen = add(10);

increment(2);
// 3

addTen(2);
// 12

对于一个普通的函数,可以使用lodashramda中的curry方法将其转化:

var curry = require('lodash').curry;

var match = curry(function(what, str) {
  return str.match(what);
});

var replace = curry(function(what, replacement, str) {
  return str.replace(what, replacement);
});

var filter = curry(function(f, ary) {
  return ary.filter(f);
});

var map = curry(function(f, ary) {
  return ary.map(f);
});

将函数统一柯里化的意义在于:

  1. λ运算是函数式编程的基础,在λ表达式中,抽象的定义是接受一个输入,返回一个输出。curry 函数所做的正是这样:每传递一个参数调用函数,就返回一个新函数处理剩余的参数。

  2. 只传给函数一部分参数通常也叫做局部调用(partial application),能够大量减少样板文件代码(boilerplate code)。举个例子:

//用 map 简单地把参数是单个元素的函数包裹一下,就能把它转换成参数为数组的函数。
var getChildren = function(x) {
  return x.childNodes;
};

var allTheChildren = map(getChildren);


//如果用 lodash 的普通 map 来写会是什么样的(注意参数的顺序也变了)
var allTheChildren = function(elements) {
  return _.map(elements, getChildren);
};

3.2 代码组合(compose)

//这就是“组合”
var compose = function(f,g) {
  return function(x) {
    return f(g(x));
  };
};

在 compose 的定义中,g 将先于 f 执行,因此就创建了一个从右到左的数据流。这样做的可读性远远高于嵌套一大堆的函数调用。

组合中的数学定律:

//单元律
const id = x => x
compose(id, f) == compose(f, id) == f

//结合律
compose(compose(f, g), h) == compose(f, compose(g, h)) == compose(f, g, h)

上述定律完全适用于函数式编程中,你可以根据单元律和结合律来重构你的代码而不用担心前后逻辑的不一致。

3.3 函子(functor)

上面演示的函数组合看起来很舒服,但是实际用处还不是很大。因为 compose 接受的函数都是纯函数,只适合用来计算。而现实世界没有那么纯洁,我们要处理 IO,逻辑分支,异常捕获,状态管理等等。单靠简单的纯函数组合是不行的。

这里我们使用容器来解决上述问题,将数据放在一个容器中,让容器代替我们去操作数据。

functor 是实现了 map 函数并遵守一些特定规则的容器类型。

举个栗子:

//Maybe Functor 可以代替我们检查操作中的值出现空值情况
var Maybe = function(x) {
  this.__value = x;
}

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

Maybe.prototype.isNothing = function() {
  return (this.__value === null || this.__value === undefined);
}

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


//使用
Maybe.of({name: "Boris"}).map(_.prop("age")).map(add(10));
//=> Maybe(null)

Maybe.of({name: "Dinah", age: 14}).map(_.prop("age")).map(add(10));
//=> Maybe(24)

functor与范畴学

functor 的概念来自于范畴学,并满足一些定律。我们先来探索这些实用的定律。

// identity
map(id) === id;

// composition
compose(map(f), map(g)) === map(compose(f, g));

在范畴学中, functor 接受一个范畴的对象和态射(morphism),然后把它们映射(map)到另一个范畴里去。根据定义,这个新范畴一定会有一个单位元(identity),也一定能够组合态射;我们无须验证这一点,前面提到的定律保证这些东西会在映射后得到保留。

可能我们关于范畴的定义还是有点模糊。你可以把范畴想象成一个有着多个对象的网络,对象之间靠态射连接。那么 functor 可以把一个范畴映射到另外一个,而且不会破坏原有的网络。如果一个对象 a 属于源范畴 C ,那么通过 functor F 把 a 映射到目标范畴 D 上之后,就可以使用 F a 来指代 a 对象(把这些字母拼起来是什么?!)。可能看图会更容易理解:

范畴

比如,Maybe 就把类型和函数的范畴映射到这样一个范畴:即每个对象都有可能不存在,每个态射都有空值检查的范畴。这个结果在代码中的实现方式是用 map 包裹每一个函数,用 functor 包裹每一个类型。这样就能保证每个普通的类型和函数都能在新环境下继续使用组合。从技术上讲,代码中的 functor 实际上是把范畴映射到了一个包含类型和函数的子范畴(sub category)上,使得这些 functor 成为了一种新的特殊的 endofunctor。

可以用一张图来表示这种态射及其对象的映射:

映射

这张图除了能表示态射借助 functor F 完成从一个范畴到另一个范畴的映射之外,我们发现它还符合交换律,也就是说,顺着箭头的方向往前,形成的每一个路径都指向同一个结果。不同的路径意味着不同的行为,但最终都会得到同一个数据类型。这种形式化给了我们原则性的方式去思考代码——无须分析和评估每一个单独的场景,只管可以大胆地应用公式即可。来看一个具体的例子。

//  topRoute :: String -> Maybe(String)
var topRoute = compose(Maybe.of, reverse);

//  bottomRoute :: String -> Maybe(String)
var bottomRoute = compose(map(reverse), Maybe.of);


topRoute("hi");
// Maybe("ih")

bottomRoute("hi");
// Maybe("ih")

或者看图:

根据所有 functor 都有的特性,我们可以立即理解代码,重构代码。

4. 函数式的应用

函数式离我们其实并不远,我们时常在有意和无意间使用着。本节列举本人平时开发遇到的有关函数式编程思想的具体应用与实践。

4.1 函数式与React组件性能优化

React背后的Virtual DOM就是尽可能地减少浏览器的重绘与重排版,从React的渲染过程来看,如何避免不必要的渲染可能是最需要解决去解决的问题。针对这个问题,React官方提供来一个便捷的方法:PureRender(即PureComponent)。

PureRender中的Pure指的是组件满足纯函数的条件,即组件的渲染被相同的props和state渲染进而得到相同的结果。

在组件中,我们重新实现组件的shouldComponentUpdate生命周期方法:

shouldComponentUpdate(nextProps, nextState) {
  return !isEqual(this.props, nextProps) || !isEqual(this.state, nextState)
}

这里如果采取深比较,成本会非常高。

在PureRender中,采取的是浅比较,结合immutable,保证props和state改变时,对象树的根结点应用必定改变,无需对对象树的所有值做判断。

4.2 函数式与redux middleware机制

下面是redux源码中对于加载中间件的实现:

import compose from './compose'

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args)
    let dispatch = () => {
      throw new Error(
        'Dispatching while constructing your middleware is not allowed. ' +
          'Other middleware would not be applied to this dispatch.'
      )
    }

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }

    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)
    return {
      ...store,
      dispatch
    }
  }
}

其中主要使用了组合(compose)来实现来中间件的顺序执行,非常清晰。

以上


参考:Guide to FP or 中文翻译版