读书笔记:Javascript函数式编程指南(一)

本博客 hjy-xh,转载请申明出处

> 面向对象编程(OO)通过封装变化是的代码更易理解。
> 函数式编程(FP)通过最小化变化使得代码更易理解。

Michael Feathers 是世界级面向对象技术专家,以丰富的软件项目开发经验著称。

简明扼要,可见函数式编程的优点。

什么是函数式编程?

简单来说:函数式是一种强调以函数使用为主的软件开发风格。

这听起来很平常,好像我们平常也都是这么做的。

先讲概念:函数式编程的目标是使用函数来抽象作用在数据之上的控制流与操作,从而在系统中消除副作用减少对状态的改变

没有栗子就是在耍流氓:

1
document.getElementById("#msg").innerHTML = "<h1>Hello world</h1>";

代码很简单,但是写死了,也就不能动态显示消息。

如果想改变消息的格式,内容或者目标 DOM 元素,需要重写整个表达式。

我们给它改进一下:

1
2
3
4
const printMessage = (elementId, NodeType, message) {
document.getElementById(elementId).innerHTML = `<${NodeType}>${message}</${NodeType}>`;
}
printMessage("msg", "h1", "Hello world");

这样好一点,但是重用性仍然不高,接下来改成函数式:

1
2
3
4
// 函数定义
const printMessage = run(addToDOM("msg"), h1, echo);

printMessage("Hello world");

这段代码与之前的完全不同: h1不再是一个量值,它与addToDOM,echo都是函数,这样看上去像是用一些较小的函数构建成一个新的函数。

代码写成这样的原因是什么?

将代码分解成一些更可重用,更可靠且易于理解的部分,再将其组合起来,形成一个更易推理的程序整体。

上面的代码有一个神奇的函数run,它序列地调用一系列函数。

在后台,run函数基本上是通过将一个函数的返回值作为下一个函数的输入,以这张方式将各个函数连接起来。这样,由echo返回字符串,被传递到h1中,其结果最终又被传递到addToDOMrun函数的细节之后会解释。

上面比较了函数式和非函数式的编程方案,尽管他们结果相同,但是实现过程截然不同。这是源于函数式编程开发中固有的声明模式,为了充分理解函数式编程,我们先来了解一些基本概念:

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

声明式编程

函数式编程属于声明式编程范式:这种范式会描述一系列的操作,但并不会暴露它们是如何实现的或是数据流如何穿过它们。

目前更加主流的是命令式或者过程式的编程范式,如 Java、C#、C++和其他大多数结构化语言和面向对象语言都对其提供支持。

命令式编程将计算机视为一系列自上而下的断言,通过修改系统的的各个状态来计算最终的结果。

看个栗子:

1
2
3
4
5
let array = [0, 1, 2, 3, 4, 5, 6];
for (let i = 0; i < array.length; i++) {
array[i] = Math.pow(array[i], 2);
}
// [ 0, 1, 4, 9, 16, 25, 36]

命令式编程很具体地告诉计算机如何执行某个任务。

而声明式编程将程序的描述与求值分离开来。它关注于如何用各种表达式来描述程序逻辑,而不一定要指明其控制流或者状态的变化。

函数式:

1
2
let array = [0, 1, 2, 3, 4, 5, 6];
array.map((num) => Math.pow(num, 2));

函数式编程只需要对应用在每个数组元素上的行为予以关注,将循环交给系统的其他其他部分去控制。

副作用带来问题和纯函数

函数式编程基于一个前提:即使用纯函数构建具有不变性的程序。

它具有以下性质:

  • 仅取决于提供的输入,而不依赖于任何在函数求值期间或调用间隔时可能变化的隐藏状态或外部状态
  • 不会造成超出其作用域的变化,例如修改全局对象或引用传递的参数

栗子:

1
2
3
4
5
6
let counter = 0;
const add = () => ++counter;

add(); // 1
counter++; // 2
add(); // 3

