深入浅出JavaScript函数式编程

1,222 阅读12分钟

前言

函数式编程是一种强调以函数使用为主的开发风格。这里的函数是数学上函数也就是变量的映射,一个函数的值仅决定于函数的参数值,不依赖其他状态。它的目标是使用函数来抽象作用在数据之上的控制流和操作,从而在程序中消除副作用减少对状态的改变

函数式编程有以下4个基本概念:

  • 声明式编程
  • 纯函数
  • 引用透明
  • 不可变性

函数式编程

声明式编程

函数式编程属于声明式编程范式,只关心数据的映射,与之对应的就是命令式编程范式。命令式编程是面向计算机硬件的抽象,有变量、赋值语句、表达式和控制语句等,而函数式编程是面向数学的抽象,将计算描述为一种表达式来求解。

举个例子:计算一个数组中所有数的平方,命令式编程如下:

const numArr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
for (var i = 0; i < numArr.length; i++) {
    numArr[i] = Math.pow(numArr[i], 2);
}
console.log(numArr); // [1, 4, 9, 16, 25, 36, 49, 64, 81]

可以看到,命令式编程要很具体的告诉计算机如何去执行任务。

与之相反,声明式编程是将程序的描述与求值分离开。它关注如何使用各种表达式来描述程序逻辑,而不一定要指明其控制流或状态的变化。典型的声明式编程就是SQL。SQL语句是由很多描述查询结果应该是什么样的断言组成,对数据检索的内部机制进行了抽象。

如果使用声明式编程来解决上述问题,只需要对应用在每个数组元素上的行为予以关注,将循环部分交给系统其他部分去控制,因为循环是一种重要的命令控制结构,但很难重用,并且很难插入其他操作中。

[1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => Math.pow(num, 2))
// [1, 4, 9, 16, 25, 36, 49, 64, 81]

纯函数

使用纯函数是函数式编程的大前提。纯函数具有以下特性:

  • 相同的输入,永远会得到相同的输出,也就是说在函数求值期间或调用间隔时不依赖外部状态。
  • 不会造成超出其作用域的变化,例如修改全局对象或引用传递的参数。 不符合以上条件的函数都是“不纯”的。
let num = 0;
function increment() {
    reuturn num++;
}

显然increment函数是不纯的,因为它读取并修改了一个外部变量num。事实上JavaScript这种充满了动态行为与变化的语言内,纯函数的确是很难使用的。但函数式编程在实践上并不限制一切状态的的改变。它只是提供了一个框架来帮助管理和减少可变状态,同时能够将纯函数从不纯的部分中分离出去。

看下面的例子:

function getPersonFromId (id) {
    const person = localStorage.getItem(id);
    if (persion !== null) {
        document.querySelector(`#${elementId}`).innerHTML = `${person.name} born in ${person.birthday}`;
    } else {
        throw new Error('person not found!')
    }
}
getPersonFromId('440111199*********');

getPersonFromId有如下不符合纯函数的规定地方:

  • 函数与一个外部变量(localStorage)进行了交互,但函数签名中并没有声明这个参数。在任何时间点,这个引用可能为null,从而导致完全不同的结果并破坏了程序的“纯度”。
  • 全局变量elementId可能在函数被调用时被改变,难以控制。
  • HTML元素也是全局变量,可能在下次调用的时候就不一样了。
  • 如果没找到这个人,该函数会抛出一个异常,将会导致整个程序的栈回退并突然结束。

getPersonFromId改为纯函数,首先要分离显示与获取这两个行为。当然,与外部交互和DOM交互产生的副作用是不可避免的,但至少可以通过将其从主逻辑中分离出来的方式使它们更易于管理。这需要使用柯里化技巧,使用柯里化可以部分传递函数参数,以便将函数的参数减少为一个。

