JavaScript函数式编程指南-读书笔记3

182 阅读10分钟

通过本文,可以学习到:

  1. 如何使用 map、reduce、filter 连续遍历和变换各种数据结构;
  2. 学习如何使用 Lodash.js 处理各种数据结构;
  3. 学习在函数式编程中具有重要作用的递归思想。

理解程序的控制流

程序为实现业务目标所要进行的路径被称为控制流。

命令式程序需要通过暴露所有的必要步骤,才能极其详细地描述其控制流。这些步骤通常涉及大量的循环和分支,以及随语句执行变化的各种变量。

var loop = optC();
while(loop) {
    var condition = optA();
    condition ? optB1() : optB2();
    loop = optC();
}
optD();

下图显示了上述程序的简单流程图: image.png

而声明式程序,特别是函数式程序,则多使用以简单拓扑连接的独立黑盒操作组合而成的较小结构化控制流,从而提升程序的抽象层次。这些连接在一起的操作只是一些能够将状态传递至下一个操作的高阶函数。

optA().optB().optC().optD(); // 点连接表示有共同的对象上定义过这些方法

连接黑盒操作的函数式控制流程。信息在一个操作和下一个(独立的纯函数)操作之间独立地流动。高阶抽象使得分支和迭代明显减少甚至被消除。 image.png

链接方法

方法链是一种能够在一个语句中调用多个方法的面向对象编程模式。当这些方法属于同一个对象时,方法链又称为方法级联。尽管该模式大多出现在面向对象的应用程序中,但在一定条件下,如操作不可变对象时,也能很好地用于函数式编程中。

const str = 'Hello World!';
str.substring(0, 5).toLowerCase() + ' JavaScript';
console.log(str); // -> 'Hello World!'

函数链

面向对象程序将继承作为代码复用的主要机制。函数式编程采用了不同的方式,它不是通过创建一个全新的数据结构满足特定需求,而是使用如数组这样的普通类型,并施加在一套粗粒度的高阶操作之上。这些操作是底层数据形态所不可见的,这些操作会做如下设计:

  • 接收函数作为参数,以便能够注入解决特定任务的特定行为;
  • 代替充斥着临时变量与副作用的传统循环结构,从而减少所要维护以及可能出错的代码。
// 这里先声明四个对象:
const p1 = new Person('Haskell', 'Curry', '111-11-1111');
p1.address = new Address('US');
p1.birthYear = 1900;

const p2 = new Person('Barkly', 'Rosser', '222-22-2222');
p2.address = new Address('Greece');
p2.birthYear = 1907;

const p3 = new Person('John', 'von Neumann', '333-33-3333');
p3.address = new Address('Hungary');
p3.birthYear = 1903;

const p4 = new Person('ALonzo', 'Church', '444-44-4444');
p4.address = new Address('US');
p4.birthYear = 1903;

lambda 表达式

lambda 表达式(在 JavaScript 中也被称为箭头函数)源自函数式编程,比起传统的函数声明,它可以采用相对简洁的语法形式来声明一个匿名函数。

const name = p => p.fullname;

lambda 函数的右侧可以是一个表达式,或者是一个封闭的多语句块。

另外一个值得注意的是,一等函数和 lambda 表达式之间的关系,函数名代表的不是一个具体的值,而是一种(惰性计算的)可获取其值的描述。换句话说,函数名指向的是代表着如何计算该数据的箭头函数。

函数式编程中鼓励使用的 map、reduce、filter 等核心高阶函数都能与 lambda 表达式良好的配合使用。JavaScript 5.1 本身提供了这些操作,但为了能够联合其他相似操作以提供完美的解决方案,书里面还是使用了 Loadash.js 函数库提供的此类操作。

用 _.map 做数据变换

假设需要对一个较大数据集合中的所有元素进行变换。例如,从一个学生对象的列表中提取每个人的全名。一般可能会这样写代码:

