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

·  阅读 1531

前篇:juejin.cn/post/693826…

第一天:重新开始学写代码

方:那我们开始了。还记得函数式的约定吗?

学生:记得,数据不可变。

方:好的,那么我估计你现在应该不会写代码了。

学生:?

方:不信?我跟你出道题。请遍历 array = ['a','b','c'] 打印出每一项的值。用 JS 写吧。

学生:简单啊

for(let i = 0; i< array.length; i++){
  console.log(array[i])
}
复制代码

方:你这么快就忘了约定?数据不可变!

学生:我没改 array 啊

方:i++ 改了 i 的值啊

学生:啊,这也算啊

方:没错。不写 i++,你再来回答一次

学生:那我用 for in

for(let key in array){
  console.log(array[key])
}
复制代码

方:有点鸡贼,先不说你遍历的顺序无法保证,这个 key 依然在变化哦,key 一开始等于 0 后来等于 1 最后等于 2。再想想吧。

学生:我还有一招:

array.forEach(item => console.log(item))
复制代码

方:总算摸到边了,这次「你」确实没有改变数据的值

学生:那就是我答对了吗?

方:还差一点。array.forEach 是 JS 内置的数组接口,JS 内部的 C++ 实现很有可能还是用到了 for 循环?你能自己写一个 forEach 吗?满足以下用法

forEach(array, item => console.log(item))
复制代码

学生:不用数组的 forEach 自己写 forEach 吗……

方:嗯

学生:能用 while 吗

方:不能,while 不也要 i++ 嘛,跟 for 没区别

学生:那用递归?

方:试试

学生:好

let array = ['a','b','c']
let forEach = (array, fn) => {
  if(array.length === 0) return
  fn(array[0])
  forEach(array.slice(1), fn)
}
forEach(array, item => console.log(item))
复制代码

方:不错,这次你遵守了约定:「数据不可变」。我想知道为什么你最后才给出这种写法呢?

学生:以前有人告诉我尽量不要用递归。

方:我猜猜,那个人是写 JS 或 Java 的?

学生:嗯,而不止一个人这么说。

方:他们给出的理由是什么?

学生:容易「栈溢出」,而且「开销大」「浪费内存」

方:说得不错,但这只是他们的一面之词。我们先来说说「栈溢出」吧。「栈溢出」的前提是得先有「调用栈 callstack」对吧。

学生:当然

方:那你知不道有些语言根本就没有 callstack 呢,比如 Haskell 就没有 callstack,它用图代替了栈。

学生:闻所未闻……

方:另外,如果我们把递归改写为尾递归,甚至循环,就基本可以消除「栈溢出」

学生:可是如果把递归改写成循环,那还不如直接写循环吧?

方:你很敏锐地发现了我的逻辑漏洞嘛,这里的「尾递归变循环」是「编译器」自动实现的!也就是说程序员专心写代码,编译器专心搞性能优化。

学生:什么是「尾递归」?

方:这个我明天再讲,今天我先把你对递归的不信任推翻再说。现在你只需要知道,把递归改写为尾递归,再配合恰当的编译器,「栈溢出」基本不成问题。

学生:JS 或 Java 的编译器不行吗?

方:问得好。实际上编译器和程序员的写法是相辅相成的,不是恒定的。我先问问你

  1. 程序员应该记住性能好的代码,就算放弃可读性也值
  2. 程序员以可读性为优先,让编译器想办法优化性能

这两个观点你支持哪个?

学生:我不确定,难道不应该尽量挑性能好的代码写吗?

方:哈哈,你以为性能好的代码就是性能好的吗?

学生:这是什么意思?

方:我跟你举个例子,你应该知道 ++i 比 i++ 的性能要好很多吧?

学生:哦?为什么?

方:原因不重要,你可以看看这篇文章,也可以不看。假设你已经知道 ++i 比 i++ 性能要好很多,请问,你在写 for 循环时,写 i++ 还是写 ++i。

学生:我以前一直写 i++,听你这么一说,我是不是应该写 ++i

方:不用,因为编译器帮你优化了,编译器一旦发现 for 循环的第三个语句是 i++,会自动优化为 ++i,所以不需要程序员自己记住这么多规则,网上这种乱七八糟的优化技巧太多了,你能记住几个

学生:编译器这么智能!

方:你也不想想,写编译器的那帮人比写 JS 的人智商高多少。

学生:也是

方:甚至有的时候,你为了提高性能而采用的怪异写法,会降低程序的性能,因为它得不到编译器的优化。编译器一般会优化常见的写法。

学生:原来是这样

方:所以程序员应该尽量写符合社区习惯的代码,只在性能瓶颈处手动优化性能。但是由于 JS 和 Java 这帮人常年的「教育」,像你这样的新人已经都认为应该尽量不用「递归」,所以 JS 和 Java 的编译器没有必要花时间优化小众写法,优化之后反而会造成安全和debug相关的问题。

学生:那说递归「开销大」和「浪费内存」是不是也是类似的误区。

方:是的,如果某个语言特性被程序员用得多,编译器就会想尽办法让它变快。现在,你可以放弃你对递归的偏见了吗?

学生:可以,那我是不是还得放弃 JS 和 Java 的编译器?

方:是的,这两门语言的社区文化与函数式不太兼容,而且主流版本的编译器是不支持这些优化的,也许未来的版本有。

学生:那我再多嘴问一句,「递归」没有缺点吗?

方:也有,以后会讲,但是瑕不掩瑜,利大于弊。

学生:好,我先记下来,虽然还没有完全被说服。

方:我们是从哪聊到这的?哦,是从「你没法第一时间给出递归的写法」聊到这的。那么我给你出第二题,写一个将字符串反转的函数,不能违反「数据不可变」的约定哦。

学生:递归写法我会

reverse = (string) => {
  if(string.length <= 1){return string}
  let last = string[string.length-1]
  let head = string.substr(0, string.length - 1)
  return last + reverse(head) // 把最后一个字符移到前面,然后递归
}
复制代码

方:写得还挺快

学生:丢下偏见之后果然写得快很多,不过还是觉得效率会慢、内存会浪费

方:过几天你就习惯了,我也会教你怎么优化。再来一个,快速排序

学生:简单

/*1*/ quickSort = (array) => {
/*2*/   if(array.length <= 1) {return array}
/*3*/   let [pivot, ...rest] = array
/*4*/   let small = rest.filter(i => i<=pivot)
/*5*/   let big = rest.filter(i => i>pivot)
/*6*/   return [...quickSort(small), pivot, ...quickSort(big) ]
/*7*/ }
复制代码

学生:这个快排写得是真的爽,但我还是担心浪费内存,第 3、4、5、6 行全是内存复制

方:哼,那是因为 JS 和 Java 的数据可变,所以不能直接复用数据啊。如果数据是不可变的,let [pivot, ...rest] = array 可以直接复用内存啊,rest 和 array 复用同一片内存问题不大

学生:好像是这么回事,那 rest.filter(i => i<=pivot) 呢

方:这个内存很难优化。但是你要知道,你用函数式写只用 5 行代码,用指令式写可能要 20 行代码,函数式追求的是逻辑上的简洁,先让逻辑变美,然后等遇到性能瓶颈了再上优化

学生:好吧

方:等下,你怎么好像对「数据不可变」还是没有完全接受啊,总想着改原来的内存?

学生:才第一天嘛,过几天我才能适应

方:好吧,今天就先到这里,明天继续。

后续:juejin.cn/post/693919…

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改