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

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

  •  1
     
  •   FrankFang128 · 259 天前 · 3570 次点击
    这是一个创建于 259 天前的主题,其中的信息可能已经有所发展或是发生改变。

    第 0 天

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

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

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

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

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

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

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

    方:不是这样的。

    学生:什么意思?

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

    学生:约定?

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

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

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

    学生:短板是什么?

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

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

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

    学生:是……

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

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

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

    学生:学完有什么好处?

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

    学生:你要花多久讲完?

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

    学生:大半年。

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

    学生:听起来弊大于利?

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

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

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

    第 1 条附言  ·  259 天前

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

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

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

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

    学生:?

    方:不信?我跟你出道题。请遍历 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 条附言  ·  259 天前
    方:我跟你举个例子,你应该知道 ++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 条附言  ·  259 天前

    第二天:递归 & 尾递归

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

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

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

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

    学生:哦……

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

    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 条附言  ·  259 天前

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

    方:可以这么理解。

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

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

    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 条附言  ·  259 天前

    方:直接看代码吧:

    /*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 条附言  ·  255 天前

    前篇: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 条附言  ·  255 天前

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

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

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

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

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

    方:我们继续。如果一个函数是多参数形式,比如 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 条附言  ·  255 天前

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

    学生:第三个知识点呢?

    方:接下来会讲一点点 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 条附言  ·  255 天前

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

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

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

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

    学生:OK

    方:那,明天再见吧。

    第 10 条附言  ·  255 天前
    换个地方连载 https://v2ex.com/t/762762
    32 条回复    2021-09-13 16:46:13 +08:00
    FrankFang128
        1
    FrankFang128   259 天前
    方:我跟你举个例子,你应该知道 ++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   259 天前
    大家请忽略 1 楼,1 楼内容是我发错了。
    FrankFang128
        3
    FrankFang128   258 天前
    @Livid 能把一楼删掉吗?
    cmdOptionKana
        4
    cmdOptionKana   258 天前
    干货啊
    GeruzoniAnsasu
        5
    GeruzoniAnsasu   258 天前
    我以为 “写给前端” 会从 “你怎么让元素 B 的颜色随着按钮 A 的位置移动而改变” 这种角度入门

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


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

    最后就进行不下去了…

    然后 Chrome 一看算了, 反正别人也不做, 就把自己的 TCO 也删了, 现在好像只有 Safari 的还留着.
    abersheeran
        13
    abersheeran   256 天前
    你这个“编译器应当优化尾递归”的说法放在 Py 里可能可以,但是放在 JS 里绝不可能的。JS 的历史包袱可是最重的。
    huabalance
        14
    huabalance   256 天前
    醍醐灌顶,期待更新。
    FrankFang128
        15
    FrankFang128   255 天前
    @huabalance 已更新
    FrankFang128
        16
    FrankFang128   255 天前
    换个地方连载 https://v2ex.com/t/762762
    sillydaddy
        17
    sillydaddy   253 天前
    “递”“归”解释的很形象哦
    cyrbuzz
        18
    cyrbuzz   252 天前
    递归最大的缺点是不是性能不高?
    Wincer
        19
    Wincer   252 天前 via Android
    不错,有种让我觉得在读《冒号课堂》的感觉。
    Chingim
        20
    Chingim   252 天前 via iPhone   ❤️ 1
    写得挺好的,关注了(一般我不夸人
    FrankFang128
        21
    FrankFang128   252 天前   ❤️ 1
    @cyrbuzz 唉,你白看了 :(
    NPC666
        22
    NPC666   251 天前
    厉害厉害,醍醐灌顶
    xml123
        23
    xml123   250 天前   ❤️ 1
    难得一见的教程贴,感谢 lz,只可惜 v2 这个平台不太适合做这种连载性质的内容
    Chingim
        24
    Chingim   249 天前
    用函数式的思想编写 js 代码, 有两个问题很困惑, 请先生教我:
    1. 提倡纯函数, 导致函数的参数特别长. 更恼人的是, 层层调用的函数, 虽然只有最里层的函数需要某个参数, 外面 n 层的函数的参数列表都不得不加上这个参数. 这种场景如何解决
    2. 如何隔离副作用. 一个典型的前端场景: 获取远端数据, 渲染到 dom 节点上, 点击刷新按钮, 重新获取数据进行渲染, 几乎都是副作用. 这个场景下的函数式代码是长什么样子的?
    FrankFang128
        25
    FrankFang128   249 天前
    @Chingim 1. Haskell 会自动简化这种情况,f a b c d 等价与 f(a)(b)(c)(d) 所以你需要换一门语言 2. React 的 useEffect 就是隔离副作用的例子,它让副作用全部都在 useEffect 的第一个参数里
    FrankFang128
        26
    FrankFang128   249 天前
    @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
        29
    Chingim   248 天前
    @FrankFang128 懂了, 就是把“传递 inner 函数需要的参数”变成“传递 inner 函数”

    谢谢
    DrakeXiang
        30
    DrakeXiang   247 天前
    我的思路真的是跟学生一模一样。。
    DaguguJ
        31
    DaguguJ   247 天前
    讲得 lisp 的编程风格,对吧
    cyrbuzz
        32
    cyrbuzz   75 天前
    半年后又读了几遍,收益很多,感谢~。
    关于   ·   帮助文档   ·   API   ·   FAQ   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   2165 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 06:42 · PVG 14:42 · LAX 22:42 · JFK 01:42
    ♥ Do have faith in what you're doing.