V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
FrankFang128
V2EX  ›  分享创造

写给前端工程师看的函数式编程对话(系列文章)

  •  1
     
  •   FrankFang128 · 2021-03-14 12:52:44 +08:00 · 4080 次点击
    这是一个创建于 515 天前的主题,其中的信息可能已经有所发展或是发生改变。

    第 0 天

    学生:方,我理解不了数据不可变。

    方:正常,我在学 Haskell 之前也理解不了。

    学生:你的意思是,我要真正理解数据不可变,就必须学 Haskell ?

    方:也不一定非得是 Haskell,任何一门「支持函数式」且推崇「数据不可变」的编程语言都可以。

    学生:为什么,用 JS 就理解不了吗?

    方:可以这么说,至少我无法用 JS 来讲解函数式,可能是我水平不够。

    学生:那么我学会 Haskell 就能理解数据不可变吗?

    方:不是这样的。

    学生:什么意思?

    方:Haskell 能发挥「数据不可变」的优势,这样你才会觉得「数据不可变」是好东西。另外,数据不可变不需要理解,这只是一个约定。

    学生:约定?

    方:是的,你以前学的编程语言约定「数据可变」,并且向你展示了「数据可变」的优势。

    学生:对啊,我觉得数据可变才是正常的。

    方:其实不是,你只是先入为主而已。你没发现数据可变的短板而已。

    学生:短板是什么?

    方:你现在不会认为这些短板是短板,因为你还没有见过「数据不可变」的长处。

    学生:我确实没见过它的好处。我觉得它多此一举。

    方:这就是问题所在,你不会认同「无副作用」「引用透明」「纯函数」是优点,你目前认为这些概念是硬凑出来的概念,对不对。

    学生:是……

    方:你被目前的计算机教育给局限了。你知道面向对象、图灵机,但不知道邱奇数、lambda 演算、Y 组合子,但是他们是同样重要的。

    学生:确实是第一次听说……

    方:那么,你想学函数式吗?

    学生:学完有什么好处?

    方:没有,只是拓宽你的思维,你可能依然继续用 JS 或者 Java 编程,很少用到这些技术。那么,你还想学函数式吗?

    学生:你要花多久讲完?

    方:你学会并习惯指令式编程语言,比如 JS 或者 Java,用了多久?

    学生:大半年。

    方:那么你至少需要同样长的时间才能习惯函数式,而且习惯之后就回不去了。我可能讲一段话,你要花一周甚至更久才能理解。你还想学函数式吗?

    学生:听起来弊大于利?

    方:我保证利大于弊,只不过短期内看不到利而已,学完之后你对编程会有完全不同的认识,但工资不一定涨。

    学生:好吧,那我试试看,想学一学。

    方:我们明天开始第一课。

    第 1 条附言  ·  2021-03-14 13:30:10 +08:00

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

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

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

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

    学生:?

    方:不信?我跟你出道题。请遍历 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. 程序员以可读性为优先,让编译器想办法优化性能

    这两个观点你支持哪个?

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

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

    学生:这是什么意思?

    第 2 条附言  ·  2021-03-14 13:30:35 +08:00
    方:我跟你举个例子,你应该知道 ++i 比 i++ 的性能要好很多吧?

    学生:哦?为什么?

    方:原因不重要,你可以看看[这篇文章]( https://www.omegaxyz.com/2017/05/20/aboutippandppi/),也可以不看。假设你已经知道 ++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 行代码,函数式追求的是逻辑上的简洁,先让逻辑变美,然后等遇到性能瓶颈了再上优化

    学生:好吧

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

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

    方:好吧,今天就先到这里,明天继续。
    第 3 条附言  ·  2021-03-14 13:31:57 +08:00

    第二天:递归 & 尾递归

    学生:早啊方,今天讲什么?

    方:今天讲递归和尾递归吧

    学生:数据不可变什么时候讲呀?

    方:不急,数据不可变是贯穿始终的,不用特别去讲它。

    学生:哦……

    方:我先写一个递归形式的阶乘:

    f = n => {
      if(n<=1) {
        return 1
      } else {
        return n * f(n-1)
      } 
    }
    

    然后用「代入法」来计算一下 f(4):

    f(4) 
    = 4 * f(3)
    = 4 * (3 * f(2))
    = 4 * (3 * (2 * f(1)))
    = 4 * (3 * (2 * 1))
    = 4 * (3 * 2)
    = 4 * 6
    = 24
    

    注意计算的「形状」,看起来像是一个大于号(>):

    方:先递进,后回归,这就是递归,英文叫做 recursion。

    学生:原来还可以这么理解递归。那我可不可以认为:「递」是调用栈压栈的过程,「归」是弹栈的过程。

    方:可以,但不够全面,我之前说过,有些语言是没有调用栈的,所以压栈弹栈只是递归的一种实现方式而已,尽管这种方式很主流。

    学生:那伪递归是怎么回事呢?

    方:还是先给例子,比阶乘还简单

    print = (n) => {
      if(n===0){return}
      console.log(n)
      return print(n-1) // 此处 return 可以省略不写,因为没有返回值
    }
    

    然后使用「代入法」来计算 print(4):

    print(4)
    = print(3) 
    = print(2) 
    = print(1) 
    = 结束 
    

    注意这次计算的形状是「一条直线」:

    学生:这也是递归吧?

    方:是也不是。

    学生:怎么讲?

    方:如果你问的是中文概念「递归」,那么这次计算没有「递」和「归」的过程,所以它不算递归。

    学生:确实没有先递再归。

    方:但如果你问的是递归的英文概念「recursion」,其中文意思是「重现」,那这次计算确实是在不断重现 print 调用,所以它是递归。

    学生:你的意思是 recursion 翻译为递归是有问题的?应该翻译为「重现」?

    方:我不敢这么说。但为了准确起见,我们用「递归」来表示计算形状为大于号的 recursion,用「尾递归」来表示计算形状为直线的 recursion。「尾递归」的代码特征是 recursion 出现在代码的最后一句(即尾位)

    学生:原来是尾巴的「尾」,我还以为是伪装的「伪」。

    方:怪我没说清楚咯?

    学生:那为什么 return n * f(n-1) 不是尾递归呢? f(n-1) 不也是写在最后一句吗?

    方:因为调用完 f(n-1) 还要将其结果与 n 相乘,所以 f(n-1) 是倒数第二步,不是最后一步,算不得尾递归。

    第 4 条附言  ·  2021-03-14 13:32:21 +08:00

    学生:原来如此。那「尾递归」的优点就是不用「归」吗?

    方:可以这么理解。

    学生:可是,尾递归版的阶乘怎么写?我想不出来

    方:多问我你就能写出来了,我先写一个给你看看

    f = (n, result = 1) =>
      n > 1 ? f(n-1, result * n) : result
    

    代入运行一下:

    f(4) 
    = f(3, 4)
    = f(2,12)
    = f(1,24)
    = 24
    

    看,这条线直不直?

    学生:啊,你为了不「归」,居然把上一步的计算结果,当做参数,传给了下一次调用

    方:对。

    学生:我怎么没想到。

    方:以后你就会想到了。

    学生:那如果是复杂的递归呢?能不能写成尾递归?比如斐波那契:

    fib = (n) =>
      n === 0 ? 0 :
      n === 1 ? 1 :
              fib(n-1) + fib(n-2)
    

    求 fib(4) 的过程是这样:

    fib(4) 
    = f(3) + f(2) // 简写为 f
    = (f(2) + f(1)) + (f(1) + f(0))
    = ((f(1) + f(0)) + f(1)) + (f(1) + f(0))
    = ((f(1) + f(0)) + f(1)) + (1 + 0)
    = ((f(1) + f(0)) + f(1)) + 1
    = ((f(1) + f(0)) + 1) + 1
    = ((1 + 0) + 1) + 1
    = (1 + 1) + 1
    = 2 + 1
    = 3
    

    这种递归不好写成尾递归吧?

    方:稍微动点脑不就可以了嘛:

    fib = (n, prevResult = 0, result = 1) =>
      n === 0 ? prevResult :
      n === 1 ? result :
        fib(n - 1, result, prevResult + result)
    

    求一下 fib(4) 的值:

    fib(4)
    = fib(3, 1, 1)
    = fib(2, 1, 2)
    = fib(1, 2, 3)
    = 3
    

    这不就妥了?

    学生:好家伙!你把前两次的值,都当作参数,传给了下一次的调用了。而你的 n 只是用来计数的,当 n 等于 1 你就返回上一次的结果!

    方:对!是不是很简单?

    学生:那……那……那快排有没有尾递归写法?

    方:有是有,但我怕你看不懂,所以我先把你昨天写的「递归版」快排放在这里:

    /*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*/ }
    

    要改写成「尾递归」存在两个问题:

    1. quickSort(small) 之后必须要回头执行 quickSort(big)
    2. quickSort(big) 执行完了之后必须要回头把 quickSort(small)、pivot、quickSort(big)拼接起来

    对吧?

    学生:是呀,看起来不可能把结果提前算出来,传给下一次函数调用,我不信你这次还能改成尾递归

    方:那就不提前算出结果,不就结了

    学生:什么叫做不提前算出结果?

    第 5 条附言  ·  2021-03-14 13:32:30 +08:00

    方:直接看代码吧:

    /*1*/ qs = (array, next) => {
    /*2*/     if(array.length <= 1) { return next(array)}
    /*3*/     const [pivot, ...rest] = array
    /*4*/     const small = rest.filter(i=>i<=pivot)
    /*5*/     const big = rest.filter(i=>i>pivot)
    /*6*/     qs(small, (sortedSmall)=>{
    /*7*/         qs(big, (sortedBig)=>{
    /*8*/             next([...sortedSmall, pivot, ...sortedBig])
                 })
             })
         }
    
    qs([1,10,5,2,7,3,8,4,9,6], (result)=>{
        console.log(result) 
    })
    
    // 输出 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    

    前面 5 行没有变化,你主要看第 6 ~ 8 行。

    学生:好家伙,你把原本的

    /*4*/ let small = rest.filter(i => i<=pivot)
    /*5*/ let big = rest.filter(i => i>pivot)
    /*6*/ return [...quickSort(small), pivot, ...quickSort(big) ]
    

    改成了这个样子

    /*6*/ qs(small, (sortedSmall)=>{
    /*7*/   qs(big, (sortedBig)=>{
    /*8*/     next([...sortedSmall, pivot, ...sortedBig])
    

    这个改动的作用是,把后续的所有操作写成一个函数,当做参数传给下一次的 quickSort!

    方:对!

    学生:确实,把原来需要回头调用的东西往后传,就不用回头了,太聪明了!我已无话可说……但我总感觉哪里怪怪的。

    方:你第一次见这样的代码当然觉得怪咯,习惯就好,这种代码风格叫做 CPS(Continuation-passing style),以后有机会还会再讲。

    学生:那,所有的递归,都能改写成尾递归吗?

    方:是的,具体的讨论你可以看这个 StackOverflow 上的问答,当然不看也没关系,记住结论就行。

    学生:今天的课程让我的脑袋都快炸了,我得去打盘游戏休息一下。

    方:行,不过我们最后再复习一下今天所学吧:

    1. 递归(recursion)有一种特殊的形式,叫做「尾递归」
    2. 递归版阶乘可以写成「尾递归」,方法是把上一次的结果传给下一次调用
    3. 递归版斐波那契可以写成「尾递归」,方法是把上两次的结果传给下一次调用
    4. 递归版快排可以写成「尾递归」,方法是把后续操作写成一个函数,传给下一次调用(即 CPS)
    5. 补充一点:递归改写成尾递归还有其他方法,并不只是我上课讲的这么一点

    学生:我们今天就讲了这么点内容?我怎么感觉今天讲了很多东西……

    方:就这么点,你不会没记住吧?注意记笔记啊!

    学生:下次一定,我先去打游戏了,再见!

    第 6 条附言  ·  2021-03-18 11:09:50 +08:00

    前篇:https://juejin.cn/post/6939192091540979725

    第三天:从 JS 函数到 Haskell 函数

    方:黑眼圈这么重,你昨晚是不是开黑到很晚?

    学生: 别取笑我了,今天讲什么呢?

    方:今年我教你写函数,先聊聊函数参数吧。

    学生:参数有什么好讲的,就是把数据传给函数吧。对了,根据你昨天讲的,函数也可以被当作参数传给另一个函数。

    方:先看代码吧:

    add = (a, b) => a + b
    add(1,2) // 得到 3
    

    add 可以求两数之和。要调用 add,只需要把 1 和 2 传给 add 即可得到 3

    学生:嗯,接下来你肯定要整点花里胡哨的写法。

    方:那是,继续看代码:

    c_add = (a) => (b) => a + b
    

    c_add 也可以求两数之和,要调用 c_add,你需要先传入 1,得到一个函数(记为 temp),然后把 2 传给 temp,得到 3:

    temp = c_add(1)
    temp(2) // 得到 3
    

    你也可以连续使用两对括号:

    c_add(1)(2) // 得到 3
    

    学生:呃……说实话,我觉得这种写法又难看,又多余。我只在面试题里见过类似的考题,工作中从来不用。

    方:确实没有什么用,不过这种写法并没有 bug 对吗?

    学生:bug 倒是没有

    方:我想告诉你的是,这两种参数写法是等价的:

    add = (a, b) => a + b
    c_add = (a) => (b) => a+b
    

    学生:等价的?

    方:嗯,我说的「等价」是指,两个函数,只要得到相同的输入,就一定得到相同的输出。

    学生:哦,你的意思是把函数当成黑盒是吧?

    方:对,那现在你看 add 和 c_add,除了传参的「形式」不同,是不是等价的?

    add(1,2) // 3
    c_add(1)(2) // 3
    
    add(18, 24) // 42
    c_add(18)(24) // 42
    

    学生:按你的说法,确实等价。毕竟他们的核心代码都是 a + b 而已。

    方:好,接下来我们举更复杂的例子:

    multi = (a, b, c) => a * b * c
    c_multi = a => b => c => a * b * c
    

    这两个函数,等价吗?

    学生:也等价

    方:继续,更复杂的例子,我将参数个数拓展到 n 个:

    anyFn = (p1, p2, ..., pn) => doSomethingWith(p1, p2, ..., pn)
    c_anyFn = p1 => p2 => ... => pn => doSomethingWith(p1, p2, ..., pn)
    

    任何一个「接受 n 个参数的函数」,是否与「分 n 次接受 1 个参数的函数」等价呢?

    学生:让我想想哈……按你的说法,看起来是等价的

    方:所以,我们能不能说 「多参数函数」一定能改写为「单参数函数」,且其输入输出保持不变

    学生:好像可以……但我从没想过这件事,这样改写有什么意义吗?

    方:我们后面才会用到这个定理,我称之为「单参定理」,先记下来。

    学生:好,我先记在小本本上。

    方:其实,你在做前端开发时,用过单参函数。

    学生:哦?我怎么不记得?

    方:你用过前端模板吧?比如 Handlebars.js:

    template = Handlebars.compile("<div>{{user.name}}</div>"); // 传第一个参数
    
    html1 = template({user:{name:'frank'}}) // 传第二个参数
    // html1 为 <div>frank</div>
    html2 = template({user:{name:'jack'}}) // 传第二个参数
    // html2 为 <div>jack</div>
    

    学生:嗯,用过,看来这个 compile 就是一个单参数函数。

    方:没错,其用法是 compile(string)(data)。一般来说,一个函数只会选择一种传参形式,要么是 (string, data),要么是 (string)(data)。不过如果想要同时支持两种形式,也是可以做到的。

    第 7 条附言  ·  2021-03-18 11:10:27 +08:00

    学生:那单参函数有什么优点吗?

    方:你如果在网上搜,会看到有人说优点是延迟计算啊、代码复用啊之类的,其实吧,多参数同样可以做到这些。

    学生:那就是没什么优点咯?

    方:后面用到函数组合的时候就能看出优点,目前讲了你也听不懂。

    学生:好吧,那我还是先记在本本上吧,没有优点的东西我大脑记不住

    方:我们继续。如果一个函数是多参数形式,比如 add,但是我们需要单参数形式,比如 c_add,该怎么办?

    学生:虽然我从来都没有这种需求,不过我听过可以通过柯里化(currify)做到?

    方:你懂得挺多,将 add 柯里化确实可以得到 c_add,但是我现在不打算讲柯里化,为了简单起见,我们直接改代码就行了,声明函数的时候手动写成 xxx = a => b => c => ... 的形式即可。

    学生:方啊,我总是猜不透你……我还等着你讲柯里化呢

    方: 柯里化以后讲啦。好了,今天的第一个内容讲完了,那就是「单参定理」,请你把内容复述一遍。

    学生:(拿起本本照着读)「多参数函数」一定能改写为「单参数函数」,且其输入输出保持不变

    方:不错

    学生:那,第二个内容是?

    方:接下来讲函数的类型。直接上 TypeScript 代码:

    type Add = (a: number, b: number) => number;
    const add: Add = (a, b) => a + b;
    // 改成单参
    type Add = (a: number) => (b: number) => number;
    const add: Add = a => b => a + b;
    

    鉴于浏览器不能直接运行 TS,你可以把代码复制到 CodeSandbox.io 上运行

    学生:看起来挺简单,就是给函数、参数和返回值加上类型

    方:没错,接下来来个复杂点的,这是一个 CPS 形式的 add,我们来给它加上类型:

    const add = a => b => fn => fn(a+b)
    add(1)(2)( (result)=> result*2 ) // 得到 6
    

    学生:这个 add 接受三个参数 a、b、fn,然后把 a + b 传给 fn,并返回 fn 的返回值,是吧?

    方:嗯,要给 add 添加类型,我们可以先声明一个 Add

    type Add = ...
    const add: Add = ...
    

    学生:嗯,目前很简单

    方:然后添加参数 a、b 的类型

    type Add = (a:number) => (b:number) => ...
    const add: Add = (a) => (b) => ...
    

    学生:还要加 fn 吧?

    方:fn 的类型是个函数: (c:number) => number,加进代码里就是这样:

    type Add = (a:number) => (b:number) => (fn: (c:number) => number ) =>
    const add: Add = (a) => (b) => (fn) => ...
    

    学生:复杂起来了呢

    方:由于 add 的返回值,就是 fn(a+b) 的返回值,所以我们最后可以加上返回值的类型:

    type Add = (a:number) => (b:number) => (fn: (c:number) => number) => number
    const add: Add = (a) => (b) => (fn) => fn(a+b)
    

    学生:看起来好复杂,加类型有什么意义吗?不写类型代码也能运行不是吗?

    第 8 条附言  ·  2021-03-18 11:10:42 +08:00

    方:类型系统是程序员的好帮手,只是需要一点时间来适应而已。现在,我们的第二个知识也讲完了:可以给函数加类型声明。

    学生:第三个知识点呢?

    方:接下来会讲一点点 Haskell 的语法,因为 JS 在后面的文章中会显得不够用,所以我必须逐渐教你一点 Haskell 了。

    学生:新语言啊,来吧。

    方:这是一个 Haskell 函数,你应该能看懂:

    doubleMe :: Int -> Int 
    doubleMe x = x * 2
    

    第一句话表示 doubleMe 的参数是 Int 类型,返回值也是 Int 类型; 第二句话表示 doubleMe x 等价于 x * 2,即,若输入 x ,则输出 x 乘以 2。

    学生:看是能看懂,那怎么调用 doubleMe 呢?

    方:Haskell 中的函数,与其说是「调用」,不如说是「代入」。比如你想调用 doubleMe(100),只需要把 100 代入到 doubleMe x = x * 2 等式左边的 x,就能得到等式右边的 100 * 2,也就是 200

    学生:倒是挺直白啊。Haskell 中的函数调用不需要括号吗?

    方:不需要。括号一般用来表示优先级。比如

      putStrLn (show (doubleMe 100))
    

    就是先执行 doubleMe 100,得到 200;然后执行 show 200,得到字符串 "200";最后执行 putStrLn "200" 打印出字符串

    学生:我用 JS 表示就是 putStrLn(show(doubleMe(100))) 咯?

    方:嗯

    学生:这样写不觉得傻吗?一层括号套一层括号,Haskell 里直接写 putStrLn show doubleMe 100 行吗?

    方:不行,因为这样写的意思是把 showdoubleMe100 作为参数传给 putStrLn

    学生:是哦,那就必须写这么多括号了吗?

    方:Hashell 给出了一个 $ 符号,方便我们把上面的代码写成这样

    putStrLn  (show  (doubleMe 100))
    写为
    putStrLn $ show $ doubleMe 100
    

    学生:这么写还行。但是看代码得从右往左看,好麻烦啊

    方:就这还嫌麻烦啊,那你可以从上往下写,多加几个中间变量即可

    let n = doubleMe 100
    let string = show n
    putStrLn string
    

    这种从上到下的写法是专门给脑容量较小的新手准备的,因为易于理解

    学生:这说得不就是我么……

    方:嗯,就是你。对了,如果你学过面向对象,可以写成 n.doubleMe().toString().print(),这叫「链式调用」

    学生:这种写法我会!给 n 所属的类添加成员方法就行。

    方:你觉得链式调用是不是比 putStrLn $ show $ doubleMe 100 看着舒服

    学生:(点头)嗯嗯嗯

    方:但是是有代价的哦

    学生:什么代价

    方:链式调用需要引入 this 或 self 关键字,因为 doubleMe、toString、print 都需要用 this 来获取 n

    学生:你不说我都忘了,确实要用到 this

    方:顺便提一下,你不觉得 class 那一套面向对象的语法引入的关键字、特殊属性和概念太多了吗?比如 class、extends、interface、this、super、constructor、public、private、继承、实例、成员属性、类属性、静态方法……

    学生:用多了还好吧,我基本都背下来了

    方:好,那就拿出你背这些概念的精神头来背背函数式的知识,目前只引入了一个 $,不要嫌烦。按你的话说,用多了就好了。

    学生:好的好的,我会耐心的

    方:最后,把我完整的 Haskell 代码给你,方便你在这个网站上面运行:

    doubleMe :: Int -> Int
    doubleMe x = x * 2
    
    main :: IO ()
    main = putStrLn $ show $ doubleMe 100
    
    第 9 条附言  ·  2021-03-18 11:10:48 +08:00

    学生:咦,这个 main = 我能理解,但是这个 main :: IO() 是指定 main 的类型为 IO() 吗? IO() 是什么?

    方:这个以后再说,看不懂的代码照抄就行。

    学生:(打开网站点击Run)我运行成功了!输出 200

    方:行,今天我们就从 JS 慢慢往 Haskell 过渡了,以后能用 JS 表示的代码我会用 JS,不能用 JS 表示的我就得用 Haskell 来写了哦。

    学生:OK

    方:那,明天再见吧。

    第 10 条附言  ·  2021-03-18 12:25:57 +08:00
    换个地方连载 https://v2ex.com/t/762762
    34 条回复    2022-01-29 13:23:43 +08:00
    FrankFang128
        1
    FrankFang128  
    OP
       2021-03-14 13:30:20 +08:00
    方:我跟你举个例子,你应该知道 ++i 比 i++ 的性能要好很多吧?

    学生:哦?为什么?

    方:原因不重要,你可以看看[这篇文章]( https://www.omegaxyz.com/2017/05/20/aboutippandppi/),也可以不看。假设你已经知道 ++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 行代码,函数式追求的是逻辑上的简洁,先让逻辑变美,然后等遇到性能瓶颈了再上优化

    学生:好吧

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

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

    方:好吧,今天就先到这里,明天继续。
    FrankFang128
        2
    FrankFang128  
    OP
       2021-03-14 13:33:01 +08:00
    大家请忽略 1 楼,1 楼内容是我发错了。
    FrankFang128
        3
    FrankFang128  
    OP
       2021-03-14 18:58:15 +08:00
    @Livid 能把一楼删掉吗?
    cmdOptionKana
        4
    cmdOptionKana  
       2021-03-14 20:05:50 +08:00
    干货啊
    GeruzoniAnsasu
        5
    GeruzoniAnsasu  
       2021-03-14 20:38:13 +08:00
    我以为 “写给前端” 会从 “你怎么让元素 B 的颜色随着按钮 A 的位置移动而改变” 这种角度入门

    之前有一个帖子问 怎么实现对象值之间的相互约束,比如定义 C=A+B,改变 AB 的值能直接导致 C 结果改变。这种具体场景才好解释函数式的特点和必要性


    写给前端为啥不直接讲讲怎样 “make something reactive” ?
    mmtromsb456
        6
    mmtromsb456  
       2021-03-14 20:44:31 +08:00
    希望楼主做个汇总版放 blog 之类的地方,支持关注一下
    AEDaydreamer
        7
    AEDaydreamer  
       2021-03-14 22:16:29 +08:00
    我最近正好在看 functional programming in JavaScript 相关的内容 ,受益良多
    lbyo
        8
    lbyo  
       2021-03-15 09:54:27 +08:00
    ++i 和 i++ 是有区别的吧
    yunyuyuan
        9
    yunyuyuan  
       2021-03-15 10:18:09 +08:00
    jinliming2
        10
    jinliming2  
       2021-03-16 01:22:11 +08:00 via iPhone
    JS ES6 提案有尾调优化,但目前好像只有用 Webkit 的 Safari 支持了,其他的 Chrome 和 Firefox 都还没实现。
    FrankFang128
        11
    FrankFang128  
    OP
       2021-03-16 03:22:18 +08:00
    @jinliming2 后来取消了这个提案
    namelosw
        12
    namelosw  
       2021-03-16 09:33:43 +08:00   ❤️ 1
    @jinliming2 一开始是 Safari 和 Chrome 实现了, 然后开会的时候 Mozilla 说我们还没做, Edge 做不了不做了.

    最后就进行不下去了…

    然后 Chrome 一看算了, 反正别人也不做, 就把自己的 TCO 也删了, 现在好像只有 Safari 的还留着.
    abersheeran
        13
    abersheeran  
       2021-03-17 09:51:10 +08:00
    你这个“编译器应当优化尾递归”的说法放在 Py 里可能可以,但是放在 JS 里绝不可能的。JS 的历史包袱可是最重的。
    huabalance
        14
    huabalance  
       2021-03-17 10:59:33 +08:00
    醍醐灌顶,期待更新。
    FrankFang128
        15
    FrankFang128  
    OP
       2021-03-18 11:11:18 +08:00
    @huabalance 已更新
    FrankFang128
        16
    FrankFang128  
    OP
       2021-03-18 12:26:09 +08:00
    换个地方连载 https://v2ex.com/t/762762
    sillydaddy
        17
    sillydaddy  
       2021-03-19 17:04:49 +08:00
    “递”“归”解释的很形象哦
    cyrbuzz
        18
    cyrbuzz  
       2021-03-20 22:23:09 +08:00
    递归最大的缺点是不是性能不高?
    Wincer
        19
    Wincer  
       2021-03-20 22:45:23 +08:00 via Android
    不错,有种让我觉得在读《冒号课堂》的感觉。
    Chingim
        20
    Chingim  
       2021-03-20 22:53:05 +08:00 via iPhone   ❤️ 1
    写得挺好的,关注了(一般我不夸人
    FrankFang128
        21
    FrankFang128  
    OP
       2021-03-20 23:12:35 +08:00   ❤️ 1
    @cyrbuzz 唉,你白看了 :(
    NPC666
        22
    NPC666  
       2021-03-21 20:31:34 +08:00
    厉害厉害,醍醐灌顶
    xml123
        23
    xml123  
       2021-03-22 18:14:13 +08:00   ❤️ 1
    难得一见的教程贴,感谢 lz,只可惜 v2 这个平台不太适合做这种连载性质的内容
    Chingim
        24
    Chingim  
       2021-03-23 20:28:07 +08:00
    用函数式的思想编写 js 代码, 有两个问题很困惑, 请先生教我:
    1. 提倡纯函数, 导致函数的参数特别长. 更恼人的是, 层层调用的函数, 虽然只有最里层的函数需要某个参数, 外面 n 层的函数的参数列表都不得不加上这个参数. 这种场景如何解决
    2. 如何隔离副作用. 一个典型的前端场景: 获取远端数据, 渲染到 dom 节点上, 点击刷新按钮, 重新获取数据进行渲染, 几乎都是副作用. 这个场景下的函数式代码是长什么样子的?
    FrankFang128
        25
    FrankFang128  
    OP
       2021-03-24 01:11:38 +08:00
    @Chingim 1. Haskell 会自动简化这种情况,f a b c d 等价与 f(a)(b)(c)(d) 所以你需要换一门语言 2. React 的 useEffect 就是隔离副作用的例子,它让副作用全部都在 useEffect 的第一个参数里
    FrankFang128
        26
    FrankFang128  
    OP
       2021-03-24 01:16:17 +08:00
    @Chingim 你说的 「虽然只有最里层的函数需要某个参数, 外面 n 层的函数的参数列表都不得不加上这个参数」这个问题指令式编程语言也存在,比如 C 语言的 f(null, null ,null, null, null, 'hi') 为了传 'hi' 不得不先传 5 个 null,指令式编程的解决办法是把所有参数都绑到对象上,比如 obj.a = 'a'; obj.b = 'b'; obj.addAB();
    那么函数式也有对应的写法 const afterCallA = f(a); const afterCallB = afterCallA(b); const getAB = afterCallB()

    我不清楚我回答的是不是你问的,如果有代码例子给我看就更好了。
    Chingim
        27
    Chingim  
       2021-03-24 11:16:14 +08:00
    Chingim
        29
    Chingim  
       2021-03-24 15:03:07 +08:00
    @FrankFang128 懂了, 就是把“传递 inner 函数需要的参数”变成“传递 inner 函数”

    谢谢
    DrakeXiang
        30
    DrakeXiang  
       2021-03-25 23:41:41 +08:00
    我的思路真的是跟学生一模一样。。
    DaguguJ
        31
    DaguguJ  
       2021-03-26 12:16:28 +08:00
    讲得 lisp 的编程风格,对吧
    cyrbuzz
        32
    cyrbuzz  
       332 天前
    半年后又读了几遍,收益很多,感谢~。
    vaporSpace
        33
    vaporSpace  
       243 天前
    受益良多
    cjydawn
        34
    cjydawn  
       194 天前
    受益良多,感谢
    关于   ·   帮助文档   ·   API   ·   FAQ   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   1774 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 36ms · UTC 17:01 · PVG 01:01 · LAX 10:01 · JFK 13:01
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.