var result = [];
var persons = [p1, p2, p3, p4];
for(let i = 0; i < persons.length; i++) {
    var p = persons[i];
    if(p !== null && p !== undefined) {
        result.push(p.fullname);
    }
}

使用高阶函数 map 的代码如下:

_.map(persons, s => s?.fullname || '')

如果整个集合元素需要进行变换,map 函数是极其有用的。因为不用编写循环,也不用处理奇怪的作用域问题。

接下来看看 map 是如何实现的?

// map 的实现
function map(arr, fn) { // 接收一个函数和一个数组
    let idx = 0;
    const len = arr.length;
    const result = new Array(len); // 结果:一个与输入数组同样长度的数组
    while (++idx < len) {
        result[index] = fn(array[idx], idx, arr); // 应用函数fn到数组中的每一个元素,再把结果放入数组
    }
    return result;
}

用 _.reduce 收集结果

假设要从一个 Person 对象集合中计算出人数最多的国家,就可以使用 reduce 来实现。高阶函数 reduce 将一个数组中的元素精简为单一的值,该值由每个元素与上一个累积值通过一个函数计算得出。

// reduce 的实现
function reduce(arr, fn, [accumulator]) {
    let idx = -1;
    const len = arr.length;
    if(!accumulator && len > 0) {
        accumulator = arr[++idx]; // 如果不提供累积值,就会用第一个元素作为累积值
    }
    while(++idx < len) {
        accumulator = fn(accumulator, arr[idx], idx, arr); // 应用 fn 到每一个元素,将结果放到累加值中
    }
    return accumulator; // 返回累加值
}

reduce 需要接收以下参数:

  1. fn:迭代函数会应用于数组的每个元素,其参数包含累积值、当前值、当前索引以及数组本身
  2. 累加器:累积初始值,之后会用于存储每次迭代函数的计算结果,并不断被传入子函数中

来个使用 reduce 的例子,假设要找出住在某个特定国家的人数

_(persons).reduce((stat, person) => {
    const country = person.address.country;
    stat[country] = _.isUndefined(stat[country]) ? 1 : stat[country] + 1;
    return stat;
}, {}); // -> { 'US': 2, 'Greece': 1, 'Hungary': 1 }

也可以使用 map-reduce 组合,使用 map 进行预处理,先提取出所有国家信息,之后再用 reduce 来收集最终结果。

const getCountry = person => person.address.country;
const gatherStats = function (stat, criteria) {
    stat[criteria] = _.isUndefined(stat[criteria]) ? 1 : stat[criteria] + 1;
    return stat;
};
_(persons).map(getCountry).reduce(gatherStats, {});

reduce 是一个会应用到所有元素的操作,这意味着没有办法将其“短路”来避免其应用于整个数组。而 _.some 能够在找到第一个真值(true)后立即返回。

用 _.filter 删除不需要的元素

在处理较大数据集合时,往往需要删除部分不能参与计算的元素。与其在代码中到处使用 if-else 语句,不如用 _.filter 来实现。

filter(也称为select)是一个能够遍历数组中的元素并返回一个新子集数组的高阶函数,其中的元素由谓词函数 p 计算出的 true 值结果来确定。filter 操作以一个数组为输入,并施加一个选择条件 p,从而产生一个可能较原数组更小的子集,条件 p 也称为函数谓词。

// filter 的实现
function filter(arr, predicate) {
    let idx = -1;
    const len = arr.length;
    const result = [];
    
    while(++idx < len) {
        const value = arr[idx];
        if(predicate(value, idx, arr)) { // 调用谓词函数,结果为真则保留,否则略过
            result.push(value);
        }
    }
    
    return result;
}

代码推理

代码推理,个人理解是指代码的可读性。函数式代码是声明式的,其控制流能够在不需要研究任何内部细节的条件下,提供该程序意图的清晰结构,这样能够更深入地了解代码,并获知数据在不同阶段是如何流入和流出的。

