不可变
函数式编程的四个概念
函数式的世界围绕四个基本概念展开:常量、纯函数、变量、脏函数。要理解这些概念,需要先建立对生命周期和作用域的认知。
- 生命周期:在程序运行期间,一个对象或者数据从创建到销毁的那一段时间。创建 -> 读取* n / 更新* n -> 销毁,(6 个 hook)。常量只会经历创建、读取和销毁,而变量会经历更新。
- 作用域:全局作用域和局部作用域,全局作用域对程序的所有部分可见,生命周期贯穿整个程序运行期间,一次程序运行期间只会被展开一次,由此导致了其作用域中的状态只能经历一次创建和销毁;局部作用域对某个部分的程序和代码可见。
- 所有权:状态具有所有权归属,往往是声明这个状态的最近一级作用域拥有其所有权。作用域需要资源的释放。
- 副作用:一个函数在运行的时候,与外界发生了交互,对外界产生了影响。
基于这些基础概念,函数式编程对变量的使用提出了明确约束:
- 优先使用常量而非变量,避免使用全局变量(只有常量才能全局);
- 当需要改变值时,销毁旧值并创建新值;
- 程序中的函数应当尽可能保持纯净;
常量
常量是指一经创建就不再改变值的量,具有可确定性、可复用性和可组合性,这些特性使其适合被放置在全局作用域中,拥有广泛的可见性和较长的生命周期。在实际工程中,合理使用常量可以减少状态管理的复杂度,让程序的行为更加可预测。
纯函数
纯函数是函数式编程的核心概念,它接受输入并返回输出,输出仅依赖于输入参数而不受外部状态影响。相同的输入永远产生相同的输出,这种一一对应关系赋予纯函数纯洁性、可复用性、可组合性、可测试性和高可维护性,使其适合放置在全局作用域中拥有广泛的可见性和较长的生命周期。
纯函数的优势:
- 可确定性:具有可控的行为和确定的输入输出;这使得程序员可以轻松地理解和预测纯函数的行为,便于使用和维护;
- 可测试性:不依赖于外界环境,可以方便地测试;而脏函数,在测试前需要对环境进行初始化,测试后需要对环境进行清理,增加了测试的复杂度;
- 可移植性和可复用性:不依赖外界环境,可以轻松移植到不同的平台,这里说的外界环境,包括全局变量、IO操作、网络请求等常见的程序操作;同时,由于依赖少,可以轻松地被复用,减少了代码的重复性;
- 可重入性和可并行性:不会改变外部状态,可以随意并行,简化了多线程编程的难度;同时,可重入性,带来了响应式编程的便利性,因为要无痛响应,保持函数行为的可控是一个重要前提;
- 可缓存性:可以轻松进行缓存,主要用于耗时任务,加快程序的速度;
变量
变量是相对于常量而言的概念,具有不确定性和 update 生命周期,在函数式编程中通常被称为状态。在大型的应用程序中,程序的复杂性往往因为变量的不可控而大大增加。
当我们需要对变量进行操作时,由于变量的不确定性,必须先校验变量的合法性才能执行操作。当一个程序依赖多个变量时,校验的复杂度会随之飙升,而当多个程序模块共享变量时,每个模块都需要重复进行校验。更复杂的是,当变量之间存在依赖关系时,一个或多个变量的变化会引发连锁反应,同步依赖变量的状态变化成为复杂而繁琐的问题,在多线程环境下还可能引入资源竞争问题。
因此,工程实践中应当尽量缩短变量的生命周期,限制其作用域,从而降低程序复杂度并保证可维护性。对于变量间的依赖同步问题,响应式编程提供了有效的解决方案。
脏函数
脏函数是相对于纯函数而言的概念,如果一个函数与外界发生交互并造成了意想不到的影响,就称为脏函数或副作用。副作用主要通过四种途径产生:
- 函数直接依赖或引用了外部作用域中的变量,从而形成了隐式参数;
- 函数通过传入的参数指针修改了指针指向的数据,从而对其参数进行了修改;
- 函数执行了 IO 操作,如读写文件、网络请求等,其本质是修改外界数据的状态;
- 调用了其他脏函数(脏函数的传染性);
总结下来,脏函数主要有三种类型:隐参函数、写参函数和 IO 函数。
JS 中的隐性副作用
除去一些常见的副作用,JS 中有一些隐性副作用可能会被忽略,如:
async/await和 Promise 的使用;setTimeout、setInterval的使用;Math.random()和Date.now()的使用;console.log()的使用;- 多数 Web API 的使用;
- ......
状态管理
对于变量而言,我们无法完全避免其副作用,但可以通过一些手段来减少副作用的影响范围,从而降低程序复杂度并保证程序的可维护性。状态管理就是致力于解决这个问题的方案,其核心思路是使用容器维护变量的生命周期,并向下游提供统一的API,下游不再直接操作数据而是通过状态管理容器的API来访问。
IO 处理
程序与外界交互时不可避免会产生副作用,IO处理提供了将这些副作用隔离和管理的机制。通过声明式IO和响应式编程,我们可以将IO操作转化为状态修改的操作,使用统一的编码模型去管理IO和状态。
脏函数和副作用
在程序设计中,我们无法完全避免副作用,但可以通过一些手段来减少副作用的影响范围,从而降低程序复杂度并保证可维护性。程序的入口函数main通常是一个脏函数,它接受输入、调用函数并输出结果,一个程序最理想的状态是除了main函数外,其他函数都是纯函数。
脏函数的延迟执行
脏函数在不被调用之前可以认为是无害的,但一旦被调用就会产生副作用。函数式编程并非要完全消除副作用,而是将副作用局限在尽可能小的范围内,或将副作用的发生延迟到尽可能后期,最迟是在用户输入数据(前端)或对外界进行访问(后端)的时候,因为此时发生的副作用是无可避免的。我们可以采用惰性求值和声明式副作用来延迟副作用的发生。
// Dirty
function readFile(path) {
return fs.readFileSync(path, 'utf-8');
}
// Pure
function readFile(path) {
return (env) => {
fs.readFileSync(path, env.encoding);
}
}
// ReadAction
// 将 read 这个 IO 动作抽象为一个数据结构或者说是配置,使用纯函数定义配置
function ReadAction(path) {
return {
path: path,
encoding : 'utf-8',
}
}
// 使用接受某种配置,然后执行一个 IO 操作的执行器
function executeAction(action) {
return fs.readFileSync(action.path, action.encoding);
}脏函数的可原谅性
在实际开发中,我们无法完全避免副作用,但可以选择原谅一些副作用以获得更高的开发自由度。
- 私有闭包,一个闭包函数独享了一个外部变量,这个外部变量除了这个函数,其他函数都不能访问。可以认为这个外部变量是函数私有的,这个副作用是可原谅的;典型例子:随机数生成器、节流函数、单例函数、函数缓存;在 JS 中,由于是单线程的,所以私有闭包不需要考虑并发问题;
- 局部变量,如果一个函数在内部创建了一个变量,即拥有一个变量的创造权,可以认为这是在初始化阶段,那么在函数的内部修改该变量或者调用写参函数修改它,不会被传染;
- 独立 IO,在函数中,调用了一些独立性较强的 IO 模块,这些模块对程序的主体功能没有直接影响,并且不会抛出错误,如日志打印、调试信息等,这些方法往往独立于程序的主体逻辑,不容易增加程序的逻辑复杂度,这些副作用是可原谅的;