函数式编程概述
函数式编程倡导使用数学中的函数思想指导编程语言中函数的编写,使用函数构建程序。其背后的哲学思想是程序的本质是计算,而函数是计算的严密的精确化表示。函数式编程关注做什么,而如何做都抽象到函数中,通过函数屏蔽了如何做的复杂性。函数式编程让能程序更健壮、更易测试、易扩展、易复用和随意并发等优点。
一些书籍说函数式还能让代码更容易理解和合理,此观点我目前还没深刻的体会。实际上,生活体验告诉我,大多数人的思维方式都是逻辑不严密的、分散的,使用数学里的函数思想来指导编程,是困难的,不容易写出好代码。估计这也是函数式编程或者声明式编程不那么流行,而面向对象、面向过程等命令式编程范式大行其道的原因。
编程范式
编程范式,即编程方式或者编程原则,代表着编程语言或者编程语言的设计者或者使用者对计算机程序的基本看法。学习一门新语言式时,从编程范式去理解它的设计思想,对写好代码有很好的帮助,它更加高屋建瓴。
编程范式一般包括三个方面,以 OOP 为例:
学科的逻辑体系——规则范式:如类/对象、继承、动态绑定、方法改写、对象替换等等机制。
心理认知因素——心理范式:按照面向对象编程之父 Alan Kay 的观点,“计算就是模拟”。OO 范式极其重视隐喻(metaphor)的价值,通过拟人化,按照自然的方式模拟自然。
自然观/世界观——观念范式:强调程序的组织技术,视程序为松散耦合的对象/类的集合,以继承机制将类组织成一个层次结构,把程序运行视为相互服务的对象们之间的对话。
不同的编程范式代表不同的世界观,使用不同的编程范式编程,代码的组织方式、可读性、测试难度和健壮性,也会很不同。
有的语言只主要支持一种编程范式,比如 Haskell
只是支持函数式编程,C 只支持过程式编程,有的语言支持多种,比如 JavaScript、 C++ 支持过程式、面向对象、函数式等,是多范式语言。
非结构化编程 (Non-structured programming)
最早期的编程范式,利用 goto
或者 label
语句在代码块之间跳转,比如汇编、早期的 BASIC 等,经过计算机科学的不懈探索,发现 goto 语句有害,代码非常难以阅读和推理,这种编程范式基本不再使用。
结构化编程 (structured programming)
计算机科学发现 goto 语句的有害后,证明了只需要 顺序语句
、 循环语句
、 条件语句
就能完成编程,结构化编程就此诞生。计算机科学们对编程语言是否废除 goto
有不少争论,有人认为 goto 能使得程序清晰,有人认为使得程序不清晰,从现在的流行的编程语言来看,主张废除一派占据上风,广大程序员已经适应了没有 goto 语句的代码。
广泛使用的编程语言都是结构化编程语言。
命令式编程
使用语句改变程序状态的编程范式。需要一步一步告诉计算机如何执行代码。
let age = age + 1 // 改变 age ,即改变程序状态
// 一步一步告诉计算机如何执行代码
let array = [1, 2, 4]
const size = array.length
for (let i = 0; i < size; i++) {
console.log(array[i])
}
支持这一范式的编程语言有:C、C++、Java、JavaScript、Python 等
结构化编程又可以细分为面向对象、面向过程、面向切面等编程范式。
可以说命令式编程是程序状态或者 副作用
驱动的,根据状态执行代码。
面向对象
面向对象编程认为,任何一个事物都可抽象成一个对象,用数据(属性)表示对象的状态,用方法表示对数据的操作,对象是数据和行为的结合。
把具有相同或者相似的对象组合,抽象成类或者类别,比如自行车有 2 个轮子、轿车有 4 个轮子,但是他们都是交通工具,就可抽象一个交通工具的类统一管理这些车,需要某个类别的车,由类创建即可,当然,你也可以把自行车、汽车分成两个类,它们可继承交通工具类的一些属性。
类是对象的模板。
这是 基于类的面向对象
。支持面向对象的语言有 Java、C++、JavaScript 等。
有人认为不需要类来抽象对象,而是需要对象的原型,这就是基于原型的面对对象。JavaScript 支持这种编程范式。
基于原型和基于类有何不同呢?
不同:还了解到具体的不同。
为何很多 JavaScript 代码式基于类的风格,而不是原型?
因为面对对象的编程范式取得巨大成功,很多大规模软件都使用这种编程范式开发。
面向过程
数据和处理的数据的函数分开。比如 C 。
面向切面
根据分离关注点的原则,可把面向对象分为 面向切面
、 面向角色
、 面向学科
编程。
关于分离关注点,体会不是很深,还需要阅读相关资料。
声明式编程
与命令式编程相反,声明编程主张告诉计算机 做什么
,而不是告诉一步一步告诉计算机 如何做
。
比如 SQL
、 scheme
、 正则
等语言。
// 希望打印数组元素
let array = [1, 2, 4]
// 命令式写法
const size = array.length
for (let i = 0; i < size; i++) {
console.log(array[i])
}
// 需要声明更多的变量,手动修改下标,容易出错
// 声明式写法
array.forEach(item => {
console.log(item)
})
// 或者
array.forEach(console.log)
// forEach 的参数直接告诉计算机打印数组元素
// 更少的变量,不要手动修改下标,不容易出错
声明式编程的基础是形式逻辑,具有容易推理、容易测试等特点,缺点就是不容易写出好代码,可能和人的天生反逻辑有关,否则我们都是数学家了。
声明式编程可细分为 函数式编程
、 逻辑式编程
。
函数式编程
是一种古老的编程范式,在冯诺依曼计算机诞生之前,就已经存在了,因为数学家们研究计算已经很久了。它的理论基础是 拉姆达演算
。
命令式编程是副作用或者状态的改变驱动的,而函数式编程主张 无状态
,是函数演算驱动的,它认为程序的本质是计算,而数学函数正是计算的精确表示。
具有的特点:
无状态: 函数不维护状态。面对对象里,对象具有状态
数据不可变:输入数据不可改变,改变了就要返回新的数据
函数一等值
带有闭包
尾递归
无控制流
一等值:
一等
不是类似一流
、王牌
、顶级
的文学表达,而是编程语言的专业术语,满足下列三个条件的函数,才能是一等值:
可作为参数
可最为返回值
可赋值给变量
仅满足第一条,叫作二等值;3 条都不满足,叫三等值。 数字、字符串、布尔值等常用的值在很多编程语言中都是一等值。
纯函数式的编程语言
, 即仅支持函数式编程的语言,是没有变量的,故第三条可理解为 可赋值给常量
,比如 Haskell
。
JS 中的函数是一等值。
具有的优点:
代码容易理解
很容易修改
容易并行执行或者异步执行
容易推理,就很容易测试
劣势:
门槛比较高,写好函数式代码很难;
数据复制频繁,消耗内存。
泛型编程
泛型为程语言提供了更高层级的抽象,即参数化类型。换句话说,就是把一个原本特定于某个类型的算法或类当中的类型信息抽象出来。泛型编程提供了更高的抽象层次,这意味着更强的表达能力。
很多面向对象的语言都支持泛型。
其他常用的编程范式
- 事件驱动
程序的流程由诸如用户操作(鼠标单击、按键)、传感器输出或从其他程序或线程传递的消息等由事件决定。通常有一个主循环在监听事件的触发,然后执行回调函数。
很多高级语言都支持。
- 数据驱动
程序语句描述要匹配的数据和所需的处理,而不是定义要采取的一系列步骤。数据驱动编程类似于事件驱动编程,两者都被构造为模式匹配和结果处理,并且通常由主循环实现,尽管它们通常应用于不同的领域。
数据驱动和其他编程范式结合,可得到数据驱动的设计。react、vue、angular 三大前端框架,都主张数据驱动视图,是这一编程范式的体现。
- 数据流编程 (data flow programming)
程序的操作是数据流动。这种编程范式具有函数式编程的一些特征。
命令式编程由分支结构、循环结构、顺序结构就能完成了,但是数据是静止的,比如面对对象编程中,对象之间的数据封装在内部的,不会轻易被外部改变。
相比之下,数据流编程强调数据的移动,让数据的输入输出连接程序操作,有点像数据驱动。程序不共享状态,天然支持并行。
数据流编程可细分, 基于流编程
和 响应式编程
,响应式编程用的比较多。
响应式编程(reactive programming):使用数据流和变化驱动程序操作。
还需要了解更多。
RxJS 是 JS 的响应式编程库,在 angular 框架中被广泛使用,门槛比较高。
参考
数学中的函数
函数表达式:
F(X)=Y
一个输入 X (可以是一个数,可以是一个坐标,获取其他式子),经过变换 F 或者映射 F,得到输出 Y。当 F 满足确定一个 X,就唯一有一个 Y 时,就叫变换 F 叫函数。
确定和唯一:
函数 F 只依赖 X,只要确定 X,Y 就确定了。 F 仅依赖输入。
一个 X 只能对应一个 Y, 而一个 Y 允许对应多个 X. 这点和确定 X,就唯一确定 Y,不矛盾。同一个 X,多次按照 F 变换后,还是得到同一个结果。
只要确定 X,就能唯一确定 Y,如果编程语言的函数也能按照这个性质编写,再把这些函数组成程序,那么程序就非常容易预测,测试起来就非常简单了。
函数式编程正是这种思想指导下的编程方式。这种编程思想在计算机诞生之前,就已经存在了。计算一直被数学家、逻辑学家和哲学家研究、使用,而计算机的发明,让计算更加高效、准确和快速。
函数式中的函数
纯函数就是数学上的函数,是函数式编程的全部。
按照数学中的函数编写的函数:仅依赖输入或者参数就能完成自身逻辑的函数。函数的依赖仅有参数,这保证了相同的参数多次调用函数,返回值相同。这样函数叫纯函数,纯函数没有副作用。
函数 vs 方法
JS 中,函数是可被调用的变量,和其他变量一样,函数可作为参数和返回值。
方法,必须和一个特定的对象绑定,通过该对象调用的函数。
副作用
纯函数内部不会 改变系统状态
,当函数能改变系统状态时,就说这个函数具有副作用。可理解成希望执行某个操作,也顺带执行其他操作,这个其他操作就是副作用。
相同的参数,第一次调用和第二次的结果不同,往往是因为函数产生副作用。
slice
是纯的, splice
是不纯的。
const numbers = [1, 2, 3, 4, 5, 6]
// 纯的,多次调用,返回值相同,且不会修改 numbers
numbers.slice(0, 3) // [1,2,3]
numbers.slice(0, 3) // [1,2,3]
numbers.slice(0, 3) // [1,2,3]
numbers.splice(0, 3) // [1,2,3]
// numbers 被修改成 [4,5,6] 即产生了副作用
numbers.splice(0, 3) // [4,5,6]
// numbers 被修改成 [] 即产生了副作用
numbers.splice(0, 3) // []
// numbers 被修改成 [] 即产生了副作用
函数式编程中,希望消除副作用。
再来一个例子:
const minAge = 21
// 不纯的,因为它依赖外部变量,相同的输入,会因为外部变量得到不同的输出
const checkAge1 = age => age >= minAge
// 纯的
const checkAge2 = age => {
const minAge = 21
return age >= minAge
}
不纯的函数返回值取决于系统状态(system state);这一点令人沮丧,因为它依赖了外部的环境,从而增加了认知负荷(cognitive load)。
依赖外部状态是影响系统复杂度的罪魁祸首。输入值之外的因素能够影响返回值,不仅让它变得不纯,而且导致每次我们思考整个软件的时候都痛苦不堪。
试想,当你看到 checkAge1 时,给它一个参数,你要知道外部变量 minAge 才能预测它的结果,你可能又在项目里到处寻找 minAge 是什么,要是需要修改,你可能要考虑想改两个地方(程序一旦写下,希望排查问题或者需求变更时,对代码改动越少越好)。
而 checkAge2 就不存在这个问题。
可以让 minAge 成为一个不可变(immutable)对象:
const immutableState = Object.freeze({
minAge: 21
})
我认为不可变对象还是不如纯函数,因为认知负荷还是没有减少。
这让我想起,动车供电系统的设计:
图中 v 字形的装置叫 受电弓
,随车移动,可折叠取回车内,其上方的那个横条和高压电线接触。
电线和受电弓上横条长期高速摩擦,肯定会有损耗,极端的情况就是电线被摩断了,那么就可能导致列车减速甚至停止运行,但是我们从来没听说过这样的事故,这是为何?
不得不提及受电弓的巧妙设计 ---- 上方和电线接触的横条是 石墨
,导电好,但是硬度不及电线,因此石墨会先被磨损坏掉。
一辆列车上有很多受电弓,一个坏了,不会给列车造成很大的影响,这和软件里的 容灾设计
异曲同工。
受电弓坏了,列车到站修复,又马上上路,要是电线那点断了,要到深山老林去检修,理论上电线上每个点都可能坏,动辄几千公里的高压电线,下面还有列车行驶,无疑有很大的安全隐患。(实际上检查电线时不会一公里一公里地检查,但是了解极端情况就好。)
而让受电弓先磨损,就很方便停车时修理了,工程设计中这叫 集中伤害
,为了能 集中检测
、 集中修理
。
而软件设计中,扩展功能时,修改的地方越少越好,是 低耦合高内聚
的体现,和 集中伤害集中修理
有异曲同工之妙。
纯函数是依赖最小的方式,也是能集中伤害的方式。当需要扩展程序的功能时,只需添加新的函数。当修复 bug 时,由于纯函数只依赖参数,只需修改函数,而不用考虑其他外部依赖。
再理解副作用
副作用
参与计算的代码单元(变量、函数调用、包块、语句等)对整个表单式的影响,仅限于代码单元的返回值,就认为该代码单元没有副作用(side effect),反之,就认为它有副作用。
let [a, b, c] = [1, 2, 3]
const d = (a + b) * c++
const e = c * c
分析
c++
, 参与计算 d ,然后自增 1,会影响后面 c 参与的计算;
a+b
, 这个代码单元不会影响后续的计算,即把 a+b
的结果使用一个数值代替,不会有任何差别。
常见的副作用:
- 修改文件系统;
- 往数据库插入数据;
- 发生 http 请求;
可变数据
;- 获取用户输入,比如调用alert或者confirm;
- DOM 操作;
- 修改系统状态,即改变全家变量;
- 赋值操作。
- 抛出错误。
等等。
只要是跟
函数外部环境
发生的交互就都是副作用。
无副作用,还可以编程吗?函数式编程的哲学就是假定副作用是造成 bug 的主要原因,当然可以编程。
不是禁止副作用,而是减少让副作用,让副作用可控,副作用难以预测,无法定位 bug。
副作用让函数 不纯
,纯函数必须要能够根据相同的输入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了。
纯函数的好处
数学中的函数,相同的输入,不管计算多少遍,都得到相同结果。比如 sin(x)
, sin(π/6)
,不管你求多少次,都是 0.5
。
相同的参数,函数执行执行多次,返回值都相同,这种特性叫 引用透明(referential transparency)
,引用透明的函数的依赖仅有参数。
关于引用透明
引用透明是分析哲学的一个概念,该学科研究自然语言的语义和含义。 如果一个句子中的实体(事物)被另一个相同的实体代替,不改变原来的含义,这个句子就是引用透明的。
一个例子:
[北京]比老鼠大 是真;
[中国的首都]比老鼠大 是真;
[(116.46E, 39.92N)的城市]比老鼠大 是真;
把 北京
替换成 中国的首都
、 北京的经纬度
,都不改变语义。这些命题的真正含义不会因为我们选择不同的引用或指代名称而受到影响。我们就可以认为这种替换是引用透明的。
与引用透明相反,蒯因(美国哲学家和逻辑学家)认为存在引用不透明( REFERENTIALLY OPAQUE),名称的改变对于整个语句意思非常重要。
比如: 北京有2个字符
是真。但是:中国的首都有 2 个字符 是假。
这样场景 xx有2个字符
的含义因为名称指称(name/reference)不同而不同。
引用透明
个人认为,引用透明翻译得不好, 指代等价
或者 替换等价
更加容易理解,也更加契合数学的等价。
数学中的函数是引用透明的。
sin(π/6)
和 0.5
是等价的,sin(π/6) 参与计算的式子中,可直接使用 0.5 替换,这叫 替换模型(substitution model)
,如果一个函数是引用透明的,就可直接使用它的返回值替换函数执行过程。这使得并发、缓存、预测等轻而易举。
纯函数的另一种定义
满足两个条件: ① 无副作用; ② 返回值仅依赖于参数的,即相同输入,必须得到相同的输出。
数学函数相同的输入必定得到相同的输出,这很自然,但是程序中的函数的输出可能依赖隐藏的状态,比如当前时间。
判断一个函数是否为纯函数的方法:在调用的地方使用它的返回值替换,多次运行程序,看看是否有不同的结果。
纯函数有诸多好处:
- 可缓存
既然纯函数对于相同的输入,都得到相同的返回,那么参数第一次执行的结果,都可缓存起来,下次在调遇到相同的参数,从缓存中获取结果。
function factorial(n) {
if (n === 0) {
return 1
} else {
return n * factorial(n - 1)
}
}
const memorized = fn => {
const factorialObj = {}
return n => (factorialObj[n] ? factorial[n] : (factorialObj[n] = fn(n)))
}
const fastFactorial = memorized(factorial)
console.time('cache')
console.log(fastFactorial(40))
console.timeEnd('cache') // 8.113ms
console.log('**************')
console.time('no cache')
console.log(factorial(40))
console.timeEnd('no cache') // 0.061ms
没缓存的函数更快速,不符合预期,这是为何?
- 可移植/自文档化 (Portable / Self-Documenting)
纯函数是完全自给自足的,它仅依赖参数。移植方便。
因为函数式编程让开发者只关注 做什么
,没有如何做的细枝末节,具由很强的自文档化。
const numbers = [1, 2, 3]
// 命令式: 需要维护下标,修改麻烦,容易出错,移植困难,理解困难
for (let i = 0, len = numbers.length; i < len; i++) {
console.log(number[i])
}
// 函数式: 一个函数搞定,只需关注参数和 console.log() 修改容易,移植容易,理解容易
numbers.forEach(item => {
console.log(item)
})
// 或者
numbers.forEach(console.log)
再来一个例子:
// 不纯的
const signUp = function(attrs) {
const user = saveUser(attrs);
welcomeUser(user);
};
const saveUser = function(attrs) {
const user = Db.save(attrs);
// ...
};
const welcomeUser = function(user) {
Email(user, ...);
// ...
};
// 纯的
const signUp = function(Db, Email, attrs) {
return function() {
const user = saveUser(Db, attrs);
welcomeUser(Email, user);
};
};
const saveUser = function(Db, attrs) {
// ...
};
const welcomeUser = function(Email, user) {
// ...
};
命令式编程中“典型”的方法和过程都深深地根植于它们所在的环境中,通过状态、依赖和有效作用(available effects)达成;
纯函数与此相反,它与环境无关,只要我们愿意,可以在任何地方运行它,保证了纯函数可移植。
vue2 的 options API ,方法、状态都依赖组件的 this,使得组件之间共享逻辑非常困难,vue3 转到 composition API 这个问题就减轻很多,我们可以复用函数来实现共享逻辑。
Erlang 语言的作者 Joe Armstrong 说的这句话:“面向对象语言的问题是,它们永远都要随身携带那些隐式的环境。你只需要一个香蕉,但却得到一个拿着香蕉的大猩猩... 以及整个丛林”。
自文档化,其实很大程度上取决于命名和编程语言的特性。
- 可测试性(Testable)
只需简单地给函数一个输入,然后断言输出就好了。
QuickCheck ---- 纯函数测试工具,JS 版本不受欢迎。
函数式编程提倡编写小的函数,一个函数只做一件事,复杂的任务通过函数的组合来完成,函数越小越容易测试。
- 合理性(Reasonable)
纯函数的执行结果,可使用它的返回只替换,和符合人类的直觉。
- 并行执行
这一点是决定性的,可
并行运行
任意纯函数。
因为纯函数根本不需要访问共享的依赖,比如内存、全局变量等,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。
并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。
如何编写纯函数
前面对纯函数的定义,都是结果导向的即需要先有一个函数,再从函数的执行情况判断是否为纯函数,无法指导程序员编写出纯函数。也就是说,判断一个函数是否为纯函数,还需要可操作的方式。
满足这两个条件,一定为纯函数:
纯函数
① 不修改外部变量; ② 不读取外部变量,仅依赖参数。
限制第一条,是为了不产生副作用。这个条件过于严格,其实,只要保证满足第二条约束,即使被修改的变量不被自己和其他代码读取,修改的变量为 只写
变量,即使函数修改了外部变量,副作用就不对程序产生影响。
限制第二条,是为了保护子不受到外部变量影响,仅依赖参数才能保证相同的输入得到相同的输出。
依据以上分析,JS 编写纯函数时,需要遵循的规则:
不改变参数,参数为对象和数组时,是可变的。
必须有返回值。
没返回值是副作用驱动的特征。
函数内部的副作用
JS 不是专门的函数式编程语言,完全消除副作用是困难的,也是不必要的。换句话说,要限制副作用仅限于函数内部。
JS 的很多操作和内置函数都是具有副作用的。
不改变参数,复制参数,会有性能损耗,尤其是频繁调用的函数,所以以下情况,允许改变参数:
根据参数返回一个稍微修改的对象或者数组。
递归调用。
不变性
函数式编程强调数据不变性(immutability)。数据不能改变,只能被替换,就具有不变形。js 中基本数据类型都是不变的,数组、对象和函数是可变的。
可借助一些开源库来实现数据不可变,比如immutable
使用复制、冻结等实现数组的不变性,比较繁琐,计算机科学家想出了无需整体复制即可实现不可变的数据结构 ---- 共享结构体。
其他
导致代码难以理解的原因:
命名混乱的原因: 同一概念使用不同的命名
。
通用代码的问题: 在命名时容易把名字绑定到具体的数据上
。
相同或者相似的功能参数不同的函数,命名差异大:比如相同的功能的 http 接口,参数不同,会增加理解成本。
// 只针对当前的博客
const validArticles = articles =>
articles.filter(article => article !== null && article !== undefined),
// better
const compact = xs => xs.filter(x => x !== null && x !== undefined);
// best
const filterResult = array = array.filter(item => ![null, void 0].includes(item))
使用函数时必须小心
this
const fs = require('fs')
// 太可怕了
fs.readFile('freaky_friday.txt', Db.save)
// 好一点点
fs.readFile('freaky_friday.txt', Db.save.bind(Db))