声明式惰性计算函数链

先看一个例子,假设需要对一组手机号进行读取、规范化、去重,最终进行排序。首先,写一个命令式的版本,如下:

const telphones = ['86 13166667777', '86-13866668888', '+86 13166669999', '86 13166667777']
const result = [];
for(let i = 0; i < telphones.length; i++) {
    const tel = telphones[i];
    if(tel !== undefined && tel !== null) {
        const newTel = tel.trim().replace(/\s+/, '-');
        if(result.indexOf(newTel) < 0) {
            result.push(newTel)
        }
    }
}
result.sort(); // -> ['+86-13166669999', '86-13166667777', '86-13866668888']

命令式代码的缺点是限定于高效地解决某个特定的问题,比起函数式代码,其抽象水平要低得多。抽象层次越低,代码复用率就越低。再来看下声明式的代码:

const telphones = ['86 13166667777', '86-13866668888', '+86 13166669999', '86 13166667777']
const isValid = v => !_.isUndefined(v) && !_isNull(v);
_.chain(telphones)
    .filter(isValid)
    .map(tel => tel.trim().replace(/\s+/, '-'))
    .uniq()
    .sort()
    .value()

再看一个例子,创建一个程序返回数据集中人数最多的国家

const gatherStats = function(stat, country) {
    if(!isValid(stat[country])) {
        stat[country] = {name: country, count: 0};
    }
    stat[country].count++;
    return stat;
}

_.chain(persons)
    .filter(isVaild)
    .map(_.property('address.country'))
    .reduce(gatherStats, {})
    .values()
    .sortBy('count')
    .reverse()
    .first()
    .value()
    .name()

_.chain 函数可以添加一个输入对象的状态,从而能够将这些输入转换为所需输出的操作链接在一起。另一个好处是可以创建具有惰性计算能力的复杂程序,在调用value()前,并不会真正地执行任何操作。

类 SQL 的数据:函数即数据

前面使用了各种函数,比如 map、reduce、filter、groupBy、sortBy、uniq 等。如果在更高层面仔细思考,会发现这些函数与 SQL 相似,这并非偶然。事实证明,使用查询语言来思考与函数式编程中操作数组类似。

// SQL 查询数据
SELECT p.firstname, p.birthYear FROM Person p
WHERE p.birthYear > 1903 and p.country IS NOT 'US'
GROUP BY p.firstname, p.birthYear

在实现此程序的 JavaScript 版本之前,先设置一些函数别名来辅助说明。Lodash 支持一种称为mixins的功能,可以用来为核心库扩展新的函数。

_.mixins({
    'select': _.plunk,
    'from': _.chain,
    'where': _.filter,
    'groupBy': _sortByOrder
});

应用此 mixin 对象后,就可以编写出以下代码:

_.from(persons)
    .where(p => p.birthYear > 1903 && p.country !== 'US')
    .groupBy(['firstname', 'birthYear'])
    .select('firstname', 'birthYear')
    .value();

像 SQL 一样,上面的 JavaScript 代码以函数的形式对数据进行建模,也就是函数即数据。因为它们是声明式的,描述了数据输出是什么,而不是数据式如何得到的。到目前为止,并不需要任何常见的循环语句,都是使用高阶抽象代替循环。

另一种用于替换循环的常见技术是递归

学会递归地思考

在 JavaScript 中,递归具有许多的应用场景,例如解析 XML、HTML 文档或图形等。

什么是递归?

递归是一种旨在通过将问题分解成较小的自相似问题来解决问题本身的技术,将这些小的自相似问题结合在一起,就可以得到最终解决方案。递归函数包含以下两个主要部分:

  • 基例(也称为终止条件)
  • 递归条件

基例是能够令递归函数计算出具体结果的一组输入,而不必在重复下去。递归条件则处理函数调用自身的一组输入(必须小于原始值)。如果输入不变小,那么递归就会无限期地运行,直至程序奔溃。随着函数的递归,输入会无条件地变小,最终达到触发基例的条件,以一个值作为递归过程的终止。