const find = R.curry((localStorage, id) => {
    const person = localStorage.getItem(id);
    if (person !== null) {
        throw new Error('person not found');
    }
    return person;
});
const showMessage = (person_ => `${person.name} born in ${person.birthday}`;
const append = R.curry((elementId, info) => {
    document.querySelector(elementId).inner = info
})

const getPersonFromId = R.compose(
                            append('#person'),
                            csv,
                            find(localStorage));
getPersonFromId('440111199*********');

尽管只是些许的改变,但已经可以显示出几个优势了:

  • 有三个可以重用的组件。
  • 将一个长函数分散,大大减少需要维护的代码量。
  • 声明式风格提供了程序需要执行的步骤一个清晰的视图,增强代码可读性。
  • HTML交互移动到单独的函数中,将纯函数中的不纯分离出去。

柯里化
柯里化是一种参数还没有全部提供前,将其挂起或者延迟函数执行,将多参数函数转换为一元函数序列的技巧。它要求所有参数都被明确地定义。因此,当调用时缺少参数的时候,它会返回一个新的函数,在真正运行前等待外部提供其余的参数。

在函数式编程语言中,柯里化是原生特性,是函数定义中的组成部分。但JavaScript不能原生支持柯里化,因为在缺少参数的情况下调用非柯里化函数会导致缺失参数的实参变成undefined。比如说如果定义一个函数f(a, b, c),并只在调用时传递aJavaScript运行时的调用机制会将bc设为undefined

所以在JavaScript中需要编写一些代码启动柯里化。比如下面这个二元参数的手动柯里化函数

function curry2(fn) {
    return function (firstArg) {
        return function (secondArg) {
            return fn(firstArg, secondArg)
        }
    }
}

如上所示,柯里化其实就是一种词法作用域,其返回的函数只不过是接收后续参数的简单嵌套函数包装器。

引用透明与可置换性

引用透明是定义一个纯函数较为正确的方式。纯度在这个意义上表明了一个函数的参数和返回值之间映射的关系。因此,如果一个函数对于相同的输入始终产生相同的结果,那么它就是引用透明的。之前的例子:

let num = 0;
function increment() {
    reuturn num++;
}

为了使其引用透明,需要删除其依赖的外部变量这一状态,使其成为函数签名中显式定义的参数。

const increment = (num) => ++num;

改完之后,变成了纯函数,对于相同的输入每次都会返回相同的输出。之所以追求这种函数特性,是因为它不仅能使代码更易于测试,还更容易推理整个程序。

引用透明来自数学概念,但编程语言的函数行为与数学上的不同,所以引用透明必须由我们来实现。构建这样的程序更容易推理,因为可以在心中形成一个状态系统的模型,并通过重写或替换来达到期望的输出。具体来说,假设任何程序可以被定义为一组的函数,对于一个给定的输入,会产生一个输出,则可表示为:

Program = [Input] + [fun1, fun2, fun3, ...] -> Output

如果[fun1, fun2, fun3, ...]是纯的,那么就可以很轻易地将由其产生的值来重写这个程序[val1, val2, val3, ...]而不改变结果。举个例子:

const input = [10, 20, 30];
const average = (arr) => divide(sum(arr), size(arr));
average(input); // 20

函数sumsize都是引用透明的,对于如下给定的输入,可以很容易地重写这个表达式。

const average = divide(60, 3); // 20

由于devide也是纯的,因此可以利用其数学符号进行改写,所以对于当前输入,平均值永远是60/3=20

存储不可变的数据

不可变数据是指那些被创建后不能更改的数据。JavaScript的所有基本类型本质上是不可变的,但引用类型都是可变的,所以如何管理对象的状态是一个大问题。

将对象视为数值
在函数式编程中,将具有不可变的类型成为数值,比如基本类型的字符串和数字。如果能将对象视为数值,那就不比担心它们被篡改。

ES6使用const关键字来创建不可变的变量

const pi = 3.14;
pi = 3.15; //Uncaught TypeError: Assignment to constant variable.

但这样还不够,因为这不能防止对象内部状态的改变。

const piObj = {
    pi: 3.14
}
piObj.pi = 3.15; // ok

一个好的办法就是采用值对象模式,指的是其相等性不依赖与标识或引用,而只是基于其值,一旦声明,其状态就不可变。在JavaScript中,实现值对象的其中一个办法就是使用函数来保障内部状态的访问,通过返回接口的方式来公开一部分方法给调用者。

function person (name, contry) {
    let _name = name;
    let _contry = contry;
    return {
        getName: () => _name;
        getContry: () => _contry;
        toString: () => _name + ' from ' _contry
}

const person1 = person('zhang', 'china');
person1.toString(); // 'zhang from china'

另外,JavaScript还有一个内部机制,通过控制writable属性来实现的。比如Object.freeze()函数可以使该属性设置为false来阻止对象状态的改变。

const piObj = Object.freeze({ pi: 3.14 })
piObj.pi = 3.15 // 不行

Object.freeze()是一种浅操作。要解决深层属性不可变问题,需要手动冻结对象的嵌套结构

const isObj = (val) => val && typeof val === 'object')
function deepFreeze (obj) {
    if (isObj(obj) && !Object.isFrozen(obj)) {
        Object.keys(obj).forEach(name => deepFreeze(obj[name]);
        Object.freeze(obj);
    }
    return obj;
}

使用Lenses定位并修改对象图
deepFreeze函数虽然能增强代码中的不可变水平,但要创建一个永不可变的程序是不现实的。因此比较可行的办法就是由原对象创建新对象,在每次方法调用时返回一个新的对象。

Lenses被称为函数式引用,是函数式程序设计中用于访问和不可改变地操作状态数据类型属性的解决方案。从本质上讲,Lenses与写时复制策略类似,即采用一个能够合理管理和赋值状态的内部存储部件。

Ramda.js库已经实现了这个方案,比如使用R.lensProp来创建一个包装了piObjpi属性的Lens

const piLens = R.lenseProp('pi');
R.view(piLens, piObj); // 3.14;

const newPi = R.set(piLens, '3.15', piObj);
newPi.pi; // 3.15
piObj.pi; // 3.14

Lense之所以有价值,是因为其提供了一种不那么繁琐的操作对象的机制,即使是一些历史遗留或超出控制范围的对象。

// Lenses修改嵌套属性
const person = {
    address: {
        country: 'china',
        city: 'guangzhou'
    }
}

const cityPath = ['address', 'city'];
const cityLens = R.lens(R.path(cityPath), R.assocPath(cityPath));
R.view(cityLens, person); // 'guangzhou'

const newPerson = R.set(cityLens, person,  'shenzhen');

如何进行函数式编程

复杂任务的分解

从宏观上来讲,函数式编程实际上是分解和组合之间的相互作用。比如getPersonFromId,将其分解为find、csv、append。函数式编程的模块化的概念与单一职责息息相关。也就是说,函数都应该拥有单一的目的,比如average函数。纯度和引用透明会促使你这样思考问题,为了将函数组合在一起,它们必须在输入和输出的形式上形成一致。

而上述用到的R.compose函数则是组合。两个函数的组合是一个新的函数,它拿到一个函数的输出,并将其传递给另外一个函数中。假设有两个函数f和g,它们的关系f * g = f(g(x)),其中g函数的返回值与f的参数之间构建了一个松耦合的且类型安全的联系。两个函数能够组合的条件是,它们必须在参数数目及参数类型上形成一致。

从本质上讲,函数组合是一种将已被分解的简单任务组织成复杂行为的整体过程。举例:

const words = 'we are family';
const explode = (str) => str.split(/\s+/);
const count = (arr) => arr.length;
const countWords = R.compose(count, explode);
countWords(words); // 3

countWords函数的调用触发了函数explode的执行并将其输出传给count以计算该数组的长度。组合将输入和输出相链接,创建出函数通道。这段代码直到countWords被调用才会触发求值。组合的结果是等待一个指定参数调用的另一个函数countWords。函数式组合的强大之处在于:将函数的描述与求值分开。

compose的实现

functon compose(...args) {
    const start = args.length - 1;
    return function () {
        let i = start;
        let result = args[start].apply(this, args);
        while(i--){
            return = args[i].call(this, result);
        return result;
    }
}

使用Ramda函数式库的好处之一就是所有函数已经被正确地柯里化,在组合函数管道时更具有通用性。再看下面一个例子:

const students = ['zhao', 'qian', 'sun', 'li'];
const scores = [90, 88, 97, 80];

找到班里成绩最高的学生

const smarTestStudent = R.compose(
    R.head, // 获取第一个元素
    R.pluck(0), // 通过抽取指定的索引的元素构建数组。这里0表示提取学生名字
    R.reverse, // 反转数组
    R.sortBy(R.prop[1]), // 根据指定属性进行升序排序
    R.zip);
smarTestStudent(students, scores); // 'sun'

使用链式来处理数据

函数链式一种惰性计算的程序,惰性计算的意思就是当需要时才会执行。这可以避免执行一些可能永不会使用的代码,节省CPU。如果写过一些Jquery代码,那么对于链式应该不陌生。链指的是一连串函数的调用,它们共享一个通用的对象返回值就像组合一样,链有助于写出简明扼要的代码,而且它通常多用于函数式和响应式的JavaScript类库。

举例:统计高年级(也就是4年级及以上的)成绩平均数

const school = [
    { grade: 1, score: 92 },
    { grade: 2, score: 93 },
    { grade: 3, score: 94 },
    { grade: 4, score: 95 },
    { grade: 5, score: 96 },
    { grade: 6, score: 97 }
]

用函数式思维分解这个问题,可以分解为3个主要步骤:

  • 筛选出合适的年级
  • 获取对应年级的成绩
  • 计算出它们的平均数 使用Lodash组合这些步骤的函数。
_.chain(school)
    .filter(grade => grade.grade > 3)
    .pluck('score')
    .average()
    .value()

_.chain函数可以添加一个输入对象的状态,从而能够将这些输入转换为所需输出的操作链接在一起。与简单地将数组包裹在_(...)对象不同,其强大之处在于可以链接序列中的任何函数。尽管这是一个复杂的程序,但仍然可以避免创建任何变量,并有效地消除所有循环。

使用_.chain的另一个好处就是可以创建具有惰性计算能力的复杂程序,在调用value()前,并不会真正地执行任何操作。这可能会对程序产生巨大的影响,因为在不需要其结果的情况下,可以跳过运行所有函数。链中的每个函数都以一种不可变的方式来处理由上一个函数构建的新数组。

如果是使用过SQL,就会发现这与SQL有相似之处。比如下表Person:

idnameagecity
1zhang23guangzhou
2li24shenzhen
3qian25shanghai
SELECT p.name, p.age FROM Person
WHERE p.age > 24 and p.city IS NOT 'shanghai'
GROUP BY p.name, p.age

Lodash支持一种被称为mixins的功能,可以用来为核心库扩展新的函数,并使得它们以相同的方式连接:

_.mixins({'select', _.pluck,
          'from',   _.chain,
          'where',  _.filter,
          'groupBy',_.sortByOrder})

之后就能编写出类似SQL的语句

_.from(persons)
    .where(p => p.age > 24 && p.city !== 'shanghai')
    .groupBy(['name', 'age'])
    .select('name', 'age')
    .value();

结尾

本文参考:

更多文章请移步楼主github,如果喜欢请点一下star,对作者也是一种鼓励。