这个函数是不纯的,因为它读取并修改了一个外部变量,即函数作用域外的 counter。

一般来说,函数在读取或写入外部资源时都会产生副作用,例如上面的代码中,counter可以在调用间隔的任何时间发生改变。

另一个常见的函数是Date.now(),它的输出是不可预见的,并且不一致,因为它依赖于一个不断变化的因素–时间。

常见的副作用:

  • 改变一个全局的变量、属性或数据结构
  • 改变一个参数的原始值
  • 处理用户输入
  • 屏幕打印
  • 查询 cookie

如果没有这些操作,那程序会有什么实用价值?

事实上,在一个充满了动态行为与变化的世界里,纯函数确实很难使用。

但是,函数式编程在实践上并不限制一切状态的改变,它只是提供了一个框架来帮助管理和减少可变状态,同时让你能够将纯函数从不纯的部分中分离出来。

栗子:
假设有一段程序,它能够通过身份证(ID)来找到一个人的记录并渲染在浏览器中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const showPerson = (ID) => {
// 查询数据库
const person = db.get(ID);
// 判断是否有这个人
if (person !== null) {
// 渲染操作
document.getElementById(
`${elementId}`
).innerHTML = `${person.ID}:${person.name}`;
} else {
throw new Error("404");
}
};

showPerson(123);

分析代码:

  • 该函数访问数据,与外部变量db进行了交互,因为该函数签名中并没有声明该参数,在任意时间,这个引用可能为null,或在调用间隔内改变,从而导致完全不同的结果并破坏了程序的完整性
  • 全局变量elementId可能随时改变,难以控制
  • HTML 元素被直接修改了,HTML 文档(DOM)本身是一个可变的、共享的全局资源
  • 如果没有找到记录,该函数可能为抛出异常,导致整个程序的栈回退并突然结束

用函数式思想改进:

  • 将这个长函数分离成多个具有单一职责的短函数
  • 通过显示地将完成功能所需的依赖都定义为函数参数来减少副作用的数量

首先通过分离显示与获取数据的行为。

当然,与外部存储系统和 DOM 交互所产生的副作用是不可避免的,但至少可以通过将其从主逻辑中分离出来的方式使它们更易于管理。

这里需要引入一种常见的函数式编程技巧–柯里化,使用它,可以允许部分地传递函数参数,以便将函数的参数减少为一个,之后会再介绍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 分离函数

const find = () => {
curry((db, id) => {
const obj = db.get(id);
if (obj === null) {
throw new Error("404");
}
return obj;
});
};

const message = (person) => {
return `${person.ID}:${person.name}`;
};

const append = () => {
curry((elementId, info) => {
document.getElementById(elementId).innerHTML = info;
});
};

const showPerson = run(append("msg"), message, find(db));

showPerson(123);

尽管这个程序只有些许改进,但是它开始展现出许多优势:

  • 灵活很多,因为有了三个可以被复用的组件
  • 这种细粒度函数的重用是提高工作效率的一种手段,因为可以大大减少需要主动维护的代码量
  • 声明式代码风格提供了程序需要执行的那些高阶步骤的一个清晰试图,增强了代码的可读性
  • 将纯函数从不纯的行为中分离出来

由于一些我们会在后续了解到的原因,能够确保一个函数有相同的返回值是一个优点,它使得函数的结果是一致的和可预测的。这是纯函数的一个特质,称为引用透明

引用透明和可置换性

引用透明是定义一个纯函数正确的方式。纯度在这个意义上表明了一个函数的参数和返回值之前映射的纯的关系。

因此,如果一个函数对于相同输入始终产生相同的结果,那么就说它是引用透明的。

1
2
let counter = 0;
const add = () => ++counter;

这个函数不是引用透明的,因为其返回值严重依赖外部变量 counter。改造:

1
const add = (counter) => ++counter;

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

现在这个函数是稳定的,对于相同输入每次都返回相同的输出结果。

