V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX 提问指南
LeeReamond
V2EX  ›  问与答

Javascript 的闭包特性是否来源于某种设计失误?或是某种设计领先?

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

    如题,最近听前端同事讨论 js 的闭包有感,这个帖子想讨论一下语言的命名空间设计,并非单纯局限在闭包问题。标题里写闭包是因为感觉这样比较容易被大家理解。

    以下正文

    =============

    我个人学习 js 的过程中没怎么在意过闭包这个特性,我感觉命名空间逐级继承是一个再自然不过的设计,其他语言中不都是如此设计的?但是旁听别人讨论后发现 js 的命名空间确实跟其他语言不太一样,从设计原因上,似乎 js 的作者希望设计出一种特性能保存函数执行过程中的栈状态,不过我个人感觉上闭包这种使用方法又没有其他语言中生成器之类的特性描述能力强。

    从使用角度来说,js 中命名空间的一个特性是子空间可以修改父空间内容,比如

    var data = 0
    var func = function(){
        data = 1
    }
    func()
    console.log(data)
    

    在这段代码中很显然 data 属于“全局变量”而被单个函数的调用过程所修改了。以前在写 js 的时候没注意,感觉自然也没什么奇怪之处。但是仔细想想,平时写其他语言时并不是这么处理的。比如 C 语言中要达到类似效果通常是通过传入指针的方式

    #include<stdio.h>
    
    void main(){
        int data = 0;
        void func(int *p){
            *p = 1;
        }
        func(&data)
        printf()
    }
    

    类似这样,即函数显然可以访问同级或上级作用域中的变量,但通过一个函数直接修改全局变量的指向是不被允许的。

    在 python 当中可能用一些变通的结构

    data = [0]
    def func(data):
    	data[0] = 1
    

    或者使用 global 将全局变量暴露在函数中

    data = 0
    def func(data):
    	global
    	data = 2
    

    不过说实话这种描述方式感觉比较局限,虽然理论上使用起来与 js 相当,但我在实际程序中几乎没有使用过 global 或者 nonlocal 之类的特性。

    java 当中又回到类似 js 的模式,变量值可以直接被修改了。

    ============

    不禁让人打起一个大大的问号,即为什么这种设计并不统一,有的语言仅可以读取,有的语言可以直接修改。他们都是出于什么目的设计这种特性的?

    第 1 条附言  ·  54 天前

    感谢大家的回答,各种朋友从各种方面回答,让问题得到了大致的解答。不过我观察楼里解释为什么支持修改父作用域的解释很多,但是没有什么人提到禁止修改父作用域的好处,那么看来这种设计大概是没有好处的了。

    比如python当然支持nonlocal,但是默认状态下它选则不开放,

    data = 1
    def func():
        data = 2
    

    使得在这段代码中func的data与另外一个data,在默认状态下指向一个新对象,而不是原有的。个人感觉这种设计并非出于某种“必要”的设计,即离开这一特性无法实现某种功能,程序员期望一个新对象时,完全可以选择另一个变量名,并且最好也规范地选择另一个变量名,避免引起混淆。那么既然非必要,且楼下没有朋友提到过这种设计有什么好处,看来这是py的一个设计失误了。

    53 条回复    2021-02-27 17:16:14 +08:00
    liprais
        1
    liprais   55 天前   ❤️ 3
    21 天搞出来的,要啥自行车
    LeeReamond
        2
    LeeReamond   55 天前
    @liprais 我个人使用过程中两种模式都没给我带来过任何不爽。但我其实很想知道如果以后还要设计更新的语言,那种模式是更合理的
    wzb0909
        3
    wzb0909   55 天前   ❤️ 2
    思而不学则怠啊。

    多看多做,多思考实际问题。然后,你就不会问这样的问题了。

    好的问题让人进步,坏的问题让人原地转圈。

    是的,回答你的问题可以让你进步,但是这进步反而又助长了你糟糕的习惯。

    我再说一遍来总结:你的问题毫无意义。不值得被思考,不值得被回答。
    tushankaka
        4
    tushankaka   55 天前 via Android
    Lisp
    mxT52CRuqR6o5
        5
    mxT52CRuqR6o5   55 天前 via Android
    首先得有内存管理才可能有闭包
    LeeReamond
        6
    LeeReamond   55 天前   ❤️ 37
    @wzb0909 谢谢,block 了
    msg7086
        7
    msg7086   55 天前
    Lisp 。

    话说首先你 C 语言的例子就错了,data 不是全局变量,C 语言里也没有 nested function 。
    你例子里 func 是和 main 平级的,func 当然不能访问 main()里的局部变量了。
    seki
        8
    seki   55 天前
    都是 ES6+ 时代了,正经前端写新的 JS 代码也不会上来一个 var,全局变量漫天飞了。这和你说的 C 语言的「通常」是一个道理
    agagega
        9
    agagega   55 天前
    你说的这个闭包可以访问外部对象的特性并不奇葩,只不过不允许它访问实现起来可能简单一些。C++的 lambda 里自己可以设定是否捕获和怎么捕获(值还是引用),不过因为没有 GC,生命周期需要自己维护。如果你熟悉 Java 这种语言的话,不妨把 JS 的闭包等价理解成一个带 call 方法的类的对象,其捕获的外部内容就是除了 call 方法外这个类其他的成员变量。JS 真正奇特的设计在 this 的作用域,不是词法作用域而是动态作用域,配合原型链造就了一系列坑(和黑魔法)。
    Mithril
        10
    Mithril   55 天前   ❤️ 2
    你要理解一个事,程序的本质是数据以及针对数据的操作。
    最原始的计算机是不区分这二者的,通过一个寄存器指向某个内存,读出的二进制都当“操作”,这个操作所引用的地方可能会被读出来当作数据。
    然后 C 那个时代的编程将对数据的操作抽象成了函数,这时候数据就有了作用域之分。
    再然后抽象层次更高一些的语言,就会想办法考虑如何将数据和操作更好的结合在一起。所有面向对象的语言设计了“类”这个概念。本质上就是给“数据”加上“操作”。
    另外一些语言,或者语言的特性会尝试着反过来做。给“操作”绑定某些“数据”。比如 JS 的闭包概念,和 C++等语言的 lambda 函数。
    两种方法只是设计的重心不同而已。
    himself65
        11
    himself65   55 天前 via iPhone
    nested function 是 gnu c 的独有语法

    https://gcc.gnu.org/onlinedocs/gcc/Nested-Functions.html

    @msg7086
    msg7086
        12
    msg7086   55 天前
    @himself65 是啊,GNU C 里的。
    不过,如果是 GNU C 里的 nested functions 的话,GNU C 是支持 nested functions 访问外层局部变量的。
    msg7086
        13
    msg7086   55 天前
    上面#10 说的其实很好。
    面向对象设计是给数据绑上了方法。
    面向闭包设计是给方法绑上了数据。
    LeeReamond
        14
    LeeReamond   55 天前 via Android
    @seki 这里只是举例,不讨论实现本身,用 var 和 let 不影响代码行为,我觉得用 var 更能体现一些特点所以用了 var
    LeeReamond
        15
    LeeReamond   55 天前 via Android
    @Mithril 感谢回复,但我觉得这个不算做问题的解答,因为单纯讨论特性的话大部分语言往往兼具两种,即他可以面向对象,同时也可以使用闭包保存状态,但是这不影响不同语言对命名空间的处理
    LeeReamond
        16
    LeeReamond   55 天前 via Android
    @msg7086 我是手机发帖没法直接测试,我上文的代码里 gcc 中 func 可以直接修改 data ?这么魔幻
    msg7086
        17
    msg7086   55 天前
    @LeeReamond

    #include <stdio.h>

    int main() {
    int data = 0;
    void func() {
    data = 3;
    }
    printf("%d\n", data);
    func();
    printf("%d\n", data);
    }

    # ./test
    0
    3
    LeeReamond
        18
    LeeReamond   55 天前
    @msg7086 比较震惊,有种这么多年 C 语言学的都是错的感觉。C 是少数专门啃过语法书的语言,但是这么多年其实没怎么写过。所以意思是主流语言的设计,GNU 的 C,java,js,都是可以实现能访问即能修改的,py 属于异类?那么禁止修改这是一种错误的设计么
    msg7086
        19
    msg7086   55 天前   ❤️ 1
    @LeeReamond
    Again,这不是 C 语言,这是 GNU C 扩展。nested functions 不是 C 标准。

    然后,不实现一种功能并不意味着是禁止你。
    比如说你的共享单车没法跑到 200 公里的速度,并不是因为单车禁止你骑那么快,只是因为你腿没那么大力而已。

    比如说,C 语言,不支持面向对象编程,不支持面向闭包编程,不支持函数式编程。
    (免杠声明:强行做是可以做的,只是没有原生的语言特性支持而已。)
    所以就只是不支持而已,他没这能力。
    你不能说他禁止你做这些事情。
    他连做这些事情的能力都没有,怎么禁止?

    至于 Python 修改全局变量的问题。
    当然是可以修改的,方法你也写出来了,就是用 global 。
    你不能说 Python 提供了 global,你不用,还强行说他不能用吧。

    再还有,不要非黑即白。任何设计都有两面性。
    比如 Python 和 Ruby 就是两个看上去很像,但是设计理念完完全全相反的语言。
    但是你不能说其中一个就是错误的。
    Python 更死板,所以写起来很难受(冗长),但是适合大团队量产程序员用,人人都能写出差不多的代码。
    Ruby 更灵活,干一件事情能有十种写法(而且大都能写得很漂亮),但是一不小心黑魔法用多了,就只剩上帝能看懂了。

    如果每种语言都是同样的设计,那还要那么多语言干什么,只留下其中一个就行了。
    msg7086
        20
    msg7086   55 天前
    另外,不同的语言在设计上差得很远很远。
    比如同样是面向对象的 Java 和 Ruby,我可以举个简单的例子。
    在 Java 里,如果有代码 obj1.data = 1,Java 会在 obj1 里找到 data 字段,然后把 1 赋值上去。
    在 Ruby 里,同样的代码,Ruby 会给 obj1 发送一个消息,执行一个叫 data= 的函数,把 1 作为参数传过去。
    Zhuzhuchenyan
        21
    Zhuzhuchenyan   55 天前
    即使 JAVA 中闭包想要用到类似题干中的 data 变量也是需要把 data 变量预先标记为 final 的吧。

    这个更多感觉是语言设计上的取舍,对于较为基本数据类型,闭包中到底捕获的是值还是引用。JAVA 就强迫让你写出不会犯错的代码,但同时要达成类似的事情(妄图在闭包中修改捕获的变量的值)就需要用引用类型包装一下这个变量。
    nlzy
        22
    nlzy   55 天前 via Android
    > Javascript 的闭包特性是否来源于某种设计失误?或是某种设计领先?
    这个特性来源于 Scheme,JavaScript 的设计很大程度上受到了 Scheme 的影响。这个特性是 JavaScript 这门狗屎语言里少数不多的“抄对了”的地方,这个特性再加上 JavaScript 对 first-class function 的支持,使得 JavaScript 有了不错的“函数式”编程能力,语言的表达力和灵活性都大大提高了。
    但也不能说领先,因为它只是在照抄 Scheme,而 Scheme 是 1973 年的编程语言了。

    > 其他语言中不都是如此设计的?
    现代的支持 first-class function 的语言很多都是这样的。不支持 first-class function 的语言,比如 C,就不会这么设计。
    nlzy
        23
    nlzy   55 天前 via Android
    @nlzy #22 修正:C 也支持函数指针,也能算 first-class function,只是这玩意比起别的语言里的函数,它实在是太难用了。
    Cbdy
        24
    Cbdy   55 天前 via Android
    Java 也有闭包,很正常
    lcwylxx921
        25
    lcwylxx921   55 天前
    正如上面所讨论的,JS 的闭包来源于函数式语言,在函数式语言中,函数作为 first-class 成员,需要具备一种能力使其可以方便地访问其定义范围之外的数据,并且这种访问在好的语言设计中应该是遵循 lexical scope 的。此外,关于你纠结的是否允许修改闭包数据的问题,这个首先在许多纯函数式语言(比如 SML )中,所有数据都是 immutable 的,也就谈不上是否允许修改闭包了,但是,出于灵活性的考虑,部分语言放开了这个限制, 比如在 Scheme 中加入了 set !关键字可以做一些 assignment 以及一些 mutable 的数据结构,但都还是有一定限制的,比如对 top-level scope 的修改,这种限制是非常合理的,可以避免在程序的各个地方做顶层的 assignment 然后对其他地方产生副作用。至于 JS 为什么毫无限制,只能说 JS 太灵活了吧。
    Mutoo
        26
    Mutoo   55 天前
    现代语言大多都使用静态作用域:即函数对引用的变量有一个独立的静态表。而如何构建这个表决定了函数能访问的变量的范围。

    闭包只是对「函数引用了不由它所定义的自由变量」形成的一种联系的学术命名。

    ref:
    代码之髓: 编程语言核心概念
    7.2 作用域
    11.5 闭包
    lcwylxx921
        27
    lcwylxx921   55 天前
    @lcwylxx921 另外, 你应该是对 namespace 有什么误解,一般来说 namespace 是用来做模块化的划分的,在闭包这个语境下,更合适的词是 scope 。
    EKkoGG
        28
    EKkoGG   55 天前
    @wzb0909 经典谜语人
    cheng6563
        29
    cheng6563   55 天前 via Android
    c 语言里,}之后局部变量就没了,如果还有指向局部变量的指针就会变成野指针。
    avastms
        30
    avastms   55 天前 via Android
    你的问题不是为什么这么设计,而是为什么设计不统一?

    时代不一样,优化方向不一样,理论积累不一样。

    外部条件不停在变,程序语言使用者偏好多种多样,根本不存在一种普世的不变的『好的』设计,这是世界观问题。

    具体到这个问题,是动态语言思潮出现后函数可以现场生成并在程序内部传来传去,这需要原本的栈内存有时需要被迁移到堆里,这才是闭包的核心,而栈上部的程序访问栈底部的内存,原本就是天经地义的,和闭包无关。
    renmu123
        31
    renmu123   55 天前 via Android
    js 中的变量提升和函数提升才更奇怪,变量提升好像是由于当时虚拟机的 bug,函数提升是特意设计。所以 es6 就用了 let 关键字来限定命名空间,所以少用 var,从现在开始。
    lcwylxx921
        32
    lcwylxx921   55 天前
    @avastms 不太认同你的观点喔,首先“函数可以现场生成并在程序内部传来传去”这个与动态语言的思潮无关,这个是 first-class function 的特性,与语言的静态或者动态无关。其次,你说的内存问题是闭包这个机制实现上的细节,而闭包是一个语言设计上的问题,设计上的问题与实现无关。
    avastms
        33
    avastms   55 天前 via Android
    @lcwylxx921 我认为是先演化出这种实现机制,之后再被总结成专有名词叫做闭包,再被后人借鉴引用成为一种设计,而不是从一开始就想好的,秀才造反十年不成
    avastms
        34
    avastms   55 天前 via Android
    闭包和作用域都是小点,是服务于更大目标的,更多是一种必经之路而不是匠心独具,而且都是演化出来的,最后被起了专门的名字而已
    misaka19000
        35
    misaka19000   55 天前
    这种问题不存在优劣,只存在好恶

    就好像有人喜欢强类型有人喜欢弱类型
    hazardous
        36
    hazardous   55 天前
    @liprais 错了,发明 javascript 只用了 10 天。21 天的是《 21 天精通***》系列
    Anshi
        37
    Anshi   55 天前
    虽然但是,LZ 讨论的是闭包吗,我怎么觉得只是词法作用域链的设计?闭包不是在函数外部使用了函数内部的变量等....(?)
    ylrshui
        38
    ylrshui   55 天前 via iPhone
    @LeeReamond
    都只是内存魔法而已,只要你知道那个数据在哪,都能修改。只是有些语言告诉你数据在哪,并给你设计了方法,有些语言不告诉你,也不给你方法。C 语言给了你一个通用方法,可以修改任何地方的数据,但它不告诉你数据在哪。
    LeeReamond
        39
    LeeReamond   55 天前 via Android
    @misaka19000 题外话,我觉得喜欢动态类型的人不少,但应该不会有人喜欢弱类型
    DOLLOR
        40
    DOLLOR   55 天前
    JS 一般没有“命名空间”( namespace )这种说法,在你这语境下应该叫“作用域”( scope )。
    在 JS 引入块级作用域之前,闭包通常是用两个嵌套函数来实现的。而引入块级作用域之后,利用{}就能实现闭包:

    let getA;
    {
    let a = 10;
    getA = () => console.log(a);
    }
    getA();// 10

    所以不要觉得闭包是什么高深莫测的东西,它的其实就是“内部作用域访问了外部作用域的变量”这么简单的事。
    作用域可以层层嵌套,并且内部作用域可以访问外部作用域的变量,这在许多编程语言看来,不是很天经地义的事情吗?
    Mohanson
        41
    Mohanson   55 天前 via Android
    @LeeReamond 科普下 c 是弱类型
    LeeReamond
        42
    LeeReamond   55 天前 via Android
    @Mohanson 我觉得不要乱科普,弱检查跟弱类型是两个概念。char 能和 int 相加有轻易明确的底层逻辑,与 js 的 1+'1'不是一个概念
    mxT52CRuqR6o5
        43
    mxT52CRuqR6o5   55 天前
    nested function 和闭包还是差很多吧
    yzqtdu
        44
    yzqtdu   55 天前
    题外话,不知楼主有没有看过 Programming Language Pragmatics,书里还有更多不同语言奇奇怪怪的特性
    Leviathann
        45
    Leviathann   55 天前 via iPhone
    我觉得闭包这个说法在不同的语言里表示的意义有点不一样啊
    有的提法是能捕获外部变量的就是闭包,而不捕获外部变量的就是函数
    而 js 好像强调的是函数返回以后可以使用返回对象中的函数操作获取闭包内部的变量
    youxiachai
        46
    youxiachai   55 天前
    lz 是没接触过函数式概念吗.... 这个是语言设计范畴的东西啊....
    要搞懂这个..还是看看一个编程语言是怎么设计的..
    xumng123
        47
    xumng123   55 天前 via iPhone
    @Mithril 讲的好,数据绑定操作,还是操作绑定数据,太精辟了
    libook
        48
    libook   54 天前
    每一种编程语言都是在特定的环境下,为了满足特定的需求而设计的;因为环境和需求不是统一的,那么自然不同编程语言的设计也并不必须是统一的。

    那么要想了解一个编程语言的特性为什么现在是这个样子的,就得了解这个编程语言的发展历史,什么时期在什么环境下遇到了什么需求导致设计了这个特性。

    JS 是从一个极其简陋的轻脚本语言,逐步发展为现在这样能胜任大多数领域的语言的。最初数值型就只提供了一种 Number 类型,而不是像其他语言一样会划分 Int 、Long 、Float 、Double 等甚至还区分 signed 和 unsigned,就是因为最初的需求并不需要如此细致地区分数值类型,一个 double 足矣被用来覆盖整数、小数、负数的常见计算需求了(虽然小数有精度损失问题,但总归是遵循 IEEE 的标准和其他主流语言保持一致的)。
    在作用域上也一样,怎么简单怎么来,最简单的方案莫过于按照代码块的嵌套关系来界定作用域,最终的效果就是 JS 子块内的代码可以直接调用父块的变量。
    对于赋值和返回机制,搞一些深 copy 、浅 copy 、指针什么的也会让语言比较复杂,简单一些就设计成了基本类型深 copy 、高级类型引用。
    后来一些开发在使用 JS 的时候有了限制变量的可访问性和操作的需求,奈何 JS 本身设计得很简单,没有专门提供如此高级的功能,但在掌握了前面说的那些特性后发现可以通过一些特别的写法来达到目的,这个写法在其他一些语言中也有类似的用法,于是乎有人给这种用法起了个名字统称为“闭包”。

    类似的还有,JS 不专门提供继承派生相关特性,但是为了节省存储空间和创建对象的开销所以设计了原型机制,而用原型链恰好可以模拟出来继承派生的机制,这个肯定也不是 JS 设计者的初衷,而是开发者们的智慧。
    Sparetire
        49
    Sparetire   54 天前 via Android
    @LeeReamond 按照一般的定义,偏向允许隐式类型转换来说,C 的确是弱类型。。也建议不要乱反驳
    参考 https://www.zhihu.com/question/19918532/answer/21647195
    ipwx
        50
    ipwx   54 天前
    只有我想吐槽楼主举的这种方法有点 anti-pattern 嘛。。。一不小心就会写错的亚子
    LeeReamond
        51
    LeeReamond   54 天前
    @ipwx 不熟悉设计模式,请教我如何写比较符合模式,谢谢。
    supermao
        52
    supermao   54 天前
    肯定不是
    其它语言也有闭包
    ipwx
        53
    ipwx   54 天前
    @LeeReamond 倒不是设计模式的问题。你想,如果你是一个大函数,外面和里面都很长的那种。甚至你是在循环体里面定义了一个闭包,然后把闭包存下来扔进一个列表之类的。

    想象一下,你循环体外面的循环变量是 i,循环体内部也写了个循环,也是 i 。然后一不小心你用的都是:

    i = 0
    while i < 100:
    ...i = i + 1

    然后就炸了不是?而且这种 bug 贼难查,毕竟是思维盲区。闭包内部默认不能改写外部的变量才能符合人的一般思考模式(局部思考)。
    - - - -

    js 的这个作用域覆盖问题由来已久了。不仅是闭包问题,你代码写长一点,if while for 作用块内部的变量不小心改写了外部变量也是灾难的。所以才有了 const 和 let 这两个关键字替代 var,避免这种作用域改写。

    https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/let
    关于   ·   帮助文档   ·   FAQ   ·   API   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   2858 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 10:19 · PVG 18:19 · LAX 03:19 · JFK 06:19
    ♥ Do have faith in what you're doing.