学会递归地思考

考虑:对数组中的所有数求和。先看下命令式的代码:

let acc = 0;
for(let i = 0; i < nums.length; i++) {
    acc += nums[i];
}

使用 _.reduce 来实现更简单

_(nums).reduce((acc, current) => acc + current, 0);

再来看看如果如果使用递归的话,可以怎么实现?

function sum(arr) {
    if(_.isEmpty(arr)) { // 基例(终止条件)
        return 0;
    }
    // 递归条件:使用更小一些的输入集调用自身。这里通过 _.first 和 _.rest 缩减输入集
    return _.first(arr) + sum(_.rest(arr)); 
}
sum([]); // -> 0
sum([1,2,3,4,5,6,7,8,9]) // -> 45

递归定义的数据结构

因为 JavaScript 没有内置的树形对象,所以需要基于节点创建一种简单的数据结构。节点式一种包含了当前值、父节点引用以及子节点数组的对象。如果一个节点没有父节点,则被称为根结点。以下是节点类型的定义:

class Node {
    constructor(val) {
        this._val = val;
        this._parent = null;
        this._children = [];
    }
    
    isRoot() {
        return isVaild(this._parent);
    }
    
    get children() {
        return this._children;
    }
    
    hasChildren() {
        return this._children.length > 0;
    }
    
    get value() {
        return this._val;
    }
    
    append(child) {
        child._parent = this;
        this._children.push(child);
        return this;
    }
    
    toString() {
        return `Node (val: ${this._val}, children: ${this._children.length})`;
    }
}

可以这样创建一个新节点:

const church = new Node(new Person('Alonzo', 'Church', '111-11-1111'));

树是包含了一个根结点的递归定义的数据结构:

class Tree {
    constructor(root) {
        this._root = root;
    }
    
    static map(node, fn, tree = null) {
        node.value = fn(node.value); // 调用遍历器函数,并更新树中的节点值
        if(tree === null) {
            tree = new Tree(node); // 与Array.prototype.map类似,结果是一个新的结构
        }
        if(node.hasChildren()) { // 如果节点没有孩子,则返回(基例)
            _.map(node.children, function(child) { // 将函数应用到每一个孩子节点
                Tree.map(child, fn, tree); // 递归地调用每一个孩子节点
            });
        }
        return tree;
    }
    
    get root() {
        return this._root;
    }
}

通过从根部不断地添加节点来填充一棵树,由 church 开始:

church.append(rosser).append(turing).append(kleene);
kleene.append(nelson).append(constable);
rosser.append(mendelson).append(sacks);
turing.append(gandy);

添加完节点后的树形结构如下图:

image.png

每个节点都包裹着一个 person 对象。递归算法执行整个树的先序遍历,从根开始并且下降到所有子节点。由于自相似性,从根节点遍历树和从任何节点遍历子树是完全一样的,这就是递归定义。

可以接受不了一个与Array.prototype.map语义类似的高阶函数 Tree.map——它接受一个对每个节点求值的函数,可以看出,无论用什么数据结构建模,该函数的语义应该保持不变。

看下Tree.map的运行效果

Tree.map(church, p => p.fullname); // -> 'Alonzo Church', 'Barkley Rosser', 'Elliot Mendelson', 'Gerald Sacks', 'Alan Turing', 'Robin Gandy', 'Stephen Kleene', 'Nels Nelson', 'Robert Constable'

小结

  • 使用 map、reduce、filter 等高阶函数来编写高扩展性的代码。
  • 使用 Lodash 进行数据处理,通过创建链创建控制流与数据变换明确分离的程序。
  • 使用声明式的函数式编程能够构建出更易理解的程序。
  • 将高阶抽象映射到 SQL 语句,从而深刻地认识数据。
  • 递归能够解决自相似问题,并解析递归定义的数据结构。