之所以追求这种函数的特质,是因为它不仅能让代码更易于测试,还可以让开发人员更容易推理整个程序

假设现在要求一个数组中的平均值:

1
2
3
4
5
6
7
8
9
const input = [20, 30, 40];

const sum = (total, current) => total + current;
const total = (arr) => arr.reduce(sum);
const size = (arr) => arr.length;
const divide = (a, b) => a / b;
const average = (arr) => divide(sum(arr), size(arr));

average(input);

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

1
2
3
divide(90, 3);

90 / 3 = 30;

存储不可变数据

不可变数据是指那些被创建后不能更改的数据。

与许多其他语言一样,JavaScript 中的所有基本类型(String、Number 等)从本质上是不可变的。但是其他对象,例如数组,都是可变的——即使他们作为输入传递给另一个函数,仍然可以通过改变原有内容的方式产生副作用。

1
2
3
4
5
6
7
8
9
const sortDesc = (arr) => {
return arr.sort((a, b) => b - a);
};

let input = [1, 2, 3, 4, 5, 6, 7];
let output = sortDesc(input);

console.log(input); // [7, 6, 5, 4, 3, 2, 1]
console.log(output); // [7, 6, 5, 4, 3, 2, 1]

排序的目的达到了,但是产生了副作用,即改变了输入。

小结

现在我们已经了解了函数式编程的一些基本原则(如声明式的、纯的和不可变的),那可以这么描述它:

函数式编程是指为创建不可变的程序,通过消除外部可见的副作用,来对纯函数的声明式的求值过程。

函数式编程的优点

  • 促使开发人员将任务分解成更小的任务(函数)
  • 使用流式的调用链来处理数据
  • 通过响应式范式降低是将驱动代码的复杂性

复杂任务的分解

从宏观上讲,函数式编程实际上是分解(将程序拆分成小片段)和组合(将小片段连接到一起)之间的相互作用。正是这种二元性,使得函数式如此模块化和高效。

这里的模块化就是函数本身,函数式思维的学习通常始于将特定任务分解为逻辑子任务(函数)的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const find = () => {
curry((db, id) => {
const obj = db.get(id);
if (obj === null) {
throw new Error("404");
}
return obj;
});
};

const message = (person) => {
return `${person.ID}:${person.name}`;
};

const append = () => {
curry((elementId, info) => {
document.getElementById(elementId).innerHTML = info;
});
};

const showPerson = run(append("msg"), message, find(db));

showPerson(123);
1
2
3
                find
showPerson → message
append

如果需要,这些子任务可以进一步分解,直到成为一个个简单的,相互独立的纯函数功能单元。

可以看出,函数式编程的模块化概念与单一职责原则息息相关,也就是说,函数都应该拥有单一的目的。纯度和引用透明会促使我们这样思考问题。

还记得上面说的那个run函数吗?

现在揭秘这个黑魔法:实际上run函数是一个极为重要的技术的别名 —— 组合

两个函数的组合是一个新的函数,它拿到一个函数的输出,并传递到另一个函数中。讲述有两个函数fg,形式上,组合可以如下描述:

1
f · g = f(g(x))

这个公式读作“f 组合上 g”,它在 g 的返回值与 f 的参数之间构建了一个松耦合的且类型安全的联系。

两个函数能够组合的条件是,它们必须在参数数目及参数类型上形成一致。

现在用compose构建组合函数showPerson:

1
2
const showPerson = compose(append("#msg"), message, find(db));
showPerson(123);

关于组合,先了解这么多。

到此为此,我们可以看到:函数式的组合提高了抽象的层次,可以清晰地够勾勒代码的所有步骤,但又不暴露任何底层细节在此代码执行的所有步骤。

由于compose接受其他函数为参数,这被称为高阶函数,但组合并不是构建流式的、模块化的代码的唯一方式。

使用流式链来处理函数

除了map,我们还可以通过导入一些函数式类库来获得更多高阶函数,比方说lodash

假设一个场景:需要计算那些选择多门课程的学生的平均分

