写在前面的话
之前想看函数式编程时找了许多资料,许多对这个只是大概的描述了一段,然后看到有人推荐这本书,所以写下这本读书笔记,给有需要的人参考。
第一章 函数式编程简介
函数的第一条原则是要小。函数的第二条原则是要更小。 ——ROBERT C.MARTIN
1.1 什么是函数式编程?
首先看数学中的函数是什么?
f(x) = Y
- 函数必须总是接受一个参数
- 函数必须总是返回一个值
- 函数应该依据接收到的参数(X)而不是外部环境运行
- 对于一个给定的X,只会输出唯一的一个Y
函数式编程技术主要基于数学函数
和它的思想
。
看个栗子:
var percentValue = 5;
var caculateTax = (value) => {
return value/100 * (100 + percentValue)
}
这个caculateTax
乍一看没有问题,但是用数学的定义分析,它依赖于全局变量percentValue
, 因此在数学意义上不能被称作真正的函数。修改成:
var caculteTax = (value, percentValue) => {
return value/100 * (100 + percentValue)
}
现在caculateTax
算得上一个真正的函数了,通过这个简单的栗子,可以简单定义一下函数式编程:函数式编程是一种范式,我们能够一次创建仅依赖输入就可以完成自身逻辑的函数。
这保证了当函数被多次调用时仍然返回相同的结果,不会改变任何外部环境的变量,这将产生可缓存、可测试的代码库。
函数与javascript方法
函数
是一段可以通过其名称被调用的代码。它可以传递参数并返回值。
var simple = (a) => { return a }
// 一个简单的函数
simple(5)
// 用其名称调用
然而方法
是一段必须通过其名称及其关联对象的名称被调用的代码。
var obj = { simple : (a) =>{ return a } }
obj.simple(5)
// 用其名称及其关联对象调用
1.2 引用透明性
所有的函数对于相同的输入都将返回相同的值,这一特性被称为引用透明性。
一个简单的函数
var identity = (i) => { return i }
该函数满足引用透明性,现在它被用于其它函数调用之间。
sum(4, 5) + identity(1)
根据引用透明性,上面的语句可以转换成
sum(4, 5) + 1
这个过程被称为替换模型
, 因为可以直接替换函数的结果(主要是因为函数的逻辑不依赖其它全局变量),这与它的值是一样的。这也使得并发代码和缓存成为可能。
根据该模型想象一下,这上面的代码可以轻松地用多线程运行,甚至不需要同步,为什么?同步的问题在于线程不应该在并发运行的时候依赖全局数据,遵循引用透明性的函数只能依赖来自参数的输入。因此线程可以自由地运行,没有任何锁机制。
引用透明性是一种哲学
“引用透明性”一词来自分析哲学。该哲学分支研究自然语言的语义及其含义。单词“Referential”或“Referent”意指表达式引用的事物。句子中的上下文是“引用透明的”,如果用另一个引用相同实体的词语替换上下文中的一个词语,并不会改变句子的含义。
替换函数的值并不影响上下文,这就是函数式编程的哲学!
1.3 命令式、声明式与抽象
函数式编程主张声明式编程和编写抽象的代码。
来看个栗子:
// 打印数组的每个元素
var array = [1, 2, 3]
for (i = 0; i < array.length; i++)
console.log(array[i]) // 打印 1, 2, 3
在这里我们用数组长度的索引计算结果编写了一个隐式的for循环并打印出数组项,而我们的任务是什么?"打印数组的元素",但是看起来像是在告诉编译器该做什么,在上面栗子中,我们告诉编译器“要获得数组长度、循环数组、用索引获取每一个数组元素,等等”我们将之称为“命令式
”解决方案。命令式编程主张告诉编译器“如何”做。
现在来看另外一方面,声明式编程。在声明式编程中,我们要告诉编译器做“什么”,而不是“如何”做。“如何”做的部分将被抽象到普通函数中(这些函数被称为高阶函数),更改如下:
var array = [1, 2, 3]
array.forEach((element) => {
console.log(element) // 打印 1, 2, 3
})
这个代码片段移除了如何做的部分,只关心做什么的部分。
函数式编程主张以抽象的方式创建函数,这些函数能够在代码的其他部分被重用。
1.4 纯函数
大多数函数式编程的好处来自于编写纯函数,那么什么是纯函数?
纯函数:对给定的输入返回相同的输出的函数。
var double = (value) => value * 2
这个"double"函数就是一个纯函数,遵循引用透明性。
- 纯函数产生可测试的代码
不纯的函数具有副作用,例如之前的这个函数
var percentValue = 5;
var caculateTax = (value) => {
return value/100 * (100 + percentValue)
}
caculateTax
不是纯函数,因为它依赖外部环境计算其逻辑。假设在运行相同的测试用例时,外部环境也在改变变量percentValue
的值,那么之前给的定输入对应的就不是唯一的值了。
纯函数有一个重要属性:纯函数不应改变任何外部环境的变量。
换而言之,纯函数不应依赖任何外部变量也不应改变任何外部变量。
// 举个反例
bar global = 'globalValue'
var badFunction = (value) => {
global = 'changed'
return value * 2
}
看看这个反例,当badFunction
被调用时,它将全局变量global
的值变更了,假设另一个函数的逻辑依赖global
变量的话,调用badFunction
就影响了其他函数的行为。具有这种性质的函数会使得代码库变得难以测试。
- 合理的代码
通过创建和使用纯函数,能够让我们非常简单的推理代码或函数。函数(无论它是否为纯函数)必须总是具有一个有意义的名称。例如double
加倍函数就不能命名成dd
。
用一个内置的
Math.max
函数来测试一下推理能力。
给定函数调用:
Math.max(3, 4, 5, 6)
为了给出结果,是不是没有看max的实现?为什么?因为Math.max是纯函数。
1.5 并发代码
纯函数总是允许我们并发地执行代码,因为纯函数不会改变它的环境。当然,js并没有真正的多线程来并发地执行函数,但是如果有一段Node环境中的服务端断面需要并发地执行函数,该怎么做?
举个栗子:
let global = "something"
let function1 = (input) => {
// 处理input
// 改变global
global = "somethingElse"
}
let function2 = () => {
if (global === "something") {
// 业务逻辑
}
}
如果我们需要并发的执行function1
和 function2
,假设线程T-1选择function1
执行,线程T-2选择function2
执行,如果T-1在T-2之前执行,那么并发执行这些函数就会引起不良反应,现在把它们改为纯函数。
let function1 = (input, global) => {
// 处理input
// 改变global
global = "somethingElse"
}
let function2 = (global) => {
if (global === "somethins") {
// 业务逻辑
}
}
上述两个移动了global
变量,把它作为两个函数的参数,使它们变成纯函数。由于函数并不依赖外部环境(global 变量),因此不必像之前那样担心线程的执行顺序。
1.6 可缓存
纯函数总是为给定的输入返回相同的输出,所以缓存纯函数的输出也是可能的。
举个栗子,有这么一个做耗时计算的函数。
var longRunningFunction = (ip) => {
// do long running tasks and return
}
然后我们有一个记账对象,它存储了longRunningFunction
函数的所有调用结果。
var longRunningFnBookKeeper = {
2: 3,
4: 5,
...
}
使用纯函数的定义,在调用longRunningFunction
之前检查key
是否存在longRunningFnBookKeeper
中,比如说:
// 检查key是否在longRunningFnBookKeeper中
// 如果在,则返回结果,否则更新记账对象
longRunningFnBookKeeper.hasOwnProperty(ip) ?
longRunningFnBookKeeper[ip] :
longRunningFnBookKeeper[ip] = longRunningFunction(ip)
这样就是缓存了纯函数的调用结果。
1.7 管道与组合
使用纯函数,我们只需要在函数中做一件事。纯函数应该被设计为只做一件事。只做一件事并把它做到完美是UNIX的哲学,我们在实现纯函数时也将遵循这一原则。UNIX和LINUX平台有很多用户日常任务的命令,例如:cat
用于打印文件内容,grep
用于搜索文件,wc
用于计算行数。这些命令的确一次只解决一个问题,但是可以通过组合或者管道来完成复杂的任务。假如我们要在一个文件中找到一个特定的名称并统计它出现次数,在命令提示符中命令如下:
cat jsBook | grep -i "composing" | wc
组合不是UNIX/LINUX命令行独有的,但它们是函数式编程范式的核心。称为函数式组合。
javascript不支持组合函数函数的操作符"|",但是我们可以创建自己的支持组合的函数。
1.8 纯函数是数学函数
先见1.6 “可缓存”中的那段longRunningFunction
和longRunningFnBookKeeper
代码。
假设通过多次调用,longRunningFnBookKeeper
增长为如下的对象:
longRunningFnBookKeeper = {
1: 32,
2: 4,
3: 5,
5: 6,
11: 44
}
分析一下该对象,可见longRunningFnBookKeeper
接受一个输入并为给定的范围映射输出,此处的关键是,输入具有强制的、相应的输出。在key中也不存在映射两个输出的输入。
比对数学函数的定义(维基百科看不了),基本上与纯函数一致,从上述例子也能看到数学函数的思想被借鉴到函数式范式的世界。
1.9 javascript是函数式编程语言吗
javascript是函数式编程语言,函数式编程主张函数必须接受至少一个参数并返回一个值。坦率的说,可以创建一个不接收参数并且实际上什么也不返回的函数,例如这个:var useless = () => {}
,这串代码执行并不会报错,原因是javascript不是一种纯函数语言(比如说Haskell),而更像是一种多范式语言。
javascript支持将函数作为参数,以及将函数传递给另一函数等特性——主要原因是javascript将函数视为一等公民,由于函数定义的约束,开发者需要在创建javascript函数时将其考虑在内。