浅析函数式编程与前端

962 阅读7分钟

毕业三年,市面上听说过的互联网公司几乎都面试了一遍,函数式编程似乎是一个面试官很感兴趣的话题,几乎有一半的面试官都会问到对函数式编程的理解,尤其是这次回成都的几场面试。每每遇到这个问题,我总是简单的说几句自己的看法,然后快速转移话题企图掩盖自己对函数式编程的无知。

本周的“思考沉淀”就回忆和总结一下对函数式编程的理解。

什么是函数式编程

我们常见的编程范式有两种:命令式和声明式,比如我们熟悉的面向对象思想就属于命令式,而函数式编程属于声明式。而且顺带说一句,函数式编程里面提到的“函数”不是我们理解的编程中的“function”概念,而是数学中的函数,即变量之间的映射。

那么,函数式编程和我们熟知的声明式编程区别是什么?引用知乎用户的一句话来做总结:函数式编程关心数据的映射,命令式编程关心解决问题的步骤

举个例子,如果编写一个函数来实现把数组的每个数字都变成它本身的2倍,命令式编程的思路应该是:遍历一次数组,并且把每个数字乘以2,代码如下:

const solution = (arr) => {
  const newArr = [];
	for(let i = 0;i < arr.length;i++) {
  	newArr.push(arr[i]*2);
  }
  return newArr;
}

但是如果从函数式编程的思维去思考,无非就是数组A的每个元素是数组B每个元素的两倍,存在一个映射:[a, b, c, d, ...] => [2a, 2b, 2c, 2d, ...],代码如下:

const solution = (arr) => {
	return arr.map(item => {
  	return item*2;
  })
}

从上面这两个简单的例子可以看出来,函数式编程与命令式编程的思路最大的不同在于:函数式更关心数据的映射。

在前端开发领域中,有很多函数式的使用,比如React框架,它本身的设计理念就是View = Fn(Data),而且还有函数式组件以及高阶组件等等,无一不都透露着对函数式编程的实践。

纯函数

纯函数是函数式编程中一个很重要的概念,它的定义是:纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,且不依赖外部环境。

比如说对于数组的方法slice和splice来说(例子来自于函数式编程指北):

var xs = [1,2,3,4,5];

// 纯的
xs.slice(0,3);
//=> [1,2,3]

xs.slice(0,3);
//=> [1,2,3]

xs.slice(0,3);
//=> [1,2,3]


// 不纯的
xs.splice(0,3);
//=> [1,2,3]

xs.splice(0,3);
//=> [4,5]

xs.splice(0,3);
//=> []

可以从例子里看出,对于slice来说,它对于相同的输入总能返回相同的输出;而splice直接在原数组上作出改变,产生了可观察到的副作用,即改变了数组。

再来一个例子(例子来自函数式编程指北):

// 不纯的
var minimum = 21;

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


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

很明显,在不纯的版本中,checkAge的返回结果直接依赖于外部的变量minimum,这样子就做不到对于相同的输入总是返回相同的输出了,因为只要外部环境的变量一变,直接影响到函数的输出。

副作用

前面一直在说副作用,到底什么是副作用?哪些影响可以被算作是副作用?

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

副作用包含但不限于:

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

可以看出来,只要是跟外部环境发生了交互的行为都会带来副作用,与外部环境交互了就可能会影响到函数的输出,函数式编程之所以这么在乎副作用,就是因为函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。

优势

纯函数因为它的纯和没有副作用,可以带来以下几个好处

可预测性

因为纯函数没有副作用,对于同一个输入来说,我们可以预测到它的输出,这对于前端常见的共享状态以及修改很有用。

再看看Redux,在它的官方文档里面提到:

To specify how the state tree is transformed by actions, you write pure reducers. Reducers are just pure functions that take the previous state and an action, and return the next > state. Remember to return new state objects, instead of mutating the previous state.

很明显,Redux要求你使用纯函数的方式来写reducer,可预测、无副作用的纯函数正中下怀。

可缓存性

因为对于纯函数,固定的输入可以得到固定的输出,所以可利用这一点来做缓存。

例如(例子来自函数式编程指北):

var memoize = function(f) {
  var cache = {};

  return function() {
    var arg_str = JSON.stringify(arguments);
    cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
    return cache[arg_str];
  };
};

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

squareNumber(4);
//=> 16

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

squareNumber(5);
//=> 25

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

柯里化

柯里化(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

上面这个例子定义了一个add函数,它接受参数x并且返回一个函数,调用add函数之后,返回的函数就以闭包的形式记住了第一个参数x。

柯里化在日常编程中的使用主要体现在“预加载函数”,即提前缓存一部分参数,以便在未来使用。例如在vue源码中,就有一个柯里化的经典使用:

export function createPatchFunction (backend) {
  let i, j
  const cbs = {}
  const { modules, nodeOps } = backend
  
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
  	//...
  }
}

因为vue需要支持web和weex,而且patch操作里面的逻辑在不同的平台上基本相同,只是把虚拟DOM映射到真实元素的操作方法不一样,所以提前把相关的API传入createPatchFunction,返回一个固化好平台操作元素API的patch方法,以后使用的时候只需要传入oldVnode和vnode等等参数就好了,这种利用柯里化的技巧值得学习。

组合

考虑一下存在如下的方法:

var toUpperCase = function(x) { return x.toUpperCase(); };
var exclaim = function(x) { return x + '!'; };
var shout = function(x){
  return exclaim(toUpperCase(x)); // 不优雅
};

shout("send in the clowns");
//=> "SEND IN THE CLOWNS!"

这种方法里面套方法的做法,看起来略难受且不优雅,如果使用组合的方式,可以修改成下面这样:

var shout = compose(exclaim, toUpperCase);

shout("send in the clowns");
//=> "SEND IN THE CLOWNS!"

组合式的写法可以让我们在开发中用拼装来代替直接封装,使代码更加优雅,比如Redux中的compose方法:

Composes functions from right to left.

This is a functional programming utility, and is included in Redux as a convenience. You might want to use it to apply several store enhancers in a row.

Arguments

(arguments): The functions to compose. Each function is expected to accept a single parameter. Its return value will be provided as an argument to the function standing to the left, and so on. The exception is the right-most argument which can accept multiple parameters, as it will provide the signature for the resulting composed function.

Returns

(Function): The final function obtained by composing the given functions from right to left.

import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import DevTools from './containers/DevTools'
import reducer from '../reducers'

const store = createStore(
  reducer,
  compose(applyMiddleware(thunk), DevTools.instrument())
)

没有银弹

本文只是对函数式最基本的一些特性的学习和介绍,以及在前端开发的实践。如果想深入学习函数式编程,比如Monad之类的,可以看一下参考链接,更上一层楼。

软件开发领域没有银弹,没有一种编程范式或者框架放之四海而皆准。对于函数式编程也是如此,取其精华去其糟粕,防止拿着锤子看什么都像是钉子。

参考

函数式编程指南

编程的宗派

什么是函数式编程思维?

前端开发js函数式编程真实用途体现在哪里?

有哪些函数式编程在前端的实践经验?

JavaScript函数式编程