1
2
3
4
5
const data = [
{ number: 2, grade: 100 },
{ number: 3, grade: 80 },
{ number: 1, grade: 89 },
];

命令式代码可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let totalGrade = 0;
let totalStudentsFound = 0;

for (let i = 0; i < data.length; i++) {
let student = data[i];
if (student !== null) {
if (student.number > 1) {
totalGrade += student.grade;
totalStudentsFound++;
}
}
}

const average = totalGrade / totalStudentsFound;

用函数式思维来分解这个问题:

  • 选择合适的学生(选课数量大于 1 的)
  • 获取他们的成绩
  • 计算平均值

通过lodash把这些操作连接起来:

1
2
3
4
5
_.chain(data)
.filter((student) => student.number > 1)
.pluck("grade")
.average()
.value(); // 执行链式队列并提取解链后的值。

我们先不用太在意发生了什么,与命令式版本比较,并注意如何消除变量的声明和变化,以及循环和判断语句。

诸如循环和逻辑分支这样的很多命令式控制流机制,会提高函数的复杂程度,因为它们会根据某些条件不同而执行不同的行为,难以测试。

上面代码略过了一些错误处理代码,我们可以利用一些纯函数式的设计模式来处理它,这个以后再说。

复杂异步应用中的响应

大家经历过回调地狱吗?

它打破了线性的代码结构,使代码变得难以阅读,因为它的成功处理和失败处理的逻辑混杂在一起。

我们经常要处理在服务端或者客户端的异步和事件驱动代码,可以用响应式编程来大幅降低这些代码的复杂性

响应式是一种范式,是函数式编程应用之一,采用它的好处在于能够提高代码的抽象级别,从而更专注业务逻辑。这种范式能够充分利用函数式编程中函数链和组合的样式。

事件有很多种:鼠标点击、文本变化、焦点变化、HTTP 请求处理或者文件写入等等。假设现在根据输入的 ID 号码查询用户是否存在,命令式代码可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let valid = false;
const element = document.getElementById("#input");
element.onkeyup = (event) => {
let val = element.value;
if (val !== null && val.length !== 0) {
// 可能进行的裁剪或者清理输入,直接改变数据
val = val.replace(/^[a-zA-Z0-9_-]{4,16}$/, "");
// 可能存在的嵌套逻辑分支
if (val.length === 9) {
valid = true;
}
} else {
console.log("404");
}
};

我刚学编程那会写过类似结构的代码,一大段逻辑塞在一起。但是对于这样一个简单的任务,编码从一开始就变得复杂,这样除了增加心智负担,代码也无法重用。

来康康响应式,这种白痴南横范式使用了一个叫做observable的概念,它能够订阅一个数据流,让开发者通过使用组合和链式操作来优雅地处理数据。

代码:

1
2
3
4
5
Rx.Observable.fromEvent(document.getElementById("#input"), "keyup")
.map((input) => input.srcElement.value)
.filter((val) => val !== null && val.length !== 0)
.skipWhile((val) => length === 9)
.subscribe(() => console.log("404"));

它和前面的例子相像,这说明,无论是处理集合元素序列或者用户输入序列,一切都被抽象出来,这使得可以用相同的方式去处理数据。

上面的代码的操作是不可变的,并且所有业务逻辑被分割成单独的函数。并不是必须要响应式地使用函数,但函数式的思维会迫使开发者这么做。

总结

  • 使用纯函数的代码绝不会更改或破坏全局状态,有助于提高代码的可测试性和可维护性
  • 函数式编程采用声明式的风格,易于推理。这提高了应用程序的整体可读性,通过使用组合和 lambda 表达式使函数更加精简
  • 集合中数据元素可以通过链接如 map 和 reduce 这样的函数来实现
  • 函数式编程将函数视为积木,通过一等高阶函数来提高代码的模块化和可重用性
  • 可以利用响应式编程组合各个函数来降低驱动程序的复杂性