写给前端工程师看的函数式编程对话 - 6

1,150 阅读9分钟

前篇 juejin.cn/post/694203…

第六天:数据不可变

学生:早,我又来了,昨天我们讲了闭包,今天我们讲什么?

方:先讲讲函数的执行过程吧,上代码:

let v0 = 'global v0'
let f1 = (p1)=>{
  let v1 = 'v1'
  return [v0, v1, p1]
}
f1('p1')

请问 f1('p1') 输出什么值?

学生:这不是和尚头上的虱子——明摆着嘛,输出 [v0, v1, p1]

方:结果是怎么来的呢?

学生:f1 执行的时候,从当前作用域读取 p1 和 v1,从全局作用域中读取 v0,然后把它们拼起来

方:对,我画个图来演示一下:

image.png

  • 全局环境用 env0 = {v0, f1};
  • 执行 f1 时,会创建 env1 = {v1, p1};
  • 最终执行 return [v0, v1, p1] 时,按 env1->env0 的顺序找值,算出最终的数组并返回。

学生:嗯,没有问题。

方:现在我们把代码变复杂点:

let v0 = 'global v0'
let f1 = (p1)=>{
  let v1 = 'v1'
  let f2 = (p2) => {
    let v2 = 'v2'
    return [v0, v1, v2, p1, p2]
  }
  return f2('p2')
}
f1('p1')

在 f1 里新增 f2,并 return f2()

学生:哦,你加了一层函数调用

方:现在的图就该画成这样:

image.png

  • 全局环境用 env0 = {v0, f1};
  • 执行 f1 时,会创建 env1 = {v1, p1, f2};
  • 执行 f2 时,会创建 env2 = {v2, p2};
  • 最终执行 return [v0, v1, v2, p1, p2] 时,按 env2 ->env1->env0 的顺序找值,算出最终的数组并返回。

发现规律没有?

学生:函数执行时,总是从 env 链中由近及远地找值?

方:没错。目前我们的代码没有赋值过,我们来试试赋值,这是我们这些天第一次使用「赋值」:

let v0 = 100
let f1 = (p1)=>{
  v0 = v0 - p1
  return v0
}
f1(1)

环境模型:

image.png

学生:全局变量 v0 的值被改成 v0 - 1 了。为什么你只把 v0 = v0 - p1 叫做赋值,难道 let v0 = 100 不是赋值吗?

方:嗯,我们约定 let v0 = 100 叫做「初始化」或「绑定」,v0 = v0 - p1 才叫做「赋值」。

学生:我管这俩叫做第一次赋值和第二次赋值……

方:按我的来

学生:行,听你的……

方:「赋值」的本质就是改变了环境(env2、env1、env0),也意味着「数据可变」

学生:方,你终于开始回答「什么是数据不可变」,我以为你早就忘了呢

方:没忘,接下来我们看看「赋值」有什么问题

学生:第二次赋值还能有问题?

方:第一个问题,函数的意义变了:

f1(1) // 得到 99
f1(1) // 得到 98
f1(1) // 得到 97

对于同一个输入参数 1,f1 的输出却一直在变

学生:这又如何?这不是很正常嘛?

方:这导致「代入法」不能再用了,因为当你代入 v0 的时候,你不能确定 v0 的值是 100 还是 99 还是 98:

f1(1) 
=> v0 = v0 - p1
=> v0 = 100 - 1 // v0 此时是 100
=> v0 = 99
f1(1)
=> v0 = v0 - p1
=> v0 = 99 - 1  // v0 此时是 99
=> v0 = 9
f1(1)
=> v0 = v0 - p1
=> v0 = 98 - 1  // v0 此时是 98
=> v0 = 97

你需要知道当前是第几次调用 f1,才能确定 v0 的值。

如果 v0 还能被其他的函数赋值,那你单单记住 f1 的调用次数也是于事无补的,你需要知道所有函数的调用次序,才能确定 v0 的值

学生:好像是诶,那「代入法」不能用就不用呗,这个很严重吗?

方:引入「赋值」看起来只是损失「代入法」,改用「环境模型」,但实际上是切断了函数与数学之间的联系。

学生:此话怎讲?

方:数学里面是没有赋值概念的,因此数学中的函数 f(x) = x - 1 对于同一个 x,总是返回相同的值。

学生:说句实话……切断编程与数学的联系……不是也挺好吗,我数学成绩本来就很差,切断了反而更清净

方:好吧,数据可变的第一个弊端看来说服不了你。我们来看第二个问题——数据共享:

const options = [{data:1}, {data:2}]
initEngine(options)
console.log(options)
____第4行_____

如果不「禁用赋值」,没有人知道 initEngine(options) 会不会修改 options 的内容,只能靠开发者自觉了。另外,如果我在第4行修改了 options,也会影响到 Engine 内部。

学生:那就把 options 复制一份再传给 initEngine 呗……

方:怎么复制?浅拷贝可没用,你不得不进行「深拷贝」,你会写深拷贝吗?著名面试题哦

