什么是函数式编程
最近通过小册和网上的一些资料学习了一下函数式编程,这里做个记录。
函数式编程是一种风格范式,没有一个标准的教条式定义。
我们把函数式编程和命令式编程做个比较
命令式编程关注的是一系列具体的执行步骤,当你想要使用一段命令式的代码来达到某个目的,你需要一步一步地告诉计算机应该"怎样做"。
与命令式编程严格对立的其实是"声明式编程":不关心"怎样做",只关心"得到什么",关注的是数据的映射。
具体到范式表达上,函数式编程总是需要我们去思考这样两个问题:
- 我想要什么样的输出?
- 我应该提供什么样的输入?
ini
(1 + 3) * 4 / 2
// 命令式
let a = 1 + 3;
let b = a * 4;
let c = b / 2;
// 声明式
let result = divide(multiply(add(1,3), 4), 2);
我们来看一下维基百科的定义:
函数式编程(英语:functional programming)或称函数程序设计、泛函编程,是一种编程范式。它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算为该语言最重要的基础。而且,λ演算的函数可以接受函数作为输入参数和输出返回值。 ------Wikipedia
维基百科中的定义比较晦涩难懂,但是我们从【特征即定义】的角度去定义,就 JS 编程而言,具备以下特征
- 拥抱 纯函数 ,隔离副作用
- 函数是"一等公民"
- 避免对状态的改变(不可变值)
函数式编程的优势
- 代码简洁,负担小,方便读写
scss
add(1,2).multiply(3).subtract(4)
-
代码可复用,方便管理和维护,利人利己
-
清晰的逻辑边界,更少的测试工作
为什么要拥抱纯函数,隔离副作用
什么是纯函数
同时满足以下两个特征的函数,我们就认为是纯函数:
-
对于相同的输入,总是会得到相同的输出
-
在执行过程中没有语义上可观察的副作用。
什么是副作用
如果一个函数除了计算之外,还对它的执行上下文、执行宿主等外部环境造成了一些其它的影响,那么这些影响就是所谓的"副作用"。
javascript
// 栗子 1
let a = 10
let b = 20
function add() {
return a+b
}
// 栗子 2
function processName(firstName, secondName) {
const fullName = `${firstName}·${secondName}`
console.log(`我是 ${fullName}`)
return fullName
}
processName('蔡', '徐坤')
// 栗子 3
function getData(url) {
const response = await fetch(url)
const { data } = response
return data
}
纯函数:输入只能够以参数形式传入,输出只能够以返回值形式传递,除了入参和返回值之外,不以任何其它形式和外界进行数据交换的函数。
为何非纯不可
- 高度的确定性。不确定性意味着风险,而风险是万恶之源。
以测试过程为例:单元测试的主要判断的依据就是函数的输入和输出。如果对于同样的输入,函数不能够给到确定的输出,测试的难度将会陡然上升。不确定性也会导致我们的代码难以被调试、数据变化难以被追溯、计算结果难以被复用等等。
- 没有副作用
因为不纯的函数有可能访问同一块资源,进而相互影响,引发意想不到的混乱结果。试想这样一种场景,A函数和B函数都需要向某个文件写入信息,一旦我们先后调用了A、B两个函数,就将触发两个并行的写入过程,进入混乱的竞争态。
- 更加灵活,可以改善代码质量
无论是引入了外部变量的 add()
函数,还是依赖了 JS 内置对象的 getToday()
函数,它们的执行都严重地依赖了函数的运行环境。更确切地说,这些函数是被困在了特定的上下文里。
从研发效率上来看,纯函数的实践,实际上是将程序的"外部影响"和"内部计算"解耦了。
这间接地促成了程序逻辑的分层,将会使得模块的功能更加内聚。
函数组合思想
因为 DRY 所以 HOF
数组方法map()
、reduce()
、filter()
...
在 JS 中,基于 reduce(),我们不仅能够推导出其它数组方法,更能够推导出经典的函数组合过程。
csharp
const arr = [1, 2, 3]
// 0 + 1 + 2 + 3
const initialValue = 0
const add = (previousValue, currentValue) => previousValue + currentValue
const sumArr = arr.reduce(
add,
initialValue
)
console.log(sumArr)
// expected output: 6
用 reduce 推导 map
Reduce 中的函数组合思想
通过观察 reduce
的工作流,我们可以发现这样两个特征:
reduce
的回调函数在做参数组合reduce
过程构建了一个函数 pipeline
用 reduce 推导 pipe
JS 的函数可以作为参数传递
callback(0, func1) = func1(0)
scss
function callback(input, func) {
func(input)
}
funcs.reduce(callback,0)
=======
function pipe(funcs) {
function callback(input, func) {
return func(input)
}
return function(param) {
return funcs.reduce(callback,param)
}
}
javascript
function add4(num) {
return num + 4
}
function multiply3(num) {
return num*3
}
function divide2(num) {
return num/2
}
const compute = pipe([add4, multiply3, divide2])
// 输出 21
console.log(compute(10))
在 React 中的体现
-
计算层:负责根据 state 的变化计算出虚拟 DOM 信息。这是一层较纯的计算逻辑。
-
副作用层:根据计算层的结果,将变化应用到真实 DOM 上。这是一层绝对不纯的副作用逻辑。
在 UI = f(data)
这个公式中,数据是自变量,视图是因变量。
而组件 作为 React 的核心工作单元,其作用正是描述数据和视图之间的关系。
也就是说,若是把这个公式代入到微观的组件世界中去,那么 React 组件毫无疑问对应的就是公式中的 f()
函数。
对于同样的入参(也即固定的 props
、 context
、 state
),函数组件总是能给到相同的输出。因此,函数组件仍然可以被视作是一个"纯函数"。
由此我们可以看出:Hook 对函数能力的拓展,并不影响函数本身的性质。函数组件始终都是从数据到 UI 的映射,是一层很纯的东西 。而以 useEffect
、useState
为代表的 Hooks,则负责消化那些不纯的逻辑。比如状态的变化,比如网络请求、DOM 操作等副作用。
也就是说,在组件设计的层面,React 也在引导我们朝着"纯函数/副作用"这个方向去思考问题。
现在,设计一个函数组件,我们关注点则被简化为"哪些逻辑可以被抽象为纯函数,哪些逻辑可以被抽象为副作用"
资料推荐
柯里化、偏函数、函子、单子......