学生:诶,我没把握能立马写出来……一般我用库。所以说「数据可变」会导致深拷贝吗?

方:不,应该说「赋值」会导致数据共享变得麻烦,你无法信任别人不改你的数据,别人也无法信任你:

  • 要么就靠大家自觉,约定大家都不改 options(我只能说祝你好运);
  • 要么传给别人之前先深拷贝一下,拿到别人的数据之后最好也先深拷贝一下……

而「禁用赋值」之后(也就是数据不可变了),所有的数据都能共享:你把数据传给任何函数都不用担心别人改了你的数据,也不用担心自己不小心改了别人的数据。

学生:这个点倒是有点说服我支持「数据不可变」了,目前我在的团队就是靠约定,但是我确实遇到过我传给别人的 options 被改的情况。

方:目前「数据不可变」的两个优点都告诉你了:

  1. 数据可变将导致「代入法」不可用,函数与数学再无关联;而数据不可变则相反
  2. 数据可变将导致数据共享变得困难;而数据不可变则相反

学生:还有其他优点吗?

方:有是有,但是例子太难构造了,而且也不具有普遍性。比如函数式编程宣称对处理并发任务有天然的优势,但我还没有找到有压倒性优势的证据。

学生:那网上说函数式编程「无副作用」是什么意思?

方:这不算什么优点吧,没有副作用的意思就是函数式编程里的函数不会去修改自己外部的 env,也不会去修改通过参数传进来的对象,顶多修改自己的本地变量而已。

学生:听起来好像没什么用……

方:确实,这意味这你用 JS 修改 window.document.title 都属于「副作用」

学生:那我如果就是想要修改 window.document.title 怎么办?

方:如果你一定要产生副作用,函数式的处理方式是,创造一个专门的区域来写副作用,就好像公共场所的吸烟室一样,比如 React 的函数式组件建议你把副作用全都写在 useEffect 里。

学生:哦,那 Haskell 是怎么做的呢?

方:这个说来话长,下次讲吧

学生:好想听这个……那「引用透明」又是什么

方:这个也挺无用的。引用透明原本是语言学中的一个概念,比如「上海」也叫「魔都」, 把句子中的「上海」替换成「魔都」应该是不改变语义的:

我喜欢上海。
我喜欢魔都。

如果在所有句子里,「上海」改成「魔都」都不改变语义,我们就说两者是「引用透明」的。但可惜,下面这个句子里的「上海」改成「魔都」可能会改变原意:

夜上海很好看。
夜魔都很好看。

dota 玩家可能会以为第二句话是在夸「夜魔」这个英雄……

学生:那「引用透明」在函数式里是什么意思呢?

方:如果 f(1) == 99,那么把代码中的 99 全改成 f(1)(或者反过来)都不应该改变原义,这就是引用透明。这要求 f(1) 总是返回 99 才行,不能一会 99 一会 98。

学生:这又有什么用呢?

方:就工作而言,没什么用……

学生:那「纯函数」呢

方:纯函数是说,这种函数的返回值不依赖于外部环境(参数不变则返回值不变),该函数也不会修改外部环境(无副作用)。如果你确实需要修改外部环境,可以在特定区域里搞。

学生:我感觉你在说车轱辘话……看起来都是在说「数据不可变」。最后一个问题,「数据不可变」等价于「函数式编程」吗?

方:并不等价,函数式编程其实是想用数学方法或者符号学的方法来写出可靠的程序,为了尽可能让代码跟数学更契合,那么就要让我们的代码尽可能的简单,抛弃二次赋值是一个非常不错的选择,这能让我们的代码减少对外界环境的依赖。所以,「数据不可变」一个是非常适合「函数式编程」的特性。

甚至函数式还因此分成了两派,「纯函数式编程」认为「数据不可变」是必须的,「非纯函数式编程」认为「数据不可变」不是必须的。前者的代表是 Haskell,后者的代表是 Lisp。

学生:那你为什么不在第一天就告诉我「数据不可变」的优点呢?

方:没用的,就算我第一天就告诉你这些优点,你也不会信服的,你肯定会问

  1. 不赋值怎么写代码?
  2. 递归性能慢怎么办?
  3. 用函数怎么封装数据?

之类的问题。而且即使到了现在,你依然没有发现「数据不可变」有什么很大的优势,对吧?

学生:嗯,既然「数据不可变」没有巨大优势,我为什么要学「数据不可变」呢?

方:我们可以从理论上来分析「数据不可变」多么多么可靠,但是从实践角度来说,两者能达成的效果确实差不多,甚至「数据可变」更适合新人一点。所以我推荐你学习「数据不可变」的函数式编程的理由是:拓宽视野。

学生:好的,我会回顾这周的学习内容的。后面你还打算讲吗?

方:我想讲到 Monad,不过先休息几天吧

学生:好,那你有空时告诉我

方:OK

目前先告一段落了,想听 Monad 的请留言。

后续 juejin.cn/post/694906…